feat: WebSocket temps réel + Finance Workflow + corrections

- Task #6: WebSocket /ws/dashboard + Kafka events (5 topics)
  * Backend: KafkaEventProducer, KafkaEventConsumer
  * Mobile: WebSocketService (reconnection, heartbeat, typed events)
  * DashboardBloc: Auto-refresh depuis WebSocket events

- Finance Workflow: approbations + budgets (backend + mobile)
  * Backend: entities, services, resources, migrations Flyway V6
  * Mobile: features finance_workflow complète avec BLoC

- Corrections DI: interfaces IRepository partout
  * IProfileRepository, IOrganizationRepository, IMembreRepository
  * GetIt configuré avec @injectable

- Spec-Kit: constitution + templates mis à jour
  * .specify/memory/constitution.md enrichie
  * Templates agent, plan, spec, tasks, checklist

- Nettoyage: fichiers temporaires supprimés

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 02:12:17 +00:00
parent bbc409de9d
commit e8ad874015
635 changed files with 58160 additions and 20674 deletions

View File

@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import '../design_system/tokens/app_colors.dart';
import '../design_system/tokens/app_typography.dart';
/// UnionFlow Mobile - Composant DRY : ActionRow
/// Centralise les interactions (J'aime, Commenter, Partager, etc.) sous une barre compacte.
class ActionRow extends StatelessWidget {
final int? likesCount;
final int? commentsCount;
final VoidCallback? onLike;
final VoidCallback? onComment;
final VoidCallback? onShare;
final bool isLiked; // Permet de teinter l'icône Like
// Peut être personnalisé pour des actions spécifiques (ex: Payer)
final String? customActionLabel;
final VoidCallback? onCustomAction;
final IconData? customActionIcon;
const ActionRow({
Key? key,
this.likesCount,
this.commentsCount,
this.onLike,
this.onComment,
this.onShare,
this.isLiked = false,
this.customActionLabel,
this.onCustomAction,
this.customActionIcon,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final iconColor = isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight;
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Actions standards (Like/Comment/Share)
Row(
children: [
if (onLike != null)
_buildActionIcon(
icon: isLiked ? Icons.favorite : Icons.favorite_border,
color: isLiked ? AppColors.error : iconColor,
count: likesCount,
onTap: onLike!,
),
if (onLike != null && onComment != null) const SizedBox(width: 24),
if (onComment != null)
_buildActionIcon(
icon: Icons.chat_bubble_outline,
color: iconColor,
count: commentsCount,
onTap: onComment!,
),
if (onComment != null && onShare != null) const SizedBox(width: 24),
if (onShare != null)
_buildActionIcon(
icon: Icons.share_outlined,
color: iconColor,
onTap: onShare!,
),
],
),
// Action personnalisée à droite (ex: Payer la cotisation)
if (onCustomAction != null)
GestureDetector(
onTap: onCustomAction,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primaryGreen.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
if (customActionIcon != null) ...[
Icon(customActionIcon, size: 14, color: AppColors.primaryGreen),
const SizedBox(width: 4),
],
Text(
customActionLabel ?? '',
style: AppTypography.badgeText.copyWith(color: AppColors.primaryGreen),
),
],
),
),
),
],
),
);
}
Widget _buildActionIcon({
required IconData icon,
required Color color,
required VoidCallback onTap,
int? count,
}) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Row(
children: [
Icon(icon, size: 16, color: color),
if (count != null && count > 0) ...[
const SizedBox(width: 4),
Text(
count.toString(),
style: AppTypography.subtitleSmall.copyWith(color: color),
),
]
],
),
);
}
}

View File

