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:
2025-08-19 19:38:03 +02:00
parent c1f23c4345
commit 5ab03751e1
1823 changed files with 272663 additions and 198438 deletions

View File

@@ -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);
}
});
},
);
},
),
),
),
],
);
},
),
),
],
),