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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
pierre
2025-10-05 20:11:15 +02:00
parent 242a90720e
commit b6584c83fa
1625 changed files with 145669 additions and 51249 deletions

View File

@@ -17,8 +17,13 @@ import 'package:geosector_app/core/services/chat_manager.dart';
import 'package:geosector_app/presentation/auth/splash_page.dart';
import 'package:geosector_app/presentation/auth/login_page.dart';
import 'package:geosector_app/presentation/auth/register_page.dart';
import 'package:geosector_app/presentation/admin/admin_dashboard_page.dart';
import 'package:geosector_app/presentation/user/user_dashboard_page.dart';
import 'package:geosector_app/presentation/pages/history_page.dart';
import 'package:geosector_app/presentation/pages/home_page.dart';
import 'package:geosector_app/presentation/pages/map_page.dart';
import 'package:geosector_app/presentation/pages/messages_page.dart';
import 'package:geosector_app/presentation/pages/amicale_page.dart';
import 'package:geosector_app/presentation/pages/operations_page.dart';
import 'package:geosector_app/presentation/pages/field_mode_page.dart';
// Instances globales des repositories (plus besoin d'injecter ApiService)
final operationRepository = OperationRepository();
@@ -203,21 +208,121 @@ class _GeosectorAppState extends State<GeosectorApp> with WidgetsBindingObserver
return const RegisterPage();
},
),
// NOUVELLE ARCHITECTURE: Pages user avec sous-routes comme admin
GoRoute(
path: '/user',
name: 'user',
builder: (context, state) {
debugPrint('GoRoute: Affichage de UserDashboardPage');
return const UserDashboardPage();
debugPrint('GoRoute: Redirection vers /user/dashboard');
// Rediriger directement vers dashboard au lieu d'utiliser UserDashboardPage
return const HomePage();
},
routes: [
// Sous-route pour le dashboard/home
GoRoute(
path: 'dashboard',
name: 'user-dashboard',
builder: (context, state) {
debugPrint('GoRoute: Affichage de HomePage (unifiée)');
return const HomePage();
},
),
// Sous-route pour l'historique
GoRoute(
path: 'history',
name: 'user-history',
builder: (context, state) {
debugPrint('GoRoute: Affichage de HistoryPage (unifiée)');
return const HistoryPage();
},
),
// Sous-route pour les messages
GoRoute(
path: 'messages',
name: 'user-messages',
builder: (context, state) {
debugPrint('GoRoute: Affichage de MessagesPage (unifiée)');
return const MessagesPage();
},
),
// Sous-route pour la carte
GoRoute(
path: 'map',
name: 'user-map',
builder: (context, state) {
debugPrint('GoRoute: Affichage de MapPage (unifiée)');
return const MapPage();
},
),
// Sous-route pour le mode terrain
GoRoute(
path: 'field-mode',
name: 'user-field-mode',
builder: (context, state) {
debugPrint('GoRoute: Affichage de FieldModePage (unifiée)');
return const FieldModePage();
},
),
],
),
// NOUVELLE ARCHITECTURE: Pages admin autonomes
GoRoute(
path: '/admin',
name: 'admin',
builder: (context, state) {
debugPrint('GoRoute: Affichage de AdminDashboardPage');
return const AdminDashboardPage();
debugPrint('GoRoute: Affichage de HomePage (unifiée)');
return const HomePage();
},
routes: [
// Sous-route pour l'historique avec membre optionnel
GoRoute(
path: 'history',
name: 'admin-history',
builder: (context, state) {
final memberId = state.uri.queryParameters['memberId'];
debugPrint('GoRoute: Affichage de HistoryPage (admin) avec memberId=$memberId');
return HistoryPage(
memberId: memberId != null ? int.tryParse(memberId) : null,
);
},
),
// Sous-route pour la carte
GoRoute(
path: 'map',
name: 'admin-map',
builder: (context, state) {
debugPrint('GoRoute: Affichage de MapPage pour admin');
return const MapPage();
},
),
// Sous-route pour les messages
GoRoute(
path: 'messages',
name: 'admin-messages',
builder: (context, state) {
debugPrint('GoRoute: Affichage de MessagesPage (unifiée)');
return const MessagesPage();
},
),
// Sous-route pour amicale & membres (role 2 uniquement)
GoRoute(
path: 'amicale',
name: 'admin-amicale',
builder: (context, state) {
debugPrint('GoRoute: Affichage de AmicalePage (unifiée)');
return const AmicalePage();
},
),
// Sous-route pour opérations (role 2 uniquement)
GoRoute(
path: 'operations',
name: 'admin-operations',
builder: (context, state) {
debugPrint('GoRoute: Affichage de OperationsPage (unifiée)');
return const OperationsPage();
},
),
],
),
],
redirect: (context, state) {

View File

@@ -26,7 +26,7 @@ class RoomAdapter extends TypeAdapter<Room> {
unreadCount: fields[6] as int,
recentMessages: (fields[7] as List?)
?.map((dynamic e) => (e as Map).cast<String, dynamic>())
?.toList(),
.toList(),
updatedAt: fields[8] as DateTime?,
createdBy: fields[9] as int?,
isSynced: fields[10] as bool,

View File

@@ -47,7 +47,7 @@ class ChatPageState extends State<ChatPage> {
Future<void> _loadInitialMessages() async {
setState(() => _isLoading = true);
print('🚀 ChatPage: Chargement initial des messages pour room ${widget.roomId}');
debugPrint('🚀 ChatPage: Chargement initial des messages pour room ${widget.roomId}');
final result = await _service.getMessages(widget.roomId, isInitialLoad: true);
setState(() {
@@ -225,12 +225,12 @@ class ChatPageState extends State<ChatPage> {
.toList()
..sort((a, b) => a.sentAt.compareTo(b.sentAt));
print('🔍 ChatPage: ${allMessages.length} messages trouvés pour room ${widget.roomId}');
debugPrint('🔍 ChatPage: ${allMessages.length} messages trouvés pour room ${widget.roomId}');
if (allMessages.isEmpty) {
print('📭 Aucun message dans Hive pour cette room');
print('📦 Total messages dans Hive: ${box.length}');
debugPrint('📭 Aucun message dans Hive pour cette room');
debugPrint('📦 Total messages dans Hive: ${box.length}');
final roomIds = box.values.map((m) => m.roomId).toSet();
print('🏠 Rooms dans Hive: $roomIds');
debugPrint('🏠 Rooms dans Hive: $roomIds');
} else {
// Détecter les doublons potentiels
final messageIds = <String>{};
@@ -242,13 +242,13 @@ class ChatPageState extends State<ChatPage> {
messageIds.add(msg.id);
}
if (duplicates.isNotEmpty) {
print('⚠️ DOUBLONS DÉTECTÉS: $duplicates');
debugPrint('⚠️ DOUBLONS DÉTECTÉS: $duplicates');
}
// Afficher les IDs des messages pour débugger
print('📝 Liste des messages:');
debugPrint('📝 Liste des messages:');
for (final msg in allMessages) {
print(' - ${msg.id}: "${msg.content.substring(0, msg.content.length > 20 ? 20 : msg.content.length)}..." (isMe: ${msg.isMe})');
debugPrint(' - ${msg.id}: "${msg.content.substring(0, msg.content.length > 20 ? 20 : msg.content.length)}..." (isMe: ${msg.isMe})');
}
}

View File

@@ -52,106 +52,38 @@ class RoomsPageEmbeddedState extends State<RoomsPageEmbedded> {
// Utiliser la vue split responsive pour toutes les plateformes
return _buildResponsiveSplitView(context);
}
Widget _buildMobileView(BuildContext context) {
final helpText = ChatConfigLoader.instance.getHelpText(_service.currentUserRole);
return ValueListenableBuilder<Box<Room>>(
valueListenable: _service.roomsBox.listenable(),
builder: (context, box, _) {
final rooms = box.values.toList()
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
if (rooms.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Aucune conversation',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
TextButton(
onPressed: widget.onAddPressed ?? createNewConversation,
child: const Text('Démarrer une conversation'),
),
if (helpText.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
helpText,
style: TextStyle(
fontSize: 13,
color: Colors.grey[500],
),
textAlign: TextAlign.center,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
// Pull to refresh = sync complète forcée par l'utilisateur
setState(() => _isLoading = true);
await _service.getRooms(forceFullSync: true);
setState(() => _isLoading = false);
},
child: ListView.builder(
itemCount: rooms.length,
itemBuilder: (context, index) {
final room = rooms[index];
return _RoomTile(
room: room,
currentUserId: _service.currentUserId,
onDelete: () => _handleDeleteRoom(room),
);
},
),
);
},
);
// Méthode publique pour rafraîchir
void refresh() {
_loadRooms();
}
Future<void> createNewConversation() async {
final currentRole = _service.currentUserRole;
final config = ChatConfigLoader.instance.getPossibleRecipientsConfig(currentRole);
// Déterminer si on permet la sélection multiple
// Pour role 1 (membre), permettre la sélection multiple pour contacter plusieurs membres/admins
// Pour role 2 (admin amicale), permettre la sélection multiple pour GEOSECTOR ou Amicale
// Pour role 9 (super admin), permettre la sélection multiple selon config
final allowMultiple = (currentRole == 1) || (currentRole == 2) ||
final allowMultiple = (currentRole == 1) || (currentRole == 2) ||
(currentRole == 9 && config.any((c) => c['allow_selection'] == true));
// Ouvrir le dialog de sélection
final result = await RecipientSelectorDialog.show(
context,
allowMultiple: allowMultiple,
);
if (result != null) {
final recipients = result['recipients'] as List<Map<String, dynamic>>?;
final initialMessage = result['initial_message'] as String?;
final isBroadcast = result['is_broadcast'] as bool? ?? false;
if (recipients != null && recipients.isNotEmpty) {
try {
Room? newRoom;
if (recipients.length == 1) {
// Conversation privée
final recipient = recipients.first;
@@ -159,7 +91,7 @@ class RoomsPageEmbeddedState extends State<RoomsPageEmbedded> {
final firstName = recipient['first_name'] ?? '';
final lastName = recipient['name'] ?? '';
final fullName = '$firstName $lastName'.trim();
newRoom = await _service.createPrivateRoom(
recipientId: recipient['id'],
recipientName: fullName.isNotEmpty ? fullName : 'Sans nom',
@@ -168,16 +100,16 @@ class RoomsPageEmbeddedState extends State<RoomsPageEmbedded> {
initialMessage: initialMessage,
);
} else {
// Conversation de groupe
// Conversation de groupe
final participantIds = recipients.map((r) => r['id'] as int).toList();
// Déterminer le titre en fonction du type de groupe
String title;
if (currentRole == 1) {
// Pour un membre
final hasAdmins = recipients.any((r) => r['role'] == 2);
final hasMembers = recipients.any((r) => r['role'] == 1);
if (hasAdmins && !hasMembers) {
title = 'Administrateurs Amicale';
} else if (recipients.length > 3) {
@@ -197,7 +129,7 @@ class RoomsPageEmbeddedState extends State<RoomsPageEmbedded> {
// Pour un admin d'amicale
final hasSuperAdmins = recipients.any((r) => r['role'] == 9);
final hasMembers = recipients.any((r) => r['role'] == 1);
if (hasSuperAdmins && !hasMembers) {
title = 'Support GEOSECTOR';
} else if (!hasSuperAdmins && hasMembers && recipients.length > 5) {
@@ -231,7 +163,7 @@ class RoomsPageEmbeddedState extends State<RoomsPageEmbedded> {
}).join(', ');
}
}
// Créer la room avec le bon type (broadcast si coché, sinon group)
newRoom = await _service.createRoom(
title: title,
@@ -240,7 +172,7 @@ class RoomsPageEmbeddedState extends State<RoomsPageEmbedded> {
initialMessage: initialMessage,
);
}
if (newRoom != null && mounted) {
// Sur le web, sélectionner la room, sur mobile naviguer
if (kIsWeb) {
@@ -275,11 +207,6 @@ class RoomsPageEmbeddedState extends State<RoomsPageEmbedded> {
}
}
// Méthode publique pour rafraîchir
void refresh() {
_loadRooms();
}
/// Méthode pour créer la vue split responsive
Widget _buildResponsiveSplitView(BuildContext context) {
return ValueListenableBuilder<Box<Room>>(
@@ -621,7 +548,7 @@ class RoomsPageEmbeddedState extends State<RoomsPageEmbedded> {
});
},
onDelete: () {
print('🗑️ Clic suppression: room.createdBy=${room.createdBy}, currentUserId=${_service.currentUserId}');
debugPrint('🗑️ Clic suppression: room.createdBy=${room.createdBy}, currentUserId=${_service.currentUserId}');
_handleDeleteRoom(room);
},
),
@@ -830,7 +757,7 @@ class RoomsPageEmbeddedState extends State<RoomsPageEmbedded> {
/// Supprimer une room
Future<void> _handleDeleteRoom(Room room) async {
print('🚀 _handleDeleteRoom appelée: room.createdBy=${room.createdBy}, currentUserId=${_service.currentUserId}');
debugPrint('🚀 _handleDeleteRoom appelée: room.createdBy=${room.createdBy}, currentUserId=${_service.currentUserId}');
// Vérifier que l'utilisateur est bien le créateur
if (room.createdBy != _service.currentUserId) {
@@ -1328,194 +1255,6 @@ class _QuickBroadcastDialogState extends State<_QuickBroadcastDialog> {
}
}
/// Widget simple pour une tuile de room
class _RoomTile extends StatelessWidget {
final Room room;
final int currentUserId;
final VoidCallback onDelete;
const _RoomTile({
required this.room,
required this.currentUserId,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(color: Colors.grey[200]!),
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: CircleAvatar(
backgroundColor: room.type == 'broadcast'
? Colors.amber.shade600
: const Color(0xFF2563EB),
child: room.type == 'broadcast'
? const Icon(Icons.campaign, color: Colors.white, size: 20)
: Text(
_getInitials(room.title),
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
title: Row(
children: [
Expanded(
child: Text(
room.title,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
overflow: TextOverflow.ellipsis,
),
),
if (room.type == 'broadcast')
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.amber.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'ANNONCE',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.amber.shade800,
),
),
),
],
),
subtitle: room.lastMessage != null
? Text(
room.lastMessage!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.grey[600]),
)
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (room.lastMessageAt != null)
Text(
_formatTime(room.lastMessageAt!),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
if (room.unreadCount > 0)
Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
borderRadius: BorderRadius.circular(10),
),
child: Text(
room.unreadCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
],
),
// Bouton de suppression si l'utilisateur est le créateur
if (room.createdBy == currentUserId) ...[
const SizedBox(width: 8),
IconButton(
icon: Icon(
Icons.delete_outline,
size: 20,
color: Colors.red[400],
),
onPressed: onDelete,
tooltip: 'Supprimer la conversation',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
),
],
],
),
onTap: () {
// Navigation normale car on est dans la vue mobile
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatPage(
roomId: room.id,
roomTitle: room.title,
roomType: room.type,
roomCreatorId: room.createdBy,
),
),
);
},
),
);
}
String _formatTime(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays > 0) {
return '${diff.inDays}j';
} else if (diff.inHours > 0) {
return '${diff.inHours}h';
} else if (diff.inMinutes > 0) {
return '${diff.inMinutes}m';
} else {
return 'Maintenant';
}
}
String _getInitials(String title) {
// Pour les titres spéciaux, retourner des initiales appropriées
if (title == 'Support GEOSECTOR') return 'SG';
if (title == 'Toute l\'Amicale') return 'TA';
if (title == 'Administrateurs Amicale') return 'AA';
// Pour les noms de personnes, extraire les initiales
final words = title.split(' ').where((w) => w.isNotEmpty).toList();
if (words.isEmpty) return '?';
// Si c'est un seul mot, prendre les 2 premières lettres
if (words.length == 1) {
final word = words[0];
return word.length >= 2
? '${word[0]}${word[1]}'.toUpperCase()
: word[0].toUpperCase();
}
// Si c'est prénom + nom, prendre la première lettre de chaque
if (words.length == 2) {
return '${words[0][0]}${words[1][0]}'.toUpperCase();
}
// Pour les groupes avec plusieurs noms, prendre les 2 premières initiales
return '${words[0][0]}${words[1][0]}'.toUpperCase();
}
}
/// Widget spécifique pour les tuiles de room sur le web
class _WebRoomTile extends StatelessWidget {
final Room room;
@@ -1534,7 +1273,7 @@ class _WebRoomTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
print('🔍 _WebRoomTile pour ${room.title}: createdBy=${room.createdBy}, currentUserId=$currentUserId, showDelete=${room.createdBy == currentUserId}');
debugPrint('🔍 _WebRoomTile pour ${room.title}: createdBy=${room.createdBy}, currentUserId=$currentUserId, showDelete=${room.createdBy == currentUserId}');
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:yaml/yaml.dart';
/// Classe pour charger et gérer la configuration du chat depuis chat_config.yaml
@@ -18,7 +19,7 @@ class ChatConfigLoader {
// Vérifier que le contenu n'est pas vide
if (yamlString.isEmpty) {
print('Fichier de configuration chat vide, utilisation de la configuration par défaut');
debugPrint('Fichier de configuration chat vide, utilisation de la configuration par défaut');
_config = _getDefaultConfig();
return;
}
@@ -28,17 +29,17 @@ class ChatConfigLoader {
try {
yamlMap = loadYaml(yamlString);
} catch (parseError) {
print('Erreur de parsing YAML (utilisation de la config par défaut): $parseError');
print('Contenu YAML problématique (premiers 500 caractères): ${yamlString.substring(0, yamlString.length > 500 ? 500 : yamlString.length)}');
debugPrint('Erreur de parsing YAML (utilisation de la config par défaut): $parseError');
debugPrint('Contenu YAML problématique (premiers 500 caractères): ${yamlString.substring(0, yamlString.length > 500 ? 500 : yamlString.length)}');
_config = _getDefaultConfig();
return;
}
// Convertir en Map<String, dynamic>
_config = _convertYamlToMap(yamlMap);
print('Configuration chat chargée avec succès');
debugPrint('Configuration chat chargée avec succès');
} catch (e) {
print('Erreur lors du chargement de la configuration chat: $e');
debugPrint('Erreur lors du chargement de la configuration chat: $e');
// Utiliser une configuration par défaut en cas d'erreur
_config = _getDefaultConfig();
}

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:dio/dio.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:uuid/uuid.dart';
@@ -77,7 +78,7 @@ class ChatService {
// Faire la sync initiale complète au login
await _instance!.getRooms(forceFullSync: true);
print('✅ Sync initiale complète effectuée au login');
debugPrint('✅ Sync initiale complète effectuée au login');
// Démarrer la synchronisation incrémentale périodique
_instance!._startSync();
@@ -106,11 +107,11 @@ class ChatService {
} else if (response.data is Map && response.data['data'] != null) {
return List<Map<String, dynamic>>.from(response.data['data']);
} else {
print('⚠️ Format inattendu pour /chat/recipients: ${response.data.runtimeType}');
debugPrint('⚠️ Format inattendu pour /chat/recipients: ${response.data.runtimeType}');
return [];
}
} catch (e) {
print('⚠️ Erreur getPossibleRecipients: $e');
debugPrint('⚠️ Erreur getPossibleRecipients: $e');
// Fallback sur logique locale selon le rôle
return _getLocalRecipients();
}
@@ -137,7 +138,7 @@ class ChatService {
Future<List<Room>> getRooms({bool forceFullSync = false}) async {
// Vérifier la connectivité
if (!connectivityService.isConnected) {
print('📵 Pas de connexion réseau - utilisation du cache');
debugPrint('📵 Pas de connexion réseau - utilisation du cache');
return _roomsBox.values.toList()
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
@@ -154,13 +155,13 @@ class ChatService {
if (needsFullSync || _lastSyncTimestamp == null) {
// Synchronisation complète
print('🔄 Synchronisation complète des rooms...');
debugPrint('🔄 Synchronisation complète des rooms...');
response = await _dio.get('/chat/rooms');
_lastFullSync = now;
} else {
// Synchronisation incrémentale
final isoTimestamp = _lastSyncTimestamp!.toUtc().toIso8601String();
print('🔄 Synchronisation incrémentale depuis $isoTimestamp');
// debugPrint('🔄 Synchronisation incrémentale depuis $isoTimestamp');
response = await _dio.get('/chat/rooms', queryParameters: {
'updated_after': isoTimestamp,
});
@@ -169,20 +170,20 @@ class ChatService {
// Extraire le timestamp de synchronisation fourni par l'API
if (response.data is Map && response.data['sync_timestamp'] != null) {
_lastSyncTimestamp = DateTime.parse(response.data['sync_timestamp']);
print('⏰ Timestamp de sync reçu de l\'API: $_lastSyncTimestamp');
// debugPrint('⏰ Timestamp de sync reçu de l\'API: $_lastSyncTimestamp');
// Sauvegarder le timestamp pour la prochaine session
await _saveSyncTimestamp();
} else {
// L'API doit toujours retourner un sync_timestamp
print('⚠️ Attention: L\'API n\'a pas retourné de sync_timestamp');
debugPrint('⚠️ Attention: L\'API n\'a pas retourné de sync_timestamp');
// On utilise le timestamp actuel comme fallback mais ce n'est pas idéal
_lastSyncTimestamp = now;
}
// Vérifier s'il y a des changements (pour sync incrémentale)
if (!needsFullSync && response.data is Map && response.data['has_changes'] == false) {
print('✅ Aucun changement depuis la dernière sync');
// debugPrint('✅ Aucun changement depuis la dernière sync');
return _roomsBox.values.toList()
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
@@ -194,7 +195,7 @@ class ChatService {
if (response.data['rooms'] != null) {
roomsData = response.data['rooms'] as List;
final hasChanges = response.data['has_changes'] ?? true;
print('✅ Réponse API: ${roomsData.length} rooms, has_changes: $hasChanges');
debugPrint('✅ Réponse API: ${roomsData.length} rooms, has_changes: $hasChanges');
} else if (response.data['data'] != null) {
roomsData = response.data['data'] as List;
} else {
@@ -221,7 +222,7 @@ class ChatService {
final room = Room.fromJson(json);
rooms.add(room);
} catch (e) {
print('❌ Erreur parsing room: $e');
debugPrint('❌ Erreur parsing room: $e');
}
}
@@ -258,17 +259,17 @@ class ChatService {
// Sauvegarder uniquement si le message n'existe pas déjà
if (!_messagesBox.containsKey(message.id) && message.id.isNotEmpty) {
await _messagesBox.put(message.id, message);
print('📩 Nouveau message ajouté depuis sync: ${message.id} dans room ${room.id}');
debugPrint('📩 Nouveau message ajouté depuis sync: ${message.id} dans room ${room.id}');
} else if (message.id.isEmpty) {
print('⚠️ Message avec ID vide ignoré');
debugPrint('⚠️ Message avec ID vide ignoré');
}
} catch (e) {
print('⚠️ Erreur lors du traitement d\'un message récent: $e');
debugPrint('⚠️ Erreur lors du traitement d\'un message récent: $e');
}
}
}
}
print('💾 Sync complète: ${rooms.length} rooms sauvegardées');
debugPrint('💾 Sync complète: ${rooms.length} rooms sauvegardées');
} else {
// Sync incrémentale : mettre à jour uniquement les changements
for (final room in rooms) {
@@ -288,7 +289,7 @@ class ChatService {
createdBy: room.createdBy ?? existingRoom?.createdBy,
);
print('💾 Sauvegarde room ${roomToSave.title} (${roomToSave.id}): createdBy=${roomToSave.createdBy}');
debugPrint('💾 Sauvegarde room ${roomToSave.title} (${roomToSave.id}): createdBy=${roomToSave.createdBy}');
await _roomsBox.put(roomToSave.id, roomToSave);
// Traiter les messages récents de la room
@@ -299,12 +300,12 @@ class ChatService {
// Sauvegarder uniquement si le message n'existe pas déjà
if (!_messagesBox.containsKey(message.id) && message.id.isNotEmpty) {
await _messagesBox.put(message.id, message);
print('📩 Nouveau message ajouté depuis sync: ${message.id} dans room ${room.id}');
debugPrint('📩 Nouveau message ajouté depuis sync: ${message.id} dans room ${room.id}');
} else if (message.id.isEmpty) {
print('⚠️ Message avec ID vide ignoré');
debugPrint('⚠️ Message avec ID vide ignoré');
}
} catch (e) {
print('⚠️ Erreur lors du traitement d\'un message récent: $e');
debugPrint('⚠️ Erreur lors du traitement d\'un message récent: $e');
}
}
}
@@ -324,9 +325,9 @@ class ChatService {
await _messagesBox.delete(msgId);
}
print('🗑️ Room $roomId supprimée avec ${messagesToDelete.length} messages');
debugPrint('🗑️ Room $roomId supprimée avec ${messagesToDelete.length} messages');
}
print('💾 Sync incrémentale: ${rooms.length} rooms mises à jour, ${deletedRoomIds.length} supprimées');
debugPrint('💾 Sync incrémentale: ${rooms.length} rooms mises à jour, ${deletedRoomIds.length} supprimées');
}
// Mettre à jour les stats globales
@@ -341,7 +342,7 @@ class ChatService {
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
} catch (e) {
print('❌ Erreur sync rooms: $e');
debugPrint('❌ Erreur sync rooms: $e');
// Fallback sur le cache local
return _roomsBox.values.toList()
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
@@ -375,7 +376,7 @@ class ChatService {
// Sauvegarder immédiatement dans Hive
await _roomsBox.put(tempId, tempRoom);
print('💾 Room temporaire sauvée: $tempId');
debugPrint('💾 Room temporaire sauvée: $tempId');
try {
// Vérifier les permissions localement d'abord
@@ -402,7 +403,7 @@ class ChatService {
// Vérifier si la room a été mise en queue (offline)
if (response.data != null && response.data['queued'] == true) {
print('📵 Room mise en file d\'attente pour synchronisation: $tempId');
debugPrint('📵 Room mise en file d\'attente pour synchronisation: $tempId');
return tempRoom; // Retourner la room temporaire
}
@@ -413,7 +414,7 @@ class ChatService {
// Remplacer la room temporaire par la room réelle
await _roomsBox.delete(tempId);
await _roomsBox.put(realRoom.id, realRoom);
print('✅ Room temporaire $tempId remplacée par ${realRoom.id}');
debugPrint('✅ Room temporaire $tempId remplacée par ${realRoom.id}');
return realRoom;
}
@@ -421,7 +422,7 @@ class ChatService {
return tempRoom;
} catch (e) {
print('⚠️ Erreur création room: $e - La room sera synchronisée plus tard');
debugPrint('⚠️ Erreur création room: $e - La room sera synchronisée plus tard');
// La room reste en local avec isSynced = false
return tempRoom;
}
@@ -497,10 +498,10 @@ class ChatService {
unreadRemaining = response.data['unread_count'] ?? 0;
if (markedAsRead > 0) {
print('$markedAsRead messages marqués comme lus automatiquement');
debugPrint('$markedAsRead messages marqués comme lus automatiquement');
}
} else {
print('⚠️ Format inattendu pour les messages: ${response.data.runtimeType}');
debugPrint('⚠️ Format inattendu pour les messages: ${response.data.runtimeType}');
messagesData = [];
}
@@ -508,9 +509,9 @@ class ChatService {
.map((json) => Message.fromJson(json, _currentUserId, roomId: roomId))
.toList();
print('📨 Messages reçus pour room $roomId: ${messages.length}');
debugPrint('📨 Messages reçus pour room $roomId: ${messages.length}');
for (final msg in messages) {
print(' - ${msg.id}: "${msg.content}" de ${msg.senderName} (${msg.senderId}) isMe: ${msg.isMe}');
debugPrint(' - ${msg.id}: "${msg.content}" de ${msg.senderName} (${msg.senderId}) isMe: ${msg.isMe}');
}
// Sauvegarder dans Hive (en limitant à 100 messages par room)
@@ -543,7 +544,7 @@ class ChatService {
'marked_as_read': markedAsRead,
};
} catch (e) {
print('Erreur getMessages: $e');
debugPrint('Erreur getMessages: $e');
// Fallback sur le cache local
final cachedMessages = _messagesBox.values
.where((m) => m.roomId == roomId)
@@ -566,14 +567,14 @@ class ChatService {
// Vérifier si le message n'existe pas déjà
if (!_messagesBox.containsKey(message.id) && message.id.isNotEmpty) {
await _messagesBox.put(message.id, message);
print('💾 Message sauvé: ${message.id} dans room ${message.roomId}');
debugPrint('💾 Message sauvé: ${message.id} dans room ${message.roomId}');
addedCount++;
} else if (_messagesBox.containsKey(message.id)) {
print('⚠️ Message ${message.id} existe déjà, ignoré pour éviter duplication');
debugPrint('⚠️ Message ${message.id} existe déjà, ignoré pour éviter duplication');
}
}
print('📊 Résumé: ${addedCount} nouveaux messages ajoutés sur ${newMessages.length} reçus');
debugPrint('📊 Résumé: ${addedCount} nouveaux messages ajoutés sur ${newMessages.length} reçus');
// Après l'ajout, récupérer TOUS les messages de la room pour le nettoyage
final allRoomMessages = _messagesBox.values
@@ -584,7 +585,7 @@ class ChatService {
// Si on dépasse 100 messages, supprimer les plus anciens
if (allRoomMessages.length > 100) {
final messagesToDelete = allRoomMessages.skip(100).toList();
print('🗑️ Suppression de ${messagesToDelete.length} anciens messages');
debugPrint('🗑️ Suppression de ${messagesToDelete.length} anciens messages');
for (final message in messagesToDelete) {
await _messagesBox.delete(message.id);
}
@@ -610,10 +611,10 @@ class ChatService {
await _messagesBox.delete(msgId);
}
print('✅ Room $roomId supprimée avec succès');
debugPrint('✅ Room $roomId supprimée avec succès');
return true;
} catch (e) {
print('❌ Erreur suppression room: $e');
debugPrint('❌ Erreur suppression room: $e');
throw Exception('Impossible de supprimer la conversation');
}
}
@@ -639,7 +640,7 @@ class ChatService {
// Sauvegarder immédiatement dans Hive pour affichage instantané
await _messagesBox.put(tempId, tempMessage);
print('💾 Message temporaire sauvé: $tempId');
debugPrint('💾 Message temporaire sauvé: $tempId');
// Mettre à jour la room localement pour affichage immédiat
final room = _roomsBox.get(roomId);
@@ -666,7 +667,7 @@ class ChatService {
// Vérifier si le message a été mis en queue (offline)
if (response.data != null && response.data['queued'] == true) {
print('📵 Message mis en file d\'attente pour synchronisation: $tempId');
debugPrint('📵 Message mis en file d\'attente pour synchronisation: $tempId');
return tempMessage; // Retourner le message temporaire
}
@@ -679,12 +680,12 @@ class ChatService {
roomId: roomId
);
print('📨 Message envoyé avec ID réel: ${realMessage.id}');
debugPrint('📨 Message envoyé avec ID réel: ${realMessage.id}');
// Remplacer le message temporaire par le message réel
await _messagesBox.delete(tempId);
await _messagesBox.put(realMessage.id, realMessage);
print('✅ Message temporaire $tempId remplacé par ${realMessage.id}');
debugPrint('✅ Message temporaire $tempId remplacé par ${realMessage.id}');
return realMessage;
}
@@ -693,7 +694,7 @@ class ChatService {
return tempMessage;
} catch (e) {
print('⚠️ Erreur envoi message: $e - Le message sera synchronisé plus tard');
debugPrint('⚠️ Erreur envoi message: $e - Le message sera synchronisé plus tard');
// Le message reste en local avec isSynced = false
return tempMessage;
}
@@ -711,11 +712,11 @@ class ChatService {
final timestamp = metaBox.get('last_sync_timestamp');
if (timestamp != null) {
_lastSyncTimestamp = DateTime.parse(timestamp);
print('📅 Dernier timestamp de sync restauré: $_lastSyncTimestamp');
debugPrint('📅 Dernier timestamp de sync restauré: $_lastSyncTimestamp');
}
}
} catch (e) {
print('⚠️ Impossible de charger le timestamp de sync: $e');
debugPrint('⚠️ Impossible de charger le timestamp de sync: $e');
}
}
@@ -734,7 +735,7 @@ class ChatService {
await metaBox.put('last_sync_timestamp', _lastSyncTimestamp!.toIso8601String());
} catch (e) {
print('⚠️ Impossible de sauvegarder le timestamp de sync: $e');
debugPrint('⚠️ Impossible de sauvegarder le timestamp de sync: $e');
}
}
@@ -744,7 +745,7 @@ class ChatService {
_syncTimer = Timer.periodic(_syncInterval, (_) async {
// Vérifier la connectivité avant de synchroniser
if (!connectivityService.isConnected) {
print('📵 Pas de connexion - sync ignorée');
debugPrint('📵 Pas de connexion - sync ignorée');
return;
}
@@ -753,28 +754,28 @@ class ChatService {
});
// Pas de sync immédiate ici car déjà faite dans init()
print('⏰ Timer de sync incrémentale démarré (toutes les 15 secondes)');
debugPrint('⏰ Timer de sync incrémentale démarré (toutes les 15 secondes)');
}
/// Mettre en pause les synchronisations (app en arrière-plan)
void pauseSyncs() {
_syncTimer?.cancel();
print('⏸️ Timer de sync arrêté (app en arrière-plan)');
debugPrint('⏸️ Timer de sync arrêté (app en arrière-plan)');
}
/// Reprendre les synchronisations (app au premier plan)
void resumeSyncs() {
if (_syncTimer == null || !_syncTimer!.isActive) {
_startSync();
print('▶️ Timer de sync redémarré (app au premier plan)');
debugPrint('▶️ Timer de sync redémarré (app au premier plan)');
// Faire une sync immédiate au retour au premier plan
// pour rattraper les messages manqués
if (connectivityService.isConnected) {
getRooms().then((_) {
print('✅ Sync de rattrapage effectuée');
debugPrint('✅ Sync de rattrapage effectuée');
}).catchError((e) {
print('⚠️ Erreur sync de rattrapage: $e');
debugPrint('⚠️ Erreur sync de rattrapage: $e');
});
}
}

View File

@@ -3,7 +3,7 @@
/// pour faciliter la maintenance et éviter les erreurs de frappe
library;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
import 'package:flutter/material.dart';
class AppKeys {
@@ -30,12 +30,12 @@ class AppKeys {
static const int roleAdmin3 = 9;
// URLs API pour les différents environnements
static const String baseApiUrlDev = 'https://app.geo.dev/api';
static const String baseApiUrlDev = 'https://dapp.geosector.fr/api';
static const String baseApiUrlRec = 'https://rapp.geosector.fr/api';
static const String baseApiUrlProd = 'https://app.geosector.fr/api';
// Identifiants d'application pour les différents environnements
static const String appIdentifierDev = 'app.geo.dev';
static const String appIdentifierDev = 'dapp.geosector.fr';
static const String appIdentifierRec = 'rapp.geosector.fr';
static const String appIdentifierProd = 'app.geosector.fr';
@@ -92,7 +92,7 @@ class AppKeys {
}
} catch (e) {
// En cas d'erreur, utiliser la clé de production par défaut
print('Erreur lors de la détection de l\'environnement: $e');
debugPrint('Erreur lors de la détection de l\'environnement: $e');
}
}
@@ -154,9 +154,9 @@ class AppKeys {
2: {
'titres': 'À finaliser',
'titre': 'À finaliser',
'couleur1': 0xFFFFFFFF, // Blanc
'couleur2': 0xFFF7A278, // Orange (Figma)
'couleur3': 0xFFE65100, // Orange foncé
'couleur1': 0xFFFFDFC2, // Orange très pâle (nbPassages=0)
'couleur2': 0xFFF7A278, // Orange moyen (nbPassages=1)
'couleur3': 0xFFE65100, // Orange foncé (nbPassages>1)
'icon_data': Icons.refresh,
},
3: {

View File

@@ -82,6 +82,9 @@ class AmicaleModel extends HiveObject {
@HiveField(25)
final bool chkUserDeletePass;
@HiveField(26)
final bool chkLotActif;
AmicaleModel({
required this.id,
required this.name,
@@ -109,6 +112,7 @@ class AmicaleModel extends HiveObject {
this.chkUsernameManuel = false,
this.logoBase64,
this.chkUserDeletePass = false,
this.chkLotActif = false,
});
// Factory pour convertir depuis JSON (API)
@@ -145,6 +149,8 @@ class AmicaleModel extends HiveObject {
json['chk_username_manuel'] == 1 || json['chk_username_manuel'] == true;
final bool chkUserDeletePass =
json['chk_user_delete_pass'] == 1 || json['chk_user_delete_pass'] == true;
final bool chkLotActif =
json['chk_lot_actif'] == 1 || json['chk_lot_actif'] == true;
// Traiter le logo si présent
String? logoBase64;
@@ -199,6 +205,7 @@ class AmicaleModel extends HiveObject {
chkUsernameManuel: chkUsernameManuel,
logoBase64: logoBase64,
chkUserDeletePass: chkUserDeletePass,
chkLotActif: chkLotActif,
);
}
@@ -230,6 +237,7 @@ class AmicaleModel extends HiveObject {
'chk_mdp_manuel': chkMdpManuel ? 1 : 0,
'chk_username_manuel': chkUsernameManuel ? 1 : 0,
'chk_user_delete_pass': chkUserDeletePass ? 1 : 0,
'chk_lot_actif': chkLotActif ? 1 : 0,
// Note: logoBase64 n'est pas envoyé via toJson (lecture seule depuis l'API)
};
}
@@ -261,6 +269,7 @@ class AmicaleModel extends HiveObject {
bool? chkUsernameManuel,
String? logoBase64,
bool? chkUserDeletePass,
bool? chkLotActif,
}) {
return AmicaleModel(
id: id,
@@ -289,6 +298,7 @@ class AmicaleModel extends HiveObject {
chkUsernameManuel: chkUsernameManuel ?? this.chkUsernameManuel,
logoBase64: logoBase64 ?? this.logoBase64,
chkUserDeletePass: chkUserDeletePass ?? this.chkUserDeletePass,
chkLotActif: chkLotActif ?? this.chkLotActif,
);
}
}

View File

@@ -43,13 +43,14 @@ class AmicaleModelAdapter extends TypeAdapter<AmicaleModel> {
chkUsernameManuel: fields[23] as bool,
logoBase64: fields[24] as String?,
chkUserDeletePass: fields[25] as bool,
chkLotActif: fields[26] as bool,
);
}
@override
void write(BinaryWriter writer, AmicaleModel obj) {
writer
..writeByte(26)
..writeByte(27)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -101,7 +102,9 @@ class AmicaleModelAdapter extends TypeAdapter<AmicaleModel> {
..writeByte(24)
..write(obj.logoBase64)
..writeByte(25)
..write(obj.chkUserDeletePass);
..write(obj.chkUserDeletePass)
..writeByte(26)
..write(obj.chkLotActif);
}
@override

View File

@@ -92,6 +92,9 @@ class PassageModel extends HiveObject {
@HiveField(28)
bool isSynced;
@HiveField(29)
String? stripePaymentId;
PassageModel({
required this.id,
required this.fkOperation,
@@ -122,6 +125,7 @@ class PassageModel extends HiveObject {
required this.lastSyncedAt,
this.isActive = true,
this.isSynced = false,
this.stripePaymentId,
});
// Factory pour convertir depuis JSON (API)
@@ -192,6 +196,7 @@ class PassageModel extends HiveObject {
lastSyncedAt: DateTime.now(),
isActive: true,
isSynced: true,
stripePaymentId: json['stripe_payment_id']?.toString(),
);
} catch (e) {
debugPrint('❌ Erreur parsing PassageModel: $e');
@@ -229,6 +234,7 @@ class PassageModel extends HiveObject {
'name': name,
'email': email,
'phone': phone,
'stripe_payment_id': stripePaymentId,
};
}
@@ -263,6 +269,7 @@ class PassageModel extends HiveObject {
DateTime? lastSyncedAt,
bool? isActive,
bool? isSynced,
String? stripePaymentId,
}) {
return PassageModel(
id: id ?? this.id,
@@ -294,6 +301,7 @@ class PassageModel extends HiveObject {
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
isActive: isActive ?? this.isActive,
isSynced: isSynced ?? this.isSynced,
stripePaymentId: stripePaymentId ?? this.stripePaymentId,
);
}

View File

@@ -46,13 +46,14 @@ class PassageModelAdapter extends TypeAdapter<PassageModel> {
lastSyncedAt: fields[26] as DateTime,
isActive: fields[27] as bool,
isSynced: fields[28] as bool,
stripePaymentId: fields[29] as String?,
);
}
@override
void write(BinaryWriter writer, PassageModel obj) {
writer
..writeByte(29)
..writeByte(30)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -110,7 +111,9 @@ class PassageModelAdapter extends TypeAdapter<PassageModel> {
..writeByte(27)
..write(obj.isActive)
..writeByte(28)
..write(obj.isSynced);
..write(obj.isSynced)
..writeByte(29)
..write(obj.stripePaymentId);
}
@override

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
@@ -167,6 +166,7 @@ class AmicaleRepository extends ChangeNotifier {
chkMdpManuel: amicale.chkMdpManuel,
chkUsernameManuel: amicale.chkUsernameManuel,
chkUserDeletePass: amicale.chkUserDeletePass,
chkLotActif: amicale.chkLotActif,
createdAt: amicale.createdAt ?? DateTime.now(),
updatedAt: DateTime.now(),
);

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/client_model.dart';

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
@@ -29,9 +28,10 @@ class MembreRepository extends ChangeNotifier {
bool _isLoading = false;
// Méthode pour réinitialiser le cache après modification de la box
void _resetCache() {
// Méthode publique pour réinitialiser le cache (ex: après nettoyage complet)
void resetCache() {
_cachedMembreBox = null;
debugPrint('🔄 Cache MembreRepository réinitialisé');
}
// Getters
@@ -109,14 +109,14 @@ class MembreRepository extends ChangeNotifier {
// Sauvegarder un membre
Future<void> saveMembreBox(MembreModel membre) async {
await _membreBox.put(membre.id, membre);
_resetCache(); // Réinitialiser le cache après modification
resetCache(); // Réinitialiser le cache après modification
notifyListeners();
}
// Supprimer un membre
Future<void> deleteMembreBox(int id) async {
await _membreBox.delete(id);
_resetCache(); // Réinitialiser le cache après modification
resetCache(); // Réinitialiser le cache après modification
notifyListeners();
}
@@ -479,7 +479,7 @@ class MembreRepository extends ChangeNotifier {
}
debugPrint('$count membres traités et stockés');
_resetCache(); // Réinitialiser le cache après traitement des données API
resetCache(); // Réinitialiser le cache après traitement des données API
notifyListeners();
} catch (e) {
debugPrint('Erreur lors du traitement des membres: $e');
@@ -534,7 +534,7 @@ class MembreRepository extends ChangeNotifier {
// Vider la boîte des membres
Future<void> clearMembres() async {
await _membreBox.clear();
_resetCache(); // Réinitialiser le cache après suppression de toutes les données
resetCache(); // Réinitialiser le cache après suppression de toutes les données
notifyListeners();
}
}

View File

@@ -1,9 +1,9 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
class PassageRepository extends ChangeNotifier {
@@ -28,9 +28,10 @@ class PassageRepository extends ChangeNotifier {
return _cachedPassageBox!;
}
// Méthode pour réinitialiser le cache après modification de la box
void _resetCache() {
// Méthode publique pour réinitialiser le cache (ex: après nettoyage complet)
void resetCache() {
_cachedPassageBox = null;
debugPrint('🔄 Cache PassageRepository réinitialisé');
}
// Méthode pour exposer la Box Hive (nécessaire pour ValueListenableBuilder)
@@ -129,7 +130,7 @@ class PassageRepository extends ChangeNotifier {
// Sauvegarder un passage
Future<void> savePassage(PassageModel passage) async {
await _passageBox.put(passage.id, passage);
_resetCache(); // Réinitialiser le cache après modification
resetCache(); // Réinitialiser le cache après modification
notifyListeners();
_notifyPassageStream();
}
@@ -146,7 +147,7 @@ class PassageRepository extends ChangeNotifier {
// Sauvegarder tous les passages en une seule opération
await _passageBox.putAll(passagesMap);
_resetCache(); // Réinitialiser le cache après modification massive
resetCache(); // Réinitialiser le cache après modification massive
notifyListeners();
_notifyPassageStream();
}
@@ -154,7 +155,7 @@ class PassageRepository extends ChangeNotifier {
// Supprimer un passage
Future<void> deletePassage(int id) async {
await _passageBox.delete(id);
_resetCache(); // Réinitialiser le cache après suppression
resetCache(); // Réinitialiser le cache après suppression
notifyListeners();
_notifyPassageStream();
}
@@ -164,7 +165,111 @@ class PassageRepository extends ChangeNotifier {
_passageStreamController?.add(getAllPassages());
}
// Créer un passage via l'API
// Créer un passage via l'API et retourner le passage créé
Future<PassageModel?> createPassageWithReturn(PassageModel passage, {BuildContext? context}) async {
_isLoading = true;
notifyListeners();
try {
// Préparer les données pour l'API
final data = passage.toJson();
// Appeler l'API pour créer le passage
final response = await ApiService.instance.post('/passages', data: data);
// Vérifier si la requête a été mise en file d'attente (mode offline)
if (response.data['queued'] == true) {
// Mode offline : créer localement avec un ID temporaire
final offlinePassage = passage.copyWith(
id: DateTime.now().millisecondsSinceEpoch, // ID temporaire unique
lastSyncedAt: null,
isSynced: false,
);
await savePassage(offlinePassage);
// Afficher le dialog d'information si un contexte est fourni
if (context != null && context.mounted) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Row(
children: [
Icon(Icons.cloud_queue, color: Colors.orange),
SizedBox(width: 12),
Text('Mode hors ligne'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Votre passage a été enregistré localement.'),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade200),
),
child: const Row(
children: [
Icon(Icons.info_outline, size: 16, color: Colors.orange),
SizedBox(width: 8),
Expanded(
child: Text(
'Le passage apparaîtra dans votre liste après synchronisation.',
style: TextStyle(fontSize: 13),
),
),
],
),
),
],
),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(dialogContext).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
child: const Text('Compris'),
),
],
),
);
}
return offlinePassage; // Retourner le passage créé localement
}
// Mode online : traitement normal
if (response.statusCode == 201 || response.statusCode == 200) {
// Récupérer l'ID du nouveau passage depuis la réponse
final passageId = response.data['id'] is String ? int.parse(response.data['id']) : response.data['id'] as int;
// Créer le passage localement avec l'ID retourné par l'API
final newPassage = passage.copyWith(
id: passageId,
lastSyncedAt: DateTime.now(),
isSynced: true,
);
await savePassage(newPassage);
return newPassage; // Retourner le passage créé avec son ID réel
}
return null;
} catch (e) {
debugPrint('Erreur lors de la création du passage: $e');
return null;
} finally {
_isLoading = false;
notifyListeners();
}
}
// Créer un passage via l'API (ancienne méthode pour compatibilité)
Future<bool> createPassage(PassageModel passage, {BuildContext? context}) async {
_isLoading = true;
notifyListeners();
@@ -275,12 +380,16 @@ class PassageRepository extends ChangeNotifier {
// Vérifier si la requête a été mise en file d'attente
if (response.data['queued'] == true) {
// Récupérer l'utilisateur actuel
final currentUserId = CurrentUserService.instance.userId;
// Mode offline : mettre à jour localement et marquer comme non synchronisé
final offlinePassage = passage.copyWith(
fkUser: currentUserId, // Le passage appartient maintenant à l'utilisateur qui l'a modifié
lastSyncedAt: null,
isSynced: false,
);
await savePassage(offlinePassage);
// Afficher un message si un contexte est fourni
@@ -309,8 +418,12 @@ class PassageRepository extends ChangeNotifier {
// Mode online : traitement normal
if (response.statusCode == 200) {
// Mettre à jour le passage localement
// Récupérer l'utilisateur actuel
final currentUserId = CurrentUserService.instance.userId;
// Mettre à jour le passage localement avec le user actuel
final updatedPassage = passage.copyWith(
fkUser: currentUserId, // Le passage appartient maintenant à l'utilisateur qui l'a modifié
lastSyncedAt: DateTime.now(),
isSynced: true,
);
@@ -412,7 +525,7 @@ class PassageRepository extends ChangeNotifier {
}
debugPrint('$count passages traités et stockés');
_resetCache(); // Réinitialiser le cache après traitement des données API
resetCache(); // Réinitialiser le cache après traitement des données API
notifyListeners();
_notifyPassageStream();
} catch (e) {
@@ -505,7 +618,7 @@ class PassageRepository extends ChangeNotifier {
// Vider tous les passages
Future<void> clearAllPassages() async {
await _passageBox.clear();
_resetCache(); // Réinitialiser le cache après suppression de toutes les données
resetCache(); // Réinitialiser le cache après suppression de toutes les données
notifyListeners();
_notifyPassageStream();
}

View File

@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
@@ -29,9 +28,10 @@ class SectorRepository extends ChangeNotifier {
// Constante pour l'ID par défaut
static const int defaultSectorId = 1;
// Méthode pour réinitialiser le cache après modification de la box
void _resetCache() {
// Méthode publique pour réinitialiser le cache (ex: après nettoyage complet)
void resetCache() {
_cachedSectorBox = null;
debugPrint('🔄 Cache SectorRepository réinitialisé');
}
// Récupérer tous les secteurs
@@ -47,14 +47,14 @@ class SectorRepository extends ChangeNotifier {
// Sauvegarder un secteur
Future<void> saveSector(SectorModel sector) async {
await _sectorBox.put(sector.id, sector);
_resetCache(); // Réinitialiser le cache après modification
resetCache(); // Réinitialiser le cache après modification
notifyListeners();
}
// Supprimer un secteur
Future<void> deleteSector(int id) async {
await _sectorBox.delete(id);
_resetCache(); // Réinitialiser le cache après modification
resetCache(); // Réinitialiser le cache après modification
notifyListeners();
}
@@ -67,7 +67,7 @@ class SectorRepository extends ChangeNotifier {
for (final sector in sectors) {
await _sectorBox.put(sector.id, sector);
}
_resetCache(); // Réinitialiser le cache après modification massive
resetCache(); // Réinitialiser le cache après modification massive
notifyListeners();
}
@@ -108,7 +108,7 @@ class SectorRepository extends ChangeNotifier {
}
debugPrint('$count secteurs traités et stockés');
_resetCache(); // Réinitialiser le cache après traitement des données API
resetCache(); // Réinitialiser le cache après traitement des données API
notifyListeners();
} catch (e) {
debugPrint('Erreur lors du traitement des secteurs: $e');

View File

@@ -22,6 +22,7 @@ import 'package:geosector_app/core/models/loading_state.dart';
class UserRepository extends ChangeNotifier {
bool _isLoading = false;
Timer? _refreshTimer;
// Constructeur simplifié - plus d'injection d'ApiService
UserRepository() {
@@ -306,6 +307,12 @@ class UserRepository extends ChangeNotifier {
debugPrint('⚠️ Erreur initialisation chat (non bloquant): $chatError');
}
// Sauvegarder le timestamp de dernière sync après un login réussi
await _saveLastSyncTimestamp(DateTime.now());
// Démarrer le timer de refresh automatique
_startAutoRefreshTimer();
debugPrint('✅ Connexion réussie');
return true;
} catch (e) {
@@ -388,13 +395,16 @@ class UserRepository extends ChangeNotifier {
// Supprimer la session API
setSessionId(null);
// Arrêter le timer de refresh automatique
_stopAutoRefreshTimer();
// Effacer les données via les services singleton
await CurrentUserService.instance.clearUser();
await CurrentAmicaleService.instance.clearAmicale();
// Arrêter le chat (stoppe les syncs)
ChatManager.instance.dispose();
// Réinitialiser les infos chat
ChatInfoService.instance.reset();
@@ -633,6 +643,298 @@ class UserRepository extends ChangeNotifier {
return amicale;
}
// === SYNCHRONISATION ET REFRESH ===
/// Rafraîchir la session (soft login)
/// Utilise un refresh partiel si la dernière sync date de moins de 24h
/// Sinon fait un refresh complet
Future<bool> refreshSession() async {
try {
debugPrint('🔄 Début du refresh de session...');
// Vérifier qu'on a bien une session valide
if (!isLoggedIn || currentUser?.sessionId == null) {
debugPrint('⚠️ Pas de session valide pour le refresh');
return false;
}
// NOUVEAU : Vérifier la connexion internet avant de faire des appels API
final hasConnection = await ApiService.instance.hasInternetConnection();
if (!hasConnection) {
debugPrint('📵 Pas de connexion internet - refresh annulé');
// On maintient la session locale mais on ne fait pas d'appel API
return true; // Retourner true car ce n'est pas une erreur
}
// S'assurer que le timer de refresh automatique est démarré
if (_refreshTimer == null || !_refreshTimer!.isActive) {
_startAutoRefreshTimer();
}
// Récupérer la dernière date de sync depuis settings
DateTime? lastSync;
try {
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
final lastSyncString = settingsBox.get('last_sync') as String?;
if (lastSyncString != null) {
lastSync = DateTime.parse(lastSyncString);
debugPrint('📅 Dernière sync: ${lastSync.toIso8601String()}');
}
}
} catch (e) {
debugPrint('⚠️ Erreur lecture last_sync: $e');
}
// Déterminer si on fait un refresh partiel ou complet
// Refresh partiel si:
// - On a une date de dernière sync
// - Cette date est de moins de 24h
final now = DateTime.now();
final shouldPartialRefresh = lastSync != null &&
now.difference(lastSync).inHours < 24;
if (shouldPartialRefresh) {
debugPrint('⚡ Refresh partiel (dernière sync < 24h)');
try {
// Appel API pour refresh partiel
final response = await ApiService.instance.refreshSessionPartial(lastSync);
if (response.data != null && response.data['status'] == 'success') {
// Traiter uniquement les données modifiées
await _processPartialRefreshData(response.data);
// Mettre à jour last_sync
await _saveLastSyncTimestamp(now);
debugPrint('✅ Refresh partiel réussi');
return true;
}
} catch (e) {
debugPrint('⚠️ Erreur refresh partiel: $e');
// Vérifier si c'est une erreur d'authentification
if (_isAuthenticationError(e)) {
debugPrint('🔒 Erreur d\'authentification détectée - nettoyage de la session locale');
await _clearInvalidSession();
return false;
}
// Sinon, on tente un refresh complet
debugPrint('Tentative de refresh complet...');
}
}
// Refresh complet
debugPrint('🔄 Refresh complet des données...');
try {
final response = await ApiService.instance.refreshSessionAll();
if (response.data != null && response.data['status'] == 'success') {
// Traiter toutes les données comme un login
await DataLoadingService.instance.processLoginData(response.data);
// Mettre à jour last_sync
await _saveLastSyncTimestamp(now);
debugPrint('✅ Refresh complet réussi');
return true;
}
} catch (e) {
debugPrint('❌ Erreur refresh complet: $e');
// Vérifier si c'est une erreur d'authentification
if (_isAuthenticationError(e)) {
debugPrint('🔒 Session invalide côté serveur - nettoyage de la session locale');
await _clearInvalidSession();
}
return false;
}
return false;
} catch (e) {
debugPrint('❌ Erreur générale refresh session: $e');
return false;
}
}
/// Traiter les données d'un refresh partiel
Future<void> _processPartialRefreshData(Map<String, dynamic> data) async {
try {
debugPrint('📦 Traitement des données partielles...');
// Traiter les secteurs modifiés
if (data['sectors'] != null && data['sectors'] is List) {
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
for (final sectorData in data['sectors']) {
final sector = SectorModel.fromJson(sectorData);
await sectorsBox.put(sector.id, sector);
}
debugPrint('${data['sectors'].length} secteurs mis à jour');
}
// Traiter les passages modifiés
if (data['passages'] != null && data['passages'] is List) {
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
for (final passageData in data['passages']) {
final passage = PassageModel.fromJson(passageData);
await passagesBox.put(passage.id, passage);
}
debugPrint('${data['passages'].length} passages mis à jour');
}
// Traiter les opérations modifiées
if (data['operations'] != null && data['operations'] is List) {
final operationsBox = Hive.box<OperationModel>(AppKeys.operationsBoxName);
for (final operationData in data['operations']) {
final operation = OperationModel.fromJson(operationData);
await operationsBox.put(operation.id, operation);
}
debugPrint('${data['operations'].length} opérations mises à jour');
}
// Traiter les membres modifiés
if (data['membres'] != null && data['membres'] is List) {
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
for (final membreData in data['membres']) {
final membre = MembreModel.fromJson(membreData);
await membresBox.put(membre.id, membre);
}
debugPrint('${data['membres'].length} membres mis à jour');
}
} catch (e) {
debugPrint('❌ Erreur traitement données partielles: $e');
rethrow;
}
}
/// Sauvegarder le timestamp de la dernière sync
Future<void> _saveLastSyncTimestamp(DateTime timestamp) async {
try {
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.put('last_sync', timestamp.toIso8601String());
debugPrint('💾 Timestamp last_sync sauvegardé: ${timestamp.toIso8601String()}');
}
} catch (e) {
debugPrint('❌ Erreur sauvegarde last_sync: $e');
}
}
/// Vérifie si l'erreur est une erreur d'authentification (401, 403)
/// Retourne false pour les erreurs 404 (route non trouvée)
bool _isAuthenticationError(dynamic error) {
final errorMessage = error.toString().toLowerCase();
// Si c'est une erreur 404, ce n'est pas une erreur d'authentification
// C'est juste que la route n'existe pas encore côté API
if (errorMessage.contains('404') || errorMessage.contains('not found')) {
debugPrint('⚠️ Route API non trouvée (404) - en attente de l\'implémentation côté serveur');
return false;
}
// Vérifier les vraies erreurs d'authentification
return errorMessage.contains('401') ||
errorMessage.contains('403') ||
errorMessage.contains('unauthorized') ||
errorMessage.contains('forbidden') ||
errorMessage.contains('session expired') ||
errorMessage.contains('authentication failed');
}
/// Nettoie la session locale invalide
Future<void> _clearInvalidSession() async {
try {
debugPrint('🗑️ Nettoyage de la session invalide...');
// Arrêter le timer de refresh
_stopAutoRefreshTimer();
// Nettoyer les données de session
await CurrentUserService.instance.clearUser();
await CurrentAmicaleService.instance.clearAmicale();
// Nettoyer les IDs dans settings
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.delete('current_user_id');
await settingsBox.delete('current_amicale_id');
await settingsBox.delete('last_sync');
}
// Supprimer le sessionId de l'API
ApiService.instance.setSessionId(null);
debugPrint('✅ Session locale nettoyée suite à erreur d\'authentification');
} catch (e) {
debugPrint('❌ Erreur lors du nettoyage de session: $e');
}
}
// === TIMER DE REFRESH AUTOMATIQUE ===
/// Démarre le timer de refresh automatique (toutes les 30 minutes)
void _startAutoRefreshTimer() {
// Arrêter le timer existant s'il y en a un
_stopAutoRefreshTimer();
// Démarrer un nouveau timer qui se déclenche toutes les 30 minutes
_refreshTimer = Timer.periodic(const Duration(minutes: 30), (timer) async {
if (isLoggedIn) {
debugPrint('⏰ Refresh automatique déclenché (30 minutes)');
// Vérifier la connexion avant de tenter le refresh
final hasConnection = await ApiService.instance.hasInternetConnection();
if (!hasConnection) {
debugPrint('📵 Refresh automatique annulé - pas de connexion');
return;
}
// Appel silencieux du refresh - on ne veut pas spammer les logs
try {
await refreshSession();
} catch (e) {
// Si c'est une erreur 404, on ignore silencieusement
if (e.toString().toLowerCase().contains('404')) {
debugPrint(' Refresh automatique ignoré (routes non disponibles)');
} else {
debugPrint('⚠️ Erreur refresh automatique: $e');
}
}
} else {
// Si l'utilisateur n'est plus connecté, arrêter le timer
_stopAutoRefreshTimer();
}
});
debugPrint('⏰ Timer de refresh automatique démarré (interval: 30 minutes)');
}
/// Arrête le timer de refresh automatique
void _stopAutoRefreshTimer() {
if (_refreshTimer != null && _refreshTimer!.isActive) {
_refreshTimer!.cancel();
_refreshTimer = null;
debugPrint('⏰ Timer de refresh automatique arrêté');
}
}
/// Déclenche manuellement un refresh (peut être appelé depuis l'UI)
Future<void> triggerManualRefresh() async {
debugPrint('🔄 Refresh manuel déclenché par l\'utilisateur');
await refreshSession();
}
@override
void dispose() {
_stopAutoRefreshTimer();
super.dispose();
}
// === SYNCHRONISATION ===
/// Synchroniser un utilisateur spécifique avec le serveur

View File

@@ -13,6 +13,7 @@ import 'package:geosector_app/core/services/connectivity_service.dart';
import 'package:geosector_app/core/data/models/pending_request.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:uuid/uuid.dart';
import 'device_info_service.dart';
class ApiService {
static ApiService? _instance;
@@ -150,7 +151,7 @@ class ApiService {
final currentUrl = html.window.location.href.toLowerCase();
if (currentUrl.contains('app.geo.dev')) {
if (currentUrl.contains('dapp.geosector.fr')) {
return 'DEV';
} else if (currentUrl.contains('rapp.geosector.fr')) {
return 'REC';
@@ -208,7 +209,7 @@ class ApiService {
}
// Fallback sur la vérification directe
final connectivityResult = await (Connectivity().checkConnectivity());
return connectivityResult.contains(ConnectivityResult.none) == false;
return connectivityResult != ConnectivityResult.none;
}
// Met une requête en file d'attente pour envoi ultérieur
@@ -1046,6 +1047,15 @@ class ApiService {
final sessionId = data['session_id'];
if (sessionId != null) {
setSessionId(sessionId);
// Collecter et envoyer les informations du device après login réussi
debugPrint('📱 Collecte des informations device après login...');
DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) {
debugPrint('✅ Informations device collectées et envoyées');
}).catchError((error) {
debugPrint('⚠️ Erreur lors de l\'envoi des infos device: $error');
// Ne pas bloquer le login si l'envoi des infos device échoue
});
}
}
@@ -1058,6 +1068,71 @@ class ApiService {
}
}
// === MÉTHODES DE REFRESH DE SESSION ===
/// Rafraîchit toutes les données de session (pour F5, démarrage)
/// Retourne les mêmes données qu'un login normal
Future<Response> refreshSessionAll() async {
try {
debugPrint('🔄 Refresh complet de session');
// Vérifier qu'on a bien un token/session
if (_sessionId == null) {
throw ApiException('Pas de session active pour le refresh');
}
final response = await post('/session/refresh/all');
// Traiter la réponse comme un login
final data = response.data as Map<String, dynamic>?;
if (data != null && data['status'] == 'success') {
// Si nouveau session_id dans la réponse, le mettre à jour
if (data.containsKey('session_id')) {
final newSessionId = data['session_id'];
if (newSessionId != null) {
setSessionId(newSessionId);
}
}
// Collecter et envoyer les informations du device après refresh réussi
debugPrint('📱 Collecte des informations device après refresh de session...');
DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) {
debugPrint('✅ Informations device collectées et envoyées (refresh)');
}).catchError((error) {
debugPrint('⚠️ Erreur lors de l\'envoi des infos device (refresh): $error');
// Ne pas bloquer le refresh si l'envoi des infos device échoue
});
}
return response;
} catch (e) {
debugPrint('❌ Erreur refresh complet: $e');
rethrow;
}
}
/// Rafraîchit partiellement les données modifiées depuis lastSync
/// Ne retourne que les données modifiées (delta)
Future<Response> refreshSessionPartial(DateTime lastSync) async {
try {
debugPrint('🔄 Refresh partiel depuis: ${lastSync.toIso8601String()}');
// Vérifier qu'on a bien un token/session
if (_sessionId == null) {
throw ApiException('Pas de session active pour le refresh');
}
final response = await post('/session/refresh/partial', data: {
'last_sync': lastSync.toIso8601String(),
});
return response;
} catch (e) {
debugPrint('❌ Erreur refresh partiel: $e');
rethrow;
}
}
// Déconnexion
Future<void> logout() async {
try {
@@ -1199,7 +1274,7 @@ class ApiService {
final blob = html.Blob([bytes]);
final url = html.Url.createObjectUrlFromBlob(blob);
final anchor = html.AnchorElement(href: url)
html.AnchorElement(href: url)
..setAttribute('download', fileName)
..click();

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:geosector_app/chat/chat_module.dart';
import 'package:geosector_app/chat/services/chat_service.dart';
import 'package:geosector_app/core/services/api_service.dart';
@@ -22,7 +23,7 @@ class ChatManager {
/// Cette méthode est idempotente - peut être appelée plusieurs fois sans effet
Future<void> initializeChat() async {
if (_isInitialized) {
print('⚠️ Chat déjà initialisé - ignoré');
debugPrint('⚠️ Chat déjà initialisé - ignoré');
return;
}
@@ -33,11 +34,11 @@ class ChatManager {
final currentAmicale = CurrentAmicaleService.instance.currentAmicale;
if (currentUser.currentUser == null) {
print('❌ Impossible d\'initialiser le chat - utilisateur non connecté');
debugPrint('❌ Impossible d\'initialiser le chat - utilisateur non connecté');
return;
}
print('🔄 Initialisation du chat pour ${currentUser.userName}...');
debugPrint('🔄 Initialisation du chat pour ${currentUser.userName}...');
// Initialiser le module chat
await ChatModule.init(
@@ -50,9 +51,9 @@ class ChatManager {
);
_isInitialized = true;
print('✅ Chat initialisé avec succès - syncs démarrées toutes les 15 secondes');
debugPrint('✅ Chat initialisé avec succès - syncs démarrées toutes les 15 secondes');
} catch (e) {
print('❌ Erreur initialisation chat: $e');
debugPrint('❌ Erreur initialisation chat: $e');
// Ne pas propager l'erreur pour ne pas bloquer l'app
// Le chat sera simplement indisponible
_isInitialized = false;
@@ -61,7 +62,7 @@ class ChatManager {
/// Réinitialiser le chat (utile après changement d'amicale ou reconnexion)
Future<void> reinitialize() async {
print('🔄 Réinitialisation du chat...');
debugPrint('🔄 Réinitialisation du chat...');
dispose();
await Future.delayed(const Duration(milliseconds: 100));
await initializeChat();
@@ -75,9 +76,9 @@ class ChatManager {
ChatModule.cleanup(); // Reset le flag _isInitialized dans ChatModule
_isInitialized = false;
_isPaused = false;
print('🛑 Chat arrêté - syncs stoppées et module réinitialisé');
debugPrint('🛑 Chat arrêté - syncs stoppées et module réinitialisé');
} catch (e) {
print('⚠️ Erreur lors de l\'arrêt du chat: $e');
debugPrint('⚠️ Erreur lors de l\'arrêt du chat: $e');
}
}
}
@@ -88,9 +89,9 @@ class ChatManager {
try {
ChatService.instance.pauseSyncs();
_isPaused = true;
print('⏸️ Syncs chat mises en pause');
debugPrint('⏸️ Syncs chat mises en pause');
} catch (e) {
print('⚠️ Erreur lors de la pause du chat: $e');
debugPrint('⚠️ Erreur lors de la pause du chat: $e');
}
}
}
@@ -101,9 +102,9 @@ class ChatManager {
try {
ChatService.instance.resumeSyncs();
_isPaused = false;
print('▶️ Syncs chat reprises');
debugPrint('▶️ Syncs chat reprises');
} catch (e) {
print('⚠️ Erreur lors de la reprise du chat: $e');
debugPrint('⚠️ Erreur lors de la reprise du chat: $e');
}
}
}
@@ -115,14 +116,14 @@ class ChatManager {
// Vérifier que l'utilisateur est toujours connecté
final currentUser = CurrentUserService.instance;
if (currentUser.currentUser == null) {
print('⚠️ Chat initialisé mais utilisateur déconnecté');
debugPrint('⚠️ Chat initialisé mais utilisateur déconnecté');
dispose();
return false;
}
// Ne pas considérer comme prêt si en pause
if (_isPaused) {
print('⚠️ Chat en pause');
debugPrint('⚠️ Chat en pause');
return false;
}

View File

@@ -6,7 +6,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
/// Service qui gère la surveillance de l'état de connectivité de l'appareil
class ConnectivityService extends ChangeNotifier {
final Connectivity _connectivity = Connectivity();
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription;
late StreamSubscription<ConnectivityResult> _connectivitySubscription;
List<ConnectivityResult> _connectionStatus = [ConnectivityResult.none];
bool _isInitialized = false;
@@ -86,11 +86,14 @@ class ConnectivityService extends ChangeNotifier {
if (kIsWeb) {
_connectionStatus = [ConnectivityResult.wifi]; // Valeur par défaut pour le web
} else {
_connectionStatus = await _connectivity.checkConnectivity();
final result = await _connectivity.checkConnectivity();
_connectionStatus = [result];
}
// S'abonner aux changements de connectivité
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
_connectivitySubscription = _connectivity.onConnectivityChanged.listen((ConnectivityResult result) {
_updateConnectionStatus([result]);
});
_isInitialized = true;
} catch (e) {
debugPrint('Erreur lors de l\'initialisation du service de connectivité: $e');
@@ -142,7 +145,8 @@ class ConnectivityService extends ChangeNotifier {
return results;
} else {
// Version mobile - utiliser l'API standard
final results = await _connectivity.checkConnectivity();
final result = await _connectivity.checkConnectivity();
final results = [result];
_updateConnectionStatus(results);
return results;
}

View File

@@ -98,9 +98,17 @@ class CurrentAmicaleService extends ChangeNotifier {
Future<void> _saveToHive() async {
try {
if (_currentAmicale != null) {
// Sauvegarder l'amicale dans sa box
final box = Hive.box<AmicaleModel>(AppKeys.amicaleBoxName);
await box.clear();
await box.put('current_amicale', _currentAmicale!);
await box.put(_currentAmicale!.id, _currentAmicale!);
// Sauvegarder l'ID dans settings pour la restauration de session
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.put('current_amicale_id', _currentAmicale!.id);
debugPrint('💾 ID amicale ${_currentAmicale!.id} sauvegardé dans settings');
}
debugPrint('💾 Amicale sauvegardée dans Hive');
}
} catch (e) {
@@ -110,9 +118,20 @@ class CurrentAmicaleService extends ChangeNotifier {
Future<void> _clearFromHive() async {
try {
final box = Hive.box<AmicaleModel>(AppKeys.amicaleBoxName);
await box.clear();
debugPrint('🗑️ Box amicale effacée');
// Effacer l'amicale de la box
if (_currentAmicale != null) {
final box = Hive.box<AmicaleModel>(AppKeys.amicaleBoxName);
await box.delete(_currentAmicale!.id);
}
// Effacer l'ID des settings
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.delete('current_amicale_id');
debugPrint('🗑️ ID amicale effacé des settings');
}
debugPrint('🗑️ Amicale effacée de Hive');
} catch (e) {
debugPrint('❌ Erreur effacement amicale Hive: $e');
}

View File

@@ -12,6 +12,10 @@ class CurrentUserService extends ChangeNotifier {
UserModel? _currentUser;
/// Mode d'affichage : 'admin' ou 'user'
/// Un admin (fkRole>=2) peut choisir de se connecter en mode 'user'
String _displayMode = 'user';
// === GETTERS ===
UserModel? get currentUser => _currentUser;
bool get isLoggedIn => _currentUser?.hasValidSession ?? false;
@@ -25,12 +29,25 @@ class CurrentUserService extends ChangeNotifier {
String? get userPhone => _currentUser?.phone;
String? get userMobile => _currentUser?.mobile;
// Vérifications de rôles
/// Mode d'affichage actuel
String get displayMode => _displayMode;
// Vérifications de rôles (basées sur le rôle RÉEL)
bool get isUser => userRole == 1;
bool get isAdminAmicale => userRole == 2;
bool get isSuperAdmin => userRole >= 3;
bool get canAccessAdmin => isAdminAmicale || isSuperAdmin;
/// Est-ce que l'utilisateur doit voir l'interface admin ?
/// Prend en compte le mode d'affichage choisi à la connexion
bool get shouldShowAdminUI {
// Si mode user, toujours afficher UI user
if (_displayMode == 'user') return false;
// Si mode admin, vérifier le rôle réel
return canAccessAdmin;
}
// === SETTERS ===
Future<void> setUser(UserModel? user) async {
_currentUser = user;
@@ -58,17 +75,40 @@ class CurrentUserService extends ChangeNotifier {
final userEmail = _currentUser?.email;
_currentUser = null;
await _clearFromHive();
await _clearDisplayMode(); // Effacer aussi le mode d'affichage
notifyListeners();
debugPrint('👤 Utilisateur effacé: $userEmail');
}
/// Définir le mode d'affichage (à appeler lors de la connexion)
/// @param mode 'admin' ou 'user'
Future<void> setDisplayMode(String mode) async {
if (mode != 'admin' && mode != 'user') {
debugPrint('⚠️ Mode d\'affichage invalide: $mode (attendu: admin ou user)');
return;
}
_displayMode = mode;
await _saveDisplayMode();
notifyListeners();
debugPrint('🎨 Mode d\'affichage défini: $_displayMode');
}
// === PERSISTENCE HIVE (nouvelle Box user) ===
Future<void> _saveToHive() async {
try {
if (_currentUser != null) {
final box = Hive.box<UserModel>(AppKeys.userBoxName); // Nouvelle Box
await box.clear();
await box.put('current_user', _currentUser!);
// Sauvegarder l'utilisateur dans sa box
final box = Hive.box<UserModel>(AppKeys.userBoxName);
await box.put(_currentUser!.id, _currentUser!);
// Sauvegarder l'ID dans settings pour la restauration de session
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.put('current_user_id', _currentUser!.id);
debugPrint('💾 ID utilisateur ${_currentUser!.id} sauvegardé dans settings');
}
debugPrint('💾 Utilisateur sauvegardé dans Box user');
}
} catch (e) {
@@ -78,9 +118,20 @@ class CurrentUserService extends ChangeNotifier {
Future<void> _clearFromHive() async {
try {
final box = Hive.box<UserModel>(AppKeys.userBoxName); // Nouvelle Box
await box.clear();
debugPrint('🗑️ Box user effacée');
// Effacer l'utilisateur de la box
if (_currentUser != null) {
final box = Hive.box<UserModel>(AppKeys.userBoxName);
await box.delete(_currentUser!.id);
}
// Effacer l'ID des settings
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.delete('current_user_id');
debugPrint('🗑️ ID utilisateur effacé des settings');
}
debugPrint('🗑️ Utilisateur effacé de Hive');
} catch (e) {
debugPrint('❌ Erreur effacement utilisateur Hive: $e');
}
@@ -94,6 +145,9 @@ class CurrentUserService extends ChangeNotifier {
if (user?.hasValidSession == true) {
_currentUser = user;
debugPrint('📥 Utilisateur chargé depuis Hive: ${user?.email}');
// Charger le mode d'affichage sauvegardé lors de la connexion
await _loadDisplayMode();
} else {
_currentUser = null;
debugPrint(' Aucun utilisateur valide trouvé dans Hive');
@@ -106,6 +160,46 @@ class CurrentUserService extends ChangeNotifier {
}
}
// === PERSISTENCE DU MODE D'AFFICHAGE ===
Future<void> _saveDisplayMode() async {
try {
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.put('display_mode', _displayMode);
debugPrint('💾 Mode d\'affichage sauvegardé: $_displayMode');
}
} catch (e) {
debugPrint('❌ Erreur sauvegarde mode d\'affichage: $e');
}
}
Future<void> _loadDisplayMode() async {
try {
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
final savedMode = settingsBox.get('display_mode', defaultValue: 'user') as String;
_displayMode = (savedMode == 'admin' || savedMode == 'user') ? savedMode : 'user';
debugPrint('📥 Mode d\'affichage chargé: $_displayMode');
}
} catch (e) {
debugPrint('❌ Erreur chargement mode d\'affichage: $e');
_displayMode = 'user';
}
}
Future<void> _clearDisplayMode() async {
try {
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.delete('display_mode');
_displayMode = 'user'; // Reset au mode par défaut
debugPrint('🗑️ Mode d\'affichage effacé');
}
} catch (e) {
debugPrint('❌ Erreur effacement mode d\'affichage: $e');
}
}
// === MÉTHODES UTILITAIRES ===
Future<void> updateLastPath(String path) async {
if (_currentUser != null) {
@@ -117,7 +211,7 @@ class CurrentUserService extends ChangeNotifier {
String getDefaultRoute() {
if (!isLoggedIn) return '/';
return canAccessAdmin ? '/admin' : '/user';
return shouldShowAdminUI ? '/admin' : '/user';
}
String getRoleLabel() {

View File

@@ -0,0 +1,420 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:battery_plus/battery_plus.dart';
import 'package:nfc_manager/nfc_manager.dart';
import 'package:network_info_plus/network_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:hive/hive.dart';
import 'api_service.dart';
import 'current_user_service.dart';
import '../constants/app_keys.dart';
class DeviceInfoService {
static final DeviceInfoService instance = DeviceInfoService._internal();
DeviceInfoService._internal();
final DeviceInfoPlugin _deviceInfo = DeviceInfoPlugin();
final Battery _battery = Battery();
final NetworkInfo _networkInfo = NetworkInfo();
Future<Map<String, dynamic>> collectDeviceInfo() async {
final deviceData = <String, dynamic>{};
try {
// Informations réseau et IP (IPv4 uniquement)
deviceData['device_ip_local'] = await _getLocalIpAddress();
deviceData['device_ip_public'] = await _getPublicIpAddress();
deviceData['device_wifi_name'] = await _networkInfo.getWifiName();
deviceData['device_wifi_bssid'] = await _networkInfo.getWifiBSSID();
// Informations batterie
final batteryLevel = await _battery.batteryLevel;
final batteryState = await _battery.batteryState;
deviceData['battery_level'] = batteryLevel; // Pourcentage 0-100
deviceData['battery_charging'] = batteryState == BatteryState.charging;
deviceData['battery_state'] = batteryState.toString().split('.').last;
// Informations plateforme
if (Platform.isIOS) {
final iosInfo = await _deviceInfo.iosInfo;
deviceData['platform'] = 'iOS';
deviceData['device_model'] = iosInfo.model;
deviceData['device_name'] = iosInfo.name;
deviceData['ios_version'] = iosInfo.systemVersion;
deviceData['device_manufacturer'] = 'Apple';
deviceData['device_identifier'] = iosInfo.utsname.machine;
deviceData['device_supports_tap_to_pay'] = _checkIosTapToPaySupport(
iosInfo.utsname.machine,
iosInfo.systemVersion
);
} else if (Platform.isAndroid) {
final androidInfo = await _deviceInfo.androidInfo;
deviceData['platform'] = 'Android';
deviceData['device_model'] = androidInfo.model;
deviceData['device_name'] = androidInfo.device;
deviceData['android_version'] = androidInfo.version.release;
deviceData['android_sdk_version'] = androidInfo.version.sdkInt;
deviceData['device_manufacturer'] = androidInfo.manufacturer;
deviceData['device_brand'] = androidInfo.brand;
deviceData['device_supports_tap_to_pay'] = androidInfo.version.sdkInt >= 28;
} else if (kIsWeb) {
deviceData['platform'] = 'Web';
deviceData['device_supports_tap_to_pay'] = false;
deviceData['battery_level'] = null;
deviceData['battery_charging'] = null;
deviceData['battery_state'] = null;
}
// Vérification NFC
if (!kIsWeb) {
try {
deviceData['device_nfc_capable'] = await NfcManager.instance.isAvailable();
} catch (e) {
deviceData['device_nfc_capable'] = false;
debugPrint('NFC check failed: $e');
}
} else {
deviceData['device_nfc_capable'] = false;
}
// Vérification de la certification Stripe Tap to Pay
if (!kIsWeb) {
try {
deviceData['device_stripe_certified'] = await checkStripeCertification();
debugPrint('📱 Certification Stripe: ${deviceData['device_stripe_certified']}');
} catch (e) {
deviceData['device_stripe_certified'] = false;
debugPrint('❌ Erreur vérification certification Stripe: $e');
}
} else {
deviceData['device_stripe_certified'] = false;
}
// Timestamp de la collecte
deviceData['last_device_info_check'] = DateTime.now().toIso8601String();
} catch (e) {
debugPrint('Error collecting device info: $e');
deviceData['platform'] = kIsWeb ? 'Web' : (Platform.isIOS ? 'iOS' : 'Android');
deviceData['device_supports_tap_to_pay'] = false;
deviceData['device_nfc_capable'] = false;
deviceData['device_stripe_certified'] = false;
}
return deviceData;
}
/// Récupère l'adresse IP locale du device (IPv4 uniquement)
Future<String?> _getLocalIpAddress() async {
try {
if (kIsWeb) {
// Sur Web, impossible d'obtenir l'IP locale pour des raisons de sécurité
return null;
}
// Méthode 1 : Via network_info_plus (retourne généralement IPv4)
String? wifiIP = await _networkInfo.getWifiIP();
if (wifiIP != null && wifiIP.isNotEmpty && _isIPv4(wifiIP)) {
return wifiIP;
}
// Méthode 2 : Via NetworkInterface avec filtre IPv4 strict
for (var interface in await NetworkInterface.list()) {
for (var addr in interface.addresses) {
// Vérifier explicitement IPv4 et non loopback
if (addr.type == InternetAddressType.IPv4 &&
!addr.isLoopback &&
_isIPv4(addr.address)) {
return addr.address;
}
}
}
return null;
} catch (e) {
debugPrint('Error getting local IPv4: $e');
return null;
}
}
/// Récupère l'adresse IP publique IPv4 via un service externe
Future<String?> _getPublicIpAddress() async {
try {
// Services qui retournent l'IPv4
final services = [
'https://api.ipify.org?format=json', // Supporte IPv4 explicitement
'https://ipv4.icanhazip.com', // Force IPv4
'https://v4.ident.me', // Force IPv4
'https://api4.ipify.org', // API IPv4 dédiée
];
final dio = Dio();
dio.options.connectTimeout = const Duration(seconds: 5);
dio.options.receiveTimeout = const Duration(seconds: 5);
for (final service in services) {
try {
final response = await dio.get(service);
String? ipAddress;
// Gérer différents formats de réponse
if (response.data is Map) {
ipAddress = response.data['ip']?.toString();
} else if (response.data is String) {
ipAddress = response.data.trim();
}
// Vérifier que c'est bien une IPv4
if (ipAddress != null && _isIPv4(ipAddress)) {
return ipAddress;
}
} catch (e) {
// Essayer le service suivant
continue;
}
}
return null;
} catch (e) {
debugPrint('Error getting public IPv4: $e');
return null;
}
}
/// Vérifie si une adresse est bien au format IPv4
bool _isIPv4(String address) {
// Pattern pour IPv4 : 4 groupes de 1-3 chiffres séparés par des points
final ipv4Regex = RegExp(
r'^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$'
);
if (!ipv4Regex.hasMatch(address)) {
return false;
}
// Vérifier que chaque octet est entre 0 et 255
final parts = address.split('.');
for (final part in parts) {
final num = int.tryParse(part);
if (num == null || num < 0 || num > 255) {
return false;
}
}
// Exclure les IPv6 (contiennent ':')
if (address.contains(':')) {
return false;
}
return true;
}
bool _checkIosTapToPaySupport(String machine, String systemVersion) {
// iPhone XS et plus récents (liste des identifiants)
final supportedDevices = [
'iPhone11,', // XS, XS Max
'iPhone12,', // 11, 11 Pro, 11 Pro Max
'iPhone13,', // 12 series
'iPhone14,', // 13 series
'iPhone15,', // 14 series
'iPhone16,', // 15 series
];
// Vérifier le modèle
bool deviceSupported = supportedDevices.any((prefix) => machine.startsWith(prefix));
// Vérifier la version iOS (16.4+ selon la documentation officielle Stripe)
final versionParts = systemVersion.split('.');
if (versionParts.isNotEmpty) {
final majorVersion = int.tryParse(versionParts[0]) ?? 0;
final minorVersion = versionParts.length > 1 ? int.tryParse(versionParts[1]) ?? 0 : 0;
// iOS 16.4 minimum selon Stripe docs
return deviceSupported && (majorVersion > 16 || (majorVersion == 16 && minorVersion >= 4));
}
return false;
}
/// Collecte et envoie les informations device à l'API
Future<bool> collectAndSendDeviceInfo() async {
try {
// 1. Collecter les infos device
final deviceData = await collectDeviceInfo();
// 2. Ajouter les infos de l'app
final packageInfo = await PackageInfo.fromPlatform();
deviceData['app_version'] = packageInfo.version;
deviceData['app_build'] = packageInfo.buildNumber;
// 3. Sauvegarder dans Hive Settings
await _saveToHiveSettings(deviceData);
// 4. Envoyer à l'API si l'utilisateur est connecté
if (CurrentUserService.instance.isLoggedIn) {
await _sendDeviceInfoToApi(deviceData);
}
return true;
} catch (e) {
debugPrint('Error collecting/sending device info: $e');
return false;
}
}
/// Sauvegarde les infos dans la box Settings
Future<void> _saveToHiveSettings(Map<String, dynamic> deviceData) async {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
// Sauvegarder chaque info dans la box settings
for (final entry in deviceData.entries) {
await settingsBox.put('device_${entry.key}', entry.value);
}
// Sauvegarder aussi l'IP pour un accès rapide
if (deviceData['device_ip_public'] != null) {
await settingsBox.put('last_known_public_ip', deviceData['device_ip_public']);
}
if (deviceData['device_ip_local'] != null) {
await settingsBox.put('last_known_local_ip', deviceData['device_ip_local']);
}
debugPrint('Device info saved to Hive Settings');
}
/// Envoie les infos device à l'API
Future<void> _sendDeviceInfoToApi(Map<String, dynamic> deviceData) async {
try {
// Nettoyer le payload (enlever les nulls)
final payload = <String, dynamic>{};
deviceData.forEach((key, value) {
if (value != null) {
payload[key] = value;
}
});
// Envoyer à l'API
final response = await ApiService.instance.post(
'/users/device-info',
data: payload,
);
if (response.statusCode == 200 || response.statusCode == 201) {
debugPrint('Device info sent to API successfully');
}
} catch (e) {
// Ne pas bloquer si l'envoi échoue
debugPrint('Failed to send device info to API: $e');
}
}
/// Récupère les infos device depuis Hive
Map<String, dynamic> getStoredDeviceInfo() {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
final deviceInfo = <String, dynamic>{};
// Liste des clés à récupérer
final keys = [
'platform', 'device_model', 'device_name', 'device_manufacturer',
'device_brand', 'device_identifier', 'ios_version',
'android_version', 'android_sdk_version', 'device_nfc_capable',
'device_supports_tap_to_pay', 'device_stripe_certified', 'battery_level',
'battery_charging', 'battery_state', 'last_device_info_check', 'app_version',
'app_build', 'device_ip_local', 'device_ip_public', 'device_wifi_name',
'device_wifi_bssid'
];
for (final key in keys) {
final value = settingsBox.get('device_$key');
if (value != null) {
deviceInfo[key] = value;
}
}
return deviceInfo;
}
/// Vérifie la certification Stripe Tap to Pay via l'API
Future<bool> checkStripeCertification() async {
try {
// Sur Web, toujours non certifié
if (kIsWeb) {
debugPrint('📱 Web platform - Tap to Pay non supporté');
return false;
}
// iOS : vérification locale (iPhone XS+ avec iOS 16.4+)
if (Platform.isIOS) {
final iosInfo = await _deviceInfo.iosInfo;
final isSupported = _checkIosTapToPaySupport(
iosInfo.utsname.machine,
iosInfo.systemVersion
);
debugPrint('📱 iOS Tap to Pay support: $isSupported');
return isSupported;
}
// Android : vérification via l'API Stripe
if (Platform.isAndroid) {
final androidInfo = await _deviceInfo.androidInfo;
debugPrint('📱 Vérification certification Stripe pour ${androidInfo.manufacturer} ${androidInfo.model}');
try {
final response = await ApiService.instance.post(
'/stripe/devices/check-tap-to-pay',
data: {
'platform': 'android',
'manufacturer': androidInfo.manufacturer,
'model': androidInfo.model,
},
);
final tapToPaySupported = response.data['tap_to_pay_supported'] == true;
final message = response.data['message'] ?? '';
debugPrint('📱 Résultat certification Stripe: $tapToPaySupported - $message');
return tapToPaySupported;
} catch (e) {
debugPrint('❌ Erreur lors de la vérification Stripe: $e');
// En cas d'erreur API, on se base sur la vérification locale
return androidInfo.version.sdkInt >= 28;
}
}
return false;
} catch (e) {
debugPrint('❌ Erreur checkStripeCertification: $e');
return false;
}
}
/// Vérifie si le device peut utiliser Tap to Pay
bool canUseTapToPay() {
final deviceInfo = getStoredDeviceInfo();
// Vérifications requises
final nfcCapable = deviceInfo['device_nfc_capable'] == true;
// Utiliser la certification Stripe si disponible, sinon l'ancienne vérification
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
final batteryLevel = deviceInfo['battery_level'] as int?;
// Batterie minimum 10% pour les paiements
final sufficientBattery = batteryLevel != null && batteryLevel >= 10;
return nfcCapable && stripeCertified == true && sufficientBattery;
}
/// Stream pour surveiller les changements de batterie
Stream<BatteryState> get batteryStateStream => _battery.onBatteryStateChanged;
}

View File

@@ -67,9 +67,7 @@ class LocationService {
if (kIsWeb) {
try {
Position position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
desiredAccuracy: LocationAccuracy.high,
);
return LatLng(position.latitude, position.longitude);
} catch (e) {
@@ -89,9 +87,7 @@ class LocationService {
// Obtenir la position actuelle
Position position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
desiredAccuracy: LocationAccuracy.high,
);
return LatLng(position.latitude, position.longitude);

View File

@@ -0,0 +1,350 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'api_service.dart';
import 'device_info_service.dart';
import 'current_user_service.dart';
import 'current_amicale_service.dart';
/// Service pour gérer les paiements Tap to Pay avec Stripe
/// Version simplifiée qui s'appuie sur l'API backend
class StripeTapToPayService {
static final StripeTapToPayService instance = StripeTapToPayService._internal();
StripeTapToPayService._internal();
bool _isInitialized = false;
String? _stripeAccountId;
String? _locationId;
bool _deviceCompatible = false;
// Stream controllers pour les événements de paiement
final _paymentStatusController = StreamController<TapToPayStatus>.broadcast();
// Getters publics
bool get isInitialized => _isInitialized;
bool get isDeviceCompatible => _deviceCompatible;
Stream<TapToPayStatus> get paymentStatusStream => _paymentStatusController.stream;
/// Initialise le service Tap to Pay
Future<bool> initialize() async {
if (_isInitialized) {
debugPrint(' StripeTapToPayService déjà initialisé');
return true;
}
try {
debugPrint('🚀 Initialisation de Tap to Pay...');
// 1. Vérifier que l'utilisateur est connecté
if (!CurrentUserService.instance.isLoggedIn) {
debugPrint('❌ Utilisateur non connecté');
return false;
}
// 2. Vérifier que l'amicale a Stripe activé
final amicale = CurrentAmicaleService.instance.currentAmicale;
if (amicale == null) {
debugPrint('❌ Aucune amicale sélectionnée');
return false;
}
if (!amicale.chkStripe || amicale.stripeId.isEmpty) {
debugPrint('❌ L\'amicale n\'a pas de compte Stripe configuré');
return false;
}
_stripeAccountId = amicale.stripeId;
// 3. Vérifier la compatibilité de l'appareil
_deviceCompatible = DeviceInfoService.instance.canUseTapToPay();
if (!_deviceCompatible) {
debugPrint('⚠️ Cet appareil ne supporte pas Tap to Pay');
_paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.error,
message: 'Appareil non compatible avec Tap to Pay',
));
return false;
}
// 4. Récupérer la configuration depuis l'API
await _fetchConfiguration();
_isInitialized = true;
debugPrint('✅ Tap to Pay initialisé avec succès');
_paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.ready,
message: 'Tap to Pay prêt',
));
return true;
} catch (e) {
debugPrint('❌ Erreur lors de l\'initialisation: $e');
_isInitialized = false;
_paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.error,
message: 'Erreur d\'initialisation: $e',
));
return false;
}
}
/// Récupère la configuration depuis l'API
Future<void> _fetchConfiguration() async {
try {
final response = await ApiService.instance.get('/api/stripe/configuration');
_locationId = response.data['location_id'];
debugPrint('✅ Configuration récupérée - Location: $_locationId');
} catch (e) {
debugPrint('❌ Erreur récupération config: $e');
throw Exception('Impossible de récupérer la configuration Stripe');
}
}
/// Crée un PaymentIntent pour un paiement Tap to Pay
Future<PaymentIntentResult?> createPaymentIntent({
required int amountInCents,
String? description,
Map<String, dynamic>? metadata,
}) async {
if (!_isInitialized) {
debugPrint('❌ Service non initialisé');
return null;
}
try {
debugPrint('💰 Création PaymentIntent pour ${amountInCents / 100}€...');
_paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.processing,
message: 'Préparation du paiement...',
));
// Créer le PaymentIntent via l'API
// Extraire passage_id des metadata si présent
final passageId = metadata?['passage_id'] ?? '0';
final response = await ApiService.instance.post(
'/api/stripe/payments/create-intent',
data: {
'amount': amountInCents,
'currency': 'eur',
'description': description ?? 'Calendrier pompiers',
'payment_method_types': ['card_present'], // Pour Tap to Pay
'capture_method': 'automatic',
'passage_id': int.tryParse(passageId.toString()) ?? 0,
'amicale_id': CurrentAmicaleService.instance.amicaleId,
'member_id': CurrentUserService.instance.userId,
'stripe_account': _stripeAccountId,
'location_id': _locationId,
'metadata': metadata,
},
);
final result = PaymentIntentResult(
paymentIntentId: response.data['payment_intent_id'],
clientSecret: response.data['client_secret'],
amount: amountInCents,
);
debugPrint('✅ PaymentIntent créé: ${result.paymentIntentId}');
_paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.awaitingTap,
message: 'Présentez la carte',
paymentIntentId: result.paymentIntentId,
));
return result;
} catch (e) {
debugPrint('❌ Erreur création PaymentIntent: $e');
_paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.error,
message: 'Erreur: $e',
));
return null;
}
}
/// Simule le processus de collecte de paiement
/// (Dans la version finale, cela appellera le SDK natif)
Future<bool> collectPayment(PaymentIntentResult paymentIntent) async {
try {
debugPrint('💳 Collecte du paiement...');
_paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.processing,
message: 'Lecture de la carte...',
paymentIntentId: paymentIntent.paymentIntentId,
));
// TODO: Ici, intégrer le vrai SDK Stripe Terminal
// Pour l'instant, on simule une attente
await Future.delayed(const Duration(seconds: 2));
debugPrint('✅ Paiement collecté');
_paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.confirming,
message: 'Confirmation du paiement...',
paymentIntentId: paymentIntent.paymentIntentId,
));
return true;
} catch (e) {
debugPrint('❌ Erreur collecte paiement: $e');
_paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.error,
message: 'Erreur lors de la collecte: $e',
paymentIntentId: paymentIntent.paymentIntentId,
));
return false;
}
}
/// Confirme le paiement auprès du serveur
Future<bool> confirmPayment(PaymentIntentResult paymentIntent) async {
try {
debugPrint('✅ Confirmation du paiement...');
// Notifier le serveur du succès
await ApiService.instance.post(
'/api/stripe/payments/confirm',
data: {
'payment_intent_id': paymentIntent.paymentIntentId,
'amount': paymentIntent.amount,
'status': 'succeeded',
'amicale_id': CurrentAmicaleService.instance.amicaleId,
'member_id': CurrentUserService.instance.userId,
},
);
debugPrint('🎉 Paiement confirmé avec succès');
_paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.success,
message: 'Paiement réussi',
paymentIntentId: paymentIntent.paymentIntentId,
amount: paymentIntent.amount,
));
return true;
} catch (e) {
debugPrint('❌ Erreur confirmation paiement: $e');
_paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.error,
message: 'Erreur de confirmation: $e',
paymentIntentId: paymentIntent.paymentIntentId,
));
return false;
}
}
/// Annule un paiement
Future<void> cancelPayment(String paymentIntentId) async {
try {
await ApiService.instance.post(
'/api/stripe/payments/cancel',
data: {
'payment_intent_id': paymentIntentId,
},
);
debugPrint('❌ Paiement annulé');
_paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.cancelled,
message: 'Paiement annulé',
paymentIntentId: paymentIntentId,
));
} catch (e) {
debugPrint('⚠️ Erreur annulation paiement: $e');
}
}
/// Vérifie si le service est prêt pour les paiements
bool isReadyForPayments() {
return _isInitialized &&
_deviceCompatible &&
_stripeAccountId != null &&
_stripeAccountId!.isNotEmpty;
}
/// Récupère les informations de statut
Map<String, dynamic> getStatus() {
return {
'initialized': _isInitialized,
'device_compatible': _deviceCompatible,
'stripe_account': _stripeAccountId,
'location_id': _locationId,
'ready_for_payments': isReadyForPayments(),
};
}
/// Nettoie les ressources
void dispose() {
_paymentStatusController.close();
_isInitialized = false;
}
}
/// Résultat de création d'un PaymentIntent
class PaymentIntentResult {
final String paymentIntentId;
final String clientSecret;
final int amount;
PaymentIntentResult({
required this.paymentIntentId,
required this.clientSecret,
required this.amount,
});
}
/// Statut du processus Tap to Pay
enum TapToPayStatusType {
ready,
awaitingTap,
processing,
confirming,
success,
error,
cancelled,
}
/// Classe pour représenter l'état du processus Tap to Pay
class TapToPayStatus {
final TapToPayStatusType type;
final String message;
final String? paymentIntentId;
final int? amount;
final DateTime timestamp;
TapToPayStatus({
required this.type,
required this.message,
this.paymentIntentId,
this.amount,
}) : timestamp = DateTime.now();
bool get isSuccess => type == TapToPayStatusType.success;
bool get isError => type == TapToPayStatusType.error;
bool get isProcessing =>
type == TapToPayStatusType.processing ||
type == TapToPayStatusType.confirming;
}

View File

@@ -0,0 +1,501 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:mek_stripe_terminal/mek_stripe_terminal.dart';
import 'package:flutter_stripe/flutter_stripe.dart' as stripe_sdk;
import 'package:permission_handler/permission_handler.dart';
import 'api_service.dart';
import 'device_info_service.dart';
import 'current_user_service.dart';
import 'current_amicale_service.dart';
class StripeTerminalService {
static final StripeTerminalService instance = StripeTerminalService._internal();
StripeTerminalService._internal();
// Instance du terminal Stripe
Terminal? _terminal;
bool _isInitialized = false;
bool _isConnected = false;
// État du reader
Reader? _currentReader;
StreamSubscription<List<Reader>>? _discoverSubscription;
// Configuration Stripe
String? _stripePublishableKey;
String? _stripeAccountId; // Connected account ID de l'amicale
String? _locationId; // Location ID pour le Terminal
// Stream controllers pour les événements
final _paymentStatusController = StreamController<PaymentStatus>.broadcast();
final _readerStatusController = StreamController<ReaderStatus>.broadcast();
// Getters publics
bool get isInitialized => _isInitialized;
bool get isConnected => _isConnected;
Reader? get currentReader => _currentReader;
Stream<PaymentStatus> get paymentStatusStream => _paymentStatusController.stream;
Stream<ReaderStatus> get readerStatusStream => _readerStatusController.stream;
/// Initialise le service Stripe Terminal
Future<bool> initialize() async {
if (_isInitialized) {
debugPrint(' StripeTerminalService déjà initialisé');
return true;
}
try {
debugPrint('🚀 Initialisation de Stripe Terminal...');
// 1. Vérifier que l'utilisateur est connecté
if (!CurrentUserService.instance.isLoggedIn) {
throw Exception('Utilisateur non connecté');
}
// 2. Vérifier que l'amicale a Stripe activé
final amicale = CurrentAmicaleService.instance.currentAmicale;
if (amicale == null) {
throw Exception('Aucune amicale sélectionnée');
}
if (!amicale.chkStripe || amicale.stripeId.isEmpty) {
throw Exception('L\'amicale n\'a pas de compte Stripe configuré');
}
_stripeAccountId = amicale.stripeId;
// 3. Demander les permissions nécessaires
await _requestPermissions();
// 4. Récupérer la configuration Stripe depuis l'API
await _fetchStripeConfiguration();
// 5. Initialiser le SDK Stripe Terminal
await Terminal.initTerminal(
fetchToken: _fetchConnectionToken,
);
_terminal = Terminal.instance;
// 6. Vérifier la compatibilité Tap to Pay
final canUseTapToPay = DeviceInfoService.instance.canUseTapToPay();
if (!canUseTapToPay) {
debugPrint('⚠️ Cet appareil ne supporte pas Tap to Pay');
// Ne pas bloquer l'initialisation, juste informer
}
_isInitialized = true;
debugPrint('✅ Stripe Terminal initialisé avec succès');
return true;
} catch (e) {
debugPrint('❌ Erreur lors de l\'initialisation Stripe Terminal: $e');
_isInitialized = false;
return false;
}
}
/// Demande les permissions nécessaires pour le Terminal
Future<void> _requestPermissions() async {
if (kIsWeb) return; // Pas de permissions sur web
final permissions = <Permission>[
Permission.locationWhenInUse,
Permission.bluetooth,
Permission.bluetoothScan,
Permission.bluetoothConnect,
];
final statuses = await permissions.request();
for (final entry in statuses.entries) {
if (!entry.value.isGranted) {
debugPrint('⚠️ Permission refusée: ${entry.key}');
}
}
}
/// Récupère la configuration Stripe depuis l'API
Future<void> _fetchStripeConfiguration() async {
try {
final response = await ApiService.instance.get('/stripe/configuration');
if (response.data['publishable_key'] != null) {
_stripePublishableKey = response.data['publishable_key'];
// Initialiser aussi le SDK Flutter Stripe standard
stripe_sdk.Stripe.publishableKey = _stripePublishableKey!;
// Si on a un connected account ID, le configurer
if (_stripeAccountId != null) {
stripe_sdk.Stripe.stripeAccountId = _stripeAccountId;
}
// Récupérer le location ID si disponible
_locationId = response.data['location_id'];
} else {
throw Exception('Clé publique Stripe non trouvée');
}
} catch (e) {
debugPrint('❌ Erreur récupération config Stripe: $e');
rethrow;
}
}
/// Callback pour récupérer un token de connexion depuis l'API
Future<String> _fetchConnectionToken() async {
try {
debugPrint('🔑 Récupération du token de connexion Stripe...');
final response = await ApiService.instance.post(
'/stripe/terminal/connection-token',
data: {
'amicale_id': CurrentAmicaleService.instance.amicaleId,
'stripe_account': _stripeAccountId,
'location_id': _locationId,
}
);
final token = response.data['secret'];
if (token == null || token.isEmpty) {
throw Exception('Token de connexion invalide');
}
debugPrint('✅ Token de connexion récupéré');
return token;
} catch (e) {
debugPrint('❌ Erreur récupération token: $e');
throw Exception('Impossible de récupérer le token de connexion');
}
}
/// Découvre les readers disponibles (Tap to Pay sur iPhone)
Future<bool> discoverReaders() async {
if (!_isInitialized || _terminal == null) {
debugPrint('❌ Terminal non initialisé');
return false;
}
try {
debugPrint('🔍 Recherche des readers disponibles...');
// Annuler la découverte précédente si elle existe
await _discoverSubscription?.cancel();
// Configuration pour découvrir le reader local (Tap to Pay)
final config = TapToPayDiscoveryConfiguration();
// Lancer la découverte (retourne un Stream)
_discoverSubscription = _terminal!
.discoverReaders(config)
.listen((List<Reader> readers) {
debugPrint('📱 ${readers.length} reader(s) trouvé(s)');
if (readers.isNotEmpty) {
// Prendre le premier reader (devrait être l'iPhone local)
final reader = readers.first;
debugPrint('📱 Reader trouvé: ${reader.label} (${reader.serialNumber})');
// Se connecter automatiquement au premier reader trouvé
connectToReader(reader);
} else {
debugPrint('⚠️ Aucun reader trouvé');
_readerStatusController.add(ReaderStatus(
isConnected: false,
reader: null,
errorMessage: 'Aucun reader disponible',
));
}
}, onError: (error) {
debugPrint('❌ Erreur découverte readers: $error');
_readerStatusController.add(ReaderStatus(
isConnected: false,
reader: null,
errorMessage: error.toString(),
));
});
return true;
} catch (e) {
debugPrint('❌ Erreur découverte reader: $e');
return false;
}
}
/// Se connecte à un reader spécifique
Future<bool> connectToReader(Reader reader) async {
if (!_isInitialized || _terminal == null) {
return false;
}
try {
debugPrint('🔌 Connexion au reader: ${reader.label}...');
// Configuration pour la connexion Tap to Pay
final config = TapToPayConnectionConfiguration(
locationId: _locationId ?? '',
autoReconnectOnUnexpectedDisconnect: true,
readerDelegate: null, // Pas de délégué pour le moment
);
// Se connecter au reader
final connectedReader = await _terminal!.connectReader(
reader,
configuration: config,
);
_currentReader = connectedReader;
_isConnected = true;
debugPrint('✅ Connecté au reader: ${connectedReader.label}');
_readerStatusController.add(ReaderStatus(
isConnected: true,
reader: connectedReader,
));
// Arrêter la découverte
await _discoverSubscription?.cancel();
_discoverSubscription = null;
return true;
} catch (e) {
debugPrint('❌ Erreur connexion reader: $e');
_readerStatusController.add(ReaderStatus(
isConnected: false,
reader: null,
errorMessage: e.toString(),
));
return false;
}
}
/// Déconnecte le reader actuel
Future<void> disconnectReader() async {
if (!_isConnected || _terminal == null) return;
try {
debugPrint('🔌 Déconnexion du reader...');
await _terminal!.disconnectReader();
_currentReader = null;
_isConnected = false;
_readerStatusController.add(ReaderStatus(
isConnected: false,
reader: null,
));
debugPrint('✅ Reader déconnecté');
} catch (e) {
debugPrint('❌ Erreur déconnexion reader: $e');
}
}
/// Processus complet de paiement
Future<PaymentResult> processPayment(int amountInCents, {String? description}) async {
if (!_isConnected || _terminal == null) {
throw Exception('Terminal non connecté');
}
PaymentIntent? paymentIntent;
try {
debugPrint('💰 Démarrage du paiement de ${amountInCents / 100}€...');
// 1. Créer le PaymentIntent côté serveur
final response = await ApiService.instance.post(
'/stripe/terminal/create-payment-intent',
data: {
'amount': amountInCents,
'currency': 'eur',
'description': description ?? 'Calendrier pompiers',
'amicale_id': CurrentAmicaleService.instance.amicaleId,
'member_id': CurrentUserService.instance.userId,
'stripe_account': _stripeAccountId,
},
);
final clientSecret = response.data['client_secret'];
if (clientSecret == null) {
throw Exception('Client secret manquant');
}
// 2. Récupérer le PaymentIntent depuis le SDK
debugPrint('💳 Récupération du PaymentIntent...');
paymentIntent = await _terminal!.retrievePaymentIntent(clientSecret);
_paymentStatusController.add(PaymentStatus(
status: PaymentIntentStatus.requiresPaymentMethod,
timestamp: DateTime.now(),
));
// 3. Collecter la méthode de paiement (présenter l'interface Tap to Pay)
debugPrint('💳 En attente du paiement sans contact...');
final collectedPaymentIntent = await _terminal!.collectPaymentMethod(paymentIntent);
_paymentStatusController.add(PaymentStatus(
status: PaymentIntentStatus.requiresConfirmation,
timestamp: DateTime.now(),
));
// 4. Confirmer le paiement
debugPrint('✅ Confirmation du paiement...');
final confirmedPaymentIntent = await _terminal!.confirmPaymentIntent(collectedPaymentIntent);
// Vérifier le statut final
if (confirmedPaymentIntent.status == PaymentIntentStatus.succeeded) {
debugPrint('🎉 Paiement réussi!');
_paymentStatusController.add(PaymentStatus(
status: PaymentIntentStatus.succeeded,
timestamp: DateTime.now(),
));
// Notifier le serveur du succès
await _notifyPaymentSuccess(confirmedPaymentIntent);
return PaymentResult(
success: true,
paymentIntent: confirmedPaymentIntent,
);
} else {
throw Exception('Paiement non confirmé: ${confirmedPaymentIntent.status}');
}
} catch (e) {
debugPrint('❌ Erreur lors du paiement: $e');
_paymentStatusController.add(PaymentStatus(
status: PaymentIntentStatus.canceled,
timestamp: DateTime.now(),
errorMessage: e.toString(),
));
// Annuler le PaymentIntent si nécessaire
if (paymentIntent != null) {
try {
await _terminal!.cancelPaymentIntent(paymentIntent);
} catch (_) {
// Ignorer les erreurs d'annulation
}
}
return PaymentResult(
success: false,
errorMessage: e.toString(),
);
}
}
/// Notifie le serveur du succès du paiement
Future<void> _notifyPaymentSuccess(PaymentIntent paymentIntent) async {
try {
await ApiService.instance.post(
'/stripe/terminal/payment-success',
data: {
'payment_intent_id': paymentIntent.id,
'amount': paymentIntent.amount,
'status': paymentIntent.status.toString(),
'amicale_id': CurrentAmicaleService.instance.amicaleId,
'member_id': CurrentUserService.instance.userId,
},
);
} catch (e) {
debugPrint('⚠️ Erreur notification succès paiement: $e');
// Ne pas bloquer si la notification échoue
}
}
/// Simule un reader de test (pour le développement)
Future<bool> simulateTestReader() async {
if (!_isInitialized || _terminal == null) {
debugPrint('❌ Terminal non initialisé');
return false;
}
try {
debugPrint('🧪 Simulation d\'un reader de test...');
// Configuration pour un reader simulé
final config = TapToPayDiscoveryConfiguration(isSimulated: true);
// Découvrir le reader simulé
_terminal!.discoverReaders(config).listen((readers) async {
if (readers.isNotEmpty) {
final testReader = readers.first;
debugPrint('🧪 Reader de test trouvé: ${testReader.label}');
// Se connecter au reader de test
await connectToReader(testReader);
}
});
return true;
} catch (e) {
debugPrint('❌ Erreur simulation reader: $e');
return false;
}
}
/// Vérifie si l'appareil supporte Tap to Pay
bool isTapToPaySupported() {
return DeviceInfoService.instance.canUseTapToPay();
}
/// Nettoie les ressources
void dispose() {
_discoverSubscription?.cancel();
_paymentStatusController.close();
_readerStatusController.close();
disconnectReader();
_isInitialized = false;
_terminal = null;
}
}
/// Classe pour représenter le résultat d'un paiement
class PaymentResult {
final bool success;
final PaymentIntent? paymentIntent;
final String? errorMessage;
PaymentResult({
required this.success,
this.paymentIntent,
this.errorMessage,
});
}
/// Classe pour représenter le statut d'un paiement
class PaymentStatus {
final PaymentIntentStatus status;
final DateTime timestamp;
final String? errorMessage;
PaymentStatus({
required this.status,
required this.timestamp,
this.errorMessage,
});
}
/// Classe pour représenter le statut du reader
class ReaderStatus {
final bool isConnected;
final Reader? reader;
final String? errorMessage;
ReaderStatus({
required this.isConnected,
this.reader,
this.errorMessage,
});
}

View File

@@ -0,0 +1,253 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:mek_stripe_terminal/mek_stripe_terminal.dart';
import 'api_service.dart';
import 'device_info_service.dart';
import 'current_user_service.dart';
import 'current_amicale_service.dart';
/// Service simplifié pour Stripe Terminal (Tap to Pay)
/// Cette version se concentre sur les fonctionnalités essentielles
class StripeTerminalServiceSimple {
static final StripeTerminalServiceSimple instance = StripeTerminalServiceSimple._internal();
StripeTerminalServiceSimple._internal();
bool _isInitialized = false;
String? _stripeAccountId;
String? _locationId;
// Getters publics
bool get isInitialized => _isInitialized;
/// Initialise le service Stripe Terminal
Future<bool> initialize() async {
if (_isInitialized) {
debugPrint(' StripeTerminalService déjà initialisé');
return true;
}
try {
debugPrint('🚀 Initialisation de Stripe Terminal...');
// 1. Vérifier que l'utilisateur est connecté
if (!CurrentUserService.instance.isLoggedIn) {
throw Exception('Utilisateur non connecté');
}
// 2. Vérifier que l'amicale a Stripe activé
final amicale = CurrentAmicaleService.instance.currentAmicale;
if (amicale == null) {
throw Exception('Aucune amicale sélectionnée');
}
if (!amicale.chkStripe || amicale.stripeId.isEmpty) {
throw Exception('L\'amicale n\'a pas de compte Stripe configuré');
}
_stripeAccountId = amicale.stripeId;
// 3. Vérifier la compatibilité Tap to Pay
final canUseTapToPay = DeviceInfoService.instance.canUseTapToPay();
if (!canUseTapToPay) {
debugPrint('⚠️ Cet appareil ne supporte pas Tap to Pay');
return false;
}
// 4. Récupérer la configuration Stripe depuis l'API
await _fetchStripeConfiguration();
// 5. Initialiser le Terminal (sera fait à la demande)
_isInitialized = true;
debugPrint('✅ StripeTerminalService prêt');
return true;
} catch (e) {
debugPrint('❌ Erreur lors de l\'initialisation Stripe Terminal: $e');
_isInitialized = false;
return false;
}
}
/// Récupère la configuration Stripe depuis l'API
Future<void> _fetchStripeConfiguration() async {
try {
final response = await ApiService.instance.get('/stripe/configuration');
// Récupérer le location ID si disponible
_locationId = response.data['location_id'];
debugPrint('✅ Configuration Stripe récupérée');
} catch (e) {
debugPrint('❌ Erreur récupération config Stripe: $e');
rethrow;
}
}
/// Callback pour récupérer un token de connexion depuis l'API
Future<String> _fetchConnectionToken() async {
try {
debugPrint('🔑 Récupération du token de connexion Stripe...');
final response = await ApiService.instance.post(
'/stripe/terminal/connection-token',
data: {
'amicale_id': CurrentAmicaleService.instance.amicaleId,
'stripe_account': _stripeAccountId,
'location_id': _locationId,
}
);
final token = response.data['secret'];
if (token == null || token.isEmpty) {
throw Exception('Token de connexion invalide');
}
debugPrint('✅ Token de connexion récupéré');
return token;
} catch (e) {
debugPrint('❌ Erreur récupération token: $e');
throw Exception('Impossible de récupérer le token de connexion');
}
}
/// Initialise le Terminal à la demande
Future<void> _ensureTerminalInitialized() async {
// Vérifier si Terminal.instance existe déjà
try {
// Tenter d'accéder à Terminal.instance
Terminal.instance;
debugPrint('✅ Terminal déjà initialisé');
} catch (_) {
// Si erreur, initialiser le Terminal
debugPrint('📱 Initialisation du Terminal SDK...');
await Terminal.initTerminal(
fetchToken: _fetchConnectionToken,
);
debugPrint('✅ Terminal SDK initialisé');
}
}
/// Processus simplifié de paiement par carte
Future<PaymentResult> processCardPayment({
required int amountInCents,
String? description,
}) async {
if (!_isInitialized) {
throw Exception('Service non initialisé');
}
try {
debugPrint('💰 Démarrage du paiement de ${amountInCents / 100}€...');
// 1. S'assurer que le Terminal est initialisé
await _ensureTerminalInitialized();
// 2. Créer le PaymentIntent côté serveur
final response = await ApiService.instance.post(
'/stripe/terminal/create-payment-intent',
data: {
'amount': amountInCents,
'currency': 'eur',
'description': description ?? 'Calendrier pompiers',
'amicale_id': CurrentAmicaleService.instance.amicaleId,
'member_id': CurrentUserService.instance.userId,
'stripe_account': _stripeAccountId,
'payment_method_types': ['card_present'],
'capture_method': 'automatic',
},
);
final paymentIntentId = response.data['payment_intent_id'];
final clientSecret = response.data['client_secret'];
if (clientSecret == null) {
throw Exception('Client secret manquant');
}
debugPrint('✅ PaymentIntent créé: $paymentIntentId');
// 3. Retourner le résultat avec les infos nécessaires
// Le processus de paiement réel sera géré par l'UI
return PaymentResult(
success: true,
paymentIntentId: paymentIntentId,
clientSecret: clientSecret,
amount: amountInCents,
);
} catch (e) {
debugPrint('❌ Erreur lors du paiement: $e');
return PaymentResult(
success: false,
errorMessage: e.toString(),
);
}
}
/// Confirme un paiement réussi auprès du serveur
Future<void> confirmPaymentSuccess({
required String paymentIntentId,
required int amount,
}) async {
try {
await ApiService.instance.post(
'/stripe/terminal/payment-success',
data: {
'payment_intent_id': paymentIntentId,
'amount': amount,
'status': 'succeeded',
'amicale_id': CurrentAmicaleService.instance.amicaleId,
'member_id': CurrentUserService.instance.userId,
},
);
debugPrint('✅ Paiement confirmé au serveur');
} catch (e) {
debugPrint('⚠️ Erreur notification succès paiement: $e');
// Ne pas bloquer si la notification échoue
}
}
/// Vérifie si l'appareil supporte Tap to Pay
bool isTapToPaySupported() {
return DeviceInfoService.instance.canUseTapToPay();
}
/// Vérifie si le service est prêt pour les paiements
bool isReadyForPayments() {
if (!_isInitialized) return false;
if (!isTapToPaySupported()) return false;
if (_stripeAccountId == null || _stripeAccountId!.isEmpty) return false;
return true;
}
/// Récupère les informations de configuration
Map<String, dynamic> getConfiguration() {
return {
'initialized': _isInitialized,
'tap_to_pay_supported': isTapToPaySupported(),
'stripe_account_id': _stripeAccountId,
'location_id': _locationId,
'device_info': DeviceInfoService.instance.getStoredDeviceInfo(),
};
}
}
/// Classe pour représenter le résultat d'un paiement
class PaymentResult {
final bool success;
final String? paymentIntentId;
final String? clientSecret;
final int? amount;
final String? errorMessage;
PaymentResult({
required this.success,
this.paymentIntentId,
this.clientSecret,
this.amount,
this.errorMessage,
});
}

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
@@ -22,9 +23,9 @@ class SyncService {
void _initConnectivityListener() {
_connectivitySubscription = Connectivity()
.onConnectivityChanged
.listen((List<ConnectivityResult> results) {
// Vérifier si au moins un type de connexion est disponible
if (results.any((result) => result != ConnectivityResult.none)) {
.listen((ConnectivityResult result) {
// Vérifier si la connexion est disponible
if (result != ConnectivityResult.none) {
// Lorsque la connexion est rétablie, déclencher une synchronisation
syncAll();
}
@@ -49,7 +50,7 @@ class SyncService {
await _userRepository.syncAllUsers();
} catch (e) {
// Gérer les erreurs de synchronisation
print('Erreur lors de la synchronisation: $e');
debugPrint('Erreur lors de la synchronisation: $e');
} finally {
_isSyncing = false;
}
@@ -61,7 +62,7 @@ class SyncService {
// Cette méthode pourrait être étendue à l'avenir pour synchroniser d'autres données utilisateur
await _userRepository.refreshFromServer();
} catch (e) {
print('Erreur lors de la synchronisation des données utilisateur: $e');
debugPrint('Erreur lors de la synchronisation des données utilisateur: $e');
}
}
@@ -75,7 +76,7 @@ class SyncService {
// Rafraîchir depuis le serveur
await _userRepository.refreshFromServer();
} catch (e) {
print('Erreur lors du rafraîchissement forcé: $e');
debugPrint('Erreur lors du rafraîchissement forcé: $e');
} finally {
_isSyncing = false;
}

View File

@@ -1,24 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
/// Service pour gérer les préférences de thème de l'application
/// Supporte la détection automatique du mode sombre/clair du système
/// Utilise Hive pour la persistance au lieu de SharedPreferences
class ThemeService extends ChangeNotifier {
static ThemeService? _instance;
static ThemeService get instance => _instance ??= ThemeService._();
ThemeService._() {
_init();
}
// Préférences stockées
SharedPreferences? _prefs;
// Mode de thème actuel
ThemeMode _themeMode = ThemeMode.system;
// Clé pour stocker les préférences
// Clé pour stocker les préférences dans Hive
static const String _themeModeKey = 'theme_mode';
/// Mode de thème actuel
@@ -45,42 +44,59 @@ class ThemeService extends ChangeNotifier {
/// Initialise le service
Future<void> _init() async {
try {
_prefs = await SharedPreferences.getInstance();
await _loadThemeMode();
// Observer les changements du système
SchedulerBinding.instance.platformDispatcher.onPlatformBrightnessChanged = () {
_onSystemBrightnessChanged();
};
debugPrint('🎨 ThemeService initialisé - Mode: $_themeMode, Système sombre: $isSystemDark');
} catch (e) {
debugPrint('❌ Erreur initialisation ThemeService: $e');
}
}
/// Charge le mode de thème depuis les préférences
/// Charge le mode de thème depuis Hive
Future<void> _loadThemeMode() async {
try {
final savedMode = _prefs?.getString(_themeModeKey);
// Vérifier si la box settings est ouverte
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
debugPrint('⚠️ Box settings pas encore ouverte, utilisation du mode système par défaut');
_themeMode = ThemeMode.system;
return;
}
final settingsBox = Hive.box(AppKeys.settingsBoxName);
final savedMode = settingsBox.get(_themeModeKey) as String?;
if (savedMode != null) {
_themeMode = ThemeMode.values.firstWhere(
(mode) => mode.name == savedMode,
orElse: () => ThemeMode.system,
);
debugPrint('🎨 Mode de thème chargé depuis Hive: $_themeMode');
} else {
debugPrint('🎨 Aucun mode de thème sauvegardé, utilisation du mode système');
}
debugPrint('🎨 Mode de thème chargé: $_themeMode');
} catch (e) {
debugPrint('❌ Erreur chargement thème: $e');
_themeMode = ThemeMode.system;
}
}
/// Sauvegarde le mode de thème
/// Sauvegarde le mode de thème dans Hive
Future<void> _saveThemeMode() async {
try {
await _prefs?.setString(_themeModeKey, _themeMode.name);
debugPrint('💾 Mode de thème sauvegardé: $_themeMode');
// Vérifier si la box settings est ouverte
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
debugPrint('⚠️ Box settings pas ouverte, impossible de sauvegarder le thème');
return;
}
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.put(_themeModeKey, _themeMode.name);
debugPrint('💾 Mode de thème sauvegardé dans Hive: $_themeMode');
} catch (e) {
debugPrint('❌ Erreur sauvegarde thème: $e');
}
@@ -158,4 +174,18 @@ class ThemeService extends ChangeNotifier {
return Icons.brightness_auto;
}
}
/// Recharge le thème depuis Hive (utile après l'ouverture des boxes)
Future<void> reloadFromHive() async {
await _loadThemeMode();
notifyListeners();
debugPrint('🔄 ThemeService rechargé depuis Hive');
}
/// Réinitialise le service au mode système
void reset() {
_themeMode = ThemeMode.system;
notifyListeners();
debugPrint('🔄 ThemeService réinitialisé');
}
}

View File

@@ -99,7 +99,7 @@ class AppTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
fontFamily: 'Figtree',
fontFamily: 'Inter',
colorScheme: const ColorScheme.light(
primary: primaryColor,
secondary: secondaryColor,
@@ -128,9 +128,9 @@ class AppTheme {
borderRadius: BorderRadius.circular(borderRadiusRounded),
),
textStyle: const TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
),
),
),
@@ -196,7 +196,7 @@ class AppTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
fontFamily: 'Figtree',
fontFamily: 'Inter',
colorScheme: const ColorScheme.dark(
primary: primaryColor,
secondary: secondaryColor,
@@ -225,9 +225,9 @@ class AppTheme {
borderRadius: BorderRadius.circular(borderRadiusRounded),
),
textStyle: const TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
),
),
),
@@ -295,88 +295,90 @@ class AppTheme {
return TextTheme(
// Display styles (très grandes tailles)
displayLarge: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor,
fontSize: 57 * scaleFactor, // Material 3 default
),
displayMedium: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor,
fontSize: 45 * scaleFactor,
),
displaySmall: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor,
fontSize: 36 * scaleFactor,
),
// Headline styles (titres principaux)
headlineLarge: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor,
fontSize: 32 * scaleFactor,
),
headlineMedium: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor,
fontSize: 28 * scaleFactor,
),
headlineSmall: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor,
fontSize: 24 * scaleFactor,
),
// Title styles (sous-titres)
titleLarge: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor,
fontSize: 22 * scaleFactor,
),
titleMedium: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor,
fontSize: 16 * scaleFactor,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
),
titleSmall: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor,
fontSize: 14 * scaleFactor,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
),
// Body styles (texte principal)
bodyLarge: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor,
fontSize: 16 * scaleFactor,
fontWeight: FontWeight.w500,
),
bodyMedium: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor,
fontSize: 14 * scaleFactor,
fontWeight: FontWeight.w500,
),
bodySmall: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor.withValues(alpha: 0.7),
fontSize: 12 * scaleFactor,
),
// Label styles (petits textes, boutons)
labelLarge: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor,
fontSize: 14 * scaleFactor,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
),
labelMedium: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor.withValues(alpha: 0.7),
fontSize: 12 * scaleFactor,
),
labelSmall: TextStyle(
fontFamily: 'Figtree',
fontFamily: 'Inter',
color: textColor.withValues(alpha: 0.7),
fontSize: 11 * scaleFactor,
),
@@ -386,21 +388,21 @@ class AppTheme {
// Version statique pour compatibilité (utilise les tailles par défaut)
static TextTheme _getTextTheme(Color textColor) {
return TextTheme(
displayLarge: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 57),
displayMedium: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 45),
displaySmall: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 36),
headlineLarge: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 32),
headlineMedium: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 28),
headlineSmall: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 24),
titleLarge: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 22),
titleMedium: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 16, fontWeight: FontWeight.w500),
titleSmall: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 14, fontWeight: FontWeight.w500),
bodyLarge: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 16),
bodyMedium: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 14),
bodySmall: TextStyle(fontFamily: 'Figtree', color: textColor.withValues(alpha: 0.7), fontSize: 12),
labelLarge: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 14, fontWeight: FontWeight.w500),
labelMedium: TextStyle(fontFamily: 'Figtree', color: textColor.withValues(alpha: 0.7), fontSize: 12),
labelSmall: TextStyle(fontFamily: 'Figtree', color: textColor.withValues(alpha: 0.7), fontSize: 11),
displayLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 57),
displayMedium: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 45),
displaySmall: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 36),
headlineLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 32),
headlineMedium: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 28),
headlineSmall: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 24),
titleLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 22),
titleMedium: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 16, fontWeight: FontWeight.w600),
titleSmall: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 14, fontWeight: FontWeight.w600),
bodyLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 16, fontWeight: FontWeight.w500),
bodyMedium: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 14, fontWeight: FontWeight.w500),
bodySmall: TextStyle(fontFamily: 'Inter', color: textColor.withValues(alpha: 0.7), fontSize: 12),
labelLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 14, fontWeight: FontWeight.w600),
labelMedium: TextStyle(fontFamily: 'Inter', color: textColor.withValues(alpha: 0.7), fontSize: 12),
labelSmall: TextStyle(fontFamily: 'Inter', color: textColor.withValues(alpha: 0.7), fontSize: 11),
);
}

