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,84 @@
/// Chemins des icônes (logos) des moyens de paiement pour listes déroulantes et composants.
///
/// Les assets sont dans [assets/images/payment_methods/{compagnie}/logo.svg] ou logo.png.
/// Utiliser [paymentMethodIconAssetSvg] / [paymentMethodIconAssetPng] ou le widget [PaymentMethodIcon].
library;
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
/// Base path des icônes de moyens de paiement.
const String kPaymentMethodIconsBase = 'assets/images/payment_methods';
/// Retourne le chemin du logo SVG pour un code (ex: WAVE_MONEY).
String? paymentMethodIconAssetSvg(String? code) => _pathFor(code, 'logo.svg');
/// Retourne le chemin du logo PNG pour un code.
String? paymentMethodIconAssetPng(String? code) => _pathFor(code, 'logo.png');
/// Retourne le chemin SVG (rétrocompatibilité).
String? paymentMethodIconAsset(String? code) => paymentMethodIconAssetSvg(code);
String? _pathFor(String? code, String filename) {
if (code == null || code.isEmpty) return null;
final path = _codeToPath[code.toUpperCase().trim()];
if (path != null) return '$kPaymentMethodIconsBase/$path/$filename';
return null;
}
/// Mapping code API → sous-dossier asset.
const Map<String, String> _codeToPath = {
'ESPECES': 'especes',
'VIREMENT': 'virement',
'CHEQUE': 'cheque',
'CARTE_BANCAIRE': 'carte_bancaire',
'WAVE_MONEY': 'wave',
'ORANGE_MONEY': 'orange_money',
'FREE_MONEY': 'free_money',
'MTN_MONEY': 'mtn_money',
'MOOV_MONEY': 'moov_money',
'MOBILE_MONEY': 'mobile_money',
'AUTRE': 'autre',
};
/// Liste des codes de méthode de paiement ayant une icône (pour itération).
List<String> get paymentMethodCodesWithIcon => _codeToPath.keys.toList();
/// Widget qui affiche le logo (PNG prioritaire, sinon SVG) pour une méthode de paiement.
class PaymentMethodIcon extends StatelessWidget {
final String? paymentMethodCode;
final double width;
final double height;
const PaymentMethodIcon({
super.key,
required this.paymentMethodCode,
this.width = 24,
this.height = 24,
});
@override
Widget build(BuildContext context) {
final pngPath = paymentMethodIconAssetPng(paymentMethodCode);
final svgPath = paymentMethodIconAssetSvg(paymentMethodCode);
if (pngPath == null && svgPath == null) return const SizedBox.shrink();
if (pngPath != null) {
return Image.asset(
pngPath,
width: width,
height: height,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) {
if (svgPath != null) {
return SvgPicture.asset(svgPath, width: width, height: height);
}
return const SizedBox.shrink();
},
);
}
if (svgPath != null) {
return SvgPicture.asset(svgPath, width: width, height: height);
}
return const SizedBox.shrink();
}
}

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import '../tokens/unionflow_colors.dart';
/// Background avec motifs géométriques africains subtils
class AfricanPatternBackground extends StatelessWidget {
final Widget child;
final Color? patternColor;
final double opacity;
const AfricanPatternBackground({
super.key,
required this.child,
this.patternColor,
this.opacity = 0.03,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Background avec motifs
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: AfricanPatternPainter(
color: (patternColor ?? UnionFlowColors.unionGreen).withOpacity(opacity),
),
),
),
),
// Contenu
child,
],
);
}
}
/// Painter pour dessiner les motifs africains
class AfricanPatternPainter extends CustomPainter {
final Color color;
AfricanPatternPainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 2;
final fillPaint = Paint()
..color = color
..style = PaintingStyle.fill;
// Espacement entre les motifs
const double spacing = 80.0;
const double patternSize = 40.0;
// Dessiner la grille de motifs
for (double y = 0; y < size.height + spacing; y += spacing) {
for (double x = 0; x < size.width + spacing; x += spacing) {
final offset = Offset(x, y);
// Alterner entre différents motifs
final patternType = ((x ~/ spacing) + (y ~/ spacing)) % 3;
switch (patternType) {
case 0:
_drawDiamondPattern(canvas, offset, patternSize, paint);
break;
case 1:
_drawTrianglePattern(canvas, offset, patternSize, fillPaint);
break;
case 2:
_drawCirclePattern(canvas, offset, patternSize, paint);
break;
}
}
}
}
void _drawDiamondPattern(Canvas canvas, Offset offset, double size, Paint paint) {
final path = Path()
..moveTo(offset.dx, offset.dy - size / 2)
..lineTo(offset.dx + size / 2, offset.dy)
..lineTo(offset.dx, offset.dy + size / 2)
..lineTo(offset.dx - size / 2, offset.dy)
..close();
canvas.drawPath(path, paint);
}
void _drawTrianglePattern(Canvas canvas, Offset offset, double size, Paint paint) {
final path = Path()
..moveTo(offset.dx, offset.dy - size / 3)
..lineTo(offset.dx + size / 3, offset.dy + size / 3)
..lineTo(offset.dx - size / 3, offset.dy + size / 3)
..close();
canvas.drawPath(path, paint);
}
void _drawCirclePattern(Canvas canvas, Offset offset, double size, Paint paint) {
canvas.drawCircle(offset, size / 4, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
/// Widget avec animation de fade-in automatique
class AnimatedFadeIn extends StatefulWidget {
final Widget child;
final Duration duration;
final Duration delay;
final Curve curve;
const AnimatedFadeIn({
super.key,
required this.child,
this.duration = const Duration(milliseconds: 600),
this.delay = Duration.zero,
this.curve = Curves.easeOut,
});
@override
State<AnimatedFadeIn> createState() => _AnimatedFadeInState();
}
class _AnimatedFadeInState extends State<AnimatedFadeIn>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
);
_animation = CurvedAnimation(
parent: _controller,
curve: widget.curve,
);
Future.delayed(widget.delay, () {
if (mounted) {
_controller.forward();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _animation,
child: widget.child,
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
/// Widget avec animation de slide-in automatique
class AnimatedSlideIn extends StatefulWidget {
final Widget child;
final Duration duration;
final Duration delay;
final Offset begin;
final Curve curve;
const AnimatedSlideIn({
super.key,
required this.child,
this.duration = const Duration(milliseconds: 600),
this.delay = Duration.zero,
this.begin = const Offset(0, 0.3),
this.curve = Curves.easeOut,
});
@override
State<AnimatedSlideIn> createState() => _AnimatedSlideInState();
}
class _AnimatedSlideInState extends State<AnimatedSlideIn>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _slideAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: widget.begin,
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: widget.curve,
));
_fadeAnimation = CurvedAnimation(
parent: _controller,
curve: widget.curve,
);
Future.delayed(widget.delay, () {
if (mounted) {
_controller.forward();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: widget.child,
),
);
}
}

View File

@@ -5,9 +5,7 @@
library uf_primary_button;
import 'package:flutter/material.dart';
import '../../tokens/color_tokens.dart';
import '../../tokens/spacing_tokens.dart';
import '../../tokens/typography_tokens.dart';
import '../../unionflow_design_system.dart';
/// Bouton primaire UnionFlow
///
@@ -38,6 +36,12 @@ class UFPrimaryButton extends StatelessWidget {
/// Hauteur personnalisée (optionnel)
final double? height;
/// Couleur de fond personnalisée (optionnel)
final Color? backgroundColor;
/// Couleur du texte/icône personnalisée (optionnel)
final Color? textColor;
const UFPrimaryButton({
super.key,
@@ -47,6 +51,8 @@ class UFPrimaryButton extends StatelessWidget {
this.icon,
this.isFullWidth = false,
this.height,
this.backgroundColor,
this.textColor,
});
@override
@@ -57,12 +63,12 @@ class UFPrimaryButton extends StatelessWidget {
child: ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: ColorTokens.primary, // Bleu roi
foregroundColor: ColorTokens.onPrimary, // Blanc
disabledBackgroundColor: ColorTokens.primary.withOpacity(0.5),
disabledForegroundColor: ColorTokens.onPrimary.withOpacity(0.7),
backgroundColor: backgroundColor ?? AppColors.primaryGreen,
foregroundColor: textColor ?? Colors.white,
disabledBackgroundColor: (backgroundColor ?? AppColors.primaryGreen).withOpacity(0.5),
disabledForegroundColor: (textColor ?? Colors.white).withOpacity(0.7),
elevation: SpacingTokens.elevationSm,
shadowColor: ColorTokens.shadow,
shadowColor: AppColors.darkBorder.withOpacity(0.1),
padding: const EdgeInsets.symmetric(
horizontal: SpacingTokens.buttonPaddingHorizontal,
vertical: SpacingTokens.buttonPaddingVertical,
@@ -78,7 +84,7 @@ class UFPrimaryButton extends StatelessWidget {
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
ColorTokens.onPrimary,
Colors.white,
),
),
)
@@ -92,7 +98,7 @@ class UFPrimaryButton extends StatelessWidget {
],
Text(
label,
style: TypographyTokens.buttonLarge,
style: AppTypography.actionText,
),
],
),

View File

@@ -5,9 +5,7 @@
library uf_secondary_button;
import 'package:flutter/material.dart';
import '../../tokens/color_tokens.dart';
import '../../tokens/spacing_tokens.dart';
import '../../tokens/typography_tokens.dart';
import '../../unionflow_design_system.dart';
/// Bouton secondaire UnionFlow
class UFSecondaryButton extends StatelessWidget {
@@ -36,12 +34,12 @@ class UFSecondaryButton extends StatelessWidget {
child: ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: ColorTokens.secondary, // Indigo
foregroundColor: ColorTokens.onSecondary, // Blanc
disabledBackgroundColor: ColorTokens.secondary.withOpacity(0.5),
disabledForegroundColor: ColorTokens.onSecondary.withOpacity(0.7),
backgroundColor: AppColors.brandGreen,
foregroundColor: Colors.white,
disabledBackgroundColor: AppColors.brandGreen.withOpacity(0.5),
disabledForegroundColor: Colors.white.withOpacity(0.7),
elevation: SpacingTokens.elevationSm,
shadowColor: ColorTokens.shadow,
shadowColor: AppColors.darkBorder.withOpacity(0.1),
padding: const EdgeInsets.symmetric(
horizontal: SpacingTokens.buttonPaddingHorizontal,
vertical: SpacingTokens.buttonPaddingVertical,
@@ -57,7 +55,7 @@ class UFSecondaryButton extends StatelessWidget {
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
ColorTokens.onSecondary,
Colors.white,
),
),
)
@@ -71,7 +69,7 @@ class UFSecondaryButton extends StatelessWidget {
],
Text(
label,
style: TypographyTokens.buttonLarge,
style: AppTypography.actionText,
),
],
),

