Files
geo/app/lib/chat/pages/rooms_page_embedded.dart
Pierre f7baa7492c feat: Mise à jour des interfaces mobiles v3.2.3
- Amélioration des interfaces utilisateur sur mobile
- Optimisation de la responsivité des composants Flutter
- Mise à jour des widgets de chat et communication
- Amélioration des formulaires et tableaux
- Ajout de nouveaux composants pour l'administration
- Optimisation des thèmes et styles visuels

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 20:35:40 +02:00

1695 lines
58 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../models/room.dart';
import '../services/chat_service.dart';
import '../services/chat_config_loader.dart';
import '../widgets/recipient_selector.dart';
import 'chat_page.dart';
import 'package:geosector_app/presentation/widgets/loading_overlay.dart';
/// Version embarquée de RoomsPage sans AppBar pour intégration
class RoomsPageEmbedded extends StatefulWidget {
final VoidCallback? onAddPressed;
final VoidCallback? onRefreshPressed;
const RoomsPageEmbedded({
super.key,
this.onAddPressed,
this.onRefreshPressed,
});
@override
State<RoomsPageEmbedded> createState() => RoomsPageEmbeddedState();
}
class RoomsPageEmbeddedState extends State<RoomsPageEmbedded> {
final _service = ChatService.instance;
bool _isLoading = false; // Pas de loading initial car déjà chargé au login
bool _isProcessingAction = false; // Pour empêcher les doubles clics
String? _selectedRoomId; // ID de la room sélectionnée (pour le web split-view)
@override
void initState() {
super.initState();
// Les rooms sont déjà chargées au login et synchronisées toutes les 15 secondes
}
Future<void> _loadRooms() async {
setState(() => _isLoading = true);
// Faire une sync incrémentale manuelle (pas complète)
await _service.getRooms(forceFullSync: false);
setState(() => _isLoading = false);
widget.onRefreshPressed?.call();
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
// 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),
);
},
),
);
},
);
}
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) ||
(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;
// Construire le nom complet avec prénom + nom
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',
recipientRole: recipient['role'],
recipientEntite: recipient['entite_id'],
initialMessage: initialMessage,
);
} else {
// 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) {
title = '${recipients.take(3).map((r) {
final firstName = r['first_name'] ?? '';
final lastName = r['name'] ?? '';
return '$firstName $lastName'.trim();
}).join(', ')} et ${recipients.length - 3} autres';
} else {
title = recipients.map((r) {
final firstName = r['first_name'] ?? '';
final lastName = r['name'] ?? '';
return '$firstName $lastName'.trim();
}).join(', ');
}
} else if (currentRole == 2) {
// 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) {
title = 'Toute l\'Amicale';
} else if (recipients.length > 3) {
title = '${recipients.take(3).map((r) {
final firstName = r['first_name'] ?? '';
final lastName = r['name'] ?? '';
return '$firstName $lastName'.trim();
}).join(', ')} et ${recipients.length - 3} autres';
} else {
title = recipients.map((r) {
final firstName = r['first_name'] ?? '';
final lastName = r['name'] ?? '';
return '$firstName $lastName'.trim();
}).join(', ');
}
} else {
// Pour un super admin
if (recipients.length > 3) {
title = '${recipients.take(3).map((r) {
final firstName = r['first_name'] ?? '';
final lastName = r['name'] ?? '';
return '$firstName $lastName'.trim();
}).join(', ')} et ${recipients.length - 3} autres';
} else {
title = recipients.map((r) {
final firstName = r['first_name'] ?? '';
final lastName = r['name'] ?? '';
return '$firstName $lastName'.trim();
}).join(', ');
}
}
// Créer la room avec le bon type (broadcast si coché, sinon group)
newRoom = await _service.createRoom(
title: title,
participantIds: participantIds,
type: isBroadcast ? 'broadcast' : 'group',
initialMessage: initialMessage,
);
}
if (newRoom != null && mounted) {
// Sur le web, sélectionner la room, sur mobile naviguer
if (kIsWeb) {
setState(() {
_selectedRoomId = newRoom?.id;
});
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatPage(
roomId: newRoom!.id,
roomTitle: newRoom.title,
roomType: newRoom.type,
roomCreatorId: newRoom.createdBy,
),
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: Colors.red,
),
);
}
}
}
}
}
// 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>>(
valueListenable: _service.roomsBox.listenable(),
builder: (context, box, _) {
// Récupérer la room sélectionnée depuis Hive avec l'ID stocké
final selectedRoom = _selectedRoomId != null
? box.values.firstWhere(
(r) => r.id == _selectedRoomId,
orElse: () {
// Si la room n'existe plus, réinitialiser la sélection
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_selectedRoomId = null;
});
}
});
return Room(
id: '',
title: '',
type: 'private',
createdAt: DateTime.now(),
);
},
)
: null;
// Déterminer si on est sur un petit écran
final screenWidth = MediaQuery.of(context).size.width;
final screenHeight = MediaQuery.of(context).size.height;
final isSmallScreen = screenWidth < 900;
// Si petit écran ou mobile, disposition verticale
if (isSmallScreen) {
// Calculer la hauteur appropriée pour la liste des rooms
// Sur mobile, utiliser 30% de la hauteur, sur web small screen 250px
final roomsHeight = kIsWeb ? 250.0 : screenHeight * 0.3;
return Column(
children: [
// Liste des rooms en haut avec hauteur adaptative
Container(
height: roomsHeight.clamp(200.0, 350.0), // Entre 200 et 350 pixels
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: _buildRoomsList(context),
),
// Conversation sélectionnée en dessous (reste de l'espace)
Expanded(
child: selectedRoom != null && selectedRoom.id.isNotEmpty
? ChatPage(
key: ValueKey(selectedRoom.id), // Clé unique par room
roomId: selectedRoom.id,
roomTitle: selectedRoom.title,
roomType: selectedRoom.type,
roomCreatorId: selectedRoom.createdBy,
isEmbedded: true, // Pour indiquer qu'on est en mode embedded
)
: _buildEmptyConversation(context),
),
],
);
}
// Si grand écran, disposition horizontale (comme avant)
return Row(
children: [
// Colonne de gauche : Liste des rooms (30%)
Container(
width: MediaQuery.of(context).size.width * 0.3,
constraints: const BoxConstraints(
minWidth: 280,
maxWidth: 400,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
right: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: _buildRoomsList(context),
),
// Colonne de droite : Conversation sélectionnée (70%)
Expanded(
child: selectedRoom != null && selectedRoom.id.isNotEmpty
? ChatPage(
key: ValueKey(selectedRoom.id), // Clé unique par room
roomId: selectedRoom.id,
roomTitle: selectedRoom.title,
roomType: selectedRoom.type,
roomCreatorId: selectedRoom.createdBy,
isEmbedded: true, // Pour indiquer qu'on est en mode embedded
)
: _buildEmptyConversation(context),
),
],
);
},
);
}
/// Liste des rooms pour le web
Widget _buildRoomsList(BuildContext context) {
final helpText = ChatConfigLoader.instance.getHelpText(_service.currentUserRole);
final userRole = _service.currentUserRole;
return Column(
children: [
// En-tête avec boutons d'action
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(color: Colors.grey[200]!),
),
),
child: Column(
children: [
// Titre de la section
Row(
children: [
Icon(Icons.chat, size: 20, color: Colors.grey[700]),
const SizedBox(width: 8),
Text(
'Conversations',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
const Spacer(),
// Bouton nouvelle conversation
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: createNewConversation,
tooltip: 'Nouvelle conversation',
color: Colors.blue[600],
),
],
),
// Boutons d'action rapide selon le rôle
if (userRole == 9) ...[
// Super Admin - Bouton pour contacter tous les admins d'amicale
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(Icons.groups, size: 16),
label: const Text('Tous les admins d\'amicale'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber[600],
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 10),
),
onPressed: _isProcessingAction ? null : () => _handleQuickActionAllAdmins(),
),
),
] else if (userRole == 2) ...[
const SizedBox(height: 8),
ValueListenableBuilder<Box<Room>>(
valueListenable: _service.roomsBox.listenable(),
builder: (context, box, _) {
// Vérifier l'existence des rooms spéciales
final hasAmicaleRoom = box.values.any((room) =>
room.title == 'Toute l\'Amicale'
);
final hasGeosectorRoom = box.values.any((room) =>
room.title == 'Support GEOSECTOR'
);
return Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: Icon(
hasAmicaleRoom ? Icons.check : Icons.group,
size: 16
),
label: Text(
hasAmicaleRoom ? 'Amicale (actif)' : 'Toute l\'Amicale',
style: const TextStyle(fontSize: 12)
),
style: ElevatedButton.styleFrom(
backgroundColor: hasAmicaleRoom
? Colors.grey[400]
: Colors.green[600],
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 8),
),
onPressed: (hasAmicaleRoom || _isProcessingAction)
? null
: () => _handleQuickActionAmicale(),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
icon: Icon(
hasGeosectorRoom ? Icons.check : Icons.support_agent,
size: 16
),
label: Text(
hasGeosectorRoom ? 'Support (actif)' : 'Support GEOSECTOR',
style: const TextStyle(fontSize: 12)
),
style: ElevatedButton.styleFrom(
backgroundColor: hasGeosectorRoom
? Colors.grey[400]
: Colors.blue[600],
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 8),
),
onPressed: (hasGeosectorRoom || _isProcessingAction)
? null
: () => _handleQuickActionGeosector(),
),
),
],
);
},
),
] else if (userRole == 1) ...[
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(Icons.admin_panel_settings, size: 16),
label: const Text('Contacter les admins'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange[600],
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 10),
),
onPressed: _isProcessingAction ? null : () => _handleQuickActionAdmins(),
),
),
],
],
),
),
// Liste des conversations
Expanded(
child: 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));
// Vérifier si la room sélectionnée existe encore
if (_selectedRoomId != null && !rooms.any((r) => r.id == _selectedRoomId)) {
// La room sélectionnée a été supprimée, la désélectionner
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_selectedRoomId = null;
});
}
});
}
if (rooms.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Aucune conversation',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
if (helpText.isNotEmpty)
Padding(
padding: const EdgeInsets.all(8),
child: Text(
helpText,
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
textAlign: TextAlign.center,
),
),
],
),
);
}
return ListView.builder(
itemCount: rooms.length,
itemBuilder: (context, index) {
final room = rooms[index];
final isSelected = _selectedRoomId == room.id;
return Container(
decoration: BoxDecoration(
color: isSelected ? Colors.blue.shade50 : Colors.white,
border: Border(
bottom: BorderSide(color: Colors.grey[200]!),
),
),
child: _WebRoomTile(
room: room,
isSelected: isSelected,
currentUserId: _service.currentUserId,
onTap: () {
setState(() {
_selectedRoomId = room.id;
});
},
onDelete: () {
print('🗑️ Clic suppression: room.createdBy=${room.createdBy}, currentUserId=${_service.currentUserId}');
_handleDeleteRoom(room);
},
),
);
},
);
},
),
),
],
);
}
/// Vue vide quand aucune conversation n'est sélectionnée
Widget _buildEmptyConversation(BuildContext context) {
return Container(
color: Colors.grey[50],
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat,
size: 64,
color: Colors.grey[300],
),
const SizedBox(height: 16),
Text(
'Sélectionnez une conversation',
style: TextStyle(
fontSize: 18,
color: Colors.grey[500],
),
),
const SizedBox(height: 8),
Text(
'ou créez-en une nouvelle',
style: TextStyle(
fontSize: 14,
color: Colors.grey[400],
),
),
],
),
),
);
}
/// Action rapide : Contacter toute l'amicale (pour admin)
Future<void> _handleQuickActionAmicale() async {
// Protection contre les doubles clics
if (_isProcessingAction) return;
setState(() {
_isProcessingAction = true;
});
try {
// Utiliser LoadingOverlay pour l'opération longue
await LoadingOverlay.show(
context: context,
message: 'Création de la conversation avec toute l\'amicale...',
future: _createAmicaleRoom(),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isProcessingAction = false;
});
}
}
}
Future<void> _createAmicaleRoom() async {
try {
// Récupérer tous les membres de l'amicale via l'API
final recipients = await _service.getPossibleRecipients();
// Filtrer pour ne garder que les membres de l'amicale (role 1 et 2)
final amicaleMembers = recipients.where((r) =>
r['role'] == 1 || r['role'] == 2
).toList();
if (amicaleMembers.isEmpty) {
throw Exception('Aucun membre trouvé dans l\'amicale');
}
// Créer une conversation de groupe avec tous les membres
final participantIds = amicaleMembers.map((r) => r['id'] as int).toList();
final newRoom = await _service.createRoom(
title: 'Toute l\'Amicale',
participantIds: participantIds,
type: 'group',
initialMessage: null,
);
if (newRoom != null && mounted) {
setState(() {
_selectedRoomId = newRoom.id;
});
}
} catch (e) {
rethrow;
}
}
/// Action rapide : Contacter GEOSECTOR (pour admin)
Future<void> _handleQuickActionGeosector() async {
// Protection contre les doubles clics
if (_isProcessingAction) return;
setState(() {
_isProcessingAction = true;
});
try {
// Utiliser LoadingOverlay pour l'opération longue
await LoadingOverlay.show(
context: context,
message: 'Création de la conversation avec le support GEOSECTOR...',
future: _createGeosectorRoom(),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isProcessingAction = false;
});
}
}
}
Future<void> _createGeosectorRoom() async {
try {
// Récupérer tous les destinataires via l'API
final recipients = await _service.getPossibleRecipients();
// Filtrer pour ne garder que les super admins (role 9)
final superAdmins = recipients.where((r) => r['role'] == 9).toList();
if (superAdmins.isEmpty) {
throw Exception('Aucun super admin disponible');
}
// Créer une conversation avec les super admins
if (superAdmins.length == 1) {
// Conversation privée si un seul super admin
final admin = superAdmins.first;
final firstName = admin['first_name'] ?? '';
final lastName = admin['name'] ?? '';
final fullName = '$firstName $lastName'.trim();
final newRoom = await _service.createPrivateRoom(
recipientId: admin['id'],
recipientName: fullName.isNotEmpty ? fullName : 'Super Admin',
recipientRole: admin['role'],
recipientEntite: admin['entite_id'],
initialMessage: null,
);
if (newRoom != null && mounted) {
setState(() {
_selectedRoomId = newRoom.id;
});
}
} else {
// Conversation de groupe si plusieurs super admins
final participantIds = superAdmins.map((r) => r['id'] as int).toList();
final newRoom = await _service.createRoom(
title: 'Support GEOSECTOR',
participantIds: participantIds,
type: 'group',
initialMessage: null,
);
if (newRoom != null && mounted) {
setState(() {
_selectedRoomId = newRoom.id;
});
}
}
} catch (e) {
rethrow;
}
}
/// Supprimer une room
Future<void> _handleDeleteRoom(Room room) async {
print('🚀 _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) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vous ne pouvez supprimer que les conversations que vous avez créées'),
backgroundColor: Colors.orange,
),
);
}
return;
}
// Demander confirmation
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer la conversation'),
content: Text('Voulez-vous vraiment supprimer la conversation "${room.title}" ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
if (confirm == true) {
try {
await _service.deleteRoom(room.id);
// Si la room supprimée était sélectionnée, désélectionner
if (_selectedRoomId == room.id) {
setState(() {
_selectedRoomId = null;
});
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Conversation supprimée'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
}
/// Action rapide : Contacter les admins (pour membre)
Future<void> _handleQuickActionAdmins() async {
// Protection contre les doubles clics
if (_isProcessingAction) return;
setState(() {
_isProcessingAction = true;
});
try {
// Utiliser LoadingOverlay pour l'opération longue
await LoadingOverlay.show(
context: context,
message: 'Création de la conversation avec les administrateurs...',
future: _createAdminsRoom(),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isProcessingAction = false;
});
}
}
}
Future<void> _createAdminsRoom() async {
try {
// Récupérer tous les destinataires via l'API
final recipients = await _service.getPossibleRecipients();
// Filtrer pour ne garder que les admins de l'amicale (role 2)
final admins = recipients.where((r) => r['role'] == 2).toList();
if (admins.isEmpty) {
throw Exception('Aucun administrateur disponible');
}
// Créer une conversation avec les admins
if (admins.length == 1) {
// Conversation privée si un seul admin
final admin = admins.first;
final firstName = admin['first_name'] ?? '';
final lastName = admin['name'] ?? '';
final fullName = '$firstName $lastName'.trim();
final newRoom = await _service.createPrivateRoom(
recipientId: admin['id'],
recipientName: fullName.isNotEmpty ? fullName : 'Admin Amicale',
recipientRole: admin['role'],
recipientEntite: admin['entite_id'],
initialMessage: null,
);
if (newRoom != null && mounted) {
setState(() {
_selectedRoomId = newRoom.id;
});
}
} else {
// Conversation de groupe si plusieurs admins
final participantIds = admins.map((r) => r['id'] as int).toList();
final newRoom = await _service.createRoom(
title: 'Administrateurs Amicale',
participantIds: participantIds,
type: 'group',
initialMessage: null,
);
if (newRoom != null && mounted) {
setState(() {
_selectedRoomId = newRoom.id;
});
}
}
} catch (e) {
rethrow;
}
}
/// Action rapide pour Super Admin - Contacter tous les admins d'amicale
Future<void> _handleQuickActionAllAdmins() async {
// Protection contre les doubles clics
if (_isProcessingAction) return;
setState(() {
_isProcessingAction = true;
});
try {
// Utiliser LoadingOverlay pour l'opération longue
await LoadingOverlay.show(
context: context,
message: 'Création de l\'annonce pour tous les administrateurs...',
future: _createAllAdminsRoom(),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isProcessingAction = false;
});
}
}
}
Future<void> _createAllAdminsRoom() async {
try {
// Récupérer tous les destinataires via l'API
final recipients = await _service.getPossibleRecipients();
// Filtrer pour ne garder que les admins d'amicale (role 2)
final allAdmins = recipients.where((r) => r['role'] == 2).toList();
if (allAdmins.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Aucun administrateur d\'amicale trouvé'),
backgroundColor: Colors.orange,
),
);
}
return;
}
// Ouvrir le dialog de création avec les admins pré-sélectionnés
// et l'option broadcast activée par défaut pour les annonces
if (!mounted) return;
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (dialogContext) => Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
maxWidth: 500,
),
child: _QuickBroadcastDialog(
recipients: allAdmins,
title: 'Annonce à tous les administrateurs',
),
),
),
);
if (result != null) {
final initialMessage = result['message'] as String?;
final isBroadcast = result['broadcast'] as bool? ?? true;
// Créer les IDs des participants
final participantIds = allAdmins.map((r) => r['id'] as int).toList();
// Créer la room broadcast
final newRoom = await _service.createRoom(
title: 'Annonce GEOSECTOR',
participantIds: participantIds,
type: isBroadcast ? 'broadcast' : 'group',
initialMessage: initialMessage,
);
if (newRoom != null && mounted) {
setState(() {
_selectedRoomId = newRoom.id;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
isBroadcast
? '📢 Annonce envoyée à ${allAdmins.length} administrateur(s)'
: 'Conversation créée avec ${allAdmins.length} administrateur(s)'
),
backgroundColor: isBroadcast ? Colors.amber[600] : Colors.green,
),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
}
/// Dialog rapide pour envoyer un broadcast
class _QuickBroadcastDialog extends StatefulWidget {
final List<Map<String, dynamic>> recipients;
final String title;
const _QuickBroadcastDialog({
required this.recipients,
required this.title,
});
@override
State<_QuickBroadcastDialog> createState() => _QuickBroadcastDialogState();
}
class _QuickBroadcastDialogState extends State<_QuickBroadcastDialog> {
final _messageController = TextEditingController();
bool _isBroadcast = true; // Par défaut en mode broadcast pour super admin
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// En-tête
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.amber.shade50,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
Icon(Icons.campaign, color: Colors.amber.shade700),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.amber.shade900,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Corps
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Info destinataires
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.group, size: 20, color: Colors.blue.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
'${widget.recipients.length} administrateur(s) d\'amicale',
style: TextStyle(
fontSize: 14,
color: Colors.blue.shade900,
),
),
),
],
),
),
const SizedBox(height: 16),
// Switch broadcast
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _isBroadcast ? Colors.amber.shade50 : Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _isBroadcast ? Colors.amber.shade300 : Colors.grey.shade300,
),
),
child: Row(
children: [
Icon(
_isBroadcast ? Icons.campaign : Icons.chat,
color: _isBroadcast ? Colors.amber.shade700 : Colors.grey.shade700,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isBroadcast ? 'Mode Annonce (Broadcast)' : 'Mode Discussion',
style: TextStyle(
fontWeight: FontWeight.w600,
color: _isBroadcast ? Colors.amber.shade900 : Colors.grey.shade900,
),
),
Text(
_isBroadcast
? 'Les destinataires ne pourront pas répondre'
: 'Tous les participants peuvent discuter',
style: TextStyle(
fontSize: 12,
color: _isBroadcast ? Colors.amber.shade700 : Colors.grey.shade600,
),
),
],
),
),
Switch(
value: _isBroadcast,
onChanged: (value) {
setState(() {
_isBroadcast = value;
});
},
activeThumbColor: Colors.amber.shade600,
),
],
),
),
const SizedBox(height: 16),
// Champ message
Text(
_isBroadcast ? 'Votre annonce' : 'Message initial (optionnel)',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: _isBroadcast
? 'Écrivez votre annonce officielle...'
: 'Écrivez votre message...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Colors.white,
),
maxLines: 5,
minLines: 3,
),
],
),
),
),
// Boutons
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(
top: BorderSide(color: Colors.grey.shade200),
),
),
child: Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
Navigator.of(context).pop({
'message': _messageController.text.trim(),
'broadcast': _isBroadcast,
});
},
icon: Icon(_isBroadcast ? Icons.campaign : Icons.send),
label: Text(_isBroadcast ? 'Envoyer l\'annonce' : 'Créer la discussion'),
style: ElevatedButton.styleFrom(
backgroundColor: _isBroadcast ? Colors.amber.shade600 : Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
],
);
}
@override
void dispose() {
_messageController.dispose();
super.dispose();
}
}
/// 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;
final bool isSelected;
final int currentUserId;
final VoidCallback onTap;
final VoidCallback onDelete;
const _WebRoomTile({
required this.room,
required this.isSelected,
required this.currentUserId,
required this.onTap,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
print('🔍 _WebRoomTile pour ${room.title}: createdBy=${room.createdBy}, currentUserId=$currentUserId, showDelete=${room.createdBy == currentUserId}');
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: CircleAvatar(
radius: 18,
backgroundColor: room.type == 'broadcast'
? (isSelected ? Colors.amber.shade600 : Colors.amber.shade400)
: (isSelected ? const Color(0xFF2563EB) : Colors.grey[400]),
child: room.type == 'broadcast'
? const Icon(Icons.campaign, color: Colors.white, size: 16)
: Text(
_getInitials(room.title),
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
title: Row(
children: [
Expanded(
child: Text(
room.title,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (room.type == 'broadcast')
Container(
margin: const EdgeInsets.only(left: 4),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.amber.shade100,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'ANN.',
style: TextStyle(
fontSize: 8,
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],
fontSize: 12,
),
)
: 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: 11,
color: Colors.grey[500],
),
),
if (room.unreadCount > 0)
Container(
margin: const EdgeInsets.only(top: 2),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
borderRadius: BorderRadius.circular(8),
),
child: Text(
room.unreadCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
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: 18,
color: Colors.red[400],
),
onPressed: onDelete,
tooltip: 'Supprimer la conversation',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
],
),
onTap: onTap,
);
}
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();
}
}