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 = {}; final Map _categorySortOrder = {}; // categoryId -> sortOrder final Map _categoryNames = {}; // categoryId -> categoryName // Track which category is currently expanded (null = none) int? _expandedCategoryId; 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(); _categorySortOrder.clear(); _categoryNames.clear(); print('[MenuBrowse] _organizeItems: ${_allItems.length} total items'); // First pass: identify category items (root items where itemId == categoryId) // These are the category headers themselves, NOT menu items final categoryItemIds = {}; for (final item in _allItems) { if (item.isRootItem && item.itemId == item.categoryId) { categoryItemIds.add(item.itemId); // Just register the category key (empty list for now) _itemsByCategory.putIfAbsent(item.itemId, () => []); // Store the sort order and name for this category _categorySortOrder[item.itemId] = item.sortOrder; _categoryNames[item.itemId] = item.name; print('[MenuBrowse] Category found: ${item.name} (ID=${item.itemId}, sortOrder=${item.sortOrder})'); } } print('[MenuBrowse] Found ${categoryItemIds.length} categories: $categoryItemIds'); // Second pass: organize menu items and modifiers for (final item in _allItems) { // Skip inactive items if (!item.isActive) continue; // Skip category header items (they're not menu items to display) if (categoryItemIds.contains(item.itemId)) continue; // Check if parent is a category if (categoryItemIds.contains(item.parentItemId)) { // Direct child of a category = menu item (goes in _itemsByCategory) _itemsByCategory.putIfAbsent(item.parentItemId, () => []).add(item); print('[MenuBrowse] Menu item: ${item.name} -> category ${item.parentItemId}'); } else { // Child of a menu item = modifier (goes in _itemsByParent) if (item.itemId != item.parentItemId) { _itemsByParent.putIfAbsent(item.parentItemId, () => []).add(item); print('[MenuBrowse] Modifier: ${item.name} -> parent ${item.parentItemId}'); } } } // 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)); } // Debug: print final counts for (final entry in _itemsByCategory.entries) { print('[MenuBrowse] Category ${entry.key}: ${entry.value.length} items'); } } List _getUniqueCategoryIds() { final categoryIds = _itemsByCategory.keys.toList(); // Sort by sortOrder (from _categorySortOrder), not by ItemID categoryIds.sort((a, b) { final orderA = _categorySortOrder[a] ?? 0; final orderB = _categorySortOrder[b] ?? 0; return orderA.compareTo(orderB); }); return categoryIds; } @override Widget build(BuildContext context) { final appState = context.watch(); final businessName = appState.selectedBusinessName ?? "Menu"; return Scaffold( appBar: AppBar( title: Row( children: [ // Business logo if (_businessId != null) Padding( padding: const EdgeInsets.only(right: 12), child: ClipRRect( borderRadius: BorderRadius.circular(6), child: SizedBox( width: 36, height: 36, child: Image.network( "$_imageBaseUrl/logos/$_businessId.png", fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return Image.network( "$_imageBaseUrl/logos/$_businessId.jpg", fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(6), ), child: Icon( Icons.store, size: 20, color: Theme.of(context).colorScheme.onPrimaryContainer, ), ); }, ); }, ), ), ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( businessName, style: const TextStyle(fontSize: 18), ), if (appState.selectedServicePointName != null) Text( appState.selectedServicePointName!, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.normal, ), ), ], ), ), ], ), actions: [ IconButton( icon: const Icon(Icons.table_restaurant), tooltip: "Change Table", onPressed: () { // Prevent changing tables if there's an active order (dine and dash prevention) if (appState.hasActiveOrder) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text("Cannot Change Table"), content: const Text("Please complete or cancel your current order before changing tables."), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text("OK"), ), ], ), ); return; } Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); }, ), 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 + 1, // +1 for header itemBuilder: (context, index) { // First item is the business header if (index == 0) { return _buildBusinessHeader(); } final categoryIndex = index - 1; final categoryId = categoryIds[categoryIndex]; final items = _itemsByCategory[categoryId] ?? []; // Use stored category name from the category item itself final categoryName = _categoryNames[categoryId] ?? "Category $categoryId"; final isExpanded = _expandedCategoryId == categoryId; // Debug: Print which items are being shown for which category if (items.isNotEmpty) { print('[MenuBrowse] DISPLAY: Category "$categoryName" (ID=$categoryId) showing items: ${items.map((i) => i.name).join(", ")}'); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildCategoryHeader(categoryId, categoryName), // Animated expand/collapse for items AnimatedCrossFade( firstChild: const SizedBox.shrink(), secondChild: Column( children: items.map((item) => _buildMenuItem(item)).toList(), ), crossFadeState: isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, duration: const Duration(milliseconds: 300), sizeCurve: Curves.easeInOut, ), ], ); }, ); }, ), ); } static const String _imageBaseUrl = "https://biz.payfrit.com/uploads"; Widget _buildBusinessHeader() { if (_businessId == null) return const SizedBox.shrink(); final appState = context.read(); final businessName = appState.selectedBusinessName ?? "Restaurant"; return Container( width: double.infinity, height: 180, child: Stack( fit: StackFit.expand, children: [ // Header background image Image.network( "$_imageBaseUrl/headers/$_businessId.png", fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return Image.network( "$_imageBaseUrl/headers/$_businessId.jpg", fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { // No header image - show gradient background return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ Theme.of(context).colorScheme.primary, Theme.of(context).colorScheme.secondary, ], ), ), ); }, ); }, ), // Top edge gradient Positioned( top: 0, left: 0, right: 0, height: 16, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withAlpha(180), Colors.black.withAlpha(0), ], ), ), ), ), // Bottom edge gradient Positioned( bottom: 0, left: 0, right: 0, height: 16, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withAlpha(0), Colors.black.withAlpha(200), ], ), ), ), ), ], ), ); } Widget _buildItemImage(int itemId) { return Container( width: 90, height: 90, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.network( "$_imageBaseUrl/items/$itemId.png", fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { // Try jpg if png fails return Image.network( "$_imageBaseUrl/items/$itemId.jpg", fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Colors.grey.shade200, Colors.grey.shade300], ), ), child: Center( child: Icon( Icons.restaurant_menu, color: Colors.grey.shade500, size: 36, ), ), ); }, ); }, ), ), ); } /// Builds category background - tries image first, falls back to styled text Widget _buildCategoryBackground(int categoryId, String categoryName) { return Image.network( "$_imageBaseUrl/categories/$categoryId.png", fit: BoxFit.cover, semanticLabel: categoryName, errorBuilder: (context, error, stackTrace) { return Image.network( "$_imageBaseUrl/categories/$categoryId.jpg", fit: BoxFit.cover, semanticLabel: categoryName, errorBuilder: (context, error, stackTrace) { // No image - show white background with dark forest green text const darkForestGreen = Color(0xFF1B4D3E); return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.white, Colors.grey.shade100, ], ), ), alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 24), child: Text( categoryName, textAlign: TextAlign.center, style: const TextStyle( fontSize: 28, fontWeight: FontWeight.bold, color: darkForestGreen, letterSpacing: 1.2, shadows: [ Shadow( offset: Offset(1, 1), blurRadius: 2, color: Colors.black26, ), ], ), ), ); }, ); }, ); } Widget _buildCategoryHeader(int categoryId, String categoryName) { final isExpanded = _expandedCategoryId == categoryId; return Semantics( label: categoryName, button: true, child: GestureDetector( onTap: () { setState(() { // Toggle: if already expanded, collapse; otherwise expand this one _expandedCategoryId = isExpanded ? null : categoryId; }); }, child: Container( width: double.infinity, height: 120, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, ), child: Stack( fit: StackFit.expand, children: [ // Category image background or styled text fallback _buildCategoryBackground(categoryId, categoryName), // Top edge gradient (subtle forest green) Positioned( top: 0, left: 0, right: 0, height: 16, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ const Color(0xFF1B4D3E).withAlpha(120), const Color(0xFF1B4D3E).withAlpha(0), ], ), ), ), ), // Bottom edge gradient (subtle forest green) Positioned( bottom: 0, left: 0, right: 0, height: 16, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ const Color(0xFF1B4D3E).withAlpha(0), const Color(0xFF1B4D3E).withAlpha(150), ], ), ), ), ), ], ), ), ), ); } Widget _buildMenuItem(MenuItem item) { final hasModifiers = _itemsByParent.containsKey(item.itemId); return Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.08), blurRadius: 12, offset: const Offset(0, 4), ), BoxShadow( color: Colors.black.withOpacity(0.04), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(16), onTap: () { if (hasModifiers) { _showItemCustomization(item); } else { _addToCart(item, {}); } }, child: Padding( padding: const EdgeInsets.all(12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Item image with shadow _buildItemImage(item.itemId), const SizedBox(width: 14), // Item details Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.name, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), if (item.description.isNotEmpty) ...[ const SizedBox(height: 4), Text( item.description, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], if (hasModifiers) ...[ const SizedBox(height: 4), Text( "Customizable", style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w500, ), ), ], ], ), ), const SizedBox(width: 12), // Price and add button Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( "\$${item.price.toStringAsFixed(2)}", style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary, ), ), const SizedBox(height: 4), Icon( Icons.add_circle, color: Theme.of(context).colorScheme.primary, size: 20, ), ], ), ], ), ), ), ), ); } 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 { // 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 ); 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!); } // Check if this item already exists in the cart (as a root item) final existingItem = cart.lineItems.where( (li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted ).firstOrNull; final newQuantity = (existingItem?.quantity ?? 0) + 1; // Add root item (or update quantity if it exists) cart = await Api.setLineItem( orderId: cart.orderId, parentOrderLineItemId: 0, itemId: item.itemId, isSelected: true, quantity: newQuantity, ); // Find the OrderLineItemID of the root item we just added 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}'), ); // Add all selected modifiers recursively print('[MenuBrowse] Adding ${selectedModifierIds.length} modifiers to root item OrderLineItemID=${rootLineItem.orderLineItemId}'); await _addModifiersRecursively( cart.orderId, rootLineItem.orderLineItemId, item.itemId, selectedModifierIds, ); // Refresh cart to get final state print('[MenuBrowse] Refreshing cart to get final state'); cart = await Api.getCart(orderId: cart.orderId); print('[MenuBrowse] Final cart has ${cart.lineItems.length} total line items'); appState.updateCartItemCount(cart.itemCount); if (!mounted) return; final message = selectedModifierIds.isEmpty ? "Added ${item.name} to cart (${cart.itemCount} item${cart.itemCount == 1 ? '' : 's'})" : "Added ${item.name} with ${selectedModifierIds.length} customizations (${cart.itemCount} item${cart.itemCount == 1 ? '' : 's'})"; 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] ?? []; print('[MenuBrowse] _addModifiersRecursively: parentItemId=$parentItemId has ${children.length} children'); print('[MenuBrowse] selectedItemIds passed in: $selectedItemIds'); for (final child in children) { final isSelected = selectedItemIds.contains(child.itemId); final grandchildren = _itemsByParent[child.itemId] ?? []; final hasGrandchildren = grandchildren.isNotEmpty; final hasSelectedDescendants = _hasSelectedDescendants(child.itemId, selectedItemIds); print('[MenuBrowse] Child ${child.name} (ItemID=${child.itemId}): selected=$isSelected, hasChildren=$hasGrandchildren, hasSelectedDescendants=$hasSelectedDescendants'); // Only add this item if it's explicitly selected // Container items (parents) should only be added if they themselves are in selectedItemIds // This prevents default items from being submitted when the user hasn't modified them if (isSelected) { print('[MenuBrowse] ADDING selected item ${child.name} with ParentOrderLineItemID=$parentOrderLineItemId'); final cart = await Api.setLineItem( orderId: orderId, parentOrderLineItemId: parentOrderLineItemId, itemId: child.itemId, isSelected: true, ); // Find the OrderLineItemID of this item 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}'), ); // Recursively add children with this item as the new parent if (hasGrandchildren) { await _addModifiersRecursively( orderId, childLineItem.orderLineItemId, child.itemId, selectedItemIds, ); } } else if (hasSelectedDescendants) { // This item itself is not selected, but it has selected descendants // We need to add it as a container to maintain hierarchy print('[MenuBrowse] ADDING container item ${child.name} (has selected descendants) with ParentOrderLineItemID=$parentOrderLineItemId'); final cart = await Api.setLineItem( orderId: orderId, parentOrderLineItemId: parentOrderLineItemId, itemId: child.itemId, isSelected: true, ); // Find the OrderLineItemID of this item 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}'), ); // Recursively add children with this item as the new parent await _addModifiersRecursively( orderId, childLineItem.orderLineItemId, child.itemId, selectedItemIds, ); } else { print('[MenuBrowse] SKIPPING ${child.name} (not selected, no selected descendants)'); } } } /// Check if any descendants of this item are selected bool _hasSelectedDescendants(int itemId, Set selectedItemIds) { final children = _itemsByParent[itemId] ?? []; for (final child in children) { if (selectedItemIds.contains(child.itemId)) { return true; } if (_hasSelectedDescendants(child.itemId, selectedItemIds)) { return true; } } return false; } } /// 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 = {}; final Set _defaultItemIds = {}; // Track which items were defaults (not user-selected) final Set _userModifiedGroups = {}; // Track which parent groups user has interacted with String? _validationError; @override void initState() { super.initState(); _initializeDefaults(widget.item.itemId); } /// Recursively initialize default selections for the ENTIRE tree /// This ensures defaults are pre-selected even for nested items void _initializeDefaults(int parentId) { final children = widget.itemsByParent[parentId] ?? []; for (final child in children) { if (child.isCheckedByDefault) { _selectedItemIds.add(child.itemId); _defaultItemIds.add(child.itemId); } // Always recurse into all children to find nested defaults _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 ALL children that are modifier groups (have their own children) // This ensures we check required selections in nested modifier groups for (final child in children) { final hasGrandchildren = widget.itemsByParent.containsKey(child.itemId) && (widget.itemsByParent[child.itemId]?.isNotEmpty ?? false); if (hasGrandchildren) { // This is a modifier group - always validate it if (!validateRecursive(child.itemId, child)) { return false; } } else if (_selectedItemIds.contains(child.itemId)) { // This is a leaf option that's selected - validate its children (if any) if (!validateRecursive(child.itemId, child)) { return false; } } } return true; } return validateRecursive(widget.item.itemId, widget.item); } void _handleAdd() { if (_validate()) { // Filter out default items in groups that user never modified final itemsToSubmit = {}; print('[Customization] ========== FILTERING LOGIC =========='); print('[Customization] All selected items: $_selectedItemIds'); print('[Customization] Default items: $_defaultItemIds'); print('[Customization] User-modified groups: $_userModifiedGroups'); for (final itemId in _selectedItemIds) { // Find which parent group this item belongs to final parentId = _findParentId(itemId); final isDefault = _defaultItemIds.contains(itemId); final groupWasModified = parentId != null && _userModifiedGroups.contains(parentId); print('[Customization] Item $itemId: isDefault=$isDefault, parentId=$parentId, groupWasModified=$groupWasModified'); // Include if: not a default, OR user modified this group if (!isDefault || groupWasModified) { print('[Customization] -> INCLUDED (not default or group was modified)'); itemsToSubmit.add(itemId); } else { print('[Customization] -> EXCLUDED (is default and group was not modified)'); } } print('[Customization] Final items to submit: $itemsToSubmit'); print('[Customization] ====================================='); widget.onAdd(itemsToSubmit); } } /// Find which parent contains this item int? _findParentId(int itemId) { for (final entry in widget.itemsByParent.entries) { if (entry.value.any((item) => item.itemId == itemId)) { return entry.key; } } return null; } @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 with item image Container( decoration: BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 2), ), ], ), child: Column( children: [ // Drag handle Center( child: Container( width: 40, height: 4, margin: const EdgeInsets.only(top: 12), decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(2), ), ), ), Padding( padding: const EdgeInsets.all(20), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Item image Container( width: 100, height: 100, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 4), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(16), child: Image.network( "https://biz.payfrit.com/uploads/items/${widget.item.itemId}.png", fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return Image.network( "https://biz.payfrit.com/uploads/items/${widget.item.itemId}.jpg", fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Colors.blue.shade100, Colors.blue.shade200], ), ), child: Center( child: Icon( Icons.restaurant_menu, color: Colors.blue.shade400, size: 40, ), ), ); }, ); }, ), ), ), const SizedBox(width: 16), // Item details Expanded( 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, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey.shade600, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], const SizedBox(height: 12), Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( color: Colors.green.shade50, borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.green.shade200), ), child: Text( "\$${widget.item.price.toStringAsFixed(2)}", style: TextStyle( color: Colors.green.shade700, fontWeight: FontWeight.bold, fontSize: 16, ), ), ), ], ), ), ], ), ), ], ), ), // 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.fromLTRB(20, 16, 20, 20), decoration: BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 10, offset: const Offset(0, -4), ), ], ), 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(12), border: Border.all(color: Colors.red.shade200), ), child: Row( children: [ Icon(Icons.error_outline, color: Colors.red.shade600, size: 20), const SizedBox(width: 10), Expanded( child: Text( _validationError!, style: TextStyle( color: Colors.red.shade800, fontSize: 13, ), ), ), ], ), ), ], SizedBox( width: double.infinity, height: 56, child: ElevatedButton( onPressed: _handleAdd, style: ElevatedButton.styleFrom( backgroundColor: Colors.blue.shade600, foregroundColor: Colors.white, elevation: 2, shadowColor: Colors.blue.shade200, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.add_shopping_cart, size: 22), const SizedBox(width: 10), Text( "Add to Cart", style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), borderRadius: BorderRadius.circular(12), ), child: Text( "\$${_calculateTotal().toStringAsFixed(2)}", style: const TextStyle( fontSize: 15, fontWeight: FontWeight.bold, ), ), ), ], ), ), ), ], ), ), ), ], ); }, ); } /// 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) { final isSelected = _selectedItemIds.contains(item.itemId); return Padding( padding: EdgeInsets.only(left: depth * 16.0, bottom: 8), child: Material( color: isSelected ? Colors.blue.shade50 : Colors.grey.shade50, borderRadius: BorderRadius.circular(12), child: InkWell( borderRadius: BorderRadius.circular(12), onTap: () => _toggleSelection(item, parent), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all( color: isSelected ? Colors.blue.shade300 : Colors.grey.shade200, width: isSelected ? 2 : 1, ), ), child: Row( children: [ _buildSelectionWidget(item, parent), const SizedBox(width: 12), Expanded( child: Text( item.name, style: TextStyle( fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, color: isSelected ? Colors.blue.shade800 : Colors.black87, ), ), ), if (item.price > 0) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.green.shade50, borderRadius: BorderRadius.circular(8), ), child: Text( "+\$${item.price.toStringAsFixed(2)}", style: TextStyle( color: Colors.green.shade700, fontWeight: FontWeight.w600, fontSize: 13, ), ), ), ], ), ), ), ), ); } Widget _buildSelectionWidget(MenuItem item, MenuItem parent) { // If this item has children, it's a container/category - don't show selection widget final hasChildren = widget.itemsByParent.containsKey(item.itemId) && (widget.itemsByParent[item.itemId]?.isNotEmpty ?? false); if (hasChildren) { return const SizedBox(width: 48); // Maintain spacing alignment } final isSelected = _selectedItemIds.contains(item.itemId); final siblings = widget.itemsByParent[parent.itemId] ?? []; // Determine if this should behave as a radio button group: // 1. Explicit: maxNumSelectionReq == 1 // 2. Inferred: Group has exactly one default-checked item (implies single selection) final isRadioGroup = parent.maxNumSelectionReq == 1 || (siblings.where((s) => s.isCheckedByDefault).length == 1 && siblings.every((s) => !s.requiresChildSelection)); if (isRadioGroup) { 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; // Mark this parent group as user-modified _userModifiedGroups.add(parent.itemId); final isCurrentlySelected = _selectedItemIds.contains(item.itemId); final siblings = widget.itemsByParent[parent.itemId] ?? []; // Determine if this should behave as a radio button group: // 1. Explicit: maxNumSelectionReq == 1 // 2. Inferred: Group has exactly one default-checked item (implies single selection) final isRadioGroup = parent.maxNumSelectionReq == 1 || (siblings.where((s) => s.isCheckedByDefault).length == 1 && siblings.every((s) => !s.requiresChildSelection)); // For radio buttons, deselect siblings and always select the clicked item if (isRadioGroup) { for (final sibling in siblings) { _selectedItemIds.remove(sibling.itemId); _deselectDescendants(sibling.itemId); } // Always select the clicked item (radio buttons can't be deselected) _selectedItemIds.add(item.itemId); return; } // For checkboxes, allow toggle on/off 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; } }