feat: Gestion complète des containers et refactorisation du matériel
Ajout de la gestion des containers (création, édition, suppression, affichage des détails).
Introduction d'un système de génération de QR codes unifié et d'un mode de sélection multiple.
**Features:**
- **Gestion des Containers :**
- Nouvelle page de gestion des containers (`container_management_page.dart`) avec recherche et filtres.
- Formulaire de création/édition de containers (`container_form_page.dart`) avec génération d'ID automatique.
- Page de détails d'un container (`container_detail_page.dart`) affichant son contenu et ses caractéristiques.
- Ajout des routes et du provider (`ContainerProvider`) nécessaires.
- **Modèle de Données :**
- Ajout du `ContainerModel` pour représenter les boîtes, flight cases, etc.
- Le modèle `EquipmentModel` a été enrichi avec des caractéristiques physiques (poids, dimensions).
- **QR Codes :**
- Nouveau service unifié (`UnifiedPDFGeneratorService`) pour générer des PDFs de QR codes pour n'importe quelle entité.
- Services `PDFGeneratorService` et `ContainerPDFGeneratorService` transformés en wrappers pour maintenir la compatibilité.
- Amélioration de la performance de la génération de QR codes en masse.
- **Interface Utilisateur (UI/UX) :**
- Nouvelle page de détails pour le matériel (`equipment_detail_page.dart`).
- Ajout d'un `SelectionModeMixin` pour gérer la sélection multiple dans les pages de gestion.
- Dialogues réutilisables pour l'affichage de QR codes (`QRCodeDialog`) et la sélection de format d'impression (`QRCodeFormatSelectorDialog`).
- Ajout d'un bouton "Gérer les boîtes" sur la page de gestion du matériel.
**Refactorisation :**
- L' `IdGenerator` a été déplacé dans le répertoire `utils` et étendu pour gérer les containers.
- Mise à jour de nombreuses dépendances `pubspec.yaml` vers des versions plus récentes.
- Séparation de la logique d'affichage des containers et du matériel dans des widgets dédiés (`ContainerHeaderCard`, `EquipmentParentContainers`, etc.).
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
import 'package:em2rp/providers/users_provider.dart';
|
||||
import 'package:em2rp/providers/event_provider.dart';
|
||||
import 'package:em2rp/providers/equipment_provider.dart';
|
||||
import 'package:em2rp/providers/container_provider.dart';
|
||||
import 'package:em2rp/providers/maintenance_provider.dart';
|
||||
import 'package:em2rp/providers/alert_provider.dart';
|
||||
import 'package:em2rp/utils/auth_guard_widget.dart';
|
||||
import 'package:em2rp/views/calendar_page.dart';
|
||||
import 'package:em2rp/views/login_page.dart';
|
||||
import 'package:em2rp/views/equipment_management_page.dart';
|
||||
import 'package:em2rp/views/container_management_page.dart';
|
||||
import 'package:em2rp/views/container_form_page.dart';
|
||||
import 'package:em2rp/views/container_detail_page.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
@@ -52,6 +57,9 @@ void main() async {
|
||||
ChangeNotifierProvider<EquipmentProvider>(
|
||||
create: (context) => EquipmentProvider(),
|
||||
),
|
||||
ChangeNotifierProvider<ContainerProvider>(
|
||||
create: (context) => ContainerProvider(),
|
||||
),
|
||||
ChangeNotifierProvider<MaintenanceProvider>(
|
||||
create: (context) => MaintenanceProvider(),
|
||||
),
|
||||
@@ -123,6 +131,25 @@ class MyApp extends StatelessWidget {
|
||||
'/equipment_management': (context) => const AuthGuard(
|
||||
requiredPermission: "view_equipment",
|
||||
child: EquipmentManagementPage()),
|
||||
'/container_management': (context) => const AuthGuard(
|
||||
requiredPermission: "view_equipment",
|
||||
child: ContainerManagementPage()),
|
||||
'/container_form': (context) {
|
||||
final args = ModalRoute.of(context)?.settings.arguments;
|
||||
return AuthGuard(
|
||||
requiredPermission: "manage_equipment",
|
||||
child: ContainerFormPage(
|
||||
container: args as ContainerModel?,
|
||||
),
|
||||
);
|
||||
},
|
||||
'/container_detail': (context) {
|
||||
final container = ModalRoute.of(context)!.settings.arguments as ContainerModel;
|
||||
return AuthGuard(
|
||||
requiredPermission: "view_equipment",
|
||||
child: ContainerDetailPage(container: container),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
144
em2rp/lib/mixins/selection_mode_mixin.dart
Normal file
144
em2rp/lib/mixins/selection_mode_mixin.dart
Normal file
@@ -0,0 +1,144 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Mixin réutilisable pour gérer le mode sélection multiple
|
||||
/// Utilisable dans equipment_management_page, container_management_page, etc.
|
||||
mixin SelectionModeMixin<T extends StatefulWidget> on State<T> {
|
||||
// État du mode sélection
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedIds = {};
|
||||
|
||||
// Getters
|
||||
bool get isSelectionMode => _isSelectionMode;
|
||||
Set<String> get selectedIds => _selectedIds;
|
||||
int get selectedCount => _selectedIds.length;
|
||||
bool get hasSelection => _selectedIds.isNotEmpty;
|
||||
|
||||
/// Active/désactive le mode sélection
|
||||
void toggleSelectionMode() {
|
||||
setState(() {
|
||||
_isSelectionMode = !_isSelectionMode;
|
||||
if (!_isSelectionMode) {
|
||||
_selectedIds.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Active le mode sélection
|
||||
void enableSelectionMode() {
|
||||
if (!_isSelectionMode) {
|
||||
setState(() {
|
||||
_isSelectionMode = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Désactive le mode sélection et efface la sélection
|
||||
void disableSelectionMode() {
|
||||
if (_isSelectionMode) {
|
||||
setState(() {
|
||||
_isSelectionMode = false;
|
||||
_selectedIds.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle la sélection d'un item
|
||||
void toggleItemSelection(String id) {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(id)) {
|
||||
_selectedIds.remove(id);
|
||||
} else {
|
||||
_selectedIds.add(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Sélectionne un item
|
||||
void selectItem(String id) {
|
||||
setState(() {
|
||||
_selectedIds.add(id);
|
||||
});
|
||||
}
|
||||
|
||||
/// Désélectionne un item
|
||||
void deselectItem(String id) {
|
||||
setState(() {
|
||||
_selectedIds.remove(id);
|
||||
});
|
||||
}
|
||||
|
||||
/// Vérifie si un item est sélectionné
|
||||
bool isItemSelected(String id) {
|
||||
return _selectedIds.contains(id);
|
||||
}
|
||||
|
||||
/// Sélectionne tous les items
|
||||
void selectAll(List<String> ids) {
|
||||
setState(() {
|
||||
_selectedIds.addAll(ids);
|
||||
});
|
||||
}
|
||||
|
||||
/// Efface la sélection
|
||||
void clearSelection() {
|
||||
setState(() {
|
||||
_selectedIds.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/// Sélectionne/désélectionne tous les items
|
||||
void toggleSelectAll(List<String> ids) {
|
||||
setState(() {
|
||||
if (_selectedIds.length == ids.length) {
|
||||
// Tout est sélectionné, on désélectionne tout
|
||||
_selectedIds.clear();
|
||||
} else {
|
||||
// Sélectionner tout
|
||||
_selectedIds.addAll(ids);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget pour afficher le nombre d'éléments sélectionnés
|
||||
Widget buildSelectionCounter({
|
||||
required Color backgroundColor,
|
||||
required Color textColor,
|
||||
String? customText,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
customText ?? '$selectedCount sélectionné${selectedCount > 1 ? 's' : ''}',
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// AppBar pour le mode sélection
|
||||
PreferredSizeWidget buildSelectionAppBar({
|
||||
required String title,
|
||||
required List<Widget> actions,
|
||||
Color? backgroundColor,
|
||||
}) {
|
||||
return AppBar(
|
||||
backgroundColor: backgroundColor,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: disableSelectionMode,
|
||||
),
|
||||
title: Text(
|
||||
'$selectedCount $title sélectionné${selectedCount > 1 ? 's' : ''}',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
actions: actions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
251
em2rp/lib/models/container_model.dart
Normal file
251
em2rp/lib/models/container_model.dart
Normal file
@@ -0,0 +1,251 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
|
||||
/// Type de container
|
||||
enum ContainerType {
|
||||
flightCase, // Flight case
|
||||
pelicase, // Pelicase
|
||||
bag, // Sac
|
||||
openCrate, // Caisse ouverte
|
||||
toolbox, // Boîte à outils
|
||||
}
|
||||
|
||||
String containerTypeToString(ContainerType type) {
|
||||
switch (type) {
|
||||
case ContainerType.flightCase:
|
||||
return 'FLIGHT_CASE';
|
||||
case ContainerType.pelicase:
|
||||
return 'PELICASE';
|
||||
case ContainerType.bag:
|
||||
return 'BAG';
|
||||
case ContainerType.openCrate:
|
||||
return 'OPEN_CRATE';
|
||||
case ContainerType.toolbox:
|
||||
return 'TOOLBOX';
|
||||
}
|
||||
}
|
||||
|
||||
ContainerType containerTypeFromString(String? type) {
|
||||
switch (type) {
|
||||
case 'FLIGHT_CASE':
|
||||
return ContainerType.flightCase;
|
||||
case 'PELICASE':
|
||||
return ContainerType.pelicase;
|
||||
case 'BAG':
|
||||
return ContainerType.bag;
|
||||
case 'OPEN_CRATE':
|
||||
return ContainerType.openCrate;
|
||||
case 'TOOLBOX':
|
||||
return ContainerType.toolbox;
|
||||
default:
|
||||
return ContainerType.flightCase;
|
||||
}
|
||||
}
|
||||
|
||||
String containerTypeLabel(ContainerType type) {
|
||||
switch (type) {
|
||||
case ContainerType.flightCase:
|
||||
return 'Flight Case';
|
||||
case ContainerType.pelicase:
|
||||
return 'Pelicase';
|
||||
case ContainerType.bag:
|
||||
return 'Sac';
|
||||
case ContainerType.openCrate:
|
||||
return 'Caisse Ouverte';
|
||||
case ContainerType.toolbox:
|
||||
return 'Boîte à Outils';
|
||||
}
|
||||
}
|
||||
|
||||
/// Modèle de container/boîte pour le matériel
|
||||
class ContainerModel {
|
||||
final String id; // Identifiant unique (généré comme pour équipement)
|
||||
final String name; // Nom du container
|
||||
final ContainerType type; // Type de container
|
||||
final EquipmentStatus status; // Statut actuel (même que équipement)
|
||||
|
||||
// Caractéristiques physiques
|
||||
final double? weight; // Poids à vide (kg)
|
||||
final double? length; // Longueur (cm)
|
||||
final double? width; // Largeur (cm)
|
||||
final double? height; // Hauteur (cm)
|
||||
|
||||
// Contenu
|
||||
final List<String> equipmentIds; // IDs des équipements contenus
|
||||
|
||||
// Événement
|
||||
final String? eventId; // ID de l'événement actuel (si en prestation)
|
||||
|
||||
// Métadonnées
|
||||
final String? notes; // Notes additionnelles
|
||||
final DateTime createdAt; // Date de création
|
||||
final DateTime updatedAt; // Date de mise à jour
|
||||
|
||||
// Historique simple (optionnel)
|
||||
final List<ContainerHistoryEntry> history; // Historique des modifications
|
||||
|
||||
ContainerModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
this.status = EquipmentStatus.available,
|
||||
this.weight,
|
||||
this.length,
|
||||
this.width,
|
||||
this.height,
|
||||
this.equipmentIds = const [],
|
||||
this.eventId,
|
||||
this.notes,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.history = const [],
|
||||
});
|
||||
|
||||
/// Vérifier si le container est vide
|
||||
bool get isEmpty => equipmentIds.isEmpty;
|
||||
|
||||
/// Nombre d'équipements dans le container
|
||||
int get itemCount => equipmentIds.length;
|
||||
|
||||
/// Calculer le volume (m³)
|
||||
double? get volume {
|
||||
if (length == null || width == null || height == null) return null;
|
||||
return (length! * width! * height!) / 1000000; // cm³ to m³
|
||||
}
|
||||
|
||||
/// Calculer le poids total (poids vide + équipements)
|
||||
/// Nécessite la liste des équipements
|
||||
double calculateTotalWeight(List<EquipmentModel> equipment) {
|
||||
double total = weight ?? 0.0;
|
||||
for (final eq in equipment) {
|
||||
if (equipmentIds.contains(eq.id) && eq.weight != null) {
|
||||
total += eq.weight!;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Factory depuis Firestore
|
||||
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
|
||||
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
||||
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
||||
|
||||
final List<dynamic> historyRaw = map['history'] ?? [];
|
||||
final List<ContainerHistoryEntry> history = historyRaw
|
||||
.map((e) => ContainerHistoryEntry.fromMap(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
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(),
|
||||
equipmentIds: equipmentIds,
|
||||
eventId: map['eventId'],
|
||||
notes: map['notes'],
|
||||
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
||||
updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
||||
history: history,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convertir en Map pour Firestore
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'name': name,
|
||||
'type': containerTypeToString(type),
|
||||
'status': equipmentStatusToString(status),
|
||||
'weight': weight,
|
||||
'length': length,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'equipmentIds': equipmentIds,
|
||||
'eventId': eventId,
|
||||
'notes': notes,
|
||||
'createdAt': Timestamp.fromDate(createdAt),
|
||||
'updatedAt': Timestamp.fromDate(updatedAt),
|
||||
'history': history.map((e) => e.toMap()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Copier avec modifications
|
||||
ContainerModel copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
ContainerType? type,
|
||||
EquipmentStatus? status,
|
||||
double? weight,
|
||||
double? length,
|
||||
double? width,
|
||||
double? height,
|
||||
List<String>? equipmentIds,
|
||||
String? eventId,
|
||||
String? notes,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
List<ContainerHistoryEntry>? history,
|
||||
}) {
|
||||
return ContainerModel(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
status: status ?? this.status,
|
||||
weight: weight ?? this.weight,
|
||||
length: length ?? this.length,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
equipmentIds: equipmentIds ?? this.equipmentIds,
|
||||
eventId: eventId ?? this.eventId,
|
||||
notes: notes ?? this.notes,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
history: history ?? this.history,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Entrée d'historique pour un container
|
||||
class ContainerHistoryEntry {
|
||||
final DateTime timestamp;
|
||||
final String action; // 'added', 'removed', 'status_change', etc.
|
||||
final String? equipmentId; // ID de l'équipement concerné (si applicable)
|
||||
final String? previousValue; // Valeur précédente
|
||||
final String? newValue; // Nouvelle valeur
|
||||
final String? userId; // ID de l'utilisateur ayant fait la modification
|
||||
|
||||
ContainerHistoryEntry({
|
||||
required this.timestamp,
|
||||
required this.action,
|
||||
this.equipmentId,
|
||||
this.previousValue,
|
||||
this.newValue,
|
||||
this.userId,
|
||||
});
|
||||
|
||||
factory ContainerHistoryEntry.fromMap(Map<String, dynamic> map) {
|
||||
return ContainerHistoryEntry(
|
||||
timestamp: (map['timestamp'] as Timestamp?)?.toDate() ?? DateTime.now(),
|
||||
action: map['action'] ?? '',
|
||||
equipmentId: map['equipmentId'],
|
||||
previousValue: map['previousValue'],
|
||||
newValue: map['newValue'],
|
||||
userId: map['userId'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'timestamp': Timestamp.fromDate(timestamp),
|
||||
'action': action,
|
||||
'equipmentId': equipmentId,
|
||||
'previousValue': previousValue,
|
||||
'newValue': newValue,
|
||||
'userId': userId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,12 @@ class EquipmentModel {
|
||||
// Boîtes parentes (plusieurs possibles)
|
||||
final List<String> parentBoxIds; // IDs des boîtes contenant cet équipement
|
||||
|
||||
// Caractéristiques physiques
|
||||
final double? weight; // Poids (kg)
|
||||
final double? length; // Longueur (cm)
|
||||
final double? width; // Largeur (cm)
|
||||
final double? height; // Hauteur (cm)
|
||||
|
||||
// Dates & maintenance
|
||||
final DateTime? purchaseDate; // Date d'achat
|
||||
final DateTime? lastMaintenanceDate; // Dernière maintenance
|
||||
@@ -148,6 +154,10 @@ class EquipmentModel {
|
||||
this.availableQuantity,
|
||||
this.criticalThreshold,
|
||||
this.parentBoxIds = const [],
|
||||
this.weight,
|
||||
this.length,
|
||||
this.width,
|
||||
this.height,
|
||||
this.purchaseDate,
|
||||
this.lastMaintenanceDate,
|
||||
this.nextMaintenanceDate,
|
||||
@@ -179,6 +189,10 @@ class EquipmentModel {
|
||||
availableQuantity: map['availableQuantity']?.toInt(),
|
||||
criticalThreshold: map['criticalThreshold']?.toInt(),
|
||||
parentBoxIds: parentBoxIds,
|
||||
weight: map['weight']?.toDouble(),
|
||||
length: map['length']?.toDouble(),
|
||||
width: map['width']?.toDouble(),
|
||||
height: map['height']?.toDouble(),
|
||||
purchaseDate: (map['purchaseDate'] as Timestamp?)?.toDate(),
|
||||
nextMaintenanceDate: (map['nextMaintenanceDate'] as Timestamp?)?.toDate(),
|
||||
maintenanceIds: maintenanceIds,
|
||||
@@ -202,6 +216,10 @@ class EquipmentModel {
|
||||
'availableQuantity': availableQuantity,
|
||||
'criticalThreshold': criticalThreshold,
|
||||
'parentBoxIds': parentBoxIds,
|
||||
'weight': weight,
|
||||
'length': length,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'lastMaintenanceDate': lastMaintenanceDate != null ? Timestamp.fromDate(lastMaintenanceDate!) : null,
|
||||
'purchaseDate': purchaseDate != null ? Timestamp.fromDate(purchaseDate!) : null,
|
||||
'nextMaintenanceDate': nextMaintenanceDate != null ? Timestamp.fromDate(nextMaintenanceDate!) : null,
|
||||
@@ -226,6 +244,10 @@ class EquipmentModel {
|
||||
int? availableQuantity,
|
||||
int? criticalThreshold,
|
||||
List<String>? parentBoxIds,
|
||||
double? weight,
|
||||
double? length,
|
||||
double? width,
|
||||
double? height,
|
||||
DateTime? purchaseDate,
|
||||
DateTime? lastMaintenanceDate,
|
||||
DateTime? nextMaintenanceDate,
|
||||
@@ -248,6 +270,10 @@ class EquipmentModel {
|
||||
availableQuantity: availableQuantity ?? this.availableQuantity,
|
||||
criticalThreshold: criticalThreshold ?? this.criticalThreshold,
|
||||
parentBoxIds: parentBoxIds ?? this.parentBoxIds,
|
||||
weight: weight ?? this.weight,
|
||||
length: length ?? this.length,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
lastMaintenanceDate: lastMaintenanceDate ?? this.lastMaintenanceDate,
|
||||
purchaseDate: purchaseDate ?? this.purchaseDate,
|
||||
nextMaintenanceDate: nextMaintenanceDate ?? this.nextMaintenanceDate,
|
||||
|
||||
165
em2rp/lib/providers/container_provider.dart
Normal file
165
em2rp/lib/providers/container_provider.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/services/container_service.dart';
|
||||
|
||||
class ContainerProvider with ChangeNotifier {
|
||||
final ContainerService _containerService = ContainerService();
|
||||
|
||||
ContainerType? _selectedType;
|
||||
EquipmentStatus? _selectedStatus;
|
||||
String _searchQuery = '';
|
||||
|
||||
ContainerType? get selectedType => _selectedType;
|
||||
EquipmentStatus? get selectedStatus => _selectedStatus;
|
||||
String get searchQuery => _searchQuery;
|
||||
|
||||
/// Stream des containers avec filtres appliqués
|
||||
Stream<List<ContainerModel>> get containersStream {
|
||||
return _containerService.getContainers(
|
||||
type: _selectedType,
|
||||
status: _selectedStatus,
|
||||
searchQuery: _searchQuery,
|
||||
);
|
||||
}
|
||||
|
||||
/// Définir le type sélectionné
|
||||
void setSelectedType(ContainerType? type) {
|
||||
_selectedType = type;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Définir le statut sélectionné
|
||||
void setSelectedStatus(EquipmentStatus? status) {
|
||||
_selectedStatus = status;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Définir la requête de recherche
|
||||
void setSearchQuery(String query) {
|
||||
_searchQuery = query;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Créer un nouveau container
|
||||
Future<void> createContainer(ContainerModel container) async {
|
||||
await _containerService.createContainer(container);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Mettre à jour un container
|
||||
Future<void> updateContainer(String id, Map<String, dynamic> data) async {
|
||||
await _containerService.updateContainer(id, data);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Supprimer un container
|
||||
Future<void> deleteContainer(String id) async {
|
||||
await _containerService.deleteContainer(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Récupérer un container par ID
|
||||
Future<ContainerModel?> getContainerById(String id) async {
|
||||
return await _containerService.getContainerById(id);
|
||||
}
|
||||
|
||||
/// Ajouter un équipement à un container
|
||||
Future<Map<String, dynamic>> addEquipmentToContainer({
|
||||
required String containerId,
|
||||
required String equipmentId,
|
||||
String? userId,
|
||||
}) async {
|
||||
final result = await _containerService.addEquipmentToContainer(
|
||||
containerId: containerId,
|
||||
equipmentId: equipmentId,
|
||||
userId: userId,
|
||||
);
|
||||
notifyListeners();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Retirer un équipement d'un container
|
||||
Future<void> removeEquipmentFromContainer({
|
||||
required String containerId,
|
||||
required String equipmentId,
|
||||
String? userId,
|
||||
}) async {
|
||||
await _containerService.removeEquipmentFromContainer(
|
||||
containerId: containerId,
|
||||
equipmentId: equipmentId,
|
||||
userId: userId,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Vérifier la disponibilité d'un container
|
||||
Future<Map<String, dynamic>> checkContainerAvailability({
|
||||
required String containerId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId,
|
||||
}) async {
|
||||
return await _containerService.checkContainerAvailability(
|
||||
containerId: containerId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludeEventId: excludeEventId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Récupérer les équipements d'un container
|
||||
Future<List<EquipmentModel>> getContainerEquipment(String containerId) async {
|
||||
return await _containerService.getContainerEquipment(containerId);
|
||||
}
|
||||
|
||||
/// Trouver tous les containers contenant un équipement
|
||||
Future<List<ContainerModel>> findContainersWithEquipment(String equipmentId) async {
|
||||
return await _containerService.findContainersWithEquipment(equipmentId);
|
||||
}
|
||||
|
||||
/// Vérifier si un ID existe
|
||||
Future<bool> checkContainerIdExists(String id) async {
|
||||
return await _containerService.checkContainerIdExists(id);
|
||||
}
|
||||
|
||||
/// Générer un ID unique pour un container
|
||||
/// Format: BOX_{TYPE}_{NAME}_{NUMBER}
|
||||
static String generateContainerId({
|
||||
required ContainerType type,
|
||||
required String name,
|
||||
int? number,
|
||||
}) {
|
||||
// Obtenir le type en majuscules
|
||||
final typeStr = containerTypeToString(type);
|
||||
|
||||
// Nettoyer le nom (enlever espaces, caractères spéciaux)
|
||||
final cleanName = name
|
||||
.replaceAll(' ', '_')
|
||||
.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '')
|
||||
.toUpperCase();
|
||||
|
||||
if (number != null) {
|
||||
return 'BOX_${typeStr}_${cleanName}_#$number';
|
||||
}
|
||||
|
||||
return 'BOX_${typeStr}_$cleanName';
|
||||
}
|
||||
|
||||
/// Assurer l'unicité d'un ID de container
|
||||
static Future<String> ensureUniqueContainerId(
|
||||
String baseId,
|
||||
ContainerService service,
|
||||
) async {
|
||||
String uniqueId = baseId;
|
||||
int counter = 1;
|
||||
|
||||
while (await service.checkContainerIdExists(uniqueId)) {
|
||||
uniqueId = '${baseId}_$counter';
|
||||
counter++;
|
||||
}
|
||||
|
||||
return uniqueId;
|
||||
}
|
||||
}
|
||||
|
||||
52
em2rp/lib/services/container_pdf_generator_service.dart
Normal file
52
em2rp/lib/services/container_pdf_generator_service.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/services/unified_pdf_generator_service.dart';
|
||||
|
||||
export 'package:em2rp/services/unified_pdf_generator_service.dart' show QRLabelFormat;
|
||||
|
||||
/// Formats d'étiquettes disponibles pour containers (legacy - utilise QRLabelFormat maintenant)
|
||||
@Deprecated('Utiliser QRLabelFormat directement')
|
||||
typedef ContainerQRLabelFormat = QRLabelFormat;
|
||||
|
||||
/// Service pour la génération de PDFs avec QR codes pour containers
|
||||
/// WRAPPER LEGACY - Utilise maintenant UnifiedPDFGeneratorService
|
||||
@Deprecated('Utiliser UnifiedPDFGeneratorService directement')
|
||||
class ContainerPDFGeneratorService {
|
||||
/// Génère un PDF avec des QR codes selon le format choisi
|
||||
static Future<Uint8List> generateQRCodesPDF({
|
||||
required List<ContainerModel> containerList,
|
||||
required Map<String, List<EquipmentModel>> containerEquipmentMap,
|
||||
required QRLabelFormat format,
|
||||
}) async {
|
||||
// Pour les grandes étiquettes, inclure les équipements
|
||||
if (format == QRLabelFormat.large) {
|
||||
return UnifiedPDFGeneratorService.generateAdvancedQRCodesPDF(
|
||||
items: containerList,
|
||||
getId: (c) => c.id,
|
||||
getTitle: (c) => c.name,
|
||||
getSubtitle: (c) {
|
||||
final equipment = containerEquipmentMap[c.id] ?? [];
|
||||
final lines = <String>[
|
||||
'Contenu (${equipment.length}):',
|
||||
...equipment.take(5).map((eq) => '- ${eq.id}'),
|
||||
if (equipment.length > 5) '... +${equipment.length - 5}',
|
||||
];
|
||||
return lines;
|
||||
},
|
||||
format: format,
|
||||
);
|
||||
}
|
||||
|
||||
// Pour les petites et moyennes étiquettes, juste ID + nom
|
||||
return UnifiedPDFGeneratorService.generateAdvancedQRCodesPDF(
|
||||
items: containerList,
|
||||
getId: (c) => c.id,
|
||||
getTitle: (c) => c.name,
|
||||
getSubtitle: null,
|
||||
format: format,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
378
em2rp/lib/services/container_service.dart
Normal file
378
em2rp/lib/services/container_service.dart
Normal file
@@ -0,0 +1,378 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
|
||||
class ContainerService {
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
|
||||
// Collection references
|
||||
CollectionReference get _containersCollection => _firestore.collection('containers');
|
||||
CollectionReference get _equipmentCollection => _firestore.collection('equipments');
|
||||
|
||||
// CRUD Operations
|
||||
|
||||
/// Créer un nouveau container
|
||||
Future<void> createContainer(ContainerModel container) async {
|
||||
try {
|
||||
await _containersCollection.doc(container.id).set(container.toMap());
|
||||
} catch (e) {
|
||||
print('Error creating container: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mettre à jour un container
|
||||
Future<void> updateContainer(String id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
data['updatedAt'] = Timestamp.fromDate(DateTime.now());
|
||||
await _containersCollection.doc(id).update(data);
|
||||
} catch (e) {
|
||||
print('Error updating container: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprimer un container
|
||||
Future<void> deleteContainer(String id) async {
|
||||
try {
|
||||
// Récupérer le container pour obtenir les équipements
|
||||
final container = await getContainerById(id);
|
||||
if (container != null && container.equipmentIds.isNotEmpty) {
|
||||
// Retirer le container des parentBoxIds de chaque équipement
|
||||
for (final equipmentId in container.equipmentIds) {
|
||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
||||
if (equipmentDoc.exists) {
|
||||
final equipment = EquipmentModel.fromMap(
|
||||
equipmentDoc.data() as Map<String, dynamic>,
|
||||
equipmentDoc.id,
|
||||
);
|
||||
final updatedParents = equipment.parentBoxIds.where((boxId) => boxId != id).toList();
|
||||
await _equipmentCollection.doc(equipmentId).update({
|
||||
'parentBoxIds': updatedParents,
|
||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _containersCollection.doc(id).delete();
|
||||
} catch (e) {
|
||||
print('Error deleting container: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupérer un container par ID
|
||||
Future<ContainerModel?> getContainerById(String id) async {
|
||||
try {
|
||||
final doc = await _containersCollection.doc(id).get();
|
||||
if (doc.exists) {
|
||||
return ContainerModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('Error getting container: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupérer tous les containers
|
||||
Stream<List<ContainerModel>> getContainers({
|
||||
ContainerType? type,
|
||||
EquipmentStatus? status,
|
||||
String? searchQuery,
|
||||
}) {
|
||||
try {
|
||||
Query query = _containersCollection;
|
||||
|
||||
// Filtre par type
|
||||
if (type != null) {
|
||||
query = query.where('type', isEqualTo: containerTypeToString(type));
|
||||
}
|
||||
|
||||
// Filtre par statut
|
||||
if (status != null) {
|
||||
query = query.where('status', isEqualTo: equipmentStatusToString(status));
|
||||
}
|
||||
|
||||
return query.snapshots().map((snapshot) {
|
||||
List<ContainerModel> containerList = snapshot.docs
|
||||
.map((doc) => ContainerModel.fromMap(doc.data() as Map<String, dynamic>, doc.id))
|
||||
.toList();
|
||||
|
||||
// Filtre par recherche texte (côté client)
|
||||
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||
final lowerSearch = searchQuery.toLowerCase();
|
||||
containerList = containerList.where((container) {
|
||||
return container.name.toLowerCase().contains(lowerSearch) ||
|
||||
container.id.toLowerCase().contains(lowerSearch);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return containerList;
|
||||
});
|
||||
} catch (e) {
|
||||
print('Error getting containers: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajouter un équipement à un container
|
||||
Future<Map<String, dynamic>> addEquipmentToContainer({
|
||||
required String containerId,
|
||||
required String equipmentId,
|
||||
String? userId,
|
||||
}) async {
|
||||
try {
|
||||
// Récupérer le container
|
||||
final container = await getContainerById(containerId);
|
||||
if (container == null) {
|
||||
return {'success': false, 'message': 'Container non trouvé'};
|
||||
}
|
||||
|
||||
// Vérifier si l'équipement n'est pas déjà dans ce container
|
||||
if (container.equipmentIds.contains(equipmentId)) {
|
||||
return {'success': false, 'message': 'Cet équipement est déjà dans ce container'};
|
||||
}
|
||||
|
||||
// Récupérer l'équipement pour vérifier s'il est déjà dans d'autres containers
|
||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
||||
if (!equipmentDoc.exists) {
|
||||
return {'success': false, 'message': 'Équipement non trouvé'};
|
||||
}
|
||||
|
||||
final equipment = EquipmentModel.fromMap(
|
||||
equipmentDoc.data() as Map<String, dynamic>,
|
||||
equipmentDoc.id,
|
||||
);
|
||||
|
||||
// Avertir si l'équipement est déjà dans d'autres containers
|
||||
List<String> otherContainers = [];
|
||||
if (equipment.parentBoxIds.isNotEmpty) {
|
||||
for (final boxId in equipment.parentBoxIds) {
|
||||
final box = await getContainerById(boxId);
|
||||
if (box != null) {
|
||||
otherContainers.add(box.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le container
|
||||
final updatedEquipmentIds = [...container.equipmentIds, equipmentId];
|
||||
await updateContainer(containerId, {
|
||||
'equipmentIds': updatedEquipmentIds,
|
||||
});
|
||||
|
||||
// Mettre à jour l'équipement
|
||||
final updatedParentBoxIds = [...equipment.parentBoxIds, containerId];
|
||||
await _equipmentCollection.doc(equipmentId).update({
|
||||
'parentBoxIds': updatedParentBoxIds,
|
||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
||||
});
|
||||
|
||||
// Ajouter une entrée dans l'historique
|
||||
await _addHistoryEntry(
|
||||
containerId: containerId,
|
||||
action: 'equipment_added',
|
||||
equipmentId: equipmentId,
|
||||
newValue: equipmentId,
|
||||
userId: userId,
|
||||
);
|
||||
|
||||
return {
|
||||
'success': true,
|
||||
'message': 'Équipement ajouté avec succès',
|
||||
'warnings': otherContainers.isNotEmpty
|
||||
? 'Attention : cet équipement est également dans les containers suivants : ${otherContainers.join(", ")}'
|
||||
: null,
|
||||
};
|
||||
} catch (e) {
|
||||
print('Error adding equipment to container: $e');
|
||||
return {'success': false, 'message': 'Erreur: $e'};
|
||||
}
|
||||
}
|
||||
|
||||
/// Retirer un équipement d'un container
|
||||
Future<void> removeEquipmentFromContainer({
|
||||
required String containerId,
|
||||
required String equipmentId,
|
||||
String? userId,
|
||||
}) async {
|
||||
try {
|
||||
// Récupérer le container
|
||||
final container = await getContainerById(containerId);
|
||||
if (container == null) throw Exception('Container non trouvé');
|
||||
|
||||
// Mettre à jour le container
|
||||
final updatedEquipmentIds = container.equipmentIds.where((id) => id != equipmentId).toList();
|
||||
await updateContainer(containerId, {
|
||||
'equipmentIds': updatedEquipmentIds,
|
||||
});
|
||||
|
||||
// Mettre à jour l'équipement
|
||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
||||
if (equipmentDoc.exists) {
|
||||
final equipment = EquipmentModel.fromMap(
|
||||
equipmentDoc.data() as Map<String, dynamic>,
|
||||
equipmentDoc.id,
|
||||
);
|
||||
final updatedParentBoxIds = equipment.parentBoxIds.where((id) => id != containerId).toList();
|
||||
await _equipmentCollection.doc(equipmentId).update({
|
||||
'parentBoxIds': updatedParentBoxIds,
|
||||
'updatedAt': Timestamp.fromDate(DateTime.now()),
|
||||
});
|
||||
}
|
||||
|
||||
// Ajouter une entrée dans l'historique
|
||||
await _addHistoryEntry(
|
||||
containerId: containerId,
|
||||
action: 'equipment_removed',
|
||||
equipmentId: equipmentId,
|
||||
previousValue: equipmentId,
|
||||
userId: userId,
|
||||
);
|
||||
} catch (e) {
|
||||
print('Error removing equipment from container: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifier la disponibilité d'un container et de son contenu pour un événement
|
||||
Future<Map<String, dynamic>> checkContainerAvailability({
|
||||
required String containerId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
String? excludeEventId,
|
||||
}) async {
|
||||
try {
|
||||
final container = await getContainerById(containerId);
|
||||
if (container == null) {
|
||||
return {'available': false, 'message': 'Container non trouvé'};
|
||||
}
|
||||
|
||||
// Vérifier le statut du container
|
||||
if (container.status != EquipmentStatus.available) {
|
||||
return {
|
||||
'available': false,
|
||||
'message': 'Container ${container.name} n\'est pas disponible (statut: ${container.status})',
|
||||
};
|
||||
}
|
||||
|
||||
// Vérifier la disponibilité de chaque équipement dans le container
|
||||
List<String> unavailableEquipment = [];
|
||||
for (final equipmentId in container.equipmentIds) {
|
||||
final equipmentDoc = await _equipmentCollection.doc(equipmentId).get();
|
||||
if (equipmentDoc.exists) {
|
||||
final equipment = EquipmentModel.fromMap(
|
||||
equipmentDoc.data() as Map<String, dynamic>,
|
||||
equipmentDoc.id,
|
||||
);
|
||||
|
||||
if (equipment.status != EquipmentStatus.available) {
|
||||
unavailableEquipment.add('${equipment.name} (${equipment.status})');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unavailableEquipment.isNotEmpty) {
|
||||
return {
|
||||
'available': false,
|
||||
'message': 'Certains équipements ne sont pas disponibles',
|
||||
'unavailableItems': unavailableEquipment,
|
||||
};
|
||||
}
|
||||
|
||||
return {'available': true, 'message': 'Container et tout son contenu disponibles'};
|
||||
} catch (e) {
|
||||
print('Error checking container availability: $e');
|
||||
return {'available': false, 'message': 'Erreur: $e'};
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupérer les équipements d'un container
|
||||
Future<List<EquipmentModel>> getContainerEquipment(String containerId) async {
|
||||
try {
|
||||
final container = await getContainerById(containerId);
|
||||
if (container == null) return [];
|
||||
|
||||
List<EquipmentModel> equipment = [];
|
||||
for (final equipmentId in container.equipmentIds) {
|
||||
final doc = await _equipmentCollection.doc(equipmentId).get();
|
||||
if (doc.exists) {
|
||||
equipment.add(EquipmentModel.fromMap(doc.data() as Map<String, dynamic>, doc.id));
|
||||
}
|
||||
}
|
||||
|
||||
return equipment;
|
||||
} catch (e) {
|
||||
print('Error getting container equipment: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Trouver tous les containers contenant un équipement spécifique
|
||||
Future<List<ContainerModel>> findContainersWithEquipment(String equipmentId) async {
|
||||
try {
|
||||
final snapshot = await _containersCollection
|
||||
.where('equipmentIds', arrayContains: equipmentId)
|
||||
.get();
|
||||
|
||||
return snapshot.docs
|
||||
.map((doc) => ContainerModel.fromMap(doc.data() as Map<String, dynamic>, doc.id))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
print('Error finding containers with equipment: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajouter une entrée d'historique
|
||||
Future<void> _addHistoryEntry({
|
||||
required String containerId,
|
||||
required String action,
|
||||
String? equipmentId,
|
||||
String? previousValue,
|
||||
String? newValue,
|
||||
String? userId,
|
||||
}) async {
|
||||
try {
|
||||
final container = await getContainerById(containerId);
|
||||
if (container == null) return;
|
||||
|
||||
final entry = ContainerHistoryEntry(
|
||||
timestamp: DateTime.now(),
|
||||
action: action,
|
||||
equipmentId: equipmentId,
|
||||
previousValue: previousValue,
|
||||
newValue: newValue,
|
||||
userId: userId,
|
||||
);
|
||||
|
||||
final updatedHistory = [...container.history, entry];
|
||||
|
||||
// Limiter l'historique aux 100 dernières entrées
|
||||
final limitedHistory = updatedHistory.length > 100
|
||||
? updatedHistory.sublist(updatedHistory.length - 100)
|
||||
: updatedHistory;
|
||||
|
||||
await updateContainer(containerId, {
|
||||
'history': limitedHistory.map((e) => e.toMap()).toList(),
|
||||
});
|
||||
} catch (e) {
|
||||
print('Error adding history entry: $e');
|
||||
// Ne pas throw pour éviter de bloquer l'opération principale
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifier si un ID de container existe déjà
|
||||
Future<bool> checkContainerIdExists(String id) async {
|
||||
try {
|
||||
final doc = await _containersCollection.doc(id).get();
|
||||
return doc.exists;
|
||||
} catch (e) {
|
||||
print('Error checking container ID: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/models/alert_model.dart';
|
||||
import 'package:em2rp/models/maintenance_model.dart';
|
||||
|
||||
class EquipmentService {
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
@@ -369,5 +370,63 @@ class EquipmentService {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupérer plusieurs équipements par leurs IDs
|
||||
Future<List<EquipmentModel>> getEquipmentsByIds(List<String> ids) async {
|
||||
try {
|
||||
if (ids.isEmpty) return [];
|
||||
|
||||
final equipments = <EquipmentModel>[];
|
||||
|
||||
// Firestore limite les requêtes whereIn à 10 éléments
|
||||
// On doit donc diviser en plusieurs requêtes si nécessaire
|
||||
for (int i = 0; i < ids.length; i += 10) {
|
||||
final batch = ids.skip(i).take(10).toList();
|
||||
final query = await _equipmentCollection
|
||||
.where(FieldPath.documentId, whereIn: batch)
|
||||
.get();
|
||||
|
||||
for (var doc in query.docs) {
|
||||
equipments.add(
|
||||
EquipmentModel.fromMap(
|
||||
doc.data() as Map<String, dynamic>,
|
||||
doc.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return equipments;
|
||||
} catch (e) {
|
||||
print('Error getting equipments by IDs: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupérer les maintenances pour un équipement
|
||||
Future<List<MaintenanceModel>> getMaintenancesForEquipment(String equipmentId) async {
|
||||
try {
|
||||
final maintenanceQuery = await _firestore
|
||||
.collection('maintenances')
|
||||
.where('equipmentIds', arrayContains: equipmentId)
|
||||
.orderBy('scheduledDate', descending: true)
|
||||
.get();
|
||||
|
||||
final maintenances = <MaintenanceModel>[];
|
||||
for (var doc in maintenanceQuery.docs) {
|
||||
maintenances.add(
|
||||
MaintenanceModel.fromMap(
|
||||
doc.data(),
|
||||
doc.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return maintenances;
|
||||
} catch (e) {
|
||||
print('Error getting maintenances for equipment: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
76
em2rp/lib/services/pdf_generator_service.dart
Normal file
76
em2rp/lib/services/pdf_generator_service.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/services/unified_pdf_generator_service.dart';
|
||||
|
||||
// Export QRLabelFormat pour rétrocompatibilité
|
||||
export 'package:em2rp/services/unified_pdf_generator_service.dart' show QRLabelFormat;
|
||||
|
||||
/// Service pour la génération de PDFs avec QR codes pour équipements
|
||||
/// WRAPPER LEGACY - Utilise maintenant UnifiedPDFGeneratorService
|
||||
@Deprecated('Utiliser UnifiedPDFGeneratorService directement')
|
||||
class PDFGeneratorService {
|
||||
/// Génère un PDF avec des QR codes selon le format choisi
|
||||
static Future<Uint8List> generateQRCodesPDF({
|
||||
required List<EquipmentModel> equipmentList,
|
||||
required QRLabelFormat format,
|
||||
}) async {
|
||||
// Pour les grandes étiquettes, ajouter les détails
|
||||
if (format == QRLabelFormat.large) {
|
||||
return UnifiedPDFGeneratorService.generateAdvancedQRCodesPDF(
|
||||
items: equipmentList,
|
||||
getId: (eq) => eq.id,
|
||||
getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(),
|
||||
getSubtitle: (eq) {
|
||||
final details = <String>[];
|
||||
|
||||
// Marque
|
||||
if (eq.brand != null && eq.brand!.isNotEmpty) {
|
||||
details.add('Marque: ${eq.brand}');
|
||||
}
|
||||
|
||||
// Modèle
|
||||
if (eq.model != null && eq.model!.isNotEmpty) {
|
||||
details.add('Modèle: ${eq.model}');
|
||||
}
|
||||
|
||||
// Catégorie
|
||||
details.add('Catégorie: ${_getCategoryLabel(eq.category)}');
|
||||
|
||||
return details;
|
||||
},
|
||||
format: format,
|
||||
);
|
||||
}
|
||||
|
||||
// Pour petites et moyennes étiquettes, juste ID + marque/modèle
|
||||
return UnifiedPDFGeneratorService.generateAdvancedQRCodesPDF(
|
||||
items: equipmentList,
|
||||
getId: (eq) => eq.id,
|
||||
getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(),
|
||||
getSubtitle: null,
|
||||
format: format,
|
||||
);
|
||||
}
|
||||
|
||||
static String _getCategoryLabel(EquipmentCategory category) {
|
||||
switch (category) {
|
||||
case EquipmentCategory.lighting:
|
||||
return 'Lumière';
|
||||
case EquipmentCategory.sound:
|
||||
return 'Son';
|
||||
case EquipmentCategory.video:
|
||||
return 'Vidéo';
|
||||
case EquipmentCategory.effect:
|
||||
return 'Effets';
|
||||
case EquipmentCategory.structure:
|
||||
return 'Structure';
|
||||
case EquipmentCategory.cable:
|
||||
return 'Câble';
|
||||
case EquipmentCategory.consumable:
|
||||
return 'Consommable';
|
||||
case EquipmentCategory.other:
|
||||
return 'Autre';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
173
em2rp/lib/services/qr_code_service.dart
Normal file
173
em2rp/lib/services/qr_code_service.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
|
||||
/// Service pour la génération de QR codes optimisée
|
||||
class QRCodeService {
|
||||
// Cache pour éviter de régénérer les mêmes QR codes
|
||||
static final Map<String, Uint8List> _qrCache = {};
|
||||
static ui.Image? _cachedLogoImage;
|
||||
|
||||
/// Génère un QR code simple sans logo
|
||||
static Future<Uint8List> generateQRCode(
|
||||
String data, {
|
||||
double size = 512,
|
||||
bool useCache = true,
|
||||
}) async {
|
||||
// Vérifier le cache
|
||||
if (useCache && _qrCache.containsKey(data)) {
|
||||
return _qrCache[data]!;
|
||||
}
|
||||
|
||||
final qrValidationResult = QrValidator.validate(
|
||||
data: data,
|
||||
version: QrVersions.auto,
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.L,
|
||||
);
|
||||
|
||||
if (qrValidationResult.status != QrValidationStatus.valid) {
|
||||
throw Exception('QR code validation failed for data: $data');
|
||||
}
|
||||
|
||||
final qrCode = qrValidationResult.qrCode!;
|
||||
final painter = QrPainter.withQr(
|
||||
qr: qrCode,
|
||||
eyeStyle: const QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: AppColors.noir,
|
||||
),
|
||||
gapless: true,
|
||||
);
|
||||
|
||||
final picData = await painter.toImageData(size, format: ui.ImageByteFormat.png);
|
||||
final bytes = picData!.buffer.asUint8List();
|
||||
|
||||
// Mettre en cache
|
||||
if (useCache) {
|
||||
_qrCache[data] = bytes;
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Génère un QR code avec logo embarqué
|
||||
static Future<Uint8List> generateQRCodeWithLogo(
|
||||
String data, {
|
||||
double size = 512,
|
||||
bool useCache = true,
|
||||
}) async {
|
||||
final cacheKey = '${data}_logo';
|
||||
|
||||
// Vérifier le cache
|
||||
if (useCache && _qrCache.containsKey(cacheKey)) {
|
||||
return _qrCache[cacheKey]!;
|
||||
}
|
||||
|
||||
final qrValidationResult = QrValidator.validate(
|
||||
data: data,
|
||||
version: QrVersions.auto,
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.L,
|
||||
);
|
||||
|
||||
if (qrValidationResult.status != QrValidationStatus.valid) {
|
||||
throw Exception('QR code validation failed for data: $data');
|
||||
}
|
||||
|
||||
final qrCode = qrValidationResult.qrCode!;
|
||||
final embedded = await _loadLogoImage();
|
||||
|
||||
final painter = QrPainter.withQr(
|
||||
qr: qrCode,
|
||||
embeddedImage: embedded,
|
||||
embeddedImageStyle: const QrEmbeddedImageStyle(
|
||||
size: Size(80, 80),
|
||||
),
|
||||
gapless: true,
|
||||
);
|
||||
|
||||
final picData = await painter.toImageData(size, format: ui.ImageByteFormat.png);
|
||||
final bytes = picData!.buffer.asUint8List();
|
||||
|
||||
// Mettre en cache
|
||||
if (useCache) {
|
||||
_qrCache[cacheKey] = bytes;
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Charge le logo depuis les assets (avec cache)
|
||||
static Future<ui.Image> _loadLogoImage() async {
|
||||
if (_cachedLogoImage != null) {
|
||||
return _cachedLogoImage!;
|
||||
}
|
||||
|
||||
final data = await rootBundle.load('assets/logos/SquareLogoBlack.png');
|
||||
final codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
|
||||
final frame = await codec.getNextFrame();
|
||||
_cachedLogoImage = frame.image;
|
||||
return _cachedLogoImage!;
|
||||
}
|
||||
|
||||
/// Génère plusieurs QR codes en parallèle (optimisé)
|
||||
static Future<List<Uint8List>> generateBulkQRCodes(
|
||||
List<String> dataList, {
|
||||
double size = 512,
|
||||
bool withLogo = false,
|
||||
bool useCache = true,
|
||||
}) async {
|
||||
// Si tout est en cache, retourner immédiatement
|
||||
if (useCache) {
|
||||
final allCached = dataList.every((data) {
|
||||
final key = withLogo ? '${data}_logo' : data;
|
||||
return _qrCache.containsKey(key);
|
||||
});
|
||||
|
||||
if (allCached) {
|
||||
return dataList.map((data) {
|
||||
final key = withLogo ? '${data}_logo' : data;
|
||||
return _qrCache[key]!;
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
// Batching adaptatif optimisé selon la taille et le nombre
|
||||
int batchSize;
|
||||
if (size <= 200) {
|
||||
batchSize = 100; // Petits QR : lots de 100
|
||||
} else if (size <= 300) {
|
||||
batchSize = 50; // Moyens QR : lots de 50
|
||||
} else if (size <= 500) {
|
||||
batchSize = 20; // Grands QR : lots de 20
|
||||
} else {
|
||||
batchSize = 10; // Très grands : lots de 10
|
||||
}
|
||||
|
||||
final List<Uint8List> results = [];
|
||||
|
||||
for (int i = 0; i < dataList.length; i += batchSize) {
|
||||
final batch = dataList.skip(i).take(batchSize).toList();
|
||||
final batchResults = await Future.wait(
|
||||
batch.map((data) => withLogo
|
||||
? generateQRCodeWithLogo(data, size: size, useCache: useCache)
|
||||
: generateQRCode(data, size: size, useCache: useCache)),
|
||||
);
|
||||
results.addAll(batchResults);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// Vide le cache des QR codes
|
||||
static void clearCache() {
|
||||
_qrCache.clear();
|
||||
}
|
||||
|
||||
/// Obtient la taille du cache
|
||||
static int getCacheSize() {
|
||||
return _qrCache.length;
|
||||
}
|
||||
}
|
||||
|
||||
354
em2rp/lib/services/unified_pdf_generator_service.dart
Normal file
354
em2rp/lib/services/unified_pdf_generator_service.dart
Normal file
@@ -0,0 +1,354 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:em2rp/services/qr_code_service.dart';
|
||||
|
||||
/// Formats d'étiquettes QR disponibles
|
||||
enum QRLabelFormat { small, medium, large }
|
||||
|
||||
/// Interface pour les items qui peuvent avoir un QR code
|
||||
abstract class QRCodeItem {
|
||||
String get id;
|
||||
String get displayName;
|
||||
}
|
||||
|
||||
/// Service unifié pour la génération de PDFs avec QR codes
|
||||
/// Fonctionne avec n'importe quel type d'objet ayant un ID
|
||||
class UnifiedPDFGeneratorService {
|
||||
static Uint8List? _cachedLogoBytes;
|
||||
|
||||
/// Tronque un texte s'il dépasse une longueur maximale
|
||||
static String _truncateText(String text, int maxLength) {
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return '${text.substring(0, maxLength - 3)}...';
|
||||
}
|
||||
|
||||
/// Charge le logo en cache (optimisation)
|
||||
static Future<void> _ensureLogoLoaded() async {
|
||||
if (_cachedLogoBytes == null) {
|
||||
try {
|
||||
final logoData = await rootBundle.load('assets/logos/LowQRectangleLogoBlack.png');
|
||||
_cachedLogoBytes = logoData.buffer.asUint8List();
|
||||
} catch (e) {
|
||||
// Logo non disponible, on continue sans
|
||||
_cachedLogoBytes = Uint8List(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Génère un PDF avec des QR codes simples (juste ID + QR)
|
||||
static Future<Uint8List> generateSimpleQRCodesPDF<T>({
|
||||
required List<T> items,
|
||||
required String Function(T) getId,
|
||||
required QRLabelFormat format,
|
||||
String Function(T)? getDisplayName,
|
||||
}) async {
|
||||
final pdf = pw.Document();
|
||||
|
||||
switch (format) {
|
||||
case QRLabelFormat.small:
|
||||
await _generateSmallQRCodesPDF(pdf, items, getId, getDisplayName);
|
||||
break;
|
||||
case QRLabelFormat.medium:
|
||||
await _generateMediumQRCodesPDF(pdf, items, getId, getDisplayName);
|
||||
break;
|
||||
case QRLabelFormat.large:
|
||||
await _generateLargeQRCodesPDF(pdf, items, getId, getDisplayName, null);
|
||||
break;
|
||||
}
|
||||
|
||||
return pdf.save();
|
||||
}
|
||||
|
||||
/// Génère un PDF avec des QR codes avancés (avec informations supplémentaires)
|
||||
static Future<Uint8List> generateAdvancedQRCodesPDF<T>({
|
||||
required List<T> items,
|
||||
required String Function(T) getId,
|
||||
required String Function(T) getTitle,
|
||||
required List<String> Function(T)? getSubtitle,
|
||||
required QRLabelFormat format,
|
||||
}) async {
|
||||
final pdf = pw.Document();
|
||||
|
||||
switch (format) {
|
||||
case QRLabelFormat.small:
|
||||
await _generateSmallQRCodesPDF(pdf, items, getId, getTitle);
|
||||
break;
|
||||
case QRLabelFormat.medium:
|
||||
await _generateMediumQRCodesPDF(pdf, items, getId, getTitle);
|
||||
break;
|
||||
case QRLabelFormat.large:
|
||||
await _generateLargeQRCodesPDF(pdf, items, getId, getTitle, getSubtitle);
|
||||
break;
|
||||
}
|
||||
|
||||
return pdf.save();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// PETITS QR CODES (2x2 cm, 20 par page)
|
||||
// ==========================================================================
|
||||
|
||||
static Future<void> _generateSmallQRCodesPDF<T>(
|
||||
pw.Document pdf,
|
||||
List<T> items,
|
||||
String Function(T) getId,
|
||||
String Function(T)? getDisplayName,
|
||||
) async {
|
||||
const qrSize = 56.69; // 2cm en points
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// Générer tous les QR codes en une fois (optimisé avec résolution réduite)
|
||||
final allQRImages = await QRCodeService.generateBulkQRCodes(
|
||||
items.map((item) => getId(item)).toList(),
|
||||
size: 150, // Réduit de 200 à 150 pour performance optimale
|
||||
withLogo: false,
|
||||
useCache: true,
|
||||
);
|
||||
|
||||
for (int pageStart = 0; pageStart < items.length; pageStart += itemsPerPage) {
|
||||
final pageItems = items.skip(pageStart).take(itemsPerPage).toList();
|
||||
final pageQRImages = allQRImages.skip(pageStart).take(itemsPerPage).toList();
|
||||
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(20),
|
||||
build: (context) {
|
||||
return pw.Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: List.generate(pageItems.length, (index) {
|
||||
return pw.Container(
|
||||
width: qrSize,
|
||||
height: qrSize + 20,
|
||||
child: pw.Column(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||
children: [
|
||||
pw.Image(pw.MemoryImage(pageQRImages[index])),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(
|
||||
getId(pageItems[index]),
|
||||
style: pw.TextStyle(
|
||||
fontSize: 6,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
textAlign: pw.TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// QR CODES MOYENS (4x4 cm, 6 par page)
|
||||
// ==========================================================================
|
||||
|
||||
static Future<void> _generateMediumQRCodesPDF<T>(
|
||||
pw.Document pdf,
|
||||
List<T> items,
|
||||
String Function(T) getId,
|
||||
String Function(T)? getDisplayName,
|
||||
) async {
|
||||
const qrSize = 113.39; // 4cm en points
|
||||
const itemsPerPage = 6;
|
||||
|
||||
// Optimisé avec résolution réduite
|
||||
final allQRImages = await QRCodeService.generateBulkQRCodes(
|
||||
items.map((item) => getId(item)).toList(),
|
||||
size: 250, // Réduit de 400 à 250 pour performance optimale
|
||||
withLogo: false,
|
||||
useCache: true,
|
||||
);
|
||||
|
||||
for (int pageStart = 0; pageStart < items.length; pageStart += itemsPerPage) {
|
||||
final pageItems = items.skip(pageStart).take(itemsPerPage).toList();
|
||||
final pageQRImages = allQRImages.skip(pageStart).take(itemsPerPage).toList();
|
||||
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(20),
|
||||
build: (context) {
|
||||
return pw.Wrap(
|
||||
spacing: 20,
|
||||
runSpacing: 20,
|
||||
children: List.generate(pageItems.length, (index) {
|
||||
return pw.Container(
|
||||
width: qrSize,
|
||||
height: qrSize + 30,
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||
children: [
|
||||
pw.Image(pw.MemoryImage(pageQRImages[index])),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(
|
||||
getId(pageItems[index]),
|
||||
style: pw.TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
textAlign: pw.TextAlign.center,
|
||||
),
|
||||
if (getDisplayName != null) ...[
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(
|
||||
_truncateText(getDisplayName(pageItems[index]), 25),
|
||||
style: const pw.TextStyle(
|
||||
fontSize: 8,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
textAlign: pw.TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// GRANDES ÉTIQUETTES (QR + infos détaillées, 6 par page)
|
||||
// ==========================================================================
|
||||
|
||||
static Future<void> _generateLargeQRCodesPDF<T>(
|
||||
pw.Document pdf,
|
||||
List<T> items,
|
||||
String Function(T) getId,
|
||||
String Function(T)? getTitle,
|
||||
List<String> Function(T)? getSubtitle,
|
||||
) async {
|
||||
const qrSize = 100.0;
|
||||
const itemsPerPage = 6;
|
||||
|
||||
// Charger le logo une seule fois
|
||||
await _ensureLogoLoaded();
|
||||
|
||||
// Générer les QR codes en bulk pour optimisation
|
||||
final allQRImages = await QRCodeService.generateBulkQRCodes(
|
||||
items.map((item) => getId(item)).toList(),
|
||||
size: 300, // Réduit de 400 à 300 pour améliorer la performance
|
||||
withLogo: false,
|
||||
useCache: true,
|
||||
);
|
||||
|
||||
for (int pageStart = 0; pageStart < items.length; pageStart += itemsPerPage) {
|
||||
final pageItems = items.skip(pageStart).take(itemsPerPage).toList();
|
||||
final pageQRImages = allQRImages.skip(pageStart).take(itemsPerPage).toList();
|
||||
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(20),
|
||||
build: (context) {
|
||||
return pw.Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: List.generate(pageItems.length, (index) {
|
||||
final item = pageItems[index];
|
||||
return pw.Container(
|
||||
width: 260,
|
||||
height: 120,
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(color: PdfColors.grey400),
|
||||
borderRadius: pw.BorderRadius.circular(4),
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
// QR Code
|
||||
pw.Container(
|
||||
width: qrSize,
|
||||
height: qrSize,
|
||||
child: pw.Image(
|
||||
pw.MemoryImage(pageQRImages[index]),
|
||||
fit: pw.BoxFit.contain,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(width: 8),
|
||||
// Informations
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
mainAxisAlignment: pw.MainAxisAlignment.start,
|
||||
children: [
|
||||
// Logo - CENTRÉ ET PLUS GRAND
|
||||
if (_cachedLogoBytes != null && _cachedLogoBytes!.isNotEmpty)
|
||||
pw.Center(
|
||||
child: pw.Container(
|
||||
height: 25, // Augmenté de 15 à 25
|
||||
margin: const pw.EdgeInsets.only(bottom: 6),
|
||||
child: pw.Image(
|
||||
pw.MemoryImage(_cachedLogoBytes!),
|
||||
fit: pw.BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
// ID (toujours affiché sur plusieurs lignes si nécessaire)
|
||||
if (getTitle != null) ...[
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(
|
||||
_truncateText(getTitle(item), 20),
|
||||
style: pw.TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(
|
||||
getId(item),
|
||||
style: const pw.TextStyle(
|
||||
fontSize: 8,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
if (getSubtitle != null) ...[
|
||||
pw.SizedBox(height: 4),
|
||||
...getSubtitle(item).take(5).map((line) {
|
||||
return pw.Padding(
|
||||
padding: const pw.EdgeInsets.only(bottom: 1),
|
||||
child: pw.Text(
|
||||
_truncateText(line, 25),
|
||||
style: const pw.TextStyle(
|
||||
fontSize: 6,
|
||||
color: PdfColors.grey800,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
155
em2rp/lib/utils/id_generator.dart
Normal file
155
em2rp/lib/utils/id_generator.dart
Normal file
@@ -0,0 +1,155 @@
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/services/equipment_service.dart';
|
||||
|
||||
/// Générateur d'identifiants unifié pour l'application
|
||||
/// Gère les équipements, containers et autres entités
|
||||
class IdGenerator {
|
||||
// ============================================================================
|
||||
// ÉQUIPEMENTS
|
||||
// ============================================================================
|
||||
|
||||
/// Génère un ID pour un équipement
|
||||
/// Format: {Marque4Chars}_{Modèle}_{#Numéro}
|
||||
/// Exemple: BEAM_7R_#1
|
||||
static String generateEquipmentId({
|
||||
required String brand,
|
||||
required String model,
|
||||
int? number,
|
||||
}) {
|
||||
final brandTrim = brand.trim().replaceAll(' ', '_');
|
||||
final modelTrim = model.trim().replaceAll(' ', '_');
|
||||
|
||||
if (brandTrim.isEmpty && modelTrim.isEmpty) {
|
||||
return 'EQ-${DateTime.now().millisecondsSinceEpoch}${number != null ? '_$number' : ''}';
|
||||
}
|
||||
|
||||
final brandPrefix = brandTrim.length >= 4
|
||||
? brandTrim.substring(0, 4)
|
||||
: brandTrim;
|
||||
|
||||
String baseId = modelTrim.isNotEmpty
|
||||
? '${brandPrefix}_$modelTrim'
|
||||
: (brandPrefix.isNotEmpty ? brandPrefix : 'EQ');
|
||||
|
||||
// Empêcher les ID commençant par BOX_ (réservé aux containers)
|
||||
if (baseId.toUpperCase().startsWith('BOX_')) {
|
||||
baseId = 'EQ_$baseId';
|
||||
}
|
||||
|
||||
if (number != null) {
|
||||
baseId += '_#$number';
|
||||
}
|
||||
|
||||
return baseId.toUpperCase();
|
||||
}
|
||||
|
||||
/// Garantit l'unicité d'un ID d'équipement
|
||||
static Future<String> ensureUniqueEquipmentId(
|
||||
String baseId,
|
||||
EquipmentService service,
|
||||
) async {
|
||||
// Vérifier que l'ID ne commence pas par BOX_
|
||||
if (baseId.toUpperCase().startsWith('BOX_')) {
|
||||
baseId = 'EQ_$baseId';
|
||||
}
|
||||
|
||||
if (await service.isIdUnique(baseId)) {
|
||||
return baseId;
|
||||
}
|
||||
|
||||
return '${baseId}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTAINERS
|
||||
// ============================================================================
|
||||
|
||||
/// Génère un ID pour un container
|
||||
/// Format: BOX_{Type}_{Nom}_{#Numéro}
|
||||
/// Exemple: BOX_FLIGHT_CASE_BEAM_#1
|
||||
static String generateContainerId({
|
||||
required ContainerType type,
|
||||
required String name,
|
||||
int? number,
|
||||
}) {
|
||||
final typeStr = containerTypeToString(type);
|
||||
final cleanName = _cleanId(name);
|
||||
|
||||
if (number != null) {
|
||||
return 'BOX_${typeStr}_${cleanName}_#$number';
|
||||
}
|
||||
|
||||
return 'BOX_${typeStr}_$cleanName';
|
||||
}
|
||||
|
||||
/// Garantit l'unicité d'un ID de container
|
||||
/// Note: La vérification d'unicité doit être faite par l'appelant
|
||||
static String ensureUniqueContainerId(String baseId) {
|
||||
// Retourne simplement l'ID de base
|
||||
// L'unicité sera vérifiée au niveau du provider/form
|
||||
return baseId;
|
||||
}
|
||||
|
||||
/// Génère un ID unique avec un timestamp si nécessaire
|
||||
static String generateUniqueContainerId(String baseId) {
|
||||
return '${baseId}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// UTILITAIRES
|
||||
// ============================================================================
|
||||
|
||||
/// Nettoie une chaîne pour en faire un ID valide
|
||||
/// - Supprime les espaces (remplacés par _)
|
||||
/// - Supprime les caractères spéciaux
|
||||
/// - Met en majuscules
|
||||
static String _cleanId(String input) {
|
||||
return input
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replaceAll(' ', '_')
|
||||
.replaceAll(RegExp(r'[^A-Z0-9_-]'), '');
|
||||
}
|
||||
|
||||
/// Valide qu'un ID d'équipement ne commence pas par un préfixe réservé
|
||||
static String? validateEquipmentId(String id) {
|
||||
if (id.isEmpty) {
|
||||
return 'L\'identifiant ne peut pas être vide';
|
||||
}
|
||||
|
||||
if (id.toUpperCase().startsWith('BOX_')) {
|
||||
return 'Les ID commençant par BOX_ sont réservés aux containers';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Valide qu'un ID de container commence bien par BOX_
|
||||
static String? validateContainerId(String id) {
|
||||
if (id.isEmpty) {
|
||||
return 'L\'identifiant ne peut pas être vide';
|
||||
}
|
||||
|
||||
if (!id.toUpperCase().startsWith('BOX_')) {
|
||||
return 'Les containers doivent avoir un ID commençant par BOX_';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Détermine le type d'entité à partir d'un ID
|
||||
static EntityType getEntityType(String id) {
|
||||
if (id.toUpperCase().startsWith('BOX_')) {
|
||||
return EntityType.container;
|
||||
}
|
||||
return EntityType.equipment;
|
||||
}
|
||||
}
|
||||
|
||||
/// Type d'entité identifiable
|
||||
enum EntityType {
|
||||
equipment,
|
||||
container,
|
||||
}
|
||||
|
||||
793
em2rp/lib/views/container_detail_page.dart
Normal file
793
em2rp/lib/views/container_detail_page.dart
Normal file
@@ -0,0 +1,793 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/providers/container_provider.dart';
|
||||
import 'package:em2rp/views/equipment_detail_page.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class ContainerDetailPage extends StatefulWidget {
|
||||
final ContainerModel container;
|
||||
|
||||
const ContainerDetailPage({super.key, required this.container});
|
||||
|
||||
@override
|
||||
State<ContainerDetailPage> createState() => _ContainerDetailPageState();
|
||||
}
|
||||
|
||||
class _ContainerDetailPageState extends State<ContainerDetailPage> {
|
||||
late ContainerModel _container;
|
||||
List<EquipmentModel> _equipmentList = [];
|
||||
bool _isLoadingEquipment = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_container = widget.container;
|
||||
_loadEquipment();
|
||||
}
|
||||
|
||||
Future<void> _loadEquipment() async {
|
||||
setState(() {
|
||||
_isLoadingEquipment = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
final equipment = await containerProvider.getContainerEquipment(_container.id);
|
||||
setState(() {
|
||||
_equipmentList = equipment;
|
||||
_isLoadingEquipment = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoadingEquipment = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur lors du chargement: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshContainer() async {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
final updated = await containerProvider.getContainerById(_container.id);
|
||||
if (updated != null) {
|
||||
setState(() {
|
||||
_container = updated;
|
||||
});
|
||||
await _loadEquipment();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Détails du Container'),
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
tooltip: 'Modifier',
|
||||
onPressed: _editContainer,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code),
|
||||
tooltip: 'QR Code',
|
||||
onPressed: _showQRCode,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: _handleMenuAction,
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, color: Colors.red, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('Supprimer', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _refreshContainer,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildHeaderCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildPhysicalCharacteristics(),
|
||||
const SizedBox(height: 16),
|
||||
_buildEquipmentSection(),
|
||||
const SizedBox(height: 16),
|
||||
if (_container.notes != null && _container.notes!.isNotEmpty)
|
||||
_buildNotesSection(),
|
||||
const SizedBox(height: 16),
|
||||
_buildHistorySection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderCard() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getTypeIcon(_container.type),
|
||||
size: 60,
|
||||
color: AppColors.rouge,
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_container.id,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_container.name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
'Type',
|
||||
containerTypeLabel(_container.type),
|
||||
Icons.category,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
'Statut',
|
||||
_getStatusLabel(_container.status),
|
||||
Icons.info,
|
||||
statusColor: _getStatusColor(_container.status),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
'Équipements',
|
||||
'${_container.itemCount}',
|
||||
Icons.inventory,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
'Poids total',
|
||||
_calculateTotalWeight(),
|
||||
Icons.scale,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhysicalCharacteristics() {
|
||||
final hasDimensions = _container.length != null ||
|
||||
_container.width != null ||
|
||||
_container.height != null;
|
||||
final hasWeight = _container.weight != null;
|
||||
final hasVolume = _container.volume != null;
|
||||
|
||||
if (!hasDimensions && !hasWeight) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Caractéristiques physiques',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
if (hasWeight)
|
||||
_buildCharacteristicRow(
|
||||
'Poids à vide',
|
||||
'${_container.weight} kg',
|
||||
Icons.scale,
|
||||
),
|
||||
if (hasDimensions) ...[
|
||||
if (hasWeight) const SizedBox(height: 12),
|
||||
_buildCharacteristicRow(
|
||||
'Dimensions (L×l×H)',
|
||||
'${_container.length ?? '?'} × ${_container.width ?? '?'} × ${_container.height ?? '?'} cm',
|
||||
Icons.straighten,
|
||||
),
|
||||
],
|
||||
if (hasVolume) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildCharacteristicRow(
|
||||
'Volume',
|
||||
'${_container.volume!.toStringAsFixed(3)} m³',
|
||||
Icons.view_in_ar,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEquipmentSection() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Contenu du container',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
if (_isLoadingEquipment)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (_equipmentList.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 60,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Aucun équipement dans ce container',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _equipmentList.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final equipment = _equipmentList[index];
|
||||
return _buildEquipmentTile(equipment);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEquipmentTile(EquipmentModel equipment) {
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppColors.rouge.withOpacity(0.1),
|
||||
child: const Icon(Icons.inventory_2, color: AppColors.rouge),
|
||||
),
|
||||
title: Text(
|
||||
equipment.id,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (equipment.brand != null || equipment.model != null)
|
||||
Text('${equipment.brand ?? ''} ${equipment.model ?? ''}'),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
_buildSmallBadge(
|
||||
_getCategoryLabel(equipment.category),
|
||||
Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (equipment.weight != null)
|
||||
_buildSmallBadge(
|
||||
'${equipment.weight} kg',
|
||||
Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.visibility, size: 20),
|
||||
tooltip: 'Voir détails',
|
||||
onPressed: () => _viewEquipment(equipment),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle, color: Colors.red, size: 20),
|
||||
tooltip: 'Retirer',
|
||||
onPressed: () => _removeEquipment(equipment),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotesSection() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Notes',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
Text(
|
||||
_container.notes!,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistorySection() {
|
||||
if (_container.history.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Historique',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _container.history.length > 10
|
||||
? 10
|
||||
: _container.history.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 16),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = _container.history[
|
||||
_container.history.length - 1 - index]; // Plus récent en premier
|
||||
return _buildHistoryEntry(entry);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryEntry(ContainerHistoryEntry entry) {
|
||||
IconData icon;
|
||||
Color color;
|
||||
String description;
|
||||
|
||||
switch (entry.action) {
|
||||
case 'equipment_added':
|
||||
icon = Icons.add_circle;
|
||||
color = Colors.green;
|
||||
description = 'Équipement ajouté: ${entry.equipmentId ?? "?"}';
|
||||
break;
|
||||
case 'equipment_removed':
|
||||
icon = Icons.remove_circle;
|
||||
color = Colors.red;
|
||||
description = 'Équipement retiré: ${entry.equipmentId ?? "?"}';
|
||||
break;
|
||||
case 'status_change':
|
||||
icon = Icons.sync;
|
||||
color = Colors.blue;
|
||||
description =
|
||||
'Statut changé: ${entry.previousValue} → ${entry.newValue}';
|
||||
break;
|
||||
default:
|
||||
icon = Icons.info;
|
||||
color = Colors.grey;
|
||||
description = entry.action;
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
description,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
DateFormat('dd/MM/yyyy HH:mm').format(entry.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoItem(String label, String value, IconData icon,
|
||||
{Color? statusColor}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: Colors.grey.shade600),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCharacteristicRow(String label, String value, IconData icon) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: AppColors.rouge),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSmallBadge(String label, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _calculateTotalWeight() {
|
||||
if (_equipmentList.isEmpty && _container.weight == null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
final totalWeight = _container.calculateTotalWeight(_equipmentList);
|
||||
return '${totalWeight.toStringAsFixed(1)} kg';
|
||||
}
|
||||
|
||||
|
||||
IconData _getTypeIcon(ContainerType type) {
|
||||
switch (type) {
|
||||
case ContainerType.flightCase:
|
||||
return Icons.work;
|
||||
case ContainerType.pelicase:
|
||||
return Icons.work_outline;
|
||||
case ContainerType.bag:
|
||||
return Icons.shopping_bag;
|
||||
case ContainerType.openCrate:
|
||||
return Icons.inventory_2;
|
||||
case ContainerType.toolbox:
|
||||
return Icons.handyman;
|
||||
}
|
||||
}
|
||||
|
||||
String _getStatusLabel(EquipmentStatus status) {
|
||||
switch (status) {
|
||||
case EquipmentStatus.available:
|
||||
return 'Disponible';
|
||||
case EquipmentStatus.inUse:
|
||||
return 'En prestation';
|
||||
case EquipmentStatus.maintenance:
|
||||
return 'Maintenance';
|
||||
case EquipmentStatus.outOfService:
|
||||
return 'Hors service';
|
||||
default:
|
||||
return 'Autre';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getStatusColor(EquipmentStatus status) {
|
||||
switch (status) {
|
||||
case EquipmentStatus.available:
|
||||
return Colors.green;
|
||||
case EquipmentStatus.inUse:
|
||||
return Colors.orange;
|
||||
case EquipmentStatus.maintenance:
|
||||
return Colors.blue;
|
||||
case EquipmentStatus.outOfService:
|
||||
return Colors.red;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
String _getCategoryLabel(EquipmentCategory category) {
|
||||
switch (category) {
|
||||
case EquipmentCategory.lighting:
|
||||
return 'Lumière';
|
||||
case EquipmentCategory.sound:
|
||||
return 'Son';
|
||||
case EquipmentCategory.video:
|
||||
return 'Vidéo';
|
||||
case EquipmentCategory.effect:
|
||||
return 'Effets';
|
||||
case EquipmentCategory.structure:
|
||||
return 'Structure';
|
||||
case EquipmentCategory.consumable:
|
||||
return 'Consommable';
|
||||
case EquipmentCategory.cable:
|
||||
return 'Câble';
|
||||
case EquipmentCategory.other:
|
||||
return 'Autre';
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMenuAction(String action) {
|
||||
if (action == 'delete') {
|
||||
_deleteContainer();
|
||||
}
|
||||
}
|
||||
|
||||
void _editContainer() {
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
'/container_form',
|
||||
arguments: _container,
|
||||
).then((_) => _refreshContainer());
|
||||
}
|
||||
|
||||
void _showQRCode() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('QR Code - ${_container.name}'),
|
||||
content: SizedBox(
|
||||
width: 250,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
QrImageView(
|
||||
data: _container.id,
|
||||
version: QrVersions.auto,
|
||||
size: 200,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_container.id,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _viewEquipment(EquipmentModel equipment) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => EquipmentDetailPage(equipment: equipment),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _removeEquipment(EquipmentModel equipment) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Retirer l\'équipement'),
|
||||
content: Text(
|
||||
'Êtes-vous sûr de vouloir retirer "${equipment.id}" de ce container ?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||
child: const Text('Retirer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true && mounted) {
|
||||
try {
|
||||
await context.read<ContainerProvider>().removeEquipmentFromContainer(
|
||||
containerId: _container.id,
|
||||
equipmentId: equipment.id,
|
||||
);
|
||||
await _refreshContainer();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Équipement retiré avec succès')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteContainer() async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
content: Text(
|
||||
'Êtes-vous sûr de vouloir supprimer le container "${_container.name}" ?\n\n'
|
||||
'Cette action est irréversible.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true && mounted) {
|
||||
try {
|
||||
await context.read<ContainerProvider>().deleteContainer(_container.id);
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Container supprimé avec succès')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
924
em2rp/lib/views/container_form_page.dart
Normal file
924
em2rp/lib/views/container_form_page.dart
Normal file
@@ -0,0 +1,924 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/providers/container_provider.dart';
|
||||
import 'package:em2rp/providers/equipment_provider.dart';
|
||||
import 'package:em2rp/utils/id_generator.dart';
|
||||
|
||||
class ContainerFormPage extends StatefulWidget {
|
||||
final ContainerModel? container;
|
||||
|
||||
const ContainerFormPage({super.key, this.container});
|
||||
|
||||
@override
|
||||
State<ContainerFormPage> createState() => _ContainerFormPageState();
|
||||
}
|
||||
|
||||
class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Controllers
|
||||
final _nameController = TextEditingController();
|
||||
final _idController = TextEditingController();
|
||||
final _weightController = TextEditingController();
|
||||
final _lengthController = TextEditingController();
|
||||
final _widthController = TextEditingController();
|
||||
final _heightController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
|
||||
// Form fields
|
||||
ContainerType _selectedType = ContainerType.flightCase;
|
||||
EquipmentStatus _selectedStatus = EquipmentStatus.available;
|
||||
bool _autoGenerateId = true;
|
||||
final Set<String> _selectedEquipmentIds = {};
|
||||
|
||||
bool _isEditing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.container != null) {
|
||||
_isEditing = true;
|
||||
_loadContainerData();
|
||||
}
|
||||
}
|
||||
|
||||
void _loadContainerData() {
|
||||
final container = widget.container!;
|
||||
_nameController.text = container.name;
|
||||
_idController.text = container.id;
|
||||
_selectedType = container.type;
|
||||
_selectedStatus = container.status;
|
||||
_weightController.text = container.weight?.toString() ?? '';
|
||||
_lengthController.text = container.length?.toString() ?? '';
|
||||
_widthController.text = container.width?.toString() ?? '';
|
||||
_heightController.text = container.height?.toString() ?? '';
|
||||
_notesController.text = container.notes ?? '';
|
||||
_selectedEquipmentIds.addAll(container.equipmentIds);
|
||||
_autoGenerateId = false;
|
||||
}
|
||||
|
||||
void _updateIdFromName() {
|
||||
if (_autoGenerateId && !_isEditing) {
|
||||
final name = _nameController.text;
|
||||
if (name.isNotEmpty) {
|
||||
final baseId = IdGenerator.generateContainerId(
|
||||
type: _selectedType,
|
||||
name: name,
|
||||
);
|
||||
_idController.text = baseId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _updateIdFromType() {
|
||||
if (_autoGenerateId && !_isEditing) {
|
||||
final name = _nameController.text;
|
||||
if (name.isNotEmpty) {
|
||||
final baseId = IdGenerator.generateContainerId(
|
||||
type: _selectedType,
|
||||
name: name,
|
||||
);
|
||||
_idController.text = baseId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isEditing ? 'Modifier Container' : 'Nouveau Container'),
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
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
|
||||
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: () {
|
||||
setState(() {
|
||||
_autoGenerateId = !_autoGenerateId;
|
||||
if (_autoGenerateId) {
|
||||
_updateIdFromName();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Type
|
||||
DropdownButtonFormField<ContainerType>(
|
||||
value: _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(containerTypeLabel(type)),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedType = value;
|
||||
_updateIdFromType();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Statut
|
||||
DropdownButtonFormField<EquipmentStatus>(
|
||||
value: _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),
|
||||
|
||||
// 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;
|
||||
},
|
||||
),
|
||||
),
|
||||
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),
|
||||
|
||||
// Section Équipements
|
||||
Text(
|
||||
'Équipements dans ce container',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 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),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _selectEquipment() async {
|
||||
final equipmentProvider = context.read<EquipmentProvider>();
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => _EquipmentSelectorDialog(
|
||||
selectedIds: _selectedEquipmentIds,
|
||||
equipmentProvider: equipmentProvider,
|
||||
),
|
||||
);
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<bool> _isIdUnique(String id) async {
|
||||
final provider = context.read<ContainerProvider>();
|
||||
final container = await provider.getContainerById(id);
|
||||
return container == null;
|
||||
}
|
||||
|
||||
Future<void> _saveContainer() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
|
||||
if (_isEditing) {
|
||||
await _updateContainer(containerProvider);
|
||||
} else {
|
||||
await _createSingleContainer(containerProvider);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createSingleContainer(ContainerProvider provider) async {
|
||||
final baseId = _idController.text.trim();
|
||||
|
||||
// Vérifier l'unicité de l'ID directement
|
||||
String uniqueId = baseId;
|
||||
if (!await _isIdUnique(baseId)) {
|
||||
uniqueId = '${baseId}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
final container = ContainerModel(
|
||||
id: uniqueId,
|
||||
name: _nameController.text.trim(),
|
||||
type: _selectedType,
|
||||
status: _selectedStatus,
|
||||
equipmentIds: _selectedEquipmentIds.toList(),
|
||||
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,
|
||||
notes: _notesController.text.trim().isNotEmpty
|
||||
? _notesController.text.trim()
|
||||
: null,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await provider.createContainer(container);
|
||||
|
||||
// Mettre à jour les parentBoxIds des équipements
|
||||
for (final equipmentId in _selectedEquipmentIds) {
|
||||
try {
|
||||
await provider.addEquipmentToContainer(
|
||||
containerId: uniqueId,
|
||||
equipmentId: equipmentId,
|
||||
);
|
||||
} catch (e) {
|
||||
print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Container créé avec succès')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateContainer(ContainerProvider provider) async {
|
||||
final container = widget.container!;
|
||||
|
||||
await provider.updateContainer(container.id, {
|
||||
'name': _nameController.text.trim(),
|
||||
'type': containerTypeToString(_selectedType),
|
||||
'status': equipmentStatusToString(_selectedStatus),
|
||||
'equipmentIds': _selectedEquipmentIds.toList(),
|
||||
'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,
|
||||
'notes': _notesController.text.trim().isNotEmpty
|
||||
? _notesController.text.trim()
|
||||
: null,
|
||||
});
|
||||
|
||||
// Gérer les équipements ajoutés
|
||||
final addedEquipment = _selectedEquipmentIds.difference(container.equipmentIds.toSet());
|
||||
for (final equipmentId in addedEquipment) {
|
||||
try {
|
||||
await provider.addEquipmentToContainer(
|
||||
containerId: container.id,
|
||||
equipmentId: equipmentId,
|
||||
);
|
||||
} catch (e) {
|
||||
print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Gérer les équipements retirés
|
||||
final removedEquipment = container.equipmentIds.toSet().difference(_selectedEquipmentIds);
|
||||
for (final equipmentId in removedEquipment) {
|
||||
try {
|
||||
await provider.removeEquipmentFromContainer(
|
||||
containerId: container.id,
|
||||
equipmentId: equipmentId,
|
||||
);
|
||||
} catch (e) {
|
||||
print('Erreur lors du retrait de l\'équipement $equipmentId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Container mis à jour avec succès')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_idController.dispose();
|
||||
_weightController.dispose();
|
||||
_lengthController.dispose();
|
||||
_widthController.dispose();
|
||||
_heightController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget de dialogue pour sélectionner les équipements
|
||||
class _EquipmentSelectorDialog extends StatefulWidget {
|
||||
final Set<String> selectedIds;
|
||||
final EquipmentProvider equipmentProvider;
|
||||
|
||||
const _EquipmentSelectorDialog({
|
||||
required this.selectedIds,
|
||||
required this.equipmentProvider,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_EquipmentSelectorDialog> createState() => _EquipmentSelectorDialogState();
|
||||
}
|
||||
|
||||
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
EquipmentCategory? _filterCategory;
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width * 0.8,
|
||||
height: MediaQuery.of(context).size.height * 0.8,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
// En-tête
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.inventory, color: AppColors.rouge),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Sélectionner des équipements',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Barre de recherche
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher un équipement...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Filtres par catégorie
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Tout'),
|
||||
selected: _filterCategory == null,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
_filterCategory = null;
|
||||
});
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
labelStyle: TextStyle(
|
||||
color: _filterCategory == null ? Colors.white : Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
...EquipmentCategory.values.map((category) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(_getCategoryLabel(category)),
|
||||
selected: _filterCategory == category,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
_filterCategory = selected ? category : null;
|
||||
});
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
labelStyle: TextStyle(
|
||||
color: _filterCategory == category ? Colors.white : Colors.black,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Compteur de sélection
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.rouge.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${widget.selectedIds.length} équipement(s) sélectionné(s)',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Liste des équipements
|
||||
Expanded(
|
||||
child: StreamBuilder<List<EquipmentModel>>(
|
||||
stream: widget.equipmentProvider.equipmentStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Erreur: ${snapshot.error}'));
|
||||
}
|
||||
|
||||
var equipment = snapshot.data ?? [];
|
||||
|
||||
// Filtrer par catégorie
|
||||
if (_filterCategory != null) {
|
||||
equipment = equipment.where((e) => e.category == _filterCategory).toList();
|
||||
}
|
||||
|
||||
// Filtrer par recherche
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
final query = _searchQuery.toLowerCase();
|
||||
equipment = equipment.where((e) {
|
||||
return e.id.toLowerCase().contains(query) ||
|
||||
(e.brand?.toLowerCase().contains(query) ?? false) ||
|
||||
(e.model?.toLowerCase().contains(query) ?? false);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
if (equipment.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Aucun équipement trouvé'),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: equipment.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = equipment[index];
|
||||
final isSelected = widget.selectedIds.contains(item.id);
|
||||
|
||||
return CheckboxListTile(
|
||||
value: isSelected,
|
||||
onChanged: (selected) {
|
||||
setState(() {
|
||||
if (selected == true) {
|
||||
widget.selectedIds.add(item.id);
|
||||
} else {
|
||||
widget.selectedIds.remove(item.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
title: Text(
|
||||
item.id,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (item.brand != null || item.model != null)
|
||||
Text('${item.brand ?? ''} ${item.model ?? ''}'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_getCategoryLabel(item.category),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
secondary: Icon(
|
||||
_getCategoryIcon(item.category),
|
||||
color: AppColors.rouge,
|
||||
),
|
||||
activeColor: AppColors.rouge,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Boutons d'action
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
),
|
||||
child: const Text(
|
||||
'Valider',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getCategoryLabel(EquipmentCategory category) {
|
||||
switch (category) {
|
||||
case EquipmentCategory.lighting:
|
||||
return 'Lumière';
|
||||
case EquipmentCategory.sound:
|
||||
return 'Son';
|
||||
case EquipmentCategory.video:
|
||||
return 'Vidéo';
|
||||
case EquipmentCategory.effect:
|
||||
return 'Effets';
|
||||
case EquipmentCategory.structure:
|
||||
return 'Structure';
|
||||
case EquipmentCategory.consumable:
|
||||
return 'Consommable';
|
||||
case EquipmentCategory.cable:
|
||||
return 'Câble';
|
||||
case EquipmentCategory.other:
|
||||
return 'Autre';
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getCategoryIcon(EquipmentCategory category) {
|
||||
switch (category) {
|
||||
case EquipmentCategory.lighting:
|
||||
return Icons.lightbulb;
|
||||
case EquipmentCategory.sound:
|
||||
return Icons.speaker;
|
||||
case EquipmentCategory.video:
|
||||
return Icons.videocam;
|
||||
case EquipmentCategory.effect:
|
||||
return Icons.auto_awesome;
|
||||
case EquipmentCategory.structure:
|
||||
return Icons.construction;
|
||||
case EquipmentCategory.consumable:
|
||||
return Icons.inventory;
|
||||
case EquipmentCategory.cable:
|
||||
return Icons.cable;
|
||||
case EquipmentCategory.other:
|
||||
return Icons.category;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
814
em2rp/lib/views/container_management_page.dart
Normal file
814
em2rp/lib/views/container_management_page.dart
Normal file
@@ -0,0 +1,814 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/utils/permission_gate.dart';
|
||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
import 'package:em2rp/providers/container_provider.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/services/container_pdf_generator_service.dart';
|
||||
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
|
||||
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
|
||||
class ContainerManagementPage extends StatefulWidget {
|
||||
const ContainerManagementPage({super.key});
|
||||
|
||||
@override
|
||||
State<ContainerManagementPage> createState() =>
|
||||
_ContainerManagementPageState();
|
||||
}
|
||||
|
||||
class _ContainerManagementPageState extends State<ContainerManagementPage>
|
||||
with SelectionModeMixin<ContainerManagementPage> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
ContainerType? _selectedType;
|
||||
EquipmentStatus? _selectedStatus;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = MediaQuery.of(context).size.width < 800;
|
||||
|
||||
return PermissionGate(
|
||||
requiredPermissions: const ['view_equipment'],
|
||||
fallback: Scaffold(
|
||||
appBar: const CustomAppBar(title: 'Accès refusé'),
|
||||
drawer: const MainDrawer(currentPage: '/container_management'),
|
||||
body: const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
'Vous n\'avez pas les permissions nécessaires pour accéder à la gestion des containers.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Scaffold(
|
||||
appBar: isSelectionMode
|
||||
? AppBar(
|
||||
backgroundColor: AppColors.rouge,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: toggleSelectionMode,
|
||||
),
|
||||
title: Text(
|
||||
'$selectedCount sélectionné(s)',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
actions: [
|
||||
if (hasSelection) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code, color: Colors.white),
|
||||
tooltip: 'Générer QR Codes',
|
||||
onPressed: _generateQRCodesForSelected,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.white),
|
||||
tooltip: 'Supprimer',
|
||||
onPressed: _deleteSelectedContainers,
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
: const CustomAppBar(title: 'Gestion des Containers'),
|
||||
drawer: const MainDrawer(currentPage: '/container_management'),
|
||||
floatingActionButton: !isSelectionMode
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () => _navigateToForm(context),
|
||||
backgroundColor: AppColors.rouge,
|
||||
icon: const Icon(Icons.add, color: Colors.white),
|
||||
label: const Text(
|
||||
'Nouveau Container',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileLayout() {
|
||||
return Column(
|
||||
children: [
|
||||
_buildSearchBar(),
|
||||
_buildMobileFilters(),
|
||||
Expanded(child: _buildContainerList()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDesktopLayout() {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 250,
|
||||
child: _buildSidebar(),
|
||||
),
|
||||
const VerticalDivider(width: 1, thickness: 1),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildSearchBar(),
|
||||
Expanded(child: _buildContainerList()),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher un container...',
|
||||
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
context.read<ContainerProvider>().setSearchQuery(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (!isSelectionMode)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.checklist, color: AppColors.rouge),
|
||||
tooltip: 'Mode sélection',
|
||||
onPressed: toggleSelectionMode,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileFilters() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
color: Colors.grey.shade50,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildTypeChip(null, 'Tous'),
|
||||
const SizedBox(width: 8),
|
||||
...ContainerType.values.map((type) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _buildTypeChip(type, containerTypeLabel(type)),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeChip(ContainerType? type, String label) {
|
||||
final isSelected = _selectedType == type;
|
||||
return ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
_selectedType = selected ? type : null;
|
||||
context.read<ContainerProvider>().setSelectedType(_selectedType);
|
||||
});
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? Colors.white : AppColors.noir,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSidebar() {
|
||||
return Container(
|
||||
color: Colors.grey.shade50,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
'Filtres',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.noir,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Filtre par type
|
||||
Text(
|
||||
'Type de container',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.noir,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildFilterOption(null, 'Tous les types'),
|
||||
...ContainerType.values.map((type) {
|
||||
return _buildFilterOption(type, containerTypeLabel(type));
|
||||
}),
|
||||
|
||||
const Divider(height: 32),
|
||||
|
||||
// Filtre par statut
|
||||
Text(
|
||||
'Statut',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.noir,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatusFilter(null, 'Tous les statuts'),
|
||||
_buildStatusFilter(EquipmentStatus.available, 'Disponible'),
|
||||
_buildStatusFilter(EquipmentStatus.inUse, 'En prestation'),
|
||||
_buildStatusFilter(EquipmentStatus.maintenance, 'En maintenance'),
|
||||
_buildStatusFilter(EquipmentStatus.outOfService, 'Hors service'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterOption(ContainerType? type, String label) {
|
||||
final isSelected = _selectedType == type;
|
||||
return RadioListTile<ContainerType?>(
|
||||
title: Text(label),
|
||||
value: type,
|
||||
groupValue: _selectedType,
|
||||
activeColor: AppColors.rouge,
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedType = value;
|
||||
context.read<ContainerProvider>().setSelectedType(_selectedType);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusFilter(EquipmentStatus? status, String label) {
|
||||
final isSelected = _selectedStatus == status;
|
||||
return RadioListTile<EquipmentStatus?>(
|
||||
title: Text(label),
|
||||
value: status,
|
||||
groupValue: _selectedStatus,
|
||||
activeColor: AppColors.rouge,
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedStatus = value;
|
||||
context.read<ContainerProvider>().setSelectedStatus(_selectedStatus);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContainerList() {
|
||||
return Consumer<ContainerProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return StreamBuilder<List<ContainerModel>>(
|
||||
stream: provider.containersStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text('Erreur: ${snapshot.error}'),
|
||||
);
|
||||
}
|
||||
|
||||
final containers = snapshot.data ?? [];
|
||||
|
||||
if (containers.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 80,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun container trouvé',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: containers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final container = containers[index];
|
||||
return _buildContainerCard(container);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContainerCard(ContainerModel container) {
|
||||
final isSelected = isItemSelected(container.id);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: isSelected
|
||||
? const BorderSide(color: AppColors.rouge, width: 2)
|
||||
: BorderSide.none,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (isSelectionMode) {
|
||||
toggleItemSelection(container.id);
|
||||
} else {
|
||||
_viewContainerDetails(container);
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
if (!isSelectionMode) {
|
||||
toggleSelectionMode();
|
||||
toggleItemSelection(container.id);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
if (isSelectionMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (value) {
|
||||
toggleItemSelection(container.id);
|
||||
},
|
||||
activeColor: AppColors.rouge,
|
||||
),
|
||||
),
|
||||
|
||||
// Icône du type de container
|
||||
Icon(
|
||||
_getTypeIcon(container.type),
|
||||
size: 40,
|
||||
color: AppColors.rouge,
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
container.id,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
container.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoChip(
|
||||
containerTypeLabel(container.type),
|
||||
Icons.category,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildInfoChip(
|
||||
'${container.itemCount} items',
|
||||
Icons.inventory,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Badge de statut
|
||||
_buildStatusBadge(container.status),
|
||||
|
||||
if (!isSelectionMode) ...[
|
||||
const SizedBox(width: 8),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (value) => _handleMenuAction(value, container),
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'view',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.visibility, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('Voir détails'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('Modifier'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'qr',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.qr_code, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('QR Code'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, color: Colors.red, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text('Supprimer', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoChip(String label, IconData icon) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: Colors.grey.shade700),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBadge(EquipmentStatus status) {
|
||||
Color color;
|
||||
String label;
|
||||
|
||||
switch (status) {
|
||||
case EquipmentStatus.available:
|
||||
color = Colors.green;
|
||||
label = 'Disponible';
|
||||
break;
|
||||
case EquipmentStatus.inUse:
|
||||
color = Colors.orange;
|
||||
label = 'En prestation';
|
||||
break;
|
||||
case EquipmentStatus.maintenance:
|
||||
color = Colors.blue;
|
||||
label = 'Maintenance';
|
||||
break;
|
||||
case EquipmentStatus.outOfService:
|
||||
color = Colors.red;
|
||||
label = 'Hors service';
|
||||
break;
|
||||
default:
|
||||
color = Colors.grey;
|
||||
label = 'Autre';
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: color),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getTypeIcon(ContainerType type) {
|
||||
switch (type) {
|
||||
case ContainerType.flightCase:
|
||||
return Icons.work;
|
||||
case ContainerType.pelicase:
|
||||
return Icons.work_outline;
|
||||
case ContainerType.bag:
|
||||
return Icons.shopping_bag;
|
||||
case ContainerType.openCrate:
|
||||
return Icons.inventory_2;
|
||||
case ContainerType.toolbox:
|
||||
return Icons.handyman;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _handleMenuAction(String action, ContainerModel container) {
|
||||
switch (action) {
|
||||
case 'view':
|
||||
_viewContainerDetails(container);
|
||||
break;
|
||||
case 'edit':
|
||||
_editContainer(container);
|
||||
break;
|
||||
case 'qr':
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => QRCodeDialog.forContainer(container),
|
||||
);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteContainer(container);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToForm(BuildContext context) async {
|
||||
final result = await Navigator.pushNamed(context, '/container_form');
|
||||
if (result == true) {
|
||||
// Rafraîchir la liste
|
||||
}
|
||||
}
|
||||
|
||||
void _viewContainerDetails(ContainerModel container) async {
|
||||
await Navigator.pushNamed(
|
||||
context,
|
||||
'/container_detail',
|
||||
arguments: container,
|
||||
);
|
||||
}
|
||||
|
||||
void _editContainer(ContainerModel container) async {
|
||||
await Navigator.pushNamed(
|
||||
context,
|
||||
'/container_form',
|
||||
arguments: container,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> _generateQRCodesForSelected() async {
|
||||
if (!hasSelection) return;
|
||||
|
||||
// Récupérer les containers sélectionnés
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
final List<ContainerModel> selectedContainers = [];
|
||||
final Map<String, List<EquipmentModel>> containerEquipmentMap = {};
|
||||
|
||||
for (final id in selectedIds) {
|
||||
final container = await containerProvider.getContainerById(id);
|
||||
if (container != null) {
|
||||
selectedContainers.add(container);
|
||||
// Charger les équipements pour ce container
|
||||
final equipment = await containerProvider.getContainerEquipment(id);
|
||||
containerEquipmentMap[id] = equipment;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedContainers.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Aucun container trouvé')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Afficher le dialogue de sélection de format
|
||||
final format = await showDialog<ContainerQRLabelFormat>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Format des étiquettes'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.qr_code_2),
|
||||
title: const Text('Petits QR codes'),
|
||||
subtitle: const Text('2×2 cm - QR code + ID (20 par page)'),
|
||||
onTap: () => Navigator.pop(context, ContainerQRLabelFormat.small),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.qr_code),
|
||||
title: const Text('QR codes moyens'),
|
||||
subtitle: const Text('4×4 cm - QR code + ID (6 par page)'),
|
||||
onTap: () => Navigator.pop(context, ContainerQRLabelFormat.medium),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.label),
|
||||
title: const Text('Grandes étiquettes'),
|
||||
subtitle: const Text('QR code + ID + Type + Contenu (6 par page)'),
|
||||
onTap: () => Navigator.pop(context, ContainerQRLabelFormat.large),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (format == null || !mounted) return;
|
||||
|
||||
// Générer et afficher le PDF
|
||||
try {
|
||||
final pdfBytes = await ContainerPDFGeneratorService.generateQRCodesPDF(
|
||||
containerList: selectedContainers,
|
||||
containerEquipmentMap: containerEquipmentMap,
|
||||
format: format,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
await Printing.layoutPdf(
|
||||
onLayout: (PdfPageFormat format) async => pdfBytes,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur lors de la génération: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteContainer(ContainerModel container) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
content: Text(
|
||||
'Êtes-vous sûr de vouloir supprimer le container "${container.name}" ?\n\n'
|
||||
'Cette action est irréversible.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true && mounted) {
|
||||
try {
|
||||
await context.read<ContainerProvider>().deleteContainer(container.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Container supprimé avec succès')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur lors de la suppression: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteSelectedContainers() async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
content: Text(
|
||||
'Êtes-vous sûr de vouloir supprimer $selectedCount container(s) ?\n\n'
|
||||
'Cette action est irréversible.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true && mounted) {
|
||||
try {
|
||||
final provider = context.read<ContainerProvider>();
|
||||
for (final id in selectedIds) {
|
||||
await provider.deleteContainer(id);
|
||||
}
|
||||
disableSelectionMode();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Containers supprimés avec succès')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur lors de la suppression: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
881
em2rp/lib/views/equipment_detail_page.dart
Normal file
881
em2rp/lib/views/equipment_detail_page.dart
Normal file
@@ -0,0 +1,881 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/models/maintenance_model.dart';
|
||||
import 'package:em2rp/providers/equipment_provider.dart';
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:em2rp/services/equipment_service.dart';
|
||||
import 'package:em2rp/services/qr_code_service.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/utils/permission_gate.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
import 'package:em2rp/views/equipment_form_page.dart';
|
||||
import 'package:em2rp/views/widgets/equipment/equipment_parent_containers.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
|
||||
class EquipmentDetailPage extends StatefulWidget {
|
||||
final EquipmentModel equipment;
|
||||
|
||||
const EquipmentDetailPage({super.key, required this.equipment});
|
||||
|
||||
@override
|
||||
State<EquipmentDetailPage> createState() => _EquipmentDetailPageState();
|
||||
}
|
||||
|
||||
class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
final EquipmentService _equipmentService = EquipmentService();
|
||||
List<MaintenanceModel> _maintenances = [];
|
||||
bool _isLoadingMaintenances = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadMaintenances();
|
||||
}
|
||||
|
||||
Future<void> _loadMaintenances() async {
|
||||
try {
|
||||
final maintenances = await _equipmentService.getMaintenancesForEquipment(widget.equipment.id);
|
||||
setState(() {
|
||||
_maintenances = maintenances;
|
||||
_isLoadingMaintenances = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoadingMaintenances = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = MediaQuery.of(context).size.width < 800;
|
||||
final userProvider = Provider.of<LocalUserProvider>(context);
|
||||
final hasManagePermission = userProvider.hasPermission('manage_equipment');
|
||||
|
||||
return Scaffold(
|
||||
appBar: CustomAppBar(
|
||||
title: widget.equipment.id,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code),
|
||||
tooltip: 'Générer QR Code',
|
||||
onPressed: _showQRCode,
|
||||
),
|
||||
if (hasManagePermission)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
tooltip: 'Modifier',
|
||||
onPressed: _editEquipment,
|
||||
),
|
||||
if (hasManagePermission)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
tooltip: 'Supprimer',
|
||||
onPressed: _deleteEquipment,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(isMobile ? 16 : 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 24),
|
||||
_buildMainInfoSection(),
|
||||
const SizedBox(height: 24),
|
||||
if (hasManagePermission) ...[
|
||||
_buildPriceSection(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (widget.equipment.category == EquipmentCategory.consumable ||
|
||||
widget.equipment.category == EquipmentCategory.cable) ...[
|
||||
_buildQuantitySection(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (widget.equipment.parentBoxIds.isNotEmpty) ...[
|
||||
EquipmentParentContainers(
|
||||
parentBoxIds: widget.equipment.parentBoxIds,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
_buildDatesSection(),
|
||||
const SizedBox(height: 24),
|
||||
if (widget.equipment.notes != null && widget.equipment.notes!.isNotEmpty) ...[
|
||||
_buildNotesSection(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
_buildMaintenanceHistorySection(hasManagePermission),
|
||||
const SizedBox(height: 24),
|
||||
_buildAssociatedEventsSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColors.rouge, AppColors.rouge.withValues(alpha: 0.8)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.rouge.withValues(alpha: 0.3),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: Colors.white,
|
||||
radius: 30,
|
||||
child: Icon(
|
||||
_getCategoryIcon(widget.equipment.category),
|
||||
color: AppColors.rouge,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.equipment.id,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim().isNotEmpty
|
||||
? '${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim()
|
||||
: 'Marque/Modèle non défini',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.equipment.category != EquipmentCategory.consumable &&
|
||||
widget.equipment.category != EquipmentCategory.cable)
|
||||
_buildStatusBadge(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBadge() {
|
||||
final statusInfo = _getStatusInfo(widget.equipment.status);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: statusInfo.$2,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
statusInfo.$1,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: statusInfo.$2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMainInfoSection() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Informations principales',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_buildInfoRow('Catégorie', _getCategoryName(widget.equipment.category)),
|
||||
if (widget.equipment.brand != null && widget.equipment.brand!.isNotEmpty)
|
||||
_buildInfoRow('Marque', widget.equipment.brand!),
|
||||
if (widget.equipment.model != null && widget.equipment.model!.isNotEmpty)
|
||||
_buildInfoRow('Modèle', widget.equipment.model!),
|
||||
if (widget.equipment.category != EquipmentCategory.consumable &&
|
||||
widget.equipment.category != EquipmentCategory.cable)
|
||||
_buildInfoRow('Statut', _getStatusInfo(widget.equipment.status).$1),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPriceSection() {
|
||||
final hasPrices = widget.equipment.purchasePrice != null || widget.equipment.rentalPrice != null;
|
||||
|
||||
if (!hasPrices) return const SizedBox.shrink();
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.euro, color: AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Prix',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
if (widget.equipment.purchasePrice != null)
|
||||
_buildInfoRow(
|
||||
'Prix d\'achat',
|
||||
'${widget.equipment.purchasePrice!.toStringAsFixed(2)} €',
|
||||
),
|
||||
if (widget.equipment.rentalPrice != null)
|
||||
_buildInfoRow(
|
||||
'Prix de location',
|
||||
'${widget.equipment.rentalPrice!.toStringAsFixed(2)} €/jour',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuantitySection() {
|
||||
final availableQty = widget.equipment.availableQuantity ?? 0;
|
||||
final totalQty = widget.equipment.totalQuantity ?? 0;
|
||||
final criticalThreshold = widget.equipment.criticalThreshold ?? 0;
|
||||
final isCritical = criticalThreshold > 0 && availableQty <= criticalThreshold;
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
color: isCritical ? Colors.red.shade50 : null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
isCritical ? Icons.warning : Icons.inventory,
|
||||
color: isCritical ? Colors.red : AppColors.rouge,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Quantités',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isCritical ? Colors.red : null,
|
||||
),
|
||||
),
|
||||
if (isCritical) ...[
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'STOCK CRITIQUE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_buildInfoRow(
|
||||
'Quantité disponible',
|
||||
availableQty.toString(),
|
||||
valueColor: isCritical ? Colors.red : null,
|
||||
valueWeight: isCritical ? FontWeight.bold : null,
|
||||
),
|
||||
_buildInfoRow('Quantité totale', totalQty.toString()),
|
||||
if (criticalThreshold > 0)
|
||||
_buildInfoRow('Seuil critique', criticalThreshold.toString()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildDatesSection() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.calendar_today, color: AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Dates',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
if (widget.equipment.purchaseDate != null)
|
||||
_buildInfoRow(
|
||||
'Date d\'achat',
|
||||
DateFormat('dd/MM/yyyy').format(widget.equipment.purchaseDate!),
|
||||
),
|
||||
if (widget.equipment.lastMaintenanceDate != null)
|
||||
_buildInfoRow(
|
||||
'Dernière maintenance',
|
||||
DateFormat('dd/MM/yyyy').format(widget.equipment.lastMaintenanceDate!),
|
||||
),
|
||||
if (widget.equipment.nextMaintenanceDate != null)
|
||||
_buildInfoRow(
|
||||
'Prochaine maintenance',
|
||||
DateFormat('dd/MM/yyyy').format(widget.equipment.nextMaintenanceDate!),
|
||||
valueColor: widget.equipment.nextMaintenanceDate!.isBefore(DateTime.now())
|
||||
? Colors.red
|
||||
: null,
|
||||
),
|
||||
_buildInfoRow(
|
||||
'Créé le',
|
||||
DateFormat('dd/MM/yyyy à HH:mm').format(widget.equipment.createdAt),
|
||||
),
|
||||
_buildInfoRow(
|
||||
'Modifié le',
|
||||
DateFormat('dd/MM/yyyy à HH:mm').format(widget.equipment.updatedAt),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotesSection() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.notes, color: AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Notes',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
Text(
|
||||
widget.equipment.notes!,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMaintenanceHistorySection(bool hasManagePermission) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.build, color: AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Historique des maintenances',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
if (_isLoadingMaintenances)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (_maintenances.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Aucune maintenance enregistrée',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _maintenances.length,
|
||||
separatorBuilder: (context, index) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final maintenance = _maintenances[index];
|
||||
return _buildMaintenanceItem(maintenance, hasManagePermission);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMaintenanceItem(MaintenanceModel maintenance, bool showCost) {
|
||||
final isCompleted = maintenance.completedDate != null;
|
||||
final typeInfo = _getMaintenanceTypeInfo(maintenance.type);
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isCompleted ? Colors.green.withValues(alpha: 0.2) : Colors.orange.withValues(alpha: 0.2),
|
||||
child: Icon(
|
||||
isCompleted ? Icons.check_circle : Icons.schedule,
|
||||
color: isCompleted ? Colors.green : Colors.orange,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
maintenance.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(typeInfo.$2, size: 16, color: Colors.grey[600]),
|
||||
const SizedBox(width: 4),
|
||||
Text(typeInfo.$1, style: TextStyle(color: Colors.grey[600], fontSize: 12)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isCompleted
|
||||
? 'Effectuée le ${DateFormat('dd/MM/yyyy').format(maintenance.completedDate!)}'
|
||||
: 'Planifiée le ${DateFormat('dd/MM/yyyy').format(maintenance.scheduledDate)}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
|
||||
),
|
||||
if (showCost && maintenance.cost != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Coût: ${maintenance.cost!.toStringAsFixed(2)} €',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssociatedEventsSection() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.event, color: AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Événements associés',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Fonctionnalité à implémenter',
|
||||
style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(
|
||||
String label,
|
||||
String value, {
|
||||
Color? valueColor,
|
||||
FontWeight? valueWeight,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: valueColor,
|
||||
fontWeight: valueWeight ?? FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showQRCode() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.qr_code, color: AppColors.rouge, size: 32),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'QR Code - ${widget.equipment.id}',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: widget.equipment.id,
|
||||
version: QrVersions.auto,
|
||||
size: 300,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.equipment.id,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(),
|
||||
style: TextStyle(color: Colors.grey[700]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _exportQRCode(),
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Télécharger PNG'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
minimumSize: const Size(0, 48),
|
||||
),
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
label: const Text(
|
||||
'Fermer',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _exportQRCode() async {
|
||||
try {
|
||||
final qrImage = await QRCodeService.generateQRCode(
|
||||
widget.equipment.id,
|
||||
size: 1024,
|
||||
useCache: false,
|
||||
);
|
||||
|
||||
await Printing.sharePdf(
|
||||
bytes: qrImage,
|
||||
filename: 'QRCode_${widget.equipment.id}.png',
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('QR Code exporté avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur lors de l\'export: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _editEquipment() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => EquipmentFormPage(equipment: widget.equipment),
|
||||
),
|
||||
).then((_) {
|
||||
Navigator.pop(context);
|
||||
});
|
||||
}
|
||||
|
||||
void _deleteEquipment() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
content: Text(
|
||||
'Voulez-vous vraiment supprimer "${widget.equipment.id}" ?\n\nCette action est irréversible.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
try {
|
||||
await context
|
||||
.read<EquipmentProvider>()
|
||||
.deleteEquipment(widget.equipment.id);
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Équipement supprimé avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getCategoryIcon(EquipmentCategory category) {
|
||||
switch (category) {
|
||||
case EquipmentCategory.lighting:
|
||||
return Icons.light_mode;
|
||||
case EquipmentCategory.sound:
|
||||
return Icons.volume_up;
|
||||
case EquipmentCategory.video:
|
||||
return Icons.videocam;
|
||||
case EquipmentCategory.effect:
|
||||
return Icons.auto_awesome;
|
||||
case EquipmentCategory.structure:
|
||||
return Icons.construction;
|
||||
case EquipmentCategory.consumable:
|
||||
return Icons.inventory_2;
|
||||
case EquipmentCategory.cable:
|
||||
return Icons.cable;
|
||||
case EquipmentCategory.other:
|
||||
return Icons.more_horiz;
|
||||
}
|
||||
}
|
||||
|
||||
String _getCategoryName(EquipmentCategory category) {
|
||||
switch (category) {
|
||||
case EquipmentCategory.lighting:
|
||||
return 'Lumière';
|
||||
case EquipmentCategory.sound:
|
||||
return 'Son';
|
||||
case EquipmentCategory.video:
|
||||
return 'Vidéo';
|
||||
case EquipmentCategory.effect:
|
||||
return 'Effets';
|
||||
case EquipmentCategory.structure:
|
||||
return 'Structure';
|
||||
case EquipmentCategory.consumable:
|
||||
return 'Consommable';
|
||||
case EquipmentCategory.cable:
|
||||
return 'Câble';
|
||||
case EquipmentCategory.other:
|
||||
return 'Autre';
|
||||
}
|
||||
}
|
||||
|
||||
(String, Color) _getStatusInfo(EquipmentStatus status) {
|
||||
switch (status) {
|
||||
case EquipmentStatus.available:
|
||||
return ('Disponible', Colors.green);
|
||||
case EquipmentStatus.inUse:
|
||||
return ('En prestation', Colors.blue);
|
||||
case EquipmentStatus.rented:
|
||||
return ('Loué', Colors.orange);
|
||||
case EquipmentStatus.lost:
|
||||
return ('Perdu', Colors.red);
|
||||
case EquipmentStatus.outOfService:
|
||||
return ('HS', Colors.red[900]!);
|
||||
case EquipmentStatus.maintenance:
|
||||
return ('Maintenance', Colors.amber);
|
||||
}
|
||||
}
|
||||
|
||||
(String, IconData) _getMaintenanceTypeInfo(MaintenanceType type) {
|
||||
switch (type) {
|
||||
case MaintenanceType.preventive:
|
||||
return ('Préventive', Icons.schedule);
|
||||
case MaintenanceType.corrective:
|
||||
return ('Corrective', Icons.build);
|
||||
case MaintenanceType.inspection:
|
||||
return ('Inspection', Icons.search);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/services/equipment_service.dart';
|
||||
|
||||
class EquipmentIdGenerator {
|
||||
static String generate({required String brand, required String model, int? number}) {
|
||||
final brandTrim = brand.trim().replaceAll(' ', '_');
|
||||
final modelTrim = model.trim().replaceAll(' ', '_');
|
||||
if (brandTrim.isEmpty && modelTrim.isEmpty) {
|
||||
return 'EQ-${DateTime.now().millisecondsSinceEpoch}${number != null ? '_$number' : ''}';
|
||||
}
|
||||
final brandPrefix = brandTrim.length >= 4 ? brandTrim.substring(0, 4) : brandTrim;
|
||||
String baseId = modelTrim.isNotEmpty ? '${brandPrefix}_$modelTrim' : (brandPrefix.isNotEmpty ? brandPrefix : 'EQ');
|
||||
if (number != null) {
|
||||
baseId += '_#$number';
|
||||
}
|
||||
return baseId;
|
||||
}
|
||||
|
||||
static Future<String> ensureUniqueId(String baseId, EquipmentService service) async {
|
||||
if (await service.isIdUnique(baseId)) {
|
||||
return baseId;
|
||||
}
|
||||
return '${baseId}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ 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/id_generator.dart';
|
||||
import 'package:em2rp/utils/id_generator.dart';
|
||||
|
||||
class EquipmentFormPage extends StatefulWidget {
|
||||
final EquipmentModel? equipment;
|
||||
@@ -165,6 +165,15 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{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 containers';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -585,13 +594,13 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
|
||||
// Générer les IDs
|
||||
if (numbers.isEmpty) {
|
||||
String baseId = EquipmentIdGenerator.generate(brand: brand, model: model, number: null);
|
||||
String uniqueId = await EquipmentIdGenerator.ensureUniqueId(baseId, _equipmentService);
|
||||
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 = EquipmentIdGenerator.generate(brand: brand, model: model, number: num);
|
||||
String uniqueId = await EquipmentIdGenerator.ensureUniqueId(baseId, _equipmentService);
|
||||
String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: num);
|
||||
String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService);
|
||||
ids.add(uniqueId);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
193
em2rp/lib/views/widgets/common/qr_code_dialog.dart
Normal file
193
em2rp/lib/views/widgets/common/qr_code_dialog.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/services/qr_code_service.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
|
||||
/// Widget réutilisable pour afficher un QR code avec option de téléchargement
|
||||
/// Utilisable pour équipements, containers, et autres entités
|
||||
class QRCodeDialog<T> extends StatelessWidget {
|
||||
final T item;
|
||||
final String Function(T) getId;
|
||||
final String Function(T) getTitle;
|
||||
final List<Widget> Function(T)? buildSubtitle;
|
||||
|
||||
const QRCodeDialog({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.getId,
|
||||
required this.getTitle,
|
||||
this.buildSubtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final id = getId(item);
|
||||
final title = getTitle(item);
|
||||
|
||||
return Dialog(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// En-tête
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.qr_code, color: AppColors.rouge, size: 32),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'QR Code - $id',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// QR Code
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: id,
|
||||
version: QrVersions.auto,
|
||||
size: 250,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Informations
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
id,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(color: Colors.grey[700]),
|
||||
),
|
||||
if (buildSubtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
...buildSubtitle!(item),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Bouton télécharger
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _downloadQRCode(context, id),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
),
|
||||
icon: const Icon(Icons.download, color: Colors.white),
|
||||
label: const Text(
|
||||
'Télécharger l\'image',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _downloadQRCode(BuildContext context, String id) async {
|
||||
try {
|
||||
// Générer l'image QR code en haute résolution
|
||||
final qrImage = await QRCodeService.generateQRCode(
|
||||
id,
|
||||
size: 1024,
|
||||
useCache: false,
|
||||
);
|
||||
|
||||
// Utiliser la bibliothèque printing pour sauvegarder l'image
|
||||
await Printing.sharePdf(
|
||||
bytes: qrImage,
|
||||
filename: 'QRCode_$id.png',
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Image QR Code téléchargée avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors du téléchargement: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory pour équipement
|
||||
static QRCodeDialog forEquipment(dynamic equipment) {
|
||||
return QRCodeDialog(
|
||||
item: equipment,
|
||||
getId: (eq) => eq.id,
|
||||
getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Factory pour container
|
||||
static QRCodeDialog forContainer(dynamic container) {
|
||||
return QRCodeDialog(
|
||||
item: container,
|
||||
getId: (c) => c.id,
|
||||
getTitle: (c) => c.name,
|
||||
buildSubtitle: (c) {
|
||||
return [
|
||||
Text(
|
||||
_getContainerTypeLabel(c.type),
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String _getContainerTypeLabel(dynamic type) {
|
||||
// Simple fallback - à améliorer avec import du model
|
||||
return type.toString().split('.').last;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/services/pdf_generator_service.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
|
||||
/// Widget réutilisable pour sélectionner le format de génération de QR codes multiples
|
||||
class QRCodeFormatSelectorDialog extends StatelessWidget {
|
||||
final List<EquipmentModel> equipmentList;
|
||||
|
||||
const QRCodeFormatSelectorDialog({
|
||||
super.key,
|
||||
required this.equipmentList,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// En-tête
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.qr_code_2, color: AppColors.rouge, size: 32),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Générer ${equipmentList.length} QR Codes',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Choisissez un format d\'étiquette :',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Liste des équipements
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: equipmentList.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final equipment = equipmentList[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.qr_code, size: 20),
|
||||
title: Text(
|
||||
equipment.id,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Boutons de format
|
||||
_FormatButton(
|
||||
icon: Icons.qr_code,
|
||||
title: 'Petits QR Codes',
|
||||
subtitle: 'QR codes compacts (2x2 cm)',
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_generatePDF(context, equipmentList, QRLabelFormat.small);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_FormatButton(
|
||||
icon: Icons.qr_code_2,
|
||||
title: 'QR Moyens',
|
||||
subtitle: 'QR codes taille moyenne (4x4 cm)',
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_generatePDF(context, equipmentList, QRLabelFormat.medium);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_FormatButton(
|
||||
icon: Icons.label,
|
||||
title: 'Grandes étiquettes',
|
||||
subtitle: 'QR + ID + Marque/Modèle (10x5 cm)',
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_generatePDF(context, equipmentList, QRLabelFormat.large);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _generatePDF(
|
||||
BuildContext context,
|
||||
List<EquipmentModel> equipmentList,
|
||||
QRLabelFormat format,
|
||||
) async {
|
||||
// Afficher le dialogue de chargement
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => Dialog(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Génération du PDF en cours...',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Génération de ${equipmentList.length} QR code(s)',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Génération du PDF
|
||||
final pdfBytes = await PDFGeneratorService.generateQRCodesPDF(
|
||||
equipmentList: equipmentList,
|
||||
format: format,
|
||||
);
|
||||
|
||||
// Fermer le dialogue de chargement
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
// Afficher le PDF
|
||||
await Printing.layoutPdf(
|
||||
onLayout: (format) async => pdfBytes,
|
||||
name: 'QRCodes_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('PDF généré avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fermer le dialogue de chargement en cas d'erreur
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur lors de la génération du PDF: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bouton de sélection de format
|
||||
class _FormatButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _FormatButton({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: AppColors.rouge, size: 32),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
110
em2rp/lib/views/widgets/containers/container_equipment_tile.dart
Normal file
110
em2rp/lib/views/widgets/containers/container_equipment_tile.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
|
||||
/// Widget pour afficher un équipement dans la liste d'un container
|
||||
class ContainerEquipmentTile extends StatelessWidget {
|
||||
final EquipmentModel equipment;
|
||||
final VoidCallback onView;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const ContainerEquipmentTile({
|
||||
super.key,
|
||||
required this.equipment,
|
||||
required this.onView,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppColors.rouge.withOpacity(0.1),
|
||||
child: const Icon(Icons.inventory_2, color: AppColors.rouge),
|
||||
),
|
||||
title: Text(
|
||||
equipment.id,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (equipment.brand != null || equipment.model != null)
|
||||
Text('${equipment.brand ?? ''} ${equipment.model ?? ''}'),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
_buildSmallBadge(
|
||||
_getCategoryLabel(equipment.category),
|
||||
Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (equipment.weight != null)
|
||||
_buildSmallBadge(
|
||||
'${equipment.weight} kg',
|
||||
Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.visibility, size: 20),
|
||||
tooltip: 'Voir détails',
|
||||
onPressed: onView,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle, color: Colors.red, size: 20),
|
||||
tooltip: 'Retirer',
|
||||
onPressed: onRemove,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSmallBadge(String label, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getCategoryLabel(EquipmentCategory category) {
|
||||
switch (category) {
|
||||
case EquipmentCategory.lighting:
|
||||
return 'Lumière';
|
||||
case EquipmentCategory.sound:
|
||||
return 'Son';
|
||||
case EquipmentCategory.video:
|
||||
return 'Vidéo';
|
||||
case EquipmentCategory.effect:
|
||||
return 'Effets';
|
||||
case EquipmentCategory.structure:
|
||||
return 'Structure';
|
||||
case EquipmentCategory.consumable:
|
||||
return 'Consommable';
|
||||
case EquipmentCategory.cable:
|
||||
return 'Câble';
|
||||
case EquipmentCategory.other:
|
||||
return 'Autre';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
197
em2rp/lib/views/widgets/containers/container_header_card.dart
Normal file
197
em2rp/lib/views/widgets/containers/container_header_card.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
|
||||
/// Widget pour afficher la carte d'en-tête d'un container
|
||||
class ContainerHeaderCard extends StatelessWidget {
|
||||
final ContainerModel container;
|
||||
final List<EquipmentModel> equipmentList;
|
||||
|
||||
const ContainerHeaderCard({
|
||||
super.key,
|
||||
required this.container,
|
||||
required this.equipmentList,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getTypeIcon(container.type),
|
||||
size: 60,
|
||||
color: AppColors.rouge,
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
container.id,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
container.name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
context,
|
||||
'Type',
|
||||
containerTypeLabel(container.type),
|
||||
Icons.category,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
context,
|
||||
'Statut',
|
||||
_getStatusLabel(container.status),
|
||||
Icons.info,
|
||||
statusColor: _getStatusColor(container.status),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
context,
|
||||
'Équipements',
|
||||
'${container.itemCount}',
|
||||
Icons.inventory,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
context,
|
||||
'Poids total',
|
||||
_calculateTotalWeight(),
|
||||
Icons.scale,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoItem(
|
||||
BuildContext context,
|
||||
String label,
|
||||
String value,
|
||||
IconData icon, {
|
||||
Color? statusColor,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: Colors.grey.shade600),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _calculateTotalWeight() {
|
||||
if (equipmentList.isEmpty && container.weight == null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
final totalWeight = container.calculateTotalWeight(equipmentList);
|
||||
return '${totalWeight.toStringAsFixed(1)} kg';
|
||||
}
|
||||
|
||||
IconData _getTypeIcon(ContainerType type) {
|
||||
switch (type) {
|
||||
case ContainerType.flightCase:
|
||||
return Icons.work;
|
||||
case ContainerType.pelicase:
|
||||
return Icons.work_outline;
|
||||
case ContainerType.bag:
|
||||
return Icons.shopping_bag;
|
||||
case ContainerType.openCrate:
|
||||
return Icons.inventory_2;
|
||||
case ContainerType.toolbox:
|
||||
return Icons.handyman;
|
||||
}
|
||||
}
|
||||
|
||||
String _getStatusLabel(EquipmentStatus status) {
|
||||
switch (status) {
|
||||
case EquipmentStatus.available:
|
||||
return 'Disponible';
|
||||
case EquipmentStatus.inUse:
|
||||
return 'En prestation';
|
||||
case EquipmentStatus.maintenance:
|
||||
return 'Maintenance';
|
||||
case EquipmentStatus.outOfService:
|
||||
return 'Hors service';
|
||||
default:
|
||||
return 'Autre';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getStatusColor(EquipmentStatus status) {
|
||||
switch (status) {
|
||||
case EquipmentStatus.available:
|
||||
return Colors.green;
|
||||
case EquipmentStatus.inUse:
|
||||
return Colors.orange;
|
||||
case EquipmentStatus.maintenance:
|
||||
return Colors.blue;
|
||||
case EquipmentStatus.outOfService:
|
||||
return Colors.red;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
|
||||
/// Widget pour afficher les caractéristiques physiques d'un container
|
||||
class ContainerPhysicalCharacteristics extends StatelessWidget {
|
||||
final ContainerModel container;
|
||||
|
||||
const ContainerPhysicalCharacteristics({
|
||||
super.key,
|
||||
required this.container,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasDimensions = container.length != null ||
|
||||
container.width != null ||
|
||||
container.height != null;
|
||||
final hasWeight = container.weight != null;
|
||||
final hasVolume = container.volume != null;
|
||||
|
||||
if (!hasDimensions && !hasWeight) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Caractéristiques physiques',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
if (hasWeight)
|
||||
_buildCharacteristicRow(
|
||||
'Poids à vide',
|
||||
'${container.weight} kg',
|
||||
Icons.scale,
|
||||
),
|
||||
if (hasDimensions) ...[
|
||||
if (hasWeight) const SizedBox(height: 12),
|
||||
_buildCharacteristicRow(
|
||||
'Dimensions (L×l×H)',
|
||||
'${container.length ?? '?'} × ${container.width ?? '?'} × ${container.height ?? '?'} cm',
|
||||
Icons.straighten,
|
||||
),
|
||||
],
|
||||
if (hasVolume) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildCharacteristicRow(
|
||||
'Volume',
|
||||
'${container.volume!.toStringAsFixed(3)} m³',
|
||||
Icons.view_in_ar,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCharacteristicRow(String label, String value, IconData icon) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: AppColors.rouge),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/providers/container_provider.dart';
|
||||
|
||||
/// Widget pour afficher les containers parents d'un équipement
|
||||
class EquipmentParentContainers extends StatefulWidget {
|
||||
final List<String> parentBoxIds;
|
||||
|
||||
const EquipmentParentContainers({
|
||||
super.key,
|
||||
required this.parentBoxIds,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EquipmentParentContainers> createState() => _EquipmentParentContainersState();
|
||||
}
|
||||
|
||||
class _EquipmentParentContainersState extends State<EquipmentParentContainers> {
|
||||
List<ContainerModel> _containers = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadContainers();
|
||||
}
|
||||
|
||||
Future<void> _loadContainers() async {
|
||||
if (widget.parentBoxIds.isEmpty) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
final List<ContainerModel> containers = [];
|
||||
|
||||
for (final boxId in widget.parentBoxIds) {
|
||||
final container = await containerProvider.getContainerById(boxId);
|
||||
if (container != null) {
|
||||
containers.add(container);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_containers = containers;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.parentBoxIds.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.inventory_2, color: AppColors.rouge, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Containers',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
if (_isLoading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (_containers.isEmpty)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'Cet équipement n\'est dans aucun container',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _containers.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final container = _containers[index];
|
||||
return _buildContainerTile(container);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContainerTile(ContainerModel container) {
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
leading: Icon(
|
||||
_getTypeIcon(container.type),
|
||||
color: AppColors.rouge,
|
||||
size: 32,
|
||||
),
|
||||
title: Text(
|
||||
container.id,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(container.name),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
containerTypeLabel(container.type),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
'/container_detail',
|
||||
arguments: container,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getTypeIcon(ContainerType type) {
|
||||
switch (type) {
|
||||
case ContainerType.flightCase:
|
||||
return Icons.work;
|
||||
case ContainerType.pelicase:
|
||||
return Icons.work_outline;
|
||||
case ContainerType.bag:
|
||||
return Icons.shopping_bag;
|
||||
case ContainerType.openCrate:
|
||||
return Icons.inventory_2;
|
||||
case ContainerType.toolbox:
|
||||
return Icons.handyman;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,37 @@ class MainDrawer extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
PermissionGate(
|
||||
requiredPermissions: const ['view_equipment'],
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.inventory),
|
||||
title: const Text('Gestion du Matériel'),
|
||||
selected: currentPage == '/equipment_management',
|
||||
selectedColor: AppColors.rouge,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
const EquipmentManagementPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
PermissionGate(
|
||||
requiredPermissions: const ['view_equipment'],
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.inventory_2),
|
||||
title: const Text('Containers'),
|
||||
selected: currentPage == '/container_management',
|
||||
selectedColor: AppColors.rouge,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pushNamed(context, '/container_management');
|
||||
},
|
||||
),
|
||||
),
|
||||
ExpansionTileTheme(
|
||||
data: const ExpansionTileThemeData(
|
||||
iconColor: AppColors.noir,
|
||||
@@ -152,24 +183,6 @@ class MainDrawer extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
PermissionGate(
|
||||
requiredPermissions: const ['view_equipment'],
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.inventory),
|
||||
title: const Text('Gestion du Matériel'),
|
||||
selected: currentPage == '/equipment_management',
|
||||
selectedColor: AppColors.rouge,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
const EquipmentManagementPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user