Browse Source

CRUD menus

master
Stephane 4 months ago
parent
commit
d4a5c60821
  1. 13
      lib/layouts/main_layout.dart
  2. 21
      lib/main.dart
  3. 387
      lib/pages/PlatEdit_screen.dart
  4. 9
      lib/pages/commandes_screen.dart
  5. 831
      lib/pages/menus_screen.dart
  6. 5
      lib/pages/tables.dart
  7. 12
      lib/widgets/bottom_navigation.dart

13
lib/layouts/main_layout.dart

@ -6,11 +6,7 @@ class MainLayout extends StatefulWidget {
final Widget child; final Widget child;
final String? currentRoute; final String? currentRoute;
const MainLayout({ const MainLayout({super.key, required this.child, this.currentRoute});
super.key,
required this.child,
this.currentRoute,
});
@override @override
_MainLayoutState createState() => _MainLayoutState(); _MainLayoutState createState() => _MainLayoutState();
@ -36,7 +32,7 @@ class _MainLayoutState extends State<MainLayout> {
return 2; return 2;
case '/menu': case '/menu':
return 3; return 3;
case '/cart': case '/plats':
return 4; return 4;
default: default:
return 0; return 0;
@ -63,7 +59,7 @@ class _MainLayoutState extends State<MainLayout> {
route = '/menu'; route = '/menu';
break; break;
case 4: case 4:
route = '/cart'; route = '/plats';
break; break;
default: default:
route = '/tables'; route = '/tables';
@ -91,7 +87,8 @@ class _MainLayoutState extends State<MainLayout> {
], ],
), ),
// Show mobile navigation on smaller screens // Show mobile navigation on smaller screens
bottomNavigationBar: isDesktop bottomNavigationBar:
isDesktop
? null ? null
: MobileBottomNavigation( : MobileBottomNavigation(
currentRoute: widget.currentRoute ?? '/tables', currentRoute: widget.currentRoute ?? '/tables',

21
lib/main.dart

@ -1,10 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:itrimobe/pages/menu.dart';
import 'layouts/main_layout.dart'; import 'layouts/main_layout.dart';
import 'pages/tables.dart'; import 'pages/tables.dart';
import 'pages/categorie.dart'; import 'pages/categorie.dart';
import 'pages/commandes_screen.dart'; import 'pages/commandes_screen.dart';
import 'pages/login_screen.dart'; import 'pages/login_screen.dart';
import 'pages/menus_screen.dart';
void main() { void main() {
runApp(const MyApp()); runApp(const MyApp());
@ -25,23 +25,28 @@ class MyApp extends StatelessWidget {
initialRoute: '/login', initialRoute: '/login',
routes: { routes: {
'/login': (context) => const LoginScreen(), '/login': (context) => const LoginScreen(),
'/tables': (context) => const MainLayout( '/tables':
(context) => const MainLayout(
currentRoute: '/tables', currentRoute: '/tables',
child: TablesScreen(), child: TablesScreen(),
), ),
'/categories': (context) => const MainLayout( '/categories':
(context) => const MainLayout(
currentRoute: '/categories', currentRoute: '/categories',
child: CategoriesPage(), child: CategoriesPage(),
), ),
'/commandes': (context) => const MainLayout( '/commandes':
(context) => const MainLayout(
currentRoute: '/commandes', currentRoute: '/commandes',
child: OrdersManagementScreen(), child: OrdersManagementScreen(),
), ),
// MODIFICATION : Route simple pour le menu // MODIFICATION : Route simple pour le menu
// '/menu': (context) => const MainLayout( '/plats':
// currentRoute: '/menu', (context) => const MainLayout(
// child: MenuPage(), // Pas de paramètres requis maintenant currentRoute: '/plats',
// ), child:
PlatsManagementScreen(), // Pas de paramètres requis maintenant
),
}, },
); );
} }

387
lib/pages/PlatEdit_screen.dart

