refactor: Ajout des sous-catégories et refonte de la gestion de l'appartenance

Cette mise à jour structurelle améliore la classification des équipements en introduisant la notion de sous-catégories et supprime la gestion directe de l'appartenance d'un équipement à une boîte (`parentBoxIds`). L'appartenance est désormais uniquement définie côté conteneur. Une nouvelle catégorie "Régie / Backline" est également ajoutée.

**Changements majeurs :**

-   **Suppression de `parentBoxIds` sur `EquipmentModel` :**
    -   Le champ `parentBoxIds` a été retiré du modèle de données `EquipmentModel` et de toutes les logiques associées (création, mise à jour, copie).
    -   La responsabilité de lier un équipement à un conteneur est désormais exclusivement gérée par le `ContainerModel` via sa liste `equipmentIds`.
    -   La logique de synchronisation complexe dans `EquipmentFormPage` qui mettait à jour les conteneurs lors de la modification d'un équipement a été entièrement supprimée, simplifiant considérablement le code.
    -   Le sélecteur de boîtes parentes (`ParentBoxesSelector`) a été retiré du formulaire d'équipement.

-   **Ajout des sous-catégories :**
    -   Un champ optionnel `subCategory` (String) a été ajouté au `EquipmentModel`.
    -   Le formulaire de création/modification d'équipement inclut désormais un nouveau champ "Sous-catégorie" avec autocomplétion.
    -   Ce champ est contextuel : il propose des suggestions basées sur les sous-catégories existantes pour la catégorie principale sélectionnée (ex: "Console", "Micro" pour la catégorie "Son").
    -   La sous-catégorie est maintenant affichée sur les fiches de détail des équipements et dans les listes de la page de gestion, améliorant la visibilité du classement.

**Nouvelle catégorie d'équipement :**

-   Une nouvelle catégorie `backline` ("Régie / Backline") a été ajoutée à `EquipmentCategory` avec une icône (`Icons.piano`) et une couleur associée.

**Refactorisation et nettoyage :**

-   Le `EquipmentProvider` et `EquipmentService` ont été mis à jour pour charger et filtrer les sous-catégories.
-   De nombreuses instanciations d'un `EquipmentModel` vide (`dummy`) à travers l'application ont été nettoyées pour retirer la référence à `parentBoxIds`.

-   **Version de l'application :**
    -   La version a été incrémentée à `1.0.4`.
This commit is contained in:
ElPoyo
2026-01-17 12:07:20 +01:00
parent 7e111ec041
commit b79791ff7a
16 changed files with 204 additions and 155 deletions

View File

@@ -624,6 +624,8 @@ class _ContainerDetailPageState extends State<ContainerDetailPage> {
return 'Câble';
case EquipmentCategory.vehicle:
return 'Véhicule';
case EquipmentCategory.backline:
return 'Régie / Backline';
case EquipmentCategory.other:
return 'Autre';
}

View File

@@ -914,6 +914,8 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
return 'Câble';
case EquipmentCategory.vehicle:
return 'Véhicule';
case EquipmentCategory.backline:
return 'Régie / Backline';
case EquipmentCategory.other:
return 'Autre';
}
@@ -937,6 +939,8 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
return Icons.cable;
case EquipmentCategory.vehicle:
return Icons.local_shipping;
case EquipmentCategory.backline:
return Icons.piano;
case EquipmentCategory.other:
return Icons.category;
}

View File

@@ -707,7 +707,6 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
name: '',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
parentBoxIds: [],
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),

View File

