feat: Version 3.3.4 - Nouvelle architecture pages, optimisations widgets Flutter et API

- Mise à jour VERSION vers 3.3.4
- Optimisations et révisions architecture API (deploy-api.sh, scripts de migration)
- Ajout documentation Stripe Tap to Pay complète
- Migration vers polices Inter Variable pour Flutter
- Optimisations build Android et nettoyage fichiers temporaires
- Amélioration système de déploiement avec gestion backups
- Ajout scripts CRON et migrations base de données

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
pierre
2025-10-05 20:11:15 +02:00
parent 2786252307
commit 570a1fa1f0
212 changed files with 24275 additions and 11321 deletions

View File

@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
import 'package:geosector_app/app.dart';
import 'dart:math' as math;
/// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
/// Scaffold partagé pour toutes les pages d'administration
/// Fournit le fond dégradé et la navigation commune
class AdminScaffold extends StatelessWidget {
/// Le contenu de la page
final Widget body;
/// L'index de navigation sélectionné
final int selectedIndex;
/// Le titre de la page
final String pageTitle;
/// Callback optionnel pour gérer la navigation personnalisée
final Function(int)? onDestinationSelected;
const AdminScaffold({
super.key,
required this.body,
required this.selectedIndex,
required this.pageTitle,
this.onDestinationSelected,
});
@override
Widget build(BuildContext context) {
final currentUser = userRepository.getCurrentUser();
final size = MediaQuery.of(context).size;
final isMobile = size.width <= 900;
return Stack(
children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox(width: double.infinity, height: double.infinity),
),
),
// Contenu de la page avec navigation
DashboardLayout(
key: ValueKey('dashboard_layout_$selectedIndex'),
title: 'Tableau de bord Administration',
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected ?? (index) {
// Navigation par défaut si pas de callback personnalisé
AdminNavigationHelper.navigateToIndex(context, index);
},
destinations: AdminNavigationHelper.getDestinations(
currentUser: currentUser,
isMobile: isMobile,
),
isAdmin: true,
body: body,
),
],
);
}
}
/// Helper pour centraliser la logique de navigation admin
class AdminNavigationHelper {
/// Obtenir la liste des destinations de navigation selon le rôle et le device
static List<NavigationDestination> getDestinations({
required dynamic currentUser,
required bool isMobile,
}) {
final destinations = <NavigationDestination>[
// Pages de base toujours visibles
const NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tableau de bord',
),
const NavigationDestination(
icon: Icon(Icons.bar_chart_outlined),
selectedIcon: Icon(Icons.bar_chart),
label: 'Statistiques',
),
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
createBadgedNavigationDestination(
icon: const Icon(Icons.chat_outlined),
selectedIcon: const Icon(Icons.chat),
label: 'Messages',
showBadge: true,
),
const NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
];
// Ajouter les pages admin (role 2) seulement sur desktop
if (currentUser?.role == 2 && !isMobile) {
destinations.addAll([
const NavigationDestination(
icon: Icon(Icons.business_outlined),
selectedIcon: Icon(Icons.business),
label: 'Amicale & membres',
),
const NavigationDestination(
icon: Icon(Icons.calendar_today_outlined),
selectedIcon: Icon(Icons.calendar_today),
label: 'Opérations',
),
]);
}
return destinations;
}
/// Naviguer vers une page selon l'index
static void navigateToIndex(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/admin');
break;
case 1:
context.go('/admin/statistics');
break;
case 2:
context.go('/admin/history');
break;
case 3:
context.go('/admin/messages');
break;
case 4:
context.go('/admin/map');
break;
case 5:
context.go('/admin/amicale');
break;
case 6:
context.go('/admin/operations');
break;
default:
context.go('/admin');
}
}
/// Obtenir l'index selon la route actuelle
static int getIndexFromRoute(String route) {
if (route.contains('/statistics')) return 1;
if (route.contains('/history')) return 2;
if (route.contains('/messages')) return 3;
if (route.contains('/map')) return 4;
if (route.contains('/amicale')) return 5;
if (route.contains('/operations')) return 6;
return 0; // Dashboard par défaut
}
/// Obtenir le nom de la page selon l'index
static String getPageNameFromIndex(int index) {
switch (index) {
case 0: return 'dashboard';
case 1: return 'statistics';
case 2: return 'history';
case 3: return 'messages';
case 4: return 'map';
case 5: return 'amicale';
case 6: return 'operations';
default: return 'dashboard';
}
}
}

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:typed_data';
import 'dart:convert';
import 'package:flutter_map/flutter_map.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
@@ -62,7 +61,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
bool _chkMdpManuel = false;
bool _chkUsernameManuel = false;
bool _chkUserDeletePass = false;
bool _chkLotActif = false;
// Pour l'upload du logo
final ImagePicker _picker = ImagePicker();
XFile? _selectedImage;
@@ -100,7 +100,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
_chkMdpManuel = amicale?.chkMdpManuel ?? false;
_chkUsernameManuel = amicale?.chkUsernameManuel ?? false;
_chkUserDeletePass = amicale?.chkUserDeletePass ?? false;
_chkLotActif = amicale?.chkLotActif ?? false;
// Note : Le logo sera chargé dynamiquement depuis l'API
// Initialiser le service Stripe si API disponible
@@ -314,6 +315,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
'chk_mdp_manuel': amicale.chkMdpManuel ? 1 : 0,
'chk_username_manuel': amicale.chkUsernameManuel ? 1 : 0,
'chk_user_delete_pass': amicale.chkUserDeletePass ? 1 : 0,
'chk_lot_actif': amicale.chkLotActif ? 1 : 0,
};
// Ajouter les champs réservés aux administrateurs si l'utilisateur est admin
@@ -564,6 +566,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
chkUserDeletePass: _chkUserDeletePass,
chkLotActif: _chkLotActif,
) ??
AmicaleModel(
id: 0, // Sera remplacé par l'API
@@ -588,6 +591,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
chkUserDeletePass: _chkUserDeletePass,
chkLotActif: _chkLotActif,
);
debugPrint('🔧 AmicaleModel créé: ${amicale.name}');
@@ -1392,6 +1396,20 @@ class _AmicaleFormState extends State<AmicaleForm> {
});
},
),
const SizedBox(height: 8),
// Checkbox pour activer le mode Lot
_buildCheckboxOption(
label: "Activer le mode Lot (distributions groupées)",
value: _chkLotActif,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkLotActif = value!;
});
},
),
const SizedBox(height: 25),
// Boutons Fermer et Enregistrer
@@ -1461,12 +1479,13 @@ class _AmicaleFormState extends State<AmicaleForm> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Note : Utilise le rôle RÉEL pour les permissions d'édition (pas le mode d'affichage)
final userRole = widget.userRepository.getUserRole();
// Déterminer si l'utilisateur peut modifier les champs restreints
// Déterminer si l'utilisateur peut modifier les champs restreints (super admin uniquement)
final bool canEditRestrictedFields = userRole > 2;
// Pour Stripe, les admins d'amicale (rôle 2) peuvent aussi configurer
// Pour Stripe, les admins d'amicale (rôle 2) et super admins peuvent configurer
final bool canEditStripe = userRole >= 2;
// Lecture seule pour les champs restreints si l'utilisateur n'a pas les droits

View File

@@ -0,0 +1,416 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/app.dart';
import 'dart:math' as math;
/// Classe pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
/// Scaffold unifié pour toutes les pages (admin et user)
/// Adapte automatiquement son apparence selon le rôle de l'utilisateur
class AppScaffold extends StatelessWidget {
/// Le contenu de la page
final Widget body;
/// L'index de navigation sélectionné
final int selectedIndex;
/// Le titre de la page
final String pageTitle;
/// Callback optionnel pour gérer la navigation personnalisée
final Function(int)? onDestinationSelected;
/// Forcer le mode admin (optionnel, sinon détecte automatiquement)
final bool? forceAdmin;
/// Afficher ou non le fond dégradé avec points (économise des ressources si désactivé)
final bool showBackground;
const AppScaffold({
super.key,
required this.body,
required this.selectedIndex,
required this.pageTitle,
this.onDestinationSelected,
this.forceAdmin,
this.showBackground = true,
});
@override
Widget build(BuildContext context) {
final currentUser = userRepository.getCurrentUser();
final size = MediaQuery.of(context).size;
final isMobile = size.width <= 900;
// Déterminer si l'utilisateur est admin (prend en compte le mode d'affichage)
final userRole = currentUser?.role ?? 1;
final isAdmin = forceAdmin ?? CurrentUserService.instance.shouldShowAdminUI;
debugPrint('🎨 AppScaffold: isAdmin=$isAdmin, displayMode=${CurrentUserService.instance.displayMode}, userRole=$userRole');
// Pour les utilisateurs standards, vérifier les conditions d'accès
if (!isAdmin) {
final hasOperation = userRepository.getCurrentOperation() != null;
final hasSectors = userRepository.getUserSectors().isNotEmpty;
// Si pas d'opération, afficher le message approprié
if (!hasOperation) {
return _buildRestrictedAccess(
context: context,
icon: Icons.warning_outlined,
title: 'Aucune opération assignée',
message: 'Vous n\'avez pas encore été affecté à une opération. '
'Veuillez contacter votre administrateur pour obtenir un accès.',
isAdmin: false,
);
}
// Si pas de secteur, afficher le message approprié
if (!hasSectors) {
return _buildRestrictedAccess(
context: context,
icon: Icons.map_outlined,
title: 'Aucun secteur assigné',
message: 'Vous n\'êtes affecté sur aucun secteur. '
'Contactez votre administrateur pour qu\'il vous en affecte au moins un.',
isAdmin: false,
);
}
}
// Couleurs de fond selon le rôle
final gradientColors = isAdmin
? [Colors.white, Colors.red.shade300] // Admin: dégradé rouge
: [Colors.white, Colors.green.shade300]; // User: dégradé vert
// Titre avec suffixe selon le rôle
final dashboardTitle = isAdmin
? 'Tableau de bord Administration'
: 'GEOSECTOR';
return Stack(
children: [
// Fond dégradé avec petits points blancs (optionnel)
if (showBackground)
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: gradientColors,
),
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox(width: double.infinity, height: double.infinity),
),
),
// Contenu de la page avec navigation
DashboardLayout(
key: ValueKey('dashboard_layout_${isAdmin ? 'admin' : 'user'}_$selectedIndex'),
title: dashboardTitle,
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected ?? (index) {
NavigationHelper.navigateToIndex(context, index, isAdmin);
},
destinations: NavigationHelper.getDestinations(
isAdmin: isAdmin,
isMobile: isMobile,
),
isAdmin: isAdmin,
body: body,
),
],
);
}
/// Construit l'écran d'accès restreint
Widget _buildRestrictedAccess({
required BuildContext context,
required IconData icon,
required String title,
required String message,
required bool isAdmin,
}) {
final theme = Theme.of(context);
// Utiliser le même fond que pour un utilisateur normal (vert)
final gradientColors = isAdmin
? [Colors.white, Colors.red.shade300]
: [Colors.white, Colors.green.shade300];
return Stack(
children: [
// Fond dégradé (optionnel)
if (showBackground)
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: gradientColors,
),
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox(width: double.infinity, height: double.infinity),
),
),
// Message d'accès restreint
DashboardLayout(
title: 'GEOSECTOR',
selectedIndex: 0,
onDestinationSelected: (index) {
// Ne rien faire car l'utilisateur ne peut pas naviguer
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.warning_outlined),
selectedIcon: Icon(Icons.warning),
label: 'Accès restreint',
),
],
isAdmin: false,
body: Center(
child: Container(
padding: const EdgeInsets.all(24),
constraints: const BoxConstraints(maxWidth: 500),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 80,
color: theme.colorScheme.error,
),
const SizedBox(height: 24),
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
message,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
],
),
),
),
),
],
);
}
}
/// Helper centralisé pour la navigation
class NavigationHelper {
/// Obtenir la liste des destinations selon le mode d'affichage et le device
static List<NavigationDestination> getDestinations({
required bool isAdmin,
required bool isMobile,
}) {
final destinations = <NavigationDestination>[];
// Pages communes à tous les rôles
destinations.addAll([
const NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tableau de bord',
),
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
const NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
createBadgedNavigationDestination(
icon: const Icon(Icons.chat_outlined),
selectedIcon: const Icon(Icons.chat),
label: 'Messages',
showBadge: true,
),
]);
// Pages spécifiques aux utilisateurs standards
if (!isAdmin) {
destinations.add(
const NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: 'Terrain',
),
);
}
// Pages spécifiques aux admins (seulement sur desktop)
if (isAdmin && !isMobile) {
destinations.addAll([
const NavigationDestination(
icon: Icon(Icons.business_outlined),
selectedIcon: Icon(Icons.business),
label: 'Amicale & membres',
),
const NavigationDestination(
icon: Icon(Icons.calendar_today_outlined),
selectedIcon: Icon(Icons.calendar_today),
label: 'Opérations',
),
]);
}
return destinations;
}
/// Naviguer vers une page selon l'index et le rôle
static void navigateToIndex(BuildContext context, int index, bool isAdmin) {
if (isAdmin) {
_navigateAdminIndex(context, index);
} else {
_navigateUserIndex(context, index);
}
}
/// Navigation pour les admins
static void _navigateAdminIndex(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/admin');
break;
case 1:
context.go('/admin/history');
break;
case 2:
context.go('/admin/map');
break;
case 3:
context.go('/admin/messages');
break;
case 4:
context.go('/admin/amicale');
break;
case 5:
context.go('/admin/operations');
break;
default:
context.go('/admin');
}
}
/// Navigation pour les utilisateurs standards
static void _navigateUserIndex(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/user/dashboard');
break;
case 1:
context.go('/user/history');
break;
case 2:
context.go('/user/map');
break;
case 3:
context.go('/user/messages');
break;
case 4:
context.go('/user/field-mode');
break;
default:
context.go('/user/dashboard');
}
}
/// Obtenir l'index selon la route actuelle et le rôle
static int getIndexFromRoute(String route, bool isAdmin) {
// Enlever les paramètres de query si présents
final cleanRoute = route.split('?').first;
if (isAdmin) {
if (cleanRoute.contains('/admin/history')) return 1;
if (cleanRoute.contains('/admin/map')) return 2;
if (cleanRoute.contains('/admin/messages')) return 3;
if (cleanRoute.contains('/admin/amicale')) return 4;
if (cleanRoute.contains('/admin/operations')) return 5;
return 0; // Dashboard par défaut
} else {
if (cleanRoute.contains('/user/history')) return 1;
if (cleanRoute.contains('/user/map')) return 2;
if (cleanRoute.contains('/user/messages')) return 3;
if (cleanRoute.contains('/user/field-mode')) return 4;
return 0; // Dashboard par défaut
}
}
/// Obtenir le nom de la page selon l'index et le rôle
static String getPageNameFromIndex(int index, bool isAdmin) {
if (isAdmin) {
switch (index) {
case 0: return 'dashboard';
case 1: return 'history';
case 2: return 'map';
case 3: return 'messages';
case 4: return 'amicale';
case 5: return 'operations';
default: return 'dashboard';
}
} else {
switch (index) {
case 0: return 'dashboard';
case 1: return 'history';
case 2: return 'map';
case 3: return 'messages';
case 4: return 'field-mode';
default: return 'dashboard';
}
}
}
}