@@ -106,7 +106,7 @@ class ConfirmationDialog extends StatelessWidget {
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
Navigator.pop(context, false);
onCancel?.call();
},
child: Text(
@@ -119,7 +119,7 @@ class ConfirmationDialog extends StatelessWidget {
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
Navigator.pop(context, true);
onConfirm?.call();
},
style: ElevatedButton.styleFrom(

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import '../design_system/tokens/app_colors.dart';
/// UnionFlow Mobile - Composant DRY Centralisé : CoreCard
/// Le seul et unique conteneur d'affichage (Posts, Événements, Profils).
/// Design : Minimaliste Premium, Bordures ultra-fines, Ombre invisible mais présente.
class CoreCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry margin;
final VoidCallback? onTap;
final Color? backgroundColor;
const CoreCard({
Key? key,
required this.child,
this.padding = const EdgeInsets.all(12.0),
this.margin = const EdgeInsets.only(bottom: 10.0),
this.onTap,
this.backgroundColor,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
width: double.infinity,
margin: margin,
decoration: BoxDecoration(
color: backgroundColor ?? (isDark ? const Color(0xFF1A1A1A) : Colors.white),
borderRadius: BorderRadius.circular(6.0),
border: Border.all(
color: isDark ? AppColors.darkBorder.withOpacity(0.5) : AppColors.lightBorder,
width: 0.4,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDark ? 0.3 : 0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6.0),
child: Padding(
padding: padding,
child: child,
),
),
),
);
}
}

View File

@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import '../design_system/tokens/app_colors.dart';
import 'core_card.dart';
/// UnionFlow Mobile - Composant DRY : CoreShimmer
/// Utilise `shimmer` package pour générer des loaders élégants sans textes.
class CoreShimmer extends StatelessWidget {
final int itemCount;
const CoreShimmer({
Key? key,
this.itemCount = 5,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final baseColor = isDark ? Colors.grey[800]! : Colors.grey[300]!;
final highlightColor = isDark ? Colors.grey[700]! : Colors.grey[100]!;
return ListView.builder(
itemCount: itemCount,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (_, __) => Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Shimmer.fromColors(
baseColor: baseColor,
highlightColor: highlightColor,
child: CoreCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(radius: 16, backgroundColor: Colors.white),
SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(width: 100, height: 10, color: Colors.white),
SizedBox(height: 4),
Container(width: 40, height: 8, color: Colors.white),
],
),
),
],
),
SizedBox(height: 12),
Container(width: double.infinity, height: 10, color: Colors.white),
SizedBox(height: 4),
Container(width: 250, height: 10, color: Colors.white),
SizedBox(height: 4),
Container(width: 150, height: 10, color: Colors.white),
SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(width: 40, height: 12, color: Colors.white),
Container(width: 40, height: 12, color: Colors.white),
Container(width: 40, height: 12, color: Colors.white),
],
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import '../design_system/tokens/app_colors.dart';
import '../design_system/tokens/app_typography.dart';
/// UnionFlow Mobile - Composant DRY : CoreTextField
/// Champ de texte minimaliste, fin, sans bordures massives.
class CoreTextField extends StatelessWidget {
final String hintText;
final IconData? prefixIcon;
final bool obscureText;
final TextEditingController? controller;
final TextInputType keyboardType;
final String? errorText;
const CoreTextField({
Key? key,
required this.hintText,
this.prefixIcon,
this.obscureText = false,
this.controller,
this.keyboardType = TextInputType.text,
this.errorText,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
style: AppTypography.actionText, // Texte d'entrée assez lisible
decoration: InputDecoration(
hintText: hintText,
hintStyle: AppTypography.subtitleSmall.copyWith(
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
),
prefixIcon: prefixIcon != null
? Icon(prefixIcon, size: 20, color: AppColors.primaryGreen)
: null,
filled: true,
fillColor: isDark ? AppColors.darkSurface : AppColors.lightSurface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: AppColors.primaryGreen,
width: 1.5,
),
),
errorText: errorText,
errorStyle: AppTypography.badgeText.copyWith(color: AppColors.error),
),
),
],
);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import '../design_system/tokens/app_colors.dart';
import '../design_system/tokens/app_typography.dart';
/// UnionFlow Mobile - Composant DRY : DynamicFAB
/// Bouton Flottant "Twitter Style" paramétrable pour les actions principales.
class DynamicFAB extends StatelessWidget {
final VoidCallback onPressed;
final IconData icon;
final String? label; // Si null, c'est juste un bouton rond. Si texte, c'est un "extended" FAB.
const DynamicFAB({
Key? key,
required this.onPressed,
required this.icon,
this.label,
}) : super(key: key);
@override
Widget build(BuildContext context) {
if (label != null) {
return FloatingActionButton.extended(
onPressed: onPressed,
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
elevation: 4,
icon: Icon(icon, size: 20),
label: Text(
label!,
style: AppTypography.actionText,
),
);
}
return FloatingActionButton(
onPressed: onPressed,
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
elevation: 4,
child: Icon(icon, size: 24),
);
}
}

