Refactoring - Version OK

This commit is contained in:
dahoud
2025-11-17 16:02:04 +00:00
parent 3f00a26308
commit 3b9ffac8cd
198 changed files with 18010 additions and 11383 deletions

View File

@@ -0,0 +1,396 @@
/// Widget adaptatif révolutionnaire avec morphing intelligent
/// Transformation dynamique selon le rôle utilisateur avec animations fluides
library adaptive_widget;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../features/authentication/data/models/user.dart';
import '../../features/authentication/data/models/user_role.dart';
import '../../features/authentication/data/datasources/permission_engine.dart';
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
/// Widget adaptatif révolutionnaire qui se transforme selon le rôle utilisateur
///
/// Fonctionnalités :
/// - Morphing intelligent avec animations fluides
/// - Widgets spécifiques par rôle
/// - Vérification de permissions intégrée
/// - Fallback gracieux pour les rôles non supportés
/// - Cache des widgets pour les performances
class AdaptiveWidget extends StatefulWidget {
/// Widgets spécifiques par rôle utilisateur
final Map<UserRole, Widget Function()> roleWidgets;
/// Permissions requises pour afficher le widget
final List<String> requiredPermissions;
/// Widget affiché si les permissions sont insuffisantes
final Widget? fallbackWidget;
/// Widget affiché pendant le chargement
final Widget? loadingWidget;
/// Activer les animations de morphing
final bool enableMorphing;
/// Durée de l'animation de morphing
final Duration morphingDuration;
/// Courbe d'animation
final Curve animationCurve;
/// Contexte organisationnel pour les permissions
final String? organizationId;
/// Activer l'audit trail
final bool auditLog;
/// Constructeur du widget adaptatif
const AdaptiveWidget({
super.key,
required this.roleWidgets,
this.requiredPermissions = const [],
this.fallbackWidget,
this.loadingWidget,
this.enableMorphing = true,
this.morphingDuration = const Duration(milliseconds: 800),
this.animationCurve = Curves.easeInOutCubic,
this.organizationId,
this.auditLog = true,
});
@override
State<AdaptiveWidget> createState() => _AdaptiveWidgetState();
}
class _AdaptiveWidgetState extends State<AdaptiveWidget>
with TickerProviderStateMixin {
/// Cache des widgets construits pour éviter les reconstructions
final Map<UserRole, Widget> _widgetCache = {};
/// Contrôleur d'animation pour le morphing
late AnimationController _morphController;
/// Animation d'opacité
late Animation<double> _opacityAnimation;
/// Animation d'échelle
late Animation<double> _scaleAnimation;
/// Rôle utilisateur précédent pour détecter les changements
UserRole? _previousRole;
@override
void initState() {
super.initState();
_initializeAnimations();
}
@override
void dispose() {
_morphController.dispose();
super.dispose();
}
/// Initialise les animations de morphing
void _initializeAnimations() {
_morphController = AnimationController(
duration: widget.morphingDuration,
vsync: this,
);
_opacityAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _morphController,
curve: widget.animationCurve,
));
_scaleAnimation = Tween<double>(
begin: 0.95,
end: 1.0,
).animate(CurvedAnimation(
parent: _morphController,
curve: widget.animationCurve,
));
// Démarrer l'animation initiale
_morphController.forward();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
// État de chargement
if (state is AuthLoading) {
return widget.loadingWidget ?? _buildLoadingWidget();
}
// État non authentifié
if (state is! AuthAuthenticated) {
return _buildForRole(UserRole.visitor);
}
final user = state.user;
final currentRole = user.primaryRole;
// Détecter le changement de rôle pour déclencher l'animation
if (_previousRole != null && _previousRole != currentRole && widget.enableMorphing) {
_triggerMorphing();
}
_previousRole = currentRole;
return FutureBuilder<bool>(
future: _checkPermissions(user),
builder: (context, permissionSnapshot) {
if (permissionSnapshot.connectionState == ConnectionState.waiting) {
return widget.loadingWidget ?? _buildLoadingWidget();
}
final hasPermissions = permissionSnapshot.data ?? false;
if (!hasPermissions) {
return widget.fallbackWidget ?? _buildUnauthorizedWidget();
}
return _buildForRole(currentRole);
},
);
},
);
}
/// Construit le widget pour un rôle spécifique
Widget _buildForRole(UserRole role) {
// Vérifier le cache
if (_widgetCache.containsKey(role)) {
return _wrapWithAnimation(_widgetCache[role]!);
}
// Trouver le widget approprié
Widget? widget = _findWidgetForRole(role);
widget ??= this.widget.fallbackWidget ?? _buildUnsupportedRoleWidget(role);
// Mettre en cache
_widgetCache[role] = widget;
return _wrapWithAnimation(widget);
}
/// Trouve le widget approprié pour un rôle
Widget? _findWidgetForRole(UserRole role) {
// Vérification directe
if (widget.roleWidgets.containsKey(role)) {
return widget.roleWidgets[role]!();
}
// Recherche du meilleur match par niveau de rôle
UserRole? bestMatch;
for (final availableRole in widget.roleWidgets.keys) {
if (availableRole.level <= role.level) {
if (bestMatch == null || availableRole.level > bestMatch.level) {
bestMatch = availableRole;
}
}
}
return bestMatch != null ? widget.roleWidgets[bestMatch]!() : null;
}
/// Enveloppe le widget avec les animations
Widget _wrapWithAnimation(Widget child) {
if (!widget.enableMorphing) return child;
return AnimatedBuilder(
animation: _morphController,
builder: (context, _) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: child,
),
);
},
);
}
/// Déclenche l'animation de morphing
void _triggerMorphing() {
_morphController.reset();
_morphController.forward();
// Vider le cache pour forcer la reconstruction
_widgetCache.clear();
}
/// Vérifie les permissions requises
Future<bool> _checkPermissions(User user) async {
if (widget.requiredPermissions.isEmpty) return true;
final results = await PermissionEngine.hasPermissions(
user,
widget.requiredPermissions,
organizationId: widget.organizationId,
auditLog: widget.auditLog,
);
return results.values.every((hasPermission) => hasPermission);
}
/// Widget de chargement par défaut
Widget _buildLoadingWidget() {
return const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
/// Widget non autorisé par défaut
Widget _buildUnauthorizedWidget() {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lock_outline,
size: 48,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 8),
Text(
'Accès non autorisé',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).disabledColor,
),
),
const SizedBox(height: 4),
Text(
'Vous n\'avez pas les permissions nécessaires',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).disabledColor,
),
textAlign: TextAlign.center,
),
],
),
);
}
/// Widget pour rôle non supporté
Widget _buildUnsupportedRoleWidget(UserRole role) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_outlined,
size: 48,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 8),
Text(
'Rôle non supporté',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
const SizedBox(height: 4),
Text(
'Le rôle ${role.displayName} n\'est pas supporté par ce widget',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
);
}
}
/// Widget sécurisé avec vérification de permissions intégrée
///
/// Version simplifiée d'AdaptiveWidget pour les cas où seules
/// les permissions importent, pas le rôle spécifique
class SecureWidget extends StatelessWidget {
/// Permissions requises pour afficher le widget
final List<String> requiredPermissions;
/// Widget à afficher si autorisé
final Widget child;
/// Widget à afficher si non autorisé
final Widget? unauthorizedWidget;
/// Widget à afficher pendant le chargement
final Widget? loadingWidget;
/// Contexte organisationnel
final String? organizationId;
/// Activer l'audit trail
final bool auditLog;
/// Constructeur du widget sécurisé
const SecureWidget({
super.key,
required this.requiredPermissions,
required this.child,
this.unauthorizedWidget,
this.loadingWidget,
this.organizationId,
this.auditLog = true,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is AuthLoading) {
return loadingWidget ?? const SizedBox.shrink();
}
if (state is! AuthAuthenticated) {
return unauthorizedWidget ?? const SizedBox.shrink();
}
return FutureBuilder<bool>(
future: _checkPermissions(state.user),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return loadingWidget ?? const SizedBox.shrink();
}
final hasPermissions = snapshot.data ?? false;
if (!hasPermissions) {
return unauthorizedWidget ?? const SizedBox.shrink();
}
return child;
},
);
},
);
}
/// Vérifie les permissions requises
Future<bool> _checkPermissions(User user) async {
if (requiredPermissions.isEmpty) return true;
final results = await PermissionEngine.hasPermissions(
user,
requiredPermissions,
organizationId: organizationId,
auditLog: auditLog,
);
return results.values.every((hasPermission) => hasPermission);
}
}

