3 changed files with 1290 additions and 0 deletions
|
After Width: | Height: | Size: 239 KiB |
@ -0,0 +1,421 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
|
||||
|
class CartPage extends StatefulWidget { |
||||
|
final int tableId; |
||||
|
final int personne; |
||||
|
final List<dynamic> cartItems; |
||||
|
|
||||
|
const CartPage({ |
||||
|
Key? key, |
||||
|
required this.tableId, |
||||
|
required this.personne, |
||||
|
required this.cartItems, |
||||
|
}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
State<CartPage> createState() => _CartPageState(); |
||||
|
} |
||||
|
|
||||
|
class _CartPageState extends State<CartPage> { |
||||
|
List<CartItemModel> _cartItems = []; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_processCartItems(); |
||||
|
} |
||||
|
|
||||
|
void _processCartItems() { |
||||
|
// Grouper les articles identiques |
||||
|
Map<String, CartItemModel> groupedItems = {}; |
||||
|
|
||||
|
for (var item in widget.cartItems) { |
||||
|
String key = "${item['id']}_${item['notes'] ?? ''}"; |
||||
|
|
||||
|
if (groupedItems.containsKey(key)) { |
||||
|
groupedItems[key]!.quantity++; |
||||
|
} else { |
||||
|
groupedItems[key] = CartItemModel( |
||||
|
id: item['id'], |
||||
|
nom: item['nom'] ?? 'Article', |
||||
|
prix: _parsePrice(item['prix']), |
||||
|
quantity: 1, |
||||
|
notes: item['notes'] ?? '', |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
setState(() { |
||||
|
_cartItems = groupedItems.values.toList(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
double _parsePrice(dynamic prix) { |
||||
|
if (prix == null) return 0.0; |
||||
|
if (prix is num) return prix.toDouble(); |
||||
|
if (prix is String) return double.tryParse(prix) ?? 0.0; |
||||
|
return 0.0; |
||||
|
} |
||||
|
|
||||
|
void _updateQuantity(int index, int newQuantity) { |
||||
|
setState(() { |
||||
|
if (newQuantity > 0) { |
||||
|
_cartItems[index].quantity = newQuantity; |
||||
|
} else { |
||||
|
_cartItems.removeAt(index); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
void _removeItem(int index) { |
||||
|
setState(() { |
||||
|
_cartItems.removeAt(index); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
double _calculateTotal() { |
||||
|
return _cartItems.fold(0.0, (sum, item) => sum + (item.prix * item.quantity)); |
||||
|
} |
||||
|
|
||||
|
int _getTotalArticles() { |
||||
|
return _cartItems.fold(0, (sum, item) => sum + item.quantity); |
||||
|
} |
||||
|
|
||||
|
void _validateOrder() { |
||||
|
// TODO: Implémenter la validation de commande |
||||
|
showDialog( |
||||
|
context: context, |
||||
|
builder: (BuildContext context) { |
||||
|
return AlertDialog( |
||||
|
title: Text('Commande validée'), |
||||
|
content: Text('Votre commande a été envoyée en cuisine !'), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () { |
||||
|
Navigator.of(context).pop(); // Fermer le dialog |
||||
|
Navigator.of(context).pop(); // Retourner au menu |
||||
|
Navigator.of(context).pop(); // Retourner aux tables |
||||
|
}, |
||||
|
child: Text('OK'), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Scaffold( |
||||
|
backgroundColor: Colors.grey[50], |
||||
|
appBar: AppBar( |
||||
|
backgroundColor: Colors.white, |
||||
|
elevation: 0, |
||||
|
leading: IconButton( |
||||
|
onPressed: () => Navigator.pop(context), |
||||
|
icon: Icon(Icons.arrow_back, color: Colors.black), |
||||
|
), |
||||
|
title: Text( |
||||
|
'Panier', |
||||
|
style: TextStyle( |
||||
|
color: Colors.black, |
||||
|
fontSize: 24, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Navigator.pop(context), |
||||
|
child: Text( |
||||
|
'Retour au menu', |
||||
|
style: TextStyle( |
||||
|
color: Colors.black, |
||||
|
fontSize: 16, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 16), |
||||
|
], |
||||
|
), |
||||
|
body: _cartItems.isEmpty |
||||
|
? Center( |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.shopping_cart_outlined, |
||||
|
size: 80, |
||||
|
color: Colors.grey[400], |
||||
|
), |
||||
|
SizedBox(height: 16), |
||||
|
Text( |
||||
|
'Votre panier est vide', |
||||
|
style: TextStyle( |
||||
|
fontSize: 18, |
||||
|
color: Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
) |
||||
|
: Column( |
||||
|
children: [ |
||||
|
// Header avec infos table |
||||
|
Container( |
||||
|
width: double.infinity, |
||||
|
padding: EdgeInsets.all(20), |
||||
|
color: Colors.white, |
||||
|
child: Text( |
||||
|
'Table ${widget.tableId} • ${widget.personne} personne${widget.personne > 1 ? 's' : ''}', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
color: Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// Liste des articles |
||||
|
Expanded( |
||||
|
child: ListView.separated( |
||||
|
padding: EdgeInsets.all(16), |
||||
|
itemCount: _cartItems.length, |
||||
|
separatorBuilder: (context, index) => SizedBox(height: 12), |
||||
|
itemBuilder: (context, index) { |
||||
|
final item = _cartItems[index]; |
||||
|
return Container( |
||||
|
padding: EdgeInsets.all(16), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 5, |
||||
|
offset: Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
item.nom, |
||||
|
style: TextStyle( |
||||
|
fontSize: 18, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
onPressed: () => _removeItem(index), |
||||
|
icon: Icon( |
||||
|
Icons.delete_outline, |
||||
|
color: Colors.red, |
||||
|
), |
||||
|
constraints: BoxConstraints(), |
||||
|
padding: EdgeInsets.zero, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
Text( |
||||
|
'${item.prix.toStringAsFixed(2)} € l\'unité', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
color: Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
if (item.notes.isNotEmpty) ...[ |
||||
|
SizedBox(height: 8), |
||||
|
Text( |
||||
|
'Notes: ${item.notes}', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
color: Colors.grey[600], |
||||
|
fontStyle: FontStyle.italic, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
SizedBox(height: 16), |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
// Contrôles de quantité |
||||
|
Row( |
||||
|
children: [ |
||||
|
IconButton( |
||||
|
onPressed: () => _updateQuantity(index, item.quantity - 1), |
||||
|
icon: Icon(Icons.remove), |
||||
|
style: IconButton.styleFrom( |
||||
|
backgroundColor: Colors.grey[200], |
||||
|
minimumSize: Size(40, 40), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 16), |
||||
|
Text( |
||||
|
item.quantity.toString(), |
||||
|
style: TextStyle( |
||||
|
fontSize: 18, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 16), |
||||
|
IconButton( |
||||
|
onPressed: () => _updateQuantity(index, item.quantity + 1), |
||||
|
icon: Icon(Icons.add), |
||||
|
style: IconButton.styleFrom( |
||||
|
backgroundColor: Colors.grey[200], |
||||
|
minimumSize: Size(40, 40), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
// Prix total de l'article |
||||
|
Text( |
||||
|
'${(item.prix * item.quantity).toStringAsFixed(2)} €', |
||||
|
style: TextStyle( |
||||
|
fontSize: 18, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: Colors.green[700], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// Récapitulatif |
||||
|
Container( |
||||
|
padding: EdgeInsets.all(20), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
border: Border( |
||||
|
top: BorderSide(color: Colors.grey[200]!), |
||||
|
), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'Récapitulatif', |
||||
|
style: TextStyle( |
||||
|
fontSize: 20, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 16), |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text('Articles:', style: TextStyle(fontSize: 16)), |
||||
|
Text( |
||||
|
_getTotalArticles().toString(), |
||||
|
style: TextStyle(fontSize: 16), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 8), |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text('Table:', style: TextStyle(fontSize: 16)), |
||||
|
Text( |
||||
|
widget.tableId.toString(), |
||||
|
style: TextStyle(fontSize: 16), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 8), |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text('Personnes:', style: TextStyle(fontSize: 16)), |
||||
|
Text( |
||||
|
widget.personne.toString(), |
||||
|
style: TextStyle(fontSize: 16), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 16), |
||||
|
Divider(), |
||||
|
SizedBox(height: 8), |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'Total:', |
||||
|
style: TextStyle( |
||||
|
fontSize: 18, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
Text( |
||||
|
'${_calculateTotal().toStringAsFixed(2)} €', |
||||
|
style: TextStyle( |
||||
|
fontSize: 18, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 20), |
||||
|
SizedBox( |
||||
|
width: double.infinity, |
||||
|
child: ElevatedButton( |
||||
|
onPressed: _cartItems.isNotEmpty ? _validateOrder : null, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.green[700], |
||||
|
foregroundColor: Colors.white, |
||||
|
padding: EdgeInsets.symmetric(vertical: 16), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
), |
||||
|
disabledBackgroundColor: Colors.grey[300], |
||||
|
), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon(Icons.check, size: 20), |
||||
|
SizedBox(width: 8), |
||||
|
Text( |
||||
|
'Valider la commande', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class CartItemModel { |
||||
|
final int id; |
||||
|
final String nom; |
||||
|
final double prix; |
||||
|
int quantity; |
||||
|
final String notes; |
||||
|
|
||||
|
CartItemModel({ |
||||
|
required this.id, |
||||
|
required this.nom, |
||||
|
required this.prix, |
||||
|
required this.quantity, |
||||
|
required this.notes, |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,869 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:http/http.dart' as http; |
||||
|
import 'dart:convert'; |
||||
|
import 'package:flutter/foundation.dart'; |
||||
|
|
||||
|
class CategoriesPage extends StatefulWidget { |
||||
|
const CategoriesPage({super.key}); |
||||
|
|
||||
|
@override |
||||
|
State<CategoriesPage> createState() => _CategoriesPageState(); |
||||
|
} |
||||
|
|
||||
|
class _CategoriesPageState extends State<CategoriesPage> { |
||||
|
List<Category> categories = []; |
||||
|
bool isLoading = true; |
||||
|
String? error; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_loadCategories(); |
||||
|
} |
||||
|
|
||||
|
Future<void> _loadCategories() async { |
||||
|
try { |
||||
|
setState(() { |
||||
|
isLoading = true; |
||||
|
error = null; |
||||
|
}); |
||||
|
|
||||
|
final response = await http.get( |
||||
|
Uri.parse("https://restaurant.careeracademy.mg/api/menu-categories"), |
||||
|
headers: {'Content-Type': 'application/json'}, |
||||
|
); |
||||
|
|
||||
|
if (response.statusCode == 200) { |
||||
|
final jsonBody = json.decode(response.body); |
||||
|
final dynamic rawData = jsonBody['data']['categories']; // ✅ ici ! |
||||
|
|
||||
|
print('✅ Données récupérées :'); |
||||
|
print(rawData); |
||||
|
|
||||
|
if (rawData is List) { |
||||
|
setState(() { |
||||
|
categories = rawData |
||||
|
.map((json) => Category.fromJson(json as Map<String, dynamic>)) |
||||
|
.toList(); |
||||
|
categories.sort((a, b) => a.ordre.compareTo(b.ordre)); |
||||
|
isLoading = false; |
||||
|
}); |
||||
|
} else { |
||||
|
throw Exception("Format inattendu pour les catégories"); |
||||
|
} |
||||
|
} |
||||
|
else { |
||||
|
setState(() { |
||||
|
error = 'Erreur lors du chargement des catégories (${response.statusCode})'; |
||||
|
isLoading = false; |
||||
|
}); |
||||
|
if (kDebugMode) { |
||||
|
print('Erreur HTTP: ${response.statusCode}'); |
||||
|
print('Contenu de la réponse: ${response.body}'); |
||||
|
} |
||||
|
} |
||||
|
} catch (e) { |
||||
|
setState(() { |
||||
|
error = 'Erreur de connexion: $e'; |
||||
|
isLoading = false; |
||||
|
}); |
||||
|
if (kDebugMode) { |
||||
|
print('Erreur dans _loadCategories: $e'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> _createCategory(Category category) async { |
||||
|
try { |
||||
|
final response = await http.post( |
||||
|
Uri.parse('https://restaurant.careeracademy.mg/api/menu-categories'), |
||||
|
headers: {'Content-Type': 'application/json'}, |
||||
|
body: json.encode(category.toJson()), |
||||
|
); |
||||
|
|
||||
|
if (response.statusCode == 201 || response.statusCode == 200) { |
||||
|
await _loadCategories(); // Recharger la liste |
||||
|
if (mounted) { |
||||
|
ScaffoldMessenger.of(context).showSnackBar( |
||||
|
const SnackBar(content: Text('Catégorie créée avec succès')), |
||||
|
); |
||||
|
} |
||||
|
} else { |
||||
|
throw Exception('Erreur lors de la création (${response.statusCode})'); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
if (mounted) { |
||||
|
ScaffoldMessenger.of(context).showSnackBar( |
||||
|
SnackBar(content: Text('Erreur: $e')), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> _updateCategory(Category category) async { |
||||
|
try { |
||||
|
final response = await http.put( |
||||
|
Uri.parse('https://restaurant.careeracademy.mg/api/menu-categories/${category.id}'), |
||||
|
headers: {'Content-Type': 'application/json'}, |
||||
|
body: json.encode(category.toJson()), |
||||
|
); |
||||
|
|
||||
|
if (response.statusCode == 200) { |
||||
|
await _loadCategories(); // Recharger la liste |
||||
|
if (mounted) { |
||||
|
ScaffoldMessenger.of(context).showSnackBar( |
||||
|
const SnackBar(content: Text('Catégorie mise à jour avec succès')), |
||||
|
); |
||||
|
} |
||||
|
} else { |
||||
|
throw Exception('Erreur lors de la mise à jour (${response.statusCode})'); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
if (mounted) { |
||||
|
ScaffoldMessenger.of(context).showSnackBar( |
||||
|
SnackBar(content: Text('Erreur: $e')), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> _deleteCategory(int categoryId) async { |
||||
|
try { |
||||
|
final response = await http.delete( |
||||
|
Uri.parse('https://restaurant.careeracademy.mg/api/menu-categories/$categoryId'), |
||||
|
headers: {'Content-Type': 'application/json'}, |
||||
|
); |
||||
|
|
||||
|
if (response.statusCode == 200 || response.statusCode == 204) { |
||||
|
await _loadCategories(); // Recharger la liste |
||||
|
if (mounted) { |
||||
|
ScaffoldMessenger.of(context).showSnackBar( |
||||
|
const SnackBar(content: Text('Catégorie supprimée avec succès')), |
||||
|
); |
||||
|
} |
||||
|
} else { |
||||
|
throw Exception('Erreur lors de la suppression (${response.statusCode})'); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
if (mounted) { |
||||
|
ScaffoldMessenger.of(context).showSnackBar( |
||||
|
SnackBar(content: Text('Erreur: $e')), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Scaffold( |
||||
|
backgroundColor: Colors.grey.shade50, |
||||
|
body: SafeArea( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(20.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
// Header |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Flexible( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'Gestion des Catégories', |
||||
|
style: TextStyle( |
||||
|
fontSize: 24, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: Colors.grey.shade800, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 4), |
||||
|
Text( |
||||
|
'Gérez les catégories de votre menu', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
color: Colors.grey.shade600, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
Row( |
||||
|
children: [ |
||||
|
IconButton( |
||||
|
onPressed: _loadCategories, |
||||
|
icon: const Icon(Icons.refresh), |
||||
|
tooltip: 'Actualiser', |
||||
|
), |
||||
|
const SizedBox(width: 8), |
||||
|
ElevatedButton.icon( |
||||
|
onPressed: _showAddCategoryDialog, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.green.shade700, |
||||
|
foregroundColor: Colors.white, |
||||
|
padding: const EdgeInsets.symmetric( |
||||
|
horizontal: 16, |
||||
|
vertical: 12, |
||||
|
), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
), |
||||
|
icon: const Icon(Icons.add, size: 18), |
||||
|
label: const Text('Nouvelle catégorie'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 30), |
||||
|
|
||||
|
// Content |
||||
|
Expanded( |
||||
|
child: _buildContent(), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildContent() { |
||||
|
if (isLoading) { |
||||
|
return const Center( |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
CircularProgressIndicator(), |
||||
|
SizedBox(height: 16), |
||||
|
Text('Chargement des catégories...'), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (error != null) { |
||||
|
return Center( |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.error_outline, |
||||
|
size: 64, |
||||
|
color: Colors.red.shade400, |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
Text( |
||||
|
error!, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
color: Colors.red.shade600, |
||||
|
), |
||||
|
textAlign: TextAlign.center, |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
ElevatedButton( |
||||
|
onPressed: _loadCategories, |
||||
|
child: const Text('Réessayer'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (categories.isEmpty) { |
||||
|
return Center( |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.category_outlined, |
||||
|
size: 64, |
||||
|
color: Colors.grey.shade400, |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
Text( |
||||
|
'Aucune catégorie trouvée', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
color: Colors.grey.shade600, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
ElevatedButton.icon( |
||||
|
onPressed: _showAddCategoryDialog, |
||||
|
icon: const Icon(Icons.add), |
||||
|
label: const Text('Créer une catégorie'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return LayoutBuilder( |
||||
|
builder: (context, constraints) { |
||||
|
// Version responsive |
||||
|
if (constraints.maxWidth > 800) { |
||||
|
return _buildDesktopTable(); |
||||
|
} else { |
||||
|
return _buildMobileList(); |
||||
|
} |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildDesktopTable() { |
||||
|
return Container( |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.grey.shade200, |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
// Table Header |
||||
|
Container( |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.grey.shade50, |
||||
|
borderRadius: const BorderRadius.only( |
||||
|
topLeft: Radius.circular(12), |
||||
|
topRight: Radius.circular(12), |
||||
|
), |
||||
|
), |
||||
|
padding: const EdgeInsets.symmetric( |
||||
|
horizontal: 20, |
||||
|
vertical: 16, |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
SizedBox( |
||||
|
width: 80, |
||||
|
child: Text( |
||||
|
'Ordre', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.grey.shade700, |
||||
|
fontSize: 14, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
Expanded( |
||||
|
flex: 2, |
||||
|
child: Text( |
||||
|
'Nom', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.grey.shade700, |
||||
|
fontSize: 14, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
Expanded( |
||||
|
flex: 3, |
||||
|
child: Text( |
||||
|
'Description', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.grey.shade700, |
||||
|
fontSize: 14, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox( |
||||
|
width: 80, |
||||
|
child: Text( |
||||
|
'Statut', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.grey.shade700, |
||||
|
fontSize: 14, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox( |
||||
|
width: 120, |
||||
|
child: Text( |
||||
|
'Actions', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.grey.shade700, |
||||
|
fontSize: 14, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// Table Body |
||||
|
Expanded( |
||||
|
child: ListView.separated( |
||||
|
padding: EdgeInsets.zero, |
||||
|
itemCount: categories.length, |
||||
|
separatorBuilder: (context, index) => Divider( |
||||
|
height: 1, |
||||
|
color: Colors.grey.shade200, |
||||
|
), |
||||
|
itemBuilder: (context, index) { |
||||
|
final category = categories[index]; |
||||
|
return _buildCategoryRow(category, index); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildMobileList() { |
||||
|
return ListView.separated( |
||||
|
itemCount: categories.length, |
||||
|
separatorBuilder: (context, index) => const SizedBox(height: 12), |
||||
|
itemBuilder: (context, index) { |
||||
|
final category = categories[index]; |
||||
|
return _buildMobileCategoryCard(category, index); |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildMobileCategoryCard(Category category, int index) { |
||||
|
return Card( |
||||
|
elevation: 2, |
||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
category.nom, |
||||
|
style: const TextStyle( |
||||
|
fontWeight: FontWeight.bold, |
||||
|
fontSize: 16, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
Container( |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), |
||||
|
decoration: BoxDecoration( |
||||
|
color: category.actif ? Colors.green.shade100 : Colors.red.shade100, |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
), |
||||
|
child: Text( |
||||
|
category.actif ? 'Actif' : 'Inactif', |
||||
|
style: TextStyle( |
||||
|
color: category.actif ? Colors.green.shade700 : Colors.red.shade700, |
||||
|
fontSize: 12, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Text( |
||||
|
category.description, |
||||
|
style: TextStyle( |
||||
|
color: Colors.grey.shade600, |
||||
|
fontSize: 14, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 12), |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'Ordre: ${category.ordre}', |
||||
|
style: TextStyle( |
||||
|
color: Colors.grey.shade600, |
||||
|
fontSize: 12, |
||||
|
), |
||||
|
), |
||||
|
Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
IconButton( |
||||
|
onPressed: () => _moveCategory(index, -1), |
||||
|
icon: Icon( |
||||
|
Icons.keyboard_arrow_up, |
||||
|
color: index > 0 ? Colors.grey.shade600 : Colors.grey.shade300, |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
onPressed: () => _moveCategory(index, 1), |
||||
|
icon: Icon( |
||||
|
Icons.keyboard_arrow_down, |
||||
|
color: index < categories.length - 1 |
||||
|
? Colors.grey.shade600 |
||||
|
: Colors.grey.shade300, |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
onPressed: () => _editCategory(category), |
||||
|
icon: Icon( |
||||
|
Icons.edit_outlined, |
||||
|
color: Colors.blue.shade600, |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
onPressed: () => _confirmDeleteCategory(category), |
||||
|
icon: Icon( |
||||
|
Icons.delete_outline, |
||||
|
color: Colors.red.shade400, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildCategoryRow(Category category, int index) { |
||||
|
return Container( |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
// Ordre avec flèches |
||||
|
SizedBox( |
||||
|
width: 80, |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
Text( |
||||
|
'${category.ordre}', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: Colors.grey.shade800, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 4), |
||||
|
Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
GestureDetector( |
||||
|
onTap: () => _moveCategory(index, -1), |
||||
|
child: Icon( |
||||
|
Icons.keyboard_arrow_up, |
||||
|
size: 16, |
||||
|
color: index > 0 ? Colors.grey.shade600 : Colors.grey.shade300, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 4), |
||||
|
GestureDetector( |
||||
|
onTap: () => _moveCategory(index, 1), |
||||
|
child: Icon( |
||||
|
Icons.keyboard_arrow_down, |
||||
|
size: 16, |
||||
|
color: index < categories.length - 1 |
||||
|
? Colors.grey.shade600 |
||||
|
: Colors.grey.shade300, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// Nom |
||||
|
Expanded( |
||||
|
flex: 2, |
||||
|
child: Text( |
||||
|
category.nom, |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: Colors.grey.shade800, |
||||
|
), |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// Description |
||||
|
Expanded( |
||||
|
flex: 3, |
||||
|
child: Text( |
||||
|
category.description, |
||||
|
style: const TextStyle( |
||||
|
color: Colors.black87, |
||||
|
fontSize: 14, |
||||
|
), |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// Statut |
||||
|
SizedBox( |
||||
|
width: 80, |
||||
|
child: Container( |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), |
||||
|
decoration: BoxDecoration( |
||||
|
color: category.actif ? Colors.green.shade100 : Colors.red.shade100, |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
), |
||||
|
child: Text( |
||||
|
category.actif ? 'Actif' : 'Inactif', |
||||
|
style: TextStyle( |
||||
|
color: category.actif ? Colors.green.shade700 : Colors.red.shade700, |
||||
|
fontSize: 12, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
), |
||||
|
textAlign: TextAlign.center, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// Actions |
||||
|
SizedBox( |
||||
|
width: 120, |
||||
|
child: Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
IconButton( |
||||
|
onPressed: () => _editCategory(category), |
||||
|
icon: Icon( |
||||
|
Icons.edit_outlined, |
||||
|
size: 18, |
||||
|
color: Colors.blue.shade600, |
||||
|
), |
||||
|
tooltip: 'Modifier', |
||||
|
), |
||||
|
IconButton( |
||||
|
onPressed: () => _confirmDeleteCategory(category), |
||||
|
icon: Icon( |
||||
|
Icons.delete_outline, |
||||
|
size: 18, |
||||
|
color: Colors.red.shade400, |
||||
|
), |
||||
|
tooltip: 'Supprimer', |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void _moveCategory(int index, int direction) async { |
||||
|
if ((direction == -1 && index > 0) || |
||||
|
(direction == 1 && index < categories.length - 1)) { |
||||
|
|
||||
|
final category1 = categories[index]; |
||||
|
final category2 = categories[index + direction]; |
||||
|
|
||||
|
// Échanger les ordres |
||||
|
final tempOrdre = category1.ordre; |
||||
|
final updatedCategory1 = category1.copyWith(ordre: category2.ordre); |
||||
|
final updatedCategory2 = category2.copyWith(ordre: tempOrdre); |
||||
|
|
||||
|
// Mettre à jour sur le serveur |
||||
|
await _updateCategory(updatedCategory1); |
||||
|
await _updateCategory(updatedCategory2); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void _editCategory(Category category) { |
||||
|
_showEditCategoryDialog(category); |
||||
|
} |
||||
|
|
||||
|
void _confirmDeleteCategory(Category category) { |
||||
|
showDialog( |
||||
|
context: context, |
||||
|
builder: (context) => AlertDialog( |
||||
|
title: const Text('Supprimer la catégorie'), |
||||
|
content: Text('Êtes-vous sûr de vouloir supprimer "${category.nom}" ?'), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Navigator.pop(context), |
||||
|
child: const Text('Annuler'), |
||||
|
), |
||||
|
TextButton( |
||||
|
onPressed: () { |
||||
|
Navigator.pop(context); |
||||
|
_deleteCategory(category.id!); |
||||
|
}, |
||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red), |
||||
|
child: const Text('Supprimer'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void _showAddCategoryDialog() { |
||||
|
_showCategoryDialog(); |
||||
|
} |
||||
|
|
||||
|
void _showEditCategoryDialog(Category category) { |
||||
|
_showCategoryDialog(category: category); |
||||
|
} |
||||
|
|
||||
|
void _showCategoryDialog({Category? category}) { |
||||
|
final nomController = TextEditingController(text: category?.nom ?? ''); |
||||
|
final descriptionController = TextEditingController(text: category?.description ?? ''); |
||||
|
final ordreController = TextEditingController(text: (category?.ordre ?? (categories.length + 1)).toString()); |
||||
|
bool actif = category?.actif ?? true; |
||||
|
|
||||
|
showDialog( |
||||
|
context: context, |
||||
|
builder: (context) => StatefulBuilder( |
||||
|
builder: (context, setDialogState) => AlertDialog( |
||||
|
title: Text(category == null ? 'Nouvelle catégorie' : 'Modifier la catégorie'), |
||||
|
content: SingleChildScrollView( |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
TextField( |
||||
|
controller: nomController, |
||||
|
decoration: const InputDecoration( |
||||
|
labelText: 'Nom de la catégorie', |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
TextField( |
||||
|
controller: descriptionController, |
||||
|
decoration: const InputDecoration( |
||||
|
labelText: 'Description', |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
maxLines: 3, |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
TextField( |
||||
|
controller: ordreController, |
||||
|
decoration: const InputDecoration( |
||||
|
labelText: 'Ordre', |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
keyboardType: TextInputType.number, |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Checkbox( |
||||
|
value: actif, |
||||
|
onChanged: (value) { |
||||
|
setDialogState(() { |
||||
|
actif = value ?? true; |
||||
|
}); |
||||
|
}, |
||||
|
), |
||||
|
const Text('Catégorie active'), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Navigator.pop(context), |
||||
|
child: const Text('Annuler'), |
||||
|
), |
||||
|
ElevatedButton( |
||||
|
onPressed: () { |
||||
|
if (nomController.text.isNotEmpty) { |
||||
|
final newCategory = Category( |
||||
|
id: category?.id, |
||||
|
nom: nomController.text.trim(), |
||||
|
description: descriptionController.text.trim(), |
||||
|
ordre: int.tryParse(ordreController.text) ?? 1, |
||||
|
actif: actif, |
||||
|
); |
||||
|
|
||||
|
if (category == null) { |
||||
|
_createCategory(newCategory); |
||||
|
} else { |
||||
|
_updateCategory(newCategory); |
||||
|
} |
||||
|
|
||||
|
Navigator.pop(context); |
||||
|
} |
||||
|
}, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.green.shade700, |
||||
|
foregroundColor: Colors.white, |
||||
|
), |
||||
|
child: Text(category == null ? 'Ajouter' : 'Modifier'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class Category { |
||||
|
final int? id; |
||||
|
final String nom; |
||||
|
final String description; |
||||
|
final int ordre; |
||||
|
final bool actif; |
||||
|
|
||||
|
Category({ |
||||
|
this.id, |
||||
|
required this.nom, |
||||
|
required this.description, |
||||
|
required this.ordre, |
||||
|
required this.actif, |
||||
|
}); |
||||
|
|
||||
|
factory Category.fromJson(Map<String, dynamic> json) { |
||||
|
return Category( |
||||
|
id: json['id'] is int ? json['id'] : int.tryParse(json['id'].toString()) ?? 0, |
||||
|
nom: json['nom']?.toString() ?? '', |
||||
|
description: json['description']?.toString() ?? '', |
||||
|
ordre: json['ordre'] is int ? json['ordre'] : int.tryParse(json['ordre'].toString()) ?? 1, |
||||
|
actif: json['actif'] is bool ? json['actif'] : (json['actif'].toString().toLowerCase() == 'true'), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Map<String, dynamic> toJson() { |
||||
|
final map = <String, dynamic>{ |
||||
|
'nom': nom, |
||||
|
'description': description, |
||||
|
'ordre': ordre, |
||||
|
'actif': actif, |
||||
|
}; |
||||
|
|
||||
|
// N'inclure l'ID que s'il existe (pour les mises à jour) |
||||
|
if (id != null) { |
||||
|
map['id'] = id; |
||||
|
} |
||||
|
|
||||
|
return map; |
||||
|
} |
||||
|
|
||||
|
Category copyWith({ |
||||
|
int? id, |
||||
|
String? nom, |
||||
|
String? description, |
||||
|
int? ordre, |
||||
|
bool? actif, |
||||
|
}) { |
||||
|
return Category( |
||||
|
id: id ?? this.id, |
||||
|
nom: nom ?? this.nom, |
||||
|
description: description ?? this.description, |
||||
|
ordre: ordre ?? this.ordre, |
||||
|
actif: actif ?? this.actif, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue