- adhesions : bloc complet avec events/states/model, dialogs paiement/rejet - admin : users bloc, user management list/detail pages - authentication : bloc + keycloak auth service + webview - backup : bloc complet, repository, models - contributions : bloc + widgets + export - dashboard : widgets connectés (activities, events, notifications, search) + charts + monitoring + shortcuts - epargne : repository, transactions, dialogs - events : bloc complet, pages (detail, connected, wrapper), models
285 lines
9.2 KiB
Dart
285 lines
9.2 KiB
Dart
/// Dialogue de création de contribution
|
|
library create_contribution_dialog;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:get_it/get_it.dart';
|
|
import '../../../../core/utils/logger.dart';
|
|
import '../../../../shared/design_system/tokens/app_colors.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../../bloc/contributions_bloc.dart';
|
|
import '../../bloc/contributions_event.dart';
|
|
import '../../data/models/contribution_model.dart';
|
|
import '../../../members/data/models/membre_complete_model.dart';
|
|
import '../../../profile/domain/repositories/profile_repository.dart';
|
|
|
|
|
|
class CreateContributionDialog extends StatefulWidget {
|
|
const CreateContributionDialog({super.key});
|
|
|
|
@override
|
|
State<CreateContributionDialog> createState() => _CreateContributionDialogState();
|
|
}
|
|
|
|
class _CreateContributionDialogState extends State<CreateContributionDialog> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _montantController = TextEditingController();
|
|
final _descriptionController = TextEditingController();
|
|
|
|
ContributionType _selectedType = ContributionType.mensuelle;
|
|
MembreCompletModel? _me;
|
|
DateTime _dateEcheance = DateTime.now().add(const Duration(days: 30));
|
|
bool _isLoading = false;
|
|
bool _isInitLoading = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadMe();
|
|
}
|
|
|
|
Future<void> _loadMe() async {
|
|
try {
|
|
final user = await GetIt.instance<IProfileRepository>().getMe();
|
|
if (mounted) {
|
|
setState(() {
|
|
_me = user;
|
|
_isInitLoading = false;
|
|
});
|
|
}
|
|
} catch (e, st) {
|
|
AppLogger.error('CreateContributionDialog: chargement profil échoué', error: e, stackTrace: st);
|
|
if (mounted) {
|
|
setState(() {
|
|
_isInitLoading = false;
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Impossible de charger le profil. Réessayez.')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_montantController.dispose();
|
|
_descriptionController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Nouvelle contribution'),
|
|
content: SizedBox(
|
|
width: MediaQuery.of(context).size.width * 0.8,
|
|
child: Form(
|
|
key: _formKey,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Utilisateur connecté
|
|
if (_isInitLoading)
|
|
const CircularProgressIndicator()
|
|
else if (_me != null)
|
|
TextFormField(
|
|
initialValue: '${_me!.prenom} ${_me!.nom}',
|
|
decoration: const InputDecoration(
|
|
labelText: 'Membre',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.person),
|
|
),
|
|
enabled: false, // Lecture seule
|
|
)
|
|
else
|
|
const Text('Impossible de récupérer votre profil', style: TextStyle(color: AppColors.error)),
|
|
const SizedBox(height: 16),
|
|
|
|
// Type de contribution
|
|
DropdownButtonFormField<ContributionType>(
|
|
value: _selectedType,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Type de contribution',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
items: ContributionType.values.map((type) {
|
|
return DropdownMenuItem(
|
|
value: type,
|
|
child: Text(_getTypeLabel(type)),
|
|
);
|
|
}).toList(),
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
setState(() {
|
|
_selectedType = value;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Montant
|
|
TextFormField(
|
|
controller: _montantController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Montant (FCFA)',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.attach_money),
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Veuillez saisir un montant';
|
|
}
|
|
if (double.tryParse(value) == null) {
|
|
return 'Montant invalide';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Date d'échéance
|
|
InkWell(
|
|
onTap: () async {
|
|
final date = await showDatePicker(
|
|
context: context,
|
|
initialDate: _dateEcheance,
|
|
firstDate: DateTime.now(),
|
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
);
|
|
if (date != null) {
|
|
setState(() {
|
|
_dateEcheance = date;
|
|
});
|
|
}
|
|
},
|
|
child: InputDecorator(
|
|
decoration: const InputDecoration(
|
|
labelText: 'Date d\'échéance',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.calendar_today),
|
|
),
|
|
child: Text(
|
|
DateFormat('dd/MM/yyyy').format(_dateEcheance),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Description
|
|
TextFormField(
|
|
controller: _descriptionController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Description (optionnel)',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.description),
|
|
),
|
|
maxLines: 3,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: _isLoading ? null : _createContribution,
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('Créer'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _getTypeLabel(ContributionType type) {
|
|
switch (type) {
|
|
case ContributionType.mensuelle:
|
|
return 'Mensuelle';
|
|
case ContributionType.trimestrielle:
|
|
return 'Trimestrielle';
|
|
case ContributionType.semestrielle:
|
|
return 'Semestrielle';
|
|
case ContributionType.annuelle:
|
|
return 'Annuelle';
|
|
case ContributionType.exceptionnelle:
|
|
return 'Exceptionnelle';
|
|
}
|
|
}
|
|
|
|
Future<void> _createContribution() async {
|
|
if (!_formKey.currentState!.validate()) {
|
|
return;
|
|
}
|
|
|
|
if (_me == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Profil non chargé'),
|
|
backgroundColor: AppColors.error,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
final membre = _me!;
|
|
String? organisationId = membre.organisationId?.trim().isNotEmpty == true
|
|
? membre.organisationId
|
|
: null;
|
|
String? organisationNom = membre.organisationNom;
|
|
|
|
|
|
if (organisationId == null || organisationId.isEmpty) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Aucune organisation disponible. Le membre et l\'utilisateur connecté doivent être rattachés à une organisation.'),
|
|
backgroundColor: AppColors.error,
|
|
),
|
|
);
|
|
setState(() => _isLoading = false);
|
|
return;
|
|
}
|
|
|
|
final contribution = ContributionModel(
|
|
membreId: membre.id!,
|
|
membreNom: membre.nom,
|
|
membrePrenom: membre.prenom,
|
|
organisationId: organisationId,
|
|
organisationNom: organisationNom,
|
|
type: _selectedType,
|
|
annee: DateTime.now().year,
|
|
montant: double.parse(_montantController.text),
|
|
dateEcheance: _dateEcheance,
|
|
description: _descriptionController.text.isNotEmpty ? _descriptionController.text : null,
|
|
statut: ContributionStatus.nonPayee,
|
|
dateCreation: DateTime.now(),
|
|
dateModification: DateTime.now(),
|
|
);
|
|
|
|
context.read<ContributionsBloc>().add(CreateContribution(contribution: contribution));
|
|
Navigator.pop(context);
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Contribution créée avec succès'),
|
|
backgroundColor: AppColors.success,
|
|
),
|
|
);
|
|
}
|
|
}
|