Version propre - Dashboard enhanced

This commit is contained in:
DahoudG
2025-09-13 19:05:06 +00:00
parent 3df010add7
commit 73459b3092
70 changed files with 15317 additions and 1498 deletions

View File

@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Container professionnel pour les graphiques du dashboard avec animations
class DashboardChartCard extends StatefulWidget {
const DashboardChartCard({
super.key,
required this.title,
required this.child,
this.subtitle,
this.actions,
this.height,
this.isLoading = false,
this.onRefresh,
this.showBorder = true,
});
final String title;
final Widget child;
final String? subtitle;
final List<Widget>? actions;
final double? height;
final bool isLoading;
final VoidCallback? onRefresh;
final bool showBorder;
@override
State<DashboardChartCard> createState() => _DashboardChartCardState();
}
class _DashboardChartCardState extends State<DashboardChartCard>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _slideAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: DesignSystem.animationMedium,
vsync: this,
);
_slideAnimation = Tween<double>(
begin: 30.0,
end: 0.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurveEnter,
));
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, _slideAnimation.value),
child: FadeTransition(
opacity: _fadeAnimation,
child: _buildCard(),
),
);
},
);
}
Widget _buildCard() {
return Container(
height: widget.height,
padding: EdgeInsets.all(DesignSystem.spacingLg),
decoration: BoxDecoration(
color: AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
boxShadow: DesignSystem.shadowCard,
border: widget.showBorder ? Border.all(
color: AppTheme.borderColor.withOpacity(0.5),
width: 1,
) : null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.spacingLg),
Expanded(
child: widget.isLoading ? _buildLoadingState() : widget.child,
),
],
),
);
}
Widget _buildHeader() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: DesignSystem.headlineMedium.copyWith(
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
Text(
widget.subtitle!,
style: DesignSystem.bodyMedium.copyWith(
color: AppTheme.textSecondary,
),
),
],
],
),
),
if (widget.actions != null || widget.onRefresh != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.onRefresh != null)
_buildRefreshButton(),
if (widget.actions != null) ...widget.actions!,
],
),
],
);
}
Widget _buildRefreshButton() {
return Container(
margin: EdgeInsets.only(right: DesignSystem.spacingSm),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onRefresh,
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
child: Container(
padding: EdgeInsets.all(DesignSystem.spacingSm),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: const Icon(
Icons.refresh,
size: 18,
color: AppTheme.primaryColor,
),
),
),
),
);
}
Widget _buildLoadingState() {
return Column(
children: [
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.primaryColor.withOpacity(0.7),
),
),
),
SizedBox(height: DesignSystem.spacingMd),
Text(
'Chargement des données...',
style: DesignSystem.bodyMedium.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
),
],
);
}
}

View File

@@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Card statistique professionnelle avec design basé sur le nombre d'or
class DashboardStatCard extends StatefulWidget {
const DashboardStatCard({
super.key,
required this.title,
required this.value,
required this.icon,
required this.color,
this.trend,
this.subtitle,
this.onTap,
this.isLoading = false,
});
final String title;
final String value;
final IconData icon;
final Color color;
final String? trend;
final String? subtitle;
final VoidCallback? onTap;
final bool isLoading;
@override
State<DashboardStatCard> createState() => _DashboardStatCardState();
}
class _DashboardStatCardState extends State<DashboardStatCard>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _fadeAnimation;
bool _isHovered = false;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: DesignSystem.animationMedium,
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurveEnter,
));
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: FadeTransition(
opacity: _fadeAnimation,
child: _buildCard(context),
),
);
},
);
}
Widget _buildCard(BuildContext context) {
return MouseRegion(
onEnter: (_) => _setHovered(true),
onExit: (_) => _setHovered(false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: DesignSystem.animationFast,
curve: DesignSystem.animationCurve,
padding: EdgeInsets.all(DesignSystem.spacingLg),
decoration: BoxDecoration(
color: AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
boxShadow: _isHovered ? DesignSystem.shadowCardHover : DesignSystem.shadowCard,
border: Border.all(
color: widget.color.withOpacity(0.1),
width: 1,
),
),
child: widget.isLoading ? _buildLoadingState() : _buildContent(),
),
),
);
}
Widget _buildLoadingState() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildShimmer(40, 40, isCircular: true),
if (widget.trend != null) _buildShimmer(60, 24, radius: 12),
],
),
SizedBox(height: DesignSystem.spacingMd),
_buildShimmer(80, 32),
SizedBox(height: DesignSystem.spacingSm),
_buildShimmer(120, 16),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
_buildShimmer(100, 14),
],
],
);
}
Widget _buildShimmer(double width, double height, {double? radius, bool isCircular = false}) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: AppTheme.textHint.withOpacity(0.1),
borderRadius: isCircular
? BorderRadius.circular(height / 2)
: BorderRadius.circular(radius ?? DesignSystem.radiusSm),
),
);
}
Widget _buildContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.goldenHeight(DesignSystem.spacingLg)),
_buildValue(),
SizedBox(height: DesignSystem.spacingSm),
_buildTitle(),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
_buildSubtitle(),
],
],
);
}
Widget _buildHeader() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildIconContainer(),
if (widget.trend != null) _buildTrendBadge(),
],
);
}
Widget _buildIconContainer() {
return Container(
width: DesignSystem.goldenWidth(32),
height: 32,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
widget.color.withOpacity(0.15),
widget.color.withOpacity(0.05),
],
),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
border: Border.all(
color: widget.color.withOpacity(0.2),
width: 1,
),
),
child: Icon(
widget.icon,
color: widget.color,
size: 20,
),
);
}
Widget _buildTrendBadge() {
return Container(
padding: EdgeInsets.symmetric(
horizontal: DesignSystem.spacingSm,
vertical: DesignSystem.spacingXs,
),
decoration: BoxDecoration(
color: _getTrendColor().withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusXl),
border: Border.all(
color: _getTrendColor().withOpacity(0.2),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getTrendIcon(),
color: _getTrendColor(),
size: 14,
),
SizedBox(width: DesignSystem.spacing2xs),
Text(
widget.trend!,
style: DesignSystem.labelSmall.copyWith(
color: _getTrendColor(),
fontWeight: FontWeight.w600,
),
),
],
),
);
}
Widget _buildValue() {
return Text(
widget.value,
style: DesignSystem.displayMedium.copyWith(
color: widget.color,
fontWeight: FontWeight.w800,
fontSize: 28,
),
);
}
Widget _buildTitle() {
return Text(
widget.title,
style: DesignSystem.labelLarge.copyWith(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
);
}
Widget _buildSubtitle() {
return Text(
widget.subtitle!,
style: DesignSystem.labelMedium.copyWith(
color: AppTheme.textHint,
),
);
}
void _setHovered(bool hovered) {
if (mounted) {
setState(() {
_isHovered = hovered;
});
}
}
Color _getTrendColor() {
if (widget.trend == null) return AppTheme.textSecondary;
if (widget.trend!.startsWith('+')) {
return AppTheme.successColor;
} else if (widget.trend!.startsWith('-')) {
return AppTheme.errorColor;
} else {
return AppTheme.warningColor;
}
}
IconData _getTrendIcon() {
if (widget.trend == null) return Icons.trending_flat;
if (widget.trend!.startsWith('+')) {
return Icons.trending_up;
} else if (widget.trend!.startsWith('-')) {
return Icons.trending_down;
} else {
return Icons.trending_flat;
}
}
}

View File

@@ -0,0 +1,456 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../pages/membre_edit_page.dart';
/// Section des actions disponibles pour un membre
class MembreActionsSection extends StatelessWidget {
const MembreActionsSection({
super.key,
required this.membre,
this.onEdit,
this.onDelete,
this.onExport,
this.onCall,
this.onMessage,
this.onEmail,
});
final MembreModel membre;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
final VoidCallback? onExport;
final VoidCallback? onCall;
final VoidCallback? onMessage;
final VoidCallback? onEmail;
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.settings,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Actions',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 16),
_buildActionGrid(context),
],
),
),
);
}
Widget _buildActionGrid(BuildContext context) {
return Column(
children: [
Row(
children: [
Expanded(
child: _buildActionButton(
context,
'Modifier',
Icons.edit,
AppTheme.primaryColor,
onEdit ?? () => _showNotImplemented(context, 'Modification'),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionButton(
context,
'Appeler',
Icons.phone,
AppTheme.successColor,
onCall ?? () => _callMember(context),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildActionButton(
context,
'Message',
Icons.message,
AppTheme.infoColor,
onMessage ?? () => _messageMember(context),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionButton(
context,
'Email',
Icons.email,
AppTheme.warningColor,
onEmail ?? () => _emailMember(context),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildActionButton(
context,
'Exporter',
Icons.download,
AppTheme.textSecondary,
onExport ?? () => _exportMember(context),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionButton(
context,
'Supprimer',
Icons.delete,
AppTheme.errorColor,
onDelete ?? () => _deleteMember(context),
),
),
],
),
const SizedBox(height: 20),
_buildQuickInfoSection(context),
],
);
}
Widget _buildActionButton(
BuildContext context,
String label,
IconData icon,
Color color,
VoidCallback onPressed,
) {
return Material(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Column(
children: [
Icon(icon, color: color, size: 24),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
Widget _buildQuickInfoSection(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.backgroundLight,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informations rapides',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
_buildQuickInfoRow(
'Numéro de membre',
membre.numeroMembre,
Icons.badge,
() => _copyToClipboard(context, membre.numeroMembre, 'Numéro de membre'),
),
_buildQuickInfoRow(
'Téléphone',
membre.telephone,
Icons.phone,
() => _copyToClipboard(context, membre.telephone, 'Téléphone'),
),
_buildQuickInfoRow(
'Email',
membre.email,
Icons.email,
() => _copyToClipboard(context, membre.email, 'Email'),
),
],
),
);
}
Widget _buildQuickInfoRow(
String label,
String value,
IconData icon,
VoidCallback onTap,
) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
children: [
Icon(icon, size: 16, color: AppTheme.textSecondary),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 10,
color: AppTheme.textSecondary,
),
),
Text(
value,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
],
),
),
const Icon(
Icons.copy,
size: 14,
color: AppTheme.textHint,
),
],
),
),
),
);
}
void _callMember(BuildContext context) {
// TODO: Implémenter l'appel téléphonique
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Appeler le membre'),
content: Text('Voulez-vous appeler ${membre.prenom} ${membre.nom} au ${membre.telephone} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_showNotImplemented(context, 'Appel téléphonique');
},
child: const Text('Appeler'),
),
],
),
);
}
void _messageMember(BuildContext context) {
// TODO: Implémenter l'envoi de SMS
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Envoyer un message'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Envoyer un SMS à ${membre.prenom} ${membre.nom} ?'),
const SizedBox(height: 16),
const TextField(
decoration: InputDecoration(
labelText: 'Message',
border: OutlineInputBorder(),
),
maxLines: 3,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_showNotImplemented(context, 'Envoi de SMS');
},
child: const Text('Envoyer'),
),
],
),
);
}
void _emailMember(BuildContext context) {
// TODO: Implémenter l'envoi d'email
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Envoyer un email'),
content: Text('Ouvrir l\'application email pour envoyer un message à ${membre.email} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_showNotImplemented(context, 'Envoi d\'email');
},
child: const Text('Ouvrir'),
),
],
),
);
}
void _exportMember(BuildContext context) {
// TODO: Implémenter l'export des données du membre
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Exporter les données'),
content: Text('Exporter les données de ${membre.prenom} ${membre.nom} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_showNotImplemented(context, 'Export des données');
},
child: const Text('Exporter'),
),
],
),
);
}
void _deleteMember(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer le membre'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning,
color: AppTheme.errorColor,
size: 48,
),
const SizedBox(height: 16),
Text(
'Êtes-vous sûr de vouloir supprimer ${membre.prenom} ${membre.nom} ?',
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Cette action est irréversible.',
style: TextStyle(
color: AppTheme.errorColor,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_showNotImplemented(context, 'Suppression du membre');
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
),
child: const Text('Supprimer'),
),
],
),
);
}
void _copyToClipboard(BuildContext context, String text, String label) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$label copié dans le presse-papiers'),
duration: const Duration(seconds: 2),
backgroundColor: AppTheme.successColor,
),
);
}
void _showNotImplemented(BuildContext context, String feature) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$feature - Fonctionnalité à implémenter'),
backgroundColor: AppTheme.infoColor,
),
);
}
}