View File

@@ -1,426 +0,0 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:math' as math;
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/presentation/widgets/sector_distribution_card.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
/// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class AdminDashboardHomePage extends StatefulWidget {
const AdminDashboardHomePage({super.key});
@override
State<AdminDashboardHomePage> createState() => _AdminDashboardHomePageState();
}
class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
// Données pour le tableau de bord
int totalPassages = 0;
double totalAmounts = 0.0;
List<Map<String, dynamic>> memberStats = [];
bool isDataLoaded = false;
bool isLoading = true;
bool isFirstLoad = true; // Pour suivre le premier chargement
// Données pour les graphiques
List<PaymentData> paymentData = [];
Map<int, int> passagesByType = {};
@override
void initState() {
super.initState();
_loadDashboardData();
}
/// Prépare les données pour le graphique de paiement
void _preparePaymentData(List<dynamic> passages) {
// Réinitialiser les données
paymentData = [];
// Compter les montants par type de règlement
Map<int, double> paymentAmounts = {};
// Initialiser les compteurs pour tous les types de règlement
for (final typeId in AppKeys.typesReglements.keys) {
paymentAmounts[typeId] = 0.0;
}
// Calculer les montants par type de règlement
for (final passage in passages) {
if (passage.fkTypeReglement != null && passage.montant != null && passage.montant.isNotEmpty) {
final typeId = passage.fkTypeReglement;
final amount = double.tryParse(passage.montant) ?? 0.0;
paymentAmounts[typeId] = (paymentAmounts[typeId] ?? 0.0) + amount;
}
}
// Créer les objets PaymentData
paymentAmounts.forEach((typeId, amount) {
if (amount > 0 && AppKeys.typesReglements.containsKey(typeId)) {
final typeInfo = AppKeys.typesReglements[typeId]!;
paymentData.add(PaymentData(
typeId: typeId,
amount: amount,
title: typeInfo['titre'] as String,
color: Color(typeInfo['couleur'] as int),
icon: typeInfo['icon_data'] as IconData,
));
}
});
}
Future<void> _loadDashboardData() async {
if (mounted) {
setState(() {
isLoading = true;
});
}
try {
debugPrint('AdminDashboardHomePage: Chargement des données du tableau de bord...');
// Utiliser les instances globales définies dans app.dart
// Pas besoin de Provider.of car les instances sont déjà disponibles
// Récupérer l'opération en cours (les boxes sont déjà ouvertes par SplashPage)
final currentOperation = userRepository.getCurrentOperation();
debugPrint('AdminDashboardHomePage: Opération récupérée: ${currentOperation?.id ?? "null"}');
if (currentOperation != null) {
// Charger les passages pour l'opération en cours
debugPrint('AdminDashboardHomePage: Chargement des passages pour l\'opération ${currentOperation.id}...');
final passages = passageRepository.getPassagesByOperation(currentOperation.id);
debugPrint('AdminDashboardHomePage: ${passages.length} passages récupérés');
// Calculer le nombre total de passages
totalPassages = passages.length;
// Calculer le montant total collecté
totalAmounts = passages.fold(0.0, (sum, passage) => sum + (passage.montant.isNotEmpty ? double.tryParse(passage.montant) ?? 0.0 : 0.0));
// Préparer les données pour le graphique de paiement
_preparePaymentData(passages);
// Compter les passages par type
passagesByType = {};
for (final passage in passages) {
final typeId = passage.fkType;
passagesByType[typeId] = (passagesByType[typeId] ?? 0) + 1;
}
// Afficher les comptages par type pour le débogage
debugPrint('AdminDashboardHomePage: Comptage des passages par type:');
passagesByType.forEach((typeId, count) {
final typeInfo = AppKeys.typesPassages[typeId];
final typeName = typeInfo != null ? typeInfo['titre'] : 'Inconnu';
debugPrint('AdminDashboardHomePage: Type $typeId ($typeName): $count passages');
});
// Charger les statistiques par membre
memberStats = [];
final Map<int, int> memberCounts = {};
// Compter les passages par membre
for (final passage in passages) {
memberCounts[passage.fkUser] = (memberCounts[passage.fkUser] ?? 0) + 1;
}
// Récupérer les informations des membres
for (final entry in memberCounts.entries) {
final user = userRepository.getUserById(entry.key);
if (user != null) {
memberStats.add({
'name': '${user.firstName ?? ''} ${user.name ?? ''}'.trim(),
'count': entry.value,
});
}
}
// Trier les membres par nombre de passages (décroissant)
memberStats.sort((a, b) => (b['count'] as int).compareTo(a['count'] as int));
} else {
debugPrint('AdminDashboardHomePage: Aucune opération en cours, impossible de charger les passages');
}
if (mounted) {
setState(() {
isDataLoaded = true;
isLoading = false;
isFirstLoad = false; // Marquer que le premier chargement est terminé
});
}
// Vérifier si les données sont correctement chargées
debugPrint(
'AdminDashboardHomePage: Données chargées: isDataLoaded=$isDataLoaded, totalPassages=$totalPassages, passagesByType=${passagesByType.length} types');
} catch (e) {
debugPrint('AdminDashboardHomePage: Erreur lors du chargement des données: $e');
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
debugPrint('Building AdminDashboardHomePage');
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
// Récupérer l'opération en cours (les boîtes sont déjà ouvertes par SplashPage)
final currentOperation = userRepository.getCurrentOperation();
// Titre dynamique avec l'ID et le nom de l'opération
final String title = currentOperation != null ? 'Opération #${currentOperation.id} ${currentOperation.name}' : 'Opération';
return Stack(children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox(width: double.infinity, height: double.infinity),
),
),
// Contenu de la page
SingleChildScrollView(
padding: const EdgeInsets.all(AppTheme.spacingL),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppTheme.spacingM),
// Afficher un indicateur de chargement si les données ne sont pas encore chargées
if (isLoading && !isDataLoaded)
const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: CircularProgressIndicator(),
),
),
// Afficher le contenu seulement si les données sont chargées ou en cours de mise à jour
if (isDataLoaded || isLoading) ...[
// LIGNE 1 : Graphiques de répartition (type de passage et mode de paiement)
isDesktop
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildPassageTypeCard(context),
),
const SizedBox(width: AppTheme.spacingM),
Expanded(
child: _buildPaymentTypeCard(context),
),
],
)
: Column(
children: [
_buildPassageTypeCard(context),
const SizedBox(height: AppTheme.spacingM),
_buildPaymentTypeCard(context),
],
),
const SizedBox(height: AppTheme.spacingL),
// LIGNE 2 : Carte de répartition par secteur (pleine largeur)
ValueListenableBuilder<Box<SectorModel>>(
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
builder: (context, Box<SectorModel> box, child) {
final sectorCount = box.values.length;
return SectorDistributionCard(
key: ValueKey('sector_distribution_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
title: '$sectorCount secteurs',
height: 500, // Hauteur maximale pour afficher tous les secteurs
);
},
),
const SizedBox(height: AppTheme.spacingL),
// LIGNE 3 : Graphique d'activité
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
child: ActivityChart(
key: ValueKey('activity_chart_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
height: 350,
showAllPassages: true, // Tous les passages, pas seulement ceux de l'utilisateur courant
title: 'Passages réalisés par jour (15 derniers jours)',
daysToShow: 15,
),
),
const SizedBox(height: AppTheme.spacingL),
// Actions rapides - uniquement visible sur le web
if (kIsWeb) ...[
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
padding: const EdgeInsets.all(AppTheme.spacingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Actions sur cette opération',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: AppTheme.spacingM),
Wrap(
spacing: AppTheme.spacingM,
runSpacing: AppTheme.spacingM,
children: [
_buildActionButton(
context,
'Exporter les données',
Icons.file_download_outlined,
AppTheme.primaryColor,
() {},
),
_buildActionButton(
context,
'Gérer les secteurs',
Icons.map_outlined,
AppTheme.accentColor,
() {},
),
],
),
],
),
),
],
],
],
),
),
]);
}
// Construit la carte de répartition par type de passage avec liste
Widget _buildPassageTypeCard(BuildContext context) {
return PassageSummaryCard(
title: 'Passages',
titleColor: AppTheme.primaryColor,
titleIcon: Icons.route,
height: 300,
useValueListenable: false, // Utiliser les données statiques
showAllPassages: true,
excludePassageTypes: const [2], // Exclure "À finaliser"
passagesByType: passagesByType,
customTotalDisplay: (total) => '$totalPassages passages',
isDesktop: MediaQuery.of(context).size.width > 800,
backgroundIcon: Icons.route,
backgroundIconColor: AppTheme.primaryColor,
backgroundIconOpacity: 0.07,
backgroundIconSize: 180,
);
}
// Construit la carte de répartition par mode de paiement
Widget _buildPaymentTypeCard(BuildContext context) {
return PaymentSummaryCard(
title: 'Règlements',
titleColor: AppTheme.buttonSuccessColor,
titleIcon: Icons.euro,
height: 300,
useValueListenable: false, // Utiliser les données statiques
showAllPayments: true,
paymentsByType: _convertPaymentDataToMap(paymentData),
customTotalDisplay: (total) => '${totalAmounts.toStringAsFixed(2)}',
isDesktop: MediaQuery.of(context).size.width > 800,
backgroundIcon: Icons.euro,
backgroundIconColor: AppTheme.primaryColor,
backgroundIconOpacity: 0.07,
backgroundIconSize: 180,
);
}
// Méthode helper pour convertir les PaymentData en Map
Map<int, double> _convertPaymentDataToMap(List<PaymentData> paymentDataList) {
final Map<int, double> result = {};
for (final payment in paymentDataList) {
result[payment.typeId] = payment.amount;
}
return result;
}
Widget _buildActionButton(
BuildContext context,
String label,
IconData icon,
Color color,
VoidCallback onPressed,
) {
return ElevatedButton.icon(
onPressed: onPressed,
icon: Icon(icon),
label: Text(label),
style: ElevatedButton.styleFrom(
backgroundColor: color,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingL,
vertical: AppTheme.spacingM,
),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
),
);
}
}

