From 64a9fe382a7d052dcc221af1a99bdf686845e0db Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Tue, 26 May 2026 21:34:35 +0200 Subject: [PATCH] feat: updated container management system with core models, providers, and UI pages --- em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache | 26 +- em2rp/lib/main.dart | 88 +- em2rp/lib/models/container_model.dart | 64 +- em2rp/lib/models/equipment_model.dart | 66 +- em2rp/lib/models/maintenance_model.dart | 33 +- em2rp/lib/providers/equipment_provider.dart | 22 +- em2rp/lib/views/container_form_page.dart | 740 ++++++------ em2rp/lib/views/equipment_form_page.dart | 1057 ++++++++++++----- .../lib/views/equipment_management_page.dart | 13 +- .../widgets/calendar_widgets/month_view.dart | 7 +- .../equipment_associated_events_section.dart | 22 +- .../equipment_current_events_section.dart | 18 +- em2rp/web/index.html | 4 +- 13 files changed, 1363 insertions(+), 797 deletions(-) diff --git a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache index 57bf586..5961b56 100644 --- a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache +++ b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache @@ -34,16 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63 assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d -version.json,1779800968600,00f600f01984c1e371af870e40f78fd44ba53d05596f2f92f9b4fc56a85f52b6 -index.html,1779800974065,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 -flutter_service_worker.js,1779801065196,879f42a05578f24ea45dc23326fdda6246d38dc59de0824ef8d4edfa4715e571 -flutter_bootstrap.js,1779800974054,977a20d5caac8da21af648cae8fa7dba00a5cd959fd2fafa7ef538a012fe87c3 -assets/FontManifest.json,1779801061892,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 -assets/AssetManifest.json,1779801061891,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6 -assets/AssetManifest.bin.json,1779801061892,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53 -assets/AssetManifest.bin,1779801061891,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907 -assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779801064441,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb -assets/shaders/ink_sparkle.frag,1779801062097,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 -assets/fonts/MaterialIcons-Regular.otf,1779801064446,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6 -assets/NOTICES,1779801061893,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e -main.dart.js,1779801060964,e87fb4dfca93c3384b5cb63d627186e48b0c15d78ed63bc7f5e61544ef292dd9 +version.json,1779802456392,7626a7c596308bd2eb1add2ed984cd6dda5d4a3f0dedb3338244d2ae45c496cf +index.html,1779802461141,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 +flutter_service_worker.js,1779802555396,fadec2c9a1e8e16c22e332aef080b0b2aacc3998c4e260e5821b79afb9e000da +flutter_bootstrap.js,1779802461128,ad20054b92acf16bb75fbffd65f81c63c6d3cb6d752f799230dca5f2118af783 +assets/FontManifest.json,1779802551869,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 +assets/AssetManifest.json,1779802551869,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6 +assets/AssetManifest.bin.json,1779802551869,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53 +assets/AssetManifest.bin,1779802551869,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907 +assets/shaders/ink_sparkle.frag,1779802552052,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 +assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779802554433,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb +assets/fonts/MaterialIcons-Regular.otf,1779802554440,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6 +assets/NOTICES,1779802551871,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e +main.dart.js,1779802550741,ab892e930c97940c1ea4ff33079922082c7f688047d307acad0644a78cfda2d7 diff --git a/em2rp/lib/main.dart b/em2rp/lib/main.dart index a448251..ec2a201 100644 --- a/em2rp/lib/main.dart +++ b/em2rp/lib/main.dart @@ -39,46 +39,66 @@ void main() { // Ne pas effectuer d'initialisations asynchrones lourdes ici. WidgetsFlutterBinding.ensureInitialized(); - runApp( - MultiProvider( - providers: [ - // Fournisseur d'initialisation de l'application (initialise Firebase et cache en tâche de fond) - ChangeNotifierProvider( - create: (_) => AppInitializer(), - ), - // LocalUserProvider pour la gestion de l'authentification - ChangeNotifierProvider( - create: (context) => LocalUserProvider()), + if (kReleaseMode) { + debugPrint = (String? message, {int? wrapWidth}) {}; + } - // UsersProvider migré vers l'API - ChangeNotifierProvider( - create: (context) => UsersProvider(), - ), + runZonedGuarded( + () { + runApp( + MultiProvider( + providers: [ + // Fournisseur d'initialisation de l'application (initialise Firebase et cache en tâche de fond) + ChangeNotifierProvider( + create: (_) => AppInitializer(), + ), + // LocalUserProvider pour la gestion de l'authentification + ChangeNotifierProvider( + create: (context) => LocalUserProvider()), - // EventProvider migré vers l'API - ChangeNotifierProvider( - create: (context) => EventProvider(), - ), + // UsersProvider migré vers l'API + ChangeNotifierProvider( + create: (context) => UsersProvider(), + ), - // EquipmentProvider migré vers l'API - ChangeNotifierProvider( - create: (context) => EquipmentProvider(), - ), + // EventProvider migré vers l'API + ChangeNotifierProvider( + create: (context) => EventProvider(), + ), - // ContainerProvider migré vers l'API - ChangeNotifierProvider( - create: (context) => ContainerProvider(), - ), + // EquipmentProvider migré vers l'API + ChangeNotifierProvider( + create: (context) => EquipmentProvider(), + ), - // MaintenanceProvider migré vers l'API - ChangeNotifierProvider( - create: (context) => MaintenanceProvider(), + // ContainerProvider migré vers l'API + ChangeNotifierProvider( + create: (context) => ContainerProvider(), + ), + + // MaintenanceProvider migré vers l'API + ChangeNotifierProvider( + create: (context) => MaintenanceProvider(), + ), + ChangeNotifierProvider( + create: (context) => AlertProvider(), + ), + ], + child: const MyApp(), ), - ChangeNotifierProvider( - create: (context) => AlertProvider(), - ), - ], - child: const MyApp(), + ); + }, + (error, stackTrace) { + if (kDebugMode) { + print('Uncaught error: $error\n$stackTrace'); + } + }, + zoneSpecification: ZoneSpecification( + print: (Zone self, ZoneDelegate parent, Zone zone, String line) { + if (!kReleaseMode) { + parent.print(zone, line); + } + }, ), ); } diff --git a/em2rp/lib/models/container_model.dart b/em2rp/lib/models/container_model.dart index 5de858e..159df58 100644 --- a/em2rp/lib/models/container_model.dart +++ b/em2rp/lib/models/container_model.dart @@ -242,34 +242,55 @@ class ContainerModel { /// Factory depuis Firestore factory ContainerModel.fromMap(Map map, String id) { - // Fonction helper pour convertir Timestamp ou String ISO en DateTime + // Fonction helper pour convertir de manière sécurisée en double + double? parseDouble(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } + + // Fonction helper pour convertir Timestamp ou String ISO ou int epoch en DateTime DateTime? parseDate(dynamic value) { if (value == null) return null; if (value is Timestamp) return value.toDate(); if (value is String) return DateTime.tryParse(value); + if (value is int) return DateTime.fromMillisecondsSinceEpoch(value); return null; } - final List equipmentIdsRaw = map['equipmentIds'] ?? []; - final List equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList(); + // Gestion sécurisée de la liste d'IDs d'équipements + final List equipmentIds = []; + if (map['equipmentIds'] is List) { + for (final e in map['equipmentIds'] as List) { + if (e != null) { + equipmentIds.add(e.toString()); + } + } + } - final List historyRaw = map['history'] ?? []; - final List history = historyRaw - .map((e) => ContainerHistoryEntry.fromMap(e as Map)) - .toList(); + // Gestion sécurisée de l'historique + final List history = []; + if (map['history'] is List) { + for (final e in map['history'] as List) { + if (e is Map) { + history.add(ContainerHistoryEntry.fromMap(e)); + } + } + } return ContainerModel( id: id, - name: map['name'] ?? '', - type: containerTypeFromString(map['type']), - status: equipmentStatusFromString(map['status']), - weight: map['weight']?.toDouble(), - length: map['length']?.toDouble(), - width: map['width']?.toDouble(), - height: map['height']?.toDouble(), + name: (map['name'] ?? '').toString(), + type: containerTypeFromString(map['type']?.toString()), + status: equipmentStatusFromString(map['status']?.toString()), + weight: parseDouble(map['weight']), + length: parseDouble(map['length']), + width: parseDouble(map['width']), + height: parseDouble(map['height']), equipmentIds: equipmentIds, - eventId: map['eventId'], - notes: map['notes'], + eventId: map['eventId']?.toString(), + notes: map['notes']?.toString(), createdAt: parseDate(map['createdAt']) ?? DateTime.now(), updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(), history: history, @@ -355,16 +376,17 @@ class ContainerHistoryEntry { if (value == null) return DateTime.now(); if (value is Timestamp) return value.toDate(); if (value is String) return DateTime.tryParse(value) ?? DateTime.now(); + if (value is int) return DateTime.fromMillisecondsSinceEpoch(value); return DateTime.now(); } return ContainerHistoryEntry( timestamp: parseDate(map['timestamp']), - action: map['action'] ?? '', - equipmentId: map['equipmentId'], - previousValue: map['previousValue'], - newValue: map['newValue'], - userId: map['userId'], + action: (map['action'] ?? '').toString(), + equipmentId: map['equipmentId']?.toString(), + previousValue: map['previousValue']?.toString(), + newValue: map['newValue']?.toString(), + userId: map['userId']?.toString(), ); } diff --git a/em2rp/lib/models/equipment_model.dart b/em2rp/lib/models/equipment_model.dart index f94853a..7d3ceaa 100644 --- a/em2rp/lib/models/equipment_model.dart +++ b/em2rp/lib/models/equipment_model.dart @@ -387,40 +387,64 @@ class EquipmentModel { }); factory EquipmentModel.fromMap(Map map, String id) { - // Fonction helper pour convertir Timestamp ou String ISO en DateTime + // Fonction helper pour convertir de manière sécurisée en double + double? parseDouble(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } + + // Fonction helper pour convertir de manière sécurisée en int + int? parseInt(dynamic value) { + if (value == null) return null; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } + + // Fonction helper pour convertir Timestamp ou String ISO ou int epoch en DateTime DateTime? parseDate(dynamic value) { if (value == null) return null; if (value is Timestamp) return value.toDate(); if (value is String) return DateTime.tryParse(value); + if (value is int) return DateTime.fromMillisecondsSinceEpoch(value); return null; } - // Gestion des listes - final List maintenanceIdsRaw = map['maintenanceIds'] ?? []; - final List maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList(); + // Gestion sécurisée des listes d'IDs de maintenance + final List maintenanceIds = []; + if (map['maintenanceIds'] is List) { + for (final e in map['maintenanceIds'] as List) { + if (e != null) { + maintenanceIds.add(e.toString()); + } + } + } return EquipmentModel( id: id, - name: map['name'] ?? '', - brand: map['brand'], - model: map['model'], - category: equipmentCategoryFromString(map['category']), - subCategory: map['subCategory'], - status: equipmentStatusFromString(map['status']), - purchasePrice: map['purchasePrice']?.toDouble(), - rentalPrice: map['rentalPrice']?.toDouble(), - totalQuantity: map['totalQuantity']?.toInt(), - availableQuantity: map['availableQuantity']?.toInt(), - criticalThreshold: map['criticalThreshold']?.toInt(), - weight: map['weight']?.toDouble(), - length: map['length']?.toDouble(), - width: map['width']?.toDouble(), - height: map['height']?.toDouble(), + name: (map['name'] ?? '').toString(), + brand: map['brand']?.toString(), + model: map['model']?.toString(), + category: equipmentCategoryFromString(map['category']?.toString()), + subCategory: map['subCategory']?.toString(), + status: equipmentStatusFromString(map['status']?.toString()), + purchasePrice: parseDouble(map['purchasePrice']), + rentalPrice: parseDouble(map['rentalPrice']), + totalQuantity: parseInt(map['totalQuantity']), + availableQuantity: parseInt(map['availableQuantity']), + criticalThreshold: parseInt(map['criticalThreshold']), + weight: parseDouble(map['weight']), + length: parseDouble(map['length']), + width: parseDouble(map['width']), + height: parseDouble(map['height']), purchaseDate: parseDate(map['purchaseDate']), + lastMaintenanceDate: parseDate(map['lastMaintenanceDate']), nextMaintenanceDate: parseDate(map['nextMaintenanceDate']), maintenanceIds: maintenanceIds, - imageUrl: map['imageUrl'], - notes: map['notes'], + imageUrl: map['imageUrl']?.toString(), + notes: map['notes']?.toString(), createdAt: parseDate(map['createdAt']) ?? DateTime.now(), updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(), ); diff --git a/em2rp/lib/models/maintenance_model.dart b/em2rp/lib/models/maintenance_model.dart index 723b28b..f64be5e 100644 --- a/em2rp/lib/models/maintenance_model.dart +++ b/em2rp/lib/models/maintenance_model.dart @@ -60,29 +60,44 @@ class MaintenanceModel { }); factory MaintenanceModel.fromMap(Map map, String id) { - // Fonction helper pour convertir Timestamp ou String ISO en DateTime + // Fonction helper pour convertir de manière sécurisée en double + double? parseDouble(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } + + // Fonction helper pour convertir Timestamp ou String ISO ou int epoch en DateTime DateTime? parseDate(dynamic value) { if (value == null) return null; if (value is Timestamp) return value.toDate(); if (value is String) return DateTime.tryParse(value); + if (value is int) return DateTime.fromMillisecondsSinceEpoch(value); return null; } // Gestion de la liste des équipements - final List equipmentIdsRaw = map['equipmentIds'] ?? []; - final List equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList(); + final List equipmentIds = []; + if (map['equipmentIds'] is List) { + for (final e in map['equipmentIds'] as List) { + if (e != null) { + equipmentIds.add(e.toString()); + } + } + } return MaintenanceModel( id: id, equipmentIds: equipmentIds, - type: maintenanceTypeFromString(map['type']), + type: maintenanceTypeFromString(map['type']?.toString()), scheduledDate: parseDate(map['scheduledDate']) ?? DateTime.now(), completedDate: parseDate(map['completedDate']), - name: map['name'] ?? '', - description: map['description'] ?? '', - performedBy: map['performedBy'], - cost: map['cost']?.toDouble(), - notes: map['notes'], + name: (map['name'] ?? '').toString(), + description: (map['description'] ?? '').toString(), + performedBy: map['performedBy']?.toString(), + cost: parseDouble(map['cost']), + notes: map['notes']?.toString(), createdAt: parseDate(map['createdAt']) ?? DateTime.now(), updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(), ); diff --git a/em2rp/lib/providers/equipment_provider.dart b/em2rp/lib/providers/equipment_provider.dart index ae978aa..dc7127a 100644 --- a/em2rp/lib/providers/equipment_provider.dart +++ b/em2rp/lib/providers/equipment_provider.dart @@ -29,6 +29,7 @@ class EquipmentProvider extends ChangeNotifier { String _searchQuery = ''; bool _isLoading = false; bool _isInitialized = false; + bool _isFullListLoaded = false; // Mode de chargement (pagination vs full) bool _usePagination = false; @@ -48,6 +49,7 @@ class EquipmentProvider extends ChangeNotifier { bool get isLoadingMore => _isLoadingMore; bool get hasMore => _hasMore; bool get isInitialized => _isInitialized; + bool get isFullListLoaded => _isFullListLoaded; bool get usePagination => _usePagination; /// S'assure que les équipements sont chargés (charge si nécessaire) @@ -58,16 +60,8 @@ class EquipmentProvider extends ChangeNotifier { return; } - // Si initialisé MAIS _equipment est vide, forcer le rechargement - if (_isInitialized && _equipment.isEmpty) { - print('[EquipmentProvider] Equipment marked as initialized but _equipment is empty! Force reloading...'); - _isInitialized = false; // Réinitialiser le flag - await loadEquipments(); - return; - } - - // Si déjà initialisé avec des données, ne rien faire - if (_isInitialized) { + // Si déjà initialisé avec le cache complet des données, ne rien faire + if (_isFullListLoaded) { print('[EquipmentProvider] Equipment already loaded (${_equipment.length} items), skipping...'); return; } @@ -120,7 +114,7 @@ class EquipmentProvider extends ChangeNotifier { // Extraire les modèles et marques uniques _extractUniqueValues(); - + _isFullListLoaded = true; _isInitialized = true; _isLoading = false; notifyListeners(); @@ -436,6 +430,8 @@ class EquipmentProvider extends ChangeNotifier { Future deleteEquipment(String equipmentId, {bool forceDelete = false}) async { try { await _dataService.deleteEquipment(equipmentId, forceDelete: forceDelete); + _isFullListLoaded = false; + _equipment.clear(); if (_usePagination) { await reload(); } else { @@ -451,6 +447,8 @@ class EquipmentProvider extends ChangeNotifier { Future addEquipment(EquipmentModel equipment) async { try { await _dataService.createEquipment(equipment.id, equipment.toMap()); + _isFullListLoaded = false; + _equipment.clear(); if (_usePagination) { await reload(); } else { @@ -466,6 +464,8 @@ class EquipmentProvider extends ChangeNotifier { Future updateEquipment(EquipmentModel equipment) async { try { await _dataService.updateEquipment(equipment.id, equipment.toMap()); + _isFullListLoaded = false; + _equipment.clear(); if (_usePagination) { await reload(); } else { diff --git a/em2rp/lib/views/container_form_page.dart b/em2rp/lib/views/container_form_page.dart index 6ad1ba7..70f41d4 100644 --- a/em2rp/lib/views/container_form_page.dart +++ b/em2rp/lib/views/container_form_page.dart @@ -10,6 +10,7 @@ import 'package:em2rp/utils/id_generator.dart'; import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/utils/debouncer.dart'; +import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; class ContainerFormPage extends StatefulWidget { final ContainerModel? container; @@ -90,369 +91,436 @@ class _ContainerFormPageState extends State { } } + Widget _buildCard({ + required String title, + required IconData icon, + required List children, + }) { + return Card( + elevation: 2, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.grey.shade200, width: 1), + ), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(icon, color: AppColors.rouge, size: 20), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.noir, + ), + ), + ], + ), + const Divider(height: 24, thickness: 1), + ...children, + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: Text(_isEditing ? 'Modifier boite' : 'Nouvelle boite'), - backgroundColor: AppColors.rouge, - foregroundColor: Colors.white, + appBar: CustomAppBar( + title: _isEditing ? 'Modifier boîte' : 'Nouvelle boîte', ), - body: Form( - key: _formKey, - child: ListView( - padding: const EdgeInsets.all(24), - children: [ - // Nom - TextFormField( - controller: _nameController, - decoration: const InputDecoration( - labelText: 'Nom du container *', - hintText: 'ex: Flight Case Beam 7R', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.label), - ), - onChanged: (_) => _updateIdFromName(), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Veuillez entrer un nom'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // ID - ValueListenableBuilder( - valueListenable: _autoGenerateIdNotifier, - builder: (context, autoGenerateId, child) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: TextFormField( - controller: _idController, + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Card 1: Informations Générales + _buildCard( + title: 'Informations générales', + icon: Icons.info_outline, + children: [ + // Nom + TextFormField( + controller: _nameController, decoration: const InputDecoration( - labelText: 'Identifiant *', - hintText: 'ex: FLIGHTCASE_BEAM', + labelText: 'Nom du container *', + hintText: 'ex: Flight Case Beam 7R', border: OutlineInputBorder(), - prefixIcon: Icon(Icons.qr_code), + prefixIcon: Icon(Icons.label), ), - enabled: !autoGenerateId || _isEditing, + onChanged: (_) => _updateIdFromName(), validator: (value) { if (value == null || value.isEmpty) { - return 'Veuillez entrer un identifiant'; + return 'Veuillez entrer un nom'; } - final validation = IdGenerator.validateContainerId(value); - return validation; + return null; }, ), - ), - if (!_isEditing) ...[ - const SizedBox(width: 8), - IconButton( - icon: Icon( - autoGenerateId ? Icons.lock : Icons.lock_open, - color: autoGenerateId ? AppColors.rouge : Colors.grey, + const SizedBox(height: 16), + + // ID + ValueListenableBuilder( + valueListenable: _autoGenerateIdNotifier, + builder: (context, autoGenerateId, child) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextFormField( + controller: _idController, + decoration: const InputDecoration( + labelText: 'Identifiant *', + hintText: 'ex: FLIGHTCASE_BEAM', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.qr_code), + ), + enabled: !autoGenerateId || _isEditing, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer un identifiant'; + } + final validation = IdGenerator.validateContainerId(value); + return validation; + }, + ), + ), + if (!_isEditing) ...[ + const SizedBox(width: 8), + IconButton( + icon: Icon( + autoGenerateId ? Icons.lock : Icons.lock_open, + color: autoGenerateId ? AppColors.rouge : Colors.grey, + ), + tooltip: autoGenerateId + ? 'Génération automatique' + : 'Saisie manuelle', + onPressed: () { + _autoGenerateIdNotifier.value = !autoGenerateId; + if (_autoGenerateIdNotifier.value) { + _updateIdFromName(); + } + }, + ), + ], + ], + ); + }, + ), + const SizedBox(height: 16), + + // Type + DropdownButtonFormField( + initialValue: _selectedType, + decoration: const InputDecoration( + labelText: 'Type de container *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), ), - tooltip: autoGenerateId - ? 'Génération automatique' - : 'Saisie manuelle', - onPressed: () { - _autoGenerateIdNotifier.value = !autoGenerateId; - if (_autoGenerateIdNotifier.value) { - _updateIdFromName(); + items: ContainerType.values.map((type) { + return DropdownMenuItem( + value: type, + child: Row( + children: [ + type.getIcon(size: 20, color: AppColors.rouge), + const SizedBox(width: 8), + Text(type.label), + ], + ), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedType = value; + _updateIdFromType(); + }); + } + }, + ), + const SizedBox(height: 16), + + // Statut + DropdownButtonFormField( + initialValue: _selectedStatus, + decoration: const InputDecoration( + labelText: 'Statut *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.info_outline), + ), + items: [ + EquipmentStatus.available, + EquipmentStatus.inUse, + EquipmentStatus.maintenance, + EquipmentStatus.outOfService, + ].map((status) { + String label; + switch (status) { + case EquipmentStatus.available: + label = 'Disponible'; + break; + case EquipmentStatus.inUse: + label = 'En prestation'; + break; + case EquipmentStatus.maintenance: + label = 'En maintenance'; + break; + case EquipmentStatus.outOfService: + label = 'Hors service'; + break; + default: + label = 'Autre'; + } + return DropdownMenuItem( + value: status, + child: Text(label), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedStatus = value; + }); } }, ), ], - ], - ); - }, - ), - const SizedBox(height: 16), - - // Type - DropdownButtonFormField( - initialValue: _selectedType, - decoration: const InputDecoration( - labelText: 'Type de container *', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.category), - ), - items: ContainerType.values.map((type) { - return DropdownMenuItem( - value: type, - child: Text(type.label), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() { - _selectedType = value; - _updateIdFromType(); - }); - } - }, - ), - const SizedBox(height: 16), - - // Statut - DropdownButtonFormField( - initialValue: _selectedStatus, - decoration: const InputDecoration( - labelText: 'Statut *', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.info), - ), - items: [ - EquipmentStatus.available, - EquipmentStatus.inUse, - EquipmentStatus.maintenance, - EquipmentStatus.outOfService, - ].map((status) { - String label; - switch (status) { - case EquipmentStatus.available: - label = 'Disponible'; - break; - case EquipmentStatus.inUse: - label = 'En prestation'; - break; - case EquipmentStatus.maintenance: - label = 'En maintenance'; - break; - case EquipmentStatus.outOfService: - label = 'Hors service'; - break; - default: - label = 'Autre'; - } - return DropdownMenuItem( - value: status, - child: Text(label), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() { - _selectedStatus = value; - }); - } - }, - ), - const SizedBox(height: 24), - - // Section Caractéristiques physiques - Text( - 'Caractéristiques physiques', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, ), - ), - const Divider(), - const SizedBox(height: 16), + const SizedBox(height: 20), - // Poids - TextFormField( - controller: _weightController, - decoration: const InputDecoration( - labelText: 'Poids à vide (kg)', - hintText: 'ex: 15.5', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.scale), - ), - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - validator: (value) { - if (value != null && value.isNotEmpty) { - if (double.tryParse(value) == null) { - return 'Veuillez entrer un nombre valide'; - } - } - return null; - }, - ), - const SizedBox(height: 16), + // Card 2: Caractéristiques Physiques + _buildCard( + title: 'Caractéristiques physiques', + icon: Icons.scale_outlined, + children: [ + // Poids + TextFormField( + controller: _weightController, + decoration: const InputDecoration( + labelText: 'Poids à vide (kg)', + hintText: 'ex: 15.5', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.scale), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'Veuillez entrer un nombre valide'; + } + } + return null; + }, + ), + const SizedBox(height: 16), - // Dimensions - Row( - children: [ - Expanded( - child: TextFormField( - controller: _lengthController, - decoration: const InputDecoration( - labelText: 'Longueur (cm)', - border: OutlineInputBorder(), - ), - keyboardType: - TextInputType.numberWithOptions(decimal: true), - validator: (value) { - if (value != null && value.isNotEmpty) { - if (double.tryParse(value) == null) { - return 'Nombre invalide'; - } - } - return null; - }, + // Dimensions + Row( + children: [ + Expanded( + child: TextFormField( + controller: _lengthController, + decoration: const InputDecoration( + labelText: 'Longueur (cm)', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'Nombre invalide'; + } + } + return null; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _widthController, + decoration: const InputDecoration( + labelText: 'Largeur (cm)', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'Nombre invalide'; + } + } + return null; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _heightController, + decoration: const InputDecoration( + labelText: 'Hauteur (cm)', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'Nombre invalide'; + } + } + return null; + }, + ), + ), + ], + ), + ], ), - ), - const SizedBox(width: 8), - Expanded( - child: TextFormField( - controller: _widthController, - decoration: const InputDecoration( - labelText: 'Largeur (cm)', - border: OutlineInputBorder(), - ), - keyboardType: - TextInputType.numberWithOptions(decimal: true), - validator: (value) { - if (value != null && value.isNotEmpty) { - if (double.tryParse(value) == null) { - return 'Nombre invalide'; - } - } - return null; - }, - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextFormField( - controller: _heightController, - decoration: const InputDecoration( - labelText: 'Hauteur (cm)', - border: OutlineInputBorder(), - ), - keyboardType: - TextInputType.numberWithOptions(decimal: true), - validator: (value) { - if (value != null && value.isNotEmpty) { - if (double.tryParse(value) == null) { - return 'Nombre invalide'; - } - } - return null; - }, - ), - ), - ], - ), - const SizedBox(height: 24), + const SizedBox(height: 20), - // Section Équipements - Text( - 'Équipements dans ce container', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const Divider(), - const SizedBox(height: 16), + // Card 3: Équipements dans ce container + _buildCard( + title: 'Équipements dans ce container', + icon: Icons.inventory_2_outlined, + children: [ + if (_selectedEquipmentIds.isNotEmpty) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade200), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade50, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_selectedEquipmentIds.length} équipement(s) sélectionné(s) :', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _selectedEquipmentIds.map((id) { + return Chip( + label: Text( + id, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ), + deleteIcon: const Icon(Icons.close, size: 16), + onDeleted: () { + setState(() { + _selectedEquipmentIds.remove(id); + }); + }, + backgroundColor: AppColors.rouge.withValues(alpha: 0.08), + side: BorderSide(color: AppColors.rouge.withValues(alpha: 0.2)), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + }).toList(), + ), + ], + ), + ) + else + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade200), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade50, + ), + child: const Center( + child: Text( + 'Aucun équipement sélectionné', + style: TextStyle(color: Colors.grey), + ), + ), + ), + const SizedBox(height: 16), - // Liste des équipements sélectionnés - if (_selectedEquipmentIds.isNotEmpty) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${_selectedEquipmentIds.length} équipement(s) sélectionné(s)', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: _selectedEquipmentIds.map((id) { - return Chip( - label: Text(id), - deleteIcon: const Icon(Icons.close, size: 18), - onDeleted: () { - setState(() { - _selectedEquipmentIds.remove(id); - }); - }, - ); - }).toList(), - ), - ], - ), - ) - else - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - color: Colors.grey.shade50, - ), - child: const Center( - child: Text( - 'Aucun équipement sélectionné', - style: TextStyle(color: Colors.grey), + // Bouton pour ajouter des équipements + OutlinedButton.icon( + onPressed: _selectEquipment, + icon: const Icon(Icons.add), + label: const Text('Ajouter des équipements'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + side: BorderSide(color: AppColors.rouge), + foregroundColor: AppColors.rouge, + ), + ), + ], ), - ), - ), - const SizedBox(height: 12), + const SizedBox(height: 20), - // Bouton pour ajouter des équipements - OutlinedButton.icon( - onPressed: _selectEquipment, - icon: const Icon(Icons.add), - label: const Text('Ajouter des équipements'), - style: OutlinedButton.styleFrom( - minimumSize: const Size(double.infinity, 48), + // Card 4: Notes + _buildCard( + title: 'Notes & Remarques', + icon: Icons.notes_outlined, + children: [ + TextFormField( + controller: _notesController, + decoration: const InputDecoration( + labelText: 'Notes complémentaires', + hintText: 'Informations additionnelles...', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.edit_note), + ), + maxLines: 3, + ), + ], + ), + const SizedBox(height: 32), + + // Actions + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler', style: TextStyle(fontSize: 16)), + ), + const SizedBox(width: 16), + ElevatedButton.icon( + onPressed: _saveContainer, + icon: const Icon(Icons.save, color: Colors.white), + label: Text( + _isEditing ? 'Enregistrer' : 'Créer', + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + const SizedBox(height: 24), + ], ), ), - const SizedBox(height: 24), - - // Notes - TextFormField( - controller: _notesController, - decoration: const InputDecoration( - labelText: 'Notes', - hintText: 'Informations additionnelles...', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.notes), - ), - maxLines: 3, - ), - const SizedBox(height: 32), - - // Boutons - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), - ), - const SizedBox(width: 16), - ElevatedButton.icon( - onPressed: _saveContainer, - icon: const Icon(Icons.save, color: Colors.white), - label: Text( - _isEditing ? 'Mettre à jour' : 'Créer', - style: const TextStyle(color: Colors.white), - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.rouge, - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, - ), - ), - ), - ], - ), - ], + ), ), ), ); diff --git a/em2rp/lib/views/equipment_form_page.dart b/em2rp/lib/views/equipment_form_page.dart index d4e3c7a..1cc424e 100644 --- a/em2rp/lib/views/equipment_form_page.dart +++ b/em2rp/lib/views/equipment_form_page.dart @@ -12,6 +12,7 @@ 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/utils/debouncer.dart'; class EquipmentFormPage extends StatefulWidget { final EquipmentModel? equipment; @@ -37,22 +38,43 @@ class _EquipmentFormPageState extends State { final TextEditingController _criticalThresholdController = TextEditingController(); final TextEditingController _notesController = TextEditingController(); final TextEditingController _quantityToAddController = TextEditingController(text: '1'); + + // Physical characteristics controllers + final TextEditingController _weightController = TextEditingController(); + final TextEditingController _lengthController = TextEditingController(); + final TextEditingController _widthController = TextEditingController(); + final TextEditingController _heightController = TextEditingController(); // State variables - EquipmentCategory _selectedCategory = EquipmentCategory.other; + EquipmentCategory? _selectedCategory; // Nullable by default to force selection EquipmentStatus _selectedStatus = EquipmentStatus.available; DateTime? _purchaseDate; DateTime? _lastMaintenanceDate; DateTime? _nextMaintenanceDate; bool _isLoading = false; - bool _addMultiple = false; String? _selectedBrand; List _filteredModels = []; List _filteredSubCategories = []; + // ID auto-generation and check + final ValueNotifier _autoGenerateIdNotifier = ValueNotifier(true); + final _idCheckDebouncer = Debouncer(delay: const Duration(milliseconds: 500)); + String? _idConflictMessage; + List _candidateIds = []; + bool _isCalculatingIds = false; + @override void initState() { + _candidateIds = []; + _isCalculatingIds = false; super.initState(); + + // Set default dates to today for new equipment + if (widget.equipment == null) { + _purchaseDate = DateTime.now(); + _lastMaintenanceDate = DateTime.now(); + } + WidgetsBinding.instance.addPostFrameCallback((_) { final provider = Provider.of(context, listen: false); provider.loadBrands(); @@ -61,11 +83,25 @@ class _EquipmentFormPageState extends State { if (_selectedBrand != null && _selectedBrand!.isNotEmpty) { _loadFilteredModels(_selectedBrand!); } - _loadFilteredSubCategories(_selectedCategory); + if (_selectedCategory != null) { + _loadFilteredSubCategories(_selectedCategory!); + } } }); + if (widget.equipment != null) { _populateFields(); + } else { + // Set up listeners for auto-generation of ID + _brandController.addListener(_triggerCandidateIdsUpdate); + _modelController.addListener(_triggerCandidateIdsUpdate); + _quantityToAddController.addListener(_triggerCandidateIdsUpdate); + _identifierController.addListener(_onIdentifierManualChanged); + + // Run initial check once the page is fully mounted/built + WidgetsBinding.instance.addPostFrameCallback((_) { + _triggerCandidateIdsUpdate(); + }); } } @@ -87,11 +123,172 @@ class _EquipmentFormPageState extends State { _lastMaintenanceDate = equipment.lastMaintenanceDate; _nextMaintenanceDate = equipment.nextMaintenanceDate; _notesController.text = equipment.notes ?? ''; + + _weightController.text = equipment.weight?.toString() ?? ''; + _lengthController.text = equipment.length?.toString() ?? ''; + _widthController.text = equipment.width?.toString() ?? ''; + _heightController.text = equipment.height?.toString() ?? ''; }); + // Disable auto-generation for editing + _autoGenerateIdNotifier.value = false; + DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}'); } + void _onIdentifierManualChanged() { + if (!_autoGenerateIdNotifier.value && widget.equipment == null) { + _triggerCandidateIdsUpdate(); + } + } + + void _triggerCandidateIdsUpdate() { + _idCheckDebouncer(() async { + if (!mounted || widget.equipment != null) return; + + setState(() { + _isCalculatingIds = true; + }); + + try { + final ids = await _calculateCandidateIds(); + if (!mounted) return; + + setState(() { + _candidateIds = ids; + + // If auto-generating, update the text field with the first generated ID + if (_autoGenerateIdNotifier.value && ids.isNotEmpty) { + _identifierController.removeListener(_onIdentifierManualChanged); + _identifierController.text = ids.first; + _identifierController.addListener(_onIdentifierManualChanged); + } + + // Determine if there was an ID replacement/conflict + _idConflictMessage = null; + if (ids.isNotEmpty) { + final brand = _brandController.text.trim(); + final model = _modelController.text.trim(); + final quantityText = _quantityToAddController.text.trim(); + final numbers = _parseQuantityOrRange(quantityText); + + if (_autoGenerateIdNotifier.value) { + if (numbers != null && numbers.isNotEmpty) { + final firstExpectedNum = numbers.first; + final baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null); + final expectedFirstId = '${baseId}_#$firstExpectedNum'; + if (ids.first != expectedFirstId) { + _idConflictMessage = "L'ID $expectedFirstId était déjà pris et a été remplacé par ${ids.first}"; + } + } + } else { + final manualId = _identifierController.text.trim().toUpperCase(); + if (manualId.isNotEmpty && ids.first != manualId) { + _idConflictMessage = "L'ID $manualId était déjà pris et a été remplacé par ${ids.first}"; + } + } + } + }); + } catch (e) { + DebugLog.error("Error calculating candidate IDs: $e"); + } finally { + if (mounted) { + setState(() { + _isCalculatingIds = false; + }); + } + } + }); + } + + List? _parseQuantityOrRange(String text) { + text = text.trim(); + if (text.isEmpty) return null; + + // Try single number + final singleNum = int.tryParse(text); + if (singleNum != null) { + if (singleNum < 1 || singleNum > 100) return null; + return List.generate(singleNum, (i) => i + 1); + } + + // Try range pattern like "3-6" or "3 - 6" + final rangeRegex = RegExp(r'^(\d+)\s*-\s*(\d+)$'); + final match = rangeRegex.firstMatch(text); + if (match != null) { + final start = int.tryParse(match.group(1)!); + final end = int.tryParse(match.group(2)!); + if (start != null && end != null && start > 0 && end >= start) { + if (end - start + 1 > 100) return null; + return List.generate(end - start + 1, (i) => start + i); + } + } + + return null; + } + + IdParseResult _parseBaseAndNumber(String id) { + final match = RegExp(r'^(.*)_#(\d+)$').firstMatch(id); + if (match != null) { + return IdParseResult(match.group(1)!, int.parse(match.group(2)!)); + } + return IdParseResult(id, null); + } + + Future> _calculateCandidateIds() async { + final brand = _brandController.text.trim(); + final model = _modelController.text.trim(); + final quantityText = _quantityToAddController.text.trim(); + + if (_autoGenerateIdNotifier.value && brand.isEmpty && model.isEmpty) { + return []; + } + + // Get base ID + String baseId; + int? initialNumber; + + if (_autoGenerateIdNotifier.value) { + baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null); + } else { + final manualId = _identifierController.text.trim().toUpperCase(); + if (manualId.isEmpty) return []; + + final parsed = _parseBaseAndNumber(manualId); + baseId = parsed.baseId; + initialNumber = parsed.number; + } + + // Parse numbers + final numbers = _parseQuantityOrRange(quantityText); + if (numbers == null || numbers.isEmpty) return []; + + // If the quantityText is just a single number (e.g. "5") and we have a manual ID with an initial number (e.g. "CUSTOM_#3"), + // we adjust the numbers to start from that initial number. + List targetNumbers = numbers; + final isSingleNumberInput = int.tryParse(quantityText) != null; + if (isSingleNumberInput && initialNumber != null) { + targetNumbers = List.generate(numbers.length, (i) => initialNumber! + i); + } + + List resultIds = []; + final Set allocatedInBatch = {}; + + for (final num in targetNumbers) { + int currentNum = num; + String candidateId = '${baseId}_#$currentNum'; + + while (allocatedInBatch.contains(candidateId) || !(await _equipmentService.isIdUnique(candidateId))) { + currentNum++; + candidateId = '${baseId}_#$currentNum'; + } + + resultIds.add(candidateId); + allocatedInBatch.add(candidateId); + } + + return resultIds; + } Future _loadFilteredModels(String brand) async { try { @@ -123,6 +320,11 @@ class _EquipmentFormPageState extends State { @override void dispose() { + _brandController.removeListener(_triggerCandidateIdsUpdate); + _modelController.removeListener(_triggerCandidateIdsUpdate); + _quantityToAddController.removeListener(_triggerCandidateIdsUpdate); + _identifierController.removeListener(_onIdentifierManualChanged); + _identifierController.dispose(); _brandController.dispose(); _modelController.dispose(); @@ -133,10 +335,57 @@ class _EquipmentFormPageState extends State { _criticalThresholdController.dispose(); _notesController.dispose(); _quantityToAddController.dispose(); + + _weightController.dispose(); + _lengthController.dispose(); + _widthController.dispose(); + _heightController.dispose(); + + _autoGenerateIdNotifier.dispose(); + _idCheckDebouncer.dispose(); super.dispose(); } - bool get _isConsumable => _selectedCategory == EquipmentCategory.consumable || _selectedCategory == EquipmentCategory.cable; + bool get _isConsumable => _selectedCategory != null && (_selectedCategory == EquipmentCategory.consumable || _selectedCategory == EquipmentCategory.cable); + + Widget _buildCard({ + required String title, + required IconData icon, + required List children, + }) { + return Card( + elevation: 2, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.grey.shade200, width: 1), + ), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(icon, color: AppColors.rouge, size: 20), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.noir, + ), + ), + ], + ), + const Divider(height: 24, thickness: 1), + ...children, + ], + ), + ), + ); + } @override Widget build(BuildContext context) { @@ -150,314 +399,488 @@ class _EquipmentFormPageState extends State { ), body: _isLoading ? const Center(child: CircularProgressIndicator()) - : SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Identifiant (généré ou saisi) - TextFormField( - controller: _identifierController, - decoration: InputDecoration( - labelText: 'Identifiant (Laissez vide pour auto-génération) *', - border: const OutlineInputBorder(), - prefixIcon: const Icon(Icons.tag), - hintText: isEditing ? null : 'Auto-attribué par défaut', - helperText: isEditing ? 'Non modifiable' : 'Génération auto recommandée basée sur Marque/Modèle', - ), - enabled: !isEditing, - validator: (value) { - if (value != null && value.isNotEmpty) { - // Empêcher les ID commençant par BOX_ (réservé aux containers) - if (value.toUpperCase().startsWith('BOX_')) { - return 'Les ID commençant par BOX_ sont réservés aux boites'; - } - } - return null; - }, - ), - const SizedBox(height: 16), - - // Case à cocher "Ajouter plusieurs" (uniquement en mode création) - if (!isEditing) ...[ - Row( - children: [ - Expanded( - flex: 2, - child: CheckboxListTile( - title: const Text('Ajouter plusieurs équipements'), - subtitle: const Text('Créer plusieurs équipements numérotés'), - value: _addMultiple, - contentPadding: EdgeInsets.zero, - onChanged: (bool? value) { - setState(() { - _addMultiple = value ?? false; - }); + : Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Card 1: Informations Générales + _buildCard( + title: 'Informations générales', + icon: Icons.info_outline, + children: [ + // ID row with padlock and warning + ValueListenableBuilder( + valueListenable: _autoGenerateIdNotifier, + builder: (context, autoGenerateId, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextFormField( + controller: _identifierController, + decoration: InputDecoration( + labelText: 'Identifiant *', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.tag), + hintText: isEditing ? null : 'Généré automatiquement', + helperText: isEditing ? 'Non modifiable' : 'Identifiant unique du matériel', + ), + enabled: !autoGenerateId && !isEditing, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer un identifiant'; + } + if (value.toUpperCase().startsWith('BOX_')) { + return 'Les ID commençant par BOX_ sont réservés aux boites'; + } + return null; + }, + ), + ), + if (!isEditing) ...[ + const SizedBox(width: 8), + IconButton( + icon: Icon( + autoGenerateId ? Icons.lock : Icons.lock_open, + color: autoGenerateId ? AppColors.rouge : Colors.grey, + ), + tooltip: autoGenerateId + ? 'Génération automatique' + : 'Saisie manuelle', + onPressed: () { + _autoGenerateIdNotifier.value = !autoGenerateId; + if (_autoGenerateIdNotifier.value) { + _triggerCandidateIdsUpdate(); + } + }, + ), + ], + ], + ), + if (_idConflictMessage != null && !isEditing) ...[ + const SizedBox(height: 6), + Row( + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.orange.shade800, size: 16), + const SizedBox(width: 6), + Expanded( + child: Text( + _idConflictMessage!, + style: TextStyle( + color: Colors.orange.shade800, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ], + ], + ); }, ), - ), - if (_addMultiple) ...[ - const SizedBox(width: 16), - Expanded( - child: TextFormField( - controller: _quantityToAddController, - decoration: const InputDecoration( - labelText: 'Quantité ou range', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.plus_one), - hintText: '5 ou 6-18', - helperText: 'Ex: 5 ou 6-18', - ), - keyboardType: TextInputType.text, - validator: (value) { - if (_addMultiple) { - if (value == null || value.isEmpty) return 'Requis'; - // Vérifier si c'est un nombre simple ou une range - if (value.contains('-')) { - final parts = value.split('-'); - if (parts.length != 2) return 'Format invalide'; - final start = int.tryParse(parts[0].trim()); - final end = int.tryParse(parts[1].trim()); - if (start == null || end == null) return 'Nombres invalides'; - if (start >= end) return 'Le début doit être < fin'; - if (end - start > 100) return 'Max 100 équipements'; - } else { - final num = int.tryParse(value); - if (num == null || num < 1 || num > 100) return '1-100'; - } - } - return null; - }, - ), - ), - ], - ], - ), - const SizedBox(height: 16), - ], + const SizedBox(height: 16), - // Sélecteur Marque/Modèle - BrandModelSelector( - brandController: _brandController, - modelController: _modelController, - selectedBrand: _selectedBrand, - filteredModels: _filteredModels, - onBrandChanged: (brand) { - setState(() { - _selectedBrand = brand; - }); - if (brand != null && brand.isNotEmpty) { - _loadFilteredModels(brand); - } else { - setState(() { - _filteredModels = []; - }); - } - }, - onModelsChanged: (models) { - setState(() { - _filteredModels = models; - }); - }, - ), - const SizedBox(height: 16), - - // Catégorie et Statut - Row( - children: [ - Expanded( - child: DropdownButtonFormField( - initialValue: _selectedCategory, - decoration: const InputDecoration( - labelText: 'Catégorie *', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.category), - ), - items: EquipmentCategory.values.map((category) { - return DropdownMenuItem( - value: category, - child: Text(category.label), - ); - }).toList(), - onChanged: (value) { - if (value != null) { + // Marque & Modèle + BrandModelSelector( + brandController: _brandController, + modelController: _modelController, + selectedBrand: _selectedBrand, + filteredModels: _filteredModels, + onBrandChanged: (brand) { setState(() { - _selectedCategory = value; - _subCategoryController.clear(); + _selectedBrand = brand; }); - _loadFilteredSubCategories(value); - } - }, - ), - ), - // Afficher le statut uniquement si ce n'est pas un consommable ou câble - if (!_isConsumable) ...[ - const SizedBox(width: 16), - Expanded( - child: DropdownButtonFormField( - initialValue: _selectedStatus, - decoration: const InputDecoration( - labelText: 'Statut *', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.info), - ), - items: EquipmentStatus.values.map((status) { - return DropdownMenuItem( - value: status, - child: Text(status.label), - ); - }).toList(), - onChanged: (value) { - if (value != null) { + if (brand != null && brand.isNotEmpty) { + _loadFilteredModels(brand); + } else { setState(() { - _selectedStatus = value; + _filteredModels = []; }); } }, + onModelsChanged: (models) { + setState(() { + _filteredModels = models; + }); + }, ), + const SizedBox(height: 16), + + // Catégorie et Statut + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: _selectedCategory, + decoration: const InputDecoration( + labelText: 'Catégorie *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + items: EquipmentCategory.values.map((category) { + return DropdownMenuItem( + value: category, + child: Text(category.label), + ); + }).toList(), + validator: (value) { + if (value == null) { + return 'Catégorie obligatoire'; + } + return null; + }, + onChanged: (value) { + if (value != null) { + setState(() { + _selectedCategory = value; + _subCategoryController.clear(); + }); + _loadFilteredSubCategories(value); + } + }, + ), + ), + if (!_isConsumable) ...[ + const SizedBox(width: 16), + Expanded( + child: DropdownButtonFormField( + initialValue: _selectedStatus, + decoration: const InputDecoration( + labelText: 'Statut *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.info_outline), + ), + items: EquipmentStatus.values.map((status) { + return DropdownMenuItem( + value: status, + child: Text(status.label), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedStatus = value; + }); + } + }, + ), + ), + ], + ], + ), + const SizedBox(height: 16), + + // Sous-catégorie + SubCategorySelector( + controller: _subCategoryController, + selectedCategory: _selectedCategory, + filteredSubCategories: _filteredSubCategories, + onChanged: (value) { + setState(() {}); + }, + ), + ], + ), + const SizedBox(height: 20), + + // Card 2: Quantité & Stock + if (!isEditing || _isConsumable) ...[ + _buildCard( + title: 'Quantité & Stock', + icon: Icons.inventory_2_outlined, + children: [ + if (!isEditing) ...[ + TextFormField( + controller: _quantityToAddController, + decoration: const InputDecoration( + labelText: 'Nombre d\'exemplaires à créer *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.copy), + helperText: 'Exemples de valeurs acceptées :\n- "5" : crée 5 exemplaires (de #1 à #5)\n- "3-6" : crée 4 exemplaires (de #3 à #6)', + helperMaxLines: 3, + ), + keyboardType: TextInputType.text, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9\s-]')), + ], + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Veuillez entrer une quantité ou une plage'; + } + final parsed = _parseQuantityOrRange(value); + if (parsed == null || parsed.isEmpty) { + return 'Format invalide (ex: "5" ou "3-6")'; + } + if (parsed.length > 100) { + return 'La quantité maximale autorisée est de 100 exemplaires'; + } + return null; + }, + ), + if (_candidateIds.isNotEmpty) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Identifiants qui seront créés (${_candidateIds.length}) :', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + ), + if (_isCalculatingIds) + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 1.5), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _candidateIds.map((id) { + return Chip( + label: Text( + id, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ), + backgroundColor: AppColors.rouge.withValues(alpha: 0.08), + side: BorderSide(color: AppColors.rouge.withValues(alpha: 0.2)), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ); + }).toList(), + ), + ], + ), + ), + ], + if (_isConsumable) const SizedBox(height: 16), + ], + if (_isConsumable) ...[ + Row( + children: [ + Expanded( + child: TextFormField( + controller: _totalQuantityController, + decoration: const InputDecoration( + labelText: 'Quantité totale', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.format_list_numbered), + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _criticalThresholdController, + decoration: const InputDecoration( + labelText: 'Seuil critique', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.warning_amber), + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ], + ), + ], + ], ), + const SizedBox(height: 20), ], - ], - ), - 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), + // Card 3: Informations Financières + if (hasManagePermission) ...[ + _buildCard( + title: 'Informations financières', + icon: Icons.euro_outlined, + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + controller: _purchasePriceController, + decoration: const InputDecoration( + labelText: 'Prix d\'achat (€)', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.shopping_bag_outlined), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))], + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _rentalPriceController, + decoration: const InputDecoration( + labelText: 'Prix de location (€)', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.sell_outlined), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))], + ), + ), + ], + ), + ], + ), + const SizedBox(height: 20), + ], - // Prix - if (hasManagePermission) ...[ - Row( - children: [ - Expanded( - child: TextFormField( - controller: _purchasePriceController, + // Card 4: Caractéristiques physiques (Amélioration) + _buildCard( + title: 'Caractéristiques physiques', + icon: Icons.scale_outlined, + children: [ + TextFormField( + controller: _weightController, decoration: const InputDecoration( - labelText: 'Prix d\'achat (€)', + labelText: 'Poids à vide (kg)', border: OutlineInputBorder(), - prefixIcon: Icon(Icons.euro), + prefixIcon: Icon(Icons.scale), ), keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))], ), - ), - const SizedBox(width: 16), - Expanded( - child: TextFormField( - controller: _rentalPriceController, - decoration: const InputDecoration( - labelText: 'Prix de location (€)', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.attach_money), - ), - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))], + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _lengthController, + decoration: const InputDecoration( + labelText: 'Longueur (cm)', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _widthController, + decoration: const InputDecoration( + labelText: 'Largeur (cm)', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _heightController, + decoration: const InputDecoration( + labelText: 'Hauteur (cm)', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + ), + ], ), - ), - ], - ), - const SizedBox(height: 16), - ], - - // Quantités pour consommables - if (_isConsumable) ...[ - const Divider(), - const Text('Gestion des quantités', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: TextFormField( - controller: _totalQuantityController, - decoration: const InputDecoration( - labelText: 'Quantité totale', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.inventory), - ), - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - ), - ), - const SizedBox(width: 16), - Expanded( - child: TextFormField( - controller: _criticalThresholdController, - decoration: const InputDecoration( - labelText: 'Seuil critique', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.warning), - ), - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - ), - ), - ], - ), - const SizedBox(height: 16), - ], - - // Dates - const Divider(), - const Text('Dates', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - _buildDateField(label: 'Date d\'achat', icon: Icons.shopping_cart, value: _purchaseDate, onTap: () => _selectDate(context, 'purchase')), - const SizedBox(height: 16), - _buildDateField(label: 'Dernière maintenance', icon: Icons.build, value: _lastMaintenanceDate, onTap: () => _selectDate(context, 'lastMaintenance')), - const SizedBox(height: 16), - _buildDateField(label: 'Prochaine maintenance', icon: Icons.event, value: _nextMaintenanceDate, onTap: () => _selectDate(context, 'nextMaintenance')), - const SizedBox(height: 16), - - // Notes - const Divider(), - TextFormField( - controller: _notesController, - decoration: const InputDecoration( - labelText: 'Notes', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.notes), - ), - maxLines: 3, - ), - const SizedBox(height: 24), - - // Boutons - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), + ], ), - const SizedBox(width: 16), - ElevatedButton( - onPressed: _saveEquipment, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.rouge, - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), - ), - child: Text(isEditing ? 'Enregistrer' : 'Créer', style: const TextStyle(color: Colors.white)), + const SizedBox(height: 20), + + // Card 5: Dates + _buildCard( + title: 'Dates & Maintenance', + icon: Icons.calendar_today_outlined, + children: [ + _buildDateField(label: 'Date d\'achat', icon: Icons.shopping_cart_outlined, value: _purchaseDate, onTap: () => _selectDate(context, 'purchase')), + const SizedBox(height: 16), + _buildDateField(label: 'Dernière maintenance', icon: Icons.build_outlined, value: _lastMaintenanceDate, onTap: () => _selectDate(context, 'lastMaintenance')), + const SizedBox(height: 16), + _buildDateField(label: 'Prochaine maintenance', icon: Icons.event_outlined, value: _nextMaintenanceDate, onTap: () => _selectDate(context, 'nextMaintenance')), + ], ), + const SizedBox(height: 20), + + // Card 6: Notes + _buildCard( + title: 'Notes & Remarques', + icon: Icons.notes_outlined, + children: [ + TextFormField( + controller: _notesController, + decoration: const InputDecoration( + labelText: 'Notes complémentaires', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.edit_note), + ), + maxLines: 3, + ), + ], + ), + const SizedBox(height: 32), + + // Actions + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler', style: TextStyle(fontSize: 16)), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: _saveEquipment, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + isEditing ? 'Enregistrer' : 'Créer', + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold) + ), + ), + ], + ), + const SizedBox(height: 24), ], ), - ], + ), ), ), ), ); } - Widget _buildDateField({required String label, required IconData icon, required DateTime? value, required VoidCallback onTap}) { return InkWell( onTap: onTap, @@ -519,6 +942,8 @@ class _EquipmentFormPageState extends State { Future _saveEquipment() async { if (!_formKey.currentState!.validate()) return; + final scaffoldMessenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); setState(() => _isLoading = true); try { @@ -536,56 +961,32 @@ class _EquipmentFormPageState extends State { } // Validation marque/modèle obligatoires - String brand = _brandController.text.trim(); - String model = _modelController.text.trim(); + final brand = _brandController.text.trim(); + final model = _modelController.text.trim(); if (brand.isEmpty || model.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( + scaffoldMessenger.showSnackBar( const SnackBar(content: Text('La marque et le modèle sont obligatoires')), ); return; } - // Génération d'identifiant si vide + // Génération d'identifiant List ids = []; - List numbers = []; - - if (!isEditing && _identifierController.text.isEmpty) { - // Gérer la range ou nombre simple - final quantityText = _quantityToAddController.text.trim(); - if (_addMultiple && quantityText.contains('-')) { - // Range: ex "6-18" - final parts = quantityText.split('-'); - final start = int.parse(parts[0].trim()); - final end = int.parse(parts[1].trim()); - for (int i = start; i <= end; i++) { - numbers.add(i); - } - } else if (_addMultiple) { - // Nombre simple - final nbToAdd = int.tryParse(quantityText) ?? 1; - for (int i = 1; i <= nbToAdd; i++) { - numbers.add(i); - } - } - - // Générer les IDs - if (numbers.isEmpty) { - String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null); - String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService); - ids.add(uniqueId); - } else { - for (final num in numbers) { - String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: num); - String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService); - ids.add(uniqueId); - } + if (!isEditing) { + ids = await _calculateCandidateIds(); + if (ids.isEmpty) { + scaffoldMessenger.showSnackBar( + const SnackBar(content: Text('Impossible de générer des identifiants valides')), + ); + setState(() => _isLoading = false); + return; } } else { - ids.add(_identifierController.text.trim()); + ids.add(_identifierController.text.trim().toUpperCase()); } - // Création des équipements + // Création/Mise à jour des équipements for (final id in ids) { final now = DateTime.now(); final equipment = EquipmentModel( @@ -593,7 +994,7 @@ class _EquipmentFormPageState extends State { name: id, // Utilisation de l'identifiant comme nom brand: brand, model: model, - category: _selectedCategory, + category: _selectedCategory!, subCategory: _subCategoryController.text.trim().isNotEmpty ? _subCategoryController.text.trim() : null, status: _selectedStatus, purchasePrice: _purchasePriceController.text.isNotEmpty ? double.tryParse(_purchasePriceController.text) : null, @@ -607,6 +1008,10 @@ class _EquipmentFormPageState extends State { createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now, updatedAt: now, availableQuantity: availableQuantity, + weight: _weightController.text.isNotEmpty ? double.tryParse(_weightController.text) : null, + length: _lengthController.text.isNotEmpty ? double.tryParse(_lengthController.text) : null, + width: _widthController.text.isNotEmpty ? double.tryParse(_widthController.text) : null, + height: _heightController.text.isNotEmpty ? double.tryParse(_heightController.text) : null, ); if (isEditing) { await equipmentProvider.updateEquipment(equipment); @@ -616,11 +1021,11 @@ class _EquipmentFormPageState extends State { } if (mounted) { - Navigator.pop(context, true); + navigator.pop(true); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( + scaffoldMessenger.showSnackBar( SnackBar(content: Text('Erreur lors de l\'enregistrement : $e')), ); } @@ -629,3 +1034,9 @@ class _EquipmentFormPageState extends State { } } } + +class IdParseResult { + final String baseId; + final int? number; + IdParseResult(this.baseId, this.number); +} diff --git a/em2rp/lib/views/equipment_management_page.dart b/em2rp/lib/views/equipment_management_page.dart index 3d392af..415e1aa 100644 --- a/em2rp/lib/views/equipment_management_page.dart +++ b/em2rp/lib/views/equipment_management_page.dart @@ -923,17 +923,8 @@ class _EquipmentManagementPageState extends State try { // Récupérer les équipements sélectionnés final provider = context.read(); - final List selectedEquipment = []; - - // On doit récupérer les équipements depuis le stream - await for (final equipmentList in provider.equipmentStream.take(1)) { - for (final equipment in equipmentList) { - if (isItemSelected(equipment.id)) { - selectedEquipment.add(equipment); - } - } - break; - } + final List selectedEquipment = + await provider.getEquipmentsByIds(selectedIds.toList()); // Fermer l'indicateur de chargement if (mounted) { diff --git a/em2rp/lib/views/widgets/calendar_widgets/month_view.dart b/em2rp/lib/views/widgets/calendar_widgets/month_view.dart index f0b352a..0bb5c69 100644 --- a/em2rp/lib/views/widgets/calendar_widgets/month_view.dart +++ b/em2rp/lib/views/widgets/calendar_widgets/month_view.dart @@ -36,12 +36,15 @@ class MonthView extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final rowCount = _computeRowCount(focusedDay); + // TableCalendar has internal vertical padding and margins (approx 16px) that cause overflow + // if not accounted for. We subtract an extra 16.0 pixels to be safe. final availableHeight = constraints.maxHeight - (_calendarPadding * 2) - _headerHeight - _headerVerticalPadding - - _daysOfWeekHeight; - final rowHeight = availableHeight / rowCount; + _daysOfWeekHeight - + 16.0; + final rowHeight = (availableHeight > 0 ? availableHeight : 200.0) / rowCount; return Container( height: constraints.maxHeight, diff --git a/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart b/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart index c1d8a00..6076f7b 100644 --- a/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart +++ b/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart @@ -40,7 +40,9 @@ class _EquipmentAssociatedEventsSectionState } Future _loadEvents() async { - setState(() => _isLoading = true); + if (mounted) { + setState(() => _isLoading = true); + } try { // Récupérer TOUS les événements via l'API @@ -128,12 +130,16 @@ class _EquipmentAssociatedEventsSectionState // Trier par date filteredEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); - setState(() { - _events = filteredEvents; - _isLoading = false; - }); + if (mounted) { + setState(() { + _events = filteredEvents; + _isLoading = false; + }); + } } catch (e) { - setState(() => _isLoading = false); + if (mounted) { + setState(() => _isLoading = false); + } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -207,7 +213,9 @@ class _EquipmentAssociatedEventsSectionState ], ), onSelected: (filter) { - setState(() => _selectedFilter = filter); + if (mounted) { + setState(() => _selectedFilter = filter); + } _loadEvents(); }, itemBuilder: (context) => EventFilter.values.map((filter) { diff --git a/em2rp/lib/views/widgets/equipment/equipment_current_events_section.dart b/em2rp/lib/views/widgets/equipment/equipment_current_events_section.dart index 5f31ed5..1d58365 100644 --- a/em2rp/lib/views/widgets/equipment/equipment_current_events_section.dart +++ b/em2rp/lib/views/widgets/equipment/equipment_current_events_section.dart @@ -34,7 +34,9 @@ class _EquipmentCurrentEventsSectionState } Future _loadCurrentEvents() async { - setState(() => _isLoading = true); + if (mounted) { + setState(() => _isLoading = true); + } try { // Récupérer TOUS les événements via l'API @@ -106,12 +108,16 @@ class _EquipmentCurrentEventsSectionState // Trier par date events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); - setState(() { - _events = events; - _isLoading = false; - }); + if (mounted) { + setState(() { + _events = events; + _isLoading = false; + }); + } } catch (e) { - setState(() => _isLoading = false); + if (mounted) { + setState(() => _isLoading = false); + } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/em2rp/web/index.html b/em2rp/web/index.html index 33fe3f2..3c2dbb8 100644 --- a/em2rp/web/index.html +++ b/em2rp/web/index.html @@ -24,10 +24,8 @@ - - - +