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:
pierre
2025-08-07 11:01:45 +02:00
parent 6a609fb467
commit 599b9fcda0
662 changed files with 213221 additions and 174243 deletions

0
app/lib/presentation/MIGRATION.md Normal file → Executable file
View File

231
app/lib/presentation/admin/admin_amicale_page.dart Normal file → Executable file
View 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 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,
),
),

View 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();

View File

57
app/lib/presentation/admin/admin_dashboard_page.dart Normal file → Executable file
View 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],
),
],
);

View 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
View 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

File diff suppressed because it is too large Load Diff

0
app/lib/presentation/admin/admin_operations_page.dart Normal file → Executable file
View File

17
app/lib/presentation/admin/admin_statistics_page.dart Normal file → Executable file
View 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
View 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
View 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
View 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),
),
),
],
),
],
),
),
],

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

View 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
View File

View 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
View File

View 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
View 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
View 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
View 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
View 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
View File

0
app/lib/presentation/widgets/amicale_row_widget.dart Normal file → Executable file
View File

0
app/lib/presentation/widgets/amicale_table_widget.dart Normal file → Executable file
View File

View 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
View File

View 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
View File

View 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(

View 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
View File

0
app/lib/presentation/widgets/charts/payment_data.dart Normal file → Executable file
View File

View 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(

View 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
View 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
View 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
View 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
View 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,

View 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
View File

56
app/lib/presentation/widgets/custom_text_field.dart Normal file → Executable file
View 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
View 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
View 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) {

View File

View 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
View 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
View 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
View 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!,

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

View File

File diff suppressed because it is too large Load Diff

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

View 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
View 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,
),
),

View 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
View 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

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

View 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
View File

0
app/lib/presentation/widgets/user_form_dialog.dart Normal file → Executable file
View File

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