View File

@@ -112,12 +112,12 @@ class UFCard extends StatelessWidget {
switch (style) {
case UFCardStyle.elevated:
return BoxDecoration(
color: color ?? ColorTokens.surface,
color: color ?? AppColors.lightSurface,
borderRadius: BorderRadius.circular(radius),
boxShadow: elevation != null
? [
BoxShadow(
color: ColorTokens.shadow,
color: AppColors.darkBorder.withOpacity(0.1),
blurRadius: elevation!,
offset: const Offset(0, 2),
),
@@ -127,17 +127,17 @@ class UFCard extends StatelessWidget {
case UFCardStyle.outlined:
return BoxDecoration(
color: color ?? ColorTokens.surface,
color: color ?? AppColors.lightSurface,
borderRadius: BorderRadius.circular(radius),
border: Border.all(
color: borderColor ?? ColorTokens.outline,
color: borderColor ?? AppColors.lightBorder,
width: borderWidth ?? 1.0,
),
);
case UFCardStyle.filled:
return BoxDecoration(
color: color ?? ColorTokens.surfaceContainer,
color: color ?? AppColors.lightSurface,
borderRadius: BorderRadius.circular(radius),
);
}

View File

@@ -4,10 +4,7 @@
library uf_info_card;
import 'package:flutter/material.dart';
import '../../tokens/color_tokens.dart';
import '../../tokens/spacing_tokens.dart';
import '../../tokens/typography_tokens.dart';
import '../../tokens/shadow_tokens.dart';
import '../../unionflow_design_system.dart';
/// Card d'information générique
///
@@ -52,13 +49,13 @@ class UFInfoCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final effectiveIconColor = iconColor ?? ColorTokens.primary;
final effectiveIconColor = iconColor ?? AppColors.primaryGreen;
final effectivePadding = padding ?? const EdgeInsets.all(SpacingTokens.xl);
return Container(
padding: effectivePadding,
decoration: BoxDecoration(
color: ColorTokens.surface,
color: Colors.white,
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
boxShadow: ShadowTokens.sm,
),
@@ -73,8 +70,8 @@ class UFInfoCard extends StatelessWidget {
Expanded(
child: Text(
title,
style: TypographyTokens.titleMedium.copyWith(
color: ColorTokens.onSurface,
style: AppTypography.headerSmall.copyWith(
color: AppColors.textPrimaryLight,
),
),
),

View File

@@ -5,8 +5,7 @@ library uf_metric_card;
import 'package:flutter/material.dart';
import '../../tokens/spacing_tokens.dart';
import '../../tokens/typography_tokens.dart';
import '../../unionflow_design_system.dart';
/// Card de métrique système
///
@@ -54,7 +53,7 @@ class UFMetricCard extends StatelessWidget {
const SizedBox(height: SpacingTokens.sm),
Text(
value,
style: TypographyTokens.labelSmall.copyWith(
style: AppTypography.badgeText.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
),
@@ -62,7 +61,7 @@ class UFMetricCard extends StatelessWidget {
),
Text(
label,
style: TypographyTokens.labelSmall.copyWith(
style: AppTypography.badgeText.copyWith(
fontSize: 9,
color: Colors.white.withOpacity(0.8),
),

View File

@@ -5,9 +5,7 @@
library uf_stat_card;
import 'package:flutter/material.dart';
import '../../tokens/color_tokens.dart';
import '../../tokens/spacing_tokens.dart';
import '../../tokens/typography_tokens.dart';
import '../../unionflow_design_system.dart';
/// Card de statistiques UnionFlow
///
@@ -57,13 +55,13 @@ class UFStatCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final effectiveIconColor = iconColor ?? ColorTokens.primary;
final effectiveIconColor = iconColor ?? AppColors.primaryGreen;
final effectiveIconBgColor = iconBackgroundColor ??
effectiveIconColor.withOpacity(0.1);
return Card(
elevation: SpacingTokens.elevationSm,
shadowColor: ColorTokens.shadow,
shadowColor: AppColors.darkBorder.withOpacity(0.1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
),
@@ -98,7 +96,7 @@ class UFStatCard extends StatelessWidget {
const Icon(
Icons.arrow_forward_ios,
size: 16,
color: ColorTokens.onSurfaceVariant,
color: AppColors.textSecondaryLight,
),
],
),
@@ -108,8 +106,8 @@ class UFStatCard extends StatelessWidget {
// Titre
Text(
title,
style: TypographyTokens.labelLarge.copyWith(
color: ColorTokens.onSurfaceVariant,
style: AppTypography.badgeText.copyWith(
color: AppColors.textSecondaryLight,
),
),
@@ -118,8 +116,8 @@ class UFStatCard extends StatelessWidget {
// Valeur
Text(
value,
style: TypographyTokens.cardValue.copyWith(
color: ColorTokens.onSurface,
style: AppTypography.headerSmall.copyWith(
color: AppColors.textPrimaryLight,
),
),
@@ -128,8 +126,8 @@ class UFStatCard extends StatelessWidget {
const SizedBox(height: SpacingTokens.sm),
Text(
subtitle!,
style: TypographyTokens.bodySmall.copyWith(
color: ColorTokens.onSurfaceVariant,
style: AppTypography.subtitleSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
],

View File

@@ -4,9 +4,7 @@
library uf_dropdown_tile;
import 'package:flutter/material.dart';
import '../../tokens/color_tokens.dart';
import '../../tokens/spacing_tokens.dart';
import '../../tokens/typography_tokens.dart';
import '../../unionflow_design_system.dart';
/// Tile de paramètre avec dropdown
///
@@ -50,7 +48,7 @@ class UFDropdownTile<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
final effectiveBgColor = backgroundColor ?? ColorTokens.surfaceVariant;
final effectiveBgColor = backgroundColor ?? AppColors.lightSurface;
final effectiveItemBuilder = itemBuilder ?? (item) => item.toString();
return Container(
@@ -65,18 +63,18 @@ class UFDropdownTile<T> extends StatelessWidget {
Expanded(
child: Text(
title,
style: TypographyTokens.bodyMedium.copyWith(
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w600,
color: ColorTokens.onSurface,
color: AppColors.textPrimaryLight,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.lg),
decoration: BoxDecoration(
color: ColorTokens.surface,
color: Colors.white,
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
border: Border.all(color: ColorTokens.outline),
border: Border.all(color: AppColors.lightBorder),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<T>(

View File

@@ -4,9 +4,7 @@
library uf_switch_tile;
import 'package:flutter/material.dart';
import '../../tokens/color_tokens.dart';
import '../../tokens/spacing_tokens.dart';
import '../../tokens/typography_tokens.dart';
import '../../unionflow_design_system.dart';
/// Tile de paramètre avec switch
///
@@ -46,7 +44,7 @@ class UFSwitchTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final effectiveBgColor = backgroundColor ?? ColorTokens.surfaceVariant;
final effectiveBgColor = backgroundColor ?? AppColors.lightSurface;
return Container(
margin: const EdgeInsets.only(bottom: SpacingTokens.lg),
@@ -63,15 +61,15 @@ class UFSwitchTile extends StatelessWidget {
children: [
Text(
title,
style: TypographyTokens.bodyMedium.copyWith(
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w600,
color: ColorTokens.onSurface,
color: AppColors.textPrimaryLight,
),
),
Text(
subtitle,
style: TypographyTokens.bodySmall.copyWith(
color: ColorTokens.onSurfaceVariant,
style: AppTypography.subtitleSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
],
@@ -80,7 +78,7 @@ class UFSwitchTile extends StatelessWidget {
Switch(
value: value,
onChanged: onChanged,
activeColor: ColorTokens.primary,
activeColor: AppColors.primaryGreen,
),
],
),

View File

@@ -3,14 +3,20 @@ import 'package:flutter/services.dart';
import '../unionflow_design_system.dart';
/// AppBar standardisé UnionFlow
///
///
/// Composant AppBar unifié pour toutes les pages de détail/formulaire.
/// Garantit la cohérence visuelle et l'expérience utilisateur.
///
/// Si [mergeLeadingWithTitle] est true et que la route peut être quittée,
/// le bouton retour et le titre sont fusionnés en une seule ligne (retour
/// toujours visible avec la même couleur que le titre).
class UFAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final List<Widget>? actions;
final Widget? leading;
final bool automaticallyImplyLeading;
/// Fusionne le bouton retour et le titre en une seule zone (retour visible, même couleur que le titre).
final bool mergeLeadingWithTitle;
final PreferredSizeWidget? bottom;
final Color? backgroundColor;
final Color? foregroundColor;
@@ -22,6 +28,7 @@ class UFAppBar extends StatelessWidget implements PreferredSizeWidget {
this.actions,
this.leading,
this.automaticallyImplyLeading = true,
this.mergeLeadingWithTitle = false,
this.bottom,
this.backgroundColor,
this.foregroundColor,
@@ -30,23 +37,59 @@ class UFAppBar extends StatelessWidget implements PreferredSizeWidget {
@override
Widget build(BuildContext context) {
final canPop = ModalRoute.of(context)?.canPop ?? false;
final fg = foregroundColor ?? Colors.white;
final useMergedTitle = mergeLeadingWithTitle && canPop;
final isTransparent = backgroundColor == Colors.transparent ||
(backgroundColor != null && backgroundColor!.opacity < 0.1);
return AppBar(
title: Text(title),
backgroundColor: backgroundColor ?? ColorTokens.primary,
foregroundColor: foregroundColor ?? ColorTokens.onPrimary,
title: useMergedTitle
? Row(
children: [
Material(
color: isTransparent ? Colors.black26 : Colors.transparent,
borderRadius: BorderRadius.circular(20),
child: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
color: fg,
tooltip: 'Retour',
style: IconButton.styleFrom(
foregroundColor: fg,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: AppTypography.headerSmall.copyWith(
color: fg,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
],
)
: Text(title),
backgroundColor: backgroundColor ?? AppColors.primaryGreen,
foregroundColor: fg,
elevation: elevation,
leading: leading,
automaticallyImplyLeading: automaticallyImplyLeading,
leading: useMergedTitle ? null : leading,
automaticallyImplyLeading: useMergedTitle ? false : automaticallyImplyLeading,
actions: actions,
bottom: bottom,
systemOverlayStyle: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light, // Icônes claires sur fond bleu
statusBarBrightness: Brightness.dark, // Pour iOS
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
),
centerTitle: false,
titleTextStyle: TypographyTokens.titleLarge.copyWith(
color: foregroundColor ?? ColorTokens.onPrimary,
titleTextStyle: AppTypography.headerSmall.copyWith(
color: fg,
fontWeight: FontWeight.w600,
),
);

View File

@@ -0,0 +1,2 @@
export 'buttons/uf_primary_button.dart';
export 'buttons/uf_secondary_button.dart';

View File

@@ -125,7 +125,7 @@ class UFContainer extends StatelessWidget {
alignment: alignment,
constraints: constraints,
decoration: BoxDecoration(
color: gradient == null ? (color ?? ColorTokens.surface) : null,
color: gradient == null ? (color ?? AppColors.lightSurface) : null,
gradient: gradient,
borderRadius: BorderRadius.circular(borderRadius),
border: border,

View File

@@ -31,7 +31,7 @@ class UFHeader extends StatelessWidget {
padding: const EdgeInsets.all(SpacingTokens.xl),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: ColorTokens.primaryGradient,
colors: [AppColors.primaryGreen, AppColors.brandGreenLight],
),
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
boxShadow: ShadowTokens.primary,
@@ -42,12 +42,12 @@ class UFHeader extends StatelessWidget {
Container(
padding: const EdgeInsets.all(SpacingTokens.sm),
decoration: BoxDecoration(
color: ColorTokens.onPrimary.withOpacity(0.2),
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
),
child: Icon(
icon,
color: ColorTokens.onPrimary,
color: Colors.white,
size: 24,
),
),
@@ -60,16 +60,16 @@ class UFHeader extends StatelessWidget {
children: [
Text(
title,
style: TypographyTokens.titleLarge.copyWith(
color: ColorTokens.onPrimary,
style: AppTypography.headerSmall.copyWith(
color: Colors.white,
),
),
if (subtitle != null) ...[
const SizedBox(height: SpacingTokens.xs),
Text(
subtitle!,
style: TypographyTokens.bodySmall.copyWith(
color: ColorTokens.onPrimary.withOpacity(0.8),
style: AppTypography.subtitleSmall.copyWith(
color: Colors.white.withOpacity(0.8),
),
),
],
@@ -94,14 +94,14 @@ class UFHeader extends StatelessWidget {
if (onNotificationTap != null)
Container(
decoration: BoxDecoration(
color: ColorTokens.onPrimary.withOpacity(0.2),
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
),
child: IconButton(
onPressed: onNotificationTap,
icon: const Icon(
Icons.notifications_outlined,
color: ColorTokens.onPrimary,
color: Colors.white,
),
),
),
@@ -110,14 +110,14 @@ class UFHeader extends StatelessWidget {
if (onSettingsTap != null)
Container(
decoration: BoxDecoration(
color: ColorTokens.onPrimary.withOpacity(0.2),
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
),
child: IconButton(
onPressed: onSettingsTap,
icon: const Icon(
Icons.settings_outlined,
color: ColorTokens.onPrimary,
color: Colors.white,
),
),
),

View File

@@ -12,7 +12,7 @@ import '../unionflow_design_system.dart';
/// title: 'Membres',
/// icon: Icons.people,
/// actions: [
/// IconButton(icon: Icon(Icons.add), onPressed: () {}),
/// IconButton(icon: Icon(Icons.add), onPressed: () => Navigator.pop(context)),
/// ],
/// )
/// ```
@@ -34,7 +34,7 @@ class UFPageHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final effectiveIconColor = iconColor ?? ColorTokens.primary;
final effectiveIconColor = iconColor ?? AppColors.primaryGreen;
return Column(
children: [
@@ -64,8 +64,8 @@ class UFPageHeader extends StatelessWidget {
Expanded(
child: Text(
title,
style: TypographyTokens.titleLarge.copyWith(
color: ColorTokens.onSurface,
style: AppTypography.headerSmall.copyWith(
color: AppColors.textPrimaryLight,
fontWeight: FontWeight.w600,
),
),
@@ -79,10 +79,10 @@ class UFPageHeader extends StatelessWidget {
// Divider optionnel
if (showDivider)
Divider(
const Divider(
height: 1,
thickness: 1,
color: ColorTokens.outline.withOpacity(0.1),
color: AppColors.lightBorder,
),
],
);
@@ -92,18 +92,6 @@ class UFPageHeader extends StatelessWidget {
/// Header de page avec statistiques
///
/// Header compact avec des métriques KPI intégrées.
///
/// Usage:
/// ```dart
/// UFPageHeaderWithStats(
/// title: 'Membres',
/// icon: Icons.people,
/// stats: [
/// UFHeaderStat(label: 'Total', value: '142'),
/// UFHeaderStat(label: 'Actifs', value: '128'),
/// ],
/// )
/// ```
class UFPageHeaderWithStats extends StatelessWidget {
final String title;
final IconData icon;
@@ -122,7 +110,7 @@ class UFPageHeaderWithStats extends StatelessWidget {
@override
Widget build(BuildContext context) {
final effectiveIconColor = iconColor ?? ColorTokens.primary;
final effectiveIconColor = iconColor ?? AppColors.primaryGreen;
return Column(
children: [
@@ -155,8 +143,8 @@ class UFPageHeaderWithStats extends StatelessWidget {
Expanded(
child: Text(
title,
style: TypographyTokens.titleLarge.copyWith(
color: ColorTokens.onSurface,
style: AppTypography.headerSmall.copyWith(
color: AppColors.textPrimaryLight,
fontWeight: FontWeight.w600,
),
),
@@ -192,37 +180,38 @@ class UFPageHeaderWithStats extends StatelessWidget {
),
// Divider
Divider(
const Divider(
height: 1,
thickness: 1,
color: ColorTokens.outline.withOpacity(0.1),
color: AppColors.lightBorder,
),
],
);
}
Widget _buildStatItem(UFHeaderStat stat) {
final effectiveColor = stat.color ?? AppColors.primaryGreen;
return UFContainer.rounded(
padding: const EdgeInsets.symmetric(
horizontal: SpacingTokens.md,
vertical: SpacingTokens.sm,
),
color: (stat.color ?? ColorTokens.primary).withOpacity(0.05),
color: effectiveColor.withOpacity(0.05),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
stat.value,
style: TypographyTokens.titleMedium.copyWith(
color: stat.color ?? ColorTokens.primary,
style: AppTypography.headerSmall.copyWith(
color: effectiveColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: SpacingTokens.xs),
Text(
stat.label,
style: TypographyTokens.labelSmall.copyWith(
color: ColorTokens.onSurfaceVariant,
style: AppTypography.badgeText.copyWith(
color: AppColors.textSecondaryLight,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import '../tokens/unionflow_colors.dart';
/// Bouton d'action rapide UnionFlow
class UnionActionButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
final Color? backgroundColor;
final Color? iconColor;
const UnionActionButton({
super.key,
required this.icon,
required this.label,
required this.onTap,
this.backgroundColor,
this.iconColor,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: backgroundColor ?? UnionFlowColors.unionGreenPale,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: (backgroundColor ?? UnionFlowColors.unionGreenPale)
.withOpacity(0.2),
width: 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 28,
color: iconColor ?? UnionFlowColors.unionGreen,
),
const SizedBox(height: 8),
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: UnionFlowColors.textPrimary,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}
/// Grid d'actions rapides
class UnionActionGrid extends StatelessWidget {
final List<UnionActionButton> actions;
const UnionActionGrid({
super.key,
required this.actions,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
for (int i = 0; i < actions.length; i++) ...[
Expanded(child: actions[i]),
if (i < actions.length - 1) const SizedBox(width: 12),
],
],
);
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import '../tokens/unionflow_colors.dart';
/// Card de balance UnionFlow - Affichage élégant du solde principal
class UnionBalanceCard extends StatelessWidget {
final String label;
final String amount;
final String? trend;
final bool? isTrendPositive;
final VoidCallback? onTap;
const UnionBalanceCard({
super.key,
required this.label,
required this.amount,
this.trend,
this.isTrendPositive,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: UnionFlowColors.softShadow,
// Bordure dorée subtile en haut
border: const Border(
top: BorderSide(
color: UnionFlowColors.gold,
width: 3,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Label
Text(
label.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: UnionFlowColors.textSecondary,
letterSpacing: 0.8,
),
),
const SizedBox(height: 8),
// Montant principal
Text(
amount,
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
color: UnionFlowColors.unionGreen,
height: 1.2,
),
),
// Trend (optionnel)
if (trend != null) ...[
const SizedBox(height: 10),
Row(
children: [
Icon(
isTrendPositive == true
? Icons.trending_up
: Icons.trending_down,
size: 16,
color: isTrendPositive == true
? UnionFlowColors.success
: UnionFlowColors.error,
),
const SizedBox(width: 4),
Text(
trend!,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isTrendPositive == true
? UnionFlowColors.success
: UnionFlowColors.error,
),
),
],
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import '../tokens/unionflow_colors.dart';
/// Type d'export disponible
enum ExportType {
pdf('PDF', Icons.picture_as_pdf),
excel('Excel', Icons.table_chart),
csv('CSV', Icons.description);
final String label;
final IconData icon;
const ExportType(this.label, this.icon);
}
/// Bouton d'export avec options
class UnionExportButton extends StatelessWidget {
final Function(ExportType) onExport;
final bool isLoading;
const UnionExportButton({
super.key,
required this.onExport,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
return PopupMenuButton<ExportType>(
icon: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
gradient: UnionFlowColors.primaryGradient,
borderRadius: BorderRadius.circular(12),
boxShadow: UnionFlowColors.greenGlowShadow,
),
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(
Icons.download,
color: Colors.white,
size: 20,
),
),
itemBuilder: (context) => ExportType.values.map((type) {
return PopupMenuItem<ExportType>(
value: type,
child: Row(
children: [
Icon(
type.icon,
size: 20,
color: UnionFlowColors.unionGreen,
),
const SizedBox(width: 12),
Text(
'Exporter en ${type.label}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: UnionFlowColors.textPrimary,
),
),
],
),
);
}).toList(),
onSelected: onExport,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 8,
color: UnionFlowColors.surface,
);
}
}
/// Dialog pour confirmer l'export
class ExportConfirmDialog extends StatelessWidget {
final ExportType exportType;
final VoidCallback onConfirm;
final String? message;
const ExportConfirmDialog({
super.key,
required this.exportType,
required this.onConfirm,
this.message,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
backgroundColor: UnionFlowColors.surface,
title: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: UnionFlowColors.unionGreen.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
exportType.icon,
color: UnionFlowColors.unionGreen,
size: 24,
),
),
const SizedBox(width: 12),
Text(
'Exporter en ${exportType.label}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: UnionFlowColors.textPrimary,
),
),
],
),
content: Text(
message ?? 'Voulez-vous exporter le rapport au format ${exportType.label}?',
style: const TextStyle(
fontSize: 14,
color: UnionFlowColors.textSecondary,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Annuler',
style: TextStyle(
color: UnionFlowColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
onConfirm();
},
style: ElevatedButton.styleFrom(
backgroundColor: UnionFlowColors.unionGreen,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: const Text(
'Confirmer',
style: TextStyle(fontWeight: FontWeight.w700),
),
),
],
);
}
}

View File

@@ -0,0 +1,65 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../tokens/unionflow_colors.dart';
/// Card avec effet glassmorphism
class UnionGlassCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double? borderRadius;
final VoidCallback? onTap;
const UnionGlassCard({
super.key,
required this.child,
this.padding,
this.margin,
this.borderRadius,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
margin: margin,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius ?? 16),
border: Border.all(
color: Colors.white.withOpacity(0.2),
width: 1.5,
),
boxShadow: [
BoxShadow(
color: UnionFlowColors.unionGreen.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(borderRadius ?? 16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
padding: padding ?? const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withOpacity(0.2),
Colors.white.withOpacity(0.1),
],
),
),
child: child,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,216 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../tokens/unionflow_colors.dart';
/// Graphique en ligne UnionFlow - Pour afficher l'évolution temporelle
class UnionLineChart extends StatelessWidget {
final List<FlSpot> spots;
final String title;
final String? subtitle;
final Color? lineColor;
final Color? gradientStartColor;
final Color? gradientEndColor;
const UnionLineChart({
super.key,
required this.spots,
required this.title,
this.subtitle,
this.lineColor,
this.gradientStartColor,
this.gradientEndColor,
});
/// Calcule maxY de manière sécurisée pour éviter NaN, Infinity ou 0
double _calculateSafeMaxY() {
if (spots.isEmpty) return 100.0;
final maxValue = spots.map((e) => e.y).reduce((a, b) => a > b ? a : b);
// Si maxValue est invalide (NaN, Infinity) ou trop petit
if (maxValue.isNaN || maxValue.isInfinite || maxValue <= 0) {
return 100.0;
}
return maxValue * 1.2;
}
/// Calcule maxX de manière sécurisée
double _calculateSafeMaxX() {
if (spots.isEmpty) return 11.0; // 12 mois - 1
return spots.length.toDouble() - 1;
}
/// Calcule l'intervalle de grille approprié basé sur maxY
double _calculateGridInterval() {
final maxY = _calculateSafeMaxY();
// Calculer un intervalle qui donne environ 4-6 lignes de grille
final baseInterval = maxY / 5;
if (baseInterval == 0) return 20.0; // Fallback si maxY est trop petit
// Arrondir à un nombre "propre" (puissance de 10)
final magnitude = pow(10.0, (log(baseInterval) / log(10.0)).floor()).toDouble();
final normalized = baseInterval / magnitude;
// Arrondir vers le haut au multiple de 1, 2 ou 5 le plus proche
double roundedInterval;
if (normalized <= 1) {
roundedInterval = 1;
} else if (normalized <= 2) {
roundedInterval = 2;
} else if (normalized <= 5) {
roundedInterval = 5;
} else {
roundedInterval = 10;
}
return roundedInterval * magnitude;
}
@override
Widget build(BuildContext context) {
final effectiveLineColor = lineColor ?? UnionFlowColors.unionGreen;
final effectiveGradientStart = gradientStartColor ?? UnionFlowColors.unionGreen.withOpacity(0.3);
final effectiveGradientEnd = gradientEndColor ?? UnionFlowColors.unionGreen.withOpacity(0.0);
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: UnionFlowColors.softShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: UnionFlowColors.textPrimary,
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: const TextStyle(
fontSize: 11,
color: UnionFlowColors.textSecondary,
),
),
],
const SizedBox(height: 20),
// Chart
SizedBox(
height: 180,
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: _calculateGridInterval(),
getDrawingHorizontalLine: (value) {
return FlLine(
color: UnionFlowColors.border.withOpacity(0.2),
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 1,
getTitlesWidget: (value, meta) {
const months = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc'];
if (value.toInt() >= 0 && value.toInt() < months.length) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
months[value.toInt()],
style: const TextStyle(
fontSize: 10,
color: UnionFlowColors.textTertiary,
),
),
);
}
return const Text('');
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
getTitlesWidget: (value, meta) {
return Text(
'${(value / 1000).toStringAsFixed(0)}K',
style: const TextStyle(
fontSize: 10,
color: UnionFlowColors.textTertiary,
),
);
},
),
),
),
borderData: FlBorderData(show: false),
minX: 0,
maxX: _calculateSafeMaxX(),
minY: 0,
maxY: _calculateSafeMaxY(),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
color: effectiveLineColor,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: UnionFlowColors.surface,
strokeWidth: 2,
strokeColor: effectiveLineColor,
);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
effectiveGradientStart,
effectiveGradientEnd,
],
),
),
),
],
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,213 @@
import 'package:flutter/material.dart';
import '../tokens/unionflow_colors.dart';
/// Badge de notification avec compteur
class UnionNotificationBadge extends StatelessWidget {
final int count;
final Widget child;
final Color? badgeColor;
final bool showZero;
const UnionNotificationBadge({
super.key,
required this.count,
required this.child,
this.badgeColor,
this.showZero = false,
});
@override
Widget build(BuildContext context) {
final shouldShow = count > 0 || showZero;
return Stack(
clipBehavior: Clip.none,
children: [
child,
if (shouldShow)
Positioned(
right: -6,
top: -6,
child: AnimatedScale(
duration: const Duration(milliseconds: 300),
scale: 1.0,
curve: Curves.elasticOut,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: badgeColor ?? UnionFlowColors.error,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: UnionFlowColors.surface,
width: 2,
),
boxShadow: [
BoxShadow(
color: (badgeColor ?? UnionFlowColors.error)
.withOpacity(0.4),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
constraints: const BoxConstraints(
minWidth: 18,
minHeight: 18,
),
child: Text(
count > 99 ? '99+' : count.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
),
),
),
],
);
}
}
/// Widget de notification en temps réel (toast)
class UnionNotificationToast extends StatelessWidget {
final String title;
final String message;
final IconData icon;
final Color color;
final VoidCallback? onTap;
const UnionNotificationToast({
super.key,
required this.title,
required this.message,
this.icon = Icons.notifications_active,
this.color = UnionFlowColors.info,
this.onTap,
});
static void show(
BuildContext context, {
required String title,
required String message,
IconData icon = Icons.notifications_active,
Color color = UnionFlowColors.info,
VoidCallback? onTap,
}) {
final overlay = Overlay.of(context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (context) => Positioned(
top: 60,
left: 16,
right: 16,
child: Material(
color: Colors.transparent,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
builder: (context, value, child) {
return Transform.translate(
offset: Offset(0, -20 * (1 - value)),
child: Opacity(
opacity: value,
child: child,
),
);
},
child: UnionNotificationToast(
title: title,
message: message,
icon: icon,
color: color,
onTap: () {
entry.remove();
onTap?.call();
},
),
),
),
),
);
overlay.insert(entry);
// Auto-dismiss après 4 secondes
Future.delayed(const Duration(seconds: 4), () {
entry.remove();
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: UnionFlowColors.mediumShadow,
border: Border(
left: BorderSide(
color: color,
width: 4,
),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 24),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: UnionFlowColors.textPrimary,
),
),
const SizedBox(height: 4),
Text(
message,
style: const TextStyle(
fontSize: 12,
color: UnionFlowColors.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
Icon(
Icons.chevron_right,
size: 18,
color: UnionFlowColors.textTertiary,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import '../tokens/unionflow_colors.dart';
/// Filtre de période pour le dashboard
enum PeriodFilter {
today('Aujourd\'hui'),
week('Cette semaine'),
month('Ce mois'),
quarter('Ce trimestre'),
year('Cette année'),
custom('Personnalisé');
final String label;
const PeriodFilter(this.label);
}
/// Widget de sélection de période
class UnionPeriodFilter extends StatelessWidget {
final PeriodFilter selectedPeriod;
final Function(PeriodFilter) onPeriodChanged;
final bool showCustom;
const UnionPeriodFilter({
super.key,
required this.selectedPeriod,
required this.onPeriodChanged,
this.showCustom = false,
});
@override
Widget build(BuildContext context) {
final periods = showCustom
? PeriodFilter.values
: PeriodFilter.values.where((p) => p != PeriodFilter.custom).toList();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: UnionFlowColors.softShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(
Icons.calendar_today,
size: 16,
color: UnionFlowColors.unionGreen,
),
SizedBox(width: 8),
Text(
'Période',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: UnionFlowColors.textPrimary,
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: periods.map((period) {
final isSelected = selectedPeriod == period;
return GestureDetector(
onTap: () => onPeriodChanged(period),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
gradient: isSelected
? UnionFlowColors.primaryGradient
: null,
color: isSelected
? null
: UnionFlowColors.surfaceVariant,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? UnionFlowColors.unionGreen
: UnionFlowColors.border,
width: isSelected ? 1.5 : 1,
),
),
child: Text(
period.label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
color: isSelected
? Colors.white
: UnionFlowColors.textSecondary,
),
),
),
);
}).toList(),
),
],
),
);
}
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../tokens/unionflow_colors.dart';
/// Graphique circulaire UnionFlow - Pour afficher des répartitions
class UnionPieChart extends StatelessWidget {
final List<PieChartSectionData> sections;
final String title;
final String? subtitle;
final double? centerSpaceRadius;
const UnionPieChart({
super.key,
required this.sections,
required this.title,
this.subtitle,
this.centerSpaceRadius,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: UnionFlowColors.softShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: UnionFlowColors.textPrimary,
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: const TextStyle(
fontSize: 11,
color: UnionFlowColors.textSecondary,
),
),
],
const SizedBox(height: 20),
// Chart
SizedBox(
height: 180,
child: PieChart(
PieChartData(
sectionsSpace: 2,
centerSpaceRadius: centerSpaceRadius ?? 50,
sections: sections,
),
),
),
],
),
);
}
}
/// Helper pour créer des sections de pie chart
class UnionPieChartSection {
static PieChartSectionData create({
required double value,
required Color color,
required String title,
double radius = 50,
bool showTitle = true,
}) {
return PieChartSectionData(
color: color,
value: value,
title: showTitle ? title : '',
radius: radius,
titleStyle: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
badgeWidget: null,
);
}
}

View File

@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import '../tokens/unionflow_colors.dart';
/// Card de progression UnionFlow avec barre de progrès élégante
class UnionProgressCard extends StatelessWidget {
final String title;
final double progress; // 0.0 à 1.0
final String subtitle;
final Color? progressColor;
final VoidCallback? onTap;
const UnionProgressCard({
super.key,
required this.title,
required this.progress,
required this.subtitle,
this.progressColor,
this.onTap,
});
@override
Widget build(BuildContext context) {
final effectiveColor = progressColor ?? UnionFlowColors.gold;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: UnionFlowColors.softShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: UnionFlowColors.textPrimary,
),
),
const SizedBox(height: 12),
// Progress bar
Stack(
children: [
// Background track
Container(
height: 14,
decoration: BoxDecoration(
color: UnionFlowColors.border,
borderRadius: BorderRadius.circular(20),
),
),
// Progress fill avec gradient
FractionallySizedBox(
widthFactor: progress.clamp(0.0, 1.0),
child: Container(
height: 14,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
effectiveColor,
effectiveColor.withOpacity(0.8),
],
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: effectiveColor.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
),
),
],
),
const SizedBox(height: 8),
// Subtitle
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: UnionFlowColors.textSecondary,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import '../tokens/unionflow_colors.dart';
/// Widget de statistique compacte avec icône et tendance
class UnionStatWidget extends StatelessWidget {
final String label;
final String value;
final IconData icon;
final Color color;
final String? trend;
final bool? isTrendUp;
const UnionStatWidget({
super.key,
required this.label,
required this.value,
required this.icon,
required this.color,
this.trend,
this.isTrendUp,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: UnionFlowColors.softShadow,
border: Border(
left: BorderSide(
color: color,
width: 4,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Icon
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 20, color: color),
),
const SizedBox(height: 12),
// Value
Text(
value,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(height: 4),
// Label
Text(
label,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: UnionFlowColors.textSecondary,
),
),
// Trend
if (trend != null) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
isTrendUp == true
? Icons.trending_up
: Icons.trending_down,
size: 14,
color: isTrendUp == true
? UnionFlowColors.success
: UnionFlowColors.error,
),
const SizedBox(width: 4),
Text(
trend!,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isTrendUp == true
? UnionFlowColors.success
: UnionFlowColors.error,
),
),
],
),
],
],
),
);
}
}

View File

@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import '../tokens/unionflow_colors.dart';
/// Tuile de transaction UnionFlow
class UnionTransactionTile extends StatelessWidget {
final String name;
final String amount;
final String status;
final String? date;
final VoidCallback? onTap;
const UnionTransactionTile({
super.key,
required this.name,
required this.amount,
required this.status,
this.date,
this.onTap,
});
Color _getStatusColor() {
switch (status.toLowerCase()) {
case 'confirmé':
case 'confirmed':
return UnionFlowColors.success;
case 'en attente':
case 'pending':
return UnionFlowColors.warning;
case 'échoué':
case 'failed':
return UnionFlowColors.error;
default:
return UnionFlowColors.textSecondary;
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(
color: UnionFlowColors.border,
width: 1,
),
),
),
child: Row(
children: [
// Avatar avec initiale
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: UnionFlowColors.primaryGradient,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
),
const SizedBox(width: 12),
// Nom et date
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: UnionFlowColors.textPrimary,
),
),
if (date != null) ...[
const SizedBox(height: 2),
Text(
date!,
style: const TextStyle(
fontSize: 11,
color: UnionFlowColors.textTertiary,
),
),
],
],
),
),
// Montant et status
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
amount,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: UnionFlowColors.unionGreen,
),
),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: _getStatusColor().withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
status,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: _getStatusColor(),
),
),
),
],
),
],
),
),
);
}
}
/// Liste de transactions dans une card
class UnionTransactionCard extends StatelessWidget {
final String title;
final List<UnionTransactionTile> transactions;
final VoidCallback? onSeeAll;
const UnionTransactionCard({
super.key,
required this.title,
required this.transactions,
this.onSeeAll,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: UnionFlowColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: UnionFlowColors.softShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: UnionFlowColors.textPrimary,
),
),
if (onSeeAll != null)
GestureDetector(
onTap: onSeeAll,
child: const Text(
'Voir tout',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: UnionFlowColors.unionGreen,
),
),
),
],
),
const SizedBox(height: 12),
// Transactions
...transactions,
],
),
);
}
}

View File

@@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import '../tokens/unionflow_colors.dart';
/// Carte premium affichant la vue unifiée "Compte Adhérent" du membre.
class UnionUnifiedAccountCard extends StatelessWidget {
final String numeroMembre;
final String organisationNom;
final String soldeTotal;
final String capaciteEmprunt;
final String epargneBloquee;
final double engagementRate; // 0.0 to 1.0
final VoidCallback? onDetailsTap;
const UnionUnifiedAccountCard({
super.key,
required this.numeroMembre,
required this.organisationNom,
required this.soldeTotal,
required this.capaciteEmprunt,
required this.epargneBloquee,
required this.engagementRate,
this.onDetailsTap,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
UnionFlowColors.unionGreen,
UnionFlowColors.unionGreen.withOpacity(0.85),
],
),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: UnionFlowColors.unionGreen.withOpacity(0.35),
offset: const Offset(0, 10),
blurRadius: 20,
),
],
),
child: Stack(
children: [
// Pattern décoratif subtil (fond)
Positioned(
right: -20,
bottom: -20,
child: Icon(
Icons.account_balance_wallet,
size: 150,
color: Colors.white.withOpacity(0.1),
),
),
Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête : Organisation + Numéro
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
organisationNom.toUpperCase(),
style: const TextStyle(
color: Colors.white70,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
const SizedBox(height: 4),
Text(
'COMPTE ADHÉRENT',
style: TextStyle(
color: Colors.white.withOpacity(0.95),
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white30),
),
child: Text(
numeroMembre,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
fontFamily: 'Courier', // Look like a card number
),
),
),
],
),
const SizedBox(height: 32),
// Solde Total Disponible
const Text(
'Solde Total Disponible',
style: TextStyle(
color: Colors.white70,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
soldeTotal,
style: const TextStyle(
color: Colors.white,
fontSize: 36,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
),
),
const SizedBox(height: 32),
// Grille de détails
Row(
children: [
_buildSubStat('Capacité Emprunt', capaciteEmprunt, Icons.rocket_launch, UnionFlowColors.gold),
const SizedBox(width: 16),
_buildSubStat('Épargne Bloquée', epargneBloquee, Icons.lock_clock, Colors.white60),
],
),
const SizedBox(height: 20),
// Barre d'engagement
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Taux d\'engagement (Cotisations)',
style: TextStyle(color: Colors.white70, fontSize: 10, fontWeight: FontWeight.w600),
),
Text(
'${(engagementRate * 100).toInt()}%',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w800),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: engagementRate,
backgroundColor: Colors.white10,
valueColor: const AlwaysStoppedAnimation<Color>(UnionFlowColors.gold),
minHeight: 6,
),
),
],
),
],
),
),
],
),
);
}
Widget _buildSubStat(String label, String value, IconData icon, Color iconColor) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 12, color: iconColor),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 10, fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}

