Ajout de la géolocalisation automatique des casernes de pompiers et amélioration du formulaire EntiteForm

This commit is contained in:
d6soft
2025-05-16 20:55:57 +02:00
parent 5c2620de30
commit 69dcff42f8
2 changed files with 720 additions and 497 deletions

View File

@@ -53,6 +53,12 @@
- **LoginPage** : Page de connexion avec détection du type d'utilisateur
- **SplashPage** : Page de démarrage et initialisation
- **EntiteForm** : Formulaire de gestion des amicales avec géolocalisation automatique
#### Géolocalisation et Cartographie
- **MapboxMap** : Widget de carte pour afficher la position des amicales
- **findFireStationCoordinates** : Service API pour localiser les casernes de pompiers via adresse.gouv.fr
## Technologies et Frameworks
@@ -69,6 +75,12 @@
- **Dio** : Client HTTP pour les requêtes API
- **GoRouter** : Navigation et routage dans Flutter
- **Svelte 5** : Framework UI pour le site web
- **Flutter Map** : Bibliothèque de cartographie pour Flutter
### APIs Externes
- **API Adresse.gouv.fr** : Utilisée pour la géolocalisation des adresses et des casernes de pompiers
- **Stripe API** : Intégration pour les paiements par carte bancaire (commission de 1.4%)
### Base de Données
@@ -100,7 +112,9 @@
### Pratiques Spécifiques au Projet
[Toute convention ou pratique spécifique à ce projet]
- Chiffrement des données sensibles (noms, emails, téléphones) dans la base de données
- Mise à jour automatique des coordonnées GPS lors des changements d'adresse
- Confirmation utilisateur pour les actions ayant un impact financier (activation Stripe)
## Flux de Travail et Processus de Développement
@@ -182,43 +196,6 @@
## Historique des Versions
| Version | Date | Description |
| ------- | ------ | ------------- |
| 1.0.0 | [Date] | [Description] |
## Processus d'Authentification et Gestion des Sessions
### Flux de Connexion
1. L'utilisateur entre ses identifiants dans la page de login (username/password)
2. L'application envoie une requête POST à `/api/login` avec les identifiants et le type de connexion (user/admin)
3. Le serveur vérifie les identifiants, crée une session PHP et renvoie:
- Un `session_id` (utilisé comme token Bearer)
- Une date d'expiration de session
- Les données de l'utilisateur et les données associées (opérations, secteurs, passages)
4. L'application stocke ces données dans des boîtes Hive locales
5. Le `session_id` est utilisé pour toutes les requêtes API suivantes
### Flux de Déconnexion
1. L'utilisateur demande une déconnexion
2. L'application envoie une requête POST à `/api/logout` avec le `session_id` dans l'en-tête
3. Le serveur détruit la session PHP avec `session_unset()` et `session_destroy()`
4. L'application:
- Vide toutes les boîtes Hive sauf la boîte utilisateur
- Conserve uniquement le username et le rôle de l'utilisateur pour faciliter la reconnexion
- Réinitialise le `session_id` à null
### Particularités
- La page de login vérifie le rôle de l'utilisateur avant de pré-remplir le champ username
- Le type de connexion (user/admin) détermine les données chargées et les droits d'accès
- Les utilisateurs avec rôle=1 sont des utilisateurs standards, ceux avec rôle>1 sont des administrateurs
- Les sessions expirent après 24 heures par défaut
## Notes Spécifiques pour les Assistants IA
- Toujours vérifier les issues GitLab avant de proposer des solutions
- Respecter strictement les conventions de code mentionnées ci-dessus
- Lors de modifications des modèles Hive, s'assurer que les typeId sont uniques pour éviter les conflits
- Vérifier la compatibilité des modifications avec les trois plateformes (web, iOS, Android)
- Pour les modifications de l'API, s'assurer que la réponse reste compatible avec le format attendu par l'application
| ------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1.0.0 | 01/03/2025 | Version initiale du projet |
| 1.1.0 | 16/05/2025 | Ajout de la géolocalisation automatique des casernes de pompiers via API adresse.gouv.fr, amélioration du formulaire EntiteForm avec confirmation pour l'activation des paiements Stripe, mise à jour automatique des coordonnées GPS lors des changements d'adresse |

View File

