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,52 @@
|
||||
/// Modèle d'un compte épargne (aligné API CompteEpargneResponse).
|
||||
class CompteEpargneModel {
|
||||
final String? id;
|
||||
final String? membreId;
|
||||
final String? organisationId;
|
||||
final String? numeroCompte;
|
||||
final String? typeCompte;
|
||||
final double soldeActuel;
|
||||
final double soldeBloque;
|
||||
final String? statut;
|
||||
final DateTime? dateOuverture;
|
||||
final DateTime? dateDerniereTransaction;
|
||||
final String? description;
|
||||
|
||||
const CompteEpargneModel({
|
||||
this.id,
|
||||
this.membreId,
|
||||
this.organisationId,
|
||||
this.numeroCompte,
|
||||
this.typeCompte,
|
||||
this.soldeActuel = 0,
|
||||
this.soldeBloque = 0,
|
||||
this.statut,
|
||||
this.dateOuverture,
|
||||
this.dateDerniereTransaction,
|
||||
this.description,
|
||||
});
|
||||
|
||||
factory CompteEpargneModel.fromJson(Map<String, dynamic> json) {
|
||||
return CompteEpargneModel(
|
||||
id: json['id']?.toString(),
|
||||
membreId: json['membreId']?.toString(),
|
||||
organisationId: json['organisationId']?.toString(),
|
||||
numeroCompte: json['numeroCompte'] as String?,
|
||||
typeCompte: json['typeCompte'] as String?,
|
||||
soldeActuel: _toDouble(json['soldeActuel']),
|
||||
soldeBloque: _toDouble(json['soldeBloque']),
|
||||
statut: json['statut'] as String?,
|
||||
dateOuverture: json['dateOuverture'] != null ? DateTime.tryParse(json['dateOuverture'].toString()) : null,
|
||||
dateDerniereTransaction: json['dateDerniereTransaction'] != null ? DateTime.tryParse(json['dateDerniereTransaction'].toString()) : null,
|
||||
description: json['description'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
static double _toDouble(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is num) return v.toDouble();
|
||||
return double.tryParse(v.toString()) ?? 0;
|
||||
}
|
||||
|
||||
double get soldeDisponible => soldeActuel - soldeBloque;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/// Modèle d'une transaction épargne (aligné API TransactionEpargneResponse).
|
||||
class TransactionEpargneModel {
|
||||
final String? id;
|
||||
final String? compteId;
|
||||
final String? type; // DEPOT, RETRAIT, TRANSFERT_ENTRANT, TRANSFERT_SORTANT
|
||||
final double montant;
|
||||
final double soldeAvant;
|
||||
final double soldeApres;
|
||||
final String? motif;
|
||||
final DateTime? dateTransaction;
|
||||
final String? statutExecution; // REUSSIE, etc.
|
||||
final String? origineFonds;
|
||||
|
||||
const TransactionEpargneModel({
|
||||
this.id,
|
||||
this.compteId,
|
||||
this.type,
|
||||
this.montant = 0,
|
||||
this.soldeAvant = 0,
|
||||
this.soldeApres = 0,
|
||||
this.motif,
|
||||
this.dateTransaction,
|
||||
this.statutExecution,
|
||||
this.origineFonds,
|
||||
});
|
||||
|
||||
factory TransactionEpargneModel.fromJson(Map<String, dynamic> json) {
|
||||
return TransactionEpargneModel(
|
||||
id: json['id']?.toString(),
|
||||
compteId: json['compteId']?.toString(),
|
||||
type: json['type']?.toString(),
|
||||
montant: _toDouble(json['montant']),
|
||||
soldeAvant: _toDouble(json['soldeAvant']),
|
||||
soldeApres: _toDouble(json['soldeApres']),
|
||||
motif: json['motif'] as String?,
|
||||
dateTransaction: json['dateTransaction'] != null
|
||||
? DateTime.tryParse(json['dateTransaction'].toString())
|
||||
: null,
|
||||
statutExecution: json['statutExecution']?.toString(),
|
||||
origineFonds: json['origineFonds'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
static double _toDouble(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is num) return v.toDouble();
|
||||
return double.tryParse(v.toString()) ?? 0;
|
||||
}
|
||||
|
||||
bool get isCredit =>
|
||||
type == 'DEPOT' || type == 'TRANSFERT_ENTRANT';
|
||||
bool get isDebit =>
|
||||
type == 'RETRAIT' || type == 'TRANSFERT_SORTANT';
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/// Modèle de requête pour une transaction épargne (aligné API backend).
|
||||
/// LCB-FT : origineFonds et pieceJustificativeId obligatoires au-dessus du seuil.
|
||||
class TransactionEpargneRequest {
|
||||
final String compteId;
|
||||
final String typeTransaction; // DEPOT, RETRAIT, TRANSFERT_ENTRANT, etc.
|
||||
final double montant;
|
||||
final String? compteDestinationId;
|
||||
final String? motif;
|
||||
final String? origineFonds;
|
||||
final String? pieceJustificativeId;
|
||||
|
||||
const TransactionEpargneRequest({
|
||||
required this.compteId,
|
||||
required this.typeTransaction,
|
||||
required this.montant,
|
||||
this.compteDestinationId,
|
||||
this.motif,
|
||||
this.origineFonds,
|
||||
this.pieceJustificativeId,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'compteId': compteId,
|
||||
'typeTransaction': typeTransaction,
|
||||
'montant': montant,
|
||||
if (compteDestinationId != null) 'compteDestinationId': compteDestinationId,
|
||||
if (motif != null && motif!.isNotEmpty) 'motif': motif,
|
||||
if (origineFonds != null && origineFonds!.isNotEmpty) 'origineFonds': origineFonds,
|
||||
if (pieceJustificativeId != null && pieceJustificativeId!.isNotEmpty)
|
||||
'pieceJustificativeId': pieceJustificativeId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
|
||||
import 'package:unionflow_mobile_apps/core/utils/logger.dart';
|
||||
import '../models/compte_epargne_model.dart';
|
||||
import '../models/transaction_epargne_request.dart';
|
||||
|
||||
/// Repository des comptes épargne — API /api/v1/epargne/comptes.
|
||||
@lazySingleton
|
||||
class CompteEpargneRepository {
|
||||
final ApiClient _apiClient;
|
||||
static const String _baseComptes = '/api/v1/epargne/comptes';
|
||||
|
||||
CompteEpargneRepository(this._apiClient);
|
||||
|
||||
List<dynamic> _parseListResponse(dynamic data) {
|
||||
if (data is List) return data;
|
||||
if (data is Map && data.containsKey('content')) {
|
||||
final content = data['content'];
|
||||
return content is List ? content : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Comptes épargne du membre connecté (GET /api/v1/epargne/comptes/mes-comptes).
|
||||
Future<List<CompteEpargneModel>> getMesComptes() async {
|
||||
try {
|
||||
final response = await _apiClient.get('$_baseComptes/mes-comptes');
|
||||
if (response.statusCode == 200) {
|
||||
final data = _parseListResponse(response.data);
|
||||
return data.map((e) => CompteEpargneModel.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
AppLogger.error('CompteEpargneRepository: getMesComptes status ${response.statusCode}');
|
||||
throw Exception('Impossible de charger les comptes: ${response.statusCode}');
|
||||
} catch (e, st) {
|
||||
AppLogger.error('CompteEpargneRepository: getMesComptes échoué', error: e, stackTrace: st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<CompteEpargneModel>> getByMembre(String membreId) async {
|
||||
try {
|
||||
final response = await _apiClient.get('$_baseComptes/membre/$membreId');
|
||||
if (response.statusCode == 200) {
|
||||
final data = _parseListResponse(response.data);
|
||||
return data.map((e) => CompteEpargneModel.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
AppLogger.error('CompteEpargneRepository: getByMembre status ${response.statusCode}');
|
||||
throw Exception('Impossible de charger les comptes du membre: ${response.statusCode}');
|
||||
} catch (e, st) {
|
||||
AppLogger.error('CompteEpargneRepository: getByMembre échoué', error: e, stackTrace: st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<CompteEpargneModel?> getById(String id) async {
|
||||
final response = await _apiClient.get('$_baseComptes/$id');
|
||||
if (response.statusCode == 200) {
|
||||
return CompteEpargneModel.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Crée un compte épargne pour un membre (réservé admin / admin organisation).
|
||||
/// POST /api/v1/epargne/comptes
|
||||
Future<CompteEpargneModel> creerCompte({
|
||||
required String membreId,
|
||||
required String organisationId,
|
||||
required String typeCompte,
|
||||
String? notesOuverture,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'membreId': membreId,
|
||||
'organisationId': organisationId,
|
||||
'typeCompte': typeCompte,
|
||||
};
|
||||
if (notesOuverture != null && notesOuverture.isNotEmpty) {
|
||||
body['notesOuverture'] = notesOuverture;
|
||||
}
|
||||
final response = await _apiClient.post(_baseComptes, data: body);
|
||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||
return CompteEpargneModel.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
throw Exception('Erreur création compte épargne: ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Repository des transactions épargne — API /api/v1/epargne/transactions.
|
||||
/// LCB-FT : le backend exige origineFonds au-dessus du seuil configuré.
|
||||
@lazySingleton
|
||||
class TransactionEpargneRepository {
|
||||
final ApiClient _apiClient;
|
||||
static const String _base = '/api/v1/epargne/transactions';
|
||||
|
||||
TransactionEpargneRepository(this._apiClient);
|
||||
|
||||
/// Exécute une transaction (dépôt, retrait, etc.).
|
||||
Future<Map<String, dynamic>> executer(TransactionEpargneRequest request) async {
|
||||
final response = await _apiClient.post(_base, data: request.toJson());
|
||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
throw Exception('Erreur transaction épargne: ${response.statusCode}');
|
||||
}
|
||||
|
||||
/// Transfert entre deux comptes.
|
||||
Future<Map<String, dynamic>> transferer(TransactionEpargneRequest request) async {
|
||||
final response = await _apiClient.post('$_base/transfert', data: request.toJson());
|
||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
throw Exception('Erreur transfert: ${response.statusCode}');
|
||||
}
|
||||
|
||||
/// Historique des transactions d'un compte.
|
||||
Future<List<Map<String, dynamic>>> getByCompte(String compteId) async {
|
||||
final response = await _apiClient.get('$_base/compte/$compteId');
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data;
|
||||
if (data is List) return List<Map<String, dynamic>>.from(data.map((e) => e as Map<String, dynamic>));
|
||||
return [];
|
||||
}
|
||||
throw Exception('Erreur chargement historique: ${response.statusCode}');
|
||||
}
|
||||
|
||||
/// Initie un dépôt sur compte épargne via Wave (même API que cotisations).
|
||||
/// Retourne l'URL à ouvrir (wave_launch_url) pour confirmer dans l'app Wave.
|
||||
Future<DepotWaveResult> initierDepotEpargneEnLigne({
|
||||
required String compteId,
|
||||
required double montant,
|
||||
required String numeroTelephone,
|
||||
}) async {
|
||||
final response = await _apiClient.post(
|
||||
'/api/paiements/initier-depot-epargne-en-ligne',
|
||||
data: {
|
||||
'compteId': compteId,
|
||||
'montant': montant,
|
||||
'numeroTelephone': numeroTelephone.replaceAll(RegExp(r'\D'), ''),
|
||||
},
|
||||
);
|
||||
if (response.statusCode != 201 && response.statusCode != 200) {
|
||||
final msg = response.data is Map
|
||||
? (response.data['message'] ?? response.data['error'] ?? response.statusCode)
|
||||
: response.statusCode;
|
||||
throw Exception('Impossible d\'initier le dépôt: $msg');
|
||||
}
|
||||
final data = response.data is Map<String, dynamic>
|
||||
? response.data as Map<String, dynamic>
|
||||
: Map<String, dynamic>.from(response.data as Map);
|
||||
return DepotWaveResult(
|
||||
waveLaunchUrl: data['waveLaunchUrl'] as String? ?? data['redirectUrl'] as String? ?? '',
|
||||
redirectUrl: data['redirectUrl'] as String? ?? data['waveLaunchUrl'] as String? ?? '',
|
||||
message: data['message'] as String? ?? 'Ouvrez Wave pour confirmer le dépôt.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Résultat de l'initiation d'un dépôt Wave (épargne).
|
||||
class DepotWaveResult {
|
||||
final String waveLaunchUrl;
|
||||
final String redirectUrl;
|
||||
final String message;
|
||||
|
||||
const DepotWaveResult({
|
||||
required this.waveLaunchUrl,
|
||||
required this.redirectUrl,
|
||||
required this.message,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../../../core/utils/logger.dart';
|
||||
import '../../data/models/compte_epargne_model.dart';
|
||||
import '../../data/models/transaction_epargne_model.dart';
|
||||
import '../../data/repositories/transaction_epargne_repository.dart'; // CompteEpargneRepository + TransactionEpargneRepository
|
||||
import '../../../../shared/design_system/unionflow_design_system.dart';
|
||||
import '../widgets/depot_epargne_dialog.dart';
|
||||
import '../widgets/retrait_epargne_dialog.dart';
|
||||
import '../widgets/transfert_epargne_dialog.dart';
|
||||
import '../widgets/historique_epargne_sheet.dart';
|
||||
|
||||
/// Page détail d'un compte épargne : solde, infos, actions (Dépôt, Retrait, Transfert), dernieres transactions.
|
||||
class EpargneDetailPage extends StatefulWidget {
|
||||
final CompteEpargneModel compte;
|
||||
final List<CompteEpargneModel> tousLesComptes;
|
||||
final VoidCallback? onDataChanged;
|
||||
|
||||
const EpargneDetailPage({
|
||||
super.key,
|
||||
required this.compte,
|
||||
required this.tousLesComptes,
|
||||
this.onDataChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EpargneDetailPage> createState() => _EpargneDetailPageState();
|
||||
}
|
||||
|
||||
class _EpargneDetailPageState extends State<EpargneDetailPage> {
|
||||
List<TransactionEpargneModel> _transactions = [];
|
||||
bool _loadingTx = true;
|
||||
String? _errorTx;
|
||||
CompteEpargneModel? _compte; // rafraîchi après actions
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_compte = widget.compte;
|
||||
_loadTransactions();
|
||||
}
|
||||
|
||||
Future<void> _refreshCompte() async {
|
||||
try {
|
||||
final repo = GetIt.I<CompteEpargneRepository>();
|
||||
if (_compte?.id != null) {
|
||||
final c = await repo.getById(_compte!.id!);
|
||||
if (c != null && mounted) setState(() => _compte = c);
|
||||
}
|
||||
} catch (e, st) {
|
||||
AppLogger.error('EpargneDetailPage: _refreshCompte échoué', error: e, stackTrace: st);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Impossible de rafraîchir le compte')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTransactions() async {
|
||||
if (_compte?.id == null) {
|
||||
setState(() {
|
||||
_loadingTx = false;
|
||||
_transactions = [];
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_loadingTx = true;
|
||||
_errorTx = null;
|
||||
});
|
||||
try {
|
||||
final repo = GetIt.I<TransactionEpargneRepository>();
|
||||
final list = await repo.getByCompte(_compte!.id!);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_transactions = list.map((e) => TransactionEpargneModel.fromJson(e)).toList();
|
||||
_loadingTx = false;
|
||||
_errorTx = null;
|
||||
});
|
||||
} catch (e, st) {
|
||||
AppLogger.error('EpargneDetailPage: _loadTransactions échoué', error: e, stackTrace: st);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_transactions = [];
|
||||
_loadingTx = false;
|
||||
_errorTx = e.toString().replaceFirst('Exception: ', '');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _openDepot() {
|
||||
if (_compte?.id == null) return;
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => DepotEpargneDialog(
|
||||
compteId: _compte!.id!,
|
||||
onSuccess: () {
|
||||
_refreshCompte();
|
||||
_loadTransactions();
|
||||
widget.onDataChanged?.call();
|
||||
},
|
||||
),
|
||||
).then((_) => _refreshCompte());
|
||||
}
|
||||
|
||||
void _openRetrait() {
|
||||
if (_compte?.id == null) return;
|
||||
final soldeDispo = (_compte!.soldeActuel - _compte!.soldeBloque).clamp(0.0, double.infinity);
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => RetraitEpargneDialog(
|
||||
compteId: _compte!.id!,
|
||||
numeroCompte: _compte!.numeroCompte ?? _compte!.id!,
|
||||
soldeDisponible: soldeDispo,
|
||||
onSuccess: () {
|
||||
_refreshCompte();
|
||||
_loadTransactions();
|
||||
widget.onDataChanged?.call();
|
||||
},
|
||||
),
|
||||
).then((_) => _refreshCompte());
|
||||
}
|
||||
|
||||
void _openTransfert() {
|
||||
if (_compte?.id == null) return;
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => TransfertEpargneDialog(
|
||||
compteSource: _compte!,
|
||||
tousLesComptes: widget.tousLesComptes,
|
||||
onSuccess: () {
|
||||
_refreshCompte();
|
||||
_loadTransactions();
|
||||
widget.onDataChanged?.call();
|
||||
},
|
||||
),
|
||||
).then((_) => _refreshCompte());
|
||||
}
|
||||
|
||||
void _openHistorique() {
|
||||
if (_compte?.id == null) return;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (ctx) => HistoriqueEpargneSheet(compte: _compte!),
|
||||
);
|
||||
}
|
||||
|
||||
String? _typeCompteLibelle(String? code) {
|
||||
if (code == null) return null;
|
||||
const map = {
|
||||
'COURANT': 'Compte courant',
|
||||
'EPARGNE_LIBRE': 'Épargne libre',
|
||||
'EPARGNE_BLOQUEE': 'Épargne bloquée',
|
||||
'DEPOT_A_TERME': 'Dépôt à terme',
|
||||
'EPARGNE_PROJET': 'Épargne projet',
|
||||
};
|
||||
return map[code] ?? code;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _compte ?? widget.compte;
|
||||
final soldeDispo = (c.soldeActuel - c.soldeBloque).clamp(0.0, double.infinity);
|
||||
final actif = c.statut == 'ACTIF';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Détail du compte'),
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: ColorTokens.onSurface,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.history),
|
||||
onPressed: _transactions.isEmpty ? null : _openHistorique,
|
||||
tooltip: 'Historique',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
const Color(0xFFfafaf9),
|
||||
const Color(0xFFfafaf9).withOpacity(0.85),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await _refreshCompte();
|
||||
await _loadTransactions();
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Card(
|
||||
elevation: 2,
|
||||
shadowColor: ColorTokens.shadow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(RadiusTokens.lg)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
c.numeroCompte ?? c.id ?? '—',
|
||||
style: TypographyTokens.titleMedium?.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
if (_typeCompteLibelle(c.typeCompte) != null)
|
||||
Text(
|
||||
_typeCompteLibelle(c.typeCompte)!,
|
||||
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
Text(
|
||||
'${c.soldeActuel.toStringAsFixed(0)} XOF',
|
||||
style: TypographyTokens.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: ColorTokens.primary,
|
||||
),
|
||||
),
|
||||
if (c.soldeBloque > 0)
|
||||
Text(
|
||||
'dont ${c.soldeBloque.toStringAsFixed(0)} XOF bloqué(s)',
|
||||
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
|
||||
),
|
||||
Text(
|
||||
'Disponible: ${soldeDispo.toStringAsFixed(0)} XOF',
|
||||
style: TypographyTokens.labelMedium?.copyWith(color: ColorTokens.primary),
|
||||
),
|
||||
if (c.dateOuverture != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: SpacingTokens.sm),
|
||||
child: Text(
|
||||
'Ouvert le ${c.dateOuverture!.day.toString().padLeft(2, '0')}/${c.dateOuverture!.month.toString().padLeft(2, '0')}/${c.dateOuverture!.year}',
|
||||
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
if (c.description != null && c.description!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: SpacingTokens.xs),
|
||||
child: Text(
|
||||
c.description!,
|
||||
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.lg),
|
||||
if (actif) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _openDepot,
|
||||
icon: const Icon(Icons.add_circle_outline, size: 20),
|
||||
label: const Text('Dépôt'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.sm),
|
||||
Expanded(
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: soldeDispo > 0 ? _openRetrait : null,
|
||||
icon: const Icon(Icons.remove_circle_outline, size: 20),
|
||||
label: const Text('Retrait'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: widget.tousLesComptes.length > 1 ? _openTransfert : null,
|
||||
icon: const Icon(Icons.swap_horiz, size: 20),
|
||||
label: const Text('Transfert vers un autre compte'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.lg),
|
||||
],
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Dernières opérations',
|
||||
style: TypographyTokens.titleSmall,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _openHistorique,
|
||||
child: const Text('Voir tout'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_loadingTx)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(SpacingTokens.xl),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_errorTx != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(SpacingTokens.md),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(_errorTx!, style: TextStyle(color: ColorTokens.error)),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: _loadTransactions,
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (_transactions.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
child: Text(
|
||||
'Aucune transaction',
|
||||
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
else
|
||||
Card(
|
||||
child: Column(
|
||||
children: _transactions.take(10).map((t) {
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: t.isCredit ? ColorTokens.success.withOpacity(0.2) : ColorTokens.error.withOpacity(0.2),
|
||||
child: Icon(
|
||||
t.isCredit ? Icons.arrow_downward : Icons.arrow_upward,
|
||||
color: t.isCredit ? ColorTokens.success : ColorTokens.error,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
_libelleType(t.type),
|
||||
style: TypographyTokens.bodyMedium,
|
||||
),
|
||||
subtitle: t.dateTransaction != null
|
||||
? Text(
|
||||
'${t.dateTransaction!.day.toString().padLeft(2, '0')}/${t.dateTransaction!.month.toString().padLeft(2, '0')}/${t.dateTransaction!.year} ${t.dateTransaction!.hour.toString().padLeft(2, '0')}:${t.dateTransaction!.minute.toString().padLeft(2, '0')}',
|
||||
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
|
||||
)
|
||||
: null,
|
||||
trailing: Text(
|
||||
'${t.isCredit ? '+' : '-'}${t.montant.toStringAsFixed(0)} XOF',
|
||||
style: TypographyTokens.titleSmall?.copyWith(
|
||||
color: t.isCredit ? ColorTokens.success : ColorTokens.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _libelleType(String? type) {
|
||||
if (type == null) return '—';
|
||||
const map = {
|
||||
'DEPOT': 'Dépôt',
|
||||
'RETRAIT': 'Retrait',
|
||||
'TRANSFERT_ENTRANT': 'Virement reçu',
|
||||
'TRANSFERT_SORTANT': 'Virement envoyé',
|
||||
};
|
||||
return map[type] ?? type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../../authentication/data/models/user_role.dart';
|
||||
import '../../../authentication/presentation/bloc/auth_bloc.dart';
|
||||
import '../../data/models/compte_epargne_model.dart';
|
||||
import '../../data/repositories/transaction_epargne_repository.dart';
|
||||
import '../../../../shared/design_system/unionflow_design_system.dart';
|
||||
import '../../../../shared/widgets/core_card.dart';
|
||||
import '../../../../shared/widgets/info_badge.dart';
|
||||
import '../widgets/creer_compte_epargne_dialog.dart';
|
||||
import '../widgets/depot_epargne_dialog.dart';
|
||||
import '../widgets/retrait_epargne_dialog.dart';
|
||||
import '../widgets/transfert_epargne_dialog.dart';
|
||||
import '../widgets/historique_epargne_sheet.dart';
|
||||
import 'epargne_detail_page.dart';
|
||||
|
||||
/// Page listant les comptes épargne — rendu bank-grade : récap, cartes avec actions (Dépôt, Retrait, Transfert, Détail, Historique).
|
||||
class EpargnePage extends StatefulWidget {
|
||||
const EpargnePage({super.key});
|
||||
|
||||
@override
|
||||
State<EpargnePage> createState() => _EpargnePageState();
|
||||
}
|
||||
|
||||
class _EpargnePageState extends State<EpargnePage> {
|
||||
List<CompteEpargneModel> _comptes = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadComptes();
|
||||
}
|
||||
|
||||
Future<void> _loadComptes() async {
|
||||
final authState = context.read<AuthBloc>().state;
|
||||
if (authState is! AuthAuthenticated) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'Non connecté';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final compteRepo = GetIt.I<CompteEpargneRepository>();
|
||||
final list = await compteRepo.getMesComptes();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_comptes = list;
|
||||
_loading = false;
|
||||
_error = null;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_comptes = [];
|
||||
_loading = false;
|
||||
_error = 'Erreur: ${e.toString().replaceFirst('Exception: ', '')}';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _openDepot(CompteEpargneModel compte) {
|
||||
if (compte.id == null) return;
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => DepotEpargneDialog(
|
||||
compteId: compte.id!,
|
||||
onSuccess: _loadComptes,
|
||||
),
|
||||
).then((_) => _loadComptes());
|
||||
}
|
||||
|
||||
void _openRetrait(CompteEpargneModel compte) {
|
||||
if (compte.id == null) return;
|
||||
final soldeDispo = (compte.soldeActuel - compte.soldeBloque).clamp(0.0, double.infinity);
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => RetraitEpargneDialog(
|
||||
compteId: compte.id!,
|
||||
numeroCompte: compte.numeroCompte ?? compte.id!,
|
||||
soldeDisponible: soldeDispo,
|
||||
onSuccess: _loadComptes,
|
||||
),
|
||||
).then((_) => _loadComptes());
|
||||
}
|
||||
|
||||
void _openTransfert(CompteEpargneModel compte) {
|
||||
if (compte.id == null) return;
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => TransfertEpargneDialog(
|
||||
compteSource: compte,
|
||||
tousLesComptes: _comptes,
|
||||
onSuccess: _loadComptes,
|
||||
),
|
||||
).then((_) => _loadComptes());
|
||||
}
|
||||
|
||||
void _openDetail(CompteEpargneModel compte) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (ctx) => EpargneDetailPage(
|
||||
compte: compte,
|
||||
tousLesComptes: _comptes,
|
||||
onDataChanged: _loadComptes,
|
||||
),
|
||||
),
|
||||
).then((_) => _loadComptes());
|
||||
}
|
||||
|
||||
void _openHistorique(CompteEpargneModel compte) {
|
||||
if (compte.id == null) return;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (ctx) => HistoriqueEpargneSheet(compte: compte),
|
||||
);
|
||||
}
|
||||
|
||||
String? _typeCompteLibelle(String? code) {
|
||||
if (code == null) return null;
|
||||
const map = {
|
||||
'COURANT': 'Compte courant',
|
||||
'EPARGNE_LIBRE': 'Épargne libre',
|
||||
'EPARGNE_BLOQUEE': 'Épargne bloquée',
|
||||
'DEPOT_A_TERME': 'Dépôt à terme',
|
||||
'EPARGNE_PROJET': 'Épargne projet',
|
||||
};
|
||||
return map[code] ?? code;
|
||||
}
|
||||
|
||||
bool _canCreateCompte(BuildContext context) {
|
||||
final state = context.read<AuthBloc>().state;
|
||||
if (state is! AuthAuthenticated) return false;
|
||||
final role = state.effectiveRole;
|
||||
return role == UserRole.superAdmin || role == UserRole.orgAdmin;
|
||||
}
|
||||
|
||||
void _openCreerCompte() {
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => CreerCompteEpargneDialog(onCreated: _loadComptes),
|
||||
).then((_) => _loadComptes());
|
||||
}
|
||||
|
||||
Widget _buildRecapCard() {
|
||||
double total = 0;
|
||||
for (final c in _comptes) {
|
||||
total += (c.soldeActuel - c.soldeBloque).clamp(0.0, double.infinity);
|
||||
}
|
||||
return CoreCard(
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'VUE D\'ENSEMBLE',
|
||||
style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Icon(Icons.account_balance_wallet, color: AppColors.primaryGreen, size: 24),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
Text(
|
||||
'${total.toStringAsFixed(0)} XOF',
|
||||
style: AppTypography.headerSmall.copyWith(fontSize: 24, color: AppColors.primaryGreen),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Text(
|
||||
'Solde disponible total • ${_comptes.length} compte${_comptes.length > 1 ? 's' : ''}',
|
||||
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.textSecondaryLight),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompteCard(CompteEpargneModel c) {
|
||||
final typeLibelle = _typeCompteLibelle(c.typeCompte);
|
||||
final dateStr = c.dateOuverture != null
|
||||
? 'Ouvert le ${c.dateOuverture!.day.toString().padLeft(2, '0')}/${c.dateOuverture!.month.toString().padLeft(2, '0')}/${c.dateOuverture!.year}'
|
||||
: null;
|
||||
final soldeDispo = (c.soldeActuel - c.soldeBloque).clamp(0.0, double.infinity);
|
||||
final actif = c.statut == 'ACTIF';
|
||||
final canTransfert = _comptes.length > 1;
|
||||
|
||||
return CoreCard(
|
||||
margin: const EdgeInsets.only(bottom: SpacingTokens.md),
|
||||
padding: const EdgeInsets.all(SpacingTokens.md),
|
||||
onTap: () => _openDetail(c),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
c.numeroCompte ?? 'Compte ${c.id ?? ""}',
|
||||
style: AppTypography.actionText,
|
||||
),
|
||||
if (typeLibelle != null)
|
||||
Text(
|
||||
typeLibelle,
|
||||
style: AppTypography.subtitleSmall.copyWith(color: AppColors.textSecondaryLight),
|
||||
),
|
||||
if (dateStr != null)
|
||||
Text(
|
||||
dateStr,
|
||||
style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, color: AppColors.textSecondaryLight),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (c.statut != null)
|
||||
InfoBadge(
|
||||
text: c.statut!,
|
||||
backgroundColor: c.statut == 'ACTIF' ? AppColors.success : AppColors.textSecondaryLight,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('SOLDE ACTUEL', style: AppTypography.subtitleSmall.copyWith(fontSize: 8, fontWeight: FontWeight.bold)),
|
||||
Text(
|
||||
'${c.soldeActuel.toStringAsFixed(0)} XOF',
|
||||
style: AppTypography.headerSmall.copyWith(fontSize: 14, color: AppColors.primaryGreen),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (c.soldeBloque > 0)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text('BLOQUÉ', style: AppTypography.subtitleSmall.copyWith(fontSize: 8, fontWeight: FontWeight.bold)),
|
||||
Text(
|
||||
'${c.soldeBloque.toStringAsFixed(0)} XOF',
|
||||
style: AppTypography.bodyTextSmall.copyWith(fontSize: 12, color: AppColors.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (c.description != null && c.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
Text(
|
||||
c.description!,
|
||||
style: AppTypography.bodyTextSmall.copyWith(fontSize: 11, color: AppColors.textSecondaryLight),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (actif) ...[
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _openDepot(c),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm),
|
||||
backgroundColor: AppColors.primaryGreen.withOpacity(0.1),
|
||||
foregroundColor: AppColors.primaryGreen,
|
||||
),
|
||||
child: const Text('Dépôt', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.xs),
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: soldeDispo > 0 ? () => _openRetrait(c) : null,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm),
|
||||
backgroundColor: AppColors.primaryGreen.withOpacity(0.1),
|
||||
foregroundColor: AppColors.primaryGreen,
|
||||
),
|
||||
child: const Text('Retrait', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
if (canTransfert) ...[
|
||||
const SizedBox(width: SpacingTokens.xs),
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: soldeDispo > 0 ? () => _openTransfert(c) : null,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm),
|
||||
backgroundColor: AppColors.primaryGreen.withOpacity(0.1),
|
||||
foregroundColor: AppColors.primaryGreen,
|
||||
),
|
||||
child: const Text('Transférer', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _openDetail(c),
|
||||
icon: const Icon(Icons.info_outline, size: 16),
|
||||
label: const Text('Détail', style: TextStyle(fontSize: 12)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm),
|
||||
foregroundColor: AppColors.textPrimaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.xs),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _openHistorique(c),
|
||||
icon: const Icon(Icons.history, size: 16),
|
||||
label: const Text('Historique', style: TextStyle(fontSize: 12)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm),
|
||||
foregroundColor: AppColors.textPrimaryLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBodyContent() {
|
||||
if (_loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
Text(
|
||||
_error!,
|
||||
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.lg),
|
||||
FilledButton(
|
||||
onPressed: _loadComptes,
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_comptes.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.xl),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.savings_outlined, size: 64, color: AppColors.textSecondaryLight),
|
||||
const SizedBox(height: SpacingTokens.lg),
|
||||
Text(
|
||||
'Aucun compte épargne',
|
||||
style: AppTypography.actionText.copyWith(color: AppColors.textSecondaryLight),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
Text(
|
||||
'Votre organisation peut ouvrir un compte épargne pour vous. Contactez-la pour en bénéficier.',
|
||||
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.textSecondaryLight),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadComptes,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
children: [
|
||||
_buildRecapCard(),
|
||||
const SizedBox(height: SpacingTokens.lg),
|
||||
..._comptes.map(_buildCompteCard),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showFab = _canCreateCompte(context);
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: const UFAppBar(
|
||||
title: 'COMPTES ÉPARGNE',
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.textPrimaryLight,
|
||||
),
|
||||
body: _buildBodyContent(),
|
||||
floatingActionButton: showFab
|
||||
? FloatingActionButton(
|
||||
onPressed: _openCreerCompte,
|
||||
tooltip: 'Créer un compte épargne pour un membre',
|
||||
backgroundColor: AppColors.primaryGreen,
|
||||
foregroundColor: Colors.white,
|
||||
child: const Icon(Icons.add),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/// Dialog de création d'un compte épargne pour un membre (admin / admin organisation).
|
||||
/// Structure : 1) Choisir l'organisation 2) Choisir le membre de cette organisation 3) Type de compte + notes.
|
||||
library creer_compte_epargne_dialog;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../../../members/data/models/membre_complete_model.dart';
|
||||
import '../../../members/domain/repositories/membre_repository.dart';
|
||||
import '../../../organizations/data/models/organization_model.dart';
|
||||
import '../../../organizations/domain/repositories/organization_repository.dart';
|
||||
import '../../../../shared/models/membre_search_criteria.dart';
|
||||
import '../../data/repositories/transaction_epargne_repository.dart';
|
||||
|
||||
/// Types de compte alignés avec le backend TypeCompteEpargne.
|
||||
const List<Map<String, String>> _typesCompte = [
|
||||
{'code': 'COURANT', 'label': 'Compte courant'},
|
||||
{'code': 'EPARGNE_LIBRE', 'label': 'Épargne libre'},
|
||||
{'code': 'EPARGNE_BLOQUEE', 'label': 'Épargne bloquée (garantie crédit)'},
|
||||
{'code': 'DEPOT_A_TERME', 'label': 'Dépôt à terme'},
|
||||
{'code': 'EPARGNE_PROJET', 'label': 'Épargne projet'},
|
||||
];
|
||||
|
||||
class CreerCompteEpargneDialog extends StatefulWidget {
|
||||
final VoidCallback? onCreated;
|
||||
|
||||
const CreerCompteEpargneDialog({super.key, this.onCreated});
|
||||
|
||||
@override
|
||||
State<CreerCompteEpargneDialog> createState() => _CreerCompteEpargneDialogState();
|
||||
}
|
||||
|
||||
class _CreerCompteEpargneDialogState extends State<CreerCompteEpargneDialog> {
|
||||
String? _organisationId;
|
||||
MembreCompletModel? _selectedMembre;
|
||||
String _typeCompte = 'EPARGNE_LIBRE';
|
||||
final _notesController = TextEditingController();
|
||||
bool _loading = false;
|
||||
bool _loadingMembres = false;
|
||||
bool _submitting = false;
|
||||
String? _error;
|
||||
List<OrganizationModel> _organisations = [];
|
||||
List<MembreCompletModel> _membres = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadOrganisations();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadOrganisations() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_organisationId = null;
|
||||
_selectedMembre = null;
|
||||
_membres = [];
|
||||
});
|
||||
try {
|
||||
final orgRepo = GetIt.instance<IOrganizationRepository>();
|
||||
final orgs = await orgRepo.getOrganizations(page: 0, size: 100);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_organisations = orgs;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'Erreur chargement organisations: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMembresDeLOrganisation(String organisationId) async {
|
||||
if (organisationId.isEmpty) {
|
||||
setState(() {
|
||||
_membres = [];
|
||||
_selectedMembre = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_loadingMembres = true;
|
||||
_selectedMembre = null;
|
||||
_membres = [];
|
||||
});
|
||||
try {
|
||||
final membreRepo = GetIt.instance<IMembreRepository>();
|
||||
final result = await membreRepo.searchMembres(
|
||||
criteria: MembreSearchCriteria(
|
||||
organisationIds: [organisationId],
|
||||
includeInactifs: false,
|
||||
),
|
||||
page: 0,
|
||||
size: 200,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_membres = result.membres;
|
||||
_loadingMembres = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loadingMembres = false;
|
||||
_membres = [];
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Impossible de charger les membres: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_organisationId == null || _organisationId!.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Sélectionnez une organisation')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_selectedMembre == null || _selectedMembre!.id == null || _selectedMembre!.id!.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Sélectionnez un membre')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
final compteRepo = GetIt.I<CompteEpargneRepository>();
|
||||
await compteRepo.creerCompte(
|
||||
membreId: _selectedMembre!.id!,
|
||||
organisationId: _organisationId!,
|
||||
typeCompte: _typeCompte,
|
||||
notesOuverture: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
widget.onCreated?.call();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Compte épargne créé')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Créer un compte épargne'),
|
||||
content: SingleChildScrollView(
|
||||
child: _loading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: _error != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(onPressed: _loadOrganisations, child: const Text('Réessayer')),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. Organisation
|
||||
DropdownButtonFormField<String>(
|
||||
value: _organisationId,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Organisation *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.business),
|
||||
),
|
||||
items: _organisations
|
||||
.map((o) => DropdownMenuItem(
|
||||
value: o.id,
|
||||
child: Text(o.nom ?? o.id ?? '', overflow: TextOverflow.ellipsis, maxLines: 1),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _submitting
|
||||
? null
|
||||
: (v) {
|
||||
setState(() {
|
||||
_organisationId = v;
|
||||
_selectedMembre = null;
|
||||
});
|
||||
if (v != null && v.isNotEmpty) _loadMembresDeLOrganisation(v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 2. Membre de l'organisation — l'administrateur sélectionne le membre pour lequel créer le compte
|
||||
if (_organisationId != null && _organisationId!.isNotEmpty) ...[
|
||||
if (_loadingMembres)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_membres.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'Aucun membre dans cette organisation. Le compte épargne ne peut être créé que pour un membre existant.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
DropdownButtonFormField<MembreCompletModel>(
|
||||
value: _selectedMembre,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Membre *',
|
||||
hintText: 'Choisir le membre pour lequel créer le compte',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person),
|
||||
),
|
||||
items: _membres
|
||||
.map((m) => DropdownMenuItem(
|
||||
value: m,
|
||||
child: Text(
|
||||
'${m.prenom} ${m.nom}${m.numeroMembre != null ? ' (${m.numeroMembre})' : ''}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _submitting ? null : (v) => setState(() => _selectedMembre = v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// 3. Type de compte
|
||||
DropdownButtonFormField<String>(
|
||||
value: _typeCompte,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Type de compte',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: _typesCompte
|
||||
.map((t) => DropdownMenuItem(
|
||||
value: t['code'],
|
||||
child: Text(t['label']!, overflow: TextOverflow.ellipsis, maxLines: 1),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: _submitting ? null : (v) => setState(() => _typeCompte = v ?? 'EPARGNE_LIBRE'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 4. Notes
|
||||
TextFormField(
|
||||
controller: _notesController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notes (optionnel)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 2,
|
||||
enabled: !_submitting,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: (_loading ||
|
||||
_submitting ||
|
||||
_organisationId == null ||
|
||||
_selectedMembre == null ||
|
||||
_selectedMembre!.id == null)
|
||||
? null
|
||||
: _submit,
|
||||
child: _submitting
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Créer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../../core/constants/lcb_ft_constants.dart';
|
||||
import '../../data/models/transaction_epargne_request.dart';
|
||||
import '../../data/repositories/transaction_epargne_repository.dart';
|
||||
|
||||
/// Dialogue de dépôt sur un compte épargne.
|
||||
/// Deux modes : enregistrement manuel (LCB-FT) ou paiement via Wave (mobile money, même flux que cotisations).
|
||||
class DepotEpargneDialog extends StatefulWidget {
|
||||
final String compteId;
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const DepotEpargneDialog({
|
||||
super.key,
|
||||
required this.compteId,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DepotEpargneDialog> createState() => _DepotEpargneDialogState();
|
||||
}
|
||||
|
||||
enum _DepotMode { manual, wave }
|
||||
|
||||
class _DepotEpargneDialogState extends State<DepotEpargneDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _montantController = TextEditingController();
|
||||
final _motifController = TextEditingController();
|
||||
final _origineFondsController = TextEditingController();
|
||||
final _wavePhoneController = TextEditingController();
|
||||
bool _loading = false;
|
||||
bool _waveLoading = false;
|
||||
_DepotMode _mode = _DepotMode.manual;
|
||||
late TransactionEpargneRepository _repository;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_repository = GetIt.I<TransactionEpargneRepository>();
|
||||
}
|
||||
|
||||
bool get _origineFondsRequis {
|
||||
final m = double.tryParse(_montantController.text.replaceAll(',', '.'));
|
||||
return m != null && m >= kSeuilOrigineFondsObligatoireXOF;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_montantController.dispose();
|
||||
_motifController.dispose();
|
||||
_origineFondsController.dispose();
|
||||
_wavePhoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submitWave() async {
|
||||
final montant = double.tryParse(_montantController.text.replaceAll(',', '.'));
|
||||
if (montant == null || montant <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Indiquez un montant valide')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final phone = _wavePhoneController.text.replaceAll(RegExp(r'\D'), '');
|
||||
if (phone.length < 9) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Indiquez votre numéro Wave (9 chiffres)')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _waveLoading = true);
|
||||
try {
|
||||
final result = await _repository.initierDepotEpargneEnLigne(
|
||||
compteId: widget.compteId,
|
||||
montant: montant,
|
||||
numeroTelephone: phone,
|
||||
);
|
||||
final url = result.waveLaunchUrl.isNotEmpty ? result.waveLaunchUrl : result.redirectUrl;
|
||||
if (url.isEmpty) throw Exception('URL Wave non reçue');
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
await launchUrl(uri);
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
widget.onSuccess?.call();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(result.message)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Wave: ${e.toString().replaceFirst('Exception: ', '')}')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _waveLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
final montant = double.tryParse(_montantController.text.replaceAll(',', '.'));
|
||||
if (montant == null || montant <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Montant invalide')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_origineFondsRequis && (_origineFondsController.text.trim().isEmpty)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'L\'origine des fonds est obligatoire pour les opérations à partir de ${kSeuilOrigineFondsObligatoireXOF.toStringAsFixed(0)} XOF (LCB-FT).',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final request = TransactionEpargneRequest(
|
||||
compteId: widget.compteId,
|
||||
typeTransaction: 'DEPOT',
|
||||
montant: montant,
|
||||
motif: _motifController.text.trim().isEmpty ? null : _motifController.text.trim(),
|
||||
origineFonds: _origineFondsController.text.trim().isEmpty ? null : _origineFondsController.text.trim(),
|
||||
);
|
||||
await _repository.executer(request);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
widget.onSuccess?.call();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Dépôt enregistré')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Dépôt sur compte épargne'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SegmentedButton<_DepotMode>(
|
||||
segments: const [
|
||||
ButtonSegment(value: _DepotMode.manual, label: Text('Manuel'), icon: Icon(Icons.edit_note)),
|
||||
ButtonSegment(value: _DepotMode.wave, label: Text('Wave'), icon: Icon(Icons.phone_android)),
|
||||
],
|
||||
selected: {_mode},
|
||||
onSelectionChanged: (s) => setState(() => _mode = s.first),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _montantController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Montant (XOF)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: _mode == _DepotMode.manual
|
||||
? (v) {
|
||||
if (v == null || v.isEmpty) return 'Obligatoire';
|
||||
final n = double.tryParse(v.replaceAll(',', '.'));
|
||||
if (n == null || n <= 0) return 'Montant invalide';
|
||||
return null;
|
||||
}
|
||||
: null,
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
if (_mode == _DepotMode.wave) ...[
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _wavePhoneController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Numéro Wave (9 chiffres) *',
|
||||
hintText: 'Ex: 771234567',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 12,
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _motifController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Motif (optionnel)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _origineFondsController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Origine des fonds (LCB-FT)',
|
||||
hintText: _origineFondsRequis ? 'Obligatoire au-dessus du seuil' : 'Optionnel',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
if (_origineFondsRequis)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
'Requis pour les opérations ≥ ${kSeuilOrigineFondsObligatoireXOF.toStringAsFixed(0)} XOF',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: (_loading || _waveLoading) ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
if (_mode == _DepotMode.wave)
|
||||
FilledButton(
|
||||
onPressed: _waveLoading ? null : _submitWave,
|
||||
child: _waveLoading
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Ouvrir Wave'),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: _loading
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../data/models/compte_epargne_model.dart';
|
||||
import '../../data/models/transaction_epargne_model.dart';
|
||||
import '../../data/repositories/transaction_epargne_repository.dart';
|
||||
import '../../../../shared/design_system/unionflow_design_system.dart';
|
||||
|
||||
/// Bottom sheet affichant l'historique complet des transactions d'un compte (charge et rafraîchit les données).
|
||||
class HistoriqueEpargneSheet extends StatefulWidget {
|
||||
final CompteEpargneModel compte;
|
||||
|
||||
const HistoriqueEpargneSheet({
|
||||
super.key,
|
||||
required this.compte,
|
||||
});
|
||||
|
||||
@override
|
||||
State<HistoriqueEpargneSheet> createState() => _HistoriqueEpargneSheetState();
|
||||
}
|
||||
|
||||
class _HistoriqueEpargneSheetState extends State<HistoriqueEpargneSheet> {
|
||||
List<TransactionEpargneModel> _transactions = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (widget.compte.id == null) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_transactions = [];
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final repo = GetIt.I<TransactionEpargneRepository>();
|
||||
final list = await repo.getByCompte(widget.compte.id!);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_transactions = list.map((e) => TransactionEpargneModel.fromJson(e)).toList();
|
||||
_loading = false;
|
||||
_error = null;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_transactions = [];
|
||||
_loading = false;
|
||||
_error = e.toString().replaceFirst('Exception: ', '');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _libelleType(String? type) {
|
||||
if (type == null) return '—';
|
||||
const map = {
|
||||
'DEPOT': 'Dépôt',
|
||||
'RETRAIT': 'Retrait',
|
||||
'TRANSFERT_ENTRANT': 'Virement reçu',
|
||||
'TRANSFERT_SORTANT': 'Virement envoyé',
|
||||
};
|
||||
return map[type] ?? type;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final compte = widget.compte;
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.3,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.onSurfaceVariant.withOpacity(0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.lg),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Historique — ${compte.numeroCompte ?? compte.id}',
|
||||
style: TypographyTokens.titleMedium,
|
||||
),
|
||||
IconButton(
|
||||
icon: _loading ? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
) : const Icon(Icons.refresh),
|
||||
onPressed: _loading ? null : _load,
|
||||
tooltip: 'Actualiser',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!, style: TextStyle(color: ColorTokens.error), textAlign: TextAlign.center),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
FilledButton.tonal(onPressed: _load, child: const Text('Réessayer')),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: _transactions.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'Aucune transaction',
|
||||
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.lg, vertical: SpacingTokens.sm),
|
||||
itemCount: _transactions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final t = _transactions[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: SpacingTokens.sm),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: t.isCredit
|
||||
? ColorTokens.success.withOpacity(0.2)
|
||||
: ColorTokens.error.withOpacity(0.2),
|
||||
child: Icon(
|
||||
t.isCredit ? Icons.arrow_downward : Icons.arrow_upward,
|
||||
color: t.isCredit ? ColorTokens.success : ColorTokens.error,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
_libelleType(t.type),
|
||||
style: TypographyTokens.bodyMedium,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (t.dateTransaction != null)
|
||||
Text(
|
||||
'${t.dateTransaction!.day.toString().padLeft(2, '0')}/${t.dateTransaction!.month.toString().padLeft(2, '0')}/${t.dateTransaction!.year} ${t.dateTransaction!.hour.toString().padLeft(2, '0')}:${t.dateTransaction!.minute.toString().padLeft(2, '0')}',
|
||||
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
|
||||
),
|
||||
if (t.motif != null && t.motif!.isNotEmpty)
|
||||
Text(
|
||||
t.motif!,
|
||||
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${t.isCredit ? '+' : '-'}${t.montant.toStringAsFixed(0)} XOF',
|
||||
style: TypographyTokens.titleSmall?.copyWith(
|
||||
color: t.isCredit ? ColorTokens.success : ColorTokens.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Solde: ${t.soldeApres.toStringAsFixed(0)}',
|
||||
style: TypographyTokens.labelSmall?.copyWith(color: ColorTokens.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../../../core/constants/lcb_ft_constants.dart';
|
||||
import '../../data/models/transaction_epargne_request.dart';
|
||||
import '../../data/repositories/transaction_epargne_repository.dart';
|
||||
import '../../../../shared/design_system/unionflow_design_system.dart';
|
||||
|
||||
/// Dialogue de retrait sur un compte épargne.
|
||||
/// LCB-FT : origine des fonds obligatoire au-dessus du seuil.
|
||||
class RetraitEpargneDialog extends StatefulWidget {
|
||||
final String compteId;
|
||||
final String numeroCompte;
|
||||
final double soldeDisponible;
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const RetraitEpargneDialog({
|
||||
super.key,
|
||||
required this.compteId,
|
||||
required this.numeroCompte,
|
||||
required this.soldeDisponible,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RetraitEpargneDialog> createState() => _RetraitEpargneDialogState();
|
||||
}
|
||||
|
||||
class _RetraitEpargneDialogState extends State<RetraitEpargneDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _montantController = TextEditingController();
|
||||
final _motifController = TextEditingController();
|
||||
final _origineFondsController = TextEditingController();
|
||||
bool _loading = false;
|
||||
late TransactionEpargneRepository _repository;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_repository = GetIt.I<TransactionEpargneRepository>();
|
||||
}
|
||||
|
||||
bool get _origineFondsRequis {
|
||||
final m = double.tryParse(_montantController.text.replaceAll(',', '.'));
|
||||
return m != null && m >= kSeuilOrigineFondsObligatoireXOF;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_montantController.dispose();
|
||||
_motifController.dispose();
|
||||
_origineFondsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
final montant = double.tryParse(_montantController.text.replaceAll(',', '.'));
|
||||
if (montant == null || montant <= 0) {
|
||||
_showSnack('Montant invalide');
|
||||
return;
|
||||
}
|
||||
if (montant > widget.soldeDisponible) {
|
||||
_showSnack('Solde disponible insuffisant (${widget.soldeDisponible.toStringAsFixed(0)} XOF)');
|
||||
return;
|
||||
}
|
||||
if (_origineFondsRequis && _origineFondsController.text.trim().isEmpty) {
|
||||
_showSnack(
|
||||
'L\'origine des fonds est obligatoire pour les opérations à partir de ${kSeuilOrigineFondsObligatoireXOF.toStringAsFixed(0)} XOF (LCB-FT).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final request = TransactionEpargneRequest(
|
||||
compteId: widget.compteId,
|
||||
typeTransaction: 'RETRAIT',
|
||||
montant: montant,
|
||||
motif: _motifController.text.trim().isEmpty ? null : _motifController.text.trim(),
|
||||
origineFonds: _origineFondsController.text.trim().isEmpty ? null : _origineFondsController.text.trim(),
|
||||
);
|
||||
await _repository.executer(request);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
widget.onSuccess?.call();
|
||||
_showSnack('Retrait enregistré', isError: false);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_showSnack('Erreur: ${e.toString().replaceFirst('Exception: ', '')}');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnack(String msg, {bool isError = true}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(msg),
|
||||
backgroundColor: isError ? ColorTokens.error : ColorTokens.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Retrait'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.numeroCompte,
|
||||
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
|
||||
),
|
||||
Text(
|
||||
'Solde disponible: ${widget.soldeDisponible.toStringAsFixed(0)} XOF',
|
||||
style: TypographyTokens.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _montantController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Montant (XOF)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Obligatoire';
|
||||
final n = double.tryParse(v.replaceAll(',', '.'));
|
||||
if (n == null || n <= 0) return 'Montant invalide';
|
||||
if (n > widget.soldeDisponible) return 'Solde insuffisant';
|
||||
return null;
|
||||
},
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _motifController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Motif (optionnel)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _origineFondsController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Origine des fonds (LCB-FT)',
|
||||
hintText: _origineFondsRequis ? 'Obligatoire au-dessus du seuil' : 'Optionnel',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
if (_origineFondsRequis)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'Requis pour les opérations ≥ ${kSeuilOrigineFondsObligatoireXOF.toStringAsFixed(0)} XOF',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: ColorTokens.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _loading ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: _loading
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Valider le retrait'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../data/models/compte_epargne_model.dart';
|
||||
import '../../data/models/transaction_epargne_request.dart';
|
||||
import '../../data/repositories/transaction_epargne_repository.dart';
|
||||
import '../../../../shared/design_system/unionflow_design_system.dart';
|
||||
|
||||
/// Dialogue de transfert entre deux comptes épargne du membre.
|
||||
class TransfertEpargneDialog extends StatefulWidget {
|
||||
final CompteEpargneModel compteSource;
|
||||
final List<CompteEpargneModel> tousLesComptes;
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const TransfertEpargneDialog({
|
||||
super.key,
|
||||
required this.compteSource,
|
||||
required this.tousLesComptes,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TransfertEpargneDialog> createState() => _TransfertEpargneDialogState();
|
||||
}
|
||||
|
||||
class _TransfertEpargneDialogState extends State<TransfertEpargneDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _montantController = TextEditingController();
|
||||
final _motifController = TextEditingController();
|
||||
bool _loading = false;
|
||||
String? _compteDestinationId;
|
||||
late TransactionEpargneRepository _repository;
|
||||
|
||||
List<CompteEpargneModel> get _comptesDestination {
|
||||
if (widget.compteSource.id == null) return [];
|
||||
return widget.tousLesComptes
|
||||
.where((c) => c.id != null && c.id != widget.compteSource.id && c.statut == 'ACTIF')
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_repository = GetIt.I<TransactionEpargneRepository>();
|
||||
if (_comptesDestination.isNotEmpty) _compteDestinationId = _comptesDestination.first.id;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_montantController.dispose();
|
||||
_motifController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_compteDestinationId == null || _compteDestinationId!.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Sélectionnez un compte de destination')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final montant = double.tryParse(_montantController.text.replaceAll(',', '.'));
|
||||
if (montant == null || montant <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Montant invalide')));
|
||||
return;
|
||||
}
|
||||
final soldeDispo = widget.compteSource.soldeActuel - widget.compteSource.soldeBloque;
|
||||
if (montant > soldeDispo) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Solde disponible insuffisant (${soldeDispo.toStringAsFixed(0)} XOF)')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final request = TransactionEpargneRequest(
|
||||
compteId: widget.compteSource.id!,
|
||||
typeTransaction: 'TRANSFERT_SORTANT',
|
||||
montant: montant,
|
||||
compteDestinationId: _compteDestinationId,
|
||||
motif: _motifController.text.trim().isEmpty ? null : _motifController.text.trim(),
|
||||
);
|
||||
await _repository.transferer(request);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
widget.onSuccess?.call();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Transfert effectué'), backgroundColor: ColorTokens.success),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur: ${e.toString().replaceFirst('Exception: ', '')}'),
|
||||
backgroundColor: ColorTokens.error,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final destinations = _comptesDestination;
|
||||
if (destinations.isEmpty) {
|
||||
return AlertDialog(
|
||||
title: const Text('Transfert'),
|
||||
content: const Text(
|
||||
'Vous n\'avez pas d\'autre compte épargne actif pour effectuer un transfert.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return AlertDialog(
|
||||
title: const Text('Transfert entre comptes'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'De: ${widget.compteSource.numeroCompte ?? widget.compteSource.id}',
|
||||
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
|
||||
),
|
||||
Text(
|
||||
'Solde disponible: ${(widget.compteSource.soldeActuel - widget.compteSource.soldeBloque).toStringAsFixed(0)} XOF',
|
||||
style: TypographyTokens.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _compteDestinationId,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Compte de destination',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: destinations
|
||||
.map((c) => DropdownMenuItem(
|
||||
value: c.id,
|
||||
child: Text(
|
||||
'${c.numeroCompte ?? c.id} — ${c.soldeActuel.toStringAsFixed(0)} XOF',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _compteDestinationId = v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _montantController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Montant (XOF)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Obligatoire';
|
||||
final n = double.tryParse(v.replaceAll(',', '.'));
|
||||
if (n == null || n <= 0) return 'Montant invalide';
|
||||
final solde = widget.compteSource.soldeActuel - widget.compteSource.soldeBloque;
|
||||
if (n > solde) return 'Solde insuffisant';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _motifController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Motif (optionnel)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _loading ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: _loading
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Transférer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user