View File

@@ -0,0 +1,431 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../shared/theme/app_theme.dart';
/// Section des cotisations d'un membre
class MembreCotisationsSection extends StatelessWidget {
const MembreCotisationsSection({
super.key,
required this.membre,
required this.cotisations,
required this.isLoading,
this.onRefresh,
});
final MembreModel membre;
final List<CotisationModel> cotisations;
final bool isLoading;
final VoidCallback? onRefresh;
@override
Widget build(BuildContext context) {
if (isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Chargement des cotisations...'),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
onRefresh?.call();
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSummaryCard(),
const SizedBox(height: 16),
_buildCotisationsList(),
],
),
),
);
}
Widget _buildSummaryCard() {
final totalDu = cotisations.fold<double>(
0,
(sum, cotisation) => sum + cotisation.montantDu,
);
final totalPaye = cotisations.fold<double>(
0,
(sum, cotisation) => sum + cotisation.montantPaye,
);
final totalRestant = totalDu - totalPaye;
final cotisationsPayees = cotisations.where((c) => c.statut == 'PAYEE').length;
final cotisationsEnRetard = cotisations.where((c) => c.isEnRetard).length;
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.account_balance_wallet,
color: AppTheme.primaryColor,
size: 24,
),
const SizedBox(width: 8),
const Text(
'Résumé des cotisations',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _buildSummaryItem(
'Total dû',
_formatAmount(totalDu),
AppTheme.infoColor,
Icons.receipt_long,
),
),
Expanded(
child: _buildSummaryItem(
'Payé',
_formatAmount(totalPaye),
AppTheme.successColor,
Icons.check_circle,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildSummaryItem(
'Restant',
_formatAmount(totalRestant),
totalRestant > 0 ? AppTheme.warningColor : AppTheme.successColor,
Icons.pending,
),
),
Expanded(
child: _buildSummaryItem(
'En retard',
'$cotisationsEnRetard',
cotisationsEnRetard > 0 ? AppTheme.errorColor : AppTheme.successColor,
Icons.warning,
),
),
],
),
const SizedBox(height: 16),
LinearProgressIndicator(
value: totalDu > 0 ? totalPaye / totalDu : 0,
backgroundColor: AppTheme.backgroundLight,
valueColor: AlwaysStoppedAnimation<Color>(
totalPaye == totalDu ? AppTheme.successColor : AppTheme.primaryColor,
),
),
const SizedBox(height: 8),
Text(
'$cotisationsPayees/${cotisations.length} cotisations payées',
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
);
}
Widget _buildSummaryItem(String label, String value, Color color, IconData icon) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Column(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
);
}
Widget _buildCotisationsList() {
if (cotisations.isEmpty) {
return Card(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(
Icons.receipt_long_outlined,
size: 48,
color: AppTheme.textHint,
),
const SizedBox(height: 16),
const Text(
'Aucune cotisation',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'Ce membre n\'a pas encore de cotisations enregistrées.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
],
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.list_alt,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Historique des cotisations',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 12),
...cotisations.map((cotisation) => _buildCotisationCard(cotisation)),
],
);
}
Widget _buildCotisationCard(CotisationModel cotisation) {
return Card(
elevation: 1,
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
cotisation.periode ?? 'Période non définie',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
cotisation.typeCotisation,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
_buildStatusBadge(cotisation),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildCotisationDetail(
'Montant dû',
_formatAmount(cotisation.montantDu),
Icons.receipt,
),
),
Expanded(
child: _buildCotisationDetail(
'Montant payé',
_formatAmount(cotisation.montantPaye),
Icons.payment,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _buildCotisationDetail(
'Échéance',
DateFormat('dd/MM/yyyy').format(cotisation.dateEcheance),
Icons.schedule,
),
),
if (cotisation.datePaiement != null)
Expanded(
child: _buildCotisationDetail(
'Payé le',
DateFormat('dd/MM/yyyy').format(cotisation.datePaiement!),
Icons.check_circle,
),
),
],
),
],
),
),
);
}
Widget _buildStatusBadge(CotisationModel cotisation) {
Color color;
String label;
switch (cotisation.statut) {
case 'PAYEE':
color = AppTheme.successColor;
label = 'Payée';
break;
case 'EN_ATTENTE':
color = AppTheme.warningColor;
label = 'En attente';
break;
case 'EN_RETARD':
color = AppTheme.errorColor;
label = 'En retard';
break;
case 'PARTIELLEMENT_PAYEE':
color = AppTheme.infoColor;
label = 'Partielle';
break;
default:
color = AppTheme.textSecondary;
label = cotisation.statut;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
Widget _buildCotisationDetail(String label, String value, IconData icon) {
return Row(
children: [
Icon(icon, size: 14, color: AppTheme.textSecondary),
const SizedBox(width: 4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 10,
color: AppTheme.textSecondary,
),
),
Text(
value,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
],
),
),
],
);
}
String _formatAmount(double amount) {
return NumberFormat.currency(
locale: 'fr_FR',
symbol: 'FCFA',
decimalDigits: 0,
).format(amount);
}
}

View File

@@ -0,0 +1,495 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../bloc/membres_bloc.dart';
import '../bloc/membres_event.dart';
import '../bloc/membres_state.dart';
/// Dialog de confirmation de suppression/désactivation d'un membre
class MembreDeleteDialog extends StatefulWidget {
const MembreDeleteDialog({
super.key,
required this.membre,
});
final MembreModel membre;
@override
State<MembreDeleteDialog> createState() => _MembreDeleteDialogState();
}
class _MembreDeleteDialogState extends State<MembreDeleteDialog> {
late MembresBloc _membresBloc;
bool _isLoading = false;
bool _softDelete = true; // Par défaut, désactivation plutôt que suppression
bool _hasActiveCotisations = false;
bool _hasUnpaidCotisations = false;
int _totalCotisations = 0;
double _unpaidAmount = 0.0;
@override
void initState() {
super.initState();
_membresBloc = getIt<MembresBloc>();
_checkMemberDependencies();
}
void _checkMemberDependencies() {
// TODO: Implémenter la vérification des dépendances via le repository
// Pour l'instant, simulation avec des données fictives
setState(() {
_hasActiveCotisations = true;
_hasUnpaidCotisations = true;
_totalCotisations = 5;
_unpaidAmount = 75000.0;
});
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _membresBloc,
child: BlocConsumer<MembresBloc, MembresState>(
listener: (context, state) {
if (state is MembreDeleted) {
setState(() {
_isLoading = false;
});
Navigator.of(context).pop(true);
} else if (state is MembreUpdated) {
setState(() {
_isLoading = false;
});
Navigator.of(context).pop(true);
} else if (state is MembresError) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppTheme.errorColor,
),
);
}
},
builder: (context, state) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(
_softDelete ? Icons.person_off : Icons.delete_forever,
color: _softDelete ? AppTheme.warningColor : AppTheme.errorColor,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_softDelete ? 'Désactiver le membre' : 'Supprimer le membre',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Informations du membre
_buildMemberInfo(),
const SizedBox(height: 20),
// Vérifications des dépendances
if (_hasActiveCotisations || _hasUnpaidCotisations)
_buildDependenciesWarning(),
const SizedBox(height: 16),
// Options de suppression
_buildDeleteOptions(),
const SizedBox(height: 20),
// Message de confirmation
_buildConfirmationMessage(),
],
),
),
actions: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: _isLoading ? null : _handleDelete,
style: ElevatedButton.styleFrom(
backgroundColor: _softDelete ? AppTheme.warningColor : AppTheme.errorColor,
foregroundColor: Colors.white,
),
child: _isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(_softDelete ? 'Désactiver' : 'Supprimer'),
),
],
);
},
),
);
}
Widget _buildMemberInfo() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.backgroundLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: AppTheme.primaryColor,
child: Text(
'${widget.membre.prenom[0]}${widget.membre.nom[0]}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${widget.membre.prenom} ${widget.membre.nom}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
Text(
widget.membre.numeroMembre,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: widget.membre.actif ? AppTheme.successColor : AppTheme.errorColor,
borderRadius: BorderRadius.circular(12),
),
child: Text(
widget.membre.actif ? 'Actif' : 'Inactif',
style: const TextStyle(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 8),
Text(
widget.membre.email,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
);
}
Widget _buildDependenciesWarning() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.warningColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.warningColor.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.warning_amber,
color: AppTheme.warningColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Attention - Dépendances détectées',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.warningColor,
),
),
],
),
const SizedBox(height: 8),
if (_hasActiveCotisations) ...[
Text(
'$_totalCotisations cotisations associées à ce membre',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
if (_hasUnpaidCotisations) ...[
Text(
'${_unpaidAmount.toStringAsFixed(0)} XOF de cotisations impayées',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
const SizedBox(height: 8),
const Text(
'La désactivation est recommandée pour préserver l\'historique.',
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
fontStyle: FontStyle.italic,
),
),
],
),
);
}
Widget _buildDeleteOptions() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Options de suppression :',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
// Option désactivation
InkWell(
onTap: () {
setState(() {
_softDelete = true;
});
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _softDelete ? AppTheme.warningColor.withOpacity(0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _softDelete ? AppTheme.warningColor : AppTheme.borderColor,
width: _softDelete ? 2 : 1,
),
),
child: Row(
children: [
Radio<bool>(
value: true,
groupValue: _softDelete,
onChanged: (value) {
setState(() {
_softDelete = value!;
});
},
activeColor: AppTheme.warningColor,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Désactiver le membre (Recommandé)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
const Text(
'Le membre sera marqué comme inactif mais ses données et historique seront préservés.',
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
),
),
const SizedBox(height: 8),
// Option suppression définitive
InkWell(
onTap: () {
setState(() {
_softDelete = false;
});
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: !_softDelete ? AppTheme.errorColor.withOpacity(0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: !_softDelete ? AppTheme.errorColor : AppTheme.borderColor,
width: !_softDelete ? 2 : 1,
),
),
child: Row(
children: [
Radio<bool>(
value: false,
groupValue: _softDelete,
onChanged: (value) {
setState(() {
_softDelete = value!;
});
},
activeColor: AppTheme.errorColor,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Supprimer définitivement',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
const Text(
'ATTENTION : Cette action est irréversible. Toutes les données du membre seront perdues.',
style: TextStyle(
fontSize: 11,
color: AppTheme.errorColor,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
),
),
],
);
}
Widget _buildConfirmationMessage() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _softDelete
? AppTheme.warningColor.withOpacity(0.1)
: AppTheme.errorColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _softDelete
? AppTheme.warningColor.withOpacity(0.3)
: AppTheme.errorColor.withOpacity(0.3),
),
),
child: Text(
_softDelete
? 'Le membre "${widget.membre.prenom} ${widget.membre.nom}" sera désactivé et ne pourra plus accéder aux services, mais son historique sera préservé.'
: 'Le membre "${widget.membre.prenom} ${widget.membre.nom}" sera définitivement supprimé avec toutes ses données. Cette action ne peut pas être annulée.',
style: TextStyle(
fontSize: 12,
color: _softDelete ? AppTheme.warningColor : AppTheme.errorColor,
fontWeight: FontWeight.w500,
),
),
);
}
void _handleDelete() {
setState(() {
_isLoading = true;
});
if (_softDelete) {
// Désactivation du membre
final membreDesactive = widget.membre.copyWith(
actif: false,
version: widget.membre.version + 1,
dateModification: DateTime.now(),
);
final memberId = widget.membre.id;
if (memberId != null && memberId.isNotEmpty) {
_membresBloc.add(UpdateMembre(memberId, membreDesactive));
} else {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Erreur : ID du membre manquant'),
backgroundColor: AppTheme.errorColor,
),
);
}
} else {
// Suppression définitive
final memberId = widget.membre.id;
if (memberId != null && memberId.isNotEmpty) {
_membresBloc.add(DeleteMembre(memberId));
} else {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Erreur : ID du membre manquant'),
backgroundColor: AppTheme.errorColor,
),
);
}
}
}
}

