import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; 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}); @override State createState() => _PlatsManagementScreenState(); } class _PlatsManagementScreenState extends State { final _baseUrl = 'https://restaurant.careeracademy.mg/api'; List plats = []; List categories = []; String search = ''; int? selectedCategoryId; String disponibilite = ''; bool isLoading = true; final TextEditingController _searchController = TextEditingController(); @override void initState() { super.initState(); _fetchCategories(); _fetchPlats(); } // Fetch categories Future _fetchCategories() async { 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) .map((item) => MenuCategory.fromJson(item)) .toList(); }); } catch (_) {} } // Fetch plats Future _fetchPlats() async { setState(() => isLoading = true); try { final uri = Uri.parse('$_baseUrl/menus').replace( queryParameters: { if (search.isNotEmpty) 'search': search, if (selectedCategoryId != null) 'category_id': selectedCategoryId.toString(), }, ); final res = await http.get(uri); final data = json.decode(res.body); setState(() { plats = (data['data']['menus'] as List) .map((item) => MenuPlat.fromJson(item)) .toList(); isLoading = false; }); if (kDebugMode) { // print('fetched plat here: $plats items'); } } catch (e) { setState(() => isLoading = false); if (kDebugMode) print("Error: $e"); } } // 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 ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Des commandes sont liées à ce plat.')), ); } } void _showEditPlatDialog(MenuPlat plat) { showDialog( context: context, builder: (_) => EditPlatDialog( plat: plat, onPlatUpdated: _fetchPlats, categories: categories, ), ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xfffcfbf9), appBar: AppBar( elevation: 0, title: const Text( 'Gestion des plats', style: TextStyle(fontWeight: FontWeight.bold), ), backgroundColor: Colors.transparent, foregroundColor: Colors.black87, actions: [ Padding( padding: const EdgeInsets.only(right: 16.0), child: ElevatedButton.icon( onPressed: () => navigateToCreate( context, categories, () => {_fetchPlats()}, ), icon: const Icon(Icons.add, size: 18), label: const Text('Nouveau plat'), style: ElevatedButton.styleFrom( backgroundColor: Colors.green[700], foregroundColor: Colors.white, padding: const EdgeInsets.symmetric( horizontal: 18, vertical: 12, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6), ), ), ), ), ], ), body: Column( children: [ Card( margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 18), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), child: Padding( padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 18), child: Row( children: [ const Icon(Icons.filter_alt_outlined, size: 22), const SizedBox(width: 10), Expanded( child: TextField( controller: _searchController, onChanged: (value) { setState(() => search = value); _fetchPlats(); }, decoration: const InputDecoration( hintText: "Rechercher un plat...", border: OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(8)), borderSide: BorderSide.none, ), filled: true, fillColor: Color(0xFFF7F7F7), isDense: true, contentPadding: EdgeInsets.symmetric( vertical: 10, horizontal: 14, ), ), ), ), const SizedBox(width: 10), // Catégories DropdownButton( value: selectedCategoryId, hint: const Text("Toutes les catégories"), items: [ const DropdownMenuItem( value: null, child: Text("Toutes les catégories"), ), ...categories.map( (cat) => DropdownMenuItem( value: cat.id, child: Text(cat.nom), ), ), ], onChanged: (v) { setState(() => selectedCategoryId = v); _fetchPlats(); }, ), ], ), ), ), if (isLoading) const Expanded(child: Center(child: CircularProgressIndicator())) else Expanded( child: ListView.builder( itemCount: plats.length, padding: const EdgeInsets.symmetric(horizontal: 24), itemBuilder: (ctx, i) { final p = plats[i]; return Card( margin: const EdgeInsets.only(bottom: 20), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), // 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( 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.disponible ? 'Disponible' : 'Indisponible', style: TextStyle( color: p.disponible ? Colors.green[700] : Colors.red[700], fontSize: 11, fontWeight: FontWeight.w600, ), ), ), ], ), ), 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( padding: const EdgeInsets.symmetric( vertical: 3.0, ), child: Text( p.ingredients!, style: const TextStyle( color: Colors.grey, fontSize: 13, ), ), ), 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: 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, ), ), 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), ), ), ], ), ); 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 ?', ), 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, ), ), ), ], ), ); if (confirm == true) _deletePlat(p.id); }, ), ), ], ), ], ), ), ], ), ), ), ); }, ), ), ], ), ); } } // ===== 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( borderRadius: BorderRadius.circular(12), // ignore: deprecated_member_use color: color.withOpacity(0.16), ), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Text( label, style: TextStyle(color: color, fontWeight: FontWeight.w600, fontSize: 13), ), ); } // ===== DIALOGUE D'ÉDITION ===== class EditPlatDialog extends StatefulWidget { final MenuPlat plat; final List categories; final VoidCallback onPlatUpdated; const EditPlatDialog({ super.key, required this.plat, required this.onPlatUpdated, required this.categories, }); @override State createState() => _EditPlatDialogState(); } class _EditPlatDialogState extends State { late String nom; late String commentaire; late String ingredients; late double prix; late bool disponible; MenuCategory? cat; final _formKey = GlobalKey(); @override void initState() { super.initState(); nom = widget.plat.nom; commentaire = widget.plat.commentaire ?? ''; ingredients = widget.plat.ingredients ?? ''; prix = widget.plat.prix; disponible = widget.plat.disponible; cat = widget.plat.category; } Future submit() async { if (!_formKey.currentState!.validate()) return; 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 ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Erreur réseau: $e'), backgroundColor: Colors.red, ), ); } } @override Widget build(BuildContext context) => AlertDialog( title: const Text("Éditer le plat"), content: Form( key: _formKey, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextFormField( initialValue: nom, onChanged: (v) => nom = v, 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 (MGA)"), keyboardType: const TextInputType.numberWithOptions( decimal: true, ), validator: (v) => (v?.isEmpty ?? true || double.tryParse(v!) == null) ? "Prix obligatoire" : null, ), const SizedBox(height: 16), DropdownButtonFormField( value: cat, 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, ), ], ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text("Annuler"), ), 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, ), ), ); }