View File

@@ -0,0 +1,286 @@
/// Widget for displaying user-friendly error messages with retry capability
library error_display_widget;
import 'package:flutter/material.dart';
import '../../core/error/failures.dart';
/// Error display widget that shows failures in a user-friendly way
class ErrorDisplayWidget extends StatelessWidget {
final Failure failure;
final VoidCallback? onRetry;
final bool showRetryButton;
const ErrorDisplayWidget({
super.key,
required this.failure,
this.onRetry,
this.showRetryButton = true,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Error icon
Icon(
_getErrorIcon(),
size: 64,
color: _getErrorColor(context),
),
const SizedBox(height: 24),
// Error title
Text(
_getErrorTitle(),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: _getErrorColor(context),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// Error message
Text(
failure.getUserMessage(),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
// Retry button (if retryable and callback provided)
if (showRetryButton && failure.isRetryable && onRetry != null) ...[
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
),
),
],
],
),
),
);
}
/// Get appropriate icon for error type
IconData _getErrorIcon() {
if (failure is NetworkFailure) {
return Icons.wifi_off;
} else if (failure is UnauthorizedFailure) {
return Icons.lock_outline;
} else if (failure is ForbiddenFailure) {
return Icons.block;
} else if (failure is NotFoundFailure) {
return Icons.search_off;
} else if (failure is ValidationFailure) {
return Icons.error_outline;
} else if (failure is ServerFailure) {
return Icons.cloud_off;
} else {
return Icons.warning_amber;
}
}
/// Get appropriate color for error type
Color _getErrorColor(BuildContext context) {
if (failure is NetworkFailure) {
return Colors.orange;
} else if (failure is UnauthorizedFailure) {
return Colors.red;
} else if (failure is ForbiddenFailure) {
return Colors.deepOrange;
} else if (failure is ValidationFailure) {
return Colors.amber;
} else {
return Theme.of(context).colorScheme.error;
}
}
/// Get appropriate title for error type
String _getErrorTitle() {
if (failure is NetworkFailure) {
return 'Problème de connexion';
} else if (failure is UnauthorizedFailure) {
return 'Session expirée';
} else if (failure is ForbiddenFailure) {
return 'Accès refusé';
} else if (failure is NotFoundFailure) {
return 'Non trouvé';
} else if (failure is ValidationFailure) {
return 'Données invalides';
} else if (failure is ServerFailure) {
return 'Erreur serveur';
} else {
return 'Une erreur est survenue';
}
}
}
/// Compact error banner for inline display
class ErrorBanner extends StatelessWidget {
final Failure failure;
final VoidCallback? onRetry;
final VoidCallback? onDismiss;
const ErrorBanner({
super.key,
required this.failure,
this.onRetry,
this.onDismiss,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _getErrorColor(context).withOpacity(0.1),
border: Border.all(
color: _getErrorColor(context),
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
_getErrorIcon(),
color: _getErrorColor(context),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getErrorTitle(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: _getErrorColor(context),
),
),
const SizedBox(height: 4),
Text(
failure.getUserMessage(),
style: TextStyle(
fontSize: 13,
color: Colors.grey[700],
),
),
],
),
),
if (failure.isRetryable && onRetry != null)
TextButton(
onPressed: onRetry,
child: const Text('Réessayer'),
),
if (onDismiss != null)
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: onDismiss,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
);
}
IconData _getErrorIcon() {
if (failure is NetworkFailure) {
return Icons.wifi_off;
} else if (failure is UnauthorizedFailure) {
return Icons.lock_outline;
} else if (failure is ForbiddenFailure) {
return Icons.block;
} else if (failure is NotFoundFailure) {
return Icons.search_off;
} else if (failure is ValidationFailure) {
return Icons.error_outline;
} else if (failure is ServerFailure) {
return Icons.cloud_off;
} else {
return Icons.warning_amber;
}
}
Color _getErrorColor(BuildContext context) {
if (failure is NetworkFailure) {
return Colors.orange;
} else if (failure is UnauthorizedFailure) {
return Colors.red;
} else if (failure is ForbiddenFailure) {
return Colors.deepOrange;
} else if (failure is ValidationFailure) {
return Colors.amber;
} else {
return Theme.of(context).colorScheme.error;
}
}
String _getErrorTitle() {
if (failure is NetworkFailure) {
return 'Problème de connexion';
} else if (failure is UnauthorizedFailure) {
return 'Session expirée';
} else if (failure is ForbiddenFailure) {
return 'Accès refusé';
} else if (failure is NotFoundFailure) {
return 'Non trouvé';
} else if (failure is ValidationFailure) {
return 'Données invalides';
} else if (failure is ServerFailure) {
return 'Erreur serveur';
} else {
return 'Erreur';
}
}
}
/// Show error as a SnackBar
void showErrorSnackBar(
BuildContext context,
Failure failure, {
VoidCallback? onRetry,
}) {
final snackBar = SnackBar(
content: Row(
children: [
Icon(
failure is NetworkFailure ? Icons.wifi_off : Icons.error_outline,
color: Colors.white,
),
const SizedBox(width: 12),
Expanded(
child: Text(failure.getUserMessage()),
),
],
),
backgroundColor: failure is NetworkFailure ? Colors.orange : Colors.red,
behavior: SnackBarBehavior.floating,
action: failure.isRetryable && onRetry != null
? SnackBarAction(
label: 'Réessayer',
textColor: Colors.white,
onPressed: onRetry,
)
: null,
duration: Duration(seconds: failure.isRetryable ? 6 : 4),
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import '../design_system/tokens/app_colors.dart';
import '../design_system/tokens/app_typography.dart';
/// UnionFlow Mobile - Composant DRY : InfoBadge
/// Indicateur compact pour les statuts ("Payé", "Admin", etc).
class InfoBadge extends StatelessWidget {
final String text;
final Color backgroundColor;
final Color textColor;
final IconData? icon;
const InfoBadge({
Key? key,
required this.text,
this.backgroundColor = AppColors.brandGreenLight,
this.textColor = Colors.white,
this.icon,
}) : super(key: key);
// Factory methods pour les statuts courants
factory InfoBadge.success(String text) {
return InfoBadge(
text: text,
backgroundColor: AppColors.success.withOpacity(0.15),
textColor: AppColors.success,
icon: Icons.check_circle_outline,
);
}
factory InfoBadge.error(String text) {
return InfoBadge(
text: text,
backgroundColor: AppColors.error.withOpacity(0.15),
textColor: AppColors.error,
icon: Icons.error_outline,
);
}
factory InfoBadge.neutral(String text) {
return InfoBadge(
text: text,
backgroundColor: AppColors.info.withOpacity(0.15),
textColor: AppColors.info,
);
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 10, color: textColor),
const SizedBox(width: 2),
],
Text(
text,
style: AppTypography.badgeText.copyWith(color: textColor),
),
],
),
);
}
}

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../design_system/tokens/app_colors.dart';
import '../design_system/tokens/app_typography.dart';
/// UnionFlow Mobile - Composant DRY : MiniAvatar
/// Évite toute répétition de configuration d'image de profil.
/// Formats contraints (24px, 32px max).
class MiniAvatar extends StatelessWidget {
final String? imageUrl;
final String fallbackText; // Ex: "JD" pour John Doe
final double size;
final bool isOnline; // Ajoute une petite pastille verte
final Color? backgroundColor;
final Color? iconColor;
final bool isIcon;
const MiniAvatar({
Key? key,
this.imageUrl,
required this.fallbackText,
this.size = 32.0,
this.isOnline = false,
this.backgroundColor,
this.iconColor,
this.isIcon = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Stack(
children: [
Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: backgroundColor ?? AppColors.primaryGreen.withOpacity(0.1),
border: Border.all(
color: AppColors.lightBorder,
width: 0.5,
),
),
child: ClipOval(
child: isIcon
? _buildIcon()
: (imageUrl != null && imageUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: imageUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => _buildFallback(),
errorWidget: (context, url, error) => _buildFallback(),
)
: _buildFallback()),
),
),
if (isOnline)
Positioned(
bottom: 0,
right: 0,
child: Container(
width: size * 0.3,
height: size * 0.3,
decoration: BoxDecoration(
color: AppColors.success,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).scaffoldBackgroundColor,
width: 1.5,
),
),
),
),
],
);
}
Widget _buildFallback() {
return Center(
child: Text(
fallbackText.toUpperCase(),
style: AppTypography.actionText.copyWith(
color: iconColor ?? AppColors.primaryGreen,
fontSize: size * 0.4,
),
),
);
}
Widget _buildIcon() {
IconData iconData;
switch (fallbackText) {
case 'people': iconData = Icons.people; break;
case 'event': iconData = Icons.event; break;
case 'business': iconData = Icons.business; break;
case 'settings': iconData = Icons.settings; break;
default: iconData = Icons.notifications;
}
return Center(
child: Icon(
iconData,
color: iconColor ?? AppColors.primaryGreen,
size: size * 0.6,
),
);
}
}

