feat: Sécurisation Firestore, gestion des prix HT/TTC et refactorisation majeure
Cette mise à jour verrouille l'accès direct à Firestore depuis le client pour renforcer la sécurité et introduit une gestion complète des prix HT/TTC dans toute l'application. Elle apporte également des améliorations significatives des permissions, des optimisations de performance et de nouvelles fonctionnalités.
### Sécurité et Backend
- **Firestore Rules :** Ajout de `firestore.rules` qui bloque par défaut tous les accès en lecture/écriture depuis le client. Toutes les opérations de données doivent maintenant passer par les Cloud Functions, renforçant considérablement la sécurité.
- **Index Firestore :** Création d'un fichier `firestore.indexes.json` pour optimiser les requêtes sur la collection `events`.
- **Cloud Functions :** Les fonctions de création/mise à jour d'événements ont été adaptées pour accepter des ID de documents (utilisateurs, type d'événement) et les convertir en `DocumentReference` côté serveur, simplifiant les appels depuis le client.
### Gestion des Prix HT/TTC
- **Calcul Automatisé :** Introduction d'un helper `PriceHelpers` et d'un widget `PriceHtTtcFields` pour calculer et synchroniser automatiquement les prix HT et TTC dans le formulaire d'événement.
- **Affichage Détaillé :**
- Les détails des événements et des options affichent désormais les prix HT, la TVA et le TTC séparément pour plus de clarté.
- Le prix de base (`basePrice`) est maintenant traité comme un prix TTC dans toute l'application.
### Permissions et Rôles
- **Centralisation (`AppPermission`) :** Création d'une énumération `AppPermission` pour centraliser toutes les permissions de l'application, avec descriptions et catégories.
- **Rôles Prédéfinis :** Définition de rôles standards (Admin, Manager, Technicien, User) avec des jeux de permissions prédéfinis.
- **Filtre par Utilisateur :** Ajout d'un filtre par utilisateur sur la page Calendrier, visible uniquement pour les utilisateurs ayant la permission `view_all_user_events`.
### Améliorations et Optimisations (Frontend)
- **`DebugLog` :** Ajout d'un utilitaire `DebugLog` pour gérer les logs, qui sont automatiquement désactivés en mode production.
- **Optimisation du Sélecteur d'Équipement :**
- La boîte de dialogue de sélection d'équipement a été lourdement optimisée pour éviter les reconstructions complètes de la liste lors de la sélection/désélection d'items.
- Utilisation de `ValueNotifier` et de caches locaux (`_cachedContainers`, `_cachedEquipment`) pour des mises à jour d'UI plus ciblées et fluides.
- La position du scroll est désormais préservée.
- **Catégorie d'Équipement :** Ajout de la catégorie `Vehicle` (Véhicule) pour les équipements.
- **Formulaires :** Les formulaires de création/modification d'événements et d'équipements ont été nettoyés de leurs logs de débogage excessifs.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -41,8 +42,6 @@ class _ForgotPasswordDialogState extends State<ForgotPasswordDialogWidget> {
|
||||
_errorMessage = "Erreur : ${e.message}";
|
||||
_emailSent = false;
|
||||
});
|
||||
print(
|
||||
"Erreur de réinitialisation du mot de passe: ${e.code} - ${e.message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/utils/price_helpers.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:em2rp/views/widgets/event_form/event_options_display_widget.dart';
|
||||
|
||||
@@ -34,13 +35,48 @@ class EventDetailsInfo extends StatelessWidget {
|
||||
'Horaire de fin',
|
||||
dateFormat.format(event.endDateTime),
|
||||
),
|
||||
if (canViewPrices)
|
||||
_buildInfoRow(
|
||||
context,
|
||||
Icons.euro,
|
||||
'Prix de base',
|
||||
currencyFormat.format(event.basePrice),
|
||||
if (canViewPrices) ...[
|
||||
// Calcul des prix HT/TVA/TTC
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final pricing = PriceHelpers.getPricing(event);
|
||||
return Column(
|
||||
children: [
|
||||
_buildInfoRow(
|
||||
context,
|
||||
Icons.euro,
|
||||
'Prix HT',
|
||||
pricing.formattedHT,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.percent, size: 16, color: Colors.grey[600]),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'TVA (${pricing.taxRatePercentage.toStringAsFixed(0)}%) : ${pricing.formattedTax}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildInfoRow(
|
||||
context,
|
||||
Icons.attach_money,
|
||||
'Prix TTC',
|
||||
pricing.formattedTTC,
|
||||
highlighted: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
if (event.options.isNotEmpty) ...[
|
||||
EventOptionsDisplayWidget(
|
||||
optionsData: event.options,
|
||||
@@ -52,34 +88,85 @@ class EventDetailsInfo extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final total = event.basePrice +
|
||||
// Total TTC = basePrice (TTC) + options (TTC)
|
||||
final totalTTC = event.basePrice +
|
||||
event.options.fold<num>(
|
||||
0,
|
||||
(sum, opt) {
|
||||
final price = opt['price'] ?? 0.0;
|
||||
final priceTTC = opt['price'] ?? 0.0;
|
||||
final quantity = opt['quantity'] ?? 1;
|
||||
return sum + (price * quantity);
|
||||
return sum + (priceTTC * quantity);
|
||||
},
|
||||
);
|
||||
|
||||
// Calculer le total HT
|
||||
final totalHT = PriceHelpers.calculateHT(totalTTC.toDouble());
|
||||
final totalTVA = PriceHelpers.calculateTax(totalHT);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.attach_money, color: AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Prix total : ',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.noir,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
// Séparateur visuel
|
||||
const Divider(thickness: 1),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Prix total HT
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.euro, color: AppColors.rouge, size: 22),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Prix total HT : ',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.noir,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
currencyFormat.format(totalHT),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.noir,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
currencyFormat.format(total),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.rouge,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
|
||||
// TVA en petit
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 30.0, top: 4.0, bottom: 4.0),
|
||||
child: Text(
|
||||
'TVA (20%) : ${currencyFormat.format(totalTVA)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Prix total TTC en surbrillance
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.attach_money, color: AppColors.rouge, size: 24),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Prix total TTC : ',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: AppColors.noir,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
currencyFormat.format(totalTTC),
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: AppColors.rouge,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -139,24 +226,28 @@ class EventDetailsInfo extends StatelessWidget {
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String label,
|
||||
String value,
|
||||
) {
|
||||
String value, {
|
||||
bool highlighted = false,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: AppColors.rouge),
|
||||
Icon(icon, color: highlighted ? AppColors.rouge : AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$label : ',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.noir,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: highlighted ? FontWeight.w900 : FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: highlighted ? AppColors.rouge : null,
|
||||
fontWeight: highlighted ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/models/user_model.dart';
|
||||
import 'package:em2rp/providers/users_provider.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
|
||||
/// Widget de filtre par utilisateur pour le calendrier
|
||||
/// Affiche un dropdown permettant de filtrer les événements par utilisateur
|
||||
class UserFilterDropdown extends StatefulWidget {
|
||||
final String? selectedUserId;
|
||||
final ValueChanged<String?> onUserSelected;
|
||||
|
||||
const UserFilterDropdown({
|
||||
super.key,
|
||||
required this.selectedUserId,
|
||||
required this.onUserSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UserFilterDropdown> createState() => _UserFilterDropdownState();
|
||||
}
|
||||
|
||||
class _UserFilterDropdownState extends State<UserFilterDropdown> {
|
||||
List<UserModel> _users = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Charger après le premier frame pour éviter setState pendant build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_loadUsers();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadUsers() async {
|
||||
if (!mounted) return;
|
||||
|
||||
final usersProvider = Provider.of<UsersProvider>(context, listen: false);
|
||||
|
||||
// Ne pas appeler fetchUsers si les utilisateurs sont déjà chargés
|
||||
if (usersProvider.users.isEmpty) {
|
||||
await usersProvider.fetchUsers();
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_users = usersProvider.users;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const SizedBox(
|
||||
width: 200,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: 250,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String?>(
|
||||
value: widget.selectedUserId,
|
||||
hint: const Row(
|
||||
children: [
|
||||
Icon(Icons.filter_list, size: 18, color: Colors.grey),
|
||||
SizedBox(width: 8),
|
||||
Text('Tous les utilisateurs', style: TextStyle(fontSize: 14)),
|
||||
],
|
||||
),
|
||||
isExpanded: true,
|
||||
icon: const Icon(Icons.arrow_drop_down, size: 24),
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||
onChanged: widget.onUserSelected,
|
||||
items: [
|
||||
// Option "Tous les utilisateurs"
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.people, size: 18, color: AppColors.rouge),
|
||||
SizedBox(width: 8),
|
||||
Text('Tous les utilisateurs', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Liste des utilisateurs
|
||||
..._users.map((user) {
|
||||
return DropdownMenuItem<String?>(
|
||||
value: user.uid,
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundImage: user.profilePhotoUrl.isNotEmpty
|
||||
? NetworkImage(user.profilePhotoUrl)
|
||||
: null,
|
||||
child: user.profilePhotoUrl.isEmpty
|
||||
? Text(
|
||||
user.firstName.isNotEmpty
|
||||
? user.firstName[0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(fontSize: 10),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${user.firstName} ${user.lastName}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,8 @@ class ContainerEquipmentTile extends StatelessWidget {
|
||||
return 'Consommable';
|
||||
case EquipmentCategory.cable:
|
||||
return 'Câble';
|
||||
case EquipmentCategory.vehicle:
|
||||
return 'Véhicule';
|
||||
case EquipmentCategory.other:
|
||||
return 'Autre';
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
@@ -60,7 +61,7 @@ class _EquipmentAssociatedEventsSectionState
|
||||
containersWithEquipment.add(containerData['id'] as String);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}: $e');
|
||||
DebugLog.error('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ class _EquipmentAssociatedEventsSectionState
|
||||
events.add(event);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[EquipmentAssociatedEventsSection] Error parsing event ${eventData['id']}: $e');
|
||||
DebugLog.error('[EquipmentAssociatedEventsSection] Error parsing event ${eventData['id']}', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
@@ -54,7 +55,7 @@ class _EquipmentCurrentEventsSectionState
|
||||
containersWithEquipment.add(containerData['id'] as String);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}: $e');
|
||||
DebugLog.error('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@ class _EquipmentCurrentEventsSectionState
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[EquipmentCurrentEventsSection] Error parsing event $eventData: $e');
|
||||
DebugLog.error('[EquipmentCurrentEventsSection] Error parsing event $eventData', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class EquipmentStatusBadge extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = Provider.of<EquipmentProvider>(context, listen: false);
|
||||
print('[EquipmentStatusBadge] Building badge for ${equipment.id}');
|
||||
// Logs désactivés en production
|
||||
|
||||
return FutureBuilder<EquipmentStatus>(
|
||||
// On calcule le statut réel de manière asynchrone
|
||||
@@ -26,7 +26,7 @@ class EquipmentStatusBadge extends StatelessWidget {
|
||||
builder: (context, snapshot) {
|
||||
// Utiliser le statut calculé s'il est disponible, sinon le statut stocké
|
||||
final status = snapshot.data ?? equipment.status;
|
||||
print('[EquipmentStatusBadge] ${equipment.id} - Status: ${status.label} (hasData: ${snapshot.hasData}, connectionState: ${snapshot.connectionState})');
|
||||
// Logs désactivés en production
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
@@ -26,22 +27,11 @@ class _ParentBoxesSelectorState extends State<ParentBoxesSelector> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
print('[ParentBoxesSelector] initState');
|
||||
print('[ParentBoxesSelector] Available boxes: ${widget.availableBoxes.length}');
|
||||
print('[ParentBoxesSelector] Selected box IDs: ${widget.selectedBoxIds}');
|
||||
|
||||
// Log détaillé de chaque boîte
|
||||
for (var box in widget.availableBoxes) {
|
||||
print('[ParentBoxesSelector] Box - ID: ${box.id}, Name: ${box.name}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ParentBoxesSelector oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
print('[ParentBoxesSelector] didUpdateWidget');
|
||||
print('[ParentBoxesSelector] Old selected: ${oldWidget.selectedBoxIds}');
|
||||
print('[ParentBoxesSelector] New selected: ${widget.selectedBoxIds}');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -283,10 +273,10 @@ class _ParentBoxesSelectorState extends State<ParentBoxesSelector> {
|
||||
final box = filteredBoxes[index];
|
||||
final isSelected = widget.selectedBoxIds.contains(box.id);
|
||||
if (index == 0) {
|
||||
print('[ParentBoxesSelector] Building item $index');
|
||||
print('[ParentBoxesSelector] Box ID: ${box.id}');
|
||||
print('[ParentBoxesSelector] Selected IDs: ${widget.selectedBoxIds}');
|
||||
print('[ParentBoxesSelector] Is selected: $isSelected');
|
||||
DebugLog.info('[ParentBoxesSelector] Building item $index');
|
||||
DebugLog.info('[ParentBoxesSelector] Box ID: ${box.id}');
|
||||
DebugLog.info('[ParentBoxesSelector] Selected IDs: ${widget.selectedBoxIds}');
|
||||
DebugLog.info('[ParentBoxesSelector] Is selected: $isSelected');
|
||||
}
|
||||
return _buildBoxCard(box, isSelected);
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
@@ -88,12 +89,14 @@ class EquipmentSelectionDialog extends StatefulWidget {
|
||||
|
||||
class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController(); // Préserve la position de scroll
|
||||
final EventAvailabilityService _availabilityService = EventAvailabilityService();
|
||||
final DataService _dataService = DataService(apiService);
|
||||
|
||||
EquipmentCategory? _selectedCategory;
|
||||
|
||||
Map<String, SelectedItem> _selectedItems = {};
|
||||
final ValueNotifier<int> _selectionChangeNotifier = ValueNotifier<int>(0); // Pour notifier les changements de sélection sans setState
|
||||
Map<String, int> _availableQuantities = {}; // Pour consommables
|
||||
Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
|
||||
Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés)
|
||||
@@ -108,8 +111,14 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
|
||||
bool _isLoadingQuantities = false;
|
||||
bool _isLoadingConflicts = false;
|
||||
bool _conflictsLoaded = false; // Flag pour éviter de recharger indéfiniment
|
||||
String _searchQuery = '';
|
||||
|
||||
// Cache pour éviter les rebuilds inutiles
|
||||
List<ContainerModel> _cachedContainers = [];
|
||||
List<EquipmentModel> _cachedEquipment = [];
|
||||
bool _initialDataLoaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -186,18 +195,19 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[EquipmentSelectionDialog] Error loading already assigned containers: $e');
|
||||
}
|
||||
}
|
||||
|
||||
print('[EquipmentSelectionDialog] Initialized with ${_selectedItems.length} already assigned items');
|
||||
}
|
||||
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentSelectionDialog] Error loading already assigned containers', e);
|
||||
}
|
||||
}
|
||||
|
||||
DebugLog.info('[EquipmentSelectionDialog] Initialized with ${_selectedItems.length} already assigned items');
|
||||
}
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_scrollController.dispose(); // Nettoyer le ScrollController
|
||||
_selectionChangeNotifier.dispose(); // Nettoyer le ValueNotifier
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -226,7 +236,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
_availableQuantities[eq.id] = available;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error loading quantities: $e');
|
||||
DebugLog.error('Error loading quantities', e);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoadingQuantities = false);
|
||||
}
|
||||
@@ -238,7 +248,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
setState(() => _isLoadingConflicts = true);
|
||||
|
||||
try {
|
||||
print('[EquipmentSelectionDialog] Loading conflicts (optimized batch method)...');
|
||||
DebugLog.info('[EquipmentSelectionDialog] Loading conflicts (optimized batch method)...');
|
||||
|
||||
final startTime = DateTime.now();
|
||||
|
||||
@@ -254,7 +264,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
final endTime = DateTime.now();
|
||||
final duration = endTime.difference(startTime);
|
||||
|
||||
print('[EquipmentSelectionDialog] Conflicts loaded in ${duration.inMilliseconds}ms');
|
||||
DebugLog.info('[EquipmentSelectionDialog] Conflicts loaded in ${duration.inMilliseconds}ms');
|
||||
|
||||
// Extraire les IDs en conflit
|
||||
final conflictingEquipmentIds = (result['conflictingEquipmentIds'] as List<dynamic>?)
|
||||
@@ -268,8 +278,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
final conflictDetails = result['conflictDetails'] as Map<String, dynamic>? ?? {};
|
||||
final equipmentQuantities = result['equipmentQuantities'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
print('[EquipmentSelectionDialog] Found ${conflictingEquipmentIds.length} equipment(s) and ${conflictingContainerIds.length} container(s) in conflict');
|
||||
print('[EquipmentSelectionDialog] Quantity info for ${equipmentQuantities.length} equipment(s)');
|
||||
DebugLog.info('[EquipmentSelectionDialog] Found ${conflictingEquipmentIds.length} equipment(s) and ${conflictingContainerIds.length} container(s) in conflict');
|
||||
DebugLog.info('[EquipmentSelectionDialog] Quantity info for ${equipmentQuantities.length} equipment(s)');
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -277,6 +287,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
_conflictingContainerIds = conflictingContainerIds;
|
||||
_conflictDetails = conflictDetails;
|
||||
_equipmentQuantities = equipmentQuantities;
|
||||
_conflictsLoaded = true; // Marquer comme chargé
|
||||
});
|
||||
}
|
||||
|
||||
@@ -284,7 +295,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
await _updateContainerConflictStatus();
|
||||
|
||||
} catch (e) {
|
||||
print('[EquipmentSelectionDialog] Error loading conflicts: $e');
|
||||
DebugLog.error('[EquipmentSelectionDialog] Error loading conflicts', e);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoadingConflicts = false);
|
||||
}
|
||||
@@ -292,10 +303,14 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
|
||||
/// Met à jour le statut de conflit des conteneurs basé sur les IDs en conflit
|
||||
Future<void> _updateContainerConflictStatus() async {
|
||||
if (!mounted) return; // Vérifier si le widget est toujours monté
|
||||
|
||||
try {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
final containers = await containerProvider.containersStream.first;
|
||||
|
||||
if (!mounted) return; // Vérifier à nouveau après l'async
|
||||
|
||||
for (var container in containers) {
|
||||
// Vérifier si le conteneur lui-même est en conflit
|
||||
if (_conflictingContainerIds.contains(container.id)) {
|
||||
@@ -323,13 +338,13 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
totalChildren: container.equipmentIds.length,
|
||||
);
|
||||
|
||||
print('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)');
|
||||
DebugLog.info('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)');
|
||||
}
|
||||
}
|
||||
|
||||
print('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}');
|
||||
DebugLog.info('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}');
|
||||
} catch (e) {
|
||||
print('[EquipmentSelectionDialog] Error updating container conflicts: $e');
|
||||
DebugLog.error('[EquipmentSelectionDialog] Error updating container conflicts', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +370,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
if (availableQty == null) return const SizedBox.shrink();
|
||||
|
||||
return Text(
|
||||
'Disponible : $availableQty ${equipment.category == EquipmentCategory.cable ? "m" : ""}',
|
||||
'Disponible : $availableQty',
|
||||
style: TextStyle(
|
||||
color: availableQty > 0 ? Colors.green : Colors.red,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -580,7 +595,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error finding recommended containers: $e');
|
||||
DebugLog.error('Error finding recommended containers', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,26 +617,26 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
|
||||
if (_selectedItems.containsKey(id)) {
|
||||
// Désélectionner
|
||||
print('[EquipmentSelectionDialog] Deselecting $type: $id');
|
||||
print('[EquipmentSelectionDialog] Before deselection, _selectedItems count: ${_selectedItems.length}');
|
||||
DebugLog.info('[EquipmentSelectionDialog] Deselecting $type: $id');
|
||||
DebugLog.info('[EquipmentSelectionDialog] Before deselection, _selectedItems count: ${_selectedItems.length}');
|
||||
|
||||
if (type == SelectionType.container) {
|
||||
// Si c'est un conteneur, désélectionner d'abord ses enfants de manière asynchrone
|
||||
await _deselectContainerChildren(id);
|
||||
}
|
||||
|
||||
// Mise à jour sans setState pour éviter le flash
|
||||
// Mise à jour sans setState - utiliser ValueNotifier pour notifier uniquement les cards concernées
|
||||
_selectedItems.remove(id);
|
||||
print('[EquipmentSelectionDialog] After deselection, _selectedItems count: ${_selectedItems.length}');
|
||||
print('[EquipmentSelectionDialog] Remaining items: ${_selectedItems.keys.toList()}');
|
||||
DebugLog.info('[EquipmentSelectionDialog] After deselection, _selectedItems count: ${_selectedItems.length}');
|
||||
DebugLog.info('[EquipmentSelectionDialog] Remaining items: ${_selectedItems.keys.toList()}');
|
||||
|
||||
// Forcer uniquement la reconstruction du panneau de sélection et de la card concernée
|
||||
if (mounted) setState(() {});
|
||||
// Notifier le changement sans rebuilder toute la liste
|
||||
_selectionChangeNotifier.value++;
|
||||
} else {
|
||||
// Sélectionner
|
||||
print('[EquipmentSelectionDialog] Selecting $type: $id');
|
||||
DebugLog.info('[EquipmentSelectionDialog] Selecting $type: $id');
|
||||
|
||||
// Mise à jour sans setState pour éviter le flash
|
||||
// Mise à jour sans setState - utiliser ValueNotifier
|
||||
_selectedItems[id] = SelectedItem(
|
||||
id: id,
|
||||
name: name,
|
||||
@@ -639,8 +654,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
await _selectContainerChildren(id);
|
||||
}
|
||||
|
||||
// Forcer uniquement la reconstruction du panneau de sélection et de la card concernée
|
||||
if (mounted) setState(() {});
|
||||
// Notifier le changement sans rebuilder toute la liste
|
||||
_selectionChangeNotifier.value++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,7 +714,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error selecting container children: $e');
|
||||
DebugLog.error('Error selecting container children', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,9 +748,9 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
// Retirer de la liste des conteneurs expandés
|
||||
_expandedContainers.remove(containerId);
|
||||
|
||||
print('[EquipmentSelectionDialog] Deselected container $containerId and ${container.equipmentIds.length} children');
|
||||
DebugLog.info('[EquipmentSelectionDialog] Deselected container $containerId and ${container.equipmentIds.length} children');
|
||||
} catch (e) {
|
||||
print('Error deselecting container children: $e');
|
||||
DebugLog.error('Error deselecting container children', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -843,7 +858,12 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(child: _buildSelectionPanel()),
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _selectionChangeNotifier,
|
||||
builder: (context, _, __) => _buildSelectionPanel(),
|
||||
),
|
||||
),
|
||||
if (_hasRecommendations)
|
||||
Container(
|
||||
height: 200,
|
||||
@@ -997,34 +1017,41 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
return _buildHierarchicalList();
|
||||
}
|
||||
|
||||
/// Vue hiérarchique unique
|
||||
/// Vue hiérarchique unique avec cache pour éviter les rebuilds inutiles
|
||||
Widget _buildHierarchicalList() {
|
||||
return Consumer2<ContainerProvider, EquipmentProvider>(
|
||||
builder: (context, containerProvider, equipmentProvider, child) {
|
||||
return StreamBuilder<List<ContainerModel>>(
|
||||
stream: containerProvider.containersStream,
|
||||
builder: (context, containerSnapshot) {
|
||||
return StreamBuilder<List<EquipmentModel>>(
|
||||
stream: equipmentProvider.equipmentStream,
|
||||
builder: (context, equipmentSnapshot) {
|
||||
if (containerSnapshot.connectionState == ConnectionState.waiting ||
|
||||
equipmentSnapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
// Charger les données initiales dans le cache si pas encore fait
|
||||
if (!_initialDataLoaded) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cachedContainers = containerProvider.containers;
|
||||
_cachedEquipment = equipmentProvider.equipment;
|
||||
_initialDataLoaded = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final allContainers = containerSnapshot.data ?? [];
|
||||
final allEquipment = equipmentSnapshot.data ?? [];
|
||||
// Utiliser les données du cache au lieu des streams
|
||||
final allContainers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
|
||||
final allEquipment = _cachedEquipment.isNotEmpty ? _cachedEquipment : equipmentProvider.equipment;
|
||||
|
||||
// Charger les conflits une seule fois après le chargement des données
|
||||
if (!_isLoadingConflicts && _conflictingEquipmentIds.isEmpty && allEquipment.isNotEmpty) {
|
||||
// Lancer le chargement des conflits en arrière-plan
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadEquipmentConflicts();
|
||||
});
|
||||
}
|
||||
// Charger les conflits une seule fois après le chargement des données
|
||||
if (!_isLoadingConflicts && !_conflictsLoaded && allEquipment.isNotEmpty) {
|
||||
// Lancer le chargement des conflits en arrière-plan
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadEquipmentConflicts();
|
||||
});
|
||||
}
|
||||
|
||||
// Filtrage des boîtes
|
||||
final filteredContainers = allContainers.where((container) {
|
||||
// Utiliser ValueListenableBuilder pour rebuild uniquement sur changement de sélection
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _selectionChangeNotifier,
|
||||
builder: (context, _, __) {
|
||||
// Filtrage des boîtes
|
||||
final filteredContainers = allContainers.where((container) {
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
final searchLower = _searchQuery.toLowerCase();
|
||||
return container.id.toLowerCase().contains(searchLower) ||
|
||||
@@ -1052,7 +1079,9 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
}).toList();
|
||||
|
||||
return ListView(
|
||||
controller: _scrollController, // Préserve la position de scroll
|
||||
padding: const EdgeInsets.all(16),
|
||||
cacheExtent: 1000, // Cache plus d'items pour éviter les rebuilds lors du scroll
|
||||
children: [
|
||||
// SECTION 1 : BOÎTES
|
||||
if (filteredContainers.isNotEmpty) ...[
|
||||
@@ -1094,10 +1123,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
); // Fin du ValueListenableBuilder
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1304,9 +1331,19 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
),
|
||||
),
|
||||
|
||||
// Sélecteur de quantité pour consommables
|
||||
if (isSelected && isConsumable && availableQty != null)
|
||||
_buildQuantitySelector(equipment.id, selectedItem!, availableQty),
|
||||
// Sélecteur de quantité pour consommables (toujours affiché)
|
||||
if (isConsumable && availableQty != null)
|
||||
_buildQuantitySelector(
|
||||
equipment.id,
|
||||
selectedItem ?? SelectedItem(
|
||||
id: equipment.id,
|
||||
name: equipment.id,
|
||||
type: SelectionType.equipment,
|
||||
quantity: 0, // Quantité 0 si non sélectionné
|
||||
),
|
||||
availableQty,
|
||||
isSelected: isSelected, // Passer l'état de sélection
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1414,40 +1451,62 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Widget pour le sélecteur de quantité (sans setState global pour éviter le refresh)
|
||||
Widget _buildQuantitySelector(String equipmentId, SelectedItem selectedItem, int maxQuantity) {
|
||||
/// Widget pour le sélecteur de quantité
|
||||
/// Si isSelected = false, le premier clic sur + sélectionne l'item avec quantité 1
|
||||
Widget _buildQuantitySelector(
|
||||
String equipmentId,
|
||||
SelectedItem selectedItem,
|
||||
int maxQuantity, {
|
||||
required bool isSelected,
|
||||
}) {
|
||||
final displayQuantity = isSelected ? selectedItem.quantity : 0;
|
||||
|
||||
return Container(
|
||||
width: 120,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed: selectedItem.quantity > 1
|
||||
onPressed: isSelected && selectedItem.quantity > 1
|
||||
? () {
|
||||
setState(() {
|
||||
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity - 1);
|
||||
});
|
||||
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity - 1);
|
||||
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
|
||||
}
|
||||
: null,
|
||||
iconSize: 20,
|
||||
color: isSelected && selectedItem.quantity > 1 ? AppColors.rouge : Colors.grey,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${selectedItem.quantity}',
|
||||
'$displayQuantity',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected ? Colors.black : Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: selectedItem.quantity < maxQuantity
|
||||
onPressed: (isSelected && selectedItem.quantity < maxQuantity) || !isSelected
|
||||
? () {
|
||||
setState(() {
|
||||
if (!isSelected) {
|
||||
// Premier clic : sélectionner avec quantité 1
|
||||
_toggleSelection(
|
||||
equipmentId,
|
||||
selectedItem.name,
|
||||
SelectionType.equipment,
|
||||
maxQuantity: maxQuantity,
|
||||
);
|
||||
} else {
|
||||
// Item déjà sélectionné : incrémenter
|
||||
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity + 1);
|
||||
});
|
||||
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
|
||||
}
|
||||
}
|
||||
: null,
|
||||
iconSize: 20,
|
||||
color: AppColors.rouge,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1635,13 +1694,12 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
color: AppColors.rouge,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (isExpanded) {
|
||||
_expandedContainers.remove(container.id);
|
||||
} else {
|
||||
_expandedContainers.add(container.id);
|
||||
}
|
||||
});
|
||||
if (isExpanded) {
|
||||
_expandedContainers.remove(container.id);
|
||||
} else {
|
||||
_expandedContainers.add(container.id);
|
||||
}
|
||||
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
|
||||
},
|
||||
tooltip: isExpanded ? 'Replier' : 'Voir le contenu',
|
||||
),
|
||||
@@ -1996,13 +2054,12 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (isExpanded) {
|
||||
_expandedContainers.remove(id);
|
||||
} else {
|
||||
_expandedContainers.add(id);
|
||||
}
|
||||
});
|
||||
if (isExpanded) {
|
||||
_expandedContainers.remove(id);
|
||||
} else {
|
||||
_expandedContainers.add(id);
|
||||
}
|
||||
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
|
||||
65
em2rp/lib/views/widgets/event/optimized_equipment_card.dart
Normal file
65
em2rp/lib/views/widgets/event/optimized_equipment_card.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
|
||||
|
||||
/// Widget optimisé pour une card d'équipement qui ne rebuild que si nécessaire
|
||||
class OptimizedEquipmentCard extends StatefulWidget {
|
||||
final EquipmentModel equipment;
|
||||
final bool isSelected;
|
||||
final int? selectedQuantity;
|
||||
final bool hasConflict;
|
||||
final String? conflictMessage;
|
||||
final int? availableQuantity;
|
||||
final VoidCallback onTap;
|
||||
final Function(int)? onQuantityChanged;
|
||||
|
||||
const OptimizedEquipmentCard({
|
||||
super.key,
|
||||
required this.equipment,
|
||||
required this.isSelected,
|
||||
this.selectedQuantity,
|
||||
required this.hasConflict,
|
||||
this.conflictMessage,
|
||||
this.availableQuantity,
|
||||
required this.onTap,
|
||||
this.onQuantityChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<OptimizedEquipmentCard> createState() => _OptimizedEquipmentCardState();
|
||||
}
|
||||
|
||||
class _OptimizedEquipmentCardState extends State<OptimizedEquipmentCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Le contenu de la card sera ici
|
||||
// Pour l'instant, retournons juste un placeholder
|
||||
return Card(
|
||||
key: ValueKey('equipment_${widget.equipment.id}'),
|
||||
child: ListTile(
|
||||
title: Text(widget.equipment.id),
|
||||
subtitle: Text('${widget.equipment.brand} - ${widget.equipment.model}'),
|
||||
trailing: widget.isSelected ? Icon(Icons.check_circle, color: Colors.green) : null,
|
||||
onTap: widget.onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is OptimizedEquipmentCard &&
|
||||
runtimeType == other.runtimeType &&
|
||||
widget.equipment.id == other.equipment.id &&
|
||||
widget.isSelected == other.isSelected &&
|
||||
widget.selectedQuantity == other.selectedQuantity &&
|
||||
widget.hasConflict == other.hasConflict;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
widget.equipment.id.hashCode ^
|
||||
widget.isSelected.hashCode ^
|
||||
widget.selectedQuantity.hashCode ^
|
||||
widget.hasConflict.hashCode;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
@@ -137,7 +138,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
}
|
||||
|
||||
Future<void> _processSelection(Map<String, SelectedItem> selection) async {
|
||||
print('[EventAssignedEquipmentSection] Processing selection of ${selection.length} items');
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Processing selection of ${selection.length} items');
|
||||
|
||||
// Séparer équipements et conteneurs
|
||||
final newEquipment = <EventEquipment>[];
|
||||
@@ -154,7 +155,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
}
|
||||
}
|
||||
|
||||
print('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
|
||||
|
||||
// Charger les équipements et conteneurs
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
@@ -163,13 +164,13 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
final allContainers = await containerProvider.containersStream.first;
|
||||
final allEquipment = await equipmentProvider.equipmentStream.first;
|
||||
|
||||
print('[EventAssignedEquipmentSection] Starting conflict checks...');
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Starting conflict checks...');
|
||||
final allConflicts = <String, List<AvailabilityConflict>>{};
|
||||
|
||||
// 1. Vérifier les conflits pour les équipements directs
|
||||
print('[EventAssignedEquipmentSection] Checking conflicts for ${newEquipment.length} equipment(s)');
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Checking conflicts for ${newEquipment.length} equipment(s)');
|
||||
for (var eq in newEquipment) {
|
||||
print('[EventAssignedEquipmentSection] Checking equipment: ${eq.equipmentId}');
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Checking equipment: ${eq.equipmentId}');
|
||||
|
||||
final equipment = allEquipment.firstWhere(
|
||||
(e) => e.id == eq.equipmentId,
|
||||
@@ -185,7 +186,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
),
|
||||
);
|
||||
|
||||
print('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: hasQuantity=${equipment.hasQuantity}');
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: hasQuantity=${equipment.hasQuantity}');
|
||||
|
||||
// Pour les équipements quantifiables (consommables/câbles)
|
||||
if (equipment.hasQuantity) {
|
||||
@@ -225,16 +226,16 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
);
|
||||
|
||||
if (conflicts.isNotEmpty) {
|
||||
print('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: ${conflicts.length} conflict(s) found');
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: ${conflicts.length} conflict(s) found');
|
||||
allConflicts[eq.equipmentId] = conflicts;
|
||||
} else {
|
||||
print('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: no conflicts');
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: no conflicts');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Vérifier les conflits pour les boîtes et leur contenu
|
||||
print('[EventAssignedEquipmentSection] Checking conflicts for ${newContainers.length} container(s)');
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Checking conflicts for ${newContainers.length} container(s)');
|
||||
for (var containerId in newContainers) {
|
||||
final container = allContainers.firstWhere(
|
||||
(c) => c.id == containerId,
|
||||
@@ -305,37 +306,37 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
}
|
||||
|
||||
if (containerConflicts.isNotEmpty) {
|
||||
print('[EventAssignedEquipmentSection] Container $containerId: ${containerConflicts.length} conflict(s) found');
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Container $containerId: ${containerConflicts.length} conflict(s) found');
|
||||
allConflicts[containerId] = containerConflicts;
|
||||
} else {
|
||||
print('[EventAssignedEquipmentSection] Container $containerId: no conflicts');
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Container $containerId: no conflicts');
|
||||
}
|
||||
}
|
||||
|
||||
print('[EventAssignedEquipmentSection] Total conflicts found: ${allConflicts.length}');
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Total conflicts found: ${allConflicts.length}');
|
||||
|
||||
if (allConflicts.isNotEmpty) {
|
||||
print('[EventAssignedEquipmentSection] Showing conflict dialog with ${allConflicts.length} items in conflict');
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Showing conflict dialog with ${allConflicts.length} items in conflict');
|
||||
// Afficher le dialog de conflits
|
||||
final action = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => EquipmentConflictDialog(conflicts: allConflicts),
|
||||
);
|
||||
|
||||
print('[EventAssignedEquipmentSection] Conflict dialog result: $action');
|
||||
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Conflict dialog result: $action');
|
||||
|
||||
if (action == 'cancel') {
|
||||
return; // Annuler l'ajout
|
||||
} else if (action == 'force_removed') {
|
||||
// Identifier quels équipements/conteneurs retirer
|
||||
final removedIds = allConflicts.keys.toSet();
|
||||
|
||||
|
||||
// Retirer les équipements directs en conflit
|
||||
newEquipment.removeWhere((eq) => removedIds.contains(eq.equipmentId));
|
||||
|
||||
|
||||
// Retirer les boîtes en conflit
|
||||
newContainers.removeWhere((containerId) => removedIds.contains(containerId));
|
||||
|
||||
|
||||
// Informer l'utilisateur des boîtes retirées
|
||||
for (var containerId in removedIds.where((id) => newContainers.contains(id))) {
|
||||
if (mounted) {
|
||||
@@ -363,15 +364,15 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
}
|
||||
// Si 'force_all', on garde tout
|
||||
}
|
||||
|
||||
|
||||
// Fusionner avec l'existant
|
||||
final updatedEquipment = [...widget.assignedEquipment];
|
||||
final updatedContainers = [...widget.assignedContainers];
|
||||
|
||||
|
||||
// Pour chaque nouvel équipement
|
||||
for (var eq in newEquipment) {
|
||||
final existingIndex = updatedEquipment.indexWhere((e) => e.equipmentId == eq.equipmentId);
|
||||
|
||||
|
||||
if (existingIndex != -1) {
|
||||
// L'équipement existe déjà : mettre à jour la quantité
|
||||
updatedEquipment[existingIndex] = EventEquipment(
|
||||
@@ -386,19 +387,19 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
updatedEquipment.add(eq);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (var containerId in newContainers) {
|
||||
if (!updatedContainers.contains(containerId)) {
|
||||
updatedContainers.add(containerId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Notifier le changement
|
||||
widget.onChanged(updatedEquipment, updatedContainers);
|
||||
|
||||
|
||||
// Recharger le cache
|
||||
await _loadEquipmentAndContainers();
|
||||
}
|
||||
}
|
||||
|
||||
void _removeEquipment(String equipmentId) {
|
||||
final updated = widget.assignedEquipment
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:em2rp/models/event_type_model.dart';
|
||||
import 'package:em2rp/views/widgets/event_form/price_ht_ttc_fields.dart';
|
||||
|
||||
class EventBasicInfoSection extends StatelessWidget {
|
||||
final TextEditingController nameController;
|
||||
@@ -80,29 +81,9 @@ class EventBasicInfoSection extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: basePriceController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Prix de base (€)*',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.euro),
|
||||
hintText: '1050.50',
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Le prix de base est requis';
|
||||
}
|
||||
final price = double.tryParse(value.replaceAll(',', '.'));
|
||||
if (price == null) {
|
||||
return 'Veuillez entrer un nombre valide';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (_) => onAnyFieldChanged(),
|
||||
PriceHtTtcFields(
|
||||
basePriceController: basePriceController,
|
||||
onPriceChanged: onAnyFieldChanged,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/utils/price_helpers.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
|
||||
@@ -62,10 +64,14 @@ class EventOptionsDisplayWidget extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...enrichedOptions.map((opt) {
|
||||
final price = (opt['price'] ?? 0.0) as num;
|
||||
final priceTTC = (opt['price'] ?? 0.0) as num;
|
||||
final quantity = (opt['quantity'] ?? 1) as int;
|
||||
final totalPrice = price * quantity;
|
||||
final isNegative = totalPrice < 0;
|
||||
final totalPriceTTC = priceTTC * quantity;
|
||||
final isNegative = totalPriceTTC < 0;
|
||||
|
||||
// Calculer le prix HT
|
||||
final priceHT = PriceHelpers.calculateHT(priceTTC.toDouble());
|
||||
final totalPriceHT = priceHT * quantity;
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(Icons.tune, color: AppColors.rouge),
|
||||
@@ -98,28 +104,64 @@ class EventOptionsDisplayWidget extends StatelessWidget {
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
if (quantity > 1 && canViewPrices)
|
||||
if (quantity > 1 && canViewPrices) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(
|
||||
'${currencyFormat.format(price)} × $quantity',
|
||||
'HT: ${currencyFormat.format(priceHT)} × $quantity',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[700],
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'TTC: ${currencyFormat.format(priceTTC)} × $quantity',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[700],
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
] else if (canViewPrices) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(
|
||||
'HT: ${currencyFormat.format(priceHT)}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 10,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: canViewPrices
|
||||
? Text(
|
||||
(isNegative ? '- ' : '+ ') +
|
||||
currencyFormat.format(totalPrice.abs()),
|
||||
style: TextStyle(
|
||||
color: isNegative ? Colors.red : AppColors.noir,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
(isNegative ? '- ' : '+ ') +
|
||||
currencyFormat.format(totalPriceTTC.abs()),
|
||||
style: TextStyle(
|
||||
color: isNegative ? Colors.red : AppColors.noir,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'HT: ${currencyFormat.format(totalPriceHT.abs())}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 10,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
@@ -139,31 +181,71 @@ class EventOptionsDisplayWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildTotalPrice(BuildContext context, List<Map<String, dynamic>> options, NumberFormat currencyFormat) {
|
||||
final optionsTotal = options.fold<num>(0, (sum, opt) {
|
||||
final price = opt['price'] ?? 0.0;
|
||||
final optionsTotalTTC = options.fold<num>(0, (sum, opt) {
|
||||
final priceTTC = opt['price'] ?? 0.0;
|
||||
final quantity = opt['quantity'] ?? 1;
|
||||
return sum + (price * quantity);
|
||||
return sum + (priceTTC * quantity);
|
||||
});
|
||||
|
||||
// Calculer le total HT
|
||||
final optionsTotalHT = PriceHelpers.calculateHT(optionsTotalTTC.toDouble());
|
||||
final optionsTVA = PriceHelpers.calculateTax(optionsTotalHT);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.tune, color: AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Total options : ',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.noir,
|
||||
fontWeight: FontWeight.bold,
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.tune, color: AppColors.rouge, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Total options HT : ',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.noir,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
currencyFormat.format(optionsTotalHT),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.noir,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 28.0, top: 2.0),
|
||||
child: Text(
|
||||
'TVA (20%) : ${currencyFormat.format(optionsTVA)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
currencyFormat.format(optionsTotal),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.rouge,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.attach_money, color: AppColors.rouge, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Total options TTC : ',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.noir,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
currencyFormat.format(optionsTotalTTC),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: AppColors.rouge,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -224,7 +306,7 @@ class EventOptionsDisplayWidget extends StatelessWidget {
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print('Erreur lors du chargement de l\'option ${optionData['id']}: $e');
|
||||
DebugLog.error('Erreur lors du chargement de l\'option ${optionData['id']}', e);
|
||||
// En cas d'erreur, créer une entrée avec les données disponibles
|
||||
enrichedOptions.add({
|
||||
'id': optionData['id'],
|
||||
|
||||
235
em2rp/lib/views/widgets/event_form/price_ht_ttc_fields.dart
Normal file
235
em2rp/lib/views/widgets/event_form/price_ht_ttc_fields.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:em2rp/utils/price_helpers.dart';
|
||||
|
||||
/// Widget pour gérer les prix HT et TTC avec calcul automatique
|
||||
/// Permet de saisir soit le prix HT, soit le prix TTC, l'autre est calculé automatiquement
|
||||
class PriceHtTtcFields extends StatefulWidget {
|
||||
final TextEditingController basePriceController;
|
||||
final VoidCallback onPriceChanged;
|
||||
final double taxRate;
|
||||
|
||||
const PriceHtTtcFields({
|
||||
super.key,
|
||||
required this.basePriceController,
|
||||
required this.onPriceChanged,
|
||||
this.taxRate = 0.20,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PriceHtTtcFields> createState() => _PriceHtTtcFieldsState();
|
||||
}
|
||||
|
||||
class _PriceHtTtcFieldsState extends State<PriceHtTtcFields> {
|
||||
final TextEditingController _htController = TextEditingController();
|
||||
final TextEditingController _ttcController = TextEditingController();
|
||||
bool _updatingFromHT = false;
|
||||
bool _updatingFromTTC = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialiser avec la valeur existante (considérée comme TTC)
|
||||
if (widget.basePriceController.text.isNotEmpty) {
|
||||
_ttcController.text = widget.basePriceController.text;
|
||||
_updateHTFromTTC();
|
||||
}
|
||||
|
||||
// Synchroniser basePriceController avec TTC
|
||||
_htController.addListener(_onHTChanged);
|
||||
_ttcController.addListener(_onTTCChanged);
|
||||
|
||||
// Écouter les changements externes du basePriceController (ex: changement de type d'événement)
|
||||
widget.basePriceController.addListener(_onBasePriceControllerChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_htController.removeListener(_onHTChanged);
|
||||
_ttcController.removeListener(_onTTCChanged);
|
||||
widget.basePriceController.removeListener(_onBasePriceControllerChanged);
|
||||
_htController.dispose();
|
||||
_ttcController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Appelé quand basePriceController change de l'extérieur (ex: sélection type d'événement)
|
||||
void _onBasePriceControllerChanged() {
|
||||
// Éviter la boucle infinie si le changement vient de nous
|
||||
if (_updatingFromHT || _updatingFromTTC) return;
|
||||
|
||||
final newTTCText = widget.basePriceController.text;
|
||||
|
||||
// Si le texte est différent de ce qu'on a dans _ttcController, mettre à jour
|
||||
if (newTTCText != _ttcController.text) {
|
||||
_ttcController.text = newTTCText;
|
||||
if (newTTCText.isNotEmpty) {
|
||||
_updateHTFromTTC();
|
||||
} else {
|
||||
_htController.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onHTChanged() {
|
||||
if (_updatingFromTTC) return;
|
||||
|
||||
final htText = _htController.text.replaceAll(',', '.');
|
||||
final htValue = double.tryParse(htText);
|
||||
|
||||
if (htValue != null) {
|
||||
_updatingFromHT = true;
|
||||
final ttcValue = PriceHelpers.calculateTTC(htValue, taxRate: widget.taxRate);
|
||||
_ttcController.text = ttcValue.toStringAsFixed(2);
|
||||
|
||||
// Mettre à jour basePriceController (qui stocke le prix TTC)
|
||||
widget.basePriceController.text = ttcValue.toStringAsFixed(2);
|
||||
widget.onPriceChanged();
|
||||
|
||||
_updatingFromHT = false;
|
||||
} else if (htText.isEmpty) {
|
||||
_ttcController.clear();
|
||||
widget.basePriceController.clear();
|
||||
widget.onPriceChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void _onTTCChanged() {
|
||||
if (_updatingFromHT) return;
|
||||
|
||||
final ttcText = _ttcController.text.replaceAll(',', '.');
|
||||
final ttcValue = double.tryParse(ttcText);
|
||||
|
||||
if (ttcValue != null) {
|
||||
_updatingFromTTC = true;
|
||||
final htValue = PriceHelpers.calculateHT(ttcValue, taxRate: widget.taxRate);
|
||||
_htController.text = htValue.toStringAsFixed(2);
|
||||
|
||||
// Mettre à jour basePriceController (qui stocke le prix TTC)
|
||||
widget.basePriceController.text = ttcValue.toStringAsFixed(2);
|
||||
widget.onPriceChanged();
|
||||
|
||||
_updatingFromTTC = false;
|
||||
} else if (ttcText.isEmpty) {
|
||||
_htController.clear();
|
||||
widget.basePriceController.clear();
|
||||
widget.onPriceChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void _updateHTFromTTC() {
|
||||
final ttcText = _ttcController.text.replaceAll(',', '.');
|
||||
final ttcValue = double.tryParse(ttcText);
|
||||
|
||||
if (ttcValue != null) {
|
||||
final htValue = PriceHelpers.calculateHT(ttcValue, taxRate: widget.taxRate);
|
||||
_htController.text = htValue.toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Prix',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
// Champ Prix HT
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _htController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Prix HT (€)*',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.euro),
|
||||
hintText: '1000.00',
|
||||
helperText: 'Hors taxes',
|
||||
helperStyle: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Requis';
|
||||
}
|
||||
final price = double.tryParse(value.replaceAll(',', '.'));
|
||||
if (price == null) {
|
||||
return 'Nombre invalide';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Champ Prix TTC
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _ttcController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Prix TTC (€)*',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.attach_money),
|
||||
hintText: '1200.00',
|
||||
helperText: 'Toutes taxes comprises',
|
||||
helperStyle: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Requis';
|
||||
}
|
||||
final price = double.tryParse(value.replaceAll(',', '.'));
|
||||
if (price == null) {
|
||||
return 'Nombre invalide';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Affichage du montant de TVA (en plus petit)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final htText = _htController.text.replaceAll(',', '.');
|
||||
final htValue = double.tryParse(htText);
|
||||
|
||||
if (htValue != null) {
|
||||
final taxAmount = PriceHelpers.calculateTax(htValue, taxRate: widget.taxRate);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, top: 4.0),
|
||||
child: Text(
|
||||
'TVA (${(widget.taxRate * 100).toStringAsFixed(0)}%) : ${taxAmount.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[600],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/models/option_model.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
@@ -379,37 +380,37 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Debug: Afficher les informations de filtrage
|
||||
print('=== DEBUG OptionPickerDialog ===');
|
||||
print('widget.eventType: ${widget.eventType}');
|
||||
print('_currentOptions.length: ${_currentOptions.length}');
|
||||
DebugLog.info('=== DEBUG OptionPickerDialog ===');
|
||||
DebugLog.info('widget.eventType: ${widget.eventType}');
|
||||
DebugLog.info('_currentOptions.length: ${_currentOptions.length}');
|
||||
|
||||
final filtered = _currentOptions.where((opt) {
|
||||
print('Option: ${opt.name}');
|
||||
print(' opt.eventTypes: ${opt.eventTypes}');
|
||||
print(' widget.eventType: ${widget.eventType}');
|
||||
DebugLog.info('Option: ${opt.name}');
|
||||
DebugLog.info(' opt.eventTypes: ${opt.eventTypes}');
|
||||
DebugLog.info(' widget.eventType: ${widget.eventType}');
|
||||
|
||||
if (widget.eventType == null) {
|
||||
print(' -> Filtered out: eventType is null');
|
||||
DebugLog.info(' -> Filtered out: eventType is null');
|
||||
return false;
|
||||
}
|
||||
|
||||
final matchesType = opt.eventTypes.contains(widget.eventType);
|
||||
print(' -> matchesType: $matchesType');
|
||||
DebugLog.info(' -> matchesType: $matchesType');
|
||||
|
||||
// Recherche dans le code ET le nom
|
||||
final searchLower = _search.toLowerCase();
|
||||
final matchesSearch = opt.name.toLowerCase().contains(searchLower) ||
|
||||
opt.code.toLowerCase().contains(searchLower);
|
||||
print(' -> matchesSearch: $matchesSearch');
|
||||
DebugLog.info(' -> matchesSearch: $matchesSearch');
|
||||
|
||||
final result = matchesType && matchesSearch;
|
||||
print(' -> Final result: $result');
|
||||
DebugLog.info(' -> Final result: $result');
|
||||
|
||||
return result;
|
||||
}).toList();
|
||||
|
||||
print('Filtered options count: ${filtered.length}');
|
||||
print('===========================');
|
||||
DebugLog.info('Filtered options count: ${filtered.length}');
|
||||
DebugLog.info('===========================');
|
||||
|
||||
return Dialog(
|
||||
child: SizedBox(
|
||||
|
||||
Reference in New Issue
Block a user