View File

@@ -1,419 +0,0 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'dart:math' as math;
// Import des pages admin
import 'admin_dashboard_home_page.dart';
import 'admin_statistics_page.dart';
import 'admin_history_page.dart';
import '../chat/chat_communication_page.dart';
import 'admin_map_page.dart';
import 'admin_amicale_page.dart';
import 'admin_operations_page.dart';
/// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class AdminDashboardPage extends StatefulWidget {
const AdminDashboardPage({super.key});
@override
State<AdminDashboardPage> createState() => _AdminDashboardPageState();
}
class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBindingObserver {
int _selectedIndex = 0;
// Pages seront construites dynamiquement dans build()
// Référence à la boîte Hive pour les paramètres
late Box _settingsBox;
// Listener pour les changements de paramètres
late ValueListenable<Box<dynamic>> _settingsListenable;
// Liste des éléments de navigation de base (toujours visibles)
final List<_NavigationItem> _baseNavigationItems = [
const _NavigationItem(
label: 'Tableau de bord',
icon: Icons.dashboard_outlined,
selectedIcon: Icons.dashboard,
pageType: _PageType.dashboardHome,
),
const _NavigationItem(
label: 'Statistiques',
icon: Icons.bar_chart_outlined,
selectedIcon: Icons.bar_chart,
pageType: _PageType.statistics,
),
const _NavigationItem(
label: 'Historique',
icon: Icons.history_outlined,
selectedIcon: Icons.history,
pageType: _PageType.history,
),
const _NavigationItem(
label: 'Messages',
icon: Icons.chat_outlined,
selectedIcon: Icons.chat,
pageType: _PageType.communication,
),
const _NavigationItem(
label: 'Carte',
icon: Icons.map_outlined,
selectedIcon: Icons.map,
pageType: _PageType.map,
),
];
// Éléments de navigation supplémentaires pour le rôle 2
final List<_NavigationItem> _adminNavigationItems = [
const _NavigationItem(
label: 'Amicale & membres',
icon: Icons.business_outlined,
selectedIcon: Icons.business,
pageType: _PageType.amicale,
requiredRole: 2,
),
const _NavigationItem(
label: 'Opérations',
icon: Icons.calendar_today_outlined,
selectedIcon: Icons.calendar_today,
pageType: _PageType.operations,
requiredRole: 2,
),
];
// Construire la page basée sur le type
Widget _buildPage(_PageType pageType) {
switch (pageType) {
case _PageType.dashboardHome:
return const AdminDashboardHomePage();
case _PageType.statistics:
return const AdminStatisticsPage();
case _PageType.history:
return const AdminHistoryPage();
case _PageType.communication:
return const ChatCommunicationPage();
case _PageType.map:
return const AdminMapPage();
case _PageType.amicale:
return AdminAmicalePage(
userRepository: userRepository,
amicaleRepository: amicaleRepository,
membreRepository: membreRepository,
passageRepository: passageRepository,
operationRepository: operationRepository,
);
case _PageType.operations:
return AdminOperationsPage(
operationRepository: operationRepository,
userRepository: userRepository,
);
}
}
// Construire la liste des destinations de navigation en fonction du rôle
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) {
// Utiliser createBadgedNavigationDestination pour les messages
if (item.label == 'Messages') {
destinations.add(
createBadgedNavigationDestination(
icon: Icon(item.icon),
selectedIcon: Icon(item.selectedIcon),
label: item.label,
showBadge: true,
),
);
} else {
destinations.add(
NavigationDestination(
icon: Icon(item.icon),
selectedIcon: Icon(item.selectedIcon),
label: item.label,
),
);
}
}
// 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) {
// Utiliser createBadgedNavigationDestination pour les messages
if (item.label == 'Messages') {
destinations.add(
createBadgedNavigationDestination(
icon: Icon(item.icon),
selectedIcon: Icon(item.selectedIcon),
label: item.label,
showBadge: true,
),
);
} else {
destinations.add(
NavigationDestination(
icon: Icon(item.icon),
selectedIcon: Icon(item.selectedIcon),
label: item.label,
),
);
}
}
}
}
return destinations;
}
// Construire la liste des pages en fonction du rôle
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) {
pages.add(_buildPage(item.pageType));
}
// 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));
}
}
}
return pages;
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
try {
debugPrint('Initialisation de AdminDashboardPage');
// Vérifier que userRepository est correctement initialisé
debugPrint('userRepository est correctement initialisé');
final currentUser = userRepository.getCurrentUser();
if (currentUser == null) {
debugPrint('ERREUR: Aucun utilisateur connecté dans AdminDashboardPage');
} else {
debugPrint('Utilisateur connecté: ${currentUser.username} (${currentUser.id})');
}
userRepository.addListener(_handleUserRepositoryChanges);
// Les pages seront construites dynamiquement dans build()
// Initialiser et charger les paramètres
_initSettings().then((_) {
// Écouter les changements de la boîte de paramètres après l'initialisation
_settingsListenable = _settingsBox.listenable(keys: ['selectedPageIndex']);
_settingsListenable.addListener(_onSettingsChanged);
});
// Vérifier si des données sont en cours de chargement
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkLoadingState();
});
} catch (e) {
debugPrint('ERREUR CRITIQUE dans AdminDashboardPage.initState: $e');
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
userRepository.removeListener(_handleUserRepositoryChanges);
_settingsListenable.removeListener(_onSettingsChanged);
super.dispose();
}
// Méthode pour gérer les changements d'état du UserRepository
void _handleUserRepositoryChanges() {
_checkLoadingState();
}
// Méthode pour gérer les changements de paramètres
void _onSettingsChanged() {
final newIndex = _settingsBox.get('selectedPageIndex');
if (newIndex != null && newIndex is int && newIndex != _selectedIndex) {
setState(() {
_selectedIndex = newIndex;
});
}
}
// Méthode pour vérifier l'état de chargement (barre de progression désactivée)
void _checkLoadingState() {
// La barre de progression est désactivée, ne rien faire
}
// Initialiser la boîte de paramètres et charger les préférences
Future<void> _initSettings() async {
try {
// Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
} else {
_settingsBox = Hive.box(AppKeys.settingsBoxName);
}
// Charger l'index de page sélectionné
final savedIndex = _settingsBox.get('selectedPageIndex');
// Vérifier si l'index sauvegardé est valide
if (savedIndex != null && savedIndex is int) {
debugPrint('Index sauvegardé trouvé: $savedIndex');
// 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',
);
}
} catch (e) {
debugPrint('Erreur lors du chargement des paramètres: $e');
}
}
// Sauvegarder les paramètres utilisateur
void _saveSettings() {
try {
// Sauvegarder l'index de page sélectionné
_settingsBox.put('selectedPageIndex', _selectedIndex);
} catch (e) {
debugPrint('Erreur lors de la sauvegarde des paramètres: $e');
}
}
@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
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox(width: double.infinity, height: double.infinity),
),
),
// Contenu de la page
DashboardLayout(
title: 'Tableau de bord Administration',
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() {
_selectedIndex = index;
_saveSettings(); // Sauvegarder l'index de page sélectionné
});
},
destinations: destinations,
isAdmin: true,
body: pages[_selectedIndex],
),
],
);
}
}
// Enum pour les types de pages
enum _PageType {
dashboardHome,
statistics,
history,
communication,
map,
amicale,
operations,
}
// Classe pour représenter une destination de navigation avec sa page associée
class _NavigationItem {
final String label;
final IconData icon;
final IconData selectedIcon;
final _PageType pageType;
final int? requiredRole; // null si accessible à tous les rôles
const _NavigationItem({
required this.label,
required this.icon,
required this.selectedIcon,
required this.pageType,
this.requiredRole,
});
}

