Files
geo/app/lib/chat/widgets/recipient_selector.dart
Pierre 3443277d4a feat: Release version 3.1.4 - Mode terrain et génération PDF
 Nouvelles fonctionnalités:
- Ajout du mode terrain pour utilisation mobile hors connexion
- Génération automatique de reçus PDF avec template personnalisé
- Révision complète du système de cartes avec amélioration des performances

🔧 Améliorations techniques:
- Refactoring du module chat avec architecture simplifiée
- Optimisation du système de sécurité NIST SP 800-63B
- Amélioration de la gestion des secteurs géographiques
- Support UTF-8 étendu pour les noms d'utilisateurs

📱 Application mobile:
- Nouveau mode terrain dans user_field_mode_page
- Interface utilisateur adaptative pour conditions difficiles
- Synchronisation offline améliorée

🗺️ Cartographie:
- Optimisation des performances MapBox
- Meilleure gestion des tuiles hors ligne
- Amélioration de l'affichage des secteurs

📄 Documentation:
- Ajout guide Android (ANDROID-GUIDE.md)
- Documentation sécurité API (API-SECURITY.md)
- Guide module chat (CHAT_MODULE.md)

🐛 Corrections:
- Résolution des erreurs 400 lors de la création d'utilisateurs
- Correction de la validation des noms d'utilisateurs
- Fix des problèmes de synchronisation chat

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 19:38:03 +02:00

599 lines
21 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 = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadInitialRecipients();
}
Future<void> _loadInitialRecipients() async {
setState(() => _isLoading = true);
try {
final recipients = await _service.getPossibleRecipients();
setState(() {
_suggestions = recipients;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
}
}
Future<void> _searchRecipients(String query) async {
if (query.length < 2) {
_loadInitialRecipients();
return;
}
setState(() => _isLoading = true);
try {
final recipients = await _service.getPossibleRecipients(search: query);
setState(() {
_suggestions = recipients;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
}
}
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).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _hexToColor(color).withOpacity(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 canBroadcast = config.any((c) => c['allow_broadcast'] == true);
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 de l'amicale (role 1)
_selectedRecipients.addAll(
allRecipients.where((r) => r['role'] == 1)
);
});
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: [
if (canBroadcast)
ActionChip(
label: const Text('Tous les admins'),
avatar: const Icon(Icons.groups, size: 18),
onPressed: () async {
final allAdmins = await _service.getPossibleRecipients();
setState(() {
_selectedRecipients.clear();
_selectedRecipients.addAll(
allAdmins.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.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: ChatConfigLoader.instance.getUIMessages()['search_placeholder']
?? 'Rechercher...',
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']
);
return ListTile(
leading: CircleAvatar(
backgroundColor: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade200,
child: Text(
recipient['name']?.substring(0, 1).toUpperCase() ?? '?',
style: TextStyle(
color: isSelected ? Colors.white : Colors.grey[700],
fontWeight: FontWeight.w600,
),
),
),
title: Text(
recipient['name'] ?? 'Sans nom',
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: recipient['entite_name'] != null
? Text(
recipient['entite_name'],
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
)
: 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();
@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) ...[
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: [
const Text(
'Message initial (optionnel)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
const SizedBox(height: 8),
TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: 'Écrivez votre premier message...',
hintStyle: TextStyle(color: Colors.grey[400]),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
maxLines: 3,
minLines: 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(),
});
},
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() {
_messageController.dispose();
super.dispose();
}
}