Version propre - Dashboard enhanced
This commit is contained in:
@@ -0,0 +1,937 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/models/membre_model.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../shared/widgets/custom_text_field.dart';
|
||||
import '../../../../shared/widgets/buttons/buttons.dart';
|
||||
import '../bloc/membres_bloc.dart';
|
||||
import '../bloc/membres_event.dart';
|
||||
import '../bloc/membres_state.dart';
|
||||
|
||||
|
||||
/// Page de création d'un nouveau membre
|
||||
class MembreCreatePage extends StatefulWidget {
|
||||
const MembreCreatePage({super.key});
|
||||
|
||||
@override
|
||||
State<MembreCreatePage> createState() => _MembreCreatePageState();
|
||||
}
|
||||
|
||||
class _MembreCreatePageState extends State<MembreCreatePage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late MembresBloc _membresBloc;
|
||||
late TabController _tabController;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Controllers pour les champs du formulaire
|
||||
final _nomController = TextEditingController();
|
||||
final _prenomController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _telephoneController = TextEditingController();
|
||||
final _adresseController = TextEditingController();
|
||||
final _villeController = TextEditingController();
|
||||
final _codePostalController = TextEditingController();
|
||||
final _paysController = TextEditingController();
|
||||
final _professionController = TextEditingController();
|
||||
final _numeroMembreController = TextEditingController();
|
||||
|
||||
// Variables d'état
|
||||
DateTime? _dateNaissance;
|
||||
DateTime _dateAdhesion = DateTime.now();
|
||||
bool _actif = true;
|
||||
bool _isLoading = false;
|
||||
int _currentStep = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_membresBloc = getIt<MembresBloc>();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
|
||||
// Générer un numéro de membre automatique
|
||||
_generateMemberNumber();
|
||||
|
||||
// Initialiser les valeurs par défaut
|
||||
_paysController.text = 'Côte d\'Ivoire';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_nomController.dispose();
|
||||
_prenomController.dispose();
|
||||
_emailController.dispose();
|
||||
_telephoneController.dispose();
|
||||
_adresseController.dispose();
|
||||
_villeController.dispose();
|
||||
_codePostalController.dispose();
|
||||
_paysController.dispose();
|
||||
_professionController.dispose();
|
||||
_numeroMembreController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _generateMemberNumber() {
|
||||
final now = DateTime.now();
|
||||
final year = now.year.toString().substring(2);
|
||||
final month = now.month.toString().padLeft(2, '0');
|
||||
final random = (DateTime.now().millisecondsSinceEpoch % 1000).toString().padLeft(3, '0');
|
||||
_numeroMembreController.text = 'MBR$year$month$random';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _membresBloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: AppTheme.backgroundLight,
|
||||
appBar: _buildAppBar(),
|
||||
body: BlocConsumer<MembresBloc, MembresState>(
|
||||
listener: (context, state) {
|
||||
if (state is MembreCreated) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Membre créé avec succès !'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
|
||||
Navigator.of(context).pop(true); // Retourner true pour indiquer le succès
|
||||
} else if (state is MembresError) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildProgressIndicator(),
|
||||
Expanded(
|
||||
child: _buildFormContent(),
|
||||
),
|
||||
_buildBottomActions(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar() {
|
||||
return AppBar(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
title: const Text(
|
||||
'Nouveau membre',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.help_outline),
|
||||
onPressed: _showHelp,
|
||||
tooltip: 'Aide',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressIndicator() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_buildStepIndicator(0, 'Informations\npersonnelles', Icons.person),
|
||||
_buildStepConnector(0),
|
||||
_buildStepIndicator(1, 'Contact &\nAdresse', Icons.contact_mail),
|
||||
_buildStepConnector(1),
|
||||
_buildStepIndicator(2, 'Finalisation', Icons.check_circle),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: (_currentStep + 1) / 3,
|
||||
backgroundColor: AppTheme.backgroundLight,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(AppTheme.primaryColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepIndicator(int step, String label, IconData icon) {
|
||||
final isActive = step == _currentStep;
|
||||
final isCompleted = step < _currentStep;
|
||||
|
||||
Color color;
|
||||
if (isCompleted) {
|
||||
color = AppTheme.successColor;
|
||||
} else if (isActive) {
|
||||
color = AppTheme.primaryColor;
|
||||
} else {
|
||||
color = AppTheme.textHint;
|
||||
}
|
||||
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isCompleted ? AppTheme.successColor :
|
||||
isActive ? AppTheme.primaryColor : AppTheme.backgroundLight,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: color, width: 2),
|
||||
),
|
||||
child: Icon(
|
||||
isCompleted ? Icons.check : icon,
|
||||
color: isCompleted || isActive ? Colors.white : color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: color,
|
||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepConnector(int step) {
|
||||
final isCompleted = step < _currentStep;
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 2,
|
||||
margin: const EdgeInsets.only(bottom: 32),
|
||||
color: isCompleted ? AppTheme.successColor : AppTheme.backgroundLight,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFormContent() {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: PageView(
|
||||
controller: PageController(initialPage: _currentStep),
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentStep = index;
|
||||
});
|
||||
},
|
||||
children: [
|
||||
_buildPersonalInfoStep(),
|
||||
_buildContactStep(),
|
||||
_buildFinalizationStep(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPersonalInfoStep() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Informations personnelles',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Renseignez les informations de base du nouveau membre',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Numéro de membre (généré automatiquement)
|
||||
CustomTextField(
|
||||
controller: _numeroMembreController,
|
||||
label: 'Numéro de membre',
|
||||
prefixIcon: Icons.badge,
|
||||
enabled: false,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Le numéro de membre est requis';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Nom et Prénom
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _prenomController,
|
||||
label: 'Prénom *',
|
||||
hintText: 'Jean',
|
||||
prefixIcon: Icons.person_outline,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Le prénom est requis';
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
return 'Le prénom doit contenir au moins 2 caractères';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _nomController,
|
||||
label: 'Nom *',
|
||||
hintText: 'Dupont',
|
||||
prefixIcon: Icons.person_outline,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Le nom est requis';
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
return 'Le nom doit contenir au moins 2 caractères';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Date de naissance
|
||||
InkWell(
|
||||
onTap: _selectDateNaissance,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppTheme.borderColor),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cake_outlined, color: AppTheme.textSecondary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Date de naissance',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_dateNaissance != null
|
||||
? DateFormat('dd/MM/yyyy').format(_dateNaissance!)
|
||||
: 'Sélectionner une date',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: _dateNaissance != null
|
||||
? AppTheme.textPrimary
|
||||
: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.calendar_today, color: AppTheme.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Profession
|
||||
CustomTextField(
|
||||
controller: _professionController,
|
||||
label: 'Profession',
|
||||
hintText: 'Enseignant, Commerçant, etc.',
|
||||
prefixIcon: Icons.work_outline,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactStep() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Contact & Adresse',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Informations de contact et adresse du membre',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Email
|
||||
CustomTextField(
|
||||
controller: _emailController,
|
||||
label: 'Email *',
|
||||
hintText: 'exemple@email.com',
|
||||
prefixIcon: Icons.email_outlined,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'L\'email est requis';
|
||||
}
|
||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
|
||||
return 'Format d\'email invalide';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Téléphone
|
||||
CustomTextField(
|
||||
controller: _telephoneController,
|
||||
label: 'Téléphone *',
|
||||
hintText: '+225 XX XX XX XX XX',
|
||||
prefixIcon: Icons.phone_outlined,
|
||||
keyboardType: TextInputType.phone,
|
||||
textInputAction: TextInputAction.next,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[0-9+\-\s\(\)]')),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Le téléphone est requis';
|
||||
}
|
||||
if (value.trim().length < 8) {
|
||||
return 'Numéro de téléphone invalide';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section Adresse
|
||||
const Text(
|
||||
'Adresse',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Adresse
|
||||
CustomTextField(
|
||||
controller: _adresseController,
|
||||
label: 'Adresse',
|
||||
hintText: 'Rue, quartier, etc.',
|
||||
prefixIcon: Icons.location_on_outlined,
|
||||
textInputAction: TextInputAction.next,
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Ville et Code postal
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: CustomTextField(
|
||||
controller: _villeController,
|
||||
label: 'Ville',
|
||||
hintText: 'Abidjan',
|
||||
prefixIcon: Icons.location_city_outlined,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _codePostalController,
|
||||
label: 'Code postal',
|
||||
hintText: '00225',
|
||||
prefixIcon: Icons.markunread_mailbox_outlined,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Pays
|
||||
CustomTextField(
|
||||
controller: _paysController,
|
||||
label: 'Pays',
|
||||
prefixIcon: Icons.flag_outlined,
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFinalizationStep() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Finalisation',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Vérifiez les informations et finalisez la création',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Résumé des informations
|
||||
_buildSummaryCard(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Date d'adhésion
|
||||
InkWell(
|
||||
onTap: _selectDateAdhesion,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppTheme.borderColor),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.calendar_today_outlined, color: AppTheme.textSecondary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Date d\'adhésion',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
DateFormat('dd/MM/yyyy').format(_dateAdhesion),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.edit, color: AppTheme.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Statut actif
|
||||
SwitchListTile(
|
||||
title: const Text('Membre actif'),
|
||||
subtitle: const Text('Le membre peut accéder aux services'),
|
||||
value: _actif,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_actif = value;
|
||||
});
|
||||
},
|
||||
activeColor: AppTheme.primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.summarize, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Résumé des informations',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildSummaryRow('Nom complet', '${_prenomController.text} ${_nomController.text}'),
|
||||
_buildSummaryRow('Email', _emailController.text),
|
||||
_buildSummaryRow('Téléphone', _telephoneController.text),
|
||||
if (_dateNaissance != null)
|
||||
_buildSummaryRow('Date de naissance', DateFormat('dd/MM/yyyy').format(_dateNaissance!)),
|
||||
if (_professionController.text.isNotEmpty)
|
||||
_buildSummaryRow('Profession', _professionController.text),
|
||||
if (_adresseController.text.isNotEmpty)
|
||||
_buildSummaryRow('Adresse', _adresseController.text),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryRow(String label, String value) {
|
||||
if (value.trim().isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomActions() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_currentStep > 0)
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _previousStep,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.primaryColor,
|
||||
side: const BorderSide(color: AppTheme.primaryColor),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('Précédent'),
|
||||
),
|
||||
),
|
||||
if (_currentStep > 0) const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: _currentStep == 0 ? 1 : 1,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _handleNextOrSubmit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Text(_currentStep == 2 ? 'Créer le membre' : 'Suivant'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _previousStep() {
|
||||
if (_currentStep > 0) {
|
||||
setState(() {
|
||||
_currentStep--;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleNextOrSubmit() {
|
||||
if (_currentStep < 2) {
|
||||
if (_validateCurrentStep()) {
|
||||
setState(() {
|
||||
_currentStep++;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
_submitForm();
|
||||
}
|
||||
}
|
||||
|
||||
bool _validateCurrentStep() {
|
||||
switch (_currentStep) {
|
||||
case 0:
|
||||
return _validatePersonalInfo();
|
||||
case 1:
|
||||
return _validateContactInfo();
|
||||
case 2:
|
||||
return true; // Pas de validation spécifique pour la finalisation
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool _validatePersonalInfo() {
|
||||
bool isValid = true;
|
||||
|
||||
if (_prenomController.text.trim().isEmpty) {
|
||||
_showFieldError('Le prénom est requis');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (_nomController.text.trim().isEmpty) {
|
||||
_showFieldError('Le nom est requis');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
bool _validateContactInfo() {
|
||||
bool isValid = true;
|
||||
|
||||
if (_emailController.text.trim().isEmpty) {
|
||||
_showFieldError('L\'email est requis');
|
||||
isValid = false;
|
||||
} else if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(_emailController.text)) {
|
||||
_showFieldError('Format d\'email invalide');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (_telephoneController.text.trim().isEmpty) {
|
||||
_showFieldError('Le téléphone est requis');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
void _showFieldError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _submitForm() {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// Créer le modèle membre
|
||||
final membre = MembreModel(
|
||||
id: '', // Sera généré par le backend
|
||||
numeroMembre: _numeroMembreController.text.trim(),
|
||||
nom: _nomController.text.trim(),
|
||||
prenom: _prenomController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
telephone: _telephoneController.text.trim(),
|
||||
dateNaissance: _dateNaissance,
|
||||
adresse: _adresseController.text.trim().isNotEmpty ? _adresseController.text.trim() : null,
|
||||
ville: _villeController.text.trim().isNotEmpty ? _villeController.text.trim() : null,
|
||||
codePostal: _codePostalController.text.trim().isNotEmpty ? _codePostalController.text.trim() : null,
|
||||
pays: _paysController.text.trim().isNotEmpty ? _paysController.text.trim() : null,
|
||||
profession: _professionController.text.trim().isNotEmpty ? _professionController.text.trim() : null,
|
||||
dateAdhesion: _dateAdhesion,
|
||||
actif: _actif,
|
||||
statut: 'ACTIF',
|
||||
version: 1,
|
||||
dateCreation: DateTime.now(),
|
||||
);
|
||||
|
||||
// Envoyer l'événement de création
|
||||
_membresBloc.add(CreateMembre(membre));
|
||||
}
|
||||
|
||||
Future<void> _selectDateNaissance() async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _dateNaissance ?? DateTime.now().subtract(const Duration(days: 365 * 25)),
|
||||
firstDate: DateTime(1900),
|
||||
lastDate: DateTime.now(),
|
||||
locale: const Locale('fr', 'FR'),
|
||||
);
|
||||
|
||||
if (date != null) {
|
||||
setState(() {
|
||||
_dateNaissance = date;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectDateAdhesion() async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _dateAdhesion,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
locale: const Locale('fr', 'FR'),
|
||||
);
|
||||
|
||||
if (date != null) {
|
||||
setState(() {
|
||||
_dateAdhesion = date;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showHelp() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Aide - Création de membre'),
|
||||
content: const SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Étapes de création :',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('1. Informations personnelles : Nom, prénom, date de naissance'),
|
||||
Text('2. Contact & Adresse : Email, téléphone, adresse'),
|
||||
Text('3. Finalisation : Vérification et validation'),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Champs obligatoires :',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('• Nom et prénom'),
|
||||
Text('• Email (format valide)'),
|
||||
Text('• Téléphone'),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Le numéro de membre est généré automatiquement selon le format : MBR + Année + Mois + Numéro séquentiel',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/models/membre_model.dart';
|
||||
import '../../../../core/models/cotisation_model.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
import '../bloc/membres_bloc.dart';
|
||||
import '../bloc/membres_event.dart';
|
||||
import '../bloc/membres_state.dart';
|
||||
import '../widgets/membre_info_section.dart';
|
||||
import '../widgets/membre_stats_section.dart';
|
||||
import '../widgets/membre_cotisations_section.dart';
|
||||
import '../widgets/membre_actions_section.dart';
|
||||
import '../widgets/membre_delete_dialog.dart';
|
||||
import 'membre_edit_page.dart';
|
||||
|
||||
/// Page de détails complète d'un membre
|
||||
class MembreDetailsPage extends StatefulWidget {
|
||||
const MembreDetailsPage({
|
||||
super.key,
|
||||
required this.membreId,
|
||||
this.membre,
|
||||
});
|
||||
|
||||
final String membreId;
|
||||
final MembreModel? membre;
|
||||
|
||||
@override
|
||||
State<MembreDetailsPage> createState() => _MembreDetailsPageState();
|
||||
}
|
||||
|
||||
class _MembreDetailsPageState extends State<MembreDetailsPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late MembresBloc _membresBloc;
|
||||
late TabController _tabController;
|
||||
|
||||
MembreModel? _currentMembre;
|
||||
List<CotisationModel> _cotisations = [];
|
||||
bool _isLoadingCotisations = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_membresBloc = getIt<MembresBloc>();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_currentMembre = widget.membre;
|
||||
|
||||
// Charger les détails du membre si pas fourni
|
||||
if (_currentMembre == null) {
|
||||
_membresBloc.add(LoadMembreById(widget.membreId));
|
||||
}
|
||||
|
||||
// Charger les cotisations du membre
|
||||
_loadMemberCotisations();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadMemberCotisations() async {
|
||||
setState(() {
|
||||
_isLoadingCotisations = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// TODO: Implémenter le chargement des cotisations via le repository
|
||||
// final cotisations = await getIt<CotisationRepository>()
|
||||
// .getCotisationsByMembre(widget.membreId);
|
||||
// setState(() {
|
||||
// _cotisations = cotisations;
|
||||
// });
|
||||
|
||||
// Simulation temporaire
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
setState(() {
|
||||
_cotisations = _generateMockCotisations();
|
||||
});
|
||||
} catch (e) {
|
||||
// Gérer l'erreur
|
||||
debugPrint('Erreur lors du chargement des cotisations: $e');
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoadingCotisations = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<CotisationModel> _generateMockCotisations() {
|
||||
// Données de test temporaires
|
||||
return [
|
||||
CotisationModel(
|
||||
id: '1',
|
||||
numeroReference: 'COT-2025-001',
|
||||
membreId: widget.membreId,
|
||||
typeCotisation: 'MENSUELLE',
|
||||
periode: 'Janvier 2025',
|
||||
montantDu: 25000,
|
||||
montantPaye: 25000,
|
||||
codeDevise: 'XOF',
|
||||
statut: 'PAYEE',
|
||||
dateEcheance: DateTime(2025, 1, 31),
|
||||
datePaiement: DateTime(2025, 1, 15),
|
||||
annee: 2025,
|
||||
recurrente: true,
|
||||
nombreRappels: 0,
|
||||
dateCreation: DateTime(2025, 1, 1),
|
||||
),
|
||||
CotisationModel(
|
||||
id: '2',
|
||||
numeroReference: 'COT-2025-002',
|
||||
membreId: widget.membreId,
|
||||
typeCotisation: 'MENSUELLE',
|
||||
periode: 'Février 2025',
|
||||
montantDu: 25000,
|
||||
montantPaye: 0,
|
||||
codeDevise: 'XOF',
|
||||
statut: 'EN_ATTENTE',
|
||||
dateEcheance: DateTime(2025, 2, 28),
|
||||
annee: 2025,
|
||||
recurrente: true,
|
||||
nombreRappels: 1,
|
||||
dateCreation: DateTime(2025, 2, 1),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _membresBloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: AppTheme.backgroundLight,
|
||||
body: BlocConsumer<MembresBloc, MembresState>(
|
||||
listener: (context, state) {
|
||||
if (state is MembreLoaded) {
|
||||
setState(() {
|
||||
_currentMembre = state.membre;
|
||||
});
|
||||
} else if (state is MembresError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is MembresLoading && _currentMembre == null) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Chargement des détails...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is MembresError && _currentMembre == null) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error, size: 64, color: AppTheme.errorColor),
|
||||
SizedBox(height: 16),
|
||||
Text(state.message),
|
||||
SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => _membresBloc.add(LoadMembreById(widget.membreId)),
|
||||
child: Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_currentMembre == null) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.person_off, size: 64),
|
||||
SizedBox(height: 16),
|
||||
Text('Membre non trouvé'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildContent();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
return NestedScrollView(
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) {
|
||||
return [
|
||||
_buildAppBar(innerBoxIsScrolled),
|
||||
_buildMemberHeader(),
|
||||
_buildTabBar(),
|
||||
];
|
||||
},
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildInfoTab(),
|
||||
_buildCotisationsTab(),
|
||||
_buildStatsTab(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(bool innerBoxIsScrolled) {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 0,
|
||||
floating: true,
|
||||
pinned: true,
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(
|
||||
_currentMembre?.nomComplet ?? 'Détails du membre',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: _editMember,
|
||||
tooltip: 'Modifier',
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: _handleMenuAction,
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'call',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.phone),
|
||||
title: Text('Appeler'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'message',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.message),
|
||||
title: Text('Message'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'export',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.download),
|
||||
title: Text('Exporter'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete, color: Colors.red),
|
||||
title: Text('Supprimer', style: TextStyle(color: Colors.red)),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemberHeader() {
|
||||
return SliverToBoxAdapter(
|
||||
child: Container(
|
||||
color: AppTheme.primaryColor,
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
child: MembreInfoSection(
|
||||
membre: _currentMembre!,
|
||||
showActions: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabBar() {
|
||||
return SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: _TabBarDelegate(
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppTheme.primaryColor,
|
||||
unselectedLabelColor: AppTheme.textSecondary,
|
||||
indicatorColor: AppTheme.primaryColor,
|
||||
indicatorWeight: 3,
|
||||
tabs: const [
|
||||
Tab(text: 'Informations'),
|
||||
Tab(text: 'Cotisations'),
|
||||
Tab(text: 'Statistiques'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
MembreInfoSection(
|
||||
membre: _currentMembre!,
|
||||
showActions: true,
|
||||
onEdit: _editMember,
|
||||
onCall: _callMember,
|
||||
onMessage: _messageMember,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
MembreActionsSection(
|
||||
membre: _currentMembre!,
|
||||
onEdit: _editMember,
|
||||
onDelete: _deleteMember,
|
||||
onExport: _exportMember,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCotisationsTab() {
|
||||
return MembreCotisationsSection(
|
||||
membre: _currentMembre!,
|
||||
cotisations: _cotisations,
|
||||
isLoading: _isLoadingCotisations,
|
||||
onRefresh: _loadMemberCotisations,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: MembreStatsSection(
|
||||
membre: _currentMembre!,
|
||||
cotisations: _cotisations,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _editMember() async {
|
||||
if (widget.membre == null) return;
|
||||
|
||||
final result = await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MembreEditPage(membre: widget.membre!),
|
||||
),
|
||||
);
|
||||
|
||||
// Si le membre a été modifié avec succès, recharger les données
|
||||
if (result == true) {
|
||||
_loadMemberCotisations();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Membre modifié avec succès !'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _callMember() {
|
||||
// TODO: Implémenter l'appel
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Appel - À implémenter')),
|
||||
);
|
||||
}
|
||||
|
||||
void _messageMember() {
|
||||
// TODO: Implémenter l'envoi de message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Message - À implémenter')),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteMember() async {
|
||||
if (widget.membre == null) return;
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => MembreDeleteDialog(membre: widget.membre!),
|
||||
);
|
||||
|
||||
// Si le membre a été supprimé/désactivé avec succès
|
||||
if (result == true && mounted) {
|
||||
// Retourner à la liste des membres
|
||||
Navigator.of(context).pop();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Membre traité avec succès !'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _exportMember() {
|
||||
// TODO: Implémenter l'export
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Export - À implémenter')),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMenuAction(String action) {
|
||||
switch (action) {
|
||||
case 'call':
|
||||
_callMember();
|
||||
break;
|
||||
case 'message':
|
||||
_messageMember();
|
||||
break;
|
||||
case 'export':
|
||||
_exportMember();
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteMember();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _TabBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
const _TabBarDelegate(this.tabBar);
|
||||
|
||||
final TabBar tabBar;
|
||||
|
||||
@override
|
||||
double get minExtent => tabBar.preferredSize.height;
|
||||
|
||||
@override
|
||||
double get maxExtent => tabBar.preferredSize.height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
child: tabBar,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../bloc/membres_bloc.dart';
|
||||
import '../bloc/membres_event.dart';
|
||||
import '../bloc/membres_state.dart';
|
||||
|
||||
class MembresDashboardPage extends StatefulWidget {
|
||||
const MembresDashboardPage({super.key});
|
||||
|
||||
@override
|
||||
State<MembresDashboardPage> createState() => _MembresDashboardPageState();
|
||||
}
|
||||
|
||||
class _MembresDashboardPageState extends State<MembresDashboardPage> {
|
||||
late MembresBloc _membresBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_membresBloc = getIt<MembresBloc>();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void _loadData() {
|
||||
_membresBloc.add(const LoadMembres());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _membresBloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: AppTheme.backgroundLight,
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Dashboard Membres',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadData,
|
||||
tooltip: 'Actualiser',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: BlocBuilder<MembresBloc, MembresState>(
|
||||
builder: (context, state) {
|
||||
if (state is MembresLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is MembresError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppTheme.errorColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Erreur de chargement',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.message,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _loadData,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Réessayer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildDashboard();
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _loadData,
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
tooltip: 'Actualiser les données',
|
||||
child: const Icon(Icons.refresh, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDashboard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.dashboard,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Dashboard Vide',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Prêt à être reconstruit pièce par pièce',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/models/membre_model.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../shared/widgets/coming_soon_page.dart';
|
||||
import '../bloc/membres_bloc.dart';
|
||||
@@ -9,6 +10,12 @@ import '../bloc/membres_event.dart';
|
||||
import '../bloc/membres_state.dart';
|
||||
import '../widgets/membre_card.dart';
|
||||
import '../widgets/membres_search_bar.dart';
|
||||
import '../widgets/membre_delete_dialog.dart';
|
||||
import '../widgets/membres_advanced_search.dart';
|
||||
import '../widgets/membres_export_dialog.dart';
|
||||
import 'membre_details_page.dart';
|
||||
import 'membre_create_page.dart';
|
||||
import 'membres_dashboard_page.dart';
|
||||
|
||||
|
||||
/// Page de liste des membres avec fonctionnalités avancées
|
||||
@@ -23,6 +30,7 @@ class _MembresListPageState extends State<MembresListPage> {
|
||||
final RefreshController _refreshController = RefreshController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
late MembresBloc _membresBloc;
|
||||
List<MembreModel> _membres = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -56,6 +64,16 @@ class _MembresListPageState extends State<MembresListPage> {
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () => _showAdvancedSearch(),
|
||||
tooltip: 'Recherche avancée',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.file_download),
|
||||
onPressed: () => _showExportDialog(),
|
||||
tooltip: 'Exporter',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: () => _showAddMemberDialog(),
|
||||
@@ -97,7 +115,14 @@ class _MembresListPageState extends State<MembresListPage> {
|
||||
} else if (state is MembresErrorWithData) {
|
||||
_showErrorSnackBar(state.message);
|
||||
}
|
||||
|
||||
|
||||
// Mettre à jour la liste des membres
|
||||
if (state is MembresLoaded) {
|
||||
_membres = state.membres;
|
||||
} else if (state is MembresErrorWithData) {
|
||||
_membres = state.membres;
|
||||
}
|
||||
|
||||
// Arrêter le refresh
|
||||
if (state is! MembresRefreshing && state is! MembresLoading) {
|
||||
_refreshController.refreshCompleted();
|
||||
@@ -288,30 +313,28 @@ class _MembresListPageState extends State<MembresListPage> {
|
||||
|
||||
/// Affiche les détails d'un membre
|
||||
void _showMemberDetails(membre) {
|
||||
// TODO: Implémenter la page de détails
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const ComingSoonPage(
|
||||
title: 'Détails du membre',
|
||||
description: 'La page de détails du membre sera bientôt disponible.',
|
||||
icon: Icons.person,
|
||||
color: AppTheme.primaryColor,
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MembreDetailsPage(
|
||||
membreId: membre.id,
|
||||
membre: membre,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche le dialog d'ajout de membre
|
||||
void _showAddMemberDialog() {
|
||||
// TODO: Implémenter le formulaire d'ajout
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const ComingSoonPage(
|
||||
title: 'Ajouter un membre',
|
||||
description: 'Le formulaire d\'ajout de membre sera bientôt disponible.',
|
||||
icon: Icons.person_add,
|
||||
color: AppTheme.successColor,
|
||||
/// Affiche le formulaire d'ajout de membre
|
||||
void _showAddMemberDialog() async {
|
||||
final result = await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const MembreCreatePage(),
|
||||
),
|
||||
);
|
||||
|
||||
// Si un membre a été créé avec succès, recharger la liste
|
||||
if (result == true) {
|
||||
_membresBloc.add(const RefreshMembres());
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche le dialog d'édition de membre
|
||||
@@ -329,29 +352,59 @@ class _MembresListPageState extends State<MembresListPage> {
|
||||
}
|
||||
|
||||
/// Affiche la confirmation de suppression
|
||||
void _showDeleteConfirmation(membre) {
|
||||
// TODO: Implémenter la confirmation de suppression
|
||||
showDialog(
|
||||
void _showDeleteConfirmation(membre) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => const ComingSoonPage(
|
||||
title: 'Supprimer le membre',
|
||||
description: 'La confirmation de suppression sera bientôt disponible.',
|
||||
icon: Icons.delete,
|
||||
color: AppTheme.errorColor,
|
||||
),
|
||||
barrierDismissible: false,
|
||||
builder: (context) => MembreDeleteDialog(membre: membre),
|
||||
);
|
||||
|
||||
// Si le membre a été supprimé/désactivé avec succès, recharger la liste
|
||||
if (result == true) {
|
||||
_membresBloc.add(const RefreshMembres());
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche les statistiques
|
||||
void _showStatsDialog() {
|
||||
// TODO: Implémenter les statistiques
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const MembresDashboardPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche la recherche avancée
|
||||
void _showAdvancedSearch() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.9,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
builder: (context, scrollController) => MembresAdvancedSearch(
|
||||
onSearch: (filters) {
|
||||
// TODO: Implémenter la recherche avec filtres
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Recherche avec ${filters.length} filtres - À implémenter'),
|
||||
backgroundColor: AppTheme.infoColor,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche le dialog d'export
|
||||
void _showExportDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const ComingSoonPage(
|
||||
title: 'Statistiques',
|
||||
description: 'Les statistiques des membres seront bientôt disponibles.',
|
||||
icon: Icons.analytics,
|
||||
color: AppTheme.infoColor,
|
||||
builder: (context) => MembresExportDialog(
|
||||
membres: _membres,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user