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.
 
 
 
 
 
 

1541 lines
55 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:qr_flutter/qr_flutter.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import 'package:excel/excel.dart' hide Border;
import '../Components/appDrawer.dart';
import '../Components/app_bar.dart';
import '../Models/produit.dart';
import '../Services/productDatabase.dart';
class ProductManagementPage extends StatefulWidget {
const ProductManagementPage({super.key});
@override
_ProductManagementPageState createState() => _ProductManagementPageState();
}
class _ProductManagementPageState extends State<ProductManagementPage> {
final ProductDatabase _productDatabase = ProductDatabase.instance;
List<Product> _products = [];
List<Product> _filteredProducts = [];
final TextEditingController _searchController = TextEditingController();
String _selectedCategory = 'Tous';
List<String> _categories = ['Tous'];
bool _isLoading = true;
// Catégories prédéfinies pour l'ajout de produits
final List<String> _predefinedCategories = [
'Sucré', 'Salé', 'Jus', 'Gateaux', 'Snacks', 'Boissons', 'Non catégorisé'
];
@override
void initState() {
super.initState();
_loadProducts();
_searchController.addListener(_filterProducts);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
//======================================================================================================
// Ajoutez ces variables à la classe _ProductManagementPageState
bool _isImporting = false;
double _importProgress = 0.0;
String _importStatusText = '';
// Ajoutez ces méthodes à la classe _ProductManagementPageState
void _resetImportState() {
setState(() {
_isImporting = false;
_importProgress = 0.0;
_importStatusText = '';
});
}
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> _downloadExcelTemplate() async {
try {
// Créez un nouveau fichier Excel
final excel = Excel.createExcel();
// Supprimez la feuille par défaut si elle existe
excel.delete('Sheet1');
// Créez une nouvelle feuille nommée "Produits"
final sheet = excel['Produits'];
// Ajoutez les en-têtes
final headers = ['Nom', 'Prix', 'Catégorie', 'Description', 'Stock'];
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',
);
}
// Ajoutez des exemples de données
final examples = [
['Croissant', '1.50', 'Sucré', 'Délicieux croissant beurré', '20'],
['Sandwich jambon', '4.00', 'Salé', 'Sandwich fait maison', '15'],
['Jus d\'orange', '2.50', 'Jus', 'Jus d\'orange frais', '30'],
['Gâteau chocolat', '18.00', 'Gateaux', 'Gâteau au chocolat portion 8 personnes', '5'],
];
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];
}
}
// Définissez la largeur des colonnes
sheet.setColWidth(0, 20);
sheet.setColWidth(1, 10);
sheet.setColWidth(2, 15);
sheet.setColWidth(3, 30);
sheet.setColWidth(4, 10);
// Sauvegardez le fichier Excel
final bytes = excel.save();
if (bytes == null) {
Get.snackbar('Erreur', 'Impossible de créer le fichier modèle');
return;
}
// Demandez à l'utilisateur où sauvegarder le fichier
final String? outputFile = await FilePicker.platform.saveFile(
fileName: 'modele_import_produits.xlsx',
allowedExtensions: ['xlsx'],
type: FileType.custom,
);
if (outputFile != null) {
try {
// Écrivez les données dans le fichier
await File(outputFile).writeAsBytes(bytes);
Get.snackbar(
'Succès',
'Modèle téléchargé avec succès\n$outputFile',
duration: const Duration(seconds: 4),
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');
}
}
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 {
setState(() {
_isImporting = true;
_importProgress = 0.0;
_importStatusText = 'Initialisation...';
});
await Future.delayed(Duration(milliseconds: 50));
excel = Excel.decodeBytes(bytes);
} 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;
}
}
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...';
});
int successCount = 0;
int errorCount = 0;
List<String> errorMessages = [];
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;
}
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 || row.length < 2) {
errorCount++;
errorMessages.add('Ligne ${i + 1}: Données insuffisantes');
continue;
}
String? nameValue;
String? priceValue;
if (row[0]?.value != null) {
nameValue = row[0]!.value.toString().trim();
}
if (row[1]?.value != null) {
priceValue = row[1]!.value.toString().trim();
}
if (nameValue == null || nameValue.isEmpty) {
errorCount++;
errorMessages.add('Ligne ${i + 1}: Nom du produit manquant');
continue;
}
if (priceValue == null || priceValue.isEmpty) {
errorCount++;
errorMessages.add('Ligne ${i + 1}: Prix manquant');
continue;
}
final name = nameValue;
final price = double.tryParse(priceValue.replaceAll(',', '.'));
if (price == null || price <= 0) {
errorCount++;
errorMessages.add('Ligne ${i + 1}: Prix invalide ($priceValue)');
continue;
}
String category = 'Non catégorisé';
if (row.length > 2 && row[2]?.value != null) {
final categoryValue = row[2]!.value.toString().trim();
if (categoryValue.isNotEmpty) {
category = categoryValue;
}
}
String description = '';
if (row.length > 3 && row[3]?.value != null) {
description = row[3]!.value.toString().trim();
}
int stock = 0;
if (row.length > 4 && row[4]?.value != null) {
final stockStr = row[4]!.value.toString().trim();
stock = int.tryParse(stockStr) ?? 0;
}
String reference = _generateUniqueReference();
var existingProduct = await _productDatabase.getProductByReference(reference);
while (existingProduct != null) {
reference = _generateUniqueReference();
existingProduct = await _productDatabase.getProductByReference(reference);
}
final product = Product(
name: name,
price: price,
image: '',
category: category,
description: description,
stock: stock,
qrCode: '',
reference: reference,
);
setState(() {
_importStatusText = 'Génération QR Code... (${i - 1}/$totalRows)';
});
final qrPath = await _generateAndSaveQRCode(reference);
product.qrCode = qrPath;
await _productDatabase.createProduct(product);
successCount++;
} catch (e) {
errorCount++;
errorMessages.add('Ligne ${i + 1}: Erreur de traitement - $e');
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();
} 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,
),
),
],
),
);
}
//=============================================================================================================================
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}';
}
// 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;
}
void _showAddProductDialog() {
final nameController = TextEditingController();
final priceController = TextEditingController();
final stockController = TextEditingController();
final descriptionController = TextEditingController();
final imageController = TextEditingController();
String selectedCategory = _predefinedCategories.last; // 'Non catégorisé' par défaut
File? pickedImage;
String? qrPreviewData;
String? currentReference;
// Fonction pour mettre à jour le QR preview
void updateQrPreview() {
if (nameController.text.isNotEmpty) {
if (currentReference == null) {
currentReference = _generateUniqueReference();
}
qrPreviewData = 'https://stock.guycom.mg/$currentReference';
} else {
currentReference = null;
qrPreviewData = null;
}
}
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) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Champs obligatoires
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.info, color: Colors.red.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),
// 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 (FCFA) *',
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 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: $currentReference',
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;
}
try {
// Générer une référence unique et vérifier son unicité
String finalReference = currentReference ?? _generateUniqueReference();
var existingProduct = await _productDatabase.getProductByReference(finalReference);
while (existingProduct != null) {
finalReference = _generateUniqueReference();
existingProduct = await _productDatabase.getProductByReference(finalReference);
}
// Générer le QR code
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,
);
await _productDatabase.createProduct(product);
Get.back();
Get.snackbar(
'Succès',
'Produit ajouté avec succès!\nRéférence: $finalReference',
backgroundColor: Colors.green,
colorText: Colors.white,
duration: const Duration(seconds: 4),
icon: const Icon(Icons.check_circle, color: Colors.white),
);
_loadProducts();
} 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),
),
),
],
),
);
}
void _showQRCode(Product product) {
final qrUrl = 'https://stock.guycom.mg/${product.reference}';
Get.dialog(
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: [
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: qrUrl,
version: QrVersions.auto,
size: 200,
backgroundColor: Colors.white,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
'Référence: ${product.reference}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
qrUrl,
style: const TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
],
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: qrUrl));
Get.back();
Get.snackbar(
'Copié',
'URL copiée dans le presse-papiers',
backgroundColor: Colors.green,
colorText: Colors.white,
);
},
child: const Text('Copier URL'),
),
TextButton(
onPressed: () => Get.back(),
child: const Text('Fermer'),
),
],
),
);
}
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);
String selectedCategory = product.category;
File? pickedImage;
Get.dialog(
AlertDialog(
title: const Text('Modifier le produit'),
content: Container(
width: 500,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Nom du produit*',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: priceController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Prix*',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: stockController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Stock',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
StatefulBuilder(
builder: (context, setDialogState) {
return Column(
children: [
Row(
children: [
Expanded(
child: TextField(
controller: imageController,
decoration: const InputDecoration(
labelText: 'Image',
border: OutlineInputBorder(),
),
readOnly: true,
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () async {
final result = await FilePicker.platform.pickFiles(type: FileType.image);
if (result != null && result.files.single.path != null) {
if (context.mounted) {
setDialogState(() {
pickedImage = File(result.files.single.path!);
imageController.text = pickedImage!.path;
});
}
}
},
child: const Text('Choisir'),
),
],
),
const SizedBox(height: 16),
if (pickedImage != null || product.image!.isNotEmpty)
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!.isNotEmpty
? Image.file(File(product.image!), fit: BoxFit.cover)
: const Icon(Icons.image, size: 50)),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: selectedCategory,
items: _categories.skip(1).map((category) =>
DropdownMenuItem(value: category, child: Text(category))).toList(),
onChanged: (value) {
if (context.mounted) {
setDialogState(() => selectedCategory = value!);
}
},
decoration: const InputDecoration(
labelText: 'Catégorie',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: descriptionController,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
),
],
);
},
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Annuler'),
),
ElevatedButton(
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;
}
final updatedProduct = Product(
id: product.id,
name: name,
price: price,
image: imageController.text,
category: selectedCategory,
description: descriptionController.text.trim(),
stock: stock,
qrCode: product.qrCode,
reference: product.reference,
);
try {
await _productDatabase.updateProduct(updatedProduct);
Get.back();
Get.snackbar(
'Succès',
'Produit modifié avec succès',
backgroundColor: Colors.green,
colorText: Colors.white,
);
_loadProducts();
} catch (e) {
Get.snackbar('Erreur', 'Modification échouée: $e');
}
},
child: const Text('Sauvegarder'),
),
],
),
);
}
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 Card(
margin: const EdgeInsets.all(8),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16),
child: 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)} FCFA',
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,
),
),
],
),
if (product.description!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
product.description!,
style: const TextStyle(fontSize: 12, color: Colors.grey),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 4),
Text(
'Réf: ${product.reference}',
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
],
),
),
// Actions
Column(
children: [
IconButton(
onPressed: () => _showQRCode(product),
icon: const Icon(Icons.qr_code_2, color: Colors.blue),
tooltip: 'Voir QR Code',
),
IconButton(
onPressed: () => _editProduct(product),
icon: const Icon(Icons.edit, color: Colors.orange),
tooltip: 'Modifier',
),
IconButton(
onPressed: () => _deleteProduct(product),
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Supprimer',
),
],
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomAppBar(title: 'Gestion des produits'),
drawer: CustomDrawer(),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
children: [
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: [
// Ajoutez cette Row pour les boutons d'import
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isImporting ? null : _importFromExcel,
icon: const Icon(Icons.upload),
label: const Text('Importer depuis Excel'),
),
),
const SizedBox(width: 10),
TextButton(
onPressed: _isImporting ? null : _downloadExcelTemplate,
child: const Text('Modèle'),
),
],
),
const SizedBox(height: 16),
// Barre de recherche existante
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'),
),
),
],
),
const SizedBox(height: 12),
// Indicateur de progression d'importation
_buildImportProgressIndicator(),
// 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 (_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,
),
),
],
),
],
),
),
// 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,
),
),
if (_products.isEmpty) ...[
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _showAddProductDialog,
icon: const Icon(Icons.add),
label: const Text('Ajouter un produit'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
],
],
),
)
: RefreshIndicator(
onRefresh: _loadProducts,
child: ListView.builder(
itemCount: _filteredProducts.length,
itemBuilder: (context, index) {
final product = _filteredProducts[index];
return _buildProductCard(product);
},
),
),
),
],
),
);
}
}