View File

@@ -1,246 +0,0 @@
import 'package:flutter/material.dart';
/// Design System pour les dashboards avec thème bleu roi et bleu pétrole
class DashboardTheme {
// === COULEURS PRINCIPALES ===
/// Bleu roi - Couleur principale
static const Color royalBlue = Color(0xFF4169E1);
/// Bleu pétrole - Couleur secondaire
static const Color tealBlue = Color(0xFF008B8B);
/// Variations du bleu roi
static const Color royalBlueLight = Color(0xFF6495ED);
static const Color royalBlueDark = Color(0xFF191970);
/// Variations du bleu pétrole
static const Color tealBlueLight = Color(0xFF20B2AA);
static const Color tealBlueDark = Color(0xFF006666);
// === COULEURS FONCTIONNELLES ===
/// Couleurs de statut
static const Color success = Color(0xFF10B981);
static const Color warning = Color(0xFFF59E0B);
static const Color error = Color(0xFFEF4444);
static const Color info = Color(0xFF3B82F6);
/// Couleurs neutres
static const Color white = Color(0xFFFFFFFF);
static const Color grey50 = Color(0xFFF9FAFB);
static const Color grey100 = Color(0xFFF3F4F6);
static const Color grey200 = Color(0xFFE5E7EB);
static const Color grey300 = Color(0xFFD1D5DB);
static const Color grey400 = Color(0xFF9CA3AF);
static const Color grey500 = Color(0xFF6B7280);
static const Color grey600 = Color(0xFF4B5563);
static const Color grey700 = Color(0xFF374151);
static const Color grey800 = Color(0xFF1F2937);
static const Color grey900 = Color(0xFF111827);
// === GRADIENTS ===
/// Gradient principal (bleu roi vers bleu pétrole)
static const LinearGradient primaryGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [royalBlue, tealBlue],
);
/// Gradient léger pour les cartes
static const LinearGradient cardGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [royalBlueLight, tealBlueLight],
stops: [0.0, 1.0],
);
/// Gradient sombre pour les headers
static const LinearGradient headerGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [royalBlueDark, royalBlue],
);
// === OMBRES ===
/// Ombre légère pour les cartes
static const List<BoxShadow> cardShadow = [
BoxShadow(
color: Color(0x1A000000),
blurRadius: 8,
offset: Offset(0, 2),
),
];
/// Ombre plus prononcée pour les éléments flottants
static const List<BoxShadow> elevatedShadow = [
BoxShadow(
color: Color(0x1F000000),
blurRadius: 16,
offset: Offset(0, 4),
),
];
/// Ombre subtile pour les éléments délicats
static const List<BoxShadow> subtleShadow = [
BoxShadow(
color: Color(0x0A000000),
blurRadius: 4,
offset: Offset(0, 1),
),
];
// === BORDURES ===
/// Rayon de bordure standard
static const double borderRadius = 12.0;
static const double borderRadiusSmall = 8.0;
static const double borderRadiusLarge = 16.0;
/// Bordures colorées
static const BorderSide primaryBorder = BorderSide(
color: royalBlue,
width: 1.0,
);
static const BorderSide secondaryBorder = BorderSide(
color: tealBlue,
width: 1.0,
);
// === ESPACEMENTS ===
static const double spacing2 = 2.0;
static const double spacing4 = 4.0;
static const double spacing6 = 6.0;
static const double spacing8 = 8.0;
static const double spacing12 = 12.0;
static const double spacing16 = 16.0;
static const double spacing20 = 20.0;
static const double spacing24 = 24.0;
static const double spacing32 = 32.0;
static const double spacing48 = 48.0;
// === STYLES DE TEXTE ===
/// Titre principal
static const TextStyle titleLarge = TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: grey900,
height: 1.2,
);
/// Titre de section
static const TextStyle titleMedium = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: grey800,
height: 1.3,
);
/// Titre de carte
static const TextStyle titleSmall = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: grey700,
height: 1.4,
);
/// Corps de texte
static const TextStyle bodyLarge = TextStyle(
fontSize: 16,
fontWeight: FontWeight.normal,
color: grey700,
height: 1.5,
);
/// Corps de texte moyen
static const TextStyle bodyMedium = TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
color: grey600,
height: 1.4,
);
/// Petit texte
static const TextStyle bodySmall = TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
color: grey500,
height: 1.3,
);
/// Texte de métrique (gros chiffres)
static const TextStyle metricLarge = TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: royalBlue,
height: 1.1,
);
/// Texte de métrique moyen
static const TextStyle metricMedium = TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: tealBlue,
height: 1.2,
);
// === STYLES DE BOUTONS ===
/// Style de bouton principal
static ButtonStyle get primaryButtonStyle => ElevatedButton.styleFrom(
backgroundColor: royalBlue,
foregroundColor: white,
elevation: 2,
shadowColor: royalBlue.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadius),
),
padding: const EdgeInsets.symmetric(
horizontal: spacing20,
vertical: spacing12,
),
);
/// Style de bouton secondaire
static ButtonStyle get secondaryButtonStyle => OutlinedButton.styleFrom(
foregroundColor: tealBlue,
side: const BorderSide(color: tealBlue),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadius),
),
padding: const EdgeInsets.symmetric(
horizontal: spacing20,
vertical: spacing12,
),
);
// === DÉCORATION DE CONTENEURS ===
/// Décoration de carte standard
static BoxDecoration get cardDecoration => BoxDecoration(
color: white,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: cardShadow,
);
/// Décoration de carte avec gradient
static BoxDecoration get gradientCardDecoration => BoxDecoration(
gradient: cardGradient,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: cardShadow,
);
/// Décoration de header
static BoxDecoration get headerDecoration => const BoxDecoration(
gradient: headerGradient,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(borderRadiusLarge),
bottomRight: Radius.circular(borderRadiusLarge),
),
);
}