View File

@@ -0,0 +1,373 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../shared/theme/app_theme.dart';
/// Section d'informations détaillées d'un membre
class MembreInfoSection extends StatelessWidget {
const MembreInfoSection({
super.key,
required this.membre,
this.showActions = false,
this.onEdit,
this.onCall,
this.onMessage,
});
final MembreModel membre;
final bool showActions;
final VoidCallback? onEdit;
final VoidCallback? onCall;
final VoidCallback? onMessage;
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
const SizedBox(height: 20),
_buildPersonalInfo(),
const SizedBox(height: 16),
_buildContactInfo(),
const SizedBox(height: 16),
_buildMembershipInfo(),
if (showActions) ...[
const SizedBox(height: 20),
_buildActionButtons(),
],
],
),
),
);
}
Widget _buildHeader() {
return Row(
children: [
_buildAvatar(),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
membre.nomComplet,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
membre.numeroMembre,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
_buildStatusBadge(),
],
),
),
],
);
}
Widget _buildAvatar() {
return Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(40),
border: Border.all(
color: AppTheme.primaryColor.withOpacity(0.3),
width: 2,
),
),
child: Icon(
Icons.person,
size: 40,
color: AppTheme.primaryColor,
),
);
}
Widget _buildStatusBadge() {
final isActive = membre.actif;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isActive ? AppTheme.successColor : AppTheme.errorColor,
borderRadius: BorderRadius.circular(20),
),
child: Text(
isActive ? 'Actif' : 'Inactif',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildPersonalInfo() {
return _buildSection(
title: 'Informations personnelles',
icon: Icons.person_outline,
children: [
_buildInfoRow(
icon: Icons.cake_outlined,
label: 'Date de naissance',
value: membre.dateNaissance != null
? DateFormat('dd/MM/yyyy').format(membre.dateNaissance!)
: 'Non renseignée',
),
_buildInfoRow(
icon: Icons.work_outline,
label: 'Profession',
value: membre.profession ?? 'Non renseignée',
),
_buildInfoRow(
icon: Icons.location_on_outlined,
label: 'Adresse',
value: _buildFullAddress(),
),
],
);
}
Widget _buildContactInfo() {
return _buildSection(
title: 'Contact',
icon: Icons.contact_phone_outlined,
children: [
_buildInfoRow(
icon: Icons.email_outlined,
label: 'Email',
value: membre.email,
isSelectable: true,
),
_buildInfoRow(
icon: Icons.phone_outlined,
label: 'Téléphone',
value: membre.telephone,
isSelectable: true,
),
],
);
}
Widget _buildMembershipInfo() {
return _buildSection(
title: 'Adhésion',
icon: Icons.card_membership_outlined,
children: [
_buildInfoRow(
icon: Icons.calendar_today_outlined,
label: 'Date d\'adhésion',
value: DateFormat('dd/MM/yyyy').format(membre.dateAdhesion),
),
_buildInfoRow(
icon: Icons.access_time_outlined,
label: 'Membre depuis',
value: _calculateMembershipDuration(),
),
_buildInfoRow(
icon: Icons.update_outlined,
label: 'Dernière modification',
value: membre.dateModification != null
? DateFormat('dd/MM/yyyy à HH:mm').format(membre.dateModification!)
: 'Jamais modifié',
),
],
);
}
Widget _buildSection({
required String title,
required IconData icon,
required List<Widget> children,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
size: 20,
color: AppTheme.primaryColor,
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 12),
...children,
],
);
}
Widget _buildInfoRow({
required IconData icon,
required String label,
required String value,
bool isSelectable = false,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 16,
color: AppTheme.textSecondary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
isSelectable
? SelectableText(
value,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
),
)
: Text(
value,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
),
),
],
),
),
],
),
);
}
Widget _buildActionButtons() {
return Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit, size: 18),
label: const Text('Modifier'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: onCall,
icon: const Icon(Icons.phone, size: 18),
label: const Text('Appeler'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primaryColor,
side: const BorderSide(color: AppTheme.primaryColor),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: onMessage,
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.infoColor,
side: const BorderSide(color: AppTheme.infoColor),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Icon(Icons.message, size: 18),
),
],
);
}
String _buildFullAddress() {
final parts = <String>[];
if (membre.adresse != null && membre.adresse!.isNotEmpty) {
parts.add(membre.adresse!);
}
if (membre.ville != null && membre.ville!.isNotEmpty) {
parts.add(membre.ville!);
}
if (membre.codePostal != null && membre.codePostal!.isNotEmpty) {
parts.add(membre.codePostal!);
}
if (membre.pays != null && membre.pays!.isNotEmpty) {
parts.add(membre.pays!);
}
return parts.isNotEmpty ? parts.join(', ') : 'Non renseignée';
}
String _calculateMembershipDuration() {
final now = DateTime.now();
final adhesion = membre.dateAdhesion;
final difference = now.difference(adhesion);
final years = (difference.inDays / 365).floor();
final months = ((difference.inDays % 365) / 30).floor();
if (years > 0) {
return months > 0 ? '$years an${years > 1 ? 's' : ''} et $months mois' : '$years an${years > 1 ? 's' : ''}';
} else if (months > 0) {
return '$months mois';
} else {
return '${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
}
}
}

View File

