Migration complète du backend pour utiliser des Cloud Functions comme couche API sécurisée, en remplacement des appels directs à Firestore depuis le client.
**Backend (Cloud Functions):**
- **Centralisation CORS :** Ajout d'un middleware `withCors` et d'une configuration `httpOptions` pour gérer uniformément les en-têtes CORS et les requêtes `OPTIONS` sur toutes les fonctions.
- **Nouvelles Fonctions de Lecture (GET) :**
- `getEquipments`, `getContainers`, `getEvents`, `getUsers`, `getOptions`, `getEventTypes`, `getRoles`, `getMaintenances`, `getAlerts`.
- Ces fonctions gèrent les permissions côté serveur, masquant les données sensibles (ex: prix des équipements) pour les utilisateurs non-autorisés.
- `getEvents` retourne également une map des utilisateurs (`usersMap`) pour optimiser le chargement des données de la main d'œuvre.
- **Nouvelle Fonction de Recherche :**
- `getContainersByEquipment` : Endpoint dédié pour trouver efficacement tous les containers qui contiennent un équipement spécifique.
- **Nouvelles Fonctions d'Écriture (CRUD) :**
- Fonctions CRUD complètes pour `eventTypes` (`create`, `update`, `delete`), incluant la validation (unicité du nom, vérification des événements futurs avant suppression).
- **Mise à jour de Fonctions Existantes :**
- Toutes les fonctions CRUD existantes (`create/update/deleteEquipment`, `create/update/deleteContainer`, etc.) sont wrappées avec le nouveau gestionnaire CORS.
**Frontend (Flutter):**
- **Introduction du `DataService` :** Nouveau service centralisant tous les appels aux Cloud Functions, servant d'intermédiaire entre l'UI/Providers et l'API.
- **Refactorisation des Providers :**
- `EquipmentProvider`, `ContainerProvider`, `EventProvider`, `UsersProvider`, `MaintenanceProvider` et `AlertProvider` ont été refactorisés pour utiliser le `DataService` au lieu d'accéder directement à Firestore.
- Les `Stream` Firestore sont remplacés par des chargements de données via des méthodes `Future` (`loadEquipments`, `loadEvents`, etc.).
- **Gestion des Relations Équipement-Container :**
- Le modèle `EquipmentModel` ne stocke plus `parentBoxIds`.
- La relation est maintenant gérée par le `ContainerModel` qui contient `equipmentIds`.
- Le `ContainerEquipmentService` est introduit pour utiliser la nouvelle fonction `getContainersByEquipment`.
- L'affichage des boîtes parentes (`EquipmentParentContainers`) et le formulaire d'équipement (`EquipmentFormPage`) ont été mis à jour pour refléter ce nouveau modèle de données, synchronisant les ajouts/suppressions d'équipements dans les containers.
- **Amélioration de l'UI :**
- Nouveau widget `ParentBoxesSelector` pour une sélection améliorée et visuelle des boîtes parentes dans le formulaire d'équipement.
- Refonte visuelle de `EquipmentParentContainers` pour une meilleure présentation.
666 lines
24 KiB
Dart
666 lines
24 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:em2rp/models/option_model.dart';
|
|
import 'package:em2rp/utils/colors.dart';
|
|
import 'package:em2rp/services/data_service.dart';
|
|
import 'package:em2rp/services/api_service.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
class OptionsManagement extends StatefulWidget {
|
|
const OptionsManagement({super.key});
|
|
|
|
@override
|
|
State<OptionsManagement> createState() => _OptionsManagementState();
|
|
}
|
|
|
|
class _OptionsManagementState extends State<OptionsManagement> {
|
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
|
String _searchQuery = '';
|
|
List<EventOption> _options = [];
|
|
Map<String, String> _eventTypeNames = {};
|
|
bool _loading = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadData();
|
|
}
|
|
|
|
Future<void> _loadData() async {
|
|
setState(() => _loading = true);
|
|
try {
|
|
// Charger les types d'événements via l'API
|
|
final eventTypesData = await _dataService.getEventTypes();
|
|
|
|
_eventTypeNames = {
|
|
for (var typeData in eventTypesData)
|
|
typeData['id'] as String: typeData['name'] as String
|
|
};
|
|
|
|
// Charger les options via l'API
|
|
final optionsData = await _dataService.getOptions();
|
|
|
|
setState(() {
|
|
_options = optionsData
|
|
.map((data) => EventOption.fromMap(data, data['id'] as String))
|
|
.toList();
|
|
// Trier par code
|
|
_options.sort((a, b) => a.code.compareTo(b.code));
|
|
_loading = false;
|
|
});
|
|
} catch (e) {
|
|
setState(() => _loading = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Erreur lors du chargement : $e')),
|
|
);
|
|
}
|
|
}
|
|
|
|
List<EventOption> get _filteredOptions {
|
|
if (_searchQuery.isEmpty) return _options;
|
|
return _options.where((option) =>
|
|
option.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
|
option.code.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
|
option.details.toLowerCase().contains(_searchQuery.toLowerCase())
|
|
).toList();
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> _getBlockingEvents(String optionId) async {
|
|
// Charger tous les événements via l'API
|
|
final result = await _dataService.getEvents();
|
|
final eventsData = result['events'] as List<dynamic>;
|
|
|
|
final now = DateTime.now();
|
|
List<Map<String, dynamic>> futureEvents = [];
|
|
List<Map<String, dynamic>> pastEvents = [];
|
|
|
|
for (final eventData in eventsData) {
|
|
final options = eventData['options'] as List<dynamic>? ?? [];
|
|
|
|
// Vérifier si cette option est utilisée dans cet événement
|
|
bool optionUsed = options.any((opt) => opt['id'] == optionId);
|
|
|
|
if (optionUsed) {
|
|
final eventDate = eventData['startDateTime'] as DateTime? ??
|
|
(eventData['StartDateTime'] as DateTime?) ??
|
|
DateTime.now();
|
|
final eventName = eventData['name'] as String? ??
|
|
eventData['Name'] as String? ??
|
|
'Événement sans nom';
|
|
final eventId = eventData['id'] as String? ?? '';
|
|
|
|
if (eventDate.isAfter(now)) {
|
|
futureEvents.add({
|
|
'id': eventId,
|
|
'name': eventName,
|
|
'startDateTime': eventDate,
|
|
});
|
|
} else {
|
|
pastEvents.add({
|
|
'id': eventId,
|
|
'name': eventName,
|
|
'startDateTime': eventDate,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...futureEvents, ...pastEvents];
|
|
}
|
|
|
|
Future<void> _deleteOption(EventOption option) async {
|
|
final events = await _getBlockingEvents(option.id);
|
|
final futureEvents = events.where((e) =>
|
|
(e['startDateTime'] as DateTime).isAfter(DateTime.now())).toList();
|
|
|
|
if (futureEvents.isNotEmpty) {
|
|
// Il y a des événements futurs, empêcher la suppression
|
|
_showBlockingEventsDialog(option, events, canDelete: false);
|
|
return;
|
|
}
|
|
|
|
if (events.isNotEmpty) {
|
|
// Il n'y a que des événements passés, afficher un avertissement
|
|
_showBlockingEventsDialog(option, events, canDelete: true);
|
|
return;
|
|
}
|
|
|
|
// Aucun événement, suppression directe
|
|
_confirmAndDelete(option);
|
|
}
|
|
|
|
void _showBlockingEventsDialog(EventOption option, List<Map<String, dynamic>> events, {required bool canDelete}) {
|
|
final futureEvents = events.where((e) =>
|
|
(e['startDateTime'] as DateTime).isAfter(DateTime.now())).toList();
|
|
final pastEvents = events.where((e) =>
|
|
(e['startDateTime'] as DateTime).isBefore(DateTime.now())).toList();
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(canDelete ? 'Avertissement' : 'Suppression impossible'),
|
|
content: SizedBox(
|
|
width: double.maxFinite,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (!canDelete) ...[
|
|
const Text(
|
|
'Impossible de supprimer cette option car elle est utilisée par des événements futurs :',
|
|
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 12),
|
|
...futureEvents.map((event) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Text(
|
|
'• ${event['name']} - ${DateFormat('dd/MM/yyyy').format(event['startDateTime'])}',
|
|
style: const TextStyle(color: Colors.red),
|
|
),
|
|
)),
|
|
],
|
|
if (canDelete && pastEvents.isNotEmpty) ...[
|
|
const Text(
|
|
'Cette option est utilisée par des événements passés :',
|
|
style: TextStyle(color: Colors.orange, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 12),
|
|
...pastEvents.take(5).map((event) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Text(
|
|
'• ${event['name']} - ${DateFormat('dd/MM/yyyy').format(event['startDateTime'])}',
|
|
style: const TextStyle(color: Colors.orange),
|
|
),
|
|
)),
|
|
if (pastEvents.length > 5)
|
|
Text('... et ${pastEvents.length - 5} autres événements'),
|
|
const SizedBox(height: 12),
|
|
const Text('Voulez-vous vraiment continuer la suppression ?'),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Annuler'),
|
|
),
|
|
if (canDelete)
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_confirmAndDelete(option);
|
|
},
|
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
|
child: const Text('Supprimer quand même', style: TextStyle(color: Colors.white)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmAndDelete(EventOption option) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Confirmer la suppression'),
|
|
content: Text('Êtes-vous sûr de vouloir supprimer l\'option "${option.code} - ${option.name}" ?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
Navigator.pop(context);
|
|
try {
|
|
await _dataService.deleteOption(option.id);
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Option supprimée avec succès')),
|
|
);
|
|
_loadData();
|
|
} catch (e) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Erreur lors de la suppression : $e')),
|
|
);
|
|
}
|
|
},
|
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
|
child: const Text('Supprimer', style: TextStyle(color: Colors.white)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showCreateEditDialog({EventOption? option}) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => _OptionFormDialog(
|
|
option: option,
|
|
eventTypeNames: _eventTypeNames,
|
|
onSaved: _loadData,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
children: [
|
|
// Header avec recherche et bouton ajouter
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
decoration: const InputDecoration(
|
|
labelText: 'Rechercher une option (code, nom, détails)',
|
|
prefixIcon: Icon(Icons.search),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
onChanged: (value) => setState(() => _searchQuery = value),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
ElevatedButton.icon(
|
|
onPressed: () => _showCreateEditDialog(),
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Nouvelle option'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.rouge,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Liste des options
|
|
Expanded(
|
|
child: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _filteredOptions.isEmpty
|
|
? const Center(
|
|
child: Text(
|
|
'Aucune option trouvée',
|
|
style: TextStyle(fontSize: 16, color: Colors.grey),
|
|
),
|
|
)
|
|
: ListView.builder(
|
|
itemCount: _filteredOptions.length,
|
|
itemBuilder: (context, index) {
|
|
final option = _filteredOptions[index];
|
|
final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '€');
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
child: ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: AppColors.rouge,
|
|
child: Text(
|
|
option.code.substring(0, 2).toUpperCase(),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
title: Text(
|
|
'${option.code} - ${option.name}',
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (option.details.isNotEmpty)
|
|
Text(option.details),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Prix : ${currencyFormat.format(option.valMin)} - ${currencyFormat.format(option.valMax)}',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.green,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Wrap(
|
|
spacing: 4,
|
|
children: option.eventTypes.map((typeId) {
|
|
final typeName = _eventTypeNames[typeId] ?? typeId;
|
|
return Chip(
|
|
label: Text(
|
|
typeName,
|
|
style: const TextStyle(fontSize: 10),
|
|
),
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
backgroundColor: AppColors.rouge.withOpacity(0.1),
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.edit, color: Colors.blue),
|
|
onPressed: () => _showCreateEditDialog(option: option),
|
|
tooltip: 'Modifier',
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete, color: Colors.red),
|
|
onPressed: () => _deleteOption(option),
|
|
tooltip: 'Supprimer',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _OptionFormDialog extends StatefulWidget {
|
|
final EventOption? option;
|
|
final Map<String, String> eventTypeNames;
|
|
final VoidCallback onSaved;
|
|
|
|
const _OptionFormDialog({
|
|
this.option,
|
|
required this.eventTypeNames,
|
|
required this.onSaved,
|
|
});
|
|
|
|
@override
|
|
State<_OptionFormDialog> createState() => _OptionFormDialogState();
|
|
}
|
|
|
|
class _OptionFormDialogState extends State<_OptionFormDialog> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _codeController = TextEditingController();
|
|
final _nameController = TextEditingController();
|
|
final _detailsController = TextEditingController();
|
|
final _minPriceController = TextEditingController();
|
|
final _maxPriceController = TextEditingController();
|
|
List<String> _selectedTypes = [];
|
|
bool _isQuantitative = false;
|
|
bool _loading = false;
|
|
String? _error;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (widget.option != null) {
|
|
_codeController.text = widget.option!.code;
|
|
_nameController.text = widget.option!.name;
|
|
_detailsController.text = widget.option!.details;
|
|
_minPriceController.text = widget.option!.valMin.toString();
|
|
_maxPriceController.text = widget.option!.valMax.toString();
|
|
_selectedTypes = List.from(widget.option!.eventTypes);
|
|
_isQuantitative = widget.option!.isQuantitative;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_codeController.dispose();
|
|
_nameController.dispose();
|
|
_detailsController.dispose();
|
|
_minPriceController.dispose();
|
|
_maxPriceController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<bool> _isCodeUnique(String code) async {
|
|
try {
|
|
// Charger toutes les options via l'API
|
|
final dataService = DataService(FirebaseFunctionsApiService());
|
|
final optionsData = await dataService.getOptions();
|
|
|
|
// Si on modifie et que c'est le même document, c'est OK
|
|
if (widget.option != null && widget.option!.id == code) {
|
|
return true;
|
|
}
|
|
|
|
// Vérifier si le code existe déjà
|
|
return !optionsData.any((opt) => opt['id'] == code);
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
if (_selectedTypes.isEmpty) {
|
|
setState(() => _error = 'Sélectionnez au moins un type d\'événement');
|
|
return;
|
|
}
|
|
|
|
final code = _codeController.text.trim().toUpperCase();
|
|
final name = _nameController.text.trim();
|
|
final min = double.tryParse(_minPriceController.text.replaceAll(',', '.'));
|
|
final max = double.tryParse(_maxPriceController.text.replaceAll(',', '.'));
|
|
|
|
if (min == null || max == null) {
|
|
setState(() => _error = 'Les prix doivent être des nombres valides');
|
|
return;
|
|
}
|
|
|
|
if (min > max) {
|
|
setState(() => _error = 'Le prix minimum ne peut pas être supérieur au prix maximum');
|
|
return;
|
|
}
|
|
|
|
setState(() => _loading = true);
|
|
|
|
try {
|
|
// Vérifier l'unicité du code seulement pour les nouvelles options
|
|
if (widget.option == null) {
|
|
final isUnique = await _isCodeUnique(code);
|
|
if (!isUnique) {
|
|
setState(() {
|
|
_error = 'Ce code d\'option existe déjà';
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
final dataService = DataService(FirebaseFunctionsApiService());
|
|
final data = {
|
|
'code': code,
|
|
'name': name,
|
|
'details': _detailsController.text.trim(),
|
|
'valMin': min,
|
|
'valMax': max,
|
|
'eventTypes': _selectedTypes,
|
|
'isQuantitative': _isQuantitative,
|
|
};
|
|
|
|
if (widget.option == null) {
|
|
// Création - utiliser le code comme ID
|
|
await dataService.createOption(code, data);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Option créée avec succès')),
|
|
);
|
|
} else {
|
|
// Modification
|
|
await dataService.updateOption(widget.option!.id, data);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Option modifiée avec succès')),
|
|
);
|
|
}
|
|
|
|
widget.onSaved();
|
|
Navigator.pop(context);
|
|
} catch (e) {
|
|
setState(() {
|
|
_error = 'Erreur : $e';
|
|
_loading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(widget.option == null ? 'Nouvelle option' : 'Modifier l\'option'),
|
|
content: SizedBox(
|
|
width: 500,
|
|
child: SingleChildScrollView(
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextFormField(
|
|
controller: _codeController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Code de l\'option *',
|
|
hintText: 'Ex: TENT_50M2',
|
|
helperText: 'Max 16 caractères, lettres, chiffres, _ et -',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
enabled: widget.option == null, // Code non modifiable en édition
|
|
maxLength: 16,
|
|
textCapitalization: TextCapitalization.characters,
|
|
validator: (v) {
|
|
if (v == null || v.isEmpty) return 'Le code est obligatoire';
|
|
if (v.length > 16) return 'Maximum 16 caractères';
|
|
if (!RegExp(r'^[A-Z0-9_-]+$').hasMatch(v)) {
|
|
return 'Seuls les lettres, chiffres, _ et - sont autorisés';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _nameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Nom de l\'option *',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
validator: (v) => v == null || v.trim().isEmpty ? 'Le nom est obligatoire' : null,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _detailsController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Détails',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
maxLines: 3,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _minPriceController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Prix min (€) *',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
validator: (v) => v == null || v.isEmpty ? 'Obligatoire' : null,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _maxPriceController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Prix max (€) *',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
validator: (v) => v == null || v.isEmpty ? 'Obligatoire' : null,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
CheckboxListTile(
|
|
title: const Text('Option quantitative'),
|
|
subtitle: const Text('Permet de spécifier une quantité lors de l\'ajout à un événement'),
|
|
value: _isQuantitative,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_isQuantitative = value ?? false;
|
|
});
|
|
},
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('Types d\'événement associés *',
|
|
style: TextStyle(fontWeight: FontWeight.w500)),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
children: widget.eventTypeNames.entries.map((entry) {
|
|
return FilterChip(
|
|
label: Text(entry.value),
|
|
selected: _selectedTypes.contains(entry.key),
|
|
onSelected: (selected) {
|
|
setState(() {
|
|
if (selected) {
|
|
_selectedTypes.add(entry.key);
|
|
} else {
|
|
_selectedTypes.remove(entry.key);
|
|
}
|
|
_error = null; // Effacer l'erreur lors de la sélection
|
|
});
|
|
},
|
|
selectedColor: AppColors.rouge.withOpacity(0.3),
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
),
|
|
if (_error != null) ...[
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_error!,
|
|
style: const TextStyle(color: Colors.red),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _loading ? null : () => Navigator.pop(context),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: _loading ? null : _submit,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.rouge,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: _loading
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text(widget.option == null ? 'Créer' : 'Modifier'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|