feat: Gestion des secteurs et migration v3.0.4+304
- Ajout système complet de gestion des secteurs avec contours géographiques - Import des contours départementaux depuis GeoJSON - API REST pour la gestion des secteurs (/api/sectors) - Service de géolocalisation pour déterminer les secteurs - Migration base de données avec tables x_departements_contours et sectors_adresses - Interface Flutter pour visualisation et gestion des secteurs - Ajout thème sombre dans l'application - Corrections diverses et optimisations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
0
app/lib/presentation/MIGRATION.md
Normal file → Executable file
0
app/lib/presentation/MIGRATION.md
Normal file → Executable file
231
app/lib/presentation/admin/admin_amicale_page.dart
Normal file → Executable file
231
app/lib/presentation/admin/admin_amicale_page.dart
Normal file → Executable file
@@ -11,7 +11,6 @@ import 'package:geosector_app/core/repositories/membre_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/amicale_table_widget.dart';
|
||||
import 'package:geosector_app/presentation/widgets/membre_table_widget.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/operation_repository.dart';
|
||||
|
||||
@@ -52,7 +51,8 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
void _loadCurrentUser() {
|
||||
final currentUser = widget.userRepository.getCurrentUser();
|
||||
|
||||
debugPrint('🔍 _loadCurrentUser - Utilisateur: ${currentUser?.username} (ID: ${currentUser?.id})');
|
||||
debugPrint(
|
||||
'🔍 _loadCurrentUser - Utilisateur: ${currentUser?.username} (ID: ${currentUser?.id})');
|
||||
debugPrint('🔍 _loadCurrentUser - fkEntite: ${currentUser?.fkEntite}');
|
||||
|
||||
if (currentUser == null) {
|
||||
@@ -70,8 +70,10 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
}
|
||||
|
||||
// Vérifier immédiatement si l'amicale existe
|
||||
final amicale = widget.amicaleRepository.getUserAmicale(currentUser.fkEntite!);
|
||||
debugPrint('🔍 Amicale trouvée dans le repository: ${amicale?.name ?? 'null'}');
|
||||
final amicale =
|
||||
widget.amicaleRepository.getUserAmicale(currentUser.fkEntite!);
|
||||
debugPrint(
|
||||
'🔍 Amicale trouvée dans le repository: ${amicale?.name ?? 'null'}');
|
||||
|
||||
setState(() {
|
||||
_currentUser = currentUser;
|
||||
@@ -85,8 +87,10 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
_currentOperationId = currentOperation?.id;
|
||||
|
||||
if (currentOperation != null) {
|
||||
debugPrint('🎯 Opération courante: ${currentOperation.id} - ${currentOperation.name}');
|
||||
debugPrint('📅 Période: ${currentOperation.dateDebut.toString().substring(0, 10)} → ${currentOperation.dateFin.toString().substring(0, 10)}');
|
||||
debugPrint(
|
||||
'🎯 Opération courante: ${currentOperation.id} - ${currentOperation.name}');
|
||||
debugPrint(
|
||||
'📅 Période: ${currentOperation.dateDebut.toString().substring(0, 10)} → ${currentOperation.dateFin.toString().substring(0, 10)}');
|
||||
} else {
|
||||
debugPrint('⚠️ Aucune opération courante trouvée');
|
||||
}
|
||||
@@ -117,16 +121,20 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
onSubmit: (updatedUser) async {
|
||||
try {
|
||||
// Convertir le UserModel mis à jour vers MembreModel
|
||||
final updatedMembre = MembreModel.fromUserModel(updatedUser, membre);
|
||||
final updatedMembre =
|
||||
MembreModel.fromUserModel(updatedUser, membre);
|
||||
|
||||
// Utiliser directement updateMembre qui passe par l'API /users
|
||||
final success = await widget.membreRepository.updateMembre(updatedMembre);
|
||||
final success =
|
||||
await widget.membreRepository.updateMembre(updatedMembre);
|
||||
|
||||
if (success && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ApiException.showSuccess(context, 'Membre ${updatedMembre.firstName} ${updatedMembre.name} mis à jour');
|
||||
ApiException.showSuccess(context,
|
||||
'Membre ${updatedMembre.firstName} ${updatedMembre.name} mis à jour');
|
||||
} else if (!success && mounted) {
|
||||
ApiException.showError(context, Exception('Erreur lors de la mise à jour'));
|
||||
ApiException.showError(
|
||||
context, Exception('Erreur lors de la mise à jour'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur mise à jour membre: $e');
|
||||
@@ -139,42 +147,119 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _handleResetPassword(MembreModel membre) async {
|
||||
// Afficher un dialog de confirmation
|
||||
final bool? confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.lock_reset, color: Colors.blue),
|
||||
SizedBox(width: 8),
|
||||
Text('Réinitialiser le mot de passe'),
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
'Voulez-vous réinitialiser le mot de passe de ${membre.firstName} ${membre.name} ?\n\n'
|
||||
'Un email sera envoyé à l\'utilisateur avec les instructions de réinitialisation.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Réinitialiser'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return;
|
||||
|
||||
try {
|
||||
debugPrint('🔐 Réinitialisation du mot de passe pour: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
|
||||
|
||||
final success = await widget.membreRepository.resetMemberPassword(membre.id);
|
||||
|
||||
if (success && mounted) {
|
||||
ApiException.showSuccess(
|
||||
context,
|
||||
'Mot de passe réinitialisé avec succès. Un email a été envoyé à ${membre.email}',
|
||||
);
|
||||
} else if (mounted) {
|
||||
ApiException.showError(
|
||||
context,
|
||||
Exception('Erreur lors de la réinitialisation du mot de passe'),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur réinitialisation mot de passe: $e');
|
||||
if (mounted) {
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDeleteMembre(MembreModel membre) async {
|
||||
try {
|
||||
debugPrint('🗑️ Début suppression du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
|
||||
debugPrint(
|
||||
'🗑️ Début suppression du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
|
||||
|
||||
// Vérifier qu'on a une opération courante
|
||||
if (_currentOperationId == null) {
|
||||
debugPrint('❌ Aucune opération courante');
|
||||
ApiException.showError(context, Exception('Aucune opération active trouvée. Impossible de supprimer le membre.'));
|
||||
ApiException.showError(
|
||||
context,
|
||||
Exception(
|
||||
'Aucune opération active trouvée. Impossible de supprimer le membre.'));
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🎯 Opération courante: $_currentOperationId');
|
||||
|
||||
// Filtrer les passages par opération courante ET par utilisateur
|
||||
final allUserPassages = widget.passageRepository.getPassagesByUser(membre.id);
|
||||
final allUserPassages =
|
||||
widget.passageRepository.getPassagesByUser(membre.id);
|
||||
debugPrint('📊 Total passages du membre: ${allUserPassages.length}');
|
||||
|
||||
final passagesRealises = allUserPassages.where((passage) => passage.fkOperation == _currentOperationId && passage.fkType != 2).toList();
|
||||
final passagesRealises = allUserPassages
|
||||
.where((passage) =>
|
||||
passage.fkOperation == _currentOperationId && passage.fkType != 2)
|
||||
.toList();
|
||||
|
||||
final passagesAFinaliser = allUserPassages.where((passage) => passage.fkOperation == _currentOperationId && passage.fkType == 2).toList();
|
||||
final passagesAFinaliser = allUserPassages
|
||||
.where((passage) =>
|
||||
passage.fkOperation == _currentOperationId && passage.fkType == 2)
|
||||
.toList();
|
||||
|
||||
final totalPassages = passagesRealises.length + passagesAFinaliser.length;
|
||||
|
||||
debugPrint('🔍 Passages réalisés (opération $_currentOperationId): ${passagesRealises.length}');
|
||||
debugPrint('🔍 Passages à finaliser (opération $_currentOperationId): ${passagesAFinaliser.length}');
|
||||
debugPrint('🔍 Total passages pour l\'opération $_currentOperationId: $totalPassages');
|
||||
debugPrint(
|
||||
'🔍 Passages réalisés (opération $_currentOperationId): ${passagesRealises.length}');
|
||||
debugPrint(
|
||||
'🔍 Passages à finaliser (opération $_currentOperationId): ${passagesAFinaliser.length}');
|
||||
debugPrint(
|
||||
'🔍 Total passages pour l\'opération $_currentOperationId: $totalPassages');
|
||||
|
||||
// Récupérer les autres membres de l'amicale (pour le transfert)
|
||||
final autresmembres = widget.membreRepository.getMembresByAmicale(_currentUser!.fkEntite!).where((m) => m.id != membre.id && m.isActive == true).toList();
|
||||
final autresmembres = widget.membreRepository
|
||||
.getMembresByAmicale(_currentUser!.fkEntite!)
|
||||
.where((m) => m.id != membre.id && m.isActive == true)
|
||||
.toList();
|
||||
|
||||
debugPrint('👥 Autres membres disponibles: ${autresmembres.length}');
|
||||
|
||||
// Afficher le dialog de confirmation approprié
|
||||
if (totalPassages > 0) {
|
||||
debugPrint('➡️ Affichage dialog avec passages');
|
||||
_showDeleteMemberWithPassagesDialog(membre, totalPassages, autresmembres);
|
||||
_showDeleteMemberWithPassagesDialog(
|
||||
membre, totalPassages, autresmembres);
|
||||
} else {
|
||||
debugPrint('➡️ Affichage dialog simple (pas de passages)');
|
||||
_showSimpleDeleteConfirmation(membre);
|
||||
@@ -192,7 +277,8 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
content: Text('Voulez-vous vraiment supprimer le membre ${membre.firstName} ${membre.name} ?\n\n'
|
||||
content: Text(
|
||||
'Voulez-vous vraiment supprimer le membre ${membre.firstName} ${membre.name} ?\n\n'
|
||||
'Ce membre n\'a aucun passage enregistré pour l\'opération courante.\n'
|
||||
'Cette action est irréversible.'),
|
||||
actions: [
|
||||
@@ -222,7 +308,8 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
int totalPassages,
|
||||
List<MembreModel> autresmembres,
|
||||
) {
|
||||
int? selectedMemberForTransfer; // Déclarer la variable à l'extérieur du builder
|
||||
int?
|
||||
selectedMemberForTransfer; // Déclarer la variable à l'extérieur du builder
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -272,13 +359,19 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
Text(
|
||||
'Sélectionnez un membre pour récupérer tous les passages ($totalPassages) :',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'* Cela peut concerner aussi les anciennes opérations s\'il avait des passages affectés',
|
||||
style: TextStyle(fontSize: 12, fontStyle: FontStyle.italic),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<int>(
|
||||
value: selectedMemberForTransfer,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Membre destinataire',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
),
|
||||
items: autresmembres
|
||||
.map((m) => DropdownMenuItem(
|
||||
@@ -290,7 +383,8 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
setDialogState(() {
|
||||
selectedMemberForTransfer = value;
|
||||
});
|
||||
debugPrint('✅ Membre destinataire sélectionné: $value');
|
||||
debugPrint(
|
||||
'✅ Membre destinataire sélectionné: $value');
|
||||
},
|
||||
),
|
||||
|
||||
@@ -305,7 +399,8 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.green, size: 16),
|
||||
const Icon(Icons.check_circle,
|
||||
color: Colors.green, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Membre sélectionné',
|
||||
@@ -329,7 +424,8 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.green.withOpacity(0.3)),
|
||||
border:
|
||||
Border.all(color: Colors.green.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -371,23 +467,31 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
ElevatedButton(
|
||||
onPressed: selectedMemberForTransfer != null
|
||||
? () async {
|
||||
debugPrint('🗑️ Suppression avec transfert vers ID: $selectedMemberForTransfer');
|
||||
debugPrint(
|
||||
'🗑️ Suppression avec transfert vers ID: $selectedMemberForTransfer');
|
||||
Navigator.of(context).pop();
|
||||
// Suppression avec passages : inclure les paramètres
|
||||
await _deleteMemberAPI(membre.id, selectedMemberForTransfer!, hasPassages: true);
|
||||
await _deleteMemberAPI(
|
||||
membre.id, selectedMemberForTransfer!,
|
||||
hasPassages: true);
|
||||
}
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: selectedMemberForTransfer != null ? Colors.red : null,
|
||||
backgroundColor:
|
||||
selectedMemberForTransfer != null ? Colors.red : null,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (selectedMemberForTransfer != null) const Icon(Icons.delete_forever, size: 16),
|
||||
if (selectedMemberForTransfer != null) const SizedBox(width: 4),
|
||||
if (selectedMemberForTransfer != null)
|
||||
const Icon(Icons.delete_forever, size: 16),
|
||||
if (selectedMemberForTransfer != null)
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
selectedMemberForTransfer != null ? 'Supprimer et transférer' : 'Sélectionner un membre',
|
||||
selectedMemberForTransfer != null
|
||||
? 'Supprimer et transférer'
|
||||
: 'Sélectionner un membre',
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -400,13 +504,15 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
}
|
||||
|
||||
// Méthode unifiée pour appeler l'API de suppression
|
||||
Future<void> _deleteMemberAPI(int membreId, int transferToUserId, {bool hasPassages = false}) async {
|
||||
Future<void> _deleteMemberAPI(int membreId, int transferToUserId,
|
||||
{bool hasPassages = false}) async {
|
||||
try {
|
||||
bool success;
|
||||
|
||||
if (hasPassages && transferToUserId > 0 && _currentOperationId != null) {
|
||||
// Suppression avec transfert de passages (inclure operation_id)
|
||||
debugPrint('🔄 Suppression avec transfert - Opération: $_currentOperationId, Vers: $transferToUserId');
|
||||
debugPrint(
|
||||
'🔄 Suppression avec transfert - Opération: $_currentOperationId, Vers: $transferToUserId');
|
||||
success = await widget.membreRepository.deleteMembre(
|
||||
membreId,
|
||||
transferToUserId,
|
||||
@@ -422,14 +528,18 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
String message = 'Membre supprimé avec succès';
|
||||
|
||||
if (hasPassages && transferToUserId > 0) {
|
||||
final transferMember = widget.membreRepository.getMembreById(transferToUserId);
|
||||
final currentOperation = widget.operationRepository.getCurrentOperation();
|
||||
message += '\nPassages de l\'opération "${currentOperation?.name}" transférés à ${transferMember?.firstName} ${transferMember?.name}';
|
||||
final transferMember =
|
||||
widget.membreRepository.getMembreById(transferToUserId);
|
||||
final currentOperation =
|
||||
widget.operationRepository.getCurrentOperation();
|
||||
message +=
|
||||
'\nPassages de l\'opération "${currentOperation?.name}" transférés à ${transferMember?.firstName} ${transferMember?.name}';
|
||||
}
|
||||
|
||||
ApiException.showSuccess(context, message);
|
||||
} else if (mounted) {
|
||||
ApiException.showError(context, Exception('Erreur lors de la suppression'));
|
||||
ApiException.showError(
|
||||
context, Exception('Erreur lors de la suppression'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur suppression membre: $e');
|
||||
@@ -445,9 +555,11 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
final success = await widget.membreRepository.updateMembre(updatedMember);
|
||||
|
||||
if (success && mounted) {
|
||||
ApiException.showSuccess(context, 'Membre ${membre.firstName} ${membre.name} désactivé avec succès');
|
||||
ApiException.showSuccess(context,
|
||||
'Membre ${membre.firstName} ${membre.name} désactivé avec succès');
|
||||
} else if (mounted) {
|
||||
ApiException.showError(context, Exception('Erreur lors de la désactivation'));
|
||||
ApiException.showError(
|
||||
context, Exception('Erreur lors de la désactivation'));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -519,17 +631,20 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
);
|
||||
|
||||
// Créer le membre via l'API (retourne maintenant le membre créé)
|
||||
final createdMembre = await widget.membreRepository.createMembre(newMembre);
|
||||
final createdMembre =
|
||||
await widget.membreRepository.createMembre(newMembre);
|
||||
|
||||
if (createdMembre != null && mounted) {
|
||||
// Fermer le dialog
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// Afficher le message de succès avec les informations du membre créé
|
||||
ApiException.showSuccess(context, 'Membre ${createdMembre.firstName} ${createdMembre.name} ajouté avec succès (ID: ${createdMembre.id})');
|
||||
ApiException.showSuccess(context,
|
||||
'Membre ${createdMembre.firstName} ${createdMembre.name} ajouté avec succès (ID: ${createdMembre.id})');
|
||||
} else if (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'));
|
||||
ApiException.showError(
|
||||
context, Exception('Erreur lors de la création du membre'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur création membre: $e');
|
||||
@@ -593,20 +708,27 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
if (_currentUser != null && _currentUser!.fkEntite != null)
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<Box<AmicaleModel>>(
|
||||
valueListenable: widget.amicaleRepository.getAmicalesBox().listenable(),
|
||||
valueListenable:
|
||||
widget.amicaleRepository.getAmicalesBox().listenable(),
|
||||
builder: (context, amicalesBox, child) {
|
||||
debugPrint('🔍 AmicalesBox - Nombre d\'amicales: ${amicalesBox.length}');
|
||||
debugPrint('🔍 AmicalesBox - Clés disponibles: ${amicalesBox.keys.toList()}');
|
||||
debugPrint('🔍 Recherche amicale avec fkEntite: ${_currentUser!.fkEntite}');
|
||||
debugPrint(
|
||||
'🔍 AmicalesBox - Nombre d\'amicales: ${amicalesBox.length}');
|
||||
debugPrint(
|
||||
'🔍 AmicalesBox - Clés disponibles: ${amicalesBox.keys.toList()}');
|
||||
debugPrint(
|
||||
'🔍 Recherche amicale avec fkEntite: ${_currentUser!.fkEntite}');
|
||||
|
||||
final amicale = amicalesBox.get(_currentUser!.fkEntite!);
|
||||
debugPrint('🔍 Amicale récupérée: ${amicale?.name ?? 'AUCUNE'}');
|
||||
debugPrint(
|
||||
'🔍 Amicale récupérée: ${amicale?.name ?? 'AUCUNE'}');
|
||||
|
||||
if (amicale == null) {
|
||||
// Ajouter plus d'informations de debug
|
||||
debugPrint('❌ PROBLÈME: Amicale non trouvée');
|
||||
debugPrint('❌ fkEntite recherché: ${_currentUser!.fkEntite}');
|
||||
debugPrint('❌ Contenu de la box: ${amicalesBox.values.map((a) => '${a.id}: ${a.name}').join(', ')}');
|
||||
debugPrint(
|
||||
'❌ fkEntite recherché: ${_currentUser!.fkEntite}');
|
||||
debugPrint(
|
||||
'❌ Contenu de la box: ${amicalesBox.values.map((a) => '${a.id}: ${a.name}').join(', ')}');
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
@@ -634,11 +756,15 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
}
|
||||
|
||||
return ValueListenableBuilder<Box<MembreModel>>(
|
||||
valueListenable: widget.membreRepository.getMembresBox().listenable(),
|
||||
valueListenable:
|
||||
widget.membreRepository.getMembresBox().listenable(),
|
||||
builder: (context, membresBox, child) {
|
||||
// Filtrer les membres par amicale
|
||||
// Note: Il faudra ajouter le champ fkEntite au modèle MembreModel
|
||||
final membres = membresBox.values.where((membre) => membre.fkEntite == _currentUser!.fkEntite).toList();
|
||||
final membres = membresBox.values
|
||||
.where((membre) =>
|
||||
membre.fkEntite == _currentUser!.fkEntite)
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -721,6 +847,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
membres: membres,
|
||||
onEdit: _handleEditMembre,
|
||||
onDelete: _handleDeleteMembre,
|
||||
onResetPassword: _handleResetPassword,
|
||||
membreRepository: widget.membreRepository,
|
||||
),
|
||||
),
|
||||
|
||||
2
app/lib/presentation/admin/admin_communication_page.dart
Normal file → Executable file
2
app/lib/presentation/admin/admin_communication_page.dart
Normal file → Executable file
@@ -5,7 +5,7 @@ import 'package:geosector_app/presentation/widgets/chat/chat_messages.dart';
|
||||
import 'package:geosector_app/presentation/widgets/chat/chat_input.dart';
|
||||
|
||||
class AdminCommunicationPage extends StatefulWidget {
|
||||
const AdminCommunicationPage({Key? key}) : super(key: key);
|
||||
const AdminCommunicationPage({super.key});
|
||||
|
||||
@override
|
||||
State<AdminCommunicationPage> createState() => _AdminCommunicationPageState();
|
||||
|
||||
0
app/lib/presentation/admin/admin_dashboard_home_page.dart
Normal file → Executable file
0
app/lib/presentation/admin/admin_dashboard_home_page.dart
Normal file → Executable file
57
app/lib/presentation/admin/admin_dashboard_page.dart
Normal file → Executable file
57
app/lib/presentation/admin/admin_dashboard_page.dart
Normal file → Executable file
@@ -47,8 +47,7 @@ class AdminDashboardPage extends StatefulWidget {
|
||||
class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBindingObserver {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
// Liste des pages à afficher
|
||||
late final List<Widget> _pages;
|
||||
// Pages seront construites dynamiquement dans build()
|
||||
|
||||
// Référence à la boîte Hive pour les paramètres
|
||||
late Box _settingsBox;
|
||||
@@ -138,6 +137,8 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
List<NavigationDestination> _buildNavigationDestinations() {
|
||||
final destinations = <NavigationDestination>[];
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isMobile = size.width <= 900;
|
||||
|
||||
// Ajouter les éléments de base
|
||||
for (final item in _baseNavigationItems) {
|
||||
@@ -153,6 +154,12 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
// Ajouter les éléments admin si l'utilisateur a le rôle requis
|
||||
if (currentUser?.role == 2) {
|
||||
for (final item in _adminNavigationItems) {
|
||||
// En mobile, exclure "Amicale & membres" et "Opérations"
|
||||
if (isMobile &&
|
||||
(item.label == 'Amicale & membres' || item.label == 'Opérations')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.requiredRole == null || item.requiredRole == 2) {
|
||||
destinations.add(
|
||||
NavigationDestination(
|
||||
@@ -172,6 +179,8 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
List<Widget> _buildPages() {
|
||||
final pages = <Widget>[];
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isMobile = size.width <= 900;
|
||||
|
||||
// Ajouter les pages de base
|
||||
for (final item in _baseNavigationItems) {
|
||||
@@ -181,6 +190,12 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
// Ajouter les pages admin si l'utilisateur a le rôle requis
|
||||
if (currentUser?.role == 2) {
|
||||
for (final item in _adminNavigationItems) {
|
||||
// En mobile, exclure "Amicale & membres" et "Opérations"
|
||||
if (isMobile &&
|
||||
(item.label == 'Amicale & membres' || item.label == 'Opérations')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.requiredRole == null || item.requiredRole == 2) {
|
||||
pages.add(_buildPage(item.pageType));
|
||||
}
|
||||
@@ -208,8 +223,7 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
}
|
||||
userRepository.addListener(_handleUserRepositoryChanges);
|
||||
|
||||
// Initialiser les pages et les destinations
|
||||
_pages = _buildPages();
|
||||
// Les pages seront construites dynamiquement dans build()
|
||||
|
||||
// Initialiser et charger les paramètres
|
||||
_initSettings();
|
||||
@@ -257,19 +271,11 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
if (savedIndex != null && savedIndex is int) {
|
||||
debugPrint('Index sauvegardé trouvé: $savedIndex');
|
||||
|
||||
// S'assurer que l'index est dans les limites valides
|
||||
if (savedIndex >= 0 && savedIndex < _pages.length) {
|
||||
setState(() {
|
||||
_selectedIndex = savedIndex;
|
||||
});
|
||||
debugPrint('Index sauvegardé valide, utilisé: $_selectedIndex');
|
||||
} else {
|
||||
debugPrint(
|
||||
'Index sauvegardé invalide ($savedIndex), utilisation de l\'index par défaut: 0',
|
||||
);
|
||||
// Réinitialiser l'index sauvegardé à 0 si invalide
|
||||
_settingsBox.put('adminSelectedPageIndex', 0);
|
||||
}
|
||||
// La validation de l'index sera faite dans build()
|
||||
setState(() {
|
||||
_selectedIndex = savedIndex;
|
||||
});
|
||||
debugPrint('Index sauvegardé utilisé: $_selectedIndex');
|
||||
} else {
|
||||
debugPrint(
|
||||
'Aucun index sauvegardé trouvé, utilisation de l\'index par défaut: 0',
|
||||
@@ -292,6 +298,19 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Construire les pages et destinations dynamiquement
|
||||
final pages = _buildPages();
|
||||
final destinations = _buildNavigationDestinations();
|
||||
|
||||
// Valider et ajuster l'index si nécessaire
|
||||
if (_selectedIndex >= pages.length) {
|
||||
_selectedIndex = 0;
|
||||
// Sauvegarder le nouvel index
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
@@ -318,10 +337,10 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
_saveSettings(); // Sauvegarder l'index de page sélectionné
|
||||
});
|
||||
},
|
||||
destinations: _buildNavigationDestinations(),
|
||||
destinations: destinations,
|
||||
showNewPassageButton: false,
|
||||
isAdmin: true,
|
||||
body: _pages[_selectedIndex],
|
||||
body: pages[_selectedIndex],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
5
app/lib/presentation/admin/admin_debug_info_widget.dart
Normal file → Executable file
5
app/lib/presentation/admin/admin_debug_info_widget.dart
Normal file → Executable file
@@ -4,7 +4,7 @@ import 'package:geosector_app/presentation/widgets/environment_info_widget.dart'
|
||||
/// Widget d'information de débogage pour l'administrateur
|
||||
/// À intégrer où nécessaire dans l'interface administrateur
|
||||
class AdminDebugInfoWidget extends StatelessWidget {
|
||||
const AdminDebugInfoWidget({Key? key}) : super(key: key);
|
||||
const AdminDebugInfoWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -30,7 +30,8 @@ class AdminDebugInfoWidget extends StatelessWidget {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('Environnement'),
|
||||
subtitle: const Text('Afficher les informations sur l\'environnement actuel'),
|
||||
subtitle: const Text(
|
||||
'Afficher les informations sur l\'environnement actuel'),
|
||||
onTap: () => EnvironmentInfoWidget.show(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
|
||||
715
app/lib/presentation/admin/admin_history_page.dart
Normal file → Executable file
715
app/lib/presentation/admin/admin_history_page.dart
Normal file → Executable file
@@ -1,15 +1,15 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.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/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/sector_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/repositories/membre_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Class pour dessiner les petits points blancs sur le fond
|
||||
@@ -36,7 +36,7 @@ class DotsPainter extends CustomPainter {
|
||||
}
|
||||
|
||||
class AdminHistoryPage extends StatefulWidget {
|
||||
const AdminHistoryPage({Key? key}) : super(key: key);
|
||||
const AdminHistoryPage({super.key});
|
||||
|
||||
@override
|
||||
State<AdminHistoryPage> createState() => _AdminHistoryPageState();
|
||||
@@ -49,25 +49,32 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
String selectedUser = 'Tous';
|
||||
String selectedType = 'Tous';
|
||||
String selectedPaymentMethod = 'Tous';
|
||||
String selectedPeriod = 'Dernier mois'; // Période par défaut
|
||||
String selectedPeriod = 'Tous'; // Période par défaut
|
||||
DateTimeRange? selectedDateRange;
|
||||
|
||||
// Contrôleur pour la recherche
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
// IDs pour les filtres
|
||||
int? selectedSectorId;
|
||||
int? selectedUserId;
|
||||
|
||||
// Listes pour les filtres
|
||||
List<SectorModel> _sectors = [];
|
||||
List<UserModel> _users = [];
|
||||
List<MembreModel> _membres = [];
|
||||
|
||||
// Repositories
|
||||
late PassageRepository _passageRepository;
|
||||
late SectorRepository _sectorRepository;
|
||||
late UserRepository _userRepository;
|
||||
late MembreRepository _membreRepository;
|
||||
|
||||
// Passages formatés
|
||||
// Passages formatés pour l'affichage
|
||||
List<Map<String, dynamic>> _formattedPassages = [];
|
||||
|
||||
// Passages originaux pour l'édition
|
||||
List<PassageModel> _originalPassages = [];
|
||||
|
||||
// État de chargement
|
||||
bool _isLoading = true;
|
||||
String _errorMessage = '';
|
||||
@@ -93,9 +100,10 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
_passageRepository = passageRepository;
|
||||
_userRepository = userRepository;
|
||||
_sectorRepository = sectorRepository;
|
||||
_membreRepository = membreRepository;
|
||||
|
||||
// Charger les secteurs et les utilisateurs
|
||||
_loadSectorsAndUsers();
|
||||
// Charger les secteurs et les membres
|
||||
_loadSectorsAndMembres();
|
||||
|
||||
// Charger les passages
|
||||
_loadPassages();
|
||||
@@ -107,18 +115,18 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
}
|
||||
}
|
||||
|
||||
// Charger les secteurs et les utilisateurs
|
||||
void _loadSectorsAndUsers() {
|
||||
// Charger les secteurs et les membres
|
||||
void _loadSectorsAndMembres() {
|
||||
try {
|
||||
// Récupérer la liste des secteurs
|
||||
_sectors = _sectorRepository.getAllSectors();
|
||||
debugPrint('Nombre de secteurs récupérés: ${_sectors.length}');
|
||||
|
||||
// Récupérer la liste des utilisateurs
|
||||
_users = _userRepository.getAllUsers();
|
||||
debugPrint('Nombre d\'utilisateurs récupérés: ${_users.length}');
|
||||
// Récupérer la liste des membres
|
||||
_membres = _membreRepository.getAllMembres();
|
||||
debugPrint('Nombre de membres récupérés: ${_membres.length}');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des secteurs et utilisateurs: $e');
|
||||
debugPrint('Erreur lors du chargement des secteurs et membres: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +141,12 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
final List<PassageModel> allPassages =
|
||||
_passageRepository.getAllPassages();
|
||||
|
||||
// Stocker les passages originaux pour l'édition
|
||||
_originalPassages = allPassages;
|
||||
|
||||
// Convertir les passages en format attendu par PassagesListWidget
|
||||
_formattedPassages = _formatPassagesForWidget(
|
||||
allPassages, _sectorRepository, _userRepository);
|
||||
allPassages, _sectorRepository, _membreRepository);
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
@@ -154,13 +165,137 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
selectedSectorId = null;
|
||||
selectedUserId = null;
|
||||
|
||||
// Période par défaut : dernier mois
|
||||
selectedPeriod = 'Dernier mois';
|
||||
// Période par défaut : toutes les périodes
|
||||
selectedPeriod = 'Tous';
|
||||
|
||||
// Plage de dates par défaut : dernier mois
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime oneMonthAgo = DateTime(now.year, now.month - 1, now.day);
|
||||
selectedDateRange = DateTimeRange(start: oneMonthAgo, end: now);
|
||||
// Plage de dates par défaut : aucune restriction
|
||||
selectedDateRange = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Méthode pour appliquer tous les filtres
|
||||
List<Map<String, dynamic>> _getFilteredPassages() {
|
||||
try {
|
||||
var filtered = _formattedPassages.where((passage) {
|
||||
try {
|
||||
// Ne plus exclure automatiquement les passages de type 2
|
||||
// car on propose maintenant un filtre par type dans les "Filtres avancés"
|
||||
|
||||
// Filtrer par utilisateur
|
||||
if (selectedUserId != null &&
|
||||
passage.containsKey('fkUser') &&
|
||||
passage['fkUser'] != selectedUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtrer par secteur
|
||||
if (selectedSectorId != null &&
|
||||
passage.containsKey('fkSector') &&
|
||||
passage['fkSector'] != selectedSectorId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtrer par type de passage
|
||||
if (selectedType != 'Tous') {
|
||||
try {
|
||||
final int? selectedTypeId = int.tryParse(selectedType);
|
||||
if (selectedTypeId != null) {
|
||||
if (!passage.containsKey('type') ||
|
||||
passage['type'] != selectedTypeId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de filtrage par type: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer par mode de règlement
|
||||
if (selectedPaymentMethod != 'Tous') {
|
||||
try {
|
||||
final int? selectedPaymentId =
|
||||
int.tryParse(selectedPaymentMethod);
|
||||
if (selectedPaymentId != null) {
|
||||
if (!passage.containsKey('payment') ||
|
||||
passage['payment'] != selectedPaymentId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de filtrage par mode de règlement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer par recherche
|
||||
if (searchQuery.isNotEmpty) {
|
||||
try {
|
||||
final query = searchQuery.toLowerCase();
|
||||
final address = passage.containsKey('address')
|
||||
? passage['address']?.toString().toLowerCase() ?? ''
|
||||
: '';
|
||||
final name = passage.containsKey('name')
|
||||
? passage['name']?.toString().toLowerCase() ?? ''
|
||||
: '';
|
||||
final notes = passage.containsKey('notes')
|
||||
? passage['notes']?.toString().toLowerCase() ?? ''
|
||||
: '';
|
||||
|
||||
if (!address.contains(query) &&
|
||||
!name.contains(query) &&
|
||||
!notes.contains(query)) {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de filtrage par recherche: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer par période/date
|
||||
if (selectedDateRange != null) {
|
||||
try {
|
||||
if (passage.containsKey('date') && passage['date'] is DateTime) {
|
||||
final DateTime passageDate = passage['date'] as DateTime;
|
||||
if (passageDate.isBefore(selectedDateRange!.start) ||
|
||||
passageDate.isAfter(selectedDateRange!.end)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de filtrage par date: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du filtrage d\'un passage: $e');
|
||||
return false;
|
||||
}
|
||||
}).toList();
|
||||
|
||||
// Trier par date décroissante (plus récent en premier)
|
||||
filtered.sort((a, b) {
|
||||
try {
|
||||
final DateTime dateA = a['date'] as DateTime;
|
||||
final DateTime dateB = b['date'] as DateTime;
|
||||
return dateB.compareTo(dateA);
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint(
|
||||
'Passages filtrés: ${filtered.length}/${_formattedPassages.length}');
|
||||
return filtered;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur globale lors du filtrage: $e');
|
||||
return _formattedPassages;
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le filtre par secteur
|
||||
@@ -230,7 +365,8 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: Container(width: double.infinity, height: double.infinity),
|
||||
child: const SizedBox(
|
||||
width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
const Center(
|
||||
@@ -258,67 +394,71 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: Container(width: double.infinity, height: double.infinity),
|
||||
child:
|
||||
const SizedBox(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
// Contenu de la page
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de la page
|
||||
Text(
|
||||
'Historique des passages',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final passages = _getFilteredPassages();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight - 32, // Moins le padding
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de la page
|
||||
Text(
|
||||
'Historique des passages',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Filtres supplémentaires (secteur, utilisateur, période)
|
||||
_buildAdditionalFilters(context),
|
||||
// Filtres supplémentaires (secteur, utilisateur, période)
|
||||
_buildAdditionalFilters(context),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Widget de liste des passages
|
||||
Expanded(
|
||||
child: PassagesListWidget(
|
||||
passages: _formattedPassages,
|
||||
showFilters: true,
|
||||
showSearch: true,
|
||||
showActions: true,
|
||||
initialSearchQuery: searchQuery,
|
||||
initialTypeFilter: selectedType,
|
||||
initialPaymentFilter: selectedPaymentMethod,
|
||||
// Exclure les passages de type 2 (À finaliser)
|
||||
excludePassageTypes: [2],
|
||||
// Filtres par utilisateur et secteur
|
||||
filterByUserId: selectedUserId,
|
||||
filterBySectorId: selectedSectorId,
|
||||
// Période par défaut (dernier mois)
|
||||
periodFilter: 'lastMonth',
|
||||
// Plage de dates personnalisée si définie
|
||||
dateRange: selectedDateRange,
|
||||
onPassageSelected: (passage) {
|
||||
_showDetailsDialog(context, passage);
|
||||
},
|
||||
onReceiptView: (passage) {
|
||||
_showReceiptDialog(context, passage);
|
||||
},
|
||||
onDetailsView: (passage) {
|
||||
_showDetailsDialog(context, passage);
|
||||
},
|
||||
onPassageEdit: (passage) {
|
||||
// Action pour modifier le passage
|
||||
// Cette fonctionnalité pourrait être implémentée ultérieurement
|
||||
},
|
||||
// Widget de liste des passages avec hauteur fixe
|
||||
SizedBox(
|
||||
height: constraints.maxHeight * 0.7, // 70% de la hauteur disponible
|
||||
child: PassagesListWidget(
|
||||
passages: passages,
|
||||
showFilters:
|
||||
false, // Désactivé car les filtres sont maintenant dans la card "Filtres avancés"
|
||||
showSearch:
|
||||
false, // Désactivé car la recherche est maintenant dans la card "Filtres avancés"
|
||||
showActions: true,
|
||||
// Ne plus passer les filtres individuels car ils sont maintenant appliqués dans _getFilteredPassages()
|
||||
onPassageSelected: (passage) {
|
||||
_openPassageEditDialog(context, passage);
|
||||
},
|
||||
onReceiptView: (passage) {
|
||||
_showReceiptDialog(context, passage);
|
||||
},
|
||||
onDetailsView: (passage) {
|
||||
_showDetailsDialog(context, passage);
|
||||
},
|
||||
onPassageEdit: (passage) {
|
||||
// Action pour modifier le passage
|
||||
// Cette fonctionnalité pourrait être implémentée ultérieurement
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -339,7 +479,8 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: Container(width: double.infinity, height: double.infinity),
|
||||
child:
|
||||
const SizedBox(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
@@ -388,14 +529,16 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
List<Map<String, dynamic>> _formatPassagesForWidget(
|
||||
List<PassageModel> passages,
|
||||
SectorRepository sectorRepository,
|
||||
UserRepository userRepository) {
|
||||
MembreRepository membreRepository) {
|
||||
return passages.map((passage) {
|
||||
// Récupérer le secteur associé au passage
|
||||
final SectorModel? sector =
|
||||
sectorRepository.getSectorById(passage.fkSector);
|
||||
// Récupérer le secteur associé au passage (si fkSector n'est pas null)
|
||||
final SectorModel? sector = passage.fkSector != null
|
||||
? sectorRepository.getSectorById(passage.fkSector!)
|
||||
: null;
|
||||
|
||||
// Récupérer l'utilisateur associé au passage
|
||||
final UserModel? user = userRepository.getUserById(passage.fkUser);
|
||||
// Récupérer le membre associé au passage
|
||||
final MembreModel? membre =
|
||||
membreRepository.getMembreById(passage.fkUser);
|
||||
|
||||
// Construire l'adresse complète
|
||||
final String address =
|
||||
@@ -406,12 +549,21 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
|
||||
return {
|
||||
'id': passage.id,
|
||||
'date': passage.passedAt,
|
||||
'address': address,
|
||||
if (passage.passedAt != null) 'date': passage.passedAt!,
|
||||
'address': address, // Adresse complète pour l'affichage
|
||||
// Champs séparés pour l'édition
|
||||
'numero': passage.numero,
|
||||
'rueBis': passage.rueBis,
|
||||
'rue': passage.rue,
|
||||
'ville': passage.ville,
|
||||
'residence': passage.residence,
|
||||
'appt': passage.appt,
|
||||
'niveau': passage.niveau,
|
||||
'fkHabitat': passage.fkHabitat,
|
||||
'fkSector': passage.fkSector,
|
||||
'sector': sector?.libelle ?? 'Secteur inconnu',
|
||||
'fkUser': passage.fkUser,
|
||||
'user': user?.name ?? 'Utilisateur inconnu',
|
||||
'user': membre?.name ?? 'Membre inconnu',
|
||||
'type': passage.fkType,
|
||||
'amount': double.tryParse(passage.montant) ?? 0.0,
|
||||
'payment': passage.fkTypeReglement,
|
||||
@@ -421,7 +573,14 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
'notes': passage.remarque,
|
||||
'name': passage.name,
|
||||
'phone': passage.phone,
|
||||
// Ajouter d'autres champs nécessaires pour le widget
|
||||
'montant': passage.montant,
|
||||
'remarque': passage.remarque,
|
||||
// Autres champs utiles
|
||||
'fkOperation': passage.fkOperation,
|
||||
'passedAt': passage.passedAt,
|
||||
'lastSyncedAt': passage.lastSyncedAt,
|
||||
'isActive': passage.isActive,
|
||||
'isSynced': passage.isSynced,
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
@@ -552,6 +711,63 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _openPassageEditDialog(
|
||||
BuildContext context, Map<String, dynamic> passage) async {
|
||||
try {
|
||||
debugPrint('=== DEBUT _openPassageEditDialog ===');
|
||||
|
||||
// Récupérer l'ID du passage
|
||||
final int passageId = passage['id'] as int;
|
||||
debugPrint('Recherche du passage ID: $passageId');
|
||||
|
||||
// Trouver le PassageModel original dans la liste
|
||||
final PassageModel? passageModel =
|
||||
_originalPassages.where((p) => p.id == passageId).firstOrNull;
|
||||
|
||||
if (passageModel == null) {
|
||||
throw Exception('Passage original introuvable avec l\'ID: $passageId');
|
||||
}
|
||||
|
||||
debugPrint('PassageModel original trouvé');
|
||||
if (!mounted) {
|
||||
debugPrint('Widget non monté, abandon');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('Ouverture du dialog...');
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => PassageFormDialog(
|
||||
passage: passageModel,
|
||||
title: 'Modifier le passage',
|
||||
passageRepository: _passageRepository,
|
||||
userRepository: _userRepository,
|
||||
operationRepository: operationRepository,
|
||||
onSuccess: () {
|
||||
debugPrint('Dialog fermé avec succès');
|
||||
// Recharger les données après modification
|
||||
_loadPassages();
|
||||
},
|
||||
),
|
||||
);
|
||||
debugPrint('=== FIN _openPassageEditDialog ===');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('=== ERREUR _openPassageEditDialog ===');
|
||||
debugPrint('Erreur: $e');
|
||||
debugPrint('StackTrace: $stackTrace');
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de l\'ouverture du formulaire: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
@@ -616,25 +832,52 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Champ de recherche
|
||||
_buildSearchField(theme),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Disposition des filtres en fonction de la taille de l'écran
|
||||
isDesktop
|
||||
? Row(
|
||||
? Column(
|
||||
children: [
|
||||
// Filtre par secteur
|
||||
Expanded(
|
||||
child: _buildSectorFilter(theme, _sectors),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Première ligne : Secteur, Utilisateur, Période
|
||||
Row(
|
||||
children: [
|
||||
// Filtre par secteur
|
||||
Expanded(
|
||||
child: _buildSectorFilter(theme, _sectors),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Filtre par utilisateur
|
||||
Expanded(
|
||||
child: _buildUserFilter(theme, _users),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Filtre par membre
|
||||
Expanded(
|
||||
child: _buildMembreFilter(theme, _membres),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Filtre par période
|
||||
Expanded(
|
||||
child: _buildPeriodFilter(theme),
|
||||
// Filtre par période
|
||||
Expanded(
|
||||
child: _buildPeriodFilter(theme),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Deuxième ligne : Type de passage, Mode de règlement
|
||||
Row(
|
||||
children: [
|
||||
// Filtre par type de passage
|
||||
Expanded(
|
||||
child: _buildTypeFilter(theme),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Filtre par mode de règlement
|
||||
Expanded(
|
||||
child: _buildPaymentFilter(theme),
|
||||
),
|
||||
// Espacement pour équilibrer avec la ligne du dessus (3 colonnes)
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -644,12 +887,20 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
_buildSectorFilter(theme, _sectors),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Filtre par utilisateur
|
||||
_buildUserFilter(theme, _users),
|
||||
// Filtre par membre
|
||||
_buildMembreFilter(theme, _membres),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Filtre par période
|
||||
_buildPeriodFilter(theme),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Filtre par type de passage
|
||||
_buildTypeFilter(theme),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Filtre par mode de règlement
|
||||
_buildPaymentFilter(theme),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -714,7 +965,7 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
],
|
||||
onChanged: (String? value) {
|
||||
if (value != null) {
|
||||
@@ -745,11 +996,52 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du filtre par utilisateur
|
||||
Widget _buildUserFilter(ThemeData theme, List<UserModel> users) {
|
||||
// Vérifier si la liste des utilisateurs est vide ou si selectedUser n'est pas dans la liste
|
||||
bool isSelectedUserValid = selectedUser == 'Tous' ||
|
||||
users.any((u) => (u.name ?? 'Utilisateur inconnu') == selectedUser);
|
||||
// Construction du filtre par membre
|
||||
Widget _buildMembreFilter(ThemeData theme, List<MembreModel> membres) {
|
||||
// Fonction pour formater le nom d'affichage d'un membre
|
||||
String formatMembreDisplayName(MembreModel membre) {
|
||||
final String firstName = membre.firstName ?? '';
|
||||
final String name = membre.name ?? '';
|
||||
final String sectName = membre.sectName ?? '';
|
||||
|
||||
// Construire le nom de base
|
||||
String displayName = '';
|
||||
if (firstName.isNotEmpty && name.isNotEmpty) {
|
||||
displayName = '$firstName $name';
|
||||
} else if (name.isNotEmpty) {
|
||||
displayName = name;
|
||||
} else if (firstName.isNotEmpty) {
|
||||
displayName = firstName;
|
||||
} else {
|
||||
displayName = 'Membre inconnu';
|
||||
}
|
||||
|
||||
// Ajouter le sectName entre parenthèses s'il existe
|
||||
if (sectName.isNotEmpty) {
|
||||
displayName = '$displayName ($sectName)';
|
||||
}
|
||||
|
||||
return displayName;
|
||||
}
|
||||
|
||||
// Trier les membres par nom de famille
|
||||
final List<MembreModel> sortedMembres = [...membres];
|
||||
sortedMembres.sort((a, b) {
|
||||
final String nameA = a.name ?? '';
|
||||
final String nameB = b.name ?? '';
|
||||
return nameA.compareTo(nameB);
|
||||
});
|
||||
|
||||
// Créer une map pour retrouver les membres par leur nom d'affichage
|
||||
final Map<String, MembreModel> membreDisplayMap = {};
|
||||
for (final membre in sortedMembres) {
|
||||
final displayName = formatMembreDisplayName(membre);
|
||||
membreDisplayMap[displayName] = membre;
|
||||
}
|
||||
|
||||
// Vérifier si la liste des membres est vide ou si selectedUser n'est pas dans la liste
|
||||
bool isSelectedUserValid =
|
||||
selectedUser == 'Tous' || membreDisplayMap.containsKey(selectedUser);
|
||||
|
||||
// Si selectedUser n'est pas valide, le réinitialiser à 'Tous'
|
||||
if (!isSelectedUserValid) {
|
||||
@@ -767,7 +1059,7 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Utilisateur',
|
||||
'Membre',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -788,19 +1080,18 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
items: [
|
||||
const DropdownMenuItem<String>(
|
||||
value: 'Tous',
|
||||
child: Text('Tous les utilisateurs'),
|
||||
child: Text('Tous les membres'),
|
||||
),
|
||||
...users.map((user) {
|
||||
// S'assurer que user.name n'est pas null
|
||||
final String userName = user.name ?? 'Utilisateur inconnu';
|
||||
...membreDisplayMap.entries.map((entry) {
|
||||
final String displayName = entry.key;
|
||||
return DropdownMenuItem<String>(
|
||||
value: userName,
|
||||
value: displayName,
|
||||
child: Text(
|
||||
userName,
|
||||
displayName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
],
|
||||
onChanged: (String? value) {
|
||||
if (value != null) {
|
||||
@@ -808,21 +1099,16 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
_updateUserFilter('Tous', null);
|
||||
} else {
|
||||
try {
|
||||
// Trouver l'utilisateur correspondant
|
||||
final user = users.firstWhere(
|
||||
(u) => (u.name ?? 'Utilisateur inconnu') == value,
|
||||
orElse: () => users.isNotEmpty
|
||||
? users.first
|
||||
: throw Exception('Liste d\'utilisateurs vide'),
|
||||
);
|
||||
// S'assurer que user.name et user.id ne sont pas null
|
||||
final String userName =
|
||||
user.name ?? 'Utilisateur inconnu';
|
||||
final int? userId = user.id;
|
||||
_updateUserFilter(userName, userId);
|
||||
// Trouver le membre correspondant dans la map
|
||||
final membre = membreDisplayMap[value];
|
||||
if (membre != null) {
|
||||
final int membreId = membre.id;
|
||||
_updateUserFilter(value, membreId);
|
||||
} else {
|
||||
throw Exception('Membre non trouvé: $value');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'Erreur lors de la sélection de l\'utilisateur: $e');
|
||||
debugPrint('Erreur lors de la sélection du membre: $e');
|
||||
_updateUserFilter('Tous', null);
|
||||
}
|
||||
}
|
||||
@@ -912,34 +1198,155 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showResendConfirmation(BuildContext context, int passageId) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Renvoyer le reçu'),
|
||||
content: Text(
|
||||
'Êtes-vous sûr de vouloir renvoyer le reçu du passage #$passageId ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
// Construction du champ de recherche
|
||||
Widget _buildSearchField(ThemeData theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Recherche',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Action pour renvoyer le reçu
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Reçu du passage #$passageId renvoyé avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher par adresse ou nom...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_searchController.clear();
|
||||
searchQuery = '';
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
searchQuery = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du filtre par type de passage
|
||||
Widget _buildTypeFilter(ThemeData theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Type de passage',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: selectedType,
|
||||
isExpanded: true,
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
items: [
|
||||
const DropdownMenuItem<String>(
|
||||
value: 'Tous',
|
||||
child: Text('Tous les types'),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Renvoyer'),
|
||||
...AppKeys.typesPassages.entries.map((entry) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: entry.key.toString(),
|
||||
child: Text(
|
||||
entry.value['titre'] as String,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
onChanged: (String? value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
selectedType = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du filtre par mode de règlement
|
||||
Widget _buildPaymentFilter(ThemeData theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Mode de règlement',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: selectedPaymentMethod,
|
||||
isExpanded: true,
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
items: [
|
||||
const DropdownMenuItem<String>(
|
||||
value: 'Tous',
|
||||
child: Text('Tous les modes'),
|
||||
),
|
||||
...AppKeys.typesReglements.entries.map((entry) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: entry.key.toString(),
|
||||
child: Text(
|
||||
entry.value['titre'] as String,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
onChanged: (String? value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
selectedPaymentMethod = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
3585
app/lib/presentation/admin/admin_map_page.dart
Normal file → Executable file
3585
app/lib/presentation/admin/admin_map_page.dart
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
0
app/lib/presentation/admin/admin_operations_page.dart
Normal file → Executable file
0
app/lib/presentation/admin/admin_operations_page.dart
Normal file → Executable file
17
app/lib/presentation/admin/admin_statistics_page.dart
Normal file → Executable file
17
app/lib/presentation/admin/admin_statistics_page.dart
Normal file → Executable file
@@ -27,7 +27,7 @@ class DotsPainter extends CustomPainter {
|
||||
}
|
||||
|
||||
class AdminStatisticsPage extends StatefulWidget {
|
||||
const AdminStatisticsPage({Key? key}) : super(key: key);
|
||||
const AdminStatisticsPage({super.key});
|
||||
|
||||
@override
|
||||
State<AdminStatisticsPage> createState() => _AdminStatisticsPageState();
|
||||
@@ -80,7 +80,8 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: Container(width: double.infinity, height: double.infinity),
|
||||
child:
|
||||
const SizedBox(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
// Contenu de la page
|
||||
@@ -228,21 +229,21 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
PaymentData(
|
||||
typeId: 1,
|
||||
amount: 1500.0,
|
||||
color: const Color(0xFFFFC107),
|
||||
color: Color(0xFFFFC107),
|
||||
icon: Icons.toll,
|
||||
title: 'Espèce',
|
||||
),
|
||||
PaymentData(
|
||||
typeId: 2,
|
||||
amount: 2500.0,
|
||||
color: const Color(0xFF8BC34A),
|
||||
color: Color(0xFF8BC34A),
|
||||
icon: Icons.wallet,
|
||||
title: 'Chèque',
|
||||
),
|
||||
PaymentData(
|
||||
typeId: 3,
|
||||
amount: 1000.0,
|
||||
color: const Color(0xFF00B0FF),
|
||||
color: Color(0xFF00B0FF),
|
||||
icon: Icons.credit_card,
|
||||
title: 'CB',
|
||||
),
|
||||
@@ -281,21 +282,21 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
PaymentData(
|
||||
typeId: 1,
|
||||
amount: 1500.0,
|
||||
color: const Color(0xFFFFC107),
|
||||
color: Color(0xFFFFC107),
|
||||
icon: Icons.toll,
|
||||
title: 'Espèce',
|
||||
),
|
||||
PaymentData(
|
||||
typeId: 2,
|
||||
amount: 2500.0,
|
||||
color: const Color(0xFF8BC34A),
|
||||
color: Color(0xFF8BC34A),
|
||||
icon: Icons.wallet,
|
||||
title: 'Chèque',
|
||||
),
|
||||
PaymentData(
|
||||
typeId: 3,
|
||||
amount: 1000.0,
|
||||
color: const Color(0xFF00B0FF),
|
||||
color: Color(0xFF00B0FF),
|
||||
icon: Icons.credit_card,
|
||||
title: 'CB',
|
||||
),
|
||||
|
||||
504
app/lib/presentation/auth/login_page.dart
Normal file → Executable file
504
app/lib/presentation/auth/login_page.dart
Normal file → Executable file
@@ -2,14 +2,13 @@ import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
||||
import 'dart:js' as js;
|
||||
import 'package:geosector_app/core/services/js_stub.dart'
|
||||
if (dart.library.js) 'dart:js' as js;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:geosector_app/core/services/app_info_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_button.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
import 'package:geosector_app/core/services/location_service.dart';
|
||||
import 'package:geosector_app/core/services/connectivity_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
@@ -57,10 +56,6 @@ class _LoginPageState extends State<LoginPage> {
|
||||
// Type de connexion (utilisateur ou administrateur)
|
||||
late String _loginType;
|
||||
|
||||
// État des permissions de géolocalisation
|
||||
bool _checkingPermission = true;
|
||||
bool _hasLocationPermission = false;
|
||||
String? _locationErrorMessage;
|
||||
|
||||
// État de la connexion Internet
|
||||
bool _isConnected = false;
|
||||
@@ -78,7 +73,9 @@ class _LoginPageState extends State<LoginPage> {
|
||||
// Fallback sur la version du AppInfoService si elle existe
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = AppInfoService.fullVersion.split(' ').last; // Extraire juste le numéro
|
||||
_appVersion = AppInfoService.fullVersion
|
||||
.split(' ')
|
||||
.last; // Extraire juste le numéro
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -101,7 +98,8 @@ class _LoginPageState extends State<LoginPage> {
|
||||
// Vérification du type de connexion
|
||||
if (widget.loginType == null) {
|
||||
// Si aucun type n'est spécifié, naviguer vers la splash page
|
||||
print('LoginPage: Aucun type de connexion spécifié, navigation vers splash page');
|
||||
print(
|
||||
'LoginPage: Aucun type de connexion spécifié, navigation vers splash page');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
GoRouter.of(context).go('/');
|
||||
});
|
||||
@@ -154,10 +152,13 @@ class _LoginPageState extends State<LoginPage> {
|
||||
'''
|
||||
]);
|
||||
|
||||
if (result != null && result is String && result.toLowerCase() == 'user') {
|
||||
if (result != null &&
|
||||
result is String &&
|
||||
result.toLowerCase() == 'user') {
|
||||
setState(() {
|
||||
_loginType = 'user';
|
||||
print('LoginPage: Type détecté depuis sessionStorage: $_loginType');
|
||||
print(
|
||||
'LoginPage: Type détecté depuis sessionStorage: $_loginType');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -170,16 +171,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier les permissions de géolocalisation au démarrage seulement sur mobile
|
||||
if (!kIsWeb) {
|
||||
_checkLocationPermission();
|
||||
} else {
|
||||
// En version web, on considère que les permissions sont accordées
|
||||
setState(() {
|
||||
_checkingPermission = false;
|
||||
_hasLocationPermission = true;
|
||||
});
|
||||
}
|
||||
// Les permissions sont maintenant vérifiées dans splash_page
|
||||
|
||||
// Initialiser l'état de la connexion
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -221,7 +213,8 @@ class _LoginPageState extends State<LoginPage> {
|
||||
debugPrint('Rôle utilisateur (1) correspond au type de login (user)');
|
||||
} else if (_loginType == 'admin' && roleValue > 1) {
|
||||
roleMatches = true;
|
||||
debugPrint('Rôle administrateur ($roleValue) correspond au type de login (admin)');
|
||||
debugPrint(
|
||||
'Rôle administrateur ($roleValue) correspond au type de login (admin)');
|
||||
}
|
||||
|
||||
// Pré-remplir le champ username seulement si le rôle correspond
|
||||
@@ -235,40 +228,17 @@ class _LoginPageState extends State<LoginPage> {
|
||||
} else if (lastUser.email.isNotEmpty) {
|
||||
_usernameController.text = lastUser.email;
|
||||
_usernameFocusNode.unfocus();
|
||||
debugPrint('Champ username pré-rempli avec email: ${lastUser.email}');
|
||||
debugPrint(
|
||||
'Champ username pré-rempli avec email: ${lastUser.email}');
|
||||
}
|
||||
} else {
|
||||
debugPrint('Le rôle ($roleValue) ne correspond pas au type de login ($_loginType), champ username non pré-rempli');
|
||||
debugPrint(
|
||||
'Le rôle ($roleValue) ne correspond pas au type de login ($_loginType), champ username non pré-rempli');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Vérifie les permissions de géolocalisation
|
||||
Future<void> _checkLocationPermission() async {
|
||||
// Ne pas vérifier les permissions en version web
|
||||
if (kIsWeb) {
|
||||
setState(() {
|
||||
_hasLocationPermission = true;
|
||||
_checkingPermission = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_checkingPermission = true;
|
||||
});
|
||||
|
||||
// Vérifier si les services de localisation sont activés et si l'application a la permission
|
||||
final hasPermission = await LocationService.checkAndRequestPermission();
|
||||
final errorMessage = await LocationService.getLocationErrorMessage();
|
||||
|
||||
setState(() {
|
||||
_hasLocationPermission = hasPermission;
|
||||
_locationErrorMessage = errorMessage;
|
||||
_checkingPermission = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -278,210 +248,6 @@ class _LoginPageState extends State<LoginPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Construit l'écran de chargement pendant la vérification des permissions
|
||||
Widget _buildLoadingScreen(ThemeData theme) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Logo simlifié
|
||||
Image.asset(
|
||||
'assets/images/logo-geosector-1024.png',
|
||||
height: 160,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Vérification des permissions...',
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit l'écran de demande de permission de géolocalisation
|
||||
Widget _buildLocationPermissionScreen(ThemeData theme) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: _loginType == 'user' ? [Colors.white, Colors.green.shade300] : [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: const SizedBox(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shadowColor: _loginType == 'user' ? Colors.green.withOpacity(0.5) : Colors.red.withOpacity(0.5),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.0)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Logo simplifié
|
||||
Image.asset(
|
||||
'assets/images/logo-geosector-1024.png',
|
||||
height: 160,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Accès à la localisation requis',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Message d'erreur
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.error.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_disabled,
|
||||
color: theme.colorScheme.error,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_locationErrorMessage ?? 'L\'accès à la localisation est nécessaire pour utiliser cette application.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Cette application utilise votre position pour enregistrer les passages et assurer le suivi des secteurs géographiques. Sans cette permission, l\'application ne peut pas fonctionner correctement.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Instructions pour activer la localisation
|
||||
Text(
|
||||
'Comment activer la localisation :',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildInstructionStep(theme, 1, 'Ouvrez les paramètres de votre appareil'),
|
||||
_buildInstructionStep(theme, 2, 'Accédez aux paramètres de confidentialité ou de localisation'),
|
||||
_buildInstructionStep(theme, 3, 'Recherchez GEOSECTOR dans la liste des applications'),
|
||||
_buildInstructionStep(theme, 4, 'Activez l\'accès à la localisation pour cette application'),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Boutons d'action
|
||||
CustomButton(
|
||||
onPressed: () async {
|
||||
// Ouvrir les paramètres de l'application
|
||||
await LocationService.openAppSettings();
|
||||
},
|
||||
text: 'Ouvrir les paramètres de l\'application',
|
||||
icon: Icons.settings,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomButton(
|
||||
onPressed: () async {
|
||||
// Ouvrir les paramètres de localisation
|
||||
await LocationService.openLocationSettings();
|
||||
},
|
||||
text: 'Ouvrir les paramètres de localisation',
|
||||
icon: Icons.location_on,
|
||||
backgroundColor: theme.colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomButton(
|
||||
onPressed: () {
|
||||
// Vérifier à nouveau les permissions
|
||||
_checkLocationPermission();
|
||||
},
|
||||
text: 'Vérifier à nouveau',
|
||||
icon: Icons.refresh,
|
||||
backgroundColor: theme.colorScheme.tertiary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit une étape d'instruction pour activer la localisation
|
||||
Widget _buildInstructionStep(ThemeData theme, int stepNumber, String instruction) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$stepNumber',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
instruction,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -491,12 +257,8 @@ class _LoginPageState extends State<LoginPage> {
|
||||
final theme = Theme.of(context);
|
||||
final size = MediaQuery.of(context).size;
|
||||
|
||||
// Afficher l'écran de permission de géolocalisation si l'utilisateur n'a pas accordé la permission (sauf en version web)
|
||||
if (!kIsWeb && _checkingPermission) {
|
||||
return _buildLoadingScreen(theme);
|
||||
} else if (!kIsWeb && !_hasLocationPermission) {
|
||||
return _buildLocationPermissionScreen(theme);
|
||||
}
|
||||
// Les permissions sont maintenant gérées dans splash_page
|
||||
// On n'a plus besoin de ces vérifications ici
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
@@ -508,12 +270,15 @@ class _LoginPageState extends State<LoginPage> {
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: _loginType == 'user' ? [Colors.white, Colors.green.shade300] : [Colors.white, Colors.red.shade300],
|
||||
colors: _loginType == 'user'
|
||||
? [Colors.white, Colors.green.shade300]
|
||||
: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: const SizedBox(width: double.infinity, height: double.infinity),
|
||||
child: const SizedBox(
|
||||
width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
@@ -524,8 +289,11 @@ class _LoginPageState extends State<LoginPage> {
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shadowColor: _loginType == 'user' ? Colors.green.withOpacity(0.5) : Colors.red.withOpacity(0.5),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.0)),
|
||||
shadowColor: _loginType == 'user'
|
||||
? Colors.green.withOpacity(0.5)
|
||||
: Colors.red.withOpacity(0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16.0)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
@@ -539,10 +307,14 @@ class _LoginPageState extends State<LoginPage> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
_loginType == 'user' ? 'Connexion Utilisateur' : 'Connexion Administrateur',
|
||||
_loginType == 'user'
|
||||
? 'Connexion Utilisateur'
|
||||
: 'Connexion Administrateur',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _loginType == 'user' ? Colors.green : Colors.red,
|
||||
color: _loginType == 'user'
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -551,14 +323,16 @@ class _LoginPageState extends State<LoginPage> {
|
||||
if (kDebugMode)
|
||||
Text(
|
||||
'Type de connexion: $_loginType',
|
||||
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||
style: const TextStyle(
|
||||
fontSize: 10, color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Bienvenue sur GEOSECTOR',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
color:
|
||||
theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -576,17 +350,23 @@ class _LoginPageState extends State<LoginPage> {
|
||||
color: theme.colorScheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.error.withOpacity(0.3),
|
||||
color:
|
||||
theme.colorScheme.error.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.signal_wifi_off, color: theme.colorScheme.error, size: 32),
|
||||
Icon(Icons.signal_wifi_off,
|
||||
color: theme.colorScheme.error, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text('Connexion Internet requise',
|
||||
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, color: theme.colorScheme.error)),
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.error)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Veuillez vous connecter à Internet (WiFi ou données mobiles) pour pouvoir vous connecter.'),
|
||||
const Text(
|
||||
'Veuillez vous connecter à Internet (WiFi ou données mobiles) pour pouvoir vous connecter.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -623,7 +403,9 @@ class _LoginPageState extends State<LoginPage> {
|
||||
obscureText: _obscurePassword,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined,
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
@@ -638,17 +420,21 @@ class _LoginPageState extends State<LoginPage> {
|
||||
return null;
|
||||
},
|
||||
onFieldSubmitted: (_) async {
|
||||
if (!userRepository.isLoading && _formKey.currentState!.validate()) {
|
||||
if (!userRepository.isLoading &&
|
||||
_formKey.currentState!.validate()) {
|
||||
// Vérifier que le type de connexion est spécifié
|
||||
if (_loginType.isEmpty) {
|
||||
print('Login: Type non spécifié, redirection vers la page de démarrage');
|
||||
print(
|
||||
'Login: Type non spécifié, redirection vers la page de démarrage');
|
||||
context.go('/');
|
||||
return;
|
||||
}
|
||||
|
||||
print('Login: Tentative avec type: $_loginType');
|
||||
print(
|
||||
'Login: Tentative avec type: $_loginType');
|
||||
|
||||
final success = await userRepository.login(
|
||||
final success =
|
||||
await userRepository.login(
|
||||
_usernameController.text.trim(),
|
||||
_passwordController.text,
|
||||
type: _loginType,
|
||||
@@ -656,12 +442,16 @@ class _LoginPageState extends State<LoginPage> {
|
||||
|
||||
if (success && mounted) {
|
||||
// Récupérer directement le rôle de l'utilisateur
|
||||
final user = userRepository.getCurrentUser();
|
||||
final user =
|
||||
userRepository.getCurrentUser();
|
||||
if (user == null) {
|
||||
debugPrint('ERREUR: Utilisateur non trouvé après connexion réussie');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
debugPrint(
|
||||
'ERREUR: Utilisateur non trouvé après connexion réussie');
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Erreur de connexion. Veuillez réessayer.'),
|
||||
content: Text(
|
||||
'Erreur de connexion. Veuillez réessayer.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -671,25 +461,32 @@ class _LoginPageState extends State<LoginPage> {
|
||||
// Convertir le rôle en int si nécessaire
|
||||
int roleValue;
|
||||
if (user.role is String) {
|
||||
roleValue = int.tryParse(user.role as String) ?? 1;
|
||||
roleValue = int.tryParse(
|
||||
user.role as String) ??
|
||||
1;
|
||||
} else {
|
||||
roleValue = user.role;
|
||||
}
|
||||
|
||||
debugPrint('Role de l\'utilisateur: $roleValue');
|
||||
debugPrint(
|
||||
'Role de l\'utilisateur: $roleValue');
|
||||
|
||||
// Redirection simple basée sur le rôle
|
||||
if (roleValue > 1) {
|
||||
debugPrint('Redirection vers /admin (rôle > 1)');
|
||||
debugPrint(
|
||||
'Redirection vers /admin (rôle > 1)');
|
||||
context.go('/admin');
|
||||
} else {
|
||||
debugPrint('Redirection vers /user (rôle = 1)');
|
||||
debugPrint(
|
||||
'Redirection vers /user (rôle = 1)');
|
||||
context.go('/user');
|
||||
}
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Échec de la connexion. Vérifiez vos identifiants.'),
|
||||
content: Text(
|
||||
'Échec de la connexion. Vérifiez vos identifiants.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -718,44 +515,45 @@ class _LoginPageState extends State<LoginPage> {
|
||||
|
||||
// Bouton de connexion
|
||||
CustomButton(
|
||||
onPressed: (userRepository.isLoading || !_isConnected)
|
||||
onPressed: (userRepository.isLoading ||
|
||||
!_isConnected)
|
||||
? null
|
||||
: () async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// Vérifier à nouveau les permissions de géolocalisation avant de se connecter (sauf en version web)
|
||||
if (!kIsWeb) {
|
||||
await _checkLocationPermission();
|
||||
|
||||
// Si l'utilisateur n'a toujours pas accordé les permissions, ne pas continuer
|
||||
if (!_hasLocationPermission) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('L\'accès à la localisation est nécessaire pour utiliser cette application.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_formKey.currentState!
|
||||
.validate()) {
|
||||
// Les permissions sont déjà vérifiées dans splash_page
|
||||
|
||||
// Vérifier la connexion Internet
|
||||
await connectivityService.checkConnectivity();
|
||||
await connectivityService
|
||||
.checkConnectivity();
|
||||
|
||||
if (!connectivityService.isConnected) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
if (!connectivityService
|
||||
.isConnected) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Aucune connexion Internet. La connexion n\'est pas possible hors ligne.'),
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
duration: const Duration(seconds: 3),
|
||||
content: const Text(
|
||||
'Aucune connexion Internet. La connexion n\'est pas possible hors ligne.'),
|
||||
backgroundColor:
|
||||
theme.colorScheme.error,
|
||||
duration: const Duration(
|
||||
seconds: 3),
|
||||
action: SnackBarAction(
|
||||
label: 'Réessayer',
|
||||
onPressed: () async {
|
||||
await connectivityService.checkConnectivity();
|
||||
if (connectivityService.isConnected && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
await connectivityService
|
||||
.checkConnectivity();
|
||||
if (connectivityService
|
||||
.isConnected &&
|
||||
mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Connexion Internet ${connectivityService.connectionType} détectée.'),
|
||||
backgroundColor: Colors.green,
|
||||
content: Text(
|
||||
'Connexion Internet ${connectivityService.connectionType} détectée.'),
|
||||
backgroundColor:
|
||||
Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -768,15 +566,18 @@ class _LoginPageState extends State<LoginPage> {
|
||||
|
||||
// Vérifier que le type de connexion est spécifié
|
||||
if (_loginType.isEmpty) {
|
||||
print('Login: Type non spécifié, redirection vers la page de démarrage');
|
||||
print(
|
||||
'Login: Type non spécifié, redirection vers la page de démarrage');
|
||||
context.go('/');
|
||||
return;
|
||||
}
|
||||
|
||||
print('Login: Tentative avec type: $_loginType');
|
||||
print(
|
||||
'Login: Tentative avec type: $_loginType');
|
||||
|
||||
// Utiliser directement userRepository avec l'overlay de chargement
|
||||
final success = await userRepository.loginWithUI(
|
||||
final success = await userRepository
|
||||
.loginWithUI(
|
||||
context,
|
||||
_usernameController.text.trim(),
|
||||
_passwordController.text,
|
||||
@@ -784,15 +585,20 @@ class _LoginPageState extends State<LoginPage> {
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
debugPrint('Connexion réussie, tentative de redirection...');
|
||||
debugPrint(
|
||||
'Connexion réussie, tentative de redirection...');
|
||||
|
||||
// Récupérer directement le rôle de l'utilisateur
|
||||
final user = userRepository.getCurrentUser();
|
||||
final user = userRepository
|
||||
.getCurrentUser();
|
||||
if (user == null) {
|
||||
debugPrint('ERREUR: Utilisateur non trouvé après connexion réussie');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
debugPrint(
|
||||
'ERREUR: Utilisateur non trouvé après connexion réussie');
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Erreur de connexion. Veuillez réessayer.'),
|
||||
content: Text(
|
||||
'Erreur de connexion. Veuillez réessayer.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -802,32 +608,41 @@ class _LoginPageState extends State<LoginPage> {
|
||||
// Convertir le rôle en int si nécessaire
|
||||
int roleValue;
|
||||
if (user.role is String) {
|
||||
roleValue = int.tryParse(user.role as String) ?? 1;
|
||||
roleValue = int.tryParse(
|
||||
user.role as String) ??
|
||||
1;
|
||||
} else {
|
||||
roleValue = user.role;
|
||||
}
|
||||
|
||||
debugPrint('Role de l\'utilisateur: $roleValue');
|
||||
debugPrint(
|
||||
'Role de l\'utilisateur: $roleValue');
|
||||
|
||||
// Redirection simple basée sur le rôle
|
||||
if (roleValue > 1) {
|
||||
debugPrint('Redirection vers /admin (rôle > 1)');
|
||||
debugPrint(
|
||||
'Redirection vers /admin (rôle > 1)');
|
||||
context.go('/admin');
|
||||
} else {
|
||||
debugPrint('Redirection vers /user (rôle = 1)');
|
||||
debugPrint(
|
||||
'Redirection vers /user (rôle = 1)');
|
||||
context.go('/user');
|
||||
}
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Échec de la connexion. Vérifiez vos identifiants.'),
|
||||
content: Text(
|
||||
'Échec de la connexion. Vérifiez vos identifiants.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
text: _isConnected ? 'Se connecter' : 'Connexion Internet requise',
|
||||
text: _isConnected
|
||||
? 'Se connecter'
|
||||
: 'Connexion Internet requise',
|
||||
isLoading: userRepository.isLoading,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -950,7 +765,8 @@ class _LoginPageState extends State<LoginPage> {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer votre email';
|
||||
}
|
||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
|
||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
|
||||
.hasMatch(value)) {
|
||||
return 'Veuillez entrer un email valide';
|
||||
}
|
||||
return null;
|
||||
@@ -1007,8 +823,10 @@ class _LoginPageState extends State<LoginPage> {
|
||||
// Si la réponse est 404, c'est peut-être un problème de route
|
||||
if (response.statusCode == 404) {
|
||||
// Essayer avec une URL alternative
|
||||
final alternativeUrl = '$baseUrl/api/index.php/lostpassword';
|
||||
print('Tentative avec URL alternative: $alternativeUrl');
|
||||
final alternativeUrl =
|
||||
'$baseUrl/api/index.php/lostpassword';
|
||||
print(
|
||||
'Tentative avec URL alternative: $alternativeUrl');
|
||||
|
||||
final alternativeResponse = await http.post(
|
||||
Uri.parse(alternativeUrl),
|
||||
@@ -1018,8 +836,10 @@ class _LoginPageState extends State<LoginPage> {
|
||||
}),
|
||||
);
|
||||
|
||||
print('Réponse alternative reçue: ${alternativeResponse.statusCode}');
|
||||
print('Corps de la réponse alternative: ${alternativeResponse.body}');
|
||||
print(
|
||||
'Réponse alternative reçue: ${alternativeResponse.statusCode}');
|
||||
print(
|
||||
'Corps de la réponse alternative: ${alternativeResponse.body}');
|
||||
|
||||
// Si la réponse alternative est un succès, utiliser cette réponse
|
||||
if (alternativeResponse.statusCode == 200) {
|
||||
@@ -1027,7 +847,8 @@ class _LoginPageState extends State<LoginPage> {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Erreur lors de l\'envoi de la requête: $e');
|
||||
print(
|
||||
'Erreur lors de l\'envoi de la requête: $e');
|
||||
throw Exception('Erreur de connexion: $e');
|
||||
}
|
||||
|
||||
@@ -1044,7 +865,8 @@ class _LoginPageState extends State<LoginPage> {
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
// Fermer automatiquement la boîte de dialogue après 2 secondes
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
Future.delayed(const Duration(seconds: 2),
|
||||
() {
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
@@ -1076,13 +898,16 @@ class _LoginPageState extends State<LoginPage> {
|
||||
|
||||
// Afficher un message d'erreur
|
||||
final responseData = json.decode(response.body);
|
||||
throw Exception(responseData['message'] ?? 'Erreur lors de la récupération du mot de passe');
|
||||
throw Exception(responseData['message'] ??
|
||||
'Erreur lors de la récupération du mot de passe');
|
||||
}
|
||||
} catch (e) {
|
||||
// Afficher un message d'erreur
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString().contains('Exception:')
|
||||
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,
|
||||
@@ -1107,7 +932,8 @@ class _LoginPageState extends State<LoginPage> {
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text('Recevoir un nouveau mot de passe'),
|
||||
|
||||
287
app/lib/presentation/auth/register_page.dart
Normal file → Executable file
287
app/lib/presentation/auth/register_page.dart
Normal file → Executable file
@@ -6,10 +6,8 @@ import 'dart:math' as math;
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_button.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
import 'package:geosector_app/core/services/connectivity_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
|
||||
import 'package:geosector_app/core/services/app_info_service.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
@@ -73,8 +71,10 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
final String _hiddenToken = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
// Valeurs pour le captcha simple
|
||||
final int _captchaNum1 = 2 + (DateTime.now().second % 5); // Nombre entre 2 et 6
|
||||
final int _captchaNum2 = 3 + (DateTime.now().minute % 4); // Nombre entre 3 et 6
|
||||
final int _captchaNum1 =
|
||||
2 + (DateTime.now().second % 5); // Nombre entre 2 et 6
|
||||
final int _captchaNum2 =
|
||||
3 + (DateTime.now().minute % 4); // Nombre entre 3 et 6
|
||||
|
||||
// État de la connexion Internet et de la plateforme
|
||||
bool _isConnected = false;
|
||||
@@ -100,7 +100,9 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
// Fallback sur la version du AppInfoService si elle existe
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = AppInfoService.fullVersion.split(' ').last; // Extraire juste le numéro
|
||||
_appVersion = AppInfoService.fullVersion
|
||||
.split(' ')
|
||||
.last; // Extraire juste le numéro
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -164,7 +166,8 @@ 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)
|
||||
final baseUrl = Uri
|
||||
.base.origin; // Récupère l'URL de base (ex: https://app.geosector.fr)
|
||||
final apiUrl = '$baseUrl/api/villes?code_postal=$postalCode';
|
||||
|
||||
final response = await http.get(
|
||||
@@ -246,7 +249,8 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: const SizedBox(width: double.infinity, height: double.infinity),
|
||||
child: const SizedBox(
|
||||
width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
@@ -289,7 +293,8 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
if (mounted && _isConnected != isConnected) {
|
||||
setState(() {
|
||||
_isConnected = isConnected;
|
||||
_connectionType = connectivityService.connectionType;
|
||||
_connectionType =
|
||||
connectivityService.connectionType;
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -336,7 +341,8 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
if (_isConnected && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Connexion Internet $_connectionType détectée.'),
|
||||
content: Text(
|
||||
'Connexion Internet $_connectionType détectée.'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
@@ -388,7 +394,8 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer votre email';
|
||||
}
|
||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
|
||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
|
||||
.hasMatch(value)) {
|
||||
return 'Veuillez entrer un email valide';
|
||||
}
|
||||
return null;
|
||||
@@ -415,7 +422,8 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
CustomTextField(
|
||||
controller: _postalCodeController,
|
||||
label: 'Code postal de l\'amicale',
|
||||
hintText: 'Entrez le code postal de votre amicale',
|
||||
hintText:
|
||||
'Entrez le code postal de votre amicale',
|
||||
prefixIcon: Icons.location_on_outlined,
|
||||
keyboardType: TextInputType.number,
|
||||
isRequired: true,
|
||||
@@ -443,7 +451,8 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
children: [
|
||||
Text(
|
||||
'Commune de l\'amicale',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
style:
|
||||
theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
@@ -473,7 +482,8 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
),
|
||||
child: _isLoadingCities
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
@@ -485,16 +495,20 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
Icons.location_city_outlined,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
hintText: _postalCodeController.text.length < 3
|
||||
hintText: _postalCodeController
|
||||
.text.length <
|
||||
3
|
||||
? 'Entrez d\'abord au moins 3 chiffres du code postal'
|
||||
: _cities.isEmpty
|
||||
? 'Aucune commune trouvée pour ce code postal'
|
||||
: 'Sélectionnez une commune',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
@@ -512,13 +526,18 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
// Mettre à jour le code postal avec celui de la ville sélectionnée
|
||||
if (newValue != null) {
|
||||
// Désactiver temporairement le listener pour éviter une boucle infinie
|
||||
_postalCodeController.removeListener(_onPostalCodeChanged);
|
||||
_postalCodeController
|
||||
.removeListener(
|
||||
_onPostalCodeChanged);
|
||||
|
||||
// Mettre à jour le code postal
|
||||
_postalCodeController.text = newValue.postalCode;
|
||||
_postalCodeController.text =
|
||||
newValue.postalCode;
|
||||
|
||||
// Réactiver le listener
|
||||
_postalCodeController.addListener(_onPostalCodeChanged);
|
||||
_postalCodeController
|
||||
.addListener(
|
||||
_onPostalCodeChanged);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -553,7 +572,8 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
const SizedBox(height: 8),
|
||||
CustomTextField(
|
||||
controller: _captchaController,
|
||||
label: 'Combien font $_captchaNum1 + $_captchaNum2 ?',
|
||||
label:
|
||||
'Combien font $_captchaNum1 + $_captchaNum2 ?',
|
||||
hintText: 'Entrez le résultat',
|
||||
prefixIcon: Icons.security,
|
||||
keyboardType: TextInputType.number,
|
||||
@@ -590,30 +610,43 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
|
||||
// Bouton d'inscription
|
||||
CustomButton(
|
||||
onPressed: (_isLoading || (_isMobile && !_isConnected))
|
||||
onPressed: (_isLoading ||
|
||||
(_isMobile && !_isConnected))
|
||||
? null
|
||||
: () async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// Vérifier la connexion Internet avant de soumettre
|
||||
// Utiliser l'instance globale de connectivityService définie dans app.dart
|
||||
await connectivityService.checkConnectivity();
|
||||
await connectivityService
|
||||
.checkConnectivity();
|
||||
|
||||
if (!connectivityService.isConnected) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Aucune connexion Internet. L\'inscription nécessite une connexion active.'),
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
duration: const Duration(seconds: 3),
|
||||
content: const Text(
|
||||
'Aucune connexion Internet. L\'inscription nécessite une connexion active.'),
|
||||
backgroundColor:
|
||||
theme.colorScheme.error,
|
||||
duration:
|
||||
const Duration(seconds: 3),
|
||||
action: SnackBarAction(
|
||||
label: 'Réessayer',
|
||||
onPressed: () async {
|
||||
await connectivityService.checkConnectivity();
|
||||
if (connectivityService.isConnected && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
await connectivityService
|
||||
.checkConnectivity();
|
||||
if (connectivityService
|
||||
.isConnected &&
|
||||
mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Connexion Internet ${connectivityService.connectionType} détectée.'),
|
||||
backgroundColor: Colors.green,
|
||||
content: Text(
|
||||
'Connexion Internet ${connectivityService.connectionType} détectée.'),
|
||||
backgroundColor:
|
||||
Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -625,11 +658,15 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
return;
|
||||
}
|
||||
// Vérifier que le captcha est correct
|
||||
final int? captchaAnswer = int.tryParse(_captchaController.text);
|
||||
if (captchaAnswer != _captchaNum1 + _captchaNum2) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
final int? captchaAnswer = int.tryParse(
|
||||
_captchaController.text);
|
||||
if (captchaAnswer !=
|
||||
_captchaNum1 + _captchaNum2) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('La vérification de sécurité a échoué. Veuillez réessayer.'),
|
||||
content: Text(
|
||||
'La vérification de sécurité a échoué. Veuillez réessayer.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -640,11 +677,16 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
final Map<String, dynamic> formData = {
|
||||
'email': _emailController.text.trim(),
|
||||
'name': _nameController.text.trim(),
|
||||
'amicale_name': _amicaleNameController.text.trim(),
|
||||
'postal_code': _postalCodeController.text,
|
||||
'city_name': _selectedCity?.name ?? '',
|
||||
'amicale_name': _amicaleNameController
|
||||
.text
|
||||
.trim(),
|
||||
'postal_code':
|
||||
_postalCodeController.text,
|
||||
'city_name':
|
||||
_selectedCity?.name ?? '',
|
||||
'captcha_answer': captchaAnswer,
|
||||
'captcha_expected': _captchaNum1 + _captchaNum2,
|
||||
'captcha_expected':
|
||||
_captchaNum1 + _captchaNum2,
|
||||
'token': _hiddenToken,
|
||||
};
|
||||
|
||||
@@ -656,12 +698,14 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
try {
|
||||
// Envoyer les données à l'API
|
||||
final baseUrl = Uri.base.origin;
|
||||
final apiUrl = '$baseUrl/api/register';
|
||||
final apiUrl =
|
||||
'$baseUrl/api/register';
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(apiUrl),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Type':
|
||||
'application/json',
|
||||
},
|
||||
body: json.encode(formData),
|
||||
);
|
||||
@@ -672,23 +716,34 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
});
|
||||
|
||||
// Traiter la réponse
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
final responseData = json.decode(response.body);
|
||||
if (response.statusCode == 200 ||
|
||||
response.statusCode == 201) {
|
||||
final responseData =
|
||||
json.decode(response.body);
|
||||
|
||||
// Vérifier si la réponse indique un succès
|
||||
final bool isSuccess = responseData['success'] == true || responseData['status'] == 'success';
|
||||
final bool isSuccess =
|
||||
responseData['success'] ==
|
||||
true ||
|
||||
responseData['status'] ==
|
||||
'success';
|
||||
|
||||
// Récupérer le message de la réponse
|
||||
final String message = responseData['message'] ??
|
||||
(isSuccess ? 'Inscription réussie !' : 'Échec de l\'inscription. Veuillez réessayer.');
|
||||
final String message = responseData[
|
||||
'message'] ??
|
||||
(isSuccess
|
||||
? 'Inscription réussie !'
|
||||
: 'Échec de l\'inscription. Veuillez réessayer.');
|
||||
|
||||
if (isSuccess) {
|
||||
if (mounted) {
|
||||
// Afficher une boîte de dialogue de succès
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false, // L'utilisateur doit cliquer sur OK
|
||||
builder: (BuildContext context) {
|
||||
barrierDismissible:
|
||||
false, // L'utilisateur doit cliquer sur OK
|
||||
builder:
|
||||
(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
@@ -697,50 +752,88 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
color: Colors.green,
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
Text('Inscription réussie'),
|
||||
Text(
|
||||
'Inscription réussie'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize:
|
||||
MainAxisSize.min,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment
|
||||
.start,
|
||||
children: [
|
||||
Text(
|
||||
'Votre demande d\'inscription a été enregistrée avec succès.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
style: theme
|
||||
.textTheme
|
||||
.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(
|
||||
height: 16),
|
||||
Text(
|
||||
'Vous allez recevoir un email contenant :',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
style: theme
|
||||
.textTheme
|
||||
.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(
|
||||
height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment
|
||||
.start,
|
||||
children: [
|
||||
Icon(Icons.arrow_right, size: 20, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons
|
||||
.arrow_right,
|
||||
size: 20,
|
||||
color: theme
|
||||
.colorScheme
|
||||
.primary),
|
||||
const SizedBox(
|
||||
width: 4),
|
||||
const Expanded(
|
||||
child: Text('Votre identifiant de connexion'),
|
||||
child: Text(
|
||||
'Votre identifiant de connexion'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(
|
||||
height: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment
|
||||
.start,
|
||||
children: [
|
||||
Icon(Icons.arrow_right, size: 20, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons
|
||||
.arrow_right,
|
||||
size: 20,
|
||||
color: theme
|
||||
.colorScheme
|
||||
.primary),
|
||||
const SizedBox(
|
||||
width: 4),
|
||||
const Expanded(
|
||||
child: Text('Un lien pour définir votre mot de passe'),
|
||||
child: Text(
|
||||
'Un lien pour définir votre mot de passe'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(
|
||||
height: 16),
|
||||
Text(
|
||||
'Vérifiez votre boîte de réception et vos spams.',
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
fontStyle:
|
||||
FontStyle
|
||||
.italic,
|
||||
color: theme
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withOpacity(
|
||||
0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -748,15 +841,27 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// Rediriger vers la page de connexion
|
||||
context.go('/login');
|
||||
Navigator.of(
|
||||
context)
|
||||
.pop();
|
||||
// Rediriger vers splash avec redirection automatique vers login admin
|
||||
context
|
||||
.go('/?action=login&type=admin');
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.primary,
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||
style: TextButton
|
||||
.styleFrom(
|
||||
foregroundColor:
|
||||
theme
|
||||
.colorScheme
|
||||
.primary,
|
||||
textStyle:
|
||||
const TextStyle(
|
||||
fontWeight:
|
||||
FontWeight
|
||||
.bold),
|
||||
),
|
||||
child: const Text('OK'),
|
||||
child:
|
||||
const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -769,16 +874,21 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
// Afficher un message d'erreur plus visible
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
builder:
|
||||
(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Erreur d\'inscription'),
|
||||
title: const Text(
|
||||
'Erreur d\'inscription'),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(
|
||||
context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
child:
|
||||
const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -786,7 +896,8 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
);
|
||||
|
||||
// Afficher également un SnackBar
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
@@ -797,9 +908,11 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
} else {
|
||||
// Gérer les erreurs HTTP
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur ${response.statusCode}: ${response.reasonPhrase ?? "Échec de l'inscription"}'),
|
||||
content: Text(
|
||||
'Erreur ${response.statusCode}: ${response.reasonPhrase ?? "Échec de l'inscription"}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -813,9 +926,11 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
|
||||
// Gérer les exceptions
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur: ${e.toString()}'),
|
||||
content: Text(
|
||||
'Erreur: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -823,7 +938,9 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
}
|
||||
}
|
||||
},
|
||||
text: (_isMobile && !_isConnected) ? 'Connexion Internet requise' : 'Enregistrer mon amicale',
|
||||
text: (_isMobile && !_isConnected)
|
||||
? 'Connexion Internet requise'
|
||||
: 'Enregistrer mon amicale',
|
||||
isLoading: _isLoading,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -838,7 +955,7 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/login');
|
||||
context.go('/?action=login&type=admin');
|
||||
},
|
||||
child: Text(
|
||||
'Se connecter',
|
||||
|
||||
309
app/lib/presentation/auth/splash_page.dart
Normal file → Executable file
309
app/lib/presentation/auth/splash_page.dart
Normal file → Executable file
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.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/services/location_service.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
@@ -9,7 +10,13 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class SplashPage extends StatefulWidget {
|
||||
const SplashPage({super.key});
|
||||
/// Action à effectuer après l'initialisation (login ou register)
|
||||
final String? action;
|
||||
|
||||
/// Type de login/register (user ou admin) - ignoré pour register
|
||||
final String? type;
|
||||
|
||||
const SplashPage({super.key, this.action, this.type});
|
||||
|
||||
@override
|
||||
State<SplashPage> createState() => _SplashPageState();
|
||||
@@ -46,6 +53,8 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
double _progress = 0.0;
|
||||
bool _showButtons = false;
|
||||
String _appVersion = '';
|
||||
bool _showLocationError = false;
|
||||
String? _locationErrorMessage;
|
||||
|
||||
Future<void> _getAppVersion() async {
|
||||
try {
|
||||
@@ -100,49 +109,127 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
try {
|
||||
debugPrint('🚀 Début de l\'initialisation complète de l\'application...');
|
||||
|
||||
// Étape 1: Initialisation complète de Hive avec HiveService
|
||||
// Étape 0: Vérification des permissions GPS (obligatoire) - 0 à 10%
|
||||
if (!kIsWeb) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Vérification des autorisations GPS...";
|
||||
_progress = 0.05;
|
||||
});
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
final hasPermission = await LocationService.checkAndRequestPermission();
|
||||
final errorMessage = await LocationService.getLocationErrorMessage();
|
||||
|
||||
if (!hasPermission) {
|
||||
// Si les permissions ne sont pas accordées, on arrête tout
|
||||
debugPrint('❌ Permissions GPS refusées');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showLocationError = true;
|
||||
_locationErrorMessage = errorMessage ?? "L'application nécessite l'accès à votre position pour fonctionner correctement.";
|
||||
_isInitializing = false;
|
||||
_progress = 0.0;
|
||||
});
|
||||
}
|
||||
return; // On arrête l'initialisation ici
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Autorisations GPS accordées...";
|
||||
_progress = 0.10;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Étape 1: Préparation - 10 à 15%
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Initialisation de la base de données...";
|
||||
_progress = 0.1;
|
||||
_statusMessage = "Démarrage de l'application...";
|
||||
_progress = 0.12;
|
||||
});
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 200)); // Petit délai pour voir le début
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Chargement des composants...";
|
||||
_progress = 0.15;
|
||||
});
|
||||
}
|
||||
|
||||
// HiveService fait TOUT le travail lourd (adaptateurs, destruction, recréation)
|
||||
// Étape 2: Initialisation Hive - 15 à 60% (étape la plus longue)
|
||||
await HiveService.instance.initializeAndResetHive();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Vérification des bases de données...";
|
||||
_progress = 0.7;
|
||||
_statusMessage = "Configuration du stockage...";
|
||||
_progress = 0.45;
|
||||
});
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 300)); // Simulation du temps de traitement
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Préparation des données...";
|
||||
_progress = 0.60;
|
||||
});
|
||||
}
|
||||
|
||||
// Étape 2: S'assurer que toutes les Box sont ouvertes
|
||||
// Étape 3: Ouverture des Box - 60 à 80%
|
||||
await HiveService.instance.ensureBoxesAreOpen();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Finalisation...";
|
||||
_progress = 0.9;
|
||||
_statusMessage = "Vérification du système...";
|
||||
_progress = 0.80;
|
||||
});
|
||||
}
|
||||
|
||||
// Étape 3: Vérification finale
|
||||
// Étape 4: Vérification finale - 80 à 95%
|
||||
final allBoxesOpen = HiveService.instance.areAllBoxesOpen();
|
||||
if (!allBoxesOpen) {
|
||||
final diagnostic = HiveService.instance.getDiagnostic();
|
||||
debugPrint('❌ Diagnostic des Box: $diagnostic');
|
||||
throw Exception('Certaines bases de données ne sont pas accessibles');
|
||||
throw Exception('Une erreur est survenue lors de l\'initialisation');
|
||||
}
|
||||
|
||||
// Finalisation
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Finalisation du chargement...";
|
||||
_progress = 0.95;
|
||||
});
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 300)); // Petit délai pour finaliser
|
||||
|
||||
// Étape 5: Finalisation - 95 à 100%
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Application prête !";
|
||||
_progress = 1.0;
|
||||
_isInitializing = false;
|
||||
_showButtons = true;
|
||||
});
|
||||
|
||||
// Attendre un court instant pour que l'utilisateur voie "Application prête !"
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
|
||||
setState(() {
|
||||
_isInitializing = false;
|
||||
});
|
||||
|
||||
// Redirection automatique si des paramètres sont fournis
|
||||
if (widget.action != null) {
|
||||
await _handleAutoRedirect();
|
||||
} else {
|
||||
setState(() {
|
||||
_showButtons = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('✅ Initialisation complète de l\'application terminée avec succès');
|
||||
@@ -151,7 +238,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Erreur d'initialisation - Redémarrage recommandé";
|
||||
_statusMessage = "Erreur de chargement - Veuillez redémarrer l'application";
|
||||
_progress = 1.0;
|
||||
_isInitializing = false;
|
||||
_showButtons = true;
|
||||
@@ -160,6 +247,51 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère la redirection automatique après l'initialisation
|
||||
Future<void> _handleAutoRedirect() async {
|
||||
// Petit délai pour voir le message "Application prête !"
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final action = widget.action?.toLowerCase();
|
||||
final type = widget.type?.toLowerCase();
|
||||
|
||||
debugPrint('🔄 Redirection automatique: action=$action, type=$type');
|
||||
|
||||
// Afficher un message de redirection avant de naviguer
|
||||
setState(() {
|
||||
_statusMessage = action == 'login'
|
||||
? "Redirection vers la connexion..."
|
||||
: action == 'register'
|
||||
? "Redirection vers l'inscription..."
|
||||
: "Redirection...";
|
||||
});
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
switch (action) {
|
||||
case 'login':
|
||||
if (type == 'admin') {
|
||||
context.go('/login/admin');
|
||||
} else {
|
||||
// Par défaut, rediriger vers user si type non spécifié ou invalid
|
||||
context.go('/login/user');
|
||||
}
|
||||
break;
|
||||
case 'register':
|
||||
// Pour register, le type n'est pas pris en compte
|
||||
context.go('/register');
|
||||
break;
|
||||
default:
|
||||
// Si action non reconnue, afficher les boutons normalement
|
||||
setState(() {
|
||||
_showButtons = true;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -243,26 +375,143 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
const Spacer(flex: 1),
|
||||
|
||||
// Indicateur de chargement
|
||||
if (_isInitializing) ...[
|
||||
if (_isInitializing && !_showLocationError) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: LinearProgressIndicator(
|
||||
value: _progress,
|
||||
backgroundColor: Colors.grey.withOpacity(0.2),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
theme.colorScheme.primary,
|
||||
child: Column(
|
||||
children: [
|
||||
// Barre de progression avec animation
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withOpacity(0.2),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween(begin: 0.0, end: _progress),
|
||||
builder: (context, value, child) {
|
||||
return LinearProgressIndicator(
|
||||
value: value,
|
||||
backgroundColor: Colors.grey.withOpacity(0.15),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
theme.colorScheme.primary,
|
||||
),
|
||||
minHeight: 12,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
minHeight: 10,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Pourcentage
|
||||
Text(
|
||||
'${(_progress * 100).round()}%',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_statusMessage,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
// Message de statut avec animation
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Text(
|
||||
_statusMessage,
|
||||
key: ValueKey(_statusMessage),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Erreur de localisation
|
||||
if (_showLocationError) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_off,
|
||||
size: 48,
|
||||
color: Colors.red.shade700,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Autorisations GPS requises',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: Colors.red.shade700,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_locationErrorMessage ?? "L'application nécessite l'accès à votre position pour fonctionner correctement.",
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.red.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Bouton Réessayer
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showLocationError = false;
|
||||
_isInitializing = true;
|
||||
});
|
||||
_startInitialization();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Réessayer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Bouton Paramètres
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
if (_locationErrorMessage?.contains('définitivement') ?? false) {
|
||||
await LocationService.openAppSettings();
|
||||
} else {
|
||||
await LocationService.openLocationSettings();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
label: const Text('Paramètres'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.red.shade700,
|
||||
side: BorderSide(color: Colors.red.shade700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
348
app/lib/presentation/dialogs/sector_action_result_dialog.dart
Normal file
348
app/lib/presentation/dialogs/sector_action_result_dialog.dart
Normal file
@@ -0,0 +1,348 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum SectorActionType {
|
||||
create,
|
||||
update,
|
||||
delete,
|
||||
}
|
||||
|
||||
class SectorActionResultDialog extends StatelessWidget {
|
||||
final SectorActionType actionType;
|
||||
final String sectorName;
|
||||
final Map<String, dynamic> statistics;
|
||||
final Map<String, dynamic>? departmentWarning;
|
||||
final VoidCallback? onConfirm;
|
||||
|
||||
const SectorActionResultDialog({
|
||||
super.key,
|
||||
required this.actionType,
|
||||
required this.sectorName,
|
||||
required this.statistics,
|
||||
this.departmentWarning,
|
||||
this.onConfirm,
|
||||
});
|
||||
|
||||
String get _actionTitle {
|
||||
switch (actionType) {
|
||||
case SectorActionType.create:
|
||||
return 'Secteur créé';
|
||||
case SectorActionType.update:
|
||||
return 'Secteur modifié';
|
||||
case SectorActionType.delete:
|
||||
return 'Secteur supprimé';
|
||||
}
|
||||
}
|
||||
|
||||
IconData get _actionIcon {
|
||||
switch (actionType) {
|
||||
case SectorActionType.create:
|
||||
return Icons.add_location_alt;
|
||||
case SectorActionType.update:
|
||||
return Icons.edit_location_alt;
|
||||
case SectorActionType.delete:
|
||||
return Icons.delete_forever;
|
||||
}
|
||||
}
|
||||
|
||||
Color get _actionColor {
|
||||
switch (actionType) {
|
||||
case SectorActionType.create:
|
||||
return Colors.green;
|
||||
case SectorActionType.update:
|
||||
return Colors.orange;
|
||||
case SectorActionType.delete:
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(_actionIcon, color: _actionColor, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_actionTitle,
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
sectorName,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Warning départemental si présent
|
||||
if (departmentWarning != null && departmentWarning!['intersecting_departments'] != null) ...[
|
||||
_buildDepartmentWarning(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Statistiques des passages
|
||||
_buildStatisticsSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onConfirm?.call();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDepartmentWarning() {
|
||||
final departments = departmentWarning!['intersecting_departments'] as List<dynamic>;
|
||||
final isMultipleDepartments = departments.length > 1;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.orange[700], size: 24),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
isMultipleDepartments
|
||||
? 'Secteur à cheval sur plusieurs départements'
|
||||
: 'Secteur sur un autre département',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange[900],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...departments.map((dept) {
|
||||
final percentage = dept['percentage_overlap'] as num;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.location_on, size: 16, color: Colors.orange[700]),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${dept['nom_dept']} (${dept['code_dept']})',
|
||||
style: TextStyle(color: Colors.orange[900]),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${percentage.toStringAsFixed(1)}%',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange[900],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatisticsSection() {
|
||||
final List<Widget> statisticWidgets = [];
|
||||
|
||||
// Titre de la section
|
||||
statisticWidgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'Statistiques des passages',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// CREATE : passages créés et intégrés
|
||||
if (actionType == SectorActionType.create) {
|
||||
final passagesCreated = statistics['passages_created'] ?? 0;
|
||||
final passagesIntegrated = statistics['passages_integrated'] ?? 0;
|
||||
final totalPassages = passagesCreated + passagesIntegrated;
|
||||
|
||||
statisticWidgets.add(_buildStatRow(
|
||||
icon: Icons.add_circle_outline,
|
||||
label: 'Nouveaux passages créés',
|
||||
value: passagesCreated,
|
||||
color: Colors.green,
|
||||
));
|
||||
|
||||
if (passagesIntegrated > 0) {
|
||||
statisticWidgets.add(_buildStatRow(
|
||||
icon: Icons.merge_type,
|
||||
label: 'Passages orphelins intégrés',
|
||||
value: passagesIntegrated,
|
||||
color: Colors.blue,
|
||||
));
|
||||
}
|
||||
|
||||
statisticWidgets.add(const Divider(height: 24));
|
||||
|
||||
statisticWidgets.add(_buildStatRow(
|
||||
icon: Icons.functions,
|
||||
label: 'Total des passages du secteur',
|
||||
value: totalPassages,
|
||||
color: Colors.indigo,
|
||||
isBold: true,
|
||||
));
|
||||
}
|
||||
|
||||
// UPDATE : passages créés, mis à jour, orphelins, total
|
||||
else if (actionType == SectorActionType.update) {
|
||||
final passagesCreated = statistics['passages_created'] ?? 0;
|
||||
final passagesUpdated = statistics['passages_updated'] ?? 0;
|
||||
final passagesOrphaned = statistics['passages_orphaned'] ?? 0;
|
||||
final passagesTotal = statistics['passages_total'] ?? 0;
|
||||
|
||||
if (passagesCreated > 0) {
|
||||
statisticWidgets.add(_buildStatRow(
|
||||
icon: Icons.add_circle_outline,
|
||||
label: 'Nouveaux passages créés',
|
||||
value: passagesCreated,
|
||||
color: Colors.green,
|
||||
));
|
||||
}
|
||||
|
||||
if (passagesUpdated > 0) {
|
||||
statisticWidgets.add(_buildStatRow(
|
||||
icon: Icons.update,
|
||||
label: 'Passages mis à jour',
|
||||
value: passagesUpdated,
|
||||
color: Colors.blue,
|
||||
));
|
||||
}
|
||||
|
||||
if (passagesOrphaned > 0) {
|
||||
statisticWidgets.add(_buildStatRow(
|
||||
icon: Icons.remove_circle_outline,
|
||||
label: 'Passages mis en orphelin',
|
||||
value: passagesOrphaned,
|
||||
color: Colors.orange,
|
||||
));
|
||||
}
|
||||
|
||||
statisticWidgets.add(const Divider(height: 24));
|
||||
|
||||
statisticWidgets.add(_buildStatRow(
|
||||
icon: Icons.functions,
|
||||
label: 'Total des passages du secteur',
|
||||
value: passagesTotal,
|
||||
color: Colors.indigo,
|
||||
isBold: true,
|
||||
));
|
||||
}
|
||||
|
||||
// DELETE : passages supprimés et conservés
|
||||
else if (actionType == SectorActionType.delete) {
|
||||
final passagesDeleted = statistics['passages_deleted'] ?? 0;
|
||||
final passagesReassigned = statistics['passages_reassigned'] ?? 0;
|
||||
|
||||
if (passagesDeleted > 0) {
|
||||
statisticWidgets.add(_buildStatRow(
|
||||
icon: Icons.delete_outline,
|
||||
label: 'Passages supprimés',
|
||||
value: passagesDeleted,
|
||||
color: Colors.red,
|
||||
));
|
||||
}
|
||||
|
||||
if (passagesReassigned > 0) {
|
||||
statisticWidgets.add(_buildStatRow(
|
||||
icon: Icons.bookmark_border,
|
||||
label: 'Passages conservés (orphelins)',
|
||||
value: passagesReassigned,
|
||||
color: Colors.orange,
|
||||
));
|
||||
}
|
||||
|
||||
if (passagesDeleted == 0 && passagesReassigned == 0) {
|
||||
statisticWidgets.add(
|
||||
Text(
|
||||
'Aucun passage dans ce secteur',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: statisticWidgets,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatRow({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required int value,
|
||||
required Color color,
|
||||
bool isBold = false,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isBold ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value.toString(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
fontSize: isBold ? 18 : 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
423
app/lib/presentation/dialogs/sector_dialog.dart
Normal file
423
app/lib/presentation/dialogs/sector_dialog.dart
Normal file
@@ -0,0 +1,423 @@
|
||||
import 'package:flutter/material.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_sector_model.dart';
|
||||
import 'package:geosector_app/core/repositories/membre_repository.dart';
|
||||
import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
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;
|
||||
|
||||
const SectorDialog({
|
||||
super.key,
|
||||
this.existingSector,
|
||||
required this.coordinates,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SectorDialog> createState() => _SectorDialogState();
|
||||
}
|
||||
|
||||
class _SectorDialogState extends State<SectorDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _nameFocusNode = FocusNode();
|
||||
Color _selectedColor = Colors.blue;
|
||||
final List<int> _selectedMemberIds = [];
|
||||
bool _isLoading = false;
|
||||
bool _membersLoaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.existingSector != null) {
|
||||
_nameController.text = widget.existingSector!.libelle;
|
||||
_selectedColor = _hexToColor(widget.existingSector!.color);
|
||||
// Charger les membres affectés au secteur
|
||||
_loadSectorMembers();
|
||||
}
|
||||
|
||||
// Donner le focus au champ nom après que le dialog soit construit
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_nameFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
// Charger les membres actuellement affectés au secteur
|
||||
void _loadSectorMembers() {
|
||||
if (widget.existingSector == null) return;
|
||||
|
||||
debugPrint('=== Début chargement membres pour secteur ${widget.existingSector!.id} - ${widget.existingSector!.libelle} ===');
|
||||
|
||||
try {
|
||||
// Vérifier si la box UserSector est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.userSectorBoxName)) {
|
||||
debugPrint('Box UserSector non ouverte');
|
||||
return;
|
||||
}
|
||||
|
||||
final userSectorBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
|
||||
debugPrint('Box UserSector contient ${userSectorBox.length} entrées au total');
|
||||
|
||||
// Afficher toutes les entrées pour debug
|
||||
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}"');
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
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})');
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint('=== Fin chargement: ${_selectedMemberIds.length} membres présélectionnés ===');
|
||||
debugPrint('IDs présélectionnés: $_selectedMemberIds');
|
||||
|
||||
// Marquer le chargement comme terminé
|
||||
setState(() {
|
||||
_membersLoaded = true;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des membres du secteur: $e');
|
||||
setState(() {
|
||||
_membersLoaded = true; // Même en cas d'erreur
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_nameFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Color _hexToColor(String hexColor) {
|
||||
final String colorStr = hexColor.startsWith('#') ? hexColor.substring(1) : hexColor;
|
||||
final String fullColorStr = colorStr.length == 6 ? 'FF$colorStr' : colorStr;
|
||||
return Color(int.parse(fullColorStr, radix: 16));
|
||||
}
|
||||
|
||||
String _colorToHex(Color color) {
|
||||
return '#${color.value.toRadixString(16).substring(2).toUpperCase()}';
|
||||
}
|
||||
|
||||
void _handleSave() async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// Vérifier qu'au moins un membre est sélectionné
|
||||
if (_selectedMemberIds.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Veuillez sélectionner au moins un membre'),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Indiquer que nous sommes en train de sauvegarder
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
// Appeler le callback onSave et attendre sa résolution
|
||||
await widget.onSave(
|
||||
_nameController.text.trim(),
|
||||
_colorToHex(_selectedColor),
|
||||
_selectedMemberIds,
|
||||
);
|
||||
|
||||
// Si tout s'est bien passé, fermer le dialog
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
// En cas d'erreur, réactiver le bouton
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
// L'erreur sera gérée par le callback onSave
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showColorPicker() {
|
||||
// Liste de couleurs prédéfinies
|
||||
final List<Color> colors = [
|
||||
Colors.red,
|
||||
Colors.pink,
|
||||
Colors.purple,
|
||||
Colors.deepPurple,
|
||||
Colors.indigo,
|
||||
Colors.blue,
|
||||
Colors.lightBlue,
|
||||
Colors.cyan,
|
||||
Colors.teal,
|
||||
Colors.green,
|
||||
Colors.lightGreen,
|
||||
Colors.lime,
|
||||
Colors.yellow,
|
||||
Colors.amber,
|
||||
Colors.orange,
|
||||
Colors.deepOrange,
|
||||
Colors.brown,
|
||||
Colors.grey,
|
||||
Colors.blueGrey,
|
||||
const Color(0xFF1E88E5), // Bleu personnalisé
|
||||
const Color(0xFF43A047), // Vert personnalisé
|
||||
const Color(0xFFE53935), // Rouge personnalisé
|
||||
const Color(0xFFFFB300), // Ambre personnalisé
|
||||
const Color(0xFF8E24AA), // Violet personnalisé
|
||||
];
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Choisir une couleur'),
|
||||
content: Container(
|
||||
width: double.maxFinite,
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
itemCount: colors.length,
|
||||
itemBuilder: (context, index) {
|
||||
final color = colors[index];
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedColor = color;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
border: Border.all(
|
||||
color: _selectedColor == color ? Colors.black : Colors.grey,
|
||||
width: _selectedColor == color ? 3 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentAmicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(widget.existingSector == null ? 'Nouveau secteur' : 'Modifier le secteur'),
|
||||
content: SingleChildScrollView(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Nom du secteur
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
focusNode: _nameFocusNode,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Nom du secteur',
|
||||
labelStyle: TextStyle(color: Colors.black),
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Nom du secteur'),
|
||||
Text(
|
||||
' *',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
hintText: 'Ex: Centre-ville',
|
||||
prefixIcon: Icon(Icons.location_on),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Veuillez entrer un nom';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Couleur du secteur
|
||||
const Text(
|
||||
'Couleur du secteur',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
_showColorPicker();
|
||||
},
|
||||
child: Container(
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Toucher pour changer',
|
||||
style: TextStyle(
|
||||
color: _selectedColor.computeLuminance() > 0.5
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Sélection des membres
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Membres affectés',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'*',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (_selectedMemberIds.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
'Sélectionnez au moins un membre',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentAmicale != null)
|
||||
ValueListenableBuilder<Box<MembreModel>>(
|
||||
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
|
||||
builder: (context, box, _) {
|
||||
debugPrint('=== Build liste membres - IDs présélectionnés: $_selectedMemberIds ===');
|
||||
|
||||
final membres = box.values
|
||||
.where((m) => m.fkEntite == currentAmicale.id)
|
||||
.toList();
|
||||
|
||||
if (membres.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Aucun membre disponible'),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: membres.length,
|
||||
itemBuilder: (context, index) {
|
||||
final membre = membres[index];
|
||||
final isSelected = _selectedMemberIds.contains(membre.id);
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
return CheckboxListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 0.0),
|
||||
title: Text(
|
||||
'${membre.firstName} ${membre.name}${membre.sectName != null && membre.sectName!.isNotEmpty ? ' (${membre.sectName})' : ''}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
value: isSelected,
|
||||
onChanged: (bool? value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_selectedMemberIds.add(membre.id);
|
||||
} else {
|
||||
_selectedMemberIds.remove(membre.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _handleSave,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(widget.existingSector == null ? 'Créer' : 'Modifier'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
0
app/lib/presentation/public/landing_page.dart
Normal file → Executable file
0
app/lib/presentation/public/landing_page.dart
Normal file → Executable file
341
app/lib/presentation/settings/theme_settings_page.dart
Executable file
341
app/lib/presentation/settings/theme_settings_page.dart
Executable file
@@ -0,0 +1,341 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/services/theme_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/theme_switcher.dart';
|
||||
|
||||
/// Page de paramètres pour la gestion du thème
|
||||
class ThemeSettingsPage extends StatelessWidget {
|
||||
const ThemeSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Paramètres d\'affichage'),
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
foregroundColor: theme.colorScheme.onSurface,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section informations
|
||||
_buildInfoSection(context),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Section sélection du thème
|
||||
_buildThemeSection(context),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Section aperçu
|
||||
_buildPreviewSection(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoSection(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'À propos des thèmes',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'• Mode Automatique : Suit les préférences de votre système\n'
|
||||
'• Mode Clair : Interface claire en permanence\n'
|
||||
'• Mode Sombre : Interface sombre en permanence\n\n'
|
||||
'Le mode automatique détecte automatiquement si votre appareil '
|
||||
'est configuré en mode sombre ou clair et adapte l\'interface en conséquence.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemeSection(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.palette_outlined, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Choix du thème',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Thème actuel
|
||||
AnimatedBuilder(
|
||||
animation: ThemeService.instance,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
ThemeService.instance.themeModeIcon,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Thème actuel',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
ThemeService.instance.themeModeDescription,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Boutons de sélection style segments
|
||||
Text(
|
||||
'Sélectionner un thème :',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Center(
|
||||
child: ThemeSwitcher(
|
||||
style: ThemeSwitcherStyle.segmentedButton,
|
||||
onThemeChanged: () {
|
||||
// Optionnel: feedback haptic ou autres actions
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Thème changé vers ${ThemeService.instance.themeModeDescription}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Options alternatives
|
||||
Text(
|
||||
'Autres options :',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Dropdown
|
||||
const Row(
|
||||
children: [
|
||||
Text('Menu déroulant : '),
|
||||
ThemeSwitcher(
|
||||
style: ThemeSwitcherStyle.dropdown,
|
||||
showLabel: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Toggle buttons
|
||||
const Row(
|
||||
children: [
|
||||
Text('Boutons : '),
|
||||
ThemeSwitcher(style: ThemeSwitcherStyle.toggleButtons),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreviewSection(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.preview, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Aperçu des couleurs',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Grille de couleurs
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
children: [
|
||||
_buildColorSample('Primary', theme.colorScheme.primary,
|
||||
theme.colorScheme.onPrimary),
|
||||
_buildColorSample('Secondary', theme.colorScheme.secondary,
|
||||
theme.colorScheme.onSecondary),
|
||||
_buildColorSample('Surface', theme.colorScheme.surface,
|
||||
theme.colorScheme.onSurface),
|
||||
_buildColorSample('Background', theme.colorScheme.surface,
|
||||
theme.colorScheme.onSurface),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Exemples de composants
|
||||
Text(
|
||||
'Exemples de composants :',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Bouton'),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Bouton'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Bouton'),
|
||||
),
|
||||
const Chip(
|
||||
label: Text('Chip'),
|
||||
avatar: Icon(Icons.star, size: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColorSample(String label, Color color, Color onColor) {
|
||||
return Container(
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: onColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog simple pour les paramètres de thème
|
||||
class ThemeSettingsDialog extends StatelessWidget {
|
||||
const ThemeSettingsDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.palette_outlined, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Apparence'),
|
||||
],
|
||||
),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ThemeInfo(),
|
||||
SizedBox(height: 16),
|
||||
ThemeSwitcher(
|
||||
style: ThemeSwitcherStyle.segmentedButton,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
0
app/lib/presentation/user/user_communication_page.dart
Normal file → Executable file
0
app/lib/presentation/user/user_communication_page.dart
Normal file → Executable file
6
app/lib/presentation/user/user_dashboard_home_page.dart
Normal file → Executable file
6
app/lib/presentation/user/user_dashboard_home_page.dart
Normal file → Executable file
@@ -241,8 +241,8 @@ Widget _buildCombinedPaymentsCard(bool isDesktop) {
|
||||
|
||||
/// Récupère les passages récents pour la liste
|
||||
List<Map<String, dynamic>> _getRecentPassages(Box<PassageModel> passagesBox) {
|
||||
final allPassages = passagesBox.values.toList();
|
||||
allPassages.sort((a, b) => b.passedAt.compareTo(a.passedAt));
|
||||
final allPassages = passagesBox.values.where((p) => p.passedAt != null).toList();
|
||||
allPassages.sort((a, b) => b.passedAt!.compareTo(a.passedAt!));
|
||||
|
||||
// Limiter aux 10 passages les plus récents
|
||||
final recentPassagesModels = allPassages.take(10).toList();
|
||||
@@ -270,7 +270,7 @@ Widget _buildCombinedPaymentsCard(bool isDesktop) {
|
||||
'id': passage.id, // Garder l'ID comme int, pas besoin de toString()
|
||||
'address': address,
|
||||
'amount': amount,
|
||||
'date': passage.passedAt,
|
||||
'date': passage.passedAt ?? DateTime.now(),
|
||||
'type': passage.fkType,
|
||||
'payment': passage.fkTypeReglement,
|
||||
'name': passage.name,
|
||||
|
||||
3
app/lib/presentation/user/user_dashboard_page.dart
Normal file → Executable file
3
app/lib/presentation/user/user_dashboard_page.dart
Normal file → Executable file
@@ -1,9 +1,6 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passages/passage_form.dart';
|
||||
|
||||
100
app/lib/presentation/user/user_history_page.dart
Normal file → Executable file
100
app/lib/presentation/user/user_history_page.dart
Normal file → Executable file
@@ -1,10 +1,8 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
// Pour accéder aux instances globales
|
||||
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
|
||||
class UserHistoryPage extends StatefulWidget {
|
||||
@@ -71,46 +69,52 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
|
||||
// Afficher la plage de dates pour le débogage
|
||||
if (filtered.isNotEmpty) {
|
||||
// Trier par date pour trouver min et max
|
||||
final sortedByDate = List<PassageModel>.from(filtered);
|
||||
sortedByDate.sort((a, b) => a.passedAt.compareTo(b.passedAt));
|
||||
// Trier par date pour trouver min et max (exclure les passages sans date)
|
||||
final sortedByDate =
|
||||
List<PassageModel>.from(filtered.where((p) => p.passedAt != null));
|
||||
if (sortedByDate.isNotEmpty) {
|
||||
sortedByDate.sort((a, b) => a.passedAt!.compareTo(b.passedAt!));
|
||||
|
||||
final DateTime minDate = sortedByDate.first.passedAt;
|
||||
final DateTime maxDate = sortedByDate.last.passedAt;
|
||||
final DateTime minDate = sortedByDate.first.passedAt!;
|
||||
final DateTime maxDate = sortedByDate.last.passedAt!;
|
||||
|
||||
// Log détaillé pour débogage
|
||||
debugPrint(
|
||||
'Plage de dates des passages: ${minDate.toString()} à ${maxDate.toString()}');
|
||||
|
||||
// Afficher les 5 passages les plus anciens et les 5 plus récents pour débogage
|
||||
debugPrint('\n--- 5 PASSAGES LES PLUS ANCIENS ---');
|
||||
for (int i = 0; i < sortedByDate.length && i < 5; i++) {
|
||||
final p = sortedByDate[i];
|
||||
// Log détaillé pour débogage
|
||||
debugPrint(
|
||||
'ID: ${p.id}, Type: ${p.fkType}, Date: ${p.passedAt}, Adresse: ${p.rue}');
|
||||
}
|
||||
'Plage de dates des passages: ${minDate.toString()} à ${maxDate.toString()}');
|
||||
|
||||
debugPrint('\n--- 5 PASSAGES LES PLUS RÉCENTS ---');
|
||||
for (int i = sortedByDate.length - 1;
|
||||
i >= 0 && i >= sortedByDate.length - 5;
|
||||
i--) {
|
||||
final p = sortedByDate[i];
|
||||
debugPrint(
|
||||
'ID: ${p.id}, Type: ${p.fkType}, Date: ${p.passedAt}, Adresse: ${p.rue}');
|
||||
}
|
||||
// Afficher les 5 passages les plus anciens et les 5 plus récents pour débogage
|
||||
debugPrint('\n--- 5 PASSAGES LES PLUS ANCIENS ---');
|
||||
for (int i = 0; i < sortedByDate.length && i < 5; i++) {
|
||||
final p = sortedByDate[i];
|
||||
debugPrint(
|
||||
'ID: ${p.id}, Type: ${p.fkType}, Date: ${p.passedAt}, Adresse: ${p.rue}');
|
||||
}
|
||||
|
||||
// Vérifier la distribution des passages par mois
|
||||
final Map<String, int> monthCount = {};
|
||||
for (var passage in filtered) {
|
||||
final String monthKey =
|
||||
'${passage.passedAt.year}-${passage.passedAt.month.toString().padLeft(2, '0')}';
|
||||
monthCount[monthKey] = (monthCount[monthKey] ?? 0) + 1;
|
||||
}
|
||||
debugPrint('\n--- 5 PASSAGES LES PLUS RÉCENTS ---');
|
||||
for (int i = sortedByDate.length - 1;
|
||||
i >= 0 && i >= sortedByDate.length - 5;
|
||||
i--) {
|
||||
final p = sortedByDate[i];
|
||||
debugPrint(
|
||||
'ID: ${p.id}, Type: ${p.fkType}, Date: ${p.passedAt}, Adresse: ${p.rue}');
|
||||
}
|
||||
|
||||
debugPrint('\n--- DISTRIBUTION PAR MOIS ---');
|
||||
final sortedMonths = monthCount.keys.toList()..sort();
|
||||
for (var month in sortedMonths) {
|
||||
debugPrint('$month: ${monthCount[month]} passages');
|
||||
// Vérifier la distribution des passages par mois
|
||||
final Map<String, int> monthCount = {};
|
||||
for (var passage in filtered) {
|
||||
// Ignorer les passages sans date
|
||||
if (passage.passedAt != null) {
|
||||
final String monthKey =
|
||||
'${passage.passedAt!.year}-${passage.passedAt!.month.toString().padLeft(2, '0')}';
|
||||
monthCount[monthKey] = (monthCount[monthKey] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('\n--- DISTRIBUTION PAR MOIS ---');
|
||||
final sortedMonths = monthCount.keys.toList()..sort();
|
||||
for (var month in sortedMonths) {
|
||||
debugPrint('$month: ${monthCount[month]} passages');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,9 +124,7 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
try {
|
||||
final Map<String, dynamic> passageMap =
|
||||
_convertPassageModelToMap(passage);
|
||||
if (passageMap != null) {
|
||||
passagesMap.add(passageMap);
|
||||
}
|
||||
passagesMap.add(passageMap);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la conversion du passage en map: $e');
|
||||
// Ignorer ce passage et continuer
|
||||
@@ -195,7 +197,7 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
// Récupérer la date avec gestion d'erreur
|
||||
DateTime date;
|
||||
try {
|
||||
date = passage.passedAt;
|
||||
date = passage.passedAt ?? DateTime.now();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération de la date: $e');
|
||||
date = DateTime.now();
|
||||
@@ -353,7 +355,7 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Détails du passage'),
|
||||
title: const Text('Détails du passage'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -379,7 +381,7 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('Fermer'),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
if (passage['hasReceipt'] == true)
|
||||
TextButton(
|
||||
@@ -387,14 +389,14 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
Navigator.of(context).pop();
|
||||
_showReceipt(passage);
|
||||
},
|
||||
child: Text('Voir le reçu'),
|
||||
child: const Text('Voir le reçu'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_editPassage(passage);
|
||||
},
|
||||
child: Text('Modifier'),
|
||||
child: const Text('Modifier'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -427,11 +429,11 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text('$label:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold))),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: isError ? TextStyle(color: Colors.red) : null,
|
||||
style: isError ? const TextStyle(color: Colors.red) : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -440,7 +442,7 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
}
|
||||
|
||||
// Variable pour gérer la recherche
|
||||
String _searchQuery = '';
|
||||
final String _searchQuery = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -533,7 +535,7 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
'Tous', // Toujours commencer avec 'Tous' pour voir tous les types
|
||||
initialPaymentFilter: 'Tous',
|
||||
// Exclure les passages de type 2 (À finaliser)
|
||||
excludePassageTypes: [2],
|
||||
excludePassageTypes: const [2],
|
||||
// Filtrer par utilisateur courant
|
||||
filterByUserId: userRepository.getCurrentUser()?.id,
|
||||
// Désactiver les filtres de date implicites
|
||||
|
||||
49
app/lib/presentation/user/user_map_page.dart
Normal file → Executable file
49
app/lib/presentation/user/user_map_page.dart
Normal file → Executable file
@@ -634,7 +634,7 @@ class _UserMapPageState extends State<UserMapPage> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.location_on,
|
||||
const Icon(Icons.location_on,
|
||||
size: 18, color: Colors.blue),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
@@ -644,7 +644,7 @@ class _UserMapPageState extends State<UserMapPage> {
|
||||
isExpanded: true,
|
||||
underline:
|
||||
Container(), // Supprimer la ligne sous le dropdown
|
||||
icon: Icon(Icons.arrow_drop_down,
|
||||
icon: const Icon(Icons.arrow_drop_down,
|
||||
color: Colors.blue),
|
||||
items: _sectorItems,
|
||||
onChanged: (int? sectorId) {
|
||||
@@ -879,10 +879,17 @@ class _UserMapPageState extends State<UserMapPage> {
|
||||
// Construire les marqueurs pour les passages
|
||||
List<Marker> _buildPassageMarkers() {
|
||||
return _passages.map((passage) {
|
||||
final PassageModel passageModel = passage['model'] as PassageModel;
|
||||
final bool hasNoSector = passageModel.fkSector == null;
|
||||
|
||||
// Si le passage n'a pas de secteur, on met une bordure rouge épaisse
|
||||
final Color borderColor = hasNoSector ? Colors.red : Colors.white;
|
||||
final double borderWidth = hasNoSector ? 3.0 : 1.0;
|
||||
|
||||
return Marker(
|
||||
point: passage['position'] as LatLng,
|
||||
width: 14.0,
|
||||
height: 14.0,
|
||||
width: hasNoSector ? 18.0 : 14.0, // Plus grand si orphelin
|
||||
height: hasNoSector ? 18.0 : 14.0,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
_showPassageInfo(passage);
|
||||
@@ -892,8 +899,8 @@ class _UserMapPageState extends State<UserMapPage> {
|
||||
color: passage['color'] as Color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 1.0,
|
||||
color: borderColor,
|
||||
width: borderWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -958,10 +965,10 @@ class _UserMapPageState extends State<UserMapPage> {
|
||||
}
|
||||
}
|
||||
|
||||
// Formater la date (uniquement si le type n'est pas 2)
|
||||
// Formater la date (uniquement si le type n'est pas 2 et si la date existe)
|
||||
String dateInfo = '';
|
||||
if (type != 2) {
|
||||
dateInfo = 'Date: ${_formatDate(passageModel.passedAt)}';
|
||||
if (type != 2 && passageModel.passedAt != null) {
|
||||
dateInfo = 'Date: ${_formatDate(passageModel.passedAt!)}';
|
||||
}
|
||||
|
||||
// Récupérer le nom du passage (si le type n'est pas 6 - Maison vide)
|
||||
@@ -1008,6 +1015,30 @@ class _UserMapPageState extends State<UserMapPage> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Afficher en premier si le passage n'est pas affecté à un secteur
|
||||
if (passageModel.fkSector == null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
border: Border.all(color: Colors.red, width: 1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Ce passage n\'est plus affecté à un secteur',
|
||||
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
Text('Adresse: $adresse'),
|
||||
if (residenceInfo != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
|
||||
46
app/lib/presentation/user/user_statistics_page.dart
Normal file → Executable file
46
app/lib/presentation/user/user_statistics_page.dart
Normal file → Executable file
@@ -56,8 +56,7 @@ class _UserStatisticsPageState extends State<UserStatisticsPage> {
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Résumé par type de règlement
|
||||
_buildPaymentTypeSummary(theme, isDesktop),
|
||||
|
||||
_buildPaymentTypeSummary(theme, isDesktop),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -219,17 +218,17 @@ _buildPaymentTypeSummary(theme, isDesktop),
|
||||
onChanged(selection.first);
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: MaterialStateProperty.resolveWith<Color>(
|
||||
(Set<MaterialState> states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
backgroundColor: WidgetStateProperty.resolveWith<Color>(
|
||||
(Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppTheme.secondaryColor;
|
||||
}
|
||||
return theme.colorScheme.surface;
|
||||
},
|
||||
),
|
||||
foregroundColor: MaterialStateProperty.resolveWith<Color>(
|
||||
(Set<MaterialState> states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
foregroundColor: WidgetStateProperty.resolveWith<Color>(
|
||||
(Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return Colors.white;
|
||||
}
|
||||
return theme.colorScheme.onSurface;
|
||||
@@ -375,20 +374,19 @@ _buildPaymentTypeSummary(theme, isDesktop),
|
||||
}
|
||||
|
||||
// Construction du résumé par type de règlement
|
||||
Widget _buildPaymentTypeSummary(ThemeData theme, bool isDesktop) {
|
||||
return PaymentSummaryCard(
|
||||
title: 'Répartition par type de règlement',
|
||||
titleColor: AppTheme.accentColor,
|
||||
titleIcon: Icons.pie_chart,
|
||||
height: 300,
|
||||
useValueListenable: true,
|
||||
userId: userRepository.getCurrentUser()?.id,
|
||||
showAllPayments: false,
|
||||
isDesktop: isDesktop,
|
||||
backgroundIcon: Icons.euro_symbol,
|
||||
backgroundIconColor: Colors.blue,
|
||||
backgroundIconOpacity: 0.05,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaymentTypeSummary(ThemeData theme, bool isDesktop) {
|
||||
return PaymentSummaryCard(
|
||||
title: 'Répartition par type de règlement',
|
||||
titleColor: AppTheme.accentColor,
|
||||
titleIcon: Icons.pie_chart,
|
||||
height: 300,
|
||||
useValueListenable: true,
|
||||
userId: userRepository.getCurrentUser()?.id,
|
||||
showAllPayments: false,
|
||||
isDesktop: isDesktop,
|
||||
backgroundIcon: Icons.euro_symbol,
|
||||
backgroundIconColor: Colors.blue,
|
||||
backgroundIconOpacity: 0.05,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
0
app/lib/presentation/widgets/amicale_form.dart
Normal file → Executable file
0
app/lib/presentation/widgets/amicale_form.dart
Normal file → Executable file
0
app/lib/presentation/widgets/amicale_row_widget.dart
Normal file → Executable file
0
app/lib/presentation/widgets/amicale_row_widget.dart
Normal file → Executable file
0
app/lib/presentation/widgets/amicale_table_widget.dart
Normal file → Executable file
0
app/lib/presentation/widgets/amicale_table_widget.dart
Normal file → Executable file
6
app/lib/presentation/widgets/charts/activity_chart.dart
Normal file → Executable file
6
app/lib/presentation/widgets/charts/activity_chart.dart
Normal file → Executable file
@@ -225,11 +225,13 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
|
||||
// Vérifier si le passage est dans la période
|
||||
final passageDate = passage.passedAt;
|
||||
if (passageDate.isBefore(startDate) || passageDate.isAfter(endDate)) {
|
||||
if (passageDate == null ||
|
||||
passageDate.isBefore(startDate) ||
|
||||
passageDate.isAfter(endDate)) {
|
||||
shouldInclude = false;
|
||||
}
|
||||
|
||||
if (shouldInclude) {
|
||||
if (shouldInclude && passageDate != null) {
|
||||
final dateStr = DateFormat('yyyy-MM-dd').format(passageDate);
|
||||
if (dataByDate.containsKey(dateStr)) {
|
||||
dataByDate[dateStr]![passage.fkType] =
|
||||
|
||||
0
app/lib/presentation/widgets/charts/charts.dart
Normal file → Executable file
0
app/lib/presentation/widgets/charts/charts.dart
Normal file → Executable file
4
app/lib/presentation/widgets/charts/combined_chart.dart
Normal file → Executable file
4
app/lib/presentation/widgets/charts/combined_chart.dart
Normal file → Executable file
@@ -198,7 +198,7 @@ class CombinedChart extends StatelessWidget {
|
||||
reservedSize: 40,
|
||||
),
|
||||
),
|
||||
topTitles: AxisTitles(
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
@@ -214,7 +214,7 @@ class CombinedChart extends StatelessWidget {
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
barGroups: _createBarGroups(allDates, passagesByType),
|
||||
extraLinesData: ExtraLinesData(
|
||||
extraLinesData: const ExtraLinesData(
|
||||
horizontalLines: [],
|
||||
verticalLines: [],
|
||||
extraLinesOnTop: true,
|
||||
|
||||
0
app/lib/presentation/widgets/charts/passage_data.dart
Normal file → Executable file
0
app/lib/presentation/widgets/charts/passage_data.dart
Normal file → Executable file
16
app/lib/presentation/widgets/charts/passage_pie_chart.dart
Normal file → Executable file
16
app/lib/presentation/widgets/charts/passage_pie_chart.dart
Normal file → Executable file
@@ -110,13 +110,15 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PassagePieChart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Relancer l'animation si les paramètres importants ont changé
|
||||
final bool shouldResetAnimation = oldWidget.userId != widget.userId ||
|
||||
!listEquals(oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
|
||||
!listEquals(
|
||||
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
|
||||
oldWidget.showAllPassages != widget.showAllPassages ||
|
||||
oldWidget.useValueListenable != widget.useValueListenable;
|
||||
|
||||
@@ -144,7 +146,8 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
|
||||
Widget _buildWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final chartData = _calculatePassageData(passagesBox);
|
||||
return _buildChart(chartData);
|
||||
@@ -192,7 +195,7 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
|
||||
if (shouldInclude) {
|
||||
passagesByType[passage.fkType] =
|
||||
(passagesByType[passage.fkType] ?? 0) + 1;
|
||||
(passagesByType[passage.fkType] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +207,8 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique en camembert à partir d'une Map
|
||||
List<PassageChartData> _prepareChartDataFromMap(Map<int, int> passagesByType) {
|
||||
List<PassageChartData> _prepareChartDataFromMap(
|
||||
Map<int, int> passagesByType) {
|
||||
final List<PassageChartData> chartData = [];
|
||||
|
||||
// Créer les données du graphique
|
||||
@@ -247,12 +251,12 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
|
||||
final explodeAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
curve: const Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
final opacityAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
curve: const Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
|
||||
27
app/lib/presentation/widgets/charts/passage_summary_card.dart
Normal file → Executable file
27
app/lib/presentation/widgets/charts/passage_summary_card.dart
Normal file → Executable file
@@ -89,7 +89,8 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
child: Icon(
|
||||
backgroundIcon,
|
||||
size: backgroundIconSize,
|
||||
color: (backgroundIconColor ?? AppTheme.primaryColor).withOpacity(backgroundIconOpacity),
|
||||
color: (backgroundIconColor ?? AppTheme.primaryColor)
|
||||
.withOpacity(backgroundIconOpacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -118,7 +119,7 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
? _buildPassagesListWithValueListenable()
|
||||
: _buildPassagesListWithStaticData(),
|
||||
),
|
||||
|
||||
|
||||
// Séparateur vertical
|
||||
if (isDesktop) const VerticalDivider(width: 24),
|
||||
|
||||
@@ -157,7 +158,8 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
/// Construction du titre avec ValueListenableBuilder
|
||||
Widget _buildTitleWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final totalUserPassages = _calculateUserPassagesCount(passagesBox);
|
||||
|
||||
@@ -181,7 +183,8 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(totalUserPassages) ?? totalUserPassages.toString(),
|
||||
customTotalDisplay?.call(totalUserPassages) ??
|
||||
totalUserPassages.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -196,8 +199,9 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
|
||||
/// Construction du titre avec données statiques
|
||||
Widget _buildTitleWithStaticData() {
|
||||
final totalPassages = passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
|
||||
|
||||
final totalPassages =
|
||||
passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
@@ -232,7 +236,8 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
/// Construction de la liste des passages avec ValueListenableBuilder
|
||||
Widget _buildPassagesListWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final passagesCounts = _calculatePassagesCounts(passagesBox);
|
||||
|
||||
@@ -293,7 +298,7 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -309,7 +314,7 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
// Pour les utilisateurs : seulement leurs passages
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
|
||||
if (targetUserId == null) return 0;
|
||||
|
||||
return passagesBox.values
|
||||
@@ -338,7 +343,7 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
// Pour les utilisateurs : compter seulement leurs passages
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
|
||||
if (targetUserId != null) {
|
||||
for (final passage in passagesBox.values) {
|
||||
if (passage.fkUser == targetUserId) {
|
||||
@@ -350,4 +355,4 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
0
app/lib/presentation/widgets/charts/passage_utils.dart
Normal file → Executable file
0
app/lib/presentation/widgets/charts/passage_utils.dart
Normal file → Executable file
0
app/lib/presentation/widgets/charts/payment_data.dart
Normal file → Executable file
0
app/lib/presentation/widgets/charts/payment_data.dart
Normal file → Executable file
158
app/lib/presentation/widgets/charts/payment_pie_chart.dart
Normal file → Executable file
158
app/lib/presentation/widgets/charts/payment_pie_chart.dart
Normal file → Executable file
@@ -102,15 +102,15 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
} else if (!widget.useValueListenable) {
|
||||
// Pour les données statiques, comparer les éléments
|
||||
if (oldWidget.payments.length != widget.payments.length) {
|
||||
shouldResetAnimation = true;
|
||||
} else {
|
||||
shouldResetAnimation = true;
|
||||
} else {
|
||||
for (int i = 0; i < oldWidget.payments.length; i++) {
|
||||
if (i >= widget.payments.length) break;
|
||||
if (oldWidget.payments[i].amount != widget.payments[i].amount ||
|
||||
oldWidget.payments[i].title != widget.payments[i].title) {
|
||||
shouldResetAnimation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,20 +131,21 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.useValueListenable) {
|
||||
return _buildWithValueListenable();
|
||||
} else {
|
||||
} else {
|
||||
return _buildWithStaticData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
|
||||
Widget _buildWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final paymentData = _calculatePaymentData(passagesBox);
|
||||
return _buildChart(paymentData);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du widget avec des données statiques
|
||||
@@ -153,85 +154,86 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
}
|
||||
|
||||
/// Calcule les données de règlement depuis la Hive box
|
||||
List<PaymentData> _calculatePaymentData(Box<PassageModel> passagesBox) {
|
||||
try {
|
||||
final passages = passagesBox.values.toList();
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? currentUserId = widget.userId ?? currentUser?.id;
|
||||
List<PaymentData> _calculatePaymentData(Box<PassageModel> passagesBox) {
|
||||
try {
|
||||
final passages = passagesBox.values.toList();
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? currentUserId = widget.userId ?? currentUser?.id;
|
||||
|
||||
// Initialiser les montants par type de règlement
|
||||
final Map<int, double> paymentAmounts = {
|
||||
0: 0.0, // Pas de règlement
|
||||
1: 0.0, // Espèces
|
||||
2: 0.0, // Chèques
|
||||
3: 0.0, // CB
|
||||
};
|
||||
// Initialiser les montants par type de règlement
|
||||
final Map<int, double> paymentAmounts = {
|
||||
0: 0.0, // Pas de règlement
|
||||
1: 0.0, // Espèces
|
||||
2: 0.0, // Chèques
|
||||
3: 0.0, // CB
|
||||
};
|
||||
|
||||
// Parcourir les passages et calculer les montants par type de règlement
|
||||
for (final passage in passages) {
|
||||
// Vérifier si le passage appartient à l'utilisateur actuel
|
||||
if (currentUserId != null && passage.fkUser == currentUserId) {
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
// Parcourir les passages et calculer les montants par type de règlement
|
||||
for (final passage in passages) {
|
||||
// Vérifier si le passage appartient à l'utilisateur actuel
|
||||
if (currentUserId != null && passage.fkUser == currentUserId) {
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
// Gérer les formats possibles (virgule ou point)
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de conversion du montant: ${passage.montant}');
|
||||
}
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
// Gérer les formats possibles (virgule ou point)
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de conversion du montant: ${passage.montant}');
|
||||
}
|
||||
|
||||
// Ne compter que les passages avec un montant > 0
|
||||
if (montant > 0) {
|
||||
// Ajouter au montant total par type de règlement
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
// Ne compter que les passages avec un montant > 0
|
||||
if (montant > 0) {
|
||||
// Ajouter au montant total par type de règlement
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir le Map en List<PaymentData>
|
||||
final List<PaymentData> paymentDataList = [];
|
||||
// Convertir le Map en List<PaymentData>
|
||||
final List<PaymentData> paymentDataList = [];
|
||||
|
||||
paymentAmounts.forEach((typeReglement, montant) {
|
||||
if (montant > 0) { // Ne retourner que les types avec un montant > 0
|
||||
// Récupérer les informations depuis AppKeys.typesReglements
|
||||
final reglementInfo = AppKeys.typesReglements[typeReglement];
|
||||
|
||||
if (reglementInfo != null) {
|
||||
paymentDataList.add(PaymentData(
|
||||
typeId: typeReglement,
|
||||
title: reglementInfo['titre'] as String,
|
||||
amount: montant,
|
||||
color: Color(reglementInfo['couleur'] as int),
|
||||
icon: reglementInfo['icon_data'] as IconData,
|
||||
));
|
||||
} else {
|
||||
// Fallback pour les types non définis
|
||||
paymentDataList.add(PaymentData(
|
||||
typeId: typeReglement,
|
||||
title: 'Type inconnu',
|
||||
amount: montant,
|
||||
color: Colors.grey,
|
||||
icon: Icons.help_outline,
|
||||
));
|
||||
paymentAmounts.forEach((typeReglement, montant) {
|
||||
if (montant > 0) {
|
||||
// Ne retourner que les types avec un montant > 0
|
||||
// Récupérer les informations depuis AppKeys.typesReglements
|
||||
final reglementInfo = AppKeys.typesReglements[typeReglement];
|
||||
|
||||
if (reglementInfo != null) {
|
||||
paymentDataList.add(PaymentData(
|
||||
typeId: typeReglement,
|
||||
title: reglementInfo['titre'] as String,
|
||||
amount: montant,
|
||||
color: Color(reglementInfo['couleur'] as int),
|
||||
icon: reglementInfo['icon_data'] as IconData,
|
||||
));
|
||||
} else {
|
||||
// Fallback pour les types non définis
|
||||
paymentDataList.add(PaymentData(
|
||||
typeId: typeReglement,
|
||||
title: 'Type inconnu',
|
||||
amount: montant,
|
||||
color: Colors.grey,
|
||||
icon: Icons.help_outline,
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return paymentDataList;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du calcul des données de règlement: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return paymentDataList;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du calcul des données de règlement: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit le graphique avec les données fournies
|
||||
Widget _buildChart(List<PaymentData> paymentData) {
|
||||
@@ -256,12 +258,12 @@ paymentAmounts.forEach((typeReglement, montant) {
|
||||
|
||||
final explodeAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
curve: const Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
final opacityAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
curve: const Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
|
||||
49
app/lib/presentation/widgets/charts/payment_summary_card.dart
Normal file → Executable file
49
app/lib/presentation/widgets/charts/payment_summary_card.dart
Normal file → Executable file
@@ -86,7 +86,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
child: Icon(
|
||||
backgroundIcon,
|
||||
size: backgroundIconSize,
|
||||
color: (backgroundIconColor ?? Colors.blue).withOpacity(backgroundIconOpacity),
|
||||
color: (backgroundIconColor ?? Colors.blue)
|
||||
.withOpacity(backgroundIconOpacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -115,7 +116,7 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
? _buildPaymentsListWithValueListenable()
|
||||
: _buildPaymentsListWithStaticData(),
|
||||
),
|
||||
|
||||
|
||||
// Séparateur vertical
|
||||
if (isDesktop) const VerticalDivider(width: 24),
|
||||
|
||||
@@ -126,7 +127,10 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: PaymentPieChart(
|
||||
useValueListenable: useValueListenable,
|
||||
payments: useValueListenable ? [] : _convertMapToPaymentData(paymentsByType ?? {}),
|
||||
payments: useValueListenable
|
||||
? []
|
||||
: _convertMapToPaymentData(
|
||||
paymentsByType ?? {}),
|
||||
userId: showAllPayments ? null : userId,
|
||||
size: double.infinity,
|
||||
labelSize: 12,
|
||||
@@ -157,7 +161,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
/// Construction du titre avec ValueListenableBuilder
|
||||
Widget _buildTitleWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final paymentStats = _calculatePaymentStats(passagesBox);
|
||||
|
||||
@@ -181,8 +186,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(paymentStats['totalAmount']) ??
|
||||
'${paymentStats['totalAmount'].toStringAsFixed(2)} €',
|
||||
customTotalDisplay?.call(paymentStats['totalAmount']) ??
|
||||
'${paymentStats['totalAmount'].toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -197,8 +202,9 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
|
||||
/// Construction du titre avec données statiques
|
||||
Widget _buildTitleWithStaticData() {
|
||||
final totalAmount = paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
|
||||
|
||||
final totalAmount =
|
||||
paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
@@ -219,7 +225,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(totalAmount) ?? '${totalAmount.toStringAsFixed(2)} €',
|
||||
customTotalDisplay?.call(totalAmount) ??
|
||||
'${totalAmount.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -233,7 +240,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
/// Construction de la liste des règlements avec ValueListenableBuilder
|
||||
Widget _buildPaymentsListWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final paymentAmounts = _calculatePaymentAmounts(passagesBox);
|
||||
|
||||
@@ -294,7 +302,7 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -330,7 +338,7 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
// Pour les utilisateurs : seulement leurs règlements
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
|
||||
if (targetUserId == null) {
|
||||
return {'passagesCount': 0, 'totalAmount': 0.0};
|
||||
}
|
||||
@@ -388,7 +396,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
|
||||
if (montant > 0) {
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] = (paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
}
|
||||
@@ -398,7 +407,7 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
// Pour les utilisateurs : compter seulement leurs règlements
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
|
||||
if (targetUserId != null) {
|
||||
for (final passage in passagesBox.values) {
|
||||
if (passage.fkUser == targetUserId) {
|
||||
@@ -415,7 +424,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
|
||||
if (montant > 0) {
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] = (paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
}
|
||||
@@ -433,10 +443,11 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
final List<PaymentData> paymentDataList = [];
|
||||
|
||||
paymentsMap.forEach((typeReglement, montant) {
|
||||
if (montant > 0) { // Ne retourner que les types avec un montant > 0
|
||||
if (montant > 0) {
|
||||
// Ne retourner que les types avec un montant > 0
|
||||
// Récupérer les informations depuis AppKeys.typesReglements
|
||||
final reglementInfo = AppKeys.typesReglements[typeReglement];
|
||||
|
||||
|
||||
if (reglementInfo != null) {
|
||||
paymentDataList.add(PaymentData(
|
||||
typeId: typeReglement,
|
||||
@@ -457,7 +468,7 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return paymentDataList;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
app/lib/presentation/widgets/chat/chat_input.dart
Normal file → Executable file
4
app/lib/presentation/widgets/chat/chat_input.dart
Normal file → Executable file
@@ -6,9 +6,9 @@ class ChatInput extends StatefulWidget {
|
||||
final Function(String) onMessageSent;
|
||||
|
||||
const ChatInput({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.onMessageSent,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatInput> createState() => _ChatInputState();
|
||||
|
||||
4
app/lib/presentation/widgets/chat/chat_messages.dart
Normal file → Executable file
4
app/lib/presentation/widgets/chat/chat_messages.dart
Normal file → Executable file
@@ -8,11 +8,11 @@ class ChatMessages extends StatelessWidget {
|
||||
final Function(Map<String, dynamic>) onReply;
|
||||
|
||||
const ChatMessages({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.messages,
|
||||
required this.currentUserId,
|
||||
required this.onReply,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
6
app/lib/presentation/widgets/chat/chat_sidebar.dart
Normal file → Executable file
6
app/lib/presentation/widgets/chat/chat_sidebar.dart
Normal file → Executable file
@@ -11,14 +11,14 @@ class ChatSidebar extends StatelessWidget {
|
||||
final Function(bool) onToggleGroup;
|
||||
|
||||
const ChatSidebar({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.teamContacts,
|
||||
required this.clientContacts,
|
||||
required this.isTeamChat,
|
||||
required this.selectedContactId,
|
||||
required this.onContactSelected,
|
||||
required this.onToggleGroup,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -178,7 +178,7 @@ class ChatSidebar extends StatelessWidget {
|
||||
if (hasUnread)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.primaryColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
|
||||
8
app/lib/presentation/widgets/clear_cache_dialog.dart
Normal file → Executable file
8
app/lib/presentation/widgets/clear_cache_dialog.dart
Normal file → Executable file
@@ -7,9 +7,9 @@ class ClearCacheDialog extends StatelessWidget {
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const ClearCacheDialog({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.onClose,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
/// Affiche le dialogue de nettoyage du cache
|
||||
static Future<void> show(BuildContext context,
|
||||
@@ -34,7 +34,7 @@ class ClearCacheDialog extends StatelessWidget {
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.orange,
|
||||
size: 28,
|
||||
@@ -120,7 +120,7 @@ class ClearCacheDialog extends StatelessWidget {
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$stepNumber',
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
|
||||
1
app/lib/presentation/widgets/connectivity_indicator.dart
Normal file → Executable file
1
app/lib/presentation/widgets/connectivity_indicator.dart
Normal file → Executable file
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/services/connectivity_service.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
|
||||
|
||||
0
app/lib/presentation/widgets/custom_button.dart
Normal file → Executable file
0
app/lib/presentation/widgets/custom_button.dart
Normal file → Executable file
56
app/lib/presentation/widgets/custom_text_field.dart
Normal file → Executable file
56
app/lib/presentation/widgets/custom_text_field.dart
Normal file → Executable file
@@ -21,6 +21,11 @@ class CustomTextField extends StatelessWidget {
|
||||
final bool obscureText;
|
||||
final Function(String)? onChanged;
|
||||
final Function(String)? onFieldSubmitted;
|
||||
|
||||
// Nouvelles propriétés pour le formulaire de passage
|
||||
final TextAlign? textAlign;
|
||||
final bool showLabel;
|
||||
final EdgeInsets? contentPadding;
|
||||
|
||||
const CustomTextField({
|
||||
super.key,
|
||||
@@ -43,12 +48,60 @@ class CustomTextField extends StatelessWidget {
|
||||
this.obscureText = false,
|
||||
this.onChanged,
|
||||
this.onFieldSubmitted,
|
||||
// Nouvelles propriétés pour le formulaire de passage
|
||||
this.textAlign,
|
||||
this.showLabel = true,
|
||||
this.contentPadding,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Mode sans label externe (pour utilisation dans des sections avec titres flottants)
|
||||
if (!showLabel) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
readOnly: readOnly,
|
||||
autofocus: autofocus,
|
||||
onTap: onTap,
|
||||
validator: validator,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
maxLength: maxLength,
|
||||
obscureText: obscureText,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
textAlign: textAlign ?? TextAlign.start,
|
||||
decoration: InputDecoration(
|
||||
labelText: isRequired ? "$label *" : label,
|
||||
hintText: hintText,
|
||||
helperText: helperText,
|
||||
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
|
||||
suffixIcon: suffixIcon,
|
||||
border: const OutlineInputBorder(),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
contentPadding: contentPadding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
buildCounter: maxLength != null
|
||||
? (context, {required currentLength, required isFocused, maxLength}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'$currentLength/${maxLength ?? 0}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
// Mode standard avec label externe
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -93,6 +146,7 @@ class CustomTextField extends StatelessWidget {
|
||||
obscureText: obscureText,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
textAlign: textAlign ?? TextAlign.start,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
helperText: helperText,
|
||||
@@ -133,7 +187,7 @@ class CustomTextField extends StatelessWidget {
|
||||
),
|
||||
filled: true,
|
||||
fillColor: readOnly ? theme.colorScheme.surfaceContainerHighest.withOpacity(0.3) : theme.colorScheme.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
contentPadding: contentPadding ?? const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
|
||||
257
app/lib/presentation/widgets/dashboard_app_bar.dart
Normal file → Executable file
257
app/lib/presentation/widgets/dashboard_app_bar.dart
Normal file → Executable file
@@ -3,7 +3,10 @@ import 'package:geosector_app/app.dart';
|
||||
import 'package:geosector_app/core/services/app_info_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/presentation/widgets/passage_form_dialog.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:geosector_app/core/services/theme_service.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// AppBar personnalisée pour les tableaux de bord
|
||||
@@ -40,13 +43,23 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AppBar(
|
||||
title: _buildTitle(context),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
elevation: 4,
|
||||
leading: _buildLogo(),
|
||||
actions: _buildActions(context),
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppBar(
|
||||
title: _buildTitle(context),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
elevation: 4,
|
||||
leading: _buildLogo(),
|
||||
actions: _buildActions(context),
|
||||
),
|
||||
// Bordure colorée selon le rôle
|
||||
Container(
|
||||
height: 3,
|
||||
color: isAdmin ? Colors.red : Colors.green,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,19 +111,49 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
|
||||
actions.add(const SizedBox(width: 8));
|
||||
|
||||
actions.add(
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add_location_alt, color: Colors.white),
|
||||
label: const Text('Nouveau passage', style: TextStyle(color: Colors.white)),
|
||||
onPressed: onNewPassagePressed,
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.secondary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
// Ajouter le bouton "Nouveau passage" seulement si l'utilisateur n'est pas admin
|
||||
if (!isAdmin) {
|
||||
actions.add(
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add_location_alt, color: Colors.white),
|
||||
label: const Text('Nouveau passage',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => PassageFormDialog(
|
||||
title: 'Nouveau passage',
|
||||
passageRepository: passageRepository,
|
||||
userRepository: userRepository,
|
||||
operationRepository: operationRepository,
|
||||
onSuccess: () {
|
||||
// Callback après création du passage
|
||||
if (onNewPassagePressed != null) {
|
||||
onNewPassagePressed!();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: Color(AppKeys.typesPassages[1]!['couleur1']
|
||||
as int), // Vert des passages effectués
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
actions.add(const SizedBox(width: 8));
|
||||
actions.add(const SizedBox(width: 8));
|
||||
}
|
||||
|
||||
// Ajouter le sélecteur de thème avec confirmation (désactivé temporairement)
|
||||
// TODO: Réactiver quand le thème sombre sera corrigé
|
||||
// actions.add(
|
||||
// _buildThemeSwitcherWithConfirmation(context),
|
||||
// );
|
||||
//
|
||||
// actions.add(const SizedBox(width: 8));
|
||||
|
||||
// Ajouter le bouton "Mon compte"
|
||||
actions.add(
|
||||
@@ -163,13 +206,17 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
tooltip: 'Déconnexion',
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
onPressed: onLogoutPressed ??
|
||||
() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Déconnexion'),
|
||||
content: const Text('Voulez-vous vraiment vous déconnecter ?'),
|
||||
content:
|
||||
const Text('Voulez-vous vraiment vous déconnecter ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
@@ -186,10 +233,12 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
// Vérification supplémentaire et navigation forcée si nécessaire
|
||||
if (success && context.mounted) {
|
||||
// Attendre un court instant pour que les changements d'état se propagent
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 100));
|
||||
|
||||
// Navigation forcée vers la page d'accueil
|
||||
context.go('/');
|
||||
// Navigation vers splash avec paramètres pour redirection automatique
|
||||
final loginType = isAdmin ? 'admin' : 'user';
|
||||
context.go('/?action=login&type=$loginType');
|
||||
}
|
||||
},
|
||||
child: const Text('Déconnexion'),
|
||||
@@ -215,16 +264,164 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
|
||||
// Construire un titre composé en fonction du rôle de l'utilisateur
|
||||
final String prefix = isAdmin ? 'Administration' : title;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(prefix),
|
||||
const Text(' - '),
|
||||
Text(pageTitle!),
|
||||
],
|
||||
|
||||
// Utiliser LayoutBuilder pour détecter la largeur disponible
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Déterminer si on est sur mobile ou écran étroit
|
||||
final isNarrowScreen = constraints.maxWidth < 600;
|
||||
final isMobilePlatform = Theme.of(context).platform == TargetPlatform.android ||
|
||||
Theme.of(context).platform == TargetPlatform.iOS;
|
||||
|
||||
// Cacher le titre de page sur mobile ou écrans étroits
|
||||
if (isNarrowScreen || isMobilePlatform) {
|
||||
return Text(prefix);
|
||||
}
|
||||
|
||||
// Afficher le titre complet sur écrans larges (web desktop)
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(prefix),
|
||||
const Text(' - '),
|
||||
Text(pageTitle!),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du sélecteur de thème avec confirmation
|
||||
Widget _buildThemeSwitcherWithConfirmation(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: Icon(ThemeService.instance.themeModeIcon),
|
||||
tooltip:
|
||||
'Changer le thème (${ThemeService.instance.themeModeDescription})',
|
||||
onPressed: () async {
|
||||
final themeService = ThemeService.instance;
|
||||
final currentTheme = themeService.themeModeDescription;
|
||||
|
||||
// Déterminer le prochain thème
|
||||
String nextTheme;
|
||||
switch (themeService.themeMode) {
|
||||
case ThemeMode.light:
|
||||
nextTheme = 'Sombre';
|
||||
break;
|
||||
case ThemeMode.dark:
|
||||
nextTheme = 'Clair';
|
||||
break;
|
||||
case ThemeMode.system:
|
||||
nextTheme = themeService.isSystemDark ? 'Clair' : 'Sombre';
|
||||
break;
|
||||
}
|
||||
|
||||
// Afficher la confirmation
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.palette_outlined),
|
||||
SizedBox(width: 8),
|
||||
Text('Changement de thème'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Vous êtes actuellement sur le thème :'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
themeService.themeModeIcon,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
currentTheme,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Voulez-vous passer au thème $nextTheme ?'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.errorContainer
|
||||
.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Note: Vous devrez vous reconnecter après ce changement.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text('Passer au thème $nextTheme'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Si confirmé, changer le thème
|
||||
if (confirmed == true) {
|
||||
await themeService.toggleTheme();
|
||||
|
||||
// Déconnecter l'utilisateur
|
||||
if (context.mounted) {
|
||||
final success = await userRepository.logout(context);
|
||||
if (success && context.mounted) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
// Rediriger vers splash avec paramètres pour revenir au même type de login
|
||||
final loginType = isAdmin ? 'admin' : 'user';
|
||||
context.go('/?action=login&type=$loginType');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
Size get preferredSize =>
|
||||
const Size.fromHeight(kToolbarHeight + 3); // +3 pour la bordure
|
||||
}
|
||||
|
||||
4
app/lib/presentation/widgets/dashboard_layout.dart
Normal file → Executable file
4
app/lib/presentation/widgets/dashboard_layout.dart
Normal file → Executable file
@@ -39,7 +39,7 @@ class DashboardLayout extends StatelessWidget {
|
||||
final VoidCallback? onLogoutPressed;
|
||||
|
||||
const DashboardLayout({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.body,
|
||||
required this.title,
|
||||
required this.selectedIndex,
|
||||
@@ -51,7 +51,7 @@ class DashboardLayout extends StatelessWidget {
|
||||
this.sidebarBottomItems,
|
||||
this.isAdmin = false,
|
||||
this.onLogoutPressed,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
0
app/lib/presentation/widgets/environment_info_widget.dart
Normal file → Executable file
0
app/lib/presentation/widgets/environment_info_widget.dart
Normal file → Executable file
76
app/lib/presentation/widgets/form_section.dart
Executable file
76
app/lib/presentation/widgets/form_section.dart
Executable file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Widget pour créer des sections de formulaire avec titre flottant
|
||||
/// Compatible avec le design du passage_form_dialog
|
||||
class FormSection extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData? icon;
|
||||
final List<Widget> children;
|
||||
final EdgeInsets? padding;
|
||||
final EdgeInsets? margin;
|
||||
final bool showBorder;
|
||||
|
||||
const FormSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.icon,
|
||||
required this.children,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.showBorder = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
margin: margin ?? const EdgeInsets.only(top: 8),
|
||||
padding: padding ?? const EdgeInsets.fromLTRB(16, 20, 16, 16),
|
||||
decoration: showBorder ? BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
) : null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
if (title.isNotEmpty)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
color: theme.colorScheme.primary,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
4
app/lib/presentation/widgets/help_dialog.dart
Normal file → Executable file
4
app/lib/presentation/widgets/help_dialog.dart
Normal file → Executable file
@@ -8,9 +8,9 @@ class HelpDialog extends StatelessWidget {
|
||||
final String currentPage;
|
||||
|
||||
const HelpDialog({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.currentPage,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
/// Affiche la boîte de dialogue d'aide
|
||||
static void show(BuildContext context, String currentPage) {
|
||||
|
||||
4
app/lib/presentation/widgets/hive_reset_dialog.dart
Normal file → Executable file
4
app/lib/presentation/widgets/hive_reset_dialog.dart
Normal file → Executable file
@@ -7,9 +7,9 @@ class HiveResetDialog extends StatelessWidget {
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const HiveResetDialog({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.onClose,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
/// Affiche le dialogue de réinitialisation Hive
|
||||
static Future<void> show(BuildContext context,
|
||||
|
||||
7
app/lib/presentation/widgets/loading_overlay.dart
Normal file → Executable file
7
app/lib/presentation/widgets/loading_overlay.dart
Normal file → Executable file
@@ -11,14 +11,14 @@ class LoadingOverlay extends StatelessWidget {
|
||||
final double strokeWidth;
|
||||
|
||||
const LoadingOverlay({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.message,
|
||||
this.backgroundColor = Colors.black54,
|
||||
this.spinnerColor = Colors.white,
|
||||
this.textColor = Colors.white,
|
||||
this.spinnerSize = 60.0,
|
||||
this.strokeWidth = 5.0,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -36,7 +36,8 @@ class LoadingOverlay extends StatelessWidget {
|
||||
strokeWidth: strokeWidth,
|
||||
),
|
||||
),
|
||||
if (message != null) ...[ // Afficher le texte seulement si message n'est pas null
|
||||
if (message != null) ...[
|
||||
// Afficher le texte seulement si message n'est pas null
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
message!,
|
||||
|
||||
8
app/lib/presentation/widgets/loading_progress_overlay.dart
Normal file → Executable file
8
app/lib/presentation/widgets/loading_progress_overlay.dart
Normal file → Executable file
@@ -14,7 +14,7 @@ class LoadingProgressOverlay extends StatefulWidget {
|
||||
final bool showPercentage;
|
||||
|
||||
const LoadingProgressOverlay({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.message,
|
||||
required this.progress,
|
||||
this.stepDescription,
|
||||
@@ -23,7 +23,7 @@ class LoadingProgressOverlay extends StatefulWidget {
|
||||
this.textColor = Colors.white,
|
||||
this.blurAmount = 5.0,
|
||||
this.showPercentage = true,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<LoadingProgressOverlay> createState() => _LoadingProgressOverlayState();
|
||||
@@ -82,12 +82,12 @@ class _LoadingProgressOverlayState extends State<LoadingProgressOverlay>
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black45,
|
||||
blurRadius: 15,
|
||||
spreadRadius: 5,
|
||||
offset: const Offset(0, 5),
|
||||
offset: Offset(0, 5),
|
||||
),
|
||||
],
|
||||
border: Border.all(
|
||||
|
||||
108
app/lib/presentation/widgets/mapbox_map.dart
Normal file → Executable file
108
app/lib/presentation/widgets/mapbox_map.dart
Normal file → Executable file
@@ -1,5 +1,9 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_cache/flutter_map_cache.dart';
|
||||
import 'package:http_cache_file_store/http_cache_file_store.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart'; // Import du service singleton
|
||||
@@ -15,12 +19,18 @@ class MapboxMap extends StatefulWidget {
|
||||
/// Niveau de zoom initial
|
||||
final double initialZoom;
|
||||
|
||||
/// Liste des marqueurs à afficher
|
||||
/// Liste des marqueurs à afficher (au-dessus de tout)
|
||||
final List<Marker>? markers;
|
||||
|
||||
/// Liste des marqueurs de labels à afficher (sous les marqueurs principaux)
|
||||
final List<Marker>? labelMarkers;
|
||||
|
||||
/// Liste des polygones à afficher
|
||||
final List<Polygon>? polygons;
|
||||
|
||||
/// Liste des polylines à afficher
|
||||
final List<Polyline>? polylines;
|
||||
|
||||
/// Contrôleur de carte externe (optionnel)
|
||||
final MapController? mapController;
|
||||
|
||||
@@ -34,16 +44,22 @@ class MapboxMap extends StatefulWidget {
|
||||
/// Si non spécifié, utilise le style par défaut 'mapbox/streets-v12'
|
||||
final String? mapStyle;
|
||||
|
||||
/// Désactive le drag de la carte
|
||||
final bool disableDrag;
|
||||
|
||||
const MapboxMap({
|
||||
super.key,
|
||||
this.initialPosition = const LatLng(48.1173, -1.6778), // Rennes par défaut
|
||||
this.initialZoom = 13.0,
|
||||
this.markers,
|
||||
this.labelMarkers,
|
||||
this.polygons,
|
||||
this.polylines,
|
||||
this.mapController,
|
||||
this.onMapEvent,
|
||||
this.showControls = true,
|
||||
this.mapStyle,
|
||||
this.disableDrag = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -57,11 +73,47 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
/// Niveau de zoom actuel
|
||||
double _currentZoom = 13.0;
|
||||
|
||||
/// Provider de cache pour les tuiles
|
||||
CachedTileProvider? _tileProvider;
|
||||
|
||||
/// Indique si le cache est initialisé
|
||||
bool _cacheInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = widget.mapController ?? MapController();
|
||||
_currentZoom = widget.initialZoom;
|
||||
_initializeCache();
|
||||
}
|
||||
|
||||
/// Initialise le cache des tuiles
|
||||
Future<void> _initializeCache() async {
|
||||
try {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final cacheStore = FileCacheStore('${dir.path}${Platform.pathSeparator}MapboxTileCache');
|
||||
|
||||
_tileProvider = CachedTileProvider(
|
||||
store: cacheStore,
|
||||
// Configuration du cache
|
||||
// maxStale permet de servir des tuiles expirées jusqu'à 30 jours
|
||||
maxStale: const Duration(days: 30),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cacheInitialized = true;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation du cache: $e');
|
||||
// En cas d'erreur, on continue sans cache
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cacheInitialized = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -110,6 +162,42 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
final String mapStyle = widget.mapStyle ?? 'mapbox/streets-v11';
|
||||
final String urlTemplate = 'https://api.mapbox.com/styles/v1/$mapStyle/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
|
||||
|
||||
// Afficher un indicateur pendant l'initialisation du cache
|
||||
if (!_cacheInitialized) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Carte sans cache en attendant
|
||||
_buildMapContent(urlTemplate, mapboxToken),
|
||||
// Indicateur discret
|
||||
const Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text('Initialisation du cache...', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return _buildMapContent(urlTemplate, mapboxToken);
|
||||
}
|
||||
|
||||
Widget _buildMapContent(String urlTemplate, String mapboxToken) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Carte principale
|
||||
@@ -118,6 +206,12 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
options: MapOptions(
|
||||
initialCenter: widget.initialPosition,
|
||||
initialZoom: widget.initialZoom,
|
||||
interactionOptions: InteractionOptions(
|
||||
enableMultiFingerGestureRace: true,
|
||||
flags: widget.disableDrag
|
||||
? InteractiveFlag.all & ~InteractiveFlag.drag
|
||||
: InteractiveFlag.all,
|
||||
),
|
||||
onMapEvent: (event) {
|
||||
if (event is MapEventMove) {
|
||||
setState(() {
|
||||
@@ -141,12 +235,22 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
additionalOptions: {
|
||||
'accessToken': mapboxToken,
|
||||
},
|
||||
// Utilise le cache si disponible
|
||||
tileProvider: _cacheInitialized && _tileProvider != null
|
||||
? _tileProvider!
|
||||
: NetworkTileProvider(),
|
||||
),
|
||||
|
||||
// Polygones
|
||||
if (widget.polygons != null && widget.polygons!.isNotEmpty) PolygonLayer(polygons: widget.polygons!),
|
||||
|
||||
// Marqueurs de labels (sous les marqueurs principaux)
|
||||
if (widget.labelMarkers != null && widget.labelMarkers!.isNotEmpty) MarkerLayer(markers: widget.labelMarkers!),
|
||||
|
||||
// Marqueurs
|
||||
// Polylines
|
||||
if (widget.polylines != null && widget.polylines!.isNotEmpty) PolylineLayer(polylines: widget.polylines!),
|
||||
|
||||
// Marqueurs principaux (au-dessus de tout)
|
||||
if (widget.markers != null && widget.markers!.isNotEmpty) MarkerLayer(markers: widget.markers!),
|
||||
],
|
||||
),
|
||||
|
||||
12
app/lib/presentation/widgets/membre_row_widget.dart
Normal file → Executable file
12
app/lib/presentation/widgets/membre_row_widget.dart
Normal file → Executable file
@@ -5,6 +5,7 @@ class MembreRowWidget extends StatelessWidget {
|
||||
final MembreModel membre;
|
||||
final Function(MembreModel)? onEdit;
|
||||
final Function(MembreModel)? onDelete;
|
||||
final Function(MembreModel)? onResetPassword;
|
||||
final bool isAlternate;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@@ -13,6 +14,7 @@ class MembreRowWidget extends StatelessWidget {
|
||||
required this.membre,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onResetPassword,
|
||||
this.isAlternate = false,
|
||||
this.onTap,
|
||||
});
|
||||
@@ -103,12 +105,20 @@ class MembreRowWidget extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Actions
|
||||
if (onEdit != null || onDelete != null)
|
||||
if (onEdit != null || onDelete != null || onResetPassword != null)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// Bouton reset password (uniquement pour les membres actifs)
|
||||
if (onResetPassword != null && membre.isActive == true)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.lock_reset, size: 22),
|
||||
onPressed: () => onResetPassword!(membre),
|
||||
tooltip: 'Réinitialiser le mot de passe',
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
if (onDelete != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, size: 22),
|
||||
|
||||
7
app/lib/presentation/widgets/membre_table_widget.dart
Normal file → Executable file
7
app/lib/presentation/widgets/membre_table_widget.dart
Normal file → Executable file
@@ -7,6 +7,7 @@ class MembreTableWidget extends StatelessWidget {
|
||||
final List<MembreModel> membres;
|
||||
final Function(MembreModel)? onEdit;
|
||||
final Function(MembreModel)? onDelete;
|
||||
final Function(MembreModel)? onResetPassword;
|
||||
final MembreRepository membreRepository;
|
||||
final bool showHeader;
|
||||
final double? height;
|
||||
@@ -20,6 +21,7 @@ class MembreTableWidget extends StatelessWidget {
|
||||
required this.membreRepository,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onResetPassword,
|
||||
this.showHeader = true,
|
||||
this.height,
|
||||
this.padding,
|
||||
@@ -131,8 +133,8 @@ class MembreTableWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Actions (si onEdit ou onDelete sont fournis)
|
||||
if (onEdit != null || onDelete != null)
|
||||
// Actions (si onEdit, onDelete ou onResetPassword sont fournis)
|
||||
if (onEdit != null || onDelete != null || onResetPassword != null)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
@@ -188,6 +190,7 @@ class MembreTableWidget extends StatelessWidget {
|
||||
membre: membre,
|
||||
onEdit: onEdit,
|
||||
onDelete: onDelete,
|
||||
onResetPassword: onResetPassword,
|
||||
isAlternate: index % 2 == 1,
|
||||
onTap: onEdit != null ? () => onEdit!(membre) : null,
|
||||
);
|
||||
|
||||
0
app/lib/presentation/widgets/operation_form_dialog.dart
Normal file → Executable file
0
app/lib/presentation/widgets/operation_form_dialog.dart
Normal file → Executable file
1058
app/lib/presentation/widgets/passage_form_dialog.dart
Executable file
1058
app/lib/presentation/widgets/passage_form_dialog.dart
Executable file
File diff suppressed because it is too large
Load Diff
242
app/lib/presentation/widgets/passage_form_modernized_example.dart
Executable file
242
app/lib/presentation/widgets/passage_form_modernized_example.dart
Executable file
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
import 'package:geosector_app/presentation/widgets/form_section.dart';
|
||||
|
||||
/// Exemple d'utilisation modernisée du formulaire de passage
|
||||
/// utilisant CustomTextField et FormSection adaptés
|
||||
class PassageFormModernizedExample extends StatefulWidget {
|
||||
final bool readOnly;
|
||||
|
||||
const PassageFormModernizedExample({
|
||||
super.key,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PassageFormModernizedExample> createState() => _PassageFormModernizedExampleState();
|
||||
}
|
||||
|
||||
class _PassageFormModernizedExampleState extends State<PassageFormModernizedExample> {
|
||||
// Controllers pour l'exemple
|
||||
final _numeroController = TextEditingController();
|
||||
final _rueBisController = TextEditingController();
|
||||
final _rueController = TextEditingController();
|
||||
final _villeController = TextEditingController();
|
||||
final _dateController = TextEditingController();
|
||||
final _timeController = TextEditingController();
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _montantController = TextEditingController();
|
||||
final _remarqueController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_numeroController.dispose();
|
||||
_rueBisController.dispose();
|
||||
_rueController.dispose();
|
||||
_villeController.dispose();
|
||||
_dateController.dispose();
|
||||
_timeController.dispose();
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_phoneController.dispose();
|
||||
_montantController.dispose();
|
||||
_remarqueController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _selectDate() {
|
||||
// Logique de sélection de date
|
||||
}
|
||||
|
||||
void _selectTime() {
|
||||
// Logique de sélection d'heure
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Formulaire Modernisé')),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Section Date et Heure avec FormSection
|
||||
FormSection(
|
||||
title: 'Date et Heure de passage',
|
||||
icon: Icons.schedule,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _dateController,
|
||||
label: "Date",
|
||||
isRequired: true,
|
||||
readOnly: true,
|
||||
showLabel: false,
|
||||
hintText: "DD/MM/YYYY",
|
||||
suffixIcon: const Icon(Icons.calendar_today),
|
||||
onTap: widget.readOnly ? null : _selectDate,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _villeController,
|
||||
label: "Ville",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section Occupant
|
||||
FormSection(
|
||||
title: 'Occupant',
|
||||
icon: Icons.person,
|
||||
children: [
|
||||
CustomTextField(
|
||||
controller: _nameController,
|
||||
label: "Nom de l'occupant",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _emailController,
|
||||
label: "Email",
|
||||
showLabel: false,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _phoneController,
|
||||
label: "Téléphone",
|
||||
showLabel: false,
|
||||
keyboardType: TextInputType.phone,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section Règlement (sans bordure)
|
||||
FormSection(
|
||||
title: '',
|
||||
showBorder: true,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _montantController,
|
||||
label: "Montant (€)",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
hintText: "0.00",
|
||||
textAlign: TextAlign.right,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text("Dropdown de type de règlement"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _remarqueController,
|
||||
label: "Remarque",
|
||||
showLabel: false,
|
||||
hintText: "Commentaire sur le passage...",
|
||||
maxLines: 2,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
180
app/lib/presentation/widgets/passage_validation_helpers.dart
Executable file
180
app/lib/presentation/widgets/passage_validation_helpers.dart
Executable file
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Helpers de validation pour le formulaire de passage
|
||||
class PassageValidationHelpers {
|
||||
|
||||
/// Validation pour numéro de rue
|
||||
static String? validateNumero(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Le numéro est obligatoire';
|
||||
}
|
||||
|
||||
final numero = int.tryParse(value.trim());
|
||||
if (numero == null || numero <= 0) {
|
||||
return 'Numéro invalide';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour rue
|
||||
static String? validateRue(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'La rue est obligatoire';
|
||||
}
|
||||
|
||||
if (value.trim().length < 3) {
|
||||
return 'La rue doit contenir au moins 3 caractères';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour ville
|
||||
static String? validateVille(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'La ville est obligatoire';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour nom d'occupant (selon type de passage)
|
||||
static String? validateNomOccupant(String? value, int? passageType) {
|
||||
// Nom obligatoire seulement pour les passages effectués (type 1)
|
||||
if (passageType == 1) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Le nom est obligatoire pour les passages effectués';
|
||||
}
|
||||
|
||||
if (value.trim().length < 2) {
|
||||
return 'Le nom doit contenir au moins 2 caractères';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour email
|
||||
static String? validateEmail(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null; // Email optionnel
|
||||
}
|
||||
|
||||
const emailRegex = r'^[^@]+@[^@]+\.[^@]+$';
|
||||
if (!RegExp(emailRegex).hasMatch(value.trim())) {
|
||||
return 'Format email invalide';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour téléphone
|
||||
static String? validatePhone(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null; // Téléphone optionnel
|
||||
}
|
||||
|
||||
// Enlever espaces et tirets
|
||||
final cleanPhone = value.replaceAll(RegExp(r'[\s\-\.]'), '');
|
||||
|
||||
// Vérifier format français basique
|
||||
if (cleanPhone.length < 10) {
|
||||
return 'Numéro trop court';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour montant (selon type de passage)
|
||||
static String? validateMontant(String? value, int? passageType) {
|
||||
// Montant obligatoire seulement pour types 1 (Effectué) et 5 (Lot)
|
||||
if (passageType == 1 || passageType == 5) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Le montant est obligatoire pour ce type';
|
||||
}
|
||||
|
||||
final montant = double.tryParse(value.replaceAll(',', '.'));
|
||||
if (montant == null) {
|
||||
return 'Montant invalide';
|
||||
}
|
||||
|
||||
if (montant <= 0) {
|
||||
return 'Le montant doit être supérieur à 0';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour remarque
|
||||
static String? validateRemarque(String? value) {
|
||||
if (value != null && value.length > 500) {
|
||||
return 'La remarque ne peut pas dépasser 500 caractères';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Focus sur le premier champ en erreur avec scroll
|
||||
static void focusFirstError(BuildContext context, GlobalKey<FormState> formKey) {
|
||||
// Déclencher la validation
|
||||
if (!formKey.currentState!.validate()) {
|
||||
// Flutter met automatiquement le focus sur le premier champ en erreur
|
||||
// Mais on peut ajouter un scroll pour s'assurer que le champ est visible
|
||||
|
||||
// Attendre que le focus soit mis
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Trouver le champ qui a le focus
|
||||
final FocusNode? focusedNode = FocusScope.of(context).focusedChild;
|
||||
if (focusedNode != null) {
|
||||
// Scroll vers le champ focusé
|
||||
Scrollable.ensureVisible(
|
||||
focusedNode.context!,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Validation globale du formulaire de passage
|
||||
static bool validatePassageForm({
|
||||
required GlobalKey<FormState> formKey,
|
||||
required BuildContext context,
|
||||
required int? selectedPassageType,
|
||||
required int fkTypeReglement,
|
||||
String? montant,
|
||||
String? nomOccupant,
|
||||
}) {
|
||||
// Validation des champs via les validators des TextFormField
|
||||
if (!formKey.currentState!.validate()) {
|
||||
// Le focus est automatiquement mis sur le premier champ en erreur
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validations spécifiques métier (comme dans l'original)
|
||||
if (selectedPassageType == 1) {
|
||||
if (nomOccupant == null || nomOccupant.trim().isEmpty) {
|
||||
// Ici on pourrait aussi mettre le focus sur le champ nom
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedPassageType == 1 || selectedPassageType == 5) {
|
||||
final montantValue = double.tryParse(montant?.replaceAll(',', '.') ?? '');
|
||||
if (montantValue == null || montantValue <= 0) {
|
||||
// Focus sur montant si erreur
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fkTypeReglement < 1 || fkTypeReglement > 3) {
|
||||
// Focus sur dropdown type règlement
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
25
app/lib/presentation/widgets/passages/passage_form.dart
Normal file → Executable file
25
app/lib/presentation/widgets/passages/passage_form.dart
Normal file → Executable file
@@ -7,10 +7,10 @@ class PassageForm extends StatefulWidget {
|
||||
final Map<String, dynamic>? initialData;
|
||||
|
||||
const PassageForm({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.onSubmit,
|
||||
this.initialData,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<PassageForm> createState() => _PassageFormState();
|
||||
@@ -123,7 +123,7 @@ class _PassageFormState extends State<PassageForm> {
|
||||
"Type d'habitat",
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onBackground,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -199,42 +199,39 @@ class _PassageFormState extends State<PassageForm> {
|
||||
"Montant reçu",
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onBackground,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _montantController,
|
||||
keyboardType:
|
||||
TextInputType.numberWithOptions(decimal: true),
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d+\.?\d{0,2}')),
|
||||
],
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '0.00 €',
|
||||
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
||||
color:
|
||||
theme.colorScheme.onBackground.withOpacity(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.onBackground.withOpacity(0.1),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color:
|
||||
theme.colorScheme.onBackground.withOpacity(0.1),
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -271,7 +268,7 @@ class _PassageFormState extends State<PassageForm> {
|
||||
"Type de règlement",
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onBackground,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -348,7 +345,7 @@ class _PassageFormState extends State<PassageForm> {
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
||||
155
app/lib/presentation/widgets/passages/passages_list_widget.dart
Normal file → Executable file
155
app/lib/presentation/widgets/passages/passages_list_widget.dart
Normal file → Executable file
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
|
||||
/// Un widget réutilisable pour afficher une liste de passages avec filtres
|
||||
@@ -110,14 +109,34 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
|
||||
// Liste filtrée avec gestion des erreurs
|
||||
List<Map<String, dynamic>> get _filteredPassages {
|
||||
try {
|
||||
// Si les filtres sont désactivés (showFilters: false), retourner directement les passages
|
||||
// car le filtrage est fait par le parent
|
||||
if (!widget.showFilters && !widget.showSearch) {
|
||||
var filtered = widget.passages;
|
||||
|
||||
// Appliquer uniquement le tri et la limitation si nécessaire
|
||||
// Trier les passages par date (les plus récents d'abord)
|
||||
filtered.sort((a, b) {
|
||||
if (a.containsKey('date') && b.containsKey('date')) {
|
||||
final DateTime dateA = a['date'] as DateTime;
|
||||
final DateTime dateB = b['date'] as DateTime;
|
||||
return dateB.compareTo(dateA); // Ordre décroissant
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Limiter le nombre de passages si maxPassages est défini
|
||||
if (widget.maxPassages != null && filtered.length > widget.maxPassages!) {
|
||||
filtered = filtered.sublist(0, widget.maxPassages!);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// Sinon, appliquer le filtrage interne (mode legacy)
|
||||
var filtered = widget.passages.where((passage) {
|
||||
try {
|
||||
// Vérification que le passage est valide
|
||||
if (passage == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclure les types de passages spécifiés
|
||||
if (widget.excludePassageTypes != null &&
|
||||
passage.containsKey('type') &&
|
||||
widget.excludePassageTypes!.contains(passage['type'])) {
|
||||
@@ -369,19 +388,6 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
|
||||
iconSize: 20,
|
||||
),
|
||||
|
||||
// Bouton Modifier
|
||||
// Dans la page admin, afficher pour tous les passages
|
||||
// Dans la page user, uniquement pour les passages de l'utilisateur courant
|
||||
if (widget.onPassageEdit != null &&
|
||||
(isAdminPage || isOwnedByCurrentUser))
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.blue),
|
||||
tooltip: 'Modifier',
|
||||
onPressed: () => widget.onPassageEdit!(passage),
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -521,7 +527,7 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: Colors.red,
|
||||
size: 16,
|
||||
@@ -690,44 +696,81 @@ class _PassagesListWidgetState extends State<PassagesListWidget> {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _filteredPassages.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun passage trouvé',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Essayez de modifier vos filtres de recherche',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header avec le nombre de passages trouvés
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
itemCount: _filteredPassages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final passage = _filteredPassages[index];
|
||||
return _buildPassageCard(passage, theme, isDesktop);
|
||||
},
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.list_alt,
|
||||
size: 20,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${_filteredPassages.length} passage${_filteredPassages.length > 1 ? 's' : ''} trouvé${_filteredPassages.length > 1 ? 's' : ''}',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu de la liste
|
||||
Expanded(
|
||||
child: _filteredPassages.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun passage trouvé',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Essayez de modifier vos filtres de recherche',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
itemCount: _filteredPassages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final passage = _filteredPassages[index];
|
||||
return _buildPassageCard(passage, theme, isDesktop);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
12
app/lib/presentation/widgets/responsive_navigation.dart
Normal file → Executable file
12
app/lib/presentation/widgets/responsive_navigation.dart
Normal file → Executable file
@@ -1,6 +1,5 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/help_dialog.dart';
|
||||
@@ -48,7 +47,7 @@ class ResponsiveNavigation extends StatefulWidget {
|
||||
final bool showAppBar;
|
||||
|
||||
const ResponsiveNavigation({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.body,
|
||||
required this.title,
|
||||
required this.selectedIndex,
|
||||
@@ -62,7 +61,7 @@ class ResponsiveNavigation extends StatefulWidget {
|
||||
this.sidebarBottomItems,
|
||||
this.isAdmin = false,
|
||||
this.showAppBar = true,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<ResponsiveNavigation> createState() => _ResponsiveNavigationState();
|
||||
@@ -162,6 +161,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
onDestinationSelected: widget.onDestinationSelected,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
elevation: 8,
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
|
||||
destinations: widget.destinations,
|
||||
);
|
||||
}
|
||||
@@ -344,7 +344,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
Widget _buildNavItem(int index, String title, Widget icon) {
|
||||
final theme = Theme.of(context);
|
||||
final isSelected = widget.selectedIndex == index;
|
||||
final IconData? iconData = (icon is Icon) ? (icon as Icon).icon : null;
|
||||
final IconData? iconData = (icon is Icon) ? (icon).icon : null;
|
||||
|
||||
// Remplacer certains titres si l'interface est de type "user"
|
||||
String displayTitle = title;
|
||||
@@ -430,10 +430,10 @@ class _SettingsItem extends StatelessWidget {
|
||||
const _SettingsItem({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
required this.onTap,
|
||||
required this.isSidebarMinimized,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
|
||||
19
app/lib/presentation/widgets/sector_distribution_card.dart
Normal file → Executable file
19
app/lib/presentation/widgets/sector_distribution_card.dart
Normal file → Executable file
@@ -11,11 +11,11 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
const SectorDistributionCard({
|
||||
Key? key,
|
||||
super.key,
|
||||
this.title = 'Répartition par secteur',
|
||||
this.height,
|
||||
this.padding,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -49,10 +49,12 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
Widget _buildAutoRefreshContent() {
|
||||
// Écouter les changements des deux boîtes
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
|
||||
builder: (context, Box<SectorModel> sectorsBox, child) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
return _buildContent(sectorsBox, passagesBox);
|
||||
},
|
||||
@@ -61,7 +63,8 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(Box<SectorModel> sectorsBox, Box<PassageModel> passagesBox) {
|
||||
Widget _buildContent(
|
||||
Box<SectorModel> sectorsBox, Box<PassageModel> passagesBox) {
|
||||
try {
|
||||
// Calculer les statistiques
|
||||
final sectorStats = _calculateSectorStats(sectorsBox, passagesBox);
|
||||
@@ -104,8 +107,8 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
final Map<int, int> sectorCounts = {};
|
||||
|
||||
for (final passage in passages) {
|
||||
// Exclure les passages où fkType==2
|
||||
if (passage.fkSector != null && passage.fkType != 2) {
|
||||
// Exclure les passages où fkType==2 et ceux sans secteur
|
||||
if (passage.fkType != 2 && passage.fkSector != null) {
|
||||
sectorCounts[passage.fkSector!] =
|
||||
(sectorCounts[passage.fkSector!] ?? 0) + 1;
|
||||
}
|
||||
@@ -179,4 +182,4 @@ class SectorDistributionCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
232
app/lib/presentation/widgets/theme_switcher.dart
Executable file
232
app/lib/presentation/widgets/theme_switcher.dart
Executable file
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/services/theme_service.dart';
|
||||
|
||||
/// Widget pour basculer entre les thèmes clair/sombre/automatique
|
||||
class ThemeSwitcher extends StatelessWidget {
|
||||
/// Style d'affichage du sélecteur
|
||||
final ThemeSwitcherStyle style;
|
||||
|
||||
/// Afficher le texte descriptif
|
||||
final bool showLabel;
|
||||
|
||||
/// Callback optionnel appelé après changement de thème
|
||||
final VoidCallback? onThemeChanged;
|
||||
|
||||
const ThemeSwitcher({
|
||||
super.key,
|
||||
this.style = ThemeSwitcherStyle.iconButton,
|
||||
this.showLabel = false,
|
||||
this.onThemeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: ThemeService.instance,
|
||||
builder: (context, child) {
|
||||
switch (style) {
|
||||
case ThemeSwitcherStyle.iconButton:
|
||||
return _buildIconButton(context);
|
||||
case ThemeSwitcherStyle.dropdown:
|
||||
return _buildDropdown(context);
|
||||
case ThemeSwitcherStyle.segmentedButton:
|
||||
return _buildSegmentedButton(context);
|
||||
case ThemeSwitcherStyle.toggleButtons:
|
||||
return _buildToggleButtons(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Bouton icône simple (bascule entre clair/sombre)
|
||||
Widget _buildIconButton(BuildContext context) {
|
||||
final themeService = ThemeService.instance;
|
||||
|
||||
return IconButton(
|
||||
icon: Icon(themeService.themeModeIcon),
|
||||
tooltip: 'Changer le thème (${themeService.themeModeDescription})',
|
||||
onPressed: () async {
|
||||
await themeService.toggleTheme();
|
||||
onThemeChanged?.call();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Dropdown avec toutes les options
|
||||
Widget _buildDropdown(BuildContext context) {
|
||||
final themeService = ThemeService.instance;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return DropdownButton<ThemeMode>(
|
||||
value: themeService.themeMode,
|
||||
icon: Icon(Icons.arrow_drop_down, color: theme.colorScheme.onSurface),
|
||||
underline: Container(),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: ThemeMode.system,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.brightness_auto, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Automatique'),
|
||||
if (showLabel) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'(${themeService.isSystemDark ? 'sombre' : 'clair'})',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const DropdownMenuItem(
|
||||
value: ThemeMode.light,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.light_mode, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('Clair'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const DropdownMenuItem(
|
||||
value: ThemeMode.dark,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.dark_mode, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('Sombre'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (ThemeMode? mode) async {
|
||||
if (mode != null) {
|
||||
await themeService.setThemeMode(mode);
|
||||
onThemeChanged?.call();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Boutons segmentés (Material 3)
|
||||
Widget _buildSegmentedButton(BuildContext context) {
|
||||
final themeService = ThemeService.instance;
|
||||
|
||||
return SegmentedButton<ThemeMode>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: ThemeMode.light,
|
||||
icon: Icon(Icons.light_mode, size: 16),
|
||||
label: Text('Clair'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.system,
|
||||
icon: Icon(Icons.brightness_auto, size: 16),
|
||||
label: Text('Auto'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.dark,
|
||||
icon: Icon(Icons.dark_mode, size: 16),
|
||||
label: Text('Sombre'),
|
||||
),
|
||||
],
|
||||
selected: {themeService.themeMode},
|
||||
onSelectionChanged: (Set<ThemeMode> selection) async {
|
||||
if (selection.isNotEmpty) {
|
||||
await themeService.setThemeMode(selection.first);
|
||||
onThemeChanged?.call();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Boutons à bascule
|
||||
Widget _buildToggleButtons(BuildContext context) {
|
||||
final themeService = ThemeService.instance;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ToggleButtons(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
constraints: const BoxConstraints(minHeight: 40, minWidth: 60),
|
||||
isSelected: [
|
||||
themeService.themeMode == ThemeMode.light,
|
||||
themeService.themeMode == ThemeMode.system,
|
||||
themeService.themeMode == ThemeMode.dark,
|
||||
],
|
||||
onPressed: (int index) async {
|
||||
final modes = [ThemeMode.light, ThemeMode.system, ThemeMode.dark];
|
||||
await themeService.setThemeMode(modes[index]);
|
||||
onThemeChanged?.call();
|
||||
},
|
||||
children: const [
|
||||
Icon(Icons.light_mode, size: 20),
|
||||
Icon(Icons.brightness_auto, size: 20),
|
||||
Icon(Icons.dark_mode, size: 20),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget d'information sur le thème actuel
|
||||
class ThemeInfo extends StatelessWidget {
|
||||
const ThemeInfo({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: ThemeService.instance,
|
||||
builder: (context, child) {
|
||||
final themeService = ThemeService.instance;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
themeService.themeModeIcon,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
themeService.themeModeDescription,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Styles d'affichage pour le ThemeSwitcher
|
||||
enum ThemeSwitcherStyle {
|
||||
/// Bouton icône simple qui bascule entre clair/sombre
|
||||
iconButton,
|
||||
|
||||
/// Menu déroulant avec toutes les options
|
||||
dropdown,
|
||||
|
||||
/// Boutons segmentés (Material 3)
|
||||
segmentedButton,
|
||||
|
||||
/// Boutons à bascule
|
||||
toggleButtons,
|
||||
}
|
||||
0
app/lib/presentation/widgets/user_form.dart
Normal file → Executable file
0
app/lib/presentation/widgets/user_form.dart
Normal file → Executable file
0
app/lib/presentation/widgets/user_form_dialog.dart
Normal file → Executable file
0
app/lib/presentation/widgets/user_form_dialog.dart
Normal file → Executable file
327
app/lib/presentation/widgets/validation_example.dart
Executable file
327
app/lib/presentation/widgets/validation_example.dart
Executable file
@@ -0,0 +1,327 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
import 'package:geosector_app/presentation/widgets/form_section.dart';
|
||||
|
||||
/// Exemple de validation avec CustomTextField
|
||||
/// Montre comment les erreurs sont gérées automatiquement
|
||||
class ValidationExample extends StatefulWidget {
|
||||
const ValidationExample({super.key});
|
||||
|
||||
@override
|
||||
State<ValidationExample> createState() => _ValidationExampleState();
|
||||
}
|
||||
|
||||
class _ValidationExampleState extends State<ValidationExample> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Controllers
|
||||
final _numeroController = TextEditingController();
|
||||
final _rueController = TextEditingController();
|
||||
final _villeController = TextEditingController();
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _montantController = TextEditingController();
|
||||
|
||||
// FocusNodes pour contrôler le focus
|
||||
final _numeroFocus = FocusNode();
|
||||
final _rueFocus = FocusNode();
|
||||
final _villeFocus = FocusNode();
|
||||
final _nameFocus = FocusNode();
|
||||
final _emailFocus = FocusNode();
|
||||
final _montantFocus = FocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_numeroController.dispose();
|
||||
_rueController.dispose();
|
||||
_villeController.dispose();
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_montantController.dispose();
|
||||
|
||||
_numeroFocus.dispose();
|
||||
_rueFocus.dispose();
|
||||
_villeFocus.dispose();
|
||||
_nameFocus.dispose();
|
||||
_emailFocus.dispose();
|
||||
_montantFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Validation du formulaire avec focus automatique sur erreur
|
||||
void _validateForm() {
|
||||
// Form.validate() fait automatiquement :
|
||||
// 1. Valide tous les champs
|
||||
// 2. Affiche les erreurs (bordures rouges)
|
||||
// 3. Met le focus sur le PREMIER champ en erreur
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// Formulaire valide
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Formulaire valide !'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Des erreurs existent - le focus est déjà mis automatiquement
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Veuillez corriger les erreurs'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validation personnalisée pour email
|
||||
String? _validateEmail(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null; // Email optionnel
|
||||
}
|
||||
|
||||
const emailRegex = r'^[^@]+@[^@]+\.[^@]+$';
|
||||
if (!RegExp(emailRegex).hasMatch(value)) {
|
||||
return 'Format email invalide';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validation pour montant
|
||||
String? _validateMontant(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Le montant est obligatoire';
|
||||
}
|
||||
|
||||
final montant = double.tryParse(value.replaceAll(',', '.'));
|
||||
if (montant == null) {
|
||||
return 'Montant invalide';
|
||||
}
|
||||
|
||||
if (montant <= 0) {
|
||||
return 'Le montant doit être supérieur à 0';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Exemple de Validation'),
|
||||
),
|
||||
body: Form( // ← Important : wrapper Form
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Section Adresse
|
||||
FormSection(
|
||||
title: 'Adresse',
|
||||
icon: Icons.location_on,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: CustomTextField(
|
||||
controller: _numeroController,
|
||||
focusNode: _numeroFocus,
|
||||
label: "Numéro",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
textAlign: TextAlign.right,
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Numéro obligatoire';
|
||||
}
|
||||
final numero = int.tryParse(value);
|
||||
if (numero == null || numero <= 0) {
|
||||
return 'Numéro invalide';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
flex: 1,
|
||||
child: CustomTextField(
|
||||
label: "Bis/Ter",
|
||||
showLabel: false,
|
||||
// Pas de validation - champ optionnel
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _rueController,
|
||||
focusNode: _rueFocus,
|
||||
label: "Rue",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'La rue est obligatoire';
|
||||
}
|
||||
if (value.trim().length < 3) {
|
||||
return 'La rue doit contenir au moins 3 caractères';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _villeController,
|
||||
focusNode: _villeFocus,
|
||||
label: "Ville",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'La ville est obligatoire';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section Occupant
|
||||
FormSection(
|
||||
title: 'Occupant',
|
||||
icon: Icons.person,
|
||||
children: [
|
||||
CustomTextField(
|
||||
controller: _nameController,
|
||||
focusNode: _nameFocus,
|
||||
label: "Nom de l'occupant",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Le nom est obligatoire pour les passages effectués';
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
return 'Le nom doit contenir au moins 2 caractères';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _emailController,
|
||||
focusNode: _emailFocus,
|
||||
label: "Email",
|
||||
showLabel: false,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: _validateEmail,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section Règlement
|
||||
FormSection(
|
||||
title: 'Règlement',
|
||||
icon: Icons.euro,
|
||||
children: [
|
||||
CustomTextField(
|
||||
controller: _montantController,
|
||||
focusNode: _montantFocus,
|
||||
label: "Montant (€)",
|
||||
isRequired: true,
|
||||
showLabel: false,
|
||||
hintText: "0.00",
|
||||
textAlign: TextAlign.right,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: _validateMontant,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Boutons de test
|
||||
Column(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: _validateForm,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Valider le formulaire'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(200, 48),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
// Effacer le formulaire
|
||||
_formKey.currentState?.reset();
|
||||
_numeroController.clear();
|
||||
_rueController.clear();
|
||||
_villeController.clear();
|
||||
_nameController.clear();
|
||||
_emailController.clear();
|
||||
_montantController.clear();
|
||||
},
|
||||
icon: const Icon(Icons.clear),
|
||||
label: const Text('Effacer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Info box
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Test de validation',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'• Laissez des champs obligatoires vides et cliquez "Valider"\n'
|
||||
'• Les bordures deviennent rouges automatiquement\n'
|
||||
'• Le focus se met sur le premier champ en erreur\n'
|
||||
'• Les messages d\'erreur s\'affichent sous les champs',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user