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>
This commit is contained in:
@@ -27,10 +27,12 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _nameFocusNode = FocusNode();
|
||||
final _searchController = TextEditingController();
|
||||
Color _selectedColor = Colors.blue;
|
||||
final List<int> _selectedMemberIds = [];
|
||||
bool _isLoading = false;
|
||||
bool _membersLoaded = false;
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -108,6 +110,7 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_nameFocusNode.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -162,69 +165,111 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
}
|
||||
|
||||
void _showColorPicker() {
|
||||
// Liste de couleurs prédéfinies
|
||||
final List<Color> colors = [
|
||||
Colors.red,
|
||||
Colors.pink,
|
||||
Colors.purple,
|
||||
Colors.deepPurple,
|
||||
Colors.indigo,
|
||||
Colors.blue,
|
||||
Colors.lightBlue,
|
||||
Colors.cyan,
|
||||
Colors.teal,
|
||||
Colors.green,
|
||||
Colors.lightGreen,
|
||||
Colors.lime,
|
||||
Colors.yellow,
|
||||
Colors.amber,
|
||||
Colors.orange,
|
||||
Colors.deepOrange,
|
||||
Colors.brown,
|
||||
Colors.grey,
|
||||
Colors.blueGrey,
|
||||
const Color(0xFF1E88E5), // Bleu personnalisé
|
||||
const Color(0xFF43A047), // Vert personnalisé
|
||||
const Color(0xFFE53935), // Rouge personnalisé
|
||||
const Color(0xFFFFB300), // Ambre personnalisé
|
||||
const Color(0xFF8E24AA), // Violet personnalisé
|
||||
];
|
||||
// Grille 6x6 de couleurs suivant le spectre
|
||||
// 6 colonnes: Rouge, Orange, Jaune, Vert, Bleu, Violet
|
||||
// 6 lignes: variations de luminosité/saturation
|
||||
final List<Color> colors = _generateSpectralColorGrid();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Choisir une couleur'),
|
||||
contentPadding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||
content: Container(
|
||||
width: double.maxFinite,
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
itemCount: colors.length,
|
||||
itemBuilder: (context, index) {
|
||||
final color = colors[index];
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedColor = color;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
border: Border.all(
|
||||
color: _selectedColor == color ? Colors.black : Colors.grey,
|
||||
width: _selectedColor == color ? 3 : 1,
|
||||
width: 280, // Largeur fixe pour contrôler la taille
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade50,
|
||||
),
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 6,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
childAspectRatio: 1.0,
|
||||
),
|
||||
itemCount: colors.length,
|
||||
itemBuilder: (context, index) {
|
||||
final color = colors[index];
|
||||
final isSelected = _selectedColor.value == color.value;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedColor = color;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Container(
|
||||
width: 35,
|
||||
height: 35,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isSelected ? Colors.black87 : Colors.grey.shade400,
|
||||
width: isSelected ? 2.5 : 0.5,
|
||||
),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black,
|
||||
blurRadius: 2,
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Affichage de la couleur sélectionnée
|
||||
Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Couleur sélectionnée',
|
||||
style: TextStyle(
|
||||
color: _selectedColor.computeLuminance() > 0.5
|
||||
? Colors.black87
|
||||
: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 13,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
@@ -237,19 +282,107 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
// Mettre en surbrillance les termes recherchés dans le texte
|
||||
List<TextSpan> _highlightSearchTerms(String text) {
|
||||
if (_searchQuery.isEmpty) {
|
||||
return [TextSpan(text: text)];
|
||||
}
|
||||
|
||||
final List<TextSpan> spans = [];
|
||||
final lowerText = text.toLowerCase();
|
||||
int start = 0;
|
||||
int index = lowerText.indexOf(_searchQuery, start);
|
||||
|
||||
while (index != -1) {
|
||||
// Ajouter le texte avant le terme trouvé
|
||||
if (index > start) {
|
||||
spans.add(TextSpan(
|
||||
text: text.substring(start, index),
|
||||
));
|
||||
}
|
||||
|
||||
// Ajouter le terme trouvé en surbrillance
|
||||
spans.add(TextSpan(
|
||||
text: text.substring(index, index + _searchQuery.length),
|
||||
style: const TextStyle(
|
||||
backgroundColor: Colors.yellow,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
));
|
||||
|
||||
start = index + _searchQuery.length;
|
||||
index = lowerText.indexOf(_searchQuery, start);
|
||||
}
|
||||
|
||||
// Ajouter le reste du texte
|
||||
if (start < text.length) {
|
||||
spans.add(TextSpan(
|
||||
text: text.substring(start),
|
||||
));
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
// Générer une grille 6x6 de couleurs spectrales
|
||||
List<Color> _generateSpectralColorGrid() {
|
||||
final List<Color> colors = [];
|
||||
|
||||
// 6 teintes de base (colonnes)
|
||||
final List<double> hues = [
|
||||
0, // Rouge
|
||||
30, // Orange
|
||||
60, // Jaune
|
||||
120, // Vert
|
||||
210, // Bleu
|
||||
270, // Violet
|
||||
];
|
||||
|
||||
// 6 variations de luminosité/saturation (lignes)
|
||||
// Du plus clair au plus foncé
|
||||
final List<Map<String, double>> variations = [
|
||||
{'saturation': 0.3, 'lightness': 0.85}, // Très clair
|
||||
{'saturation': 0.5, 'lightness': 0.70}, // Clair
|
||||
{'saturation': 0.7, 'lightness': 0.55}, // Moyen clair
|
||||
{'saturation': 0.85, 'lightness': 0.45}, // Moyen foncé
|
||||
{'saturation': 0.95, 'lightness': 0.35}, // Foncé
|
||||
{'saturation': 1.0, 'lightness': 0.25}, // Très foncé
|
||||
];
|
||||
|
||||
// Générer la grille ligne par ligne
|
||||
for (final variation in variations) {
|
||||
for (final hue in hues) {
|
||||
colors.add(
|
||||
HSLColor.fromAHSL(
|
||||
1.0,
|
||||
hue,
|
||||
variation['saturation']!,
|
||||
variation['lightness']!,
|
||||
).toColor(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentAmicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
final dialogHeight = (screenHeight * 0.8).clamp(0.0, 800.0); // 80% de l'écran avec max 800px
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(widget.existingSector == null ? 'Nouveau secteur' : 'Modifier le secteur'),
|
||||
content: SingleChildScrollView(
|
||||
content: Container(
|
||||
width: 450, // Largeur fixe pour la dialog
|
||||
height: dialogHeight, // Hauteur avec maximum de 800px
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section scrollable pour nom et couleur
|
||||
// Nom du secteur
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
@@ -329,6 +462,37 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Champ de recherche pour filtrer les membres
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher par prénom, nom ou nom de tournée...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_searchController.clear();
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value.toLowerCase();
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
if (_selectedMemberIds.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
@@ -341,62 +505,111 @@ class _SectorDialogState extends State<SectorDialog> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des membres avec scrolling et filtre
|
||||
if (currentAmicale != null)
|
||||
ValueListenableBuilder<Box<MembreModel>>(
|
||||
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
|
||||
builder: (context, box, _) {
|
||||
debugPrint('=== Build liste membres - IDs présélectionnés: $_selectedMemberIds ===');
|
||||
|
||||
final membres = box.values
|
||||
.where((m) => m.fkEntite == currentAmicale.id)
|
||||
.toList();
|
||||
|
||||
if (membres.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Aucun membre disponible'),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: membres.length,
|
||||
itemBuilder: (context, index) {
|
||||
final membre = membres[index];
|
||||
final isSelected = _selectedMemberIds.contains(membre.id);
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<Box<MembreModel>>(
|
||||
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
|
||||
builder: (context, box, _) {
|
||||
debugPrint('=== Build liste membres - IDs présélectionnés: $_selectedMemberIds ===');
|
||||
|
||||
// Filtrer les membres de l'amicale
|
||||
var membres = box.values
|
||||
.where((m) => m.fkEntite == currentAmicale.id)
|
||||
.toList();
|
||||
|
||||
// Appliquer le filtre de recherche
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
membres = membres.where((membre) {
|
||||
final firstName = membre.firstName?.toLowerCase() ?? '';
|
||||
final lastName = membre.name?.toLowerCase() ?? '';
|
||||
final sectName = membre.sectName?.toLowerCase() ?? '';
|
||||
|
||||
// Log pour debug
|
||||
if (index < 3) { // Limiter les logs aux 3 premiers membres
|
||||
debugPrint('Membre ${index}: ${membre.firstName} ${membre.name} (ID: ${membre.id}) - isSelected: $isSelected');
|
||||
}
|
||||
|
||||
return CheckboxListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 0.0),
|
||||
title: Text(
|
||||
'${membre.firstName} ${membre.name}${membre.sectName != null && membre.sectName!.isNotEmpty ? ' (${membre.sectName})' : ''}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
return firstName.contains(_searchQuery) ||
|
||||
lastName.contains(_searchQuery) ||
|
||||
sectName.contains(_searchQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
if (membres.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Text(
|
||||
_searchQuery.isNotEmpty
|
||||
? 'Aucun membre trouvé pour "$_searchQuery"'
|
||||
: 'Aucun membre disponible',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
value: isSelected,
|
||||
onChanged: (bool? value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_selectedMemberIds.add(membre.id);
|
||||
} else {
|
||||
_selectedMemberIds.remove(membre.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Afficher le nombre de résultats
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Text(
|
||||
'${membres.length} membre${membres.length > 1 ? 's' : ''} ${_searchQuery.isNotEmpty ? 'trouvé${membres.length > 1 ? 's' : ''}' : 'disponible${membres.length > 1 ? 's' : ''}'}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListView.builder(
|
||||
itemCount: membres.length,
|
||||
itemBuilder: (context, index) {
|
||||
final membre = membres[index];
|
||||
final isSelected = _selectedMemberIds.contains(membre.id);
|
||||
|
||||
// Log pour debug
|
||||
if (index < 3) { // Limiter les logs aux 3 premiers membres
|
||||
debugPrint('Membre ${index}: ${membre.firstName} ${membre.name} (ID: ${membre.id}) - isSelected: $isSelected');
|
||||
}
|
||||
|
||||
return CheckboxListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 0.0),
|
||||
title: RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||
children: _highlightSearchTerms(
|
||||
'${membre.firstName} ${membre.name}${membre.sectName != null && membre.sectName!.isNotEmpty ? ' (${membre.sectName})' : ''}',
|
||||
),
|
||||
),
|
||||
),
|
||||
value: isSelected,
|
||||
onChanged: (bool? value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_selectedMemberIds.add(membre.id);
|
||||
} else {
|
||||
_selectedMemberIds.remove(membre.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user