You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

5025 lines
195 KiB

import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:file_picker/file_picker.dart';
import 'package:open_file/open_file.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:qr_flutter/qr_flutter.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import 'package:excel/excel.dart' hide Border;
import 'package:youmazgestion/Services/stock_managementDatabase.dart';
import 'package:youmazgestion/controller/userController.dart';
import '../Components/appDrawer.dart';
import '../Components/app_bar.dart';
import '../Models/produit.dart';
class ProductManagementPage extends StatefulWidget {
const ProductManagementPage({super.key});
@override
_ProductManagementPageState createState() => _ProductManagementPageState();
}
class _ProductManagementPageState extends State<ProductManagementPage> {
final AppDatabase _productDatabase = AppDatabase.instance;
final AppDatabase _appDatabase = AppDatabase.instance;
final UserController _userController = Get.find<UserController>();
List<Product> _products = [];
List<Product> _filteredProducts = [];
final TextEditingController _searchController = TextEditingController();
String _selectedCategory = 'Tous';
List<String> _categories = ['Tous'];
bool _isLoading = true;
List<Map<String, dynamic>> _pointsDeVente = [];
String? _selectedPointDeVente;
// Variables pour le scanner
QRViewController? _qrController;
bool _isScanning = false;
bool _isAssigning = false;
final GlobalKey _qrKey = GlobalKey(debugLabel: 'QR');
// Catégories prédéfinies pour l'ajout de produits
final List<String> _predefinedCategories = [
'Smartphone',
'Tablette',
'Accessoires',
'Multimedia',
'Informatique',
'Laptop',
'Non catégorisé'
];
bool _isUserSuperAdmin() {
return _userController.role == 'Super Admin';
}
// Variables pour l'import Excel (conservées du code original)
bool _isImporting = false;
double _importProgress = 0.0;
String _importStatusText = '';
@override
void initState() {
super.initState();
_loadProducts();
_loadPointsDeVente();
_searchController.addListener(_filterProducts);
}
@override
void dispose() {
_qrController?.dispose();
_searchController.dispose();
super.dispose();
}
void _showAddProductDialog() {
final nameController = TextEditingController();
final priceController = TextEditingController();
final stockController = TextEditingController();
final descriptionController = TextEditingController();
final imageController = TextEditingController();
final referenceController = TextEditingController();
final marqueController = TextEditingController();
final ramController = TextEditingController();
final memoireInterneController = TextEditingController();
final imeiController = TextEditingController();
final newPointDeVenteController = TextEditingController();
String? selectedPointDeVente;
List<Map<String, dynamic>> pointsDeVente = [];
bool isLoadingPoints = true;
String selectedCategory =
_predefinedCategories.last; // 'Non catégorisé' par défaut
File? pickedImage;
String? qrPreviewData;
bool autoGenerateReference = true;
bool showAddNewPoint = false;
// 🎨 Widget pour les cartes d'information
Widget _buildInfoCard(String label, String value, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Column(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
Text(
value,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: color,
),
textAlign: TextAlign.center,
),
],
),
);
}
// 🎨 Widget pour les étapes de transfert
Widget _buildTransferStep(String label, String pointDeVente, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, color: color, size: 16),
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade600,
fontWeight: FontWeight.bold,
),
),
Text(
pointDeVente,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
// 🎨 INTERFACE AMÉLIORÉE: Dialog moderne pour demande de transfert
Future<void> _showDemandeTransfertDialog(Product product) async {
final quantiteController = TextEditingController(text: '1');
final notesController = TextEditingController();
final _formKey = GlobalKey<FormState>();
// Récupérer les infos du point de vente source
final pointDeVenteSource = await _appDatabase.getPointDeVenteNomById(product.pointDeVenteId ?? 0);
final pointDeVenteDestination = await _appDatabase.getPointDeVenteNomById(_userController.pointDeVenteId);
await showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
contentPadding: EdgeInsets.zero,
content: Container(
width: 400,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// En-tête avec design moderne
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.blue.shade600, Colors.blue.shade700],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Column(
children: [
Icon(
Icons.swap_horizontal_circle,
size: 48,
color: Colors.white,
),
const SizedBox(height: 8),
Text(
'Demande de transfert',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Text(
'Transférer un produit entre points de vente',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.9),
),
),
],
),
),
// Contenu principal
Padding(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Informations du produit
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.inventory_2,
color: Colors.blue.shade700,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Produit à transférer',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
Text(
product.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildInfoCard(
'Prix unitaire',
'${NumberFormat('#,##0.00', 'fr_FR').format(product.price)} MGA',
Icons.attach_money,
Colors.green,
),
),
const SizedBox(width: 8),
Expanded(
child: _buildInfoCard(
'Stock disponible',
'${product.stock ?? 0}',
Icons.inventory,
product.stock != null && product.stock! > 0
? Colors.green
: Colors.red,
),
),
],
),
if (product.reference != null && product.reference!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Référence: ${product.reference}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontFamily: 'monospace',
),
),
],
],
),
),
const SizedBox(height: 20),
// Informations de transfert
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.orange.shade200),
),
child: Column(
children: [
Row(
children: [
Icon(Icons.arrow_forward, color: Colors.orange.shade700),
const SizedBox(width: 8),
Text(
'Informations de transfert',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.orange.shade700,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildTransferStep(
'DE',
pointDeVenteSource ?? 'Chargement...',
Icons.store_outlined,
Colors.red.shade600,
),
),
Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
child: Icon(
Icons.arrow_forward,
color: Colors.orange.shade700,
size: 24,
),
),
Expanded(
child: _buildTransferStep(
'VERS',
pointDeVenteDestination ?? 'Chargement...',
Icons.store,
Colors.green.shade600,
),
),
],
),
],
),
),
const SizedBox(height: 20),
// Champ quantité avec design amélioré
Text(
'Quantité à transférer',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
children: [
IconButton(
onPressed: () {
int currentQty = int.tryParse(quantiteController.text) ?? 1;
if (currentQty > 1) {
quantiteController.text = (currentQty - 1).toString();
}
},
icon: Icon(Icons.remove, color: Colors.grey.shade600),
),
Expanded(
child: TextFormField(
controller: quantiteController,
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 16),
hintText: 'Quantité',
),
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer une quantité';
}
final qty = int.tryParse(value) ?? 0;
if (qty <= 0) {
return 'Quantité invalide';
}
if (product.stock != null && qty > product.stock!) {
return 'Quantité supérieure au stock disponible';
}
return null;
},
),
),
IconButton(
onPressed: () {
int currentQty = int.tryParse(quantiteController.text) ?? 1;
int maxStock = product.stock ?? 999;
if (currentQty < maxStock) {
quantiteController.text = (currentQty + 1).toString();
}
},
icon: Icon(Icons.add, color: Colors.grey.shade600),
),
],
),
),
// Boutons d'action avec design moderne
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade300),
),
),
child: Text(
'Annuler',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: ElevatedButton.icon(
onPressed: () async {
if (!_formKey.currentState!.validate()) return;
final qty = int.tryParse(quantiteController.text) ?? 0;
if (qty <= 0) {
Get.snackbar(
'Erreur',
'Quantité invalide',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
try {
setState(() => _isLoading = true);
Navigator.pop(context);
await _appDatabase.createDemandeTransfert(
produitId: product.id!,
pointDeVenteSourceId: product.pointDeVenteId!,
pointDeVenteDestinationId: _userController.pointDeVenteId,
demandeurId: _userController.userId,
quantite: qty,
notes: notesController.text.isNotEmpty
? notesController.text
: 'Demande de transfert depuis l\'application mobile',
);
Get.snackbar(
'Demande envoyée ✅',
'Votre demande de transfert de $qty unité(s) a été enregistrée et sera traitée prochainement.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
duration: const Duration(seconds: 4),
icon: const Icon(Icons.check_circle, color: Colors.white),
);
} catch (e) {
Get.snackbar(
'Erreur',
'Impossible d\'envoyer la demande: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
duration: const Duration(seconds: 4),
);
} finally {
setState(() => _isLoading = false);
}
},
icon: const Icon(Icons.send, color: Colors.white),
label: const Text(
'Envoyer la demande',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade600,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
),
),
],
),
],
),
),
),
],
),
),
),
),
);
}
// Fonction pour mettre à jour le QR preview
void updateQrPreview() {
if (nameController.text.isNotEmpty) {
final reference = autoGenerateReference
? _generateUniqueReference()
: referenceController.text.trim();
if (reference.isNotEmpty) {
qrPreviewData = 'https://stock.guycom.mg/$reference';
} else {
qrPreviewData = null;
}
} else {
qrPreviewData = null;
}
}
final AppDatabase _productDatabase = AppDatabase.instance;
Future<void> loadPointsDeVente(StateSetter setDialogState) async {
try {
final result = await _productDatabase.getPointsDeVente();
setDialogState(() {
// Ajouter l'option "Aucun" à la liste
pointsDeVente = [
{'id': null, 'nom': 'Aucun'}, // Option pour pointDeVenteId null
...result
];
isLoadingPoints = false;
if (selectedPointDeVente == null && result.isNotEmpty) {
selectedPointDeVente = 'Aucun'; // Par défaut, sélectionner "Aucun"
}
});
} catch (e) {
setDialogState(() {
isLoadingPoints = false;
});
Get.snackbar('Erreur', 'Impossible de charger les points de vente: $e');
}
}
Get.dialog(
AlertDialog(
title: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(8),
),
child:
Icon(Icons.add_shopping_cart, color: Colors.green.shade700),
),
const SizedBox(width: 12),
const Text('Ajouter un produit'),
],
),
content: Container(
width: 600,
constraints: const BoxConstraints(maxHeight: 600),
child: SingleChildScrollView(
child: StatefulBuilder(
builder: (context, setDialogState) {
// Charger les points de vente une seule fois
if (isLoadingPoints && pointsDeVente.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
loadPointsDeVente(setDialogState);
});
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Champs obligatoires
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.teal.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.teal.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.store, color: Colors.teal.shade700),
const SizedBox(width: 8),
Text(
'Point de vente',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.teal.shade700,
),
),
],
),
const SizedBox(height: 12),
if (isLoadingPoints)
const Center(child: CircularProgressIndicator())
else
Column(
children: [
if (!showAddNewPoint) ...[
DropdownButtonFormField<String>(
value: selectedPointDeVente,
items: pointsDeVente.map((point) {
return DropdownMenuItem(
value: point['nom'] as String,
child: Text(point['nom'] as String),
);
}).toList(),
onChanged: (value) {
setDialogState(
() => selectedPointDeVente = value);
},
decoration: const InputDecoration(
labelText:
'Sélectionner un point de vente',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.store),
filled: true,
fillColor: Colors.white,
),
),
const SizedBox(height: 8),
Row(
children: [
TextButton.icon(
onPressed: () {
setDialogState(() {
showAddNewPoint = true;
newPointDeVenteController.clear();
});
},
icon: const Icon(Icons.add, size: 16),
label:
const Text('Ajouter nouveau point'),
style: TextButton.styleFrom(
foregroundColor: Colors.teal.shade700,
),
),
const Spacer(),
TextButton.icon(
onPressed: () =>
loadPointsDeVente(setDialogState),
icon:
const Icon(Icons.refresh, size: 16),
label: const Text('Actualiser'),
),
],
),
],
if (showAddNewPoint) ...[
DropdownButtonFormField<String>(
value: selectedPointDeVente,
items: pointsDeVente.map((point) {
return DropdownMenuItem(
value: point['nom'] as String,
child: Text(point['nom'] as String),
);
}).toList(),
onChanged: (value) {
setDialogState(
() => selectedPointDeVente = value);
},
decoration: const InputDecoration(
labelText:
'Sélectionner un point de vente',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.store),
filled: true,
fillColor: Colors.white,
),
),
const SizedBox(height: 8),
Row(
children: [
TextButton.icon(
onPressed: () {
setDialogState(() {
showAddNewPoint = true;
newPointDeVenteController.clear();
});
},
icon: const Icon(Icons.add, size: 16),
label:
const Text('Ajouter nouveau point'),
style: TextButton.styleFrom(
foregroundColor: Colors.teal.shade700,
),
),
const Spacer(),
TextButton.icon(
onPressed: () =>
loadPointsDeVente(setDialogState),
icon:
const Icon(Icons.refresh, size: 16),
label: const Text('Actualiser'),
),
],
),
],
if (showAddNewPoint) ...[
TextField(
controller: newPointDeVenteController,
decoration: const InputDecoration(
labelText:
'Nom du nouveau point de vente',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.add_business),
filled: true,
fillColor: Colors.white,
),
),
const SizedBox(height: 8),
Row(
children: [
TextButton(
onPressed: () {
setDialogState(() {
showAddNewPoint = false;
newPointDeVenteController.clear();
});
},
child: const Text('Annuler'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () async {
final nom = newPointDeVenteController
.text
.trim();
if (nom.isNotEmpty) {
try {
final id = await _productDatabase
.getOrCreatePointDeVenteByNom(
nom);
if (id != null) {
setDialogState(() {
showAddNewPoint = false;
selectedPointDeVente = nom;
newPointDeVenteController
.clear();
});
// Recharger la liste
await loadPointsDeVente(
setDialogState);
Get.snackbar(
'Succès',
'Point de vente "$nom" créé avec succès',
backgroundColor: Colors.green,
colorText: Colors.white,
);
}
} catch (e) {
Get.snackbar('Erreur',
'Impossible de créer le point de vente: $e');
}
}
},
icon: const Icon(Icons.save, size: 16),
label: const Text('Créer'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.teal,
foregroundColor: Colors.white,
),
),
],
),
],
],
),
],
),
),
const SizedBox(height: 16),
// Nom du produit
TextField(
controller: nameController,
decoration: InputDecoration(
labelText: 'Nom du produit *',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.shopping_bag),
filled: true,
fillColor: Colors.grey.shade50,
),
onChanged: (value) {
setDialogState(() {
updateQrPreview();
});
},
),
const SizedBox(height: 16),
// Prix et Stock sur la même ligne
Row(
children: [
Expanded(
child: TextField(
controller: priceController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
decoration: InputDecoration(
labelText: 'Prix (MGA) *',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.attach_money),
filled: true,
fillColor: Colors.grey.shade50,
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: stockController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Stock initial',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.inventory),
filled: true,
fillColor: Colors.grey.shade50,
),
),
),
],
),
const SizedBox(height: 16),
// Catégorie
DropdownButtonFormField<String>(
value: selectedCategory,
items: _predefinedCategories
.map((category) => DropdownMenuItem(
value: category, child: Text(category)))
.toList(),
onChanged: (value) {
setDialogState(() => selectedCategory = value!);
},
decoration: InputDecoration(
labelText: 'Catégorie',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.category),
filled: true,
fillColor: Colors.grey.shade50,
),
),
const SizedBox(height: 16),
// Description
TextField(
controller: descriptionController,
maxLines: 3,
decoration: InputDecoration(
labelText: 'Description',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.description),
filled: true,
fillColor: Colors.grey.shade50,
),
),
const SizedBox(height: 16),
// Section Référence
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.purple.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.purple.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.confirmation_number,
color: Colors.purple.shade700),
const SizedBox(width: 8),
Text(
'Référence du produit',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.purple.shade700,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Checkbox(
value: autoGenerateReference,
onChanged: (value) {
setDialogState(() {
autoGenerateReference = value!;
updateQrPreview();
});
},
),
const Text('Générer automatiquement'),
],
),
const SizedBox(height: 8),
if (!autoGenerateReference)
TextField(
controller: referenceController,
decoration: const InputDecoration(
labelText: 'Référence *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.tag),
filled: true,
fillColor: Colors.white,
),
onChanged: (value) {
setDialogState(() {
updateQrPreview();
});
},
),
if (autoGenerateReference)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Référence générée automatiquement',
style: TextStyle(color: Colors.grey.shade700),
),
),
],
),
),
const SizedBox(height: 16),
// Nouveaux champs (Marque, RAM, Mémoire interne, IMEI)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.memory, color: Colors.orange.shade700),
const SizedBox(width: 8),
Text(
'Spécifications techniques',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.orange.shade700,
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: marqueController,
decoration: const InputDecoration(
labelText: 'Marque',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.branding_watermark),
filled: true,
fillColor: Colors.white,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: ramController,
decoration: const InputDecoration(
labelText: 'RAM',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.memory),
filled: true,
fillColor: Colors.white,
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: memoireInterneController,
decoration: const InputDecoration(
labelText: 'Mémoire interne',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.storage),
filled: true,
fillColor: Colors.white,
),
),
),
],
),
const SizedBox(height: 8),
TextField(
controller: imeiController,
decoration: const InputDecoration(
labelText: 'IMEI (pour téléphones)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.smartphone),
filled: true,
fillColor: Colors.white,
),
),
],
),
),
const SizedBox(height: 16),
// Section Image
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.image, color: Colors.blue.shade700),
const SizedBox(width: 8),
Text(
'Image du produit (optionnel)',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.blue.shade700,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: imageController,
decoration: const InputDecoration(
labelText: 'Chemin de l\'image',
border: OutlineInputBorder(),
isDense: true,
),
readOnly: true,
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () async {
final result = await FilePicker.platform
.pickFiles(type: FileType.image);
if (result != null &&
result.files.single.path != null) {
setDialogState(() {
pickedImage =
File(result.files.single.path!);
imageController.text = pickedImage!.path;
});
}
},
icon: const Icon(Icons.folder_open, size: 16),
label: const Text('Choisir'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(12),
),
),
],
),
const SizedBox(height: 12),
// Aperçu de l'image
if (pickedImage != null)
Center(
child: Container(
height: 100,
width: 100,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border:
Border.all(color: Colors.grey.shade300),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(pickedImage!,
fit: BoxFit.cover),
),
),
),
],
),
),
const SizedBox(height: 16),
// Aperçu QR Code
if (qrPreviewData != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.shade200),
),
child: Column(
children: [
Row(
children: [
Icon(Icons.qr_code_2,
color: Colors.green.shade700),
const SizedBox(width: 8),
Text(
'Aperçu du QR Code',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.green.shade700,
),
),
],
),
const SizedBox(height: 12),
Center(
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: QrImageView(
data: qrPreviewData!,
version: QrVersions.auto,
size: 80,
backgroundColor: Colors.white,
),
),
),
const SizedBox(height: 8),
Text(
'Réf: ${autoGenerateReference ? _generateUniqueReference() : referenceController.text.trim()}',
style: const TextStyle(
fontSize: 10, color: Colors.grey),
),
],
),
),
],
);
},
),
),
),
actions: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Annuler'),
),
ElevatedButton.icon(
onPressed: () async {
final name = nameController.text.trim();
final price = double.tryParse(priceController.text.trim()) ?? 0.0;
final stock = int.tryParse(stockController.text.trim()) ?? 0;
if (name.isEmpty || price <= 0) {
Get.snackbar('Erreur', 'Nom et prix sont obligatoires');
return;
}
// Vérification de la référence
String finalReference;
if (autoGenerateReference) {
finalReference = _generateUniqueReference();
} else {
finalReference = referenceController.text.trim();
if (finalReference.isEmpty) {
Get.snackbar('Erreur', 'La référence est obligatoire');
return;
}
final existingProduct = await _productDatabase
.getProductByReference(finalReference);
if (existingProduct != null) {
Get.snackbar('Erreur', 'Cette référence existe déjà');
return;
}
}
// Gérer le point de vente
int? pointDeVenteId;
String? finalPointDeVenteNom;
if (showAddNewPoint &&
newPointDeVenteController.text.trim().isNotEmpty) {
finalPointDeVenteNom = newPointDeVenteController.text.trim();
} else if (selectedPointDeVente != null &&
selectedPointDeVente != 'Aucun') {
finalPointDeVenteNom = selectedPointDeVente;
}
if (finalPointDeVenteNom != null) {
pointDeVenteId = await _productDatabase
.getOrCreatePointDeVenteByNom(finalPointDeVenteNom);
}
// Si "Aucun" est sélectionné, pointDeVenteId reste null
try {
final qrPath = await _generateAndSaveQRCode(finalReference);
final product = Product(
name: name,
price: price,
image: imageController.text,
category: selectedCategory,
description: descriptionController.text.trim(),
stock: stock,
qrCode: qrPath,
reference: finalReference,
marque: marqueController.text.trim(),
ram: ramController.text.trim(),
memoireInterne: memoireInterneController.text.trim(),
imei: imeiController.text.trim(),
pointDeVenteId: pointDeVenteId, // Peut être null si "Aucun"
);
await _productDatabase.createProduct(product);
Get.back();
Get.snackbar(
'Succès',
'Produit ajouté avec succès!\nRéférence: $finalReference${finalPointDeVenteNom != null ? '\nPoint de vente: $finalPointDeVenteNom' : ''}',
backgroundColor: Colors.green,
colorText: Colors.white,
duration: const Duration(seconds: 4),
icon: const Icon(Icons.check_circle, color: Colors.white),
);
_loadProducts();
_loadPointsDeVente();
} catch (e) {
Get.snackbar('Erreur', 'Ajout du produit échoué: $e');
}
},
icon: const Icon(Icons.save),
label: const Text('Ajouter le produit'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
),
),
],
),
);
}
// === FONCTIONS DE SCAN POUR ATTRIBUTION POINT DE VENTE ===
String? _getColumnValue(
List<Data?> row, Map<String, int> mapping, String field) {
if (!mapping.containsKey(field)) return null;
int columnIndex = mapping[field]!;
if (columnIndex >= row.length || row[columnIndex]?.value == null)
return null;
return row[columnIndex]!.value.toString().trim();
}
void _startPointDeVenteAssignmentScanning() {
if (_isScanning) return;
// Vérifier que l'utilisateur a un point de vente
if (_userController.pointDeVenteId <= 0) {
Get.snackbar(
'Erreur',
'Vous n\'avez pas de point de vente assigné. Contactez un administrateur.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red.shade600,
colorText: Colors.white,
duration: const Duration(seconds: 4),
icon: const Icon(Icons.error, color: Colors.white),
);
return;
}
setState(() {
_isScanning = true;
});
Get.to(() => _buildAssignmentScannerPage())?.then((_) {
setState(() {
_isScanning = false;
});
});
}
Map<String, dynamic> _normalizeRowData(
List<Data?> row, Map<String, int> mapping, int rowIndex) {
final normalizedData = <String, dynamic>{};
// Fonction interne pour nettoyer et normaliser les valeurs
String? _cleanValue(String? value) {
if (value == null) return null;
return value.toString().trim();
}
// Fonction simple pour les nombres (maintenant ils sont corrects)
double? _normalizeNumber(String? value) {
if (value == null || value.isEmpty) return null;
// Remplacer les virgules par des points et supprimer les espaces
final cleaned = value.replaceAll(',', '.').replaceAll(RegExp(r'\s+'), '');
// Supprimer les caractères non numériques sauf le point
final numericString = cleaned.replaceAll(RegExp(r'[^0-9.]'), '');
return double.tryParse(numericString);
}
// Normalisation du nom
if (mapping.containsKey('name')) {
final name = _cleanValue(_getColumnValue(row, mapping, 'name'));
if (name != null && name.isNotEmpty) {
normalizedData['name'] = name;
}
}
// Normalisation du prix (maintenant simple car corrigé en amont)
if (mapping.containsKey('price')) {
final priceValue = _cleanValue(_getColumnValue(row, mapping, 'price'));
final price = _normalizeNumber(priceValue);
if (price != null && price > 0) {
normalizedData['price'] = price;
print('✅ Prix normalisé: $price');
}
}
// Normalisation de la référence
if (mapping.containsKey('reference')) {
final reference = _cleanValue(_getColumnValue(row, mapping, 'reference'));
if (reference != null && reference.isNotEmpty) {
normalizedData['reference'] = reference;
} else {
// Génération automatique si non fournie
normalizedData['reference'] = _generateUniqueReference();
}
}
// Normalisation de la catégorie
if (mapping.containsKey('category')) {
final category = _cleanValue(_getColumnValue(row, mapping, 'category'));
normalizedData['category'] = category ?? 'Non catégorisé';
} else {
normalizedData['category'] = 'Non catégorisé';
}
// Normalisation de la marque
if (mapping.containsKey('marque')) {
final marque = _cleanValue(_getColumnValue(row, mapping, 'marque'));
if (marque != null && marque.isNotEmpty) {
normalizedData['marque'] = marque;
}
}
// Normalisation de la RAM
if (mapping.containsKey('ram')) {
final ram = _cleanValue(_getColumnValue(row, mapping, 'ram'));
if (ram != null && ram.isNotEmpty) {
// Standardisation du format (ex: "8 Go", "16GB" -> "8 Go", "16 Go")
final ramValue = ram.replaceAll('GB', 'Go').replaceAll('go', 'Go');
normalizedData['ram'] = ramValue;
}
}
// Normalisation de la mémoire interne
if (mapping.containsKey('memoire_interne')) {
final memoire =
_cleanValue(_getColumnValue(row, mapping, 'memoire_interne'));
if (memoire != null && memoire.isNotEmpty) {
// Standardisation du format (ex: "256GB" -> "256 Go")
final memoireValue =
memoire.replaceAll('GB', 'Go').replaceAll('go', 'Go');
normalizedData['memoire_interne'] = memoireValue;
}
}
// Normalisation de l'IMEI
if (mapping.containsKey('imei')) {
final imei = _cleanValue(_getColumnValue(row, mapping, 'imei'));
if (imei != null && imei.isNotEmpty) {
// Suppression des espaces et tirets dans l'IMEI
final imeiValue = imei.replaceAll(RegExp(r'[\s-]'), '');
if (imeiValue.length >= 15) {
normalizedData['imei'] = imeiValue.substring(0, 15);
} else {
normalizedData['imei'] = imeiValue;
}
}
}
// Normalisation du point de vente
if (mapping.containsKey('point_de_vente')) {
final pv = _cleanValue(_getColumnValue(row, mapping, 'point_de_vente'));
if (pv != null && pv.isNotEmpty) {
// Suppression des espaces superflus
normalizedData['point_de_vente'] =
pv.replaceAll(RegExp(r'\s+'), ' ').trim();
}
}
// Valeurs par défaut
normalizedData['description'] = ''; // Description toujours vide
if (mapping.containsKey('stock')) {
final stockValue = _cleanValue(_getColumnValue(row, mapping, 'stock'));
final stock = int.tryParse(stockValue ?? '0') ?? 1;
normalizedData['stock'] = stock > 0 ? stock : 1;
} else {
normalizedData['stock'] = 1; // Valeur par défaut
}
// Validation des données obligatoires
if (normalizedData['name'] == null || normalizedData['price'] == null) {
throw Exception(
'Ligne ${rowIndex + 1}: Données obligatoires manquantes (nom ou prix)');
}
return normalizedData;
}
// Méthode pour mapper les en-têtes aux colonnes (CORRIGÉE)
Map<String, int> _mapHeaders(List<Data?> headerRow) {
Map<String, int> columnMapping = {};
for (int i = 0; i < headerRow.length; i++) {
if (headerRow[i]?.value == null) continue;
String header = headerRow[i]!.value.toString().trim().toUpperCase();
// Debug : afficher chaque en-tête trouvé
print('En-tête trouvé: "$header" à la colonne $i');
// Mapping amélioré pour gérer les variations
if ((header.contains('NOM') &&
(header.contains('PRODUIT') || header.contains('DU'))) ||
header == 'NOM DU PRODUITS' ||
header == 'NOM') {
columnMapping['name'] = i;
print('→ Mappé vers name');
} else if ((header.contains('REFERENCE') &&
(header.contains('PRODUIT') || header.contains('PRODUITS'))) ||
header == 'REFERENCE PRODUITS' ||
header == 'REFERENCE') {
columnMapping['reference'] = i;
print('→ Mappé vers reference');
} else if ((header.contains('CATEGORIES') &&
(header.contains('PRODUIT') || header.contains('PRODUITS'))) ||
header == 'CATEGORIES PRODUITS' ||
header == 'CATEGORIE' ||
header == 'CATEGORY') {
columnMapping['category'] = i;
print('→ Mappé vers category');
} else if (header == 'MARQUE' || header == 'BRAND') {
columnMapping['marque'] = i;
print('→ Mappé vers marque');
} else if (header == 'RAM' || header.contains('MEMOIRE RAM')) {
columnMapping['ram'] = i;
print('→ Mappé vers ram');
} else if (header == 'INTERNE' ||
header.contains('MEMOIRE INTERNE') ||
header.contains('STOCKAGE')) {
columnMapping['memoire_interne'] = i;
print('→ Mappé vers memoire_interne');
} else if (header == 'IMEI' || header.contains('NUMERO IMEI')) {
columnMapping['imei'] = i;
print('→ Mappé vers imei');
} else if (header == 'PRIX' || header == 'PRICE') {
columnMapping['price'] = i;
print('→ Mappé vers price');
} else if (header == 'STOCK' || header == 'QUANTITY' || header == 'QTE') {
columnMapping['stock'] = i;
print('→ Mappé vers stock');
} else if (header == 'BOUTIQUE' ||
header.contains('POINT DE VENTE') ||
header == 'MAGASIN') {
columnMapping['point_de_vente'] = i;
print('→ Mappé vers point_de_vente');
} else {
print('→ Non reconnu');
}
}
// Debug : afficher le mapping final
print('Mapping final: $columnMapping');
return columnMapping;
}
Widget _buildAssignmentScannerPage() {
return Scaffold(
appBar: AppBar(
title: const Text('Scanner pour Attribution'),
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_qrController?.dispose();
Get.back();
},
),
actions: [
IconButton(
icon: const Icon(Icons.flash_on),
onPressed: () async {
await _qrController?.toggleFlash();
},
),
IconButton(
icon: const Icon(Icons.flip_camera_ios),
onPressed: () async {
await _qrController?.flipCamera();
},
),
],
),
body: Stack(
children: [
// Scanner view
QRView(
key: _qrKey,
onQRViewCreated: _onAssignmentQRViewCreated,
overlay: QrScannerOverlayShape(
borderColor: Colors.orange,
borderRadius: 10,
borderLength: 30,
borderWidth: 10,
cutOutSize: 250,
),
),
// Instructions overlay
Positioned(
bottom: 100,
left: 20,
right: 20,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.assignment,
color: Colors.orange.shade300, size: 40),
const SizedBox(height: 8),
const Text(
'Scanner l\'IMEI pour assigner au point de vente',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
'Point de vente: ${_userController.pointDeVenteDesignation}',
style: TextStyle(
color: Colors.orange.shade300,
fontSize: 14,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
],
),
),
),
],
),
);
}
void _onAssignmentQRViewCreated(QRViewController controller) {
_qrController = controller;
controller.scannedDataStream.listen((scanData) {
if (scanData.code != null && scanData.code!.isNotEmpty) {
// Pauser le scanner pour éviter les scans multiples
controller.pauseCamera();
// Fermer la page du scanner
Get.back();
// Traiter le résultat
_assignProductToUserPointDeVente(scanData.code!);
}
});
}
Future<void> _assignProductToUserPointDeVente(String scannedImei) async {
if (_isAssigning) return;
setState(() {
_isAssigning = true;
});
try {
// Montrer un indicateur de chargement
Get.dialog(
AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: Colors.orange.shade700),
const SizedBox(height: 16),
const Text('Recherche du produit...'),
const SizedBox(height: 8),
Text(
'IMEI: $scannedImei',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontFamily: 'monospace',
),
),
],
),
),
barrierDismissible: false,
);
// Attendre un court instant pour l'effet visuel
await Future.delayed(const Duration(milliseconds: 300));
// Chercher le produit avec l'IMEI scanné
Product? foundProduct =
await _productDatabase.getProductByIMEI(scannedImei);
// Fermer l'indicateur de chargement
Get.back();
if (foundProduct == null) {
_showProductNotFoundDialog(scannedImei);
return;
}
// Vérifier si le produit a déjà le bon point de vente
if (foundProduct.pointDeVenteId == _userController.pointDeVenteId) {
_showAlreadyAssignedDialog(foundProduct);
return;
}
// Assigner le point de vente de l'utilisateur au produit
// final updatedProduct = Product(
// id: foundProduct.id,
// name: foundProduct.name,
// price: foundProduct.price,
// image: foundProduct.image,
// category: foundProduct.category,
// description: foundProduct.description,
// stock: foundProduct.stock,
// qrCode: foundProduct.qrCode,
// reference: foundProduct.reference,
// marque: foundProduct.marque,
// ram: foundProduct.ram,
// memoireInterne: foundProduct.memoireInterne,
// imei: foundProduct.imei,
// pointDeVenteId:
// _userController.pointDeVenteId, // Nouveau point de vente
// );
await _appDatabase.createDemandeTransfert(
produitId: foundProduct.id!,
pointDeVenteSourceId: _userController.pointDeVenteId,
pointDeVenteDestinationId: _userController.pointDeVenteId,
demandeurId: _userController.userId,
quantite: foundProduct.stock,
notes: 'produit non assigner',
);
// await _productDatabase.updateProduct(updatedProduct);
// Recharger les produits pour refléter les changements
_loadProducts();
// Afficher le dialogue de succès
_showAssignmentSuccessDialog(foundProduct);
} catch (e) {
// Fermer l'indicateur de chargement si il est encore ouvert
if (Get.isDialogOpen!) Get.back();
Get.snackbar(
'Erreur',
'Une erreur est survenue: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red.shade600,
colorText: Colors.white,
duration: const Duration(seconds: 3),
);
} finally {
setState(() {
_isAssigning = false;
});
}
}
void _showAssignmentSuccessDialog(Product product) {
Get.dialog(
AlertDialog(
title: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.check_circle, color: Colors.green.shade700),
),
const SizedBox(width: 12),
const Expanded(child: Text( 'demande attribution réussie en attente de validation!')),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text('IMEI: ${product.imei}'),
Text('Prix: ${NumberFormat('#,##0.00', 'fr_FR').format(product.price)} MGA'),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Produit assigné au point de vente:',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Text(
_userController.pointDeVenteDesignation,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.green.shade700,
),
),
],
),
),
],
),
actions: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Fermer'),
),
ElevatedButton(
onPressed: () {
Get.back();
_startPointDeVenteAssignmentScanning(); // Scanner un autre produit
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
),
child: const Text('Scanner encore'),
),
],
),
);
}
void _showAlreadyAssignedDialog(Product product) {
Get.dialog(
AlertDialog(
title: Row(
children: [
Icon(Icons.info, color: Colors.blue.shade600),
const SizedBox(width: 8),
const Text('Déjà assigné'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text('IMEI: ${product.imei}'),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Ce produit est déjà assigné au point de vente:',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Text(
_userController.pointDeVenteDesignation,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blue.shade700,
),
),
],
),
),
],
),
actions: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Fermer'),
),
ElevatedButton(
onPressed: () {
Get.back();
_startPointDeVenteAssignmentScanning(); // Scanner un autre produit
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
),
child: const Text('Scanner encore'),
),
],
),
);
}
void _showProductNotFoundDialog(String scannedImei) {
Get.dialog(
AlertDialog(
title: Row(
children: [
Icon(Icons.search_off, color: Colors.red.shade600),
const SizedBox(width: 8),
const Text('Produit non trouvé'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Aucun produit trouvé avec cet IMEI:'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
scannedImei,
style: const TextStyle(
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
Text(
'Vérifiez que l\'IMEI est correct ou que le produit existe dans la base de données.',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
actions: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Fermer'),
),
ElevatedButton(
onPressed: () {
Get.back();
_startPointDeVenteAssignmentScanning();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
),
child: const Text('Scanner à nouveau'),
),
],
),
);
}
Widget _buildAssignmentScanCard() {
final isMobile = MediaQuery.of(context).size.width < 600;
return Card(
elevation: 2,
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.assignment,
color: Colors.orange.shade700,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Assigner produits à votre point de vente',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color.fromARGB(255, 9, 56, 95),
),
),
const SizedBox(height: 4),
Text(
'Point de vente: ${_userController.pointDeVenteDesignation}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
ElevatedButton.icon(
onPressed: (_isScanning ||
_isAssigning ||
_userController.pointDeVenteId <= 0)
? null
: _startPointDeVenteAssignmentScanning,
icon: (_isScanning || _isAssigning)
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.qr_code_scanner, size: 18),
label:
Text((_isScanning || _isAssigning) ? 'Scan...' : 'Assigner'),
style: ElevatedButton.styleFrom(
backgroundColor: (_isScanning ||
_isAssigning ||
_userController.pointDeVenteId <= 0)
? Colors.grey
: Colors.orange.shade700,
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
],
),
),
);
}
// === FONCTIONS CONSERVÉES DU CODE ORIGINAL ===
// [Conservez toutes les autres méthodes du code original ici]
// Réinitialisation de l'état d'import
void _resetImportState() {
setState(() {
_isImporting = false;
_importProgress = 0.0;
_importStatusText = '';
});
}
Future<void> _loadPointsDeVente() async {
try {
final points = await _productDatabase.getPointsDeVente();
setState(() {
_pointsDeVente = points;
if (points.isNotEmpty) {
_selectedPointDeVente = points.first['nom'] as String;
}
});
} catch (e) {
Get.snackbar('Erreur', 'Impossible de charger les points de vente: $e');
}
}
Future<void> _loadProducts() async {
setState(() => _isLoading = true);
try {
await _productDatabase.initDatabase();
final products = await _productDatabase.getProducts();
final categories = await _productDatabase.getCategories();
setState(() {
_products = products;
_filteredProducts = products;
_categories = ['Tous', ...categories];
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
Get.snackbar('Erreur', 'Impossible de charger les produits: $e');
}
}
void _filterProducts() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredProducts = _products.where((product) {
final matchesSearch = product.name.toLowerCase().contains(query) ||
product.description!.toLowerCase().contains(query) ||
product.reference!.toLowerCase().contains(query);
final matchesCategory = _selectedCategory == 'Tous' ||
product.category == _selectedCategory;
return matchesSearch && matchesCategory;
}).toList();
});
}
// Méthode pour générer une référence unique
String _generateUniqueReference() {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final randomSuffix = DateTime.now().microsecond.toString().padLeft(6, '0');
return 'PROD_${timestamp}${randomSuffix}';
}
Future<void> _downloadExcelTemplate() async {
try {
final excel = Excel.createExcel();
final sheet = excel['Sheet1'];
// En-têtes modifiés sans DESCRIPTION et STOCK
final headers = [
'ID PRODUITS', // Sera ignoré lors de l'import
'NOM DU PRODUITS', // name
'REFERENCE PRODUITS', // reference
'CATEGORIES PRODUITS', // category
'MARQUE', // marque
'RAM', // ram
'INTERNE', // memoire_interne
'IMEI', // imei
'STOCK'
'PRIX', // price
'BOUTIQUE', // point_de_vente
];
// Ajouter les en-têtes avec style
for (int i = 0; i < headers.length; i++) {
final cell =
sheet.cell(CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 0));
cell.value = headers[i];
cell.cellStyle = CellStyle(
bold: true,
backgroundColorHex: '#E8F4FD',
horizontalAlign: HorizontalAlign.Center,
);
}
// Exemples modifiés sans DESCRIPTION et STOCK
final examples = [
[
'1', // ID PRODUITS (sera ignoré)
'Smartphone Galaxy S24', // NOM DU PRODUITS
'SGS24-001', // REFERENCE PRODUITS
'Téléphone', // CATEGORIES PRODUITS
'Samsung', // MARQUE
'8 Go', // RAM
'256 Go', // INTERNE
'123456789012345', // IMEI
'1200.00', // PRIX
'405A', // BOUTIQUE
],
[
'2', // ID PRODUITS
'iPhone 15 Pro', // NOM DU PRODUITS
'IP15P-001', // REFERENCE PRODUITS
'Téléphone', // CATEGORIES PRODUITS
'Apple', // MARQUE
'8 Go', // RAM
'512 Go', // INTERNE
'987654321098765', // IMEI
'1599.00', // PRIX
'405B', // BOUTIQUE
],
[
'3', // ID PRODUITS
'MacBook Pro 14"', // NOM DU PRODUITS
'MBP14-001', // REFERENCE PRODUITS
'Informatique', // CATEGORIES PRODUITS
'Apple', // MARQUE
'16 Go', // RAM
'1 To', // INTERNE
'', // IMEI (vide pour un ordinateur)
'2499.00', // PRIX
'S405A', // BOUTIQUE
],
[
'4', // ID PRODUITS
'iPad Air', // NOM DU PRODUITS
'IPA-001', // REFERENCE PRODUITS
'Tablette', // CATEGORIES PRODUITS
'Apple', // MARQUE
'8 Go', // RAM
'256 Go', // INTERNE
'456789123456789', // IMEI
'699.00', // PRIX
'405A', // BOUTIQUE
],
[
'5', // ID PRODUITS
'Gaming Laptop ROG', // NOM DU PRODUITS
'ROG-001', // REFERENCE PRODUITS
'Informatique', // CATEGORIES PRODUITS
'ASUS', // MARQUE
'32 Go', // RAM
'1 To', // INTERNE
'', // IMEI (vide)
'1899.00', // PRIX
'405B', // BOUTIQUE
]
];
// Ajouter les exemples
for (int row = 0; row < examples.length; row++) {
for (int col = 0; col < examples[row].length; col++) {
final cell = sheet.cell(
CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row + 1));
cell.value = examples[row][col];
// Style pour les données (prix en gras)
if (col == 8) {
// Colonne PRIX
cell.cellStyle = CellStyle(
bold: true,
);
}
}
}
// Ajuster la largeur des colonnes (sans DESCRIPTION et STOCK)
sheet.setColWidth(0, 12); // ID PRODUITS
sheet.setColWidth(1, 25); // NOM DU PRODUITS
sheet.setColWidth(2, 18); // REFERENCE PRODUITS
sheet.setColWidth(3, 18); // CATEGORIES PRODUITS
sheet.setColWidth(4, 15); // MARQUE
sheet.setColWidth(5, 10); // RAM
sheet.setColWidth(6, 12); // INTERNE
sheet.setColWidth(7, 18); // IMEI
sheet.setColWidth(8, 12); // PRIX
sheet.setColWidth(9, 12); // BOUTIQUE
// Ajouter une feuille d'instructions mise à jour
final instructionSheet = excel['Instructions'];
final instructions = [
['INSTRUCTIONS D\'IMPORTATION'],
[''],
['Format des colonnes:'],
['• ID PRODUITS: Numéro d\'identification (ignoré lors de l\'import)'],
['• NOM DU PRODUITS: Nom du produit (OBLIGATOIRE)'],
['• REFERENCE PRODUITS: Référence unique du produit'],
['• CATEGORIES PRODUITS: Catégorie du produit'],
['• MARQUE: Marque du produit'],
['• RAM: Mémoire RAM (ex: "8 Go", "16 Go")'],
['• INTERNE: Stockage interne (ex: "256 Go", "1 To")'],
['• IMEI: Numéro IMEI (pour les appareils mobiles)'],
['• PRIX: Prix du produit en euros (OBLIGATOIRE)'],
['• BOUTIQUE: Code du point de vente'],
[''],
['Remarques importantes:'],
['• Les colonnes NOM DU PRODUITS et PRIX sont obligatoires'],
['• Si CATEGORIES PRODUITS est vide, "Non catégorisé" sera utilisé'],
[
'• Si REFERENCE PRODUITS est vide, une référence sera générée automatiquement'
],
['• Le stock sera automatiquement initialisé à 1 pour chaque produit'],
['• La description sera automatiquement vide pour chaque produit'],
['• Les colonnes peuvent être dans n\'importe quel ordre'],
['• Vous pouvez supprimer les colonnes non utilisées'],
[''],
['Formats acceptés:'],
['• PRIX: 1200.00 ou 1200,00 ou 1200'],
['• RAM/INTERNE: Texte libre (ex: "8 Go", "256 Go", "1 To")'],
];
for (int i = 0; i < instructions.length; i++) {
final cell = instructionSheet
.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: i));
cell.value = instructions[i][0];
if (i == 0) {
// Titre
cell.cellStyle = CellStyle(
bold: true,
fontSize: 16,
backgroundColorHex: '#4CAF50',
fontColorHex: '#FFFFFF',
);
} else if (instructions[i][0].startsWith('')) {
// Points de liste
cell.cellStyle = CellStyle(
italic: true,
);
} else if (instructions[i][0].endsWith(':')) {
// Sous-titres
cell.cellStyle = CellStyle(
bold: true,
backgroundColorHex: '#F5F5F5',
);
}
}
// Ajuster la largeur de la colonne instructions
instructionSheet.setColWidth(0, 80);
final bytes = excel.save();
if (bytes == null) {
Get.snackbar('Erreur', 'Impossible de créer le fichier modèle');
return;
}
final String? outputFile = await FilePicker.platform.saveFile(
fileName: 'modele_import_produits_v3.xlsx',
allowedExtensions: ['xlsx'],
type: FileType.custom,
);
if (outputFile != null) {
try {
await File(outputFile).writeAsBytes(bytes);
Get.snackbar(
'Succès',
'Modèle téléchargé avec succès\n$outputFile\n\nConsultez l\'onglet "Instructions" pour plus d\'informations.',
duration: const Duration(seconds: 6),
backgroundColor: Colors.green,
colorText: Colors.white,
);
} catch (e) {
Get.snackbar('Erreur', 'Impossible d\'écrire le fichier: $e');
}
}
} catch (e) {
Get.snackbar('Erreur', 'Erreur lors de la création du modèle: $e');
debugPrint('Erreur création modèle Excel: $e');
}
}
// Détecter si une valeur semble être un format de date/temps suspect
bool _isSuspiciousDateFormat(dynamic value) {
if (value == null) return false;
String valueStr = value.toString();
// Détecter les formats de date suspects qui devraient être des nombres
if (valueStr.contains('-') && valueStr.contains(':')) {
// Format DateTime détecté
print('🔍 Format DateTime suspect: $valueStr');
return true;
}
// Détecter les très grands nombres (timestamps en millisecondes)
if (valueStr.length > 10 && !valueStr.contains('.')) {
double? numValue = double.tryParse(valueStr);
if (numValue != null && numValue > 10000000000) {
print('🔍 Grand nombre suspect: $valueStr');
return true;
}
}
return false;
}
// Identifier les colonnes qui devraient contenir des nombres
List<int> _identifyNumberColumns(List<Data?> headerRow) {
List<int> numberColumns = [];
for (int i = 0; i < headerRow.length; i++) {
if (headerRow[i]?.value == null) continue;
String header = headerRow[i]!.value.toString().trim().toUpperCase();
// Identifier les en-têtes qui correspondent à des valeurs numériques
if (_isNumericHeader(header)) {
numberColumns.add(i);
print('📊 Colonne numérique: "$header" (index $i)');
}
}
return numberColumns;
}
// Vérifier si un en-tête correspond à une colonne numérique
bool _isNumericHeader(String header) {
List<String> numericHeaders = [
'PRIX',
'PRICE',
'COST',
'COUT',
'MONTANT',
'AMOUNT',
'TOTAL',
'QUANTITE',
'QUANTITY',
'QTE',
'STOCK',
'NOMBRE',
'NUMBER',
'TAILLE',
'SIZE',
'POIDS',
'WEIGHT',
'RAM',
'MEMOIRE',
'STORAGE',
'STOCKAGE'
];
return numericHeaders.any((keyword) => header.contains(keyword));
}
// Fonction de débogage pour analyser le fichier Excel
void _debugExcelFile(Excel excel) {
print('=== DEBUG EXCEL FILE ===');
print('Nombre de feuilles: ${excel.tables.length}');
for (var sheetName in excel.tables.keys) {
print('Feuille: $sheetName');
var sheet = excel.tables[sheetName]!;
print('Nombre de lignes: ${sheet.rows.length}');
if (sheet.rows.isNotEmpty) {
print('En-têtes (première ligne):');
for (int i = 0; i < sheet.rows[0].length; i++) {
var cellValue = sheet.rows[0][i]?.value;
print(' Colonne $i: "$cellValue" (${cellValue.runtimeType})');
}
if (sheet.rows.length > 1) {
print('Première ligne de données:');
for (int i = 0; i < sheet.rows[1].length; i++) {
var cellValue = sheet.rows[1][i]?.value;
print(' Colonne $i: "$cellValue"');
}
}
}
}
print('=== FIN DEBUG ===');
}
Excel _fixExcelNumberFormats(Excel excel) {
print('🔧 Correction des formats de cellules Excel...');
for (var sheetName in excel.tables.keys) {
print('📋 Traitement de la feuille: $sheetName');
var sheet = excel.tables[sheetName]!;
if (sheet.rows.isEmpty) continue;
// Analyser la première ligne pour identifier les colonnes de prix/nombres
List<int> numberColumns = _identifyNumberColumns(sheet.rows[0]);
print('🔢 Colonnes numériques détectées: $numberColumns');
// Corriger chaque ligne de données (ignorer la ligne d'en-tête)
for (int rowIndex = 1; rowIndex < sheet.rows.length; rowIndex++) {
var row = sheet.rows[rowIndex];
for (int colIndex in numberColumns) {
if (colIndex < row.length && row[colIndex] != null) {
var cell = row[colIndex]!;
var originalValue = cell.value;
// Détecter si la cellule a un format de date/temps suspect
if (_isSuspiciousDateFormat(originalValue)) {
print(
'⚠️ Cellule suspecte détectée en ($rowIndex, $colIndex): $originalValue');
// Convertir la valeur corrompue en nombre standard
var correctedValue = _convertSuspiciousValue(originalValue);
if (correctedValue != null) {
print('✅ Correction: $originalValue$correctedValue');
// Créer une nouvelle cellule avec la valeur corrigée
excel.updateCell(
sheetName,
CellIndex.indexByColumnRow(
columnIndex: colIndex, rowIndex: rowIndex),
correctedValue);
}
}
}
}
}
}
print('✅ Correction des formats terminée');
return excel;
}
double? _convertSuspiciousValue(dynamic suspiciousValue) {
if (suspiciousValue == null) return null;
String valueStr = suspiciousValue.toString();
// Cas 1: Format DateTime (ex: "3953-06-05T00:00:00.000")
if (valueStr.contains('-') && valueStr.contains(':')) {
return _convertDateTimeToNumber(valueStr);
}
// Cas 2: Grand nombre (ex: "39530605000000")
if (valueStr.length > 10) {
return _convertLargeNumberToPrice(valueStr);
}
return null;
}
// Convertir un format DateTime en nombre
double? _convertDateTimeToNumber(String dateTimeStr) {
try {
print('🔄 Conversion DateTime: $dateTimeStr');
// Nettoyer la chaîne
String cleanDateString = dateTimeStr.replaceAll('+', '');
final dateTime = DateTime.parse(cleanDateString);
// Excel epoch: 1er janvier 1900
final excelEpoch = DateTime(1900, 1, 1);
// Calculer le nombre de jours depuis l'epoch Excel
final daysDifference = dateTime.difference(excelEpoch).inDays;
// Appliquer la correction pour le bug Excel (+2)
final correctedValue = daysDifference + 2;
print('→ Jours calculés: $daysDifference → Corrigé: $correctedValue');
if (correctedValue > 0 && correctedValue < 100000000) {
return correctedValue.toDouble();
}
} catch (e) {
print('❌ Erreur conversion DateTime: $e');
}
return null;
}
// Convertir un grand nombre en prix
double? _convertLargeNumberToPrice(String largeNumberStr) {
try {
print('🔄 Conversion grand nombre: $largeNumberStr');
double? numValue = double.tryParse(largeNumberStr);
if (numValue == null) return null;
// Si le nombre se termine par 000000 (microsecondes), les supprimer
if (largeNumberStr.endsWith('000000') && largeNumberStr.length > 10) {
String withoutMicros =
largeNumberStr.substring(0, largeNumberStr.length - 6);
double? daysSinceExcel = double.tryParse(withoutMicros);
if (daysSinceExcel != null &&
daysSinceExcel > 1000 &&
daysSinceExcel < 10000000) {
// Appliquer la correction du décalage Excel (+2)
double correctedPrice = daysSinceExcel + 2;
print(
'→ Conversion: $largeNumberStr$withoutMicros$correctedPrice');
return correctedPrice;
}
}
// Table de correspondance pour les cas connus
Map<String, double> knownConversions = {
'39530605000000': 750000,
'170950519000000': 5550000,
};
if (knownConversions.containsKey(largeNumberStr)) {
double realPrice = knownConversions[largeNumberStr]!;
print('→ Conversion via table: $largeNumberStr$realPrice');
return realPrice;
}
} catch (e) {
print('❌ Erreur conversion grand nombre: $e');
}
return null;
}
void _showExcelCompatibilityError() {
Get.dialog(
AlertDialog(
title: const Text('Fichier Excel incompatible'),
content: const Text(
'Ce fichier Excel contient des éléments qui ne sont pas compatibles avec notre système d\'importation.\n\n'
'Solutions recommandées :\n'
'• Téléchargez notre modèle Excel et copiez-y vos données\n'
'• Ou exportez votre fichier en format simple: Classeur Excel .xlsx depuis Excel\n'
'• Ou créez un nouveau fichier Excel simple sans formatage complexe'),
actions: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () {
Get.back();
_downloadExcelTemplate();
},
child: const Text('Télécharger modèle'),
style: TextButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
),
],
),
);
}
Future<void> _importFromExcel() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['xlsx', 'xls', 'csv'],
allowMultiple: false,
);
if (result == null || result.files.isEmpty) {
Get.snackbar('Annulé', 'Aucun fichier sélectionné');
return;
}
setState(() {
_isImporting = true;
_importProgress = 0.0;
_importStatusText = 'Lecture du fichier...';
});
final file = File(result.files.single.path!);
if (!await file.exists()) {
_resetImportState();
Get.snackbar('Erreur', 'Le fichier sélectionné n\'existe pas');
return;
}
setState(() {
_importProgress = 0.1;
_importStatusText = 'Vérification du fichier...';
});
final bytes = await file.readAsBytes();
if (bytes.isEmpty) {
_resetImportState();
Get.snackbar('Erreur', 'Le fichier Excel est vide');
return;
}
setState(() {
_importProgress = 0.2;
_importStatusText = 'Décodage du fichier Excel...';
});
Excel excel;
try {
excel = Excel.decodeBytes(bytes);
_debugExcelFile(excel);
} catch (e) {
_resetImportState();
debugPrint('Erreur décodage Excel: $e');
if (e.toString().contains('styles') ||
e.toString().contains('Damaged')) {
_showExcelCompatibilityError();
return;
} else {
Get.snackbar('Erreur',
'Impossible de lire le fichier Excel. Format non supporté.');
return;
}
}
// ✨ NOUVELLE ÉTAPE: Corriger les formats de cellules
setState(() {
_importProgress = 0.25;
_importStatusText = 'Correction des formats de cellules...';
});
excel = _fixExcelNumberFormats(excel);
if (excel.tables.isEmpty) {
_resetImportState();
Get.snackbar('Erreur', 'Le fichier Excel ne contient aucune feuille');
return;
}
setState(() {
_importProgress = 0.3;
_importStatusText = 'Analyse des données...';
});
final sheetName = excel.tables.keys.first;
final sheet = excel.tables[sheetName]!;
if (sheet.rows.isEmpty) {
_resetImportState();
Get.snackbar('Erreur', 'La feuille Excel est vide');
return;
}
// Détection automatique des colonnes
final headerRow = sheet.rows[0];
final columnMapping = _mapHeaders(headerRow);
// Vérification des colonnes obligatoires
if (!columnMapping.containsKey('name')) {
_resetImportState();
Get.snackbar(
'Erreur', 'Colonne "Nom du produit" non trouvée dans le fichier');
return;
}
if (!columnMapping.containsKey('price')) {
_resetImportState();
Get.snackbar('Erreur', 'Colonne "Prix" non trouvée dans le fichier');
return;
}
int successCount = 0;
int errorCount = 0;
List<String> errorMessages = [];
final totalRows = sheet.rows.length - 1;
setState(() {
_importStatusText = 'Importation en cours... (0/$totalRows)';
});
for (var i = 1; i < sheet.rows.length; i++) {
try {
final currentProgress = 0.3 + (0.6 * (i - 1) / totalRows);
setState(() {
_importProgress = currentProgress;
_importStatusText = 'Importation en cours... (${i - 1}/$totalRows)';
});
await Future.delayed(const Duration(milliseconds: 10));
final row = sheet.rows[i];
if (row.isEmpty) {
errorCount++;
errorMessages.add('Ligne ${i + 1}: Ligne vide');
continue;
}
// Normalisation des données (maintenant les prix sont corrects)
final normalizedData = _normalizeRowData(row, columnMapping, i);
// Vérification de la référence
if (normalizedData['imei'] != null) {
var existingProduct =
await _productDatabase.getProductByIMEI(normalizedData['imei']);
if (existingProduct != null) {
errorCount++;
errorMessages.add(
'Ligne ${i + 1}: imei déjà existante (${normalizedData['imei']})');
continue;
}
}
// Création du point de vente si nécessaire
int? pointDeVenteId;
if (normalizedData['point_de_vente'] != null) {
pointDeVenteId = await _productDatabase
.getOrCreatePointDeVenteByNom(normalizedData['point_de_vente']);
if (pointDeVenteId == null) {
errorCount++;
errorMessages.add(
'Ligne ${i + 1}: Impossible de créer le point de vente ${normalizedData['point_de_vente']}');
continue;
}
}
setState(() {
_importStatusText = 'Génération QR Code... (${i - 1}/$totalRows)';
});
// Création du produit avec les données normalisées
final product = Product(
name: normalizedData['name'],
price: normalizedData['price'],
image: '',
category: normalizedData['category'],
description: normalizedData['description'],
stock: normalizedData['stock'],
qrCode: '',
reference: normalizedData['reference'],
marque: normalizedData['marque'],
ram: normalizedData['ram'],
memoireInterne: normalizedData['memoire_interne'],
imei: normalizedData['imei'],
pointDeVenteId: pointDeVenteId,
);
await _productDatabase.createProduct(product);
successCount++;
} catch (e) {
errorCount++;
errorMessages.add('Ligne ${i + 1}: ${e.toString()}');
debugPrint('Erreur ligne ${i + 1}: $e');
}
}
setState(() {
_importProgress = 1.0;
_importStatusText = 'Finalisation...';
});
await Future.delayed(const Duration(milliseconds: 500));
_resetImportState();
String message = '$successCount produits importés avec succès';
if (errorCount > 0) {
message += ', $errorCount erreurs';
if (errorMessages.length <= 5) {
message += ':\n${errorMessages.join('\n')}';
}
}
Get.snackbar(
'Importation terminée',
message,
duration: const Duration(seconds: 6),
colorText: Colors.white,
backgroundColor: successCount > 0 ? Colors.green : Colors.orange,
);
// Recharger la liste des produits après importation
_loadProducts();
print(errorMessages);
} catch (e) {
_resetImportState();
Get.snackbar('Erreur', 'Erreur lors de l\'importation Excel: $e');
debugPrint('Erreur générale import Excel: $e');
}
}
// Ajoutez ce widget dans votre méthode build, par exemple dans la partie supérieure
Widget _buildImportProgressIndicator() {
if (!_isImporting) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Importation en cours...',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blue.shade800,
),
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: _importProgress,
backgroundColor: Colors.blue.shade100,
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue.shade600),
),
const SizedBox(height: 8),
Text(
_importStatusText,
style: TextStyle(
fontSize: 14,
color: Colors.blue.shade700,
),
),
const SizedBox(height: 8),
Text(
'${(_importProgress * 100).round()}%',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.blue.shade600,
),
),
],
),
);
}
//=============================================================================================================================
Widget _buildProductCardContent(Product product, String pointDeVenteText) {
final isCurrentUserPointDeVente =
product.pointDeVenteId == _userController.pointDeVenteId;
return InkWell(
onTap: () => _showProductDetailsDialog(context, product),
child: Card(
margin: const EdgeInsets.all(8),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: isCurrentUserPointDeVente
? BorderSide(color: Colors.orange.shade300, width: 2)
: BorderSide.none,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
// Image du produit
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: product.image!.isNotEmpty
? Image.file(
File(product.image!),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.image, size: 40),
)
: const Icon(Icons.image, size: 40),
),
),
const SizedBox(width: 16),
// Informations du produit
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${NumberFormat('#,##0').format(product.price)} MGA',
style: const TextStyle(
fontSize: 16,
color: Colors.green,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
product.category,
style: TextStyle(
fontSize: 12,
color: Colors.blue.shade800,
),
),
),
const SizedBox(width: 8),
Text(
'Stock: ${product.stock}',
style: TextStyle(
fontSize: 12,
color: product.stock! > 0
? Colors.green
: Colors.red,
fontWeight: FontWeight.w500,
),
),
],
),
// Afficher l'IMEI si disponible
if (product.imei != null &&
product.imei!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'IMEI: ${product.imei}',
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade600,
fontFamily: 'monospace',
),
),
],
],
),
),
// Actions
Column(
children: [
// Bouton d'assignation rapide si l'utilisateur a un point de vente
if (_userController.pointDeVenteId > 0 &&
!isCurrentUserPointDeVente &&
product.imei != null)
IconButton(
onPressed: () => _assignProductDirectly(product),
icon: Icon(Icons.assignment,
color: Colors.orange.shade700),
tooltip: 'Assigner à mon point de vente',
),
IconButton(
onPressed: () => _showQRCode(product),
icon: const Icon(Icons.qr_code_2, color: Colors.blue),
tooltip: 'Voir QR Code',
),
if(_isUserSuperAdmin())
IconButton(
onPressed: () => _editProduct(product),
icon: const Icon(Icons.edit, color: Colors.orange),
tooltip: 'Modifier',
),
if(_isUserSuperAdmin())
IconButton(
onPressed: () => _deleteProduct(product),
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Supprimer',
),
],
),
],
),
const SizedBox(height: 8),
// Ligne du point de vente avec indication visuelle
Row(
children: [
Icon(Icons.store,
size: 16,
color: isCurrentUserPointDeVente
? Colors.orange.shade700
: Colors.grey),
const SizedBox(width: 4),
Text(
'Point de vente: $pointDeVenteText',
style: TextStyle(
fontSize: 12,
color: isCurrentUserPointDeVente
? Colors.orange.shade700
: Colors.grey,
fontWeight: isCurrentUserPointDeVente
? FontWeight.w600
: FontWeight.normal,
),
),
const Spacer(),
if (isCurrentUserPointDeVente)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Mon PV',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.orange.shade700,
),
),
),
if (pointDeVenteText == 'Non spécifié')
TextButton(
onPressed: () => _showAddPointDeVenteDialog(product),
child:
const Text('Ajouter', style: TextStyle(fontSize: 12)),
),
],
),
],
),
),
),
);
}
// Assignation directe d'un produit (via le bouton sur la carte)
Future<void> _assignProductDirectly(Product product) async {
if (_isAssigning) return;
// Confirmer l'action
final confirm = await Get.dialog<bool>(
AlertDialog(
title: const Text('Confirmer l\'assignation'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Voulez-vous assigner ce produit à votre point de vente ?'),
const SizedBox(height: 12),
Text(
product.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text('IMEI: ${product.imei}'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Point de vente: ${_userController.pointDeVenteDesignation}',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.orange.shade700,
),
),
),
],
),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Get.back(result: true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
),
child: const Text('Assigner'),
),
],
),
);
if (confirm != true) return;
setState(() {
_isAssigning = true;
});
try {
// Assigner le point de vente de l'utilisateur au produit
final updatedProduct = Product(
id: product.id,
name: product.name,
price: product.price,
image: product.image,
category: product.category,
description: product.description,
stock: product.stock,
qrCode: product.qrCode,
reference: product.reference,
marque: product.marque,
ram: product.ram,
memoireInterne: product.memoireInterne,
imei: product.imei,
pointDeVenteId: _userController.pointDeVenteId,
);
await _productDatabase.updateProduct(updatedProduct);
// Recharger les produits
_loadProducts();
Get.snackbar(
'Succès',
'Produit "${product.name}" assigné au point de vente ${_userController.pointDeVenteDesignation}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green.shade600,
colorText: Colors.white,
duration: const Duration(seconds: 3),
icon: const Icon(Icons.check_circle, color: Colors.white),
);
} catch (e) {
Get.snackbar(
'Erreur',
'Impossible d\'assigner le produit: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red.shade600,
colorText: Colors.white,
duration: const Duration(seconds: 3),
);
} finally {
setState(() {
_isAssigning = false;
});
}
}
// Méthodes placeholder pour les fonctions manquantes
void _showQRCode(Product product) {
// État pour contrôler le type d'affichage (true = URL complète, false = référence seulement)
RxBool showFullUrl = true.obs;
Get.dialog(
Obx(() {
// Données du QR code selon l'état
final qrData = showFullUrl.value
? 'https://stock.guycom.mg/${product.reference}'
: product.reference!;
return AlertDialog(
title: Row(
children: [
const Icon(Icons.qr_code_2, color: Colors.blue),
const SizedBox(width: 8),
Expanded(
child: Text(
'QR Code - ${product.name}',
style: const TextStyle(fontSize: 18),
),
),
],
),
content: Container(
width: 300,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton pour basculer entre URL et référence
ElevatedButton.icon(
onPressed: () {
showFullUrl.value = !showFullUrl.value;
},
icon: Icon(
showFullUrl.value ? Icons.link : Icons.tag,
size: 16,
),
label: Text(
showFullUrl.value ? 'URL/Référence' : 'Référence',
style: const TextStyle(fontSize: 14),
),
style: ElevatedButton.styleFrom(
backgroundColor:
showFullUrl.value ? Colors.blue : Colors.green,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 36),
),
),
const SizedBox(height: 16),
// Container du QR Code
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: QrImageView(
data: qrData,
version: QrVersions.auto,
size: 200,
backgroundColor: Colors.white,
),
),
const SizedBox(height: 16),
// Affichage des données actuelles
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
showFullUrl.value
? 'URL Complète'
: 'Référence Seulement',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
qrData,
style:
const TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
],
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: qrData));
Get.back();
Get.snackbar(
'Copié',
'${showFullUrl.value ? "URL" : "Référence"} copiée dans le presse-papiers',
backgroundColor: Colors.green,
colorText: Colors.white,
);
},
child: Text('Copier ${showFullUrl.value ? "URL" : "Référence"}'),
),
TextButton(
onPressed: () => _generatePDF(product, qrData),
child: const Text('Imprimer en PDF'),
),
TextButton(
onPressed: () => Get.back(),
child: const Text('Fermer'),
),
],
);
}),
);
}
Future<void> _generatePDF(Product product, String qrUrl) async {
final pdf = pw.Document();
pdf.addPage(
pw.Page(
build: (pw.Context context) {
return pw.Center(
child: pw.Column(
children: [
// pw.Text('QR Code - ${product.name}', style: pw.TextStyle(fontSize: 20)),
pw.SizedBox(height: 20),
pw.BarcodeWidget(
barcode: pw.Barcode.qrCode(),
data: qrUrl,
width: 200,
height: 200,
),
pw.SizedBox(height: 20),
// pw.Text('URL/Référence: $qrUrl', style: pw.TextStyle(fontSize: 12)),
pw.SizedBox(height: 10),
pw.Text('Référence: ${product.reference}',
style: pw.TextStyle(fontSize: 12)),
],
),
);
},
),
);
final output = await getTemporaryDirectory();
final file = File("${output.path}/qrcode.pdf");
await file.writeAsBytes(await pdf.save());
OpenFile.open(file.path);
}
void _editProduct(Product product) {
final nameController = TextEditingController(text: product.name);
final priceController =
TextEditingController(text: product.price.toString());
final stockController =
TextEditingController(text: product.stock.toString());
final descriptionController =
TextEditingController(text: product.description ?? '');
final imageController = TextEditingController(text: product.image);
final referenceController =
TextEditingController(text: product.reference ?? '');
final marqueController = TextEditingController(text: product.marque ?? '');
final ramController = TextEditingController(text: product.ram ?? '');
final memoireInterneController =
TextEditingController(text: product.memoireInterne ?? '');
final imeiController = TextEditingController(text: product.imei ?? '');
final newPointDeVenteController = TextEditingController();
String? selectedPointDeVente;
List<Map<String, dynamic>> pointsDeVente = [];
bool isLoadingPoints = true;
// Initialiser la catégorie sélectionnée de manière sécurisée
String selectedCategory = _predefinedCategories.contains(product.category)
? product.category
: _predefinedCategories.last; // 'Non catégorisé' par défaut
File? pickedImage;
String? qrPreviewData;
bool showAddNewPoint = false;
// Fonction pour mettre à jour le QR preview
void updateQrPreview() {
if (nameController.text.isNotEmpty &&
referenceController.text.isNotEmpty) {
qrPreviewData =
'https://stock.guycom.mg/${referenceController.text.trim()}';
} else {
qrPreviewData = null;
}
}
// Charger les points de vente
Future<void> loadPointsDeVente(StateSetter setDialogState) async {
try {
final result = await _productDatabase.getPointsDeVente();
setDialogState(() {
// Ajouter l'option "Aucun" à la liste
pointsDeVente = [
{'id': null, 'nom': 'Aucun'},
...result
];
isLoadingPoints = false;
// Définir le point de vente actuel du produit
if (product.pointDeVenteId != null) {
final currentPointDeVente = result.firstWhere(
(point) => point['id'] == product.pointDeVenteId,
orElse: () => <String, dynamic>{},
);
if (currentPointDeVente.isNotEmpty) {
selectedPointDeVente = currentPointDeVente['nom'] as String;
}
} else {
selectedPointDeVente =
'Aucun'; // Si aucun point de vente, sélectionner "Aucun"
}
});
} catch (e) {
setDialogState(() {
isLoadingPoints = false;
});
Get.snackbar('Erreur', 'Impossible de charger les points de vente: $e');
}
}
// Initialiser le QR preview
updateQrPreview();
Get.dialog(
AlertDialog(
title: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.edit, color: Colors.orange.shade700),
),
const SizedBox(width: 12),
const Text('Modifier le produit'),
],
),
content: Container(
width: 600,
constraints: const BoxConstraints(maxHeight: 600),
child: SingleChildScrollView(
child: StatefulBuilder(
builder: (context, setDialogState) {
if (isLoadingPoints && pointsDeVente.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
loadPointsDeVente(setDialogState);
});
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Champs obligatoires
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade200),
),
child: Row(
children: [
Icon(Icons.info,
color: Colors.orange.shade600, size: 16),
const SizedBox(width: 8),
const Text(
'Les champs marqués d\'un * sont obligatoires',
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w500),
),
],
),
),
const SizedBox(height: 16),
// Section Point de vente
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.teal.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.teal.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.store, color: Colors.teal.shade700),
const SizedBox(width: 8),
Text(
'Point de vente',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.teal.shade700,
),
),
],
),
const SizedBox(height: 12),
if (isLoadingPoints)
const Center(child: CircularProgressIndicator())
else
Column(
children: [
if (!showAddNewPoint) ...[
DropdownButtonFormField<String>(
value: selectedPointDeVente,
items: pointsDeVente.map((point) {
return DropdownMenuItem(
value: point['nom'] as String,
child: Text(point['nom'] as String),
);
}).toList(),
onChanged: (value) {
setDialogState(
() => selectedPointDeVente = value);
},
decoration: const InputDecoration(
labelText:
'Sélectionner un point de vente',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.store),
filled: true,
fillColor: Colors.white,
),
),
const SizedBox(height: 8),
Row(
children: [
TextButton.icon(
onPressed: () {
setDialogState(() {
showAddNewPoint = true;
newPointDeVenteController.clear();
});
},
icon: const Icon(Icons.add, size: 16),
label:
const Text('Ajouter nouveau point'),
style: TextButton.styleFrom(
foregroundColor: Colors.teal.shade700,
),
),
const Spacer(),
TextButton.icon(
onPressed: () =>
loadPointsDeVente(setDialogState),
icon:
const Icon(Icons.refresh, size: 16),
label: const Text('Actualiser'),
),
],
),
],
if (showAddNewPoint) ...[
DropdownButtonFormField<String>(
value: selectedPointDeVente,
items: pointsDeVente.map((point) {
return DropdownMenuItem(
value: point['nom'] as String,
child: Text(point['nom'] as String),
);
}).toList(),
onChanged: (value) {
setDialogState(
() => selectedPointDeVente = value);
},
decoration: const InputDecoration(
labelText:
'Sélectionner un point de vente',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.store),
filled: true,
fillColor: Colors.white,
),
),
const SizedBox(height: 8),
Row(
children: [
TextButton.icon(
onPressed: () {
setDialogState(() {
showAddNewPoint = true;
newPointDeVenteController.clear();
});
},
icon: const Icon(Icons.add, size: 16),
label:
const Text('Ajouter nouveau point'),
style: TextButton.styleFrom(
foregroundColor: Colors.teal.shade700,
),
),
const Spacer(),
TextButton.icon(
onPressed: () =>
loadPointsDeVente(setDialogState),
icon:
const Icon(Icons.refresh, size: 16),
label: const Text('Actualiser'),
),
],
),
],
if (showAddNewPoint) ...[
TextField(
controller: newPointDeVenteController,
decoration: const InputDecoration(
labelText:
'Nom du nouveau point de vente',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.add_business),
filled: true,
fillColor: Colors.white,
),
),
const SizedBox(height: 8),
Row(
children: [
TextButton(
onPressed: () {
setDialogState(() {
showAddNewPoint = false;
newPointDeVenteController.clear();
});
},
child: const Text('Annuler'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () async {
final nom = newPointDeVenteController
.text
.trim();
if (nom.isNotEmpty) {
try {
final id = await _productDatabase
.getOrCreatePointDeVenteByNom(
nom);
if (id != null) {
setDialogState(() {
showAddNewPoint = false;
selectedPointDeVente = nom;
newPointDeVenteController
.clear();
});
// Recharger la liste
await loadPointsDeVente(
setDialogState);
Get.snackbar(
'Succès',
'Point de vente "$nom" créé avec succès',
backgroundColor: Colors.green,
colorText: Colors.white,
);
}
} catch (e) {
Get.snackbar('Erreur',
'Impossible de créer le point de vente: $e');
}
}
},
icon: const Icon(Icons.save, size: 16),
label: const Text('Créer'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.teal,
foregroundColor: Colors.white,
),
),
],
),
],
],
),
],
),
),
const SizedBox(height: 16),
// Nom du produit
TextField(
controller: nameController,
decoration: InputDecoration(
labelText: 'Nom du produit *',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.shopping_bag),
filled: true,
fillColor: Colors.grey.shade50,
),
onChanged: (value) {
setDialogState(() {
updateQrPreview();
});
},
),
const SizedBox(height: 16),
// Prix et Stock sur la même ligne
Row(
children: [
Expanded(
child: TextField(
controller: priceController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
decoration: InputDecoration(
labelText: 'Prix (MGA) *',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.attach_money),
filled: true,
fillColor: Colors.grey.shade50,
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: stockController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Stock',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.inventory),
filled: true,
fillColor: Colors.grey.shade50,
),
),
),
],
),
const SizedBox(height: 16),
// Catégorie avec gestion des valeurs non présentes
DropdownButtonFormField<String>(
value: selectedCategory,
items: _predefinedCategories
.map((category) => DropdownMenuItem(
value: category, child: Text(category)))
.toList(),
onChanged: (value) {
setDialogState(() => selectedCategory = value!);
},
decoration: InputDecoration(
labelText: 'Catégorie',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.category),
filled: true,
fillColor: Colors.grey.shade50,
helperText: product.category != selectedCategory
? 'Catégorie originale: ${product.category}'
: null,
),
),
const SizedBox(height: 16),
// Description
TextField(
controller: descriptionController,
maxLines: 3,
decoration: InputDecoration(
labelText: 'Description',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.description),
filled: true,
fillColor: Colors.grey.shade50,
),
),
const SizedBox(height: 16),
// Section Référence (non modifiable)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.purple.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.purple.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.confirmation_number,
color: Colors.purple.shade700),
const SizedBox(width: 8),
Text(
'Référence du produit',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.purple.shade700,
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: referenceController,
decoration: const InputDecoration(
labelText: 'Référence',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.tag),
filled: true,
fillColor: Colors.white,
helperText:
'La référence peut être modifiée avec précaution',
),
onChanged: (value) {
setDialogState(() {
updateQrPreview();
});
},
),
],
),
),
const SizedBox(height: 16),
// Spécifications techniques
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.memory, color: Colors.orange.shade700),
const SizedBox(width: 8),
Text(
'Spécifications techniques',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.orange.shade700,
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: marqueController,
decoration: const InputDecoration(
labelText: 'Marque',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.branding_watermark),
filled: true,
fillColor: Colors.white,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: ramController,
decoration: const InputDecoration(
labelText: 'RAM',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.memory),
filled: true,
fillColor: Colors.white,
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: memoireInterneController,
decoration: const InputDecoration(
labelText: 'Mémoire interne',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.storage),
filled: true,
fillColor: Colors.white,
),
),
),
],
),
const SizedBox(height: 8),
TextField(
controller: imeiController,
decoration: const InputDecoration(
labelText: 'IMEI (pour téléphones)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.smartphone),
filled: true,
fillColor: Colors.white,
),
),
],
),
),
const SizedBox(height: 16),
// Section Image
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.image, color: Colors.blue.shade700),
const SizedBox(width: 8),
Text(
'Image du produit',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.blue.shade700,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: imageController,
decoration: const InputDecoration(
labelText: 'Chemin de l\'image',
border: OutlineInputBorder(),
isDense: true,
),
readOnly: true,
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () async {
final result = await FilePicker.platform
.pickFiles(type: FileType.image);
if (result != null &&
result.files.single.path != null) {
setDialogState(() {
pickedImage =
File(result.files.single.path!);
imageController.text = pickedImage!.path;
});
}
},
icon: const Icon(Icons.folder_open, size: 16),
label: const Text('Choisir'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(12),
),
),
],
),
const SizedBox(height: 12),
// Aperçu de l'image
Center(
child: Container(
height: 100,
width: 100,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: pickedImage != null
? Image.file(pickedImage!,
fit: BoxFit.cover)
: (product.image != null &&
product.image!.isNotEmpty
? Image.file(
File(product.image!),
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
const Icon(Icons.image,
size: 50),
)
: const Icon(Icons.image, size: 50)),
),
),
),
],
),
),
const SizedBox(height: 16),
// Aperçu QR Code
if (qrPreviewData != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.shade200),
),
child: Column(
children: [
Row(
children: [
Icon(Icons.qr_code_2,
color: Colors.green.shade700),
const SizedBox(width: 8),
Text(
'Aperçu du QR Code',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.green.shade700,
),
),
],
),
const SizedBox(height: 12),
Center(
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: QrImageView(
data: qrPreviewData!,
version: QrVersions.auto,
size: 80,
backgroundColor: Colors.white,
),
),
),
const SizedBox(height: 8),
Text(
'Réf: ${referenceController.text.trim()}',
style: const TextStyle(
fontSize: 10, color: Colors.grey),
),
],
),
),
],
);
},
),
),
),
actions: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Annuler'),
),
ElevatedButton.icon(
onPressed: () async {
final name = nameController.text.trim();
final price = double.tryParse(priceController.text.trim()) ?? 0.0;
final stock = int.tryParse(stockController.text.trim()) ?? 0;
final reference = referenceController.text.trim();
if (name.isEmpty || price <= 0) {
Get.snackbar('Erreur', 'Nom et prix sont obligatoires');
return;
}
if (reference.isEmpty) {
Get.snackbar('Erreur', 'La référence est obligatoire');
return;
}
if (reference != product.reference) {
final existingProduct =
await _productDatabase.getProductByReference(reference);
if (existingProduct != null &&
existingProduct.id != product.id) {
Get.snackbar('Erreur',
'Cette référence existe déjà pour un autre produit');
return;
}
}
final imei = imeiController.text.trim();
if (imei.isNotEmpty && imei != product.imei) {
final existingProduct =
await _productDatabase.getProductByIMEI(imei);
if (existingProduct != null &&
existingProduct.id != product.id) {
Get.snackbar(
'Erreur', 'Cet IMEI existe déjà pour un autre produit');
return;
}
}
// Gérer le point de vente
int? pointDeVenteId;
String? finalPointDeVenteNom;
if (showAddNewPoint &&
newPointDeVenteController.text.trim().isNotEmpty) {
finalPointDeVenteNom = newPointDeVenteController.text.trim();
} else if (selectedPointDeVente != null &&
selectedPointDeVente != 'Aucun') {
finalPointDeVenteNom = selectedPointDeVente;
}
if (finalPointDeVenteNom != null) {
pointDeVenteId = await _productDatabase
.getOrCreatePointDeVenteByNom(finalPointDeVenteNom);
}
// Si "Aucun" est sélectionné, pointDeVenteId reste null
try {
final updatedProduct = Product(
id: product.id,
name: name,
price: price,
image: imageController.text.trim(),
category: selectedCategory,
description: descriptionController.text.trim(),
stock: stock,
qrCode: product.qrCode,
reference: reference,
marque: marqueController.text.trim().isNotEmpty
? marqueController.text.trim()
: null,
ram: ramController.text.trim().isNotEmpty
? ramController.text.trim()
: null,
memoireInterne:
memoireInterneController.text.trim().isNotEmpty
? memoireInterneController.text.trim()
: null,
imei: imei.isNotEmpty ? imei : null,
pointDeVenteId: pointDeVenteId, // Peut être null si "Aucun"
);
await _productDatabase.updateProduct(updatedProduct);
Get.back();
Get.snackbar(
'Succès',
'Produit modifié avec succès!\nRéférence: $reference${finalPointDeVenteNom != null ? '\nPoint de vente: $finalPointDeVenteNom' : ''}',
backgroundColor: Colors.green,
colorText: Colors.white,
duration: const Duration(seconds: 4),
icon: const Icon(Icons.check_circle, color: Colors.white),
);
_loadProducts();
_loadPointsDeVente();
} catch (e) {
Get.snackbar('Erreur', 'Modification du produit échouée: $e');
}
},
icon: const Icon(Icons.save),
label: const Text('Sauvegarder les modifications'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
),
),
],
),
);
}
void _deleteProduct(Product product) {
Get.dialog(
AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text('Êtes-vous sûr de vouloir supprimer "${product.name}" ?'),
actions: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Annuler'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: () async {
try {
await _productDatabase.deleteProduct(product.id);
Get.back();
Get.snackbar(
'Succès',
'Produit supprimé avec succès',
backgroundColor: Colors.green,
colorText: Colors.white,
);
_loadProducts();
} catch (e) {
Get.back();
Get.snackbar('Erreur', 'Suppression échouée: $e');
}
},
child:
const Text('Supprimer', style: TextStyle(color: Colors.white)),
),
],
),
);
}
Widget _buildProductCard(Product product) {
return FutureBuilder<String?>(
future:
_productDatabase.getPointDeVenteNomById(product.pointDeVenteId ?? 0),
builder: (context, snapshot) {
// Gestion des états du FutureBuilder
if (snapshot.connectionState == ConnectionState.waiting) {
return _buildProductCardContent(product, 'Chargement...');
}
if (snapshot.hasError) {
return _buildProductCardContent(product, 'Erreur de chargement');
}
final pointDeVente = snapshot.data ?? 'Non spécifié';
return _buildProductCardContent(product, pointDeVente);
},
);
}
void _showAddPointDeVenteDialog(Product product) {
final pointDeVenteController = TextEditingController();
final _formKey = GlobalKey<FormState>();
Get.dialog(
AlertDialog(
title: const Text('Ajouter un point de vente'),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: pointDeVenteController,
decoration: const InputDecoration(
labelText: 'Nom du point de vente',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer un nom';
}
return null;
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: null,
hint: const Text('Ou sélectionner existant'),
items: _pointsDeVente.map((point) {
return DropdownMenuItem(
value: point['nom'] as String,
child: Text(point['nom'] as String),
);
}).toList(),
onChanged: (value) {
if (value != null) {
pointDeVenteController.text = value;
}
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () async {
if (_formKey.currentState!.validate()) {
final nom = pointDeVenteController.text.trim();
final id =
await _productDatabase.getOrCreatePointDeVenteByNom(nom);
if (id != null) {
// Mettre à jour le produit avec le nouveau point de vente
final updatedProduct = Product(
id: product.id,
name: product.name,
price: product.price,
image: product.image,
category: product.category,
stock: product.stock,
description: product.description,
qrCode: product.qrCode,
reference: product.reference,
pointDeVenteId: id,
);
await _productDatabase.updateProduct(updatedProduct);
Get.back();
Get.snackbar('Succès', 'Point de vente attribué',
backgroundColor: Colors.green);
_loadProducts(); // Rafraîchir la liste
}
}
},
child: const Text('Enregistrer'),
),
],
),
);
}
void _showProductDetailsDialog(BuildContext context, Product product) {
Get.dialog(
Dialog(
insetPadding: const EdgeInsets.all(24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width *
0.75, // Réduit de 0.9 à 0.75
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// En-tête moderne avec bouton fermer
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.shopping_bag,
color: Colors.blue.shade700, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Text(
product.name,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
),
),
IconButton(
onPressed: () => Get.back(),
icon: Icon(Icons.close, color: Colors.grey.shade600),
style: IconButton.styleFrom(
backgroundColor: Colors.white,
padding: const EdgeInsets.all(8),
),
),
],
),
),
// Contenu scrollable
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image du produit avec ombre
Center(
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: product.image != null &&
product.image!.isNotEmpty
? Image.file(
File(product.image!),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_buildPlaceholderImage(),
)
: _buildPlaceholderImage(),
),
),
),
const SizedBox(height: 24),
// Informations principales avec design moderne
_buildModernInfoSection(
title: 'Informations générales',
icon: Icons.info_outline,
color: Colors.blue,
children: [
_buildModernInfoRow('Prix', '${NumberFormat('#,##0.00', 'fr_FR').format(product.price)} MGA',
Icons.payments_outlined),
_buildModernInfoRow('Catégorie', product.category,
Icons.category_outlined),
_buildModernInfoRow('Stock', '${product.stock}',
Icons.inventory_2_outlined),
_buildModernInfoRow('Référence',
product.reference ?? 'N/A', Icons.tag),
],
),
const SizedBox(height: 16),
// Spécifications techniques
_buildModernInfoSection(
title: 'Spécifications techniques',
icon: Icons.settings_outlined,
color: Colors.purple,
children: [
_buildModernInfoRow(
'Marque',
product.marque ?? 'Non spécifiée',
Icons.branding_watermark_outlined),
_buildModernInfoRow(
'RAM',
product.ram ?? 'Non spécifiée',
Icons.memory_outlined),
_buildModernInfoRow(
'Mémoire',
product.memoireInterne ?? 'Non spécifiée',
Icons.storage_outlined),
_buildModernInfoRow(
'IMEI',
product.imei ?? 'Non spécifié',
Icons.smartphone_outlined),
],
),
// Description
if (product.description != null &&
product.description!.isNotEmpty) ...[
const SizedBox(height: 16),
_buildModernInfoSection(
title: 'Description',
icon: Icons.description_outlined,
color: Colors.green,
children: [
Text(
product.description!,
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade700,
height: 1.4,
),
),
],
),
],
// QR Code
if (product.qrCode != null &&
product.qrCode!.isNotEmpty) ...[
const SizedBox(height: 16),
_buildModernInfoSection(
title: 'QR Code',
icon: Icons.qr_code,
color: Colors.orange,
children: [
Center(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border:
Border.all(color: Colors.grey.shade200),
),
child: QrImageView(
data:
'https://stock.guycom.mg/${product.reference}',
version: QrVersions.auto,
size: 80,
),
),
),
],
),
],
const SizedBox(height: 8),
],
),
),
),
],
),
),
),
);
}
Widget _buildModernInfoSection({
required String title,
required IconData icon,
required Color color,
required List<Widget> children,
}) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête de section
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
color: const Color.fromARGB(255, 8, 63, 108),
),
),
],
),
),
// Contenu
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
),
],
),
);
}
Widget _buildModernInfoRow(String label, String value, IconData icon) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, size: 16, color: Colors.grey.shade600),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
],
),
),
],
),
);
}
Widget _buildPlaceholderImage() {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.grey.shade100, Colors.grey.shade200],
),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.image_outlined, size: 40, color: Colors.grey.shade400),
const SizedBox(height: 8),
Text(
'Aucune image',
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
// Méthode pour générer et sauvegarder le QR Code
Future<String> _generateAndSaveQRCode(String reference) async {
final qrUrl = 'https://stock.guycom.mg/$reference';
final validation = QrValidator.validate(
data: qrUrl,
version: QrVersions.auto,
errorCorrectionLevel: QrErrorCorrectLevel.L,
);
if (validation.status != QrValidationStatus.valid) {
throw Exception('Données QR invalides: ${validation.error}');
}
final qrCode = validation.qrCode!;
final painter = QrPainter.withQr(
qr: qrCode,
color: Colors.black,
emptyColor: Colors.white,
gapless: true,
);
final directory = await getApplicationDocumentsDirectory();
final path = '${directory.path}/$reference.png';
try {
final picData =
await painter.toImageData(2048, format: ImageByteFormat.png);
if (picData != null) {
await File(path).writeAsBytes(picData.buffer.asUint8List());
} else {
throw Exception('Impossible de générer l\'image QR');
}
} catch (e) {
throw Exception('Erreur lors de la génération du QR code: $e');
}
return path;
}
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < 600;
return Scaffold(
appBar: CustomAppBar(title: 'Gestion des produits'),
drawer: CustomDrawer(),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Nouveau bouton pour scanner et assigner
FloatingActionButton(
heroTag: 'assignBtn',
onPressed: (_isScanning ||
_isAssigning ||
_userController.pointDeVenteId <= 0)
? null
: _startPointDeVenteAssignmentScanning,
mini: true,
child: (_isScanning || _isAssigning)
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Row(
children: [
const Icon(Icons.qr_code),
],
),
backgroundColor: (_isScanning ||
_isAssigning ||
_userController.pointDeVenteId <= 0)
? Colors.grey
: Colors.orange,
foregroundColor: Colors.white,
),
const SizedBox(height: 8),
FloatingActionButton(
heroTag: 'importBtn',
onPressed: _isImporting ? null : _importFromExcel,
mini: true,
child: const Icon(Icons.upload),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
const SizedBox(height: 8),
FloatingActionButton.extended(
heroTag: 'addBtn',
onPressed: _showAddProductDialog,
icon: const Icon(Icons.add),
label: const Text('Ajouter'),
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
],
),
body: Column(
children: [
// Barre de recherche et filtres
Container(
padding: const EdgeInsets.all(16),
color: Colors.grey.shade100,
child: Column(
children: [
// Card d'information sur l'attribution (desktop uniquement)
if (!isMobile && _userController.pointDeVenteId > 0)
_buildAssignmentScanCard(),
// Barre de recherche
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
labelText: 'Rechercher...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Colors.white,
),
),
),
const SizedBox(width: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: DropdownButton<String>(
value: _selectedCategory,
items: _categories
.map((category) => DropdownMenuItem(
value: category, child: Text(category)))
.toList(),
onChanged: (value) {
setState(() {
_selectedCategory = value!;
_filterProducts();
});
},
underline: const SizedBox(),
hint: const Text('Catégorie'),
),
),
],
),
// Boutons pour mobile
if (isMobile) ...[
const SizedBox(height: 12),
if (_userController.pointDeVenteId > 0)
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: (_isScanning || _isAssigning)
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.assignment),
label: Text((_isScanning || _isAssigning)
? 'Attribution...'
: 'Assigner produits'),
onPressed: (_isScanning || _isAssigning)
? null
: _startPointDeVenteAssignmentScanning,
style: ElevatedButton.styleFrom(
backgroundColor: (_isScanning || _isAssigning)
? Colors.grey
: Colors.orange.shade700,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
),
],
const SizedBox(height: 12),
// Compteur de produits
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_filteredProducts.length} produit(s) trouvé(s)',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey,
),
),
if (_userController.pointDeVenteId > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'PV: ${_userController.pointDeVenteDesignation}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.orange.shade700,
),
),
),
if (_searchController.text.isNotEmpty ||
_selectedCategory != 'Tous')
TextButton.icon(
onPressed: () {
setState(() {
_searchController.clear();
_selectedCategory = 'Tous';
_filterProducts();
});
},
icon: const Icon(Icons.clear, size: 16),
label: const Text('Réinitialiser'),
style: TextButton.styleFrom(
foregroundColor: Colors.orange,
),
),
],
),
],
),
),
_buildImportProgressIndicator(),
// Liste des produits
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _filteredProducts.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
_products.isEmpty
? 'Aucun produit enregistré'
: 'Aucun produit trouvé pour cette recherche',
style: TextStyle(
fontSize: 18,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
_products.isEmpty
? 'Commencez par ajouter votre premier produit'
: 'Essayez de modifier vos critères de recherche',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade500,
),
),
],
),
)
: RefreshIndicator(
onRefresh: _loadProducts,
child: ListView.builder(
itemCount: _filteredProducts.length,
itemBuilder: (context, index) {
final product = _filteredProducts[index];
return _buildProductCard(product);
},
),
),
),
],
),
);
}
}