View File

@@ -1,47 +0,0 @@
import 'package:flutter/material.dart';
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({super.key});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.all(16.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.bug_report, color: Colors.grey),
const SizedBox(width: 8),
Text(
'Informations de débogage',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('Environnement'),
subtitle: const Text(
'Afficher les informations sur l\'environnement actuel'),
onTap: () => EnvironmentInfoWidget.show(context),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
tileColor: Colors.grey.withValues(alpha: 0.1),
),
// Autres options de débogage peuvent être ajoutées ici
],
),
),
);
}
}

View File

@@ -1,946 +0,0 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/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/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
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
// Enum pour gérer les types de tri
enum PassageSortType {
dateDesc, // Plus récent en premier (défaut)
dateAsc, // Plus ancien en premier
addressAsc, // Adresse A-Z
addressDesc, // Adresse Z-A
}
class AdminHistoryPage extends StatefulWidget {
const AdminHistoryPage({super.key});
@override
State<AdminHistoryPage> createState() => _AdminHistoryPageState();
}
class _AdminHistoryPageState extends State<AdminHistoryPage> {
// État du tri actuel
PassageSortType _currentSort = PassageSortType.dateDesc;
// Filtres présélectionnés depuis une autre page
int? selectedSectorId;
String selectedSector = 'Tous';
String selectedType = 'Tous';
// Listes pour les filtres
List<SectorModel> _sectors = [];
List<MembreModel> _membres = [];
// Repositories
late PassageRepository _passageRepository;
late SectorRepository _sectorRepository;
late UserRepository _userRepository;
late MembreRepository _membreRepository;
// Passages originaux pour l'édition
List<PassageModel> _originalPassages = [];
// État de chargement
bool _isLoading = true;
String _errorMessage = '';
@override
void initState() {
super.initState();
// Initialiser les filtres
_initializeFilters();
// Charger les filtres présélectionnés depuis Hive si disponibles
_loadPreselectedFilters();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Récupérer les repositories une seule fois
_loadRepositories();
}
// Charger les repositories et les données
void _loadRepositories() {
try {
// Utiliser les instances globales définies dans app.dart
_passageRepository = passageRepository;
_userRepository = userRepository;
_sectorRepository = sectorRepository;
_membreRepository = membreRepository;
// Charger les secteurs et les membres
_loadSectorsAndMembres();
// Charger les passages
_loadPassages();
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Erreur lors du chargement des repositories: $e';
});
}
}
// 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 membres
_membres = _membreRepository.getAllMembres();
debugPrint('Nombre de membres récupérés: ${_membres.length}');
} catch (e) {
debugPrint('Erreur lors du chargement des secteurs et membres: $e');
}
}
// Charger les passages
void _loadPassages() {
setState(() {
_isLoading = true;
});
try {
// Récupérer les passages
final List<PassageModel> allPassages =
_passageRepository.getAllPassages();
// Stocker les passages originaux pour l'édition
_originalPassages = allPassages;
setState(() {
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Erreur lors du chargement des passages: $e';
});
}
}
// Initialiser les filtres
void _initializeFilters() {
// Par défaut, on n'applique pas de filtre présélectionné
selectedSectorId = null;
selectedSector = 'Tous';
selectedType = 'Tous';
}
// Charger les filtres présélectionnés depuis Hive
void _loadPreselectedFilters() {
try {
// Utiliser Hive directement sans async
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
// Charger le secteur présélectionné
final int? preselectedSectorId =
settingsBox.get('history_selectedSectorId');
final String? preselectedSectorName =
settingsBox.get('history_selectedSectorName');
final int? preselectedTypeId =
settingsBox.get('history_selectedTypeId');
if (preselectedSectorId != null && preselectedSectorName != null) {
selectedSectorId = preselectedSectorId;
selectedSector = preselectedSectorName;
debugPrint(
'Secteur présélectionné: $preselectedSectorName (ID: $preselectedSectorId)');
}
if (preselectedTypeId != null) {
selectedType = preselectedTypeId.toString();
debugPrint('Type de passage présélectionné: $preselectedTypeId');
}
// Nettoyer les valeurs après utilisation pour ne pas les réutiliser la prochaine fois
settingsBox.delete('history_selectedSectorId');
settingsBox.delete('history_selectedSectorName');
settingsBox.delete('history_selectedTypeId');
}
} catch (e) {
debugPrint('Erreur lors du chargement des filtres présélectionnés: $e');
}
}
@override
Widget build(BuildContext context) {
// Afficher un widget de chargement ou d'erreur si nécessaire
if (_isLoading) {
return Stack(
children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox(
width: double.infinity, height: double.infinity),
),
),
const Center(
child: CircularProgressIndicator(),
),
],
);
}
if (_errorMessage.isNotEmpty) {
return _buildErrorWidget(_errorMessage);
}
// Retourner le widget principal avec les données chargées
return Stack(
children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child:
const SizedBox(width: double.infinity, height: double.infinity),
),
),
// Contenu de la page
LayoutBuilder(
builder: (context, constraints) {
// Padding responsive : réduit sur mobile pour maximiser l'espace
final screenWidth = MediaQuery.of(context).size.width;
final horizontalPadding = screenWidth < 600 ? 8.0 : 16.0;
final verticalPadding = 16.0;
return Padding(
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
vertical: verticalPadding,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Widget de liste des passages avec ValueListenableBuilder
Expanded(
child: ValueListenableBuilder(
valueListenable:
Hive.box<PassageModel>(AppKeys.passagesBoxName)
.listenable(),
builder:
(context, Box<PassageModel> passagesBox, child) {
// Reconvertir les passages à chaque changement
final List<PassageModel> allPassages =
passagesBox.values.toList();
// Convertir et formater les passages
final formattedPassages = _formatPassagesForWidget(
allPassages,
_sectorRepository,
_membreRepository);
// Récupérer les UserModel depuis les MembreModel
final users = _membres.map((membre) {
return userRepository.getUserById(membre.id);
}).where((user) => user != null).toList();
return PassagesListWidget(
// Données
passages: formattedPassages,
// Activation des filtres
showFilters: true,
showSearch: true,
showTypeFilter: true,
showPaymentFilter: true,
showSectorFilter: true,
showUserFilter: true,
showPeriodFilter: true,
// Données pour les filtres
sectors: _sectors,
members: users.cast<UserModel>(),
// Bouton d'ajout
showAddButton: true,
onAddPassage: () async {
// Ouvrir le dialogue de création de passage
await showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return PassageFormDialog(
title: 'Nouveau passage',
passageRepository: _passageRepository,
userRepository: _userRepository,
operationRepository: operationRepository,
onSuccess: () {
// Le widget se rafraîchira automatiquement via ValueListenableBuilder
},
);
},
);
},
sortingButtons: Row(
children: [
// Bouton tri par date avec icône calendrier
IconButton(
icon: Icon(
Icons.calendar_today,
size: 20,
color: _currentSort ==
PassageSortType.dateDesc ||
_currentSort ==
PassageSortType.dateAsc
? Theme.of(context).colorScheme.primary
: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
tooltip:
_currentSort == PassageSortType.dateAsc
? 'Tri par date (ancien en premier)'
: 'Tri par date (récent en premier)',
onPressed: () {
setState(() {
if (_currentSort ==
PassageSortType.dateDesc) {
_currentSort = PassageSortType.dateAsc;
} else {
_currentSort = PassageSortType.dateDesc;
}
});
},
),
// Indicateur de direction pour la date
if (_currentSort == PassageSortType.dateDesc ||
_currentSort == PassageSortType.dateAsc)
Icon(
_currentSort == PassageSortType.dateAsc
? Icons.arrow_upward
: Icons.arrow_downward,
size: 14,
color:
Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 4),
// Bouton tri par adresse avec icône maison
IconButton(
icon: Icon(
Icons.home,
size: 20,
color: _currentSort ==
PassageSortType.addressDesc ||
_currentSort ==
PassageSortType.addressAsc
? Theme.of(context).colorScheme.primary
: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
tooltip:
_currentSort == PassageSortType.addressAsc
? 'Tri par adresse (A-Z)'
: 'Tri par adresse (Z-A)',
onPressed: () {
setState(() {
if (_currentSort ==
PassageSortType.addressAsc) {
_currentSort =
PassageSortType.addressDesc;
} else {
_currentSort =
PassageSortType.addressAsc;
}
});
},
),
// Indicateur de direction pour l'adresse
if (_currentSort ==
PassageSortType.addressDesc ||
_currentSort == PassageSortType.addressAsc)
Icon(
_currentSort == PassageSortType.addressAsc
? Icons.arrow_upward
: Icons.arrow_downward,
size: 14,
color:
Theme.of(context).colorScheme.primary,
),
],
),
// Actions
showActions: true,
// Le widget gère maintenant le flux conditionnel par défaut
onPassageSelected: null,
onReceiptView: (passage) {
_showReceiptDialog(context, passage);
},
onDetailsView: (passage) {
_showDetailsDialog(context, passage);
},
onPassageEdit: (passage) {
// Action pour modifier le passage
},
onPassageDelete: (passage) {
_showDeleteConfirmationDialog(passage);
},
);
},
),
),
],
),
);
},
),
],
);
}
// Widget d'erreur pour afficher un message d'erreur
Widget _buildErrorWidget(String message) {
return Stack(
children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child:
const SizedBox(width: double.infinity, height: double.infinity),
),
),
Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Colors.red,
size: 64,
),
const SizedBox(height: 16),
Text(
'Erreur',
style: TextStyle(
fontSize: AppTheme.r(context, 24),
fontWeight: FontWeight.bold,
color: Colors.red[700],
),
),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(fontSize: AppTheme.r(context, 16)),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
// Recharger la page
setState(() {});
},
child: const Text('Réessayer'),
),
],
),
),
),
],
);
}
// Convertir les passages du modèle Hive vers le format attendu par le widget
List<Map<String, dynamic>> _formatPassagesForWidget(
List<PassageModel> passages,
SectorRepository sectorRepository,
MembreRepository membreRepository) {
return passages.map((passage) {
// 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 le membre associé au passage
final MembreModel? membre =
membreRepository.getMembreById(passage.fkUser);
// Construire l'adresse complète
final String address =
'${passage.numero} ${passage.rue}${passage.rueBis.isNotEmpty ? ' ${passage.rueBis}' : ''}, ${passage.ville}';
// Déterminer si le passage a une erreur d'envoi de reçu
final bool hasError = passage.emailErreur.isNotEmpty;
// Récupérer l'ID de l'utilisateur courant pour déterminer la propriété
final currentUserId = _userRepository.getCurrentUser()?.id;
return {
'id': passage.id,
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': membre?.name ?? 'Membre inconnu',
'type': passage.fkType,
'amount': double.tryParse(passage.montant) ?? 0.0,
'payment': passage.fkTypeReglement,
'email': passage.email,
'hasReceipt': passage.nomRecu.isNotEmpty,
'hasError': hasError,
'notes': passage.remarque,
'name': passage.name,
'phone': passage.phone,
'montant': passage.montant,
'remarque': passage.remarque,
// Autres champs utiles
'fkOperation': passage.fkOperation,
'passedAt': passage.passedAt,
'lastSyncedAt': passage.lastSyncedAt,
'isActive': passage.isActive,
'isSynced': passage.isSynced,
'isOwnedByCurrentUser':
passage.fkUser == currentUserId, // Ajout du champ pour le widget
};
}).toList();
}
void _showReceiptDialog(BuildContext context, Map<String, dynamic> passage) {
final int passageId = passage['id'] as int;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Reçu du passage #$passageId'),
content: const SizedBox(
width: 500,
height: 600,
child: Center(
child: Text('Aperçu du reçu PDF'),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
ElevatedButton(
onPressed: () {
// Action pour télécharger le reçu
Navigator.pop(context);
},
child: const Text('Télécharger'),
),
],
),
);
}
// Méthode pour conserver l'ancienne _showDetailsDialog pour les autres usages
void _showDetailsDialog(BuildContext context, Map<String, dynamic> passage) {
final int passageId = passage['id'] as int;
final DateTime date = passage['date'] as DateTime;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Détails du passage #$passageId'),
content: SizedBox(
width: 500,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildDetailRow('Date',
'${date.day}/${date.month}/${date.year} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}'),
_buildDetailRow('Adresse', passage['address'] as String),
_buildDetailRow('Secteur', passage['sector'] as String),
_buildDetailRow('Collecteur', passage['user'] as String),
_buildDetailRow(
'Type',
AppKeys.typesPassages[passage['type']]?['titre'] ??
'Inconnu'),
_buildDetailRow('Montant', '${passage['amount']}'),
_buildDetailRow(
'Mode de paiement',
AppKeys.typesReglements[passage['payment']]?['titre'] ??
'Inconnu'),
_buildDetailRow('Email', passage['email'] as String),
_buildDetailRow(
'Reçu envoyé', passage['hasReceipt'] ? 'Oui' : 'Non'),
_buildDetailRow(
'Erreur d\'envoi', passage['hasError'] ? 'Oui' : 'Non'),
_buildDetailRow(
'Notes',
(passage['notes'] as String).isEmpty
? '-'
: passage['notes'] as String),
const SizedBox(height: 16),
const Text(
'Historique des actions',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHistoryItem(
date,
passage['user'] as String,
'Création du passage',
),
if (passage['hasReceipt'])
_buildHistoryItem(
date.add(const Duration(minutes: 5)),
'Système',
'Envoi du reçu par email',
),
if (passage['hasError'])
_buildHistoryItem(
date.add(const Duration(minutes: 6)),
'Système',
'Erreur lors de l\'envoi du reçu',
),
],
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
),
);
}
// Méthode extraite pour ouvrir le dialog de modification
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 150,
child: Text(
'$label :',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
child: Text(value),
),
],
),
);
}
Widget _buildHistoryItem(DateTime date, String user, String action) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${date.day}/${date.month}/${date.year} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}',
style: TextStyle(
fontWeight: FontWeight.bold, fontSize: AppTheme.r(context, 12)),
),
Text('$user - $action'),
const Divider(),
],
),
);
}
// Afficher le dialog de confirmation de suppression
void _showDeleteConfirmationDialog(Map<String, dynamic> passage) {
final TextEditingController confirmController = TextEditingController();
// Récupérer l'ID du passage et trouver le PassageModel original
final int passageId = passage['id'] as int;
final PassageModel? passageModel =
_originalPassages.where((p) => p.id == passageId).firstOrNull;
if (passageModel == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Impossible de trouver le passage'),
backgroundColor: Colors.red,
),
);
return;
}
final String streetNumber = passageModel.numero;
final String fullAddress =
'${passageModel.numero} ${passageModel.rueBis} ${passageModel.rue}'
.trim();
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red, size: 28),
SizedBox(width: 8),
Text('Confirmation de suppression'),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ATTENTION : Cette action est irréversible !',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red,
fontSize: AppTheme.r(context, 16),
),
),
const SizedBox(height: 16),
Text(
'Vous êtes sur le point de supprimer définitivement le passage :',
style: TextStyle(color: Colors.grey[800]),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[300]!),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fullAddress.isEmpty ? 'Adresse inconnue' : fullAddress,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: AppTheme.r(context, 14),
),
),
const SizedBox(height: 4),
if (passage['user'] != null)
Text(
'Collecteur: ${passage['user']}',
style: TextStyle(
fontSize: AppTheme.r(context, 12),
color: Colors.grey[600],
),
),
if (passage['date'] != null)
Text(
'Date: ${_formatDate(passage['date'] as DateTime)}',
style: TextStyle(
fontSize: AppTheme.r(context, 12),
color: Colors.grey[600],
),
),
],
),
),
const SizedBox(height: 20),
const Text(
'Pour confirmer la suppression, veuillez saisir le numéro de rue de ce passage :',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 12),
TextField(
controller: confirmController,
decoration: InputDecoration(
labelText: 'Numéro de rue',
hintText: streetNumber.isNotEmpty
? 'Ex: $streetNumber'
: 'Saisir le numéro',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.home),
),
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.characters,
),
],
),
),
actions: [
TextButton(
onPressed: () {
confirmController.dispose();
Navigator.of(dialogContext).pop();
},
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () async {
// Vérifier que le numéro saisi correspond
final enteredNumber = confirmController.text.trim();
if (enteredNumber.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez saisir le numéro de rue'),
backgroundColor: Colors.orange,
),
);
return;
}
if (streetNumber.isNotEmpty &&
enteredNumber.toUpperCase() != streetNumber.toUpperCase()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le numéro de rue ne correspond pas'),
backgroundColor: Colors.red,
),
);
return;
}
// Fermer le dialog
confirmController.dispose();
Navigator.of(dialogContext).pop();
// Effectuer la suppression
await _deletePassage(passageModel);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Supprimer définitivement'),
),
],
);
},
);
}
// Supprimer un passage
Future<void> _deletePassage(PassageModel passage) async {
try {
// Appeler le repository pour supprimer via l'API
final success = await _passageRepository.deletePassageViaApi(passage.id);
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Passage supprimé avec succès'),
backgroundColor: Colors.green,
),
);
// Pas besoin de recharger, le ValueListenableBuilder
// se rafraîchira automatiquement après la suppression dans Hive
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Erreur lors de la suppression du passage'),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
debugPrint('Erreur suppression passage: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
// Formater une date
String _formatDate(DateTime date) {
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
}
}