View File

@@ -0,0 +1,292 @@
/// Dialogue de confirmation réutilisable
/// Utilisé pour confirmer les actions critiques (suppression, etc.)
library confirmation_dialog;
import 'package:flutter/material.dart';
/// Type d'action pour personnaliser l'apparence du dialogue
enum ConfirmationAction {
delete,
deactivate,
activate,
cancel,
warning,
info,
}
/// Dialogue de confirmation générique
class ConfirmationDialog extends StatelessWidget {
final String title;
final String message;
final String confirmText;
final String cancelText;
final ConfirmationAction action;
final VoidCallback? onConfirm;
final VoidCallback? onCancel;
const ConfirmationDialog({
super.key,
required this.title,
required this.message,
this.confirmText = 'Confirmer',
this.cancelText = 'Annuler',
this.action = ConfirmationAction.warning,
this.onConfirm,
this.onCancel,
});
/// Constructeur pour suppression
const ConfirmationDialog.delete({
super.key,
required this.title,
required this.message,
this.confirmText = 'Supprimer',
this.cancelText = 'Annuler',
this.onConfirm,
this.onCancel,
}) : action = ConfirmationAction.delete;
/// Constructeur pour désactivation
const ConfirmationDialog.deactivate({
super.key,
required this.title,
required this.message,
this.confirmText = 'Désactiver',
this.cancelText = 'Annuler',
this.onConfirm,
this.onCancel,
}) : action = ConfirmationAction.deactivate;
/// Constructeur pour activation
const ConfirmationDialog.activate({
super.key,
required this.title,
required this.message,
this.confirmText = 'Activer',
this.cancelText = 'Annuler',
this.onConfirm,
this.onCancel,
}) : action = ConfirmationAction.activate;
@override
Widget build(BuildContext context) {
final colors = _getColors();
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
title: Row(
children: [
Icon(
_getIcon(),
color: colors['icon'],
size: 28,
),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: TextStyle(
color: colors['title'],
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
],
),
content: Text(
message,
style: const TextStyle(
fontSize: 16,
height: 1.5,
),
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
onCancel?.call();
},
child: Text(
cancelText,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
onConfirm?.call();
},
style: ElevatedButton.styleFrom(
backgroundColor: colors['button'],
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
),
child: Text(
confirmText,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
);
}
IconData _getIcon() {
switch (action) {
case ConfirmationAction.delete:
return Icons.delete_forever;
case ConfirmationAction.deactivate:
return Icons.block;
case ConfirmationAction.activate:
return Icons.check_circle;
case ConfirmationAction.cancel:
return Icons.cancel;
case ConfirmationAction.warning:
return Icons.warning;
case ConfirmationAction.info:
return Icons.info;
}
}
Map<String, Color> _getColors() {
switch (action) {
case ConfirmationAction.delete:
return {
'icon': Colors.red,
'title': Colors.red[700]!,
'button': Colors.red,
};
case ConfirmationAction.deactivate:
return {
'icon': Colors.orange,
'title': Colors.orange[700]!,
'button': Colors.orange,
};
case ConfirmationAction.activate:
return {
'icon': Colors.green,
'title': Colors.green[700]!,
'button': Colors.green,
};
case ConfirmationAction.cancel:
return {
'icon': Colors.grey,
'title': Colors.grey[700]!,
'button': Colors.grey,
};
case ConfirmationAction.warning:
return {
'icon': Colors.amber,
'title': Colors.amber[700]!,
'button': Colors.amber,
};
case ConfirmationAction.info:
return {
'icon': Colors.blue,
'title': Colors.blue[700]!,
'button': Colors.blue,
};
}
}
}
/// Fonction utilitaire pour afficher un dialogue de confirmation
Future<bool> showConfirmationDialog({
required BuildContext context,
required String title,
required String message,
String confirmText = 'Confirmer',
String cancelText = 'Annuler',
ConfirmationAction action = ConfirmationAction.warning,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => ConfirmationDialog(
title: title,
message: message,
confirmText: confirmText,
cancelText: cancelText,
action: action,
onConfirm: () {},
onCancel: () {},
),
);
return result ?? false;
}
/// Fonction utilitaire pour dialogue de suppression
Future<bool> showDeleteConfirmation({
required BuildContext context,
required String itemName,
String? additionalMessage,
}) async {
final message = additionalMessage != null
? 'Êtes-vous sûr de vouloir supprimer "$itemName" ?\n\n$additionalMessage\n\nCette action est irréversible.'
: 'Êtes-vous sûr de vouloir supprimer "$itemName" ?\n\nCette action est irréversible.';
final result = await showDialog<bool>(
context: context,
builder: (context) => ConfirmationDialog.delete(
title: 'Confirmer la suppression',
message: message,
onConfirm: () {},
onCancel: () {},
),
);
return result ?? false;
}
/// Fonction utilitaire pour dialogue de désactivation
Future<bool> showDeactivateConfirmation({
required BuildContext context,
required String itemName,
String? reason,
}) async {
final message = reason != null
? 'Êtes-vous sûr de vouloir désactiver "$itemName" ?\n\n$reason'
: 'Êtes-vous sûr de vouloir désactiver "$itemName" ?';
final result = await showDialog<bool>(
context: context,
builder: (context) => ConfirmationDialog.deactivate(
title: 'Confirmer la désactivation',
message: message,
onConfirm: () {},
onCancel: () {},
),
);
return result ?? false;
}
/// Fonction utilitaire pour dialogue d'activation
Future<bool> showActivateConfirmation({
required BuildContext context,
required String itemName,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => ConfirmationDialog.activate(
title: 'Confirmer l\'activation',
message: 'Êtes-vous sûr de vouloir activer "$itemName" ?',
onConfirm: () {},
onCancel: () {},
),
);
return result ?? false;
}

View File

@@ -0,0 +1,168 @@
/// Widget d'erreur réutilisable pour toute l'application
library error_widget;
import 'package:flutter/material.dart';
/// Widget d'erreur avec message et bouton de retry
class AppErrorWidget extends StatelessWidget {
/// Message d'erreur à afficher
final String message;
/// Callback appelé lors du clic sur le bouton retry
final VoidCallback? onRetry;
/// Icône personnalisée (optionnel)
final IconData? icon;
/// Titre personnalisé (optionnel)
final String? title;
const AppErrorWidget({
super.key,
required this.message,
this.onRetry,
this.icon,
this.title,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon ?? Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
title ?? 'Oups !',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (onRetry != null) ...[
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
],
],
),
),
);
}
}
/// Widget d'erreur réseau spécifique
class NetworkErrorWidget extends StatelessWidget {
final VoidCallback? onRetry;
const NetworkErrorWidget({
super.key,
this.onRetry,
});
@override
Widget build(BuildContext context) {
return AppErrorWidget(
message: 'Impossible de se connecter au serveur.\nVérifiez votre connexion internet.',
onRetry: onRetry,
icon: Icons.wifi_off,
title: 'Pas de connexion',
);
}
}
/// Widget d'erreur de permissions
class PermissionErrorWidget extends StatelessWidget {
final String? message;
const PermissionErrorWidget({
super.key,
this.message,
});
@override
Widget build(BuildContext context) {
return AppErrorWidget(
message: message ?? 'Vous n\'avez pas les permissions nécessaires pour accéder à cette ressource.',
icon: Icons.lock_outline,
title: 'Accès refusé',
);
}
}
/// Widget d'erreur "Aucune donnée"
class EmptyDataWidget extends StatelessWidget {
final String message;
final IconData? icon;
final VoidCallback? onAction;
final String? actionLabel;
const EmptyDataWidget({
super.key,
required this.message,
this.icon,
this.onAction,
this.actionLabel,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon ?? Icons.inbox_outlined,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (onAction != null && actionLabel != null) ...[
const SizedBox(height: 24),
ElevatedButton(
onPressed: onAction,
child: Text(actionLabel!),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,244 @@
/// Widgets de chargement réutilisables pour toute l'application
library loading_widget;
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
/// Widget de chargement simple avec CircularProgressIndicator
class AppLoadingWidget extends StatelessWidget {
final String? message;
final double? size;
const AppLoadingWidget({
super.key,
this.message,
this.size,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: size ?? 40,
height: size ?? 40,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
),
if (message != null) ...[
const SizedBox(height: 16),
Text(
message!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
);
}
}
/// Widget de chargement avec effet shimmer pour les listes
class ShimmerListLoading extends StatelessWidget {
final int itemCount;
final double itemHeight;
const ShimmerListLoading({
super.key,
this.itemCount = 5,
this.itemHeight = 80,
});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: itemCount,
padding: const EdgeInsets.all(16),
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: Container(
height: itemHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
),
),
);
},
);
}
}
/// Widget de chargement avec effet shimmer pour les cartes
class ShimmerCardLoading extends StatelessWidget {
final double height;
final double? width;
const ShimmerCardLoading({
super.key,
this.height = 120,
this.width,
});
@override
Widget build(BuildContext context) {
return Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: Container(
height: height,
width: width,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
),
);
}
}
/// Widget de chargement avec effet shimmer pour une grille
class ShimmerGridLoading extends StatelessWidget {
final int itemCount;
final int crossAxisCount;
final double childAspectRatio;
const ShimmerGridLoading({
super.key,
this.itemCount = 6,
this.crossAxisCount = 2,
this.childAspectRatio = 1.0,
});
@override
Widget build(BuildContext context) {
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: childAspectRatio,
),
itemCount: itemCount,
itemBuilder: (context, index) {
return Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
),
);
},
);
}
}
/// Widget de chargement pour les détails d'un élément
class ShimmerDetailLoading extends StatelessWidget {
const ShimmerDetailLoading({super.key});
@override
Widget build(BuildContext context) {
return Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Container(
height: 200,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
),
const SizedBox(height: 16),
// Title
Container(
height: 24,
width: double.infinity,
color: Colors.white,
),
const SizedBox(height: 8),
// Subtitle
Container(
height: 16,
width: 200,
color: Colors.white,
),
const SizedBox(height: 24),
// Content lines
...List.generate(5, (index) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Container(
height: 12,
width: double.infinity,
color: Colors.white,
),
);
}),
],
),
),
);
}
}
/// Widget de chargement inline (petit)
class InlineLoadingWidget extends StatelessWidget {
final String? message;
const InlineLoadingWidget({
super.key,
this.message,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
),
if (message != null) ...[
const SizedBox(width: 8),
Text(
message!,
style: Theme.of(context).textTheme.bodySmall,
),
],
],
);
}
}