diff --git a/CONTEXT-AI.md b/CONTEXT-AI.md index 25ccd3a0..d4420cc3 100644 --- a/CONTEXT-AI.md +++ b/CONTEXT-AI.md @@ -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 @@ -181,44 +195,7 @@ ## 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 +| Version | 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 | diff --git a/app/lib/presentation/widgets/entite_form.dart b/app/lib/presentation/widgets/entite_form.dart index c0c903bb..0d7e19ca 100644 --- a/app/lib/presentation/widgets/entite_form.dart +++ b/app/lib/presentation/widgets/entite_form.dart @@ -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 { super.dispose(); } + // Appeler l'API pour mettre à jour l'entité + Future _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 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(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(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,12 +277,602 @@ class _EntiteFormState extends State { 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); } } } + // 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, + ), + ), + + // 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( + 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 + CustomTextField( + controller: _nameController, + label: "Nom", + readOnly: widget.readOnly, + isRequired: true, + validator: (value) { + if (value == null || value.isEmpty) { + return "Veuillez entrer un nom"; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Bloc Adresse + Text( + "Adresse", + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onBackground, + ), + ), + const SizedBox(height: 8), + + // Adresse 1 + CustomTextField( + 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), + + // Adresse 2 + CustomTextField( + controller: _adresse2Controller, + label: "Adresse ligne 2", + readOnly: widget.readOnly, + ), + const SizedBox(height: 16), + + // Code Postal et Ville + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Code Postal + Expanded( + flex: 1, + child: CustomTextField( + controller: _codePostalController, + label: "Code Postal", + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(5), + ], + readOnly: widget.readOnly, + isRequired: true, + validator: (value) { + 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; + }, + ), + ), + const SizedBox(width: 16), + // Ville + Expanded( + flex: 2, + child: CustomTextField( + controller: _villeController, + label: "Ville", + readOnly: widget.readOnly, + isRequired: true, + validator: (value) { + if (value == null || value.isEmpty) { + return "Veuillez entrer une ville"; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Région + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Région", + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + color: theme.colorScheme.onBackground, + ), + ), + const SizedBox(height: 8), + _buildRegionDropdown(restrictedFieldsReadOnly), + ], + ), + const SizedBox(height: 16), + + // Contact + Text( + "Contact", + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onBackground, + ), + ), + const SizedBox(height: 8), + + // Téléphone fixe et mobile sur la même ligne + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Téléphone fixe + Expanded( + child: CustomTextField( + controller: _phoneController, + label: "Téléphone fixe", + keyboardType: TextInputType.phone, + readOnly: widget.readOnly, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + ], + validator: (value) { + if (value != null && value.isNotEmpty && value.length < 10) { + return "Le numéro de téléphone doit contenir 10 chiffres"; + } + return null; + }, + ), + ), + const SizedBox(width: 16), + // Téléphone mobile + Expanded( + child: CustomTextField( + controller: _mobileController, + label: "Téléphone mobile", + keyboardType: TextInputType.phone, + readOnly: widget.readOnly, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + ], + validator: (value) { + if (value != null && value.isNotEmpty && value.length < 10) { + return "Le numéro de mobile doit contenir 10 chiffres"; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Email + CustomTextField( + controller: _emailController, + label: "Email", + keyboardType: TextInputType.emailAddress, + readOnly: widget.readOnly, + isRequired: true, + validator: (value) { + if (value == null || value.isEmpty) { + return "Veuillez entrer l'adresse email"; + } + if (!value.contains('@') || !value.contains('.')) { + return "Veuillez entrer une adresse email valide"; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Informations avancées (visibles uniquement pour les administrateurs) + if (_shouldShowAdvancedInfo()) ...[ + Text( + "Informations avancées", + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onBackground, + ), + ), + const SizedBox(height: 8), + + // GPS Latitude et Longitude + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // GPS Latitude + Expanded( + child: CustomTextField( + controller: _gpsLatController, + label: "GPS Latitude", + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + readOnly: restrictedFieldsReadOnly, + ), + ), + const SizedBox(width: 16), + // GPS Longitude + Expanded( + child: CustomTextField( + controller: _gpsLngController, + label: "GPS Longitude", + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + readOnly: restrictedFieldsReadOnly, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Stripe Checkbox et Stripe ID sur la même ligne + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Checkbox Stripe + Checkbox( + value: _chkStripe, + 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 = 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( + "Accepte les règlements en CB", + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onBackground, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 16), + // Stripe ID + Expanded( + child: CustomTextField( + controller: _stripeIdController, + label: "ID Stripe Paiements CB", + readOnly: restrictedFieldsReadOnly, + helperText: + "Les règlements par CB sont taxés d'une commission de 1.4%", + ), + ), + ], + ), + const SizedBox(height: 16), + ], + + // Options + Text( + "Options", + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onBackground, + ), + ), + const SizedBox(height: 8), + + // Checkbox Demo + _buildCheckboxOption( + label: "Mode démo", + value: _chkDemo, + onChanged: restrictedFieldsReadOnly + ? null + : (value) { + setState(() { + _chkDemo = value!; + }); + }, + ), + const SizedBox(height: 8), + + // Checkbox Copie Mail Reçu + _buildCheckboxOption( + label: "Copie des mails reçus", + value: _chkCopieMailRecu, + onChanged: widget.readOnly + ? null + : (value) { + setState(() { + _chkCopieMailRecu = value!; + }); + }, + ), + const SizedBox(height: 8), + + // Checkbox Accept SMS + _buildCheckboxOption( + label: "Accepte les SMS", + value: _chkAcceptSms, + onChanged: widget.readOnly + ? null + : (value) { + setState(() { + _chkAcceptSms = value!; + }); + }, + ), + const SizedBox(height: 8), + + // Checkbox Active + _buildCheckboxOption( + label: "Actif", + value: _chkActive, + onChanged: restrictedFieldsReadOnly + ? null + : (value) { + setState(() { + _chkActive = value!; + }); + }, + ), + const SizedBox(height: 25), + + // Boutons Fermer et Enregistrer + if (!widget.readOnly) + Center( + 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), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + minimumSize: const Size(150, 50), + ), + child: const Text( + 'Enregistrer', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ); + } + + // Vérifier si les informations avancées doivent être affichées + bool _shouldShowAdvancedInfo() { + final userRepository = Provider.of(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); @@ -187,458 +887,4 @@ class _EntiteFormState extends State { 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, - child: Container( - constraints: BoxConstraints(maxWidth: formMaxWidth), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Nom - CustomTextField( - controller: _nameController, - label: "Nom", - readOnly: widget.readOnly, - validator: (value) { - if (value == null || value.isEmpty) { - return "Veuillez entrer un nom"; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Bloc Adresse - Text( - "Adresse", - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.onBackground, - ), - ), - const SizedBox(height: 8), - - // Adresse 1 - CustomTextField( - controller: _adresse1Controller, - label: "Adresse ligne 1", - readOnly: widget.readOnly, - ), - const SizedBox(height: 16), - - // Adresse 2 - CustomTextField( - controller: _adresse2Controller, - label: "Adresse ligne 2", - readOnly: widget.readOnly, - ), - const SizedBox(height: 16), - - // Code Postal et Ville - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Code Postal - Expanded( - flex: 1, - child: CustomTextField( - controller: _codePostalController, - label: "Code Postal", - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(5), - ], - readOnly: widget.readOnly, - validator: (value) { - if (value != null && - value.isNotEmpty && - value.length < 5) { - return "Le code postal doit contenir 5 chiffres"; - } - return null; - }, - ), - ), - const SizedBox(width: 16), - // Ville - Expanded( - flex: 2, - child: CustomTextField( - controller: _villeController, - label: "Ville", - readOnly: widget.readOnly, - ), - ), - ], - ), - const SizedBox(height: 16), - - // Région - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Région", - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w500, - color: theme.colorScheme.onBackground, - ), - ), - const SizedBox(height: 8), - _buildRegionDropdown(restrictedFieldsReadOnly), - ], - ), - const SizedBox(height: 16), - - // Contact - Text( - "Contact", - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.onBackground, - ), - ), - const SizedBox(height: 8), - - // Téléphone fixe et mobile sur la même ligne - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Téléphone fixe - Expanded( - child: CustomTextField( - controller: _phoneController, - label: "Téléphone fixe", - keyboardType: TextInputType.phone, - readOnly: widget.readOnly, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), - ], - validator: (value) { - if (value != null && - value.isNotEmpty && - value.length < 10) { - return "Le numéro de téléphone doit contenir 10 chiffres"; - } - return null; - }, - ), - ), - const SizedBox(width: 16), - // Téléphone mobile - Expanded( - child: CustomTextField( - controller: _mobileController, - label: "Téléphone mobile", - keyboardType: TextInputType.phone, - readOnly: widget.readOnly, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), - ], - validator: (value) { - if (value != null && - value.isNotEmpty && - value.length < 10) { - return "Le numéro de mobile doit contenir 10 chiffres"; - } - return null; - }, - ), - ), - ], - ), - const SizedBox(height: 16), - - // Email - CustomTextField( - controller: _emailController, - label: "Email", - keyboardType: TextInputType.emailAddress, - readOnly: widget.readOnly, - validator: (value) { - if (value == null || value.isEmpty) { - return "Veuillez entrer l'adresse email"; - } - if (!value.contains('@') || !value.contains('.')) { - return "Veuillez entrer une adresse email valide"; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Informations avancées (visibles uniquement pour les administrateurs) - if (canEditRestrictedFields || - (_gpsLatController.text.isNotEmpty || - _gpsLngController.text.isNotEmpty || - _stripeIdController.text.isNotEmpty)) ...[ - Text( - "Informations avancées", - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.onBackground, - ), - ), - const SizedBox(height: 8), - - // GPS Latitude et Longitude - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // GPS Latitude - Expanded( - child: CustomTextField( - controller: _gpsLatController, - label: "GPS Latitude", - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - readOnly: restrictedFieldsReadOnly, - ), - ), - const SizedBox(width: 16), - // GPS Longitude - Expanded( - child: CustomTextField( - controller: _gpsLngController, - label: "GPS Longitude", - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - readOnly: restrictedFieldsReadOnly, - ), - ), - ], - ), - const SizedBox(height: 16), - - // Stripe Checkbox et Stripe ID sur la même ligne - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Checkbox Stripe - Checkbox( - value: _chkStripe, - onChanged: restrictedFieldsReadOnly - ? null - : (value) { - setState(() { - _chkStripe = value!; - }); - }, - activeColor: const Color(0xFF20335E), - ), - Text( - "Stripe activé", - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onBackground, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 16), - // Stripe ID - Expanded( - child: CustomTextField( - controller: _stripeIdController, - label: "Stripe ID", - readOnly: restrictedFieldsReadOnly, - ), - ), - ], - ), - const SizedBox(height: 16), - ], - - // Options - Text( - "Options", - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.onBackground, - ), - ), - const SizedBox(height: 8), - - // Checkbox Demo - _buildCheckboxOption( - label: "Mode démo", - value: _chkDemo, - onChanged: restrictedFieldsReadOnly - ? null - : (value) { - setState(() { - _chkDemo = value!; - }); - }, - ), - const SizedBox(height: 8), - - // Checkbox Copie Mail Reçu - _buildCheckboxOption( - label: "Copie des mails reçus", - value: _chkCopieMailRecu, - onChanged: widget.readOnly - ? null - : (value) { - setState(() { - _chkCopieMailRecu = value!; - }); - }, - ), - const SizedBox(height: 8), - - // Checkbox Accept SMS - _buildCheckboxOption( - label: "Accepte les SMS", - value: _chkAcceptSms, - onChanged: widget.readOnly - ? null - : (value) { - setState(() { - _chkAcceptSms = value!; - }); - }, - ), - const SizedBox(height: 8), - - // Checkbox Active - _buildCheckboxOption( - label: "Actif", - value: _chkActive, - onChanged: restrictedFieldsReadOnly - ? null - : (value) { - setState(() { - _chkActive = value!; - }); - }, - ), - const SizedBox(height: 25), - - // Bouton Enregistrer - if (!widget.readOnly) - Center( - child: ElevatedButton( - onPressed: _submitForm, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF20335E), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(50), - ), - minimumSize: const Size(200, 50), - ), - child: const Text( - 'Enregistrer', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ), - ), - ); - } - - 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) { - final theme = Theme.of(context); - - // 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, - ), - ), - ); - } - - 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( - 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>((Map region) { - return DropdownMenuItem( - 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; - } - }); - }, - ), - ), - ); - } -} + final screenWidth = MediaQuery.of(context