View File

@@ -1,589 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/constants/app_keys.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/presentation/widgets/charts/charts.dart';
import 'dart:math' as math;
/// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class AdminStatisticsPage extends StatefulWidget {
const AdminStatisticsPage({super.key});
@override
State<AdminStatisticsPage> createState() => _AdminStatisticsPageState();
}
class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
// Filtres
String _selectedPeriod = 'Jour';
String _selectedSector = 'Tous';
String _selectedMember = 'Tous';
int _daysToShow = 15;
// Liste des périodes
final List<String> _periods = ['Jour', 'Semaine', 'Mois', 'Année'];
// Listes dynamiques pour les secteurs et membres
List<String> _sectors = ['Tous'];
List<String> _members = ['Tous'];
// Listes complètes (non filtrées) pour réinitialisation
List<SectorModel> _allSectors = [];
List<MembreModel> _allMembers = [];
List<UserSectorModel> _userSectors = [];
// Map pour stocker les IDs correspondants
final Map<String, int> _sectorIds = {};
final Map<String, int> _memberIds = {};
@override
void initState() {
super.initState();
_loadData();
}
void _loadData() {
// Charger les secteurs depuis Hive
if (Hive.isBoxOpen(AppKeys.sectorsBoxName)) {
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
_allSectors = sectorsBox.values.toList();
}
// Charger les membres depuis Hive
if (Hive.isBoxOpen(AppKeys.membresBoxName)) {
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
_allMembers = membresBox.values.toList();
}
// Charger les associations user-sector depuis Hive
if (Hive.isBoxOpen(AppKeys.userSectorBoxName)) {
final userSectorBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
_userSectors = userSectorBox.values.toList();
}
// Initialiser les listes avec toutes les données
_updateSectorsList();
_updateMembersList();
}
// Mettre à jour la liste des secteurs (filtrée ou complète)
void _updateSectorsList({int? forMemberId}) {
setState(() {
_sectors = ['Tous'];
_sectorIds.clear();
List<SectorModel> sectorsToShow = _allSectors;
// Si un membre est sélectionné, filtrer les secteurs
if (forMemberId != null) {
final memberSectorIds = _userSectors
.where((us) => us.id == forMemberId)
.map((us) => us.fkSector)
.toSet();
sectorsToShow = _allSectors
.where((sector) => memberSectorIds.contains(sector.id))
.toList();
}
// Ajouter les secteurs à la liste
for (final sector in sectorsToShow) {
_sectors.add(sector.libelle);
_sectorIds[sector.libelle] = sector.id;
}
});
}
// Mettre à jour la liste des membres (filtrée ou complète)
void _updateMembersList({int? forSectorId}) {
setState(() {
_members = ['Tous'];
_memberIds.clear();
List<MembreModel> membersToShow = _allMembers;
// Si un secteur est sélectionné, filtrer les membres
if (forSectorId != null) {
final sectorMemberIds = _userSectors
.where((us) => us.fkSector == forSectorId)
.map((us) => us.id)
.toSet();
membersToShow = _allMembers
.where((member) => sectorMemberIds.contains(member.id))
.toList();
}
// Ajouter les membres à la liste
for (final membre in membersToShow) {
final fullName = '${membre.firstName} ${membre.name}'.trim();
_members.add(fullName);
_memberIds[fullName] = membre.id;
}
});
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
// Utiliser un Builder simple avec listeners pour les boxes
// On écoute les changements et on reconstruit le widget
return Stack(
children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child:
const SizedBox(width: double.infinity, height: double.infinity),
),
),
// Contenu de la page
SingleChildScrollView(
padding: const EdgeInsets.all(AppTheme.spacingL),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Filtres
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(AppTheme.borderRadiusMedium),
),
color: Colors.white, // Fond opaque
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Filtres',
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppTheme.spacingM),
isDesktop
? Column(
children: [
Row(
children: [
Expanded(child: _buildPeriodDropdown()),
const SizedBox(width: AppTheme.spacingM),
Expanded(child: _buildDaysDropdown()),
],
),
const SizedBox(height: AppTheme.spacingM),
Row(
children: [
Expanded(child: _buildSectorDropdown()),
const SizedBox(width: AppTheme.spacingM),
Expanded(child: _buildMemberDropdown()),
],
),
],
)
: Column(
children: [
_buildPeriodDropdown(),
const SizedBox(height: AppTheme.spacingM),
_buildDaysDropdown(),
const SizedBox(height: AppTheme.spacingM),
_buildSectorDropdown(),
const SizedBox(height: AppTheme.spacingM),
_buildMemberDropdown(),
],
),
],
),
),
),
const SizedBox(height: AppTheme.spacingL),
// Graphique d'activité principal
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(AppTheme.borderRadiusMedium),
),
color: Colors.white, // Fond opaque
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Évolution des passages',
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppTheme.spacingM),
ActivityChart(
height: 350,
showAllPassages: _selectedMember == 'Tous', // Afficher tous les passages seulement si "Tous" est sélectionné
title: '',
daysToShow: _daysToShow,
periodType: _selectedPeriod,
userId: _selectedMember != 'Tous'
? _getMemberIdFromName(_selectedMember)
: null,
// Note: Le filtre par secteur nécessite une modification du widget ActivityChart
// Pour filtrer par secteur, il faudrait ajouter un paramètre sectorId au widget
),
],
),
),
),
const SizedBox(height: AppTheme.spacingL),
// Graphiques de répartition
isDesktop
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildChartCard(
'Répartition par type de passage',
PassageSummaryCard(
title: '',
titleColor: AppTheme.primaryColor,
titleIcon: Icons.pie_chart,
height: 300,
useValueListenable: true,
showAllPassages: _selectedMember == 'Tous',
excludePassageTypes: const [
2
], // Exclure "À finaliser"
userId: _selectedMember != 'Tous'
? _getMemberIdFromName(_selectedMember)
: null,
// Note: Le filtre par secteur nécessite une modification du widget PassageSummaryCard
isDesktop:
MediaQuery.of(context).size.width > 800,
),
),
),
const SizedBox(width: AppTheme.spacingM),
Expanded(
child: _buildChartCard(
'Répartition par mode de paiement',
PaymentPieChart(
useValueListenable: true,
showAllPassages: _selectedMember == 'Tous',
userId: _selectedMember != 'Tous'
? _getMemberIdFromName(_selectedMember)
: null,
size: 300,
),
),
),
],
)
: Column(
children: [
_buildChartCard(
'Répartition par type de passage',
PassageSummaryCard(
title: '',
titleColor: AppTheme.primaryColor,
titleIcon: Icons.pie_chart,
height: 300,
useValueListenable: true,
showAllPassages: _selectedMember == 'Tous',
excludePassageTypes: const [
2
], // Exclure "À finaliser"
userId: _selectedMember != 'Tous'
? _getMemberIdFromName(_selectedMember)
: null,
// Note: Le filtre par secteur nécessite une modification du widget PassageSummaryCard
isDesktop: MediaQuery.of(context).size.width > 800,
),
),
const SizedBox(height: AppTheme.spacingM),
_buildChartCard(
'Répartition par mode de paiement',
PaymentPieChart(
useValueListenable: true,
showAllPassages: _selectedMember == 'Tous',
userId: _selectedMember != 'Tous'
? _getMemberIdFromName(_selectedMember)
: null,
size: 300,
),
),
],
),
],
),
),
],
);
}
// Dropdown pour la période
Widget _buildPeriodDropdown() {
return InputDecorator(
decoration: InputDecoration(
labelText: 'Période',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingM,
vertical: AppTheme.spacingS,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedPeriod,
isDense: true,
isExpanded: true,
items: _periods.map((String period) {
return DropdownMenuItem<String>(
value: period,
child: Text(period),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
setState(() {
_selectedPeriod = newValue;
});
}
},
),
),
);
}
// Dropdown pour le nombre de jours
Widget _buildDaysDropdown() {
return InputDecorator(
decoration: InputDecoration(
labelText: 'Nombre de jours',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingM,
vertical: AppTheme.spacingS,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _daysToShow,
isDense: true,
isExpanded: true,
items: [7, 15, 30, 60, 90, 180, 365].map((int days) {
return DropdownMenuItem<int>(
value: days,
child: Text('$days jours'),
);
}).toList(),
onChanged: (int? newValue) {
if (newValue != null) {
setState(() {
_daysToShow = newValue;
});
}
},
),
),
);
}
// Dropdown pour les secteurs
Widget _buildSectorDropdown() {
return InputDecorator(
decoration: InputDecoration(
labelText: 'Secteur',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingM,
vertical: AppTheme.spacingS,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedSector,
isDense: true,
isExpanded: true,
items: _sectors.map((String sector) {
return DropdownMenuItem<String>(
value: sector,
child: Text(sector),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
setState(() {
_selectedSector = newValue;
// Si "Tous" est sélectionné, réinitialiser la liste des membres
if (newValue == 'Tous') {
_updateMembersList();
// Garder le membre sélectionné s'il existe
} else {
// Sinon, filtrer les membres pour ce secteur
final sectorId = _getSectorIdFromName(newValue);
_updateMembersList(forSectorId: sectorId);
// Si le membre actuellement sélectionné n'est pas dans la liste filtrée
if (_selectedMember == 'Tous' || !_members.contains(_selectedMember)) {
// Auto-sélectionner le premier membre du secteur (après "Tous")
// Puisque chaque secteur a au moins un membre, il y aura toujours un membre à sélectionner
if (_members.length > 1) {
_selectedMember = _members[1]; // Index 1 car 0 est "Tous"
}
}
// Si le membre sélectionné est dans la liste, on le garde
// Les graphiques afficheront ses données
}
});
}
},
),
),
);
}
// Dropdown pour les membres
Widget _buildMemberDropdown() {
return InputDecorator(
decoration: InputDecoration(
labelText: 'Membre',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingM,
vertical: AppTheme.spacingS,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedMember,
isDense: true,
isExpanded: true,
items: _members.map((String member) {
return DropdownMenuItem<String>(
value: member,
child: Text(member),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
setState(() {
_selectedMember = newValue;
// Si "Tous" est sélectionné, réinitialiser la liste des secteurs
if (newValue == 'Tous') {
_updateSectorsList();
// On peut réinitialiser le secteur car "Tous" les membres = pas de filtre secteur pertinent
_selectedSector = 'Tous';
} else {
// Sinon, filtrer les secteurs pour ce membre
final memberId = _getMemberIdFromName(newValue);
_updateSectorsList(forMemberId: memberId);
// Si le secteur actuellement sélectionné n'est plus dans la liste, réinitialiser
if (_selectedSector != 'Tous' && !_sectors.contains(_selectedSector)) {
_selectedSector = 'Tous';
}
// Si le secteur est toujours dans la liste, on le garde sélectionné
}
});
}
},
),
),
);
}
// Widget pour envelopper un graphique dans une carte
Widget _buildChartCard(String title, Widget chart) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
color: Colors.white, // Fond opaque
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppTheme.spacingM),
chart,
],
),
),
);
}
// Méthode utilitaire pour obtenir l'ID membre à partir de son nom
int? _getMemberIdFromName(String name) {
if (name == 'Tous') return null;
return _memberIds[name];
}
// Méthode utilitaire pour obtenir l'ID du secteur à partir de son nom
int? _getSectorIdFromName(String name) {
if (name == 'Tous') return null;
return _sectorIds[name];
}
// Méthode pour obtenir tous les IDs des membres d'un secteur
// Méthode pour déterminer quel userId utiliser pour les graphiques
// Méthode pour déterminer si on doit afficher tous les passages
}