@@ -0,0 +1,592 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../shared/theme/app_theme.dart';
/// Section des statistiques d'un membre
class MembreStatsSection extends StatelessWidget {
const MembreStatsSection({
super.key,
required this.membre,
required this.cotisations,
});
final MembreModel membre;
final List<CotisationModel> cotisations;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildOverviewCard(),
const SizedBox(height: 16),
_buildPaymentChart(),
const SizedBox(height: 16),
_buildStatusChart(),
const SizedBox(height: 16),
_buildTimelineCard(),
],
),
);
}
Widget _buildOverviewCard() {
final totalCotisations = cotisations.length;
final cotisationsPayees = cotisations.where((c) => c.statut == 'PAYEE').length;
final cotisationsEnRetard = cotisations.where((c) => c.isEnRetard).length;
final tauxPaiement = totalCotisations > 0 ? (cotisationsPayees / totalCotisations * 100) : 0.0;
final totalMontantDu = cotisations.fold<double>(0, (sum, c) => sum + c.montantDu);
final totalMontantPaye = cotisations.fold<double>(0, (sum, c) => sum + c.montantPaye);
final membershipDuration = DateTime.now().difference(membre.dateAdhesion).inDays;
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.analytics,
color: AppTheme.primaryColor,
size: 24,
),
const SizedBox(width: 8),
const Text(
'Vue d\'ensemble',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _buildStatItem(
'Cotisations',
'$totalCotisations',
AppTheme.primaryColor,
Icons.receipt_long,
),
),
Expanded(
child: _buildStatItem(
'Taux de paiement',
'${tauxPaiement.toStringAsFixed(1)}%',
tauxPaiement >= 80 ? AppTheme.successColor :
tauxPaiement >= 50 ? AppTheme.warningColor : AppTheme.errorColor,
Icons.trending_up,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildStatItem(
'En retard',
'$cotisationsEnRetard',
cotisationsEnRetard > 0 ? AppTheme.errorColor : AppTheme.successColor,
Icons.warning,
),
),
Expanded(
child: _buildStatItem(
'Ancienneté',
'${(membershipDuration / 365).floor()} an${(membershipDuration / 365).floor() > 1 ? 's' : ''}',
AppTheme.infoColor,
Icons.schedule,
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.backgroundLight,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Total payé',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
Text(
_formatAmount(totalMontantPaye),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.successColor,
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Text(
'Restant à payer',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
Text(
_formatAmount(totalMontantDu - totalMontantPaye),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: totalMontantDu > totalMontantPaye ? AppTheme.warningColor : AppTheme.successColor,
),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildStatItem(String label, String value, Color color, IconData icon) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Column(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildPaymentChart() {
if (cotisations.isEmpty) {
return _buildEmptyChart('Aucune donnée de paiement');
}
final paymentData = _getPaymentChartData();
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.pie_chart,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Répartition des paiements',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 20),
SizedBox(
height: 200,
child: PieChart(
PieChartData(
sections: paymentData,
centerSpaceRadius: 40,
sectionsSpace: 2,
),
),
),
const SizedBox(height: 16),
_buildChartLegend(),
],
),
),
);
}
Widget _buildStatusChart() {
if (cotisations.isEmpty) {
return _buildEmptyChart('Aucune donnée de statut');
}
final statusData = _getStatusChartData();
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.bar_chart,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Évolution des montants',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 20),
SizedBox(
height: 200,
child: BarChart(
BarChartData(
barGroups: statusData,
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 60,
getTitlesWidget: (value, meta) {
return Text(
_formatAmount(value),
style: const TextStyle(
fontSize: 10,
color: AppTheme.textSecondary,
),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index >= 0 && index < cotisations.length) {
return Text(
(cotisations[index].periode ?? 'N/A').substring(0, 3),
style: const TextStyle(
fontSize: 10,
color: AppTheme.textSecondary,
),
);
}
return const Text('');
},
),
),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: false),
gridData: const FlGridData(show: false),
),
),
),
],
),
),
);
}
Widget _buildTimelineCard() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.timeline,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Chronologie',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 16),
_buildTimelineItem(
'Adhésion',
DateFormat('dd/MM/yyyy').format(membre.dateAdhesion),
AppTheme.primaryColor,
Icons.person_add,
true,
),
if (cotisations.isNotEmpty) ...[
_buildTimelineItem(
'Première cotisation',
DateFormat('dd/MM/yyyy').format(
cotisations.map((c) => c.dateCreation).reduce((a, b) => a.isBefore(b) ? a : b),
),
AppTheme.infoColor,
Icons.payment,
true,
),
_buildTimelineItem(
'Dernière cotisation',
DateFormat('dd/MM/yyyy').format(
cotisations.map((c) => c.dateCreation).reduce((a, b) => a.isAfter(b) ? a : b),
),
AppTheme.successColor,
Icons.receipt,
false,
),
],
],
),
),
);
}
Widget _buildTimelineItem(String title, String date, Color color, IconData icon, bool showLine) {
return Row(
children: [
Column(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(icon, color: Colors.white, size: 16),
),
if (showLine)
Container(
width: 2,
height: 24,
color: color.withOpacity(0.3),
),
],
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
Text(
date,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
],
);
}
Widget _buildEmptyChart(String message) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(40),
child: Column(
children: [
Icon(
Icons.bar_chart,
size: 48,
color: AppTheme.textHint,
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
],
),
),
);
}
Widget _buildChartLegend() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildLegendItem('Payé', AppTheme.successColor),
_buildLegendItem('En attente', AppTheme.warningColor),
_buildLegendItem('En retard', AppTheme.errorColor),
],
);
}
Widget _buildLegendItem(String label, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
);
}
List<PieChartSectionData> _getPaymentChartData() {
final payees = cotisations.where((c) => c.statut == 'PAYEE').length;
final enAttente = cotisations.where((c) => c.statut == 'EN_ATTENTE').length;
final enRetard = cotisations.where((c) => c.isEnRetard).length;
final total = cotisations.length;
return [
if (payees > 0)
PieChartSectionData(
color: AppTheme.successColor,
value: payees.toDouble(),
title: '${(payees / total * 100).toStringAsFixed(1)}%',
radius: 50,
),
if (enAttente > 0)
PieChartSectionData(
color: AppTheme.warningColor,
value: enAttente.toDouble(),
title: '${(enAttente / total * 100).toStringAsFixed(1)}%',
radius: 50,
),
if (enRetard > 0)
PieChartSectionData(
color: AppTheme.errorColor,
value: enRetard.toDouble(),
title: '${(enRetard / total * 100).toStringAsFixed(1)}%',
radius: 50,
),
];
}
List<BarChartGroupData> _getStatusChartData() {
return cotisations.asMap().entries.map((entry) {
final index = entry.key;
final cotisation = entry.value;
return BarChartGroupData(
x: index,
barRods: [
BarChartRodData(
toY: cotisation.montantDu,
color: AppTheme.infoColor.withOpacity(0.7),
width: 8,
),
BarChartRodData(
toY: cotisation.montantPaye,
color: AppTheme.successColor,
width: 8,
),
],
);
}).toList();
}
String _formatAmount(double amount) {
return NumberFormat.currency(
locale: 'fr_FR',
symbol: 'FCFA',
decimalDigits: 0,
).format(amount);
}
}

View File

@@ -0,0 +1,626 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/custom_text_field.dart';
/// Widget de recherche avancée pour les membres
class MembresAdvancedSearch extends StatefulWidget {
const MembresAdvancedSearch({
super.key,
required this.onSearch,
this.initialFilters,
});
final Function(Map<String, dynamic>) onSearch;
final Map<String, dynamic>? initialFilters;
@override
State<MembresAdvancedSearch> createState() => _MembresAdvancedSearchState();
}
class _MembresAdvancedSearchState extends State<MembresAdvancedSearch> {
final _formKey = GlobalKey<FormState>();
// Contrôleurs de texte
final _nomController = TextEditingController();
final _prenomController = TextEditingController();
final _emailController = TextEditingController();
final _telephoneController = TextEditingController();
final _numeroMembreController = TextEditingController();
final _professionController = TextEditingController();
final _villeController = TextEditingController();
// Filtres de statut
bool? _actifFilter;
// Filtres de date
DateTime? _dateAdhesionDebut;
DateTime? _dateAdhesionFin;
DateTime? _dateNaissanceDebut;
DateTime? _dateNaissanceFin;
// Filtres d'âge
int? _ageMin;
int? _ageMax;
@override
void initState() {
super.initState();
_initializeFilters();
}
void _initializeFilters() {
if (widget.initialFilters != null) {
final filters = widget.initialFilters!;
_nomController.text = filters['nom'] ?? '';
_prenomController.text = filters['prenom'] ?? '';
_emailController.text = filters['email'] ?? '';
_telephoneController.text = filters['telephone'] ?? '';
_numeroMembreController.text = filters['numeroMembre'] ?? '';
_professionController.text = filters['profession'] ?? '';
_villeController.text = filters['ville'] ?? '';
_actifFilter = filters['actif'];
_dateAdhesionDebut = filters['dateAdhesionDebut'];
_dateAdhesionFin = filters['dateAdhesionFin'];
_dateNaissanceDebut = filters['dateNaissanceDebut'];
_dateNaissanceFin = filters['dateNaissanceFin'];
_ageMin = filters['ageMin'];
_ageMax = filters['ageMax'];
}
}
@override
void dispose() {
_nomController.dispose();
_prenomController.dispose();
_emailController.dispose();
_telephoneController.dispose();
_numeroMembreController.dispose();
_professionController.dispose();
_villeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête
_buildHeader(),
const SizedBox(height: 20),
// Contenu scrollable
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Informations personnelles
_buildSection(
'Informations personnelles',
Icons.person,
[
Row(
children: [
Expanded(
child: CustomTextField(
controller: _nomController,
label: 'Nom',
prefixIcon: Icons.person_outline,
),
),
const SizedBox(width: 12),
Expanded(
child: CustomTextField(
controller: _prenomController,
label: 'Prénom',
prefixIcon: Icons.person_outline,
),
),
],
),
const SizedBox(height: 12),
CustomTextField(
controller: _numeroMembreController,
label: 'Numéro de membre',
prefixIcon: Icons.badge,
),
const SizedBox(height: 12),
CustomTextField(
controller: _professionController,
label: 'Profession',
prefixIcon: Icons.work,
),
],
),
const SizedBox(height: 20),
// Contact et localisation
_buildSection(
'Contact et localisation',
Icons.contact_phone,
[
CustomTextField(
controller: _emailController,
label: 'Email',
prefixIcon: Icons.email,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 12),
CustomTextField(
controller: _telephoneController,
label: 'Téléphone',
prefixIcon: Icons.phone,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
CustomTextField(
controller: _villeController,
label: 'Ville',
prefixIcon: Icons.location_city,
),
],
),
const SizedBox(height: 20),
// Statut et dates
_buildSection(
'Statut et dates',
Icons.calendar_today,
[
_buildStatusFilter(),
const SizedBox(height: 16),
_buildDateRangeFilter(
'Période d\'adhésion',
_dateAdhesionDebut,
_dateAdhesionFin,
(debut, fin) {
setState(() {
_dateAdhesionDebut = debut;
_dateAdhesionFin = fin;
});
},
),
const SizedBox(height: 16),
_buildDateRangeFilter(
'Période de naissance',
_dateNaissanceDebut,
_dateNaissanceFin,
(debut, fin) {
setState(() {
_dateNaissanceDebut = debut;
_dateNaissanceFin = fin;
});
},
),
const SizedBox(height: 16),
_buildAgeRangeFilter(),
],
),
],
),
),
),
const SizedBox(height: 20),
// Boutons d'action
_buildActionButtons(),
],
),
),
);
}
Widget _buildHeader() {
return Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.search,
color: AppTheme.primaryColor,
size: 24,
),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Recherche avancée',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
color: AppTheme.textSecondary,
),
],
);
}
Widget _buildSection(String title, IconData icon, List<Widget> children) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 12),
...children,
],
);
}
Widget _buildStatusFilter() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Statut du membre',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: RadioListTile<bool?>(
title: const Text('Tous', style: TextStyle(fontSize: 14)),
value: null,
groupValue: _actifFilter,
onChanged: (value) {
setState(() {
_actifFilter = value;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
),
Expanded(
child: RadioListTile<bool?>(
title: const Text('Actifs', style: TextStyle(fontSize: 14)),
value: true,
groupValue: _actifFilter,
onChanged: (value) {
setState(() {
_actifFilter = value;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
),
Expanded(
child: RadioListTile<bool?>(
title: const Text('Inactifs', style: TextStyle(fontSize: 14)),
value: false,
groupValue: _actifFilter,
onChanged: (value) {
setState(() {
_actifFilter = value;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
),
],
),
],
);
}
Widget _buildDateRangeFilter(
String title,
DateTime? dateDebut,
DateTime? dateFin,
Function(DateTime?, DateTime?) onChanged,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: InkWell(
onTap: () => _selectDate(context, dateDebut, (date) {
onChanged(date, dateFin);
}),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
decoration: BoxDecoration(
border: Border.all(color: AppTheme.borderColor),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.calendar_today,
color: AppTheme.textSecondary,
size: 16,
),
const SizedBox(width: 8),
Text(
dateDebut != null
? DateFormat('dd/MM/yyyy').format(dateDebut)
: 'Date début',
style: TextStyle(
fontSize: 14,
color: dateDebut != null
? AppTheme.textPrimary
: AppTheme.textSecondary,
),
),
],
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: InkWell(
onTap: () => _selectDate(context, dateFin, (date) {
onChanged(dateDebut, date);
}),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
decoration: BoxDecoration(
border: Border.all(color: AppTheme.borderColor),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.calendar_today,
color: AppTheme.textSecondary,
size: 16,
),
const SizedBox(width: 8),
Text(
dateFin != null
? DateFormat('dd/MM/yyyy').format(dateFin)
: 'Date fin',
style: TextStyle(
fontSize: 14,
color: dateFin != null
? AppTheme.textPrimary
: AppTheme.textSecondary,
),
),
],
),
),
),
),
],
),
],
);
}
Widget _buildAgeRangeFilter() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Tranche d\'âge',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextFormField(
initialValue: _ageMin?.toString(),
decoration: InputDecoration(
labelText: 'Âge minimum',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
),
keyboardType: TextInputType.number,
onChanged: (value) {
_ageMin = int.tryParse(value);
},
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
initialValue: _ageMax?.toString(),
decoration: InputDecoration(
labelText: 'Âge maximum',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
),
keyboardType: TextInputType.number,
onChanged: (value) {
_ageMax = int.tryParse(value);
},
),
),
],
),
],
);
}
Widget _buildActionButtons() {
return Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _clearFilters,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: AppTheme.borderColor),
),
child: const Text('Effacer'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: ElevatedButton(
onPressed: _performSearch,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('Rechercher'),
),
),
],
);
}
Future<void> _selectDate(
BuildContext context,
DateTime? initialDate,
Function(DateTime?) onDateSelected,
) async {
final date = await showDatePicker(
context: context,
initialDate: initialDate ?? DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
);
if (date != null) {
onDateSelected(date);
}
}
void _clearFilters() {
setState(() {
_nomController.clear();
_prenomController.clear();
_emailController.clear();
_telephoneController.clear();
_numeroMembreController.clear();
_professionController.clear();
_villeController.clear();
_actifFilter = null;
_dateAdhesionDebut = null;
_dateAdhesionFin = null;
_dateNaissanceDebut = null;
_dateNaissanceFin = null;
_ageMin = null;
_ageMax = null;
});
}
void _performSearch() {
final filters = <String, dynamic>{};
// Ajout des filtres texte
if (_nomController.text.isNotEmpty) {
filters['nom'] = _nomController.text;
}
if (_prenomController.text.isNotEmpty) {
filters['prenom'] = _prenomController.text;
}
if (_emailController.text.isNotEmpty) {
filters['email'] = _emailController.text;
}
if (_telephoneController.text.isNotEmpty) {
filters['telephone'] = _telephoneController.text;
}
if (_numeroMembreController.text.isNotEmpty) {
filters['numeroMembre'] = _numeroMembreController.text;
}
if (_professionController.text.isNotEmpty) {
filters['profession'] = _professionController.text;
}
if (_villeController.text.isNotEmpty) {
filters['ville'] = _villeController.text;
}
// Ajout des filtres de statut
if (_actifFilter != null) {
filters['actif'] = _actifFilter;
}
// Ajout des filtres de date
if (_dateAdhesionDebut != null) {
filters['dateAdhesionDebut'] = _dateAdhesionDebut;
}
if (_dateAdhesionFin != null) {
filters['dateAdhesionFin'] = _dateAdhesionFin;
}
if (_dateNaissanceDebut != null) {
filters['dateNaissanceDebut'] = _dateNaissanceDebut;
}
if (_dateNaissanceFin != null) {
filters['dateNaissanceFin'] = _dateNaissanceFin;
}
// Ajout des filtres d'âge
if (_ageMin != null) {
filters['ageMin'] = _ageMin;
}
if (_ageMax != null) {
filters['ageMax'] = _ageMax;
}
widget.onSearch(filters);
Navigator.of(context).pop();
}
}

