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:
207
app/lib/presentation/widgets/admin_scaffold.dart
Normal file
207
app/lib/presentation/widgets/admin_scaffold.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
416
app/lib/presentation/widgets/app_scaffold.dart
Normal file
416
app/lib/presentation/widgets/app_scaffold.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
|
||||
899
app/lib/presentation/widgets/members_board_passages.dart
Normal file
899
app/lib/presentation/widgets/members_board_passages.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user