@@ -249,6 +249,17 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(),
style: TextStyle(color: Colors.grey[700]),
),
if (widget.equipment.subCategory != null && widget.equipment.subCategory!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'📁 ${widget.equipment.subCategory}',
style: TextStyle(
color: Colors.grey[600],
fontSize: 13,
fontStyle: FontStyle.italic,
),
),
],
],
),
),

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/equipment_model.dart';
/// Widget de sélection de sous-catégorie avec autocomplétion
/// Similaire au système Brand/Model mais filtré par catégorie
class SubCategorySelector extends StatelessWidget {
final TextEditingController controller;
final EquipmentCategory? selectedCategory;
final List<String> filteredSubCategories;
final ValueChanged<String?>? onChanged;
const SubCategorySelector({
super.key,
required this.controller,
required this.selectedCategory,
required this.filteredSubCategories,
this.onChanged,
});
@override
Widget build(BuildContext context) {
return Autocomplete<String>(
initialValue: TextEditingValue(text: controller.text),
optionsBuilder: (TextEditingValue textEditingValue) {
if (selectedCategory == null) {
return const Iterable<String>.empty();
}
if (textEditingValue.text.isEmpty) {
return filteredSubCategories;
}
return filteredSubCategories.where((String subCategory) {
return subCategory.toLowerCase().contains(
textEditingValue.text.toLowerCase(),
);
});
},
onSelected: (String selection) {
controller.text = selection;
onChanged?.call(selection);
},
fieldViewBuilder: (context, fieldController, focusNode, onEditingComplete) {
if (fieldController.text != controller.text) {
fieldController.text = controller.text;
}
return TextFormField(
controller: fieldController,
focusNode: focusNode,
enabled: selectedCategory != null,
decoration: InputDecoration(
labelText: 'Sous-catégorie',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.category_outlined),
hintText: selectedCategory == null
? 'Catégorie requise'
: 'Saisissez la sous-catégorie',
helperText: 'Optionnel - Permet un classement plus précis',
),
onChanged: (value) {
controller.text = value;
onChanged?.call(value.isNotEmpty ? value : null);
},
);
},
);
}
}

View File

