Refactoring

This commit is contained in:
DahoudG
2025-09-17 17:54:06 +00:00
parent 12d514d866
commit 63fe107f98
165 changed files with 54220 additions and 276 deletions

View File

@@ -32,6 +32,65 @@ class AppTheme {
static const Color borderColor = Color(0xFFE0E0E0);
static const Color borderLight = Color(0xFFF5F5F5);
static const Color dividerColor = Color(0xFFBDBDBD);
// Couleurs Material 3 supplémentaires pour les composants unifiés
static const Color outline = Color(0xFFE0E0E0);
static const Color surfaceVariant = Color(0xFFF5F5F5);
static const Color onSurfaceVariant = Color(0xFF757575);
// Tokens de design unifiés
static const double borderRadiusSmall = 8.0;
static const double borderRadiusMedium = 12.0;
static const double borderRadiusLarge = 16.0;
static const double borderRadiusXLarge = 20.0;
static const double spacingXSmall = 4.0;
static const double spacingSmall = 8.0;
static const double spacingMedium = 16.0;
static const double spacingLarge = 24.0;
static const double spacingXLarge = 32.0;
static const double elevationSmall = 1.0;
static const double elevationMedium = 2.0;
static const double elevationLarge = 4.0;
static const double elevationXLarge = 8.0;
// Styles de texte unifiés
static const TextStyle headlineSmall = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: textPrimary,
);
static const TextStyle titleMedium = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: textPrimary,
);
static const TextStyle bodyMedium = TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
color: textPrimary,
);
static const TextStyle bodySmall = TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
color: textSecondary,
);
static const TextStyle titleSmall = TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: textPrimary,
);
static const TextStyle bodyLarge = TextStyle(
fontSize: 16,
fontWeight: FontWeight.normal,
color: textPrimary,
);
// Thème clair
static ThemeData get lightTheme {

View File

@@ -0,0 +1,411 @@
import 'package:flutter/material.dart';
import '../../theme/app_theme.dart';
/// Ensemble de boutons unifiés pour toute l'application
///
/// Fournit des styles cohérents pour :
/// - Boutons primaires, secondaires, tertiaires
/// - Boutons d'action (success, warning, error)
/// - Boutons avec icônes
/// - États de chargement et désactivé
class UnifiedButton extends StatefulWidget {
/// Texte du bouton
final String text;
/// Icône optionnelle
final IconData? icon;
/// Position de l'icône
final UnifiedButtonIconPosition iconPosition;
/// Callback lors du tap
final VoidCallback? onPressed;
/// Style du bouton
final UnifiedButtonStyle style;
/// Taille du bouton
final UnifiedButtonSize size;
/// Indique si le bouton est en cours de chargement
final bool isLoading;
/// Indique si le bouton prend toute la largeur disponible
final bool fullWidth;
/// Couleur personnalisée
final Color? customColor;
const UnifiedButton({
super.key,
required this.text,
this.icon,
this.iconPosition = UnifiedButtonIconPosition.left,
this.onPressed,
this.style = UnifiedButtonStyle.primary,
this.size = UnifiedButtonSize.medium,
this.isLoading = false,
this.fullWidth = false,
this.customColor,
});
/// Constructeur pour bouton primaire
const UnifiedButton.primary({
super.key,
required this.text,
this.icon,
this.iconPosition = UnifiedButtonIconPosition.left,
this.onPressed,
this.size = UnifiedButtonSize.medium,
this.isLoading = false,
this.fullWidth = false,
}) : style = UnifiedButtonStyle.primary,
customColor = null;
/// Constructeur pour bouton secondaire
const UnifiedButton.secondary({
super.key,
required this.text,
this.icon,
this.iconPosition = UnifiedButtonIconPosition.left,
this.onPressed,
this.size = UnifiedButtonSize.medium,
this.isLoading = false,
this.fullWidth = false,
}) : style = UnifiedButtonStyle.secondary,
customColor = null;
/// Constructeur pour bouton tertiaire
const UnifiedButton.tertiary({
super.key,
required this.text,
this.icon,
this.iconPosition = UnifiedButtonIconPosition.left,
this.onPressed,
this.isLoading = false,
this.size = UnifiedButtonSize.medium,
this.fullWidth = false,
}) : style = UnifiedButtonStyle.tertiary,
customColor = null;
/// Constructeur pour bouton de succès
const UnifiedButton.success({
super.key,
required this.text,
this.icon,
this.iconPosition = UnifiedButtonIconPosition.left,
this.onPressed,
this.size = UnifiedButtonSize.medium,
this.isLoading = false,
this.fullWidth = false,
}) : style = UnifiedButtonStyle.success,
customColor = null;
/// Constructeur pour bouton d'erreur
const UnifiedButton.error({
super.key,
required this.text,
this.icon,
this.iconPosition = UnifiedButtonIconPosition.left,
this.onPressed,
this.size = UnifiedButtonSize.medium,
this.isLoading = false,
this.fullWidth = false,
}) : style = UnifiedButtonStyle.error,
customColor = null;
@override
State<UnifiedButton> createState() => _UnifiedButtonState();
}
class _UnifiedButtonState extends State<UnifiedButton>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 100),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isEnabled = widget.onPressed != null && !widget.isLoading;
return AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: SizedBox(
width: widget.fullWidth ? double.infinity : null,
height: _getButtonHeight(),
child: GestureDetector(
onTapDown: isEnabled ? (_) => _animationController.forward() : null,
onTapUp: isEnabled ? (_) => _animationController.reverse() : null,
onTapCancel: isEnabled ? () => _animationController.reverse() : null,
child: ElevatedButton(
onPressed: isEnabled ? widget.onPressed : null,
style: _getButtonStyle(),
child: widget.isLoading ? _buildLoadingContent() : _buildContent(),
),
),
),
);
},
);
}
double _getButtonHeight() {
switch (widget.size) {
case UnifiedButtonSize.small:
return 36;
case UnifiedButtonSize.medium:
return 44;
case UnifiedButtonSize.large:
return 52;
}
}
ButtonStyle _getButtonStyle() {
final colors = _getColors();
return ElevatedButton.styleFrom(
backgroundColor: colors.background,
foregroundColor: colors.foreground,
disabledBackgroundColor: colors.disabledBackground,
disabledForegroundColor: colors.disabledForeground,
elevation: _getElevation(),
shadowColor: colors.shadow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_getBorderRadius()),
side: _getBorderSide(colors),
),
padding: _getPadding(),
);
}
_ButtonColors _getColors() {
final customColor = widget.customColor;
switch (widget.style) {
case UnifiedButtonStyle.primary:
return _ButtonColors(
background: customColor ?? AppTheme.primaryColor,
foreground: Colors.white,
disabledBackground: AppTheme.surfaceVariant,
disabledForeground: AppTheme.textSecondary,
shadow: (customColor ?? AppTheme.primaryColor).withOpacity(0.3),
);
case UnifiedButtonStyle.secondary:
return _ButtonColors(
background: Colors.white,
foreground: customColor ?? AppTheme.primaryColor,
disabledBackground: AppTheme.surfaceVariant,
disabledForeground: AppTheme.textSecondary,
shadow: Colors.black.withOpacity(0.1),
borderColor: customColor ?? AppTheme.primaryColor,
);
case UnifiedButtonStyle.tertiary:
return _ButtonColors(
background: Colors.transparent,
foreground: customColor ?? AppTheme.primaryColor,
disabledBackground: Colors.transparent,
disabledForeground: AppTheme.textSecondary,
shadow: Colors.transparent,
);
case UnifiedButtonStyle.success:
return _ButtonColors(
background: customColor ?? AppTheme.successColor,
foreground: Colors.white,
disabledBackground: AppTheme.surfaceVariant,
disabledForeground: AppTheme.textSecondary,
shadow: (customColor ?? AppTheme.successColor).withOpacity(0.3),
);
case UnifiedButtonStyle.warning:
return _ButtonColors(
background: customColor ?? AppTheme.warningColor,
foreground: Colors.white,
disabledBackground: AppTheme.surfaceVariant,
disabledForeground: AppTheme.textSecondary,
shadow: (customColor ?? AppTheme.warningColor).withOpacity(0.3),
);
case UnifiedButtonStyle.error:
return _ButtonColors(
background: customColor ?? AppTheme.errorColor,
foreground: Colors.white,
disabledBackground: AppTheme.surfaceVariant,
disabledForeground: AppTheme.textSecondary,
shadow: (customColor ?? AppTheme.errorColor).withOpacity(0.3),
);
}
}
double _getElevation() {
switch (widget.style) {
case UnifiedButtonStyle.primary:
case UnifiedButtonStyle.success:
case UnifiedButtonStyle.warning:
case UnifiedButtonStyle.error:
return 2;
case UnifiedButtonStyle.secondary:
return 1;
case UnifiedButtonStyle.tertiary:
return 0;
}
}
double _getBorderRadius() {
switch (widget.size) {
case UnifiedButtonSize.small:
return 8;
case UnifiedButtonSize.medium:
return 10;
case UnifiedButtonSize.large:
return 12;
}
}
BorderSide _getBorderSide(_ButtonColors colors) {
if (colors.borderColor != null) {
return BorderSide(color: colors.borderColor!, width: 1);
}
return BorderSide.none;
}
EdgeInsetsGeometry _getPadding() {
switch (widget.size) {
case UnifiedButtonSize.small:
return const EdgeInsets.symmetric(horizontal: 12, vertical: 6);
case UnifiedButtonSize.medium:
return const EdgeInsets.symmetric(horizontal: 16, vertical: 8);
case UnifiedButtonSize.large:
return const EdgeInsets.symmetric(horizontal: 20, vertical: 10);
}
}
Widget _buildContent() {
final List<Widget> children = [];
if (widget.icon != null && widget.iconPosition == UnifiedButtonIconPosition.left) {
children.add(Icon(widget.icon, size: _getIconSize()));
children.add(const SizedBox(width: 8));
}
children.add(
Text(
widget.text,
style: TextStyle(
fontSize: _getFontSize(),
fontWeight: FontWeight.w600,
),
),
);
if (widget.icon != null && widget.iconPosition == UnifiedButtonIconPosition.right) {
children.add(const SizedBox(width: 8));
children.add(Icon(widget.icon, size: _getIconSize()));
}
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: children,
);
}
Widget _buildLoadingContent() {
return SizedBox(
width: _getIconSize(),
height: _getIconSize(),
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
_getColors().foreground,
),
),
);
}
double _getIconSize() {
switch (widget.size) {
case UnifiedButtonSize.small:
return 16;
case UnifiedButtonSize.medium:
return 18;
case UnifiedButtonSize.large:
return 20;
}
}
double _getFontSize() {
switch (widget.size) {
case UnifiedButtonSize.small:
return 12;
case UnifiedButtonSize.medium:
return 14;
case UnifiedButtonSize.large:
return 16;
}
}
}
/// Styles de boutons disponibles
enum UnifiedButtonStyle {
primary,
secondary,
tertiary,
success,
warning,
error,
}
/// Tailles de boutons disponibles
enum UnifiedButtonSize {
small,
medium,
large,
}
/// Position de l'icône dans le bouton
enum UnifiedButtonIconPosition {
left,
right,
}
/// Classe pour gérer les couleurs des boutons
class _ButtonColors {
final Color background;
final Color foreground;
final Color disabledBackground;
final Color disabledForeground;
final Color shadow;
final Color? borderColor;
const _ButtonColors({
required this.background,
required this.foreground,
required this.disabledBackground,
required this.disabledForeground,
required this.shadow,
this.borderColor,
});
}

