Version propre - Dashboard enhanced
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' : ''}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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é
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user