import "package:flutter/material.dart"; import "package:provider/provider.dart"; import "../app/app_router.dart"; import "../app/app_state.dart"; import "../models/cart.dart"; import "../models/menu_item.dart"; import "../services/api.dart"; class MenuBrowseScreen extends StatefulWidget { const MenuBrowseScreen({super.key}); @override State createState() => _MenuBrowseScreenState(); } class _MenuBrowseScreenState extends State { Future>? _future; int? _businessId; int? _servicePointId; int? _userId; List _allItems = []; final Map> _itemsByCategory = {}; final Map> _itemsByParent = {}; int? _asIntNullable(dynamic v) { if (v == null) return null; if (v is int) return v; if (v is num) return v.toInt(); if (v is String) { final s = v.trim(); if (s.isEmpty) return null; return int.tryParse(s); } return null; } @override void didChangeDependencies() { super.didChangeDependencies(); final appState = context.watch(); final u = appState.userId; final args = ModalRoute.of(context)?.settings.arguments; if (args is Map) { final b = _asIntNullable(args["BusinessID"]) ?? _asIntNullable(args["businessId"]); final sp = _asIntNullable(args["ServicePointID"]) ?? _asIntNullable(args["servicePointId"]); if (_businessId != b || _servicePointId != sp || _userId != u || _future == null) { _businessId = b; _servicePointId = sp; _userId = u; if (_businessId != null && _businessId! > 0) { _future = _loadMenu(); } else { _future = Future.value([]); } } } } Future> _loadMenu() async { final items = await Api.listMenuItems(businessId: _businessId!); setState(() { _allItems = items; _organizeItems(); }); return items; } void _organizeItems() { _itemsByCategory.clear(); _itemsByParent.clear(); for (final item in _allItems) { if (item.isRootItem) { _itemsByCategory.putIfAbsent(item.categoryId, () => []).add(item); } else { _itemsByParent.putIfAbsent(item.parentItemId, () => []).add(item); } } // Sort items within each category by sortOrder for (final list in _itemsByCategory.values) { list.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); } // Sort modifiers within each parent for (final list in _itemsByParent.values) { list.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); } } List _getUniqueCategoryIds() { final categoryIds = _itemsByCategory.keys.toList(); categoryIds.sort(); return categoryIds; } @override Widget build(BuildContext context) { final appState = context.watch(); return Scaffold( appBar: AppBar( title: const Text("Menu"), actions: [ IconButton( icon: Badge( label: Text("${appState.cartItemCount}"), isLabelVisible: appState.cartItemCount > 0, child: const Icon(Icons.shopping_cart), ), onPressed: () { Navigator.of(context).pushNamed(AppRoutes.cartView); }, ), ], ), body: (_businessId == null || _businessId! <= 0) ? const Padding( padding: EdgeInsets.all(16), child: Text("Missing BusinessID"), ) : FutureBuilder>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return Padding( padding: const EdgeInsets.all(16), child: Column( children: [ Text("Error loading menu: ${snapshot.error}"), const SizedBox(height: 16), ElevatedButton( onPressed: () => setState(() => _future = _loadMenu()), child: const Text("Retry"), ), ], ), ); } if (_allItems.isEmpty) { return const Padding( padding: EdgeInsets.all(16), child: Text("No menu items available"), ); } final categoryIds = _getUniqueCategoryIds(); return ListView.builder( itemCount: categoryIds.length, itemBuilder: (context, index) { final categoryId = categoryIds[index]; final items = _itemsByCategory[categoryId] ?? []; final categoryName = items.isNotEmpty ? items.first.categoryName : "Category $categoryId"; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( categoryName, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), ), ...items.map((item) => _buildMenuItem(item)), const Divider(height: 32), ], ); }, ); }, ), ); } Widget _buildMenuItem(MenuItem item) { final hasModifiers = _itemsByParent.containsKey(item.itemId); return ListTile( title: Text(item.name), subtitle: item.description.isNotEmpty ? Text( item.description, maxLines: 2, overflow: TextOverflow.ellipsis, ) : null, trailing: Text( "\$${item.price.toStringAsFixed(2)}", style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), onTap: () { if (hasModifiers) { _showItemCustomization(item); } else { _addToCart(item, {}); } }, ); } void _showItemCustomization(MenuItem item) { showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => _ItemCustomizationSheet( item: item, itemsByParent: _itemsByParent, onAdd: (selectedItemIds) { Navigator.pop(context); _addToCart(item, selectedItemIds); }, ), ); } Future _addToCart(MenuItem item, Set selectedModifierIds) async { // ignore: avoid_print print("DEBUG: _addToCart called for item ${item.name} (ItemID=${item.itemId})"); print("DEBUG: Selected modifier IDs: $selectedModifierIds"); // Check if user is logged in - if not, navigate to login if (_userId == null) { final shouldLogin = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text("Login Required"), content: const Text("Please login to add items to your cart."), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text("Cancel"), ), FilledButton( onPressed: () => Navigator.pop(context, true), child: const Text("Login"), ), ], ), ); if (shouldLogin == true && mounted) { Navigator.of(context).pushNamed(AppRoutes.login); } return; } if (_businessId == null || _servicePointId == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text("Missing required information")), ); return; } try { final appState = context.read(); // Get or create cart Cart cart; if (appState.cartOrderId == null) { cart = await Api.getOrCreateCart( userId: _userId!, businessId: _businessId!, servicePointId: _servicePointId!, orderTypeId: 1, // Dine-in ); // ignore: avoid_print print("DEBUG: Created cart with orderId=${cart.orderId}"); appState.setCartOrder( orderId: cart.orderId, orderUuid: cart.orderUuid, itemCount: cart.itemCount, ); } else { // We have an existing cart ID cart = await Api.getCart(orderId: appState.cartOrderId!); // ignore: avoid_print print("DEBUG: Loaded existing cart with orderId=${cart.orderId}"); } // ignore: avoid_print print("DEBUG: About to add root item ${item.itemId} to cart ${cart.orderId}"); // Add root item cart = await Api.setLineItem( orderId: cart.orderId, parentOrderLineItemId: 0, itemId: item.itemId, isSelected: true, quantity: 1, ); // ignore: avoid_print print("DEBUG: Added root item, cart now has ${cart.lineItems.length} line items"); // Find the OrderLineItemID of the root item we just added // ignore: avoid_print print("DEBUG: Looking for root item with ItemID=${item.itemId} in ${cart.lineItems.length} line items"); print("DEBUG: Line items: ${cart.lineItems.map((li) => 'ID=${li.orderLineItemId}, ItemID=${li.itemId}, ParentID=${li.parentOrderLineItemId}').join(', ')}"); final rootLineItem = cart.lineItems.lastWhere( (li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted, orElse: () => throw StateError('Root line item not found for ItemID=${item.itemId}'), ); // ignore: avoid_print print("DEBUG: Root item found - OrderLineItemID=${rootLineItem.orderLineItemId}"); // Add all selected modifiers recursively await _addModifiersRecursively( cart.orderId, rootLineItem.orderLineItemId, item.itemId, selectedModifierIds, ); // Refresh cart to get final state cart = await Api.getCart(orderId: cart.orderId); appState.updateCartItemCount(cart.itemCount); if (!mounted) return; final message = selectedModifierIds.isEmpty ? "Added ${item.name} to cart" : "Added ${item.name} with ${selectedModifierIds.length} customizations"; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message)), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Error adding to cart: $e")), ); } } Future _addModifiersRecursively( int orderId, int parentOrderLineItemId, int parentItemId, Set selectedItemIds, ) async { final children = _itemsByParent[parentItemId] ?? []; // ignore: avoid_print print("DEBUG: _addModifiersRecursively called with ParentItemID=$parentItemId, ParentOrderLineItemID=$parentOrderLineItemId"); print("DEBUG: Found ${children.length} children for ItemID=$parentItemId"); print("DEBUG: Children ItemIDs: ${children.map((c) => c.itemId).join(', ')}"); print("DEBUG: Selected ItemIDs: ${selectedItemIds.join(', ')}"); for (final child in children) { final isSelected = selectedItemIds.contains(child.itemId); // ignore: avoid_print print("DEBUG: Processing child ItemID=${child.itemId} (${child.name}), isSelected=$isSelected"); // Add this modifier with the correct parent OrderLineItemID final cart = await Api.setLineItem( orderId: orderId, parentOrderLineItemId: parentOrderLineItemId, itemId: child.itemId, isSelected: isSelected, ); // ignore: avoid_print print("DEBUG: setLineItem response: cart has ${cart.lineItems.length} line items"); // Recursively add grandchildren if this modifier was selected if (isSelected) { // Find the OrderLineItemID of this modifier we just added final childLineItem = cart.lineItems.lastWhere( (li) => li.itemId == child.itemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted, orElse: () => throw StateError('Child line item not found for ItemID=${child.itemId}'), ); // ignore: avoid_print print("DEBUG: Child modifier OrderLineItemID=${childLineItem.orderLineItemId}"); await _addModifiersRecursively( orderId, childLineItem.orderLineItemId, child.itemId, selectedItemIds, ); } } } } /// Recursive item customization sheet with full rule support class _ItemCustomizationSheet extends StatefulWidget { final MenuItem item; final Map> itemsByParent; final Function(Set) onAdd; const _ItemCustomizationSheet({ required this.item, required this.itemsByParent, required this.onAdd, }); @override State<_ItemCustomizationSheet> createState() => _ItemCustomizationSheetState(); } class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> { final Set _selectedItemIds = {}; String? _validationError; @override void initState() { super.initState(); _initializeDefaults(widget.item.itemId); } /// Recursively initialize default selections void _initializeDefaults(int parentId) { final children = widget.itemsByParent[parentId] ?? []; for (final child in children) { if (child.isCheckedByDefault) { _selectedItemIds.add(child.itemId); _initializeDefaults(child.itemId); } } } /// Calculate total price including all selected items recursively double _calculateTotal() { double total = widget.item.price; void addPriceRecursively(int itemId) { final children = widget.itemsByParent[itemId] ?? []; for (final child in children) { if (_selectedItemIds.contains(child.itemId)) { total += child.price; addPriceRecursively(child.itemId); } } } addPriceRecursively(widget.item.itemId); return total; } /// Validate selections before adding to cart bool _validate() { setState(() => _validationError = null); bool validateRecursive(int parentId, MenuItem parent) { final children = widget.itemsByParent[parentId] ?? []; if (children.isEmpty) return true; final selectedChildren = children.where((c) => _selectedItemIds.contains(c.itemId)).toList(); // Check if child selection is required if (parent.requiresChildSelection && selectedChildren.isEmpty) { setState(() => _validationError = "Please select an option for: ${parent.name}"); return false; } // Check max selection limit if (parent.maxNumSelectionReq > 0 && selectedChildren.length > parent.maxNumSelectionReq) { setState(() => _validationError = "${parent.name}: Max ${parent.maxNumSelectionReq} selections allowed"); return false; } // Recursively validate selected children for (final child in selectedChildren) { if (!validateRecursive(child.itemId, child)) { return false; } } return true; } return validateRecursive(widget.item.itemId, widget.item); } void _handleAdd() { if (_validate()) { widget.onAdd(_selectedItemIds); } } @override Widget build(BuildContext context) { return DraggableScrollableSheet( initialChildSize: 0.75, minChildSize: 0.5, maxChildSize: 0.95, expand: false, builder: (context, scrollController) { return Column( children: [ // Header Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, boxShadow: [ BoxShadow( color: Colors.black.withAlpha(25), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.item.name, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), if (widget.item.description.isNotEmpty) ...[ const SizedBox(height: 8), Text(widget.item.description), ], const SizedBox(height: 8), Text( "Base price: \$${widget.item.price.toStringAsFixed(2)}", style: const TextStyle(fontWeight: FontWeight.w500), ), ], ), ), // Scrollable content Expanded( child: ListView( controller: scrollController, padding: const EdgeInsets.all(16), children: _buildModifierTree(widget.item.itemId, 0), ), ), // Footer with validation error and add button Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, boxShadow: [ BoxShadow( color: Colors.black.withAlpha(25), blurRadius: 4, offset: const Offset(0, -2), ), ], ), child: SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (_validationError != null) ...[ Container( padding: const EdgeInsets.all(12), margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( color: Colors.red.shade50, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.red.shade300), ), child: Row( children: [ Icon(Icons.error_outline, color: Colors.red.shade700), const SizedBox(width: 12), Expanded( child: Text( _validationError!, style: TextStyle(color: Colors.red.shade900), ), ), ], ), ), ], SizedBox( width: double.infinity, child: FilledButton( onPressed: _handleAdd, child: Text("Add to Cart - \$${_calculateTotal().toStringAsFixed(2)}"), ), ), ], ), ), ), ], ); }, ); } /// Recursively build modifier tree List _buildModifierTree(int parentId, int depth) { final children = widget.itemsByParent[parentId] ?? []; if (children.isEmpty) return []; final parent = _findItemById(parentId); if (parent == null) return []; final widgets = []; for (final child in children) { final hasGrandchildren = widget.itemsByParent.containsKey(child.itemId); if (hasGrandchildren && child.isCollapsible) { // Collapsible section with ExpansionTile widgets.add(_buildExpansionTile(child, parent, depth)); } else { // Regular checkbox/radio item widgets.add(_buildSelectableItem(child, parent, depth)); // Recursively add grandchildren if (hasGrandchildren && _selectedItemIds.contains(child.itemId)) { widgets.addAll(_buildModifierTree(child.itemId, depth + 1)); } } } return widgets; } Widget _buildExpansionTile(MenuItem item, MenuItem parent, int depth) { final isSelected = _selectedItemIds.contains(item.itemId); return Padding( padding: EdgeInsets.only(left: depth * 16.0), child: ExpansionTile( title: Text(item.name), subtitle: item.price > 0 ? Text("+\$${item.price.toStringAsFixed(2)}") : null, initiallyExpanded: isSelected || item.isCheckedByDefault, leading: _buildSelectionWidget(item, parent), children: _buildModifierTree(item.itemId, depth + 1), ), ); } Widget _buildSelectableItem(MenuItem item, MenuItem parent, int depth) { return Padding( padding: EdgeInsets.only(left: depth * 16.0), child: ListTile( leading: _buildSelectionWidget(item, parent), title: Text(item.name), subtitle: item.price > 0 ? Text("+\$${item.price.toStringAsFixed(2)}") : null, onTap: () => _toggleSelection(item, parent), ), ); } Widget _buildSelectionWidget(MenuItem item, MenuItem parent) { final isSelected = _selectedItemIds.contains(item.itemId); // Radio button if max selection is 1 if (parent.maxNumSelectionReq == 1) { return Radio( value: item.itemId, groupValue: _getSelectedInGroup(parent.itemId), onChanged: (_) => _toggleSelection(item, parent), ); } // Checkbox for multiple or unlimited selections return Checkbox( value: isSelected, onChanged: (_) => _toggleSelection(item, parent), ); } int? _getSelectedInGroup(int parentId) { final children = widget.itemsByParent[parentId] ?? []; for (final child in children) { if (_selectedItemIds.contains(child.itemId)) { return child.itemId; } } return null; } void _toggleSelection(MenuItem item, MenuItem parent) { setState(() { _validationError = null; final isCurrentlySelected = _selectedItemIds.contains(item.itemId); // For radio buttons (max = 1), deselect siblings if (parent.maxNumSelectionReq == 1) { final siblings = widget.itemsByParent[parent.itemId] ?? []; for (final sibling in siblings) { _selectedItemIds.remove(sibling.itemId); _deselectDescendants(sibling.itemId); } } if (isCurrentlySelected) { // Deselect this item and all descendants _selectedItemIds.remove(item.itemId); _deselectDescendants(item.itemId); } else { // Check max selection limit if (parent.maxNumSelectionReq > 0) { final siblings = widget.itemsByParent[parent.itemId] ?? []; final selectedSiblings = siblings.where((s) => _selectedItemIds.contains(s.itemId)).length; if (selectedSiblings >= parent.maxNumSelectionReq) { _validationError = "${parent.name}: Max ${parent.maxNumSelectionReq} selections allowed"; return; } } // Select this item _selectedItemIds.add(item.itemId); } }); } void _deselectDescendants(int parentId) { final children = widget.itemsByParent[parentId] ?? []; for (final child in children) { _selectedItemIds.remove(child.itemId); _deselectDescendants(child.itemId); } } MenuItem? _findItemById(int itemId) { for (final list in widget.itemsByParent.values) { for (final item in list) { if (item.itemId == itemId) return item; } } return widget.item.itemId == itemId ? widget.item : null; } }