View File

@@ -0,0 +1,340 @@
import 'package:flutter/material.dart';
import '../../theme/app_theme.dart';
/// Widget de carte unifié pour toute l'application
///
/// Fournit un design cohérent avec :
/// - Styles standardisés (élévation, bordures, couleurs)
/// - Support des animations hover et tap
/// - Variantes de style (elevated, outlined, filled)
/// - Gestion des états (loading, disabled)
class UnifiedCard extends StatefulWidget {
/// Contenu principal de la carte
final Widget child;
/// Callback lors du tap sur la carte
final VoidCallback? onTap;
/// Callback lors du long press
final VoidCallback? onLongPress;
/// Padding interne de la carte
final EdgeInsetsGeometry? padding;
/// Marge externe de la carte
final EdgeInsetsGeometry? margin;
/// Largeur de la carte
final double? width;
/// Hauteur de la carte
final double? height;
/// Variante de style de la carte
final UnifiedCardVariant variant;
/// Couleur de fond personnalisée
final Color? backgroundColor;
/// Couleur de bordure personnalisée
final Color? borderColor;
/// Indique si la carte est désactivée
final bool disabled;
/// Indique si la carte est en cours de chargement
final bool loading;
/// Élévation personnalisée
final double? elevation;
/// Rayon des bordures personnalisé
final double? borderRadius;
const UnifiedCard({
super.key,
required this.child,
this.onTap,
this.onLongPress,
this.padding,
this.margin,
this.width,
this.height,
this.variant = UnifiedCardVariant.elevated,
this.backgroundColor,
this.borderColor,
this.disabled = false,
this.loading = false,
this.elevation,
this.borderRadius,
});
/// Constructeur pour une carte élevée
const UnifiedCard.elevated({
super.key,
required this.child,
this.onTap,
this.onLongPress,
this.padding,
this.margin,
this.width,
this.height,
this.backgroundColor,
this.disabled = false,
this.loading = false,
this.elevation,
this.borderRadius,
}) : variant = UnifiedCardVariant.elevated,
borderColor = null;
/// Constructeur pour une carte avec bordure
const UnifiedCard.outlined({
super.key,
required this.child,
this.onTap,
this.onLongPress,
this.padding,
this.margin,
this.width,
this.height,
this.backgroundColor,
this.borderColor,
this.disabled = false,
this.loading = false,
this.elevation,
this.borderRadius,
}) : variant = UnifiedCardVariant.outlined;
/// Constructeur pour une carte remplie
const UnifiedCard.filled({
super.key,
required this.child,
this.onTap,
this.onLongPress,
this.padding,
this.margin,
this.width,
this.height,
this.backgroundColor,
this.borderColor,
this.disabled = false,
this.loading = false,
this.elevation,
this.borderRadius,
}) : variant = UnifiedCardVariant.filled;
/// Constructeur pour une carte KPI
const UnifiedCard.kpi({
super.key,
required this.child,
this.onTap,
this.onLongPress,
this.margin,
this.width,
this.height,
this.backgroundColor,
this.disabled = false,
this.loading = false,
}) : variant = UnifiedCardVariant.elevated,
padding = const EdgeInsets.all(20),
borderColor = null,
elevation = 2,
borderRadius = 16;
/// Constructeur pour une carte de liste
const UnifiedCard.listItem({
super.key,
required this.child,
this.onTap,
this.onLongPress,
this.margin,
this.width,
this.height,
this.backgroundColor,
this.disabled = false,
this.loading = false,
}) : variant = UnifiedCardVariant.outlined,
padding = const EdgeInsets.all(16),
borderColor = null,
elevation = 0,
borderRadius = 12;
@override
State<UnifiedCard> createState() => _UnifiedCardState();
}
class _UnifiedCardState extends State<UnifiedCard>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _elevationAnimation;
bool _isHovered = false;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.98,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
_elevationAnimation = Tween<double>(
begin: _getBaseElevation(),
end: _getBaseElevation() + 2,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
double _getBaseElevation() {
if (widget.elevation != null) return widget.elevation!;
switch (widget.variant) {
case UnifiedCardVariant.elevated:
return 2;
case UnifiedCardVariant.outlined:
return 0;
case UnifiedCardVariant.filled:
return 1;
}
}
Color _getBackgroundColor() {
if (widget.backgroundColor != null) return widget.backgroundColor!;
if (widget.disabled) return AppTheme.surfaceVariant.withOpacity(0.5);
switch (widget.variant) {
case UnifiedCardVariant.elevated:
return Colors.white;
case UnifiedCardVariant.outlined:
return Colors.white;
case UnifiedCardVariant.filled:
return AppTheme.surfaceVariant;
}
}
Border? _getBorder() {
if (widget.variant == UnifiedCardVariant.outlined) {
return Border.all(
color: widget.borderColor ?? AppTheme.outline,
width: 1,
);
}
return null;
}
@override
Widget build(BuildContext context) {
Widget card = AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Container(
width: widget.width,
height: widget.height,
margin: widget.margin,
decoration: BoxDecoration(
color: _getBackgroundColor(),
borderRadius: BorderRadius.circular(widget.borderRadius ?? 12),
border: _getBorder(),
boxShadow: widget.variant == UnifiedCardVariant.elevated
? [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: _elevationAnimation.value * 2,
offset: Offset(0, _elevationAnimation.value),
),
]
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(widget.borderRadius ?? 12),
child: Material(
color: Colors.transparent,
child: widget.loading
? _buildLoadingState()
: Padding(
padding: widget.padding ?? const EdgeInsets.all(16),
child: widget.child,
),
),
),
),
);
},
);
if (widget.onTap != null && !widget.disabled && !widget.loading) {
card = MouseRegion(
onEnter: (_) => _onHover(true),
onExit: (_) => _onHover(false),
child: GestureDetector(
onTap: widget.onTap,
onLongPress: widget.onLongPress,
onTapDown: (_) => _animationController.forward(),
onTapUp: (_) => _animationController.reverse(),
onTapCancel: () => _animationController.reverse(),
child: card,
),
);
}
return card;
}
void _onHover(bool isHovered) {
if (mounted && !widget.disabled && !widget.loading) {
setState(() {
_isHovered = isHovered;
});
if (isHovered) {
_animationController.forward();
} else {
_animationController.reverse();
}
}
}
Widget _buildLoadingState() {
return Container(
padding: widget.padding ?? const EdgeInsets.all(16),
child: const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primaryColor),
),
),
),
);
}
}
/// Variantes de style pour les cartes unifiées
enum UnifiedCardVariant {
/// Carte avec élévation et ombre
elevated,
/// Carte avec bordure uniquement
outlined,
/// Carte avec fond coloré
filled,
}