View File

@@ -6,6 +6,7 @@ import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/user_sector_model.dart';
/// Widget de graphique d'activité affichant les passages
class ActivityChart extends StatefulWidget {
@@ -183,9 +184,15 @@ class _ActivityChartState extends State<ActivityChart>
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
// Déterminer l'utilisateur cible selon les filtres
final int? targetUserId =
widget.showAllPassages ? null : (widget.userId ?? currentUser?.id);
// Pour les users : récupérer les secteurs assignés
Set<int>? userSectorIds;
if (!widget.showAllPassages && currentUser != null) {
final userSectorBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
userSectorIds = userSectorBox.values
.where((us) => us.id == currentUser.id)
.map((us) => us.fkSector)
.toSet();
}
// Calculer la date de début (nombre de jours en arrière)
final endDate = DateTime.now();
@@ -213,8 +220,8 @@ class _ActivityChartState extends State<ActivityChart>
// Appliquer les filtres
bool shouldInclude = true;
// Filtrer par utilisateur si nécessaire
if (targetUserId != null && passage.fkUser != targetUserId) {
// Filtrer par secteurs assignés si nécessaire (pour les users)
if (userSectorIds != null && !userSectorIds.contains(passage.fkSector)) {
shouldInclude = false;
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
@@ -157,7 +158,23 @@ class _PassagePieChartState extends State<PassagePieChart>
/// Construction du widget avec des données statiques (ancien système)
Widget _buildWithStaticData() {
final chartData = _prepareChartDataFromMap(widget.passagesByType);
// Vérifier si le type Lot doit être affiché
bool showLotType = true;
final currentUser = CurrentUserService.instance.currentUser;
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
showLotType = userAmicale.chkLotActif;
}
}
// Filtrer les données pour exclure le type 5 si nécessaire
Map<int, int> filteredData = Map.from(widget.passagesByType);
if (!showLotType) {
filteredData.remove(5);
}
final chartData = _prepareChartDataFromMap(filteredData);
return _buildChart(chartData);
}
@@ -167,25 +184,38 @@ class _PassagePieChartState extends State<PassagePieChart>
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
// Vérifier si le type Lot doit être affiché
bool showLotType = true;
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
showLotType = userAmicale.chkLotActif;
}
}
// Calculer les données selon les filtres
final Map<int, int> passagesByType = {};
// Initialiser tous les types de passage possibles
for (final typeId in AppKeys.typesPassages.keys) {
// Exclure le type Lot (5) si chkLotActif = false
if (typeId == 5 && !showLotType) {
continue;
}
if (!widget.excludePassageTypes.contains(typeId)) {
passagesByType[typeId] = 0;
}
}
// L'API filtre déjà les passages côté serveur
// On compte simplement tous les passages de la box
for (final passage in passages) {
// Appliquer les filtres
// Appliquer les filtres locaux uniquement
bool shouldInclude = true;
// Filtrer par utilisateur si nécessaire
if (!widget.showAllPassages && widget.userId != null) {
// Filtrer par userId si spécifié (cas particulier pour compatibilité)
if (widget.userId != null) {
shouldInclude = passage.fkUser == widget.userId;
} else if (!widget.showAllPassages && currentUser != null) {
shouldInclude = passage.fkUser == currentUser.id;
}
// Exclure certains types
@@ -193,6 +223,11 @@ class _PassagePieChartState extends State<PassagePieChart>
shouldInclude = false;
}
// Exclure le type Lot (5) si chkLotActif = false
if (passage.fkType == 5 && !showLotType) {
shouldInclude = false;
}
if (shouldInclude) {
passagesByType[passage.fkType] =
(passagesByType[passage.fkType] ?? 0) + 1;
@@ -211,8 +246,23 @@ class _PassagePieChartState extends State<PassagePieChart>
Map<int, int> passagesByType) {
final List<PassageChartData> chartData = [];
// Vérifier si le type Lot doit être affiché
bool showLotType = true;
final currentUser = CurrentUserService.instance.currentUser;
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
showLotType = userAmicale.chkLotActif;
}
}
// Créer les données du graphique
passagesByType.forEach((typeId, count) {
// Exclure le type Lot (5) si chkLotActif = false
if (typeId == 5 && !showLotType) {
return; // Skip ce type
}
// Vérifier que le type existe et que le compteur est positif
if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) {
final typeInfo = AppKeys.typesPassages[typeId]!;

View File

@@ -75,6 +75,39 @@ class PassageSummaryCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Si useValueListenable, construire avec ValueListenableBuilder centralisé
if (useValueListenable) {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
// Calculer les données une seule fois
final passagesCounts = _calculatePassagesCounts(passagesBox);
final totalUserPassages = passagesCounts.values.fold(0, (sum, count) => sum + count);
return _buildCardContent(
context,
totalUserPassages: totalUserPassages,
passagesCounts: passagesCounts,
);
},
);
} else {
// Données statiques
final totalPassages = passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
return _buildCardContent(
context,
totalUserPassages: totalPassages,
passagesCounts: passagesByType ?? {},
);
}
}
/// Construit le contenu de la card avec les données calculées
Widget _buildCardContent(
BuildContext context, {
required int totalUserPassages,
required Map<int, int> passagesCounts,
}) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
@@ -102,9 +135,7 @@ class PassageSummaryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre avec comptage
useValueListenable
? _buildTitleWithValueListenable()
: _buildTitleWithStaticData(context),
_buildTitle(context, totalUserPassages),
const Divider(height: 24),
// Contenu principal
Expanded(
@@ -115,9 +146,7 @@ class PassageSummaryCard extends StatelessWidget {
// Liste des passages à gauche
Expanded(
flex: isDesktop ? 1 : 2,
child: useValueListenable
? _buildPassagesListWithValueListenable()
: _buildPassagesListWithStaticData(context),
child: _buildPassagesList(context, passagesCounts),
),
// Séparateur vertical
@@ -129,9 +158,10 @@ class PassageSummaryCard extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(8.0),
child: PassagePieChart(
useValueListenable: useValueListenable,
passagesByType: passagesByType ?? {},
useValueListenable: false, // Utilise les données calculées
passagesByType: passagesCounts,
excludePassageTypes: excludePassageTypes,
showAllPassages: showAllPassages,
userId: showAllPassages ? null : userId,
size: double.infinity,
labelSize: 12,
@@ -155,53 +185,8 @@ class PassageSummaryCard extends StatelessWidget {
);
}
/// Construction du titre avec ValueListenableBuilder
Widget _buildTitleWithValueListenable() {
return ValueListenableBuilder(
valueListenable:
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final totalUserPassages = _calculateUserPassagesCount(passagesBox);
return Row(
children: [
if (titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: AppTheme.r(context, 16),
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(totalUserPassages) ??
totalUserPassages.toString(),
style: TextStyle(
fontSize: AppTheme.r(context, 20),
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
},
);
}
/// Construction du titre avec données statiques
Widget _buildTitleWithStaticData(BuildContext context) {
final totalPassages =
passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
/// Construction du titre
Widget _buildTitle(BuildContext context, int totalUserPassages) {
return Row(
children: [
if (titleIcon != null) ...[
@@ -222,7 +207,8 @@ class PassageSummaryCard extends StatelessWidget {
),
),
Text(
customTotalDisplay?.call(totalPassages) ?? totalPassages.toString(),
customTotalDisplay?.call(totalUserPassages) ??
totalUserPassages.toString(),
style: TextStyle(
fontSize: AppTheme.r(context, 20),
fontWeight: FontWeight.bold,
@@ -233,30 +219,28 @@ class PassageSummaryCard extends StatelessWidget {
);
}
/// Construction de la liste des passages avec ValueListenableBuilder
Widget _buildPassagesListWithValueListenable() {
return ValueListenableBuilder(
valueListenable:
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final passagesCounts = _calculatePassagesCounts(passagesBox);
return _buildPassagesList(context, passagesCounts);
},
);
}
/// Construction de la liste des passages avec données statiques
Widget _buildPassagesListWithStaticData(BuildContext context) {
return _buildPassagesList(context, passagesByType ?? {});
}
/// Construction de la liste des passages
Widget _buildPassagesList(BuildContext context, Map<int, int> passagesCounts) {
// Vérifier si le type Lot doit être affiché
bool showLotType = true;
final currentUser = userRepository.getCurrentUser();
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
showLotType = userAmicale.chkLotActif;
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...AppKeys.typesPassages.entries.map((entry) {
...AppKeys.typesPassages.entries.where((entry) {
// Exclure le type Lot (5) si chkLotActif = false
if (entry.key == 5 && !showLotType) {
return false;
}
return true;
}).map((entry) {
final int typeId = entry.key;
final Map<String, dynamic> typeData = entry.value;
final int count = passagesCounts[typeId] ?? 0;
@@ -303,54 +287,45 @@ class PassageSummaryCard extends StatelessWidget {
);
}
/// Calcule le nombre total de passages pour l'utilisateur
int _calculateUserPassagesCount(Box<PassageModel> passagesBox) {
if (showAllPassages) {
// Pour les administrateurs : tous les passages sauf ceux exclus
return passagesBox.values
.where((passage) => !excludePassageTypes.contains(passage.fkType))
.length;
} else {
// Pour les utilisateurs : seulement leurs passages
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId == null) return 0;
return passagesBox.values
.where((passage) =>
passage.fkUser == targetUserId &&
!excludePassageTypes.contains(passage.fkType))
.length;
}
}
/// Calcule les compteurs de passages par type
Map<int, int> _calculatePassagesCounts(Box<PassageModel> passagesBox) {
final Map<int, int> counts = {};
// Vérifier si le type Lot doit être affiché
bool showLotType = true;
final currentUser = userRepository.getCurrentUser();
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
showLotType = userAmicale.chkLotActif;
}
}
// Initialiser tous les types
for (final typeId in AppKeys.typesPassages.keys) {
// Exclure le type Lot (5) si chkLotActif = false
if (typeId == 5 && !showLotType) {
continue;
}
// Exclure les types non désirés
if (excludePassageTypes.contains(typeId)) {
continue;
}
counts[typeId] = 0;
}
if (showAllPassages) {
// Pour les administrateurs : compter tous les passages
for (final passage in passagesBox.values) {
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
// L'API filtre déjà les passages côté serveur
// On compte simplement tous les passages de la box
for (final passage in passagesBox.values) {
// Exclure le type Lot (5) si chkLotActif = false
if (passage.fkType == 5 && !showLotType) {
continue;
}
} else {
// Pour les utilisateurs : compter seulement leurs passages
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId != null) {
for (final passage in passagesBox.values) {
if (passage.fkUser == targetUserId) {
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
}
}
// Exclure les types non désirés
if (excludePassageTypes.contains(passage.fkType)) {
continue;
}
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
}
return counts;

View File

@@ -163,11 +163,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
try {
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
// Déterminer l'utilisateur cible selon les filtres
final int? targetUserId = widget.showAllPassages
? null
: (widget.userId ?? currentUser?.id);
// Initialiser les montants par type de règlement
final Map<int, double> paymentAmounts = {
@@ -177,37 +172,38 @@ class _PaymentPieChartState extends State<PaymentPieChart>
3: 0.0, // CB
};
// Parcourir les passages et calculer les montants par type de règlement
// Déterminer le filtre utilisateur : en mode user, on filtre par fkUser
final int? filterUserId = widget.showAllPassages
? null
: (widget.userId ?? currentUser?.id);
for (final passage in passages) {
// Appliquer le filtre utilisateur si nécessaire
bool shouldInclude = true;
if (targetUserId != null && passage.fkUser != targetUserId) {
shouldInclude = false;
// En mode user, ne compter que les passages de l'utilisateur
if (filterUserId != null && passage.fkUser != filterUserId) {
continue;
}
if (shouldInclude) {
final int typeReglement = passage.fkTypeReglement;
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
// Gérer les formats possibles (virgule ou point)
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
debugPrint('Erreur de conversion du montant: ${passage.montant}');
}
final int typeReglement = passage.fkTypeReglement;
// Ne compter que les passages avec un montant > 0
if (montant > 0) {
// Ajouter au montant total par type de règlement
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] =
(paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
// Gérer les formats possibles (virgule ou point)
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
debugPrint('Erreur de conversion du montant: ${passage.montant}');
}
// Ne compter que les passages avec un montant > 0
if (montant > 0) {
// Ajouter au montant total par type de règlement
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] =
(paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
}
}

View File

@@ -72,6 +72,39 @@ class PaymentSummaryCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Si useValueListenable, construire avec ValueListenableBuilder centralisé
if (useValueListenable) {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
// Calculer les données une seule fois
final paymentAmounts = _calculatePaymentAmounts(passagesBox);
final totalAmount = paymentAmounts.values.fold(0.0, (sum, amount) => sum + amount);
return _buildCardContent(
context,
totalAmount: totalAmount,
paymentAmounts: paymentAmounts,
);
},
);
} else {
// Données statiques
final totalAmount = paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
return _buildCardContent(
context,
totalAmount: totalAmount,
paymentAmounts: paymentsByType ?? {},
);
}
}
/// Construit le contenu de la card avec les données calculées
Widget _buildCardContent(
BuildContext context, {
required double totalAmount,
required Map<int, double> paymentAmounts,
}) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
@@ -99,9 +132,7 @@ class PaymentSummaryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre avec comptage
useValueListenable
? _buildTitleWithValueListenable()
: _buildTitleWithStaticData(context),
_buildTitle(context, totalAmount),
const Divider(height: 24),
// Contenu principal
Expanded(
@@ -112,9 +143,7 @@ class PaymentSummaryCard extends StatelessWidget {
// Liste des règlements à gauche
Expanded(
flex: isDesktop ? 1 : 2,
child: useValueListenable
? _buildPaymentsListWithValueListenable()
: _buildPaymentsListWithStaticData(context),
child: _buildPaymentsList(context, paymentAmounts),
),
// Séparateur vertical
@@ -126,11 +155,9 @@ class PaymentSummaryCard extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(8.0),
child: PaymentPieChart(
useValueListenable: useValueListenable,
payments: useValueListenable
? []
: _convertMapToPaymentData(
paymentsByType ?? {}),
useValueListenable: false, // Utilise les données calculées
payments: _convertMapToPaymentData(paymentAmounts),
showAllPassages: showAllPayments,
userId: showAllPayments ? null : userId,
size: double.infinity,
labelSize: 12,
@@ -158,53 +185,8 @@ class PaymentSummaryCard extends StatelessWidget {
);
}
/// Construction du titre avec ValueListenableBuilder
Widget _buildTitleWithValueListenable() {
return ValueListenableBuilder(
valueListenable:
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final paymentStats = _calculatePaymentStats(passagesBox);
return Row(
children: [
if (titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: AppTheme.r(context, 16),
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(paymentStats['totalAmount']) ??
'${paymentStats['totalAmount'].toStringAsFixed(2)}',
style: TextStyle(
fontSize: AppTheme.r(context, 20),
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
},
);
}
/// Construction du titre avec données statiques
Widget _buildTitleWithStaticData(BuildContext context) {
final totalAmount =
paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
/// Construction du titre
Widget _buildTitle(BuildContext context, double totalAmount) {
return Row(
children: [
if (titleIcon != null) ...[
@@ -237,24 +219,6 @@ class PaymentSummaryCard extends StatelessWidget {
);
}
/// Construction de la liste des règlements avec ValueListenableBuilder
Widget _buildPaymentsListWithValueListenable() {
return ValueListenableBuilder(
valueListenable:
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final paymentAmounts = _calculatePaymentAmounts(passagesBox);
return _buildPaymentsList(context, paymentAmounts);
},
);
}
/// Construction de la liste des règlements avec données statiques
Widget _buildPaymentsListWithStaticData(BuildContext context) {
return _buildPaymentsList(context, paymentsByType ?? {});
}
/// Construction de la liste des règlements
Widget _buildPaymentsList(BuildContext context, Map<int, double> paymentAmounts) {
return Column(
@@ -307,70 +271,6 @@ class PaymentSummaryCard extends StatelessWidget {
);
}
/// Calcule les statistiques de règlement
Map<String, dynamic> _calculatePaymentStats(Box<PassageModel> passagesBox) {
if (showAllPayments) {
// Pour les administrateurs : tous les règlements
int passagesWithPaymentCount = 0;
double totalAmount = 0.0;
for (final passage in passagesBox.values) {
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
passagesWithPaymentCount++;
totalAmount += montant;
}
}
return {
'passagesCount': passagesWithPaymentCount,
'totalAmount': totalAmount,
};
} else {
// Pour les utilisateurs : seulement leurs règlements
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId == null) {
return {'passagesCount': 0, 'totalAmount': 0.0};
}
int passagesWithPaymentCount = 0;
double totalAmount = 0.0;
for (final passage in passagesBox.values) {
if (passage.fkUser == targetUserId) {
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
passagesWithPaymentCount++;
totalAmount += montant;
}
}
}
return {
'passagesCount': passagesWithPaymentCount,
'totalAmount': totalAmount,
};
}
}
/// Calcule les montants par type de règlement
Map<int, double> _calculatePaymentAmounts(Box<PassageModel> passagesBox) {
final Map<int, double> paymentAmounts = {};
@@ -380,57 +280,33 @@ class PaymentSummaryCard extends StatelessWidget {
paymentAmounts[typeId] = 0.0;
}
if (showAllPayments) {
// Pour les administrateurs : compter tous les règlements
for (final passage in passagesBox.values) {
final int typeReglement = passage.fkTypeReglement;
// En mode user, filtrer uniquement les passages créés par l'utilisateur (fkUser)
final currentUser = userRepository.getCurrentUser();
final int? filterUserId = showAllPayments ? null : currentUser?.id;
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] =
(paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
}
for (final passage in passagesBox.values) {
// En mode user, ne compter que les passages de l'utilisateur
if (filterUserId != null && passage.fkUser != filterUserId) {
continue;
}
} else {
// Pour les utilisateurs : compter seulement leurs règlements
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId != null) {
for (final passage in passagesBox.values) {
if (passage.fkUser == targetUserId) {
final int typeReglement = passage.fkTypeReglement;
final int typeReglement = passage.fkTypeReglement;
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] =
(paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
}
}
if (montant > 0) {
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] =
(paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
}
}

View File

@@ -1,9 +1,7 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/dashboard_app_bar.dart';
import 'package:geosector_app/presentation/widgets/responsive_navigation.dart';
import 'package:geosector_app/app.dart'; // Pour accéder à userRepository
import 'package:geosector_app/core/theme/app_theme.dart'; // Pour les couleurs du thème
import 'dart:math' as math;
/// Layout commun pour les tableaux de bord utilisateur et administrateur
/// Combine DashboardAppBar et ResponsiveNavigation
@@ -74,60 +72,33 @@ class DashboardLayout extends StatelessWidget {
);
}
// Déterminer le rôle de l'utilisateur
final currentUser = userRepository.getCurrentUser();
final userRole = currentUser?.role ?? 1;
// Définir les couleurs du gradient selon le rôle
final gradientColors = userRole > 1
? [Colors.white, Colors.red.shade300] // Admin : fond rouge
: [
Colors.white,
AppTheme.accentColor.withValues(alpha: 0.3)
]; // User : fond vert
return Stack(
children: [
// Fond dégradé avec points
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: gradientColors,
),
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox.expand(),
),
),
// Scaffold avec fond transparent
Scaffold(
backgroundColor: Colors.transparent,
appBar: DashboardAppBar(
title: title,
pageTitle: destinations[selectedIndex].label,
isAdmin: isAdmin,
onLogoutPressed: onLogoutPressed,
),
body: ResponsiveNavigation(
title:
title, // Même si le titre n'est pas affiché dans la navigation, il est utilisé pour la cohérence
body: body,
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
destinations: destinations,
// Ne pas afficher le bouton "Nouveau passage" dans la navigation
showNewPassageButton: false,
onNewPassagePressed: null,
sidebarBottomItems: sidebarBottomItems,
isAdmin: isAdmin,
// Ne pas afficher l'AppBar dans la navigation car nous utilisons DashboardAppBar
showAppBar: false,
),
),
],
// Scaffold avec fond transparent (le fond est géré par AppScaffold)
return Scaffold(
key: ValueKey('dashboard_scaffold_$selectedIndex'),
backgroundColor: Colors.transparent,
appBar: DashboardAppBar(
key: ValueKey('dashboard_appbar_$selectedIndex'),
title: title,
pageTitle: destinations[selectedIndex].label,
isAdmin: isAdmin,
onLogoutPressed: onLogoutPressed,
),
body: ResponsiveNavigation(
key: ValueKey('responsive_nav_$selectedIndex'),
title:
title, // Même si le titre n'est pas affiché dans la navigation, il est utilisé pour la cohérence
body: body,
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
destinations: destinations,
// Ne pas afficher le bouton "Nouveau passage" dans la navigation
showNewPassageButton: false,
onNewPassagePressed: null,
sidebarBottomItems: sidebarBottomItems,
isAdmin: isAdmin,
// Ne pas afficher l'AppBar dans la navigation car nous utilisons DashboardAppBar
showAppBar: false,
),
);
} catch (e) {
debugPrint('ERREUR CRITIQUE dans DashboardLayout.build: $e');
@@ -166,26 +137,3 @@ class DashboardLayout extends StatelessWidget {
}
}
}
/// CustomPainter pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@@ -1,9 +1,8 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_cache/flutter_map_cache.dart';
import 'package:http_cache_file_store/http_cache_file_store.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import 'package:path_provider/path_provider.dart';
import 'package:latlong2/latlong.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
@@ -79,8 +78,8 @@ class _MapboxMapState extends State<MapboxMap> {
// ignore: unused_field
double _currentZoom = 13.0;
/// Provider de cache pour les tuiles
CachedTileProvider? _tileProvider;
/// Provider de tuiles (peut être NetworkTileProvider ou CachedTileProvider)
TileProvider? _tileProvider;
/// Indique si le cache est initialisé
bool _cacheInitialized = false;
@@ -96,18 +95,31 @@ class _MapboxMapState extends State<MapboxMap> {
/// Initialise le cache des tuiles
Future<void> _initializeCache() async {
try {
if (kIsWeb) {
// Pas de cache sur Web (non supporté)
setState(() {
_cacheInitialized = true;
});
return;
}
final dir = await getTemporaryDirectory();
// Utiliser un nom de cache différent selon le provider
final cacheName = widget.useOpenStreetMap ? 'OSMTileCache' : 'MapboxTileCache';
final cacheStore = FileCacheStore('${dir.path}${Platform.pathSeparator}$cacheName');
_tileProvider = CachedTileProvider(
store: cacheStore,
// Configuration du cache
// maxStale permet de servir des tuiles expirées jusqu'à 30 jours
maxStale: const Duration(days: 30),
final cacheDir = '${dir.path}/map_tiles_cache';
// Initialiser le HiveCacheStore
final cacheStore = HiveCacheStore(
cacheDir,
hiveBoxName: 'mapTilesCache',
);
// Initialiser le CachedTileProvider
_tileProvider = CachedTileProvider(
maxStale: const Duration(days: 30),
store: cacheStore,
);
debugPrint('MapboxMap: Cache initialisé dans $cacheDir');
if (mounted) {
setState(() {
_cacheInitialized = true;
@@ -238,6 +250,8 @@ class _MapboxMapState extends State<MapboxMap> {
options: MapOptions(
initialCenter: widget.initialPosition,
initialZoom: widget.initialZoom,
minZoom: 7.0, // Zoom minimum pour éviter que les tuiles ne se chargent pas
maxZoom: 20.0, // Zoom maximum
interactionOptions: InteractionOptions(
enableMultiFingerGestureRace: true,
flags: widget.disableDrag
@@ -265,22 +279,21 @@ class _MapboxMapState extends State<MapboxMap> {
userAgentPackageName: 'app.geosector.fr',
maxNativeZoom: 19,
maxZoom: 20,
minZoom: 1,
// Retirer tileSize pour utiliser la valeur par défaut
// Les additionalOptions ne sont pas nécessaires car le token est dans l'URL
// Utilise le cache si disponible sur web, NetworkTileProvider sur mobile
tileProvider: _cacheInitialized && _tileProvider != null
minZoom: 7,
// Utiliser le cache sur mobile, NetworkTileProvider sur Web
tileProvider: !kIsWeb && _cacheInitialized && _tileProvider != null
? _tileProvider!
: NetworkTileProvider(
headers: {
'User-Agent': 'geosector_app/3.1.3',
'User-Agent': 'geosector_app/3.3.1',
'Accept': '*/*',
},
),
errorTileCallback: (tile, error, stackTrace) {
debugPrint('MapboxMap: Erreur de chargement de tuile: $error');
debugPrint('MapboxMap: Coordonnées de la tuile: ${tile.coordinates}');
debugPrint('MapboxMap: Stack trace: $stackTrace');
// Réduire les logs d'erreur pour ne pas polluer la console
if (!error.toString().contains('abortTrigger')) {
debugPrint('MapboxMap: Erreur de chargement de tuile: $error');
}
},
),

View File

@@ -0,0 +1,899 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/repositories/operation_repository.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/app.dart';
/// Widget affichant un tableau détaillé des membres avec leurs statistiques de passages
/// Uniquement visible sur plateforme Web
class MembersBoardPassages extends StatefulWidget {
final String title;
final double? height;
const MembersBoardPassages({
super.key,
this.title = 'Détails par membre',
this.height,
});
@override
State<MembersBoardPassages> createState() => _MembersBoardPassagesState();
}
class _MembersBoardPassagesState extends State<MembersBoardPassages> {
// Repository pour récupérer l'opération courante uniquement
final OperationRepository _operationRepository = operationRepository;
// Vérifier si le type Lot doit être affiché
bool _shouldShowLotType() {
final currentUser = CurrentUserService.instance.currentUser;
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
return userAmicale.chkLotActif;
}
}
return true; // Par défaut, on affiche
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
constraints: BoxConstraints(
maxHeight: widget.height ?? 700, // Hauteur max, sinon s'adapte au contenu
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête de la card
Container(
padding: const EdgeInsets.all(AppTheme.spacingM),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.05),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppTheme.borderRadiusMedium),
topRight: Radius.circular(AppTheme.borderRadiusMedium),
),
),
child: Row(
children: [
Icon(
Icons.people_outline,
color: theme.colorScheme.primary,
size: 24,
),
const SizedBox(width: AppTheme.spacingS),
Text(
widget.title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
],
),
),
// Corps avec le tableau
Expanded(
child: ValueListenableBuilder<Box<MembreModel>>(
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
builder: (context, membresBox, child) {
final membres = membresBox.values.toList();
// Récupérer l'opération courante
final currentOperation = _operationRepository.getCurrentOperation();
if (currentOperation == null) {
return const Center(
child: Padding(
padding: EdgeInsets.all(AppTheme.spacingL),
child: Text('Aucune opération en cours'),
),
);
}
// Trier les membres par nom
membres.sort((a, b) {
final nameA = '${a.firstName ?? ''} ${a.name ?? ''}'.trim();
final nameB = '${b.firstName ?? ''} ${b.name ?? ''}'.trim();
return nameA.compareTo(nameB);
});
// Construire les lignes : TOTAL en première position + détails membres
final allRows = [
_buildTotalRow(membres, currentOperation.id, theme),
..._buildRows(membres, currentOperation.id, theme),
];
// Utilise seulement le scroll vertical, le tableau s'adapte à la largeur
return SingleChildScrollView(
scrollDirection: Axis.vertical,
child: SizedBox(
width: double.infinity, // Prendre toute la largeur disponible
child: DataTable(
columnSpacing: 4, // Espacement minimal entre colonnes
horizontalMargin: 4, // Marges horizontales minimales
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
dataRowMinHeight: 42,
dataRowMaxHeight: 42,
headingRowColor: WidgetStateProperty.all(
theme.colorScheme.primary.withValues(alpha: 0.08),
),
columns: _buildColumns(theme),
rows: allRows,
),
),
);
},
),
),
],
),
);
}
/// Construit les colonnes du tableau
List<DataColumn> _buildColumns(ThemeData theme) {
// Utilise le thème pour une meilleure lisibilité
final headerStyle = theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
) ?? const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
);
final showLotType = _shouldShowLotType();
final columns = [
// Nom
DataColumn(
label: Expanded(
child: Text('Nom', style: headerStyle),
),
),
// Total
DataColumn(
label: Expanded(
child: Center(
child: Text('Total', style: headerStyle),
),
),
numeric: true,
),
// Effectués
DataColumn(
label: Expanded(
child: Container(
color: Colors.green.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text('Effectués', style: headerStyle),
),
),
numeric: true,
),
// Montant moyen
DataColumn(
label: Expanded(
child: Center(
child: Text('Moy./passage', style: headerStyle),
),
),
numeric: true,
),
// À finaliser
DataColumn(
label: Expanded(
child: Container(
color: Colors.orange.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text('À finaliser', style: headerStyle),
),
),
numeric: true,
),
// Refusés
DataColumn(
label: Expanded(
child: Container(
color: Colors.red.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text('Refusés', style: headerStyle),
),
),
numeric: true,
),
// Dons
DataColumn(
label: Expanded(
child: Container(
color: Colors.lightBlue.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text('Dons', style: headerStyle),
),
),
numeric: true,
),
// Lots - affiché seulement si chkLotActif = true
if (showLotType)
DataColumn(
label: Expanded(
child: Container(
color: Colors.blue.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text('Lots', style: headerStyle),
),
),
numeric: true,
),
// Vides
DataColumn(
label: Expanded(
child: Container(
color: Colors.grey.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text('Vides', style: headerStyle),
),
),
numeric: true,
),
// Taux d'avancement
DataColumn(
label: Expanded(
child: Center(
child: Text('Avancement', style: headerStyle),
),
),
),
// Secteurs
DataColumn(
label: Expanded(
child: Center(
child: Text('Secteurs', style: headerStyle),
),
),
numeric: true,
),
];
return columns;
}
/// Construit la ligne de totaux
DataRow _buildTotalRow(List<MembreModel> membres, int operationId, ThemeData theme) {
final showLotType = _shouldShowLotType();
// Récupérer directement depuis les boxes Hive (déjà ouvertes)
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final allPassages = passageBox.values.where((p) => p.fkOperation == operationId).toList();
// Calculer les totaux globaux
int totalCount = allPassages.length;
int effectueCount = 0;
double effectueMontant = 0.0;
int aFinaliserCount = 0;
int refuseCount = 0;
int donCount = 0;
int lotsCount = 0;
double lotsMontant = 0.0;
int videCount = 0;
for (final passage in allPassages) {
switch (passage.fkType) {
case 1: // Effectué
effectueCount++;
if (passage.montant.isNotEmpty) {
effectueMontant += double.tryParse(passage.montant) ?? 0.0;
}
break;
case 2: // À finaliser
aFinaliserCount++;
break;
case 3: // Refusé
refuseCount++;
break;
case 4: // Don
donCount++;
break;
case 5: // Lots
if (showLotType) {
lotsCount++;
if (passage.montant.isNotEmpty) {
lotsMontant += double.tryParse(passage.montant) ?? 0.0;
}
}
break;
case 6: // Vide
videCount++;
break;
}
}
// Calculer le montant moyen global
double montantMoyen = effectueCount > 0 ? effectueMontant / effectueCount : 0.0;
// Compter les secteurs uniques
final Set<int> uniqueSectorIds = {};
for (final passage in allPassages) {
if (passage.fkSector != null) {
uniqueSectorIds.add(passage.fkSector!);
}
}
final sectorCount = uniqueSectorIds.length;
// Calculer le taux d'avancement global
double tauxAvancement = 0.0;
if (sectorCount > 0 && membres.isNotEmpty) {
tauxAvancement = effectueCount / (sectorCount * membres.length);
if (tauxAvancement > 1) tauxAvancement = 1.0;
}
return DataRow(
color: WidgetStateProperty.all(theme.colorScheme.primary.withValues(alpha: 0.15)),
cells: [
// Nom
DataCell(
Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'TOTAL',
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 16,
) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
),
// Total
DataCell(
Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
totalCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
// Effectués
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.green.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
effectueCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
'(${effectueMontant.toStringAsFixed(2)}€)',
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
),
],
),
),
),
// Montant moyen
DataCell(
Center(
child: Text(
montantMoyen > 0 ? '${montantMoyen.toStringAsFixed(2)}' : '-',
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
// À finaliser
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.orange.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text(
aFinaliserCount.toString(),
style: (theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14))
.copyWith(fontWeight: FontWeight.bold, fontStyle: FontStyle.italic),
),
),
),
// Refusés
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.red.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text(
refuseCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
// Dons
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.lightBlue.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text(
donCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
// Lots - affiché seulement si chkLotActif = true
if (showLotType)
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.blue.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
lotsCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
'(${lotsMontant.toStringAsFixed(2)}€)',
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
),
],
),
),
),
// Vides
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.grey.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text(
videCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
// Taux d'avancement
DataCell(
SizedBox(
width: 100,
child: Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: tauxAvancement,
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.blue.shade600,
),
),
),
const SizedBox(width: 8),
Text(
'${(tauxAvancement * 100).toInt()}%',
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
],
),
),
),
// Secteurs
DataCell(
Center(
child: Text(
sectorCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
],
);
}
/// Construit les lignes du tableau
List<DataRow> _buildRows(List<MembreModel> membres, int operationId, ThemeData theme) {
final List<DataRow> rows = [];
final showLotType = _shouldShowLotType();
// Récupérer directement depuis les boxes Hive (déjà ouvertes)
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final allPassages = passageBox.values.where((p) => p.fkOperation == operationId).toList();
// Récupérer tous les secteurs directement depuis la box
final sectorBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
final allSectors = sectorBox.values.toList();
for (int index = 0; index < membres.length; index++) {
final membre = membres[index];
final isEvenRow = index % 2 == 0;
// Récupérer les passages du membre
final memberPassages = allPassages.where((p) => p.fkUser == membre.id).toList();
// Calculer les statistiques par type
int totalCount = memberPassages.length;
int effectueCount = 0;
double effectueMontant = 0.0;
int aFinaliserCount = 0;
int refuseCount = 0;
int donCount = 0;
int lotsCount = 0;
double lotsMontant = 0.0;
int videCount = 0;
for (final passage in memberPassages) {
switch (passage.fkType) {
case 1: // Effectué
effectueCount++;
if (passage.montant.isNotEmpty) {
effectueMontant += double.tryParse(passage.montant) ?? 0.0;
}
break;
case 2: // À finaliser
aFinaliserCount++;
break;
case 3: // Refusé
refuseCount++;
break;
case 4: // Don
donCount++;
break;
case 5: // Lots
if (showLotType) { // Compter seulement si Lots est activé
lotsCount++;
if (passage.montant.isNotEmpty) {
lotsMontant += double.tryParse(passage.montant) ?? 0.0;
}
}
break;
case 6: // Vide
videCount++;
break;
}
}
// Calculer le montant moyen
double montantMoyen = effectueCount > 0 ? effectueMontant / effectueCount : 0.0;
// Récupérer les secteurs uniques du membre via ses passages
final Set<int> memberSectorIds = {};
for (final passage in memberPassages) {
if (passage.fkSector != null) {
memberSectorIds.add(passage.fkSector!);
}
}
final sectorCount = memberSectorIds.length;
final memberSectors = allSectors.where((s) => memberSectorIds.contains(s.id)).toList();
// Calculer le taux d'avancement (passages effectués / secteurs attribués)
double tauxAvancement = 0.0;
bool hasWarning = false;
if (sectorCount > 0) {
// On considère que chaque secteur devrait avoir au moins un passage effectué
tauxAvancement = effectueCount / sectorCount;
if (tauxAvancement > 1) tauxAvancement = 1.0; // Limiter à 100%
hasWarning = tauxAvancement < 0.5; // Avertissement si moins de 50%
} else {
hasWarning = true; // Avertissement si aucun secteur attribué
}
rows.add(
DataRow(
color: WidgetStateProperty.all(
isEvenRow ? Colors.white : Colors.grey.shade50,
),
cells: [
// Nom - Cliquable pour naviguer vers l'historique avec le membre sélectionné
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
debugPrint('MembersBoardPassages: Clic sur membre ${membre.id}');
// Naviguer directement vers la page history avec memberId
debugPrint('MembersBoardPassages: Navigation vers /admin/history?memberId=${membre.id}');
if (mounted) {
context.go('/admin/history?memberId=${membre.id}');
}
},
child: Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
_buildMemberDisplayName(membre),
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600) ??
const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
),
),
),
),
// Total - Cliquable pour naviguer vers l'historique avec le membre sélectionné
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
debugPrint('MembersBoardPassages: Clic sur membre ${membre.id}');
// Naviguer directement vers la page history avec memberId
debugPrint('MembersBoardPassages: Navigation vers /admin/history?memberId=${membre.id}');
if (mounted) {
context.go('/admin/history?memberId=${membre.id}');
}
},
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
totalCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
),
),
// Effectués
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.green.withValues(alpha: 0.1),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
effectueCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
'(${effectueMontant.toStringAsFixed(2)}€)',
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
),
],
),
),
),
// Montant moyen
DataCell(Center(child: Text(
montantMoyen > 0 ? '${montantMoyen.toStringAsFixed(2)}' : '-',
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
))),
// À finaliser
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.orange.withValues(alpha: 0.1),
alignment: Alignment.center,
child: Text(
aFinaliserCount.toString(),
style: (theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14))
.copyWith(fontStyle: FontStyle.italic),
),
),
),
// Refusés
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.red.withValues(alpha: 0.1),
alignment: Alignment.center,
child: Text(
refuseCount.toString(),
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
),
),
),
// Dons
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.lightBlue.withValues(alpha: 0.1),
alignment: Alignment.center,
child: Text(
donCount.toString(),
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
),
),
),
// Lots - affiché seulement si chkLotActif = true
if (showLotType)
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.blue.withValues(alpha: 0.1),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
lotsCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
'(${lotsMontant.toStringAsFixed(2)}€)',
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
),
],
),
),
),
// Vides
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.grey.withValues(alpha: 0.1),
alignment: Alignment.center,
child: Text(
videCount.toString(),
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
),
),
),
// Taux d'avancement
DataCell(
SizedBox(
width: 100,
child: Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: tauxAvancement,
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation<Color>(
hasWarning ? Colors.red.shade400 : Colors.green.shade400,
),
),
),
const SizedBox(width: 8),
if (hasWarning)
Icon(
Icons.warning,
color: Colors.red.shade400,
size: 16,
)
else
Text(
'${(tauxAvancement * 100).toInt()}%',
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
],
),
),
),
// Secteurs
DataCell(
Row(
children: [
if (sectorCount == 0)
Icon(
Icons.warning,
color: Colors.red.shade400,
size: 16,
),
const SizedBox(width: 4),
Text(
sectorCount.toString(),
style: TextStyle(
fontSize: theme.textTheme.bodyMedium?.fontSize ?? 14,
fontWeight: sectorCount > 0 ? FontWeight.bold : FontWeight.normal,
color: sectorCount > 0 ? Colors.green.shade700 : Colors.red.shade700,
),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.map_outlined, size: 16),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
_showMemberSectorsDialog(context, membre, memberSectors.toList());
},
),
],
),
),
],
),
);
}
return rows;
}
/// Construit le nom d'affichage d'un membre avec son sectName si disponible
String _buildMemberDisplayName(MembreModel membre) {
String displayName = '${membre.firstName ?? ''} ${membre.name ?? ''}'.trim();
// Ajouter le sectName entre parenthèses s'il existe
if (membre.sectName != null && membre.sectName!.isNotEmpty) {
displayName += ' (${membre.sectName})';
}
return displayName;
}
/// Affiche un dialogue avec les secteurs du membre
void _showMemberSectorsDialog(BuildContext context, MembreModel membre, List<SectorModel> memberSectors) {
final theme = Theme.of(context);
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Secteurs de ${membre.firstName} ${membre.name}'),
content: SizedBox(
width: 400,
child: memberSectors.isEmpty
? const Text('Aucun secteur attribué')
: ListView.builder(
shrinkWrap: true,
itemCount: memberSectors.length,
itemBuilder: (context, index) {
final sector = memberSectors[index];
return ListTile(
leading: Icon(
Icons.map,
color: theme.colorScheme.primary,
),
title: Text(sector.libelle),
subtitle: Text('Secteur #${sector.id}'),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
);
},
);
}
}