View File

@@ -0,0 +1,433 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../core/models/membre_model.dart';
/// Dialog d'export des données des membres
class MembresExportDialog extends StatefulWidget {
const MembresExportDialog({
super.key,
required this.membres,
this.selectedMembers,
});
final List<MembreModel> membres;
final List<MembreModel>? selectedMembers;
@override
State<MembresExportDialog> createState() => _MembresExportDialogState();
}
class _MembresExportDialogState extends State<MembresExportDialog> {
String _selectedFormat = 'excel';
bool _includeInactiveMembers = true;
bool _includePersonalInfo = true;
bool _includeContactInfo = true;
bool _includeAdhesionInfo = true;
bool _includeStatistics = false;
final List<String> _availableFormats = [
'excel',
'csv',
'pdf',
'json',
];
@override
Widget build(BuildContext context) {
final membersToExport = widget.selectedMembers ?? widget.membres;
final activeMembers = membersToExport.where((m) => m.actif).length;
final inactiveMembers = membersToExport.length - activeMembers;
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.file_download,
color: AppTheme.primaryColor,
size: 24,
),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Exporter les données',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Résumé des données à exporter
_buildDataSummary(membersToExport.length, activeMembers, inactiveMembers),
const SizedBox(height: 20),
// Sélection du format
_buildFormatSelection(),
const SizedBox(height: 20),
// Options d'export
_buildExportOptions(),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton.icon(
onPressed: () => _performExport(membersToExport),
icon: const Icon(Icons.download),
label: const Text('Exporter'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
],
);
}
Widget _buildDataSummary(int total, int active, int inactive) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.backgroundLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Données à exporter',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildSummaryItem(
'Total',
total.toString(),
AppTheme.primaryColor,
Icons.people,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildSummaryItem(
'Actifs',
active.toString(),
AppTheme.successColor,
Icons.person,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildSummaryItem(
'Inactifs',
inactive.toString(),
AppTheme.errorColor,
Icons.person_off,
),
),
],
),
],
),
);
}
Widget _buildSummaryItem(String label, String value, Color color, IconData icon) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Icon(
icon,
color: color,
size: 20,
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
);
}
Widget _buildFormatSelection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Format d\'export',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _availableFormats.map((format) {
final isSelected = _selectedFormat == format;
return InkWell(
onTap: () {
setState(() {
_selectedFormat = format;
});
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isSelected ? AppTheme.primaryColor : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? AppTheme.primaryColor : AppTheme.borderColor,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getFormatIcon(format),
color: isSelected ? Colors.white : AppTheme.textSecondary,
size: 20,
),
const SizedBox(width: 8),
Text(
_getFormatLabel(format),
style: TextStyle(
color: isSelected ? Colors.white : AppTheme.textPrimary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
);
}).toList(),
),
],
);
}
Widget _buildExportOptions() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Options d\'export',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
// Inclusion des membres inactifs
CheckboxListTile(
title: const Text('Inclure les membres inactifs'),
subtitle: const Text('Exporter aussi les membres désactivés'),
value: _includeInactiveMembers,
onChanged: (value) {
setState(() {
_includeInactiveMembers = value ?? true;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
const Divider(),
// Sections de données à inclure
const Text(
'Sections à inclure',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Informations personnelles'),
subtitle: const Text('Nom, prénom, date de naissance, etc.'),
value: _includePersonalInfo,
onChanged: (value) {
setState(() {
_includePersonalInfo = value ?? true;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
title: const Text('Informations de contact'),
subtitle: const Text('Email, téléphone, adresse'),
value: _includeContactInfo,
onChanged: (value) {
setState(() {
_includeContactInfo = value ?? true;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
title: const Text('Informations d\'adhésion'),
subtitle: const Text('Date d\'adhésion, statut, numéro de membre'),
value: _includeAdhesionInfo,
onChanged: (value) {
setState(() {
_includeAdhesionInfo = value ?? true;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
title: const Text('Statistiques'),
subtitle: const Text('Données de cotisations et statistiques'),
value: _includeStatistics,
onChanged: (value) {
setState(() {
_includeStatistics = value ?? false;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
],
);
}
IconData _getFormatIcon(String format) {
switch (format) {
case 'excel':
return Icons.table_chart;
case 'csv':
return Icons.text_snippet;
case 'pdf':
return Icons.picture_as_pdf;
case 'json':
return Icons.code;
default:
return Icons.file_download;
}
}
String _getFormatLabel(String format) {
switch (format) {
case 'excel':
return 'Excel (.xlsx)';
case 'csv':
return 'CSV (.csv)';
case 'pdf':
return 'PDF (.pdf)';
case 'json':
return 'JSON (.json)';
default:
return format.toUpperCase();
}
}
void _performExport(List<MembreModel> membersToExport) {
// Filtrer les membres selon les options
List<MembreModel> filteredMembers = membersToExport;
if (!_includeInactiveMembers) {
filteredMembers = filteredMembers.where((m) => m.actif).toList();
}
// Créer les options d'export
final exportOptions = {
'format': _selectedFormat,
'includePersonalInfo': _includePersonalInfo,
'includeContactInfo': _includeContactInfo,
'includeAdhesionInfo': _includeAdhesionInfo,
'includeStatistics': _includeStatistics,
'includeInactiveMembers': _includeInactiveMembers,
};
// TODO: Implémenter l'export réel selon le format
_showExportResult(filteredMembers.length, _selectedFormat);
}
void _showExportResult(int count, String format) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Export $format de $count membres - À implémenter',
),
backgroundColor: AppTheme.infoColor,
action: SnackBarAction(
label: 'Voir',
onPressed: () {
// TODO: Ouvrir le fichier exporté
},
),
),
);
}
}

View File

@@ -0,0 +1,340 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Floating Action Button moderne avec animations et design professionnel
class ModernFloatingActionButton extends StatefulWidget {
const ModernFloatingActionButton({
super.key,
required this.onPressed,
required this.icon,
this.label,
this.backgroundColor,
this.foregroundColor,
this.heroTag,
this.tooltip,
this.mini = false,
this.extended = false,
});
final VoidCallback? onPressed;
final IconData icon;
final String? label;
final Color? backgroundColor;
final Color? foregroundColor;
final Object? heroTag;
final String? tooltip;
final bool mini;
final bool extended;
@override
State<ModernFloatingActionButton> createState() => _ModernFloatingActionButtonState();
}
class _ModernFloatingActionButtonState extends State<ModernFloatingActionButton>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _rotationAnimation;
bool _isPressed = false;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: DesignSystem.animationFast,
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
_rotationAnimation = Tween<double>(
begin: 0.0,
end: 0.1,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
setState(() => _isPressed = true);
_animationController.forward();
}
void _handleTapUp(TapUpDetails details) {
setState(() => _isPressed = false);
_animationController.reverse();
}
void _handleTapCancel() {
setState(() => _isPressed = false);
_animationController.reverse();
}
@override
Widget build(BuildContext context) {
if (widget.extended && widget.label != null) {
return _buildExtendedFAB();
}
return _buildRegularFAB();
}
Widget _buildRegularFAB() {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Transform.rotate(
angle: _rotationAnimation.value,
child: GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
onTap: widget.onPressed,
child: Container(
width: widget.mini ? 40 : 56,
height: widget.mini ? 40 : 56,
decoration: BoxDecoration(
gradient: DesignSystem.primaryGradient,
borderRadius: BorderRadius.circular(
widget.mini ? 20 : 28,
),
boxShadow: [
BoxShadow(
color: (widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
...DesignSystem.shadowCard,
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(
widget.mini ? 20 : 28,
),
onTap: widget.onPressed,
child: Center(
child: Icon(
widget.icon,
color: widget.foregroundColor ?? Colors.white,
size: widget.mini ? 20 : 24,
),
),
),
),
),
),
),
);
},
);
}
Widget _buildExtendedFAB() {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
onTap: widget.onPressed,
child: Container(
height: 48,
padding: EdgeInsets.symmetric(
horizontal: DesignSystem.spacingLg,
vertical: DesignSystem.spacingSm,
),
decoration: BoxDecoration(
gradient: DesignSystem.primaryGradient,
borderRadius: BorderRadius.circular(DesignSystem.radiusXl),
boxShadow: [
BoxShadow(
color: (widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
...DesignSystem.shadowCard,
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(DesignSystem.radiusXl),
onTap: widget.onPressed,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
widget.icon,
color: widget.foregroundColor ?? Colors.white,
size: 20,
),
SizedBox(width: DesignSystem.spacingSm),
Text(
widget.label!,
style: DesignSystem.labelLarge.copyWith(
color: widget.foregroundColor ?? Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
),
);
},
);
}
}
/// Widget de FAB avec menu contextuel
class ModernFABWithMenu extends StatefulWidget {
const ModernFABWithMenu({
super.key,
required this.mainAction,
required this.menuItems,
this.heroTag,
});
final ModernFABAction mainAction;
final List<ModernFABAction> menuItems;
final Object? heroTag;
@override
State<ModernFABWithMenu> createState() => _ModernFABWithMenuState();
}
class _ModernFABWithMenuState extends State<ModernFABWithMenu>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _rotationAnimation;
bool _isOpen = false;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: DesignSystem.animationMedium,
vsync: this,
);
_rotationAnimation = Tween<double>(
begin: 0.0,
end: 0.75,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _toggleMenu() {
setState(() {
_isOpen = !_isOpen;
if (_isOpen) {
_animationController.forward();
} else {
_animationController.reverse();
}
});
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.bottomRight,
children: [
// Menu items
...widget.menuItems.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
final offset = (index + 1) * 70.0 * _animationController.value;
return Transform.translate(
offset: Offset(0, -offset),
child: Opacity(
opacity: _animationController.value,
child: ModernFloatingActionButton(
onPressed: () {
_toggleMenu();
item.onPressed?.call();
},
icon: item.icon,
mini: true,
backgroundColor: item.backgroundColor,
foregroundColor: item.foregroundColor,
heroTag: '${widget.heroTag}_$index',
),
),
);
},
);
}).toList(),
// Main FAB
AnimatedBuilder(
animation: _rotationAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _rotationAnimation.value * 2 * 3.14159,
child: ModernFloatingActionButton(
onPressed: _toggleMenu,
icon: _isOpen ? Icons.close : widget.mainAction.icon,
backgroundColor: widget.mainAction.backgroundColor,
foregroundColor: widget.mainAction.foregroundColor,
heroTag: widget.heroTag,
),
);
},
),
],
);
}
}
/// Modèle pour une action de FAB
class ModernFABAction {
const ModernFABAction({
required this.icon,
this.onPressed,
this.backgroundColor,
this.foregroundColor,
this.label,
});
final IconData icon;
final VoidCallback? onPressed;
final Color? backgroundColor;
final Color? foregroundColor;
final String? label;
}