View File

@@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
import '../../theme/app_theme.dart';
/// Layout de page unifié pour toutes les features de l'application
///
/// Fournit une structure cohérente avec :
/// - AppBar standardisée avec actions personnalisables
/// - Body avec padding et scroll automatique
/// - FloatingActionButton optionnel
/// - Gestion des états de chargement et d'erreur
class UnifiedPageLayout extends StatelessWidget {
/// Titre de la page affiché dans l'AppBar
final String title;
/// Sous-titre optionnel affiché sous le titre
final String? subtitle;
/// Icône principale de la page
final IconData? icon;
/// Couleur de l'icône (par défaut : primaryColor)
final Color? iconColor;
/// Actions personnalisées dans l'AppBar
final List<Widget>? actions;
/// Contenu principal de la page
final Widget body;
/// FloatingActionButton optionnel
final Widget? floatingActionButton;
/// Position du FloatingActionButton
final FloatingActionButtonLocation? floatingActionButtonLocation;
/// Indique si la page est en cours de chargement
final bool isLoading;
/// Message d'erreur à afficher
final String? errorMessage;
/// Callback pour rafraîchir la page
final VoidCallback? onRefresh;
/// Padding personnalisé pour le body (par défaut : 16.0)
final EdgeInsetsGeometry? padding;
/// Indique si le body doit être scrollable (par défaut : true)
final bool scrollable;
/// Couleur de fond personnalisée
final Color? backgroundColor;
/// Indique si l'AppBar doit être affichée (par défaut : true)
final bool showAppBar;
const UnifiedPageLayout({
super.key,
required this.title,
required this.body,
this.subtitle,
this.icon,
this.iconColor,
this.actions,
this.floatingActionButton,
this.floatingActionButtonLocation,
this.isLoading = false,
this.errorMessage,
this.onRefresh,
this.padding,
this.scrollable = true,
this.backgroundColor,
this.showAppBar = true,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: backgroundColor ?? AppTheme.backgroundLight,
appBar: showAppBar ? _buildAppBar(context) : null,
body: _buildBody(context),
floatingActionButton: floatingActionButton,
floatingActionButtonLocation: floatingActionButtonLocation,
);
}
PreferredSizeWidget _buildAppBar(BuildContext context) {
return AppBar(
backgroundColor: Colors.white,
elevation: 0,
scrolledUnderElevation: 1,
surfaceTintColor: Colors.white,
title: Row(
children: [
if (icon != null) ...[
Icon(
icon,
color: iconColor ?? AppTheme.primaryColor,
size: 24,
),
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
if (subtitle != null)
Text(
subtitle!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
actions: actions,
);
}
Widget _buildBody(BuildContext context) {
Widget content = body;
// Gestion des états d'erreur
if (errorMessage != null) {
content = _buildErrorState(context);
}
// Gestion de l'état de chargement
else if (isLoading) {
content = _buildLoadingState();
}
// Application du padding
if (padding != null || (padding == null && scrollable)) {
content = Padding(
padding: padding ?? const EdgeInsets.all(16.0),
child: content,
);
}
// Gestion du scroll
if (scrollable && errorMessage == null && !isLoading) {
if (onRefresh != null) {
content = RefreshIndicator(
onRefresh: () async => onRefresh!(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: content,
),
);
} else {
content = SingleChildScrollView(child: content);
}
}
return SafeArea(child: content);
}
Widget _buildLoadingState() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primaryColor),
),
SizedBox(height: 16),
Text(
'Chargement...',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 16,
),
),
],
),
);
}
Widget _buildErrorState(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: AppTheme.errorColor,
),
const SizedBox(height: 16),
Text(
'Une erreur est survenue',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Text(
errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 24),
if (onRefresh != null)
ElevatedButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,371 @@
import 'package:flutter/material.dart';
import '../../theme/app_theme.dart';
import '../cards/unified_card_widget.dart';
/// Widget de liste unifié avec animations et gestion d'états
///
/// Fournit :
/// - Animations d'apparition staggerées
/// - Gestion du scroll infini
/// - États de chargement et d'erreur
/// - Refresh-to-reload
/// - Séparateurs personnalisables
class UnifiedListWidget<T> extends StatefulWidget {
/// Liste des éléments à afficher
final List<T> items;
/// Builder pour chaque élément de la liste
final Widget Function(BuildContext context, T item, int index) itemBuilder;
/// Indique si la liste est en cours de chargement
final bool isLoading;
/// Indique si tous les éléments ont été chargés (pour le scroll infini)
final bool hasReachedMax;
/// Callback pour charger plus d'éléments
final VoidCallback? onLoadMore;
/// Callback pour rafraîchir la liste
final Future<void> Function()? onRefresh;
/// Message d'erreur à afficher
final String? errorMessage;
/// Callback pour réessayer en cas d'erreur
final VoidCallback? onRetry;
/// Widget à afficher quand la liste est vide
final Widget? emptyWidget;
/// Message à afficher quand la liste est vide
final String? emptyMessage;
/// Icône à afficher quand la liste est vide
final IconData? emptyIcon;
/// Padding de la liste
final EdgeInsetsGeometry? padding;
/// Espacement entre les éléments
final double itemSpacing;
/// Indique si les animations d'apparition sont activées
final bool enableAnimations;
/// Durée de l'animation d'apparition de chaque élément
final Duration animationDuration;
/// Délai entre les animations d'éléments
final Duration animationDelay;
/// Contrôleur de scroll personnalisé
final ScrollController? scrollController;
/// Physics du scroll
final ScrollPhysics? physics;
const UnifiedListWidget({
super.key,
required this.items,
required this.itemBuilder,
this.isLoading = false,
this.hasReachedMax = false,
this.onLoadMore,
this.onRefresh,
this.errorMessage,
this.onRetry,
this.emptyWidget,
this.emptyMessage,
this.emptyIcon,
this.padding,
this.itemSpacing = 12.0,
this.enableAnimations = true,
this.animationDuration = const Duration(milliseconds: 300),
this.animationDelay = const Duration(milliseconds: 100),
this.scrollController,
this.physics,
});
@override
State<UnifiedListWidget<T>> createState() => _UnifiedListWidgetState<T>();
}
class _UnifiedListWidgetState<T> extends State<UnifiedListWidget<T>>
with TickerProviderStateMixin {
late ScrollController _scrollController;
late AnimationController _listAnimationController;
List<AnimationController> _itemControllers = [];
List<Animation<double>> _itemAnimations = [];
List<Animation<Offset>> _slideAnimations = [];
@override
void initState() {
super.initState();
_scrollController = widget.scrollController ?? ScrollController();
_scrollController.addListener(_onScroll);
_listAnimationController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_initializeItemAnimations();
if (widget.enableAnimations) {
_startAnimations();
}
}
@override
void didUpdateWidget(UnifiedListWidget<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.items.length != oldWidget.items.length) {
_updateItemAnimations();
}
}
@override
void dispose() {
if (widget.scrollController == null) {
_scrollController.dispose();
}
_listAnimationController.dispose();
for (final controller in _itemControllers) {
controller.dispose();
}
super.dispose();
}
void _initializeItemAnimations() {
if (!widget.enableAnimations) return;
_updateItemAnimations();
}
void _updateItemAnimations() {
if (!widget.enableAnimations) return;
// Dispose des anciens controllers s'ils existent
if (_itemControllers.isNotEmpty) {
for (final controller in _itemControllers) {
controller.dispose();
}
}
// Créer de nouveaux controllers pour chaque élément
_itemControllers = List.generate(
widget.items.length,
(index) => AnimationController(
duration: widget.animationDuration,
vsync: this,
),
);
// Animations de fade et scale
_itemAnimations = _itemControllers.map((controller) {
return Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: controller,
curve: Curves.easeOutCubic,
),
);
}).toList();
// Animations de slide depuis le bas
_slideAnimations = _itemControllers.map((controller) {
return Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: controller,
curve: Curves.easeOutCubic,
),
);
}).toList();
}
void _startAnimations() {
if (!widget.enableAnimations) return;
_listAnimationController.forward();
// Démarrer les animations des éléments avec un délai
for (int i = 0; i < _itemControllers.length; i++) {
Future.delayed(widget.animationDelay * i, () {
if (mounted && i < _itemControllers.length) {
_itemControllers[i].forward();
}
});
}
}
void _onScroll() {
if (_isBottom && widget.onLoadMore != null && !widget.isLoading && !widget.hasReachedMax) {
widget.onLoadMore!();
}
}
bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
@override
Widget build(BuildContext context) {
// Gestion de l'état d'erreur
if (widget.errorMessage != null) {
return _buildErrorState();
}
// Gestion de l'état vide
if (widget.items.isEmpty && !widget.isLoading) {
return widget.emptyWidget ?? _buildEmptyState();
}
Widget listView = ListView.separated(
controller: _scrollController,
physics: widget.physics ?? const AlwaysScrollableScrollPhysics(),
padding: widget.padding ?? const EdgeInsets.all(16),
itemCount: widget.items.length + (widget.isLoading ? 1 : 0),
separatorBuilder: (context, index) => SizedBox(height: widget.itemSpacing),
itemBuilder: (context, index) {
// Indicateur de chargement en bas de liste
if (index >= widget.items.length) {
return _buildLoadingIndicator();
}
final item = widget.items[index];
Widget itemWidget = widget.itemBuilder(context, item, index);
// Application des animations si activées
if (widget.enableAnimations && index < _itemAnimations.length) {
itemWidget = AnimatedBuilder(
animation: _itemAnimations[index],
builder: (context, child) {
return FadeTransition(
opacity: _itemAnimations[index],
child: SlideTransition(
position: _slideAnimations[index],
child: Transform.scale(
scale: 0.8 + (0.2 * _itemAnimations[index].value),
child: child,
),
),
);
},
child: itemWidget,
);
}
return itemWidget;
},
);
// Ajout du RefreshIndicator si onRefresh est fourni
if (widget.onRefresh != null) {
listView = RefreshIndicator(
onRefresh: widget.onRefresh!,
child: listView,
);
}
return listView;
}
Widget _buildLoadingIndicator() {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primaryColor),
),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox_outlined,
size: 64,
color: AppTheme.textSecondary.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'Aucun élément',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary.withOpacity(0.7),
),
),
const SizedBox(height: 8),
Text(
'La liste est vide pour le moment',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary.withOpacity(0.5),
),
),
],
),
),
);
}
Widget _buildErrorState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: AppTheme.errorColor,
),
const SizedBox(height: 16),
const Text(
'Une erreur est survenue',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Text(
widget.errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 24),
if (widget.onRetry != null)
ElevatedButton.icon(
onPressed: widget.onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,376 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import '../../../core/performance/performance_optimizer.dart';
/// ListView optimisé avec lazy loading intelligent et gestion de performance
///
/// Fonctionnalités :
/// - Lazy loading avec seuil configurable
/// - Recyclage automatique des widgets
/// - Animations optimisées
/// - Gestion mémoire intelligente
/// - Monitoring des performances
class OptimizedListView<T> extends StatefulWidget {
/// Liste des éléments à afficher
final List<T> items;
/// Builder pour chaque élément
final Widget Function(BuildContext context, T item, int index) itemBuilder;
/// Callback pour charger plus d'éléments
final Future<void> Function()? onLoadMore;
/// Callback pour rafraîchir la liste
final Future<void> Function()? onRefresh;
/// Indique si plus d'éléments peuvent être chargés
final bool hasMore;
/// Indique si le chargement est en cours
final bool isLoading;
/// Seuil pour déclencher le chargement (nombre d'éléments avant la fin)
final int loadMoreThreshold;
/// Hauteur estimée d'un élément (pour l'optimisation)
final double? itemExtent;
/// Padding de la liste
final EdgeInsetsGeometry? padding;
/// Séparateur entre les éléments
final Widget? separator;
/// Widget affiché quand la liste est vide
final Widget? emptyWidget;
/// Widget de chargement personnalisé
final Widget? loadingWidget;
/// Activer les animations
final bool enableAnimations;
/// Durée des animations
final Duration animationDuration;
/// Contrôleur de scroll personnalisé
final ScrollController? scrollController;
/// Physics du scroll
final ScrollPhysics? physics;
/// Activer le recyclage des widgets
final bool enableRecycling;
/// Nombre maximum de widgets en cache
final int maxCachedWidgets;
const OptimizedListView({
super.key,
required this.items,
required this.itemBuilder,
this.onLoadMore,
this.onRefresh,
this.hasMore = true,
this.isLoading = false,
this.loadMoreThreshold = 3,
this.itemExtent,
this.padding,
this.separator,
this.emptyWidget,
this.loadingWidget,
this.enableAnimations = true,
this.animationDuration = const Duration(milliseconds: 300),
this.scrollController,
this.physics,
this.enableRecycling = true,
this.maxCachedWidgets = 50,
});
@override
State<OptimizedListView<T>> createState() => _OptimizedListViewState<T>();
}
class _OptimizedListViewState<T> extends State<OptimizedListView<T>>
with TickerProviderStateMixin {
late ScrollController _scrollController;
late AnimationController _animationController;
/// Cache des widgets recyclés
final Map<String, Widget> _widgetCache = {};
/// Performance optimizer instance
final _optimizer = PerformanceOptimizer();
/// Indique si le chargement est en cours
bool _isLoadingMore = false;
@override
void initState() {
super.initState();
_scrollController = widget.scrollController ?? ScrollController();
_animationController = PerformanceOptimizer.createOptimizedController(
duration: widget.animationDuration,
vsync: this,
debugLabel: 'OptimizedListView',
);
// Écouter le scroll pour le lazy loading
_scrollController.addListener(_onScroll);
// Démarrer les animations si activées
if (widget.enableAnimations) {
_animationController.forward();
}
_optimizer.startTimer('list_build');
}
@override
void dispose() {
if (widget.scrollController == null) {
_scrollController.dispose();
}
_animationController.dispose();
_widgetCache.clear();
_optimizer.stopTimer('list_build');
super.dispose();
}
void _onScroll() {
if (!_scrollController.hasClients) return;
final position = _scrollController.position;
final maxScroll = position.maxScrollExtent;
final currentScroll = position.pixels;
// Calculer si on approche de la fin
final threshold = maxScroll - (widget.loadMoreThreshold * (widget.itemExtent ?? 100));
if (currentScroll >= threshold &&
widget.hasMore &&
!_isLoadingMore &&
widget.onLoadMore != null) {
_loadMore();
}
}
Future<void> _loadMore() async {
if (_isLoadingMore) return;
setState(() {
_isLoadingMore = true;
});
_optimizer.startTimer('load_more');
try {
await widget.onLoadMore!();
} finally {
if (mounted) {
setState(() {
_isLoadingMore = false;
});
}
_optimizer.stopTimer('load_more');
}
}
Widget _buildOptimizedItem(BuildContext context, int index) {
if (index >= widget.items.length) {
// Widget de chargement en fin de liste
return _buildLoadingIndicator();
}
final item = widget.items[index];
final cacheKey = 'item_${item.hashCode}_$index';
// Utiliser le cache si le recyclage est activé
if (widget.enableRecycling && _widgetCache.containsKey(cacheKey)) {
_optimizer.incrementCounter('cache_hit');
return _widgetCache[cacheKey]!;
}
// Construire le widget
Widget itemWidget = widget.itemBuilder(context, item, index);
// Optimiser le widget
itemWidget = PerformanceOptimizer.optimizeWidget(
itemWidget,
key: 'optimized_$index',
forceRepaintBoundary: true,
);
// Ajouter les animations si activées
if (widget.enableAnimations) {
itemWidget = _buildAnimatedItem(itemWidget, index);
}
// Mettre en cache si le recyclage est activé
if (widget.enableRecycling) {
_cacheWidget(cacheKey, itemWidget);
}
_optimizer.incrementCounter('item_built');
return itemWidget;
}
Widget _buildAnimatedItem(Widget child, int index) {
final delay = Duration(milliseconds: (index * 50).clamp(0, 500));
return AnimatedBuilder(
animation: _animationController,
builder: (context, _) {
final animationValue = Curves.easeOutCubic.transform(
(_animationController.value - (delay.inMilliseconds / widget.animationDuration.inMilliseconds))
.clamp(0.0, 1.0),
);
return Transform.translate(
offset: Offset(0, 50 * (1 - animationValue)),
child: Opacity(
opacity: animationValue,
child: child,
),
);
},
);
}
void _cacheWidget(String key, Widget widget) {
// Limiter la taille du cache
if (_widgetCache.length >= widget.maxCachedWidgets) {
// Supprimer les plus anciens (simple FIFO)
final oldestKey = _widgetCache.keys.first;
_widgetCache.remove(oldestKey);
}
_widgetCache[key] = widget;
}
Widget _buildLoadingIndicator() {
return widget.loadingWidget ??
const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: CircularProgressIndicator(),
),
);
}
Widget _buildEmptyState() {
return widget.emptyWidget ??
const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox_outlined,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'Aucun élément à afficher',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
// Liste vide
if (widget.items.isEmpty && !widget.isLoading) {
return _buildEmptyState();
}
// Calculer le nombre total d'éléments (items + indicateur de chargement)
final itemCount = widget.items.length + (widget.hasMore && _isLoadingMore ? 1 : 0);
Widget listView;
if (widget.separator != null) {
// ListView avec séparateurs
listView = ListView.separated(
controller: _scrollController,
physics: widget.physics,
padding: widget.padding,
itemCount: itemCount,
itemBuilder: _buildOptimizedItem,
separatorBuilder: (context, index) => widget.separator!,
);
} else {
// ListView standard
listView = ListView.builder(
controller: _scrollController,
physics: widget.physics,
padding: widget.padding,
itemCount: itemCount,
itemExtent: widget.itemExtent,
itemBuilder: _buildOptimizedItem,
);
}
// Ajouter RefreshIndicator si onRefresh est fourni
if (widget.onRefresh != null) {
listView = RefreshIndicator(
onRefresh: widget.onRefresh!,
child: listView,
);
}
return listView;
}
}
/// Extension pour faciliter l'utilisation
extension OptimizedListViewExtension<T> on List<T> {
/// Crée un OptimizedListView à partir de cette liste
Widget toOptimizedListView({
required Widget Function(BuildContext context, T item, int index) itemBuilder,
Future<void> Function()? onLoadMore,
Future<void> Function()? onRefresh,
bool hasMore = false,
bool isLoading = false,
int loadMoreThreshold = 3,
double? itemExtent,
EdgeInsetsGeometry? padding,
Widget? separator,
Widget? emptyWidget,
Widget? loadingWidget,
bool enableAnimations = true,
Duration animationDuration = const Duration(milliseconds: 300),
ScrollController? scrollController,
ScrollPhysics? physics,
bool enableRecycling = true,
int maxCachedWidgets = 50,
}) {
return OptimizedListView<T>(
items: this,
itemBuilder: itemBuilder,
onLoadMore: onLoadMore,
onRefresh: onRefresh,
hasMore: hasMore,
isLoading: isLoading,
loadMoreThreshold: loadMoreThreshold,
itemExtent: itemExtent,
padding: padding,
separator: separator,
emptyWidget: emptyWidget,
loadingWidget: loadingWidget,
enableAnimations: enableAnimations,
animationDuration: animationDuration,
scrollController: scrollController,
physics: physics,
enableRecycling: enableRecycling,
maxCachedWidgets: maxCachedWidgets,
);
}
}

