feat: Ajout de la gestion de la quantité pour les options d'événement

This commit is contained in:
ElPoyo
2025-12-16 19:23:48 +01:00
parent 08f046c89c
commit 28d9e008af
6 changed files with 240 additions and 28 deletions

View File

@@ -6,6 +6,7 @@ class EventOption {
final double valMin;
final double valMax;
final List<String> eventTypes; // Changé de List<DocumentReference> à List<String>
final bool isQuantitative; // Indique si l'option peut avoir une quantité
EventOption({
required this.id,
@@ -15,6 +16,7 @@ class EventOption {
required this.valMin,
required this.valMax,
required this.eventTypes,
this.isQuantitative = false,
});
factory EventOption.fromMap(Map<String, dynamic> map, String id) {
@@ -28,6 +30,7 @@ class EventOption {
eventTypes: (map['eventTypes'] as List<dynamic>? ?? [])
.map((e) => e.toString()) // Convertit en String (supporte IDs et références)
.toList(),
isQuantitative: map['isQuantitative'] ?? false,
);
}
@@ -39,6 +42,7 @@ class EventOption {
'valMin': valMin,
'valMax': valMax,
'eventTypes': eventTypes,
'isQuantitative': isQuantitative,
};
}
}

View File

@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/views/event_add_page.dart';
class EventDetailsHeader extends StatelessWidget {
class EventDetailsHeader extends StatefulWidget {
final EventModel event;
const EventDetailsHeader({
@@ -13,6 +14,52 @@ class EventDetailsHeader extends StatelessWidget {
required this.event,
});
@override
State<EventDetailsHeader> createState() => _EventDetailsHeaderState();
}
class _EventDetailsHeaderState extends State<EventDetailsHeader> {
String? _eventTypeName;
bool _isLoadingEventType = true;
@override
void initState() {
super.initState();
_fetchEventTypeName();
}
Future<void> _fetchEventTypeName() async {
try {
if (widget.event.eventTypeId.isEmpty) {
setState(() => _isLoadingEventType = false);
return;
}
final doc = await FirebaseFirestore.instance
.collection('eventTypes')
.doc(widget.event.eventTypeId)
.get();
if (doc.exists) {
setState(() {
_eventTypeName = doc.data()?['name'] as String? ?? widget.event.eventTypeId;
_isLoadingEventType = false;
});
} else {
setState(() {
_eventTypeName = widget.event.eventTypeId;
_isLoadingEventType = false;
});
}
} catch (e) {
print('Erreur lors du chargement du type d\'événement: $e');
setState(() {
_eventTypeName = widget.event.eventTypeId;
_isLoadingEventType = false;
});
}
}
@override
Widget build(BuildContext context) {
return Row(
@@ -25,7 +72,7 @@ class EventDetailsHeader extends StatelessWidget {
Container(
constraints: const BoxConstraints(maxHeight: 80),
child: SelectableText(
event.name,
widget.event.name,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: AppColors.noir,
fontWeight: FontWeight.bold,
@@ -34,7 +81,9 @@ class EventDetailsHeader extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
event.eventTypeId,
_isLoadingEventType
? 'Chargement...'
: _eventTypeName ?? widget.event.eventTypeId,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.rouge,
),
@@ -43,7 +92,7 @@ class EventDetailsHeader extends StatelessWidget {
),
),
const SizedBox(width: 12),
_buildStatusIcon(event.status),
_buildStatusIcon(widget.event.status),
if (Provider.of<LocalUserProvider>(context, listen: false)
.hasPermission('edit_event')) ...[
const SizedBox(width: 8),
@@ -53,7 +102,7 @@ class EventDetailsHeader extends StatelessWidget {
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EventAddEditPage(event: event),
builder: (context) => EventAddEditPage(event: widget.event),
),
);
},

View File

@@ -55,7 +55,11 @@ class EventDetailsInfo extends StatelessWidget {
final total = event.basePrice +
event.options.fold<num>(
0,
(sum, opt) => sum + (opt['price'] ?? 0.0),
(sum, opt) {
final price = opt['price'] ?? 0.0;
final quantity = opt['quantity'] ?? 1;
return sum + (price * quantity);
},
);
return Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),

View File

@@ -392,6 +392,7 @@ class _OptionFormDialogState extends State<_OptionFormDialog> {
final _minPriceController = TextEditingController();
final _maxPriceController = TextEditingController();
List<String> _selectedTypes = [];
bool _isQuantitative = false;
bool _loading = false;
String? _error;
@@ -405,6 +406,7 @@ class _OptionFormDialogState extends State<_OptionFormDialog> {
_minPriceController.text = widget.option!.valMin.toString();
_maxPriceController.text = widget.option!.valMax.toString();
_selectedTypes = List.from(widget.option!.eventTypes);
_isQuantitative = widget.option!.isQuantitative;
}
}
@@ -476,6 +478,7 @@ class _OptionFormDialogState extends State<_OptionFormDialog> {
'valMin': min,
'valMax': max,
'eventTypes': _selectedTypes,
'isQuantitative': _isQuantitative,
};
if (widget.option == null) {
@@ -584,6 +587,18 @@ class _OptionFormDialogState extends State<_OptionFormDialog> {
],
),
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: [

View File

@@ -62,7 +62,9 @@ class EventOptionsDisplayWidget extends StatelessWidget {
children: [
...enrichedOptions.map((opt) {
final price = (opt['price'] ?? 0.0) as num;
final isNegative = price < 0;
final quantity = (opt['quantity'] ?? 1) as int;
final totalPrice = price * quantity;
final isNegative = totalPrice < 0;
return ListTile(
leading: Icon(Icons.tune, color: AppColors.rouge),
@@ -72,8 +74,11 @@ class EventOptionsDisplayWidget extends StatelessWidget {
: opt['name'] ?? '',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: opt['details'] != null && opt['details'].toString().trim().isNotEmpty
? Text(
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (opt['details'] != null && opt['details'].toString().trim().isNotEmpty)
Text(
opt['details'].toString().trim(),
style: const TextStyle(
fontWeight: FontWeight.normal,
@@ -82,7 +87,8 @@ class EventOptionsDisplayWidget extends StatelessWidget {
fontStyle: FontStyle.italic,
),
)
: Text(
else
Text(
'Aucun détail disponible',
style: TextStyle(
fontWeight: FontWeight.normal,
@@ -91,10 +97,24 @@ class EventOptionsDisplayWidget extends StatelessWidget {
fontStyle: FontStyle.italic,
),
),
if (quantity > 1 && canViewPrices)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'${currencyFormat.format(price)} × $quantity',
style: TextStyle(
color: Colors.grey[700],
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
trailing: canViewPrices
? Text(
(isNegative ? '- ' : '+ ') +
currencyFormat.format(price.abs()),
currencyFormat.format(totalPrice.abs()),
style: TextStyle(
color: isNegative ? Colors.red : AppColors.noir,
fontWeight: FontWeight.bold,
@@ -118,7 +138,11 @@ class EventOptionsDisplayWidget extends StatelessWidget {
}
Widget _buildTotalPrice(BuildContext context, List<Map<String, dynamic>> options, NumberFormat currencyFormat) {
final optionsTotal = options.fold<num>(0, (sum, opt) => sum + (opt['price'] ?? 0.0));
final optionsTotal = options.fold<num>(0, (sum, opt) {
final price = opt['price'] ?? 0.0;
final quantity = opt['quantity'] ?? 1;
return sum + (price * quantity);
});
return Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
@@ -166,6 +190,8 @@ class EventOptionsDisplayWidget extends StatelessWidget {
'name': firestoreData['name'], // Récupéré depuis Firestore
'details': firestoreData['details'] ?? '', // Récupéré depuis Firestore
'price': optionData['price'], // Prix choisi par l'utilisateur
'quantity': optionData['quantity'] ?? 1, // Quantité
'isQuantitative': firestoreData['isQuantitative'] ?? false,
'valMin': firestoreData['valMin'],
'valMax': firestoreData['valMax'],
});
@@ -176,6 +202,8 @@ class EventOptionsDisplayWidget extends StatelessWidget {
'name': 'Option supprimée (ID: ${optionData['id']})',
'details': 'Cette option n\'existe plus dans la base de données',
'price': optionData['price'],
'quantity': optionData['quantity'] ?? 1,
'isQuantitative': false,
});
}
} else {
@@ -185,6 +213,8 @@ class EventOptionsDisplayWidget extends StatelessWidget {
'name': optionData['name'] ?? 'Option inconnue',
'details': optionData['details'] ?? 'Aucun détail disponible',
'price': optionData['price'] ?? 0.0,
'quantity': optionData['quantity'] ?? 1,
'isQuantitative': false,
});
}
} catch (e) {
@@ -195,6 +225,8 @@ class EventOptionsDisplayWidget extends StatelessWidget {
'name': 'Erreur de chargement (ID: ${optionData['id']})',
'details': 'Impossible de charger les détails de cette option',
'price': optionData['price'] ?? 0.0,
'quantity': optionData['quantity'] ?? 1,
'isQuantitative': false,
});
}
}

View File

@@ -149,8 +149,65 @@ class _OptionSelectorWidgetState extends State<OptionSelectorWidget> {
child: Text(opt['details'],
style: const TextStyle(fontSize: 13)),
),
Text('Prix : ${opt['price'] ?? ''}',
style: const TextStyle(fontSize: 13)),
Row(
children: [
Text('Prix unitaire : ${opt['price'] ?? ''}',
style: const TextStyle(fontSize: 13)),
if (opt['isQuantitative'] == true && opt['quantity'] != null && opt['quantity'] > 1) ...[
const Text(' × ', style: TextStyle(fontSize: 13)),
Text('${opt['quantity']}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)),
Text(' = ${((opt['price'] ?? 0) * (opt['quantity'] ?? 1)).toStringAsFixed(2)}',
style: const TextStyle(fontSize: 13, color: Colors.green, fontWeight: FontWeight.bold)),
],
],
),
if (opt['isQuantitative'] == true)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
const Text('Quantité : ', style: TextStyle(fontSize: 12)),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
final currentQty = opt['quantity'] ?? 1;
if (currentQty > 1) {
final newList = List<Map<String, dynamic>>.from(widget.selectedOptions);
final index = newList.indexWhere((o) => o['id'] == opt['id']);
if (index != -1) {
newList[index] = {...newList[index], 'quantity': currentQty - 1};
widget.onChanged(newList);
setState(() => _rebuildKey++);
}
}
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text('${opt['quantity'] ?? 1}',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
),
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
final currentQty = opt['quantity'] ?? 1;
final newList = List<Map<String, dynamic>>.from(widget.selectedOptions);
final index = newList.indexWhere((o) => o['id'] == opt['id']);
if (index != -1) {
newList[index] = {...newList[index], 'quantity': currentQty + 1};
widget.onChanged(newList);
setState(() => _rebuildKey++);
}
},
),
],
),
),
],
),
),
@@ -224,6 +281,8 @@ class _OptionSelectorWidgetState extends State<OptionSelectorWidget> {
: firestoreData['name'], // Affichage avec code
'details': firestoreData['details'] ?? '',
'price': optionData['price'],
'quantity': optionData['quantity'] ?? 1,
'isQuantitative': firestoreData['isQuantitative'] ?? false,
});
} else {
enrichedOptions.add({
@@ -231,11 +290,17 @@ class _OptionSelectorWidgetState extends State<OptionSelectorWidget> {
'name': 'Option supprimée (${optionData['id']})',
'details': 'Cette option n\'existe plus',
'price': optionData['price'],
'quantity': optionData['quantity'] ?? 1,
'isQuantitative': false,
});
}
} else {
// Ancien format, utiliser les données locales
enrichedOptions.add(optionData);
enrichedOptions.add({
...optionData,
'quantity': optionData['quantity'] ?? 1,
'isQuantitative': optionData['isQuantitative'] ?? false,
});
}
} catch (e) {
// En cas d'erreur, utiliser les données disponibles
@@ -244,6 +309,8 @@ class _OptionSelectorWidgetState extends State<OptionSelectorWidget> {
'name': optionData['name'] ?? 'Erreur de chargement',
'details': 'Impossible de charger les détails',
'price': optionData['price'],
'quantity': optionData['quantity'] ?? 1,
'isQuantitative': false,
});
}
}
@@ -354,27 +421,46 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> {
itemBuilder: (context, i) {
final opt = filtered[i];
return ListTile(
title: Text('${opt.code} - ${opt.name}'), // Affichage avec code
title: Text('${opt.code} - ${opt.name}${opt.isQuantitative ? ' (Quantitatif)' : ''}'), // Affichage avec code
subtitle: Text('${opt.details}\nFourchette: ${opt.valMin}€ ~ ${opt.valMax}'),
onTap: () async {
final min = opt.valMin;
final max = opt.valMax;
final defaultPrice =
((min + max) / 2).toStringAsFixed(2);
final price = await showDialog<double>(
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (ctx) {
final priceController =
TextEditingController(text: defaultPrice);
final quantityController =
TextEditingController(text: '1');
return AlertDialog(
title: Text('Prix pour ${opt.code} - ${opt.name}'), // Affichage avec code
content: TextField(
controller: priceController,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true),
decoration: const InputDecoration(
labelText: 'Prix (€)'),
title: Text('Ajouter ${opt.code} - ${opt.name}'), // Affichage avec code
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: priceController,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true),
decoration: const InputDecoration(
labelText: 'Prix unitaire (€)'),
),
if (opt.isQuantitative) ...[
const SizedBox(height: 16),
TextField(
controller: quantityController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Quantité',
helperText: 'Le prix sera multiplié par la quantité',
),
),
],
],
),
actions: [
TextButton(
@@ -387,7 +473,13 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> {
priceController.text
.replaceAll(',', '.')) ??
0.0;
Navigator.pop(ctx, price);
final quantity = opt.isQuantitative
? (int.tryParse(quantityController.text) ?? 1)
: 1;
Navigator.pop(ctx, {
'price': price,
'quantity': quantity,
});
},
child: const Text('Ajouter'),
),
@@ -395,10 +487,11 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> {
);
},
);
if (price != null) {
if (result != null) {
Navigator.pop(context, {
'id': opt.id, // ID de l'option (obligatoire pour récupérer les données)
'price': price, // Prix choisi par l'utilisateur (obligatoire car personnalisé)
'price': result['price'], // Prix choisi par l'utilisateur (obligatoire car personnalisé)
'quantity': result['quantity'] ?? 1, // Quantité (par défaut 1)
});
}
},
@@ -457,6 +550,7 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> {
final _minPriceController = TextEditingController();
final _maxPriceController = TextEditingController();
final List<String> _selectedTypes = [];
bool _isQuantitative = false;
String? _error;
bool _checkingCode = false;
List<Map<String,dynamic>> _allEventTypes = [];
@@ -559,6 +653,19 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> {
],
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Option quantitative'),
subtitle: const Text('Permet de spécifier une quantité'),
value: _isQuantitative,
onChanged: (value) {
setState(() {
_isQuantitative = value ?? false;
});
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -642,6 +749,7 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> {
'valMin': min,
'valMax': max,
'eventTypes': _selectedTypes,
'isQuantitative': _isQuantitative,
});
Navigator.pop(context, true);
} catch (e) {