Refactoring
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user