diff --git a/lib/models/menus.dart b/lib/models/menus.dart new file mode 100644 index 0000000..f891c2f --- /dev/null +++ b/lib/models/menus.dart @@ -0,0 +1,88 @@ +class MenuCategory { + final int id; + final String nom; + final String? description; + final int? ordre; + final bool actif; + + MenuCategory({ + required this.id, + required this.nom, + this.description, + this.ordre, + required this.actif, + }); + + factory MenuCategory.fromJson(Map json) { + return MenuCategory( + id: json['id'], + nom: json['nom'], + description: json['description'], + ordre: json['ordre'], + actif: json['actif'] ?? true, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MenuCategory && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; +} + +class MenuPlat { + final int id; + final String nom; + final String? commentaire; + final double prix; + final String? ingredients; + final String? imageUrl; + final bool disponible; + final MenuCategory? category; // Single category pour la compatibilité API + final List? categories; // Multiple categories si besoin + + MenuPlat({ + required this.id, + required this.nom, + this.commentaire, + required this.prix, + this.ingredients, + this.imageUrl, + required this.disponible, + this.category, + this.categories, + }); + + factory MenuPlat.fromJson(Map json) { + double parsePrix(dynamic p) { + if (p is int) return p.toDouble(); + if (p is double) return p; + if (p is String) return double.tryParse(p) ?? 0; + return 0; + } + + return MenuPlat( + id: json['id'], + nom: json['nom'], + commentaire: json['commentaire'], + prix: parsePrix(json['prix']), + ingredients: json['ingredients'], + imageUrl: json['image_url'], + disponible: json['disponible'] ?? true, + // Support pour single category (API actuelle) + category: json['category'] != null + ? MenuCategory.fromJson(json['category']) + : null, + // Support pour multiple categories si l'API évolue + categories: json['categories'] != null + ? (json['categories'] as List) + .map((c) => MenuCategory.fromJson(c)) + .toList() + : null, + ); + } +} diff --git a/lib/pages/PlatEdit_screen.dart b/lib/pages/PlatEdit_screen.dart index bc1e8e3..613e919 100644 --- a/lib/pages/PlatEdit_screen.dart +++ b/lib/pages/PlatEdit_screen.dart @@ -1,62 +1,9 @@ -import 'dart:convert'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; -// Use your actual models and http logic -class MenuPlat { - final int id; - final String nom; - final String? commentaire; - final double prix; - final String? imageUrl; - final bool disponible; - final List categories; // Default to true - MenuPlat({ - required this.id, - required this.nom, - this.commentaire, - required this.prix, - this.imageUrl, - required this.disponible, - required this.categories, - }); - factory MenuPlat.fromJson(Map json) { - return MenuPlat( - id: json['id'], - nom: json['nom'], - commentaire: json['commentaire'], - prix: (json['prix'] as num).toDouble(), - imageUrl: json['image_url'], - disponible: json['disponible'] ?? true, - categories: - (json['categories'] as List) - .map((c) => MenuCategory.fromJson(c)) - .toList(), - ); - } -} - -class MenuCategory { - final int id; - final String nom; - - MenuCategory({required this.id, required this.nom}); - - factory MenuCategory.fromJson(Map json) { - return MenuCategory(id: json['id'], nom: json['nom']); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is MenuCategory && - runtimeType == other.runtimeType && - id == other.id; - - @override - int get hashCode => id.hashCode; -} +import '../models/menus.dart'; class PlatEditPage extends StatefulWidget { final List categories; @@ -76,9 +23,9 @@ class PlatEditPage extends StatefulWidget { class _PlatEditPageState extends State { final _formKey = GlobalKey(); - late TextEditingController nomCtrl, descCtrl, prixCtrl, imgCtrl; + late TextEditingController nomCtrl, descCtrl, prixCtrl, ingredientsCtrl; late bool disponible; - late List selectedCategories; + MenuCategory? selectedCategory; // Une seule catégorie pour correspondre à l'API @override void initState() { @@ -88,9 +35,14 @@ class _PlatEditPageState extends State { prixCtrl = TextEditingController( text: widget.plat != null ? widget.plat!.prix.toStringAsFixed(2) : "", ); - imgCtrl = TextEditingController(text: widget.plat?.imageUrl ?? ""); + ingredientsCtrl = TextEditingController(text: widget.plat?.ingredients ?? ""); disponible = widget.plat?.disponible ?? true; - selectedCategories = widget.plat?.categories.toList() ?? []; + + // Utiliser la catégorie unique ou la première des multiples + selectedCategory = widget.plat?.category ?? + (widget.plat?.categories?.isNotEmpty == true + ? widget.plat!.categories!.first + : null); } @override @@ -98,56 +50,72 @@ class _PlatEditPageState extends State { nomCtrl.dispose(); descCtrl.dispose(); prixCtrl.dispose(); - imgCtrl.dispose(); + ingredientsCtrl.dispose(); super.dispose(); } - void submit() async { + Future submit() async { if (!_formKey.currentState!.validate()) return; - if (selectedCategories.isEmpty) { + if (selectedCategory == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Sélectionnez au moins une catégorie.')), + const SnackBar(content: Text('Sélectionnez une catégorie.')), ); return; } - // Build request data + // Build request data selon votre API final body = { "nom": nomCtrl.text, "commentaire": descCtrl.text, + "ingredients": ingredientsCtrl.text, "prix": double.tryParse(prixCtrl.text) ?? 0, - "categories": selectedCategories.map((c) => c.id).toList(), + "categorie_id": selectedCategory!.id, // L'API attend categorie_id "disponible": disponible, - "categorie_id": - selectedCategories.isNotEmpty - ? selectedCategories.first.id - : null, // Use first category if available }; try { - final res = await http.post( - Uri.parse( - 'https://restaurant.careeracademy.mg/api/menus', - ), // your API URL here - headers: {"Content-Type": "application/json"}, - body: json.encode(body), - ); + final isEdit = widget.plat != null; + final url = isEdit + ? 'https://restaurant.careeracademy.mg/api/menus/${widget.plat!.id}' + : 'https://restaurant.careeracademy.mg/api/menus'; + + final res = isEdit + ? await http.put( + Uri.parse(url), + headers: {"Content-Type": "application/json"}, + body: json.encode(body), + ) + : await http.post( + Uri.parse(url), + headers: {"Content-Type": "application/json"}, + body: json.encode(body), + ); if (res.statusCode == 200 || res.statusCode == 201) { widget.onSaved?.call(); Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(isEdit ? 'Plat modifié avec succès' : 'Plat créé avec succès'), + backgroundColor: Colors.green, + ), + ); } else { - // show error - print('Error creating plat: ${res.body}\n here is body: $body'); + print('Error: ${res.body}\nBody sent: $body'); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur lors de la création du plat')), + SnackBar( + content: Text('Erreur lors de ${isEdit ? "la modification" : "la création"} du plat'), + backgroundColor: Colors.red, + ), ); } } catch (e) { - // show error - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Erreur réseau: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur réseau: $e'), + backgroundColor: Colors.red, + ), + ); } } @@ -155,6 +123,7 @@ class _PlatEditPageState extends State { Widget build(BuildContext context) { final isEdit = widget.plat != null; return Scaffold( + backgroundColor: const Color(0xfffcfbf9), appBar: AppBar( title: Text(isEdit ? 'Éditer le plat' : 'Nouveau plat'), leading: IconButton( @@ -163,8 +132,8 @@ class _PlatEditPageState extends State { ), centerTitle: true, elevation: 0, - backgroundColor: Colors.white, - foregroundColor: Colors.black, + backgroundColor: Colors.transparent, + foregroundColor: Colors.black87, ), body: Center( child: ConstrainedBox( @@ -179,187 +148,179 @@ class _PlatEditPageState extends State { padding: const EdgeInsets.all(28), child: Form( key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Informations du plat', - style: TextStyle( - fontSize: 21, - fontWeight: FontWeight.w600, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isEdit ? 'Modifier le plat' : 'Nouveau plat', + style: const TextStyle( + fontSize: 21, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 20), + + // Nom du plat + TextFormField( + controller: nomCtrl, + decoration: const InputDecoration( + labelText: "Nom du plat *", + hintText: "Ex: Steak frites", + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + filled: true, + fillColor: Color(0xFFF7F7F7), + ), + validator: (v) => (v == null || v.isEmpty) ? "Obligatoire" : null, ), - ), - const SizedBox(height: 14), - TextFormField( - controller: nomCtrl, - decoration: const InputDecoration( - labelText: "Nom du plat *", - hintText: "Ex: Steak frites", + const SizedBox(height: 16), + + // Description + TextFormField( + controller: descCtrl, + decoration: const InputDecoration( + labelText: "Description", + hintText: "Description détaillée du plat...", + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + filled: true, + fillColor: Color(0xFFF7F7F7), + ), + maxLines: 3, ), - validator: - (v) => - (v == null || v.isEmpty) ? "Obligatoire" : null, - ), - const SizedBox(height: 13), - TextFormField( - controller: descCtrl, - decoration: const InputDecoration( - labelText: "Description", - hintText: "Description détaillée du plat...", + const SizedBox(height: 16), + + // Ingrédients + TextFormField( + controller: ingredientsCtrl, + decoration: const InputDecoration( + labelText: "Ingrédients", + hintText: "Liste des ingrédients...", + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + filled: true, + fillColor: Color(0xFFF7F7F7), + ), + maxLines: 2, ), - maxLines: 3, - ), - const SizedBox(height: 13), - Row( - children: [ - Expanded( - child: TextFormField( - controller: prixCtrl, - decoration: const InputDecoration( - labelText: "Prix (MGA) *", - ), - validator: - (v) => - (v == null || - v.isEmpty || - double.tryParse(v) == null) - ? "Obligatoire" - : null, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, + const SizedBox(height: 16), + + Row( + children: [ + // Prix + Expanded( + child: TextFormField( + controller: prixCtrl, + decoration: const InputDecoration( + labelText: "Prix (MGA) *", + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + filled: true, + fillColor: Color(0xFFF7F7F7), + ), + validator: (v) => + (v == null || v.isEmpty || double.tryParse(v) == null) + ? "Obligatoire" + : null, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), ), ), - ), - const SizedBox(width: 16), - // Multi-category picker - Expanded( - child: InkWell( - onTap: () async { - final result = await showDialog( - context: context, - builder: (context) { - MenuCategory? temp = - selectedCategories.isNotEmpty - ? selectedCategories.first - : null; - - return StatefulBuilder( - builder: (context, setStateDialog) { - return AlertDialog( - title: const Text("Catégories"), - content: SizedBox( - width: 330, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: - widget.categories.map((cat) { - return RadioListTile< - MenuCategory - >( - value: cat, - groupValue: temp, - title: Text(cat.nom), - onChanged: (value) { - setStateDialog(() { - temp = value; - }); - }, - ); - }).toList(), - ), - ), - ), - actions: [ - TextButton( - onPressed: () { - if (temp != null) { - Navigator.pop(context, temp); - } else { - Navigator.pop(context, null); - } - }, - child: const Text('OK'), - ), - ], - ); - }, - ); - }, - ); - - if (result != null) { - setState(() => selectedCategories = [result]); - } - }, - child: InputDecorator( + const SizedBox(width: 16), + + // Catégorie + Expanded( + child: DropdownButtonFormField( + value: selectedCategory, decoration: const InputDecoration( labelText: "Catégorie *", - border: OutlineInputBorder(gapPadding: 2), + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + filled: true, + fillColor: Color(0xFFF7F7F7), ), - child: - selectedCategories.isEmpty - ? const Text( - "Aucune sélection", - style: TextStyle(color: Colors.grey), - ) - : Text(selectedCategories.first.nom), + validator: (v) => v == null ? "Obligatoire" : null, + items: widget.categories.map((cat) { + return DropdownMenuItem( + value: cat, + child: Text(cat.nom), + ); + }).toList(), + onChanged: (value) { + setState(() => selectedCategory = value); + }, ), ), - ), - ], - ), - // const SizedBox(height: 13), - // TextFormField( - // controller: imgCtrl, - // decoration: const InputDecoration( - // labelText: "URL de l'image", - // hintText: "/api/placeholder/300/200", - // ), - // ), - const SizedBox(height: 13), - Row( - children: [ - Switch( - value: disponible, - activeColor: Colors.green, - onChanged: (v) => setState(() => disponible = v), - ), - const SizedBox(width: 7), - const Text('Plat disponible'), - ], - ), - const SizedBox(height: 22), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - OutlinedButton( - onPressed: () => Navigator.pop(context), - child: const Text( - "Annuler", - style: TextStyle(color: Colors.black), + ], + ), + const SizedBox(height: 20), + + // Switch disponibilité + Row( + children: [ + Switch( + value: disponible, + activeColor: Colors.green, + onChanged: (v) => setState(() => disponible = v), ), - ), - const SizedBox(width: 18), - ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, + const SizedBox(width: 8), + Text( + 'Plat disponible', + style: TextStyle( + fontSize: 16, + color: disponible ? Colors.black87 : Colors.grey, + ), ), - onPressed: submit, - icon: const Icon( - Icons.save, - size: 18, - color: Colors.white, + ], + ), + const SizedBox(height: 30), + + // Boutons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + child: const Text( + "Annuler", + style: TextStyle(color: Colors.black54), + ), ), - label: Text( - isEdit ? "Enregistrer" : "Ajouter", - style: const TextStyle(color: Colors.white), + const SizedBox(width: 16), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green[700], + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + onPressed: submit, + icon: const Icon(Icons.save, size: 18), + label: Text(isEdit ? "Enregistrer" : "Créer le plat"), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ), @@ -368,4 +329,4 @@ class _PlatEditPageState extends State { ), ); } -} +} \ No newline at end of file diff --git a/lib/pages/menu.dart b/lib/pages/menu.dart index c23b1c8..a64ee17 100644 --- a/lib/pages/menu.dart +++ b/lib/pages/menu.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; +import 'package:intl/intl.dart'; import 'dart:convert'; // Ajoutez cet import pour la page panier @@ -75,13 +76,20 @@ class _MenuPageState extends State { if (response.statusCode == 200) { final jsonResponse = json.decode(response.body); + print(jsonResponse); - final List menusList = - jsonResponse is List ? jsonResponse : (jsonResponse['data'] ?? []); +final List menusList = + jsonResponse is List ? jsonResponse : (jsonResponse['data'] ?? []); - setState(() { - _menus = menusList; - }); +// Filtrage côté client +final List menusDisponibles = menusList.where((menu) { + final disponible = menu['disponible']; + return disponible == true || disponible == 1 || disponible == '1' || disponible == 'true'; +}).toList(); + +setState(() { + _menus = menusDisponibles; +}); } else { if (kDebugMode) { print("Erreur API menus: ${response.statusCode}"); @@ -225,7 +233,7 @@ class _MenuPageState extends State { ), subtitle: Text(item['commentaire'] ?? ''), trailing: Text( - "${formatPrix(item['prix'])} MGA", + "${NumberFormat("#,##0.00", "fr_FR").format(double.tryParse(item['prix'].toString()) ?? 0.0)} MGA", style: TextStyle( color: Colors.green[700], fontWeight: FontWeight.bold, @@ -385,7 +393,7 @@ class _AddToCartModalState extends State { style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), ), Text( - "${formatPrix(widget.item['prix'])} MGA", + "${NumberFormat("#,##0.00", "fr_FR").format(double.tryParse(widget.item['prix'].toString()) ?? 0.0)} MGA", style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -483,7 +491,7 @@ class _AddToCartModalState extends State { ), ), Text( - "${calculateTotal().toStringAsFixed(2)} MGA", + "${NumberFormat("#,##0.00", "fr_FR").format(calculateTotal())} MGA", style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, diff --git a/lib/pages/menus_screen.dart b/lib/pages/menus_screen.dart index 1441263..d011a20 100644 --- a/lib/pages/menus_screen.dart +++ b/lib/pages/menus_screen.dart @@ -2,8 +2,90 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; -import './PlatEdit_screen.dart'; +import 'package:intl/intl.dart'; +// ===== MODÈLES UNIFIÉS ===== +class MenuCategory { + final int id; + final String nom; + final String? description; + final int? ordre; + final bool actif; + + MenuCategory({ + required this.id, + required this.nom, + this.description, + this.ordre, + required this.actif, + }); + + factory MenuCategory.fromJson(Map json) { + return MenuCategory( + id: json['id'], + nom: json['nom'], + description: json['description'], + ordre: json['ordre'], + actif: json['actif'] ?? true, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MenuCategory && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; +} + +class MenuPlat { + final int id; + final String nom; + final String? commentaire; + final double prix; + final String? ingredients; + final String? imageUrl; + final MenuCategory? category; + final bool disponible; + + MenuPlat({ + required this.id, + required this.nom, + this.commentaire, + required this.prix, + this.ingredients, + this.imageUrl, + this.category, + required this.disponible, + }); + + factory MenuPlat.fromJson(Map json) { + double parsePrix(dynamic p) { + if (p is int) return p.toDouble(); + if (p is double) return p; + if (p is String) return double.tryParse(p) ?? 0; + return 0; + } + + return MenuPlat( + id: json['id'], + nom: json['nom'], + commentaire: json['commentaire'], + prix: parsePrix(json['prix']), + ingredients: json['ingredients'], + imageUrl: json['image_url'], + category: json['category'] != null + ? MenuCategory.fromJson(json['category']) + : null, + disponible: json['disponible'] ?? true, + ); + } +} + +// ===== ÉCRAN PRINCIPAL ===== class PlatsManagementScreen extends StatefulWidget { const PlatsManagementScreen({super.key}); @@ -34,6 +116,7 @@ class _PlatsManagementScreenState extends State { try { final res = await http.get(Uri.parse('$_baseUrl/menu-categories')); final data = json.decode(res.body); + print(data); setState(() { categories = (data['data']['categories'] as List) @@ -72,10 +155,65 @@ class _PlatsManagementScreenState extends State { } } + // Nouvelle méthode pour changer la disponibilité + Future _toggleDisponibilite(MenuPlat plat) async { + try { + final newDisponibilite = !plat.disponible; + final res = await http.put( + Uri.parse('$_baseUrl/menus/${plat.id}'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({ + "nom": plat.nom, + "commentaire": plat.commentaire, + "ingredients": plat.ingredients, + "prix": plat.prix, + "categorie_id": plat.category?.id, + "disponible": newDisponibilite, + }), + ); + + if (res.statusCode == 200) { + _fetchPlats(); // Recharger la liste + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + newDisponibilite + ? '${plat.nom} est maintenant disponible' + : '${plat.nom} est maintenant indisponible' + ), + backgroundColor: newDisponibilite ? Colors.green : Colors.orange, + ), + ); + } else { + if (kDebugMode) print("Erreur lors de la mise à jour: ${res.body}"); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Erreur lors de la mise à jour de la disponibilité'), + backgroundColor: Colors.red, + ), + ); + } + } catch (e) { + if (kDebugMode) print("Erreur: $e"); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Erreur de connexion'), + backgroundColor: Colors.red, + ), + ); + } + } + Future _deletePlat(int id) async { final res = await http.delete(Uri.parse('$_baseUrl/menus/$id')); if (res.statusCode == 200) { _fetchPlats(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Plat supprimé avec succès'), + backgroundColor: Colors.green, + ), + ); } else { if (kDebugMode) print("Error deleting plat: ${res.body}"); // ignore: use_build_context_synchronously @@ -88,12 +226,11 @@ class _PlatsManagementScreenState extends State { void _showEditPlatDialog(MenuPlat plat) { showDialog( context: context, - builder: - (_) => EditPlatDialog( - plat: plat, - onPlatUpdated: _fetchPlats, - categories: categories, - ), + builder: (_) => EditPlatDialog( + plat: plat, + onPlatUpdated: _fetchPlats, + categories: categories, + ), ); } @@ -113,12 +250,11 @@ class _PlatsManagementScreenState extends State { Padding( padding: const EdgeInsets.only(right: 16.0), child: ElevatedButton.icon( - onPressed: - () => navigateToCreate( - context, - categories, - () => {_fetchPlats()}, - ), + onPressed: () => navigateToCreate( + context, + categories, + () => {_fetchPlats()}, + ), icon: const Icon(Icons.add, size: 18), label: const Text('Nouveau plat'), style: ElevatedButton.styleFrom( @@ -212,146 +348,247 @@ class _PlatsManagementScreenState extends State { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 18, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 2, - child: Text( - p.nom, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 18, - ), - ), - ), - Expanded( - flex: 6, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - p.commentaire ?? "", - style: const TextStyle(fontSize: 15), - ), - if (p.ingredients != null && - p.ingredients!.isNotEmpty) - Padding( + // Ajouter une opacité si le plat n'est pas disponible + child: Opacity( + opacity: p.disponible ? 1.0 : 0.6, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 18, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + p.nom, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + decoration: p.disponible + ? TextDecoration.none + : TextDecoration.lineThrough, + ), + ), + const SizedBox(height: 4), + // Badge de statut + Container( padding: const EdgeInsets.symmetric( - vertical: 3.0, + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: p.disponible + ? Colors.green.withOpacity(0.1) + : Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: p.disponible + ? Colors.green + : Colors.red, + width: 1, + ), ), child: Text( - p.ingredients!, - style: const TextStyle( - color: Colors.grey, - fontSize: 13, + p.disponible ? 'Disponible' : 'Indisponible', + style: TextStyle( + color: p.disponible + ? Colors.green[700] + : Colors.red[700], + fontSize: 11, + fontWeight: FontWeight.w600, ), ), ), - Row( - children: [ - if (p.category != null) - CategoryChip( - label: p.category!.nom, - color: Colors.black, - ) - else - const CategoryChip( - label: "Catégorie", - color: Colors.black, - ), - ], - ), - ], + ], + ), ), - ), - Expanded( - flex: 2, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - "${p.prix.toStringAsFixed(2)} MGA", - style: const TextStyle( - fontWeight: FontWeight.bold, - color: Colors.green, - fontSize: 20, + Expanded( + flex: 6, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + p.commentaire ?? "", + style: const TextStyle(fontSize: 15), ), - ), - const SizedBox(width: 10), - SizedBox( - height: 38, - width: 38, - child: IconButton( - icon: const Icon( - Icons.edit, - color: Colors.black54, + if (p.ingredients != null && + p.ingredients!.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric( + vertical: 3.0, + ), + child: Text( + p.ingredients!, + style: const TextStyle( + color: Colors.grey, + fontSize: 13, + ), + ), ), - onPressed: () => _showEditPlatDialog(p), + Row( + children: [ + if (p.category != null) + CategoryChip( + label: p.category!.nom, + color: Colors.black, + ) + else + const CategoryChip( + label: "Catégorie", + color: Colors.black, + ), + ], ), - ), - SizedBox( - height: 38, - width: 38, - child: IconButton( - icon: const Icon( - Icons.delete, - color: Colors.redAccent, + ], + ), + ), + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + "${NumberFormat("#,##0.00", "fr_FR").format(p.prix)} MGA", + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.green, + fontSize: 20, ), - onPressed: () async { - final confirm = await showDialog( - context: context, - builder: - (_) => AlertDialog( - title: const Text( - 'Supprimer ce plat ?', - ), - content: Text( - 'Supprimer ${p.nom} ', + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Bouton de disponibilité + SizedBox( + height: 38, + width: 38, + child: IconButton( + icon: Icon( + p.disponible + ? Icons.visibility + : Icons.visibility_off, + color: p.disponible + ? Colors.green + : Colors.red, + ), + tooltip: p.disponible + ? 'Rendre indisponible' + : 'Rendre disponible', + onPressed: () async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + p.disponible + ? 'Rendre indisponible ?' + : 'Rendre disponible ?' + ), + content: Text( + p.disponible + ? '${p.nom} ne sera plus visible aux clients' + : '${p.nom} sera de nouveau visible aux clients' + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: p.disponible + ? Colors.orange + : Colors.green, + ), + child: Text( + p.disponible + ? 'Rendre indisponible' + : 'Rendre disponible', + style: const TextStyle(color: Colors.white), + ), + ), + ], ), - actions: [ - TextButton( - onPressed: - () => Navigator.pop( - context, - false, - ), - child: const Text("Annuler"), + ); + + if (confirm == true) { + _toggleDisponibilite(p); + } + }, + ), + ), + SizedBox( + height: 38, + width: 38, + child: IconButton( + icon: const Icon( + Icons.edit, + color: Colors.black54, + ), + onPressed: () => _showEditPlatDialog(p), + ), + ), + SizedBox( + height: 38, + width: 38, + child: IconButton( + icon: const Icon( + Icons.delete, + color: Colors.redAccent, + ), + onPressed: () async { + final confirm = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text( + 'Supprimer ce plat ?', ), - ElevatedButton( - onPressed: - () => Navigator.pop( - context, - true, - ), - style: - ElevatedButton.styleFrom( - backgroundColor: - Colors.red, + content: Text( + 'Supprimer ${p.nom} ', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop( + context, + false, + ), + child: const Text("Annuler"), + ), + ElevatedButton( + onPressed: () => Navigator.pop( + context, + true, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + child: const Text( + "Supprimer", + style: TextStyle( + color: Colors.white, ), - child: const Text( - "Supprimer", - style: TextStyle( - color: Colors.white, ), ), - ), - ], - ), - ); - if (confirm == true) _deletePlat(p.id); - }, + ], + ), + ); + if (confirm == true) _deletePlat(p.id); + }, + ), + ), + ], ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), ); @@ -364,51 +601,12 @@ class _PlatsManagementScreenState extends State { } } -class MenuPlat { - final int id; - final String nom; - final String? commentaire; - final double prix; - final String? ingredients; - final MenuCategory? category; // single category - - MenuPlat({ - required this.id, - required this.nom, - this.commentaire, - required this.prix, - this.ingredients, - this.category, - }); - - factory MenuPlat.fromJson(Map json) { - double parsePrix(dynamic p) { - if (p is int) return p.toDouble(); - if (p is double) return p; - if (p is String) return double.tryParse(p) ?? 0; - return 0; - } - - return MenuPlat( - id: json['id'], - nom: json['nom'], - commentaire: json['commentaire'], - prix: parsePrix(json['prix']), - ingredients: json['ingredients'], - category: - json['category'] != null - ? MenuCategory.fromJson(json['category']) - : null, - ); - } - - get disponible => null; -} - +// ===== COMPOSANTS ===== class CategoryChip extends StatelessWidget { final String label; final Color color; const CategoryChip({super.key, required this.label, required this.color}); + @override Widget build(BuildContext context) => Container( decoration: BoxDecoration( @@ -424,23 +622,12 @@ class CategoryChip extends StatelessWidget { ); } -Future navigateToCreate( - BuildContext context, - List categories, - Function()? onSaved, -) async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => PlatEditPage(categories: categories, onSaved: onSaved), - ), - ); -} - +// ===== DIALOGUE D'ÉDITION ===== class EditPlatDialog extends StatefulWidget { final MenuPlat plat; final List categories; final VoidCallback onPlatUpdated; + const EditPlatDialog({ super.key, required this.plat, @@ -457,7 +644,7 @@ class _EditPlatDialogState extends State { late String commentaire; late String ingredients; late double prix; - // late bool disponible; + late bool disponible; MenuCategory? cat; final _formKey = GlobalKey(); @@ -468,29 +655,57 @@ class _EditPlatDialogState extends State { commentaire = widget.plat.commentaire ?? ''; ingredients = widget.plat.ingredients ?? ''; prix = widget.plat.prix; - // cat = (widget.plat.categories) as MenuCategory?; + disponible = widget.plat.disponible; cat = widget.plat.category; } Future submit() async { if (!_formKey.currentState!.validate()) return; - final res = await http.put( - Uri.parse( - 'https://restaurant.careeracademy.mg/api/menus/${widget.plat.id}', - ), - headers: {'Content-Type': 'application/json'}, - body: json.encode({ - "nom": nom, - "commentaire": commentaire, - "ingredients": ingredients, - "prix": prix, - "categorie_id": cat?.id, - }), - ); - if (res.statusCode == 200) { - widget.onPlatUpdated(); + + try { + final res = await http.put( + Uri.parse( + 'https://restaurant.careeracademy.mg/api/menus/${widget.plat.id}', + ), + headers: {'Content-Type': 'application/json'}, + body: json.encode({ + "nom": nom, + "commentaire": commentaire, + "ingredients": ingredients, + "prix": prix, + "categorie_id": cat?.id, + "disponible": disponible, + }), + ); + + if (res.statusCode == 200) { + widget.onPlatUpdated(); + // ignore: use_build_context_synchronously + Navigator.pop(context); + // ignore: use_build_context_synchronously + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Plat modifié avec succès'), + backgroundColor: Colors.green, + ), + ); + } else { + // ignore: use_build_context_synchronously + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: ${res.body}'), + backgroundColor: Colors.red, + ), + ); + } + } catch (e) { // ignore: use_build_context_synchronously - Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur réseau: $e'), + backgroundColor: Colors.red, + ), + ); } } @@ -509,37 +724,63 @@ class _EditPlatDialogState extends State { decoration: const InputDecoration(labelText: "Nom"), validator: (v) => (v?.isEmpty ?? true) ? "Obligatoire" : null, ), + const SizedBox(height: 8), TextFormField( initialValue: commentaire, onChanged: (v) => commentaire = v, decoration: const InputDecoration(labelText: "Commentaire"), + maxLines: 2, ), + const SizedBox(height: 8), TextFormField( initialValue: ingredients, onChanged: (v) => ingredients = v, decoration: const InputDecoration(labelText: "Ingrédients"), + maxLines: 2, ), + const SizedBox(height: 8), TextFormField( initialValue: prix.toString(), onChanged: (v) => prix = double.tryParse(v) ?? 0, - decoration: const InputDecoration(labelText: "Prix"), + decoration: const InputDecoration(labelText: "Prix (MGA)"), keyboardType: const TextInputType.numberWithOptions( decimal: true, ), + validator: (v) => (v?.isEmpty ?? true || double.tryParse(v!) == null) + ? "Prix obligatoire" : null, ), - DropdownButton( - hint: const Text("Catégorie"), + const SizedBox(height: 16), + DropdownButtonFormField( value: cat, - isExpanded: true, - items: - widget.categories - .toSet() - .map( - (c) => DropdownMenuItem(value: c, child: Text(c.nom)), - ) - .toList(), + hint: const Text("Catégorie"), + decoration: const InputDecoration( + labelText: "Catégorie", + border: OutlineInputBorder(), + ), + items: widget.categories + .map( + (c) => DropdownMenuItem(value: c, child: Text(c.nom)), + ) + .toList(), onChanged: (v) => setState(() => cat = v), ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Disponible", + style: TextStyle(fontSize: 16), + ), + Switch( + value: disponible, + onChanged: (value) { + setState(() => disponible = value); + }, + activeColor: Colors.green, + ), + ], + ), ], ), ), @@ -549,7 +790,368 @@ class _EditPlatDialogState extends State { onPressed: () => Navigator.pop(context), child: const Text("Annuler"), ), - ElevatedButton(onPressed: submit, child: const Text("Enregistrer")), + ElevatedButton( + onPressed: submit, + style: ElevatedButton.styleFrom(backgroundColor: Colors.green), + child: const Text("Enregistrer", style: TextStyle(color: Colors.white)), + ), ], ); } + +// ===== PAGE DE CRÉATION/ÉDITION ===== +class PlatEditPage extends StatefulWidget { + final List categories; + final MenuPlat? plat; + final Function()? onSaved; + + const PlatEditPage({ + super.key, + required this.categories, + this.plat, + this.onSaved, + }); + + @override + State createState() => _PlatEditPageState(); +} + +class _PlatEditPageState extends State { + final _formKey = GlobalKey(); + late TextEditingController nomCtrl, descCtrl, prixCtrl, ingredientsCtrl; + late bool disponible; + MenuCategory? selectedCategory; + + @override + void initState() { + super.initState(); + nomCtrl = TextEditingController(text: widget.plat?.nom ?? ""); + descCtrl = TextEditingController(text: widget.plat?.commentaire ?? ""); + prixCtrl = TextEditingController( + text: widget.plat != null ? widget.plat!.prix.toStringAsFixed(2) : "", + ); + ingredientsCtrl = TextEditingController(text: widget.plat?.ingredients ?? ""); + disponible = widget.plat?.disponible ?? true; + selectedCategory = widget.plat?.category; + } + + @override + void dispose() { + nomCtrl.dispose(); + descCtrl.dispose(); + prixCtrl.dispose(); + ingredientsCtrl.dispose(); + super.dispose(); + } + + Future submit() async { + if (!_formKey.currentState!.validate()) return; + if (selectedCategory == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Sélectionnez une catégorie.')), + ); + return; + } + + final body = { + "nom": nomCtrl.text, + "commentaire": descCtrl.text, + "ingredients": ingredientsCtrl.text, + "prix": double.tryParse(prixCtrl.text) ?? 0, + "categorie_id": selectedCategory!.id, + "disponible": disponible, + }; + + try { + final isEdit = widget.plat != null; + final url = isEdit + ? 'https://restaurant.careeracademy.mg/api/menus/${widget.plat!.id}' + : 'https://restaurant.careeracademy.mg/api/menus'; + + final res = isEdit + ? await http.put( + Uri.parse(url), + headers: {"Content-Type": "application/json"}, + body: json.encode(body), + ) + : await http.post( + Uri.parse(url), + headers: {"Content-Type": "application/json"}, + body: json.encode(body), + ); + + if (res.statusCode == 200 || res.statusCode == 201) { + widget.onSaved?.call(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(isEdit ? 'Plat modifié avec succès' : 'Plat créé avec succès'), + backgroundColor: Colors.green, + ), + ); + } else { + print('Error: ${res.body}\nBody sent: $body'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur lors de ${isEdit ? "la modification" : "la création"} du plat'), + backgroundColor: Colors.red, + ), + ); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur réseau: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.plat != null; + return Scaffold( + backgroundColor: const Color(0xfffcfbf9), + appBar: AppBar( + title: Text(isEdit ? 'Éditer le plat' : 'Nouveau plat'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + centerTitle: true, + elevation: 0, + backgroundColor: Colors.transparent, + foregroundColor: Colors.black87, + ), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 550), + child: Card( + elevation: 2, + margin: const EdgeInsets.all(32), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(28), + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isEdit ? 'Modifier le plat' : 'Nouveau plat', + style: const TextStyle( + fontSize: 21, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 20), + + // Nom du plat + TextFormField( + controller: nomCtrl, + decoration: const InputDecoration( + labelText: "Nom du plat *", + hintText: "Ex: Steak frites", + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + filled: true, + fillColor: Color(0xFFF7F7F7), + ), + validator: (v) => (v == null || v.isEmpty) ? "Obligatoire" : null, + ), + const SizedBox(height: 16), + + // Description + TextFormField( + controller: descCtrl, + decoration: const InputDecoration( + labelText: "Description", + hintText: "Description détaillée du plat...", + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + filled: true, + fillColor: Color(0xFFF7F7F7), + ), + maxLines: 3, + ), + const SizedBox(height: 16), + + // Ingrédients + TextFormField( + controller: ingredientsCtrl, + decoration: const InputDecoration( + labelText: "Ingrédients", + hintText: "Liste des ingrédients...", + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + filled: true, + fillColor: Color(0xFFF7F7F7), + ), + maxLines: 2, + ), + const SizedBox(height: 16), + + Row( + children: [ + // Prix + Expanded( + child: TextFormField( + controller: prixCtrl, + decoration: const InputDecoration( + labelText: "Prix (MGA) *", + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + filled: true, + fillColor: Color(0xFFF7F7F7), + ), + validator: (v) => + (v == null || v.isEmpty || double.tryParse(v) == null) + ? "Obligatoire" + : null, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + ), + ), + const SizedBox(width: 16), + + // Catégorie + Expanded( + child: DropdownButtonFormField( + value: selectedCategory, + decoration: const InputDecoration( + labelText: "Catégorie *", + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + filled: true, + fillColor: Color(0xFFF7F7F7), + ), + validator: (v) => v == null ? "Obligatoire" : null, + items: widget.categories.map((cat) { + return DropdownMenuItem( + value: cat, + child: Text(cat.nom), + ); + }).toList(), + onChanged: (value) { + setState(() => selectedCategory = value); + }, + ), + ), + ], + ), + const SizedBox(height: 20), + + // Switch disponibilité + Row( + children: [ + Switch( + value: disponible, + activeColor: Colors.green, + onChanged: (v) => setState(() => disponible = v), + ), + const SizedBox(width: 8), + Text( + 'Plat disponible', + style: TextStyle( + fontSize: 16, + color: disponible ? Colors.black87 : Colors.grey, + ), + ), + ], + ), + const SizedBox(height: 30), + + // Boutons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + child: const Text( + "Annuler", + style: TextStyle(color: Colors.black54), + ), + ), + const SizedBox(width: 16), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green[700], + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + onPressed: submit, + icon: const Icon(Icons.save, size: 18), + label: Text(isEdit ? "Enregistrer" : "Créer le plat"), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +// ===== FONCTIONS DE NAVIGATION ===== + +Future navigateToCreate( + BuildContext context, + List categories, + Function()? onSaved, +) async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PlatEditPage( + categories: categories, + onSaved: onSaved + ), + ), + ); +} + +Future navigateToEdit( + BuildContext context, + List categories, + MenuPlat plat, + Function()? onSaved, +) async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PlatEditPage( + categories: categories, + plat: plat, + onSaved: onSaved, + ), + ), + ); +} \ No newline at end of file