@@ -2,19 +2,16 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/container_equipment_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:intl/intl.dart';
import 'package:em2rp/views/equipment_form/brand_model_selector.dart';
import 'package:em2rp/views/equipment_form/subcategory_selector.dart';
import 'package:em2rp/utils/id_generator.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/views/widgets/equipment/parent_boxes_selector.dart';
class EquipmentFormPage extends StatefulWidget {
final EquipmentModel? equipment;
@@ -33,6 +30,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
final TextEditingController _identifierController = TextEditingController();
final TextEditingController _brandController = TextEditingController();
final TextEditingController _modelController = TextEditingController();
final TextEditingController _subCategoryController = TextEditingController();
final TextEditingController _purchasePriceController = TextEditingController();
final TextEditingController _rentalPriceController = TextEditingController();
final TextEditingController _totalQuantityController = TextEditingController();
@@ -46,18 +44,15 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
DateTime? _purchaseDate;
DateTime? _lastMaintenanceDate;
DateTime? _nextMaintenanceDate;
List<String> _selectedParentBoxIds = [];
List<ContainerModel> _availableBoxes = [];
bool _isLoading = false;
bool _isLoadingBoxes = true;
bool _addMultiple = false;
String? _selectedBrand;
List<String> _filteredModels = [];
List<String> _filteredSubCategories = [];
@override
void initState() {
super.initState();
_loadAvailableBoxes();
WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = Provider.of<EquipmentProvider>(context, listen: false);
provider.loadBrands();
@@ -75,6 +70,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
_brandController.text = equipment.brand ?? '';
_selectedBrand = equipment.brand;
_modelController.text = equipment.model ?? '';
_subCategoryController.text = equipment.subCategory ?? '';
_selectedCategory = equipment.category;
_selectedStatus = equipment.status;
_purchasePriceController.text = equipment.purchasePrice?.toStringAsFixed(2) ?? '';
@@ -89,51 +85,15 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}');
// Charger les containers contenant cet équipement depuis Firestore
_loadCurrentContainers(equipment.id);
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
_loadFilteredModels(_selectedBrand!);
}
// Charger les sous-catégories pour la catégorie sélectionnée
_loadFilteredSubCategories(_selectedCategory);
}
/// Charge les containers qui contiennent actuellement cet équipement
Future<void> _loadCurrentContainers(String equipmentId) async {
try {
final containers = await containerEquipmentService.getContainersByEquipment(equipmentId);
setState(() {
_selectedParentBoxIds = containers.map((c) => c.id).toList();
});
DebugLog.info('[EquipmentForm] Loaded ${containers.length} containers for equipment $equipmentId');
DebugLog.info('[EquipmentForm] Selected container IDs: $_selectedParentBoxIds');
} catch (e) {
DebugLog.error('[EquipmentForm] Error loading containers for equipment', e);
}
}
Future<void> _loadAvailableBoxes() async {
try {
final boxes = await _equipmentService.getBoxes();
DebugLog.info('[EquipmentForm] Loaded ${boxes.length} boxes from service');
for (var box in boxes) {
DebugLog.info('[EquipmentForm] Box loaded - ID: ${box.id}, Name: ${box.name}');
}
setState(() {
_availableBoxes = boxes;
_isLoadingBoxes = false;
});
} catch (e) {
DebugLog.error('[EquipmentForm] Error loading boxes', e);
setState(() {
_isLoadingBoxes = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors du chargement des boîtes : $e')),
);
}
}
}
Future<void> _loadFilteredModels(String brand) async {
try {
@@ -149,11 +109,26 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
}
}
Future<void> _loadFilteredSubCategories(EquipmentCategory category) async {
try {
final equipmentProvider = Provider.of<EquipmentProvider>(context, listen: false);
final subCategories = await equipmentProvider.loadSubCategoriesByCategory(category);
setState(() {
_filteredSubCategories = subCategories;
});
} catch (e) {
setState(() {
_filteredSubCategories = [];
});
}
}
@override
void dispose() {
_identifierController.dispose();
_brandController.dispose();
_modelController.dispose();
_subCategoryController.dispose();
_purchasePriceController.dispose();
_rentalPriceController.dispose();
_totalQuantityController.dispose();
@@ -312,7 +287,9 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
if (value != null) {
setState(() {
_selectedCategory = value;
_subCategoryController.clear();
});
_loadFilteredSubCategories(value);
}
},
),
@@ -348,6 +325,19 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
),
const SizedBox(height: 16),
// Sous-catégorie
SubCategorySelector(
controller: _subCategoryController,
selectedCategory: _selectedCategory,
filteredSubCategories: _filteredSubCategories,
onChanged: (value) {
setState(() {
// La valeur est déjà dans le controller
});
},
),
const SizedBox(height: 16),
// Prix
if (hasManagePermission) ...[
Row(
@@ -419,18 +409,6 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
const SizedBox(height: 16),
],
// Boîtes parentes
const SizedBox(height: 8),
_isLoadingBoxes
? const Card(
child: Padding(
padding: EdgeInsets.all(32.0),
child: Center(child: CircularProgressIndicator()),
),
)
: _buildParentBoxesSelector(),
const SizedBox(height: 16),
// Dates
const Divider(),
const Text('Dates', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
@@ -481,17 +459,6 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
);
}
Widget _buildParentBoxesSelector() {
return ParentBoxesSelector(
availableBoxes: _availableBoxes,
selectedBoxIds: _selectedParentBoxIds,
onSelectionChanged: (newSelection) {
setState(() {
_selectedParentBoxIds = newSelection;
});
},
);
}
Widget _buildDateField({required String label, required IconData icon, required DateTime? value, required VoidCallback onTap}) {
return InkWell(
@@ -629,6 +596,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
brand: brand,
model: model,
category: _selectedCategory,
subCategory: _subCategoryController.text.trim().isNotEmpty ? _subCategoryController.text.trim() : null,
status: _selectedStatus,
purchasePrice: _purchasePriceController.text.isNotEmpty ? double.tryParse(_purchasePriceController.text) : null,
rentalPrice: _rentalPriceController.text.isNotEmpty ? double.tryParse(_rentalPriceController.text) : null,
@@ -637,7 +605,6 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
purchaseDate: _purchaseDate,
lastMaintenanceDate: _lastMaintenanceDate,
nextMaintenanceDate: _nextMaintenanceDate,
parentBoxIds: [], // On ne stocke plus les parentBoxIds dans l'équipement
notes: _notesController.text,
createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now,
updatedAt: now,
@@ -645,58 +612,8 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
);
if (isEditing) {
await equipmentProvider.updateEquipment(equipment);
// Synchroniser les containers : mettre à jour equipmentIds des containers
// Charger les anciens containers depuis Firestore
final oldContainers = await containerEquipmentService.getContainersByEquipment(equipment.id);
final oldParentBoxIds = oldContainers.map((c) => c.id).toList();
final newParentBoxIds = _selectedParentBoxIds;
// Boîtes ajoutées : ajouter cet équipement à leur equipmentIds
final addedBoxes = newParentBoxIds.where((id) => !oldParentBoxIds.contains(id));
for (final boxId in addedBoxes) {
try {
final containerProvider = Provider.of<ContainerProvider>(context, listen: false);
await containerProvider.addEquipmentToContainer(
containerId: boxId,
equipmentId: equipment.id,
);
DebugLog.info('[EquipmentForm] Added equipment ${equipment.id} to container $boxId');
} catch (e) {
DebugLog.error('[EquipmentForm] Error adding equipment to container $boxId', e);
}
}
// Boîtes retirées : retirer cet équipement de leur equipmentIds
final removedBoxes = oldParentBoxIds.where((id) => !newParentBoxIds.contains(id));
for (final boxId in removedBoxes) {
try {
final containerProvider = Provider.of<ContainerProvider>(context, listen: false);
await containerProvider.removeEquipmentFromContainer(
containerId: boxId,
equipmentId: equipment.id,
);
DebugLog.info('[EquipmentForm] Removed equipment ${equipment.id} from container $boxId');
} catch (e) {
DebugLog.error('[EquipmentForm] Error removing equipment from container $boxId', e);
}
}
} else {
await equipmentProvider.addEquipment(equipment);
// Pour un nouvel équipement, ajouter à tous les containers sélectionnés
for (final boxId in _selectedParentBoxIds) {
try {
final containerProvider = Provider.of<ContainerProvider>(context, listen: false);
await containerProvider.addEquipmentToContainer(
containerId: boxId,
equipmentId: equipment.id,
);
DebugLog.info('[EquipmentForm] Added new equipment ${equipment.id} to container $boxId');
} catch (e) {
DebugLog.error('[EquipmentForm] Error adding new equipment to container $boxId', e);
}
}
}
}

