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"; import "../services/auth_storage.dart"; import "chat_screen.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; } /// Decode virtual ID back to real ItemID /// Virtual IDs are formatted as: menuItemID * 100000 + realItemID int _decodeVirtualId(int id) { if (id > 100000) { return id % 100000; } return id; } @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; } bool _isCallingServer = false; /// Show bottom sheet with choice: Server Visit or Chat (dine-in) or just Chat (non-dine-in) Future _handleCallServer(AppState appState) async { if (_businessId == null) return; // For non-dine-in without a service point, use 0 as placeholder final servicePointId = _servicePointId ?? 0; // Check for active chat first int? activeTaskId; try { activeTaskId = await Api.getActiveChat( businessId: _businessId!, servicePointId: servicePointId, ); } catch (e) { // Continue without active chat } if (!mounted) return; final isDineIn = appState.isDineIn; showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (context) => SafeArea( child: Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 40, height: 4, margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(2), ), ), Text( isDineIn ? 'How can we help?' : 'Contact Us', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 16), // Only show "Request Server Visit" for dine-in orders if (isDineIn && _servicePointId != null) ...[ ListTile( leading: const CircleAvatar( backgroundColor: Colors.orange, child: Icon(Icons.room_service, color: Colors.white), ), title: const Text('Request Server Visit'), subtitle: const Text('Staff will come to your table'), onTap: () { Navigator.pop(context); _sendServerRequest(appState); }, ), const Divider(), ], // Show either "Rejoin Chat" OR "Chat with Staff" - never both if (activeTaskId != null) ListTile( leading: const CircleAvatar( backgroundColor: Colors.green, child: Icon(Icons.chat_bubble, color: Colors.white), ), title: const Text('Rejoin Chat'), subtitle: const Text('Continue your conversation'), onTap: () { Navigator.pop(context); _rejoinChat(activeTaskId!); }, ) else ListTile( leading: const CircleAvatar( backgroundColor: Colors.blue, child: Icon(Icons.chat, color: Colors.white), ), title: const Text('Chat with Staff'), subtitle: const Text('Send a message to our team'), onTap: () { Navigator.pop(context); _startChat(appState); }, ), ], ), ), ), ); } /// Check if user is logged in, prompt login if not /// Returns true if logged in, false if user needs to log in Future _ensureLoggedIn() async { final auth = await AuthStorage.loadAuth(); if (auth != null && auth.userId > 0) { return true; } if (!mounted) return false; // Show login prompt final shouldLogin = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Sign In Required'), content: const Text('Please sign in to use the chat feature.'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.pop(context, true), child: const Text('Sign In'), ), ], ), ); if (shouldLogin == true && mounted) { Navigator.pushNamed(context, AppRoutes.login); } return false; } /// Rejoin an existing active chat Future _rejoinChat(int taskId) async { if (!await _ensureLoggedIn()) return; if (!mounted) return; Navigator.push( context, MaterialPageRoute( builder: (context) => ChatScreen( taskId: taskId, userType: 'customer', ), ), ); } /// Send a server visit request (ping) Future _sendServerRequest(AppState appState) async { if (_isCallingServer) return; setState(() => _isCallingServer = true); try { await Api.callServer( businessId: _businessId!, servicePointId: _servicePointId!, orderId: appState.cartOrderId, userId: appState.userId, ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Row( children: [ Icon(Icons.check_circle, color: Colors.black), SizedBox(width: 8), Expanded(child: Text("Server has been notified", style: TextStyle(color: Colors.black))), ], ), backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Row( children: [ const Icon(Icons.error, color: Colors.black), const SizedBox(width: 8), Expanded(child: Text("Failed to call server: $e", style: const TextStyle(color: Colors.black))), ], ), backgroundColor: Colors.red.shade100, behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } finally { if (mounted) { setState(() => _isCallingServer = false); } } } /// Start a new chat with staff Future _startChat(AppState appState) async { if (_isCallingServer) return; // Check login first if (!await _ensureLoggedIn()) return; setState(() => _isCallingServer = true); try { // Reload auth to get userId final auth = await AuthStorage.loadAuth(); final userId = auth?.userId; // Create new chat final taskId = await Api.createChatTask( businessId: _businessId!, servicePointId: _servicePointId!, orderId: appState.cartOrderId, userId: userId, ); if (!mounted) return; // Navigate to chat screen Navigator.push( context, MaterialPageRoute( builder: (context) => ChatScreen( taskId: taskId, userType: 'customer', ), ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Row( children: [ const Icon(Icons.error, color: Colors.black), const SizedBox(width: 8), Expanded(child: Text("Failed to start chat: $e", style: const TextStyle(color: Colors.black))), ], ), backgroundColor: Colors.red.shade100, behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } finally { if (mounted) { setState(() => _isCallingServer = false); } } } void _organizeItems() { _itemsByCategory.clear(); _itemsByParent.clear(); _categorySortOrder.clear(); _categoryNames.clear(); // First pass: identify category items (root items where itemId == categoryId) final categoryItemIds = {}; for (final item in _allItems) { if (item.isRootItem && item.itemId == item.categoryId) { categoryItemIds.add(item.itemId); _itemsByCategory.putIfAbsent(item.itemId, () => []); _categorySortOrder[item.itemId] = item.sortOrder; _categoryNames[item.itemId] = item.name; } } // Second pass: organize menu items and modifiers for (final item in _allItems) { if (!item.isActive) continue; if (categoryItemIds.contains(item.itemId)) continue; if (categoryItemIds.contains(item.parentItemId)) { _itemsByCategory.putIfAbsent(item.parentItemId, () => []).add(item); } else { if (item.itemId != item.parentItemId) { _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(); // 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), ), // Only show table name for dine-in orders (beacon detected) if (appState.isDineIn && appState.selectedServicePointName != null) Text( appState.selectedServicePointName!, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.normal, ), ), ], ), ), ], ), actions: [ // Call Server (dine-in) or Chat (non-dine-in) button IconButton( icon: Icon(appState.isDineIn ? Icons.room_service : Icons.chat_bubble_outline), tooltip: appState.isDineIn ? "Call Server" : "Chat", onPressed: () => _handleCallServer(appState), ), IconButton( icon: Badge( label: Text("${appState.cartItemCount}"), isLabelVisible: appState.cartItemCount > 0, child: const Icon(Icons.shopping_cart), ), onPressed: () { Navigator.of(context).pushNamed(AppRoutes.cartView); }, ), IconButton( icon: const Icon(Icons.person_outline), tooltip: "Account", onPressed: () { Navigator.of(context).pushNamed(AppRoutes.account); }, ), ], ), 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; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildCategoryHeader(categoryId, categoryName), // Animated expand/collapse for items AnimatedCrossFade( firstChild: const SizedBox.shrink(), secondChild: Container( // Slightly darker background to distinguish from category bar color: const Color(0xFFF0F0F0), child: Column( children: [ // Top gradient transition from category bar Container( height: 12, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ const Color(0xFF1B4D3E).withAlpha(60), const Color(0xFFF0F0F0), ], ), ), ), ...items.map((item) => _buildMenuItem(item)), // Bottom fade-out gradient to show end of expanded section Container( height: 24, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ const Color(0xFFF0F0F0), const Color(0xFF1B4D3E).withAlpha(60), ], ), ), ), ], ), ), 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 - styled text only (no images) Widget _buildCategoryBackground(int categoryId, String categoryName) { 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, quantity) { Navigator.pop(context); _addToCart(item, selectedItemIds, quantity: quantity); }, ), ); } Future _addToCart(MenuItem item, Set selectedModifierIds, {int quantity = 1}) 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( SnackBar( content: const Row( children: [ Icon(Icons.warning, color: Colors.black), SizedBox(width: 8), Text("Missing required information", style: TextStyle(color: Colors.black)), ], ), backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); return; } try { final appState = context.read(); // Get or create cart Cart cart; if (appState.cartOrderId == null) { // Determine order type: 1=dine-in (beacon), 0=undecided (no beacon, will choose at checkout) final orderTypeId = appState.isDineIn ? 1 : 0; cart = await Api.getOrCreateCart( userId: _userId!, businessId: _businessId!, servicePointId: _servicePointId!, orderTypeId: orderTypeId, ); 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!); // If cart is not in cart status (0), it's been submitted - create a new cart if (cart.statusId != 0) { debugPrint('Cart has been submitted (status=${cart.statusId}), creating new cart'); appState.clearCart(); final orderTypeId = appState.isDineIn ? 1 : 0; cart = await Api.getOrCreateCart( userId: _userId!, businessId: _businessId!, servicePointId: _servicePointId!, orderTypeId: orderTypeId, ); appState.setCartOrder( orderId: cart.orderId, orderUuid: cart.orderUuid, itemCount: cart.itemCount, ); } else if (appState.isDineIn && cart.orderTypeId == 0) { // If we're dine-in (beacon detected) but cart has no order type set, update it cart = await Api.setOrderType( orderId: cart.orderId, orderTypeId: 1, // dine-in ); } // Note: Dine-in orders (orderTypeId=1) stay as dine-in - no order type selection needed } // For items with customizations, always create a new line item // For items without customizations, increment quantity of existing item if (selectedModifierIds.isEmpty) { // No customizations - find existing and increment quantity final existingItem = cart.lineItems.where( (li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted ).firstOrNull; final newQuantity = (existingItem?.quantity ?? 0) + 1; cart = await Api.setLineItem( orderId: cart.orderId, parentOrderLineItemId: 0, itemId: item.itemId, isSelected: true, quantity: newQuantity, ); } else { // Has customizations - always create a new line item with specified quantity // Use a special flag or approach to force new line item creation cart = await Api.setLineItem( orderId: cart.orderId, parentOrderLineItemId: 0, itemId: item.itemId, isSelected: true, quantity: quantity, forceNew: true, ); } // 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 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 (${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: Row( children: [ const Icon(Icons.check_circle, color: Colors.black), const SizedBox(width: 8), Expanded(child: Text(message, style: const TextStyle(color: Colors.black))), ], ), backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Row( children: [ const Icon(Icons.error, color: Colors.black), const SizedBox(width: 8), Expanded(child: Text("Error adding to cart: $e", style: const TextStyle(color: Colors.black))), ], ), backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } } Future _addModifiersRecursively( int orderId, int parentOrderLineItemId, int parentItemId, Set selectedItemIds, ) async { final children = _itemsByParent[parentItemId] ?? []; 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); // The cart returns real ItemIDs, but child.itemId may be a virtual ID // Decode the virtual ID to match against the cart's real ItemID final realChildItemId = _decodeVirtualId(child.itemId); if (isSelected) { final cart = await Api.setLineItem( orderId: orderId, parentOrderLineItemId: parentOrderLineItemId, itemId: child.itemId, isSelected: true, ); final childLineItem = cart.lineItems.lastWhere( (li) => li.itemId == realChildItemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted, orElse: () => throw StateError('Failed to add item'), ); if (hasGrandchildren) { await _addModifiersRecursively( orderId, childLineItem.orderLineItemId, child.itemId, selectedItemIds, ); } } else if (hasSelectedDescendants) { final cart = await Api.setLineItem( orderId: orderId, parentOrderLineItemId: parentOrderLineItemId, itemId: child.itemId, isSelected: true, ); final childLineItem = cart.lineItems.lastWhere( (li) => li.itemId == realChildItemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted, orElse: () => throw StateError('Failed to add item'), ); await _addModifiersRecursively( orderId, childLineItem.orderLineItemId, child.itemId, selectedItemIds, ); } } } /// 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, int) onAdd; // (selectedModifierIds, quantity) 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; int _quantity = 1; @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 (multiplied by quantity) double _calculateTotal() { double unitPrice = widget.item.price; void addPriceRecursively(int itemId) { final children = widget.itemsByParent[itemId] ?? []; for (final child in children) { if (_selectedItemIds.contains(child.itemId)) { unitPrice += child.price; addPriceRecursively(child.itemId); } } } addPriceRecursively(widget.item.itemId); return unitPrice * _quantity; } /// Validate selections before adding to cart bool _validate() { setState(() => _validationError = null); // Helper to check if a modifier group has any selected descendants bool hasSelectedDescendant(int itemId) { if (_selectedItemIds.contains(itemId)) return true; final children = widget.itemsByParent[itemId] ?? []; return children.any((c) => hasSelectedDescendant(c.itemId)); } bool validateRecursive(int parentId, MenuItem parent) { final children = widget.itemsByParent[parentId] ?? []; if (children.isEmpty) return true; // A child is "selected" if it's directly selected OR if it's a group with selected descendants final selectedChildren = children.where((c) => _selectedItemIds.contains(c.itemId) || hasSelectedDescendant(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 = {}; for (final itemId in _selectedItemIds) { final parentId = _findParentId(itemId); final isDefault = _defaultItemIds.contains(itemId); final groupWasModified = parentId != null && _userModifiedGroups.contains(parentId); // Include if: not a default, OR user modified this group if (!isDefault || groupWasModified) { itemsToSubmit.add(itemId); } } widget.onAdd(itemsToSubmit, _quantity); } } /// 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, ), ), ), ], ), ), ], Row( children: [ // Quantity selector Container( decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey.shade300), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.remove, size: 20), onPressed: _quantity > 1 ? () => setState(() => _quantity--) : null, visualDensity: VisualDensity.compact, padding: const EdgeInsets.all(8), ), Container( constraints: const BoxConstraints(minWidth: 32), alignment: Alignment.center, child: Text( "$_quantity", style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), ), IconButton( icon: const Icon(Icons.add, size: 20), onPressed: () => setState(() => _quantity++), visualDensity: VisualDensity.compact, padding: const EdgeInsets.all(8), ), ], ), ), const SizedBox(width: 12), // Add to cart button Expanded( child: SizedBox( height: 52, 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(12), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.add_shopping_cart, size: 20), const SizedBox(width: 8), Text( "Add", style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, ), ), const SizedBox(width: 8), 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; } }