feat: Version 3.5.2 - Configuration Stripe et gestion des immeubles
- Configuration complète Stripe pour les 3 environnements (DEV/REC/PROD) * DEV: Clés TEST Pierre (mode test) * REC: Clés TEST Client (mode test) * PROD: Clés LIVE Client (mode live) - Ajout de la gestion des bases de données immeubles/bâtiments * Configuration buildings_database pour DEV/REC/PROD * Service BuildingService pour enrichissement des adresses - Optimisations pages et améliorations ergonomie - Mises à jour des dépendances Composer - Nettoyage des fichiers obsolètes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ import 'package:geosector_app/presentation/widgets/membre_table_widget.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/operation_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
|
||||
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
|
||||
|
||||
/// Page d'administration de l'amicale et des membres
|
||||
/// Cette page est intégrée dans le tableau de bord administrateur
|
||||
@@ -123,6 +125,12 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
),
|
||||
],
|
||||
onSubmit: (updatedUser, {String? password}) async {
|
||||
// Afficher le loading
|
||||
final overlay = LoadingSpinOverlayUtils.show(
|
||||
context: context,
|
||||
message: 'Mise à jour en cours...',
|
||||
);
|
||||
|
||||
try {
|
||||
// Convertir le UserModel mis à jour vers MembreModel
|
||||
final updatedMembre =
|
||||
@@ -134,19 +142,33 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
password: password,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
if (success && context.mounted) {
|
||||
// Afficher le résultat de succès
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: true,
|
||||
message: 'Membre ${updatedMembre.firstName} ${updatedMembre.name} mis à jour',
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
if (context.mounted) {
|
||||
ApiException.showSuccess(context,
|
||||
'Membre ${updatedMembre.firstName} ${updatedMembre.name} mis à jour');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur mise à jour membre: $e');
|
||||
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
if (context.mounted) {
|
||||
ApiException.showError(context, e);
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: ApiException.fromError(e).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -230,10 +252,10 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
|
||||
debugPrint('🎯 Opération courante: $_currentOperationId');
|
||||
|
||||
// Filtrer les passages par opération courante ET par utilisateur
|
||||
// Filtrer les passages par opération courante ET par utilisateur (utiliser opeUserId)
|
||||
final allUserPassages =
|
||||
widget.passageRepository.getPassagesByUser(membre.id);
|
||||
debugPrint('📊 Total passages du membre: ${allUserPassages.length}');
|
||||
widget.passageRepository.getPassagesByUser(membre.opeUserId ?? 0);
|
||||
debugPrint('📊 Total passages du membre (opeUserId=${membre.opeUserId}): ${allUserPassages.length}');
|
||||
|
||||
final passagesRealises = allUserPassages
|
||||
.where((passage) =>
|
||||
@@ -348,9 +370,9 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
|
||||
border: Border.all(color: Colors.blue.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -373,7 +395,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<int>(
|
||||
initialValue: selectedMemberForTransfer,
|
||||
value: selectedMemberForTransfer,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Membre destinataire',
|
||||
border: OutlineInputBorder(),
|
||||
@@ -401,7 +423,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
color: Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
@@ -429,10 +451,10 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
color: Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border:
|
||||
Border.all(color: Colors.green.withValues(alpha: 0.3)),
|
||||
Border.all(color: Colors.green.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -619,6 +641,12 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
),
|
||||
],
|
||||
onSubmit: (newUserData, {String? password}) async {
|
||||
// Afficher le loading
|
||||
final overlay = LoadingSpinOverlayUtils.show(
|
||||
context: context,
|
||||
message: 'Création en cours...',
|
||||
);
|
||||
|
||||
try {
|
||||
// Créer un nouveau MembreModel directement
|
||||
final newMembre = MembreModel(
|
||||
@@ -645,27 +673,41 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
password: password,
|
||||
);
|
||||
|
||||
if (createdMembre != null) {
|
||||
// Fermer le dialog
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
if (createdMembre != null && context.mounted) {
|
||||
// Afficher le résultat de succès
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: true,
|
||||
message: 'Membre ${createdMembre.firstName} ${createdMembre.name} ajouté avec succès (ID: ${createdMembre.id})',
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
// Afficher le message de succès avec les informations du membre créé
|
||||
if (context.mounted) {
|
||||
ApiException.showSuccess(context,
|
||||
'Membre ${createdMembre.firstName} ${createdMembre.name} ajouté avec succès (ID: ${createdMembre.id})');
|
||||
}
|
||||
} else if (context.mounted) {
|
||||
// En cas d'échec, ne pas fermer le dialog pour permettre la correction
|
||||
ApiException.showError(
|
||||
context, Exception('Erreur lors de la création du membre'));
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: 'Erreur lors de la création du membre',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur création membre: $e');
|
||||
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
if (context.mounted) {
|
||||
// En cas d'exception, ne pas fermer le dialog
|
||||
ApiException.showError(context, e);
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: ApiException.fromError(e).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -701,9 +743,9 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
|
||||
border: Border.all(color: Colors.red.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -752,7 +794,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
Icon(
|
||||
Icons.business_outlined,
|
||||
size: 64,
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.7),
|
||||
color: theme.colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
@@ -801,7 +843,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -852,7 +894,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
|
||||
@@ -445,7 +445,7 @@ class _AdminOperationsPageState extends State<AdminOperationsPage> {
|
||||
bottomRight: Radius.circular(8),
|
||||
),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.1),
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -471,10 +471,10 @@ class _AdminOperationsPageState extends State<AdminOperationsPage> {
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.1),
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor.withValues(alpha: 0.3),
|
||||
color: theme.dividerColor.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -544,13 +544,13 @@ class _AdminOperationsPageState extends State<AdminOperationsPage> {
|
||||
|
||||
return InkWell(
|
||||
onTap: operation.isActive ? () => _showEditOperationDialog(operation) : null,
|
||||
hoverColor: operation.isActive ? theme.colorScheme.primary.withValues(alpha: 0.05) : null,
|
||||
hoverColor: operation.isActive ? theme.colorScheme.primary.withOpacity(0.05) : null,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor.withValues(alpha: 0.3),
|
||||
color: theme.dividerColor.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -582,7 +582,7 @@ class _AdminOperationsPageState extends State<AdminOperationsPage> {
|
||||
Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.6),
|
||||
color: theme.colorScheme.primary.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
@@ -768,7 +768,7 @@ class _AdminOperationsPageState extends State<AdminOperationsPage> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -783,7 +783,7 @@ class _AdminOperationsPageState extends State<AdminOperationsPage> {
|
||||
Icon(
|
||||
Icons.calendar_today_outlined,
|
||||
size: 64,
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.5),
|
||||
color: theme.colorScheme.primary.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
@@ -796,7 +796,7 @@ class _AdminOperationsPageState extends State<AdminOperationsPage> {
|
||||
Text(
|
||||
"Cliquez sur 'Nouvelle opération' pour commencer",
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode, debugPrint;
|
||||
import 'package:universal_html/html.dart' as html;
|
||||
import 'package:geosector_app/core/services/js_stub.dart'
|
||||
if (dart.library.js) 'dart:js' as js;
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -13,7 +14,8 @@ import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_button.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
|
||||
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:geosector_app/core/services/hive_service.dart'; // Pour vérifier l'initialisation
|
||||
|
||||
@@ -31,7 +33,7 @@ class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.5)
|
||||
..color = Colors.white.withOpacity(0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
@@ -66,23 +68,11 @@ class _LoginPageState extends State<LoginPage> {
|
||||
bool _isConnected = true; // Par défaut, on suppose qu'il y a une connexion
|
||||
|
||||
Future<void> _getAppVersion() async {
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = packageInfo.version;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération de la version: $e');
|
||||
// Fallback sur la version du AppInfoService si elle existe
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = AppInfoService.fullVersion
|
||||
.split(' ')
|
||||
.last; // Extraire juste le numéro
|
||||
});
|
||||
}
|
||||
// Utilise directement AppInfoService (remplace package_info_plus)
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = AppInfoService.version;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,15 +384,19 @@ class _LoginPageState extends State<LoginPage> {
|
||||
if (confirm == true) {
|
||||
setState(() => _isCleaningCache = true);
|
||||
debugPrint('👤 Utilisateur a demandé un nettoyage du cache');
|
||||
|
||||
|
||||
// Nettoyer le cache Hive
|
||||
await HiveService.instance.cleanDataOnLogout();
|
||||
|
||||
setState(() => _isCleaningCache = false);
|
||||
|
||||
// Rediriger vers la page splash pour réinitialiser
|
||||
if (context.mounted) {
|
||||
context.go('/');
|
||||
|
||||
// Forcer le rechargement complet de la page sur Web
|
||||
if (kIsWeb) {
|
||||
html.window.location.reload();
|
||||
} else {
|
||||
// Sur mobile, rediriger vers splash
|
||||
setState(() => _isCleaningCache = false);
|
||||
if (context.mounted) {
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -432,8 +426,8 @@ class _LoginPageState extends State<LoginPage> {
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shadowColor: _loginType == 'user'
|
||||
? Colors.green.withValues(alpha: 0.5)
|
||||
: Colors.red.withValues(alpha: 0.5),
|
||||
? Colors.green.withOpacity(0.5)
|
||||
: Colors.red.withOpacity(0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16.0)),
|
||||
child: Padding(
|
||||
@@ -474,7 +468,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
'Bienvenue sur GEOSECTOR',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color:
|
||||
theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -489,11 +483,11 @@ class _LoginPageState extends State<LoginPage> {
|
||||
margin: const EdgeInsets.only(top: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.1),
|
||||
color: theme.colorScheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color:
|
||||
theme.colorScheme.error.withValues(alpha: 0.3),
|
||||
theme.colorScheme.error.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -591,13 +585,10 @@ class _LoginPageState extends State<LoginPage> {
|
||||
debugPrint(
|
||||
'ERREUR: Utilisateur non trouvé après connexion réussie');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Erreur de connexion. Veuillez réessayer.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: 'Erreur de connexion. Veuillez réessayer.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -636,26 +627,20 @@ class _LoginPageState extends State<LoginPage> {
|
||||
// Un user (rôle 1) ne peut pas se connecter en mode admin
|
||||
debugPrint('Erreur: User (rôle 1) tentant de se connecter en mode admin');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Accès administrateur non autorisé pour ce compte.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: 'Accès administrateur non autorisé pour ce compte.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Échec de la connexion. Vérifiez vos identifiants.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: 'Échec de la connexion. Vérifiez vos identifiants.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -714,15 +699,10 @@ class _LoginPageState extends State<LoginPage> {
|
||||
if (connectivityService
|
||||
.isConnected &&
|
||||
context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Connexion Internet ${connectivityService.connectionType} détectée.'),
|
||||
backgroundColor:
|
||||
Colors.green,
|
||||
),
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: true,
|
||||
message: 'Connexion Internet ${connectivityService.connectionType} détectée.',
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -767,13 +747,10 @@ class _LoginPageState extends State<LoginPage> {
|
||||
debugPrint(
|
||||
'ERREUR: Utilisateur non trouvé après connexion réussie');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Erreur de connexion. Veuillez réessayer.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: 'Erreur de connexion. Veuillez réessayer.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -812,26 +789,20 @@ class _LoginPageState extends State<LoginPage> {
|
||||
// Un user (rôle 1) ne peut pas se connecter en mode admin
|
||||
debugPrint('Erreur: User (rôle 1) tentant de se connecter en mode admin');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Accès administrateur non autorisé pour ce compte.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: 'Accès administrateur non autorisé pour ce compte.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Échec de la connexion. Vérifiez vos identifiants.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: 'Échec de la connexion. Vérifiez vos identifiants.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -925,17 +896,17 @@ class _LoginPageState extends State<LoginPage> {
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.1),
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.3),
|
||||
color: theme.colorScheme.primary.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'v$_appVersion',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.8),
|
||||
color: theme.colorScheme.primary.withOpacity(0.8),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -1024,6 +995,12 @@ class _LoginPageState extends State<LoginPage> {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
// Afficher le loading overlay
|
||||
final overlay = LoadingSpinOverlayUtils.show(
|
||||
context: context,
|
||||
message: 'Envoi en cours...',
|
||||
);
|
||||
|
||||
try {
|
||||
// Vérifier la connexion Internet
|
||||
await connectivityService.checkConnectivity();
|
||||
@@ -1087,6 +1064,9 @@ class _LoginPageState extends State<LoginPage> {
|
||||
|
||||
// Traiter la réponse
|
||||
if (response.statusCode == 200) {
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
// Modifier le contenu de la boîte de dialogue pour afficher le message de succès
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
@@ -1128,6 +1108,9 @@ class _LoginPageState extends State<LoginPage> {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
// Fermer la boîte de dialogue actuelle
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
@@ -1139,17 +1122,17 @@ class _LoginPageState extends State<LoginPage> {
|
||||
'Erreur lors de la récupération du mot de passe');
|
||||
}
|
||||
} catch (e) {
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
// Afficher un message d'erreur
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e
|
||||
.toString()
|
||||
.contains('Exception:')
|
||||
? e.toString().split('Exception: ')[1]
|
||||
: 'Erreur lors de la récupération du mot de passe'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: e.toString().contains('Exception:')
|
||||
? e.toString().split('Exception: ')[1]
|
||||
: 'Erreur lors de la récupération du mot de passe',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -10,10 +10,11 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_button.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
|
||||
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
|
||||
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
|
||||
import 'package:geosector_app/core/services/app_info_service.dart';
|
||||
import 'package:geosector_app/core/services/hive_service.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
|
||||
class RegisterPage extends StatefulWidget {
|
||||
@@ -28,7 +29,7 @@ class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.5)
|
||||
..color = Colors.white.withOpacity(0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
@@ -91,23 +92,11 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
bool _isLoadingCities = false;
|
||||
|
||||
Future<void> _getAppVersion() async {
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = packageInfo.version;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération de la version: $e');
|
||||
// Fallback sur la version du AppInfoService si elle existe
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = AppInfoService.fullVersion
|
||||
.split(' ')
|
||||
.last; // Extraire juste le numéro
|
||||
});
|
||||
}
|
||||
// Utilise directement AppInfoService (remplace package_info_plus)
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = AppInfoService.version;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +203,7 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
try {
|
||||
// Utiliser l'API interne de geosector pour récupérer les villes par code postal
|
||||
final baseUrl = Uri
|
||||
.base.origin; // Récupère l'URL de base (ex: https://app.geosector.fr)
|
||||
.base.origin; // Récupère l'URL de base (ex: https://app3.geosector.fr)
|
||||
final apiUrl = '$baseUrl/api/villes?code_postal=$postalCode';
|
||||
|
||||
final response = await http.get(
|
||||
@@ -327,7 +316,7 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
Text(
|
||||
'Enregistrez votre amicale sur GeoSector',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -352,10 +341,10 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
margin: const EdgeInsets.only(top: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.1),
|
||||
color: theme.colorScheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.3),
|
||||
color: theme.colorScheme.error.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -385,12 +374,10 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
onPressed: () async {
|
||||
await _checkConnectivity();
|
||||
if (_isConnected && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Connexion Internet $_connectionType détectée.'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: true,
|
||||
message: 'Connexion Internet $_connectionType détectée.',
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -520,7 +507,7 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
color: const Color(0xFFECEFF1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -535,7 +522,7 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
),
|
||||
)
|
||||
: DropdownButtonFormField<City>(
|
||||
initialValue: _selectedCity,
|
||||
value: _selectedCity,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: Icon(
|
||||
Icons.location_city_outlined,
|
||||
@@ -685,15 +672,10 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
if (connectivityService
|
||||
.isConnected &&
|
||||
context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Connexion Internet ${connectivityService.connectionType} détectée.'),
|
||||
backgroundColor:
|
||||
Colors.green,
|
||||
),
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: true,
|
||||
message: 'Connexion Internet ${connectivityService.connectionType} détectée.',
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -709,13 +691,10 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
if (captchaAnswer !=
|
||||
_captchaNum1 + _captchaNum2) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'La vérification de sécurité a échoué. Veuillez réessayer.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: 'La vérification de sécurité a échoué. Veuillez réessayer.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -742,6 +721,12 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// Afficher le loading overlay
|
||||
final overlay = LoadingSpinOverlayUtils.show(
|
||||
context: context,
|
||||
message: 'Inscription en cours...',
|
||||
);
|
||||
|
||||
try {
|
||||
// Envoyer les données à l'API
|
||||
final baseUrl = Uri.base.origin;
|
||||
@@ -757,6 +742,9 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
body: json.encode(formData),
|
||||
);
|
||||
|
||||
// Masquer le loading overlay
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
// Masquer l'indicateur de chargement
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
@@ -879,7 +867,7 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
color: theme
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.7),
|
||||
.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -940,33 +928,45 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Afficher également un SnackBar
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Gérer les erreurs HTTP
|
||||
// Gérer les erreurs HTTP (409, 400, etc.)
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Erreur ${response.statusCode}: ${response.reasonPhrase ?? "Échec de l'inscription"}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
// Essayer d'extraire le message de l'API
|
||||
String errorMessage = 'Échec de l\'inscription';
|
||||
try {
|
||||
final errorData = json.decode(response.body);
|
||||
errorMessage = errorData['message'] ?? errorMessage;
|
||||
} catch (e) {
|
||||
// Si le parsing échoue, utiliser le message par défaut
|
||||
errorMessage = response.reasonPhrase ?? errorMessage;
|
||||
}
|
||||
|
||||
// Afficher une AlertDialog pour plus de visibilité
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Erreur d\'inscription'),
|
||||
content: Text(errorMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Masquer le loading overlay
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
// Masquer l'indicateur de chargement
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
@@ -974,13 +974,10 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
|
||||
// Gérer les exceptions
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Erreur: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: 'Erreur: ${e.toString()}',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1079,17 +1076,17 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.1),
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.3),
|
||||
color: theme.colorScheme.primary.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'v$_appVersion',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.8),
|
||||
color: theme.colorScheme.primary.withOpacity(0.8),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
// Import conditionnel pour le web
|
||||
import 'package:universal_html/html.dart' as html;
|
||||
@@ -37,7 +36,7 @@ class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.5)
|
||||
..color = Colors.white.withOpacity(0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
@@ -68,20 +67,11 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
bool _isCleaningCache = false;
|
||||
|
||||
Future<void> _getAppVersion() async {
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = packageInfo.version;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération de la version: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = AppInfoService.fullVersion.split(' ').last;
|
||||
});
|
||||
}
|
||||
// Utilise directement AppInfoService (remplace package_info_plus)
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = AppInfoService.version;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +311,9 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
ApiService.instance.setSessionId(sessionId);
|
||||
|
||||
// Appeler le nouvel endpoint API pour restaurer la session
|
||||
final response = await ApiService.instance.get(
|
||||
// IMPORTANT: Utiliser getWithoutQueue() pour ne JAMAIS mettre cette requête en file d'attente
|
||||
// Les refresh de session sont liés à une session spécifique et ne doivent pas être rejoués
|
||||
final response = await ApiService.instance.getWithoutQueue(
|
||||
'/api/user/session',
|
||||
queryParameters: {'mode': displayMode},
|
||||
);
|
||||
@@ -858,7 +850,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
'Une application puissante et intuitive de gestion de vos distributions de calendriers',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
@@ -878,7 +870,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.2),
|
||||
color: theme.colorScheme.primary.withOpacity(0.2),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -893,7 +885,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
builder: (context, value, child) {
|
||||
return LinearProgressIndicator(
|
||||
value: value,
|
||||
backgroundColor: Colors.grey.withValues(alpha: 0.15),
|
||||
backgroundColor: Colors.grey.withOpacity(0.15),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
theme.colorScheme.primary,
|
||||
),
|
||||
@@ -923,7 +915,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
_statusMessage,
|
||||
key: ValueKey(_statusMessage),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
@@ -1188,8 +1180,13 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
// Reset du cache des repositories après nettoyage
|
||||
_resetAllRepositoriesCache();
|
||||
|
||||
// Après le nettoyage, relancer l'initialisation
|
||||
_startInitialization();
|
||||
// Forcer le rechargement complet de la page
|
||||
if (kIsWeb) {
|
||||
html.window.location.reload();
|
||||
} else {
|
||||
// Sur mobile, relancer l'initialisation normalement
|
||||
_startInitialization();
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
@@ -1229,7 +1226,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.1),
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary,
|
||||
|
||||
@@ -81,13 +81,13 @@ class _ChatCommunicationPageState extends State<ChatCommunicationPage> {
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 80,
|
||||
color: _themeColor.withValues(alpha: 0.3),
|
||||
color: _themeColor.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Module de communication non disponible',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -95,7 +95,7 @@ class _ChatCommunicationPageState extends State<ChatCommunicationPage> {
|
||||
Text(
|
||||
_getUnavailableMessage(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.4),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
@@ -119,9 +119,9 @@ class SectorActionResultDialog extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
class SectorDialog extends StatefulWidget {
|
||||
final SectorModel? existingSector;
|
||||
final List<List<double>> coordinates;
|
||||
final Future<void> Function(String name, String color, List<int> memberIds) onSave;
|
||||
final Future<void> Function(String name, String color, List<int> memberIds, bool updatePassages) onSave;
|
||||
|
||||
const SectorDialog({
|
||||
super.key,
|
||||
@@ -31,6 +31,8 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
final List<int> _selectedMemberIds = [];
|
||||
bool _isLoading = false;
|
||||
String _searchQuery = '';
|
||||
bool _updatePassages = true; // Par défaut activé
|
||||
bool _initialUpdatePassages = true; // Valeur initiale pour détecter les changements
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -68,24 +70,24 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
for (var i = 0; i < userSectorBox.length; i++) {
|
||||
final us = userSectorBox.getAt(i);
|
||||
if (us != null) {
|
||||
debugPrint(' - UserSector[$i]: membreId=${us.id}, fkSector=${us.fkSector}, name="${us.firstName} ${us.name}"');
|
||||
debugPrint(' - UserSector[$i]: userId=${us.userId}, opeUserId=${us.opeUserId}, fkSector=${us.fkSector}, name="${us.firstName} ${us.name}"');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Récupérer tous les UserSectorModel pour ce secteur
|
||||
final userSectors = userSectorBox.values
|
||||
.where((us) => us.fkSector == widget.existingSector!.id)
|
||||
.toList();
|
||||
|
||||
|
||||
debugPrint('Trouvé ${userSectors.length} UserSectorModel pour le secteur ${widget.existingSector!.id}');
|
||||
|
||||
// Pré-sélectionner les IDs des membres affectés
|
||||
|
||||
// Pré-sélectionner les IDs des membres affectés (ope_users.id)
|
||||
setState(() {
|
||||
_selectedMemberIds.clear();
|
||||
for (final userSector in userSectors) {
|
||||
// userSector.id est l'ID du membre (pas de l'utilisateur)
|
||||
_selectedMemberIds.add(userSector.id);
|
||||
debugPrint('Membre présélectionné: ${userSector.firstName} ${userSector.name} (membreId: ${userSector.id}, fkSector: ${userSector.fkSector})');
|
||||
// Utiliser opeUserId (ope_users.id) pour la sélection
|
||||
_selectedMemberIds.add(userSector.opeUserId);
|
||||
debugPrint('Membre présélectionné: ${userSector.firstName} ${userSector.name} (userId: ${userSector.userId}, opeUserId: ${userSector.opeUserId}, fkSector: ${userSector.fkSector})');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -118,7 +120,51 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
}
|
||||
|
||||
String _colorToHex(Color color) {
|
||||
return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}';
|
||||
return '#${color.value.toRadixString(16).substring(2).toUpperCase()}';
|
||||
}
|
||||
|
||||
// Dialogue de confirmation pour le changement du switch
|
||||
Future<bool> _showUpdatePassagesConfirmation(bool newValue) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.orange,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Confirmation'),
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
newValue
|
||||
? 'Êtes-vous sûr de vouloir recalculer les passages ?\n\n'
|
||||
'Tous les passages du secteur seront réaffectés selon les nouvelles limites.'
|
||||
: 'Êtes-vous sûr de vouloir conserver les passages existants ?\n\n'
|
||||
'Les passages actuels ne seront pas modifiés même si les limites du secteur changent.',
|
||||
style: const TextStyle(fontSize: 15),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Confirmer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
void _handleSave() async {
|
||||
@@ -144,6 +190,7 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
_nameController.text.trim(),
|
||||
_colorToHex(_selectedColor),
|
||||
_selectedMemberIds,
|
||||
_updatePassages, // Passer le paramètre updatePassages
|
||||
);
|
||||
|
||||
// Si tout s'est bien passé, fermer le dialog
|
||||
@@ -197,8 +244,8 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
itemCount: colors.length,
|
||||
itemBuilder: (context, index) {
|
||||
final color = colors[index];
|
||||
final isSelected = _selectedColor.toARGB32() == color.toARGB32();
|
||||
|
||||
final isSelected = _selectedColor.value == color.value;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
@@ -219,7 +266,7 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -441,6 +488,73 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Switch pour la mise à jour des passages (uniquement en mode édition)
|
||||
if (widget.existingSector != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Mise à jour des passages',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
color: Colors.blue.shade900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_updatePassages
|
||||
? 'Les passages seront recalculés et réaffectés'
|
||||
: 'Les passages existants ne seront pas modifiés',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade700,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: _updatePassages,
|
||||
onChanged: (value) async {
|
||||
// Afficher confirmation uniquement si la valeur change par rapport à l'initiale
|
||||
if (value != _initialUpdatePassages) {
|
||||
final confirmed = await _showUpdatePassagesConfirmation(value);
|
||||
if (confirmed) {
|
||||
setState(() {
|
||||
_updatePassages = value;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_updatePassages = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
activeColor: Colors.blue,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// Sélection des membres
|
||||
Row(
|
||||
children: [
|
||||
@@ -510,33 +624,50 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
|
||||
builder: (context, box, _) {
|
||||
debugPrint('=== Build liste membres - IDs présélectionnés: $_selectedMemberIds ===');
|
||||
|
||||
// Filtrer les membres de l'amicale
|
||||
|
||||
// Récupérer tous les membres (déjà uniques dans la box)
|
||||
// Filtrer uniquement ceux qui ont un opeUserId (dans l'opération courante)
|
||||
var membres = box.values
|
||||
.where((m) => m.fkEntite == currentAmicale.id)
|
||||
.whereType<MembreModel>()
|
||||
.where((membre) => membre.opeUserId != null)
|
||||
.toList();
|
||||
|
||||
|
||||
// Appliquer le filtre de recherche
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
membres = membres.where((membre) {
|
||||
final firstName = membre.firstName?.toLowerCase() ?? '';
|
||||
final lastName = membre.name?.toLowerCase() ?? '';
|
||||
final sectName = membre.sectName?.toLowerCase() ?? '';
|
||||
|
||||
|
||||
return firstName.contains(_searchQuery) ||
|
||||
lastName.contains(_searchQuery) ||
|
||||
sectName.contains(_searchQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
|
||||
// Trier : membres affectés en premier, puis les autres
|
||||
membres.sort((a, b) {
|
||||
final aSelected = _selectedMemberIds.contains(a.opeUserId);
|
||||
final bSelected = _selectedMemberIds.contains(b.opeUserId);
|
||||
|
||||
// Si l'un est sélectionné et pas l'autre, le mettre en premier
|
||||
if (aSelected && !bSelected) return -1;
|
||||
if (!aSelected && bSelected) return 1;
|
||||
|
||||
// Sinon, trier alphabétiquement par prénom puis nom
|
||||
final firstNameCompare = (a.firstName ?? '').compareTo(b.firstName ?? '');
|
||||
if (firstNameCompare != 0) return firstNameCompare;
|
||||
return (a.name ?? '').compareTo(b.name ?? '');
|
||||
});
|
||||
|
||||
if (membres.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Text(
|
||||
_searchQuery.isNotEmpty
|
||||
_searchQuery.isNotEmpty
|
||||
? 'Aucun membre trouvé pour "$_searchQuery"'
|
||||
: 'Aucun membre disponible',
|
||||
: 'Aucun membre disponible pour cette opération',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 14,
|
||||
@@ -545,7 +676,7 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Afficher le nombre de résultats
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -570,13 +701,13 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
itemCount: membres.length,
|
||||
itemBuilder: (context, index) {
|
||||
final membre = membres[index];
|
||||
final isSelected = _selectedMemberIds.contains(membre.id);
|
||||
|
||||
final isSelected = _selectedMemberIds.contains(membre.opeUserId);
|
||||
|
||||
// Log pour debug
|
||||
if (index < 3) { // Limiter les logs aux 3 premiers membres
|
||||
debugPrint('Membre ${index}: ${membre.firstName} ${membre.name} (ID: ${membre.id}) - isSelected: $isSelected');
|
||||
debugPrint('Membre ${index}: ${membre.firstName} ${membre.name} (userId: ${membre.id}, opeUserId: ${membre.opeUserId}) - isSelected: $isSelected');
|
||||
}
|
||||
|
||||
|
||||
return CheckboxListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 0.0),
|
||||
@@ -592,9 +723,10 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
onChanged: (bool? value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_selectedMemberIds.add(membre.id);
|
||||
// opeUserId ne peut pas être null grâce au filtre ligne 517
|
||||
_selectedMemberIds.add(membre.opeUserId!);
|
||||
} else {
|
||||
_selectedMemberIds.remove(membre.id);
|
||||
_selectedMemberIds.remove(membre.opeUserId!);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -7,11 +7,11 @@ import 'package:geosector_app/core/theme/app_theme.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/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
|
||||
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
|
||||
import 'package:geosector_app/presentation/widgets/btn_passages.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Page d'historique unifiée utilisant AppScaffold
|
||||
@@ -56,12 +56,16 @@ enum PassageSortType {
|
||||
addressDesc, // Adresse Z-A
|
||||
}
|
||||
|
||||
class _HistoryContentState extends State<HistoryContent> {
|
||||
class _HistoryContentState extends State<HistoryContent> with SingleTickerProviderStateMixin {
|
||||
// Détection du rôle et permissions
|
||||
late final bool isAdmin;
|
||||
late final int currentUserId;
|
||||
late final int currentUserId; // users.id (table centrale)
|
||||
late final int? currentOpeUserId; // ope_users.id (pour comparaisons avec passages)
|
||||
late final bool canDeletePassages; // Permission de suppression pour les users
|
||||
|
||||
// TabController pour les onglets Filtres / Statistiques
|
||||
late TabController _tabController;
|
||||
|
||||
// Filtres principaux (nouveaux)
|
||||
String _selectedTypeFilter = 'Tous les types';
|
||||
String _selectedPaymentFilter = 'Tous les règlements';
|
||||
@@ -89,8 +93,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
|
||||
// Listes pour les filtres
|
||||
List<SectorModel> _sectors = [];
|
||||
List<MembreModel> _membres = [];
|
||||
List<UserModel> _users = []; // Liste des users pour le filtre
|
||||
List<MembreModel> _membres = []; // Liste des membres de l'opération
|
||||
|
||||
// Passages originaux pour l'édition
|
||||
List<PassageModel> _originalPassages = [];
|
||||
@@ -100,14 +103,15 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
bool _isLoading = true;
|
||||
String _errorMessage = '';
|
||||
|
||||
|
||||
// Statistiques pour l'affichage
|
||||
int _totalSectors = 0;
|
||||
int _sharedMembersCount = 0;
|
||||
|
||||
// État de la section graphiques
|
||||
bool _isGraphicsExpanded = true;
|
||||
|
||||
// Hauteur dynamique du TabBarView selon l'onglet actif
|
||||
double _tabBarViewHeight = 280.0; // Hauteur par défaut (Filtres)
|
||||
|
||||
// Onglet précédemment sélectionné (pour détecter les clics sur le même onglet)
|
||||
int _previousTabIndex = 0;
|
||||
|
||||
// Listener pour les changements de secteur depuis map_page
|
||||
late final Box _settingsBox;
|
||||
|
||||
@@ -115,6 +119,9 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Initialiser le TabController (2 onglets)
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
|
||||
// Initialiser la box settings et écouter les changements de secteur
|
||||
_initSettingsListener();
|
||||
|
||||
@@ -122,6 +129,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
currentUserId = currentUser?.id ?? 0;
|
||||
currentOpeUserId = currentUser?.opeUserId; // ID dans ope_users pour comparaisons avec passages
|
||||
|
||||
// Vérifier la permission de suppression pour les users
|
||||
bool userCanDelete = false;
|
||||
@@ -158,6 +166,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_startDateController.dispose();
|
||||
_endDateController.dispose();
|
||||
_searchController.dispose();
|
||||
@@ -165,6 +174,68 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Callback pour gérer les clics sur les onglets
|
||||
void _onTabTapped(int index) {
|
||||
setState(() {
|
||||
// Si on clique sur le même onglet alors que l'ExpansionTile est ouvert → le fermer
|
||||
if (index == _previousTabIndex && _isGraphicsExpanded) {
|
||||
_isGraphicsExpanded = false;
|
||||
_saveGraphicsExpandedState();
|
||||
}
|
||||
// Sinon, ouvrir l'ExpansionTile et ajuster la hauteur
|
||||
else {
|
||||
if (!_isGraphicsExpanded) {
|
||||
_isGraphicsExpanded = true;
|
||||
_saveGraphicsExpandedState();
|
||||
}
|
||||
// Onglet 0 = Filtres (hauteur plus petite)
|
||||
// Onglet 1 = Statistiques (hauteur plus grande)
|
||||
_tabBarViewHeight = index == 0 ? 280.0 : 800.0;
|
||||
}
|
||||
|
||||
_previousTabIndex = index;
|
||||
});
|
||||
}
|
||||
|
||||
// Callback pour gérer les clics sur les boutons de type de passage
|
||||
void _handleTypeSelected(int? typeId) {
|
||||
setState(() {
|
||||
// Réinitialiser tous les filtres
|
||||
_selectedPaymentFilter = 'Tous les règlements';
|
||||
_selectedPaymentTypeId = null;
|
||||
selectedPaymentTypeId = null;
|
||||
startDate = null;
|
||||
endDate = null;
|
||||
_startDateController.clear();
|
||||
_endDateController.clear();
|
||||
_searchQuery = '';
|
||||
_searchController.clear();
|
||||
_selectedSectorId = null;
|
||||
selectedSectorId = null;
|
||||
if (isAdmin) {
|
||||
_selectedUserId = null;
|
||||
selectedMemberId = null;
|
||||
}
|
||||
|
||||
// Appliquer le filtre de type
|
||||
if (typeId == null) {
|
||||
// Tous les passages
|
||||
_selectedTypeFilter = 'Tous les types';
|
||||
selectedTypeId = null;
|
||||
} else {
|
||||
// Type spécifique
|
||||
selectedTypeId = typeId;
|
||||
final typeInfo = AppKeys.typesPassages[typeId];
|
||||
if (typeInfo != null) {
|
||||
_selectedTypeFilter = typeInfo['titre'] as String;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Appliquer les filtres
|
||||
_notifyFiltersChanged();
|
||||
}
|
||||
|
||||
// Initialiser le listener pour les changements de secteur
|
||||
Future<void> _initSettingsListener() async {
|
||||
try {
|
||||
@@ -368,14 +439,87 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
debugPrint('HistoryPage: ${filteredPassages.length} passages filtrés sur ${_originalPassages.length}');
|
||||
}
|
||||
|
||||
/// Construire la card de filtres intégrée
|
||||
Widget _buildFiltersCard() {
|
||||
/// Construire la section TabBar + ExpansionTile
|
||||
Widget _buildTabBarSection() {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
colorScheme: Theme.of(context).colorScheme.copyWith(
|
||||
primary: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
child: ExpansionTile(
|
||||
key: ValueKey('expansion_tile_$_isGraphicsExpanded'),
|
||||
initiallyExpanded: _isGraphicsExpanded,
|
||||
trailing: const SizedBox.shrink(), // Masquer la flèche d'expansion
|
||||
onExpansionChanged: (expanded) {
|
||||
setState(() {
|
||||
_isGraphicsExpanded = expanded;
|
||||
// Réinitialiser _previousTabIndex quand on ferme manuellement
|
||||
// pour permettre de rouvrir en cliquant sur l'onglet actif
|
||||
if (!expanded) {
|
||||
_previousTabIndex = -1;
|
||||
}
|
||||
});
|
||||
_saveGraphicsExpandedState();
|
||||
},
|
||||
tilePadding: EdgeInsets.zero,
|
||||
childrenPadding: EdgeInsets.zero,
|
||||
title: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppTheme.primaryColor,
|
||||
unselectedLabelColor: Colors.grey[600],
|
||||
indicatorColor: AppTheme.primaryColor,
|
||||
indicatorWeight: 3,
|
||||
onTap: _onTabTapped,
|
||||
tabs: const [
|
||||
Tab(
|
||||
icon: Icon(Icons.filter_list, size: 20),
|
||||
text: 'Filtres',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.analytics_outlined, size: 20),
|
||||
text: 'Statistiques',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
height: _tabBarViewHeight,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
// Onglet 1 : Filtres
|
||||
_buildFiltersContent(),
|
||||
// Onglet 2 : Statistiques
|
||||
_buildGraphicsContent(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construire le contenu des filtres (ancien _buildFiltersCard sans la Card)
|
||||
Widget _buildFiltersContent() {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth > 800;
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
color: Colors.transparent,
|
||||
return SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
@@ -534,10 +678,10 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
value: null,
|
||||
child: Text('Membres'),
|
||||
),
|
||||
..._users.map((UserModel user) {
|
||||
..._membres.map((MembreModel membre) {
|
||||
return DropdownMenuItem<int?>(
|
||||
value: user.id,
|
||||
child: Text('${user.firstName ?? ''} ${user.name ?? ''}'),
|
||||
value: membre.opeUserId,
|
||||
child: Text('${membre.firstName ?? ''} ${membre.name ?? ''}${membre.sectName != null && membre.sectName!.isNotEmpty ? ' (${membre.sectName})' : ''}'),
|
||||
);
|
||||
}),
|
||||
],
|
||||
@@ -910,7 +1054,34 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
// Charger le type de passage sélectionné
|
||||
final typeId = settingsBox.get('history_selectedTypeId');
|
||||
if (typeId != null && typeId is int) {
|
||||
// Réinitialiser TOUS les filtres avant d'appliquer le type
|
||||
setState(() {
|
||||
// Réinitialiser les filtres de type et paiement
|
||||
_selectedPaymentFilter = 'Tous les règlements';
|
||||
_selectedPaymentTypeId = null;
|
||||
selectedPaymentTypeId = null;
|
||||
|
||||
// Réinitialiser les dates
|
||||
startDate = null;
|
||||
endDate = null;
|
||||
_startDateController.clear();
|
||||
_endDateController.clear();
|
||||
|
||||
// Réinitialiser la recherche
|
||||
_searchQuery = '';
|
||||
_searchController.clear();
|
||||
|
||||
// Réinitialiser le secteur
|
||||
_selectedSectorId = null;
|
||||
selectedSectorId = null;
|
||||
|
||||
// Réinitialiser le membre (admin seulement)
|
||||
if (isAdmin) {
|
||||
_selectedUserId = null;
|
||||
selectedMemberId = null;
|
||||
}
|
||||
|
||||
// Appliquer le type de passage sélectionné
|
||||
selectedTypeId = typeId;
|
||||
final typeInfo = AppKeys.typesPassages[typeId];
|
||||
selectedType = typeInfo != null ? typeInfo['titre'] as String : 'Inconnu';
|
||||
@@ -919,7 +1090,11 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
_selectedTypeFilter = typeInfo['titre'] as String;
|
||||
}
|
||||
});
|
||||
debugPrint('HistoryPage: Type de passage présélectionné: $typeId');
|
||||
|
||||
// Supprimer le typeId de Hive après l'avoir utilisé
|
||||
settingsBox.delete('history_selectedTypeId');
|
||||
|
||||
debugPrint('HistoryPage: Type de passage présélectionné: $typeId (tous les autres filtres réinitialisés)');
|
||||
}
|
||||
|
||||
// Charger le type de règlement sélectionné
|
||||
@@ -977,30 +1152,15 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
_sectors = sectorRepository.getAllSectors()
|
||||
.where((s) => userSectorIds.contains(s.id))
|
||||
.toList();
|
||||
|
||||
// Calculer les statistiques pour l'utilisateur
|
||||
_totalSectors = _sectors.length;
|
||||
|
||||
// Compter les membres partageant les mêmes secteurs
|
||||
final allUserSectors = userRepository.getUserSectors();
|
||||
final sharedMembers = <int>{};
|
||||
for (final userSector in allUserSectors) {
|
||||
if (userSectorIds.contains(userSector.id) && userSector.id != currentUserId) {
|
||||
sharedMembers.add(userSector.id);
|
||||
}
|
||||
}
|
||||
_sharedMembersCount = sharedMembers.length;
|
||||
}
|
||||
debugPrint('Nombre de secteurs récupérés: ${_sectors.length}');
|
||||
|
||||
// Charger les membres (admin seulement)
|
||||
// Charger les membres de l'opération (admin seulement)
|
||||
if (isAdmin) {
|
||||
_membres = membreRepository.getAllMembres();
|
||||
debugPrint('Nombre de membres récupérés: ${_membres.length}');
|
||||
|
||||
// Convertir les membres en users pour le filtre
|
||||
_users = _convertMembresToUsers();
|
||||
debugPrint('Nombre d\'utilisateurs pour le filtre: ${_users.length}');
|
||||
// Charger directement depuis MembreModel (déjà unique, pas de déduplication nécessaire)
|
||||
final membreBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
|
||||
_membres = membreBox.values.whereType<MembreModel>().toList();
|
||||
debugPrint('Nombre de membres de l\'opération récupérés: ${_membres.length}');
|
||||
}
|
||||
|
||||
// Charger les passages
|
||||
@@ -1018,7 +1178,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
return true;
|
||||
}
|
||||
// Autres types : seulement les passages de l'utilisateur
|
||||
return p.fkUser == currentUserId;
|
||||
return p.fkUser == currentOpeUserId;
|
||||
}).toList();
|
||||
}
|
||||
debugPrint('Nombre de passages récupérés: ${_originalPassages.length}');
|
||||
@@ -1042,46 +1202,6 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir les MembreModel en UserModel pour le filtre (admin seulement)
|
||||
List<UserModel> _convertMembresToUsers() {
|
||||
final users = <UserModel>[];
|
||||
|
||||
for (final membre in _membres) {
|
||||
// Utiliser l'ID du membre pour récupérer l'utilisateur associé
|
||||
final user = userRepository.getUserById(membre.id);
|
||||
if (user != null) {
|
||||
// Si l'utilisateur existe, copier avec le sectName du membre
|
||||
users.add(user.copyWith(
|
||||
sectName: membre.sectName ?? user.sectName,
|
||||
));
|
||||
} else {
|
||||
// Créer un UserModel temporaire si l'utilisateur n'existe pas
|
||||
users.add(UserModel(
|
||||
id: membre.id,
|
||||
username: membre.username ?? 'membre_${membre.id}',
|
||||
name: membre.name,
|
||||
firstName: membre.firstName,
|
||||
email: membre.email,
|
||||
role: membre.role,
|
||||
isActive: membre.isActive,
|
||||
createdAt: membre.createdAt,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
sectName: membre.sectName,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Trier par nom complet
|
||||
users.sort((a, b) {
|
||||
final nameA = '${a.firstName ?? ''} ${a.name ?? ''}'.trim().toLowerCase();
|
||||
final nameB = '${b.firstName ?? ''} ${b.name ?? ''}'.trim().toLowerCase();
|
||||
return nameA.compareTo(nameB);
|
||||
});
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Le contenu sans scaffold (AppScaffold est déjà dans HistoryPage)
|
||||
@@ -1111,129 +1231,47 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
// Titre unique pour tous
|
||||
const pageTitle = 'Historique des passages';
|
||||
|
||||
// Statistiques pour les users
|
||||
final statsText = !isAdmin
|
||||
? '$_totalSectors secteur${_totalSectors > 1 ? 's' : ''} | $_sharedMembersCount membre${_sharedMembersCount > 1 ? 's' : ''} en partage'
|
||||
: null;
|
||||
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth > 800;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
|
||||
vertical: AppTheme.spacingL,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// En-tête avec titre
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre
|
||||
Text(
|
||||
pageTitle,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (statsText != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
statsText,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 1. Card de filtres intégrée
|
||||
_buildFiltersCard(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 2. Section graphiques (rétractable)
|
||||
_buildGraphicsSection(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 3. Liste des passages avec hauteur maximale
|
||||
Card(
|
||||
elevation: 2,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 700,
|
||||
),
|
||||
child: PassagesListWidget(
|
||||
passages: _convertPassagesToMaps(),
|
||||
showActions: true, // Actions disponibles pour tous (avec vérification des droits)
|
||||
showAddButton: true, // Bouton + pour tous
|
||||
onPassageEdit: _handlePassageEditMap, // Tous peuvent éditer (avec vérification)
|
||||
onPassageDelete: canDeletePassages ? _handlePassageDeleteMap : null, // Selon permissions
|
||||
onAddPassage: () async {
|
||||
await _showPassageFormDialog(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
|
||||
vertical: AppTheme.spacingL,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction de la section graphiques rétractable (pour intégration dans PassagesListWidget)
|
||||
Widget _buildGraphicsSection() {
|
||||
// final screenWidth = MediaQuery.of(context).size.width; // Non utilisé actuellement
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
colorScheme: Theme.of(context).colorScheme.copyWith(
|
||||
primary: AppTheme.primaryColor,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 0. BtnPassages
|
||||
BtnPassages(
|
||||
onTypeSelected: _handleTypeSelected,
|
||||
selectedTypeId: selectedTypeId,
|
||||
),
|
||||
),
|
||||
child: ExpansionTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.analytics_outlined, color: AppTheme.primaryColor, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Statistiques graphiques',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// 1. TabBar + ExpansionTile (Filtres / Statistiques) - FIXE EN HAUT
|
||||
_buildTabBarSection(),
|
||||
|
||||
SizedBox(height: _isGraphicsExpanded ? 8 : 16),
|
||||
|
||||
// 2. Liste des passages - EXPANDED pour prendre tout l'espace restant
|
||||
Expanded(
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
child: PassagesListWidget(
|
||||
passages: _convertPassagesToMaps(),
|
||||
showActions: true, // Actions disponibles pour tous (avec vérification des droits)
|
||||
showAddButton: true, // Bouton + pour tous
|
||||
onPassageEdit: _handlePassageEditMap, // Tous peuvent éditer (avec vérification)
|
||||
onPassageDelete: canDeletePassages ? _handlePassageDeleteMap : null, // Selon permissions
|
||||
onAddPassage: () async {
|
||||
await _showPassageFormDialog(context);
|
||||
},
|
||||
filteredPassageType: _selectedTypeFilter != 'Tous les types' ? _selectedTypeFilter : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
subtitle: !_isGraphicsExpanded ? Text(
|
||||
isAdmin ? "Tous les passages de l'opération" : "Mes passages de l'opération",
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
) : null,
|
||||
initiallyExpanded: _isGraphicsExpanded,
|
||||
onExpansionChanged: (expanded) {
|
||||
setState(() {
|
||||
_isGraphicsExpanded = expanded;
|
||||
});
|
||||
_saveGraphicsExpandedState();
|
||||
},
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
childrenPadding: const EdgeInsets.only(top: 0, bottom: 16.0),
|
||||
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildGraphicsContent(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1243,8 +1281,9 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth > 800;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// Graphiques en camembert (côte à côte sur desktop)
|
||||
isDesktop
|
||||
? Row(
|
||||
@@ -1266,7 +1305,8 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
|
||||
// Graphique d'activité
|
||||
_buildActivityChart(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1603,7 +1643,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
// - Admin peut tout éditer
|
||||
// - User peut éditer ses propres passages
|
||||
// - Type 2 (À finaliser) : éditable par tous les utilisateurs
|
||||
if (isAdmin || passage.fkUser == currentUserId || passage.fkType == 2) {
|
||||
if (isAdmin || passage.fkUser == currentOpeUserId || passage.fkType == 2) {
|
||||
_handlePassageEdit(passage);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -1624,7 +1664,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
);
|
||||
|
||||
// Vérifier les permissions : admin peut tout supprimer, user seulement ses propres passages si autorisé
|
||||
if (isAdmin || (canDeletePassages && passage.fkUser == currentUserId)) {
|
||||
if (isAdmin || (canDeletePassages && passage.fkUser == currentOpeUserId)) {
|
||||
_handlePassageDelete(passage);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:geosector_app/presentation/widgets/members_board_passages.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
|
||||
import 'package:geosector_app/presentation/widgets/btn_passages.dart';
|
||||
|
||||
/// Widget de contenu du tableau de bord unifié (sans scaffold)
|
||||
class HomeContent extends StatefulWidget {
|
||||
@@ -22,16 +23,13 @@ class HomeContent extends StatefulWidget {
|
||||
class _HomeContentState extends State<HomeContent> {
|
||||
// Détection du rôle
|
||||
late final bool isAdmin;
|
||||
late final int currentUserId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Déterminer le rôle de l'utilisateur et le mode d'affichage
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
// Déterminer le mode d'affichage
|
||||
isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
currentUserId = currentUser?.id ?? 0;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -41,14 +39,6 @@ class _HomeContentState extends State<HomeContent> {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth > 800;
|
||||
|
||||
// Récupérer l'opération en cours
|
||||
final currentOperation = userRepository.getCurrentOperation();
|
||||
|
||||
// Titre dynamique avec l'ID et le nom de l'opération
|
||||
final String title = currentOperation != null
|
||||
? 'Opération #${currentOperation.id} ${currentOperation.name}'
|
||||
: 'Opération';
|
||||
|
||||
// Retourner seulement le contenu (sans scaffold)
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(
|
||||
@@ -58,14 +48,9 @@ class _HomeContentState extends State<HomeContent> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
// Widget BtnPassages
|
||||
const BtnPassages(),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// LIGNE 1 : Graphiques de répartition (type de passage et mode de paiement)
|
||||
isDesktop
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:geosector_app/core/repositories/operation_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passage_map_dialog.dart';
|
||||
import 'package:geosector_app/presentation/widgets/grouped_passages_dialog.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Page de carte globale pour admin et utilisateurs
|
||||
@@ -29,6 +30,7 @@ class MapPage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
debugPrint('🔄 MapPage.build() appelé');
|
||||
// Utiliser le mode d'affichage au lieu du rôle réel
|
||||
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
|
||||
@@ -78,6 +80,9 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
bool _showZoomIndicator = false;
|
||||
Timer? _zoomIndicatorTimer;
|
||||
|
||||
// Timer pour debouncer le setState et la sauvegarde lors du déplacement de carte
|
||||
Timer? _mapMoveDebounceTimer;
|
||||
|
||||
// États
|
||||
MapMode _mapMode = MapMode.view;
|
||||
int? _selectedSectorId;
|
||||
@@ -102,7 +107,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
// États pour le mode édition
|
||||
SectorModel? _selectedSectorForEdit;
|
||||
List<LatLng> _editingPoints = [];
|
||||
Map<int, LatLng> _originalPoints = {}; // Pour annuler les modifications
|
||||
final Map<int, LatLng> _originalPoints = {}; // Pour annuler les modifications
|
||||
int? _hoveredPointIndex; // Index du point principal survolé
|
||||
|
||||
// État pour le mode suppression
|
||||
@@ -115,6 +120,9 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
// État pour bloquer le drag de la carte pendant le déplacement des points
|
||||
bool _isDraggingPoint = false;
|
||||
|
||||
// État pour bloquer la sauvegarde du zoom lors du centrage sur secteur
|
||||
bool _isCenteringOnSector = false;
|
||||
|
||||
// Comptages des secteurs (calculés uniquement lors de création/modification de secteurs)
|
||||
Map<int, int> _sectorPassageCount = {};
|
||||
Map<int, int> _sectorMemberCount = {};
|
||||
@@ -143,6 +151,14 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
// Écouter les changements du secteur sélectionné
|
||||
_settingsListenable = _settingsBox.listenable(keys: ['selectedSectorId']);
|
||||
_settingsListenable.addListener(_onSectorSelectionChanged);
|
||||
|
||||
// Centrer sur le secteur si déjà sélectionné (navigation depuis home_page)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_selectedSectorId != null && _sectors.any((s) => s['id'] == _selectedSectorId)) {
|
||||
debugPrint('🎯 MapPage: Secteur présélectionné détecté ($_selectedSectorId), centrage...');
|
||||
_centerMapOnSpecificSector(_selectedSectorId!);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -225,6 +241,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
void dispose() {
|
||||
_settingsListenable.removeListener(_onSectorSelectionChanged);
|
||||
_zoomIndicatorTimer?.cancel();
|
||||
_mapMoveDebounceTimer?.cancel();
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -258,10 +275,14 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
_settingsBox.put('selectedSectorId', _selectedSectorId);
|
||||
}
|
||||
|
||||
// Sauvegarder la position et le zoom actuels
|
||||
// Sauvegarder la position
|
||||
_settingsBox.put('mapLat', _currentPosition.latitude);
|
||||
_settingsBox.put('mapLng', _currentPosition.longitude);
|
||||
_settingsBox.put('mapZoom', _currentZoom);
|
||||
|
||||
// Sauvegarder le zoom SAUF si on est en train de centrer sur un secteur
|
||||
if (!_isCenteringOnSector) {
|
||||
_settingsBox.put('mapZoom', _currentZoom);
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour les comptages des secteurs (passages et membres)
|
||||
@@ -380,7 +401,8 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
// Ne faire setState QUE si les données ont vraiment changé
|
||||
if (mounted && !_arePassagesEqual(_passages, newPassages)) {
|
||||
setState(() {
|
||||
_passages.clear();
|
||||
_passages.addAll(newPassages);
|
||||
@@ -391,6 +413,25 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
}
|
||||
}
|
||||
|
||||
// Comparer deux listes de passages pour éviter les setState inutiles
|
||||
bool _arePassagesEqual(List<Map<String, dynamic>> oldPassages, List<Map<String, dynamic>> newPassages) {
|
||||
if (oldPassages.length != newPassages.length) return false;
|
||||
|
||||
// Créer des clés uniques incluant ID + fkType pour détecter les changements de type
|
||||
// (important pour le gradient des immeubles qui dépend du fkType)
|
||||
final oldKeys = oldPassages.map((p) {
|
||||
final model = p['model'] as PassageModel;
|
||||
return '${model.id}_${model.fkType}';
|
||||
}).toSet();
|
||||
|
||||
final newKeys = newPassages.map((p) {
|
||||
final model = p['model'] as PassageModel;
|
||||
return '${model.id}_${model.fkType}';
|
||||
}).toSet();
|
||||
|
||||
return oldKeys.length == newKeys.length && oldKeys.containsAll(newKeys);
|
||||
}
|
||||
|
||||
// Charger les passages depuis la boîte Hive (avec setState)
|
||||
void _loadPassages() {
|
||||
// L'API retourne déjà les passages filtrés selon le rôle (admin ou user)
|
||||
@@ -640,21 +681,30 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
final centerLat = (minLat + maxLat) / 2;
|
||||
final centerLng = (minLng + maxLng) / 2;
|
||||
|
||||
// Lire le zoom actuel de la caméra pour le conserver exactement
|
||||
final currentZoom = _mapController.camera.zoom;
|
||||
// CAPTURER le zoom actuel AVANT toute opération pour le conserver
|
||||
final preservedZoom = _currentZoom;
|
||||
|
||||
// Centrer la carte sur le secteur SANS changer le zoom
|
||||
debugPrint('🔍 MapPage: Centrage sur secteur (zoom conservé: $currentZoom)');
|
||||
_mapController.move(LatLng(centerLat, centerLng), currentZoom);
|
||||
// ACTIVER le flag pour bloquer la sauvegarde du zoom
|
||||
_isCenteringOnSector = true;
|
||||
|
||||
// Mettre à jour uniquement la position (pas le zoom)
|
||||
// Centrer la carte sur le secteur en FORCANT le zoom actuel
|
||||
debugPrint('🔍 MapPage: Centrage sur secteur (zoom FORCÉ à conserver: $preservedZoom)');
|
||||
_mapController.move(LatLng(centerLat, centerLng), preservedZoom);
|
||||
|
||||
// Mettre à jour UNIQUEMENT la position, PAS le zoom
|
||||
setState(() {
|
||||
_currentPosition = LatLng(centerLat, centerLng);
|
||||
// On ne touche PAS à _currentZoom !
|
||||
});
|
||||
|
||||
// Sauvegarder la nouvelle position
|
||||
// Sauvegarder la nouvelle position (le zoom ne sera pas sauvegardé grâce au flag)
|
||||
_saveSettings();
|
||||
|
||||
// DÉSACTIVER le flag après un court délai pour permettre les sauvegardes normales
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
_isCenteringOnSector = false;
|
||||
});
|
||||
|
||||
// Recharger les passages pour appliquer le filtre par secteur
|
||||
_loadPassages();
|
||||
}
|
||||
@@ -765,10 +815,10 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
Set<int>? userSectorIds;
|
||||
if (!isAdmin) {
|
||||
final userSectorBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
|
||||
final currentUserId = CurrentUserService.instance.currentUser?.id;
|
||||
if (currentUserId != null) {
|
||||
final currentOpeUserId = CurrentUserService.instance.opeUserId;
|
||||
if (currentOpeUserId != null) {
|
||||
userSectorIds = userSectorBox.values
|
||||
.where((us) => us.id == currentUserId)
|
||||
.where((us) => us.opeUserId == currentOpeUserId)
|
||||
.map((us) => us.fkSector)
|
||||
.toSet();
|
||||
}
|
||||
@@ -824,6 +874,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
|
||||
// Construire les marqueurs de labels pour les secteurs
|
||||
List<Marker> _buildSectorLabels() {
|
||||
debugPrint('🔄 _buildSectorLabels() appelé - ${_sectors.length} secteurs');
|
||||
// Ne pas afficher les labels en mode dessin ou suppression
|
||||
if (_sectors.isEmpty || _mapMode != MapMode.view) {
|
||||
return [];
|
||||
@@ -859,24 +910,9 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
fontSize: 14,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
offset: const Offset(1, 1),
|
||||
blurRadius: 3,
|
||||
),
|
||||
Shadow(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
offset: const Offset(-1, -1),
|
||||
blurRadius: 3,
|
||||
),
|
||||
Shadow(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
offset: const Offset(1, -1),
|
||||
blurRadius: 3,
|
||||
),
|
||||
Shadow(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
offset: const Offset(-1, 1),
|
||||
blurRadius: 3,
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
offset: const Offset(0, 0),
|
||||
blurRadius: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -892,24 +928,9 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
offset: const Offset(1, 1),
|
||||
blurRadius: 3,
|
||||
),
|
||||
Shadow(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
offset: const Offset(-1, -1),
|
||||
blurRadius: 3,
|
||||
),
|
||||
Shadow(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
offset: const Offset(1, -1),
|
||||
blurRadius: 3,
|
||||
),
|
||||
Shadow(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
offset: const Offset(-1, 1),
|
||||
blurRadius: 3,
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
offset: const Offset(0, 0),
|
||||
blurRadius: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -923,24 +944,9 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
fontWeight: FontWeight.w500,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
offset: const Offset(1, 1),
|
||||
blurRadius: 3,
|
||||
),
|
||||
Shadow(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
offset: const Offset(-1, -1),
|
||||
blurRadius: 3,
|
||||
),
|
||||
Shadow(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
offset: const Offset(1, -1),
|
||||
blurRadius: 3,
|
||||
),
|
||||
Shadow(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
offset: const Offset(-1, 1),
|
||||
blurRadius: 3,
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
offset: const Offset(0, 0),
|
||||
blurRadius: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -954,20 +960,60 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
}
|
||||
|
||||
// Méthode pour construire les marqueurs des passages
|
||||
/// Groupe les passages par adresse (pour fkHabitat=2)
|
||||
/// Clé: numero+rueBis+rue+ville
|
||||
Map<String, List<Map<String, dynamic>>> _groupPassagesByAddress() {
|
||||
final Map<String, List<Map<String, dynamic>>> grouped = {};
|
||||
|
||||
for (final passage in _passages) {
|
||||
final PassageModel model = passage['model'] as PassageModel;
|
||||
|
||||
// Ne grouper que les passages avec fkHabitat=2
|
||||
if (model.fkHabitat == 2) {
|
||||
final key = '${model.numero}|${model.rueBis}|${model.rue}|${model.ville}';
|
||||
grouped.putIfAbsent(key, () => []);
|
||||
grouped[key]!.add(passage);
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
List<Marker> _buildMarkers() {
|
||||
debugPrint('🔄 _buildMarkers() appelé - ${_passages.length} passages');
|
||||
if (_passages.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _passages.map((passage) {
|
||||
final int passageType = passage['type'] as int;
|
||||
final Color color1 =
|
||||
passage['color'] as Color; // couleur1 du type de passage
|
||||
final List<Marker> markers = [];
|
||||
|
||||
// 1. Grouper les passages fkHabitat=2 par adresse
|
||||
final groupedPassages = _groupPassagesByAddress();
|
||||
final Set<int> groupedPassageIds = {};
|
||||
|
||||
// Collecter les IDs des passages groupés
|
||||
for (final group in groupedPassages.values) {
|
||||
for (final passage in group) {
|
||||
final PassageModel model = passage['model'] as PassageModel;
|
||||
groupedPassageIds.add(model.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Créer les markers pour passages individuels (fkHabitat=1 ou non groupés)
|
||||
for (final passage in _passages) {
|
||||
final PassageModel passageModel = passage['model'] as PassageModel;
|
||||
|
||||
// Ignorer les passages déjà groupés
|
||||
if (groupedPassageIds.contains(passageModel.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final int passageType = passage['type'] as int;
|
||||
final Color color1 = passage['color'] as Color;
|
||||
final bool hasNoSector = passageModel.fkSector == null;
|
||||
|
||||
// Récupérer la couleur2 du type de passage
|
||||
Color color2 = Colors.white; // Couleur par défaut
|
||||
Color color2 = Colors.white;
|
||||
if (AppKeys.typesPassages.containsKey(passageType)) {
|
||||
final colorValue =
|
||||
AppKeys.typesPassages[passageType]!['couleur2'] as int;
|
||||
@@ -978,38 +1024,112 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
final Color borderColor = hasNoSector ? Colors.red : color2;
|
||||
final double borderWidth = hasNoSector ? 3.0 : 1.0;
|
||||
|
||||
return Marker(
|
||||
point: passage['position'] as LatLng,
|
||||
width: hasNoSector ? 18.0 : 14.0, // Plus grand si orphelin
|
||||
height: hasNoSector ? 18.0 : 14.0,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
_showPassageInfo(passage);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color1,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: borderWidth,
|
||||
markers.add(
|
||||
Marker(
|
||||
point: passage['position'] as LatLng,
|
||||
width: hasNoSector ? 18.0 : 14.0,
|
||||
height: hasNoSector ? 18.0 : 14.0,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
_showPassageInfo(passage);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color1,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: borderWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// 3. Créer les markers groupés (carrés avec dégradé blanc→vert selon avancement)
|
||||
for (final entry in groupedPassages.entries) {
|
||||
final passages = entry.value;
|
||||
if (passages.isEmpty) continue;
|
||||
|
||||
// Utiliser la position du premier passage du groupe
|
||||
final position = passages.first['position'] as LatLng;
|
||||
final count = passages.length;
|
||||
final displayCount = count >= 99 ? '99' : count.toString();
|
||||
|
||||
// Calculer le pourcentage de passages réalisés (fkType != 2)
|
||||
final models = passages.map((p) => p['model'] as PassageModel).toList();
|
||||
final realizedCount = models.where((p) => p.fkType != 2).length;
|
||||
final percentage = realizedCount / models.length;
|
||||
|
||||
// Déterminer la couleur de remplissage selon le palier (5 niveaux)
|
||||
Color fillColor;
|
||||
if (percentage == 0) {
|
||||
// 0% : Blanc pur
|
||||
fillColor = Colors.white;
|
||||
} else if (percentage <= 0.25) {
|
||||
// 1-25% : Blanc cassé → Vert très clair
|
||||
fillColor = Color.lerp(Colors.white, const Color(0xFFB3F5E0), (percentage / 0.25))!;
|
||||
} else if (percentage <= 0.50) {
|
||||
// 26-50% : Vert très clair → Vert clair
|
||||
fillColor = Color.lerp(const Color(0xFFB3F5E0), const Color(0xFF66EBBB), ((percentage - 0.25) / 0.25))!;
|
||||
} else if (percentage <= 0.75) {
|
||||
// 51-75% : Vert clair → Vert moyen
|
||||
fillColor = Color.lerp(const Color(0xFF66EBBB), const Color(0xFF33E1A0), ((percentage - 0.50) / 0.25))!;
|
||||
} else if (percentage < 1.0) {
|
||||
// 76-99% : Vert moyen → Vert foncé
|
||||
fillColor = Color.lerp(const Color(0xFF33E1A0), const Color(0xFF00E09D), ((percentage - 0.75) / 0.25))!;
|
||||
} else {
|
||||
// 100% : Vert foncé (couleur "Effectué")
|
||||
fillColor = const Color(0xFF00E09D);
|
||||
}
|
||||
|
||||
markers.add(
|
||||
Marker(
|
||||
point: position,
|
||||
width: 24.0,
|
||||
height: 24.0,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
_showGroupedPassagesDialog(passages.first['model'] as PassageModel);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: fillColor,
|
||||
shape: BoxShape.rectangle,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: Colors.blue, // Bordure bleue toujours
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
displayCount,
|
||||
style: TextStyle(
|
||||
color: percentage < 0.5 ? Colors.black87 : Colors.white, // Texte noir sur fond clair, blanc sur fond foncé
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
// Méthode pour construire les polygones des secteurs
|
||||
List<Polygon> _buildPolygons() {
|
||||
debugPrint('🔄 _buildPolygons() appelé - ${_sectors.length} secteurs');
|
||||
if (_sectors.isEmpty) {
|
||||
debugPrint('MapPage: Aucun secteur à afficher');
|
||||
return [];
|
||||
}
|
||||
|
||||
debugPrint('MapPage: Construction de ${_sectors.length} polygones');
|
||||
|
||||
return _sectors.map((sector) {
|
||||
final int sectorId = sector['id'] as int;
|
||||
final bool isSelected = _selectedSectorId == sectorId;
|
||||
@@ -1024,8 +1144,6 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
_mapMode == MapMode.editing && _selectedSectorForEdit?.id == sectorId;
|
||||
final Color sectorColor = sector['color'] as Color;
|
||||
|
||||
debugPrint('MapPage: Secteur ${sector['name']} - Couleur: $sectorColor');
|
||||
|
||||
// Déterminer la couleur et l'opacité selon l'état
|
||||
Color fillColor;
|
||||
Color borderColor;
|
||||
@@ -1033,33 +1151,33 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
|
||||
if (isMarkedForDeletion) {
|
||||
// Secteur marqué pour suppression
|
||||
fillColor = Colors.red.withValues(alpha: 0.5);
|
||||
fillColor = Colors.red.withOpacity(0.5);
|
||||
borderColor = Colors.red;
|
||||
borderWidth = 4.0;
|
||||
} else if (isHovered) {
|
||||
// Secteur survolé en mode suppression
|
||||
fillColor = sectorColor.withValues(alpha: 0.45);
|
||||
borderColor = Colors.red.withValues(alpha: 0.8);
|
||||
fillColor = sectorColor.withOpacity(0.45);
|
||||
borderColor = Colors.red.withOpacity(0.8);
|
||||
borderWidth = 3.0;
|
||||
} else if (isHoveredForEdit) {
|
||||
// Secteur survolé en mode édition
|
||||
fillColor = sectorColor.withValues(alpha: 0.45);
|
||||
fillColor = sectorColor.withOpacity(0.45);
|
||||
borderColor = Colors.green;
|
||||
borderWidth = 4.0;
|
||||
} else if (isSelectedForEdit) {
|
||||
// Secteur sélectionné pour édition
|
||||
fillColor = sectorColor.withValues(alpha: 0.5);
|
||||
fillColor = sectorColor.withOpacity(0.5);
|
||||
borderColor = Colors.orange;
|
||||
borderWidth = 4.0;
|
||||
} else if (isSelected) {
|
||||
// Secteur sélectionné
|
||||
fillColor = sectorColor.withValues(alpha: 0.5);
|
||||
fillColor = sectorColor.withOpacity(0.5);
|
||||
borderColor = sectorColor;
|
||||
borderWidth = 3.0;
|
||||
} else {
|
||||
// Secteur normal
|
||||
fillColor = sectorColor.withValues(alpha: 0.3);
|
||||
borderColor = sectorColor.withValues(alpha: 0.8);
|
||||
fillColor = sectorColor.withOpacity(0.3);
|
||||
borderColor = sectorColor.withOpacity(0.8);
|
||||
borderWidth = 2.0;
|
||||
}
|
||||
|
||||
@@ -1068,7 +1186,6 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
color: fillColor,
|
||||
borderColor: borderColor,
|
||||
borderStrokeWidth: borderWidth,
|
||||
isFilled: true, // IMPORTANT: Active le remplissage coloré
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
@@ -1090,13 +1207,31 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
);
|
||||
}
|
||||
|
||||
// Afficher le dialogue des passages groupés (immeuble)
|
||||
void _showGroupedPassagesDialog(PassageModel referencePassage) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => GroupedPassagesDialog(
|
||||
referencePassage: referencePassage,
|
||||
isAdmin: isAdmin,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Démarrer le mode dessin
|
||||
void _startDrawingMode() {
|
||||
if (!canEditSectors) return; // Vérifier les permissions
|
||||
setState(() {
|
||||
_mapMode = MapMode.drawing;
|
||||
_drawingPoints.clear();
|
||||
|
||||
// Sélectionner automatiquement "Aucun passage"
|
||||
_selectedPassageTypeFilter = null;
|
||||
_settingsBox.put('selectedPassageTypeFilter', null);
|
||||
});
|
||||
|
||||
// Recharger les passages avec le nouveau filtre
|
||||
_loadPassages();
|
||||
}
|
||||
|
||||
// Démarrer le mode suppression
|
||||
@@ -1105,7 +1240,14 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
setState(() {
|
||||
_mapMode = MapMode.deleting;
|
||||
_sectorToDeleteId = null;
|
||||
|
||||
// Sélectionner automatiquement "Aucun passage"
|
||||
_selectedPassageTypeFilter = null;
|
||||
_settingsBox.put('selectedPassageTypeFilter', null);
|
||||
});
|
||||
|
||||
// Recharger les passages avec le nouveau filtre
|
||||
_loadPassages();
|
||||
}
|
||||
|
||||
// Démarrer le mode édition
|
||||
@@ -1115,7 +1257,14 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
_mapMode = MapMode.editing;
|
||||
_selectedSectorForEdit = null;
|
||||
_editingPoints.clear();
|
||||
|
||||
// Sélectionner automatiquement "Aucun passage"
|
||||
_selectedPassageTypeFilter = null;
|
||||
_settingsBox.put('selectedPassageTypeFilter', null);
|
||||
});
|
||||
|
||||
// Recharger les passages avec le nouveau filtre
|
||||
_loadPassages();
|
||||
}
|
||||
|
||||
// Construire la carte d'aide pour le mode création
|
||||
@@ -1127,10 +1276,10 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
width: 320,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.95),
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.blue.withValues(alpha: 0.3),
|
||||
color: Colors.blue.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -1263,10 +1412,10 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
width: 360,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.95),
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.red.withValues(alpha: 0.3),
|
||||
color: Colors.red.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -1332,10 +1481,10 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
width: 340,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.95),
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.orange.withValues(alpha: 0.3),
|
||||
color: Colors.orange.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -1384,10 +1533,10 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border:
|
||||
Border.all(color: Colors.orange.withValues(alpha: 0.3)),
|
||||
Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
'La modification est verrouillée sur ce secteur.\n'
|
||||
@@ -2772,9 +2921,9 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -2885,6 +3034,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
// Recharger les secteurs et passages après la suppression
|
||||
_loadSectors();
|
||||
_loadPassages();
|
||||
_updateSectorCounts(); // Rafraîchir le comptage des membres
|
||||
|
||||
// Message de succès simple
|
||||
if (mounted) {
|
||||
@@ -2965,7 +3115,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
builder: (dialogContext) => SectorDialog(
|
||||
existingSector: existingSector,
|
||||
coordinates: finalCoordinates,
|
||||
onSave: (name, color, memberIds) async {
|
||||
onSave: (name, color, memberIds, updatePassages) async {
|
||||
// Le dialog se ferme automatiquement dans _handleSave()
|
||||
// Attendre un peu pour s'assurer que le dialog est fermé
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
@@ -2998,10 +3148,9 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
if (existingSector == null) {
|
||||
// Création d'un nouveau secteur
|
||||
// Convertir les coordonnées au format attendu par l'API : "lat/lng#lat/lng#..."
|
||||
final sectorString = finalCoordinates
|
||||
final sectorString = '${finalCoordinates
|
||||
.map((coord) => '${coord[0]}/${coord[1]}')
|
||||
.join('#') +
|
||||
'#'; // Ajouter un # final comme dans les exemples
|
||||
.join('#')}#'; // Ajouter un # final comme dans les exemples
|
||||
|
||||
final newSector = SectorModel(
|
||||
id: 0, // L'API assignera l'ID
|
||||
@@ -3059,12 +3208,26 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
|
||||
// Recharger les secteurs et passages
|
||||
_loadSectors();
|
||||
_loadPassages();
|
||||
_updateSectorCounts(); // Rafraîchir le comptage des membres
|
||||
|
||||
// Centrer la carte sur le nouveau secteur
|
||||
// Présélectionner le secteur créé et afficher tous ses passages
|
||||
if (result.containsKey('sector') && result['sector'] != null) {
|
||||
final newSector = result['sector'] as SectorModel;
|
||||
// Attendre un peu que les données soient chargées
|
||||
|
||||
setState(() {
|
||||
// Sélectionner le secteur créé
|
||||
_selectedSectorId = newSector.id;
|
||||
_settingsBox.put('selectedSectorId', newSector.id);
|
||||
|
||||
// Mettre le filtre sur "Tous les passages"
|
||||
_selectedPassageTypeFilter = -1;
|
||||
_settingsBox.put('selectedPassageTypeFilter', -1);
|
||||
});
|
||||
|
||||
// Recharger les passages avec le nouveau filtre
|
||||
_loadPassages();
|
||||
|
||||
// Centrer la carte sur le nouveau secteur
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
_centerMapOnSpecificSector(newSector.id);
|
||||
@@ -3098,10 +3261,9 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
}
|
||||
} else {
|
||||
// Modification d'un secteur existant
|
||||
final sectorString = finalCoordinates
|
||||
final sectorString = '${finalCoordinates
|
||||
.map((coord) => '${coord[0]}/${coord[1]}')
|
||||
.join('#') +
|
||||
'#';
|
||||
.join('#')}#';
|
||||
|
||||
final updatedSector = existingSector.copyWith(
|
||||
libelle: name,
|
||||
@@ -3109,8 +3271,11 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
sector: sectorString,
|
||||
);
|
||||
|
||||
result = await sectorRepository.updateSector(updatedSector,
|
||||
users: memberIds);
|
||||
result = await sectorRepository.updateSector(
|
||||
updatedSector,
|
||||
users: memberIds,
|
||||
chkAdressesChange: updatePassages ? 1 : 0,
|
||||
);
|
||||
|
||||
if (result['status'] != 'success') {
|
||||
throw Exception(result['message'] ??
|
||||
@@ -3131,8 +3296,29 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
|
||||
// Recharger les secteurs et passages
|
||||
_loadSectors();
|
||||
_updateSectorCounts(); // Rafraîchir le comptage des membres
|
||||
|
||||
// Présélectionner le secteur modifié et afficher tous ses passages
|
||||
setState(() {
|
||||
// Sélectionner le secteur modifié
|
||||
_selectedSectorId = existingSector.id;
|
||||
_settingsBox.put('selectedSectorId', existingSector.id);
|
||||
|
||||
// Mettre le filtre sur "Tous les passages"
|
||||
_selectedPassageTypeFilter = -1;
|
||||
_settingsBox.put('selectedPassageTypeFilter', -1);
|
||||
});
|
||||
|
||||
// Recharger les passages avec le nouveau filtre
|
||||
_loadPassages();
|
||||
|
||||
// Centrer la carte sur le secteur modifié
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
_centerMapOnSpecificSector(existingSector.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (parentContext.mounted) {
|
||||
ScaffoldMessenger.of(parentContext).hideCurrentSnackBar();
|
||||
}
|
||||
@@ -3206,7 +3392,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -3316,7 +3502,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
Polyline(
|
||||
points: _drawingPoints,
|
||||
strokeWidth: 3.0,
|
||||
color: Colors.blue.withValues(alpha: 0.8),
|
||||
color: Colors.blue.withOpacity(0.8),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -3330,7 +3516,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
_editingPoints.first
|
||||
], // Fermer le polygone
|
||||
strokeWidth: 3.0,
|
||||
color: Colors.orange.withValues(alpha: 0.8),
|
||||
color: Colors.orange.withOpacity(0.8),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -3450,7 +3636,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: _draggingPointIndex == i ? 6 : 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -3520,8 +3706,8 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: _hoveredMidpointIndex == i
|
||||
? Colors.blue.withValues(alpha: 0.8)
|
||||
: Colors.grey.withValues(alpha: 0.5),
|
||||
? Colors.blue.withOpacity(0.8)
|
||||
: Colors.grey.withOpacity(0.5),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color:
|
||||
@@ -3666,7 +3852,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
height: 20.0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.5),
|
||||
color: Colors.orange.withOpacity(0.5),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.orange,
|
||||
@@ -3674,7 +3860,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.orange.withValues(alpha: 0.5),
|
||||
color: Colors.orange.withOpacity(0.5),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
@@ -3825,13 +4011,13 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: isDragging ? 8 : (isHovered ? 6 : 4),
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
if (isHovered && !isDragging)
|
||||
BoxShadow(
|
||||
color: Colors.orange.withValues(alpha: 0.3),
|
||||
color: Colors.orange.withOpacity(0.3),
|
||||
blurRadius: 15,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
@@ -3889,8 +4075,8 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: _hoveredMidpointIndex == i
|
||||
? Colors.orange.withValues(alpha: 0.8)
|
||||
: Colors.grey.withValues(alpha: 0.5),
|
||||
? Colors.orange.withOpacity(0.8)
|
||||
: Colors.grey.withOpacity(0.5),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: _hoveredMidpointIndex == i
|
||||
@@ -3980,24 +4166,29 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
onMapEvent: (event) {
|
||||
if (event is MapEventMove) {
|
||||
final displayedZoom = event.camera.zoom;
|
||||
debugPrint('🔍 MapPage: Zoom affiché par la caméra = $displayedZoom (précédent _currentZoom = $_currentZoom)');
|
||||
|
||||
// Afficher l'indicateur de zoom si le niveau a changé
|
||||
if ((displayedZoom - _currentZoom).abs() > 0.01) {
|
||||
_showZoomIndicatorTemporarily();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_currentPosition = event.camera.center;
|
||||
_currentZoom = displayedZoom;
|
||||
// Mettre à jour les variables sans setState() immédiat
|
||||
_currentPosition = event.camera.center;
|
||||
_currentZoom = displayedZoom;
|
||||
|
||||
// Annuler le timer précédent
|
||||
_mapMoveDebounceTimer?.cancel();
|
||||
|
||||
// Lancer un nouveau timer de 300ms pour debouncer
|
||||
_mapMoveDebounceTimer = Timer(const Duration(milliseconds: 300), () {
|
||||
if (mounted) {
|
||||
// setState uniquement après 300ms sans mouvement
|
||||
setState(() {
|
||||
// Les variables sont déjà à jour
|
||||
});
|
||||
_saveSettings();
|
||||
}
|
||||
});
|
||||
_saveSettings();
|
||||
// Mettre à jour le survol après un mouvement de carte
|
||||
if (_mapMode == MapMode.deleting && kIsWeb) {
|
||||
// On doit recalculer car la carte a bougé
|
||||
// Note: On ne peut pas obtenir la position de la souris ici,
|
||||
// elle sera mise à jour au prochain mouvement de souris
|
||||
}
|
||||
} else if (event is MapEventTap &&
|
||||
(_mapMode == MapMode.drawing ||
|
||||
_mapMode == MapMode.deleting ||
|
||||
@@ -4091,7 +4282,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
horizontal: 12, vertical: 4),
|
||||
width: 220, // Largeur fixe pour accommoder les noms longs
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.95),
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
@@ -4152,7 +4343,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
horizontal: 12, vertical: 4),
|
||||
width: 220,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.95),
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
@@ -4229,7 +4420,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.3),
|
||||
color: Colors.blue.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
|
||||
@@ -102,10 +102,10 @@ class ThemeSettingsPage extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
color: theme.colorScheme.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.3),
|
||||
color: theme.colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -287,7 +287,7 @@ class ThemeSettingsPage extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -15,8 +14,8 @@ import 'package:geosector_app/core/services/api_service.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/presentation/widgets/passages/passages_list_widget.dart';
|
||||
import 'package:geosector_app/presentation/widgets/grouped_passages_dialog.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
|
||||
class UserFieldModePage extends StatefulWidget {
|
||||
@@ -45,14 +44,9 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
|
||||
// Qualité des signaux
|
||||
double _gpsAccuracy = 999;
|
||||
ConnectivityResult _connectivityResult = ConnectivityResult.none;
|
||||
List<ConnectivityResult> _connectivityResult = [ConnectivityResult.none];
|
||||
bool _isGpsEnabled = false;
|
||||
|
||||
// Mode boussole
|
||||
bool _compassMode = false;
|
||||
double _heading = 0;
|
||||
StreamSubscription<MagnetometerEvent>? _magnetometerSubscription;
|
||||
|
||||
// Filtrage et recherche
|
||||
String _searchQuery = '';
|
||||
List<PassageModel> _nearbyPassages = [];
|
||||
@@ -62,11 +56,18 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
bool _locationPermissionGranted = false;
|
||||
String _statusMessage = '';
|
||||
|
||||
// Listener pour les changements de la box passages
|
||||
Box<PassageModel>? _passagesBox;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAnimations();
|
||||
|
||||
// Écouter les changements de la Hive box passages pour rafraîchir la carte
|
||||
_passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
_passagesBox?.listenable().addListener(_onPassagesChanged);
|
||||
|
||||
if (kIsWeb) {
|
||||
// Sur web, utiliser une position simulée pour éviter le blocage
|
||||
_initializeWebMode();
|
||||
@@ -77,6 +78,13 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
}
|
||||
}
|
||||
|
||||
// Callback appelé quand la box passages change
|
||||
void _onPassagesChanged() {
|
||||
if (mounted) {
|
||||
_updateNearbyPassages();
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeWebMode() async {
|
||||
// Essayer d'obtenir la position réelle depuis le navigateur
|
||||
try {
|
||||
@@ -86,14 +94,16 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
|
||||
// Demander la permission et obtenir la position
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_currentPosition = position;
|
||||
_gpsAccuracy = position.accuracy;
|
||||
_isGpsEnabled = true;
|
||||
_connectivityResult = ConnectivityResult.wifi;
|
||||
_connectivityResult = [ConnectivityResult.wifi];
|
||||
_isLoading = false;
|
||||
_locationPermissionGranted = true;
|
||||
_statusMessage = "";
|
||||
@@ -148,7 +158,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
);
|
||||
_gpsAccuracy = 100.0;
|
||||
_isGpsEnabled = false;
|
||||
_connectivityResult = ConnectivityResult.wifi;
|
||||
_connectivityResult = [ConnectivityResult.wifi];
|
||||
_isLoading = false;
|
||||
_locationPermissionGranted = false;
|
||||
_statusMessage = statusMessage;
|
||||
@@ -215,9 +225,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
_updateBlinkAnimations();
|
||||
|
||||
// Centrer la carte sur la nouvelle position
|
||||
if (!_compassMode) {
|
||||
_mapController.move(LatLng(position.latitude, position.longitude), 17);
|
||||
}
|
||||
_mapController.move(LatLng(position.latitude, position.longitude), 17);
|
||||
}, onError: (error) {
|
||||
setState(() {
|
||||
_isGpsEnabled = false;
|
||||
@@ -256,8 +264,8 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
}
|
||||
|
||||
// Réseau: clignoter si connexion faible ou absente
|
||||
if (_connectivityResult == ConnectivityResult.none ||
|
||||
_connectivityResult == ConnectivityResult.mobile) {
|
||||
if (_connectivityResult.contains(ConnectivityResult.none) ||
|
||||
_connectivityResult.contains(ConnectivityResult.mobile)) {
|
||||
_networkBlinkController.repeat(reverse: true);
|
||||
} else {
|
||||
_networkBlinkController.stop();
|
||||
@@ -288,12 +296,29 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
|
||||
passagesWithDistance.sort((a, b) => a.value.compareTo(b.value));
|
||||
|
||||
setState(() {
|
||||
_nearbyPassages = passagesWithDistance
|
||||
.where((entry) => entry.value <= 500) // Max 500m
|
||||
.map((entry) => entry.key)
|
||||
.toList();
|
||||
});
|
||||
final newNearbyPassages = passagesWithDistance
|
||||
.where((entry) => entry.value <= 500) // Max 500m
|
||||
.map((entry) => entry.key)
|
||||
.toList();
|
||||
|
||||
// Ne setState que si les passages ont vraiment changé
|
||||
if (!_arePassagesEqual(_nearbyPassages, newNearbyPassages)) {
|
||||
setState(() {
|
||||
_nearbyPassages = newNearbyPassages;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Comparer deux listes de passages pour éviter les setState inutiles
|
||||
bool _arePassagesEqual(List<PassageModel> oldPassages, List<PassageModel> newPassages) {
|
||||
if (oldPassages.length != newPassages.length) return false;
|
||||
|
||||
// Créer des clés uniques incluant ID + fkType pour détecter les changements de type
|
||||
// (important pour le gradient des immeubles qui dépend du fkType)
|
||||
final oldKeys = oldPassages.map((p) => '${p.id}_${p.fkType}').toSet();
|
||||
final newKeys = newPassages.map((p) => '${p.id}_${p.fkType}').toSet();
|
||||
|
||||
return oldKeys.length == newKeys.length && oldKeys.containsAll(newKeys);
|
||||
}
|
||||
|
||||
double _calculateDistance(
|
||||
@@ -306,49 +331,6 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleCompassMode() {
|
||||
// Mode boussole désactivé sur web
|
||||
if (kIsWeb) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Le mode boussole nécessite un appareil mobile'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_compassMode = !_compassMode;
|
||||
});
|
||||
|
||||
if (_compassMode) {
|
||||
_startCompass();
|
||||
// Vibration légère pour feedback
|
||||
HapticFeedback.lightImpact();
|
||||
} else {
|
||||
_stopCompass();
|
||||
}
|
||||
}
|
||||
|
||||
void _startCompass() {
|
||||
_magnetometerSubscription =
|
||||
magnetometerEvents.listen((MagnetometerEvent event) {
|
||||
setState(() {
|
||||
// Calculer l'orientation à partir du magnétomètre
|
||||
_heading = math.atan2(event.y, event.x) * (180 / math.pi);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _stopCompass() {
|
||||
_magnetometerSubscription?.cancel();
|
||||
_magnetometerSubscription = null;
|
||||
setState(() {
|
||||
_heading = 0;
|
||||
});
|
||||
}
|
||||
|
||||
void _recenterMap() {
|
||||
if (_currentPosition != null) {
|
||||
_mapController.move(
|
||||
@@ -378,6 +360,17 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
);
|
||||
}
|
||||
|
||||
// Afficher la dialog groupée pour les immeubles
|
||||
void _showGroupedPassagesDialog(PassageModel referencePassage) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => GroupedPassagesDialog(
|
||||
referencePassage: referencePassage,
|
||||
isAdmin: false, // Mode terrain = utilisateur simple
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier si l'amicale autorise la suppression des passages
|
||||
bool _canDeletePassages() {
|
||||
try {
|
||||
@@ -546,10 +539,10 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
void dispose() {
|
||||
_positionStreamSubscription?.cancel();
|
||||
_qualityUpdateTimer?.cancel();
|
||||
_magnetometerSubscription?.cancel();
|
||||
_gpsBlinkController.dispose();
|
||||
_networkBlinkController.dispose();
|
||||
_searchController.dispose();
|
||||
_passagesBox?.listenable().removeListener(_onPassagesChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -568,7 +561,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
@@ -722,7 +715,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.2),
|
||||
color: color.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
@@ -752,7 +745,13 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
String label;
|
||||
String tooltip;
|
||||
|
||||
switch (_connectivityResult) {
|
||||
// Utiliser le premier élément de la liste pour déterminer le type de connexion
|
||||
final primaryResult = _connectivityResult.firstWhere(
|
||||
(result) => result != ConnectivityResult.none,
|
||||
orElse: () => ConnectivityResult.none
|
||||
);
|
||||
|
||||
switch (primaryResult) {
|
||||
case ConnectivityResult.wifi:
|
||||
icon = Icons.wifi;
|
||||
color = Colors.green;
|
||||
@@ -790,7 +789,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.2),
|
||||
color: color.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
@@ -830,9 +829,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Transform.rotate(
|
||||
angle: _compassMode ? _heading * (math.pi / 180) : 0,
|
||||
child: FlutterMap(
|
||||
FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: LatLng(
|
||||
@@ -851,41 +848,11 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
urlTemplate: kIsWeb
|
||||
? 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/{z}/{x}/{y}?access_token=$mapboxApiKey'
|
||||
: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', // OpenStreetMap temporairement sur mobile
|
||||
userAgentPackageName: 'app.geosector.fr',
|
||||
userAgentPackageName: 'app3.geosector.fr',
|
||||
additionalOptions: const {
|
||||
'attribution': '© OpenStreetMap contributors',
|
||||
},
|
||||
),
|
||||
// Cercles de distance en mode boussole
|
||||
if (_compassMode)
|
||||
CircleLayer(
|
||||
circles: [
|
||||
CircleMarker(
|
||||
point: LatLng(_currentPosition!.latitude,
|
||||
_currentPosition!.longitude),
|
||||
radius: 50,
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
borderColor: Colors.blue.withValues(alpha: 0.3),
|
||||
borderStrokeWidth: 1,
|
||||
),
|
||||
CircleMarker(
|
||||
point: LatLng(_currentPosition!.latitude,
|
||||
_currentPosition!.longitude),
|
||||
radius: 100,
|
||||
color: Colors.transparent,
|
||||
borderColor: Colors.blue.withValues(alpha: 0.2),
|
||||
borderStrokeWidth: 1,
|
||||
),
|
||||
CircleMarker(
|
||||
point: LatLng(_currentPosition!.latitude,
|
||||
_currentPosition!.longitude),
|
||||
radius: 250,
|
||||
color: Colors.transparent,
|
||||
borderColor: Colors.blue.withValues(alpha: 0.15),
|
||||
borderStrokeWidth: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Markers des passages
|
||||
MarkerLayer(
|
||||
markers: _buildPassageMarkers(),
|
||||
@@ -905,7 +872,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.blue.withValues(alpha: 0.3),
|
||||
color: Colors.blue.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 5,
|
||||
),
|
||||
@@ -921,7 +888,6 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Bouton recentrage (bas gauche)
|
||||
Positioned(
|
||||
@@ -934,48 +900,6 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
child: const Icon(Icons.my_location),
|
||||
),
|
||||
),
|
||||
// Bouton boussole (bas droite)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: FloatingActionButton.small(
|
||||
backgroundColor: _compassMode ? Colors.green : Colors.white,
|
||||
foregroundColor: _compassMode ? Colors.white : Colors.grey[700],
|
||||
onPressed: _toggleCompassMode,
|
||||
child: Transform.rotate(
|
||||
angle: _compassMode ? _heading * (math.pi / 180) : 0,
|
||||
child: const Icon(Icons.explore),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Indicateur de mode boussole
|
||||
if (_compassMode)
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.explore, color: Colors.white, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Mode boussole',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: AppTheme.r(context, 12),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -993,7 +917,34 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
List<Marker> _buildPassageMarkers() {
|
||||
if (_currentPosition == null) return [];
|
||||
|
||||
return _nearbyPassages.map((passage) {
|
||||
final List<Marker> markers = [];
|
||||
|
||||
// 1. Séparer les passages immeubles (fkHabitat=2) des autres
|
||||
final buildingPassages = <String, List<Map<String, dynamic>>>{};
|
||||
final individualPassages = <PassageModel>[];
|
||||
|
||||
for (final passage in _nearbyPassages) {
|
||||
if (passage.fkHabitat == 2) {
|
||||
// Créer une clé unique basée sur l'adresse complète
|
||||
final addressKey = '${passage.numero}|${passage.rueBis}|${passage.rue}|${passage.ville}';
|
||||
|
||||
// Convertir les coordonnées GPS string en double
|
||||
final double lat = double.tryParse(passage.gpsLat) ?? 0;
|
||||
final double lng = double.tryParse(passage.gpsLng) ?? 0;
|
||||
|
||||
buildingPassages.putIfAbsent(addressKey, () => []);
|
||||
buildingPassages[addressKey]!.add({
|
||||
'model': passage,
|
||||
'position': LatLng(lat, lng),
|
||||
'id': passage.id,
|
||||
});
|
||||
} else {
|
||||
individualPassages.add(passage);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Créer les markers individuels (fkHabitat != 2) - Cercles
|
||||
for (final passage in individualPassages) {
|
||||
// Déterminer la couleur selon le type de passage
|
||||
Color fillColor = Colors.grey; // Couleur par défaut
|
||||
|
||||
@@ -1022,45 +973,121 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
final double lat = double.tryParse(passage.gpsLat) ?? 0;
|
||||
final double lng = double.tryParse(passage.gpsLng) ?? 0;
|
||||
|
||||
return Marker(
|
||||
point: LatLng(lat, lng),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: GestureDetector(
|
||||
onTap: () => _openPassageForm(passage),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: fillColor,
|
||||
border: Border.all(color: borderColor, width: 3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
markers.add(
|
||||
Marker(
|
||||
point: LatLng(lat, lng),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: GestureDetector(
|
||||
onTap: () => _openPassageForm(passage),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: fillColor,
|
||||
border: Border.all(color: borderColor, width: 3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${passage.numero}${(passage.rueBis.isNotEmpty) ? passage.rueBis.substring(0, 1).toLowerCase() : ''}',
|
||||
style: TextStyle(
|
||||
// Texte noir sur fond clair, blanc sur fond foncé
|
||||
color: fillColor.computeLuminance() > 0.5
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: AppTheme.r(context, 12),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${passage.numero}${(passage.rueBis.isNotEmpty) ? passage.rueBis.substring(0, 1).toLowerCase() : ''}',
|
||||
style: TextStyle(
|
||||
// Texte noir sur fond clair, blanc sur fond foncé
|
||||
color: fillColor.computeLuminance() > 0.5
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: AppTheme.r(context, 12),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// 3. Créer les markers groupés (carrés avec dégradé blanc→vert selon avancement)
|
||||
for (final entry in buildingPassages.entries) {
|
||||
final passages = entry.value;
|
||||
if (passages.isEmpty) continue;
|
||||
|
||||
// Utiliser la position du premier passage du groupe
|
||||
final position = passages.first['position'] as LatLng;
|
||||
final count = passages.length;
|
||||
final displayCount = count >= 99 ? '99' : count.toString();
|
||||
|
||||
// Calculer le pourcentage de passages réalisés (fkType != 2)
|
||||
final models = passages.map((p) => p['model'] as PassageModel).toList();
|
||||
final realizedCount = models.where((p) => p.fkType != 2).length;
|
||||
final percentage = realizedCount / models.length;
|
||||
|
||||
// Déterminer la couleur de remplissage selon le palier (5 niveaux)
|
||||
Color fillColor;
|
||||
if (percentage == 0) {
|
||||
// 0% : Blanc pur
|
||||
fillColor = Colors.white;
|
||||
} else if (percentage <= 0.25) {
|
||||
// 1-25% : Blanc cassé → Vert très clair
|
||||
fillColor = Color.lerp(Colors.white, const Color(0xFFB3F5E0), (percentage / 0.25))!;
|
||||
} else if (percentage <= 0.50) {
|
||||
// 26-50% : Vert très clair → Vert clair
|
||||
fillColor = Color.lerp(const Color(0xFFB3F5E0), const Color(0xFF66EBBB), ((percentage - 0.25) / 0.25))!;
|
||||
} else if (percentage <= 0.75) {
|
||||
// 51-75% : Vert clair → Vert moyen
|
||||
fillColor = Color.lerp(const Color(0xFF66EBBB), const Color(0xFF33E1A0), ((percentage - 0.50) / 0.25))!;
|
||||
} else if (percentage < 1.0) {
|
||||
// 76-99% : Vert moyen → Vert foncé
|
||||
fillColor = Color.lerp(const Color(0xFF33E1A0), const Color(0xFF00E09D), ((percentage - 0.75) / 0.25))!;
|
||||
} else {
|
||||
// 100% : Vert foncé (couleur "Effectué")
|
||||
fillColor = const Color(0xFF00E09D);
|
||||
}
|
||||
|
||||
markers.add(
|
||||
Marker(
|
||||
point: position,
|
||||
width: 24.0,
|
||||
height: 24.0,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
_showGroupedPassagesDialog(passages.first['model'] as PassageModel);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: fillColor,
|
||||
shape: BoxShape.rectangle,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: Colors.blue, // Bordure bleue toujours
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
displayCount,
|
||||
style: TextStyle(
|
||||
color: percentage < 0.5 ? Colors.black87 : Colors.white, // Texte noir sur fond clair, blanc sur fond foncé
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _getFilteredPassages() {
|
||||
@@ -1121,7 +1148,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
'isOwnedByCurrentUser': passage.fkUser ==
|
||||
userRepository
|
||||
.getCurrentUser()
|
||||
?.id, // Ajout du champ pour le widget
|
||||
?.opeUserId, // Comparer avec ope_users.id
|
||||
// Garder les données originales pour l'édition
|
||||
'numero': passage.numero,
|
||||
'rueBis': passage.rueBis,
|
||||
|
||||
@@ -10,7 +10,7 @@ class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.5)
|
||||
..color = Colors.white.withOpacity(0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'dart:io';
|
||||
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
|
||||
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
|
||||
import 'custom_text_field.dart';
|
||||
|
||||
class AmicaleForm extends StatefulWidget {
|
||||
@@ -196,6 +197,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
// Afficher le loading
|
||||
if (!context.mounted) return;
|
||||
showDialog(
|
||||
// ignore: use_build_context_synchronously
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const AlertDialog(
|
||||
@@ -279,24 +281,13 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
Future<void> _updateAmicale(AmicaleModel amicale) async {
|
||||
if (!mounted) return;
|
||||
|
||||
// Afficher l'overlay de chargement
|
||||
final overlay = LoadingSpinOverlayUtils.show(
|
||||
context: context,
|
||||
message: 'Mise à jour en cours...',
|
||||
);
|
||||
|
||||
try {
|
||||
// Afficher un indicateur de chargement
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return const AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Mise à jour en cours...'),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Préparer les données pour l'API
|
||||
final Map<String, dynamic> data = {
|
||||
@@ -357,10 +348,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
}
|
||||
}
|
||||
|
||||
// Fermer l'indicateur de chargement
|
||||
if (mounted && Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -370,46 +359,39 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
widget.onSubmit!(amicale);
|
||||
}
|
||||
|
||||
// Afficher un message de succès
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(widget.apiService != null ? 'Amicale mise à jour avec succès' : 'Modifications enregistrées localement'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
// Afficher le résultat de succès
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: true,
|
||||
message: widget.apiService != null
|
||||
? 'Amicale mise à jour avec succès'
|
||||
: 'Modifications enregistrées localement',
|
||||
);
|
||||
|
||||
// Fermer le formulaire après un délai pour que l'utilisateur voie le message
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Fermer le formulaire
|
||||
if (mounted && Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} else {
|
||||
// Afficher un message d'erreur
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage ?? 'Erreur lors de la mise à jour'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
// Afficher le résultat d'erreur
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: errorMessage ?? 'Erreur lors de la mise à jour',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur générale dans _updateAmicale: $e');
|
||||
|
||||
// Fermer l'indicateur de chargement si encore ouvert
|
||||
if (mounted && Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
// Afficher un message d'erreur
|
||||
// Afficher l'erreur
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur inattendue: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: 'Erreur inattendue: ${e.toString()}',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -527,81 +509,114 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
void _submitForm() {
|
||||
debugPrint('🔧 _submitForm appelée');
|
||||
|
||||
if (_formKey.currentState!.validate()) {
|
||||
debugPrint('🔧 Formulaire valide');
|
||||
|
||||
// Vérifier qu'au moins un numéro de téléphone est renseigné
|
||||
if (_phoneController.text.isEmpty && _mobileController.text.isEmpty) {
|
||||
debugPrint('⚠️ Aucun numéro de téléphone renseigné');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Veuillez renseigner au moins un numéro de téléphone'),
|
||||
backgroundColor: Colors.red,
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
// Afficher une dialog si la validation échoue
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('Formulaire incomplet'),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🔧 Création de l\'objet AmicaleModel...');
|
||||
|
||||
final amicale = widget.amicale?.copyWith(
|
||||
name: _nameController.text,
|
||||
adresse1: _adresse1Controller.text,
|
||||
adresse2: _adresse2Controller.text,
|
||||
codePostal: _codePostalController.text,
|
||||
ville: _villeController.text,
|
||||
fkRegion: _fkRegion,
|
||||
libRegion: _libRegion,
|
||||
phone: _phoneController.text,
|
||||
mobile: _mobileController.text,
|
||||
email: _emailController.text,
|
||||
gpsLat: _gpsLatController.text,
|
||||
gpsLng: _gpsLngController.text,
|
||||
stripeId: _stripeIdController.text,
|
||||
chkDemo: _chkDemo,
|
||||
chkCopieMailRecu: _chkCopieMailRecu,
|
||||
chkAcceptSms: _chkAcceptSms,
|
||||
chkActive: _chkActive,
|
||||
chkStripe: _chkStripe,
|
||||
chkMdpManuel: _chkMdpManuel,
|
||||
chkUsernameManuel: _chkUsernameManuel,
|
||||
chkUserDeletePass: _chkUserDeletePass,
|
||||
chkLotActif: _chkLotActif,
|
||||
) ??
|
||||
AmicaleModel(
|
||||
id: 0, // Sera remplacé par l'API
|
||||
name: _nameController.text,
|
||||
adresse1: _adresse1Controller.text,
|
||||
adresse2: _adresse2Controller.text,
|
||||
codePostal: _codePostalController.text,
|
||||
ville: _villeController.text,
|
||||
fkRegion: _fkRegion,
|
||||
libRegion: _libRegion,
|
||||
phone: _phoneController.text,
|
||||
mobile: _mobileController.text,
|
||||
email: _emailController.text,
|
||||
gpsLat: _gpsLatController.text,
|
||||
gpsLng: _gpsLngController.text,
|
||||
stripeId: _stripeIdController.text,
|
||||
chkDemo: _chkDemo,
|
||||
chkCopieMailRecu: _chkCopieMailRecu,
|
||||
chkAcceptSms: _chkAcceptSms,
|
||||
chkActive: _chkActive,
|
||||
chkStripe: _chkStripe,
|
||||
chkMdpManuel: _chkMdpManuel,
|
||||
chkUsernameManuel: _chkUsernameManuel,
|
||||
chkUserDeletePass: _chkUserDeletePass,
|
||||
chkLotActif: _chkLotActif,
|
||||
);
|
||||
|
||||
debugPrint('🔧 AmicaleModel créé: ${amicale.name}');
|
||||
debugPrint('🔧 Appel de _updateAmicale...');
|
||||
|
||||
// Appeler l'API pour mettre à jour l'amicale
|
||||
_updateAmicale(amicale);
|
||||
} else {
|
||||
debugPrint('❌ Formulaire invalide');
|
||||
content: const Text('Veuillez vérifier tous les champs marqués en rouge avant d\'enregistrer'),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🔧 Formulaire valide');
|
||||
|
||||
// Vérifier qu'au moins un numéro de téléphone est renseigné
|
||||
if (_phoneController.text.isEmpty && _mobileController.text.isEmpty) {
|
||||
debugPrint('⚠️ Aucun numéro de téléphone renseigné');
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('Formulaire incomplet'),
|
||||
],
|
||||
),
|
||||
content: const Text('Veuillez renseigner au moins un numéro de téléphone'),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🔧 Création de l\'objet AmicaleModel...');
|
||||
|
||||
final amicale = widget.amicale?.copyWith(
|
||||
name: _nameController.text,
|
||||
adresse1: _adresse1Controller.text,
|
||||
adresse2: _adresse2Controller.text,
|
||||
codePostal: _codePostalController.text,
|
||||
ville: _villeController.text,
|
||||
fkRegion: _fkRegion,
|
||||
libRegion: _libRegion,
|
||||
phone: _phoneController.text,
|
||||
mobile: _mobileController.text,
|
||||
email: _emailController.text,
|
||||
gpsLat: _gpsLatController.text,
|
||||
gpsLng: _gpsLngController.text,
|
||||
stripeId: _stripeIdController.text,
|
||||
chkDemo: _chkDemo,
|
||||
chkCopieMailRecu: _chkCopieMailRecu,
|
||||
chkAcceptSms: _chkAcceptSms,
|
||||
chkActive: _chkActive,
|
||||
chkStripe: _chkStripe,
|
||||
chkMdpManuel: _chkMdpManuel,
|
||||
chkUsernameManuel: _chkUsernameManuel,
|
||||
chkUserDeletePass: _chkUserDeletePass,
|
||||
chkLotActif: _chkLotActif,
|
||||
) ??
|
||||
AmicaleModel(
|
||||
id: 0, // Sera remplacé par l'API
|
||||
name: _nameController.text,
|
||||
adresse1: _adresse1Controller.text,
|
||||
adresse2: _adresse2Controller.text,
|
||||
codePostal: _codePostalController.text,
|
||||
ville: _villeController.text,
|
||||
fkRegion: _fkRegion,
|
||||
libRegion: _libRegion,
|
||||
phone: _phoneController.text,
|
||||
mobile: _mobileController.text,
|
||||
email: _emailController.text,
|
||||
gpsLat: _gpsLatController.text,
|
||||
gpsLng: _gpsLngController.text,
|
||||
stripeId: _stripeIdController.text,
|
||||
chkDemo: _chkDemo,
|
||||
chkCopieMailRecu: _chkCopieMailRecu,
|
||||
chkAcceptSms: _chkAcceptSms,
|
||||
chkActive: _chkActive,
|
||||
chkStripe: _chkStripe,
|
||||
chkMdpManuel: _chkMdpManuel,
|
||||
chkUsernameManuel: _chkUsernameManuel,
|
||||
chkUserDeletePass: _chkUserDeletePass,
|
||||
chkLotActif: _chkLotActif,
|
||||
);
|
||||
|
||||
debugPrint('🔧 AmicaleModel créé: ${amicale.name}');
|
||||
debugPrint('🔧 Appel de _updateAmicale...');
|
||||
|
||||
// Appeler l'API pour mettre à jour l'amicale
|
||||
_updateAmicale(amicale);
|
||||
}
|
||||
|
||||
// Construire la section logo
|
||||
@@ -618,7 +633,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -642,7 +657,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
onTap: _selectImage,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -822,7 +837,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -1234,10 +1249,10 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: _stripeStatus?.statusColor.withValues(alpha: 0.1) ?? Colors.orange.withValues(alpha: 0.1),
|
||||
color: _stripeStatus?.statusColor.withOpacity(0.1) ?? Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: _stripeStatus?.statusColor.withValues(alpha: 0.3) ?? Colors.orange.withValues(alpha: 0.3),
|
||||
color: _stripeStatus?.statusColor.withOpacity(0.3) ?? Colors.orange.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
|
||||
@@ -38,7 +38,7 @@ class AmicaleRowWidget extends StatelessWidget {
|
||||
: theme.textTheme.bodyMedium;
|
||||
|
||||
// Couleur de fond en fonction du type de ligne
|
||||
final backgroundColor = isHeader ? theme.colorScheme.primary.withValues(alpha: 0.1) : (isAlternate ? theme.colorScheme.surface : theme.colorScheme.surface);
|
||||
final backgroundColor = isHeader ? theme.colorScheme.primary.withOpacity(0.1) : (isAlternate ? theme.colorScheme.surface : theme.colorScheme.surface);
|
||||
|
||||
return InkWell(
|
||||
onTap: isHeader || onTap == null ? null : () => onTap!(amicale),
|
||||
@@ -47,7 +47,7 @@ class AmicaleRowWidget extends StatelessWidget {
|
||||
color: backgroundColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor.withValues(alpha: 0.3),
|
||||
color: theme.dividerColor.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -134,7 +134,7 @@ class AmicaleTableWidget extends StatelessWidget {
|
||||
bottomRight: Radius.circular(8),
|
||||
),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.1),
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -161,7 +161,7 @@ class AmicaleTableWidget extends StatelessWidget {
|
||||
child: Text(
|
||||
emptyMessage ?? 'Aucune amicale trouvée',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,7 +11,7 @@ class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.5)
|
||||
..color = Colors.white.withOpacity(0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
@@ -206,7 +206,7 @@ class AppScaffold extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.shadowColor.withValues(alpha: 0.1),
|
||||
color: theme.shadowColor.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
@@ -233,7 +233,7 @@ class AppScaffold extends StatelessWidget {
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
379
app/lib/presentation/widgets/btn_passages.dart
Normal file
379
app/lib/presentation/widgets/btn_passages.dart
Normal file
@@ -0,0 +1,379 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
|
||||
|
||||
/// Widget affichant 8 colonnes de statistiques de passages
|
||||
class BtnPassages extends StatelessWidget {
|
||||
final VoidCallback? onAddPassage;
|
||||
|
||||
/// Callback appelé lors du clic sur un type de passage
|
||||
/// Si null, navigue vers /user/history (comportement par défaut)
|
||||
/// Si fourni, appelle ce callback avec le typeId (ou null pour "Tous")
|
||||
final Function(int? typeId)? onTypeSelected;
|
||||
|
||||
/// Type de passage actuellement sélectionné (pour l'indicateur visuel)
|
||||
/// null = tous les passages
|
||||
final int? selectedTypeId;
|
||||
|
||||
const BtnPassages({
|
||||
super.key,
|
||||
this.onAddPassage,
|
||||
this.onTypeSelected,
|
||||
this.selectedTypeId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Récupérer l'utilisateur courant
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final currentOpeUserId = currentUser?.opeUserId;
|
||||
final currentOperation = userRepository.getCurrentOperation();
|
||||
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
|
||||
// Vérifier si le type Lot doit être affiché
|
||||
final shouldShowLotType = _shouldShowLotType();
|
||||
|
||||
return SizedBox(
|
||||
height: 80,
|
||||
width: double.infinity,
|
||||
child: ValueListenableBuilder<Box<PassageModel>>(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, box, child) {
|
||||
// Filtrer les passages de l'opération courante
|
||||
final allPassages = box.values.where((p) {
|
||||
if (currentOperation == null) return false;
|
||||
if (p.fkOperation != currentOperation.id) return false;
|
||||
|
||||
// Mode Admin : afficher tous les passages de l'opération
|
||||
if (isAdmin) return true;
|
||||
|
||||
// Mode Membre : logique spéciale pour type 2 (À finaliser) : afficher tous
|
||||
if (p.fkType == 2) return true;
|
||||
|
||||
// Mode Membre : autres types : seulement les passages de l'utilisateur
|
||||
return p.fkUser == currentOpeUserId;
|
||||
}).toList();
|
||||
|
||||
// Calculer les statistiques par type
|
||||
final Map<int, int> countsByType = {};
|
||||
int totalPassages = 0;
|
||||
|
||||
for (final passage in allPassages) {
|
||||
countsByType[passage.fkType] = (countsByType[passage.fkType] ?? 0) + 1;
|
||||
totalPassages++;
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// Colonne 1 : Total (non cliquable)
|
||||
Expanded(
|
||||
child: _buildTotalColumn(context, totalPassages),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
|
||||
// Colonnes 2-7 : Types de passages (cliquables)
|
||||
...AppKeys.typesPassages.entries.expand((entry) {
|
||||
final typeId = entry.key;
|
||||
final typeInfo = entry.value;
|
||||
|
||||
// Exclure le type Lot (5) si chkLotActif = false
|
||||
if (typeId == 5 && !shouldShowLotType) {
|
||||
return <Widget>[];
|
||||
}
|
||||
|
||||
final count = countsByType[typeId] ?? 0;
|
||||
final titre = typeInfo['titre'] as String;
|
||||
final couleur = Color(typeInfo['couleur2'] as int);
|
||||
final iconData = typeInfo['icon_data'] as IconData;
|
||||
|
||||
return <Widget>[
|
||||
Expanded(
|
||||
child: _buildTypeColumn(
|
||||
context,
|
||||
typeId,
|
||||
titre,
|
||||
count,
|
||||
couleur,
|
||||
iconData,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
];
|
||||
}),
|
||||
|
||||
// Colonne 8 : Bouton + (nouveau passage)
|
||||
Expanded(
|
||||
child: _buildAddColumn(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Colonne TOTAL (cliquable, affiche tous les passages)
|
||||
Widget _buildTotalColumn(BuildContext context, int total) {
|
||||
final bool isSelected = selectedTypeId == null;
|
||||
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
if (onTypeSelected != null) {
|
||||
// Mode callback : appeler le callback avec null (tous les passages)
|
||||
onTypeSelected!(null);
|
||||
} else {
|
||||
// Mode navigation : sauvegarder dans Hive et naviguer
|
||||
try {
|
||||
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
await Hive.openBox(AppKeys.settingsBoxName);
|
||||
}
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
await settingsBox.delete('history_selectedTypeId');
|
||||
debugPrint('BtnPassages: Filtre type réinitialisé (tous les passages)');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur réinitialisation filtre: $e');
|
||||
}
|
||||
|
||||
// Navigation vers /history avec GoRouter (détection automatique admin/user)
|
||||
if (context.mounted) {
|
||||
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
context.go(isAdmin ? '/admin/history' : '/user/history');
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
border: Border.all(
|
||||
color: Colors.grey[400]!,
|
||||
width: isSelected ? 5 : 1,
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(AppTheme.borderRadiusMedium),
|
||||
bottomLeft: Radius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.route,
|
||||
size: 20,
|
||||
color: Colors.black54,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
total.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
total > 1 ? 'passages' : 'passage',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Colonne TYPE DE PASSAGE (cliquable, navigue vers /history avec filtre)
|
||||
Widget _buildTypeColumn(
|
||||
BuildContext context,
|
||||
int typeId,
|
||||
String titre,
|
||||
int count,
|
||||
Color couleur,
|
||||
IconData iconData,
|
||||
) {
|
||||
final bool isSelected = selectedTypeId == typeId;
|
||||
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
if (onTypeSelected != null) {
|
||||
// Mode callback : appeler le callback avec le typeId
|
||||
onTypeSelected!(typeId);
|
||||
} else {
|
||||
// Mode navigation : sauvegarder dans Hive et naviguer
|
||||
try {
|
||||
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
await Hive.openBox(AppKeys.settingsBoxName);
|
||||
}
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
await settingsBox.put('history_selectedTypeId', typeId);
|
||||
debugPrint('BtnPassages: Type $typeId sauvegardé dans Hive');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur sauvegarde type: $e');
|
||||
}
|
||||
|
||||
// Navigation vers /history avec GoRouter (détection automatique admin/user)
|
||||
if (context.mounted) {
|
||||
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
context.go(isAdmin ? '/admin/history' : '/user/history');
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: couleur.withOpacity(0.1),
|
||||
border: Border.all(
|
||||
color: couleur,
|
||||
width: isSelected ? 5 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
iconData,
|
||||
size: 20,
|
||||
color: couleur,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
count.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: couleur,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Text(
|
||||
titre,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: couleur,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Colonne NOUVEAU PASSAGE (bouton +, fond vert)
|
||||
Widget _buildAddColumn(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
if (onAddPassage != null) {
|
||||
onAddPassage!();
|
||||
} else {
|
||||
// Par défaut, ouvrir le dialogue de création
|
||||
_showPassageFormDialog(context);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.buttonSuccessColor.withOpacity(0.1),
|
||||
border: Border.all(
|
||||
color: AppTheme.buttonSuccessColor,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(AppTheme.borderRadiusMedium),
|
||||
bottomRight: Radius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_circle_outline,
|
||||
size: 24,
|
||||
color: AppTheme.buttonSuccessColor,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Nouveau',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppTheme.buttonSuccessColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Vérifier si le type Lot doit être affiché
|
||||
bool _shouldShowLotType() {
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
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
|
||||
}
|
||||
|
||||
/// Afficher le dialogue de création de passage
|
||||
Future<void> _showPassageFormDialog(BuildContext context) async {
|
||||
await showDialog<PassageModel>(
|
||||
context: context,
|
||||
builder: (context) => PassageFormDialog(
|
||||
title: 'Nouveau passage',
|
||||
readOnly: false,
|
||||
passageRepository: passageRepository,
|
||||
userRepository: userRepository,
|
||||
operationRepository: operationRepository,
|
||||
amicaleRepository: amicaleRepository,
|
||||
onSuccess: () {
|
||||
debugPrint('BtnPassages: Passage créé avec succès');
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -190,7 +189,8 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
}
|
||||
|
||||
/// Calcule les données d'activité depuis la Hive box
|
||||
List<ActivityData> _calculateActivityData(Box<PassageModel> passagesBox, int daysToShow) {
|
||||
List<ActivityData> _calculateActivityData(
|
||||
Box<PassageModel> passagesBox, int daysToShow) {
|
||||
try {
|
||||
final passages = passagesBox.values.toList();
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
@@ -200,7 +200,8 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
if (!widget.showAllPassages && currentUser != null) {
|
||||
final userSectors = userRepository.getUserSectors();
|
||||
userSectorIds = userSectors.map((sector) => sector.id).toSet();
|
||||
debugPrint('ActivityChart: Mode USER - Secteurs assignés: $userSectorIds');
|
||||
debugPrint(
|
||||
'ActivityChart: Mode USER - Secteurs assignés: $userSectorIds');
|
||||
} else {
|
||||
debugPrint('ActivityChart: Mode ADMIN - Tous les passages');
|
||||
}
|
||||
@@ -209,7 +210,8 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
final endDate = DateTime.now();
|
||||
final startDate = endDate.subtract(Duration(days: daysToShow - 1));
|
||||
|
||||
debugPrint('ActivityChart: Période du ${DateFormat('yyyy-MM-dd').format(startDate)} au ${DateFormat('yyyy-MM-dd').format(endDate)}');
|
||||
debugPrint(
|
||||
'ActivityChart: Période du ${DateFormat('yyyy-MM-dd').format(startDate)} au ${DateFormat('yyyy-MM-dd').format(endDate)}');
|
||||
debugPrint('ActivityChart: Nombre total de passages: ${passages.length}');
|
||||
|
||||
// Préparer les données par date
|
||||
@@ -232,29 +234,25 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
for (final passage in passages) {
|
||||
// Appliquer les filtres
|
||||
bool shouldInclude = true;
|
||||
String excludeReason = '';
|
||||
|
||||
// Filtrer par secteurs assignés si nécessaire (pour les users)
|
||||
if (userSectorIds != null && !userSectorIds.contains(passage.fkSector)) {
|
||||
if (userSectorIds != null &&
|
||||
!userSectorIds.contains(passage.fkSector)) {
|
||||
shouldInclude = false;
|
||||
excludeReason = 'Secteur ${passage.fkSector} non assigné';
|
||||
}
|
||||
|
||||
// Exclure les passages de type 2 (À finaliser) avec nbPassages = 0
|
||||
if (shouldInclude && passage.fkType == 2 && passage.nbPassages == 0) {
|
||||
shouldInclude = false;
|
||||
excludeReason = 'Type 2 avec nbPassages=0';
|
||||
}
|
||||
|
||||
// Vérifier si le passage est dans la période
|
||||
final passageDate = passage.passedAt;
|
||||
if (shouldInclude && (passageDate == null ||
|
||||
passageDate.isBefore(startDate) ||
|
||||
passageDate.isAfter(endDate))) {
|
||||
if (shouldInclude &&
|
||||
(passageDate == null ||
|
||||
passageDate.isBefore(startDate) ||
|
||||
passageDate.isAfter(endDate))) {
|
||||
shouldInclude = false;
|
||||
excludeReason = passageDate == null
|
||||
? 'Date null'
|
||||
: 'Hors période (${DateFormat('yyyy-MM-dd').format(passageDate)})';
|
||||
}
|
||||
|
||||
if (shouldInclude && passageDate != null) {
|
||||
@@ -264,12 +262,16 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
(dataByDate[dateStr]![passage.fkType] ?? 0) + 1;
|
||||
includedCount++;
|
||||
}
|
||||
} else if (!shouldInclude && userSectorIds != null) {
|
||||
debugPrint('ActivityChart: Passage #${passage.id} exclu - $excludeReason (type=${passage.fkType}, secteur=${passage.fkSector}, date=${passageDate != null ? DateFormat('yyyy-MM-dd').format(passageDate) : 'null'})');
|
||||
}
|
||||
// Debug désactivé pour éviter la pollution de la console avec les passages type 2 sans date
|
||||
// else if (!shouldInclude && userSectorIds != null) {
|
||||
// debugPrint(
|
||||
// 'ActivityChart: Passage #${passage.id} exclu - $excludeReason (type=${passage.fkType}, secteur=${passage.fkSector}, date=${passageDate != null ? DateFormat('yyyy-MM-dd').format(passageDate) : 'null'})');
|
||||
// }
|
||||
}
|
||||
|
||||
debugPrint('ActivityChart: Passages inclus dans le graphique: $includedCount');
|
||||
debugPrint(
|
||||
'ActivityChart: Passages inclus dans le graphique: $includedCount');
|
||||
|
||||
// Convertir en liste d'ActivityData
|
||||
final List<ActivityData> chartData = [];
|
||||
@@ -520,9 +522,11 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
markerSettings: const MarkerSettings(isVisible: false),
|
||||
animationDuration: 1500,
|
||||
// Ajouter le callback de clic uniquement depuis home_page
|
||||
onPointTap: widget.showPeriodButtons ? (ChartPointDetails details) {
|
||||
_handlePointTap(details, typeId);
|
||||
} : null,
|
||||
onPointTap: widget.showPeriodButtons
|
||||
? (ChartPointDetails details) {
|
||||
_handlePointTap(details, typeId);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -537,11 +541,6 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
|
||||
// Récupérer les données du point cliqué
|
||||
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
final passages = passageBox.values.toList();
|
||||
|
||||
// Calculer la date de début (nombre de jours en arrière)
|
||||
final endDate = DateTime.now();
|
||||
final startDate = endDate.subtract(Duration(days: _selectedDays - 1));
|
||||
|
||||
// Créer les données d'activité
|
||||
final chartData = _calculateActivityData(passageBox, _selectedDays);
|
||||
@@ -562,11 +561,13 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
settingsBox.put('history_selectedTypeId', typeId);
|
||||
|
||||
// Date de début : début de la journée cliquée
|
||||
final startDateTime = DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 0, 0, 0);
|
||||
final startDateTime =
|
||||
DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 0, 0, 0);
|
||||
settingsBox.put('history_startDate', startDateTime.millisecondsSinceEpoch);
|
||||
|
||||
// Date de fin : fin de la journée cliquée
|
||||
final endDateTime = DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 23, 59, 59);
|
||||
final endDateTime = DateTime(
|
||||
clickedDate.year, clickedDate.month, clickedDate.day, 23, 59, 59);
|
||||
settingsBox.put('history_endDate', endDateTime.millisecondsSinceEpoch);
|
||||
|
||||
// Naviguer vers la page historique
|
||||
@@ -592,7 +593,7 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.withValues(alpha: 0.1),
|
||||
: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/// Bibliothèque de widgets de graphiques pour l'application GeoSector
|
||||
library geosector_charts;
|
||||
|
||||
export 'payment_data.dart';
|
||||
export 'payment_summary_card.dart';
|
||||
export 'passage_data.dart';
|
||||
export 'passage_utils.dart';
|
||||
export 'passage_summary_card.dart';
|
||||
export 'activity_chart.dart';
|
||||
export 'combined_chart.dart';
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_data.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_utils.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Widget de graphique combiné pour afficher les passages et règlements
|
||||
class CombinedChart extends StatelessWidget {
|
||||
/// Liste des données de passage par type
|
||||
final List<Map<String, dynamic>> passageData;
|
||||
|
||||
/// Liste des données de règlement par type
|
||||
final List<Map<String, dynamic>> paymentData;
|
||||
|
||||
/// Type de période (Jour, Semaine, Mois, Année)
|
||||
final String periodType;
|
||||
|
||||
/// Hauteur du graphique
|
||||
final double height;
|
||||
|
||||
/// Largeur des barres
|
||||
final double barWidth;
|
||||
|
||||
/// Rayon des points sur les lignes
|
||||
final double dotRadius;
|
||||
|
||||
/// Épaisseur des lignes
|
||||
final double lineWidth;
|
||||
|
||||
/// Montant maximum pour l'axe Y des règlements
|
||||
final double? maxYAmount;
|
||||
|
||||
/// Nombre maximum pour l'axe Y des passages
|
||||
final int? maxYCount;
|
||||
|
||||
const CombinedChart({
|
||||
super.key,
|
||||
required this.passageData,
|
||||
required this.paymentData,
|
||||
this.periodType = 'Jour',
|
||||
this.height = 300,
|
||||
this.barWidth = 16,
|
||||
this.dotRadius = 4,
|
||||
this.lineWidth = 3,
|
||||
this.maxYAmount,
|
||||
this.maxYCount,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Convertir les données brutes en modèles structurés
|
||||
final passagesByType = PassageUtils.getPassageDataByType(passageData);
|
||||
final paymentsByType = PassageUtils.getPaymentDataByType(paymentData);
|
||||
|
||||
// Extraire les dates uniques pour l'axe X
|
||||
final List<DateTime> allDates = [];
|
||||
for (final data in passageData) {
|
||||
final DateTime date = data['date'] is DateTime
|
||||
? data['date']
|
||||
: DateTime.parse(data['date']);
|
||||
if (!allDates.any((d) =>
|
||||
d.year == date.year && d.month == date.month && d.day == date.day)) {
|
||||
allDates.add(date);
|
||||
}
|
||||
}
|
||||
|
||||
// Trier les dates
|
||||
allDates.sort((a, b) => a.compareTo(b));
|
||||
|
||||
// Calculer le maximum pour les axes Y
|
||||
double maxAmount = 0;
|
||||
for (final typeData in paymentsByType) {
|
||||
for (final data in typeData) {
|
||||
if (data.amount > maxAmount) {
|
||||
maxAmount = data.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int maxCount = 0;
|
||||
for (final typeData in passagesByType) {
|
||||
for (final data in typeData) {
|
||||
if (data.count > maxCount) {
|
||||
maxCount = data.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utiliser les maximums fournis ou calculés
|
||||
final effectiveMaxYAmount = maxYAmount ?? (maxAmount * 1.2).ceilToDouble();
|
||||
final effectiveMaxYCount = maxYCount ?? (maxCount * 1.2).ceil();
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: effectiveMaxYCount.toDouble(),
|
||||
barTouchData: BarTouchData(
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
tooltipPadding: const EdgeInsets.all(8),
|
||||
tooltipMargin: 8,
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
final date = allDates[group.x.toInt()];
|
||||
final formattedDate = DateFormat('dd/MM').format(date);
|
||||
|
||||
// Calculer le total des passages pour cette date
|
||||
int totalPassages = 0;
|
||||
for (final typeData in passagesByType) {
|
||||
for (final data in typeData) {
|
||||
if (data.date.year == date.year &&
|
||||
data.date.month == date.month &&
|
||||
data.date.day == date.day) {
|
||||
totalPassages += data.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BarTooltipItem(
|
||||
'$formattedDate: $totalPassages passages',
|
||||
TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
getTitlesWidget: (value, meta) {
|
||||
if (value >= 0 && value < allDates.length) {
|
||||
final date = allDates[value.toInt()];
|
||||
final formattedDate =
|
||||
PassageUtils.formatDateForChart(date, periodType);
|
||||
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
space: 8,
|
||||
child: Text(
|
||||
formattedDate,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
space: 8,
|
||||
child: Text(
|
||||
value.toInt().toString(),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
reservedSize: 30,
|
||||
),
|
||||
),
|
||||
rightTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
// Convertir la valeur de l'axe Y des passages à l'échelle des montants
|
||||
final amountValue =
|
||||
(value / effectiveMaxYCount) * effectiveMaxYAmount;
|
||||
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
space: 8,
|
||||
child: Text(
|
||||
'${amountValue.toInt()}€',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
reservedSize: 40,
|
||||
),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: theme.dividerColor.withValues(alpha: 0.2),
|
||||
strokeWidth: 1,
|
||||
);
|
||||
},
|
||||
drawVerticalLine: false,
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
barGroups: _createBarGroups(allDates, passagesByType),
|
||||
extraLinesData: const ExtraLinesData(
|
||||
horizontalLines: [],
|
||||
verticalLines: [],
|
||||
extraLinesOnTop: true,
|
||||
),
|
||||
),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Créer les groupes de barres pour les passages
|
||||
List<BarChartGroupData> _createBarGroups(
|
||||
List<DateTime> allDates,
|
||||
List<List<PassageData>> passagesByType,
|
||||
) {
|
||||
final List<BarChartGroupData> groups = [];
|
||||
|
||||
for (int i = 0; i < allDates.length; i++) {
|
||||
final date = allDates[i];
|
||||
|
||||
// Calculer le total des passages pour cette date
|
||||
int totalPassages = 0;
|
||||
for (final typeData in passagesByType) {
|
||||
for (final data in typeData) {
|
||||
if (data.date.year == date.year &&
|
||||
data.date.month == date.month &&
|
||||
data.date.day == date.day) {
|
||||
totalPassages += data.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Créer un groupe de barres pour cette date
|
||||
groups.add(
|
||||
BarChartGroupData(
|
||||
x: i,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: totalPassages.toDouble(),
|
||||
color: Colors.blue.shade700,
|
||||
width: barWidth,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(6),
|
||||
topRight: Radius.circular(6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget de légende pour le graphique combiné
|
||||
class CombinedChartLegend extends StatelessWidget {
|
||||
const CombinedChartLegend({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildLegendItem('Passages', Colors.blue.shade700, isBar: true),
|
||||
_buildLegendItem('Espèces', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem('Chèques', const Color(0xFF2196F3)),
|
||||
_buildLegendItem('CB', const Color(0xFFF44336)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Créer un élément de légende
|
||||
Widget _buildLegendItem(String label, Color color, {bool isBar = false}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: isBar ? BoxShape.rectangle : BoxShape.circle,
|
||||
borderRadius: isBar ? BorderRadius.circular(3) : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -184,7 +184,7 @@ class _PassageSummaryCardState extends State<PassageSummaryCard>
|
||||
widget.backgroundIcon,
|
||||
size: widget.backgroundIconSize,
|
||||
color: (widget.backgroundIconColor ?? AppTheme.primaryColor)
|
||||
.withValues(alpha: widget.backgroundIconOpacity),
|
||||
.withOpacity(widget.backgroundIconOpacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -161,7 +161,7 @@ class _PaymentSummaryCardState extends State<PaymentSummaryCard>
|
||||
widget.backgroundIcon,
|
||||
size: widget.backgroundIconSize,
|
||||
color: (widget.backgroundIconColor ?? Colors.blue)
|
||||
.withValues(alpha: widget.backgroundIconOpacity),
|
||||
.withOpacity(widget.backgroundIconOpacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -422,10 +422,10 @@ class _PaymentSummaryCardState extends State<PaymentSummaryCard>
|
||||
|
||||
// En mode user, filtrer uniquement les passages créés par l'utilisateur (fkUser)
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? filterUserId = widget.showAllPayments ? null : currentUser?.id;
|
||||
final int? filterUserId = widget.showAllPayments ? null : currentUser?.opeUserId;
|
||||
|
||||
for (final passage in passagesBox.values) {
|
||||
// En mode user, ne compter que les passages de l'utilisateur
|
||||
// En mode user, ne compter que les passages de l'utilisateur (comparer avec ope_users.id)
|
||||
if (filterUserId != null && passage.fkUser != filterUserId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class _ChatInputState extends State<ChatInput> {
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
@@ -195,7 +195,7 @@ class _ChatInputState extends State<ChatInput> {
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
color: color.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
|
||||
@@ -87,7 +87,7 @@ class ChatMessages extends StatelessWidget {
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor:
|
||||
AppTheme.primaryColor.withValues(alpha: 0.2),
|
||||
AppTheme.primaryColor.withOpacity(0.2),
|
||||
backgroundImage: message['avatar'] != null
|
||||
? AssetImage(message['avatar'] as String)
|
||||
: null,
|
||||
@@ -141,7 +141,7 @@ class ChatMessages extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
|
||||
@@ -31,7 +31,7 @@ class ChatSidebar extends StatelessWidget {
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -114,9 +114,9 @@ class ChatSidebar extends StatelessWidget {
|
||||
|
||||
return ListTile(
|
||||
selected: isSelected,
|
||||
selectedTileColor: Colors.blue.withValues(alpha: 0.1),
|
||||
selectedTileColor: Colors.blue.withOpacity(0.1),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.2),
|
||||
backgroundColor: AppTheme.primaryColor.withOpacity(0.2),
|
||||
backgroundImage: contact['avatar'] != null
|
||||
? AssetImage(contact['avatar'] as String)
|
||||
: null,
|
||||
|
||||
@@ -78,7 +78,7 @@ class ClearCacheDialog extends StatelessWidget {
|
||||
'Note : Cette opération est nécessaire en raison d\'une mise à jour de la structure des données. Toutes vos données seront récupérées depuis le serveur après reconnexion.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/data/models/pending_request.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
|
||||
/// Widget qui affiche l'état de la connexion Internet et le nombre de requêtes en attente
|
||||
class ConnectivityIndicator extends StatefulWidget {
|
||||
@@ -105,10 +106,10 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.1),
|
||||
color: theme.colorScheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.3),
|
||||
color: theme.colorScheme.error.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -187,38 +188,41 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: pendingCount > 0
|
||||
? Colors.orange.withValues(alpha: 0.1 * _animation.value)
|
||||
: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: pendingCount > 0
|
||||
? Colors.orange.withValues(alpha: 0.3 * _animation.value)
|
||||
: color.withValues(alpha: 0.3),
|
||||
return GestureDetector(
|
||||
onTap: pendingCount > 0 ? () => _showPendingRequestsDialog(context) : null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: pendingCount > 0
|
||||
? Colors.orange.withOpacity(0.1 * _animation.value)
|
||||
: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: pendingCount > 0
|
||||
? Colors.orange.withOpacity(0.3 * _animation.value)
|
||||
: color.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
pendingCount > 0 ? Icons.sync : icon,
|
||||
color: pendingCount > 0 ? Colors.orange : color,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
pendingCount > 0
|
||||
? '$pendingCount en attente'
|
||||
: connectionType,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
pendingCount > 0 ? Icons.sync : icon,
|
||||
color: pendingCount > 0 ? Colors.orange : color,
|
||||
fontWeight: FontWeight.bold,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
pendingCount > 0
|
||||
? '$pendingCount en attente'
|
||||
: connectionType,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: pendingCount > 0 ? Colors.orange : color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -238,10 +242,10 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.1),
|
||||
color: theme.colorScheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.3),
|
||||
color: theme.colorScheme.error.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -270,10 +274,10 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: color.withValues(alpha: 0.3),
|
||||
color: color.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -346,4 +350,335 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
|
||||
return theme.colorScheme.error;
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche une boîte de dialogue pour gérer les requêtes en attente
|
||||
void _showPendingRequestsDialog(BuildContext context) {
|
||||
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => ValueListenableBuilder<Box<PendingRequest>>(
|
||||
valueListenable: box.listenable(),
|
||||
builder: (context, box, _) {
|
||||
final requests = box.values.toList()
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
|
||||
// Si plus de requêtes, fermer la dialog
|
||||
if (requests.isEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.sync_problem, color: Colors.orange),
|
||||
const SizedBox(width: 8),
|
||||
Text('Requêtes en attente (${requests.length})'),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Actions globales
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
// Réessayer toutes les requêtes
|
||||
Navigator.of(dialogContext).pop();
|
||||
await ApiService.instance.processPendingRequests();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Traitement des requêtes en cours...'),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Tout réessayer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
// Confirmer avant de tout supprimer
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: dialogContext,
|
||||
builder: (confirmContext) => AlertDialog(
|
||||
title: const Text('Confirmation'),
|
||||
content: const Text(
|
||||
'Êtes-vous sûr de vouloir supprimer toutes les requêtes en attente ?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(confirmContext).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(confirmContext).pop(true),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await box.clear();
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Toutes les requêtes ont été supprimées'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.delete_sweep),
|
||||
label: const Text('Tout supprimer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
// Liste des requêtes
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: requests.length,
|
||||
itemBuilder: (context, index) {
|
||||
final request = requests[index];
|
||||
final hasConflict = request.metadata?['hasConflict'] == true;
|
||||
final hasErrors = request.retryCount >= 5;
|
||||
|
||||
return Card(
|
||||
color: hasConflict
|
||||
? Colors.red.shade50
|
||||
: hasErrors
|
||||
? Colors.orange.shade50
|
||||
: null,
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
hasConflict
|
||||
? Icons.error
|
||||
: hasErrors
|
||||
? Icons.warning
|
||||
: Icons.sync,
|
||||
color: hasConflict
|
||||
? Colors.red
|
||||
: hasErrors
|
||||
? Colors.orange
|
||||
: Colors.blue,
|
||||
),
|
||||
title: Text('${request.method} ${request.path}'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Créé: ${_formatDate(request.createdAt)}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
if (request.retryCount > 0)
|
||||
Text(
|
||||
'Tentatives: ${request.retryCount}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
if (hasConflict)
|
||||
const Text(
|
||||
'CONFLIT (409)',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (hasErrors)
|
||||
const Text(
|
||||
'ÉCHEC (5 tentatives)',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Bouton détails
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline, size: 20),
|
||||
tooltip: 'Détails',
|
||||
onPressed: () => _showRequestDetails(dialogContext, request),
|
||||
),
|
||||
// Bouton réessayer
|
||||
if (hasConflict || hasErrors)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
tooltip: 'Réessayer',
|
||||
color: Colors.blue,
|
||||
onPressed: () async {
|
||||
await ApiService.instance.resolveConflictByRetry(request.id);
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Requête marquée pour réessai'),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
// Bouton supprimer
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
tooltip: 'Supprimer',
|
||||
color: Colors.red,
|
||||
onPressed: () async {
|
||||
if (hasConflict) {
|
||||
await ApiService.instance.resolveConflictByDeletion(request.id);
|
||||
} else {
|
||||
await box.delete(request.key);
|
||||
}
|
||||
// La dialog se ferme automatiquement via ValueListenableBuilder si box vide
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche les détails d'une requête
|
||||
void _showRequestDetails(BuildContext context, PendingRequest request) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Détails de la requête'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDetailRow('Méthode', request.method),
|
||||
_buildDetailRow('Chemin', request.path),
|
||||
_buildDetailRow('Créé le', _formatDate(request.createdAt)),
|
||||
_buildDetailRow('Tentatives', request.retryCount.toString()),
|
||||
if (request.tempId != null)
|
||||
_buildDetailRow('ID temporaire', request.tempId!),
|
||||
if (request.errorMessage != null)
|
||||
_buildDetailRow('Erreur', request.errorMessage!, isError: true),
|
||||
if (request.metadata != null && request.metadata!.isNotEmpty)
|
||||
_buildDetailRow('Métadonnées', request.metadata.toString()),
|
||||
if (request.data != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Données:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
request.data.toString(),
|
||||
style: const TextStyle(fontSize: 11, fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value, {bool isError = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: isError ? Colors.red : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inMinutes < 1) {
|
||||
return 'Il y a quelques secondes';
|
||||
} else if (diff.inHours < 1) {
|
||||
return 'Il y a ${diff.inMinutes} min';
|
||||
} else if (diff.inDays < 1) {
|
||||
return 'Il y a ${diff.inHours} h';
|
||||
} else {
|
||||
return 'Il y a ${diff.inDays} jour${diff.inDays > 1 ? 's' : ''}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class CustomTextField extends StatelessWidget {
|
||||
child: Text(
|
||||
'$currentLength/${maxLength ?? 0}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -165,7 +165,7 @@ class CustomTextField extends StatelessWidget {
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.5),
|
||||
color: theme.colorScheme.outline.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
@@ -190,7 +190,7 @@ class CustomTextField extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: readOnly ? theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3) : theme.colorScheme.surface,
|
||||
fillColor: readOnly ? theme.colorScheme.surfaceContainerHighest.withOpacity(0.3) : theme.colorScheme.surface,
|
||||
contentPadding: contentPadding ?? const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
@@ -203,7 +203,7 @@ class CustomTextField extends StatelessWidget {
|
||||
child: Text(
|
||||
'$currentLength/${maxLength ?? 0}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -7,6 +7,8 @@ import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
|
||||
import 'package:geosector_app/presentation/widgets/user_form_dialog.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
|
||||
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// AppBar personnalisée pour les tableaux de bord
|
||||
@@ -184,19 +186,45 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
readOnly: false,
|
||||
showRoleSelector: false,
|
||||
onSubmit: (updatedUser, {String? password}) async {
|
||||
// Afficher le loading
|
||||
final overlay = LoadingSpinOverlayUtils.show(
|
||||
context: context,
|
||||
message: 'Mise à jour du profil...',
|
||||
);
|
||||
|
||||
try {
|
||||
// Sauvegarder les modifications de l'utilisateur
|
||||
// Note: password est ignoré ici car l'utilisateur normal ne peut pas changer son mot de passe
|
||||
await userRepository.updateUser(updatedUser);
|
||||
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ApiException.showSuccess(context, 'Profil mis à jour');
|
||||
// Afficher le résultat de succès
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: true,
|
||||
message: 'Profil mis à jour',
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur mise à jour de votre profil: $e');
|
||||
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
if (context.mounted) {
|
||||
ApiException.showError(context, e);
|
||||
// Afficher l'erreur
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: ApiException.fromError(e).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
648
app/lib/presentation/widgets/grouped_passages_dialog.dart
Normal file
648
app/lib/presentation/widgets/grouped_passages_dialog.dart
Normal file
@@ -0,0 +1,648 @@
|
||||
import 'package:flutter/material.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/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';
|
||||
|
||||
/// Dialogue pour afficher les passages groupés d'un immeuble (fkHabitat=2)
|
||||
class GroupedPassagesDialog extends StatelessWidget {
|
||||
final PassageModel referencePassage;
|
||||
final bool isAdmin;
|
||||
|
||||
const GroupedPassagesDialog({
|
||||
super.key,
|
||||
required this.referencePassage,
|
||||
this.isAdmin = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Construire l'adresse complète
|
||||
final String adresse =
|
||||
'${referencePassage.numero} ${referencePassage.rueBis} ${referencePassage.rue}'
|
||||
.trim();
|
||||
final String ville = referencePassage.ville;
|
||||
final String residence = referencePassage.residence;
|
||||
|
||||
// Calculer les dimensions
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
final dialogWidth = kIsWeb
|
||||
? 600.0 // Web : largeur fixe plus large
|
||||
: screenWidth * 0.9; // Mobile : 90% largeur
|
||||
final dialogHeight = screenHeight * 0.8; // 80% hauteur max
|
||||
|
||||
// Vérifier si l'utilisateur peut supprimer
|
||||
bool canDelete = isAdmin;
|
||||
if (!isAdmin) {
|
||||
try {
|
||||
final amicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
if (amicale != null) {
|
||||
canDelete = amicale.chkUserDeletePass == true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification des permissions: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Container(
|
||||
width: dialogWidth,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: dialogHeight,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// En-tête avec adresse, ville, résidence et bouton X
|
||||
_buildHeader(context, adresse, ville, residence),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
// Liste des passages avec ValueListenableBuilder
|
||||
Flexible(
|
||||
child: ValueListenableBuilder<Box<PassageModel>>(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName)
|
||||
.listenable(),
|
||||
builder: (context, box, child) {
|
||||
// Filtrer les passages de la même adresse
|
||||
final passages = _filterPassagesByAddress(box);
|
||||
|
||||
if (passages.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Text('Aucun passage trouvé'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: passages.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final passage = passages[index];
|
||||
return _buildPassageItem(context, passage, canDelete);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construire l'en-tête avec adresse, ville, résidence et boutons
|
||||
Widget _buildHeader(
|
||||
BuildContext context, String adresse, String ville, String residence) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Adresse
|
||||
if (adresse.isNotEmpty)
|
||||
Text(
|
||||
adresse,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
// Ville
|
||||
if (ville.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.location_city, size: 16, color: Colors.grey[600]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
ville,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
// Résidence
|
||||
if (residence.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.apartment, size: 16, color: Colors.grey[600]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
residence,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Bouton + pour ajouter un passage
|
||||
IconButton(
|
||||
onPressed: () => _showAddPassageDialog(context),
|
||||
icon: const Icon(Icons.add_circle, size: 28),
|
||||
tooltip: 'Ajouter un passage',
|
||||
color: Colors.green,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Bouton X pour fermer
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Fermer',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construire une ligne de passage
|
||||
Widget _buildPassageItem(
|
||||
BuildContext context, PassageModel passage, bool canDelete) {
|
||||
final int type = passage.fkType;
|
||||
|
||||
// Récupérer la couleur2 du type
|
||||
final Color typeColor =
|
||||
Color(AppKeys.typesPassages[type]?['couleur2'] ?? 0xFF9E9E9E);
|
||||
|
||||
// Niveau + Appt
|
||||
final String location = [
|
||||
if (passage.niveau.isNotEmpty) 'Niv. ${passage.niveau}',
|
||||
if (passage.appt.isNotEmpty) 'Appt ${passage.appt}',
|
||||
].join(', ');
|
||||
|
||||
// Calculer le montant et vérifier s'il est payé
|
||||
final amount = _parseAmount(passage.montant);
|
||||
final isPaid = amount > 0;
|
||||
final formattedAmount = '${amount.toStringAsFixed(2).replaceAll('.', ',')} €';
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
onTap: () => _showEditDialog(context, passage),
|
||||
leading: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: typeColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
// Nom
|
||||
if (passage.name.isNotEmpty)
|
||||
Flexible(
|
||||
child: Text(
|
||||
passage.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Sans nom',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: location.isNotEmpty || (isPaid && (type == 1 || type == 5))
|
||||
? _buildSubtitle(context, location, passage, isPaid, type, formattedAmount)
|
||||
: null,
|
||||
trailing: _buildTrailing(context, passage, canDelete),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construire la ligne 2 (subtitle) avec Niveau/Appt + Badge montant
|
||||
Widget _buildSubtitle(
|
||||
BuildContext context,
|
||||
String location,
|
||||
PassageModel passage,
|
||||
bool isPaid,
|
||||
int type,
|
||||
String formattedAmount,
|
||||
) {
|
||||
return Row(
|
||||
children: [
|
||||
// Niveau + Appt
|
||||
if (location.isNotEmpty)
|
||||
Text(
|
||||
location,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
const Spacer(),
|
||||
// Badge montant (si > 0 et type 1 ou 5)
|
||||
if (isPaid && (type == 1 || type == 5)) ...[
|
||||
// Récupérer le type de règlement
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final typeReglement = passage.fkTypeReglement;
|
||||
final reglementInfo = AppKeys.typesReglements[typeReglement];
|
||||
final reglementIcon = reglementInfo?['icon_data'] as IconData? ?? Icons.help_outline;
|
||||
final reglementColor = Color(reglementInfo?['couleur'] as int? ?? 0xFF9E9E9E);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: reglementColor.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: reglementColor.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
reglementIcon,
|
||||
size: 12,
|
||||
color: reglementColor,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
formattedAmount,
|
||||
style: TextStyle(
|
||||
color: reglementColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construire le trailing avec icône remarque et bouton delete (ligne 1)
|
||||
Widget? _buildTrailing(
|
||||
BuildContext context,
|
||||
PassageModel passage,
|
||||
bool canDelete,
|
||||
) {
|
||||
final List<Widget> trailingWidgets = [];
|
||||
|
||||
// Icône remarque (si passage.remarque non vide)
|
||||
if (passage.remarque.isNotEmpty) {
|
||||
trailingWidgets.add(
|
||||
Icon(
|
||||
Icons.comment_outlined,
|
||||
size: 16,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Bouton delete
|
||||
if (canDelete) {
|
||||
if (trailingWidgets.isNotEmpty) {
|
||||
trailingWidgets.add(const SizedBox(width: 8));
|
||||
}
|
||||
trailingWidgets.add(
|
||||
IconButton(
|
||||
onPressed: () => _showDeleteDialog(context, passage),
|
||||
icon: const Icon(Icons.delete, size: 20),
|
||||
tooltip: 'Supprimer',
|
||||
padding: const EdgeInsets.all(8),
|
||||
constraints: const BoxConstraints(),
|
||||
color: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Retourner null si aucun widget, sinon Row
|
||||
if (trailingWidgets.isEmpty) return null;
|
||||
if (trailingWidgets.length == 1) return trailingWidgets.first;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: trailingWidgets,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parser le montant depuis String vers double
|
||||
double _parseAmount(String montantStr) {
|
||||
if (montantStr.isEmpty) return 0.0;
|
||||
try {
|
||||
final cleaned = montantStr.replaceAll(',', '.');
|
||||
return double.tryParse(cleaned) ?? 0.0;
|
||||
} catch (e) {
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtrer les passages par adresse et trier par niveau + appt
|
||||
List<PassageModel> _filterPassagesByAddress(Box<PassageModel> box) {
|
||||
// Clé d'adresse du passage de référence
|
||||
final referenceKey =
|
||||
'${referencePassage.numero}|${referencePassage.rueBis}|${referencePassage.rue}|${referencePassage.ville}';
|
||||
|
||||
// Filtrer les passages de la même adresse
|
||||
final passages = box.values.where((p) {
|
||||
final key = '${p.numero}|${p.rueBis}|${p.rue}|${p.ville}';
|
||||
return key == referenceKey && p.fkHabitat == 2;
|
||||
}).toList();
|
||||
|
||||
// Trier par niveau puis appt
|
||||
passages.sort((a, b) {
|
||||
// Convertir niveau en int pour tri numérique
|
||||
final nivA = int.tryParse(a.niveau) ?? 0;
|
||||
final nivB = int.tryParse(b.niveau) ?? 0;
|
||||
|
||||
if (nivA != nivB) {
|
||||
return nivA.compareTo(nivB);
|
||||
}
|
||||
|
||||
// Si même niveau, trier par appt
|
||||
final apptA = a.appt.toLowerCase();
|
||||
final apptB = b.appt.toLowerCase();
|
||||
return apptA.compareTo(apptB);
|
||||
});
|
||||
|
||||
return passages;
|
||||
}
|
||||
|
||||
/// Afficher le dialogue de modification
|
||||
void _showEditDialog(BuildContext context, PassageModel passage) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return PassageFormDialog(
|
||||
passage: passage,
|
||||
title: 'Modifier le passage',
|
||||
passageRepository: passageRepository,
|
||||
userRepository: userRepository,
|
||||
operationRepository: operationRepository,
|
||||
amicaleRepository: amicaleRepository,
|
||||
// Pas de callback onSuccess - ValueListenableBuilder gère la réactivité
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Afficher le dialogue d'ajout d'un passage pré-rempli
|
||||
void _showAddPassageDialog(BuildContext context) {
|
||||
// Créer un passage temporaire pré-rempli avec les infos de l'immeuble
|
||||
final newPassage = PassageModel(
|
||||
id: 0, // Nouveau passage
|
||||
fkOperation: referencePassage.fkOperation,
|
||||
fkSector: referencePassage.fkSector,
|
||||
fkUser: referencePassage.fkUser,
|
||||
fkType: 2, // Type "À finaliser" par défaut
|
||||
fkAdresse: referencePassage.fkAdresse,
|
||||
passedAt: DateTime.now(),
|
||||
numero: referencePassage.numero,
|
||||
rue: referencePassage.rue,
|
||||
rueBis: referencePassage.rueBis,
|
||||
ville: referencePassage.ville,
|
||||
residence: referencePassage.residence,
|
||||
fkHabitat: 2, // Appartement
|
||||
appt: '', // Vide pour saisie
|
||||
niveau: '', // Vide pour saisie
|
||||
gpsLat: referencePassage.gpsLat,
|
||||
gpsLng: referencePassage.gpsLng,
|
||||
nomRecu: '',
|
||||
remarque: '',
|
||||
montant: '0.00',
|
||||
fkTypeReglement: 4,
|
||||
emailErreur: '',
|
||||
nbPassages: 1,
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
stripePaymentId: null,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: true,
|
||||
isSynced: false,
|
||||
);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return PassageFormDialog(
|
||||
passage: newPassage,
|
||||
title: 'Nouveau passage dans l\'immeuble',
|
||||
passageRepository: passageRepository,
|
||||
userRepository: userRepository,
|
||||
operationRepository: operationRepository,
|
||||
amicaleRepository: amicaleRepository,
|
||||
// Pas de callback onSuccess - ValueListenableBuilder gère la réactivité
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Afficher le dialogue de suppression
|
||||
void _showDeleteDialog(BuildContext context, PassageModel passage) {
|
||||
// Réutiliser le même système de confirmation que PassageMapDialog
|
||||
final TextEditingController confirmController = TextEditingController();
|
||||
final String streetNumber = passage.numero;
|
||||
final String fullAddress =
|
||||
'${passage.numero} ${passage.rueBis} ${passage.rue}'.trim();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.red, size: 28),
|
||||
SizedBox(width: 8),
|
||||
Text('Confirmation de suppression'),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'ATTENTION : Cette action est irréversible !',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Vous êtes sur le point de supprimer définitivement le passage :',
|
||||
style: TextStyle(color: Colors.grey[800]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
fullAddress.isEmpty ? 'Adresse inconnue' : fullAddress,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (passage.niveau.isNotEmpty || passage.appt.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
[
|
||||
if (passage.niveau.isNotEmpty) 'Niveau ${passage.niveau}',
|
||||
if (passage.appt.isNotEmpty) 'Appt ${passage.appt}',
|
||||
].join(', '),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (passage.name.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
passage.name,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Pour confirmer la suppression, veuillez saisir le numéro de rue de ce passage :',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: confirmController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Numéro de rue',
|
||||
hintText: streetNumber.isNotEmpty
|
||||
? 'Ex: $streetNumber'
|
||||
: 'Saisir le numéro',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.home),
|
||||
),
|
||||
keyboardType: TextInputType.text,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
confirmController.dispose();
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
// Vérifier que le numéro saisi correspond
|
||||
final enteredNumber = confirmController.text.trim();
|
||||
if (enteredNumber.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Veuillez saisir le numéro de rue'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (streetNumber.isNotEmpty &&
|
||||
enteredNumber.toUpperCase() != streetNumber.toUpperCase()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Le numéro de rue ne correspond pas'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fermer le dialog
|
||||
confirmController.dispose();
|
||||
Navigator.of(dialogContext).pop();
|
||||
|
||||
// Effectuer la suppression
|
||||
await _deletePassage(context, passage);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Supprimer définitivement'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Supprimer un passage
|
||||
Future<void> _deletePassage(BuildContext context, PassageModel passage) async {
|
||||
try {
|
||||
// Appeler le repository pour supprimer via l'API
|
||||
final success = await passageRepository.deletePassageViaApi(passage.id);
|
||||
|
||||
if (success && context.mounted) {
|
||||
ApiException.showSuccess(context, 'Passage supprimé avec succès');
|
||||
// Pas de callback - ValueListenableBuilder rafraîchit automatiquement
|
||||
} else if (context.mounted) {
|
||||
ApiException.showError(
|
||||
context, Exception('Erreur lors de la suppression'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur suppression passage: $e');
|
||||
if (context.mounted) {
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ class HiveResetDialog extends StatelessWidget {
|
||||
'Note : Si vous aviez des modifications non synchronisées, elles ont été perdues. Nous vous recommandons de synchroniser régulièrement vos données.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -19,7 +19,7 @@ class LoadingSpinOverlay extends StatefulWidget {
|
||||
this.spinnerColor = Colors.blue,
|
||||
this.textColor = Colors.white,
|
||||
this.blurAmount = 8.0,
|
||||
this.spinnerSize = 50.0,
|
||||
this.spinnerSize = 64.0,
|
||||
this.showCard = true,
|
||||
});
|
||||
|
||||
@@ -95,11 +95,11 @@ class _LoadingSpinOverlayState extends State<LoadingSpinOverlay>
|
||||
maxWidth: 280,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.92), // Semi-transparent
|
||||
color: Colors.white.withOpacity(0.92), // Semi-transparent
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
color: Colors.black.withOpacity(0.15),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 2,
|
||||
offset: const Offset(0, 8),
|
||||
@@ -114,7 +114,7 @@ class _LoadingSpinOverlayState extends State<LoadingSpinOverlay>
|
||||
width: widget.spinnerSize,
|
||||
height: widget.spinnerSize,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
strokeWidth: 4.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(widget.spinnerColor),
|
||||
),
|
||||
),
|
||||
@@ -145,7 +145,7 @@ class _LoadingSpinOverlayState extends State<LoadingSpinOverlay>
|
||||
width: widget.spinnerSize,
|
||||
height: widget.spinnerSize,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
strokeWidth: 4.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(widget.spinnerColor),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,7 +2,7 @@ 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_hive_store/http_cache_hive_store.dart'; // Mise à jour v2.0.0 (06/10/2025)
|
||||
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';
|
||||
@@ -160,7 +160,7 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
@@ -198,12 +198,6 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
// Format: mapbox.streets pour les rues, mapbox.satellite pour satellite
|
||||
urlTemplate = 'https://api.tiles.mapbox.com/v4/mapbox.streets/{z}/{x}/{y}@2x.png?access_token=$mapboxToken';
|
||||
}
|
||||
|
||||
// Debug pour vérifier la configuration
|
||||
debugPrint('MapboxMap: Plateforme: ${kIsWeb ? "Web" : "Mobile"}');
|
||||
debugPrint('MapboxMap: Environnement: $environment');
|
||||
debugPrint('MapboxMap: Token: ${mapboxToken.substring(0, 10)}...'); // Afficher seulement le début du token
|
||||
debugPrint('MapboxMap: URL Template: ${urlTemplate.substring(0, 50)}...');
|
||||
}
|
||||
|
||||
// Afficher un indicateur pendant l'initialisation du cache
|
||||
@@ -260,10 +254,8 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
),
|
||||
onMapEvent: (event) {
|
||||
if (event is MapEventMove) {
|
||||
setState(() {
|
||||
// Dans flutter_map 8.1.1, nous devons utiliser le contrôleur pour obtenir le zoom actuel
|
||||
_currentZoom = _mapController.camera.zoom;
|
||||
});
|
||||
// Mise à jour du zoom sans rebuild (la variable n'est pas utilisée dans le UI)
|
||||
_currentZoom = _mapController.camera.zoom;
|
||||
}
|
||||
|
||||
// Appeler le callback externe si fourni
|
||||
@@ -276,7 +268,7 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
// Tuiles de la carte (Mapbox)
|
||||
TileLayer(
|
||||
urlTemplate: urlTemplate,
|
||||
userAgentPackageName: 'app.geosector.fr',
|
||||
userAgentPackageName: 'app3.geosector.fr',
|
||||
maxNativeZoom: 19,
|
||||
maxZoom: 20,
|
||||
minZoom: 7,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,13 +27,13 @@ class MembreRowWidget extends StatelessWidget {
|
||||
|
||||
// Couleur de fond alternée
|
||||
final backgroundColor = isAlternate
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.05)
|
||||
? theme.colorScheme.primary.withOpacity(0.05)
|
||||
: Colors.transparent;
|
||||
|
||||
return InkWell(
|
||||
// Envelopper le contenu dans un InkWell
|
||||
onTap: onTap, // Utiliser le callback onTap
|
||||
hoverColor: theme.colorScheme.primary.withValues(alpha: 0.15),
|
||||
hoverColor: theme.colorScheme.primary.withOpacity(0.15),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -43,7 +43,7 @@ class MembreTableWidget extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -58,7 +58,7 @@ class MembreTableWidget extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
|
||||
margin: const EdgeInsets.only(bottom: 16.0),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.1),
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
child: Row(
|
||||
@@ -189,7 +189,7 @@ class MembreTableWidget extends StatelessWidget {
|
||||
child: Text(
|
||||
emptyMessage ?? 'Aucun membre trouvé',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -199,7 +199,7 @@ class MembreTableWidget extends StatelessWidget {
|
||||
return ListView.separated(
|
||||
itemCount: membres.length,
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
|
||||
color: Theme.of(context).dividerColor.withOpacity(0.3),
|
||||
height: 1,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:uuid/uuid.dart';
|
||||
/// Widget de test pour vérifier le fonctionnement de la file d'attente offline
|
||||
/// À utiliser uniquement en développement
|
||||
class OfflineTestButton extends StatefulWidget {
|
||||
const OfflineTestButton({Key? key}) : super(key: key);
|
||||
const OfflineTestButton({super.key});
|
||||
|
||||
@override
|
||||
State<OfflineTestButton> createState() => _OfflineTestButtonState();
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:geosector_app/core/repositories/operation_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
|
||||
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
|
||||
|
||||
class OperationFormDialog extends StatefulWidget {
|
||||
final OperationModel? operation;
|
||||
@@ -140,6 +142,12 @@ class _OperationFormDialogState extends State<OperationFormDialog> {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
|
||||
// Afficher l'overlay de chargement
|
||||
final overlay = LoadingSpinOverlayUtils.show(
|
||||
context: context,
|
||||
message: 'Enregistrement en cours...',
|
||||
);
|
||||
|
||||
try {
|
||||
// Récupérer l'utilisateur actuel pour le fkEntite
|
||||
final currentUser = widget.userRepository.getCurrentUser();
|
||||
@@ -173,45 +181,58 @@ class _OperationFormDialogState extends State<OperationFormDialog> {
|
||||
final success = await widget.operationRepository.saveOperationFromModel(operationData);
|
||||
|
||||
if (success && mounted) {
|
||||
debugPrint('=== SUCCÈS - AUTO-FERMETURE ===');
|
||||
debugPrint('=== context.mounted: ${context.mounted} ===');
|
||||
debugPrint('=== SUCCÈS ===');
|
||||
|
||||
// Délai pour laisser le temps à Hive de se synchroniser
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) {
|
||||
debugPrint('=== FERMETURE DIFFÉRÉE ===');
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
// Auto-fermeture de la dialog
|
||||
try {
|
||||
debugPrint('=== AVANT Navigator.pop() ===');
|
||||
Navigator.of(context).pop();
|
||||
debugPrint('=== APRÈS Navigator.pop() ===');
|
||||
} catch (e) {
|
||||
debugPrint('=== ERREUR Navigator.pop(): $e ===');
|
||||
}
|
||||
// Afficher le résultat de succès
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: true,
|
||||
message: widget.operation == null
|
||||
? "Nouvelle opération créée avec succès"
|
||||
: "Opération modifiée avec succès",
|
||||
);
|
||||
|
||||
// Notifier la page parente pour setState()
|
||||
debugPrint('=== AVANT onSuccess?.call() ===');
|
||||
// Auto-fermeture de la dialog
|
||||
if (mounted) {
|
||||
debugPrint('=== FERMETURE DIALOG ===');
|
||||
try {
|
||||
Navigator.of(context).pop();
|
||||
widget.onSuccess?.call();
|
||||
debugPrint('=== APRÈS onSuccess?.call() ===');
|
||||
|
||||
// Message de succès
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
debugPrint('=== AFFICHAGE MESSAGE SUCCÈS ===');
|
||||
ApiException.showSuccess(context, widget.operation == null ? "Nouvelle opération créée avec succès" : "Opération modifiée avec succès");
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('=== ERREUR Navigator.pop(): $e ===');
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (mounted) {
|
||||
debugPrint('=== ÉCHEC - AFFICHAGE ERREUR ===');
|
||||
ApiException.showError(context, Exception(widget.operation == null ? "Échec de la création de l'opération" : "Échec de la mise à jour de l'opération"));
|
||||
debugPrint('=== ÉCHEC ===');
|
||||
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
// Afficher l'erreur
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: widget.operation == null
|
||||
? "Échec de la création de l'opération"
|
||||
: "Échec de la mise à jour de l'opération",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('=== ERREUR dans _handleSubmit: $e ===');
|
||||
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
// Afficher l'erreur
|
||||
if (mounted) {
|
||||
ApiException.showError(context, e);
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: ApiException.fromError(e).message,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Réinitialiser l'état de soumission seulement si le widget est encore monté
|
||||
@@ -310,9 +331,9 @@ class _OperationFormDialogState extends State<OperationFormDialog> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.5)),
|
||||
border: Border.all(color: theme.colorScheme.outline.withOpacity(0.5)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: theme.colorScheme.surface.withValues(alpha: 0.3),
|
||||
color: theme.colorScheme.surface.withOpacity(0.3),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -422,10 +443,10 @@ class _OperationFormDialogState extends State<OperationFormDialog> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondaryContainer.withValues(alpha: 0.3),
|
||||
color: theme.colorScheme.secondaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||
color: theme.colorScheme.outline.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
|
||||
@@ -11,11 +11,15 @@ 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/services/stripe_connect_service.dart';
|
||||
import 'package:geosector_app/core/services/api_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';
|
||||
import 'package:geosector_app/presentation/widgets/form_section.dart';
|
||||
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
|
||||
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
|
||||
import 'package:geosector_app/presentation/widgets/payment_method_selection_dialog.dart';
|
||||
|
||||
class PassageFormDialog extends StatefulWidget {
|
||||
final PassageModel? passage;
|
||||
@@ -75,6 +79,10 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
// Variable pour Tap to Pay
|
||||
String? _stripePaymentIntentId;
|
||||
|
||||
// État d'expansion des sections
|
||||
bool _isAddressSectionExpanded = true;
|
||||
bool _isDateTimeSectionExpanded = false; // Toujours fermée par défaut
|
||||
|
||||
// Boîte Hive pour mémoriser la dernière adresse
|
||||
late Box _settingsBox;
|
||||
|
||||
@@ -183,6 +191,9 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
_fkHabitat = passage?.fkHabitat ?? 1;
|
||||
_fkTypeReglement = passage?.fkTypeReglement ?? 4;
|
||||
|
||||
// Section Adresse : ouverte si nouveau passage, fermée si modification
|
||||
_isAddressSectionExpanded = passage == null;
|
||||
|
||||
debugPrint('Initialisation des controllers...');
|
||||
|
||||
// S'assurer que toutes les valeurs null deviennent des chaînes vides
|
||||
@@ -308,14 +319,12 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
_fkTypeReglement = 4; // Non renseigné
|
||||
}
|
||||
|
||||
// Si c'est un nouveau passage et qu'on change de type, réinitialiser la date à maintenant
|
||||
if (widget.passage == null) {
|
||||
_passedAt = DateTime.now();
|
||||
_dateController.text =
|
||||
'${_passedAt.day.toString().padLeft(2, '0')}/${_passedAt.month.toString().padLeft(2, '0')}/${_passedAt.year}';
|
||||
_timeController.text =
|
||||
'${_passedAt.hour.toString().padLeft(2, '0')}:${_passedAt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
// Toujours mettre à jour la date et l'heure à maintenant lors de la sélection du type
|
||||
_passedAt = DateTime.now();
|
||||
_dateController.text =
|
||||
'${_passedAt.day.toString().padLeft(2, '0')}/${_passedAt.month.toString().padLeft(2, '0')}/${_passedAt.year}';
|
||||
_timeController.text =
|
||||
'${_passedAt.hour.toString().padLeft(2, '0')}:${_passedAt.minute.toString().padLeft(2, '0')}';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -334,10 +343,18 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
}
|
||||
|
||||
Future<void> _savePassage() async {
|
||||
if (_isSubmitting) return;
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
|
||||
// Afficher l'overlay de chargement
|
||||
final overlay = LoadingSpinOverlayUtils.show(
|
||||
context: context,
|
||||
message: 'Enregistrement en cours...',
|
||||
);
|
||||
|
||||
try {
|
||||
final currentUser = widget.userRepository.getCurrentUser();
|
||||
if (currentUser == null) {
|
||||
@@ -352,7 +369,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
// Déterminer les valeurs de montant et type de règlement selon le type de passage
|
||||
final String finalMontant =
|
||||
(_selectedPassageType == 1 || _selectedPassageType == 5)
|
||||
? _montantController.text.trim()
|
||||
? _montantController.text.trim().replaceAll(',', '.')
|
||||
: '0';
|
||||
// Déterminer le type de règlement final selon le type de passage
|
||||
final int finalTypeReglement;
|
||||
@@ -437,8 +454,8 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
|
||||
// Sauvegarder le passage d'abord
|
||||
PassageModel? savedPassage;
|
||||
if (widget.passage == null) {
|
||||
// Création d'un nouveau passage
|
||||
if (widget.passage == null || widget.passage!.id == 0) {
|
||||
// Création d'un nouveau passage (passage null OU id=0)
|
||||
savedPassage = await widget.passageRepository.createPassageWithReturn(passageData);
|
||||
} else {
|
||||
// Mise à jour d'un passage existant
|
||||
@@ -449,107 +466,114 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
}
|
||||
|
||||
if (savedPassage == null) {
|
||||
throw Exception(widget.passage == null
|
||||
throw Exception(widget.passage == null || widget.passage!.id == 0
|
||||
? "Échec de la création du passage"
|
||||
: "Échec de la mise à jour du passage");
|
||||
}
|
||||
|
||||
// Garantir le type non-nullable après la vérification
|
||||
final confirmedPassage = savedPassage;
|
||||
|
||||
// Mémoriser l'adresse pour la prochaine création de passage
|
||||
await _saveLastPassageAddress();
|
||||
|
||||
// Propager la résidence aux autres passages de l'immeuble si nécessaire
|
||||
if (_fkHabitat == 2 && _residenceController.text.trim().isNotEmpty) {
|
||||
await _propagateResidenceToBuilding(confirmedPassage);
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Vérifier si l'amicale a Stripe activé
|
||||
final amicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
final stripeEnabled = amicale?.chkStripe == true &&
|
||||
amicale?.stripeId != null &&
|
||||
amicale!.stripeId.isNotEmpty;
|
||||
|
||||
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)
|
||||
if (stripeEnabled) {
|
||||
// Masquer le loading avant d'afficher le dialog de sélection
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
// Afficher le dialog de sélection de méthode de paiement
|
||||
if (mounted) {
|
||||
final habitantName = _nameController.text.trim();
|
||||
await PaymentMethodSelectionDialog.show(
|
||||
context: context,
|
||||
passage: confirmedPassage,
|
||||
amount: montant,
|
||||
habitantName: habitantName.isNotEmpty ? habitantName : 'Client',
|
||||
stripeConnectService: StripeConnectService(
|
||||
apiService: ApiService.instance,
|
||||
),
|
||||
passageRepository: widget.passageRepository,
|
||||
onTapToPaySelected: () async {
|
||||
// Lancer le flow Tap to Pay
|
||||
final paymentSuccess = await _attemptTapToPayWithPassage(confirmedPassage, montant);
|
||||
|
||||
if (!paymentSuccess) {
|
||||
debugPrint('⚠️ Paiement Tap to Pay échoué pour le passage ${confirmedPassage.id}');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Fermer le formulaire après le choix de paiement
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Le device ne supporte pas Tap to Pay (Web ou device non compatible)
|
||||
// Stripe non activé pour cette amicale
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
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'];
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: true,
|
||||
message: "Passage enregistré avec succès.\n\nℹ️ Note : Les paiements par carte ne sont pas activés pour votre amicale. Contactez l'administrateur pour activer Stripe.",
|
||||
);
|
||||
|
||||
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.";
|
||||
}
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Pas de paiement CB, fermer le dialog avec succès
|
||||
// Pas de paiement CB, afficher le succès
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: true,
|
||||
message: widget.passage == null || widget.passage!.id == 0
|
||||
? "Nouveau passage créé avec succès"
|
||||
: "Passage modifié avec succès",
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Masquer le loading
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
// Afficher l'erreur
|
||||
if (mounted) {
|
||||
ApiException.showError(context, e);
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: ApiException.fromError(e).message,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
@@ -578,6 +602,45 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Propager la résidence aux autres passages de l'immeuble (fkType=2, même adresse, résidence vide)
|
||||
Future<void> _propagateResidenceToBuilding(PassageModel savedPassage) async {
|
||||
try {
|
||||
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
final residence = _residenceController.text.trim();
|
||||
|
||||
// Clé d'adresse du passage sauvegardé
|
||||
final addressKey = '${savedPassage.numero}|${savedPassage.rueBis}|${savedPassage.rue}|${savedPassage.ville}';
|
||||
|
||||
int updatedCount = 0;
|
||||
|
||||
// Parcourir tous les passages
|
||||
for (int i = 0; i < passagesBox.length; i++) {
|
||||
final passage = passagesBox.getAt(i);
|
||||
if (passage != null) {
|
||||
// Vérifier les critères
|
||||
final passageAddressKey = '${passage.numero}|${passage.rueBis}|${passage.rue}|${passage.ville}';
|
||||
|
||||
if (passage.id != savedPassage.id && // Pas le passage actuel
|
||||
passage.fkHabitat == 2 && // Appartement
|
||||
passageAddressKey == addressKey && // Même adresse
|
||||
passage.residence.trim().isEmpty) { // Résidence vide
|
||||
|
||||
// Mettre à jour la résidence dans Hive
|
||||
final updatedPassage = passage.copyWith(residence: residence);
|
||||
await passagesBox.put(passage.key, updatedPassage);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
debugPrint('✅ Résidence propagée à $updatedCount passage(s) de l\'immeuble');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur lors de la propagation de la résidence: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPassageTypeSelection() {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
@@ -643,7 +706,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(typeData['couleur2'] as int? ?? 0xFF000000)
|
||||
.withValues(alpha: 0.15),
|
||||
.withOpacity(0.15),
|
||||
border: Border.all(
|
||||
color: Color(typeData['couleur2'] as int? ?? 0xFF000000),
|
||||
width: isSelected ? 3 : 2,
|
||||
@@ -654,7 +717,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
BoxShadow(
|
||||
color: Color(typeData['couleur2'] as int? ??
|
||||
0xFF000000)
|
||||
.withValues(alpha: 0.2),
|
||||
.withOpacity(0.2),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
@@ -709,122 +772,222 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section Date et Heure
|
||||
FormSection(
|
||||
title: 'Date et Heure de passage',
|
||||
icon: Icons.schedule,
|
||||
children: [
|
||||
// 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,
|
||||
),
|
||||
],
|
||||
// Section Date et Heure (rétractable)
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
key: ValueKey(_isDateTimeSectionExpanded),
|
||||
initiallyExpanded: _isDateTimeSectionExpanded,
|
||||
onExpansionChanged: (expanded) {
|
||||
setState(() {
|
||||
_isDateTimeSectionExpanded = expanded;
|
||||
});
|
||||
},
|
||||
leading: Icon(
|
||||
Icons.schedule,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
title: _isDateTimeSectionExpanded
|
||||
? Text(
|
||||
'Date et Heure de passage',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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,
|
||||
Text(
|
||||
'Date et Heure de passage',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
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: 4),
|
||||
Text(
|
||||
'${_dateController.text} à ${_timeController.text}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
// 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(height: 24),
|
||||
|
||||
// Section Adresse
|
||||
FormSection(
|
||||
title: 'Adresse',
|
||||
icon: Icons.location_on,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: CustomTextField(
|
||||
controller: _numeroController,
|
||||
label: "Numéro",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
textAlign: TextAlign.right,
|
||||
keyboardType: TextInputType.number,
|
||||
readOnly: widget.readOnly,
|
||||
validator: _validateNumero,
|
||||
// Section Adresse (rétractable)
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
key: ValueKey(_isAddressSectionExpanded),
|
||||
initiallyExpanded: _isAddressSectionExpanded,
|
||||
onExpansionChanged: (expanded) {
|
||||
setState(() {
|
||||
_isAddressSectionExpanded = expanded;
|
||||
});
|
||||
},
|
||||
leading: Icon(
|
||||
Icons.location_on,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
title: _isAddressSectionExpanded
|
||||
? Text(
|
||||
'Adresse',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Adresse',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${_numeroController.text} ${_rueBisController.text} ${_rueController.text}, ${_villeController.text}'.trim().replaceAll(RegExp(r'\s+'), ' '),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: CustomTextField(
|
||||
controller: _numeroController,
|
||||
label: "Numéro",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
textAlign: TextAlign.right,
|
||||
keyboardType: TextInputType.number,
|
||||
readOnly: widget.readOnly,
|
||||
validator: _validateNumero,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: CustomTextField(
|
||||
controller: _rueBisController,
|
||||
label: "Bis, Ter...",
|
||||
showLabel: false,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _rueController,
|
||||
label: "Rue",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
readOnly: widget.readOnly,
|
||||
validator: _validateRue,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _villeController,
|
||||
label: "Ville",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
readOnly: widget.readOnly,
|
||||
validator: _validateVille,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: CustomTextField(
|
||||
controller: _rueBisController,
|
||||
label: "Bis, Ter...",
|
||||
showLabel: false,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _rueController,
|
||||
label: "Rue",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
readOnly: widget.readOnly,
|
||||
validator: _validateRue,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _villeController,
|
||||
label: "Ville",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
readOnly: widget.readOnly,
|
||||
validator: _validateVille,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -1014,7 +1177,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<int>(
|
||||
initialValue: _fkTypeReglement,
|
||||
value: _fkTypeReglement,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "Type de règlement *",
|
||||
border: OutlineInputBorder(),
|
||||
@@ -1149,7 +1312,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
? Color(AppKeys.typesPassages[_selectedPassageType]!['couleur2']
|
||||
as int? ??
|
||||
0xFF000000)
|
||||
.withValues(alpha: 0.1)
|
||||
.withOpacity(0.1)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
@@ -1319,7 +1482,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
: theme.colorScheme.primary;
|
||||
|
||||
return AppBar(
|
||||
backgroundColor: typeColor.withValues(alpha: 0.1),
|
||||
backgroundColor: typeColor.withOpacity(0.1),
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.close, color: typeColor),
|
||||
@@ -1413,20 +1576,16 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
|
||||
// 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",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: true,
|
||||
message: "Paiement effectué avec succès",
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1434,7 +1593,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
} catch (e) {
|
||||
debugPrint('Erreur Tap to Pay: $e');
|
||||
if (mounted) {
|
||||
ApiException.showError(context, e);
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: ApiException.fromError(e).message,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1453,35 +1616,28 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
return Scaffold(
|
||||
appBar: _buildMobileAppBar(),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildContent(),
|
||||
),
|
||||
// Contenu du formulaire
|
||||
if (!_showForm) ...[
|
||||
_buildPassageTypeSelection(),
|
||||
] else ...[
|
||||
_buildPassageForm(),
|
||||
],
|
||||
|
||||
// Boutons en bas du scroll
|
||||
if (_showForm && _selectedPassageType != null) ...[
|
||||
const SizedBox(height: 32),
|
||||
_buildFooterButtons(),
|
||||
const SizedBox(height: 16), // Padding supplémentaire pour le confort
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: _showForm && _selectedPassageType != null
|
||||
? SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _buildFooterButtons(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
} else {
|
||||
// Mode Dialog pour desktop/tablette
|
||||
|
||||
@@ -77,6 +77,29 @@ class PassageMapDialog extends StatelessWidget {
|
||||
// Ville
|
||||
if (passage.ville.isNotEmpty)
|
||||
_buildInfoRow(Icons.location_city, 'Ville', passage.ville),
|
||||
|
||||
// Type d'habitat
|
||||
if (passage.fkHabitat == 1)
|
||||
_buildInfoRow(Icons.home, 'Habitat', 'Maison')
|
||||
else if (passage.fkHabitat == 2) ...[
|
||||
_buildInfoRow(
|
||||
Icons.home,
|
||||
'Habitat',
|
||||
'Appartement${passage.niveau.isNotEmpty || passage.appt.isNotEmpty ? ' (' : ''}${passage.niveau.isNotEmpty ? 'Niveau ${passage.niveau}' : ''}${passage.niveau.isNotEmpty && passage.appt.isNotEmpty ? ', ' : ''}${passage.appt.isNotEmpty ? 'Appt ${passage.appt}' : ''}${passage.niveau.isNotEmpty || passage.appt.isNotEmpty ? ')' : ''}',
|
||||
),
|
||||
],
|
||||
|
||||
// Résidence
|
||||
if (passage.residence.isNotEmpty)
|
||||
_buildInfoRow(Icons.apartment, 'Résidence', passage.residence),
|
||||
|
||||
// Nom
|
||||
if (passage.name.isNotEmpty)
|
||||
_buildInfoRow(Icons.person, 'Nom', passage.name),
|
||||
|
||||
// Remarque
|
||||
if (passage.remarque.isNotEmpty)
|
||||
_buildInfoRow(Icons.note, 'Remarque', passage.remarque),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
|
||||
@@ -218,21 +218,21 @@ class _PassageFormState extends State<PassageForm> {
|
||||
decoration: InputDecoration(
|
||||
hintText: '0.00 €',
|
||||
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
fillColor: const Color(0xFFF4F5F6),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.1),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.1),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -360,10 +360,10 @@ class _PassageFormState extends State<PassageForm> {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF4F5F6).withValues(alpha: 0.85),
|
||||
color: const Color(0xFFF4F5F6).withOpacity(0.85),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF20335E).withValues(alpha: 0.1),
|
||||
color: const Color(0xFF20335E).withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/data/models/payment_link_result.dart';
|
||||
import 'package:geosector_app/presentation/widgets/qr_code_payment_dialog.dart';
|
||||
|
||||
/// Un widget réutilisable pour afficher une liste de passages (affichage pur)
|
||||
class PassagesListWidget extends StatelessWidget {
|
||||
@@ -35,6 +37,9 @@ class PassagesListWidget extends StatelessWidget {
|
||||
/// Callback appelé lorsque le bouton d'ajout est cliqué
|
||||
final VoidCallback? onAddPassage;
|
||||
|
||||
/// Type de passage filtré (optionnel, pour affichage dans le titre)
|
||||
final String? filteredPassageType;
|
||||
|
||||
const PassagesListWidget({
|
||||
super.key,
|
||||
required this.passages,
|
||||
@@ -47,6 +52,7 @@ class PassagesListWidget extends StatelessWidget {
|
||||
this.onDetailsView,
|
||||
this.onPassageDelete,
|
||||
this.onAddPassage,
|
||||
this.filteredPassageType,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -81,7 +87,7 @@ class PassagesListWidget extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Color.alphaBlend(
|
||||
theme.colorScheme.primary.withValues(alpha: 0.1),
|
||||
theme.colorScheme.primary.withOpacity(0.1),
|
||||
theme.colorScheme.surface,
|
||||
),
|
||||
),
|
||||
@@ -91,13 +97,13 @@ class PassagesListWidget extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.list_alt,
|
||||
Icons.route,
|
||||
size: 20,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${passages.length} passage${passages.length > 1 ? 's' : ''}',
|
||||
_buildPassageCountText(),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
@@ -200,23 +206,37 @@ class PassagesListWidget extends StatelessWidget {
|
||||
'icon_data': Icons.help_outline,
|
||||
};
|
||||
|
||||
// Récupérer nbPassages pour le type 2
|
||||
final nbPassages = passage['nb_passages'] as int? ?? passage['nbPassages'] as int? ?? 0;
|
||||
|
||||
// Récupérer la couleur de fond selon le type et nbPassages
|
||||
Color backgroundColor;
|
||||
Color iconColor;
|
||||
bool useOutlinedIcon = false;
|
||||
|
||||
if (typeId == 2) {
|
||||
// Type 2 (À finaliser) : adapter la couleur selon nbPassages
|
||||
final nbPassages = passage['nbPassages'] as int? ?? passage['nb_passages'] as int? ?? 0;
|
||||
if (nbPassages == 0) {
|
||||
backgroundColor = Color(typeInfo['couleur1'] as int? ?? 0xFFFFFFFF);
|
||||
iconColor = Color(typeInfo['couleur2'] as int? ?? 0xFFFFB978);
|
||||
useOutlinedIcon = true; // Utiliser l'icône outlined pour la visibilité
|
||||
} else if (nbPassages == 1) {
|
||||
backgroundColor = Color(typeInfo['couleur2'] as int? ?? 0xFFF7A278);
|
||||
backgroundColor = Color(typeInfo['couleur2'] as int? ?? 0xFFFFB978);
|
||||
iconColor = backgroundColor;
|
||||
useOutlinedIcon = false;
|
||||
} else {
|
||||
// nbPassages > 1
|
||||
backgroundColor = Color(typeInfo['couleur3'] as int? ?? 0xFFE65100);
|
||||
backgroundColor = Color(typeInfo['couleur3'] as int? ?? 0xFFE66F00);
|
||||
iconColor = backgroundColor;
|
||||
useOutlinedIcon = false;
|
||||
}
|
||||
} else {
|
||||
// Autres types : utiliser couleur2 par défaut
|
||||
backgroundColor = Color(typeInfo['couleur2'] as int? ?? 0xFF9E9E9E);
|
||||
iconColor = backgroundColor;
|
||||
useOutlinedIcon = false;
|
||||
}
|
||||
|
||||
final typeIcon = typeInfo['icon_data'] as IconData? ?? Icons.help_outline;
|
||||
|
||||
// Informations du passage
|
||||
@@ -291,13 +311,13 @@ class PassagesListWidget extends StatelessWidget {
|
||||
height: 50,
|
||||
width: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor.withValues(alpha: 0.5),
|
||||
color: backgroundColor.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
typeIcon,
|
||||
useOutlinedIcon ? Icons.refresh_outlined : typeIcon,
|
||||
size: 28,
|
||||
color: backgroundColor.withValues(alpha: 1.0),
|
||||
color: iconColor.withOpacity(1.0),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -308,23 +328,47 @@ class PassagesListWidget extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Ligne 1 : Date (si définie) + Actions à droite
|
||||
// Ligne 1 : Date (si définie) + Nom + Actions à droite
|
||||
Row(
|
||||
children: [
|
||||
// Date (si définie)
|
||||
if (formattedDate != null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
formattedDate,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
else
|
||||
const Spacer(),
|
||||
// Date et nom
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// Date (si définie)
|
||||
if (formattedDate != null)
|
||||
Text(
|
||||
formattedDate,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
||||
// Nom du passage (si défini)
|
||||
if (passage['name'] != null &&
|
||||
(passage['name'] as String).trim().isNotEmpty) ...[
|
||||
if (formattedDate != null)
|
||||
Text(
|
||||
' - ',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
passage['name'] as String,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Actions
|
||||
if (showActions) ...[
|
||||
@@ -343,7 +387,7 @@ class PassagesListWidget extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Ligne 2 : Adresse courte + Badge montant à droite
|
||||
// Ligne 2 : Adresse courte + Icônes + Badge montant à droite
|
||||
Row(
|
||||
children: [
|
||||
// Adresse courte
|
||||
@@ -359,6 +403,76 @@ class PassagesListWidget extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Icône remarque (si présente)
|
||||
if (passage['remarque'] != null &&
|
||||
(passage['remarque'] as String).trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Tooltip(
|
||||
message: passage['remarque'],
|
||||
preferBelow: false,
|
||||
child: Icon(
|
||||
Icons.comment_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Icône email (si présent)
|
||||
if (passage['email'] != null &&
|
||||
(passage['email'] as String).trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final email = passage['email'] as String;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Email: $email'),
|
||||
duration: const Duration(seconds: 3),
|
||||
action: SnackBarAction(
|
||||
label: 'OK',
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Tooltip(
|
||||
message: passage['email'],
|
||||
preferBelow: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
Icons.alternate_email,
|
||||
size: 16,
|
||||
color: (passage['emailErreur'] != null &&
|
||||
(passage['emailErreur'] as String).trim().isNotEmpty)
|
||||
? Colors.red.withOpacity(0.7)
|
||||
: Colors.blue.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Icône reçu (si présent)
|
||||
if (passage['nomRecu'] != null &&
|
||||
(passage['nomRecu'] as String).trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Tooltip(
|
||||
message: 'Reçu disponible',
|
||||
preferBelow: false,
|
||||
child: Icon(
|
||||
Icons.receipt_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.secondary.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Badge montant (si > 0 et type 1 ou 5)
|
||||
if (isPaid && (typeId == 1 || typeId == 5))
|
||||
Builder(
|
||||
@@ -368,17 +482,18 @@ class PassagesListWidget extends StatelessWidget {
|
||||
passage['payment'] as int? ??
|
||||
4; // 4 = Non renseigné par défaut
|
||||
|
||||
// Récupérer l'icône du type de règlement
|
||||
// Récupérer l'icône ET la couleur du type de règlement
|
||||
final reglementInfo = AppKeys.typesReglements[typeReglement];
|
||||
final reglementIcon = reglementInfo?['icon_data'] as IconData? ?? Icons.help_outline;
|
||||
final reglementColor = Color(reglementInfo?['couleur'] as int? ?? 0xFF9E9E9E); // Gris par défaut
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.15),
|
||||
color: reglementColor.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.green.withValues(alpha: 0.4),
|
||||
color: reglementColor.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -387,13 +502,13 @@ class PassagesListWidget extends StatelessWidget {
|
||||
Icon(
|
||||
reglementIcon,
|
||||
size: 12,
|
||||
color: Colors.green.shade700,
|
||||
color: reglementColor,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
formattedAmount,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.green.shade700,
|
||||
color: reglementColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
@@ -403,6 +518,29 @@ class PassagesListWidget extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Icône QR Code (si Payment Link généré)
|
||||
if (passage['stripe_payment_link_id'] != null &&
|
||||
(passage['stripe_payment_link_id'] as String).trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: InkWell(
|
||||
onTap: () => _showQRCodeDialog(context, passage),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Tooltip(
|
||||
message: 'Afficher le QR Code',
|
||||
preferBelow: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
Icons.qr_code_2,
|
||||
size: 16,
|
||||
color: Colors.blue.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -414,4 +552,71 @@ class PassagesListWidget extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit le texte du nombre de passages avec le type si filtré
|
||||
String _buildPassageCountText() {
|
||||
final count = passages.length;
|
||||
final baseText = '$count passage${count > 1 ? 's' : ''}';
|
||||
|
||||
// Si un type de passage est filtré et différent de "Tous les types"
|
||||
if (filteredPassageType != null && filteredPassageType!.isNotEmpty) {
|
||||
final typeLowerCase = filteredPassageType!.toLowerCase();
|
||||
|
||||
// Gérer le pluriel selon le type
|
||||
String typeWithPlural;
|
||||
if (count > 1) {
|
||||
// Gestion des pluriels spécifiques
|
||||
if (typeLowerCase == 'à finaliser') {
|
||||
typeWithPlural = 'à finaliser'; // Invariable
|
||||
} else if (typeLowerCase.endsWith('é')) {
|
||||
typeWithPlural = '${typeLowerCase}s'; // effectué → effectués, refusé → refusés
|
||||
} else if (typeLowerCase == 'maison vide') {
|
||||
typeWithPlural = 'maisons vides';
|
||||
} else {
|
||||
typeWithPlural = '${typeLowerCase}s'; // don → dons, lot → lots
|
||||
}
|
||||
} else {
|
||||
typeWithPlural = typeLowerCase;
|
||||
}
|
||||
|
||||
return '$count passage${count > 1 ? 's' : ''} $typeWithPlural';
|
||||
}
|
||||
|
||||
return baseText;
|
||||
}
|
||||
|
||||
/// Afficher le QR Code pour un passage avec Payment Link
|
||||
void _showQRCodeDialog(BuildContext context, Map<String, dynamic> passage) {
|
||||
final paymentLinkUrl = passage['stripe_payment_link_url'] as String?;
|
||||
final paymentLinkId = passage['stripe_payment_link_id'] as String?;
|
||||
|
||||
if (paymentLinkUrl == null || paymentLinkUrl.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('URL du QR Code non disponible'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer le montant du passage
|
||||
final montantStr = passage['montant'] as String? ?? '0';
|
||||
final montant = double.tryParse(montantStr.replaceAll(',', '.')) ?? 0;
|
||||
final amountInCents = (montant * 100).round();
|
||||
|
||||
// Créer un PaymentLinkResult avec les données du passage
|
||||
final paymentLink = PaymentLinkResult(
|
||||
paymentLinkId: paymentLinkId ?? '',
|
||||
url: paymentLinkUrl,
|
||||
amount: amountInCents,
|
||||
passageId: passage['id'] as int?,
|
||||
);
|
||||
|
||||
// Afficher le QR Code
|
||||
QRCodePaymentDialog.show(
|
||||
context: context,
|
||||
paymentLink: paymentLink,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/services/stripe_connect_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/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/qr_code_payment_dialog.dart';
|
||||
|
||||
/// Dialog de sélection de la méthode de paiement CB
|
||||
/// Affiche les options QR Code et/ou Tap to Pay selon la compatibilité
|
||||
class PaymentMethodSelectionDialog extends StatelessWidget {
|
||||
final PassageModel passage;
|
||||
final double amount;
|
||||
final String habitantName;
|
||||
final StripeConnectService stripeConnectService;
|
||||
final PassageRepository? passageRepository;
|
||||
final VoidCallback? onTapToPaySelected;
|
||||
|
||||
const PaymentMethodSelectionDialog({
|
||||
super.key,
|
||||
required this.passage,
|
||||
required this.amount,
|
||||
required this.habitantName,
|
||||
required this.stripeConnectService,
|
||||
this.passageRepository,
|
||||
this.onTapToPaySelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canUseTapToPay = DeviceInfoService.instance.canUseTapToPay();
|
||||
final amountEuros = amount.toStringAsFixed(2);
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 450),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// En-tête
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Règlement CB',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Informations du paiement
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.blue.shade200),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.person, color: Colors.blue),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
habitantName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.euro,
|
||||
color: Colors.blue,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
amountEuros,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Titre section méthodes
|
||||
const Text(
|
||||
'Sélectionnez une méthode de paiement :',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bouton QR Code
|
||||
_buildPaymentButton(
|
||||
context: context,
|
||||
icon: Icons.qr_code_2,
|
||||
label: 'Paiement par QR Code',
|
||||
description: 'Le client scanne le code avec son téléphone',
|
||||
onPressed: () => _handleQRCodePayment(context),
|
||||
color: Colors.blue,
|
||||
),
|
||||
|
||||
if (canUseTapToPay) ...[
|
||||
const SizedBox(height: 12),
|
||||
// Bouton Tap to Pay
|
||||
_buildPaymentButton(
|
||||
context: context,
|
||||
icon: Icons.contactless,
|
||||
label: 'Tap to Pay',
|
||||
description: 'Paiement sans contact sur cet appareil',
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onTapToPaySelected?.call();
|
||||
},
|
||||
color: Colors.green,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Logo Stripe
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.security,
|
||||
color: Colors.grey.shade600,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Paiements sécurisés par Stripe',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaymentButton({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String description,
|
||||
required VoidCallback onPressed,
|
||||
required Color color,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
border: Border.all(color: color.withOpacity(0.3), width: 2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 32),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.arrow_forward_ios, color: color, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Gérer le paiement par QR Code
|
||||
Future<void> _handleQRCodePayment(BuildContext context) async {
|
||||
// Sauvegarder le navigator avant de fermer les dialogs
|
||||
final navigator = Navigator.of(context);
|
||||
|
||||
try {
|
||||
// Afficher un loader
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
|
||||
// Créer le Payment Link
|
||||
final amountInCents = (amount * 100).round();
|
||||
debugPrint('🔵 Création Payment Link : ${amountInCents} cents, passage ${passage.id}');
|
||||
|
||||
final paymentLink = await stripeConnectService.createPaymentLink(
|
||||
amountInCents: amountInCents,
|
||||
passageId: passage.id,
|
||||
description: 'Calendrier pompiers - ${habitantName}',
|
||||
metadata: {
|
||||
'passage_id': passage.id.toString(),
|
||||
'habitant_name': habitantName,
|
||||
'adresse': '${passage.numero} ${passage.rue}, ${passage.ville}',
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('🔵 Payment Link reçu : ${paymentLink != null ? "OK" : "NULL"}');
|
||||
if (paymentLink != null) {
|
||||
debugPrint(' URL: ${paymentLink.url}');
|
||||
debugPrint(' ID: ${paymentLink.paymentLinkId}');
|
||||
}
|
||||
|
||||
// Fermer le loader
|
||||
navigator.pop();
|
||||
debugPrint('🔵 Loader fermé');
|
||||
|
||||
if (paymentLink == null) {
|
||||
throw Exception('Impossible de créer le lien de paiement');
|
||||
}
|
||||
|
||||
// Sauvegarder l'URL du Payment Link dans le passage
|
||||
if (passageRepository != null) {
|
||||
try {
|
||||
debugPrint('🔵 Sauvegarde de l\'URL du Payment Link dans le passage...');
|
||||
final updatedPassage = passage.copyWith(
|
||||
stripePaymentLinkUrl: paymentLink.url,
|
||||
);
|
||||
await passageRepository!.updatePassage(updatedPassage);
|
||||
debugPrint('✅ URL du Payment Link sauvegardée');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur lors de la sauvegarde de l\'URL: $e');
|
||||
// On continue quand même, ce n'est pas bloquant
|
||||
}
|
||||
}
|
||||
|
||||
// Fermer le dialog de sélection
|
||||
navigator.pop();
|
||||
debugPrint('🔵 Dialog de sélection fermé');
|
||||
|
||||
// Attendre un frame pour que les dialogs soient bien fermés
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
// Afficher le QR Code avec le navigator root
|
||||
debugPrint('🔵 Ouverture dialog QR Code...');
|
||||
await showDialog(
|
||||
context: navigator.context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) => QRCodePaymentDialog(
|
||||
paymentLink: paymentLink,
|
||||
),
|
||||
);
|
||||
debugPrint('🔵 Dialog QR Code affiché');
|
||||
|
||||
} catch (e, stack) {
|
||||
debugPrint('❌ Erreur dans _handleQRCodePayment: $e');
|
||||
debugPrint(' Stack: $stack');
|
||||
|
||||
// Fermer le loader si encore ouvert
|
||||
try {
|
||||
navigator.pop();
|
||||
} catch (_) {}
|
||||
|
||||
// Afficher l'erreur
|
||||
if (context.mounted) {
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Afficher le dialog de sélection de méthode de paiement
|
||||
static Future<void> show({
|
||||
required BuildContext context,
|
||||
required PassageModel passage,
|
||||
required double amount,
|
||||
required String habitantName,
|
||||
required StripeConnectService stripeConnectService,
|
||||
PassageRepository? passageRepository,
|
||||
VoidCallback? onTapToPaySelected,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) => PaymentMethodSelectionDialog(
|
||||
passage: passage,
|
||||
amount: amount,
|
||||
habitantName: habitantName,
|
||||
stripeConnectService: stripeConnectService,
|
||||
passageRepository: passageRepository,
|
||||
onTapToPaySelected: onTapToPaySelected,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
182
app/lib/presentation/widgets/qr_code_payment_dialog.dart
Normal file
182
app/lib/presentation/widgets/qr_code_payment_dialog.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/payment_link_result.dart';
|
||||
|
||||
/// Dialog qui affiche un QR code pour le paiement Stripe
|
||||
class QRCodePaymentDialog extends StatelessWidget {
|
||||
final PaymentLinkResult paymentLink;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const QRCodePaymentDialog({
|
||||
super.key,
|
||||
required this.paymentLink,
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final amountEuros = (paymentLink.amount / 100).toStringAsFixed(2);
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Titre
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Paiement par QR Code',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onClose?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Montant
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.euro,
|
||||
color: Colors.blue,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
amountEuros,
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// QR Code
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey.shade300, width: 2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: paymentLink.url,
|
||||
version: QrVersions.auto,
|
||||
size: 250,
|
||||
backgroundColor: Colors.white,
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.H,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Instructions
|
||||
const Text(
|
||||
'Scannez ce QR code avec votre téléphone',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Vous serez redirigé vers une page de paiement sécurisée Stripe',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Logo Stripe
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.security,
|
||||
color: Colors.green.shade600,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Paiement sécurisé par Stripe',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bouton Fermer
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onClose?.call();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Fermer',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Afficher le dialog de paiement par QR code
|
||||
static Future<void> show({
|
||||
required BuildContext context,
|
||||
required PaymentLinkResult paymentLink,
|
||||
VoidCallback? onClose,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) => QRCodePaymentDialog(
|
||||
paymentLink: paymentLink,
|
||||
onClose: onClose,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
data: theme.copyWith(
|
||||
colorScheme: theme.colorScheme.copyWith(
|
||||
onSecondaryContainer: selectedColor, // Couleur de l'icône sélectionnée
|
||||
secondaryContainer: selectedColor.withValues(alpha: 0.15), // Couleur de fond de l'indicateur
|
||||
secondaryContainer: selectedColor.withOpacity(0.15), // Couleur de fond de l'indicateur
|
||||
),
|
||||
),
|
||||
child: NavigationBar(
|
||||
@@ -360,7 +360,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
|
||||
// Définir les couleurs selon le rôle (admin = rouge, user = vert)
|
||||
final Color selectedColor = widget.isAdmin ? Colors.red : Colors.green;
|
||||
final Color unselectedColor = theme.colorScheme.onSurface.withValues(alpha: 0.6);
|
||||
final Color unselectedColor = theme.colorScheme.onSurface.withOpacity(0.6);
|
||||
|
||||
// Gérer le cas où l'icône est un BadgedIcon ou autre widget composite
|
||||
Widget iconWidget;
|
||||
@@ -402,7 +402,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? selectedColor.withValues(alpha: 0.1)
|
||||
? selectedColor.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
@@ -423,7 +423,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
),
|
||||
),
|
||||
tileColor:
|
||||
isSelected ? selectedColor.withValues(alpha: 0.1) : null,
|
||||
isSelected ? selectedColor.withOpacity(0.1) : null,
|
||||
onTap: () {
|
||||
widget.onDestinationSelected(index);
|
||||
},
|
||||
|
||||
197
app/lib/presentation/widgets/result_dialog.dart
Normal file
197
app/lib/presentation/widgets/result_dialog.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:ui';
|
||||
|
||||
/// Dialog de résultat centré avec animation
|
||||
/// Affiche un résultat de succès ou d'erreur de manière élégante
|
||||
class ResultDialog extends StatefulWidget {
|
||||
final bool success;
|
||||
final String message;
|
||||
final Duration? autoDismiss;
|
||||
|
||||
const ResultDialog({
|
||||
super.key,
|
||||
required this.success,
|
||||
required this.message,
|
||||
this.autoDismiss,
|
||||
});
|
||||
|
||||
/// Affiche un dialog de résultat centré
|
||||
///
|
||||
/// [success] : true pour succès, false pour erreur
|
||||
/// [message] : Message à afficher
|
||||
/// [autoDismiss] : Durée avant fermeture automatique (optionnel, uniquement pour succès)
|
||||
static Future<void> show({
|
||||
required BuildContext context,
|
||||
required bool success,
|
||||
required String message,
|
||||
Duration? autoDismiss,
|
||||
}) async {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
barrierColor: Colors.black54,
|
||||
builder: (context) => ResultDialog(
|
||||
success: success,
|
||||
message: message,
|
||||
autoDismiss: success ? (autoDismiss ?? const Duration(seconds: 2)) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ResultDialog> createState() => _ResultDialogState();
|
||||
}
|
||||
|
||||
class _ResultDialogState extends State<ResultDialog>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
);
|
||||
|
||||
_scaleAnimation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.elasticOut,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
|
||||
));
|
||||
|
||||
_controller.forward();
|
||||
|
||||
// Auto-fermeture si demandé
|
||||
if (widget.autoDismiss != null) {
|
||||
Future.delayed(widget.autoDismiss!, () {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 8.0,
|
||||
sigmaY: 8.0,
|
||||
),
|
||||
child: Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
child: ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: _buildContent(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final iconColor = widget.success ? Colors.green : Colors.red;
|
||||
final icon = widget.success ? Icons.check_circle : Icons.error;
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 340,
|
||||
),
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 24,
|
||||
spreadRadius: 4,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Icône principale
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 50,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Message
|
||||
Text(
|
||||
widget.message,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey[800],
|
||||
height: 1.4,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
// Bouton OK pour les erreurs
|
||||
if (!widget.success) ...[
|
||||
const SizedBox(height: 28),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: iconColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'OK',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -64,8 +64,8 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? Colors.blue.withValues(alpha: 0.1)
|
||||
: Colors.grey.withValues(alpha: 0.1),
|
||||
? Colors.blue.withOpacity(0.1)
|
||||
: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isActive ? Colors.blue : Colors.grey[400]!,
|
||||
@@ -295,7 +295,6 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
// Récupérer les données du secteur actuel
|
||||
final sectorData = allStats.firstWhere((s) => s['name'] == name);
|
||||
final Map<int, int> passagesByType = sectorData['passagesByType'] ?? {};
|
||||
final int progressPercentage = sectorData['progressPercentage'] ?? 0;
|
||||
final int sectorId = sectorData['id'] ?? 0;
|
||||
|
||||
// Calculer le ratio par rapport au maximum (éviter division par zéro)
|
||||
@@ -310,72 +309,51 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Nom du secteur et total
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
|
||||
if (isAdmin) {
|
||||
// Admin : naviguer vers la page carte
|
||||
settingsBox.put('selectedSectorId', sectorId);
|
||||
settingsBox.put('selectedPageIndex', 4); // Index de la page carte
|
||||
context.go('/admin');
|
||||
} else {
|
||||
// User : naviguer vers la page historique avec le secteur sélectionné
|
||||
settingsBox.delete('history_selectedTypeId');
|
||||
settingsBox.delete('history_selectedPaymentTypeId');
|
||||
settingsBox.delete('history_selectedMemberId');
|
||||
settingsBox.delete('history_startDate');
|
||||
settingsBox.delete('history_endDate');
|
||||
// Sélectionner le secteur et "Tous les passages"
|
||||
settingsBox.put('selectedSectorId', sectorId);
|
||||
settingsBox.put('selectedPassageTypeFilter', -1); // -1 = Tous les passages
|
||||
|
||||
settingsBox.put('history_selectedSectorId', sectorId);
|
||||
settingsBox.put('history_selectedSectorName', name);
|
||||
context.go('/user/history');
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 14),
|
||||
color: textColor,
|
||||
fontWeight:
|
||||
hasPassages ? FontWeight.w600 : FontWeight.w300,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: textColor.withValues(alpha: 0.5),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
if (isAdmin) {
|
||||
// Admin : naviguer vers la page carte
|
||||
context.go('/admin/map');
|
||||
} else {
|
||||
// User : naviguer vers la page carte
|
||||
context.go('/user/map');
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
// Première "cellule" : Nom du secteur avec nombre de passages (largeur fixe)
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: Text(
|
||||
hasPassages
|
||||
? '$count passages ($progressPercentage% d\'avancement)'
|
||||
: '0 passage',
|
||||
? '$name ($count passages)'
|
||||
: '$name (0 passage)',
|
||||
style: TextStyle(
|
||||
fontWeight: hasPassages ? FontWeight.bold : FontWeight.normal,
|
||||
fontSize: AppTheme.r(context, 13),
|
||||
fontSize: AppTheme.r(context, 14),
|
||||
color: textColor,
|
||||
fontWeight: hasPassages ? FontWeight.w600 : FontWeight.w300,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// Seconde "cellule" : Barre horizontale alignée à gauche
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: widthRatio,
|
||||
child: _buildStackedBar(passagesByType, count, sectorId, name),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Barre horizontale cumulée avec largeur proportionnelle
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: widthRatio,
|
||||
child: _buildStackedBar(passagesByType, count, sectorId, name),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -385,7 +363,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
if (totalCount == 0) {
|
||||
// Barre vide pour les secteurs sans passages
|
||||
return Container(
|
||||
height: 24,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -397,7 +375,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
final typeOrder = [1, 3, 4, 5, 6, 7, 8, 9, 2];
|
||||
|
||||
return Container(
|
||||
height: 24,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.grey[300]!, width: 0.5),
|
||||
|
||||
@@ -185,10 +185,10 @@ class ThemeInfo extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||
color: theme.colorScheme.outline.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
|
||||
@@ -204,26 +204,10 @@ class _UserFormState extends State<UserForm> {
|
||||
}).catchError((error) {
|
||||
// Gérer les erreurs spécifiques au sélecteur de date
|
||||
debugPrint('Erreur lors de la sélection de la date: $error');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Erreur lors de la sélection de la date'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Gérer toutes les autres erreurs
|
||||
debugPrint('Exception lors de l\'affichage du sélecteur de date: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Impossible d\'afficher le sélecteur de date'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +409,28 @@ class _UserFormState extends State<UserForm> {
|
||||
// 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()) {
|
||||
// Afficher une dialog si la validation échoue
|
||||
if (context.mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('Formulaire incomplet'),
|
||||
],
|
||||
),
|
||||
content: const Text('Veuillez vérifier tous les champs marqués en rouge avant d\'enregistrer'),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,22 +62,47 @@ class _UserFormDialogState extends State<UserFormDialog> {
|
||||
final userData = await _userFormKey.currentState?.validateAndGetUserAsync(context);
|
||||
final password = _userFormKey.currentState?.getPassword(); // Récupérer le mot de passe
|
||||
|
||||
if (userData != null) {
|
||||
var finalUser = userData;
|
||||
|
||||
// Ajouter le rôle sélectionné si applicable
|
||||
if (widget.showRoleSelector && _selectedRole != null) {
|
||||
finalUser = finalUser.copyWith(role: _selectedRole);
|
||||
if (userData == null) {
|
||||
// Afficher une dialog si la validation échoue
|
||||
if (context.mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('Formulaire incomplet'),
|
||||
],
|
||||
),
|
||||
content: const Text('Veuillez vérifier tous les champs marqués en rouge avant d\'enregistrer'),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ajouter le statut actif si applicable
|
||||
if (widget.showActiveCheckbox && _isActive != null) {
|
||||
finalUser = finalUser.copyWith(isActive: _isActive);
|
||||
}
|
||||
// À ce stade, userData ne peut pas être null
|
||||
var finalUser = userData;
|
||||
|
||||
if (widget.onSubmit != null) {
|
||||
widget.onSubmit!(finalUser, password: password); // Passer le mot de passe
|
||||
}
|
||||
// Ajouter le rôle sélectionné si applicable
|
||||
if (widget.showRoleSelector && _selectedRole != null) {
|
||||
finalUser = finalUser.copyWith(role: _selectedRole);
|
||||
}
|
||||
|
||||
// Ajouter le statut actif si applicable
|
||||
if (widget.showActiveCheckbox && _isActive != null) {
|
||||
finalUser = finalUser.copyWith(isActive: _isActive);
|
||||
}
|
||||
|
||||
if (widget.onSubmit != null) {
|
||||
widget.onSubmit!(finalUser, password: password); // Passer le mot de passe
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,33 +245,33 @@ class _UserFormDialogState extends State<UserFormDialog> {
|
||||
isAdmin: widget.isAdmin, // Passer isAdmin
|
||||
onSubmit: null, // Pas besoin de callback
|
||||
),
|
||||
|
||||
// Boutons en bas du scroll
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
if (!widget.readOnly)
|
||||
ElevatedButton(
|
||||
onPressed: _handleSubmit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16), // Padding supplémentaire pour le confort
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Footer avec boutons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
if (!widget.readOnly)
|
||||
ElevatedButton(
|
||||
onPressed: _handleSubmit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -282,10 +282,10 @@ class _ValidationExampleState extends State<ValidationExample> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
|
||||
Reference in New Issue
Block a user