View File

@@ -0,0 +1,326 @@
/// Reusable validated text field with consistent styling
library validated_text_field;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Validated text field with consistent styling and behavior
class ValidatedTextField extends StatelessWidget {
final TextEditingController? controller;
final String? labelText;
final String? hintText;
final String? helperText;
final String? initialValue;
final String? Function(String?)? validator;
final void Function(String)? onChanged;
final void Function(String?)? onSaved;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final bool obscureText;
final bool enabled;
final bool readOnly;
final int? maxLines;
final int? minLines;
final int? maxLength;
final Widget? prefixIcon;
final Widget? suffixIcon;
final List<TextInputFormatter>? inputFormatters;
final FocusNode? focusNode;
final void Function()? onEditingComplete;
final void Function(String)? onFieldSubmitted;
final AutovalidateMode? autovalidateMode;
final bool showCounter;
const ValidatedTextField({
super.key,
this.controller,
this.labelText,
this.hintText,
this.helperText,
this.initialValue,
this.validator,
this.onChanged,
this.onSaved,
this.keyboardType,
this.textInputAction,
this.obscureText = false,
this.enabled = true,
this.readOnly = false,
this.maxLines = 1,
this.minLines,
this.maxLength,
this.prefixIcon,
this.suffixIcon,
this.inputFormatters,
this.focusNode,
this.onEditingComplete,
this.onFieldSubmitted,
this.autovalidateMode,
this.showCounter = true,
});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
initialValue: initialValue,
decoration: InputDecoration(
labelText: labelText,
hintText: hintText,
helperText: helperText,
prefixIcon: prefixIcon,
suffixIcon: suffixIcon,
border: const OutlineInputBorder(),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.grey.shade400,
width: 1.0,
),
),
focusedBorder: const OutlineInputBorder(
borderSide: BorderSide(
color: Colors.blue,
width: 2.0,
),
),
errorBorder: const OutlineInputBorder(
borderSide: BorderSide(
color: Colors.red,
width: 1.0,
),
),
focusedErrorBorder: const OutlineInputBorder(
borderSide: BorderSide(
color: Colors.red,
width: 2.0,
),
),
filled: !enabled,
fillColor: !enabled ? Colors.grey.shade100 : null,
counterText: showCounter ? null : '',
),
validator: validator,
onChanged: onChanged,
onSaved: onSaved,
keyboardType: keyboardType,
textInputAction: textInputAction,
obscureText: obscureText,
enabled: enabled,
readOnly: readOnly,
maxLines: maxLines,
minLines: minLines,
maxLength: maxLength,
inputFormatters: inputFormatters,
focusNode: focusNode,
onEditingComplete: onEditingComplete,
onFieldSubmitted: onFieldSubmitted,
autovalidateMode: autovalidateMode,
);
}
}
/// Validated amount field with currency formatting
class ValidatedAmountField extends StatelessWidget {
final TextEditingController? controller;
final String? labelText;
final String? hintText;
final String? initialValue;
final String? Function(String?)? validator;
final void Function(String)? onChanged;
final void Function(String?)? onSaved;
final bool enabled;
final String currencySymbol;
final FocusNode? focusNode;
const ValidatedAmountField({
super.key,
this.controller,
this.labelText,
this.hintText,
this.initialValue,
this.validator,
this.onChanged,
this.onSaved,
this.enabled = true,
this.currencySymbol = 'FCFA',
this.focusNode,
});
@override
Widget build(BuildContext context) {
return ValidatedTextField(
controller: controller,
initialValue: initialValue,
labelText: labelText,
hintText: hintText,
helperText: 'Entrez un montant positif (max 2 décimales)',
validator: validator,
onChanged: onChanged,
onSaved: onSaved,
enabled: enabled,
focusNode: focusNode,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
textInputAction: TextInputAction.next,
suffixIcon: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
currencySymbol,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')),
],
);
}
}
/// Validated dropdown field
class ValidatedDropdownField<T> extends StatelessWidget {
final T? value;
final List<DropdownMenuItem<T>> items;
final String? labelText;
final String? hintText;
final String? helperText;
final String? Function(T?)? validator;
final void Function(T?)? onChanged;
final void Function(T?)? onSaved;
final bool enabled;
const ValidatedDropdownField({
super.key,
this.value,
required this.items,
this.labelText,
this.hintText,
this.helperText,
this.validator,
this.onChanged,
this.onSaved,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<T>(
value: value,
items: items,
decoration: InputDecoration(
labelText: labelText,
hintText: hintText,
helperText: helperText,
border: const OutlineInputBorder(),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.grey.shade400,
width: 1.0,
),
),
focusedBorder: const OutlineInputBorder(
borderSide: BorderSide(
color: Colors.blue,
width: 2.0,
),
),
errorBorder: const OutlineInputBorder(
borderSide: BorderSide(
color: Colors.red,
width: 1.0,
),
),
filled: !enabled,
fillColor: !enabled ? Colors.grey.shade100 : null,
),
validator: validator,
onChanged: enabled ? onChanged : null,
onSaved: onSaved,
);
}
}
/// Validated date picker field
class ValidatedDateField extends StatelessWidget {
final DateTime? selectedDate;
final String? labelText;
final String? hintText;
final String? helperText;
final String? Function(DateTime?)? validator;
final void Function(DateTime)? onChanged;
final DateTime? firstDate;
final DateTime? lastDate;
final bool enabled;
const ValidatedDateField({
super.key,
this.selectedDate,
this.labelText,
this.hintText,
this.helperText,
this.validator,
this.onChanged,
this.firstDate,
this.lastDate,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: enabled
? () async {
final date = await showDatePicker(
context: context,
initialDate: selectedDate ?? DateTime.now(),
firstDate: firstDate ?? DateTime(2000),
lastDate: lastDate ?? DateTime(2100),
);
if (date != null && onChanged != null) {
onChanged!(date);
}
}
: null,
child: InputDecorator(
decoration: InputDecoration(
labelText: labelText,
hintText: hintText,
helperText: helperText,
suffixIcon: const Icon(Icons.calendar_today),
border: const OutlineInputBorder(),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.grey.shade400,
width: 1.0,
),
),
focusedBorder: const OutlineInputBorder(
borderSide: BorderSide(
color: Colors.blue,
width: 2.0,
),
),
errorBorder: const OutlineInputBorder(
borderSide: BorderSide(
color: Colors.red,
width: 1.0,
),
),
filled: !enabled,
fillColor: !enabled ? Colors.grey.shade100 : null,
errorText: validator != null ? validator!(selectedDate) : null,
),
child: Text(
selectedDate != null
? '${selectedDate!.day}/${selectedDate!.month}/${selectedDate!.year}'
: hintText ?? 'Sélectionner une date',
style: TextStyle(
color: selectedDate != null ? Colors.black87 : Colors.grey,
),
),
),
);
}
}