View File

@@ -1,13 +1,14 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'dart:convert';
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode, debugPrint;
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:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/services/app_info_service.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/custom_button.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
@@ -163,7 +164,7 @@ class _LoginPageState extends State<LoginPage> {
// Vérification du type de connexion (seulement si Hive est initialisé)
if (widget.loginType == null) {
// Si aucun type n'est spécifié, naviguer vers la splash page
print(
debugPrint(
'LoginPage: Aucun type de connexion spécifié, navigation vers splash page');
WidgetsBinding.instance.addPostFrameCallback((_) {
GoRouter.of(context).go('/');
@@ -171,7 +172,7 @@ class _LoginPageState extends State<LoginPage> {
_loginType = '';
} else {
_loginType = widget.loginType!;
print('LoginPage: Type de connexion utilisé: $_loginType');
debugPrint('LoginPage: Type de connexion utilisé: $_loginType');
}
// En mode web, essayer de détecter le paramètre dans l'URL directement
@@ -222,17 +223,17 @@ class _LoginPageState extends State<LoginPage> {
result.toLowerCase() == 'user') {
setState(() {
_loginType = 'user';
print(
debugPrint(
'LoginPage: Type détecté depuis sessionStorage: $_loginType');
});
}
} catch (e) {
print('LoginPage: Erreur lors de l\'accès au sessionStorage: $e');
debugPrint('LoginPage: Erreur lors de l\'accès au sessionStorage: $e');
}
});
}
} catch (e) {
print('Erreur lors de la récupération des paramètres d\'URL: $e');
debugPrint('Erreur lors de la récupération des paramètres d\'URL: $e');
}
}
@@ -327,7 +328,7 @@ class _LoginPageState extends State<LoginPage> {
@override
Widget build(BuildContext context) {
print('DEBUG BUILD: Reconstruction de LoginPage avec type: $_loginType');
debugPrint('DEBUG BUILD: Reconstruction de LoginPage avec type: $_loginType');
// Utiliser l'instance globale de userRepository
final theme = Theme.of(context);
@@ -565,13 +566,13 @@ class _LoginPageState extends State<LoginPage> {
_formKey.currentState!.validate()) {
// Vérifier que le type de connexion est spécifié
if (_loginType.isEmpty) {
print(
debugPrint(
'Login: Type non spécifié, redirection vers la page de démarrage');
context.go('/');
return;
}
print(
debugPrint(
'Login: Tentative avec type: $_loginType');
final success =
@@ -615,19 +616,37 @@ class _LoginPageState extends State<LoginPage> {
debugPrint(
'Role de l\'utilisateur: $roleValue');
// Redirection simple basée sur le rôle
if (roleValue > 1) {
debugPrint(
'Redirection vers /admin (rôle > 1)');
if (context.mounted) {
context.go('/admin');
}
} else {
debugPrint(
'Redirection vers /user (rôle = 1)');
// Définir le mode d'affichage selon le type de connexion
if (_loginType == 'user') {
// Connexion en mode user : toujours mode user
await CurrentUserService.instance.setDisplayMode('user');
debugPrint('Mode d\'affichage défini: user');
if (context.mounted) {
context.go('/user');
}
} else {
// Connexion en mode admin
if (roleValue >= 2) {
await CurrentUserService.instance.setDisplayMode('admin');
debugPrint('Mode d\'affichage défini: admin');
if (context.mounted) {
context.go('/admin');
}
} else {
// Un user (rôle 1) ne peut pas se connecter en mode admin
debugPrint('Erreur: User (rôle 1) tentant de se connecter en mode admin');
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Accès administrateur non autorisé pour ce compte.'),
backgroundColor: Colors.red,
),
);
}
return;
}
}
} else if (context.mounted) {
ScaffoldMessenger.of(context)
@@ -716,7 +735,7 @@ class _LoginPageState extends State<LoginPage> {
// Vérifier que le type de connexion est spécifié
if (_loginType.isEmpty) {
print(
debugPrint(
'Login: Type non spécifié, redirection vers la page de démarrage');
if (context.mounted) {
context.go('/');
@@ -724,7 +743,7 @@ class _LoginPageState extends State<LoginPage> {
return;
}
print(
debugPrint(
'Login: Tentative avec type: $_loginType');
// Utiliser le nouveau spinner moderne pour la connexion
@@ -773,19 +792,37 @@ class _LoginPageState extends State<LoginPage> {
debugPrint(
'Role de l\'utilisateur: $roleValue');
// Redirection simple basée sur le rôle
if (roleValue > 1) {
debugPrint(
'Redirection vers /admin (rôle > 1)');
if (context.mounted) {
context.go('/admin');
}
} else {
debugPrint(
'Redirection vers /user (rôle = 1)');
// Définir le mode d'affichage selon le type de connexion
if (_loginType == 'user') {
// Connexion en mode user : toujours mode user
await CurrentUserService.instance.setDisplayMode('user');
debugPrint('Mode d\'affichage défini: user');
if (context.mounted) {
context.go('/user');
}
} else {
// Connexion en mode admin
if (roleValue >= 2) {
await CurrentUserService.instance.setDisplayMode('admin');
debugPrint('Mode d\'affichage défini: admin');
if (context.mounted) {
context.go('/admin');
}
} else {
// Un user (rôle 1) ne peut pas se connecter en mode admin
debugPrint('Erreur: User (rôle 1) tentant de se connecter en mode admin');
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Accès administrateur non autorisé pour ce compte.'),
backgroundColor: Colors.red,
),
);
}
return;
}
}
} else if (context.mounted) {
ScaffoldMessenger.of(context)
@@ -998,8 +1035,8 @@ class _LoginPageState extends State<LoginPage> {
final baseUrl = Uri.base.origin;
final apiUrl = '$baseUrl/api/lostpassword';
print('Envoi de la requête à: $apiUrl');
print('Email: ${emailController.text.trim()}');
debugPrint('Envoi de la requête à: $apiUrl');
debugPrint('Email: ${emailController.text.trim()}');
http.Response? response;
@@ -1013,15 +1050,15 @@ class _LoginPageState extends State<LoginPage> {
}),
);
print('Réponse reçue: ${response.statusCode}');
print('Corps de la réponse: ${response.body}');
debugPrint('Réponse reçue: ${response.statusCode}');
debugPrint('Corps de la réponse: ${response.body}');
// 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(
debugPrint(
'Tentative avec URL alternative: $alternativeUrl');
final alternativeResponse = await http.post(
@@ -1032,9 +1069,9 @@ class _LoginPageState extends State<LoginPage> {
}),
);
print(
debugPrint(
'Réponse alternative reçue: ${alternativeResponse.statusCode}');
print(
debugPrint(
'Corps de la réponse alternative: ${alternativeResponse.body}');
// Si la réponse alternative est un succès, utiliser cette réponse
@@ -1043,7 +1080,7 @@ class _LoginPageState extends State<LoginPage> {
}
}
} catch (e) {
print(
debugPrint(
'Erreur lors de l\'envoi de la requête: $e');
throw Exception('Erreur de connexion: $e');
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
import 'package:go_router/go_router.dart';
import 'dart:math' as math;
import 'dart:convert';
@@ -256,7 +256,7 @@ class _RegisterPageState extends State<RegisterPage> {
});
}
} catch (e) {
print('Erreur lors de la récupération des villes: $e');
debugPrint('Erreur lors de la récupération des villes: $e');
setState(() {
_cities = [];
_isLoadingCities = false;

View File

@@ -10,9 +10,14 @@ import 'dart:math' as math;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:shared_preferences/shared_preferences.dart';
// Import conditionnel pour le web
import 'package:universal_html/html.dart' as html;
// Import des repositories pour reset du cache
import 'package:geosector_app/app.dart' show passageRepository, sectorRepository, membreRepository;
// Import des services pour la gestion de session F5
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/services/data_loading_service.dart';
class SplashPage extends StatefulWidget {
/// Action à effectuer après l'initialisation (login ou register)
@@ -130,18 +135,29 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
});
}
// Étape 2: Sauvegarder les données de pending_requests
debugPrint('💾 Sauvegarde des requêtes en attente...');
// Étape 2: Sauvegarder les données critiques (pending_requests + app_version)
debugPrint('💾 Sauvegarde des données critiques...');
List<dynamic>? pendingRequests;
String? savedAppVersion;
try {
// Sauvegarder pending_requests
if (Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
final pendingBox = Hive.box(AppKeys.pendingRequestsBoxName);
pendingRequests = pendingBox.values.toList();
debugPrint('📊 ${pendingRequests.length} requêtes en attente sauvegardées');
await pendingBox.close();
}
// Sauvegarder app_version pour éviter de perdre l'info de version
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
savedAppVersion = settingsBox.get('app_version') as String?;
if (savedAppVersion != null) {
debugPrint('📦 Version sauvegardée: $savedAppVersion');
}
}
} catch (e) {
debugPrint('⚠️ Erreur lors de la sauvegarde des requêtes: $e');
debugPrint('⚠️ Erreur lors de la sauvegarde: $e');
}
if (mounted) {
@@ -194,7 +210,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
await Future.delayed(const Duration(milliseconds: 500));
await Hive.initFlutter();
// Étape 6: Restaurer les requêtes en attente
// Étape 6: Restaurer les données critiques
if (pendingRequests != null && pendingRequests.isNotEmpty) {
debugPrint('♻️ Restauration des requêtes en attente...');
final pendingBox = await Hive.openBox(AppKeys.pendingRequestsBoxName);
@@ -204,6 +220,14 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
debugPrint('${pendingRequests.length} requêtes restaurées');
}
// Restaurer app_version pour maintenir la détection de changement de version
if (savedAppVersion != null) {
debugPrint('♻️ Restauration de la version...');
final settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
await settingsBox.put('app_version', savedAppVersion);
debugPrint('✅ Version restaurée: $savedAppVersion');
}
if (mounted) {
setState(() {
_statusMessage = "Nettoyage terminé !";
@@ -211,13 +235,6 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
});
}
// Étape 7: Sauvegarder la nouvelle version
if (!manual && kIsWeb) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('app_version', _appVersion);
debugPrint('💾 Version $_appVersion sauvegardée');
}
debugPrint('🎉 === NETTOYAGE TERMINÉ AVEC SUCCÈS === 🎉');
// Petit délai pour voir le message de succès
@@ -250,6 +267,206 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
}
}
/// Réinitialise le cache de tous les repositories après nettoyage complet
void _resetAllRepositoriesCache() {
try {
debugPrint('🔄 === RESET DU CACHE DES REPOSITORIES === 🔄');
// Reset du cache des 3 repositories qui utilisent le pattern de cache
passageRepository.resetCache();
sectorRepository.resetCache();
membreRepository.resetCache();
debugPrint('✅ Cache de tous les repositories réinitialisé');
} catch (e) {
debugPrint('⚠️ Erreur lors du reset des caches: $e');
// Ne pas faire échouer le processus si le reset échoue
}
}
/// Détecte et gère le refresh (F5) avec session existante
/// Retourne true si une session a été restaurée, false sinon
Future<bool> _handleSessionRefreshIfNeeded() async {
if (!kIsWeb) {
debugPrint('📱 Plateforme mobile - pas de gestion F5');
return false;
}
try {
debugPrint('🔍 Vérification d\'une session existante (F5)...');
// Charger l'utilisateur depuis Hive
await CurrentUserService.instance.loadFromHive();
final isLoggedIn = CurrentUserService.instance.isLoggedIn;
final displayMode = CurrentUserService.instance.displayMode;
final sessionId = CurrentUserService.instance.sessionId;
if (!isLoggedIn || sessionId == null) {
debugPrint(' Aucune session active - affichage normal de la splash');
return false;
}
debugPrint('🔄 Session active détectée - mode: $displayMode');
debugPrint('🔄 Rechargement des données depuis l\'API...');
if (mounted) {
setState(() {
_statusMessage = "Restauration de votre session...";
_progress = 0.85;
});
}
// Configurer ApiService avec le sessionId existant
ApiService.instance.setSessionId(sessionId);
// Appeler le nouvel endpoint API pour restaurer la session
final response = await ApiService.instance.get(
'/api/user/session',
queryParameters: {'mode': displayMode},
);
// Gestion des codes de retour HTTP
final statusCode = response.statusCode ?? 0;
final data = response.data as Map<String, dynamic>?;
switch (statusCode) {
case 200:
// Succès - traiter les données
if (data == null || data['success'] != true) {
debugPrint('❌ Format de réponse invalide (200 mais pas success=true)');
await CurrentUserService.instance.clearUser();
return false;
}
debugPrint('✅ Données reçues de l\'API, traitement...');
if (mounted) {
setState(() {
_statusMessage = "Chargement de vos données...";
_progress = 0.90;
});
}
// Traiter les données avec DataLoadingService
final apiData = data['data'] as Map<String, dynamic>?;
if (apiData == null) {
debugPrint('❌ Données manquantes dans la réponse');
await CurrentUserService.instance.clearUser();
return false;
}
await DataLoadingService.instance.processLoginData(apiData);
debugPrint('✅ Session restaurée avec succès');
break;
case 400:
// Paramètre mode invalide - erreur technique
debugPrint('❌ Paramètre mode invalide: $displayMode');
await CurrentUserService.instance.clearUser();
if (mounted) {
setState(() {
_statusMessage = "Erreur technique - veuillez vous reconnecter";
});
}
return false;
case 401:
// Session invalide ou expirée
debugPrint('⚠️ Session invalide ou expirée');
await CurrentUserService.instance.clearUser();
if (mounted) {
setState(() {
_statusMessage = "Session expirée - veuillez vous reconnecter";
});
}
return false;
case 403:
// Accès interdit (membre → admin) ou entité inactive
final message = data?['message'] ?? 'Accès interdit';
debugPrint('🚫 Accès interdit: $message');
await CurrentUserService.instance.clearUser();
if (mounted) {
setState(() {
_statusMessage = "Accès interdit - veuillez vous reconnecter";
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 5),
),
);
}
return false;
case 500:
// Erreur serveur
final message = data?['message'] ?? 'Erreur serveur';
debugPrint('❌ Erreur serveur: $message');
if (mounted) {
setState(() {
_statusMessage = "Erreur serveur - veuillez réessayer";
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur serveur: $message'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
),
);
}
// Ne pas effacer la session en cas d'erreur serveur
return false;
default:
// Code de retour inattendu
debugPrint('❌ Code HTTP inattendu: $statusCode');
await CurrentUserService.instance.clearUser();
return false;
}
if (mounted) {
setState(() {
_statusMessage = "Session restaurée !";
_progress = 0.95;
});
}
// Petit délai pour voir le message
await Future.delayed(const Duration(milliseconds: 500));
// Rediriger vers la bonne interface selon le mode
if (!mounted) return true;
if (displayMode == 'admin') {
debugPrint('🔀 Redirection vers interface admin');
context.go('/admin/home');
} else {
debugPrint('🔀 Redirection vers interface user');
context.go('/user/field-mode');
}
return true;
} catch (e) {
debugPrint('❌ Erreur lors de la restauration de session: $e');
// En cas d'erreur, effacer la session invalide
await CurrentUserService.instance.clearUser();
if (mounted) {
setState(() {
_statusMessage = "Erreur de restauration - veuillez vous reconnecter";
_progress = 0.0;
});
}
return false;
}
}
/// Vérifie si une nouvelle version est disponible et nettoie si nécessaire
Future<void> _checkVersionAndCleanIfNeeded() async {
if (!kIsWeb) {
@@ -258,9 +475,14 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
}
try {
final prefs = await SharedPreferences.getInstance();
final lastVersion = prefs.getString('app_version') ?? '';
String lastVersion = '';
// Lire la version depuis Hive settings
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
lastVersion = settingsBox.get('app_version', defaultValue: '') as String;
}
debugPrint('🔍 Vérification de version:');
debugPrint(' Version stockée: $lastVersion');
debugPrint(' Version actuelle: $_appVersion');
@@ -269,7 +491,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
if (lastVersion.isNotEmpty && lastVersion != _appVersion) {
debugPrint('🆕 NOUVELLE VERSION DÉTECTÉE !');
debugPrint(' Migration de $lastVersion vers $_appVersion');
if (mounted) {
setState(() {
_statusMessage = "Nouvelle version détectée, mise à jour...";
@@ -278,10 +500,17 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
// Effectuer le nettoyage automatique
await _performSelectiveCleanup(manual: false);
// Reset du cache des repositories après nettoyage
_resetAllRepositoriesCache();
} else if (lastVersion.isEmpty) {
// Première installation
debugPrint('🎉 Première installation détectée');
await prefs.setString('app_version', _appVersion);
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.put('app_version', _appVersion);
debugPrint('💾 Version initiale sauvegardée dans Hive: $_appVersion');
}
} else {
debugPrint('✅ Même version - pas de nettoyage nécessaire');
}
@@ -325,9 +554,6 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
try {
debugPrint('🚀 Début de l\'initialisation complète de l\'application...');
// Étape 0: Vérifier et nettoyer si nouvelle version (Web uniquement)
await _checkVersionAndCleanIfNeeded();
// Étape 1: Vérification des permissions GPS (obligatoire) - 0 à 10%
if (!kIsWeb) {
if (mounted) {
@@ -402,7 +628,20 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
// Étape 3: Ouverture des Box - 60 à 80%
await HiveService.instance.ensureBoxesAreOpen();
// NOUVEAU : Vérifier et nettoyer si nouvelle version (Web uniquement)
// Maintenant que les boxes sont ouvertes, on peut vérifier la version dans Hive
await _checkVersionAndCleanIfNeeded();
// NOUVEAU : Détecter et gérer le F5 (refresh de page web avec session existante)
final sessionRestored = await _handleSessionRefreshIfNeeded();
if (sessionRestored) {
// Session restaurée avec succès, on arrête ici
// L'utilisateur a été redirigé vers son interface
debugPrint('✅ Session restaurée via F5 - fin de l\'initialisation');
return;
}
// Gérer la box pending_requests séparément pour préserver les données
try {
debugPrint('📦 Gestion de la box pending_requests...');
@@ -907,62 +1146,66 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
const SizedBox(height: 8),
// Bouton de nettoyage du cache (en noir)
AnimatedOpacity(
opacity: _showButtons ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: TextButton.icon(
onPressed: _isCleaningCache ? null : () async {
// Confirmation avant nettoyage
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Nettoyer le cache ?'),
content: const Text(
'Cette action va :\n'
'• Supprimer toutes les données locales\n'
'Préserver les requêtes en attente\n'
'Forcer le rechargement de l\'application\n\n'
'Continuer ?'
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
// Bouton de nettoyage du cache (Web uniquement)
if (kIsWeb)
AnimatedOpacity(
opacity: _showButtons ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: TextButton.icon(
onPressed: _isCleaningCache ? null : () async {
// Confirmation avant nettoyage
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Nettoyer le cache ?'),
content: const Text(
'Cette action va :\n'
'Supprimer toutes les données locales\n'
'Préserver les requêtes en attente\n'
'• Forcer le rechargement de l\'application\n\n'
'Continuer ?'
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
child: const Text('Nettoyer'),
),
],
),
);
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
child: const Text('Nettoyer'),
),
],
),
);
if (confirm == true) {
debugPrint('👤 Utilisateur a demandé un nettoyage manuel');
await _performSelectiveCleanup(manual: true);
// Après le nettoyage, relancer l'initialisation
_startInitialization();
}
},
icon: Icon(
Icons.cleaning_services,
size: 18,
color: _isCleaningCache ? Colors.grey : Colors.black87,
),
label: Text(
_isCleaningCache ? 'Nettoyage...' : 'Nettoyer le cache',
style: TextStyle(
if (confirm == true) {
debugPrint('👤 Utilisateur a demandé un nettoyage manuel');
await _performSelectiveCleanup(manual: true);
// Reset du cache des repositories après nettoyage
_resetAllRepositoriesCache();
// Après le nettoyage, relancer l'initialisation
_startInitialization();
}
},
icon: Icon(
Icons.cleaning_services,
size: 18,
color: _isCleaningCache ? Colors.grey : Colors.black87,
fontWeight: FontWeight.w500,
),
label: Text(
_isCleaningCache ? 'Nettoyage...' : 'Nettoyer le cache',
style: TextStyle(
color: _isCleaningCache ? Colors.grey : Colors.black87,
fontWeight: FontWeight.w500,
),
),
),
),
),
],
const Spacer(flex: 1),

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
import 'package:geosector_app/presentation/admin/admin_amicale_page.dart';
import 'package:geosector_app/app.dart';
/// Page de l'amicale unifiée utilisant AppScaffold
/// Accessible uniquement aux administrateurs (rôle 2)
class AmicalePage extends StatelessWidget {
const AmicalePage({super.key});
@override
Widget build(BuildContext context) {
// Vérifier le rôle pour l'accès
final currentUser = userRepository.getCurrentUser();
final userRole = currentUser?.role ?? 1;
// Vérifier que l'utilisateur a le rôle 2 (admin amicale)
if (userRole < 2) {
// Rediriger ou afficher un message d'erreur
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacementNamed('/user/dashboard');
});
return const SizedBox.shrink();
}
return AppScaffold(
key: const ValueKey('amicale_scaffold_admin'),
selectedIndex: 4, // Amicale est l'index 4
pageTitle: 'Amicale & membres',
body: AdminAmicalePage(
userRepository: userRepository,
amicaleRepository: amicaleRepository,
membreRepository: membreRepository,
passageRepository: passageRepository,
operationRepository: operationRepository,
),
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
import 'package:geosector_app/presentation/user/user_field_mode_page.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
/// Page de mode terrain unifiée utilisant AppScaffold (users seulement)
class FieldModePage extends StatelessWidget {
const FieldModePage({super.key});
@override
Widget build(BuildContext context) {
// Déterminer le mode d'affichage (prend en compte le mode choisi à la connexion)
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
// Rediriger les admins vers le dashboard
if (isAdmin) {
// Les admins ne devraient pas avoir accès à cette page
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacementNamed('/admin');
});
return const SizedBox.shrink();
}
return AppScaffold(
key: const ValueKey('field_mode_scaffold_user'),
selectedIndex: 4, // Field mode est l'index 4 pour les users (après Dashboard, Historique, Messages, Carte)
pageTitle: 'Mode Terrain',
showBackground: false, // Pas de fond inutile, le mode terrain a son propre fond
body: const UserFieldModePage(), // Réutiliser la page existante
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,276 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/presentation/widgets/sector_distribution_card.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
import 'package:geosector_app/presentation/widgets/members_board_passages.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
/// Widget de contenu du tableau de bord unifié (sans scaffold)
class HomeContent extends StatefulWidget {
const HomeContent({super.key});
@override
State<HomeContent> createState() => _HomeContentState();
}
class _HomeContentState extends State<HomeContent> {
// Détection du rôle
late final bool isAdmin;
late final int currentUserId;
@override
void initState() {
super.initState();
// Déterminer le rôle de l'utilisateur et le mode d'affichage
final currentUser = userRepository.getCurrentUser();
isAdmin = CurrentUserService.instance.shouldShowAdminUI;
currentUserId = currentUser?.id ?? 0;
}
@override
Widget build(BuildContext context) {
debugPrint('Building HomeContent (isAdmin: $isAdmin)');
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
// Récupérer l'opération en cours
final currentOperation = userRepository.getCurrentOperation();
// Titre dynamique avec l'ID et le nom de l'opération
final String title = currentOperation != null
? 'Opération #${currentOperation.id} ${currentOperation.name}'
: 'Opération';
// Retourner seulement le contenu (sans scaffold)
return SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
vertical: AppTheme.spacingL,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppTheme.spacingM),
// LIGNE 1 : Graphiques de répartition (type de passage et mode de paiement)
isDesktop
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildPassageTypeCard(context),
),
const SizedBox(width: AppTheme.spacingM),
Expanded(
child: _buildPaymentTypeCard(context),
),
],
)
: Column(
children: [
_buildPassageTypeCard(context),
const SizedBox(height: AppTheme.spacingM),
_buildPaymentTypeCard(context),
],
),
const SizedBox(height: AppTheme.spacingL),
// Tableau détaillé des membres - uniquement pour admin sur Web
if (isAdmin && kIsWeb) ...[
const MembersBoardPassages(
height: 700,
),
const SizedBox(height: AppTheme.spacingL),
],
// LIGNE 2 : Carte de répartition par secteur
// Le widget filtre automatiquement selon le rôle de l'utilisateur
ValueListenableBuilder<Box<SectorModel>>(
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
builder: (context, Box<SectorModel> box, child) {
// Filtrer les secteurs pour les users
int sectorCount;
if (isAdmin) {
sectorCount = box.values.length;
} else {
final userSectors = userRepository.getUserSectors();
sectorCount = userSectors.length;
}
return SectorDistributionCard(
title: '$sectorCount secteur${sectorCount > 1 ? 's' : ''}',
height: 500,
);
},
),
const SizedBox(height: AppTheme.spacingL),
// LIGNE 3 : Graphique d'activité
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
child: ActivityChart(
height: 350,
showAllPassages: isAdmin, // Admin voit tout, user voit tous les passages de ses secteurs
title: isAdmin
? 'Passages réalisés par jour (15 derniers jours)'
: 'Passages de mes secteurs par jour (15 derniers jours)',
daysToShow: 15,
),
),
const SizedBox(height: AppTheme.spacingL),
// Actions rapides - uniquement pour admin sur le web
if (isAdmin && kIsWeb) ...[
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
padding: const EdgeInsets.all(AppTheme.spacingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Actions sur cette opération',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: AppTheme.spacingM),
Wrap(
spacing: AppTheme.spacingM,
runSpacing: AppTheme.spacingM,
children: [
_buildActionButton(
context,
'Exporter les données',
Icons.file_download_outlined,
AppTheme.primaryColor,
() {},
),
_buildActionButton(
context,
'Gérer les secteurs',
Icons.map_outlined,
AppTheme.accentColor,
() {},
),
],
),
],
),
),
],
],
),
);
}
// Construit la carte de répartition par type de passage
Widget _buildPassageTypeCard(BuildContext context) {
return PassageSummaryCard(
title: isAdmin ? 'Passages' : 'Passages de mes secteurs',
titleColor: AppTheme.primaryColor,
titleIcon: Icons.route,
height: 300,
useValueListenable: true,
showAllPassages: isAdmin, // Admin voit tout, user voit tous les passages de ses secteurs
userId: null, // Pas de filtre par userId, on filtre par secteurs assignés
excludePassageTypes: const [], // Afficher tous les types de passages
customTotalDisplay: (total) => '$total passage${total > 1 ? 's' : ''}',
isDesktop: MediaQuery.of(context).size.width > 800,
backgroundIcon: Icons.route,
backgroundIconColor: AppTheme.primaryColor,
backgroundIconOpacity: 0.07,
backgroundIconSize: 180,
);
}
// Construit la carte de répartition par mode de paiement
Widget _buildPaymentTypeCard(BuildContext context) {
return PaymentSummaryCard(
title: isAdmin ? 'Règlements' : 'Mes règlements',
titleColor: AppTheme.buttonSuccessColor,
titleIcon: Icons.euro,
height: 300,
useValueListenable: true,
showAllPayments: isAdmin, // Admin voit tout, user voit uniquement ses règlements (fkUser)
userId: null, // Le filtre fkUser est géré automatiquement dans PaymentSummaryCard
customTotalDisplay: (total) => '${total.toStringAsFixed(2)}',
isDesktop: MediaQuery.of(context).size.width > 800,
backgroundIcon: Icons.euro,
backgroundIconColor: AppTheme.primaryColor,
backgroundIconOpacity: 0.07,
backgroundIconSize: 180,
);
}
Widget _buildActionButton(
BuildContext context,
String label,
IconData icon,
Color color,
VoidCallback onPressed,
) {
return ElevatedButton.icon(
onPressed: onPressed,
icon: Icon(icon),
label: Text(label),
style: ElevatedButton.styleFrom(
backgroundColor: color,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingL,
vertical: AppTheme.spacingM,
),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
),
);
}
}
/// Page autonome du tableau de bord unifié utilisant AppScaffold
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
// Utiliser le mode d'affichage pour déterminer l'UI
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
return AppScaffold(
key: ValueKey('home_scaffold_${isAdmin ? 'admin' : 'user'}'),
selectedIndex: 0, // Dashboard/Home est toujours l'index 0
pageTitle: 'Tableau de bord',
body: const HomeContent(),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
import 'package:geosector_app/presentation/chat/chat_communication_page.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
/// Page de messages unifiée utilisant AppScaffold
class MessagesPage extends StatelessWidget {
const MessagesPage({super.key});
@override
Widget build(BuildContext context) {
// Utiliser le mode d'affichage pour déterminer l'UI
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
return AppScaffold(
key: ValueKey('messages_scaffold_${isAdmin ? 'admin' : 'user'}'),
selectedIndex: 3, // Messages est l'index 3
pageTitle: 'Messages',
showBackground: false, // Pas de fond inutile, le chat a son propre fond
body: const ChatCommunicationPage(), // Réutiliser la page de chat existante
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
import 'package:geosector_app/presentation/admin/admin_operations_page.dart';
import 'package:geosector_app/app.dart';
/// Page des opérations unifiée utilisant AppScaffold
/// Accessible uniquement aux administrateurs (rôle 2)
class OperationsPage extends StatelessWidget {
const OperationsPage({super.key});
@override
Widget build(BuildContext context) {
// Vérifier le rôle pour l'accès
final currentUser = userRepository.getCurrentUser();
final userRole = currentUser?.role ?? 1;
// Vérifier que l'utilisateur a le rôle 2 (admin amicale)
if (userRole < 2) {
// Rediriger ou afficher un message d'erreur
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacementNamed('/user/dashboard');
});
return const SizedBox.shrink();
}
return AppScaffold(
key: const ValueKey('operations_scaffold_admin'),
selectedIndex: 5, // Opérations est l'index 5
pageTitle: 'Opérations',
body: AdminOperationsPage(
operationRepository: operationRepository,
userRepository: userRepository,
),
);
}
}

View File

@@ -1,266 +0,0 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.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/passages/passages_list_widget.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
class UserDashboardHomePage extends StatefulWidget {
const UserDashboardHomePage({super.key});
@override
State<UserDashboardHomePage> createState() => _UserDashboardHomePageState();
}
class _UserDashboardHomePageState extends State<UserDashboardHomePage> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 900;
final isMobile = size.width < 600;
final double horizontalPadding = isMobile ? 8.0 : 16.0;
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.all(horizontalPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Builder(builder: (context) {
// Récupérer l'opération actuelle
final operation = userRepository.getCurrentOperation();
if (operation != null) {
return Text(
operation.name,
style: TextStyle(
fontSize: AppTheme.r(context, 20),
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
);
} else {
return Text(
'Tableau de bord',
style: TextStyle(
fontSize: AppTheme.r(context, 20),
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
);
}
}),
const SizedBox(height: 24),
// Synthèse des passages
_buildSummaryCards(isDesktop),
const SizedBox(height: 24),
// Graphique des passages
_buildPassagesChart(context, theme),
const SizedBox(height: 24),
// Derniers passages
_buildRecentPassages(context, theme),
],
),
),
),
);
}
// Construction des cartes de synthèse
Widget _buildSummaryCards(bool isDesktop) {
return Column(
children: [
_buildCombinedPassagesCard(context, isDesktop),
const SizedBox(height: 16),
_buildCombinedPaymentsCard(isDesktop),
],
);
}
// Construction d'une carte combinée pour les règlements (liste + graphique)
Widget _buildCombinedPaymentsCard(bool isDesktop) {
return PaymentSummaryCard(
title: 'Règlements',
titleColor: AppTheme.accentColor,
titleIcon: Icons.euro,
height: 300,
useValueListenable: true,
userId: userRepository.getCurrentUser()?.id,
showAllPayments: false,
isDesktop: isDesktop,
backgroundIcon: Icons.euro_symbol,
backgroundIconColor: Colors.blue,
backgroundIconOpacity: 0.07,
backgroundIconSize: 180,
customTotalDisplay: (totalAmount) {
return '${totalAmount.toStringAsFixed(2)}';
},
);
}
// Construction d'une carte combinée pour les passages (liste + graphique)
Widget _buildCombinedPassagesCard(BuildContext context, bool isDesktop) {
return PassageSummaryCard(
title: 'Passages',
titleColor: AppTheme.primaryColor,
titleIcon: Icons.route,
height: 300,
useValueListenable: true,
userId: userRepository.getCurrentUser()?.id,
showAllPassages: false,
excludePassageTypes: const [2], // Exclure "À finaliser"
isDesktop: isDesktop,
);
}
// Construction du graphique des passages
Widget _buildPassagesChart(BuildContext context, ThemeData theme) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 350,
child: ActivityChart(
useValueListenable: true, // Utiliser le système réactif
excludePassageTypes: const [
2
], // Exclure les passages "À finaliser"
daysToShow: 15,
periodType: 'Jour',
height: 350,
userId: userRepository.getCurrentUser()?.id,
title: 'Dernière activité enregistrée sur 15 jours',
),
),
],
),
),
);
}
// Construction de la liste des derniers passages
Widget _buildRecentPassages(BuildContext context, ThemeData theme) {
// Utilisation directe du widget PassagesListWidget
return ValueListenableBuilder(
valueListenable:
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final recentPassages = _getRecentPassages(passagesBox);
// Debug : afficher le nombre de passages récupérés
debugPrint(
'UserDashboardHomePage: ${recentPassages.length} passages récents récupérés');
if (recentPassages.isEmpty) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: const Padding(
padding: EdgeInsets.all(32.0),
child: Center(
child: Text(
'Aucun passage récent',
style: TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
),
),
);
}
// Utiliser PassagesListWidget sans hauteur fixe - laisse le widget gérer sa propre taille
return PassagesListWidget(
passages: recentPassages,
showFilters: false,
showSearch: false,
showActions: true,
maxPassages: 20,
showAddButton: false,
sortBy: 'date',
);
},
);
}
/// Récupère les passages récents pour la liste
List<Map<String, dynamic>> _getRecentPassages(Box<PassageModel> passagesBox) {
final currentUserId = userRepository.getCurrentUser()?.id;
// Filtrer les passages :
// - Avoir une date passedAt
// - Exclure le type 2 ("À finaliser")
// - Appartenir à l'utilisateur courant
final allPassages = passagesBox.values.where((p) {
if (p.passedAt == null) return false;
if (p.fkType == 2) return false; // Exclure les passages "À finaliser"
if (currentUserId != null && p.fkUser != currentUserId) {
return false; // Filtrer par utilisateur
}
return true;
}).toList();
// Trier par date décroissante
allPassages.sort((a, b) => b.passedAt!.compareTo(a.passedAt!));
// Limiter aux 20 passages les plus récents
final recentPassagesModels = allPassages.take(20).toList();
// Convertir les modèles de passage au format attendu par le widget PassagesListWidget
return recentPassagesModels.map((passage) {
// Construire l'adresse complète à partir des champs disponibles
final String address =
'${passage.numero} ${passage.rue}${passage.rueBis.isNotEmpty ? ' ${passage.rueBis}' : ''}, ${passage.ville}';
// Convertir le montant en double
double amount = 0.0;
try {
if (passage.montant.isNotEmpty) {
// Gérer les formats possibles (virgule ou point)
String montantStr = passage.montant.replaceAll(',', '.');
amount = double.tryParse(montantStr) ?? 0.0;
}
} catch (e) {
debugPrint('Erreur de conversion du montant: ${passage.montant}');
amount = 0.0;
}
return {
'id': passage.id, // Garder l'ID comme int, pas besoin de toString()
'address': address,
'amount': amount,
'date': passage.passedAt ?? DateTime.now(),
'type': passage.fkType,
'payment': passage.fkTypeReglement,
'name': passage.name,
'notes': passage.remarque,
'hasReceipt': passage.nomRecu.isNotEmpty,
'hasError': passage.emailErreur.isNotEmpty,
'fkUser': passage.fkUser,
'isOwnedByCurrentUser': passage.fkUser ==
userRepository
.getCurrentUser()
?.id, // Ajout du champ pour le widget
};
}).toList();
}
}

