Refactoring
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user