Files
unionflow-server-api/unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisation_create_page.dart
2025-09-17 17:54:06 +00:00

566 lines
18 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/custom_text_field.dart';
import '../../../../shared/widgets/loading_button.dart';
import '../bloc/cotisations_bloc.dart';
import '../bloc/cotisations_event.dart';
import '../bloc/cotisations_state.dart';
/// Page de création d'une nouvelle cotisation
class CotisationCreatePage extends StatefulWidget {
final MembreModel? membre; // Membre pré-sélectionné (optionnel)
const CotisationCreatePage({
super.key,
this.membre,
});
@override
State<CotisationCreatePage> createState() => _CotisationCreatePageState();
}
class _CotisationCreatePageState extends State<CotisationCreatePage> {
final _formKey = GlobalKey<FormState>();
late CotisationsBloc _cotisationsBloc;
// Contrôleurs de champs
final _montantController = TextEditingController();
final _descriptionController = TextEditingController();
final _periodeController = TextEditingController();
// Valeurs sélectionnées
String _typeCotisation = 'MENSUELLE';
DateTime _dateEcheance = DateTime.now().add(const Duration(days: 30));
MembreModel? _membreSelectionne;
// Options disponibles
final List<String> _typesCotisation = [
'MENSUELLE',
'TRIMESTRIELLE',
'SEMESTRIELLE',
'ANNUELLE',
'EXCEPTIONNELLE',
];
@override
void initState() {
super.initState();
_cotisationsBloc = getIt<CotisationsBloc>();
_membreSelectionne = widget.membre;
// Pré-remplir la période selon le type
_updatePeriodeFromType();
}
@override
void dispose() {
_montantController.dispose();
_descriptionController.dispose();
_periodeController.dispose();
super.dispose();
}
void _updatePeriodeFromType() {
final now = DateTime.now();
String periode;
switch (_typeCotisation) {
case 'MENSUELLE':
periode = '${_getMonthName(now.month)} ${now.year}';
break;
case 'TRIMESTRIELLE':
final trimestre = ((now.month - 1) ~/ 3) + 1;
periode = 'T$trimestre ${now.year}';
break;
case 'SEMESTRIELLE':
final semestre = now.month <= 6 ? 1 : 2;
periode = 'S$semestre ${now.year}';
break;
case 'ANNUELLE':
periode = '${now.year}';
break;
case 'EXCEPTIONNELLE':
periode = 'Exceptionnelle ${now.day}/${now.month}/${now.year}';
break;
default:
periode = '${now.month}/${now.year}';
}
_periodeController.text = periode;
}
String _getMonthName(int month) {
const months = [
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
];
return months[month - 1];
}
void _onTypeChanged(String? newType) {
if (newType != null) {
setState(() {
_typeCotisation = newType;
_updatePeriodeFromType();
});
}
}
Future<void> _selectDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _dateEcheance,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
locale: const Locale('fr', 'FR'),
);
if (picked != null) {
setState(() {
_dateEcheance = picked;
});
}
}
Future<void> _selectMembre() async {
// TODO: Implémenter la sélection de membre
// Pour l'instant, afficher un message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fonctionnalité de sélection de membre à implémenter'),
backgroundColor: AppTheme.infoColor,
),
);
}
void _createCotisation() {
if (!_formKey.currentState!.validate()) {
return;
}
if (_membreSelectionne == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez sélectionner un membre'),
backgroundColor: AppTheme.errorColor,
),
);
return;
}
final montant = double.tryParse(_montantController.text.replaceAll(' ', ''));
if (montant == null || montant <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez saisir un montant valide'),
backgroundColor: AppTheme.errorColor,
),
);
return;
}
// Créer la cotisation
final cotisation = CotisationModel(
id: '', // Sera généré par le backend
numeroReference: '', // Sera généré par le backend
membreId: _membreSelectionne!.id ?? '',
nomMembre: _membreSelectionne!.nomComplet,
typeCotisation: _typeCotisation,
montantDu: montant,
montantPaye: 0.0,
dateEcheance: _dateEcheance,
statut: 'EN_ATTENTE',
description: _descriptionController.text.trim(),
periode: _periodeController.text.trim(),
annee: _dateEcheance.year,
mois: _dateEcheance.month,
codeDevise: 'XOF',
recurrente: _typeCotisation != 'EXCEPTIONNELLE',
nombreRappels: 0,
dateCreation: DateTime.now(),
dateModification: DateTime.now(),
);
_cotisationsBloc.add(CreateCotisation(cotisation));
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cotisationsBloc,
child: Scaffold(
backgroundColor: AppTheme.backgroundLight,
appBar: AppBar(
title: const Text('Nouvelle Cotisation'),
backgroundColor: AppTheme.accentColor,
foregroundColor: Colors.white,
elevation: 0,
),
body: BlocListener<CotisationsBloc, CotisationsState>(
listener: (context, state) {
if (state is CotisationCreated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cotisation créée avec succès'),
backgroundColor: AppTheme.successColor,
),
);
Navigator.of(context).pop(true);
} else if (state is CotisationsError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppTheme.errorColor,
),
);
}
},
child: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Sélection du membre
_buildMembreSection(),
const SizedBox(height: 24),
// Type de cotisation
_buildTypeSection(),
const SizedBox(height: 24),
// Montant
_buildMontantSection(),
const SizedBox(height: 24),
// Période et échéance
_buildPeriodeSection(),
const SizedBox(height: 24),
// Description
_buildDescriptionSection(),
const SizedBox(height: 32),
// Bouton de création
_buildCreateButton(),
],
),
),
),
),
),
);
}
Widget _buildMembreSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Membre',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
if (_membreSelectionne != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.accentColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.accentColor.withOpacity(0.3)),
),
child: Row(
children: [
CircleAvatar(
backgroundColor: AppTheme.accentColor,
child: Text(
_membreSelectionne!.nomComplet.substring(0, 1).toUpperCase(),
style: const TextStyle(color: Colors.white),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_membreSelectionne!.nomComplet,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
Text(
_membreSelectionne!.telephone.isNotEmpty
? _membreSelectionne!.telephone
: 'Pas de téléphone',
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.change_circle),
onPressed: _selectMembre,
color: AppTheme.accentColor,
),
],
),
)
else
ElevatedButton.icon(
onPressed: _selectMembre,
icon: const Icon(Icons.person_add),
label: const Text('Sélectionner un membre'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentColor,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
),
),
],
),
),
);
}
Widget _buildTypeSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Type de cotisation',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _typeCotisation,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: _typesCotisation.map((type) {
return DropdownMenuItem(
value: type,
child: Text(_getTypeLabel(type)),
);
}).toList(),
onChanged: _onTypeChanged,
),
],
),
),
);
}
String _getTypeLabel(String type) {
switch (type) {
case 'MENSUELLE': return 'Mensuelle';
case 'TRIMESTRIELLE': return 'Trimestrielle';
case 'SEMESTRIELLE': return 'Semestrielle';
case 'ANNUELLE': return 'Annuelle';
case 'EXCEPTIONNELLE': return 'Exceptionnelle';
default: return type;
}
}
Widget _buildMontantSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Montant',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
CustomTextField(
controller: _montantController,
label: 'Montant (XOF)',
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
TextInputFormatter.withFunction((oldValue, newValue) {
// Formater avec des espaces pour les milliers
final text = newValue.text.replaceAll(' ', '');
if (text.isEmpty) return newValue;
final number = int.tryParse(text);
if (number == null) return oldValue;
final formatted = number.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]} ',
);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}),
],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir un montant';
}
final montant = double.tryParse(value.replaceAll(' ', ''));
if (montant == null || montant <= 0) {
return 'Veuillez saisir un montant valide';
}
return null;
},
suffixIcon: const Icon(Icons.attach_money),
),
],
),
),
);
}
Widget _buildPeriodeSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Période et échéance',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
CustomTextField(
controller: _periodeController,
label: 'Période',
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir une période';
}
return null;
},
),
const SizedBox(height: 16),
InkWell(
onTap: _selectDate,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.calendar_today, color: AppTheme.accentColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Date d\'échéance',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
Text(
'${_dateEcheance.day}/${_dateEcheance.month}/${_dateEcheance.year}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
],
),
),
const Icon(Icons.arrow_drop_down),
],
),
),
),
],
),
),
);
}
Widget _buildDescriptionSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Description (optionnelle)',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
CustomTextField(
controller: _descriptionController,
label: 'Description de la cotisation',
maxLines: 3,
maxLength: 500,
),
],
),
),
);
}
Widget _buildCreateButton() {
return BlocBuilder<CotisationsBloc, CotisationsState>(
builder: (context, state) {
final isLoading = state is CotisationsLoading;
return LoadingButton(
onPressed: isLoading ? null : _createCotisation,
isLoading: isLoading,
text: 'Créer la cotisation',
backgroundColor: AppTheme.accentColor,
textColor: Colors.white,
);
},
);
}
}