View File

@@ -0,0 +1,262 @@
import 'package:flutter/material.dart';
import '../../theme/app_theme.dart';
import '../cards/unified_card_widget.dart';
/// Section KPI unifiée pour afficher des indicateurs clés
///
/// Fournit :
/// - Cartes KPI avec animations
/// - Layouts adaptatifs (grille ou liste)
/// - Indicateurs de tendance
/// - Couleurs thématiques
class UnifiedKPISection extends StatelessWidget {
/// Liste des KPI à afficher
final List<UnifiedKPIData> kpis;
/// Titre de la section
final String? title;
/// Nombre de colonnes dans la grille (par défaut : 2)
final int crossAxisCount;
/// Espacement entre les cartes
final double spacing;
/// Callback lors du tap sur un KPI
final void Function(UnifiedKPIData kpi)? onKPITap;
const UnifiedKPISection({
super.key,
required this.kpis,
this.title,
this.crossAxisCount = 2,
this.spacing = 16.0,
this.onKPITap,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Text(
title!,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
],
_buildKPIGrid(),
],
);
}
Widget _buildKPIGrid() {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: spacing,
mainAxisSpacing: spacing,
childAspectRatio: 1.4,
),
itemCount: kpis.length,
itemBuilder: (context, index) {
final kpi = kpis[index];
return UnifiedCard.kpi(
onTap: onKPITap != null ? () => onKPITap!(kpi) : null,
child: _buildKPIContent(kpi),
);
},
);
}
Widget _buildKPIContent(UnifiedKPIData kpi) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// En-tête avec icône et titre
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: kpi.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
kpi.icon,
color: kpi.color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
kpi.title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
// Valeur principale
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
kpi.value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (kpi.trend != null) ...[
const SizedBox(width: 8),
_buildTrendIndicator(kpi.trend!),
],
],
),
// Sous-titre ou description
if (kpi.subtitle != null) ...[
const SizedBox(height: 4),
Text(
kpi.subtitle!,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
);
}
Widget _buildTrendIndicator(UnifiedKPITrend trend) {
IconData icon;
Color color;
switch (trend.direction) {
case UnifiedKPITrendDirection.up:
icon = Icons.trending_up;
color = AppTheme.successColor;
break;
case UnifiedKPITrendDirection.down:
icon = Icons.trending_down;
color = AppTheme.errorColor;
break;
case UnifiedKPITrendDirection.stable:
icon = Icons.trending_flat;
color = AppTheme.textSecondary;
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 12,
color: color,
),
const SizedBox(width: 2),
Text(
trend.value,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
);
}
}
/// Données pour un KPI unifié
class UnifiedKPIData {
/// Titre du KPI
final String title;
/// Valeur principale à afficher
final String value;
/// Sous-titre ou description optionnelle
final String? subtitle;
/// Icône représentative
final IconData icon;
/// Couleur thématique
final Color color;
/// Indicateur de tendance optionnel
final UnifiedKPITrend? trend;
/// Données supplémentaires pour les callbacks
final Map<String, dynamic>? metadata;
const UnifiedKPIData({
required this.title,
required this.value,
required this.icon,
required this.color,
this.subtitle,
this.trend,
this.metadata,
});
}
/// Indicateur de tendance pour les KPI
class UnifiedKPITrend {
/// Direction de la tendance
final UnifiedKPITrendDirection direction;
/// Valeur de la tendance (ex: "+12%", "-5", "stable")
final String value;
/// Label descriptif de la tendance (ex: "ce mois", "vs mois dernier")
final String? label;
const UnifiedKPITrend({
required this.direction,
required this.value,
this.label,
});
}
/// Direction de tendance disponibles
enum UnifiedKPITrendDirection {
up,
down,
stable,
}