View File

@@ -0,0 +1,205 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// TabBar moderne avec animations et design professionnel
class ModernTabBar extends StatefulWidget implements PreferredSizeWidget {
const ModernTabBar({
super.key,
required this.controller,
required this.tabs,
this.onTap,
});
final TabController controller;
final List<ModernTab> tabs;
final ValueChanged<int>? onTap;
@override
State<ModernTabBar> createState() => _ModernTabBarState();
@override
Size get preferredSize => Size.fromHeight(DesignSystem.goldenWidth(60));
}
class _ModernTabBarState extends State<ModernTabBar>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: DesignSystem.animationFast,
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
widget.controller.addListener(_onTabChanged);
}
@override
void dispose() {
widget.controller.removeListener(_onTabChanged);
_animationController.dispose();
super.dispose();
}
void _onTabChanged() {
if (mounted) {
_animationController.forward().then((_) {
_animationController.reverse();
});
}
}
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.symmetric(
horizontal: DesignSystem.spacingLg,
vertical: DesignSystem.spacingSm,
),
decoration: BoxDecoration(
color: AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
boxShadow: DesignSystem.shadowCard,
border: Border.all(
color: AppTheme.borderColor.withOpacity(0.1),
width: 1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
child: TabBar(
controller: widget.controller,
onTap: widget.onTap,
indicator: BoxDecoration(
gradient: DesignSystem.primaryGradient,
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: EdgeInsets.all(DesignSystem.spacingXs),
labelColor: Colors.white,
unselectedLabelColor: AppTheme.textSecondary,
labelStyle: DesignSystem.labelLarge.copyWith(
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: DesignSystem.labelLarge.copyWith(
fontWeight: FontWeight.w500,
),
dividerColor: Colors.transparent,
tabs: widget.tabs.asMap().entries.map((entry) {
final index = entry.key;
final tab = entry.value;
final isSelected = widget.controller.index == index;
return AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: isSelected ? _scaleAnimation.value : 1.0,
child: _buildTab(tab, isSelected),
);
},
);
}).toList(),
),
),
);
}
Widget _buildTab(ModernTab tab, bool isSelected) {
return Container(
height: DesignSystem.goldenWidth(50),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: DesignSystem.animationFast,
child: Icon(
tab.icon,
size: isSelected ? 20 : 18,
color: isSelected ? Colors.white : AppTheme.textSecondary,
),
),
if (tab.label != null) ...[
SizedBox(width: DesignSystem.spacingXs),
AnimatedDefaultTextStyle(
duration: DesignSystem.animationFast,
style: (isSelected ? DesignSystem.labelLarge : DesignSystem.labelMedium).copyWith(
color: isSelected ? Colors.white : AppTheme.textSecondary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
child: Text(tab.label!),
),
],
if (tab.badge != null) ...[
SizedBox(width: DesignSystem.spacingXs),
_buildBadge(tab.badge!, isSelected),
],
],
),
);
}
Widget _buildBadge(String badge, bool isSelected) {
return AnimatedContainer(
duration: DesignSystem.animationFast,
padding: EdgeInsets.symmetric(
horizontal: DesignSystem.spacingXs,
vertical: 2,
),
decoration: BoxDecoration(
color: isSelected
? Colors.white.withOpacity(0.2)
: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: Text(
badge,
style: DesignSystem.labelSmall.copyWith(
color: isSelected ? Colors.white : AppTheme.primaryColor,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
);
}
}
/// Modèle pour un onglet moderne
class ModernTab {
const ModernTab({
required this.icon,
this.label,
this.badge,
});
final IconData icon;
final String? label;
final String? badge;
}
/// Extension pour créer facilement des onglets modernes
extension ModernTabExtension on Tab {
static ModernTab modern({
required IconData icon,
String? label,
String? badge,
}) {
return ModernTab(
icon: icon,
label: label,
badge: badge,
);
}
}

View File

@@ -0,0 +1,269 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Graphique en barres professionnel avec animations et interactions
class ProfessionalBarChart extends StatefulWidget {
const ProfessionalBarChart({
super.key,
required this.data,
required this.title,
this.subtitle,
this.showGrid = true,
this.showValues = true,
this.animationDuration = const Duration(milliseconds: 1500),
this.barColor,
this.gradientColors,
});
final List<BarDataPoint> data;
final String title;
final String? subtitle;
final bool showGrid;
final bool showValues;
final Duration animationDuration;
final Color? barColor;
final List<Color>? gradientColors;
@override
State<ProfessionalBarChart> createState() => _ProfessionalBarChartState();
}
class _ProfessionalBarChartState extends State<ProfessionalBarChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
int _touchedIndex = -1;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_animation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.spacingLg),
Expanded(
child: _buildChart(),
),
],
);
}
Widget _buildHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: DesignSystem.titleLarge.copyWith(
fontWeight: FontWeight.w700,
),
),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
Text(
widget.subtitle!,
style: DesignSystem.bodyMedium.copyWith(
color: AppTheme.textSecondary,
),
),
],
],
);
}
Widget _buildChart() {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: _getMaxY() * 1.2,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
tooltipBgColor: AppTheme.textPrimary.withOpacity(0.9),
tooltipRoundedRadius: DesignSystem.radiusSm,
tooltipPadding: EdgeInsets.all(DesignSystem.spacingSm),
getTooltipItem: (group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${widget.data[groupIndex].label}\n${rod.toY.toInt()}',
DesignSystem.labelMedium.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
);
},
),
touchCallback: (FlTouchEvent event, barTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions ||
barTouchResponse == null ||
barTouchResponse.spot == null) {
_touchedIndex = -1;
return;
}
_touchedIndex = barTouchResponse.spot!.touchedBarGroupIndex;
});
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: _buildBottomTitles,
reservedSize: 42,
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: _buildLeftTitles,
reservedSize: 40,
),
),
),
borderData: FlBorderData(show: false),
gridData: FlGridData(
show: widget.showGrid,
drawVerticalLine: false,
horizontalInterval: _getMaxY() / 5,
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppTheme.borderColor.withOpacity(0.3),
strokeWidth: 1,
);
},
),
barGroups: _buildBarGroups(),
),
);
},
);
}
List<BarChartGroupData> _buildBarGroups() {
return widget.data.asMap().entries.map((entry) {
final index = entry.key;
final data = entry.value;
final isTouched = index == _touchedIndex;
return BarChartGroupData(
x: index,
barRods: [
BarChartRodData(
toY: data.value * _animation.value,
color: _getBarColor(index, isTouched),
width: isTouched ? 24 : 20,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(DesignSystem.radiusXs),
topRight: Radius.circular(DesignSystem.radiusXs),
),
gradient: widget.gradientColors != null ? LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: widget.gradientColors!,
) : null,
),
],
showingTooltipIndicators: isTouched ? [0] : [],
);
}).toList();
}
Color _getBarColor(int index, bool isTouched) {
if (widget.barColor != null) {
return isTouched
? widget.barColor!
: widget.barColor!.withOpacity(0.8);
}
final colors = DesignSystem.chartColors;
final color = colors[index % colors.length];
return isTouched ? color : color.withOpacity(0.8);
}
Widget _buildBottomTitles(double value, TitleMeta meta) {
if (value.toInt() >= widget.data.length) return const SizedBox.shrink();
final data = widget.data[value.toInt()];
return SideTitleWidget(
axisSide: meta.axisSide,
child: Padding(
padding: EdgeInsets.only(top: DesignSystem.spacingXs),
child: Text(
data.label,
style: DesignSystem.labelSmall.copyWith(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
);
}
Widget _buildLeftTitles(double value, TitleMeta meta) {
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(
value.toInt().toString(),
style: DesignSystem.labelSmall.copyWith(
color: AppTheme.textSecondary,
),
),
);
}
double _getMaxY() {
if (widget.data.isEmpty) return 10;
return widget.data.map((e) => e.value).reduce((a, b) => a > b ? a : b);
}
}
/// Modèle de données pour le graphique en barres
class BarDataPoint {
const BarDataPoint({
required this.label,
required this.value,
this.color,
});
final String label;
final double value;
final Color? color;
}

View File

@@ -0,0 +1,282 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Graphique linéaire professionnel avec animations et interactions
class ProfessionalLineChart extends StatefulWidget {
const ProfessionalLineChart({
super.key,
required this.data,
required this.title,
this.subtitle,
this.showGrid = true,
this.showDots = true,
this.showArea = false,
this.animationDuration = const Duration(milliseconds: 1500),
this.lineColor,
this.gradientColors,
});
final List<LineDataPoint> data;
final String title;
final String? subtitle;
final bool showGrid;
final bool showDots;
final bool showArea;
final Duration animationDuration;
final Color? lineColor;
final List<Color>? gradientColors;
@override
State<ProfessionalLineChart> createState() => _ProfessionalLineChartState();
}
class _ProfessionalLineChartState extends State<ProfessionalLineChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
List<int> _showingTooltipOnSpots = [];
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_animation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.spacingLg),
Expanded(
child: _buildChart(),
),
],
);
}
Widget _buildHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: DesignSystem.titleLarge.copyWith(
fontWeight: FontWeight.w700,
),
),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
Text(
widget.subtitle!,
style: DesignSystem.bodyMedium.copyWith(
color: AppTheme.textSecondary,
),
),
],
],
);
}
Widget _buildChart() {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return LineChart(
LineChartData(
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
tooltipBgColor: AppTheme.textPrimary.withOpacity(0.9),
tooltipRoundedRadius: DesignSystem.radiusSm,
tooltipPadding: EdgeInsets.all(DesignSystem.spacingSm),
getTooltipItems: (List<LineBarSpot> touchedBarSpots) {
return touchedBarSpots.map((barSpot) {
final data = widget.data[barSpot.x.toInt()];
return LineTooltipItem(
'${data.label}\n${barSpot.y.toInt()}',
DesignSystem.labelMedium.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
);
}).toList();
},
),
handleBuiltInTouches: true,
getTouchedSpotIndicator: (LineChartBarData barData, List<int> spotIndexes) {
return spotIndexes.map((index) {
return TouchedSpotIndicatorData(
FlLine(
color: widget.lineColor ?? AppTheme.primaryColor,
strokeWidth: 2,
dashArray: [3, 3],
),
FlDotData(
getDotPainter: (spot, percent, barData, index) =>
FlDotCirclePainter(
radius: 6,
color: widget.lineColor ?? AppTheme.primaryColor,
strokeWidth: 2,
strokeColor: Colors.white,
),
),
);
}).toList();
},
),
gridData: FlGridData(
show: widget.showGrid,
drawVerticalLine: false,
horizontalInterval: _getMaxY() / 5,
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppTheme.borderColor.withOpacity(0.3),
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: _buildBottomTitles,
reservedSize: 42,
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: _buildLeftTitles,
reservedSize: 40,
),
),
),
borderData: FlBorderData(show: false),
minX: 0,
maxX: widget.data.length.toDouble() - 1,
minY: 0,
maxY: _getMaxY() * 1.2,
lineBarsData: [
_buildLineBarData(),
],
),
);
},
);
}
LineChartBarData _buildLineBarData() {
final spots = widget.data.asMap().entries.map((entry) {
final index = entry.key;
final data = entry.value;
return FlSpot(index.toDouble(), data.value * _animation.value);
}).toList();
return LineChartBarData(
spots: spots,
isCurved: true,
curveSmoothness: 0.3,
color: widget.lineColor ?? AppTheme.primaryColor,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: widget.showDots,
getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter(
radius: 4,
color: widget.lineColor ?? AppTheme.primaryColor,
strokeWidth: 2,
strokeColor: Colors.white,
),
),
belowBarData: widget.showArea ? BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: widget.gradientColors ?? [
(widget.lineColor ?? AppTheme.primaryColor).withOpacity(0.3),
(widget.lineColor ?? AppTheme.primaryColor).withOpacity(0.05),
],
),
) : BarAreaData(show: false),
);
}
Widget _buildBottomTitles(double value, TitleMeta meta) {
if (value.toInt() >= widget.data.length) return const SizedBox.shrink();
final data = widget.data[value.toInt()];
return SideTitleWidget(
axisSide: meta.axisSide,
child: Padding(
padding: EdgeInsets.only(top: DesignSystem.spacingXs),
child: Text(
data.label,
style: DesignSystem.labelSmall.copyWith(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
);
}
Widget _buildLeftTitles(double value, TitleMeta meta) {
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(
value.toInt().toString(),
style: DesignSystem.labelSmall.copyWith(
color: AppTheme.textSecondary,
),
),
);
}
double _getMaxY() {
if (widget.data.isEmpty) return 10;
return widget.data.map((e) => e.value).reduce((a, b) => a > b ? a : b);
}
}
/// Modèle de données pour le graphique linéaire
class LineDataPoint {
const LineDataPoint({
required this.label,
required this.value,
});
final String label;
final double value;
}

View File

@@ -0,0 +1,307 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Graphique en secteurs professionnel avec animations et légendes
class ProfessionalPieChart extends StatefulWidget {
const ProfessionalPieChart({
super.key,
required this.data,
required this.title,
this.subtitle,
this.centerText,
this.showLegend = true,
this.showPercentages = true,
this.animationDuration = const Duration(milliseconds: 1500),
});
final List<ChartDataPoint> data;
final String title;
final String? subtitle;
final String? centerText;
final bool showLegend;
final bool showPercentages;
final Duration animationDuration;
@override
State<ProfessionalPieChart> createState() => _ProfessionalPieChartState();
}
class _ProfessionalPieChartState extends State<ProfessionalPieChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
int _touchedIndex = -1;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_animation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.spacingLg),
Expanded(
child: Row(
children: [
Expanded(
flex: 3,
child: _buildChart(),
),
if (widget.showLegend) ...[
SizedBox(width: DesignSystem.spacingLg),
Expanded(
flex: 2,
child: _buildLegend(),
),
],
],
),
),
],
);
}
Widget _buildHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: DesignSystem.titleLarge.copyWith(
fontWeight: FontWeight.w700,
),
),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
Text(
widget.subtitle!,
style: DesignSystem.bodyMedium.copyWith(
color: AppTheme.textSecondary,
),
),
],
],
);
}
Widget _buildChart() {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
height: 140, // Hauteur encore plus réduite
padding: const EdgeInsets.all(4), // Padding minimal pour contenir le graphique
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
touchCallback: (FlTouchEvent event, pieTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions ||
pieTouchResponse == null ||
pieTouchResponse.touchedSection == null) {
_touchedIndex = -1;
return;
}
_touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex;
});
},
),
borderData: FlBorderData(show: false),
sectionsSpace: 1, // Espace réduit entre sections
centerSpaceRadius: widget.centerText != null ? 45 : 30, // Rayon central réduit
sections: _buildSections(),
),
),
);
},
);
}
List<PieChartSectionData> _buildSections() {
final total = widget.data.fold<double>(0, (sum, item) => sum + item.value);
return widget.data.asMap().entries.map((entry) {
final index = entry.key;
final data = entry.value;
final isTouched = index == _touchedIndex;
final percentage = (data.value / total * 100);
return PieChartSectionData(
color: data.color,
value: data.value * _animation.value,
title: widget.showPercentages ? '${percentage.toStringAsFixed(1)}%' : '',
radius: isTouched ? 70 : 60,
titleStyle: DesignSystem.labelMedium.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
shadows: [
Shadow(
color: Colors.black.withOpacity(0.3),
offset: const Offset(1, 1),
blurRadius: 2,
),
],
),
titlePositionPercentageOffset: 0.6,
badgeWidget: isTouched ? _buildBadge(data) : null,
badgePositionPercentageOffset: 1.3,
);
}).toList();
}
Widget _buildBadge(ChartDataPoint data) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: DesignSystem.spacingSm,
vertical: DesignSystem.spacingXs,
),
decoration: BoxDecoration(
color: data.color,
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
boxShadow: DesignSystem.shadowCard,
),
child: Text(
data.value.toInt().toString(),
style: DesignSystem.labelMedium.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildLegend() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.centerText != null) ...[
_buildCenterInfo(),
SizedBox(height: DesignSystem.spacingLg),
],
...widget.data.asMap().entries.map((entry) {
final index = entry.key;
final data = entry.value;
final isSelected = index == _touchedIndex;
return AnimatedContainer(
duration: DesignSystem.animationFast,
margin: EdgeInsets.only(bottom: DesignSystem.spacingSm),
padding: EdgeInsets.all(DesignSystem.spacingSm),
decoration: BoxDecoration(
color: isSelected ? data.color.withOpacity(0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
border: isSelected ? Border.all(
color: data.color.withOpacity(0.3),
width: 1,
) : null,
),
child: Row(
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: data.color,
borderRadius: BorderRadius.circular(DesignSystem.radiusXs),
),
),
SizedBox(width: DesignSystem.spacingSm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.label,
style: DesignSystem.labelLarge.copyWith(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
),
Text(
data.value.toInt().toString(),
style: DesignSystem.labelMedium.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
);
}).toList(),
],
);
}
Widget _buildCenterInfo() {
return Container(
padding: EdgeInsets.all(DesignSystem.spacingMd),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
border: Border.all(
color: AppTheme.primaryColor.withOpacity(0.2),
width: 1,
),
),
child: Column(
children: [
Text(
'Total',
style: DesignSystem.labelMedium.copyWith(
color: AppTheme.textSecondary,
),
),
SizedBox(height: DesignSystem.spacingXs),
Text(
widget.centerText!,
style: DesignSystem.headlineMedium.copyWith(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
/// Modèle de données pour le graphique en secteurs
class ChartDataPoint {
const ChartDataPoint({
required this.label,
required this.value,
required this.color,
});
final String label;
final double value;
final Color color;
}

View File

@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Grille de statistiques compacte pour mobile
class StatsGridCard extends StatefulWidget {
const StatsGridCard({
super.key,
required this.stats,
this.crossAxisCount = 2,
this.childAspectRatio = 1.2,
});
final Map<String, dynamic> stats;
final int crossAxisCount;
final double childAspectRatio;
@override
State<StatsGridCard> createState() => _StatsGridCardState();
}
class _StatsGridCardState extends State<StatsGridCard>
with TickerProviderStateMixin {
late List<AnimationController> _animationControllers;
late List<Animation<double>> _scaleAnimations;
late List<Animation<Offset>> _slideAnimations;
@override
void initState() {
super.initState();
_initializeAnimations();
}
void _initializeAnimations() {
const itemCount = 4; // Nombre de statistiques
_animationControllers = List.generate(
itemCount,
(index) => AnimationController(
duration: Duration(
milliseconds: DesignSystem.animationMedium.inMilliseconds + (index * 100),
),
vsync: this,
),
);
_scaleAnimations = _animationControllers.map((controller) {
return Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: controller,
curve: DesignSystem.animationCurveEnter,
));
}).toList();
_slideAnimations = _animationControllers.map((controller) {
return Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller,
curve: DesignSystem.animationCurveEnter,
));
}).toList();
// Démarrer les animations en cascade
for (int i = 0; i < _animationControllers.length; i++) {
Future.delayed(Duration(milliseconds: i * 100), () {
if (mounted) {
_animationControllers[i].forward();
}
});
}
}
@override
void dispose() {
for (final controller in _animationControllers) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final statsItems = [
_StatItem(
title: 'Total Membres',
value: widget.stats['totalMembres'].toString(),
icon: Icons.people,
color: AppTheme.primaryColor,
trend: '+${widget.stats['nouveauxCeMois']}',
trendPositive: true,
),
_StatItem(
title: 'Membres Actifs',
value: widget.stats['membresActifs'].toString(),
icon: Icons.person,
color: AppTheme.successColor,
trend: '${widget.stats['tauxActivite']}%',
trendPositive: widget.stats['tauxActivite'] >= 70,
),
_StatItem(
title: 'Nouveaux ce mois',
value: widget.stats['nouveauxCeMois'].toString(),
icon: Icons.person_add,
color: AppTheme.infoColor,
trend: 'Ce mois',
trendPositive: widget.stats['nouveauxCeMois'] > 0,
),
_StatItem(
title: 'Taux d\'activité',
value: '${widget.stats['tauxActivite']}%',
icon: Icons.trending_up,
color: AppTheme.warningColor,
trend: widget.stats['tauxActivite'] >= 70 ? 'Excellent' : 'Moyen',
trendPositive: widget.stats['tauxActivite'] >= 70,
),
];
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: widget.crossAxisCount,
childAspectRatio: widget.childAspectRatio,
crossAxisSpacing: DesignSystem.spacingMd,
mainAxisSpacing: DesignSystem.spacingMd,
),
itemCount: statsItems.length,
itemBuilder: (context, index) {
return AnimatedBuilder(
animation: _animationControllers[index],
builder: (context, child) {
return SlideTransition(
position: _slideAnimations[index],
child: ScaleTransition(
scale: _scaleAnimations[index],
child: _buildStatCard(statsItems[index]),
),
);
},
);
},
);
}
Widget _buildStatCard(_StatItem item) {
return Container(
padding: EdgeInsets.all(DesignSystem.spacingMd),
decoration: BoxDecoration(
color: AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
boxShadow: DesignSystem.shadowCard,
border: Border.all(
color: item.color.withOpacity(0.1),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: EdgeInsets.all(DesignSystem.spacingSm),
decoration: BoxDecoration(
color: item.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
child: Icon(
item.icon,
color: item.color,
size: 20,
),
),
Container(
padding: EdgeInsets.symmetric(
horizontal: DesignSystem.spacingXs,
vertical: 2,
),
decoration: BoxDecoration(
color: item.trendPositive
? AppTheme.successColor.withOpacity(0.1)
: AppTheme.errorColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: Text(
item.trend,
style: DesignSystem.labelSmall.copyWith(
color: item.trendPositive
? AppTheme.successColor
: AppTheme.errorColor,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
),
],
),
SizedBox(height: DesignSystem.spacingSm),
Text(
item.value,
style: DesignSystem.headlineMedium.copyWith(
fontWeight: FontWeight.w800,
color: AppTheme.textPrimary,
),
),
SizedBox(height: DesignSystem.spacingXs),
Text(
item.title,
style: DesignSystem.labelMedium.copyWith(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
/// Modèle pour un élément de statistique
class _StatItem {
const _StatItem({
required this.title,
required this.value,
required this.icon,
required this.color,
required this.trend,
required this.trendPositive,
});
final String title;
final String value;
final IconData icon;
final Color color;
final String trend;
final bool trendPositive;
}

View File

@@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Card de vue d'ensemble des statistiques avec design professionnel
class StatsOverviewCard extends StatefulWidget {
const StatsOverviewCard({
super.key,
required this.stats,
this.onTap,
});
final Map<String, dynamic> stats;
final VoidCallback? onTap;
@override
State<StatsOverviewCard> createState() => _StatsOverviewCardState();
}
class _StatsOverviewCardState extends State<StatsOverviewCard>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: DesignSystem.animationMedium,
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurveEnter,
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: _buildCard(),
),
);
},
);
}
Widget _buildCard() {
return Container(
padding: EdgeInsets.all(DesignSystem.spacingLg),
decoration: BoxDecoration(
gradient: DesignSystem.primaryGradient,
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
boxShadow: DesignSystem.shadowCard,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.spacingLg),
_buildMainStats(),
SizedBox(height: DesignSystem.spacingLg),
_buildSecondaryStats(),
SizedBox(height: DesignSystem.spacingMd),
_buildProgressIndicator(),
],
),
);
}
Widget _buildHeader() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Vue d\'ensemble',
style: DesignSystem.titleLarge.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: DesignSystem.spacingXs),
Text(
'Statistiques générales',
style: DesignSystem.bodyMedium.copyWith(
color: Colors.white.withOpacity(0.9),
),
),
],
),
Container(
padding: EdgeInsets.all(DesignSystem.spacingSm),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
child: const Icon(
Icons.analytics,
color: Colors.white,
size: 24,
),
),
],
);
}
Widget _buildMainStats() {
return Row(
children: [
Expanded(
child: _buildStatItem(
'Total Membres',
widget.stats['totalMembres'].toString(),
Icons.people,
Colors.white,
),
),
SizedBox(width: DesignSystem.spacingLg),
Expanded(
child: _buildStatItem(
'Membres Actifs',
widget.stats['membresActifs'].toString(),
Icons.person,
Colors.white,
),
),
],
);
}
Widget _buildSecondaryStats() {
return Row(
children: [
Expanded(
child: _buildStatItem(
'Nouveaux ce mois',
widget.stats['nouveauxCeMois'].toString(),
Icons.person_add,
Colors.white.withOpacity(0.9),
isSecondary: true,
),
),
SizedBox(width: DesignSystem.spacingLg),
Expanded(
child: _buildStatItem(
'Taux d\'activité',
'${widget.stats['tauxActivite']}%',
Icons.trending_up,
Colors.white.withOpacity(0.9),
isSecondary: true,
),
),
],
);
}
Widget _buildStatItem(
String label,
String value,
IconData icon,
Color color, {
bool isSecondary = false,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
color: color,
size: isSecondary ? 16 : 20,
),
SizedBox(width: DesignSystem.spacingXs),
Text(
label,
style: (isSecondary ? DesignSystem.labelMedium : DesignSystem.labelLarge).copyWith(
color: color,
fontWeight: FontWeight.w500,
),
),
],
),
SizedBox(height: DesignSystem.spacingXs),
Text(
value,
style: (isSecondary ? DesignSystem.headlineMedium : DesignSystem.displayMedium).copyWith(
color: color,
fontWeight: FontWeight.w800,
fontSize: isSecondary ? 20 : 32,
),
),
],
);
}
Widget _buildProgressIndicator() {
final tauxActivite = widget.stats['tauxActivite'] as int;
final progress = tauxActivite / 100.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Engagement communautaire',
style: DesignSystem.labelMedium.copyWith(
color: Colors.white.withOpacity(0.9),
fontWeight: FontWeight.w500,
),
),
Text(
'$tauxActivite%',
style: DesignSystem.labelMedium.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
SizedBox(height: DesignSystem.spacingXs),
Container(
height: 6,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(DesignSystem.radiusXs),
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: progress,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(DesignSystem.radiusXs),
boxShadow: [
BoxShadow(
color: Colors.white.withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
),
),
),
],
);
}
}