Files
geo/app/lib/chat/widgets/recipient_selector.dart
Pierre 43d4cd66e1 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

729 lines
28 KiB
Dart

import 'package:flutter/material.dart';
import '../services/chat_service.dart';
import '../services/chat_config_loader.dart';
/// Widget pour sélectionner les destinataires avec autocomplete
/// Respecte les règles de permissions définies dans chat_config.yaml
class RecipientSelector extends StatefulWidget {
final Function(List<Map<String, dynamic>>) onRecipientsSelected;
final bool allowMultiple;
const RecipientSelector({
super.key,
required this.onRecipientsSelected,
this.allowMultiple = false,
});
@override
State<RecipientSelector> createState() => _RecipientSelectorState();
}
class _RecipientSelectorState extends State<RecipientSelector> {
final _service = ChatService.instance;
final _searchController = TextEditingController();
final _selectedRecipients = <Map<String, dynamic>>[];
List<Map<String, dynamic>> _suggestions = [];
List<Map<String, dynamic>> _allRecipients = []; // Liste complète pour la recherche locale
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadInitialRecipients();
}
Future<void> _loadInitialRecipients() async {
setState(() => _isLoading = true);
try {
final recipients = await _service.getPossibleRecipients();
setState(() {
// Pour un admin (rôle 2), on filtre les super admins de la liste de sélection individuelle
// et on exclut aussi l'utilisateur lui-même
if (_service.currentUserRole == 2) {
_allRecipients = recipients.where((r) =>
r['role'] != 9 && // Pas de super admins
r['id'] != _service.currentUserId // Pas l'utilisateur lui-même
).toList();
} else {
// Pour les autres rôles, on exclut juste l'utilisateur lui-même
_allRecipients = recipients.where((r) =>
r['id'] != _service.currentUserId
).toList();
}
_suggestions = _allRecipients; // Afficher tous les destinataires initialement
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
}
}
Future<void> _searchRecipients(String query) async {
if (query.isEmpty) {
setState(() {
_suggestions = _allRecipients;
});
return;
}
// Recherche locale sur les trois champs
final searchQuery = query.toLowerCase().trim();
setState(() {
_suggestions = _allRecipients.where((recipient) {
// Récupérer les trois champs à rechercher
final firstName = (recipient['first_name'] ?? '').toString().toLowerCase();
final lastName = (recipient['name'] ?? '').toString().toLowerCase();
final sectName = (recipient['sect_name'] ?? '').toString().toLowerCase();
// Rechercher dans les trois champs
return firstName.contains(searchQuery) ||
lastName.contains(searchQuery) ||
sectName.contains(searchQuery);
}).toList();
});
}
void _toggleRecipient(Map<String, dynamic> recipient) {
setState(() {
if (widget.allowMultiple) {
final exists = _selectedRecipients.any((r) => r['id'] == recipient['id']);
if (exists) {
_selectedRecipients.removeWhere((r) => r['id'] == recipient['id']);
} else {
_selectedRecipients.add(recipient);
}
} else {
_selectedRecipients.clear();
_selectedRecipients.add(recipient);
}
});
widget.onRecipientsSelected(_selectedRecipients);
}
Widget _buildRoleBadge(int role) {
final color = ChatConfigLoader.instance.getRoleColor(role);
final name = ChatConfigLoader.instance.getRoleName(role);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _hexToColor(color).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _hexToColor(color).withValues(alpha: 0.3)),
),
child: Text(
name,
style: TextStyle(
fontSize: 11,
color: _hexToColor(color),
fontWeight: FontWeight.w600,
),
),
);
}
Color _hexToColor(String hex) {
final buffer = StringBuffer();
if (hex.length == 6 || hex.length == 7) buffer.write('ff');
buffer.write(hex.replaceFirst('#', ''));
return Color(int.parse(buffer.toString(), radix: 16));
}
@override
Widget build(BuildContext context) {
final currentRole = _service.currentUserRole;
final config = ChatConfigLoader.instance.getPossibleRecipientsConfig(currentRole);
final canSelect = config.any((c) => c['allow_selection'] == true);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// En-tête avec options pour membre role 1
if (currentRole == 1) ...[
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sélectionner les destinataires',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
// Bouton Admin pour contacter tous les admins de l'amicale
ActionChip(
label: const Text('Administrateurs'),
avatar: const Icon(Icons.admin_panel_settings, size: 18),
backgroundColor: Colors.red.shade50,
onPressed: () async {
final allRecipients = await _service.getPossibleRecipients();
setState(() {
_selectedRecipients.clear();
// Sélectionner tous les admins de l'amicale (role 2)
_selectedRecipients.addAll(
allRecipients.where((r) => r['role'] == 2)
);
});
widget.onRecipientsSelected(_selectedRecipients);
},
),
if (_selectedRecipients.isNotEmpty)
ActionChip(
label: Text('${_selectedRecipients.length} sélectionné(s)'),
avatar: const Icon(Icons.check_circle, size: 18),
backgroundColor: Colors.orange.shade50,
onPressed: () {
setState(() => _selectedRecipients.clear());
widget.onRecipientsSelected(_selectedRecipients);
},
),
],
),
const SizedBox(height: 8),
Text(
'Ou sélectionnez des membres individuellement :',
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
),
],
),
),
const Divider(height: 1),
],
// En-tête avec options pour admin role 2
if (currentRole == 2) ...[
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sélectionner les destinataires',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
// Bouton GEOSECTOR pour contacter tous les super-admins
ActionChip(
label: const Text('GEOSECTOR'),
avatar: const Icon(Icons.business, size: 18),
backgroundColor: Colors.blue.shade50,
onPressed: () async {
final allRecipients = await _service.getPossibleRecipients();
setState(() {
_selectedRecipients.clear();
// Sélectionner tous les super-admins (role 9)
_selectedRecipients.addAll(
allRecipients.where((r) => r['role'] == 9)
);
});
widget.onRecipientsSelected(_selectedRecipients);
},
),
// Bouton Amicale pour contacter tous les membres de son amicale
ActionChip(
label: const Text('Toute l\'Amicale'),
avatar: const Icon(Icons.group, size: 18),
backgroundColor: Colors.green.shade50,
onPressed: () async {
final allRecipients = await _service.getPossibleRecipients();
setState(() {
_selectedRecipients.clear();
// Sélectionner tous les membres actifs de l'amicale (pas les super admins)
// On inclut l'utilisateur lui-même pour qu'il reçoive aussi le message
// L'API devrait déjà retourner tous les membres actifs de l'amicale (rôle 1 et 2)
_selectedRecipients.addAll(
allRecipients.where((r) => r['role'] != 9)
);
// Si l'utilisateur n'est pas dans la liste, on l'ajoute
if (!_selectedRecipients.any((r) => r['id'] == _service.currentUserId)) {
// On utilise le nom complet fourni au service
// TODO: Il faudrait passer prénom et nom séparément lors de l'init du ChatService
_selectedRecipients.add({
'id': _service.currentUserId,
'name': _service.currentUserName,
'first_name': '',
'role': _service.currentUserRole,
});
}
});
widget.onRecipientsSelected(_selectedRecipients);
},
),
if (_selectedRecipients.isNotEmpty)
ActionChip(
label: Text('${_selectedRecipients.length} sélectionné(s)'),
avatar: const Icon(Icons.check_circle, size: 18),
backgroundColor: Colors.orange.shade50,
onPressed: () {
setState(() => _selectedRecipients.clear());
widget.onRecipientsSelected(_selectedRecipients);
},
),
],
),
const SizedBox(height: 8),
Text(
'Ou sélectionnez des membres individuellement :',
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
),
],
),
),
const Divider(height: 1),
],
// En-tête avec options pour super-admin
if (currentRole == 9) ...[
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sélectionner les destinataires',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
// Bouton "Tous les admins" retiré - maintenant disponible via le bouton d'action rapide dans la liste des rooms
if (_selectedRecipients.isNotEmpty)
ActionChip(
label: Text('${_selectedRecipients.length} sélectionné(s)'),
avatar: const Icon(Icons.check_circle, size: 18),
backgroundColor: Colors.green.shade50,
onPressed: () {
setState(() => _selectedRecipients.clear());
widget.onRecipientsSelected(_selectedRecipients);
},
),
],
),
],
),
),
const Divider(height: 1),
],
// Barre de recherche
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher un destinataire...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
onChanged: _searchRecipients,
),
),
// Liste des suggestions
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _suggestions.isEmpty
? Center(
child: Text(
ChatConfigLoader.instance.getUIMessages()['no_recipients']
?? 'Aucun destinataire disponible',
style: TextStyle(color: Colors.grey[600]),
),
)
: ListView.builder(
itemCount: _suggestions.length,
itemBuilder: (context, index) {
final recipient = _suggestions[index];
final isSelected = _selectedRecipients.any(
(r) => r['id'] == recipient['id']
);
// Construire le nom complet (prénom + nom)
final String firstName = recipient['first_name'] ?? '';
final String lastName = recipient['name'] ?? '';
final String fullName = '${firstName.trim()} ${lastName.trim()}'.trim();
final String displayName = fullName.isNotEmpty ? fullName : 'Sans nom';
// Utiliser sect_name s'il existe, sinon rien
final String? sectName = recipient['sect_name'];
final String? subtitle = sectName?.isNotEmpty == true
? sectName
: null;
// Initiales pour l'avatar (première lettre prénom + première lettre nom)
String avatarLetters = '';
if (firstName.isNotEmpty) {
avatarLetters += firstName.substring(0, 1).toUpperCase();
}
if (lastName.isNotEmpty) {
avatarLetters += lastName.substring(0, 1).toUpperCase();
}
if (avatarLetters.isEmpty) {
avatarLetters = '?';
}
return ListTile(
leading: CircleAvatar(
backgroundColor: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade200,
child: Text(
avatarLetters,
style: TextStyle(
color: isSelected ? Colors.white : Colors.grey[700],
fontWeight: FontWeight.w600,
fontSize: avatarLetters.length > 1 ? 14 : 16,
),
),
),
title: Text(
displayName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: subtitle != null
? Text(
subtitle,
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
fontStyle: sectName?.isNotEmpty == true
? FontStyle.italic
: FontStyle.normal,
),
)
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (recipient['role'] != null)
_buildRoleBadge(recipient['role']),
if (widget.allowMultiple || canSelect)
Checkbox(
value: isSelected,
onChanged: (_) => _toggleRecipient(recipient),
),
],
),
onTap: () => _toggleRecipient(recipient),
);
},
),
),
// Bouton de validation
if (_selectedRecipients.isNotEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: Colors.grey.shade200),
),
),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(_selectedRecipients),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
backgroundColor: Theme.of(context).primaryColor,
),
child: Text(
widget.allowMultiple
? 'Créer conversation avec ${_selectedRecipients.length} personne(s)'
: 'Créer conversation',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
],
);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
}
/// Dialog pour sélectionner les destinataires
class RecipientSelectorDialog extends StatelessWidget {
final bool allowMultiple;
const RecipientSelectorDialog({
super.key,
this.allowMultiple = false,
});
static Future<Map<String, dynamic>?> show(
BuildContext context, {
bool allowMultiple = false,
}) async {
return showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => RecipientSelectorDialog(
allowMultiple: allowMultiple,
),
);
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
maxWidth: 500,
),
child: _RecipientSelectorWithMessage(
allowMultiple: allowMultiple,
),
),
);
}
}
/// Widget interne pour gérer la sélection et le message initial
class _RecipientSelectorWithMessage extends StatefulWidget {
final bool allowMultiple;
const _RecipientSelectorWithMessage({
required this.allowMultiple,
});
@override
State<_RecipientSelectorWithMessage> createState() => _RecipientSelectorWithMessageState();
}
class _RecipientSelectorWithMessageState extends State<_RecipientSelectorWithMessage> {
List<Map<String, dynamic>> _selectedRecipients = [];
final _messageController = TextEditingController();
bool _isBroadcast = false; // Option broadcast pour super admins
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Sélecteur de destinataires
Expanded(
child: RecipientSelector(
allowMultiple: widget.allowMultiple,
onRecipientsSelected: (recipients) {
setState(() {
_selectedRecipients = recipients;
});
},
),
),
// Champ de message initial si des destinataires sont sélectionnés
if (_selectedRecipients.isNotEmpty) ...[
// Option broadcast pour super admins uniquement
if (ChatService.instance.currentUserRole == 9)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.amber.shade50,
border: Border(
top: BorderSide(color: Colors.grey.shade200),
),
),
child: Row(
children: [
Expanded(
child: Row(
children: [
Icon(Icons.campaign, size: 20, color: Colors.amber.shade700),
const SizedBox(width: 8),
Text(
'Mode Annonce (Broadcast)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.amber.shade900,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Les destinataires ne pourront pas répondre',
style: TextStyle(
fontSize: 12,
color: Colors.amber.shade700,
fontStyle: FontStyle.italic,
),
),
),
],
),
),
Switch(
value: _isBroadcast,
onChanged: (value) {
setState(() {
_isBroadcast = value;
});
},
activeThumbColor: Colors.amber.shade600,
),
],
),
),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(
top: BorderSide(color: Colors.grey.shade200),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isBroadcast ? 'Message de l\'annonce' : 'Message initial (optionnel)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _isBroadcast ? Colors.amber.shade900 : const Color(0xFF1E293B),
),
),
const SizedBox(height: 8),
TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: _isBroadcast
? 'Écrivez votre annonce officielle...'
: 'Écrivez votre premier message...',
hintStyle: TextStyle(color: Colors.grey[400]),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: _isBroadcast ? Colors.amber.shade300 : Colors.grey.shade300,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: _isBroadcast ? Colors.amber.shade300 : Colors.grey.shade300,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: _isBroadcast ? Colors.amber.shade600 : Theme.of(context).primaryColor,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
maxLines: _isBroadcast ? 5 : 3,
minLines: _isBroadcast ? 3 : 2,
),
],
),
),
// Bouton de validation
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: Colors.grey.shade200),
),
),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop({
'recipients': _selectedRecipients,
'initial_message': _messageController.text.trim(),
'is_broadcast': _isBroadcast, // Ajouter le flag broadcast
});
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
backgroundColor: _isBroadcast
? Colors.amber.shade600
: Theme.of(context).primaryColor,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isBroadcast) ...[
const Icon(Icons.campaign, color: Colors.white, size: 20),
const SizedBox(width: 8),
],
Text(
_isBroadcast
? 'Envoyer l\'annonce à ${_selectedRecipients.length} admin(s)'
: widget.allowMultiple
? 'Créer conversation avec ${_selectedRecipients.length} personne(s)'
: 'Créer conversation',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
),
],
],
);
}
@override
void dispose() {
_messageController.dispose();
super.dispose();
}
}