/// Widget de carte de statistique individuelle - Version Améliorée /// Affiche une métrique sophistiquée avec animations, tendances et comparaisons library dashboard_stats_card; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../../../core/design_system/tokens/color_tokens.dart'; import '../../../../core/design_system/tokens/spacing_tokens.dart'; import '../../../../core/design_system/tokens/typography_tokens.dart'; /// Types de statistiques disponibles enum StatType { count, percentage, currency, duration, rate, score, custom, } /// Styles de cartes de statistiques enum StatCardStyle { standard, minimal, elevated, outlined, gradient, compact, detailed, } /// Tailles de cartes de statistiques enum StatCardSize { small, medium, large, extraLarge, } /// Tendances des statistiques enum StatTrend { up, down, stable, unknown, } /// Modèle de données avancé pour une statistique class DashboardStat { /// Icône représentative de la statistique final IconData icon; /// Valeur numérique à afficher final String value; /// Titre descriptif de la statistique final String title; /// Sous-titre ou description final String? subtitle; /// Couleur thématique de la carte final Color color; /// Type de statistique final StatType type; /// Style de la carte final StatCardStyle style; /// Taille de la carte final StatCardSize size; /// Callback optionnel lors du tap sur la carte final VoidCallback? onTap; /// Callback optionnel lors du long press final VoidCallback? onLongPress; /// Valeur précédente pour comparaison final String? previousValue; /// Pourcentage de changement final double? changePercentage; /// Tendance de la statistique final StatTrend trend; /// Période de comparaison final String? period; /// Icône de tendance personnalisée final IconData? trendIcon; /// Gradient personnalisé final Gradient? gradient; /// Badge à afficher final String? badge; /// Couleur du badge final Color? badgeColor; /// Graphique miniature (sparkline) final List? sparklineData; /// Animation activée final bool animated; /// Feedback haptique activé final bool hapticFeedback; /// Formatage personnalisé de la valeur final String Function(String)? valueFormatter; /// Constructeur du modèle de statistique amélioré const DashboardStat({ required this.icon, required this.value, required this.title, this.subtitle, required this.color, this.type = StatType.count, this.style = StatCardStyle.standard, this.size = StatCardSize.medium, this.onTap, this.onLongPress, this.previousValue, this.changePercentage, this.trend = StatTrend.unknown, this.period, this.trendIcon, this.gradient, this.badge, this.badgeColor, this.sparklineData, this.animated = true, this.hapticFeedback = true, this.valueFormatter, }); /// Constructeur pour statistique de comptage const DashboardStat.count({ required this.icon, required this.value, required this.title, this.subtitle, required this.color, this.onTap, this.onLongPress, this.previousValue, this.changePercentage, this.trend = StatTrend.unknown, this.period, this.badge, this.size = StatCardSize.medium, this.animated = true, this.hapticFeedback = true, }) : type = StatType.count, style = StatCardStyle.standard, trendIcon = null, gradient = null, badgeColor = null, sparklineData = null, valueFormatter = null; /// Constructeur pour pourcentage const DashboardStat.percentage({ required this.icon, required this.value, required this.title, this.subtitle, required this.color, this.onTap, this.onLongPress, this.changePercentage, this.trend = StatTrend.unknown, this.period, this.size = StatCardSize.medium, this.animated = true, this.hapticFeedback = true, }) : type = StatType.percentage, style = StatCardStyle.elevated, previousValue = null, trendIcon = null, gradient = null, badge = null, badgeColor = null, sparklineData = null, valueFormatter = null; /// Constructeur pour devise const DashboardStat.currency({ required this.icon, required this.value, required this.title, this.subtitle, required this.color, this.onTap, this.onLongPress, this.previousValue, this.changePercentage, this.trend = StatTrend.unknown, this.period, this.sparklineData, this.size = StatCardSize.medium, this.animated = true, this.hapticFeedback = true, }) : type = StatType.currency, style = StatCardStyle.detailed, trendIcon = null, gradient = null, badge = null, badgeColor = null, valueFormatter = null; /// Constructeur avec gradient const DashboardStat.gradient({ required this.icon, required this.value, required this.title, this.subtitle, required this.gradient, this.onTap, this.onLongPress, this.changePercentage, this.trend = StatTrend.unknown, this.period, this.size = StatCardSize.medium, this.animated = true, this.hapticFeedback = true, }) : type = StatType.custom, style = StatCardStyle.gradient, color = ColorTokens.primary, previousValue = null, trendIcon = null, badge = null, badgeColor = null, sparklineData = null, valueFormatter = null; } /// Widget de carte de statistique amélioré /// /// Affiche une métrique sophistiquée avec : /// - Icône colorée thématique avec animations /// - Valeur numérique formatée et mise en évidence /// - Titre et sous-titre descriptifs /// - Indicateurs de tendance et comparaisons /// - Graphiques miniatures (sparklines) /// - Badges et notifications /// - Styles multiples (standard, gradient, minimal) /// - Design Material 3 avec élévation adaptative /// - Support du tap et long press avec feedback haptique class DashboardStatsCard extends StatefulWidget { /// Données de la statistique à afficher final DashboardStat stat; /// Constructeur de la carte de statistique améliorée const DashboardStatsCard({ super.key, required this.stat, }); @override State createState() => _DashboardStatsCardState(); } class _DashboardStatsCardState extends State with SingleTickerProviderStateMixin { late AnimationController _animationController; late Animation _scaleAnimation; late Animation _fadeAnimation; late Animation _slideAnimation; @override void initState() { super.initState(); _setupAnimations(); } @override void dispose() { _animationController.dispose(); super.dispose(); } /// Configure les animations void _setupAnimations() { _animationController = AnimationController( duration: const Duration(milliseconds: 800), vsync: this, ); _scaleAnimation = Tween( begin: 0.8, end: 1.0, ).animate(CurvedAnimation( parent: _animationController, curve: Curves.elasticOut, )); _fadeAnimation = Tween( begin: 0.0, end: 1.0, ).animate(CurvedAnimation( parent: _animationController, curve: const Interval(0.0, 0.6, curve: Curves.easeOut), )); _slideAnimation = Tween( begin: 30.0, end: 0.0, ).animate(CurvedAnimation( parent: _animationController, curve: const Interval(0.2, 0.8, curve: Curves.easeOutCubic), )); if (widget.stat.animated) { _animationController.forward(); } else { _animationController.value = 1.0; } } /// Obtient les dimensions selon la taille EdgeInsets _getPadding() { switch (widget.stat.size) { case StatCardSize.small: return const EdgeInsets.all(SpacingTokens.sm); case StatCardSize.medium: return const EdgeInsets.all(SpacingTokens.md); case StatCardSize.large: return const EdgeInsets.all(SpacingTokens.lg); case StatCardSize.extraLarge: return const EdgeInsets.all(SpacingTokens.xl); } } /// Obtient la taille de l'icône selon la taille de la carte double _getIconSize() { switch (widget.stat.size) { case StatCardSize.small: return 20.0; case StatCardSize.medium: return 28.0; case StatCardSize.large: return 36.0; case StatCardSize.extraLarge: return 44.0; } } /// Obtient le style de texte pour la valeur TextStyle _getValueStyle() { final baseStyle = widget.stat.size == StatCardSize.small ? TypographyTokens.headlineSmall : widget.stat.size == StatCardSize.medium ? TypographyTokens.headlineMedium : widget.stat.size == StatCardSize.large ? TypographyTokens.headlineLarge : TypographyTokens.displaySmall; return baseStyle.copyWith( fontWeight: FontWeight.w700, color: _getTextColor(), ); } /// Obtient le style de texte pour le titre TextStyle _getTitleStyle() { final baseStyle = widget.stat.size == StatCardSize.small ? TypographyTokens.bodySmall : widget.stat.size == StatCardSize.medium ? TypographyTokens.bodyMedium : TypographyTokens.bodyLarge; return baseStyle.copyWith( color: _getSecondaryTextColor(), fontWeight: FontWeight.w500, ); } /// Obtient la couleur du texte selon le style Color _getTextColor() { switch (widget.stat.style) { case StatCardStyle.gradient: return Colors.white; case StatCardStyle.standard: case StatCardStyle.minimal: case StatCardStyle.elevated: case StatCardStyle.outlined: case StatCardStyle.compact: case StatCardStyle.detailed: return widget.stat.color; } } /// Obtient la couleur du texte secondaire Color _getSecondaryTextColor() { switch (widget.stat.style) { case StatCardStyle.gradient: return Colors.white.withOpacity(0.9); case StatCardStyle.standard: case StatCardStyle.minimal: case StatCardStyle.elevated: case StatCardStyle.outlined: case StatCardStyle.compact: case StatCardStyle.detailed: return ColorTokens.onSurfaceVariant; } } /// Gère le tap avec feedback haptique void _handleTap() { if (widget.stat.hapticFeedback) { HapticFeedback.lightImpact(); } widget.stat.onTap?.call(); } /// Gère le long press void _handleLongPress() { if (widget.stat.hapticFeedback) { HapticFeedback.mediumImpact(); } widget.stat.onLongPress?.call(); } @override Widget build(BuildContext context) { if (!widget.stat.animated) { return _buildCard(); } return AnimatedBuilder( animation: _animationController, builder: (context, child) { return Transform.scale( scale: _scaleAnimation.value, child: Transform.translate( offset: Offset(0, _slideAnimation.value), child: Opacity( opacity: _fadeAnimation.value, child: child, ), ), ); }, child: _buildCard(), ); } /// Construit la carte selon le style défini Widget _buildCard() { switch (widget.stat.style) { case StatCardStyle.standard: return _buildStandardCard(); case StatCardStyle.minimal: return _buildMinimalCard(); case StatCardStyle.elevated: return _buildElevatedCard(); case StatCardStyle.outlined: return _buildOutlinedCard(); case StatCardStyle.gradient: return _buildGradientCard(); case StatCardStyle.compact: return _buildCompactCard(); case StatCardStyle.detailed: return _buildDetailedCard(); } } /// Construit une carte standard Widget _buildStandardCard() { return Card( elevation: 1, child: InkWell( onTap: _handleTap, onLongPress: _handleLongPress, borderRadius: BorderRadius.circular(12), child: Padding( padding: _getPadding(), child: _buildCardContent(), ), ), ); } /// Construit une carte minimale Widget _buildMinimalCard() { return InkWell( onTap: _handleTap, onLongPress: _handleLongPress, borderRadius: BorderRadius.circular(12), child: Container( padding: _getPadding(), decoration: BoxDecoration( color: widget.stat.color.withOpacity(0.05), borderRadius: BorderRadius.circular(12), border: Border.all( color: widget.stat.color.withOpacity(0.2), width: 1, ), ), child: _buildCardContent(), ), ); } /// Construit une carte élevée Widget _buildElevatedCard() { return Card( elevation: 4, shadowColor: widget.stat.color.withOpacity(0.3), child: InkWell( onTap: _handleTap, onLongPress: _handleLongPress, borderRadius: BorderRadius.circular(12), child: Padding( padding: _getPadding(), child: _buildCardContent(), ), ), ); } /// Construit une carte avec contour Widget _buildOutlinedCard() { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all( color: widget.stat.color, width: 2, ), ), child: InkWell( onTap: _handleTap, onLongPress: _handleLongPress, borderRadius: BorderRadius.circular(12), child: Padding( padding: _getPadding(), child: _buildCardContent(), ), ), ); } /// Construit une carte avec gradient Widget _buildGradientCard() { return Container( decoration: BoxDecoration( gradient: widget.stat.gradient ?? LinearGradient( colors: [widget.stat.color, widget.stat.color.withOpacity(0.8)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: widget.stat.color.withOpacity(0.3), blurRadius: 8, offset: const Offset(0, 4), ), ], ), child: Material( color: Colors.transparent, child: InkWell( onTap: _handleTap, onLongPress: _handleLongPress, borderRadius: BorderRadius.circular(12), child: Padding( padding: _getPadding(), child: _buildCardContent(), ), ), ), ); } /// Construit une carte compacte Widget _buildCompactCard() { return Card( elevation: 1, child: InkWell( onTap: _handleTap, onLongPress: _handleLongPress, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(SpacingTokens.sm), child: Row( children: [ Icon( widget.stat.icon, size: 24, color: widget.stat.color, ), const SizedBox(width: SpacingTokens.sm), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( widget.stat.value, style: TypographyTokens.headlineSmall.copyWith( fontWeight: FontWeight.w700, color: widget.stat.color, ), ), Text( widget.stat.title, style: TypographyTokens.bodySmall.copyWith( color: ColorTokens.onSurfaceVariant, ), ), ], ), ), if (widget.stat.trend != StatTrend.unknown) _buildTrendIndicator(), ], ), ), ), ); } /// Construit une carte détaillée Widget _buildDetailedCard() { return Card( elevation: 2, child: InkWell( onTap: _handleTap, onLongPress: _handleLongPress, borderRadius: BorderRadius.circular(12), child: Padding( padding: _getPadding(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Icon( widget.stat.icon, size: _getIconSize(), color: widget.stat.color, ), if (widget.stat.badge != null) _buildBadge(), ], ), const SizedBox(height: SpacingTokens.md), Text( _formatValue(widget.stat.value), style: _getValueStyle(), ), const SizedBox(height: SpacingTokens.xs), Text( widget.stat.title, style: _getTitleStyle(), ), if (widget.stat.subtitle != null) ...[ const SizedBox(height: 2), Text( widget.stat.subtitle!, style: TypographyTokens.bodySmall.copyWith( color: _getSecondaryTextColor().withOpacity(0.7), ), ), ], if (widget.stat.changePercentage != null) ...[ const SizedBox(height: SpacingTokens.sm), _buildChangeIndicator(), ], if (widget.stat.sparklineData != null) ...[ const SizedBox(height: SpacingTokens.sm), _buildSparkline(), ], ], ), ), ), ); } /// Construit le contenu standard de la carte Widget _buildCardContent() { return Stack( clipBehavior: Clip.none, children: [ Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( widget.stat.icon, size: _getIconSize(), color: _getTextColor(), ), const SizedBox(height: SpacingTokens.sm), Text( _formatValue(widget.stat.value), style: _getValueStyle(), textAlign: TextAlign.center, ), const SizedBox(height: SpacingTokens.xs), Text( widget.stat.title, style: _getTitleStyle(), textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, ), if (widget.stat.subtitle != null) ...[ const SizedBox(height: 2), Text( widget.stat.subtitle!, style: TypographyTokens.bodySmall.copyWith( color: _getSecondaryTextColor().withOpacity(0.7), ), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], if (widget.stat.changePercentage != null) ...[ const SizedBox(height: SpacingTokens.xs), _buildChangeIndicator(), ], ], ), // Badge en haut à droite if (widget.stat.badge != null) Positioned( top: -8, right: -8, child: _buildBadge(), ), ], ); } /// Formate la valeur selon le type String _formatValue(String value) { if (widget.stat.valueFormatter != null) { return widget.stat.valueFormatter!(value); } switch (widget.stat.type) { case StatType.percentage: return '$value%'; case StatType.currency: return '€$value'; case StatType.duration: return '${value}h'; case StatType.rate: return '$value/min'; case StatType.count: case StatType.score: case StatType.custom: return value; } } /// Construit l'indicateur de changement Widget _buildChangeIndicator() { if (widget.stat.changePercentage == null) { return const SizedBox.shrink(); } final isPositive = widget.stat.changePercentage! > 0; final color = isPositive ? ColorTokens.success : ColorTokens.error; final icon = isPositive ? Icons.trending_up : Icons.trending_down; return Row( mainAxisSize: MainAxisSize.min, children: [ Icon( widget.stat.trendIcon ?? icon, size: 14, color: color, ), const SizedBox(width: 4), Text( '${isPositive ? '+' : ''}${widget.stat.changePercentage!.toStringAsFixed(1)}%', style: TypographyTokens.bodySmall.copyWith( color: color, fontWeight: FontWeight.w600, ), ), if (widget.stat.period != null) ...[ const SizedBox(width: 4), Text( widget.stat.period!, style: TypographyTokens.bodySmall.copyWith( color: _getSecondaryTextColor().withOpacity(0.6), ), ), ], ], ); } /// Construit l'indicateur de tendance Widget _buildTrendIndicator() { IconData icon; Color color; switch (widget.stat.trend) { case StatTrend.up: icon = Icons.trending_up; color = ColorTokens.success; break; case StatTrend.down: icon = Icons.trending_down; color = ColorTokens.error; break; case StatTrend.stable: icon = Icons.trending_flat; color = ColorTokens.warning; break; case StatTrend.unknown: return const SizedBox.shrink(); } return Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(4), ), child: Icon( widget.stat.trendIcon ?? icon, size: 16, color: color, ), ); } /// Construit le badge Widget _buildBadge() { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: widget.stat.badgeColor ?? ColorTokens.error, borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.2), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: Text( widget.stat.badge!, style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600, ), ), ); } /// Construit un graphique miniature (sparkline) Widget _buildSparkline() { if (widget.stat.sparklineData == null || widget.stat.sparklineData!.isEmpty) { return const SizedBox.shrink(); } return SizedBox( height: 40, child: CustomPaint( painter: SparklinePainter( data: widget.stat.sparklineData!, color: widget.stat.color, ), ), ); } } /// Painter pour dessiner un graphique miniature class SparklinePainter extends CustomPainter { final List data; final Color color; SparklinePainter({ required this.data, required this.color, }); @override void paint(Canvas canvas, Size size) { if (data.length < 2) return; final paint = Paint() ..color = color ..strokeWidth = 2 ..style = PaintingStyle.stroke; final path = Path(); final maxValue = data.reduce((a, b) => a > b ? a : b); final minValue = data.reduce((a, b) => a < b ? a : b); final range = maxValue - minValue; if (range == 0) return; for (int i = 0; i < data.length; i++) { final x = (i / (data.length - 1)) * size.width; final y = size.height - ((data[i] - minValue) / range) * size.height; if (i == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } } canvas.drawPath(path, paint); // Dessiner des points aux extrémités final pointPaint = Paint() ..color = color ..style = PaintingStyle.fill; canvas.drawCircle( Offset(0, size.height - ((data.first - minValue) / range) * size.height), 2, pointPaint, ); canvas.drawCircle( Offset(size.width, size.height - ((data.last - minValue) / range) * size.height), 2, pointPaint, ); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => true; }