View File

@@ -1,281 +0,0 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.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/badged_navigation_destination.dart';
// Import des pages utilisateur
import 'user_dashboard_home_page.dart';
import 'user_statistics_page.dart';
import 'user_history_page.dart';
import '../chat/chat_communication_page.dart';
import 'user_map_page.dart';
import 'user_field_mode_page.dart';
class UserDashboardPage extends StatefulWidget {
const UserDashboardPage({super.key});
@override
State<UserDashboardPage> createState() => _UserDashboardPageState();
}
class _UserDashboardPageState extends State<UserDashboardPage> {
int _selectedIndex = 0;
// Liste des pages à afficher
late final List<Widget> _pages;
// Référence à la boîte Hive pour les paramètres
late Box _settingsBox;
@override
void initState() {
super.initState();
_pages = [
const UserDashboardHomePage(),
const UserStatisticsPage(),
const UserHistoryPage(),
const ChatCommunicationPage(),
const UserMapPage(),
const UserFieldModePage(),
];
// Initialiser et charger les paramètres
_initSettings();
}
// Initialiser la boîte de paramètres et charger les préférences
Future<void> _initSettings() async {
try {
// Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
} else {
_settingsBox = Hive.box(AppKeys.settingsBoxName);
}
// Charger l'index de page sélectionné
final savedIndex = _settingsBox.get('selectedPageIndex');
if (savedIndex != null &&
savedIndex is int &&
savedIndex >= 0 &&
savedIndex < _pages.length) {
setState(() {
_selectedIndex = savedIndex;
});
}
} catch (e) {
debugPrint('Erreur lors du chargement des paramètres: $e');
}
}
// Sauvegarder les paramètres utilisateur
void _saveSettings() {
try {
// Sauvegarder l'index de page sélectionné
_settingsBox.put('selectedPageIndex', _selectedIndex);
} catch (e) {
debugPrint('Erreur lors de la sauvegarde des paramètres: $e');
}
}
@override
Widget build(BuildContext context) {
// Utiliser l'instance globale définie dans app.dart
final hasOperation = userRepository.getCurrentOperation() != null;
final hasSectors = userRepository.getUserSectors().isNotEmpty;
final isStandardUser = userRepository.currentUser != null &&
userRepository.currentUser!.role ==
'1'; // Rôle 1 = utilisateur standard
// Si l'utilisateur est standard et n'a pas d'opération assignée ou n'a pas de secteur, afficher un message spécial
final bool shouldShowNoOperationMessage = isStandardUser && !hasOperation;
final bool shouldShowNoSectorMessage = isStandardUser && !hasSectors;
// Si l'utilisateur n'a pas d'opération ou de secteur, utiliser DashboardLayout avec un body spécial
if (shouldShowNoOperationMessage) {
return DashboardLayout(
title: 'GEOSECTOR',
selectedIndex: 0, // Index par défaut
onDestinationSelected: (index) {
// Ne rien faire car l'utilisateur ne peut pas naviguer
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.warning_outlined),
selectedIcon: Icon(Icons.warning),
label: 'Accès restreint',
),
],
body: _buildNoOperationMessage(context),
);
}
if (shouldShowNoSectorMessage) {
return DashboardLayout(
title: 'GEOSECTOR',
selectedIndex: 0, // Index par défaut
onDestinationSelected: (index) {
// Ne rien faire car l'utilisateur ne peut pas naviguer
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.warning_outlined),
selectedIcon: Icon(Icons.warning),
label: 'Accès restreint',
),
],
body: _buildNoSectorMessage(context),
);
}
// Utilisateur normal avec accès complet
return DashboardLayout(
title: 'GEOSECTOR',
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() {
_selectedIndex = index;
_saveSettings(); // Sauvegarder l'index de page sélectionné
});
},
destinations: [
const NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tableau de bord',
),
const NavigationDestination(
icon: Icon(Icons.bar_chart_outlined),
selectedIcon: Icon(Icons.bar_chart),
label: 'Stats',
),
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
createBadgedNavigationDestination(
icon: const Icon(Icons.chat_outlined),
selectedIcon: const Icon(Icons.chat),
label: 'Messages',
showBadge: true,
),
const NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
const NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: 'Terrain',
),
],
body: _pages[_selectedIndex],
);
}
// Message pour les utilisateurs sans opération assignée
Widget _buildNoOperationMessage(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Container(
padding: const EdgeInsets.all(24),
constraints: const BoxConstraints(maxWidth: 500),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_amber_rounded,
size: 80,
color: theme.colorScheme.error,
),
const SizedBox(height: 24),
Text(
'Aucune opération assignée',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Vous n\'avez pas encore été affecté à une opération. Veuillez contacter votre administrateur pour obtenir un accès.',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
],
),
),
);
}
// Message pour les utilisateurs sans secteur assigné
Widget _buildNoSectorMessage(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Container(
padding: const EdgeInsets.all(24),
constraints: const BoxConstraints(maxWidth: 500),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.map_outlined,
size: 80,
color: theme.colorScheme.error,
),
const SizedBox(height: 24),
Text(
'Aucun secteur assigné',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Vous n\'êtes affecté sur aucun secteur. Contactez votre administrateur pour qu\'il vous en affecte au moins un.',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
],
),
),
);
}
// Affiche le formulaire de passage
}

View File

@@ -86,9 +86,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
// Demander la permission et obtenir la position
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
desiredAccuracy: LocationAccuracy.high,
);
setState(() {
@@ -232,12 +230,9 @@ class _UserFieldModePageState extends State<UserFieldModePage>
_qualityUpdateTimer =
Timer.periodic(const Duration(seconds: 5), (timer) async {
// Vérifier la connexion réseau
final connectivityResults = await Connectivity().checkConnectivity();
final connectivityResult = await Connectivity().checkConnectivity();
setState(() {
// Prendre le premier résultat de la liste
_connectivityResult = connectivityResults.isNotEmpty
? connectivityResults.first
: ConnectivityResult.none;
_connectivityResult = connectivityResult;
});
// Vérifier si le GPS est activé
@@ -274,7 +269,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
if (_currentPosition == null) return;
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final allPassages = passagesBox.values.where((p) => p.fkType == 2).toList();
final allPassages = passagesBox.values.toList(); // Tous les types de passages
// Calculer les distances et trier
final passagesWithDistance = allPassages.map((passage) {
@@ -295,8 +290,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
setState(() {
_nearbyPassages = passagesWithDistance
.take(50) // Limiter à 50 passages
.where((entry) => entry.value <= 2000) // Max 2km
.where((entry) => entry.value <= 500) // Max 500m
.map((entry) => entry.key)
.toList();
});
@@ -339,7 +333,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
void _startCompass() {
_magnetometerSubscription =
magnetometerEventStream().listen((MagnetometerEvent event) {
magnetometerEvents.listen((MagnetometerEvent event) {
setState(() {
// Calculer l'orientation à partir du magnétomètre
_heading = math.atan2(event.y, event.x) * (180 / math.pi);
@@ -375,6 +369,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
passageRepository: passageRepository,
userRepository: userRepository,
operationRepository: operationRepository,
amicaleRepository: amicaleRepository,
onSuccess: () {
// Rafraîchir les passages après modification
_updateNearbyPassages();
@@ -985,22 +980,43 @@ class _UserFieldModePageState extends State<UserFieldModePage>
);
}
// Assombrir une couleur pour les bordures
Color _darkenColor(Color color, [double amount = 0.3]) {
assert(amount >= 0 && amount <= 1);
final hsl = HSLColor.fromColor(color);
final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0));
return hslDark.toColor();
}
List<Marker> _buildPassageMarkers() {
if (_currentPosition == null) return [];
return _nearbyPassages.map((passage) {
// Déterminer la couleur selon nbPassages
Color fillColor;
if (passage.nbPassages == 0) {
fillColor = const Color(0xFFFFFFFF); // couleur1: Blanc
} else if (passage.nbPassages == 1) {
fillColor = const Color(0xFFF7A278); // couleur2: Orange
} else {
fillColor = const Color(0xFFE65100); // couleur3: Orange foncé
// Déterminer la couleur selon le type de passage
Color fillColor = Colors.grey; // Couleur par défaut
if (AppKeys.typesPassages.containsKey(passage.fkType)) {
final typeInfo = AppKeys.typesPassages[passage.fkType]!;
if (passage.fkType == 2) {
// Type 2 (À finaliser) : adapter la couleur selon nbPassages
if (passage.nbPassages == 0) {
fillColor = Color(typeInfo['couleur1'] as int);
} else if (passage.nbPassages == 1) {
fillColor = Color(typeInfo['couleur2'] as int);
} else {
fillColor = Color(typeInfo['couleur3'] as int);
}
} else {
// Autres types : utiliser couleur2 par défaut
fillColor = Color(typeInfo['couleur2'] as int);
}
}
// Bordure toujours orange (couleur2)
const borderColor = Color(0xFFF7A278);
// Bordure : version assombrie de la couleur de remplissage
final borderColor = _darkenColor(fillColor, 0.3);
// Convertir les coordonnées GPS string en double
final double lat = double.tryParse(passage.gpsLat) ?? 0;
@@ -1029,8 +1045,10 @@ class _UserFieldModePageState extends State<UserFieldModePage>
child: Text(
'${passage.numero}${(passage.rueBis.isNotEmpty) ? passage.rueBis.substring(0, 1).toLowerCase() : ''}',
style: TextStyle(
color:
fillColor == Colors.white ? Colors.black : Colors.white,
// Texte noir sur fond clair, blanc sur fond foncé
color: fillColor.computeLuminance() > 0.5
? Colors.black
: Colors.white,
fontWeight: FontWeight.bold,
fontSize: AppTheme.r(context, 12),
),
@@ -1120,14 +1138,18 @@ class _UserFieldModePageState extends State<UserFieldModePage>
color: Colors.white,
child: PassagesListWidget(
passages: filteredPassages,
showFilters: false, // Pas de filtres, juste la liste
showSearch: false, // La recherche est déjà dans l'interface
showActions: true,
sortBy: 'distance', // Tri par distance pour le mode terrain
excludePassageTypes: const [], // Afficher tous les types (notamment le type 2)
showAddButton: true, // Activer le bouton de création
// Le widget gère maintenant le flux conditionnel par défaut
onPassageSelected: null,
onPassageEdit: (passage) {
// Retrouver le PassageModel original pour l'édition
final passageId = passage['id'] as int;
final originalPassage = _nearbyPassages.firstWhere(
(p) => p.id == passageId,
orElse: () => _nearbyPassages.first,
);
_openPassageForm(originalPassage);
},
onAddPassage: () async {
// Ouvrir le dialogue de création de passage
await showDialog(
@@ -1139,6 +1161,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
passageRepository: passageRepository,
userRepository: userRepository,
operationRepository: operationRepository,
amicaleRepository: amicaleRepository,
onSuccess: () {
// Le widget se rafraîchira automatiquement via ValueListenableBuilder
},

View File

@@ -1,844 +0,0 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
import 'package:geosector_app/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/repositories/sector_repository.dart';
class UserHistoryPage extends StatefulWidget {
const UserHistoryPage({super.key});
@override
State<UserHistoryPage> createState() => _UserHistoryPageState();
}
// Enum pour gérer les types de tri
enum PassageSortType {
dateDesc, // Plus récent en premier (défaut)
dateAsc, // Plus ancien en premier
addressAsc, // Adresse A-Z
addressDesc, // Adresse Z-A
}
class _UserHistoryPageState extends State<UserHistoryPage> {
// Liste qui contiendra les passages convertis
List<Map<String, dynamic>> _convertedPassages = [];
// Variables pour indiquer l'état de chargement
bool _isLoading = true;
String _errorMessage = '';
// Statistiques pour l'affichage
int _totalSectors = 0;
int _sharedMembersCount = 0;
// État du tri actuel
PassageSortType _currentSort = PassageSortType.dateDesc;
// État des filtres (uniquement pour synchronisation)
int? selectedSectorId;
String selectedPeriod = 'Toutes';
DateTimeRange? selectedDateRange;
// Repository pour les secteurs
late SectorRepository _sectorRepository;
// Liste des secteurs disponibles pour l'utilisateur
List<SectorModel> _userSectors = [];
// Box des settings pour sauvegarder les préférences
late Box _settingsBox;
@override
void initState() {
super.initState();
// Initialiser le repository
_sectorRepository = sectorRepository;
// Initialiser les settings et charger les données
_initSettingsAndLoad();
}
// Initialiser les settings et charger les préférences
Future<void> _initSettingsAndLoad() async {
try {
// Ouvrir la box des settings
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
} else {
_settingsBox = Hive.box(AppKeys.settingsBoxName);
}
// Charger les préférences présélectionnées
_loadPreselectedFilters();
// Charger les secteurs de l'utilisateur
_loadUserSectors();
// Charger les passages
await _loadPassages();
} catch (e) {
debugPrint('Erreur lors de l\'initialisation: $e');
setState(() {
_isLoading = false;
_errorMessage = 'Erreur lors de l\'initialisation: $e';
});
}
}
// Charger les secteurs de l'utilisateur
void _loadUserSectors() {
try {
// Récupérer l'ID de l'utilisateur courant
final currentUserId = userRepository.getCurrentUser()?.id;
if (currentUserId != null) {
// Récupérer tous les secteurs
final allSectors = _sectorRepository.getAllSectors();
// Filtrer les secteurs où l'utilisateur a des passages
final userSectorIds = <int>{};
final allPassages = passageRepository.passages;
for (var passage in allPassages) {
if (passage.fkUser == currentUserId && passage.fkSector != null) {
userSectorIds.add(passage.fkSector!);
}
}
// Récupérer les secteurs correspondants
_userSectors = allSectors.where((sector) => userSectorIds.contains(sector.id)).toList();
debugPrint('Nombre de secteurs pour l\'utilisateur: ${_userSectors.length}');
}
} catch (e) {
debugPrint('Erreur lors du chargement des secteurs utilisateur: $e');
}
}
// Charger les filtres présélectionnés depuis Hive
void _loadPreselectedFilters() {
try {
// Charger le secteur présélectionné
final int? preselectedSectorId = _settingsBox.get('history_selectedSectorId');
final String? preselectedPeriod = _settingsBox.get('history_selectedPeriod');
if (preselectedSectorId != null) {
selectedSectorId = preselectedSectorId;
debugPrint('Secteur présélectionné: ID $preselectedSectorId');
}
if (preselectedPeriod != null) {
selectedPeriod = preselectedPeriod;
_updatePeriodFilter(preselectedPeriod);
debugPrint('Période présélectionnée: $preselectedPeriod');
}
// Nettoyer les valeurs après utilisation
_settingsBox.delete('history_selectedSectorId');
_settingsBox.delete('history_selectedSectorName');
_settingsBox.delete('history_selectedTypeId');
_settingsBox.delete('history_selectedPeriod');
_settingsBox.delete('history_selectedPaymentId');
} catch (e) {
debugPrint('Erreur lors du chargement des filtres présélectionnés: $e');
}
}
// Sauvegarder les préférences de filtres
void _saveFilterPreferences() {
try {
if (selectedSectorId != null) {
_settingsBox.put('history_selectedSectorId', selectedSectorId);
}
if (selectedPeriod != 'Toutes') {
_settingsBox.put('history_selectedPeriod', selectedPeriod);
}
} catch (e) {
debugPrint('Erreur lors de la sauvegarde des préférences: $e');
}
}
// Mettre à jour le filtre par secteur
void _updateSectorFilter(String sectorName, int? sectorId) {
setState(() {
selectedSectorId = sectorId;
});
_saveFilterPreferences();
}
// Mettre à jour le filtre par période
void _updatePeriodFilter(String period) {
setState(() {
selectedPeriod = period;
// Mettre à jour la plage de dates en fonction de la période
final DateTime now = DateTime.now();
switch (period) {
case 'Derniers 15 jours':
selectedDateRange = DateTimeRange(
start: now.subtract(const Duration(days: 15)),
end: now,
);
break;
case 'Dernière semaine':
selectedDateRange = DateTimeRange(
start: now.subtract(const Duration(days: 7)),
end: now,
);
break;
case 'Dernier mois':
selectedDateRange = DateTimeRange(
start: DateTime(now.year, now.month - 1, now.day),
end: now,
);
break;
case 'Tous':
selectedDateRange = null;
break;
}
});
_saveFilterPreferences();
}
// Méthode pour charger les passages depuis le repository
Future<void> _loadPassages() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
// Utiliser l'instance globale définie dans app.dart
final List<PassageModel> allPassages = passageRepository.passages;
debugPrint('Nombre total de passages dans la box: ${allPassages.length}');
// Filtrer les passages de l'utilisateur courant
final currentUserId = userRepository.getCurrentUser()?.id;
List<PassageModel> filtered = allPassages.where((p) => p.fkUser == currentUserId).toList();
debugPrint('Nombre de passages de l\'utilisateur: ${filtered.length}');
// Afficher la distribution des types de passages pour le débogage
final Map<int, int> typeCount = {};
for (var passage in filtered) {
typeCount[passage.fkType] = (typeCount[passage.fkType] ?? 0) + 1;
}
typeCount.forEach((type, count) {
debugPrint('Type de passage $type: $count passages');
});
// Calculer le nombre de secteurs uniques
final Set<int> uniqueSectors = {};
for (var passage in filtered) {
if (passage.fkSector != null && passage.fkSector! > 0) {
uniqueSectors.add(passage.fkSector!);
}
}
// Compter les membres partagés (autres membres dans la même amicale)
int sharedMembers = 0;
try {
final allMembers = membreRepository.membres;
// Compter les membres autres que l'utilisateur courant
sharedMembers = allMembers.where((membre) => membre.id != currentUserId).length;
debugPrint('Nombre de membres partagés: $sharedMembers');
} catch (e) {
debugPrint('Erreur lors du comptage des membres: $e');
}
// Convertir les modèles en Maps pour l'affichage
List<Map<String, dynamic>> passagesMap = [];
for (var passage in filtered) {
try {
final Map<String, dynamic> passageMap = _convertPassageModelToMap(passage);
passagesMap.add(passageMap);
} catch (e) {
debugPrint('Erreur lors de la conversion du passage en map: $e');
}
}
debugPrint('Nombre de passages après conversion: ${passagesMap.length}');
// Trier par date (plus récent en premier)
passagesMap = _sortPassages(passagesMap);
setState(() {
_convertedPassages = passagesMap;
_totalSectors = uniqueSectors.length;
_sharedMembersCount = sharedMembers;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Erreur lors du chargement des passages: $e';
_isLoading = false;
});
debugPrint(_errorMessage);
}
}
// Filtrer les passages selon les critères sélectionnés
List<Map<String, dynamic>> _getFilteredPassages(List<Map<String, dynamic>> passages) {
return passages.where((passage) {
// Filtrer par secteur
if (selectedSectorId != null && passage['fkSector'] != selectedSectorId) {
return false;
}
// Filtrer par période/date
if (selectedDateRange != null && passage['date'] is DateTime) {
final DateTime passageDate = passage['date'] as DateTime;
if (passageDate.isBefore(selectedDateRange!.start) ||
passageDate.isAfter(selectedDateRange!.end)) {
return false;
}
}
return true;
}).toList();
}
// Convertir un modèle de passage en Map pour l'affichage
Map<String, dynamic> _convertPassageModelToMap(PassageModel passage) {
try {
// Construire l'adresse complète
String address = _buildFullAddress(passage);
// Convertir le montant en double
double amount = 0.0;
if (passage.montant.isNotEmpty) {
amount = double.tryParse(passage.montant) ?? 0.0;
}
// Récupérer la date
DateTime date = passage.passedAt ?? DateTime.now();
// Récupérer le type
int type = passage.fkType;
if (!AppKeys.typesPassages.containsKey(type)) {
type = 1; // Type 1 par défaut (Effectué)
}
// Récupérer le type de règlement
int payment = passage.fkTypeReglement;
if (!AppKeys.typesReglements.containsKey(payment)) {
payment = 0; // Type de règlement inconnu
}
// Vérifier si un reçu est disponible
bool hasReceipt = amount > 0 && type == 1 && passage.nomRecu.isNotEmpty;
// Vérifier s'il y a une erreur
bool hasError = passage.emailErreur.isNotEmpty;
// Récupérer le secteur
SectorModel? sector;
if (passage.fkSector != null) {
sector = _sectorRepository.getSectorById(passage.fkSector!);
}
return {
'id': passage.id,
'address': address,
'amount': amount,
'date': date,
'type': type,
'payment': payment,
'name': passage.name,
'notes': passage.remarque,
'hasReceipt': hasReceipt,
'hasError': hasError,
'fkUser': passage.fkUser,
'fkSector': passage.fkSector,
'sector': sector?.libelle ?? 'Secteur inconnu',
'isOwnedByCurrentUser': passage.fkUser == userRepository.getCurrentUser()?.id,
// Composants de l'adresse pour le tri
'rue': passage.rue,
'numero': passage.numero,
'rueBis': passage.rueBis,
};
} catch (e) {
debugPrint('Erreur lors de la conversion du passage: $e');
// Retourner un objet valide par défaut
final currentUserId = userRepository.getCurrentUser()?.id;
return {
'id': 0,
'address': 'Adresse non disponible',
'amount': 0.0,
'date': DateTime.now(),
'type': 1,
'payment': 1,
'name': 'Nom non disponible',
'notes': '',
'hasReceipt': false,
'hasError': true,
'fkUser': currentUserId,
'fkSector': null,
'sector': 'Secteur inconnu',
'rue': '',
'numero': '',
'rueBis': '',
};
}
}
// Méthode pour trier les passages selon le type de tri sélectionné
List<Map<String, dynamic>> _sortPassages(List<Map<String, dynamic>> passages) {
final sortedPassages = List<Map<String, dynamic>>.from(passages);
switch (_currentSort) {
case PassageSortType.dateDesc:
sortedPassages.sort((a, b) {
try {
return (b['date'] as DateTime).compareTo(a['date'] as DateTime);
} catch (e) {
return 0;
}
});
break;
case PassageSortType.dateAsc:
sortedPassages.sort((a, b) {
try {
return (a['date'] as DateTime).compareTo(b['date'] as DateTime);
} catch (e) {
return 0;
}
});
break;
case PassageSortType.addressAsc:
sortedPassages.sort((a, b) {
try {
// Tri intelligent par rue, numéro, rueBis
final String rueA = a['rue'] ?? '';
final String rueB = b['rue'] ?? '';
final String numeroA = a['numero'] ?? '';
final String numeroB = b['numero'] ?? '';
final String rueBisA = a['rueBis'] ?? '';
final String rueBisB = b['rueBis'] ?? '';
// D'abord comparer les rues
int rueCompare = rueA.toLowerCase().compareTo(rueB.toLowerCase());
if (rueCompare != 0) return rueCompare;
// Si les rues sont identiques, comparer les numéros (numériquement)
int numA = int.tryParse(numeroA) ?? 0;
int numB = int.tryParse(numeroB) ?? 0;
int numCompare = numA.compareTo(numB);
if (numCompare != 0) return numCompare;
// Si les numéros sont identiques, comparer les rueBis
return rueBisA.toLowerCase().compareTo(rueBisB.toLowerCase());
} catch (e) {
return 0;
}
});
break;
case PassageSortType.addressDesc:
sortedPassages.sort((a, b) {
try {
// Tri intelligent inversé
final String rueA = a['rue'] ?? '';
final String rueB = b['rue'] ?? '';
final String numeroA = a['numero'] ?? '';
final String numeroB = b['numero'] ?? '';
final String rueBisA = a['rueBis'] ?? '';
final String rueBisB = b['rueBis'] ?? '';
// D'abord comparer les rues (inversé)
int rueCompare = rueB.toLowerCase().compareTo(rueA.toLowerCase());
if (rueCompare != 0) return rueCompare;
// Si les rues sont identiques, comparer les numéros (inversé)
int numA = int.tryParse(numeroA) ?? 0;
int numB = int.tryParse(numeroB) ?? 0;
int numCompare = numB.compareTo(numA);
if (numCompare != 0) return numCompare;
// Si les numéros sont identiques, comparer les rueBis (inversé)
return rueBisB.toLowerCase().compareTo(rueBisA.toLowerCase());
} catch (e) {
return 0;
}
});
break;
}
return sortedPassages;
}
// Construire l'adresse complète à partir des composants
String _buildFullAddress(PassageModel passage) {
final List<String> addressParts = [];
// Numéro et rue
if (passage.numero.isNotEmpty) {
addressParts.add('${passage.numero} ${passage.rue}');
} else {
addressParts.add(passage.rue);
}
// Complément rue bis
if (passage.rueBis.isNotEmpty) {
addressParts.add(passage.rueBis);
}
// Résidence/Bâtiment
if (passage.residence.isNotEmpty) {
addressParts.add(passage.residence);
}
// Appartement
if (passage.appt.isNotEmpty) {
addressParts.add('Appt ${passage.appt}');
}
// Niveau
if (passage.niveau.isNotEmpty) {
addressParts.add('Niveau ${passage.niveau}');
}
// Ville
if (passage.ville.isNotEmpty) {
addressParts.add(passage.ville);
}
return addressParts.join(', ');
}
// Méthode pour afficher les détails d'un passage
void _showPassageDetails(Map<String, dynamic> passage) {
// Récupérer les informations du type de passage et du type de règlement
final typePassage = AppKeys.typesPassages[passage['type']] as Map<String, dynamic>;
final typeReglement = AppKeys.typesReglements[passage['payment']] as Map<String, dynamic>;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Détails du passage'),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildDetailRow('Adresse', passage['address']),
_buildDetailRow('Nom', passage['name']),
_buildDetailRow('Date',
'${passage['date'].day}/${passage['date'].month}/${passage['date'].year}'),
_buildDetailRow('Type', typePassage['titre']),
_buildDetailRow('Règlement', typeReglement['titre']),
_buildDetailRow('Montant', '${passage['amount']}'),
if (passage['sector'] != null)
_buildDetailRow('Secteur', passage['sector']),
if (passage['notes'] != null && passage['notes'].toString().isNotEmpty)
_buildDetailRow('Notes', passage['notes']),
if (passage['hasReceipt'] == true)
_buildDetailRow('Reçu', 'Disponible'),
if (passage['hasError'] == true)
_buildDetailRow('Erreur', 'Détectée', isError: true),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
if (passage['hasReceipt'] == true)
TextButton(
onPressed: () {
Navigator.of(context).pop();
_showReceipt(passage);
},
child: const Text('Voir le reçu'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_editPassage(passage);
},
child: const Text('Modifier'),
),
],
),
);
}
// Méthode pour éditer un passage
void _editPassage(Map<String, dynamic> passage) {
debugPrint('Édition du passage ${passage['id']}');
}
// Méthode pour afficher un reçu
void _showReceipt(Map<String, dynamic> passage) {
debugPrint('Affichage du reçu pour le passage ${passage['id']}');
}
// Helper pour construire une ligne de détails
Widget _buildDetailRow(String label, String value, {bool isError = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text('$label:',
style: const TextStyle(fontWeight: FontWeight.bold))),
Expanded(
child: Text(
value,
style: isError ? const TextStyle(color: Colors.red) : null,
),
),
],
),
);
}
// Les filtres sont maintenant gérés directement dans le PassagesListWidget
// Méthodes de filtre retirées car maintenant gérées dans le widget
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Les filtres sont maintenant intégrés dans le PassagesListWidget
// Affichage du chargement ou des erreurs
if (_isLoading)
const Expanded(
child: Center(
child: CircularProgressIndicator(),
),
)
else if (_errorMessage.isNotEmpty)
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline,
size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(
'Erreur de chargement',
style: TextStyle(
fontSize: AppTheme.r(context, 22),
color: Colors.red),
),
const SizedBox(height: 8),
Text(_errorMessage),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadPassages,
child: const Text('Réessayer'),
),
],
),
),
)
// Utilisation du widget PassagesListWidget pour afficher la liste des passages
else
Expanded(
child: Container(
color: Colors.transparent,
child: Column(
children: [
// Widget de liste des passages avec ValueListenableBuilder
Expanded(
child: ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
// Reconvertir les passages à chaque changement
final currentUserId = userRepository.getCurrentUser()?.id;
final List<PassageModel> allPassages = passagesBox.values
.where((p) => p.fkUser == currentUserId)
.toList();
// Appliquer le même filtrage et conversion
List<Map<String, dynamic>> passagesMap = [];
for (var passage in allPassages) {
try {
final Map<String, dynamic> passageMap = _convertPassageModelToMap(passage);
passagesMap.add(passageMap);
} catch (e) {
debugPrint('Erreur lors de la conversion du passage en map: $e');
}
}
// Appliquer le tri sélectionné
passagesMap = _sortPassages(passagesMap);
return PassagesListWidget(
// Données
passages: passagesMap,
// Activation des filtres
showFilters: true,
showSearch: true,
showTypeFilter: true,
showPaymentFilter: true,
showSectorFilter: true,
showUserFilter: false, // Pas de filtre membre pour la page user
showPeriodFilter: true,
// Données pour les filtres
sectors: _userSectors,
members: null, // Pas de filtre membre pour la page user
// Valeurs initiales
initialSectorId: selectedSectorId,
initialPeriod: selectedPeriod,
dateRange: selectedDateRange,
// Filtre par utilisateur courant
filterByUserId: currentUserId,
// Bouton d'ajout
showAddButton: true,
onAddPassage: () async {
// Ouvrir le dialogue de création de passage
await showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return PassageFormDialog(
title: 'Nouveau passage',
passageRepository: passageRepository,
userRepository: userRepository,
operationRepository: operationRepository,
onSuccess: () {
// Le widget se rafraîchira automatiquement via ValueListenableBuilder
},
);
},
);
},
sortingButtons: Row(
children: [
// Bouton tri par date avec icône calendrier
IconButton(
icon: Icon(
Icons.calendar_today,
size: 20,
color: _currentSort == PassageSortType.dateDesc ||
_currentSort == PassageSortType.dateAsc
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
tooltip: _currentSort == PassageSortType.dateAsc
? 'Tri par date (ancien en premier)'
: 'Tri par date (récent en premier)',
onPressed: () {
setState(() {
if (_currentSort == PassageSortType.dateDesc) {
_currentSort = PassageSortType.dateAsc;
} else {
_currentSort = PassageSortType.dateDesc;
}
});
},
),
// Indicateur de direction pour la date
if (_currentSort == PassageSortType.dateDesc ||
_currentSort == PassageSortType.dateAsc)
Icon(
_currentSort == PassageSortType.dateAsc
? Icons.arrow_upward
: Icons.arrow_downward,
size: 14,
color: theme.colorScheme.primary,
),
const SizedBox(width: 4),
// Bouton tri par adresse avec icône maison
IconButton(
icon: Icon(
Icons.home,
size: 20,
color: _currentSort == PassageSortType.addressDesc ||
_currentSort == PassageSortType.addressAsc
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
tooltip: _currentSort == PassageSortType.addressAsc
? 'Tri par adresse (A-Z)'
: 'Tri par adresse (Z-A)',
onPressed: () {
setState(() {
if (_currentSort == PassageSortType.addressAsc) {
_currentSort = PassageSortType.addressDesc;
} else {
_currentSort = PassageSortType.addressAsc;
}
});
},
),
// Indicateur de direction pour l'adresse
if (_currentSort == PassageSortType.addressDesc ||
_currentSort == PassageSortType.addressAsc)
Icon(
_currentSort == PassageSortType.addressAsc
? Icons.arrow_upward
: Icons.arrow_downward,
size: 14,
color: theme.colorScheme.primary,
),
],
),
// Actions
showActions: true,
key: const ValueKey('user_passages_list'),
// Callback pour synchroniser les filtres
onFiltersChanged: (filters) {
setState(() {
selectedSectorId = filters['sectorId'];
selectedPeriod = filters['period'] ?? 'Toutes';
selectedDateRange = filters['dateRange'];
});
},
onDetailsView: (passage) {
debugPrint('Affichage des détails: ${passage['id']}');
_showPassageDetails(passage);
},
onPassageEdit: (passage) {
debugPrint('Modification du passage: ${passage['id']}');
_editPassage(passage);
},
onReceiptView: (passage) {
debugPrint('Affichage du reçu pour le passage: ${passage['id']}');
_showReceipt(passage);
},
onPassageDelete: (passage) {
// Pas besoin de recharger, le ValueListenableBuilder
// se rafraîchira automatiquement après la suppression
},
);
},
),
),
],
),
),
),
],
),
),
);
}
@override
void dispose() {
super.dispose();
}
}