@ -0,0 +1,387 @@
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<MenuCategory> categories; // Default to true
MenuPlat({
required this.id,
required this.nom,
this.commentaire,
required this.prix,
this.imageUrl,
required this.disponible,
required this.categories,
});
}
class MenuCategory {
final int id;
final String nom;
final String? description;
MenuCategory({required this.id, required this.nom, this.description});
factory MenuCategory.fromJson(Map<String, dynamic> json) {
return MenuCategory(
id: json['id'],
nom: json['nom'],
description: json['description'],
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MenuCategory &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
}
class PlatEditPage extends StatefulWidget {
final List<MenuCategory> categories;
final MenuPlat? plat; // if present = edit, else create
final Function()? onSaved; // callback when saved
const PlatEditPage({
super.key,
required this.categories,
this.plat,
this.onSaved,
});
@override
State<PlatEditPage> createState() => _PlatEditPageState();
}
class _PlatEditPageState extends State<PlatEditPage> {
final _formKey = GlobalKey<FormState>();
late TextEditingController nomCtrl, descCtrl, prixCtrl, imgCtrl;
late bool disponible;
late List<MenuCategory> selectedCategories;
@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) : "",
);
imgCtrl = TextEditingController(text: widget.plat?.imageUrl ?? "");
disponible = widget.plat?.disponible ?? true;
selectedCategories = widget.plat?.categories.toList() ?? [];
}
@override
void dispose() {
nomCtrl.dispose();
descCtrl.dispose();
prixCtrl.dispose();
imgCtrl.dispose();
super.dispose();
}
void submit() async {
if (!_formKey.currentState!.validate()) return;
if (selectedCategories.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sélectionnez au moins une catégorie.')),
);
return;
}
// Build request data
final body = {
"nom": nomCtrl.text,
"commentaire": descCtrl.text,
"prix": double.tryParse(prixCtrl.text) ?? 0,
"categories": selectedCategories.map((c) => c.id).toList(),
"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),
);
if (res.statusCode == 200 || res.statusCode == 201) {
widget.onSaved?.call();
Navigator.pop(context);
} else {
// show error
print('Error creating plat: ${res.body}\n here is body: $body');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors de la création du plat')),
);
}
} catch (e) {
// show error
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Erreur réseau: $e')));
}
}
@override
Widget build(BuildContext context) {
final isEdit = widget.plat != null;
return Scaffold(
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.white,
foregroundColor: Colors.black,
),
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: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informations du plat',
style: TextStyle(
fontSize: 21,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 14),
TextFormField(
controller: nomCtrl,
decoration: const InputDecoration(
labelText: "Nom du plat *",
hintText: "Ex: Steak frites",
),
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...",
),
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(width: 16),
// Multi-category picker
Expanded(
child: InkWell(
onTap: () async {
final result = await showDialog<
List<MenuCategory>
>(
context: context,
builder: (context) {
// temp inside a StatefulBuilder so state changes refresh UI
Set<int> temp =
selectedCategories
.map((e) => e.id)
.toSet();
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 CheckboxListTile(
value: temp.contains(
cat.id,
),
title: Text(cat.nom),
onChanged: (checked) {
setStateDialog(() {
if (checked == true) {
temp.add(cat.id);
} else {
temp.remove(cat.id);
}
});
},
);
}).toList(),
),
),
),
actions: [
TextButton(
onPressed: () {
final newList =
widget.categories
.where(
(cat) => temp.contains(
cat.id,
),
)
.toList();
Navigator.pop(context, newList);
},
child: const Text('OK'),
),
],
);
},
);
},
);
if (result != null)
setState(() => selectedCategories = result);
},
child: InputDecorator(
decoration: const InputDecoration(
labelText: "Catégories *",
border: OutlineInputBorder(gapPadding: 2),
),
child: Wrap(
spacing: 6,
runSpacing: -8,
children:
selectedCategories.isEmpty
? [
const Text(
"Aucune sélection",
style: TextStyle(
color: Colors.grey,
),
),
]
: selectedCategories
.map(
(c) => Chip(
label: Text(c.nom),
visualDensity:
VisualDensity.compact,
),
)
.toList(),
),
),
),
),
],
),
// 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(width: 18),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
),
onPressed: submit,
icon: const Icon(
Icons.save,
size: 18,
color: Colors.white,
),
label: Text(
isEdit ? "Enregistrer" : "Ajouter",
style: const TextStyle(color: Colors.white),
),
),
],
),
],
),
),
),
),
),
),
);
}
}