View File

@@ -559,6 +559,18 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
: 'Marque/Modèle non défini',
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
// Afficher la sous-catégorie si elle existe
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
'📁 ${equipment.subCategory}',
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
],
// Afficher la quantité disponible pour les consommables/câbles
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable) ...[
@@ -1099,7 +1111,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
name: '',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
parentBoxIds: [],
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),

View File

@@ -165,7 +165,6 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
name: 'Équipement inconnu',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
parentBoxIds: [],
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),

View File

@@ -104,6 +104,8 @@ class ContainerEquipmentTile extends StatelessWidget {
return 'Câble';
case EquipmentCategory.vehicle:
return 'Véhicule';
case EquipmentCategory.backline:
return 'Régie / Backline';
case EquipmentCategory.other:
return 'Autre';
}

View File

@@ -766,7 +766,6 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
name: 'Inconnu',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
parentBoxIds: [],
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
@@ -1156,7 +1155,6 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
name: '',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
parentBoxIds: [],
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),

View File

@@ -84,7 +84,6 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
name: 'Équipement inconnu',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
parentBoxIds: [],
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
@@ -179,7 +178,6 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
name: 'Inconnu',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
parentBoxIds: [],
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
@@ -259,7 +257,6 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
name: 'Inconnu',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
parentBoxIds: [],
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),