View File

@@ -1,938 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/services/location_service.dart';
import 'package:geosector_app/presentation/widgets/mapbox_map.dart';
import '../../core/constants/app_keys.dart';
import '../../core/data/models/sector_model.dart';
import '../../core/data/models/passage_model.dart';
import '../../presentation/widgets/passage_map_dialog.dart';
// Extension pour ajouter ln2 (logarithme népérien de 2) comme constante
extension MathConstants on math.Random {
static const double ln2 = 0.6931471805599453; // ln(2)
}
class UserMapPage extends StatefulWidget {
const UserMapPage({super.key});
@override
State<UserMapPage> createState() => _UserMapPageState();
}
class _UserMapPageState extends State<UserMapPage> {
// Contrôleur de carte
final MapController _mapController = MapController();
// Position actuelle et zoom
LatLng _currentPosition =
const LatLng(48.117266, -1.6777926); // Position initiale sur Rennes
double _currentZoom = 12.0; // Zoom initial
// Données des secteurs et passages
final List<Map<String, dynamic>> _sectors = [];
final List<Map<String, dynamic>> _passages = [];
// Items pour la combobox de secteurs
List<DropdownMenuItem<int?>> _sectorItems = [];
// Filtres pour les types de passages
bool _showEffectues = true;
bool _showAFinaliser = true;
bool _showRefuses = true;
bool _showDons = true;
bool _showLots = true;
bool _showMaisonsVides = true;
// Référence à la boîte Hive pour les paramètres
late Box _settingsBox;
// Vérifier si la combobox de secteurs doit être affichée
bool get _shouldShowSectorCombobox => _sectors.length > 1;
int? _selectedSectorId;
@override
void initState() {
super.initState();
_initSettings().then((_) {
_loadSectors();
_loadPassages();
});
}
// Initialiser la boîte de paramètres et charger les préférences
Future<void> _initSettings() async {
// Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
} else {
_settingsBox = Hive.box(AppKeys.settingsBoxName);
}
// Charger les filtres sauvegardés
_showEffectues = _settingsBox.get('showEffectues', defaultValue: true);
_showAFinaliser = _settingsBox.get('showAFinaliser', defaultValue: true);
_showRefuses = _settingsBox.get('showRefuses', defaultValue: true);
_showDons = _settingsBox.get('showDons', defaultValue: true);
_showLots = _settingsBox.get('showLots', defaultValue: true);
_showMaisonsVides =
_settingsBox.get('showMaisonsVides', defaultValue: true);
// Charger le secteur sélectionné
_selectedSectorId = _settingsBox.get('selectedSectorId');
// Charger la position et le zoom
final double? savedLat = _settingsBox.get('mapLat');
final double? savedLng = _settingsBox.get('mapLng');
final double? savedZoom = _settingsBox.get('mapZoom');
if (savedLat != null && savedLng != null) {
_currentPosition = LatLng(savedLat, savedLng);
}
if (savedZoom != null) {
_currentZoom = savedZoom;
}
}
// Obtenir la position actuelle de l'utilisateur
Future<void> _getUserLocation() async {
try {
// Afficher un indicateur de chargement
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Recherche de votre position...'),
duration: Duration(seconds: 2),
),
);
// Obtenir la position actuelle via le service de géolocalisation
final position = await LocationService.getCurrentPosition();
if (position != null) {
// Mettre à jour la position sur la carte
_updateMapPosition(position, zoom: 17);
// Sauvegarder la nouvelle position
_settingsBox.put('mapLat', position.latitude);
_settingsBox.put('mapLng', position.longitude);
// Informer l'utilisateur
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Position actualisée'),
backgroundColor: Colors.green,
duration: Duration(seconds: 1),
),
);
}
} else {
// Informer l'utilisateur en cas d'échec
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Impossible d\'obtenir votre position. Vérifiez vos paramètres de localisation.'),
backgroundColor: Colors.red,
),
);
}
}
} catch (e) {
// Gérer les erreurs
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
// Sauvegarder les paramètres utilisateur
void _saveSettings() {
// Sauvegarder les filtres
_settingsBox.put('showEffectues', _showEffectues);
_settingsBox.put('showAFinaliser', _showAFinaliser);
_settingsBox.put('showRefuses', _showRefuses);
_settingsBox.put('showDons', _showDons);
_settingsBox.put('showLots', _showLots);
_settingsBox.put('showMaisonsVides', _showMaisonsVides);
// Sauvegarder le secteur sélectionné
if (_selectedSectorId != null) {
_settingsBox.put('selectedSectorId', _selectedSectorId);
}
// Sauvegarder la position et le zoom actuels
_settingsBox.put('mapLat', _currentPosition.latitude);
_settingsBox.put('mapLng', _currentPosition.longitude);
_settingsBox.put('mapZoom', _currentZoom);
}
// Charger les secteurs depuis la boîte Hive
void _loadSectors() {
try {
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
final sectors = sectorsBox.values.toList();
setState(() {
_sectors.clear();
for (final sector in sectors) {
final List<List<double>> coordinates = sector.getCoordinates();
final List<LatLng> points =
coordinates.map((coord) => LatLng(coord[0], coord[1])).toList();
if (points.isNotEmpty) {
_sectors.add({
'id': sector.id,
'name': sector.libelle,
'color': _hexToColor(sector.color),
'points': points,
});
}
}
// Mettre à jour les items de la combobox de secteurs
_updateSectorItems();
// Si un secteur était sélectionné précédemment, le centrer
if (_selectedSectorId != null &&
_sectors.any((s) => s['id'] == _selectedSectorId)) {
_centerMapOnSpecificSector(_selectedSectorId!);
}
// Sinon, centrer la carte sur tous les secteurs
else if (_sectors.isNotEmpty) {
_centerMapOnSectors();
}
});
} catch (e) {
debugPrint('Erreur lors du chargement des secteurs: $e');
}
}
// Mettre à jour les items de la combobox de secteurs
void _updateSectorItems() {
// Créer l'item "Tous les secteurs"
final List<DropdownMenuItem<int?>> items = [
const DropdownMenuItem<int?>(
value: null,
child: Text('Tous les secteurs'),
),
];
// Ajouter tous les secteurs
for (final sector in _sectors) {
items.add(
DropdownMenuItem<int?>(
value: sector['id'] as int,
child: Text(sector['name'] as String),
),
);
}
setState(() {
_sectorItems = items;
});
}
// Charger les passages depuis la boîte Hive
void _loadPassages() {
try {
// Récupérer la boîte des passages
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
// Créer une nouvelle liste temporaire
final List<Map<String, dynamic>> newPassages = [];
// Parcourir tous les passages dans la boîte
for (var i = 0; i < passagesBox.length; i++) {
final passage = passagesBox.getAt(i);
if (passage != null) {
// Vérifier si les coordonnées GPS sont valides
final lat = double.tryParse(passage.gpsLat);
final lng = double.tryParse(passage.gpsLng);
// Filtrer par secteur si un secteur est sélectionné
if (_selectedSectorId != null &&
passage.fkSector != _selectedSectorId) {
continue;
}
if (lat != null && lng != null) {
// Obtenir la couleur du type de passage
Color passageColor = Colors.grey; // Couleur par défaut
// Vérifier si le type de passage existe dans AppKeys.typesPassages
if (AppKeys.typesPassages.containsKey(passage.fkType)) {
// Utiliser la couleur1 du type de passage
final colorValue =
AppKeys.typesPassages[passage.fkType]!['couleur1'] as int;
passageColor = Color(colorValue);
// Ajouter le passage à la liste temporaire avec filtrage
if (_shouldShowPassage(passage.fkType)) {
newPassages.add({
'id': passage.id,
'position': LatLng(lat, lng),
'type': passage.fkType,
'color': passageColor,
'model': passage, // Ajouter le modèle complet
});
}
}
}
}
}
// Mettre à jour la liste des passages dans l'état
setState(() {
_passages.clear();
_passages.addAll(newPassages);
});
// Sauvegarder les paramètres après chargement des passages
_saveSettings();
} catch (e) {
debugPrint('Erreur lors du chargement des passages: $e');
}
}
// Vérifier si un passage doit être affiché en fonction de son type
bool _shouldShowPassage(int type) {
switch (type) {
case 1: // Effectué
return _showEffectues;
case 2: // À finaliser
return _showAFinaliser;
case 3: // Refusé
return _showRefuses;
case 4: // Don
return _showDons;
case 5: // Lot
return _showLots;
case 6: // Maison vide
return _showMaisonsVides;
default:
return true;
}
}
// Convertir une couleur hexadécimale en Color
Color _hexToColor(String hexColor) {
// Supprimer le # si présent
final String colorStr =
hexColor.startsWith('#') ? hexColor.substring(1) : hexColor;
// Ajouter FF pour l'opacité si nécessaire (6 caractères -> 8 caractères)
final String fullColorStr = colorStr.length == 6 ? 'FF$colorStr' : colorStr;
// Convertir en entier et créer la couleur
return Color(int.parse(fullColorStr, radix: 16));
}
// Centrer la carte sur tous les secteurs
void _centerMapOnSectors() {
if (_sectors.isEmpty) return;
// Trouver les limites de tous les secteurs
double minLat = 90.0;
double maxLat = -90.0;
double minLng = 180.0;
double maxLng = -180.0;
for (final sector in _sectors) {
final points = sector['points'] as List<LatLng>;
for (final point in points) {
minLat = point.latitude < minLat ? point.latitude : minLat;
maxLat = point.latitude > maxLat ? point.latitude : maxLat;
minLng = point.longitude < minLng ? point.longitude : minLng;
maxLng = point.longitude > maxLng ? point.longitude : maxLng;
}
}
// Ajouter un padding aux limites pour s'assurer que tous les secteurs sont entièrement visibles
// avec une marge autour (5% de la taille totale)
final latPadding = (maxLat - minLat) * 0.05;
final lngPadding = (maxLng - minLng) * 0.05;
minLat -= latPadding;
maxLat += latPadding;
minLng -= lngPadding;
maxLng += lngPadding;
// Calculer le centre
final centerLat = (minLat + maxLat) / 2;
final centerLng = (minLng + maxLng) / 2;
// Calculer le zoom approprié en tenant compte des dimensions de l'écran
final mapWidth = MediaQuery.of(context).size.width;
final mapHeight = MediaQuery.of(context).size.height *
0.7; // Estimation de la hauteur de la carte
final zoom = _calculateOptimalZoom(
minLat, maxLat, minLng, maxLng, mapWidth, mapHeight);
// Centrer la carte sur ces limites avec animation
_mapController.move(LatLng(centerLat, centerLng), zoom);
// Mettre à jour l'état pour refléter la nouvelle position
setState(() {
_currentPosition = LatLng(centerLat, centerLng);
_currentZoom = zoom;
});
debugPrint('Carte centrée sur tous les secteurs avec zoom: $zoom');
}
// Centrer la carte sur un secteur spécifique
void _centerMapOnSpecificSector(int sectorId) {
final sectorIndex = _sectors.indexWhere((s) => s['id'] == sectorId);
if (sectorIndex == -1) return;
// Mettre à jour le secteur sélectionné
_selectedSectorId = sectorId;
final sector = _sectors[sectorIndex];
final points = sector['points'] as List<LatLng>;
final sectorName = sector['name'] as String;
debugPrint(
'Centrage sur le secteur: $sectorName (ID: $sectorId) avec ${points.length} points');
if (points.isEmpty) {
debugPrint('Aucun point dans ce secteur!');
return;
}
// Trouver les limites du secteur
double minLat = 90.0;
double maxLat = -90.0;
double minLng = 180.0;
double maxLng = -180.0;
for (final point in points) {
minLat = point.latitude < minLat ? point.latitude : minLat;
maxLat = point.latitude > maxLat ? point.latitude : maxLat;
minLng = point.longitude < minLng ? point.longitude : minLng;
maxLng = point.longitude > maxLng ? point.longitude : maxLng;
}
debugPrint(
'Limites du secteur: minLat=$minLat, maxLat=$maxLat, minLng=$minLng, maxLng=$maxLng');
// Vérifier si les coordonnées sont valides
if (minLat >= maxLat || minLng >= maxLng) {
debugPrint('Coordonnées invalides pour le secteur $sectorName');
return;
}
// Calculer la taille du secteur
final latSpan = maxLat - minLat;
final lngSpan = maxLng - minLng;
debugPrint('Taille du secteur: latSpan=$latSpan, lngSpan=$lngSpan');
// Ajouter un padding minimal aux limites pour s'assurer que le secteur est bien visible
// mais prend le maximum de place sur la carte
final double latPadding, lngPadding;
if (latSpan < 0.01 || lngSpan < 0.01) {
// Pour les très petits secteurs, utiliser un padding très réduit
latPadding = 0.0003;
lngPadding = 0.0003;
} else if (latSpan < 0.05 || lngSpan < 0.05) {
// Pour les petits secteurs, padding réduit
latPadding = 0.0005;
lngPadding = 0.0005;
} else {
// Pour les secteurs plus grands, utiliser un pourcentage minimal
latPadding = latSpan * 0.03; // 3% au lieu de 10%
lngPadding = lngSpan * 0.03;
}
minLat -= latPadding;
maxLat += latPadding;
minLng -= lngPadding;
maxLng += lngPadding;
debugPrint(
'Limites avec padding: minLat=$minLat, maxLat=$maxLat, minLng=$minLng, maxLng=$maxLng');
// Calculer le centre
final centerLat = (minLat + maxLat) / 2;
final centerLng = (minLng + maxLng) / 2;
// Déterminer le zoom approprié en fonction de la taille du secteur
double zoom;
// Pour les très petits secteurs (comme des quartiers), utiliser un zoom fixe élevé
if (latSpan < 0.01 && lngSpan < 0.01) {
zoom = 16.0; // Zoom élevé pour les petits quartiers
} else if (latSpan < 0.02 && lngSpan < 0.02) {
zoom = 15.0; // Zoom élevé pour les petits quartiers
} else if (latSpan < 0.05 && lngSpan < 0.05) {
zoom =
13.0; // Zoom pour les secteurs de taille moyenne (quelques quartiers)
} else if (latSpan < 0.1 && lngSpan < 0.1) {
zoom = 12.0; // Zoom pour les grands secteurs (ville)
} else {
// Pour les secteurs plus grands, calculer le zoom
final mapWidth = MediaQuery.of(context).size.width;
final mapHeight = MediaQuery.of(context).size.height * 0.7;
zoom = _calculateOptimalZoom(
minLat, maxLat, minLng, maxLng, mapWidth, mapHeight);
}
debugPrint('Zoom calculé pour le secteur $sectorName: $zoom');
// Centrer la carte sur le secteur avec animation
_mapController.move(LatLng(centerLat, centerLng), zoom);
// Mettre à jour l'état pour refléter la nouvelle position
setState(() {
_currentPosition = LatLng(centerLat, centerLng);
_currentZoom = zoom;
});
}
// Calculer le zoom optimal pour afficher une zone géographique dans la fenêtre de la carte
double _calculateOptimalZoom(double minLat, double maxLat, double minLng,
double maxLng, double mapWidth, double mapHeight) {
// Méthode simplifiée et plus fiable pour calculer le zoom
// Vérifier si les coordonnées sont valides
if (minLat >= maxLat || minLng >= maxLng) {
debugPrint('Coordonnées invalides pour le calcul du zoom');
return 12.0; // Valeur par défaut raisonnable
}
// Calculer la taille en degrés
final latSpan = maxLat - minLat;
final lngSpan = maxLng - minLng;
debugPrint(
'_calculateOptimalZoom - Taille: latSpan=$latSpan, lngSpan=$lngSpan');
// Ajouter un facteur de sécurité pour éviter les divisions par zéro
if (latSpan < 0.0000001 || lngSpan < 0.0000001) {
return 15.0; // Zoom élevé pour un point très précis
}
// Formule simplifiée pour le calcul du zoom
// Basée sur l'expérience et adaptée pour les petites zones
double zoom;
if (latSpan < 0.005 || lngSpan < 0.005) {
// Très petite zone (quartier)
zoom = 16.0;
} else if (latSpan < 0.01 || lngSpan < 0.01) {
// Petite zone (quartier)
zoom = 15.0;
} else if (latSpan < 0.02 || lngSpan < 0.02) {
// Petite zone (plusieurs quartiers)
zoom = 14.0;
} else if (latSpan < 0.05 || lngSpan < 0.05) {
// Zone moyenne (ville)
zoom = 13.0;
} else if (latSpan < 0.2 || lngSpan < 0.2) {
// Grande zone (agglomération)
zoom = 11.0;
} else if (latSpan < 0.5 || lngSpan < 0.5) {
// Très grande zone (département)
zoom = 9.0;
} else if (latSpan < 2.0 || lngSpan < 2.0) {
// Région
zoom = 7.0;
} else if (latSpan < 5.0 || lngSpan < 5.0) {
// Pays
zoom = 5.0;
} else {
// Continent ou plus
zoom = 3.0;
}
debugPrint('Zoom calculé: $zoom pour zone: lat $latSpan, lng $lngSpan');
return zoom;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Carte
Expanded(
child: Stack(
children: [
// Carte principale utilisant le widget commun MapboxMap
MapboxMap(
initialPosition: _currentPosition,
initialZoom: _currentZoom,
mapController: _mapController,
// Utiliser OpenStreetMap sur mobile, Mapbox sur web
useOpenStreetMap: !kIsWeb,
markers: _buildPassageMarkers(),
polygons: _buildSectorPolygons(),
showControls: false, // Désactiver les contrôles par défaut pour éviter la duplication
onMapEvent: (event) {
if (event is MapEventMove) {
// Mettre à jour la position et le zoom actuels
setState(() {
_currentPosition = event.camera.center;
_currentZoom = event.camera.zoom;
});
}
},
),
// Combobox de sélection de secteurs (si plus d'un secteur)
if (_shouldShowSectorCombobox)
Positioned(
left: 16.0,
top: 16.0,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
width:
220, // Largeur fixe pour accommoder les noms longs
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.location_on,
size: 18, color: Colors.blue),
const SizedBox(width: 8),
Expanded(
child: DropdownButton<int?>(
value: _selectedSectorId,
hint: const Text('Tous les secteurs'),
isExpanded: true,
underline:
Container(), // Supprimer la ligne sous le dropdown
icon: const Icon(Icons.arrow_drop_down,
color: Colors.blue),
items: _sectorItems,
onChanged: (int? sectorId) {
setState(() {
_selectedSectorId = sectorId;
});
if (sectorId != null) {
_centerMapOnSpecificSector(sectorId);
} else {
// Si "Tous les secteurs" est sélectionné
_centerMapOnSectors();
// Recharger tous les passages sans filtrage par secteur
_loadPassages();
}
},
),
),
],
),
),
),
),
// Contrôles de zoom et localisation en bas à droite
Positioned(
bottom: 16.0,
right: 16.0,
child: Column(
children: [
// Bouton zoom +
_buildMapButton(
icon: Icons.add,
onPressed: () {
final newZoom = _currentZoom + 1;
_mapController.move(_currentPosition, newZoom);
setState(() {
_currentZoom = newZoom;
});
_saveSettings();
},
),
const SizedBox(height: 8),
// Bouton zoom -
_buildMapButton(
icon: Icons.remove,
onPressed: () {
final newZoom = _currentZoom - 1;
_mapController.move(_currentPosition, newZoom);
setState(() {
_currentZoom = newZoom;
});
_saveSettings();
},
),
const SizedBox(height: 8),
// Bouton de localisation
_buildMapButton(
icon: Icons.my_location,
onPressed: () {
_getUserLocation();
},
),
],
),
),
// Filtres de type de passage en bas à gauche
Positioned(
bottom: 16.0,
left: 16.0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Filtre Effectués (type 1)
_buildFilterDot(
color: Color(AppKeys.typesPassages[1]?['couleur2'] as int),
selected: _showEffectues,
onTap: () {
setState(() {
_showEffectues = !_showEffectues;
_loadPassages();
_saveSettings();
});
},
),
const SizedBox(width: 6),
// Filtre À finaliser (type 2)
_buildFilterDot(
color: Color(AppKeys.typesPassages[2]?['couleur2'] as int),
selected: _showAFinaliser,
onTap: () {
setState(() {
_showAFinaliser = !_showAFinaliser;
_loadPassages();
_saveSettings();
});
},
),
const SizedBox(width: 6),
// Filtre Refusés (type 3)
_buildFilterDot(
color: Color(AppKeys.typesPassages[3]?['couleur2'] as int),
selected: _showRefuses,
onTap: () {
setState(() {
_showRefuses = !_showRefuses;
_loadPassages();
_saveSettings();
});
},
),
const SizedBox(width: 6),
// Filtre Dons (type 4)
_buildFilterDot(
color: Color(AppKeys.typesPassages[4]?['couleur2'] as int),
selected: _showDons,
onTap: () {
setState(() {
_showDons = !_showDons;
_loadPassages();
_saveSettings();
});
},
),
const SizedBox(width: 6),
// Filtre Lots (type 5)
_buildFilterDot(
color: Color(AppKeys.typesPassages[5]?['couleur2'] as int),
selected: _showLots,
onTap: () {
setState(() {
_showLots = !_showLots;
_loadPassages();
_saveSettings();
});
},
),
const SizedBox(width: 6),
// Filtre Maisons vides (type 6)
_buildFilterDot(
color: Color(AppKeys.typesPassages[6]?['couleur2'] as int),
selected: _showMaisonsVides,
onTap: () {
setState(() {
_showMaisonsVides = !_showMaisonsVides;
_loadPassages();
_saveSettings();
});
},
),
],
),
),
),
],
),
),
],
),
),
);
}
// Construire une pastille de filtre pour la carte
Widget _buildFilterDot({
required Color color,
required bool selected,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: selected ? color : color.withValues(alpha: 0.3),
shape: BoxShape.circle,
border: Border.all(
color: selected ? Colors.white : Colors.white.withValues(alpha: 0.5),
width: 1.5,
),
),
),
);
}
// Construction d'un bouton de carte personnalisé
Widget _buildMapButton({
required IconData icon,
required VoidCallback onPressed,
}) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: IconButton(
icon: Icon(icon, size: 20),
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
color: Colors.blue,
),
);
}
// 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: hasNoSector ? 18.0 : 14.0, // Plus grand si orphelin
height: hasNoSector ? 18.0 : 14.0,
child: GestureDetector(
onTap: () {
_showPassageInfo(passage);
},
child: Container(
decoration: BoxDecoration(
color: passage['color'] as Color,
shape: BoxShape.circle,
border: Border.all(
color: borderColor,
width: borderWidth,
),
),
),
),
);
}).toList();
}
// Construire les polygones pour les secteurs
List<Polygon> _buildSectorPolygons() {
return _sectors.map((sector) {
return Polygon(
points: sector['points'] as List<LatLng>,
color: (sector['color'] as Color).withValues(alpha: 0.3),
borderColor: (sector['color'] as Color).withValues(alpha: 1.0),
borderStrokeWidth: 2.0,
);
}).toList();
}
// Méthode pour mettre à jour la position sur la carte
void _updateMapPosition(LatLng position, {double? zoom}) {
_mapController.move(
position,
zoom ?? _mapController.camera.zoom,
);
// Mettre à jour les variables d'état
setState(() {
_currentPosition = position;
if (zoom != null) {
_currentZoom = zoom;
}
});
// Sauvegarder les paramètres après mise à jour de la position
_saveSettings();
}
// Afficher les informations d'un passage lorsqu'on clique dessus
void _showPassageInfo(Map<String, dynamic> passage) {
final PassageModel passageModel = passage['model'] as PassageModel;
showDialog(
context: context,
builder: (context) => PassageMapDialog(
passage: passageModel,
isAdmin: false, // L'utilisateur n'est pas admin
onDeleted: () {
// Recharger les passages après suppression
_loadPassages();
},
),
);
}
}

View File

@@ -1,383 +0,0 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
class UserStatisticsPage extends StatefulWidget {
const UserStatisticsPage({super.key});
@override
State<UserStatisticsPage> createState() => _UserStatisticsPageState();
}
class _UserStatisticsPageState extends State<UserStatisticsPage> {
// Période sélectionnée
String _selectedPeriod = 'Semaine';
// Secteur sélectionné (0 = tous les secteurs)
int _selectedSectorId = 0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 900;
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Filtres
_buildFilters(theme, isDesktop),
const SizedBox(height: 24),
// Graphiques
_buildCharts(theme),
const SizedBox(height: 24),
// Résumé par type de passage
_buildPassageTypeSummary(theme, isDesktop),
const SizedBox(height: 24),
// Résumé par type de règlement
_buildPaymentTypeSummary(theme, isDesktop),
],
),
),
),
);
}
// Construction des filtres
Widget _buildFilters(ThemeData theme, bool isDesktop) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Filtres',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
// Sélection de la période
_buildFilterSection(
'Période',
['Jour', 'Semaine', 'Mois', 'Année'],
_selectedPeriod,
(value) {
setState(() {
_selectedPeriod = value;
});
},
theme,
),
// Sélection du secteur (si l'utilisateur a plusieurs secteurs)
_buildSectorSelector(context, theme),
// Bouton d'application des filtres
ElevatedButton.icon(
onPressed: () {
// Actualiser les statistiques avec les filtres sélectionnés
setState(() {
// Dans une implémentation réelle, on chargerait ici les données
// filtrées par période et secteur
});
},
icon: const Icon(Icons.filter_list),
label: const Text('Appliquer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
],
),
],
),
),
);
}
// Construction du sélecteur de secteur
Widget _buildSectorSelector(BuildContext context, ThemeData theme) {
// Utiliser l'instance globale définie dans app.dart
// Récupérer les secteurs de l'utilisateur
final sectors = userRepository.getUserSectors();
// Si l'utilisateur n'a qu'un seul secteur, ne pas afficher le sélecteur
if (sectors.length <= 1) {
return const SizedBox.shrink();
}
// Créer la liste des options avec "Tous" comme première option
final List<DropdownMenuItem<int>> items = [
const DropdownMenuItem<int>(
value: 0,
child: Text('Tous les secteurs'),
),
];
// Ajouter les secteurs de l'utilisateur
for (final sector in sectors) {
items.add(
DropdownMenuItem<int>(
value: sector.id,
child: Text(sector.libelle),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Secteur',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxWidth: 250),
child: DropdownButton<int>(
value: _selectedSectorId,
isExpanded: true,
items: items,
onChanged: (value) {
if (value != null) {
setState(() {
_selectedSectorId = value;
});
}
},
hint: const Text('Sélectionner un secteur'),
),
),
],
);
}
// Construction d'une section de filtre
Widget _buildFilterSection(
String title,
List<String> options,
String selectedValue,
Function(String) onChanged,
ThemeData theme,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: options.map((option) {
return ButtonSegment<String>(
value: option,
label: Text(option),
);
}).toList(),
selected: {selectedValue},
onSelectionChanged: (Set<String> selection) {
onChanged(selection.first);
},
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith<Color>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.selected)) {
return AppTheme.secondaryColor;
}
return theme.colorScheme.surface;
},
),
foregroundColor: WidgetStateProperty.resolveWith<Color>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.selected)) {
return Colors.white;
}
return theme.colorScheme.onSurface;
},
),
),
),
],
);
}
// Construction des graphiques
Widget _buildCharts(ThemeData theme) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Passages et règlements par $_selectedPeriod',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
SizedBox(
height: 300,
child: _buildActivityChart(theme),
),
],
),
),
);
}
// Construction du graphique d'activité
Widget _buildActivityChart(ThemeData theme) {
// Générer des données fictives pour les passages
final now = DateTime.now();
final List<Map<String, dynamic>> passageData = [];
// Récupérer le secteur sélectionné (si applicable)
final String sectorLabel = _selectedSectorId == 0
? 'Tous les secteurs'
: userRepository.getSectorById(_selectedSectorId)?.libelle ??
'Secteur inconnu';
// Déterminer la plage de dates en fonction de la période sélectionnée
DateTime startDate;
int daysToGenerate;
switch (_selectedPeriod) {
case 'Jour':
startDate = DateTime(now.year, now.month, now.day);
daysToGenerate = 1;
break;
case 'Semaine':
// Début de la semaine (lundi)
final weekday = now.weekday;
startDate = now.subtract(Duration(days: weekday - 1));
daysToGenerate = 7;
break;
case 'Mois':
// Début du mois
startDate = DateTime(now.year, now.month, 1);
// Calculer le nombre de jours dans le mois
final lastDayOfMonth = DateTime(now.year, now.month + 1, 0).day;
daysToGenerate = lastDayOfMonth;
break;
case 'Année':
// Début de l'année
startDate = DateTime(now.year, 1, 1);
daysToGenerate = 365;
break;
default:
startDate = DateTime(now.year, now.month, now.day);
daysToGenerate = 7;
}
// Générer des données pour la période sélectionnée
for (int i = 0; i < daysToGenerate; i++) {
final date = startDate.add(Duration(days: i));
// Générer des données pour chaque type de passage
for (int typeId = 1; typeId <= 6; typeId++) {
// Générer un nombre de passages basé sur le jour et le type
final count = (typeId == 1 || typeId == 2)
? (2 + (date.day % 6)) // Plus de passages pour les types 1 et 2
: (date.day % 4); // Moins pour les autres types
if (count > 0) {
passageData.add({
'date': date.toIso8601String(),
'type_passage': typeId,
'nb': count,
});
}
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Afficher le secteur sélectionné si ce n'est pas "Tous"
if (_selectedSectorId != 0)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
'Secteur: $sectorLabel',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
ActivityChart(
passageData: passageData,
periodType: _selectedPeriod,
height: 300,
),
],
);
}
// Construction du résumé par type de passage
Widget _buildPassageTypeSummary(ThemeData theme, bool isDesktop) {
return PassageSummaryCard(
title: 'Répartition par type de passage',
titleColor: theme.colorScheme.primary,
titleIcon: Icons.pie_chart,
height: 300,
useValueListenable: true,
userId: userRepository.getCurrentUser()?.id,
showAllPassages: false,
excludePassageTypes: const [2], // Exclure "À finaliser"
isDesktop: 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,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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