View File

@@ -1,10 +1,17 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/repositories/operation_repository.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/core/services/stripe_tap_to_pay_service.dart';
import 'package:geosector_app/core/services/device_info_service.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
@@ -17,6 +24,7 @@ class PassageFormDialog extends StatefulWidget {
final PassageRepository passageRepository;
final UserRepository userRepository;
final OperationRepository operationRepository;
final AmicaleRepository amicaleRepository;
final VoidCallback? onSuccess;
const PassageFormDialog({
@@ -27,6 +35,7 @@ class PassageFormDialog extends StatefulWidget {
required this.passageRepository,
required this.userRepository,
required this.operationRepository,
required this.amicaleRepository,
this.onSuccess,
});
@@ -63,6 +72,12 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
int _fkTypeReglement = 4; // Par défaut Non renseigné
DateTime _passedAt = DateTime.now(); // Date et heure de passage
// Variable pour Tap to Pay
String? _stripePaymentIntentId;
// Boîte Hive pour mémoriser la dernière adresse
late Box _settingsBox;
// Helpers de validation
String? _validateNumero(String? value) {
if (value == null || value.trim().isEmpty) {
@@ -93,9 +108,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
String? _validateNomOccupant(String? value) {
if (_selectedPassageType == 1) {
// Le nom est obligatoire uniquement si un email est renseigné
final emailValue = _emailController.text.trim();
if (emailValue.isNotEmpty) {
if (value == null || value.trim().isEmpty) {
return 'Le nom est obligatoire pour les passages effectués';
return 'Le nom est obligatoire si un email est renseigné';
}
if (value.trim().length < 2) {
return 'Le nom doit contenir au moins 2 caractères';
@@ -138,6 +155,9 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
try {
debugPrint('=== DEBUT PassageFormDialog.initState ===');
// Accéder à la settingsBox (déjà ouverte dans l'app)
_settingsBox = Hive.box(AppKeys.settingsBoxName);
// Initialize controllers with passage data if available
final passage = widget.passage;
debugPrint('Passage reçu: ${passage != null}');
@@ -166,10 +186,10 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
debugPrint('Initialisation des controllers...');
// S'assurer que toutes les valeurs null deviennent des chaînes vides
final String numero = passage?.numero.toString() ?? '';
final String rueBis = passage?.rueBis.toString() ?? '';
final String rue = passage?.rue.toString() ?? '';
final String ville = passage?.ville.toString() ?? '';
String numero = passage?.numero.toString() ?? '';
String rueBis = passage?.rueBis.toString() ?? '';
String rue = passage?.rue.toString() ?? '';
String ville = passage?.ville.toString() ?? '';
final String name = passage?.name.toString() ?? '';
final String email = passage?.email.toString() ?? '';
final String phone = passage?.phone.toString() ?? '';
@@ -179,11 +199,26 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
(montantRaw == '0.00' || montantRaw == '0' || montantRaw == '0.0')
? ''
: montantRaw;
final String appt = passage?.appt.toString() ?? '';
final String niveau = passage?.niveau.toString() ?? '';
final String residence = passage?.residence.toString() ?? '';
String appt = passage?.appt.toString() ?? '';
String niveau = passage?.niveau.toString() ?? '';
String residence = passage?.residence.toString() ?? '';
final String remarque = passage?.remarque.toString() ?? '';
// Si nouveau passage, charger les valeurs mémorisées de la dernière adresse
if (passage == null) {
debugPrint('Nouveau passage: chargement des valeurs mémorisées...');
numero = _settingsBox.get('lastPassageNumero', defaultValue: '') as String;
rueBis = _settingsBox.get('lastPassageRueBis', defaultValue: '') as String;
rue = _settingsBox.get('lastPassageRue', defaultValue: '') as String;
ville = _settingsBox.get('lastPassageVille', defaultValue: '') as String;
residence = _settingsBox.get('lastPassageResidence', defaultValue: '') as String;
_fkHabitat = _settingsBox.get('lastPassageFkHabitat', defaultValue: 1) as int;
appt = _settingsBox.get('lastPassageAppt', defaultValue: '') as String;
niveau = _settingsBox.get('lastPassageNiveau', defaultValue: '') as String;
debugPrint('Valeurs chargées: numero="$numero", rue="$rue", ville="$ville"');
}
// Initialiser la date de passage
_passedAt = passage?.passedAt ?? DateTime.now();
final String dateFormatted =
@@ -220,6 +255,16 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
_dateController = TextEditingController(text: dateFormatted);
_timeController = TextEditingController(text: timeFormatted);
// Ajouter un listener sur le champ email pour mettre à jour la validation du nom
_emailController.addListener(() {
// Force la revalidation du formulaire quand l'email change
if (mounted) {
setState(() {
// Cela va déclencher un rebuild et mettre à jour l'indicateur isRequired
});
}
});
debugPrint('=== FIN PassageFormDialog.initState ===');
} catch (e, stackTrace) {
debugPrint('=== ERREUR PassageFormDialog.initState ===');
@@ -284,6 +329,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
return;
}
// Toujours sauvegarder le passage en premier
await _savePassage();
}
Future<void> _savePassage() async {
setState(() {
_isSubmitting = true;
});
@@ -314,6 +364,23 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
finalTypeReglement = 4;
}
// Déterminer la valeur de nbPassages selon le type de passage
final int finalNbPassages;
if (widget.passage != null) {
// Modification d'un passage existant
if (_selectedPassageType == 2) {
// Type 2 (À finaliser) : toujours incrémenter
finalNbPassages = widget.passage!.nbPassages + 1;
} else {
// Autres types : mettre à 1 si actuellement 0, sinon conserver
final currentNbPassages = widget.passage!.nbPassages;
finalNbPassages = currentNbPassages == 0 ? 1 : currentNbPassages;
}
} else {
// Nouveau passage : toujours 1
finalNbPassages = 1;
}
final passageData = widget.passage?.copyWith(
fkType: _selectedPassageType!,
numero: _numeroController.text.trim(),
@@ -330,7 +397,9 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
residence: _residenceController.text.trim(),
remarque: _remarqueController.text.trim(),
fkTypeReglement: finalTypeReglement,
nbPassages: finalNbPassages,
passedAt: _passedAt,
stripePaymentId: _stripePaymentIntentId,
lastSyncedAt: DateTime.now(),
) ??
PassageModel(
@@ -356,43 +425,127 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
montant: finalMontant,
fkTypeReglement: finalTypeReglement,
emailErreur: '',
nbPassages: 1,
nbPassages: finalNbPassages,
name: _nameController.text.trim(),
email: _emailController.text.trim(),
phone: _phoneController.text.trim(),
stripePaymentId: _stripePaymentIntentId,
lastSyncedAt: DateTime.now(),
isActive: true,
isSynced: false,
);
final success = widget.passage == null
? await widget.passageRepository.createPassage(passageData)
: await widget.passageRepository.updatePassage(passageData);
// Sauvegarder le passage d'abord
PassageModel? savedPassage;
if (widget.passage == null) {
// Création d'un nouveau passage
savedPassage = await widget.passageRepository.createPassageWithReturn(passageData);
} else {
// Mise à jour d'un passage existant
final success = await widget.passageRepository.updatePassage(passageData);
if (success) {
savedPassage = passageData;
}
}
if (success && mounted) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
ApiException.showSuccess(
context,
widget.passage == null
? "Nouveau passage créé avec succès"
: "Passage modifié avec succès",
);
if (savedPassage == null) {
throw Exception(widget.passage == null
? "Échec de la création du passage"
: "Échec de la mise à jour du passage");
}
// Mémoriser l'adresse pour la prochaine création de passage
await _saveLastPassageAddress();
// Vérifier si paiement CB nécessaire APRÈS la sauvegarde
if (finalTypeReglement == 3 &&
(_selectedPassageType == 1 || _selectedPassageType == 5)) {
final montant = double.tryParse(finalMontant.replaceAll(',', '.')) ?? 0;
if (montant > 0 && mounted) {
// Vérifier si le device supporte Tap to Pay
if (DeviceInfoService.instance.canUseTapToPay()) {
// Lancer le flow Tap to Pay avec l'ID du passage sauvegardé
final paymentSuccess = await _attemptTapToPayWithPassage(savedPassage, montant);
if (!paymentSuccess) {
// Si le paiement échoue, on pourrait marquer le passage comme "À finaliser"
// ou le supprimer selon la logique métier
debugPrint('⚠️ Paiement échoué pour le passage ${savedPassage.id}');
// Optionnel : mettre à jour le passage en type "À finaliser" (7)
}
} else {
// Le device ne supporte pas Tap to Pay (Web ou device non compatible)
if (mounted) {
// Déterminer le message d'avertissement approprié
String warningMessage;
if (kIsWeb) {
warningMessage = "Passage enregistré avec succès.\n\n Note : Le paiement sans contact n'est pas disponible sur navigateur web. Utilisez l'application mobile native pour cette fonctionnalité.";
} else {
// Vérifier pourquoi le device n'est pas compatible
final deviceInfo = DeviceInfoService.instance.getStoredDeviceInfo();
final nfcCapable = deviceInfo['device_nfc_capable'] == true;
final stripeCertified = deviceInfo['device_stripe_certified'] == true;
final batteryLevel = deviceInfo['battery_level'] as int?;
final platform = deviceInfo['platform'];
if (!nfcCapable) {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre appareil n'a pas de NFC activé ou disponible pour les paiements sans contact.";
} else if (!stripeCertified) {
if (platform == 'iOS') {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre iPhone n'est pas compatible. Tap to Pay nécessite un iPhone XS ou plus récent avec iOS 16.4+.";
} else {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre appareil Android n'est pas certifié par Stripe pour les paiements sans contact en France.";
}
} else if (batteryLevel != null && batteryLevel < 10) {
warningMessage = "Passage enregistré avec succès.\n\n Note : Batterie trop faible ($batteryLevel%). Minimum 10% requis pour les paiements sans contact.";
} else {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre appareil ne peut pas utiliser le paiement sans contact actuellement.";
}
}
});
// Fermer le dialog et afficher le message de succès avec avertissement
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
// Afficher un SnackBar orange pour l'avertissement
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(warningMessage),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 5),
),
);
}
});
}
});
}
}
});
} else if (mounted) {
ApiException.showError(
context,
Exception(widget.passage == null
? "Échec de la création du passage"
: "Échec de la mise à jour du passage"),
);
}
} else {
// Pas de paiement CB, fermer le dialog avec succès
if (mounted) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
ApiException.showSuccess(
context,
widget.passage == null
? "Nouveau passage créé avec succès"
: "Passage modifié avec succès",
);
}
});
}
});
}
}
} catch (e) {
if (mounted) {
@@ -407,9 +560,47 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
}
/// Mémoriser l'adresse du passage pour la prochaine création
Future<void> _saveLastPassageAddress() async {
try {
await _settingsBox.put('lastPassageNumero', _numeroController.text.trim());
await _settingsBox.put('lastPassageRueBis', _rueBisController.text.trim());
await _settingsBox.put('lastPassageRue', _rueController.text.trim());
await _settingsBox.put('lastPassageVille', _villeController.text.trim());
await _settingsBox.put('lastPassageResidence', _residenceController.text.trim());
await _settingsBox.put('lastPassageFkHabitat', _fkHabitat);
await _settingsBox.put('lastPassageAppt', _apptController.text.trim());
await _settingsBox.put('lastPassageNiveau', _niveauController.text.trim());
debugPrint('✅ Adresse mémorisée pour la prochaine création de passage');
} catch (e) {
debugPrint('⚠️ Erreur lors de la mémorisation de l\'adresse: $e');
}
}
Widget _buildPassageTypeSelection() {
final theme = Theme.of(context);
// Récupérer l'amicale de l'utilisateur pour vérifier chkLotActif
final currentUser = CurrentUserService.instance.currentUser;
bool showLotType = true; // Par défaut, on affiche le type Lot
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = widget.amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
// Si chkLotActif = false (0), on ne doit pas afficher le type Lot (5)
showLotType = userAmicale.chkLotActif;
debugPrint('Amicale ${userAmicale.name}: chkLotActif = $showLotType');
}
}
// Filtrer les types de passages en fonction de chkLotActif
final filteredTypes = Map<int, Map<String, dynamic>>.from(AppKeys.typesPassages);
if (!showLotType) {
filteredTypes.remove(5); // Retirer le type "Lot" si chkLotActif = 0
debugPrint('Type Lot (5) masqué car chkLotActif = false');
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -431,11 +622,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: AppKeys.typesPassages.length,
itemCount: filteredTypes.length,
itemBuilder: (context, index) {
try {
final typeId = AppKeys.typesPassages.keys.elementAt(index);
final typeData = AppKeys.typesPassages[typeId];
final typeId = filteredTypes.keys.elementAt(index);
final typeData = filteredTypes[typeId];
if (typeData == null) {
debugPrint('ERREUR: typeData null pour typeId: $typeId');
@@ -523,35 +714,62 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
title: 'Date et Heure de passage',
icon: Icons.schedule,
children: [
Row(
children: [
Expanded(
child: CustomTextField(
controller: _dateController,
label: "Date",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "DD/MM/YYYY",
suffixIcon: const Icon(Icons.calendar_today),
onTap: widget.readOnly ? null : _selectDate,
// Layout responsive : 1 ligne desktop, 2 lignes mobile
_isMobile(context)
? Column(
children: [
CustomTextField(
controller: _dateController,
label: "Date",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "DD/MM/YYYY",
suffixIcon: const Icon(Icons.calendar_today),
onTap: widget.readOnly ? null : _selectDate,
),
const SizedBox(height: 16),
CustomTextField(
controller: _timeController,
label: "Heure",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "HH:MM",
suffixIcon: const Icon(Icons.access_time),
onTap: widget.readOnly ? null : _selectTime,
),
],
)
: Row(
children: [
Expanded(
child: CustomTextField(
controller: _dateController,
label: "Date",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "DD/MM/YYYY",
suffixIcon: const Icon(Icons.calendar_today),
onTap: widget.readOnly ? null : _selectDate,
),
),
const SizedBox(width: 12),
Expanded(
child: CustomTextField(
controller: _timeController,
label: "Heure",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "HH:MM",
suffixIcon: const Icon(Icons.access_time),
onTap: widget.readOnly ? null : _selectTime,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: CustomTextField(
controller: _timeController,
label: "Heure",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "HH:MM",
suffixIcon: const Icon(Icons.access_time),
onTap: widget.readOnly ? null : _selectTime,
),
),
],
),
],
),
const SizedBox(height: 24),
@@ -619,11 +837,12 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
Row(
children: [
Expanded(
// TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+)
child: RadioListTile<int>(
title: const Text('Maison'),
value: 1,
groupValue: _fkHabitat,
onChanged: widget.readOnly
groupValue: _fkHabitat, // ignore: deprecated_member_use
onChanged: widget.readOnly // ignore: deprecated_member_use
? null
: (value) {
setState(() {
@@ -637,8 +856,8 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
child: RadioListTile<int>(
title: const Text('Appart'),
value: 2,
groupValue: _fkHabitat,
onChanged: widget.readOnly
groupValue: _fkHabitat, // ignore: deprecated_member_use
onChanged: widget.readOnly // ignore: deprecated_member_use
? null
: (value) {
setState(() {
@@ -705,41 +924,63 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
children: [
CustomTextField(
controller: _nameController,
label: _selectedPassageType == 1
? "Nom de l'occupant"
: "Nom de l'occupant",
isRequired: _selectedPassageType == 1,
label: "Nom de l'occupant",
isRequired: _emailController.text.trim().isNotEmpty,
showLabel: false,
readOnly: widget.readOnly,
validator: _validateNomOccupant,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: CustomTextField(
controller: _emailController,
label: "Email",
showLabel: false,
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
validator: _validateEmail,
prefixIcon: Icons.email,
// Layout responsive : 1 ligne desktop, 2 lignes mobile
_isMobile(context)
? Column(
children: [
CustomTextField(
controller: _emailController,
label: "Email",
showLabel: false,
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
validator: _validateEmail,
prefixIcon: Icons.email,
),
const SizedBox(height: 16),
CustomTextField(
controller: _phoneController,
label: "Téléphone",
showLabel: false,
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
prefixIcon: Icons.phone,
),
],
)
: Row(
children: [
Expanded(
child: CustomTextField(
controller: _emailController,
label: "Email",
showLabel: false,
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
validator: _validateEmail,
prefixIcon: Icons.email,
),
),
const SizedBox(width: 12),
Expanded(
child: CustomTextField(
controller: _phoneController,
label: "Téléphone",
showLabel: false,
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
prefixIcon: Icons.phone,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: CustomTextField(
controller: _phoneController,
label: "Téléphone",
showLabel: false,
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
prefixIcon: Icons.phone,
),
),
],
),
],
),
const SizedBox(height: 24),
@@ -1140,6 +1381,65 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
);
}
/// Tente d'effectuer un paiement Tap to Pay avec un passage déjà sauvegardé
Future<bool> _attemptTapToPayWithPassage(PassageModel passage, double montant) async {
try {
// Afficher le dialog de paiement avec l'ID réel du passage
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => _TapToPayFlowDialog(
amount: montant,
passageId: passage.id, // ID réel du passage sauvegardé
onSuccess: (paymentIntentId) {
// Mettre à jour le passage avec le stripe_payment_id
final updatedPassage = passage.copyWith(
stripePaymentId: paymentIntentId,
);
// Envoyer la mise à jour à l'API (sera fait de manière asynchrone)
widget.passageRepository.updatePassage(updatedPassage).then((_) {
debugPrint('✅ Passage mis à jour avec stripe_payment_id: $paymentIntentId');
}).catchError((error) {
debugPrint('❌ Erreur mise à jour passage: $error');
});
setState(() {
_stripePaymentIntentId = paymentIntentId;
});
},
),
);
// Si paiement réussi, afficher le message de succès et fermer
if (result == true && mounted) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
ApiException.showSuccess(
context,
"Paiement effectué avec succès",
);
}
});
}
});
return true;
}
return false;
} catch (e) {
debugPrint('Erreur Tap to Pay: $e');
if (mounted) {
ApiException.showError(context, e);
}
return false;
}
}
@override
Widget build(BuildContext context) {
try {
@@ -1228,3 +1528,340 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
}
}
/// Dialog pour gérer le flow de paiement Tap to Pay
class _TapToPayFlowDialog extends StatefulWidget {
final double amount;
final int passageId;
final void Function(String paymentIntentId)? onSuccess;
const _TapToPayFlowDialog({
required this.amount,
required this.passageId,
this.onSuccess,
});
@override
State<_TapToPayFlowDialog> createState() => _TapToPayFlowDialogState();
}
class _TapToPayFlowDialogState extends State<_TapToPayFlowDialog> {
String _currentState = 'confirming';
String? _paymentIntentId;
String? _errorMessage;
StreamSubscription<TapToPayStatus>? _statusSubscription;
@override
void initState() {
super.initState();
_listenToPaymentStatus();
}
@override
void dispose() {
_statusSubscription?.cancel();
super.dispose();
}
void _listenToPaymentStatus() {
_statusSubscription = StripeTapToPayService.instance.paymentStatusStream.listen(
(status) {
if (!mounted) return;
setState(() {
switch (status.type) {
case TapToPayStatusType.ready:
_currentState = 'ready';
break;
case TapToPayStatusType.awaitingTap:
_currentState = 'awaiting_tap';
break;
case TapToPayStatusType.processing:
_currentState = 'processing';
break;
case TapToPayStatusType.confirming:
_currentState = 'confirming';
break;
case TapToPayStatusType.success:
_currentState = 'success';
_paymentIntentId = status.paymentIntentId;
_handleSuccess();
break;
case TapToPayStatusType.error:
_currentState = 'error';
_errorMessage = status.message;
break;
case TapToPayStatusType.cancelled:
Navigator.pop(context, false);
break;
}
});
},
);
}
void _handleSuccess() {
if (_paymentIntentId != null) {
widget.onSuccess?.call(_paymentIntentId!);
// Attendre un peu pour montrer le succès
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
Navigator.pop(context, true);
}
});
}
}
Future<void> _startPayment() async {
setState(() {
_currentState = 'initializing';
_errorMessage = null;
});
try {
// Initialiser le service si nécessaire
if (!StripeTapToPayService.instance.isInitialized) {
final initialized = await StripeTapToPayService.instance.initialize();
if (!initialized) {
throw Exception('Impossible d\'initialiser Tap to Pay');
}
}
// Vérifier que le service est prêt
if (!StripeTapToPayService.instance.isReadyForPayments()) {
throw Exception('L\'appareil n\'est pas prêt pour les paiements');
}
// Créer le PaymentIntent avec l'ID du passage dans les metadata
final paymentIntent = await StripeTapToPayService.instance.createPaymentIntent(
amountInCents: (widget.amount * 100).round(),
description: 'Calendrier pompiers${widget.passageId > 0 ? " - Passage #${widget.passageId}" : ""}',
metadata: {
'type': 'tap_to_pay',
'passage_id': widget.passageId.toString(),
'amicale_id': CurrentAmicaleService.instance.amicaleId.toString(),
'member_id': CurrentUserService.instance.userId.toString(),
},
);
if (paymentIntent == null) {
throw Exception('Impossible de créer le paiement');
}
_paymentIntentId = paymentIntent.paymentIntentId;
// Collecter le paiement
final collected = await StripeTapToPayService.instance.collectPayment(paymentIntent);
if (!collected) {
throw Exception('Échec de la collecte du paiement');
}
// Confirmer le paiement
final confirmed = await StripeTapToPayService.instance.confirmPayment(paymentIntent);
if (!confirmed) {
throw Exception('Échec de la confirmation du paiement');
}
} catch (e) {
setState(() {
_currentState = 'error';
_errorMessage = e.toString();
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Widget content;
List<Widget> actions = [];
switch (_currentState) {
case 'confirming':
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.contactless, size: 64, color: theme.colorScheme.primary),
const SizedBox(height: 24),
Text(
'Paiement par carte sans contact',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 16),
Text(
'Montant: ${widget.amount.toStringAsFixed(2)}',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 24),
const Text(
'Le client va payer par carte bancaire sans contact.\n'
'Son téléphone ou sa carte sera présenté(e) sur cet appareil.',
textAlign: TextAlign.center,
),
],
);
actions = [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
ElevatedButton.icon(
onPressed: _startPayment,
icon: const Icon(Icons.payment),
label: const Text('Lancer le paiement'),
),
];
break;
case 'initializing':
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 24),
Text(
'Initialisation du terminal...',
style: theme.textTheme.titleMedium,
),
],
);
break;
case 'awaiting_tap':
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.tap_and_play,
size: 80,
color: theme.colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'Présentez la carte',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 16),
const LinearProgressIndicator(),
const SizedBox(height: 16),
Text(
'Montant: ${widget.amount.toStringAsFixed(2)}',
style: theme.textTheme.titleMedium,
),
],
);
actions = [
TextButton(
onPressed: () {
if (_paymentIntentId != null) {
StripeTapToPayService.instance.cancelPayment(_paymentIntentId!);
}
Navigator.pop(context, false);
},
child: const Text('Annuler'),
),
];
break;
case 'processing':
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 24),
Text(
'Traitement du paiement...',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text('Ne pas retirer la carte'),
],
);
break;
case 'success':
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.check_circle,
size: 80,
color: Colors.green,
),
const SizedBox(height: 24),
Text(
'Paiement réussi !',
style: theme.textTheme.headlineSmall?.copyWith(
color: Colors.green,
),
),
const SizedBox(height: 16),
Text(
'${widget.amount.toStringAsFixed(2)}€ payé par carte',
style: theme.textTheme.titleMedium,
),
],
);
break;
case 'error':
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.error_outline,
size: 80,
color: Colors.red,
),
const SizedBox(height: 24),
Text(
'Échec du paiement',
style: theme.textTheme.headlineSmall?.copyWith(
color: Colors.red,
),
),
const SizedBox(height: 16),
Text(
_errorMessage ?? 'Une erreur est survenue',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
],
);
actions = [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
ElevatedButton.icon(
onPressed: _startPayment,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
];
break;
default:
content = const Center(child: CircularProgressIndicator());
}
return AlertDialog(
title: Row(
children: [
Icon(Icons.contactless, color: theme.colorScheme.primary),
const SizedBox(width: 8),
const Text('Paiement sans contact'),
],
),
content: Container(
constraints: const BoxConstraints(maxWidth: 400),
child: content,
),
actions: actions.isEmpty ? null : actions,
);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
import 'package:geosector_app/app.dart';
class PassageMapDialog extends StatelessWidget {
@@ -24,78 +25,14 @@ class PassageMapDialog extends StatelessWidget {
// Récupérer le type de passage
final String typePassage =
AppKeys.typesPassages[type]?['titre'] ?? 'Inconnu';
// Utiliser couleur2 pour le badge (couleur1 peut être blanche pour type 2)
final Color typeColor =
Color(AppKeys.typesPassages[type]?['couleur1'] ?? 0xFF9E9E9E);
Color(AppKeys.typesPassages[type]?['couleur2'] ?? 0xFF9E9E9E);
// Construire l'adresse complète
final String adresse =
'${passage.numero} ${passage.rueBis} ${passage.rue}'.trim();
// Informations sur l'étage, l'appartement et la résidence (si habitat = 2)
String? etageInfo;
String? apptInfo;
String? residenceInfo;
if (passage.fkHabitat == 2) {
if (passage.niveau.isNotEmpty) {
etageInfo = 'Étage ${passage.niveau}';
}
if (passage.appt.isNotEmpty) {
apptInfo = 'Appt. ${passage.appt}';
}
if (passage.residence.isNotEmpty) {
residenceInfo = passage.residence;
}
}
// Formater la date (uniquement si le type n'est pas 2 et si la date existe)
String? dateInfo;
if (type != 2 && passage.passedAt != null) {
final date = passage.passedAt!;
dateInfo =
'${_formatDate(date)} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}';
}
// Récupérer le nom du passage (si le type n'est pas 6 - Maison vide)
String? nomInfo;
if (type != 6 && passage.name.isNotEmpty) {
nomInfo = passage.name;
}
// Récupérer les informations de règlement si le type est 1 (Effectué) ou 5 (Lot)
Widget? reglementInfo;
if ((type == 1 || type == 5) && passage.fkTypeReglement > 0) {
final int typeReglementId = passage.fkTypeReglement;
final String montant = passage.montant;
// Récupérer les informations du type de règlement
if (AppKeys.typesReglements.containsKey(typeReglementId)) {
final Map<String, dynamic> typeReglement =
AppKeys.typesReglements[typeReglementId]!;
final String titre = typeReglement['titre'] as String;
final Color couleur = Color(typeReglement['couleur'] as int);
final IconData iconData = typeReglement['icon_data'] as IconData;
reglementInfo = Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
color: couleur.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: couleur.withValues(alpha: 0.3)),
),
child: Row(
children: [
Icon(iconData, color: couleur, size: 20),
const SizedBox(width: 8),
Text('$titre: $montant',
style:
TextStyle(color: couleur, fontWeight: FontWeight.bold)),
],
),
);
}
}
// Vérifier si l'utilisateur peut supprimer (admin ou user avec permission)
bool canDelete = isAdmin;
if (!isAdmin) {
@@ -122,93 +59,39 @@ class PassageMapDialog extends StatelessWidget {
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Passage #${passage.id}',
style: const TextStyle(fontSize: 18),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: typeColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
typePassage,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: typeColor,
),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Afficher en premier si le passage n'est pas affecté à un secteur
if (passage.fkSector == null) ...[
Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
border: Border.all(color: Colors.red, width: 1),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
const Icon(Icons.warning, color: Colors.red, size: 20),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Ce passage n\'est plus affecté à un secteur',
style: TextStyle(
color: Colors.red, fontWeight: FontWeight.bold),
),
),
],
),
),
],
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Adresse
_buildInfoRow(Icons.location_on, 'Adresse',
adresse.isEmpty ? 'Non renseignée' : adresse),
// Adresse
_buildInfoRow(Icons.location_on, 'Adresse',
adresse.isEmpty ? 'Non renseignée' : adresse),
// Résidence
if (residenceInfo != null)
_buildInfoRow(Icons.apartment, 'Résidence', residenceInfo),
// Étage et appartement
if (etageInfo != null || apptInfo != null)
_buildInfoRow(Icons.stairs, 'Localisation',
[etageInfo, apptInfo].where((e) => e != null).join(' - ')),
// Date
if (dateInfo != null)
_buildInfoRow(Icons.calendar_today, 'Date', dateInfo),
// Nom
if (nomInfo != null) _buildInfoRow(Icons.person, 'Nom', nomInfo),
// Ville
if (passage.ville.isNotEmpty)
_buildInfoRow(Icons.location_city, 'Ville', passage.ville),
// Remarque
if (passage.remarque.isNotEmpty)
_buildInfoRow(Icons.note, 'Remarque', passage.remarque),
// Règlement
if (reglementInfo != null) reglementInfo,
],
),
// Ville
if (passage.ville.isNotEmpty)
_buildInfoRow(Icons.location_city, 'Ville', passage.ville),
],
),
actions: [
// Bouton de modification
TextButton.icon(
onPressed: () {
Navigator.of(context).pop();
_showEditDialog(context);
},
icon: const Icon(Icons.edit, size: 20),
label: const Text('Modifier'),
style: TextButton.styleFrom(
foregroundColor: Colors.blue,
),
),
// Bouton de suppression si autorisé
if (canDelete)
TextButton.icon(
@@ -259,9 +142,25 @@ class PassageMapDialog extends StatelessWidget {
);
}
// Formater une date
String _formatDate(DateTime date) {
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
// Afficher le dialog de modification
void _showEditDialog(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return PassageFormDialog(
passage: passage,
title: 'Modifier le passage',
passageRepository: passageRepository,
userRepository: userRepository,
operationRepository: operationRepository,
amicaleRepository: amicaleRepository,
onSuccess: () {
// Appeler le callback si fourni pour rafraîchir l'affichage
onDeleted?.call();
},
);
},
);
}
// Afficher le dialog de confirmation de suppression