View File

@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import '../tokens/app_colors.dart';
import '../tokens/app_typography.dart';
/// UnionFlow Mobile App - Thème Global
/// Utilise la charte stricte (Vert/Blanc/Noir OLED) et force la petite typographie.
class AppTheme {
// --- THÈME CLAIR (Mode Jour) ---
static final ThemeData lightTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.light,
primaryColor: AppColors.primaryGreen,
scaffoldBackgroundColor: AppColors.lightSurface,
colorScheme: const ColorScheme.light(
primary: AppColors.primaryGreen,
secondary: AppColors.brandGreenLight,
surface: AppColors.lightBackground,
error: AppColors.error,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: AppColors.textPrimaryLight,
onError: Colors.white,
),
// Forcer la typographie standardisée
textTheme: const TextTheme(
titleMedium: AppTypography.headerSmall,
bodyMedium: AppTypography.bodyTextSmall,
bodySmall: AppTypography.subtitleSmall,
labelLarge: AppTypography.actionText,
labelSmall: AppTypography.badgeText,
),
// Personnalisation des AppBar (Garder la minimaliste)
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.lightBackground,
foregroundColor: AppColors.textPrimaryLight,
elevation: 0,
centerTitle: true,
iconTheme: IconThemeData(color: AppColors.textPrimaryLight, size: 20),
titleTextStyle: AppTypography.headerSmall,
),
// Boutons par défaut
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
elevation: 0,
textStyle: AppTypography.actionText,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
minimumSize: const Size(64, 32),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
// BottomNavigationBar ultra-compacte
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: AppColors.lightBackground,
selectedItemColor: AppColors.primaryGreen,
unselectedItemColor: AppColors.textSecondaryLight,
showSelectedLabels: false,
showUnselectedLabels: false,
elevation: 8,
type: BottomNavigationBarType.fixed,
),
dividerTheme: const DividerThemeData(
color: AppColors.lightBorder,
thickness: 1,
space: 1,
),
);
// --- THÈME SOMBRE (Mode Nuit OLED) ---
static final ThemeData darkTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
primaryColor: AppColors.primaryGreen,
scaffoldBackgroundColor: AppColors.darkBackground, // Noir OLED
colorScheme: const ColorScheme.dark(
primary: AppColors.primaryGreen,
secondary: AppColors.brandGreenLight,
surface: AppColors.darkSurface, // Gris très sombre
error: AppColors.error,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: AppColors.textPrimaryDark,
onError: Colors.white,
),
textTheme: const TextTheme(
titleMedium: AppTypography.headerSmall,
bodyMedium: AppTypography.bodyTextSmall,
bodySmall: AppTypography.subtitleSmall,
labelLarge: AppTypography.actionText,
labelSmall: AppTypography.badgeText,
).apply(
bodyColor: AppColors.textPrimaryDark,
displayColor: AppColors.textPrimaryDark,
),
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.darkBackground,
foregroundColor: AppColors.textPrimaryDark,
elevation: 0,
centerTitle: true,
iconTheme: IconThemeData(color: AppColors.textPrimaryDark, size: 20),
titleTextStyle: AppTypography.headerSmall, // Remplace titleTextStyle
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
elevation: 0,
textStyle: AppTypography.actionText,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
minimumSize: const Size(64, 32),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: AppColors.darkBackground,
selectedItemColor: AppColors.primaryGreen,
unselectedItemColor: AppColors.textSecondaryDark,
showSelectedLabels: false,
showUnselectedLabels: false,
elevation: 8,
type: BottomNavigationBarType.fixed,
),
dividerTheme: const DividerThemeData(
color: AppColors.darkBorder,
thickness: 1,
space: 1,
),
);
}

