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:
117
app/lib/app.dart
117
app/lib/app.dart
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
420
app/lib/core/services/device_info_service.dart
Normal file
420
app/lib/core/services/device_info_service.dart
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
350
app/lib/core/services/stripe_tap_to_pay_service.dart
Normal file
350
app/lib/core/services/stripe_tap_to_pay_service.dart
Normal 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;
|
||||
}
|
||||
501
app/lib/core/services/stripe_terminal_service.dart
Normal file
501
app/lib/core/services/stripe_terminal_service.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
253
app/lib/core/services/stripe_terminal_service_simple.dart
Normal file
253
app/lib/core/services/stripe_terminal_service_simple.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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é');
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
39
app/lib/presentation/pages/amicale_page.dart
Normal file
39
app/lib/presentation/pages/amicale_page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
32
app/lib/presentation/pages/field_mode_page.dart
Normal file
32
app/lib/presentation/pages/field_mode_page.dart
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
1737
app/lib/presentation/pages/history_page.dart
Normal file
1737
app/lib/presentation/pages/history_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
276
app/lib/presentation/pages/home_page.dart
Normal file
276
app/lib/presentation/pages/home_page.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
672
app/lib/presentation/admin/admin_map_page.dart → app/lib/presentation/pages/map_page.dart
Executable file → Normal file
672
app/lib/presentation/admin/admin_map_page.dart → app/lib/presentation/pages/map_page.dart
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
23
app/lib/presentation/pages/messages_page.dart
Normal file
23
app/lib/presentation/pages/messages_page.dart
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
36
app/lib/presentation/pages/operations_page.dart
Normal file
36
app/lib/presentation/pages/operations_page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
207
app/lib/presentation/widgets/admin_scaffold.dart
Normal file
207
app/lib/presentation/widgets/admin_scaffold.dart
Normal file
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
|
||||
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Class pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
final numberOfDots = (size.width * size.height) ~/ 1500;
|
||||
|
||||
for (int i = 0; i < numberOfDots; i++) {
|
||||
final x = random.nextDouble() * size.width;
|
||||
final y = random.nextDouble() * size.height;
|
||||
final radius = 1.0 + random.nextDouble() * 2.0;
|
||||
canvas.drawCircle(Offset(x, y), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// Scaffold partagé pour toutes les pages d'administration
|
||||
/// Fournit le fond dégradé et la navigation commune
|
||||
class AdminScaffold extends StatelessWidget {
|
||||
/// Le contenu de la page
|
||||
final Widget body;
|
||||
|
||||
/// L'index de navigation sélectionné
|
||||
final int selectedIndex;
|
||||
|
||||
/// Le titre de la page
|
||||
final String pageTitle;
|
||||
|
||||
/// Callback optionnel pour gérer la navigation personnalisée
|
||||
final Function(int)? onDestinationSelected;
|
||||
|
||||
const AdminScaffold({
|
||||
super.key,
|
||||
required this.body,
|
||||
required this.selectedIndex,
|
||||
required this.pageTitle,
|
||||
this.onDestinationSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isMobile = size.width <= 900;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: const SizedBox(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu de la page avec navigation
|
||||
DashboardLayout(
|
||||
key: ValueKey('dashboard_layout_$selectedIndex'),
|
||||
title: 'Tableau de bord Administration',
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onDestinationSelected ?? (index) {
|
||||
// Navigation par défaut si pas de callback personnalisé
|
||||
AdminNavigationHelper.navigateToIndex(context, index);
|
||||
},
|
||||
destinations: AdminNavigationHelper.getDestinations(
|
||||
currentUser: currentUser,
|
||||
isMobile: isMobile,
|
||||
),
|
||||
isAdmin: true,
|
||||
body: body,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper pour centraliser la logique de navigation admin
|
||||
class AdminNavigationHelper {
|
||||
/// Obtenir la liste des destinations de navigation selon le rôle et le device
|
||||
static List<NavigationDestination> getDestinations({
|
||||
required dynamic currentUser,
|
||||
required bool isMobile,
|
||||
}) {
|
||||
final destinations = <NavigationDestination>[
|
||||
// Pages de base toujours visibles
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: 'Tableau de bord',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.bar_chart_outlined),
|
||||
selectedIcon: Icon(Icons.bar_chart),
|
||||
label: 'Statistiques',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.history_outlined),
|
||||
selectedIcon: Icon(Icons.history),
|
||||
label: 'Historique',
|
||||
),
|
||||
createBadgedNavigationDestination(
|
||||
icon: const Icon(Icons.chat_outlined),
|
||||
selectedIcon: const Icon(Icons.chat),
|
||||
label: 'Messages',
|
||||
showBadge: true,
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.map_outlined),
|
||||
selectedIcon: Icon(Icons.map),
|
||||
label: 'Carte',
|
||||
),
|
||||
];
|
||||
|
||||
// Ajouter les pages admin (role 2) seulement sur desktop
|
||||
if (currentUser?.role == 2 && !isMobile) {
|
||||
destinations.addAll([
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.business_outlined),
|
||||
selectedIcon: Icon(Icons.business),
|
||||
label: 'Amicale & membres',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.calendar_today_outlined),
|
||||
selectedIcon: Icon(Icons.calendar_today),
|
||||
label: 'Opérations',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return destinations;
|
||||
}
|
||||
|
||||
/// Naviguer vers une page selon l'index
|
||||
static void navigateToIndex(BuildContext context, int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
context.go('/admin');
|
||||
break;
|
||||
case 1:
|
||||
context.go('/admin/statistics');
|
||||
break;
|
||||
case 2:
|
||||
context.go('/admin/history');
|
||||
break;
|
||||
case 3:
|
||||
context.go('/admin/messages');
|
||||
break;
|
||||
case 4:
|
||||
context.go('/admin/map');
|
||||
break;
|
||||
case 5:
|
||||
context.go('/admin/amicale');
|
||||
break;
|
||||
case 6:
|
||||
context.go('/admin/operations');
|
||||
break;
|
||||
default:
|
||||
context.go('/admin');
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtenir l'index selon la route actuelle
|
||||
static int getIndexFromRoute(String route) {
|
||||
if (route.contains('/statistics')) return 1;
|
||||
if (route.contains('/history')) return 2;
|
||||
if (route.contains('/messages')) return 3;
|
||||
if (route.contains('/map')) return 4;
|
||||
if (route.contains('/amicale')) return 5;
|
||||
if (route.contains('/operations')) return 6;
|
||||
return 0; // Dashboard par défaut
|
||||
}
|
||||
|
||||
/// Obtenir le nom de la page selon l'index
|
||||
static String getPageNameFromIndex(int index) {
|
||||
switch (index) {
|
||||
case 0: return 'dashboard';
|
||||
case 1: return 'statistics';
|
||||
case 2: return 'history';
|
||||
case 3: return 'messages';
|
||||
case 4: return 'map';
|
||||
case 5: return 'amicale';
|
||||
case 6: return 'operations';
|
||||
default: return 'dashboard';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
@@ -62,7 +61,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
bool _chkMdpManuel = false;
|
||||
bool _chkUsernameManuel = false;
|
||||
bool _chkUserDeletePass = false;
|
||||
|
||||
bool _chkLotActif = false;
|
||||
|
||||
// Pour l'upload du logo
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
XFile? _selectedImage;
|
||||
@@ -100,7 +100,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
_chkMdpManuel = amicale?.chkMdpManuel ?? false;
|
||||
_chkUsernameManuel = amicale?.chkUsernameManuel ?? false;
|
||||
_chkUserDeletePass = amicale?.chkUserDeletePass ?? false;
|
||||
|
||||
_chkLotActif = amicale?.chkLotActif ?? false;
|
||||
|
||||
// Note : Le logo sera chargé dynamiquement depuis l'API
|
||||
|
||||
// Initialiser le service Stripe si API disponible
|
||||
@@ -314,6 +315,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
'chk_mdp_manuel': amicale.chkMdpManuel ? 1 : 0,
|
||||
'chk_username_manuel': amicale.chkUsernameManuel ? 1 : 0,
|
||||
'chk_user_delete_pass': amicale.chkUserDeletePass ? 1 : 0,
|
||||
'chk_lot_actif': amicale.chkLotActif ? 1 : 0,
|
||||
};
|
||||
|
||||
// Ajouter les champs réservés aux administrateurs si l'utilisateur est admin
|
||||
@@ -564,6 +566,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
chkMdpManuel: _chkMdpManuel,
|
||||
chkUsernameManuel: _chkUsernameManuel,
|
||||
chkUserDeletePass: _chkUserDeletePass,
|
||||
chkLotActif: _chkLotActif,
|
||||
) ??
|
||||
AmicaleModel(
|
||||
id: 0, // Sera remplacé par l'API
|
||||
@@ -588,6 +591,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
chkMdpManuel: _chkMdpManuel,
|
||||
chkUsernameManuel: _chkUsernameManuel,
|
||||
chkUserDeletePass: _chkUserDeletePass,
|
||||
chkLotActif: _chkLotActif,
|
||||
);
|
||||
|
||||
debugPrint('🔧 AmicaleModel créé: ${amicale.name}');
|
||||
@@ -1392,6 +1396,20 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Checkbox pour activer le mode Lot
|
||||
_buildCheckboxOption(
|
||||
label: "Activer le mode Lot (distributions groupées)",
|
||||
value: _chkLotActif,
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_chkLotActif = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// Boutons Fermer et Enregistrer
|
||||
@@ -1461,12 +1479,13 @@ class _AmicaleFormState extends State<AmicaleForm> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// Note : Utilise le rôle RÉEL pour les permissions d'édition (pas le mode d'affichage)
|
||||
final userRole = widget.userRepository.getUserRole();
|
||||
|
||||
// Déterminer si l'utilisateur peut modifier les champs restreints
|
||||
// Déterminer si l'utilisateur peut modifier les champs restreints (super admin uniquement)
|
||||
final bool canEditRestrictedFields = userRole > 2;
|
||||
|
||||
// Pour Stripe, les admins d'amicale (rôle 2) peuvent aussi configurer
|
||||
|
||||
// Pour Stripe, les admins d'amicale (rôle 2) et super admins peuvent configurer
|
||||
final bool canEditStripe = userRole >= 2;
|
||||
|
||||
// Lecture seule pour les champs restreints si l'utilisateur n'a pas les droits
|
||||
|
||||
416
app/lib/presentation/widgets/app_scaffold.dart
Normal file
416
app/lib/presentation/widgets/app_scaffold.dart
Normal file
@@ -0,0 +1,416 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
|
||||
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Classe pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
final numberOfDots = (size.width * size.height) ~/ 1500;
|
||||
|
||||
for (int i = 0; i < numberOfDots; i++) {
|
||||
final x = random.nextDouble() * size.width;
|
||||
final y = random.nextDouble() * size.height;
|
||||
final radius = 1.0 + random.nextDouble() * 2.0;
|
||||
canvas.drawCircle(Offset(x, y), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// Scaffold unifié pour toutes les pages (admin et user)
|
||||
/// Adapte automatiquement son apparence selon le rôle de l'utilisateur
|
||||
class AppScaffold extends StatelessWidget {
|
||||
/// Le contenu de la page
|
||||
final Widget body;
|
||||
|
||||
/// L'index de navigation sélectionné
|
||||
final int selectedIndex;
|
||||
|
||||
/// Le titre de la page
|
||||
final String pageTitle;
|
||||
|
||||
/// Callback optionnel pour gérer la navigation personnalisée
|
||||
final Function(int)? onDestinationSelected;
|
||||
|
||||
/// Forcer le mode admin (optionnel, sinon détecte automatiquement)
|
||||
final bool? forceAdmin;
|
||||
|
||||
/// Afficher ou non le fond dégradé avec points (économise des ressources si désactivé)
|
||||
final bool showBackground;
|
||||
|
||||
const AppScaffold({
|
||||
super.key,
|
||||
required this.body,
|
||||
required this.selectedIndex,
|
||||
required this.pageTitle,
|
||||
this.onDestinationSelected,
|
||||
this.forceAdmin,
|
||||
this.showBackground = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isMobile = size.width <= 900;
|
||||
|
||||
// Déterminer si l'utilisateur est admin (prend en compte le mode d'affichage)
|
||||
final userRole = currentUser?.role ?? 1;
|
||||
final isAdmin = forceAdmin ?? CurrentUserService.instance.shouldShowAdminUI;
|
||||
|
||||
debugPrint('🎨 AppScaffold: isAdmin=$isAdmin, displayMode=${CurrentUserService.instance.displayMode}, userRole=$userRole');
|
||||
|
||||
// Pour les utilisateurs standards, vérifier les conditions d'accès
|
||||
if (!isAdmin) {
|
||||
final hasOperation = userRepository.getCurrentOperation() != null;
|
||||
final hasSectors = userRepository.getUserSectors().isNotEmpty;
|
||||
|
||||
// Si pas d'opération, afficher le message approprié
|
||||
if (!hasOperation) {
|
||||
return _buildRestrictedAccess(
|
||||
context: context,
|
||||
icon: Icons.warning_outlined,
|
||||
title: 'Aucune opération assignée',
|
||||
message: 'Vous n\'avez pas encore été affecté à une opération. '
|
||||
'Veuillez contacter votre administrateur pour obtenir un accès.',
|
||||
isAdmin: false,
|
||||
);
|
||||
}
|
||||
|
||||
// Si pas de secteur, afficher le message approprié
|
||||
if (!hasSectors) {
|
||||
return _buildRestrictedAccess(
|
||||
context: context,
|
||||
icon: Icons.map_outlined,
|
||||
title: 'Aucun secteur assigné',
|
||||
message: 'Vous n\'êtes affecté sur aucun secteur. '
|
||||
'Contactez votre administrateur pour qu\'il vous en affecte au moins un.',
|
||||
isAdmin: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Couleurs de fond selon le rôle
|
||||
final gradientColors = isAdmin
|
||||
? [Colors.white, Colors.red.shade300] // Admin: dégradé rouge
|
||||
: [Colors.white, Colors.green.shade300]; // User: dégradé vert
|
||||
|
||||
// Titre avec suffixe selon le rôle
|
||||
final dashboardTitle = isAdmin
|
||||
? 'Tableau de bord Administration'
|
||||
: 'GEOSECTOR';
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs (optionnel)
|
||||
if (showBackground)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: gradientColors,
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: const SizedBox(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu de la page avec navigation
|
||||
DashboardLayout(
|
||||
key: ValueKey('dashboard_layout_${isAdmin ? 'admin' : 'user'}_$selectedIndex'),
|
||||
title: dashboardTitle,
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onDestinationSelected ?? (index) {
|
||||
NavigationHelper.navigateToIndex(context, index, isAdmin);
|
||||
},
|
||||
destinations: NavigationHelper.getDestinations(
|
||||
isAdmin: isAdmin,
|
||||
isMobile: isMobile,
|
||||
),
|
||||
isAdmin: isAdmin,
|
||||
body: body,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit l'écran d'accès restreint
|
||||
Widget _buildRestrictedAccess({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String message,
|
||||
required bool isAdmin,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Utiliser le même fond que pour un utilisateur normal (vert)
|
||||
final gradientColors = isAdmin
|
||||
? [Colors.white, Colors.red.shade300]
|
||||
: [Colors.white, Colors.green.shade300];
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond dégradé (optionnel)
|
||||
if (showBackground)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: gradientColors,
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: const SizedBox(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
|
||||
// Message d'accès restreint
|
||||
DashboardLayout(
|
||||
title: 'GEOSECTOR',
|
||||
selectedIndex: 0,
|
||||
onDestinationSelected: (index) {
|
||||
// Ne rien faire car l'utilisateur ne peut pas naviguer
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.warning_outlined),
|
||||
selectedIcon: Icon(Icons.warning),
|
||||
label: 'Accès restreint',
|
||||
),
|
||||
],
|
||||
isAdmin: false,
|
||||
body: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.shadowColor.withValues(alpha: 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 80,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper centralisé pour la navigation
|
||||
class NavigationHelper {
|
||||
/// Obtenir la liste des destinations selon le mode d'affichage et le device
|
||||
static List<NavigationDestination> getDestinations({
|
||||
required bool isAdmin,
|
||||
required bool isMobile,
|
||||
}) {
|
||||
final destinations = <NavigationDestination>[];
|
||||
|
||||
// Pages communes à tous les rôles
|
||||
destinations.addAll([
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: 'Tableau de bord',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.history_outlined),
|
||||
selectedIcon: Icon(Icons.history),
|
||||
label: 'Historique',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.map_outlined),
|
||||
selectedIcon: Icon(Icons.map),
|
||||
label: 'Carte',
|
||||
),
|
||||
createBadgedNavigationDestination(
|
||||
icon: const Icon(Icons.chat_outlined),
|
||||
selectedIcon: const Icon(Icons.chat),
|
||||
label: 'Messages',
|
||||
showBadge: true,
|
||||
),
|
||||
]);
|
||||
|
||||
// Pages spécifiques aux utilisateurs standards
|
||||
if (!isAdmin) {
|
||||
destinations.add(
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.explore_outlined),
|
||||
selectedIcon: Icon(Icons.explore),
|
||||
label: 'Terrain',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Pages spécifiques aux admins (seulement sur desktop)
|
||||
if (isAdmin && !isMobile) {
|
||||
destinations.addAll([
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.business_outlined),
|
||||
selectedIcon: Icon(Icons.business),
|
||||
label: 'Amicale & membres',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.calendar_today_outlined),
|
||||
selectedIcon: Icon(Icons.calendar_today),
|
||||
label: 'Opérations',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return destinations;
|
||||
}
|
||||
|
||||
/// Naviguer vers une page selon l'index et le rôle
|
||||
static void navigateToIndex(BuildContext context, int index, bool isAdmin) {
|
||||
if (isAdmin) {
|
||||
_navigateAdminIndex(context, index);
|
||||
} else {
|
||||
_navigateUserIndex(context, index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation pour les admins
|
||||
static void _navigateAdminIndex(BuildContext context, int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
context.go('/admin');
|
||||
break;
|
||||
case 1:
|
||||
context.go('/admin/history');
|
||||
break;
|
||||
case 2:
|
||||
context.go('/admin/map');
|
||||
break;
|
||||
case 3:
|
||||
context.go('/admin/messages');
|
||||
break;
|
||||
case 4:
|
||||
context.go('/admin/amicale');
|
||||
break;
|
||||
case 5:
|
||||
context.go('/admin/operations');
|
||||
break;
|
||||
default:
|
||||
context.go('/admin');
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation pour les utilisateurs standards
|
||||
static void _navigateUserIndex(BuildContext context, int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
context.go('/user/dashboard');
|
||||
break;
|
||||
case 1:
|
||||
context.go('/user/history');
|
||||
break;
|
||||
case 2:
|
||||
context.go('/user/map');
|
||||
break;
|
||||
case 3:
|
||||
context.go('/user/messages');
|
||||
break;
|
||||
case 4:
|
||||
context.go('/user/field-mode');
|
||||
break;
|
||||
default:
|
||||
context.go('/user/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtenir l'index selon la route actuelle et le rôle
|
||||
static int getIndexFromRoute(String route, bool isAdmin) {
|
||||
// Enlever les paramètres de query si présents
|
||||
final cleanRoute = route.split('?').first;
|
||||
|
||||
if (isAdmin) {
|
||||
if (cleanRoute.contains('/admin/history')) return 1;
|
||||
if (cleanRoute.contains('/admin/map')) return 2;
|
||||
if (cleanRoute.contains('/admin/messages')) return 3;
|
||||
if (cleanRoute.contains('/admin/amicale')) return 4;
|
||||
if (cleanRoute.contains('/admin/operations')) return 5;
|
||||
return 0; // Dashboard par défaut
|
||||
} else {
|
||||
if (cleanRoute.contains('/user/history')) return 1;
|
||||
if (cleanRoute.contains('/user/map')) return 2;
|
||||
if (cleanRoute.contains('/user/messages')) return 3;
|
||||
if (cleanRoute.contains('/user/field-mode')) return 4;
|
||||
return 0; // Dashboard par défaut
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtenir le nom de la page selon l'index et le rôle
|
||||
static String getPageNameFromIndex(int index, bool isAdmin) {
|
||||
if (isAdmin) {
|
||||
switch (index) {
|
||||
case 0: return 'dashboard';
|
||||
case 1: return 'history';
|
||||
case 2: return 'map';
|
||||
case 3: return 'messages';
|
||||
case 4: return 'amicale';
|
||||
case 5: return 'operations';
|
||||
default: return 'dashboard';
|
||||
}
|
||||
} else {
|
||||
switch (index) {
|
||||
case 0: return 'dashboard';
|
||||
case 1: return 'history';
|
||||
case 2: return 'map';
|
||||
case 3: return 'messages';
|
||||
case 4: return 'field-mode';
|
||||
default: return 'dashboard';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/user_sector_model.dart';
|
||||
|
||||
/// Widget de graphique d'activité affichant les passages
|
||||
class ActivityChart extends StatefulWidget {
|
||||
@@ -183,9 +184,15 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
final passages = passagesBox.values.toList();
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
|
||||
// Déterminer l'utilisateur cible selon les filtres
|
||||
final int? targetUserId =
|
||||
widget.showAllPassages ? null : (widget.userId ?? currentUser?.id);
|
||||
// Pour les users : récupérer les secteurs assignés
|
||||
Set<int>? userSectorIds;
|
||||
if (!widget.showAllPassages && currentUser != null) {
|
||||
final userSectorBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
|
||||
userSectorIds = userSectorBox.values
|
||||
.where((us) => us.id == currentUser.id)
|
||||
.map((us) => us.fkSector)
|
||||
.toSet();
|
||||
}
|
||||
|
||||
// Calculer la date de début (nombre de jours en arrière)
|
||||
final endDate = DateTime.now();
|
||||
@@ -213,8 +220,8 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
// Appliquer les filtres
|
||||
bool shouldInclude = true;
|
||||
|
||||
// Filtrer par utilisateur si nécessaire
|
||||
if (targetUserId != null && passage.fkUser != targetUserId) {
|
||||
// Filtrer par secteurs assignés si nécessaire (pour les users)
|
||||
if (userSectorIds != null && !userSectorIds.contains(passage.fkSector)) {
|
||||
shouldInclude = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
|
||||
@@ -157,7 +158,23 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
|
||||
/// Construction du widget avec des données statiques (ancien système)
|
||||
Widget _buildWithStaticData() {
|
||||
final chartData = _prepareChartDataFromMap(widget.passagesByType);
|
||||
// Vérifier si le type Lot doit être affiché
|
||||
bool showLotType = true;
|
||||
final currentUser = CurrentUserService.instance.currentUser;
|
||||
if (currentUser != null && currentUser.fkEntite != null) {
|
||||
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
||||
if (userAmicale != null) {
|
||||
showLotType = userAmicale.chkLotActif;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer les données pour exclure le type 5 si nécessaire
|
||||
Map<int, int> filteredData = Map.from(widget.passagesByType);
|
||||
if (!showLotType) {
|
||||
filteredData.remove(5);
|
||||
}
|
||||
|
||||
final chartData = _prepareChartDataFromMap(filteredData);
|
||||
return _buildChart(chartData);
|
||||
}
|
||||
|
||||
@@ -167,25 +184,38 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
final passages = passagesBox.values.toList();
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
|
||||
// Vérifier si le type Lot doit être affiché
|
||||
bool showLotType = true;
|
||||
if (currentUser != null && currentUser.fkEntite != null) {
|
||||
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
||||
if (userAmicale != null) {
|
||||
showLotType = userAmicale.chkLotActif;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculer les données selon les filtres
|
||||
final Map<int, int> passagesByType = {};
|
||||
|
||||
// Initialiser tous les types de passage possibles
|
||||
for (final typeId in AppKeys.typesPassages.keys) {
|
||||
// Exclure le type Lot (5) si chkLotActif = false
|
||||
if (typeId == 5 && !showLotType) {
|
||||
continue;
|
||||
}
|
||||
if (!widget.excludePassageTypes.contains(typeId)) {
|
||||
passagesByType[typeId] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// L'API filtre déjà les passages côté serveur
|
||||
// On compte simplement tous les passages de la box
|
||||
for (final passage in passages) {
|
||||
// Appliquer les filtres
|
||||
// Appliquer les filtres locaux uniquement
|
||||
bool shouldInclude = true;
|
||||
|
||||
// Filtrer par utilisateur si nécessaire
|
||||
if (!widget.showAllPassages && widget.userId != null) {
|
||||
// Filtrer par userId si spécifié (cas particulier pour compatibilité)
|
||||
if (widget.userId != null) {
|
||||
shouldInclude = passage.fkUser == widget.userId;
|
||||
} else if (!widget.showAllPassages && currentUser != null) {
|
||||
shouldInclude = passage.fkUser == currentUser.id;
|
||||
}
|
||||
|
||||
// Exclure certains types
|
||||
@@ -193,6 +223,11 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
shouldInclude = false;
|
||||
}
|
||||
|
||||
// Exclure le type Lot (5) si chkLotActif = false
|
||||
if (passage.fkType == 5 && !showLotType) {
|
||||
shouldInclude = false;
|
||||
}
|
||||
|
||||
if (shouldInclude) {
|
||||
passagesByType[passage.fkType] =
|
||||
(passagesByType[passage.fkType] ?? 0) + 1;
|
||||
@@ -211,8 +246,23 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
Map<int, int> passagesByType) {
|
||||
final List<PassageChartData> chartData = [];
|
||||
|
||||
// Vérifier si le type Lot doit être affiché
|
||||
bool showLotType = true;
|
||||
final currentUser = CurrentUserService.instance.currentUser;
|
||||
if (currentUser != null && currentUser.fkEntite != null) {
|
||||
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
||||
if (userAmicale != null) {
|
||||
showLotType = userAmicale.chkLotActif;
|
||||
}
|
||||
}
|
||||
|
||||
// Créer les données du graphique
|
||||
passagesByType.forEach((typeId, count) {
|
||||
// Exclure le type Lot (5) si chkLotActif = false
|
||||
if (typeId == 5 && !showLotType) {
|
||||
return; // Skip ce type
|
||||
}
|
||||
|
||||
// Vérifier que le type existe et que le compteur est positif
|
||||
if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) {
|
||||
final typeInfo = AppKeys.typesPassages[typeId]!;
|
||||
|
||||
@@ -75,6 +75,39 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Si useValueListenable, construire avec ValueListenableBuilder centralisé
|
||||
if (useValueListenable) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
// Calculer les données une seule fois
|
||||
final passagesCounts = _calculatePassagesCounts(passagesBox);
|
||||
final totalUserPassages = passagesCounts.values.fold(0, (sum, count) => sum + count);
|
||||
|
||||
return _buildCardContent(
|
||||
context,
|
||||
totalUserPassages: totalUserPassages,
|
||||
passagesCounts: passagesCounts,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Données statiques
|
||||
final totalPassages = passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
|
||||
return _buildCardContent(
|
||||
context,
|
||||
totalUserPassages: totalPassages,
|
||||
passagesCounts: passagesByType ?? {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit le contenu de la card avec les données calculées
|
||||
Widget _buildCardContent(
|
||||
BuildContext context, {
|
||||
required int totalUserPassages,
|
||||
required Map<int, int> passagesCounts,
|
||||
}) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -102,9 +135,7 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre avec comptage
|
||||
useValueListenable
|
||||
? _buildTitleWithValueListenable()
|
||||
: _buildTitleWithStaticData(context),
|
||||
_buildTitle(context, totalUserPassages),
|
||||
const Divider(height: 24),
|
||||
// Contenu principal
|
||||
Expanded(
|
||||
@@ -115,9 +146,7 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
// Liste des passages à gauche
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: useValueListenable
|
||||
? _buildPassagesListWithValueListenable()
|
||||
: _buildPassagesListWithStaticData(context),
|
||||
child: _buildPassagesList(context, passagesCounts),
|
||||
),
|
||||
|
||||
// Séparateur vertical
|
||||
@@ -129,9 +158,10 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: PassagePieChart(
|
||||
useValueListenable: useValueListenable,
|
||||
passagesByType: passagesByType ?? {},
|
||||
useValueListenable: false, // Utilise les données calculées
|
||||
passagesByType: passagesCounts,
|
||||
excludePassageTypes: excludePassageTypes,
|
||||
showAllPassages: showAllPassages,
|
||||
userId: showAllPassages ? null : userId,
|
||||
size: double.infinity,
|
||||
labelSize: 12,
|
||||
@@ -155,53 +185,8 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du titre avec ValueListenableBuilder
|
||||
Widget _buildTitleWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final totalUserPassages = _calculateUserPassagesCount(passagesBox);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
Icon(
|
||||
titleIcon,
|
||||
color: titleColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 16),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(totalUserPassages) ??
|
||||
totalUserPassages.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 20),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du titre avec données statiques
|
||||
Widget _buildTitleWithStaticData(BuildContext context) {
|
||||
final totalPassages =
|
||||
passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
|
||||
|
||||
/// Construction du titre
|
||||
Widget _buildTitle(BuildContext context, int totalUserPassages) {
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
@@ -222,7 +207,8 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(totalPassages) ?? totalPassages.toString(),
|
||||
customTotalDisplay?.call(totalUserPassages) ??
|
||||
totalUserPassages.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 20),
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -233,30 +219,28 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction de la liste des passages avec ValueListenableBuilder
|
||||
Widget _buildPassagesListWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final passagesCounts = _calculatePassagesCounts(passagesBox);
|
||||
|
||||
return _buildPassagesList(context, passagesCounts);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction de la liste des passages avec données statiques
|
||||
Widget _buildPassagesListWithStaticData(BuildContext context) {
|
||||
return _buildPassagesList(context, passagesByType ?? {});
|
||||
}
|
||||
|
||||
/// Construction de la liste des passages
|
||||
Widget _buildPassagesList(BuildContext context, Map<int, int> passagesCounts) {
|
||||
// Vérifier si le type Lot doit être affiché
|
||||
bool showLotType = true;
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
if (currentUser != null && currentUser.fkEntite != null) {
|
||||
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
||||
if (userAmicale != null) {
|
||||
showLotType = userAmicale.chkLotActif;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...AppKeys.typesPassages.entries.map((entry) {
|
||||
...AppKeys.typesPassages.entries.where((entry) {
|
||||
// Exclure le type Lot (5) si chkLotActif = false
|
||||
if (entry.key == 5 && !showLotType) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map((entry) {
|
||||
final int typeId = entry.key;
|
||||
final Map<String, dynamic> typeData = entry.value;
|
||||
final int count = passagesCounts[typeId] ?? 0;
|
||||
@@ -303,54 +287,45 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Calcule le nombre total de passages pour l'utilisateur
|
||||
int _calculateUserPassagesCount(Box<PassageModel> passagesBox) {
|
||||
if (showAllPassages) {
|
||||
// Pour les administrateurs : tous les passages sauf ceux exclus
|
||||
return passagesBox.values
|
||||
.where((passage) => !excludePassageTypes.contains(passage.fkType))
|
||||
.length;
|
||||
} else {
|
||||
// Pour les utilisateurs : seulement leurs passages
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
if (targetUserId == null) return 0;
|
||||
|
||||
return passagesBox.values
|
||||
.where((passage) =>
|
||||
passage.fkUser == targetUserId &&
|
||||
!excludePassageTypes.contains(passage.fkType))
|
||||
.length;
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcule les compteurs de passages par type
|
||||
Map<int, int> _calculatePassagesCounts(Box<PassageModel> passagesBox) {
|
||||
final Map<int, int> counts = {};
|
||||
|
||||
// Vérifier si le type Lot doit être affiché
|
||||
bool showLotType = true;
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
if (currentUser != null && currentUser.fkEntite != null) {
|
||||
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
||||
if (userAmicale != null) {
|
||||
showLotType = userAmicale.chkLotActif;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialiser tous les types
|
||||
for (final typeId in AppKeys.typesPassages.keys) {
|
||||
// Exclure le type Lot (5) si chkLotActif = false
|
||||
if (typeId == 5 && !showLotType) {
|
||||
continue;
|
||||
}
|
||||
// Exclure les types non désirés
|
||||
if (excludePassageTypes.contains(typeId)) {
|
||||
continue;
|
||||
}
|
||||
counts[typeId] = 0;
|
||||
}
|
||||
|
||||
if (showAllPassages) {
|
||||
// Pour les administrateurs : compter tous les passages
|
||||
for (final passage in passagesBox.values) {
|
||||
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
|
||||
// L'API filtre déjà les passages côté serveur
|
||||
// On compte simplement tous les passages de la box
|
||||
for (final passage in passagesBox.values) {
|
||||
// Exclure le type Lot (5) si chkLotActif = false
|
||||
if (passage.fkType == 5 && !showLotType) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Pour les utilisateurs : compter seulement leurs passages
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
if (targetUserId != null) {
|
||||
for (final passage in passagesBox.values) {
|
||||
if (passage.fkUser == targetUserId) {
|
||||
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
// Exclure les types non désirés
|
||||
if (excludePassageTypes.contains(passage.fkType)) {
|
||||
continue;
|
||||
}
|
||||
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
|
||||
@@ -163,11 +163,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
try {
|
||||
final passages = passagesBox.values.toList();
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
|
||||
// Déterminer l'utilisateur cible selon les filtres
|
||||
final int? targetUserId = widget.showAllPassages
|
||||
? null
|
||||
: (widget.userId ?? currentUser?.id);
|
||||
|
||||
// Initialiser les montants par type de règlement
|
||||
final Map<int, double> paymentAmounts = {
|
||||
@@ -177,37 +172,38 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
3: 0.0, // CB
|
||||
};
|
||||
|
||||
// Parcourir les passages et calculer les montants par type de règlement
|
||||
// Déterminer le filtre utilisateur : en mode user, on filtre par fkUser
|
||||
final int? filterUserId = widget.showAllPassages
|
||||
? null
|
||||
: (widget.userId ?? currentUser?.id);
|
||||
|
||||
for (final passage in passages) {
|
||||
// Appliquer le filtre utilisateur si nécessaire
|
||||
bool shouldInclude = true;
|
||||
if (targetUserId != null && passage.fkUser != targetUserId) {
|
||||
shouldInclude = false;
|
||||
// En mode user, ne compter que les passages de l'utilisateur
|
||||
if (filterUserId != null && passage.fkUser != filterUserId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldInclude) {
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
// Gérer les formats possibles (virgule ou point)
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de conversion du montant: ${passage.montant}');
|
||||
}
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
|
||||
// Ne compter que les passages avec un montant > 0
|
||||
if (montant > 0) {
|
||||
// Ajouter au montant total par type de règlement
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
}
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
// Gérer les formats possibles (virgule ou point)
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de conversion du montant: ${passage.montant}');
|
||||
}
|
||||
|
||||
// Ne compter que les passages avec un montant > 0
|
||||
if (montant > 0) {
|
||||
// Ajouter au montant total par type de règlement
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,39 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Si useValueListenable, construire avec ValueListenableBuilder centralisé
|
||||
if (useValueListenable) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
// Calculer les données une seule fois
|
||||
final paymentAmounts = _calculatePaymentAmounts(passagesBox);
|
||||
final totalAmount = paymentAmounts.values.fold(0.0, (sum, amount) => sum + amount);
|
||||
|
||||
return _buildCardContent(
|
||||
context,
|
||||
totalAmount: totalAmount,
|
||||
paymentAmounts: paymentAmounts,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Données statiques
|
||||
final totalAmount = paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
|
||||
return _buildCardContent(
|
||||
context,
|
||||
totalAmount: totalAmount,
|
||||
paymentAmounts: paymentsByType ?? {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit le contenu de la card avec les données calculées
|
||||
Widget _buildCardContent(
|
||||
BuildContext context, {
|
||||
required double totalAmount,
|
||||
required Map<int, double> paymentAmounts,
|
||||
}) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -99,9 +132,7 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre avec comptage
|
||||
useValueListenable
|
||||
? _buildTitleWithValueListenable()
|
||||
: _buildTitleWithStaticData(context),
|
||||
_buildTitle(context, totalAmount),
|
||||
const Divider(height: 24),
|
||||
// Contenu principal
|
||||
Expanded(
|
||||
@@ -112,9 +143,7 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
// Liste des règlements à gauche
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: useValueListenable
|
||||
? _buildPaymentsListWithValueListenable()
|
||||
: _buildPaymentsListWithStaticData(context),
|
||||
child: _buildPaymentsList(context, paymentAmounts),
|
||||
),
|
||||
|
||||
// Séparateur vertical
|
||||
@@ -126,11 +155,9 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: PaymentPieChart(
|
||||
useValueListenable: useValueListenable,
|
||||
payments: useValueListenable
|
||||
? []
|
||||
: _convertMapToPaymentData(
|
||||
paymentsByType ?? {}),
|
||||
useValueListenable: false, // Utilise les données calculées
|
||||
payments: _convertMapToPaymentData(paymentAmounts),
|
||||
showAllPassages: showAllPayments,
|
||||
userId: showAllPayments ? null : userId,
|
||||
size: double.infinity,
|
||||
labelSize: 12,
|
||||
@@ -158,53 +185,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du titre avec ValueListenableBuilder
|
||||
Widget _buildTitleWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final paymentStats = _calculatePaymentStats(passagesBox);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
Icon(
|
||||
titleIcon,
|
||||
color: titleColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 16),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(paymentStats['totalAmount']) ??
|
||||
'${paymentStats['totalAmount'].toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 20),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du titre avec données statiques
|
||||
Widget _buildTitleWithStaticData(BuildContext context) {
|
||||
final totalAmount =
|
||||
paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
|
||||
|
||||
/// Construction du titre
|
||||
Widget _buildTitle(BuildContext context, double totalAmount) {
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
@@ -237,24 +219,6 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction de la liste des règlements avec ValueListenableBuilder
|
||||
Widget _buildPaymentsListWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final paymentAmounts = _calculatePaymentAmounts(passagesBox);
|
||||
|
||||
return _buildPaymentsList(context, paymentAmounts);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction de la liste des règlements avec données statiques
|
||||
Widget _buildPaymentsListWithStaticData(BuildContext context) {
|
||||
return _buildPaymentsList(context, paymentsByType ?? {});
|
||||
}
|
||||
|
||||
/// Construction de la liste des règlements
|
||||
Widget _buildPaymentsList(BuildContext context, Map<int, double> paymentAmounts) {
|
||||
return Column(
|
||||
@@ -307,70 +271,6 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Calcule les statistiques de règlement
|
||||
Map<String, dynamic> _calculatePaymentStats(Box<PassageModel> passagesBox) {
|
||||
if (showAllPayments) {
|
||||
// Pour les administrateurs : tous les règlements
|
||||
int passagesWithPaymentCount = 0;
|
||||
double totalAmount = 0.0;
|
||||
|
||||
for (final passage in passagesBox.values) {
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de conversion
|
||||
}
|
||||
|
||||
if (montant > 0) {
|
||||
passagesWithPaymentCount++;
|
||||
totalAmount += montant;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'passagesCount': passagesWithPaymentCount,
|
||||
'totalAmount': totalAmount,
|
||||
};
|
||||
} else {
|
||||
// Pour les utilisateurs : seulement leurs règlements
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
if (targetUserId == null) {
|
||||
return {'passagesCount': 0, 'totalAmount': 0.0};
|
||||
}
|
||||
|
||||
int passagesWithPaymentCount = 0;
|
||||
double totalAmount = 0.0;
|
||||
|
||||
for (final passage in passagesBox.values) {
|
||||
if (passage.fkUser == targetUserId) {
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de conversion
|
||||
}
|
||||
|
||||
if (montant > 0) {
|
||||
passagesWithPaymentCount++;
|
||||
totalAmount += montant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'passagesCount': passagesWithPaymentCount,
|
||||
'totalAmount': totalAmount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcule les montants par type de règlement
|
||||
Map<int, double> _calculatePaymentAmounts(Box<PassageModel> passagesBox) {
|
||||
final Map<int, double> paymentAmounts = {};
|
||||
@@ -380,57 +280,33 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
paymentAmounts[typeId] = 0.0;
|
||||
}
|
||||
|
||||
if (showAllPayments) {
|
||||
// Pour les administrateurs : compter tous les règlements
|
||||
for (final passage in passagesBox.values) {
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
// En mode user, filtrer uniquement les passages créés par l'utilisateur (fkUser)
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? filterUserId = showAllPayments ? null : currentUser?.id;
|
||||
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de conversion
|
||||
}
|
||||
|
||||
if (montant > 0) {
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
}
|
||||
}
|
||||
for (final passage in passagesBox.values) {
|
||||
// En mode user, ne compter que les passages de l'utilisateur
|
||||
if (filterUserId != null && passage.fkUser != filterUserId) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Pour les utilisateurs : compter seulement leurs règlements
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
if (targetUserId != null) {
|
||||
for (final passage in passagesBox.values) {
|
||||
if (passage.fkUser == targetUserId) {
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de conversion
|
||||
}
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de conversion
|
||||
}
|
||||
|
||||
if (montant > 0) {
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (montant > 0) {
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_app_bar.dart';
|
||||
import 'package:geosector_app/presentation/widgets/responsive_navigation.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder à userRepository
|
||||
import 'package:geosector_app/core/theme/app_theme.dart'; // Pour les couleurs du thème
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Layout commun pour les tableaux de bord utilisateur et administrateur
|
||||
/// Combine DashboardAppBar et ResponsiveNavigation
|
||||
@@ -74,60 +72,33 @@ class DashboardLayout extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Déterminer le rôle de l'utilisateur
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final userRole = currentUser?.role ?? 1;
|
||||
|
||||
// Définir les couleurs du gradient selon le rôle
|
||||
final gradientColors = userRole > 1
|
||||
? [Colors.white, Colors.red.shade300] // Admin : fond rouge
|
||||
: [
|
||||
Colors.white,
|
||||
AppTheme.accentColor.withValues(alpha: 0.3)
|
||||
]; // User : fond vert
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond dégradé avec points
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: gradientColors,
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
// Scaffold avec fond transparent
|
||||
Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: DashboardAppBar(
|
||||
title: title,
|
||||
pageTitle: destinations[selectedIndex].label,
|
||||
isAdmin: isAdmin,
|
||||
onLogoutPressed: onLogoutPressed,
|
||||
),
|
||||
body: ResponsiveNavigation(
|
||||
title:
|
||||
title, // Même si le titre n'est pas affiché dans la navigation, il est utilisé pour la cohérence
|
||||
body: body,
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
destinations: destinations,
|
||||
// Ne pas afficher le bouton "Nouveau passage" dans la navigation
|
||||
showNewPassageButton: false,
|
||||
onNewPassagePressed: null,
|
||||
sidebarBottomItems: sidebarBottomItems,
|
||||
isAdmin: isAdmin,
|
||||
// Ne pas afficher l'AppBar dans la navigation car nous utilisons DashboardAppBar
|
||||
showAppBar: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
// Scaffold avec fond transparent (le fond est géré par AppScaffold)
|
||||
return Scaffold(
|
||||
key: ValueKey('dashboard_scaffold_$selectedIndex'),
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: DashboardAppBar(
|
||||
key: ValueKey('dashboard_appbar_$selectedIndex'),
|
||||
title: title,
|
||||
pageTitle: destinations[selectedIndex].label,
|
||||
isAdmin: isAdmin,
|
||||
onLogoutPressed: onLogoutPressed,
|
||||
),
|
||||
body: ResponsiveNavigation(
|
||||
key: ValueKey('responsive_nav_$selectedIndex'),
|
||||
title:
|
||||
title, // Même si le titre n'est pas affiché dans la navigation, il est utilisé pour la cohérence
|
||||
body: body,
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
destinations: destinations,
|
||||
// Ne pas afficher le bouton "Nouveau passage" dans la navigation
|
||||
showNewPassageButton: false,
|
||||
onNewPassagePressed: null,
|
||||
sidebarBottomItems: sidebarBottomItems,
|
||||
isAdmin: isAdmin,
|
||||
// Ne pas afficher l'AppBar dans la navigation car nous utilisons DashboardAppBar
|
||||
showAppBar: false,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERREUR CRITIQUE dans DashboardLayout.build: $e');
|
||||
@@ -166,26 +137,3 @@ class DashboardLayout extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CustomPainter pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
final numberOfDots = (size.width * size.height) ~/ 1500;
|
||||
|
||||
for (int i = 0; i < numberOfDots; i++) {
|
||||
final x = random.nextDouble() * size.width;
|
||||
final y = random.nextDouble() * size.height;
|
||||
final radius = 1.0 + random.nextDouble() * 2.0;
|
||||
canvas.drawCircle(Offset(x, y), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_cache/flutter_map_cache.dart';
|
||||
import 'package:http_cache_file_store/http_cache_file_store.dart';
|
||||
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
@@ -79,8 +78,8 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
// ignore: unused_field
|
||||
double _currentZoom = 13.0;
|
||||
|
||||
/// Provider de cache pour les tuiles
|
||||
CachedTileProvider? _tileProvider;
|
||||
/// Provider de tuiles (peut être NetworkTileProvider ou CachedTileProvider)
|
||||
TileProvider? _tileProvider;
|
||||
|
||||
/// Indique si le cache est initialisé
|
||||
bool _cacheInitialized = false;
|
||||
@@ -96,18 +95,31 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
/// Initialise le cache des tuiles
|
||||
Future<void> _initializeCache() async {
|
||||
try {
|
||||
if (kIsWeb) {
|
||||
// Pas de cache sur Web (non supporté)
|
||||
setState(() {
|
||||
_cacheInitialized = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final dir = await getTemporaryDirectory();
|
||||
// Utiliser un nom de cache différent selon le provider
|
||||
final cacheName = widget.useOpenStreetMap ? 'OSMTileCache' : 'MapboxTileCache';
|
||||
final cacheStore = FileCacheStore('${dir.path}${Platform.pathSeparator}$cacheName');
|
||||
|
||||
_tileProvider = CachedTileProvider(
|
||||
store: cacheStore,
|
||||
// Configuration du cache
|
||||
// maxStale permet de servir des tuiles expirées jusqu'à 30 jours
|
||||
maxStale: const Duration(days: 30),
|
||||
final cacheDir = '${dir.path}/map_tiles_cache';
|
||||
|
||||
// Initialiser le HiveCacheStore
|
||||
final cacheStore = HiveCacheStore(
|
||||
cacheDir,
|
||||
hiveBoxName: 'mapTilesCache',
|
||||
);
|
||||
|
||||
|
||||
// Initialiser le CachedTileProvider
|
||||
_tileProvider = CachedTileProvider(
|
||||
maxStale: const Duration(days: 30),
|
||||
store: cacheStore,
|
||||
);
|
||||
|
||||
debugPrint('MapboxMap: Cache initialisé dans $cacheDir');
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cacheInitialized = true;
|
||||
@@ -238,6 +250,8 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
options: MapOptions(
|
||||
initialCenter: widget.initialPosition,
|
||||
initialZoom: widget.initialZoom,
|
||||
minZoom: 7.0, // Zoom minimum pour éviter que les tuiles ne se chargent pas
|
||||
maxZoom: 20.0, // Zoom maximum
|
||||
interactionOptions: InteractionOptions(
|
||||
enableMultiFingerGestureRace: true,
|
||||
flags: widget.disableDrag
|
||||
@@ -265,22 +279,21 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
userAgentPackageName: 'app.geosector.fr',
|
||||
maxNativeZoom: 19,
|
||||
maxZoom: 20,
|
||||
minZoom: 1,
|
||||
// Retirer tileSize pour utiliser la valeur par défaut
|
||||
// Les additionalOptions ne sont pas nécessaires car le token est dans l'URL
|
||||
// Utilise le cache si disponible sur web, NetworkTileProvider sur mobile
|
||||
tileProvider: _cacheInitialized && _tileProvider != null
|
||||
minZoom: 7,
|
||||
// Utiliser le cache sur mobile, NetworkTileProvider sur Web
|
||||
tileProvider: !kIsWeb && _cacheInitialized && _tileProvider != null
|
||||
? _tileProvider!
|
||||
: NetworkTileProvider(
|
||||
headers: {
|
||||
'User-Agent': 'geosector_app/3.1.3',
|
||||
'User-Agent': 'geosector_app/3.3.1',
|
||||
'Accept': '*/*',
|
||||
},
|
||||
),
|
||||
errorTileCallback: (tile, error, stackTrace) {
|
||||
debugPrint('MapboxMap: Erreur de chargement de tuile: $error');
|
||||
debugPrint('MapboxMap: Coordonnées de la tuile: ${tile.coordinates}');
|
||||
debugPrint('MapboxMap: Stack trace: $stackTrace');
|
||||
// Réduire les logs d'erreur pour ne pas polluer la console
|
||||
if (!error.toString().contains('abortTrigger')) {
|
||||
debugPrint('MapboxMap: Erreur de chargement de tuile: $error');
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
|
||||
899
app/lib/presentation/widgets/members_board_passages.dart
Normal file
899
app/lib/presentation/widgets/members_board_passages.dart
Normal file
@@ -0,0 +1,899 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/repositories/operation_repository.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
|
||||
/// Widget affichant un tableau détaillé des membres avec leurs statistiques de passages
|
||||
/// Uniquement visible sur plateforme Web
|
||||
class MembersBoardPassages extends StatefulWidget {
|
||||
final String title;
|
||||
final double? height;
|
||||
|
||||
const MembersBoardPassages({
|
||||
super.key,
|
||||
this.title = 'Détails par membre',
|
||||
this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MembersBoardPassages> createState() => _MembersBoardPassagesState();
|
||||
}
|
||||
|
||||
class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
||||
// Repository pour récupérer l'opération courante uniquement
|
||||
final OperationRepository _operationRepository = operationRepository;
|
||||
|
||||
// Vérifier si le type Lot doit être affiché
|
||||
bool _shouldShowLotType() {
|
||||
final currentUser = CurrentUserService.instance.currentUser;
|
||||
if (currentUser != null && currentUser.fkEntite != null) {
|
||||
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
||||
if (userAmicale != null) {
|
||||
return userAmicale.chkLotActif;
|
||||
}
|
||||
}
|
||||
return true; // Par défaut, on affiche
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: widget.height ?? 700, // Hauteur max, sinon s'adapte au contenu
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// En-tête de la card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.05),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(AppTheme.borderRadiusMedium),
|
||||
topRight: Radius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.people_outline,
|
||||
color: theme.colorScheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingS),
|
||||
Text(
|
||||
widget.title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Corps avec le tableau
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<Box<MembreModel>>(
|
||||
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
|
||||
builder: (context, membresBox, child) {
|
||||
final membres = membresBox.values.toList();
|
||||
|
||||
// Récupérer l'opération courante
|
||||
final currentOperation = _operationRepository.getCurrentOperation();
|
||||
if (currentOperation == null) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(AppTheme.spacingL),
|
||||
child: Text('Aucune opération en cours'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Trier les membres par nom
|
||||
membres.sort((a, b) {
|
||||
final nameA = '${a.firstName ?? ''} ${a.name ?? ''}'.trim();
|
||||
final nameB = '${b.firstName ?? ''} ${b.name ?? ''}'.trim();
|
||||
return nameA.compareTo(nameB);
|
||||
});
|
||||
|
||||
// Construire les lignes : TOTAL en première position + détails membres
|
||||
final allRows = [
|
||||
_buildTotalRow(membres, currentOperation.id, theme),
|
||||
..._buildRows(membres, currentOperation.id, theme),
|
||||
];
|
||||
|
||||
// Utilise seulement le scroll vertical, le tableau s'adapte à la largeur
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.vertical,
|
||||
child: SizedBox(
|
||||
width: double.infinity, // Prendre toute la largeur disponible
|
||||
child: DataTable(
|
||||
columnSpacing: 4, // Espacement minimal entre colonnes
|
||||
horizontalMargin: 4, // Marges horizontales minimales
|
||||
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
|
||||
dataRowMinHeight: 42,
|
||||
dataRowMaxHeight: 42,
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
theme.colorScheme.primary.withValues(alpha: 0.08),
|
||||
),
|
||||
columns: _buildColumns(theme),
|
||||
rows: allRows,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit les colonnes du tableau
|
||||
List<DataColumn> _buildColumns(ThemeData theme) {
|
||||
// Utilise le thème pour une meilleure lisibilité
|
||||
final headerStyle = theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
) ?? const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
);
|
||||
|
||||
final showLotType = _shouldShowLotType();
|
||||
|
||||
final columns = [
|
||||
// Nom
|
||||
DataColumn(
|
||||
label: Expanded(
|
||||
child: Text('Nom', style: headerStyle),
|
||||
),
|
||||
),
|
||||
// Total
|
||||
DataColumn(
|
||||
label: Expanded(
|
||||
child: Center(
|
||||
child: Text('Total', style: headerStyle),
|
||||
),
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
// Effectués
|
||||
DataColumn(
|
||||
label: Expanded(
|
||||
child: Container(
|
||||
color: Colors.green.withValues(alpha: 0.2),
|
||||
alignment: Alignment.center,
|
||||
child: Text('Effectués', style: headerStyle),
|
||||
),
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
// Montant moyen
|
||||
DataColumn(
|
||||
label: Expanded(
|
||||
child: Center(
|
||||
child: Text('Moy./passage', style: headerStyle),
|
||||
),
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
// À finaliser
|
||||
DataColumn(
|
||||
label: Expanded(
|
||||
child: Container(
|
||||
color: Colors.orange.withValues(alpha: 0.2),
|
||||
alignment: Alignment.center,
|
||||
child: Text('À finaliser', style: headerStyle),
|
||||
),
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
// Refusés
|
||||
DataColumn(
|
||||
label: Expanded(
|
||||
child: Container(
|
||||
color: Colors.red.withValues(alpha: 0.2),
|
||||
alignment: Alignment.center,
|
||||
child: Text('Refusés', style: headerStyle),
|
||||
),
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
// Dons
|
||||
DataColumn(
|
||||
label: Expanded(
|
||||
child: Container(
|
||||
color: Colors.lightBlue.withValues(alpha: 0.2),
|
||||
alignment: Alignment.center,
|
||||
child: Text('Dons', style: headerStyle),
|
||||
),
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
// Lots - affiché seulement si chkLotActif = true
|
||||
if (showLotType)
|
||||
DataColumn(
|
||||
label: Expanded(
|
||||
child: Container(
|
||||
color: Colors.blue.withValues(alpha: 0.2),
|
||||
alignment: Alignment.center,
|
||||
child: Text('Lots', style: headerStyle),
|
||||
),
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
// Vides
|
||||
DataColumn(
|
||||
label: Expanded(
|
||||
child: Container(
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
alignment: Alignment.center,
|
||||
child: Text('Vides', style: headerStyle),
|
||||
),
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
// Taux d'avancement
|
||||
DataColumn(
|
||||
label: Expanded(
|
||||
child: Center(
|
||||
child: Text('Avancement', style: headerStyle),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Secteurs
|
||||
DataColumn(
|
||||
label: Expanded(
|
||||
child: Center(
|
||||
child: Text('Secteurs', style: headerStyle),
|
||||
),
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
];
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
/// Construit la ligne de totaux
|
||||
DataRow _buildTotalRow(List<MembreModel> membres, int operationId, ThemeData theme) {
|
||||
final showLotType = _shouldShowLotType();
|
||||
|
||||
// Récupérer directement depuis les boxes Hive (déjà ouvertes)
|
||||
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
final allPassages = passageBox.values.where((p) => p.fkOperation == operationId).toList();
|
||||
|
||||
// Calculer les totaux globaux
|
||||
int totalCount = allPassages.length;
|
||||
int effectueCount = 0;
|
||||
double effectueMontant = 0.0;
|
||||
int aFinaliserCount = 0;
|
||||
int refuseCount = 0;
|
||||
int donCount = 0;
|
||||
int lotsCount = 0;
|
||||
double lotsMontant = 0.0;
|
||||
int videCount = 0;
|
||||
|
||||
for (final passage in allPassages) {
|
||||
switch (passage.fkType) {
|
||||
case 1: // Effectué
|
||||
effectueCount++;
|
||||
if (passage.montant.isNotEmpty) {
|
||||
effectueMontant += double.tryParse(passage.montant) ?? 0.0;
|
||||
}
|
||||
break;
|
||||
case 2: // À finaliser
|
||||
aFinaliserCount++;
|
||||
break;
|
||||
case 3: // Refusé
|
||||
refuseCount++;
|
||||
break;
|
||||
case 4: // Don
|
||||
donCount++;
|
||||
break;
|
||||
case 5: // Lots
|
||||
if (showLotType) {
|
||||
lotsCount++;
|
||||
if (passage.montant.isNotEmpty) {
|
||||
lotsMontant += double.tryParse(passage.montant) ?? 0.0;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 6: // Vide
|
||||
videCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculer le montant moyen global
|
||||
double montantMoyen = effectueCount > 0 ? effectueMontant / effectueCount : 0.0;
|
||||
|
||||
// Compter les secteurs uniques
|
||||
final Set<int> uniqueSectorIds = {};
|
||||
for (final passage in allPassages) {
|
||||
if (passage.fkSector != null) {
|
||||
uniqueSectorIds.add(passage.fkSector!);
|
||||
}
|
||||
}
|
||||
final sectorCount = uniqueSectorIds.length;
|
||||
|
||||
// Calculer le taux d'avancement global
|
||||
double tauxAvancement = 0.0;
|
||||
if (sectorCount > 0 && membres.isNotEmpty) {
|
||||
tauxAvancement = effectueCount / (sectorCount * membres.length);
|
||||
if (tauxAvancement > 1) tauxAvancement = 1.0;
|
||||
}
|
||||
|
||||
return DataRow(
|
||||
color: WidgetStateProperty.all(theme.colorScheme.primary.withValues(alpha: 0.15)),
|
||||
cells: [
|
||||
// Nom
|
||||
DataCell(
|
||||
Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'TOTAL',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
) ??
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Total
|
||||
DataCell(
|
||||
Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
totalCount.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Effectués
|
||||
DataCell(
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.green.withValues(alpha: 0.2),
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
effectueCount.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'(${effectueMontant.toStringAsFixed(2)}€)',
|
||||
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Montant moyen
|
||||
DataCell(
|
||||
Center(
|
||||
child: Text(
|
||||
montantMoyen > 0 ? '${montantMoyen.toStringAsFixed(2)}€' : '-',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
// À finaliser
|
||||
DataCell(
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.orange.withValues(alpha: 0.2),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
aFinaliserCount.toString(),
|
||||
style: (theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14))
|
||||
.copyWith(fontWeight: FontWeight.bold, fontStyle: FontStyle.italic),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Refusés
|
||||
DataCell(
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.red.withValues(alpha: 0.2),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
refuseCount.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Dons
|
||||
DataCell(
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.lightBlue.withValues(alpha: 0.2),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
donCount.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Lots - affiché seulement si chkLotActif = true
|
||||
if (showLotType)
|
||||
DataCell(
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.blue.withValues(alpha: 0.2),
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
lotsCount.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'(${lotsMontant.toStringAsFixed(2)}€)',
|
||||
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Vides
|
||||
DataCell(
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
videCount.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Taux d'avancement
|
||||
DataCell(
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: tauxAvancement,
|
||||
backgroundColor: Colors.grey.shade300,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.blue.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${(tauxAvancement * 100).toInt()}%',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
|
||||
const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Secteurs
|
||||
DataCell(
|
||||
Center(
|
||||
child: Text(
|
||||
sectorCount.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit les lignes du tableau
|
||||
List<DataRow> _buildRows(List<MembreModel> membres, int operationId, ThemeData theme) {
|
||||
final List<DataRow> rows = [];
|
||||
final showLotType = _shouldShowLotType();
|
||||
|
||||
// Récupérer directement depuis les boxes Hive (déjà ouvertes)
|
||||
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
final allPassages = passageBox.values.where((p) => p.fkOperation == operationId).toList();
|
||||
|
||||
// Récupérer tous les secteurs directement depuis la box
|
||||
final sectorBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
|
||||
final allSectors = sectorBox.values.toList();
|
||||
|
||||
for (int index = 0; index < membres.length; index++) {
|
||||
final membre = membres[index];
|
||||
final isEvenRow = index % 2 == 0;
|
||||
|
||||
// Récupérer les passages du membre
|
||||
final memberPassages = allPassages.where((p) => p.fkUser == membre.id).toList();
|
||||
|
||||
// Calculer les statistiques par type
|
||||
int totalCount = memberPassages.length;
|
||||
int effectueCount = 0;
|
||||
double effectueMontant = 0.0;
|
||||
int aFinaliserCount = 0;
|
||||
int refuseCount = 0;
|
||||
int donCount = 0;
|
||||
int lotsCount = 0;
|
||||
double lotsMontant = 0.0;
|
||||
int videCount = 0;
|
||||
|
||||
for (final passage in memberPassages) {
|
||||
switch (passage.fkType) {
|
||||
case 1: // Effectué
|
||||
effectueCount++;
|
||||
if (passage.montant.isNotEmpty) {
|
||||
effectueMontant += double.tryParse(passage.montant) ?? 0.0;
|
||||
}
|
||||
break;
|
||||
case 2: // À finaliser
|
||||
aFinaliserCount++;
|
||||
break;
|
||||
case 3: // Refusé
|
||||
refuseCount++;
|
||||
break;
|
||||
case 4: // Don
|
||||
donCount++;
|
||||
break;
|
||||
case 5: // Lots
|
||||
if (showLotType) { // Compter seulement si Lots est activé
|
||||
lotsCount++;
|
||||
if (passage.montant.isNotEmpty) {
|
||||
lotsMontant += double.tryParse(passage.montant) ?? 0.0;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 6: // Vide
|
||||
videCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculer le montant moyen
|
||||
double montantMoyen = effectueCount > 0 ? effectueMontant / effectueCount : 0.0;
|
||||
|
||||
// Récupérer les secteurs uniques du membre via ses passages
|
||||
final Set<int> memberSectorIds = {};
|
||||
for (final passage in memberPassages) {
|
||||
if (passage.fkSector != null) {
|
||||
memberSectorIds.add(passage.fkSector!);
|
||||
}
|
||||
}
|
||||
final sectorCount = memberSectorIds.length;
|
||||
final memberSectors = allSectors.where((s) => memberSectorIds.contains(s.id)).toList();
|
||||
|
||||
// Calculer le taux d'avancement (passages effectués / secteurs attribués)
|
||||
double tauxAvancement = 0.0;
|
||||
bool hasWarning = false;
|
||||
|
||||
if (sectorCount > 0) {
|
||||
// On considère que chaque secteur devrait avoir au moins un passage effectué
|
||||
tauxAvancement = effectueCount / sectorCount;
|
||||
if (tauxAvancement > 1) tauxAvancement = 1.0; // Limiter à 100%
|
||||
hasWarning = tauxAvancement < 0.5; // Avertissement si moins de 50%
|
||||
} else {
|
||||
hasWarning = true; // Avertissement si aucun secteur attribué
|
||||
}
|
||||
|
||||
rows.add(
|
||||
DataRow(
|
||||
color: WidgetStateProperty.all(
|
||||
isEvenRow ? Colors.white : Colors.grey.shade50,
|
||||
),
|
||||
cells: [
|
||||
// Nom - Cliquable pour naviguer vers l'historique avec le membre sélectionné
|
||||
DataCell(
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
debugPrint('MembersBoardPassages: Clic sur membre ${membre.id}');
|
||||
|
||||
// Naviguer directement vers la page history avec memberId
|
||||
debugPrint('MembersBoardPassages: Navigation vers /admin/history?memberId=${membre.id}');
|
||||
if (mounted) {
|
||||
context.go('/admin/history?memberId=${membre.id}');
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
_buildMemberDisplayName(membre),
|
||||
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600) ??
|
||||
const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Total - Cliquable pour naviguer vers l'historique avec le membre sélectionné
|
||||
DataCell(
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
debugPrint('MembersBoardPassages: Clic sur membre ${membre.id}');
|
||||
|
||||
// Naviguer directement vers la page history avec memberId
|
||||
debugPrint('MembersBoardPassages: Navigation vers /admin/history?memberId=${membre.id}');
|
||||
if (mounted) {
|
||||
context.go('/admin/history?memberId=${membre.id}');
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
totalCount.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Effectués
|
||||
DataCell(
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
effectueCount.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'(${effectueMontant.toStringAsFixed(2)}€)',
|
||||
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Montant moyen
|
||||
DataCell(Center(child: Text(
|
||||
montantMoyen > 0 ? '${montantMoyen.toStringAsFixed(2)}€' : '-',
|
||||
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
|
||||
))),
|
||||
// À finaliser
|
||||
DataCell(
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
aFinaliserCount.toString(),
|
||||
style: (theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14))
|
||||
.copyWith(fontStyle: FontStyle.italic),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Refusés
|
||||
DataCell(
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
refuseCount.toString(),
|
||||
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Dons
|
||||
DataCell(
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.lightBlue.withValues(alpha: 0.1),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
donCount.toString(),
|
||||
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Lots - affiché seulement si chkLotActif = true
|
||||
if (showLotType)
|
||||
DataCell(
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
lotsCount.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'(${lotsMontant.toStringAsFixed(2)}€)',
|
||||
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Vides
|
||||
DataCell(
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: Colors.grey.withValues(alpha: 0.1),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
videCount.toString(),
|
||||
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Taux d'avancement
|
||||
DataCell(
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: tauxAvancement,
|
||||
backgroundColor: Colors.grey.shade300,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
hasWarning ? Colors.red.shade400 : Colors.green.shade400,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (hasWarning)
|
||||
Icon(
|
||||
Icons.warning,
|
||||
color: Colors.red.shade400,
|
||||
size: 16,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'${(tauxAvancement * 100).toInt()}%',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
|
||||
const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Secteurs
|
||||
DataCell(
|
||||
Row(
|
||||
children: [
|
||||
if (sectorCount == 0)
|
||||
Icon(
|
||||
Icons.warning,
|
||||
color: Colors.red.shade400,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
sectorCount.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: theme.textTheme.bodyMedium?.fontSize ?? 14,
|
||||
fontWeight: sectorCount > 0 ? FontWeight.bold : FontWeight.normal,
|
||||
color: sectorCount > 0 ? Colors.green.shade700 : Colors.red.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.map_outlined, size: 16),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
_showMemberSectorsDialog(context, membre, memberSectors.toList());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// Construit le nom d'affichage d'un membre avec son sectName si disponible
|
||||
String _buildMemberDisplayName(MembreModel membre) {
|
||||
String displayName = '${membre.firstName ?? ''} ${membre.name ?? ''}'.trim();
|
||||
|
||||
// Ajouter le sectName entre parenthèses s'il existe
|
||||
if (membre.sectName != null && membre.sectName!.isNotEmpty) {
|
||||
displayName += ' (${membre.sectName})';
|
||||
}
|
||||
|
||||
return displayName;
|
||||
}
|
||||
|
||||
/// Affiche un dialogue avec les secteurs du membre
|
||||
void _showMemberSectorsDialog(BuildContext context, MembreModel membre, List<SectorModel> memberSectors) {
|
||||
final theme = Theme.of(context);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Secteurs de ${membre.firstName} ${membre.name}'),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: memberSectors.isEmpty
|
||||
? const Text('Aucun secteur attribué')
|
||||
: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: memberSectors.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sector = memberSectors[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.map,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Text(sector.libelle),
|
||||
subtitle: Text('Secteur #${sector.id}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/operation_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/amicale_repository.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
import 'package:geosector_app/core/services/stripe_tap_to_pay_service.dart';
|
||||
import 'package:geosector_app/core/services/device_info_service.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
@@ -17,6 +24,7 @@ class PassageFormDialog extends StatefulWidget {
|
||||
final PassageRepository passageRepository;
|
||||
final UserRepository userRepository;
|
||||
final OperationRepository operationRepository;
|
||||
final AmicaleRepository amicaleRepository;
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const PassageFormDialog({
|
||||
@@ -27,6 +35,7 @@ class PassageFormDialog extends StatefulWidget {
|
||||
required this.passageRepository,
|
||||
required this.userRepository,
|
||||
required this.operationRepository,
|
||||
required this.amicaleRepository,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
@@ -63,6 +72,12 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
int _fkTypeReglement = 4; // Par défaut Non renseigné
|
||||
DateTime _passedAt = DateTime.now(); // Date et heure de passage
|
||||
|
||||
// Variable pour Tap to Pay
|
||||
String? _stripePaymentIntentId;
|
||||
|
||||
// Boîte Hive pour mémoriser la dernière adresse
|
||||
late Box _settingsBox;
|
||||
|
||||
// Helpers de validation
|
||||
String? _validateNumero(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
@@ -93,9 +108,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
}
|
||||
|
||||
String? _validateNomOccupant(String? value) {
|
||||
if (_selectedPassageType == 1) {
|
||||
// Le nom est obligatoire uniquement si un email est renseigné
|
||||
final emailValue = _emailController.text.trim();
|
||||
if (emailValue.isNotEmpty) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Le nom est obligatoire pour les passages effectués';
|
||||
return 'Le nom est obligatoire si un email est renseigné';
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
return 'Le nom doit contenir au moins 2 caractères';
|
||||
@@ -138,6 +155,9 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
try {
|
||||
debugPrint('=== DEBUT PassageFormDialog.initState ===');
|
||||
|
||||
// Accéder à la settingsBox (déjà ouverte dans l'app)
|
||||
_settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
|
||||
// Initialize controllers with passage data if available
|
||||
final passage = widget.passage;
|
||||
debugPrint('Passage reçu: ${passage != null}');
|
||||
@@ -166,10 +186,10 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
debugPrint('Initialisation des controllers...');
|
||||
|
||||
// S'assurer que toutes les valeurs null deviennent des chaînes vides
|
||||
final String numero = passage?.numero.toString() ?? '';
|
||||
final String rueBis = passage?.rueBis.toString() ?? '';
|
||||
final String rue = passage?.rue.toString() ?? '';
|
||||
final String ville = passage?.ville.toString() ?? '';
|
||||
String numero = passage?.numero.toString() ?? '';
|
||||
String rueBis = passage?.rueBis.toString() ?? '';
|
||||
String rue = passage?.rue.toString() ?? '';
|
||||
String ville = passage?.ville.toString() ?? '';
|
||||
final String name = passage?.name.toString() ?? '';
|
||||
final String email = passage?.email.toString() ?? '';
|
||||
final String phone = passage?.phone.toString() ?? '';
|
||||
@@ -179,11 +199,26 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
(montantRaw == '0.00' || montantRaw == '0' || montantRaw == '0.0')
|
||||
? ''
|
||||
: montantRaw;
|
||||
final String appt = passage?.appt.toString() ?? '';
|
||||
final String niveau = passage?.niveau.toString() ?? '';
|
||||
final String residence = passage?.residence.toString() ?? '';
|
||||
String appt = passage?.appt.toString() ?? '';
|
||||
String niveau = passage?.niveau.toString() ?? '';
|
||||
String residence = passage?.residence.toString() ?? '';
|
||||
final String remarque = passage?.remarque.toString() ?? '';
|
||||
|
||||
// Si nouveau passage, charger les valeurs mémorisées de la dernière adresse
|
||||
if (passage == null) {
|
||||
debugPrint('Nouveau passage: chargement des valeurs mémorisées...');
|
||||
numero = _settingsBox.get('lastPassageNumero', defaultValue: '') as String;
|
||||
rueBis = _settingsBox.get('lastPassageRueBis', defaultValue: '') as String;
|
||||
rue = _settingsBox.get('lastPassageRue', defaultValue: '') as String;
|
||||
ville = _settingsBox.get('lastPassageVille', defaultValue: '') as String;
|
||||
residence = _settingsBox.get('lastPassageResidence', defaultValue: '') as String;
|
||||
_fkHabitat = _settingsBox.get('lastPassageFkHabitat', defaultValue: 1) as int;
|
||||
appt = _settingsBox.get('lastPassageAppt', defaultValue: '') as String;
|
||||
niveau = _settingsBox.get('lastPassageNiveau', defaultValue: '') as String;
|
||||
|
||||
debugPrint('Valeurs chargées: numero="$numero", rue="$rue", ville="$ville"');
|
||||
}
|
||||
|
||||
// Initialiser la date de passage
|
||||
_passedAt = passage?.passedAt ?? DateTime.now();
|
||||
final String dateFormatted =
|
||||
@@ -220,6 +255,16 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
_dateController = TextEditingController(text: dateFormatted);
|
||||
_timeController = TextEditingController(text: timeFormatted);
|
||||
|
||||
// Ajouter un listener sur le champ email pour mettre à jour la validation du nom
|
||||
_emailController.addListener(() {
|
||||
// Force la revalidation du formulaire quand l'email change
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
// Cela va déclencher un rebuild et mettre à jour l'indicateur isRequired
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint('=== FIN PassageFormDialog.initState ===');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('=== ERREUR PassageFormDialog.initState ===');
|
||||
@@ -284,6 +329,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toujours sauvegarder le passage en premier
|
||||
await _savePassage();
|
||||
}
|
||||
|
||||
Future<void> _savePassage() async {
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
@@ -314,6 +364,23 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
finalTypeReglement = 4;
|
||||
}
|
||||
|
||||
// Déterminer la valeur de nbPassages selon le type de passage
|
||||
final int finalNbPassages;
|
||||
if (widget.passage != null) {
|
||||
// Modification d'un passage existant
|
||||
if (_selectedPassageType == 2) {
|
||||
// Type 2 (À finaliser) : toujours incrémenter
|
||||
finalNbPassages = widget.passage!.nbPassages + 1;
|
||||
} else {
|
||||
// Autres types : mettre à 1 si actuellement 0, sinon conserver
|
||||
final currentNbPassages = widget.passage!.nbPassages;
|
||||
finalNbPassages = currentNbPassages == 0 ? 1 : currentNbPassages;
|
||||
}
|
||||
} else {
|
||||
// Nouveau passage : toujours 1
|
||||
finalNbPassages = 1;
|
||||
}
|
||||
|
||||
final passageData = widget.passage?.copyWith(
|
||||
fkType: _selectedPassageType!,
|
||||
numero: _numeroController.text.trim(),
|
||||
@@ -330,7 +397,9 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
residence: _residenceController.text.trim(),
|
||||
remarque: _remarqueController.text.trim(),
|
||||
fkTypeReglement: finalTypeReglement,
|
||||
nbPassages: finalNbPassages,
|
||||
passedAt: _passedAt,
|
||||
stripePaymentId: _stripePaymentIntentId,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
) ??
|
||||
PassageModel(
|
||||
@@ -356,43 +425,127 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
montant: finalMontant,
|
||||
fkTypeReglement: finalTypeReglement,
|
||||
emailErreur: '',
|
||||
nbPassages: 1,
|
||||
nbPassages: finalNbPassages,
|
||||
name: _nameController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
phone: _phoneController.text.trim(),
|
||||
stripePaymentId: _stripePaymentIntentId,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: true,
|
||||
isSynced: false,
|
||||
);
|
||||
|
||||
final success = widget.passage == null
|
||||
? await widget.passageRepository.createPassage(passageData)
|
||||
: await widget.passageRepository.updatePassage(passageData);
|
||||
// Sauvegarder le passage d'abord
|
||||
PassageModel? savedPassage;
|
||||
if (widget.passage == null) {
|
||||
// Création d'un nouveau passage
|
||||
savedPassage = await widget.passageRepository.createPassageWithReturn(passageData);
|
||||
} else {
|
||||
// Mise à jour d'un passage existant
|
||||
final success = await widget.passageRepository.updatePassage(passageData);
|
||||
if (success) {
|
||||
savedPassage = passageData;
|
||||
}
|
||||
}
|
||||
|
||||
if (success && mounted) {
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(
|
||||
context,
|
||||
widget.passage == null
|
||||
? "Nouveau passage créé avec succès"
|
||||
: "Passage modifié avec succès",
|
||||
);
|
||||
if (savedPassage == null) {
|
||||
throw Exception(widget.passage == null
|
||||
? "Échec de la création du passage"
|
||||
: "Échec de la mise à jour du passage");
|
||||
}
|
||||
|
||||
// Mémoriser l'adresse pour la prochaine création de passage
|
||||
await _saveLastPassageAddress();
|
||||
|
||||
// Vérifier si paiement CB nécessaire APRÈS la sauvegarde
|
||||
if (finalTypeReglement == 3 &&
|
||||
(_selectedPassageType == 1 || _selectedPassageType == 5)) {
|
||||
final montant = double.tryParse(finalMontant.replaceAll(',', '.')) ?? 0;
|
||||
|
||||
if (montant > 0 && mounted) {
|
||||
// Vérifier si le device supporte Tap to Pay
|
||||
if (DeviceInfoService.instance.canUseTapToPay()) {
|
||||
// Lancer le flow Tap to Pay avec l'ID du passage sauvegardé
|
||||
final paymentSuccess = await _attemptTapToPayWithPassage(savedPassage, montant);
|
||||
|
||||
if (!paymentSuccess) {
|
||||
// Si le paiement échoue, on pourrait marquer le passage comme "À finaliser"
|
||||
// ou le supprimer selon la logique métier
|
||||
debugPrint('⚠️ Paiement échoué pour le passage ${savedPassage.id}');
|
||||
// Optionnel : mettre à jour le passage en type "À finaliser" (7)
|
||||
}
|
||||
} else {
|
||||
// Le device ne supporte pas Tap to Pay (Web ou device non compatible)
|
||||
if (mounted) {
|
||||
// Déterminer le message d'avertissement approprié
|
||||
String warningMessage;
|
||||
if (kIsWeb) {
|
||||
warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Le paiement sans contact n'est pas disponible sur navigateur web. Utilisez l'application mobile native pour cette fonctionnalité.";
|
||||
} else {
|
||||
// Vérifier pourquoi le device n'est pas compatible
|
||||
final deviceInfo = DeviceInfoService.instance.getStoredDeviceInfo();
|
||||
final nfcCapable = deviceInfo['device_nfc_capable'] == true;
|
||||
final stripeCertified = deviceInfo['device_stripe_certified'] == true;
|
||||
final batteryLevel = deviceInfo['battery_level'] as int?;
|
||||
final platform = deviceInfo['platform'];
|
||||
|
||||
if (!nfcCapable) {
|
||||
warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Votre appareil n'a pas de NFC activé ou disponible pour les paiements sans contact.";
|
||||
} else if (!stripeCertified) {
|
||||
if (platform == 'iOS') {
|
||||
warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Votre iPhone n'est pas compatible. Tap to Pay nécessite un iPhone XS ou plus récent avec iOS 16.4+.";
|
||||
} else {
|
||||
warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Votre appareil Android n'est pas certifié par Stripe pour les paiements sans contact en France.";
|
||||
}
|
||||
} else if (batteryLevel != null && batteryLevel < 10) {
|
||||
warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Batterie trop faible ($batteryLevel%). Minimum 10% requis pour les paiements sans contact.";
|
||||
} else {
|
||||
warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Votre appareil ne peut pas utiliser le paiement sans contact actuellement.";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Fermer le dialog et afficher le message de succès avec avertissement
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
// Afficher un SnackBar orange pour l'avertissement
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(warningMessage),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (mounted) {
|
||||
ApiException.showError(
|
||||
context,
|
||||
Exception(widget.passage == null
|
||||
? "Échec de la création du passage"
|
||||
: "Échec de la mise à jour du passage"),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Pas de paiement CB, fermer le dialog avec succès
|
||||
if (mounted) {
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(
|
||||
context,
|
||||
widget.passage == null
|
||||
? "Nouveau passage créé avec succès"
|
||||
: "Passage modifié avec succès",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -407,9 +560,47 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mémoriser l'adresse du passage pour la prochaine création
|
||||
Future<void> _saveLastPassageAddress() async {
|
||||
try {
|
||||
await _settingsBox.put('lastPassageNumero', _numeroController.text.trim());
|
||||
await _settingsBox.put('lastPassageRueBis', _rueBisController.text.trim());
|
||||
await _settingsBox.put('lastPassageRue', _rueController.text.trim());
|
||||
await _settingsBox.put('lastPassageVille', _villeController.text.trim());
|
||||
await _settingsBox.put('lastPassageResidence', _residenceController.text.trim());
|
||||
await _settingsBox.put('lastPassageFkHabitat', _fkHabitat);
|
||||
await _settingsBox.put('lastPassageAppt', _apptController.text.trim());
|
||||
await _settingsBox.put('lastPassageNiveau', _niveauController.text.trim());
|
||||
|
||||
debugPrint('✅ Adresse mémorisée pour la prochaine création de passage');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur lors de la mémorisation de l\'adresse: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPassageTypeSelection() {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Récupérer l'amicale de l'utilisateur pour vérifier chkLotActif
|
||||
final currentUser = CurrentUserService.instance.currentUser;
|
||||
bool showLotType = true; // Par défaut, on affiche le type Lot
|
||||
|
||||
if (currentUser != null && currentUser.fkEntite != null) {
|
||||
final userAmicale = widget.amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
||||
if (userAmicale != null) {
|
||||
// Si chkLotActif = false (0), on ne doit pas afficher le type Lot (5)
|
||||
showLotType = userAmicale.chkLotActif;
|
||||
debugPrint('Amicale ${userAmicale.name}: chkLotActif = $showLotType');
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer les types de passages en fonction de chkLotActif
|
||||
final filteredTypes = Map<int, Map<String, dynamic>>.from(AppKeys.typesPassages);
|
||||
if (!showLotType) {
|
||||
filteredTypes.remove(5); // Retirer le type "Lot" si chkLotActif = 0
|
||||
debugPrint('Type Lot (5) masqué car chkLotActif = false');
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -431,11 +622,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: AppKeys.typesPassages.length,
|
||||
itemCount: filteredTypes.length,
|
||||
itemBuilder: (context, index) {
|
||||
try {
|
||||
final typeId = AppKeys.typesPassages.keys.elementAt(index);
|
||||
final typeData = AppKeys.typesPassages[typeId];
|
||||
final typeId = filteredTypes.keys.elementAt(index);
|
||||
final typeData = filteredTypes[typeId];
|
||||
|
||||
if (typeData == null) {
|
||||
debugPrint('ERREUR: typeData null pour typeId: $typeId');
|
||||
@@ -523,35 +714,62 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
title: 'Date et Heure de passage',
|
||||
icon: Icons.schedule,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _dateController,
|
||||
label: "Date",
|
||||
isRequired: true,
|
||||
readOnly: true,
|
||||
showLabel: false,
|
||||
hintText: "DD/MM/YYYY",
|
||||
suffixIcon: const Icon(Icons.calendar_today),
|
||||
onTap: widget.readOnly ? null : _selectDate,
|
||||
// Layout responsive : 1 ligne desktop, 2 lignes mobile
|
||||
_isMobile(context)
|
||||
? Column(
|
||||
children: [
|
||||
CustomTextField(
|
||||
controller: _dateController,
|
||||
label: "Date",
|
||||
isRequired: true,
|
||||
readOnly: true,
|
||||
showLabel: false,
|
||||
hintText: "DD/MM/YYYY",
|
||||
suffixIcon: const Icon(Icons.calendar_today),
|
||||
onTap: widget.readOnly ? null : _selectDate,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _timeController,
|
||||
label: "Heure",
|
||||
isRequired: true,
|
||||
readOnly: true,
|
||||
showLabel: false,
|
||||
hintText: "HH:MM",
|
||||
suffixIcon: const Icon(Icons.access_time),
|
||||
onTap: widget.readOnly ? null : _selectTime,
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _dateController,
|
||||
label: "Date",
|
||||
isRequired: true,
|
||||
readOnly: true,
|
||||
showLabel: false,
|
||||
hintText: "DD/MM/YYYY",
|
||||
suffixIcon: const Icon(Icons.calendar_today),
|
||||
onTap: widget.readOnly ? null : _selectDate,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _timeController,
|
||||
label: "Heure",
|
||||
isRequired: true,
|
||||
readOnly: true,
|
||||
showLabel: false,
|
||||
hintText: "HH:MM",
|
||||
suffixIcon: const Icon(Icons.access_time),
|
||||
onTap: widget.readOnly ? null : _selectTime,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _timeController,
|
||||
label: "Heure",
|
||||
isRequired: true,
|
||||
readOnly: true,
|
||||
showLabel: false,
|
||||
hintText: "HH:MM",
|
||||
suffixIcon: const Icon(Icons.access_time),
|
||||
onTap: widget.readOnly ? null : _selectTime,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -619,11 +837,12 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
// TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+)
|
||||
child: RadioListTile<int>(
|
||||
title: const Text('Maison'),
|
||||
value: 1,
|
||||
groupValue: _fkHabitat,
|
||||
onChanged: widget.readOnly
|
||||
groupValue: _fkHabitat, // ignore: deprecated_member_use
|
||||
onChanged: widget.readOnly // ignore: deprecated_member_use
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
@@ -637,8 +856,8 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
child: RadioListTile<int>(
|
||||
title: const Text('Appart'),
|
||||
value: 2,
|
||||
groupValue: _fkHabitat,
|
||||
onChanged: widget.readOnly
|
||||
groupValue: _fkHabitat, // ignore: deprecated_member_use
|
||||
onChanged: widget.readOnly // ignore: deprecated_member_use
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
@@ -705,41 +924,63 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
children: [
|
||||
CustomTextField(
|
||||
controller: _nameController,
|
||||
label: _selectedPassageType == 1
|
||||
? "Nom de l'occupant"
|
||||
: "Nom de l'occupant",
|
||||
isRequired: _selectedPassageType == 1,
|
||||
label: "Nom de l'occupant",
|
||||
isRequired: _emailController.text.trim().isNotEmpty,
|
||||
showLabel: false,
|
||||
readOnly: widget.readOnly,
|
||||
validator: _validateNomOccupant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _emailController,
|
||||
label: "Email",
|
||||
showLabel: false,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
readOnly: widget.readOnly,
|
||||
validator: _validateEmail,
|
||||
prefixIcon: Icons.email,
|
||||
// Layout responsive : 1 ligne desktop, 2 lignes mobile
|
||||
_isMobile(context)
|
||||
? Column(
|
||||
children: [
|
||||
CustomTextField(
|
||||
controller: _emailController,
|
||||
label: "Email",
|
||||
showLabel: false,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
readOnly: widget.readOnly,
|
||||
validator: _validateEmail,
|
||||
prefixIcon: Icons.email,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: _phoneController,
|
||||
label: "Téléphone",
|
||||
showLabel: false,
|
||||
keyboardType: TextInputType.phone,
|
||||
readOnly: widget.readOnly,
|
||||
prefixIcon: Icons.phone,
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _emailController,
|
||||
label: "Email",
|
||||
showLabel: false,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
readOnly: widget.readOnly,
|
||||
validator: _validateEmail,
|
||||
prefixIcon: Icons.email,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _phoneController,
|
||||
label: "Téléphone",
|
||||
showLabel: false,
|
||||
keyboardType: TextInputType.phone,
|
||||
readOnly: widget.readOnly,
|
||||
prefixIcon: Icons.phone,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _phoneController,
|
||||
label: "Téléphone",
|
||||
showLabel: false,
|
||||
keyboardType: TextInputType.phone,
|
||||
readOnly: widget.readOnly,
|
||||
prefixIcon: Icons.phone,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -1140,6 +1381,65 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Tente d'effectuer un paiement Tap to Pay avec un passage déjà sauvegardé
|
||||
Future<bool> _attemptTapToPayWithPassage(PassageModel passage, double montant) async {
|
||||
try {
|
||||
// Afficher le dialog de paiement avec l'ID réel du passage
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => _TapToPayFlowDialog(
|
||||
amount: montant,
|
||||
passageId: passage.id, // ID réel du passage sauvegardé
|
||||
onSuccess: (paymentIntentId) {
|
||||
// Mettre à jour le passage avec le stripe_payment_id
|
||||
final updatedPassage = passage.copyWith(
|
||||
stripePaymentId: paymentIntentId,
|
||||
);
|
||||
|
||||
// Envoyer la mise à jour à l'API (sera fait de manière asynchrone)
|
||||
widget.passageRepository.updatePassage(updatedPassage).then((_) {
|
||||
debugPrint('✅ Passage mis à jour avec stripe_payment_id: $paymentIntentId');
|
||||
}).catchError((error) {
|
||||
debugPrint('❌ Erreur mise à jour passage: $error');
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_stripePaymentIntentId = paymentIntentId;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Si paiement réussi, afficher le message de succès et fermer
|
||||
if (result == true && mounted) {
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(
|
||||
context,
|
||||
"Paiement effectué avec succès",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur Tap to Pay: $e');
|
||||
if (mounted) {
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
try {
|
||||
@@ -1228,3 +1528,340 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog pour gérer le flow de paiement Tap to Pay
|
||||
class _TapToPayFlowDialog extends StatefulWidget {
|
||||
final double amount;
|
||||
final int passageId;
|
||||
final void Function(String paymentIntentId)? onSuccess;
|
||||
|
||||
const _TapToPayFlowDialog({
|
||||
required this.amount,
|
||||
required this.passageId,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TapToPayFlowDialog> createState() => _TapToPayFlowDialogState();
|
||||
}
|
||||
|
||||
class _TapToPayFlowDialogState extends State<_TapToPayFlowDialog> {
|
||||
String _currentState = 'confirming';
|
||||
String? _paymentIntentId;
|
||||
String? _errorMessage;
|
||||
StreamSubscription<TapToPayStatus>? _statusSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listenToPaymentStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_statusSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _listenToPaymentStatus() {
|
||||
_statusSubscription = StripeTapToPayService.instance.paymentStatusStream.listen(
|
||||
(status) {
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
switch (status.type) {
|
||||
case TapToPayStatusType.ready:
|
||||
_currentState = 'ready';
|
||||
break;
|
||||
case TapToPayStatusType.awaitingTap:
|
||||
_currentState = 'awaiting_tap';
|
||||
break;
|
||||
case TapToPayStatusType.processing:
|
||||
_currentState = 'processing';
|
||||
break;
|
||||
case TapToPayStatusType.confirming:
|
||||
_currentState = 'confirming';
|
||||
break;
|
||||
case TapToPayStatusType.success:
|
||||
_currentState = 'success';
|
||||
_paymentIntentId = status.paymentIntentId;
|
||||
_handleSuccess();
|
||||
break;
|
||||
case TapToPayStatusType.error:
|
||||
_currentState = 'error';
|
||||
_errorMessage = status.message;
|
||||
break;
|
||||
case TapToPayStatusType.cancelled:
|
||||
Navigator.pop(context, false);
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSuccess() {
|
||||
if (_paymentIntentId != null) {
|
||||
widget.onSuccess?.call(_paymentIntentId!);
|
||||
// Attendre un peu pour montrer le succès
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startPayment() async {
|
||||
setState(() {
|
||||
_currentState = 'initializing';
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Initialiser le service si nécessaire
|
||||
if (!StripeTapToPayService.instance.isInitialized) {
|
||||
final initialized = await StripeTapToPayService.instance.initialize();
|
||||
if (!initialized) {
|
||||
throw Exception('Impossible d\'initialiser Tap to Pay');
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier que le service est prêt
|
||||
if (!StripeTapToPayService.instance.isReadyForPayments()) {
|
||||
throw Exception('L\'appareil n\'est pas prêt pour les paiements');
|
||||
}
|
||||
|
||||
// Créer le PaymentIntent avec l'ID du passage dans les metadata
|
||||
final paymentIntent = await StripeTapToPayService.instance.createPaymentIntent(
|
||||
amountInCents: (widget.amount * 100).round(),
|
||||
description: 'Calendrier pompiers${widget.passageId > 0 ? " - Passage #${widget.passageId}" : ""}',
|
||||
metadata: {
|
||||
'type': 'tap_to_pay',
|
||||
'passage_id': widget.passageId.toString(),
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId.toString(),
|
||||
'member_id': CurrentUserService.instance.userId.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
if (paymentIntent == null) {
|
||||
throw Exception('Impossible de créer le paiement');
|
||||
}
|
||||
|
||||
_paymentIntentId = paymentIntent.paymentIntentId;
|
||||
|
||||
// Collecter le paiement
|
||||
final collected = await StripeTapToPayService.instance.collectPayment(paymentIntent);
|
||||
if (!collected) {
|
||||
throw Exception('Échec de la collecte du paiement');
|
||||
}
|
||||
|
||||
// Confirmer le paiement
|
||||
final confirmed = await StripeTapToPayService.instance.confirmPayment(paymentIntent);
|
||||
if (!confirmed) {
|
||||
throw Exception('Échec de la confirmation du paiement');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_currentState = 'error';
|
||||
_errorMessage = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
Widget content;
|
||||
List<Widget> actions = [];
|
||||
|
||||
switch (_currentState) {
|
||||
case 'confirming':
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.contactless, size: 64, color: theme.colorScheme.primary),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Paiement par carte sans contact',
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Montant: ${widget.amount.toStringAsFixed(2)}€',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Le client va payer par carte bancaire sans contact.\n'
|
||||
'Son téléphone ou sa carte sera présenté(e) sur cet appareil.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
actions = [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _startPayment,
|
||||
icon: const Icon(Icons.payment),
|
||||
label: const Text('Lancer le paiement'),
|
||||
),
|
||||
];
|
||||
break;
|
||||
|
||||
case 'initializing':
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Initialisation du terminal...',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
);
|
||||
break;
|
||||
|
||||
case 'awaiting_tap':
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.tap_and_play,
|
||||
size: 80,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Présentez la carte',
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Montant: ${widget.amount.toStringAsFixed(2)}€',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
);
|
||||
actions = [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (_paymentIntentId != null) {
|
||||
StripeTapToPayService.instance.cancelPayment(_paymentIntentId!);
|
||||
}
|
||||
Navigator.pop(context, false);
|
||||
},
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
];
|
||||
break;
|
||||
|
||||
case 'processing':
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Traitement du paiement...',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Ne pas retirer la carte'),
|
||||
],
|
||||
);
|
||||
break;
|
||||
|
||||
case 'success':
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
size: 80,
|
||||
color: Colors.green,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Paiement réussi !',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'${widget.amount.toStringAsFixed(2)}€ payé par carte',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 80,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Échec du paiement',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage ?? 'Une erreur est survenue',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
);
|
||||
actions = [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _startPayment,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Réessayer'),
|
||||
),
|
||||
];
|
||||
break;
|
||||
|
||||
default:
|
||||
content = const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.contactless, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Paiement sans contact'),
|
||||
],
|
||||
),
|
||||
content: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: content,
|
||||
),
|
||||
actions: actions.isEmpty ? null : actions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
|
||||
class PassageMapDialog extends StatelessWidget {
|
||||
@@ -24,78 +25,14 @@ class PassageMapDialog extends StatelessWidget {
|
||||
// Récupérer le type de passage
|
||||
final String typePassage =
|
||||
AppKeys.typesPassages[type]?['titre'] ?? 'Inconnu';
|
||||
// Utiliser couleur2 pour le badge (couleur1 peut être blanche pour type 2)
|
||||
final Color typeColor =
|
||||
Color(AppKeys.typesPassages[type]?['couleur1'] ?? 0xFF9E9E9E);
|
||||
Color(AppKeys.typesPassages[type]?['couleur2'] ?? 0xFF9E9E9E);
|
||||
|
||||
// Construire l'adresse complète
|
||||
final String adresse =
|
||||
'${passage.numero} ${passage.rueBis} ${passage.rue}'.trim();
|
||||
|
||||
// Informations sur l'étage, l'appartement et la résidence (si habitat = 2)
|
||||
String? etageInfo;
|
||||
String? apptInfo;
|
||||
String? residenceInfo;
|
||||
if (passage.fkHabitat == 2) {
|
||||
if (passage.niveau.isNotEmpty) {
|
||||
etageInfo = 'Étage ${passage.niveau}';
|
||||
}
|
||||
if (passage.appt.isNotEmpty) {
|
||||
apptInfo = 'Appt. ${passage.appt}';
|
||||
}
|
||||
if (passage.residence.isNotEmpty) {
|
||||
residenceInfo = passage.residence;
|
||||
}
|
||||
}
|
||||
|
||||
// Formater la date (uniquement si le type n'est pas 2 et si la date existe)
|
||||
String? dateInfo;
|
||||
if (type != 2 && passage.passedAt != null) {
|
||||
final date = passage.passedAt!;
|
||||
dateInfo =
|
||||
'${_formatDate(date)} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
// Récupérer le nom du passage (si le type n'est pas 6 - Maison vide)
|
||||
String? nomInfo;
|
||||
if (type != 6 && passage.name.isNotEmpty) {
|
||||
nomInfo = passage.name;
|
||||
}
|
||||
|
||||
// Récupérer les informations de règlement si le type est 1 (Effectué) ou 5 (Lot)
|
||||
Widget? reglementInfo;
|
||||
if ((type == 1 || type == 5) && passage.fkTypeReglement > 0) {
|
||||
final int typeReglementId = passage.fkTypeReglement;
|
||||
final String montant = passage.montant;
|
||||
|
||||
// Récupérer les informations du type de règlement
|
||||
if (AppKeys.typesReglements.containsKey(typeReglementId)) {
|
||||
final Map<String, dynamic> typeReglement =
|
||||
AppKeys.typesReglements[typeReglementId]!;
|
||||
final String titre = typeReglement['titre'] as String;
|
||||
final Color couleur = Color(typeReglement['couleur'] as int);
|
||||
final IconData iconData = typeReglement['icon_data'] as IconData;
|
||||
|
||||
reglementInfo = Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
margin: const EdgeInsets.only(top: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: couleur.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: couleur.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(iconData, color: couleur, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text('$titre: $montant €',
|
||||
style:
|
||||
TextStyle(color: couleur, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier si l'utilisateur peut supprimer (admin ou user avec permission)
|
||||
bool canDelete = isAdmin;
|
||||
if (!isAdmin) {
|
||||
@@ -122,93 +59,39 @@ class PassageMapDialog extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Passage #${passage.id}',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: typeColor.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
typePassage,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: typeColor,
|
||||
),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Afficher en premier si le passage n'est pas affecté à un secteur
|
||||
if (passage.fkSector == null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
border: Border.all(color: Colors.red, width: 1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Ce passage n\'est plus affecté à un secteur',
|
||||
style: TextStyle(
|
||||
color: Colors.red, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Adresse
|
||||
_buildInfoRow(Icons.location_on, 'Adresse',
|
||||
adresse.isEmpty ? 'Non renseignée' : adresse),
|
||||
|
||||
// Adresse
|
||||
_buildInfoRow(Icons.location_on, 'Adresse',
|
||||
adresse.isEmpty ? 'Non renseignée' : adresse),
|
||||
|
||||
// Résidence
|
||||
if (residenceInfo != null)
|
||||
_buildInfoRow(Icons.apartment, 'Résidence', residenceInfo),
|
||||
|
||||
// Étage et appartement
|
||||
if (etageInfo != null || apptInfo != null)
|
||||
_buildInfoRow(Icons.stairs, 'Localisation',
|
||||
[etageInfo, apptInfo].where((e) => e != null).join(' - ')),
|
||||
|
||||
// Date
|
||||
if (dateInfo != null)
|
||||
_buildInfoRow(Icons.calendar_today, 'Date', dateInfo),
|
||||
|
||||
// Nom
|
||||
if (nomInfo != null) _buildInfoRow(Icons.person, 'Nom', nomInfo),
|
||||
|
||||
// Ville
|
||||
if (passage.ville.isNotEmpty)
|
||||
_buildInfoRow(Icons.location_city, 'Ville', passage.ville),
|
||||
|
||||
// Remarque
|
||||
if (passage.remarque.isNotEmpty)
|
||||
_buildInfoRow(Icons.note, 'Remarque', passage.remarque),
|
||||
|
||||
// Règlement
|
||||
if (reglementInfo != null) reglementInfo,
|
||||
],
|
||||
),
|
||||
// Ville
|
||||
if (passage.ville.isNotEmpty)
|
||||
_buildInfoRow(Icons.location_city, 'Ville', passage.ville),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
// Bouton de modification
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_showEditDialog(context);
|
||||
},
|
||||
icon: const Icon(Icons.edit, size: 20),
|
||||
label: const Text('Modifier'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.blue,
|
||||
),
|
||||
),
|
||||
// Bouton de suppression si autorisé
|
||||
if (canDelete)
|
||||
TextButton.icon(
|
||||
@@ -259,9 +142,25 @@ class PassageMapDialog extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Formater une date
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
|
||||
// Afficher le dialog de modification
|
||||
void _showEditDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return PassageFormDialog(
|
||||
passage: passage,
|
||||
title: 'Modifier le passage',
|
||||
passageRepository: passageRepository,
|
||||
userRepository: userRepository,
|
||||
operationRepository: operationRepository,
|
||||
amicaleRepository: amicaleRepository,
|
||||
onSuccess: () {
|
||||
// Appeler le callback si fourni pour rafraîchir l'affichage
|
||||
onDeleted?.call();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Afficher le dialog de confirmation de suppression
|
||||
|
||||
@@ -336,10 +336,11 @@ class _PassageFormState extends State<PassageForm> {
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+)
|
||||
Radio<String>(
|
||||
value: value,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged,
|
||||
groupValue: groupValue, // ignore: deprecated_member_use
|
||||
onChanged: onChanged, // ignore: deprecated_member_use
|
||||
activeColor: const Color(0xFF20335E),
|
||||
),
|
||||
Text(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -138,6 +138,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
child: Container(
|
||||
color: Colors
|
||||
.transparent, // Fond transparent pour voir l'AdminBackground
|
||||
alignment: Alignment.topCenter, // Aligner le contenu en haut
|
||||
child: widget.body,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -305,8 +305,8 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
final bool hasPassages = count > 0;
|
||||
final textColor = hasPassages ? Colors.black87 : Colors.grey;
|
||||
|
||||
// Vérifier si l'utilisateur est admin
|
||||
final bool isAdmin = CurrentUserService.instance.canAccessAdmin;
|
||||
// Vérifier si l'utilisateur est admin (prend en compte le mode d'affichage)
|
||||
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppTheme.spacingM),
|
||||
@@ -420,8 +420,8 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
? Color(typeInfo['couleur2'] as int)
|
||||
: Colors.grey;
|
||||
|
||||
// Vérifier si l'utilisateur est admin pour les clics
|
||||
final bool isAdmin = CurrentUserService.instance.canAccessAdmin;
|
||||
// Vérifier si l'utilisateur est admin pour les clics (prend en compte le mode d'affichage)
|
||||
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
|
||||
return Expanded(
|
||||
flex: count,
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:math';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'custom_text_field.dart';
|
||||
|
||||
class UserForm extends StatefulWidget {
|
||||
@@ -50,10 +51,13 @@ class _UserFormState extends State<UserForm> {
|
||||
int _fkTitre = 1; // 1 = M., 2 = Mme
|
||||
DateTime? _dateNaissance;
|
||||
DateTime? _dateEmbauche;
|
||||
|
||||
|
||||
// Pour la génération automatique d'username
|
||||
bool _isGeneratingUsername = false;
|
||||
final Random _random = Random();
|
||||
|
||||
// Pour détecter la modification du username
|
||||
String? _initialUsername;
|
||||
|
||||
// Pour afficher/masquer le mot de passe
|
||||
bool _obscurePassword = true;
|
||||
@@ -72,6 +76,9 @@ class _UserFormState extends State<UserForm> {
|
||||
_mobileController = TextEditingController(text: user?.mobile ?? '');
|
||||
_emailController = TextEditingController(text: user?.email ?? '');
|
||||
|
||||
// Stocker le username initial pour détecter les modifications
|
||||
_initialUsername = user?.username;
|
||||
|
||||
_dateNaissance = user?.dateNaissance;
|
||||
_dateEmbauche = user?.dateEmbauche;
|
||||
|
||||
@@ -373,80 +380,6 @@ class _UserFormState extends State<UserForm> {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Générer un mot de passe selon les normes NIST (phrases de passe recommandées)
|
||||
String _generatePassword() {
|
||||
// Listes de mots pour créer des phrases de passe mémorables
|
||||
final sujets = [
|
||||
'Mon chat', 'Le chien', 'Ma voiture', 'Mon vélo', 'La maison',
|
||||
'Mon jardin', 'Le soleil', 'La lune', 'Mon café', 'Le train',
|
||||
'Ma pizza', 'Le gâteau', 'Mon livre', 'La musique', 'Mon film'
|
||||
];
|
||||
|
||||
final noms = [
|
||||
'Félix', 'Max', 'Luna', 'Bella', 'Charlie', 'Rocky', 'Maya',
|
||||
'Oscar', 'Ruby', 'Leo', 'Emma', 'Jack', 'Sophie', 'Milo', 'Zoé'
|
||||
];
|
||||
|
||||
final verbes = [
|
||||
'aime', 'mange', 'court', 'saute', 'danse', 'chante', 'joue',
|
||||
'dort', 'rêve', 'vole', 'nage', 'lit', 'écrit', 'peint', 'cuisine'
|
||||
];
|
||||
|
||||
final complements = [
|
||||
'dans le jardin', 'sous la pluie', 'avec joie', 'très vite', 'tout le temps',
|
||||
'en été', 'le matin', 'la nuit', 'au soleil', 'dans la neige',
|
||||
'sur la plage', 'à Paris', 'en vacances', 'avec passion', 'doucement'
|
||||
];
|
||||
|
||||
// Choisir un type de phrase aléatoirement
|
||||
final typePhrase = _random.nextInt(3);
|
||||
String phrase;
|
||||
|
||||
switch (typePhrase) {
|
||||
case 0:
|
||||
// Type: Sujet + nom propre + verbe + complément
|
||||
final sujet = sujets[_random.nextInt(sujets.length)];
|
||||
final nom = noms[_random.nextInt(noms.length)];
|
||||
final verbe = verbes[_random.nextInt(verbes.length)];
|
||||
final complement = complements[_random.nextInt(complements.length)];
|
||||
phrase = '$sujet $nom $verbe $complement';
|
||||
break;
|
||||
|
||||
case 1:
|
||||
// Type: Nom propre + a + nombre + ans + point d'exclamation
|
||||
final nom = noms[_random.nextInt(noms.length)];
|
||||
final age = 1 + _random.nextInt(20);
|
||||
phrase = '$nom a $age ans!';
|
||||
break;
|
||||
|
||||
default:
|
||||
// Type: Sujet + verbe + nombre + complément
|
||||
final sujet = sujets[_random.nextInt(sujets.length)];
|
||||
final verbe = verbes[_random.nextInt(verbes.length)];
|
||||
final nombre = 1 + _random.nextInt(100);
|
||||
final complement = complements[_random.nextInt(complements.length)];
|
||||
phrase = '$sujet $verbe $nombre fois $complement';
|
||||
}
|
||||
|
||||
// Ajouter éventuellement un caractère spécial à la fin
|
||||
if (_random.nextBool()) {
|
||||
final speciaux = ['!', '?', '.', '...', '♥', '☀', '★', '♪'];
|
||||
phrase += speciaux[_random.nextInt(speciaux.length)];
|
||||
}
|
||||
|
||||
// S'assurer que la phrase fait au moins 8 caractères (elle le sera presque toujours)
|
||||
if (phrase.length < 8) {
|
||||
phrase += ' ${1000 + _random.nextInt(9000)}';
|
||||
}
|
||||
|
||||
// Tronquer si trop long (max 64 caractères selon NIST)
|
||||
if (phrase.length > 64) {
|
||||
phrase = phrase.substring(0, 64);
|
||||
}
|
||||
|
||||
return phrase;
|
||||
}
|
||||
|
||||
// Méthode publique pour récupérer le mot de passe si défini
|
||||
String? getPassword() {
|
||||
@@ -489,6 +422,93 @@ class _UserFormState extends State<UserForm> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Méthode asynchrone pour valider et récupérer l'utilisateur avec vérification du username
|
||||
Future<UserModel?> validateAndGetUserAsync(BuildContext context) async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Déterminer si on doit afficher le champ username selon les règles
|
||||
final bool shouldShowUsernameField = widget.isAdmin && widget.amicale?.chkUsernameManuel == true;
|
||||
// Déterminer si le username est éditable
|
||||
final bool canEditUsername = shouldShowUsernameField && widget.allowUsernameEdit;
|
||||
|
||||
// Vérifier si le username a été modifié (seulement en mode édition)
|
||||
final currentUsername = _usernameController.text;
|
||||
final bool isUsernameModified = widget.user?.id != 0 && // Mode édition
|
||||
_initialUsername != null &&
|
||||
_initialUsername != currentUsername &&
|
||||
canEditUsername;
|
||||
|
||||
// Si le username a été modifié, vérifier sa disponibilité
|
||||
if (isUsernameModified) {
|
||||
try {
|
||||
final result = await _checkUsernameAvailability(currentUsername);
|
||||
|
||||
if (result['available'] != true) {
|
||||
// Afficher l'erreur
|
||||
if (context.mounted) {
|
||||
ApiException.showError(
|
||||
context,
|
||||
Exception(result['message'] ?? 'Ce nom d\'utilisateur est déjà utilisé')
|
||||
);
|
||||
|
||||
// Si des suggestions sont disponibles, les afficher
|
||||
if (result['suggestions'] != null && (result['suggestions'] as List).isNotEmpty) {
|
||||
final suggestions = (result['suggestions'] as List).take(3).join(', ');
|
||||
if (context.mounted) {
|
||||
ApiException.showError(
|
||||
context,
|
||||
Exception('Suggestions disponibles : $suggestions')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null; // Bloquer la soumission
|
||||
}
|
||||
} catch (e) {
|
||||
// En cas d'erreur réseau ou autre
|
||||
if (context.mounted) {
|
||||
ApiException.showError(
|
||||
context,
|
||||
Exception('Impossible de vérifier la disponibilité du nom d\'utilisateur')
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Si tout est OK, retourner l'utilisateur
|
||||
return widget.user?.copyWith(
|
||||
username: _usernameController.text, // NIST: ne pas faire de trim sur username
|
||||
firstName: _firstNameController.text.trim(),
|
||||
name: _nameController.text.trim(),
|
||||
sectName: _sectNameController.text.trim(),
|
||||
phone: _phoneController.text.trim(),
|
||||
mobile: _mobileController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
fkTitre: _fkTitre,
|
||||
dateNaissance: _dateNaissance,
|
||||
dateEmbauche: _dateEmbauche,
|
||||
) ??
|
||||
UserModel(
|
||||
id: 0,
|
||||
username: _usernameController.text, // NIST: ne pas faire de trim sur username
|
||||
firstName: _firstNameController.text.trim(),
|
||||
name: _nameController.text.trim(),
|
||||
sectName: _sectNameController.text.trim(),
|
||||
phone: _phoneController.text.trim(),
|
||||
mobile: _mobileController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
fkTitre: _fkTitre,
|
||||
dateNaissance: _dateNaissance,
|
||||
dateEmbauche: _dateEmbauche,
|
||||
role: 1,
|
||||
createdAt: DateTime.now(),
|
||||
lastSyncedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -496,8 +516,8 @@ class _UserFormState extends State<UserForm> {
|
||||
|
||||
// Déterminer si on doit afficher le champ username selon les règles
|
||||
final bool shouldShowUsernameField = widget.isAdmin && widget.amicale?.chkUsernameManuel == true;
|
||||
// Déterminer si le username est éditable (seulement en création, jamais en modification)
|
||||
final bool canEditUsername = shouldShowUsernameField && widget.allowUsernameEdit && widget.user?.id == 0;
|
||||
// Déterminer si le username est éditable
|
||||
final bool canEditUsername = shouldShowUsernameField && widget.allowUsernameEdit;
|
||||
// Déterminer si on doit afficher le champ mot de passe
|
||||
final bool shouldShowPasswordField = widget.isAdmin && widget.amicale?.chkMdpManuel == true;
|
||||
|
||||
@@ -512,13 +532,12 @@ class _UserFormState extends State<UserForm> {
|
||||
label: "Email",
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
readOnly: widget.readOnly,
|
||||
isRequired: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return "Veuillez entrer l'adresse email";
|
||||
}
|
||||
if (!value.contains('@') || !value.contains('.')) {
|
||||
return "Veuillez entrer une adresse email valide";
|
||||
// Email optionnel - valider seulement si une valeur est saisie
|
||||
if (value != null && value.isNotEmpty) {
|
||||
if (!value.contains('@') || !value.contains('.')) {
|
||||
return "Veuillez entrer une adresse email valide";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -731,7 +750,7 @@ class _UserFormState extends State<UserForm> {
|
||||
readOnly: !canEditUsername,
|
||||
prefixIcon: Icons.account_circle,
|
||||
isRequired: canEditUsername,
|
||||
suffixIcon: (widget.user?.id == 0 && canEditUsername)
|
||||
suffixIcon: canEditUsername
|
||||
? _isGeneratingUsername
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
@@ -749,9 +768,9 @@ class _UserFormState extends State<UserForm> {
|
||||
tooltip: "Générer un nom d'utilisateur",
|
||||
)
|
||||
: null,
|
||||
helperText: canEditUsername
|
||||
? "8 à 64 caractères. Tous les caractères sont acceptés, y compris les espaces et accents."
|
||||
: null,
|
||||
helperText: canEditUsername
|
||||
? "Identifiant de connexion. 8 à 64 caractères. Tous les caractères sont acceptés."
|
||||
: "Identifiant de connexion",
|
||||
helperMaxLines: 2,
|
||||
validator: canEditUsername
|
||||
? (value) {
|
||||
@@ -782,35 +801,14 @@ class _UserFormState extends State<UserForm> {
|
||||
obscureText: _obscurePassword,
|
||||
readOnly: widget.readOnly,
|
||||
prefixIcon: Icons.lock,
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Bouton pour afficher/masquer le mot de passe
|
||||
IconButton(
|
||||
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
|
||||
),
|
||||
// Bouton pour générer un mot de passe (seulement si éditable)
|
||||
if (!widget.readOnly)
|
||||
IconButton(
|
||||
icon: Icon(Icons.auto_awesome),
|
||||
onPressed: () {
|
||||
final newPassword = _generatePassword();
|
||||
setState(() {
|
||||
_passwordController.text = newPassword;
|
||||
_obscurePassword = false; // Afficher le mot de passe généré
|
||||
});
|
||||
// Revalider le formulaire
|
||||
_formKey.currentState?.validate();
|
||||
},
|
||||
tooltip: "Générer un mot de passe sécurisé",
|
||||
),
|
||||
],
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
|
||||
),
|
||||
helperText: widget.user?.id != 0
|
||||
? "Laissez vide pour conserver le mot de passe actuel"
|
||||
@@ -833,7 +831,7 @@ class _UserFormState extends State<UserForm> {
|
||||
readOnly: !canEditUsername,
|
||||
prefixIcon: Icons.account_circle,
|
||||
isRequired: canEditUsername,
|
||||
suffixIcon: (widget.user?.id == 0 && canEditUsername)
|
||||
suffixIcon: canEditUsername
|
||||
? _isGeneratingUsername
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
@@ -851,9 +849,9 @@ class _UserFormState extends State<UserForm> {
|
||||
tooltip: "Générer un nom d'utilisateur",
|
||||
)
|
||||
: null,
|
||||
helperText: canEditUsername
|
||||
? "8 à 64 caractères. Tous les caractères sont acceptés, y compris les espaces et accents."
|
||||
: null,
|
||||
helperText: canEditUsername
|
||||
? "Identifiant de connexion. 8 à 64 caractères. Tous les caractères sont acceptés."
|
||||
: "Identifiant de connexion",
|
||||
helperMaxLines: 2,
|
||||
validator: canEditUsername
|
||||
? (value) {
|
||||
@@ -882,35 +880,14 @@ class _UserFormState extends State<UserForm> {
|
||||
obscureText: _obscurePassword,
|
||||
readOnly: widget.readOnly,
|
||||
prefixIcon: Icons.lock,
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Bouton pour afficher/masquer le mot de passe
|
||||
IconButton(
|
||||
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
|
||||
),
|
||||
// Bouton pour générer un mot de passe (seulement si éditable)
|
||||
if (!widget.readOnly)
|
||||
IconButton(
|
||||
icon: Icon(Icons.auto_awesome),
|
||||
onPressed: () {
|
||||
final newPassword = _generatePassword();
|
||||
setState(() {
|
||||
_passwordController.text = newPassword;
|
||||
_obscurePassword = false; // Afficher le mot de passe généré
|
||||
});
|
||||
// Revalider le formulaire
|
||||
_formKey.currentState?.validate();
|
||||
},
|
||||
tooltip: "Générer un mot de passe sécurisé",
|
||||
),
|
||||
],
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
|
||||
),
|
||||
helperText: widget.user?.id != 0
|
||||
? "Laissez vide pour conserver le mot de passe actuel"
|
||||
@@ -996,10 +973,11 @@ class _UserFormState extends State<UserForm> {
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+)
|
||||
Radio<int>(
|
||||
value: value,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged,
|
||||
groupValue: groupValue, // ignore: deprecated_member_use
|
||||
onChanged: onChanged, // ignore: deprecated_member_use
|
||||
activeColor: const Color(0xFF20335E),
|
||||
),
|
||||
Text(
|
||||
|
||||
@@ -58,8 +58,8 @@ class _UserFormDialogState extends State<UserFormDialog> {
|
||||
}
|
||||
|
||||
void _handleSubmit() async {
|
||||
// Utiliser la méthode validateAndGetUser du UserForm
|
||||
final userData = _userFormKey.currentState?.validateAndGetUser();
|
||||
// Utiliser la méthode asynchrone validateAndGetUserAsync du UserForm
|
||||
final userData = await _userFormKey.currentState?.validateAndGetUserAsync(context);
|
||||
final password = _userFormKey.currentState?.getPassword(); // Récupérer le mot de passe
|
||||
|
||||
if (userData != null) {
|
||||
@@ -134,33 +134,43 @@ class _UserFormDialogState extends State<UserFormDialog> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
children: widget.availableRoles!.map((role) {
|
||||
return RadioListTile<int>(
|
||||
title: Text(role.label),
|
||||
subtitle: Text(
|
||||
role.description,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
value: role.value,
|
||||
groupValue: _selectedRole,
|
||||
onChanged: widget.readOnly
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_selectedRole = value;
|
||||
});
|
||||
},
|
||||
activeColor: theme.colorScheme.primary,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
Row(
|
||||
children: widget.availableRoles!.map((role) {
|
||||
return Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+)
|
||||
Radio<int>(
|
||||
value: role.value,
|
||||
groupValue: _selectedRole, // ignore: deprecated_member_use
|
||||
onChanged: widget.readOnly // ignore: deprecated_member_use
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_selectedRole = value;
|
||||
});
|
||||
},
|
||||
activeColor: theme.colorScheme.primary,
|
||||
),
|
||||
Flexible(
|
||||
child: GestureDetector(
|
||||
onTap: widget.readOnly
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_selectedRole = role.value;
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
role.label,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user