View File

@@ -336,10 +336,11 @@ class _PassageFormState extends State<PassageForm> {
return Row(
children: [
// TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+)
Radio<String>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
groupValue: groupValue, // ignore: deprecated_member_use
onChanged: onChanged, // ignore: deprecated_member_use
activeColor: const Color(0xFF20335E),
),
Text(

File diff suppressed because it is too large Load Diff

View File

@@ -138,6 +138,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
child: Container(
color: Colors
.transparent, // Fond transparent pour voir l'AdminBackground
alignment: Alignment.topCenter, // Aligner le contenu en haut
child: widget.body,
),
),

View File

@@ -305,8 +305,8 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
final bool hasPassages = count > 0;
final textColor = hasPassages ? Colors.black87 : Colors.grey;
// Vérifier si l'utilisateur est admin
final bool isAdmin = CurrentUserService.instance.canAccessAdmin;
// Vérifier si l'utilisateur est admin (prend en compte le mode d'affichage)
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
return Padding(
padding: const EdgeInsets.only(bottom: AppTheme.spacingM),
@@ -420,8 +420,8 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
? Color(typeInfo['couleur2'] as int)
: Colors.grey;
// Vérifier si l'utilisateur est admin pour les clics
final bool isAdmin = CurrentUserService.instance.canAccessAdmin;
// Vérifier si l'utilisateur est admin pour les clics (prend en compte le mode d'affichage)
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
return Expanded(
flex: count,

View File

@@ -5,6 +5,7 @@ import 'dart:math';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'custom_text_field.dart';
class UserForm extends StatefulWidget {
@@ -50,10 +51,13 @@ class _UserFormState extends State<UserForm> {
int _fkTitre = 1; // 1 = M., 2 = Mme
DateTime? _dateNaissance;
DateTime? _dateEmbauche;
// Pour la génération automatique d'username
bool _isGeneratingUsername = false;
final Random _random = Random();
// Pour détecter la modification du username
String? _initialUsername;
// Pour afficher/masquer le mot de passe
bool _obscurePassword = true;
@@ -72,6 +76,9 @@ class _UserFormState extends State<UserForm> {
_mobileController = TextEditingController(text: user?.mobile ?? '');
_emailController = TextEditingController(text: user?.email ?? '');
// Stocker le username initial pour détecter les modifications
_initialUsername = user?.username;
_dateNaissance = user?.dateNaissance;
_dateEmbauche = user?.dateEmbauche;
@@ -373,80 +380,6 @@ class _UserFormState extends State<UserForm> {
return null;
}
// Générer un mot de passe selon les normes NIST (phrases de passe recommandées)
String _generatePassword() {
// Listes de mots pour créer des phrases de passe mémorables
final sujets = [
'Mon chat', 'Le chien', 'Ma voiture', 'Mon vélo', 'La maison',
'Mon jardin', 'Le soleil', 'La lune', 'Mon café', 'Le train',
'Ma pizza', 'Le gâteau', 'Mon livre', 'La musique', 'Mon film'
];
final noms = [
'Félix', 'Max', 'Luna', 'Bella', 'Charlie', 'Rocky', 'Maya',
'Oscar', 'Ruby', 'Leo', 'Emma', 'Jack', 'Sophie', 'Milo', 'Zoé'
];
final verbes = [
'aime', 'mange', 'court', 'saute', 'danse', 'chante', 'joue',
'dort', 'rêve', 'vole', 'nage', 'lit', 'écrit', 'peint', 'cuisine'
];
final complements = [
'dans le jardin', 'sous la pluie', 'avec joie', 'très vite', 'tout le temps',
'en été', 'le matin', 'la nuit', 'au soleil', 'dans la neige',
'sur la plage', 'à Paris', 'en vacances', 'avec passion', 'doucement'
];
// Choisir un type de phrase aléatoirement
final typePhrase = _random.nextInt(3);
String phrase;
switch (typePhrase) {
case 0:
// Type: Sujet + nom propre + verbe + complément
final sujet = sujets[_random.nextInt(sujets.length)];
final nom = noms[_random.nextInt(noms.length)];
final verbe = verbes[_random.nextInt(verbes.length)];
final complement = complements[_random.nextInt(complements.length)];
phrase = '$sujet $nom $verbe $complement';
break;
case 1:
// Type: Nom propre + a + nombre + ans + point d'exclamation
final nom = noms[_random.nextInt(noms.length)];
final age = 1 + _random.nextInt(20);
phrase = '$nom a $age ans!';
break;
default:
// Type: Sujet + verbe + nombre + complément
final sujet = sujets[_random.nextInt(sujets.length)];
final verbe = verbes[_random.nextInt(verbes.length)];
final nombre = 1 + _random.nextInt(100);
final complement = complements[_random.nextInt(complements.length)];
phrase = '$sujet $verbe $nombre fois $complement';
}
// Ajouter éventuellement un caractère spécial à la fin
if (_random.nextBool()) {
final speciaux = ['!', '?', '.', '...', '', '', '', ''];
phrase += speciaux[_random.nextInt(speciaux.length)];
}
// S'assurer que la phrase fait au moins 8 caractères (elle le sera presque toujours)
if (phrase.length < 8) {
phrase += ' ${1000 + _random.nextInt(9000)}';
}
// Tronquer si trop long (max 64 caractères selon NIST)
if (phrase.length > 64) {
phrase = phrase.substring(0, 64);
}
return phrase;
}
// Méthode publique pour récupérer le mot de passe si défini
String? getPassword() {
@@ -489,6 +422,93 @@ class _UserFormState extends State<UserForm> {
return null;
}
// Méthode asynchrone pour valider et récupérer l'utilisateur avec vérification du username
Future<UserModel?> validateAndGetUserAsync(BuildContext context) async {
if (!_formKey.currentState!.validate()) {
return null;
}
// Déterminer si on doit afficher le champ username selon les règles
final bool shouldShowUsernameField = widget.isAdmin && widget.amicale?.chkUsernameManuel == true;
// Déterminer si le username est éditable
final bool canEditUsername = shouldShowUsernameField && widget.allowUsernameEdit;
// Vérifier si le username a été modifié (seulement en mode édition)
final currentUsername = _usernameController.text;
final bool isUsernameModified = widget.user?.id != 0 && // Mode édition
_initialUsername != null &&
_initialUsername != currentUsername &&
canEditUsername;
// Si le username a été modifié, vérifier sa disponibilité
if (isUsernameModified) {
try {
final result = await _checkUsernameAvailability(currentUsername);
if (result['available'] != true) {
// Afficher l'erreur
if (context.mounted) {
ApiException.showError(
context,
Exception(result['message'] ?? 'Ce nom d\'utilisateur est déjà utilisé')
);
// Si des suggestions sont disponibles, les afficher
if (result['suggestions'] != null && (result['suggestions'] as List).isNotEmpty) {
final suggestions = (result['suggestions'] as List).take(3).join(', ');
if (context.mounted) {
ApiException.showError(
context,
Exception('Suggestions disponibles : $suggestions')
);
}
}
}
return null; // Bloquer la soumission
}
} catch (e) {
// En cas d'erreur réseau ou autre
if (context.mounted) {
ApiException.showError(
context,
Exception('Impossible de vérifier la disponibilité du nom d\'utilisateur')
);
}
return null;
}
}
// Si tout est OK, retourner l'utilisateur
return widget.user?.copyWith(
username: _usernameController.text, // NIST: ne pas faire de trim sur username
firstName: _firstNameController.text.trim(),
name: _nameController.text.trim(),
sectName: _sectNameController.text.trim(),
phone: _phoneController.text.trim(),
mobile: _mobileController.text.trim(),
email: _emailController.text.trim(),
fkTitre: _fkTitre,
dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche,
) ??
UserModel(
id: 0,
username: _usernameController.text, // NIST: ne pas faire de trim sur username
firstName: _firstNameController.text.trim(),
name: _nameController.text.trim(),
sectName: _sectNameController.text.trim(),
phone: _phoneController.text.trim(),
mobile: _mobileController.text.trim(),
email: _emailController.text.trim(),
fkTitre: _fkTitre,
dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche,
role: 1,
createdAt: DateTime.now(),
lastSyncedAt: DateTime.now(),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -496,8 +516,8 @@ class _UserFormState extends State<UserForm> {
// Déterminer si on doit afficher le champ username selon les règles
final bool shouldShowUsernameField = widget.isAdmin && widget.amicale?.chkUsernameManuel == true;
// Déterminer si le username est éditable (seulement en création, jamais en modification)
final bool canEditUsername = shouldShowUsernameField && widget.allowUsernameEdit && widget.user?.id == 0;
// Déterminer si le username est éditable
final bool canEditUsername = shouldShowUsernameField && widget.allowUsernameEdit;
// Déterminer si on doit afficher le champ mot de passe
final bool shouldShowPasswordField = widget.isAdmin && widget.amicale?.chkMdpManuel == true;
@@ -512,13 +532,12 @@ class _UserFormState extends State<UserForm> {
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
isRequired: true,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer l'adresse email";
}
if (!value.contains('@') || !value.contains('.')) {
return "Veuillez entrer une adresse email valide";
// Email optionnel - valider seulement si une valeur est saisie
if (value != null && value.isNotEmpty) {
if (!value.contains('@') || !value.contains('.')) {
return "Veuillez entrer une adresse email valide";
}
}
return null;
},
@@ -731,7 +750,7 @@ class _UserFormState extends State<UserForm> {
readOnly: !canEditUsername,
prefixIcon: Icons.account_circle,
isRequired: canEditUsername,
suffixIcon: (widget.user?.id == 0 && canEditUsername)
suffixIcon: canEditUsername
? _isGeneratingUsername
? SizedBox(
width: 20,
@@ -749,9 +768,9 @@ class _UserFormState extends State<UserForm> {
tooltip: "Générer un nom d'utilisateur",
)
: null,
helperText: canEditUsername
? "8 à 64 caractères. Tous les caractères sont acceptés, y compris les espaces et accents."
: null,
helperText: canEditUsername
? "Identifiant de connexion. 8 à 64 caractères. Tous les caractères sont acceptés."
: "Identifiant de connexion",
helperMaxLines: 2,
validator: canEditUsername
? (value) {
@@ -782,35 +801,14 @@ class _UserFormState extends State<UserForm> {
obscureText: _obscurePassword,
readOnly: widget.readOnly,
prefixIcon: Icons.lock,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton pour afficher/masquer le mot de passe
IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
),
// Bouton pour générer un mot de passe (seulement si éditable)
if (!widget.readOnly)
IconButton(
icon: Icon(Icons.auto_awesome),
onPressed: () {
final newPassword = _generatePassword();
setState(() {
_passwordController.text = newPassword;
_obscurePassword = false; // Afficher le mot de passe généré
});
// Revalider le formulaire
_formKey.currentState?.validate();
},
tooltip: "Générer un mot de passe sécurisé",
),
],
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
),
helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel"
@@ -833,7 +831,7 @@ class _UserFormState extends State<UserForm> {
readOnly: !canEditUsername,
prefixIcon: Icons.account_circle,
isRequired: canEditUsername,
suffixIcon: (widget.user?.id == 0 && canEditUsername)
suffixIcon: canEditUsername
? _isGeneratingUsername
? SizedBox(
width: 20,
@@ -851,9 +849,9 @@ class _UserFormState extends State<UserForm> {
tooltip: "Générer un nom d'utilisateur",
)
: null,
helperText: canEditUsername
? "8 à 64 caractères. Tous les caractères sont acceptés, y compris les espaces et accents."
: null,
helperText: canEditUsername
? "Identifiant de connexion. 8 à 64 caractères. Tous les caractères sont acceptés."
: "Identifiant de connexion",
helperMaxLines: 2,
validator: canEditUsername
? (value) {
@@ -882,35 +880,14 @@ class _UserFormState extends State<UserForm> {
obscureText: _obscurePassword,
readOnly: widget.readOnly,
prefixIcon: Icons.lock,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton pour afficher/masquer le mot de passe
IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
),
// Bouton pour générer un mot de passe (seulement si éditable)
if (!widget.readOnly)
IconButton(
icon: Icon(Icons.auto_awesome),
onPressed: () {
final newPassword = _generatePassword();
setState(() {
_passwordController.text = newPassword;
_obscurePassword = false; // Afficher le mot de passe généré
});
// Revalider le formulaire
_formKey.currentState?.validate();
},
tooltip: "Générer un mot de passe sécurisé",
),
],
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
),
helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel"
@@ -996,10 +973,11 @@ class _UserFormState extends State<UserForm> {
return Row(
children: [
// TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+)
Radio<int>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
groupValue: groupValue, // ignore: deprecated_member_use
onChanged: onChanged, // ignore: deprecated_member_use
activeColor: const Color(0xFF20335E),
),
Text(

View File

@@ -58,8 +58,8 @@ class _UserFormDialogState extends State<UserFormDialog> {
}
void _handleSubmit() async {
// Utiliser la méthode validateAndGetUser du UserForm
final userData = _userFormKey.currentState?.validateAndGetUser();
// Utiliser la méthode asynchrone validateAndGetUserAsync du UserForm
final userData = await _userFormKey.currentState?.validateAndGetUserAsync(context);
final password = _userFormKey.currentState?.getPassword(); // Récupérer le mot de passe
if (userData != null) {
@@ -134,33 +134,43 @@ class _UserFormDialogState extends State<UserFormDialog> {
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: widget.availableRoles!.map((role) {
return RadioListTile<int>(
title: Text(role.label),
subtitle: Text(
role.description,
style: theme.textTheme.bodySmall,
),
value: role.value,
groupValue: _selectedRole,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_selectedRole = value;
});
},
activeColor: theme.colorScheme.primary,
);
}).toList(),
),
Row(
children: widget.availableRoles!.map((role) {
return Expanded(
child: Row(
children: [
// TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+)
Radio<int>(
value: role.value,
groupValue: _selectedRole, // ignore: deprecated_member_use
onChanged: widget.readOnly // ignore: deprecated_member_use
? null
: (value) {
setState(() {
_selectedRole = value;
});
},
activeColor: theme.colorScheme.primary,
),
Flexible(
child: GestureDetector(
onTap: widget.readOnly
? null
: () {
setState(() {
_selectedRole = role.value;
});
},
child: Text(
role.label,
style: theme.textTheme.bodyMedium,
),
),
),
],
),
);
}).toList(),
),
const SizedBox(height: 16),
],