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.
1613 lines
57 KiB
1613 lines
57 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: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éer un fichier Excel temporaire comme modèle
|
|
final excel = Excel.createExcel();
|
|
|
|
// // Renommer la feuille par défaut
|
|
// excel.rename('Sheet1', 'Produits');
|
|
|
|
// Accéder à la feuille renommée
|
|
final sheet = excel['Sheet1'];
|
|
|
|
// Ajouter les en-têtes avec du style
|
|
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',
|
|
);
|
|
}
|
|
|
|
// Ajouter des exemples
|
|
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];
|
|
}
|
|
}
|
|
|
|
// Ajuster la largeur des colonnes
|
|
sheet.setColWidth(0, 20); // Nom
|
|
sheet.setColWidth(1, 10); // Prix
|
|
sheet.setColWidth(2, 15); // Catégorie
|
|
sheet.setColWidth(3, 30); // Description
|
|
sheet.setColWidth(4, 10); // Stock
|
|
|
|
// Sauvegarder en mémoire
|
|
final bytes = excel.save();
|
|
|
|
if (bytes == null) {
|
|
Get.snackbar('Erreur', 'Impossible de créer le fichier modèle');
|
|
return;
|
|
}
|
|
|
|
// Demander où sauvegarder
|
|
final String? outputFile = await FilePicker.platform.saveFile(
|
|
fileName: 'modele_import_produits.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',
|
|
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 (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 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) {
|
|
// É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);
|
|
|
|
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)} 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|