9
lib/pages/commandes_screen.dart

@ -138,8 +138,6 @@ class _OrdersManagementScreenState extends State<OrdersManagementScreen> {
return null; return null;
} }
Future<void> updateOrderStatus( Future<void> updateOrderStatus(
Order order, Order order,
String newStatus, { String newStatus, {
@ -287,9 +285,6 @@ class _OrdersManagementScreenState extends State<OrdersManagementScreen> {
} }
} }
List<Order> get activeOrders { List<Order> get activeOrders {
return orders return orders
.where( .where(
@ -345,7 +340,6 @@ class _OrdersManagementScreenState extends State<OrdersManagementScreen> {
style: TextStyle(color: Colors.white), style: TextStyle(color: Colors.white),
), ),
), ),
], ],
); );
}, },
@ -355,7 +349,8 @@ class _OrdersManagementScreenState extends State<OrdersManagementScreen> {
} }
Future<void> updateTableStatus(int tableId, String newStatus) async { Future<void> updateTableStatus(int tableId, String newStatus) async {
const String apiUrl = 'https://restaurant.careeracademy.mg/api/tables'; // adapte lURL si besoin const String apiUrl =
'https://restaurant.careeracademy.mg/api/tables'; // adapte lURL si besoin
final response = await http.put( final response = await http.put(
Uri.parse('$apiUrl/$tableId'), Uri.parse('$apiUrl/$tableId'),

831
lib/pages/menus_screen.dart

@ -1,500 +1,563 @@
// TODO Implement this library. import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'dart:convert'; import './PlatEdit_screen.dart';
class MenuPage extends StatefulWidget { class PlatsManagementScreen extends StatefulWidget {
final int? tableId; const PlatsManagementScreen({super.key});
final int? personne;
const MenuPage({Key? key, required this.tableId, required this.personne})
: super(key: key);
@override @override
State<MenuPage> createState() => _MenuPageState(); State<PlatsManagementScreen> createState() => _PlatsManagementScreenState();
} }
class _MenuPageState extends State<MenuPage> { class _PlatsManagementScreenState extends State<PlatsManagementScreen> {
int? _selectedCategory; final _baseUrl = 'https://restaurant.careeracademy.mg/api';
List<dynamic> _categories = []; List<MenuPlat> plats = [];
List<dynamic> _menus = []; List<MenuCategory> categories = [];
List<dynamic> _cart = []; String search = '';
int? selectedCategoryId;
String disponibilite = '';
bool isLoading = true;
final TextEditingController _searchController = TextEditingController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
fetchCategories(); _fetchCategories();
_fetchPlats();
} }
Future<void> fetchCategories() async { // Fetch categories
Future<void> _fetchCategories() async {
try { try {
final url = Uri.parse( final res = await http.get(Uri.parse('$_baseUrl/menu-categories'));
"https://restaurant.careeracademy.mg/api/menu-categories", final data = json.decode(res.body);
);
final response = await http.get(url);
if (response.statusCode == 200) {
final jsonResponse = json.decode(response.body);
final categoriesList =
(jsonResponse['data']?['categories'] ?? []) as List<dynamic>;
setState(() { setState(() {
_categories = categoriesList; categories =
if (_categories.isNotEmpty) { (data['data']['categories'] as List)
_selectedCategory = _categories[0]['id']; .map((item) => MenuCategory.fromJson(item))
fetchMenus(_selectedCategory!); .toList();
}
}); });
} else { } catch (_) {}
print("Erreur API catégories: ${response.statusCode}");
}
} catch (e) {
print("Exception fetchCategories: $e");
}
} }
Future<void> fetchMenus(int categoryId) async { // Fetch plats
Future<void> _fetchPlats() async {
setState(() => isLoading = true);
try { try {
final url = Uri.parse( final uri = Uri.parse('$_baseUrl/menus').replace(
"https://restaurant.careeracademy.mg/api/menus/category/$categoryId?disponible=true", queryParameters: {
if (search.isNotEmpty) 'search': search,
if (selectedCategoryId != null)
'category_id': selectedCategoryId.toString(),
},
); );
final response = await http.get(url); final res = await http.get(uri);
final data = json.decode(res.body);
if (response.statusCode == 200) {
final jsonResponse = json.decode(response.body);
final List<dynamic> menusList =
jsonResponse is List ? jsonResponse : (jsonResponse['data'] ?? []);
setState(() { setState(() {
_menus = menusList; plats =
(data['data']['menus'] as List)
.map((item) => MenuPlat.fromJson(item))
.toList();
isLoading = false;
}); });
} else { if (kDebugMode) {
print("Erreur API menus: ${response.statusCode}"); // print('fetched plat here: $plats items');
} }
} catch (e) { } catch (e) {
print("Exception fetchMenus: $e"); setState(() => isLoading = false);
} if (kDebugMode) print("Error: $e");
} }
void changeCategory(int id) {
setState(() {
_selectedCategory = id;
_menus = [];
});
fetchMenus(id);
} }
void addToCart(dynamic item, int quantity, String notes) { Future<void> _deletePlat(int id) async {
setState(() { final res = await http.delete(Uri.parse('$_baseUrl/menus/$id'));
for (int i = 0; i < quantity; i++) { if (res.statusCode == 200)
Map<String, dynamic> cartItem = Map<String, dynamic>.from(item); _fetchPlats();
if (notes.isNotEmpty) { else {
cartItem['notes'] = notes; if (kDebugMode) print("Error deleting plat: ${res.body}");
} ScaffoldMessenger.of(context).showSnackBar(
_cart.add(cartItem); SnackBar(content: Text('Des commandes sont liées à ce plat.')),
);
} }
});
} }
void showAddToCartModal(dynamic item) { void _showEditPlatDialog(MenuPlat plat) {
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext context) { builder:
return AddToCartModal(item: item, onAddToCart: addToCart); (_) => EditPlatDialog(
}, plat: plat,
onPlatUpdated: _fetchPlats,
categories: categories,
),
); );
} }
/// Conversion sécurisée du prix en string avec 2 décimales
String formatPrix(dynamic prix) {
if (prix == null) return "";
double? val;
if (prix is num) {
val = prix.toDouble();
} else if (prix is String) {
val = double.tryParse(prix);
}
return val != null ? val.toStringAsFixed(2) : "";
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xfffcfbf9),
appBar: AppBar( appBar: AppBar(
title: Text("Menu"), elevation: 0,
title: const Text(
'Gestion des plats',
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: Colors.transparent,
foregroundColor: Colors.black87,
actions: [ actions: [
IconButton( Padding(
onPressed: () { padding: const EdgeInsets.only(right: 16.0),
if (_selectedCategory != null) fetchMenus(_selectedCategory!); child: ElevatedButton.icon(
}, onPressed:
icon: Icon(Icons.refresh), () => 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( body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Card(
padding: const EdgeInsets.all(8.0), margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 18),
child: Text( shape: RoundedRectangleBorder(
"Table ${widget.tableId}${widget.personne} personne${widget.personne! > 1 ? 's' : ''}", borderRadius: BorderRadius.circular(8),
style: TextStyle(fontSize: 16),
),
), ),
if (_categories.isNotEmpty) child: Padding(
Row( padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 18),
mainAxisAlignment: MainAxisAlignment.spaceEvenly, child: Row(
children: children: [
_categories.map<Widget>((cat) { const Icon(Icons.filter_alt_outlined, size: 22),
return buildCategoryButton(cat['nom'], cat['id']); const SizedBox(width: 10),
}).toList(),
)
else
Center(child: CircularProgressIndicator()),
Expanded( Expanded(
child: child: TextField(
_menus.isNotEmpty controller: _searchController,
? ListView.builder( onChanged: (value) {
itemCount: _menus.length, setState(() => search = value);
itemBuilder: (context, index) { _fetchPlats();
final item = _menus[index]; },
return Card( decoration: const InputDecoration(
margin: EdgeInsets.all(8), hintText: "Rechercher un plat...",
child: ListTile( border: OutlineInputBorder(
onTap: borderRadius: BorderRadius.all(Radius.circular(8)),
() => showAddToCartModal( borderSide: BorderSide.none,
item,
), // Clic sur tout l'item
leading: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.green[100],
borderRadius: BorderRadius.circular(8),
), ),
child: Icon( filled: true,
Icons.restaurant_menu, fillColor: Color(0xFFF7F7F7),
color: Colors.green[700], isDense: true,
size: 30, contentPadding: EdgeInsets.symmetric(
vertical: 10,
horizontal: 14,
), ),
), ),
title: Text(
item['nom'] ?? 'Nom non disponible',
style: TextStyle(fontWeight: FontWeight.bold),
), ),
subtitle: Text(item['commentaire'] ?? ''),
trailing: Text(
"${formatPrix(item['prix'])} MGA",
style: TextStyle(
color: Colors.green[700],
fontWeight: FontWeight.bold,
fontSize: 16,
), ),
const SizedBox(width: 10),
// Catégories
DropdownButton<int?>(
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),
), ),
);
},
)
: Center(child: Text("Aucun menu disponible")),
),
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 12),
color: Colors.green[700],
child: Center(
child: TextButton(
onPressed: () {
// TODO: Naviguer vers la page panier
},
child: Text(
"Voir le panier (${_cart.length})",
style: TextStyle(color: Colors.white, fontSize: 16),
), ),
],
onChanged: (v) {
setState(() => selectedCategoryId = v);
_fetchPlats();
},
), ),
],
), ),
), ),
],
), ),
); if (isLoading)
} const Expanded(child: Center(child: CircularProgressIndicator()))
else
Widget buildCategoryButton(String label, int id) { Expanded(
final selected = _selectedCategory == id; child: ListView.builder(
return Expanded( itemCount: plats.length,
child: GestureDetector( padding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () => changeCategory(id), itemBuilder: (ctx, i) {
child: Container( final p = plats[i];
padding: EdgeInsets.symmetric(vertical: 10), return Card(
color: selected ? Colors.grey[300] : Colors.grey[100], margin: const EdgeInsets.only(bottom: 20),
child: Center( shape: RoundedRectangleBorder(
child: Text( borderRadius: BorderRadius.circular(10),
label,
style: TextStyle(
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
), ),
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,
}
// Modal pour ajouter au panier
class AddToCartModal extends StatefulWidget {
final dynamic item;
final Function(dynamic, int, String) onAddToCart;
const AddToCartModal({
Key? key,
required this.item,
required this.onAddToCart,
}) : super(key: key);
@override
State<AddToCartModal> createState() => _AddToCartModalState();
}
class _AddToCartModalState extends State<AddToCartModal> {
int _quantity = 1;
final TextEditingController _notesController = TextEditingController();
String formatPrix(dynamic prix) {
if (prix == null) return "0.00";
double? val;
if (prix is num) {
val = prix.toDouble();
} else if (prix is String) {
val = double.tryParse(prix);
}
return val != null ? val.toStringAsFixed(2) : "0.00";
}
double calculateTotal() {
double prix = 0.0;
if (widget.item['prix'] is num) {
prix = widget.item['prix'].toDouble();
} else if (widget.item['prix'] is String) {
prix = double.tryParse(widget.item['prix']) ?? 0.0;
}
return prix * _quantity;
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
padding: EdgeInsets.all(20),
constraints: BoxConstraints(maxWidth: 400),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
widget.item['nom'] ?? 'Menu', p.commentaire ?? "",
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 15),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.close),
padding: EdgeInsets.zero,
constraints: BoxConstraints(),
),
],
), ),
SizedBox(height: 16), if (p.ingredients != null &&
p.ingredients!.isNotEmpty)
// Image placeholder Padding(
Container( padding: const EdgeInsets.symmetric(
width: double.infinity, vertical: 3.0,
height: 150,
decoration: BoxDecoration(
color: Colors.green[100],
borderRadius: BorderRadius.circular(12),
), ),
child: Icon( child: Text(
Icons.restaurant_menu, p.ingredients!,
size: 60, style: const TextStyle(
color: Colors.green[700], color: Colors.grey,
fontSize: 13,
), ),
), ),
SizedBox(height: 16),
// Description
if (widget.item['commentaire'] != null &&
widget.item['commentaire'].toString().isNotEmpty)
Text(
widget.item['commentaire'],
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
), ),
SizedBox(height: 16),
// Prix unitaire
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( if (p.categories.isNotEmpty)
"Prix unitaire", Wrap(
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), spacing: 4,
runSpacing: 4,
children:
p.categories
.map(
(c) => CategoryChip(
label: c.nom,
color: Colors.black,
), ),
Text( )
"${formatPrix(widget.item['prix'])} MGA", .toList(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.green[700],
), ),
if (p.categories.isEmpty)
const CategoryChip(
label: "Catégorie",
color: Colors.black,
), ),
], ],
), ),
SizedBox(height: 20), ],
// Quantité
Text(
"Quantité",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed:
_quantity > 1
? () {
setState(() {
_quantity--;
});
}
: null,
icon: Icon(Icons.remove),
style: IconButton.styleFrom(
backgroundColor: Colors.grey[200],
), ),
), ),
SizedBox(width: 20), Expanded(
flex: 2,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text( Text(
_quantity.toString(), "${p.prix.toStringAsFixed(2)} MGA",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.green,
fontSize: 20,
), ),
SizedBox(width: 20),
IconButton(
onPressed: () {
setState(() {
_quantity++;
});
},
icon: Icon(Icons.add),
style: IconButton.styleFrom(
backgroundColor: Colors.grey[200],
), ),
const SizedBox(width: 10),
SizedBox(
height: 38,
width: 38,
child: IconButton(
icon: const Icon(
Icons.edit,
color: Colors.black54,
), ),
], onPressed: () => _showEditPlatDialog(p),
), ),
SizedBox(height: 20),
// Notes
Text(
"Notes (optionnel)",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
SizedBox(height: 8),
TextField(
controller: _notesController,
maxLines: 3,
decoration: InputDecoration(
hintText: "Commentaires spéciaux (sans oignons, bien cuit...)",
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
), ),
contentPadding: EdgeInsets.all(12), 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,
), ),
SizedBox(height: 24), child: const Text("Annuler"),
// Total et bouton d'ajout
Container(
width: double.infinity,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(12),
), ),
child: Column( ElevatedButton(
children: [ onPressed:
Row( () => Navigator.pop(
mainAxisAlignment: MainAxisAlignment.spaceBetween, context,
children: [ true,
Text(
"Total",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
), ),
style:
ElevatedButton.styleFrom(
backgroundColor:
Colors.red,
), ),
Text( child: const Text(
"${calculateTotal().toStringAsFixed(2)} MGA", "Supprimer",
style: TextStyle( style: TextStyle(
fontSize: 18, color: Colors.white,
fontWeight: FontWeight.bold, ),
color: Colors.green[700],
), ),
), ),
], ],
), ),
SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
widget.onAddToCart(
widget.item,
_quantity,
_notesController.text,
); );
Navigator.of(context).pop(); if (confirm == true) _deletePlat(p.id);
},
// Afficher un snackbar de confirmation ),
ScaffoldMessenger.of(context).showSnackBar( ),
SnackBar( ],
content: Text( ),
"${widget.item['nom']} ajouté au panier", ),
],
), ),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
), ),
); );
}, },
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green[700],
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
), ),
), ),
child: Text( ],
"Ajouter au panier",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
), ),
);
}
}
class MenuPlat {
final int id;
final String nom;
final String? commentaire;
final double prix;
final String? ingredients;
final int disponible = 1; // Default to true
final List<MenuCategory> categories; // NOW A LIST!
MenuPlat({
required this.id,
required this.nom,
this.commentaire,
required this.prix,
this.ingredients,
required this.categories,
});
factory MenuPlat.fromJson(Map<String, dynamic> 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'],
categories:
(json['categories'] as List? ?? [])
.map((c) => MenuCategory.fromJson(c))
.toList(),
);
}
}
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),
), ),
);
}
Future<void> navigateToCreate(
BuildContext context,
List<MenuCategory> categories,
Function()? onSaved,
) async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlatEditPage(categories: categories, onSaved: onSaved),
),
);
}
class EditPlatDialog extends StatefulWidget {
final MenuPlat plat;
final List<MenuCategory> categories;
final VoidCallback onPlatUpdated;
const EditPlatDialog({
super.key,
required this.plat,
required this.onPlatUpdated,
required this.categories,
});
@override
State<EditPlatDialog> createState() => _EditPlatDialogState();
}
class _EditPlatDialogState extends State<EditPlatDialog> {
late String nom;
late String commentaire;
late String ingredients;
late double prix;
// late bool disponible;
MenuCategory? cat;
final _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
nom = widget.plat.nom;
commentaire = widget.plat.commentaire ?? '';
ingredients = widget.plat.ingredients ?? '';
prix = widget.plat.prix;
var disponible = widget.plat.disponible;
// cat = (widget.plat.categories) as MenuCategory?;
cat =
widget.plat.categories.isNotEmpty ? widget.plat.categories.first : null;
}
Future<void> 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();
// ignore: use_build_context_synchronously
Navigator.pop(context);
}
}
@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,
),
TextFormField(
initialValue: commentaire,
onChanged: (v) => commentaire = v,
decoration: const InputDecoration(labelText: "Commentaire"),
),
TextFormField(
initialValue: ingredients,
onChanged: (v) => ingredients = v,
decoration: const InputDecoration(labelText: "Ingrédients"),
),
TextFormField(
initialValue: prix.toString(),
onChanged: (v) => prix = double.tryParse(v) ?? 0,
decoration: const InputDecoration(labelText: "Prix"),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
),
DropdownButton<MenuCategory>(
hint: const Text("Catégorie"),
value: cat,
isExpanded: true,
items:
widget.categories
.map(
(c) => DropdownMenuItem(value: c, child: Text(c.nom)),
)
.toList(),
onChanged: (v) => setState(() => cat = v),
), ),
], ],
), ),
), ),
],
), ),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Annuler"),
), ),
ElevatedButton(onPressed: submit, child: const Text("Enregistrer")),
],
); );
} }
}

