Files
EM2_ERP/em2rp/lib/views/equipment_management_page.dart

1475 lines
51 KiB
Dart

import 'package:firebase_storage/firebase_storage.dart';
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/equipment_provider.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/views/equipment_form_page.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import 'dart:typed_data';
import 'dart:ui' as ui;
class EquipmentManagementPage extends StatefulWidget {
const EquipmentManagementPage({super.key});
@override
State<EquipmentManagementPage> createState() => _EquipmentManagementPageState();
}
enum QRLabelFormat { small, medium, large }
class _EquipmentManagementPageState extends State<EquipmentManagementPage> {
final TextEditingController _searchController = TextEditingController();
EquipmentCategory? _selectedCategory;
bool _isSelectionMode = false;
final Set<String> _selectedEquipmentIds = {};
void _toggleSelectionMode() {
setState(() {
_isSelectionMode = !_isSelectionMode;
if (!_isSelectionMode) {
_selectedEquipmentIds.clear();
}
});
}
void _toggleEquipmentSelection(String id) {
setState(() {
if (_selectedEquipmentIds.contains(id)) {
_selectedEquipmentIds.remove(id);
} else {
_selectedEquipmentIds.add(id);
}
});
}
@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: '/equipment_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 du matériel.',
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(
'${_selectedEquipmentIds.length} sélectionné(s)',
style: const TextStyle(color: Colors.white),
),
actions: [
if (_selectedEquipmentIds.isNotEmpty) ...[
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: _deleteSelectedEquipment,
),
],
],
)
: CustomAppBar(
title: 'Gestion du matériel',
actions: [
IconButton(
icon: const Icon(Icons.checklist),
tooltip: 'Mode sélection',
onPressed: _toggleSelectionMode,
),
],
),
drawer: const MainDrawer(currentPage: '/equipment_management'),
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
floatingActionButton: _isSelectionMode ? null : _buildFAB(),
),
);
}
Widget _buildFAB() {
return PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: FloatingActionButton.extended(
onPressed: _createNewEquipment,
backgroundColor: AppColors.rouge,
icon: const Icon(Icons.add),
label: const Text('Ajouter un équipement'),
),
);
}
Widget _buildMobileLayout() {
return Column(
children: [
// Barre de recherche
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher par nom, modèle ou ID...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
),
),
// Menu horizontal de filtres par catégorie
SizedBox(
height: 60,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ChoiceChip(
label: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.all_inclusive, size: 16, color: Colors.white),
SizedBox(width: 8),
Text('Tout'),
],
),
selected: _selectedCategory == null,
onSelected: (selected) {
if (selected) {
setState(() => _selectedCategory = null);
context.read<EquipmentProvider>().setSelectedCategory(null);
}
},
selectedColor: AppColors.rouge,
labelStyle: TextStyle(
color: _selectedCategory == null ? Colors.white : AppColors.rouge,
fontWeight: _selectedCategory == null ? FontWeight.bold : FontWeight.normal,
),
),
),
..._buildCategoryChips(),
],
),
),
const Divider(),
// Liste des équipements
Expanded(child: _buildEquipmentList()),
],
);
}
Widget _buildDesktopLayout() {
return Row(
children: [
// Sidebar gauche avec filtres
Container(
width: 280,
decoration: BoxDecoration(
color: Colors.grey[100],
border: const Border(
right: BorderSide(color: Colors.grey, width: 1),
),
),
child: Column(
children: [
// En-tête
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.rouge.withOpacity(0.1),
),
child: Row(
children: [
Icon(Icons.inventory, color: AppColors.rouge),
const SizedBox(width: 12),
Expanded(
child: Text(
'Filtres',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.rouge,
),
),
),
],
),
),
// Barre de recherche
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher...',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
),
),
const Divider(),
// Filtres par catégorie
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Catégories',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.grey[700],
),
),
),
),
Expanded(
child: ListView(
children: [
ListTile(
leading: Icon(
Icons.all_inclusive,
color: _selectedCategory == null ? AppColors.rouge : Colors.grey[600],
),
title: Text(
'Tout',
style: TextStyle(
color: _selectedCategory == null ? AppColors.rouge : Colors.black87,
fontWeight: _selectedCategory == null ? FontWeight.bold : FontWeight.normal,
),
),
selected: _selectedCategory == null,
selectedTileColor: AppColors.rouge.withOpacity(0.1),
onTap: () {
setState(() => _selectedCategory = null);
context.read<EquipmentProvider>().setSelectedCategory(null);
},
),
..._buildCategoryListTiles(),
],
),
),
],
),
),
// Contenu principal
Expanded(child: _buildEquipmentList()),
],
);
}
List<Widget> _buildCategoryChips() {
final categories = [
(EquipmentCategory.lighting, Icons.light_mode, 'Lumière'),
(EquipmentCategory.sound, Icons.volume_up, 'Son'),
(EquipmentCategory.video, Icons.videocam, 'Vidéo'),
(EquipmentCategory.effect, Icons.auto_awesome, 'Effets'),
(EquipmentCategory.structure, Icons.construction, 'Structure'),
(EquipmentCategory.consumable, Icons.inventory_2, 'Consommable'),
(EquipmentCategory.cable, Icons.cable, 'Câble'),
(EquipmentCategory.other, Icons.more_horiz, 'Autre'),
];
return categories.map((cat) {
final isSelected = _selectedCategory == cat.$1;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ChoiceChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
cat.$2,
size: 16,
color: isSelected ? Colors.white : AppColors.rouge,
),
const SizedBox(width: 8),
Text(cat.$3),
],
),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() => _selectedCategory = cat.$1);
context.read<EquipmentProvider>().setSelectedCategory(cat.$1);
}
},
selectedColor: AppColors.rouge,
labelStyle: TextStyle(
color: isSelected ? Colors.white : AppColors.rouge,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
);
}).toList();
}
List<Widget> _buildCategoryListTiles() {
final categories = [
(EquipmentCategory.lighting, Icons.light_mode, 'Lumière'),
(EquipmentCategory.sound, Icons.volume_up, 'Son'),
(EquipmentCategory.video, Icons.videocam, 'Vidéo'),
(EquipmentCategory.effect, Icons.auto_awesome, 'Effets'),
(EquipmentCategory.structure, Icons.construction, 'Structure'),
(EquipmentCategory.consumable, Icons.inventory_2, 'Consommable'),
(EquipmentCategory.cable, Icons.cable, 'Câble'),
(EquipmentCategory.other, Icons.more_horiz, 'Autre'),
];
return categories.map((cat) {
final isSelected = _selectedCategory == cat.$1;
return ListTile(
leading: Icon(
cat.$2,
color: isSelected ? AppColors.rouge : Colors.grey[600],
),
title: Text(
cat.$3,
style: TextStyle(
color: isSelected ? AppColors.rouge : Colors.black87,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
selected: isSelected,
selectedTileColor: AppColors.rouge.withOpacity(0.1),
onTap: () {
setState(() => _selectedCategory = cat.$1);
context.read<EquipmentProvider>().setSelectedCategory(cat.$1);
},
);
}).toList();
}
Widget _buildEquipmentList() {
return Consumer<EquipmentProvider>(
builder: (context, provider, child) {
return StreamBuilder<List<EquipmentModel>>(
stream: provider.equipmentStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Text('Erreur: ${snapshot.error}'),
);
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inventory_2_outlined, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'Aucun équipement trouvé',
style: TextStyle(fontSize: 18, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Ajoutez votre premier équipement',
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
),
);
}
// Tri par nom
final equipment = snapshot.data!;
equipment.sort((a, b) => a.name.compareTo(b.name));
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: equipment.length,
itemBuilder: (context, index) {
return _buildEquipmentCard(equipment[index]);
},
);
},
);
},
);
}
Widget _buildEquipmentCard(EquipmentModel equipment) {
final isSelected = _selectedEquipmentIds.contains(equipment.id);
return Card(
margin: const EdgeInsets.only(bottom: 12),
color: _isSelectionMode && isSelected ? AppColors.rouge.withOpacity(0.1) : null,
child: ListTile(
leading: _isSelectionMode
? Checkbox(
value: isSelected,
onChanged: (value) => _toggleEquipmentSelection(equipment.id),
activeColor: AppColors.rouge,
)
: CircleAvatar(
backgroundColor: _getStatusColor(equipment.status).withOpacity(0.2),
child: Icon(
_getCategoryIcon(equipment.category),
color: _getStatusColor(equipment.status),
),
),
title: Row(
children: [
Expanded(
child: Text(
equipment.id,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
// Afficher le statut uniquement si ce n'est pas un consommable ou câble
if (equipment.category != EquipmentCategory.consumable &&
equipment.category != EquipmentCategory.cable)
_buildStatusBadge(equipment.status),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
'${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim().isNotEmpty
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
: 'Marque/Modèle non défini',
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
// Afficher la quantité disponible pour les consommables/câbles
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable) ...[
const SizedBox(height: 4),
_buildQuantityDisplay(equipment),
],
],
),
trailing: _isSelectionMode
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton Restock (uniquement pour consommables/câbles avec permission)
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.add_shopping_cart, color: AppColors.rouge),
tooltip: 'Restock',
onPressed: () => _showRestockDialog(equipment),
),
),
// Bouton QR Code
IconButton(
icon: const Icon(Icons.qr_code, color: AppColors.rouge),
tooltip: 'QR Code',
onPressed: () => _showSingleQRCode(equipment),
),
// Bouton Modifier (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.edit, color: AppColors.rouge),
tooltip: 'Modifier',
onPressed: () => _editEquipment(equipment),
),
),
// Bouton Supprimer (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Supprimer',
onPressed: () => _deleteEquipment(equipment),
),
),
],
),
onTap: _isSelectionMode
? () => _toggleEquipmentSelection(equipment.id)
: () => _viewEquipmentDetails(equipment),
),
);
}
Widget _buildQuantityDisplay(EquipmentModel equipment) {
final availableQty = equipment.availableQuantity ?? 0;
final totalQty = equipment.totalQuantity ?? 0;
final criticalThreshold = equipment.criticalThreshold ?? 0;
final isCritical = criticalThreshold > 0 && availableQty <= criticalThreshold;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isCritical ? Colors.red.withOpacity(0.15) : Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isCritical ? Colors.red : Colors.grey.shade400,
width: isCritical ? 2 : 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isCritical ? Icons.warning : Icons.inventory,
size: 16,
color: isCritical ? Colors.red : Colors.grey[700],
),
const SizedBox(width: 6),
Text(
'Disponible: $availableQty / $totalQty',
style: TextStyle(
fontSize: 13,
fontWeight: isCritical ? FontWeight.bold : FontWeight.normal,
color: isCritical ? Colors.red : Colors.grey[700],
),
),
if (isCritical) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'CRITIQUE',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
],
),
);
}
Widget _buildStatusBadge(EquipmentStatus status) {
final statusInfo = _getStatusInfo(status);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusInfo.$2.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: statusInfo.$2),
),
child: Text(
statusInfo.$1,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: statusInfo.$2,
),
),
);
}
(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);
}
}
Color _getStatusColor(EquipmentStatus status) {
return _getStatusInfo(status).$2;
}
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;
}
}
// Actions
void _createNewEquipment() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const EquipmentFormPage(),
),
);
}
void _editEquipment(EquipmentModel equipment) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EquipmentFormPage(equipment: equipment),
),
);
}
void _deleteEquipment(EquipmentModel equipment) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text('Voulez-vous vraiment supprimer "${equipment.name}" ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
try {
await context.read<EquipmentProvider>().deleteEquipment(equipment.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Équipement supprimé avec succès')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
}
void _deleteSelectedEquipment() async {
if (_selectedEquipmentIds.isEmpty) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Voulez-vous vraiment supprimer ${_selectedEquipmentIds.length} équipement(s) ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
try {
final provider = context.read<EquipmentProvider>();
for (final id in _selectedEquipmentIds) {
await provider.deleteEquipment(id);
}
setState(() {
_selectedEquipmentIds.clear();
_isSelectionMode = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${_selectedEquipmentIds.length} équipement(s) supprimé(s) 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'),
),
],
),
);
}
void _generateQRCodesForSelected() async {
if (_selectedEquipmentIds.isEmpty) return;
// Récupérer les équipements sélectionnés
final provider = context.read<EquipmentProvider>();
final List<EquipmentModel> selectedEquipment = [];
// On doit récupérer les équipements depuis le stream
await for (final equipmentList in provider.equipmentStream.take(1)) {
for (final equipment in equipmentList) {
if (_selectedEquipmentIds.contains(equipment.id)) {
selectedEquipment.add(equipment);
}
}
break;
}
if (selectedEquipment.isEmpty) return;
if (selectedEquipment.length == 1) {
_showSingleQRCode(selectedEquipment.first);
} else {
_showMultipleQRCodesDialog(selectedEquipment);
}
}
void _showSingleQRCode(EquipmentModel equipment) {
showDialog(
context: context,
builder: (context) => Dialog(
child: Container(
padding: const EdgeInsets.all(24),
constraints: const BoxConstraints(maxWidth: 400),
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 - ${equipment.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: equipment.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(
equipment.id,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
'${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(),
style: TextStyle(color: Colors.grey[700]),
),
],
),
),
const SizedBox(height: 24),
// Bouton télécharger
ElevatedButton.icon(
onPressed: () => _downloadSingleQRCodeImage(equipment),
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),
),
),
],
),
),
),
);
}
void _showMultipleQRCodesDialog(List<EquipmentModel> equipmentList) {
showDialog(
context: context,
builder: (context) => Dialog(
child: Container(
padding: const EdgeInsets.all(24),
constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
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
_buildFormatButton(
context,
icon: Icons.qr_code,
title: 'Petits QR Codes',
subtitle: 'QR codes compacts (2x2 cm)',
onPressed: () {
Navigator.pop(context);
_generatePDF(equipmentList, QRLabelFormat.small);
},
),
const SizedBox(height: 12),
_buildFormatButton(
context,
icon: Icons.qr_code_2,
title: 'QR Moyens',
subtitle: 'QR codes taille moyenne (4x4 cm)',
onPressed: () {
Navigator.pop(context);
_generatePDF(equipmentList, QRLabelFormat.medium);
},
),
const SizedBox(height: 12),
_buildFormatButton(
context,
icon: Icons.label,
title: 'Grandes étiquettes',
subtitle: 'QR + ID + Marque/Modèle (10x5 cm)',
onPressed: () {
Navigator.pop(context);
_generatePDF(equipmentList, QRLabelFormat.large);
},
),
],
),
),
),
);
}
Widget _buildFormatButton(
BuildContext context, {
required IconData icon,
required String title,
required String subtitle,
required VoidCallback onPressed,
}) {
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),
],
),
),
);
}
Future<void> _downloadSingleQRCodeImage(EquipmentModel equipment) async {
try {
// Générer l'image QR code en haute résolution
final qrImage = await _generateQRImage(equipment.id, size: 1024);
// Utiliser la bibliothèque printing pour sauvegarder l'image
await Printing.sharePdf(
bytes: qrImage,
filename: 'QRCode_${equipment.id}.png',
);
if (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 (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors du téléchargement de l\'image: $e')),
);
}
}
}
Future<void> _generatePDF(List<EquipmentModel> equipmentList, QRLabelFormat format) async {
try {
final pdf = pw.Document();
switch (format) {
case QRLabelFormat.small:
await _generateSmallQRCodesPDF(pdf, equipmentList);
break;
case QRLabelFormat.medium:
await _generateMediumQRCodesPDF(pdf, equipmentList);
break;
case QRLabelFormat.large:
await _generateLargeQRCodesPDF(pdf, equipmentList);
break;
}
await Printing.layoutPdf(
onLayout: (format) async => pdf.save(),
name: 'QRCodes_${DateTime.now().millisecondsSinceEpoch}.pdf',
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('PDF généré avec succès'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors de la génération du PDF: $e')),
);
}
}
}
Future<void> _generateSmallQRCodesPDF(pw.Document pdf, List<EquipmentModel> equipmentList) async {
// Petits QR codes : 2x2 cm, 9 par page (3x3)
const qrSize = 56.69; // 2cm en points
const itemsPerRow = 4;
const itemsPerPage = 20;
for (int pageStart = 0; pageStart < equipmentList.length; pageStart += itemsPerPage) {
final pageEquipment = equipmentList.skip(pageStart).take(itemsPerPage).toList();
final qrImages = await Future.wait(
pageEquipment.map((eq) => _generateQRImage(eq.id)),
);
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(pageEquipment.length, (index) {
return pw.Container(
width: qrSize,
height: qrSize,
child: pw.Image(pw.MemoryImage(qrImages[index])),
);
}),
);
},
),
);
}
}
Future<void> _generateMediumQRCodesPDF(pw.Document pdf, List<EquipmentModel> equipmentList) async {
// QR moyens : 4x4 cm, 6 par page (2x3)
const qrSize = 113.39; // 4cm en points
const itemsPerRow = 2;
const itemsPerPage = 6;
for (int pageStart = 0; pageStart < equipmentList.length; pageStart += itemsPerPage) {
final pageEquipment = equipmentList.skip(pageStart).take(itemsPerPage).toList();
final qrImages = await Future.wait(
pageEquipment.map((eq) => _generateQRImage(eq.id)),
);
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(pageEquipment.length, (index) {
return pw.Container(
width: qrSize,
height: qrSize,
child: pw.Image(pw.MemoryImage(qrImages[index])),
);
}),
);
},
),
);
}
}
Future<void> _generateLargeQRCodesPDF(pw.Document pdf, List<EquipmentModel> equipmentList) async {
// Grandes étiquettes : 10x5 cm, 4 par page
const labelWidth = 283.46; // 10cm en points
const labelHeight = 141.73; // 5cm en points
const qrSize = 113.39; // 4cm en points
const itemsPerPage = 4;
for (int pageStart = 0; pageStart < equipmentList.length; pageStart += itemsPerPage) {
final pageEquipment = equipmentList.skip(pageStart).take(itemsPerPage).toList();
final qrImages = await Future.wait(
pageEquipment.map((eq) => _generateQRImage(eq.id)),
);
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(pageEquipment.length, (index) {
final equipment = pageEquipment[index];
return pw.Container(
width: labelWidth,
height: labelHeight,
padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.grey300),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(5)),
),
child: pw.Row(
children: [
pw.Image(pw.MemoryImage(qrImages[index]), width: qrSize, height: qrSize),
pw.SizedBox(width: 15),
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
mainAxisAlignment: pw.MainAxisAlignment.center,
children: [
pw.Text(
equipment.id,
style: pw.TextStyle(
fontSize: 14,
fontWeight: pw.FontWeight.bold,
),
),
pw.SizedBox(height: 8),
pw.Text(
'Marque: ${equipment.brand ?? 'N/A'}',
style: const pw.TextStyle(fontSize: 10),
),
pw.SizedBox(height: 4),
pw.Text(
'Modèle: ${equipment.model ?? 'N/A'}',
style: const pw.TextStyle(fontSize: 10),
),
],
),
),
],
),
);
}),
);
},
),
);
}
}
Future<Uint8List> _generateQRImage(String data, {double size = 512}) async {
final qrValidationResult = QrValidator.validate(
data: data,
version: QrVersions.auto,
errorCorrectionLevel: QrErrorCorrectLevel.L,
);
if (qrValidationResult.status != QrValidationStatus.valid) {
throw Exception('QR code validation failed');
}
final qrCode = qrValidationResult.qrCode!;
final painter = QrPainter.withQr(
qr: qrCode,
color: const Color(0xFF000000),
emptyColor: const Color(0xFFFFFFFF),
gapless: true,
);
final picData = await painter.toImageData(size, format: ui.ImageByteFormat.png);
return picData!.buffer.asUint8List();
}
void _showRestockDialog(EquipmentModel equipment) {
final TextEditingController quantityController = TextEditingController();
bool addToTotal = false;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Row(
children: [
const Icon(Icons.add_shopping_cart, color: AppColors.rouge),
const SizedBox(width: 12),
Expanded(
child: Text('Restock - ${equipment.name}'),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Quantités actuelles',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey[700],
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Disponible:'),
Text(
'${equipment.availableQuantity ?? 0}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Total:'),
Text(
'${equipment.totalQuantity ?? 0}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
],
),
),
const SizedBox(height: 20),
TextField(
controller: quantityController,
decoration: const InputDecoration(
labelText: 'Quantité à ajouter/retirer',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.inventory),
hintText: 'Ex: 10 ou -5',
helperText: 'Nombre positif pour ajouter, négatif pour retirer',
),
keyboardType: const TextInputType.numberWithOptions(signed: true),
autofocus: true,
),
const SizedBox(height: 16),
CheckboxListTile(
title: const Text('Ajouter à la quantité totale'),
subtitle: const Text('Mettre à jour aussi la quantité totale'),
value: addToTotal,
contentPadding: EdgeInsets.zero,
onChanged: (bool? value) {
setState(() {
addToTotal = value ?? false;
});
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () async {
final quantityText = quantityController.text.trim();
if (quantityText.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Veuillez entrer une quantité')),
);
return;
}
final quantity = int.tryParse(quantityText);
if (quantity == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Quantité invalide')),
);
return;
}
Navigator.pop(context);
try {
final currentAvailable = equipment.availableQuantity ?? 0;
final currentTotal = equipment.totalQuantity ?? 0;
final newAvailable = currentAvailable + quantity;
final newTotal = addToTotal ? currentTotal + quantity : currentTotal;
if (newAvailable < 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('La quantité disponible ne peut pas être négative')),
);
return;
}
if (newTotal < 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('La quantité totale ne peut pas être négative')),
);
return;
}
final updatedData = {
'availableQuantity': newAvailable,
'totalQuantity': newTotal,
'updatedAt': DateTime.now().toIso8601String(),
};
await context.read<EquipmentProvider>().updateEquipment(
equipment.id,
updatedData,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
quantity > 0
? 'Ajout de $quantity unité(s) effectué'
: 'Retrait de ${quantity.abs()} unité(s) effectué',
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
),
child: const Text('Valider', style: TextStyle(color: Colors.white)),
),
],
);
},
),
);
}
void _showQRCode(EquipmentModel equipment) {
// TODO: Afficher le QR code
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('QR Code pour ${equipment.name} - À implémenter')),
);
}
void _viewEquipmentDetails(EquipmentModel equipment) {
// TODO: Naviguer vers la page de détails
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Détails de ${equipment.name} - À implémenter')),
);
}
}