View File

@@ -0,0 +1,262 @@
import 'package:flutter/material.dart';
import '../../theme/app_theme.dart';
import '../cards/unified_card_widget.dart';
/// Section d'actions rapides unifiée
///
/// Fournit :
/// - Grille d'actions avec icônes
/// - Animations au tap
/// - Layouts adaptatifs
/// - Badges de notification
class UnifiedQuickActionsSection extends StatelessWidget {
/// Liste des actions rapides
final List<UnifiedQuickAction> actions;
/// Titre de la section
final String? title;
/// Nombre de colonnes dans la grille (par défaut : 3)
final int crossAxisCount;
/// Espacement entre les actions
final double spacing;
/// Callback lors du tap sur une action
final void Function(UnifiedQuickAction action)? onActionTap;
const UnifiedQuickActionsSection({
super.key,
required this.actions,
this.title,
this.crossAxisCount = 3,
this.spacing = 12.0,
this.onActionTap,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Text(
title!,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
],
_buildActionsGrid(),
],
);
}
Widget _buildActionsGrid() {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: spacing,
mainAxisSpacing: spacing,
childAspectRatio: 1.0,
),
itemCount: actions.length,
itemBuilder: (context, index) {
final action = actions[index];
return _buildActionCard(action);
},
);
}
Widget _buildActionCard(UnifiedQuickAction action) {
return UnifiedCard(
onTap: action.enabled && onActionTap != null
? () => onActionTap!(action)
: null,
variant: UnifiedCardVariant.outlined,
padding: const EdgeInsets.all(12),
child: Stack(
children: [
// Contenu principal
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icône avec conteneur coloré
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: action.enabled
? action.color.withOpacity(0.1)
: AppTheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
action.icon,
color: action.enabled
? action.color
: AppTheme.textSecondary.withOpacity(0.5),
size: 24,
),
),
const SizedBox(height: 8),
// Titre de l'action
Text(
action.title,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: action.enabled
? AppTheme.textPrimary
: AppTheme.textSecondary.withOpacity(0.5),
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
// Badge de notification
if (action.badgeCount != null && action.badgeCount! > 0)
Positioned(
top: 0,
right: 0,
child: _buildBadge(action.badgeCount!),
),
// Indicateur "nouveau"
if (action.isNew)
Positioned(
top: 4,
right: 4,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppTheme.accentColor,
shape: BoxShape.circle,
),
),
),
],
),
);
}
Widget _buildBadge(int count) {
final displayCount = count > 99 ? '99+' : count.toString();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.errorColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white, width: 2),
),
constraints: const BoxConstraints(
minWidth: 20,
minHeight: 20,
),
child: Text(
displayCount,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
);
}
}
/// Données pour une action rapide unifiée
class UnifiedQuickAction {
/// Identifiant unique de l'action
final String id;
/// Titre de l'action
final String title;
/// Icône représentative
final IconData icon;
/// Couleur thématique
final Color color;
/// Indique si l'action est activée
final bool enabled;
/// Nombre de notifications/badges (optionnel)
final int? badgeCount;
/// Indique si l'action est nouvelle
final bool isNew;
/// Données supplémentaires pour les callbacks
final Map<String, dynamic>? metadata;
const UnifiedQuickAction({
required this.id,
required this.title,
required this.icon,
required this.color,
this.enabled = true,
this.badgeCount,
this.isNew = false,
this.metadata,
});
}
/// Actions rapides prédéfinies communes
class CommonQuickActions {
static const UnifiedQuickAction addMember = UnifiedQuickAction(
id: 'add_member',
title: 'Ajouter\nMembre',
icon: Icons.person_add,
color: AppTheme.primaryColor,
);
static const UnifiedQuickAction addEvent = UnifiedQuickAction(
id: 'add_event',
title: 'Nouvel\nÉvénement',
icon: Icons.event_available,
color: AppTheme.accentColor,
);
static const UnifiedQuickAction collectPayment = UnifiedQuickAction(
id: 'collect_payment',
title: 'Collecter\nCotisation',
icon: Icons.payment,
color: AppTheme.successColor,
);
static const UnifiedQuickAction sendMessage = UnifiedQuickAction(
id: 'send_message',
title: 'Envoyer\nMessage',
icon: Icons.message,
color: AppTheme.infoColor,
);
static const UnifiedQuickAction generateReport = UnifiedQuickAction(
id: 'generate_report',
title: 'Générer\nRapport',
icon: Icons.assessment,
color: AppTheme.warningColor,
);
static const UnifiedQuickAction manageSettings = UnifiedQuickAction(
id: 'manage_settings',
title: 'Paramètres',
icon: Icons.settings,
color: AppTheme.textSecondary,
);
}

View File

@@ -0,0 +1,34 @@
/// Fichier d'export pour tous les composants unifiés de l'application
///
/// Permet d'importer facilement tous les widgets standardisés :
/// ```dart
/// import 'package:unionflow_mobile_apps/shared/widgets/unified_components.dart';
/// ```
// Layouts et structures
export 'common/unified_page_layout.dart';
// Cartes et conteneurs
export 'cards/unified_card_widget.dart';
// Listes et grilles
export 'lists/unified_list_widget.dart';
// Boutons et interactions
export 'buttons/unified_button_set.dart';
// Sections communes
export 'sections/unified_kpi_section.dart';
export 'sections/unified_quick_actions_section.dart';
// Widgets existants réutilisables
export 'coming_soon_page.dart';
export 'custom_text_field.dart';
export 'loading_button.dart';
export 'permission_widget.dart';
// Sous-dossiers existants (commentés car certains fichiers n'existent pas encore)
// export 'avatars/avatar_widget.dart';
// export 'badges/status_badge.dart';
// export 'buttons/action_button.dart';
// export 'cards/info_card.dart';