5
lib/pages/tables.dart

@ -306,10 +306,7 @@ class _TablesScreenState extends State<TablesScreen> {
crossAxisCount: crossAxisCount, crossAxisCount: crossAxisCount,
crossAxisSpacing: 12, crossAxisSpacing: 12,
mainAxisSpacing: 12, mainAxisSpacing: 12,
childAspectRatio: childAspectRatio: isDesktop ? 1.7 : 2.1,
isDesktop
? 1.7
: 2.1,
), ),
itemCount: tables.length, itemCount: tables.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {

12
lib/widgets/bottom_navigation.dart

@ -155,12 +155,12 @@ class AppBottomNavigation extends StatelessWidget {
const SizedBox(width: 20), const SizedBox(width: 20),
GestureDetector( GestureDetector(
onTap: () => onItemTapped(3), onTap: () => onItemTapped(4),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color:
selectedIndex == 3 selectedIndex == 4
? Colors.green.shade700 ? Colors.green.shade700
: Colors.transparent, : Colors.transparent,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@ -171,21 +171,21 @@ class AppBottomNavigation extends StatelessWidget {
Icon( Icon(
Icons.restaurant_menu, Icons.restaurant_menu,
color: color:
selectedIndex == 3 selectedIndex == 4
? Colors.white ? Colors.white
: Colors.grey.shade600, : Colors.grey.shade600,
size: 16, size: 16,
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'Menu', 'Plats',
style: TextStyle( style: TextStyle(
color: color:
selectedIndex == 3 selectedIndex == 4
? Colors.white ? Colors.white
: Colors.grey.shade600, : Colors.grey.shade600,
fontWeight: fontWeight:
selectedIndex == 3 selectedIndex == 4
? FontWeight.w500 ? FontWeight.w500
: FontWeight.normal, : FontWeight.normal,
), ),

Loading…
Cancel
Save