Ajout de la géolocalisation automatique des casernes de pompiers et amélioration du formulaire EntiteForm
This commit is contained in:
@@ -53,6 +53,12 @@
|
|||||||
|
|
||||||
- **LoginPage** : Page de connexion avec détection du type d'utilisateur
|
- **LoginPage** : Page de connexion avec détection du type d'utilisateur
|
||||||
- **SplashPage** : Page de démarrage et initialisation
|
- **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
|
## Technologies et Frameworks
|
||||||
|
|
||||||
@@ -69,6 +75,12 @@
|
|||||||
- **Dio** : Client HTTP pour les requêtes API
|
- **Dio** : Client HTTP pour les requêtes API
|
||||||
- **GoRouter** : Navigation et routage dans Flutter
|
- **GoRouter** : Navigation et routage dans Flutter
|
||||||
- **Svelte 5** : Framework UI pour le site web
|
- **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
|
### Base de Données
|
||||||
|
|
||||||
@@ -100,7 +112,9 @@
|
|||||||
|
|
||||||
### Pratiques Spécifiques au Projet
|
### 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
|
## Flux de Travail et Processus de Développement
|
||||||
|
|
||||||
@@ -182,43 +196,6 @@
|
|||||||
## Historique des Versions
|
## Historique des Versions
|
||||||
|
|
||||||
| Version | Date | Description |
|
| Version | Date | Description |
|
||||||
| ------- | ------ | ------------- |
|
| ------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 1.0.0 | [Date] | [Description] |
|
| 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 |
|
||||||
## 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,8 +1,13 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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/data/models/amicale_model.dart';
|
||||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||||
import 'package:geosector_app/core/repositories/region_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 'package:provider/provider.dart';
|
||||||
import 'custom_text_field.dart';
|
import 'custom_text_field.dart';
|
||||||
|
|
||||||
@@ -124,8 +129,113 @@ class _EntiteFormState extends State<EntiteForm> {
|
|||||||
super.dispose();
|
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() {
|
void _submitForm() {
|
||||||
if (_formKey.currentState!.validate()) {
|
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(
|
final amicale = widget.amicale?.copyWith(
|
||||||
name: _nameController.text,
|
name: _nameController.text,
|
||||||
adresse1: _adresse1Controller.text,
|
adresse1: _adresse1Controller.text,
|
||||||
@@ -167,34 +277,156 @@ class _EntiteFormState extends State<EntiteForm> {
|
|||||||
chkActive: _chkActive,
|
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) {
|
if (widget.onSubmit != null) {
|
||||||
widget.onSubmit!(amicale);
|
widget.onSubmit!(amicale);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
// Construire la section logo
|
||||||
Widget build(BuildContext context) {
|
Widget _buildLogoSection() {
|
||||||
final theme = Theme.of(context);
|
return Container(
|
||||||
final userRepository = Provider.of<UserRepository>(context, listen: false);
|
width: 150,
|
||||||
final userRole = userRepository.getUserRole();
|
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
|
// Overlay pour indiquer que l'image est modifiable (si non en lecture seule)
|
||||||
final bool canEditRestrictedFields = userRole > 2;
|
if (!widget.readOnly)
|
||||||
|
Positioned.fill(
|
||||||
// Lecture seule pour les champs restreints si l'utilisateur n'a pas les droits
|
child: Material(
|
||||||
final bool restrictedFieldsReadOnly =
|
color: Colors.transparent,
|
||||||
widget.readOnly || !canEditRestrictedFields;
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
// Calculer la largeur maximale du formulaire pour les écrans larges
|
// TODO: Implémenter la sélection d'image
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
final formMaxWidth = screenWidth > 800 ? 600.0 : screenWidth;
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
return Form(
|
'Fonctionnalité de modification du logo à venir'),
|
||||||
key: _formKey,
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: BoxConstraints(maxWidth: formMaxWidth),
|
decoration: BoxDecoration(
|
||||||
child: Column(
|
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,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Nom
|
// Nom
|
||||||
@@ -202,6 +434,7 @@ class _EntiteFormState extends State<EntiteForm> {
|
|||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
label: "Nom",
|
label: "Nom",
|
||||||
readOnly: widget.readOnly,
|
readOnly: widget.readOnly,
|
||||||
|
isRequired: true,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return "Veuillez entrer un nom";
|
return "Veuillez entrer un nom";
|
||||||
@@ -226,6 +459,13 @@ class _EntiteFormState extends State<EntiteForm> {
|
|||||||
controller: _adresse1Controller,
|
controller: _adresse1Controller,
|
||||||
label: "Adresse ligne 1",
|
label: "Adresse ligne 1",
|
||||||
readOnly: widget.readOnly,
|
readOnly: widget.readOnly,
|
||||||
|
isRequired: true,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return "Veuillez entrer une adresse";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
@@ -253,10 +493,12 @@ class _EntiteFormState extends State<EntiteForm> {
|
|||||||
LengthLimitingTextInputFormatter(5),
|
LengthLimitingTextInputFormatter(5),
|
||||||
],
|
],
|
||||||
readOnly: widget.readOnly,
|
readOnly: widget.readOnly,
|
||||||
|
isRequired: true,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value != null &&
|
if (value == null || value.isEmpty) {
|
||||||
value.isNotEmpty &&
|
return "Veuillez entrer un code postal";
|
||||||
value.length < 5) {
|
}
|
||||||
|
if (value.length < 5) {
|
||||||
return "Le code postal doit contenir 5 chiffres";
|
return "Le code postal doit contenir 5 chiffres";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -271,6 +513,13 @@ class _EntiteFormState extends State<EntiteForm> {
|
|||||||
controller: _villeController,
|
controller: _villeController,
|
||||||
label: "Ville",
|
label: "Ville",
|
||||||
readOnly: widget.readOnly,
|
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),
|
LengthLimitingTextInputFormatter(10),
|
||||||
],
|
],
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value != null &&
|
if (value != null && value.isNotEmpty && value.length < 10) {
|
||||||
value.isNotEmpty &&
|
|
||||||
value.length < 10) {
|
|
||||||
return "Le numéro de téléphone doit contenir 10 chiffres";
|
return "Le numéro de téléphone doit contenir 10 chiffres";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -342,9 +589,7 @@ class _EntiteFormState extends State<EntiteForm> {
|
|||||||
LengthLimitingTextInputFormatter(10),
|
LengthLimitingTextInputFormatter(10),
|
||||||
],
|
],
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value != null &&
|
if (value != null && value.isNotEmpty && value.length < 10) {
|
||||||
value.isNotEmpty &&
|
|
||||||
value.length < 10) {
|
|
||||||
return "Le numéro de mobile doit contenir 10 chiffres";
|
return "Le numéro de mobile doit contenir 10 chiffres";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -361,6 +606,7 @@ class _EntiteFormState extends State<EntiteForm> {
|
|||||||
label: "Email",
|
label: "Email",
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
readOnly: widget.readOnly,
|
readOnly: widget.readOnly,
|
||||||
|
isRequired: true,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return "Veuillez entrer l'adresse email";
|
return "Veuillez entrer l'adresse email";
|
||||||
@@ -374,10 +620,7 @@ class _EntiteFormState extends State<EntiteForm> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Informations avancées (visibles uniquement pour les administrateurs)
|
// Informations avancées (visibles uniquement pour les administrateurs)
|
||||||
if (canEditRestrictedFields ||
|
if (_shouldShowAdvancedInfo()) ...[
|
||||||
(_gpsLatController.text.isNotEmpty ||
|
|
||||||
_gpsLngController.text.isNotEmpty ||
|
|
||||||
_stripeIdController.text.isNotEmpty)) ...[
|
|
||||||
Text(
|
Text(
|
||||||
"Informations avancées",
|
"Informations avancées",
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
@@ -426,14 +669,53 @@ class _EntiteFormState extends State<EntiteForm> {
|
|||||||
onChanged: restrictedFieldsReadOnly
|
onChanged: restrictedFieldsReadOnly
|
||||||
? null
|
? null
|
||||||
: (value) {
|
: (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(() {
|
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),
|
activeColor: const Color(0xFF20335E),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
"Stripe activé",
|
"Accepte les règlements en CB",
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: theme.colorScheme.onBackground,
|
color: theme.colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -444,8 +726,10 @@ class _EntiteFormState extends State<EntiteForm> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: CustomTextField(
|
child: CustomTextField(
|
||||||
controller: _stripeIdController,
|
controller: _stripeIdController,
|
||||||
label: "Stripe ID",
|
label: "ID Stripe Paiements CB",
|
||||||
readOnly: restrictedFieldsReadOnly,
|
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),
|
const SizedBox(height: 25),
|
||||||
|
|
||||||
// Bouton Enregistrer
|
// Boutons Fermer et Enregistrer
|
||||||
if (!widget.readOnly)
|
if (!widget.readOnly)
|
||||||
Center(
|
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,
|
onPressed: _submitForm,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF20335E),
|
backgroundColor: const Color(0xFF20335E),
|
||||||
@@ -532,7 +844,7 @@ class _EntiteFormState extends State<EntiteForm> {
|
|||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: BorderRadius.circular(50),
|
||||||
),
|
),
|
||||||
minimumSize: const Size(200, 50),
|
minimumSize: const Size(150, 50),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Enregistrer',
|
'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 theme = Theme.of(context);
|
||||||
|
final userRepository = Provider.of<UserRepository>(context, listen: false);
|
||||||
|
final userRole = userRepository.getUserRole();
|
||||||
|
|
||||||
// Si en lecture seule, afficher simplement le texte
|
// Déterminer si l'utilisateur peut modifier les champs restreints
|
||||||
if (readOnly && _libRegion != null) {
|
final bool canEditRestrictedFields = userRole > 2;
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
||||||
child: Text(
|
|
||||||
_libRegion!,
|
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onBackground,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
// Lecture seule pour les champs restreints si l'utilisateur n'a pas les droits
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
|
final bool restrictedFieldsReadOnly =
|
||||||
decoration: BoxDecoration(
|
widget.readOnly || !canEditRestrictedFields;
|
||||||
color: const Color(0xFFF4F5F6).withOpacity(0.85),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
// Calculer la largeur maximale du formulaire pour les écrans larges
|
||||||
border: Border.all(
|
final screenWidth = MediaQuery.of(context
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user