View File

@@ -85,6 +85,22 @@ class AppThemeSophisticated {
);
}
/// Thème sombre (suit le système ou sélection manuelle)
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.dark(
primary: ColorTokens.primary,
onPrimary: Colors.white,
surface: const Color(0xFF121212),
onSurface: Colors.white,
error: ColorTokens.error,
),
scaffoldBackgroundColor: const Color(0xFF121212),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// SCHÉMA DE COULEURS
// ═══════════════════════════════════════════════════════════════════════════
@@ -349,7 +365,7 @@ class AppThemeSophisticated {
contentTextStyle: TypographyTokens.bodyMedium,
);
/// Configuration des snackbars
/// Configuration des snackbars (fixed pour éviter "Floating SnackBar off screen" avec bottomNavigationBar)
static final SnackBarThemeData _snackBarTheme = SnackBarThemeData(
backgroundColor: ColorTokens.onSurface,
contentTextStyle: TypographyTokens.bodyMedium.copyWith(
@@ -358,7 +374,7 @@ class AppThemeSophisticated {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
),
behavior: SnackBarBehavior.floating,
behavior: SnackBarBehavior.fixed,
);
/// Configuration des puces

View File

@@ -1,57 +1,107 @@
/// UnionFlow Design System - Point d'entrée unique
///
/// Ce fichier centralise tous les tokens et composants du Design System UnionFlow.
/// Importer ce fichier pour accéder à tous les éléments de design.
///
/// Palette de couleurs: Bleu Roi (#4169E1) + Bleu Pétrole (#2C5F6F)
/// Basé sur Material Design 3 et les tendances UI/UX 2024-2025
///
/// Usage:
/// ```dart
/// import 'package:unionflow_mobile_apps/shared/design_system/unionflow_design_system.dart';
///
/// // Utiliser les tokens
/// Container(
/// color: ColorTokens.primary,
/// padding: EdgeInsets.all(SpacingTokens.xl),
/// child: Text(
/// 'UnionFlow',
/// style: TypographyTokens.headlineMedium,
/// ),
/// );
/// ```
library unionflow_design_system;
// ═══════════════════════════════════════════════════════════════════════════
// TOKENS - Valeurs de design fondamentales
// IMPORTS de base pour le Design System
// ═══════════════════════════════════════════════════════════════════════════
/// Tokens de couleurs (Bleu Roi + Bleu Pétrole)
export 'tokens/color_tokens.dart';
import 'package:flutter/material.dart';
import 'tokens/app_colors.dart';
import 'tokens/app_typography.dart';
import 'tokens/spacing_tokens.dart';
/// Tokens de typographie (Inter, SF Pro Display, JetBrains Mono)
export 'tokens/typography_tokens.dart';
// ═══════════════════════════════════════════════════════════════════════════
// EXPORTS - Point d'entrée unique (DRY)
// ═══════════════════════════════════════════════════════════════════════════
/// Tokens d'espacement (Grille 4px)
export 'tokens/app_colors.dart';
export 'tokens/app_typography.dart';
export 'tokens/spacing_tokens.dart';
/// Tokens de rayons de bordure
export 'tokens/radius_tokens.dart';
/// Tokens d'ombres standardisés
export 'tokens/shadow_tokens.dart';
// ═══════════════════════════════════════════════════════════════════════════
// THÈME - Configuration Material Design 3
// ═══════════════════════════════════════════════════════════════════════════
/// Thème sophistiqué (Light + Dark)
export 'theme/app_theme_sophisticated.dart';
// ═══════════════════════════════════════════════════════════════════════════
// COMPOSANTS - Widgets réutilisables
// ═══════════════════════════════════════════════════════════════════════════
/// Composants (boutons, cards, inputs, etc.)
export 'theme/app_theme.dart';
export 'components/components.dart';
// ═══════════════════════════════════════════════════════════════════════════
// COMPATIBILITÉ - Shims pour les anciens tokens (Migration progressive)
// ═══════════════════════════════════════════════════════════════════════════
/// Shim de compatibilité pour ColorTokens
class ColorTokens {
static const Color primary = AppColors.primaryGreen;
static const Color primaryContainer = AppColors.lightSurface;
static const Color onPrimary = Colors.white;
static const Color onPrimaryContainer = AppColors.textPrimaryLight;
static const Color secondary = AppColors.brandGreen;
static const Color secondaryContainer = AppColors.lightSurface;
static const Color onSecondary = Colors.white;
static const Color tertiary = AppColors.brandGreenLight;
static const Color tertiaryContainer = AppColors.lightSurface;
static const Color onTertiary = Colors.white;
static const Color surface = AppColors.lightSurface;
static const Color surfaceVariant = AppColors.lightSurface;
static const Color background = AppColors.lightBackground;
static const Color onSurface = AppColors.textPrimaryLight;
static const Color onSurfaceVariant = AppColors.textSecondaryLight;
static const Color outline = AppColors.lightBorder;
static const Color outlineVariant = AppColors.lightBorder;
static const Color error = AppColors.error;
static const Color onError = Colors.white;
static const Color success = AppColors.success;
static const Color onSuccess = Colors.white;
static const Color info = Color(0xFF2196F3);
static const Color warning = Color(0xFFFFC107);
static const Color shadow = Color(0x1A000000);
static const List<Color> primaryGradient = [
AppColors.primaryGreen,
AppColors.brandGreenLight,
];
}
/// Shim de compatibilité pour ShadowTokens
class ShadowTokens {
static const List<BoxShadow> sm = [
BoxShadow(
color: Color(0x1A000000),
blurRadius: 4,
offset: Offset(0, 2),
),
];
static const List<BoxShadow> md = [
BoxShadow(
color: Color(0x26000000),
blurRadius: 8,
offset: Offset(0, 4),
),
];
static const List<BoxShadow> primary = md;
}
/// Shim de compatibilité pour RadiusTokens
class RadiusTokens {
static const double sm = SpacingTokens.radiusSm;
static const double md = SpacingTokens.radiusMd;
static const double lg = SpacingTokens.radiusLg;
static const double xl = SpacingTokens.radiusXl;
static const double circular = SpacingTokens.radiusCircular;
static const double round = SpacingTokens.radiusCircular; // Ajouté pour compatibilité
}
/// Shim de compatibilité pour TypographyTokens
class TypographyTokens {
static const TextStyle displayLarge = AppTypography.headerSmall;
static const TextStyle displayMedium = AppTypography.headerSmall;
static const TextStyle displaySmall = AppTypography.headerSmall;
static const TextStyle headlineLarge = AppTypography.headerSmall;
static const TextStyle headlineMedium = AppTypography.headerSmall;
static const TextStyle headlineSmall = AppTypography.headerSmall;
static const TextStyle titleLarge = AppTypography.headerSmall;
static const TextStyle titleMedium = AppTypography.headerSmall;
static const TextStyle titleSmall = AppTypography.headerSmall;
static const TextStyle bodyLarge = AppTypography.bodyTextSmall;
static const TextStyle bodyMedium = AppTypography.bodyTextSmall;
static const TextStyle bodySmall = AppTypography.subtitleSmall;
static const TextStyle labelLarge = AppTypography.actionText;
static const TextStyle labelMedium = AppTypography.badgeText;
static const TextStyle labelSmall = AppTypography.badgeText;
static const TextStyle buttonLarge = AppTypography.actionText;
static const TextStyle cardValue = AppTypography.headerSmall;
}

View File

@@ -0,0 +1,28 @@
/// UnionFlow Design System V2 - Design Signature Original
/// Export centralisé de tous les composants et tokens du nouveau design system
library unionflow_design_v2;
// ═══════════════════════════════════════════════════════════════
// TOKENS
// ═══════════════════════════════════════════════════════════════
export 'tokens/unionflow_colors.dart';
// ═══════════════════════════════════════════════════════════════
// COMPOSANTS SIGNATURE
// ═══════════════════════════════════════════════════════════════
export 'components/union_balance_card.dart';
export 'components/union_progress_card.dart';
export 'components/union_action_button.dart';
export 'components/union_transaction_tile.dart';
export 'components/union_line_chart.dart';
export 'components/union_pie_chart.dart';
export 'components/union_stat_widget.dart';
export 'components/animated_fade_in.dart';
export 'components/animated_slide_in.dart';
export 'components/union_glass_card.dart';
export 'components/african_pattern_background.dart';
export 'components/union_unified_account_card.dart';
export 'components/union_period_filter.dart';
export 'components/union_export_button.dart';
export 'components/union_notification_badge.dart';

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,
),
),
),
);
}
}