@@ -1,8 +1,13 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/repositories/region_repository.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/presentation/widgets/mapbox_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import 'custom_text_field.dart';
@@ -124,8 +129,113 @@ class _EntiteFormState extends State<EntiteForm> {
super.dispose();
}
// Appeler l'API pour mettre à jour l'entité
Future<void> _updateEntite(AmicaleModel amicale) async {
try {
// Afficher un indicateur de chargement
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return const Center(
child: CircularProgressIndicator(),
);
},
);
// Préparer les données pour l'API
final Map<String, dynamic> data = {
'id': amicale.id,
'name': amicale.name,
'adresse1': amicale.adresse1,
'adresse2': amicale.adresse2,
'code_postal': amicale.codePostal,
'ville': amicale.ville,
'phone': amicale.phone,
'mobile': amicale.mobile,
'email': amicale.email,
'chk_copie_mail_recu': amicale.chkCopieMailRecu,
'chk_accept_sms': amicale.chkAcceptSms,
'chk_stripe': amicale.chkStripe,
};
// Ajouter les champs réservés aux administrateurs si l'utilisateur est admin
final userRepository =
Provider.of<UserRepository>(context, listen: false);
final userRole = userRepository.getUserRole();
if (userRole > 2) {
data['gps_lat'] = amicale.gpsLat;
data['gps_lng'] = amicale.gpsLng;
data['stripe_id'] = amicale.stripeId;
data['chk_demo'] = amicale.chkDemo;
data['chk_active'] = amicale.chkActive;
}
// Appeler l'API
try {
// Obtenir l'instance du service API
final apiService = Provider.of<ApiService>(context, listen: false);
// Appeler la méthode post du service API
await apiService.post('/entite/update', data: data);
// Fermer l'indicateur de chargement
Navigator.of(context).pop();
// Afficher un message de succès
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Entité mise à jour avec succès'),
backgroundColor: Colors.green,
),
);
// Appeler la fonction onSubmit si elle existe
if (widget.onSubmit != null) {
widget.onSubmit!(amicale);
}
// Fermer le formulaire
Navigator.of(context).pop();
} catch (error) {
// Fermer l'indicateur de chargement
Navigator.of(context).pop();
// Afficher un message d'erreur
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la mise à jour de l\'entité: $error'),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
// Fermer l'indicateur de chargement
Navigator.of(context).pop();
// Afficher un message d'erreur
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
// Vérifier qu'au moins un numéro de téléphone est renseigné
if (_phoneController.text.isEmpty && _mobileController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Veuillez renseigner au moins un numéro de téléphone'),
backgroundColor: Colors.red,
),
);
return;
}
final amicale = widget.amicale?.copyWith(
name: _nameController.text,
adresse1: _adresse1Controller.text,
@@ -167,34 +277,156 @@ class _EntiteFormState extends State<EntiteForm> {
chkActive: _chkActive,
);
// Appeler l'API pour mettre à jour l'entité
_updateEntite(amicale);
// Appeler la fonction onSubmit si elle existe (pour la compatibilité avec le code existant)
if (widget.onSubmit != null) {
widget.onSubmit!(amicale);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final userRepository = Provider.of<UserRepository>(context, listen: false);
final userRole = userRepository.getUserRole();
// Construire la section logo
Widget _buildLogoSection() {
return Container(
width: 150,
height: 150,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
// Image par défaut
Center(
child: Image.asset(
'assets/images/logo_recu.png',
width: 150,
height: 150,
fit: BoxFit.contain,
),
),
// Déterminer si l'utilisateur peut modifier les champs restreints
final bool canEditRestrictedFields = userRole > 2;
// Lecture seule pour les champs restreints si l'utilisateur n'a pas les droits
final bool restrictedFieldsReadOnly =
widget.readOnly || !canEditRestrictedFields;
// Calculer la largeur maximale du formulaire pour les écrans larges
final screenWidth = MediaQuery.of(context).size.width;
final formMaxWidth = screenWidth > 800 ? 600.0 : screenWidth;
return Form(
key: _formKey,
// Overlay pour indiquer que l'image est modifiable (si non en lecture seule)
if (!widget.readOnly)
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
// TODO: Implémenter la sélection d'image
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Fonctionnalité de modification du logo à venir'),
),
);
},
child: Container(
constraints: BoxConstraints(maxWidth: formMaxWidth),
child: Column(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.1),
),
child: const Center(
child: Icon(
Icons.camera_alt,
color: Colors.white,
size: 40,
),
),
),
),
),
),
],
),
),
);
}
// Construire la minimap
Widget _buildMiniMap() {
// Vérifier si les coordonnées GPS sont valides
double? lat = double.tryParse(_gpsLatController.text);
double? lng = double.tryParse(_gpsLngController.text);
// Si les coordonnées ne sont pas valides, afficher un message
if (lat == null || lng == null) {
return Container(
width: 150,
height: 150,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Text(
'Aucune coordonnée GPS',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
),
);
}
// Créer la position pour la carte
final position = LatLng(lat, lng);
// Créer un marqueur pour la position de l'amicale
final markers = [
Marker(
point: position,
width: 20,
height: 20,
child: const Icon(
Icons.location_on,
color: Color(0xFF20335E),
size: 20,
),
),
];
// Retourner la minimap
return Container(
width: 150,
height: 150,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: MapboxMap(
initialPosition: position,
initialZoom: 15.0,
markers: markers,
showControls: false,
),
),
);
}
// Construire le formulaire principal
Widget _buildMainForm(ThemeData theme, bool restrictedFieldsReadOnly) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom
@@ -202,6 +434,7 @@ class _EntiteFormState extends State<EntiteForm> {
controller: _nameController,
label: "Nom",
readOnly: widget.readOnly,
isRequired: true,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer un nom";
@@ -226,6 +459,13 @@ class _EntiteFormState extends State<EntiteForm> {
controller: _adresse1Controller,
label: "Adresse ligne 1",
readOnly: widget.readOnly,
isRequired: true,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer une adresse";
}
return null;
},
),
const SizedBox(height: 16),
@@ -253,10 +493,12 @@ class _EntiteFormState extends State<EntiteForm> {
LengthLimitingTextInputFormatter(5),
],
readOnly: widget.readOnly,
isRequired: true,
validator: (value) {
if (value != null &&
value.isNotEmpty &&
value.length < 5) {
if (value == null || value.isEmpty) {
return "Veuillez entrer un code postal";
}
if (value.length < 5) {
return "Le code postal doit contenir 5 chiffres";
}
return null;
@@ -271,6 +513,13 @@ class _EntiteFormState extends State<EntiteForm> {
controller: _villeController,
label: "Ville",
readOnly: widget.readOnly,
isRequired: true,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer une ville";
}
return null;
},
),
),
],
@@ -320,9 +569,7 @@ class _EntiteFormState extends State<EntiteForm> {
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null &&
value.isNotEmpty &&
value.length < 10) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro de téléphone doit contenir 10 chiffres";
}
return null;
@@ -342,9 +589,7 @@ class _EntiteFormState extends State<EntiteForm> {
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null &&
value.isNotEmpty &&
value.length < 10) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro de mobile doit contenir 10 chiffres";
}
return null;
@@ -361,6 +606,7 @@ class _EntiteFormState extends State<EntiteForm> {
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
isRequired: true,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer l'adresse email";
@@ -374,10 +620,7 @@ class _EntiteFormState extends State<EntiteForm> {
const SizedBox(height: 16),
// Informations avancées (visibles uniquement pour les administrateurs)
if (canEditRestrictedFields ||
(_gpsLatController.text.isNotEmpty ||
_gpsLngController.text.isNotEmpty ||
_stripeIdController.text.isNotEmpty)) ...[
if (_shouldShowAdvancedInfo()) ...[
Text(
"Informations avancées",
style: theme.textTheme.titleMedium?.copyWith(
@@ -426,14 +669,53 @@ class _EntiteFormState extends State<EntiteForm> {
onChanged: restrictedFieldsReadOnly
? null
: (value) {
if (value == true) {
// Afficher une boîte de dialogue de confirmation
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmation'),
content: const Text(
'En acceptant les règlements par carte bancaire, des commissions de 1.4% seront prélevées sur les montants encaissés. Souhaitez-vous continuer ?'),
actions: [
TextButton(
onPressed: () {
// L'utilisateur a répondu "non"
setState(() {
_chkStripe = value!;
_chkStripe = false;
});
Navigator.of(context).pop();
},
child: const Text('Non'),
),
ElevatedButton(
onPressed: () {
// L'utilisateur a répondu "oui"
setState(() {
_chkStripe = true;
});
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF20335E),
foregroundColor: Colors.white,
),
child: const Text('Oui'),
),
],
),
);
} else {
// Si l'utilisateur décoche la case, pas besoin de confirmation
setState(() {
_chkStripe = false;
});
}
},
activeColor: const Color(0xFF20335E),
),
Text(
"Stripe activé",
"Accepte les règlements en CB",
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onBackground,
fontWeight: FontWeight.w500,
@@ -444,8 +726,10 @@ class _EntiteFormState extends State<EntiteForm> {
Expanded(
child: CustomTextField(
controller: _stripeIdController,
label: "Stripe ID",
label: "ID Stripe Paiements CB",
readOnly: restrictedFieldsReadOnly,
helperText:
"Les règlements par CB sont taxés d'une commission de 1.4%",
),
),
],
@@ -519,10 +803,38 @@ class _EntiteFormState extends State<EntiteForm> {
),
const SizedBox(height: 25),
// Bouton Enregistrer
// Boutons Fermer et Enregistrer
if (!widget.readOnly)
Center(
child: ElevatedButton(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Bouton Fermer
OutlinedButton(
onPressed: () {
Navigator.of(context).pop();
},
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF20335E),
side: const BorderSide(color: Color(0xFF20335E)),
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
minimumSize: const Size(150, 50),
),
child: const Text(
'Fermer',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 20),
// Bouton Enregistrer
ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF20335E),
@@ -532,7 +844,7 @@ class _EntiteFormState extends State<EntiteForm> {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
minimumSize: const Size(200, 50),
minimumSize: const Size(150, 50),
),
child: const Text(
'Enregistrer',
@@ -542,103 +854,37 @@ class _EntiteFormState extends State<EntiteForm> {
),
),
),
),
],
),
),
);
}
Widget _buildCheckboxOption({
required String label,
required bool value,
required Function(bool?)? onChanged,
}) {
final theme = Theme.of(context);
return Row(
children: [
Checkbox(
value: value,
onChanged: onChanged,
activeColor: const Color(0xFF20335E),
),
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
],
);
}
Widget _buildRegionDropdown(bool readOnly) {
// Vérifier si les informations avancées doivent être affichées
bool _shouldShowAdvancedInfo() {
final userRepository = Provider.of<UserRepository>(context, listen: false);
final userRole = userRepository.getUserRole();
final bool canEditRestrictedFields = userRole > 2;
return canEditRestrictedFields ||
_gpsLatController.text.isNotEmpty ||
_gpsLngController.text.isNotEmpty ||
_stripeIdController.text.isNotEmpty;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final userRepository = Provider.of<UserRepository>(context, listen: false);
final userRole = userRepository.getUserRole();
// Si en lecture seule, afficher simplement le texte
if (readOnly && _libRegion != null) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
_libRegion!,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground,
),
),
);
}
// Déterminer si l'utilisateur peut modifier les champs restreints
final bool canEditRestrictedFields = userRole > 2;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFFF4F5F6).withOpacity(0.85),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFF20335E).withOpacity(0.1),
width: 1,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _fkRegion,
isExpanded: true,
hint: const Text("Sélectionnez une région"),
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF20335E),
),
style: theme.textTheme.bodyMedium?.copyWith(
color: const Color(0xFF20335E),
),
dropdownColor: Colors.white,
items: _regions
.map<DropdownMenuItem<int>>((Map<String, dynamic> region) {
return DropdownMenuItem<int>(
value: region['id'] as int,
child: Text(region['name'] as String),
);
}).toList(),
onChanged: readOnly
? null
: (int? newValue) {
setState(() {
_fkRegion = newValue;
// Trouver le libellé correspondant
if (newValue != null) {
final selectedRegion = _regions.firstWhere(
(region) => region['id'] == newValue,
orElse: () => {'id': newValue, 'name': ''},
);
_libRegion = selectedRegion['name'] as String;
} else {
_libRegion = null;
}
});
},
),
),
);
}
}
// Lecture seule pour les champs restreints si l'utilisateur n'a pas les droits
final bool restrictedFieldsReadOnly =
widget.readOnly || !canEditRestrictedFields;
// Calculer la largeur maximale du formulaire pour les écrans larges
final screenWidth = MediaQuery.of(context