import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../app/app_state.dart'; import '../main.dart' show rootScaffoldMessengerKey; import '../models/cart.dart'; import '../models/menu_item.dart'; import '../services/api.dart'; import '../services/order_polling_service.dart'; import '../services/stripe_service.dart'; import '../widgets/rescan_button.dart'; /// Helper class to store modifier breadcrumb paths class ModifierPath { final List names; final double price; const ModifierPath({ required this.names, required this.price, }); } class CartViewScreen extends StatefulWidget { const CartViewScreen({super.key}); @override State createState() => _CartViewScreenState(); } class _CartViewScreenState extends State { Cart? _cart; bool _isLoading = true; bool _isProcessingPayment = false; String? _error; Map _menuItemsById = {}; // Order type selection (for delivery/takeaway - when orderTypeId is 0) // 2 = Takeaway, 3 = Delivery int? _selectedOrderType; // Delivery address selection List _addresses = []; DeliveryAddress? _selectedAddress; bool _loadingAddresses = false; // Tip options as percentages (null = custom) static const List _tipPercentages = [0, 15, 18, 20, null]; int _selectedTipIndex = 1; // Default to 15% int _customTipPercent = 25; // Default custom tip if selected /// Whether the cart needs order type selection (delivery/takeaway) bool get _needsOrderTypeSelection => _cart != null && _cart!.orderTypeId == 0; /// Whether delivery is selected and needs address bool get _needsDeliveryAddress => _selectedOrderType == 3; /// Whether we can proceed to payment (order type selected if needed) bool get _canProceedToPayment { if (_cart == null || _cart!.itemCount == 0) return false; if (_needsOrderTypeSelection && _selectedOrderType == null) return false; if (_needsDeliveryAddress && _selectedAddress == null) return false; return true; } /// Get the effective delivery fee to display and charge /// - If order type is already set to delivery (3), use the order's delivery fee /// - If user selected delivery but hasn't confirmed, show the business's preview fee double get _effectiveDeliveryFee { if (_cart == null) return 0.0; // Order already confirmed as delivery if (_cart!.orderTypeId == 3) return _cart!.deliveryFee; // User selected delivery (preview) if (_selectedOrderType == 3) return _cart!.businessDeliveryFee; return 0.0; } double get _tipAmount { if (_cart == null) return 0.0; final percent = _tipPercentages[_selectedTipIndex]; if (percent == null) { // Custom tip return _cart!.subtotal * (_customTipPercent / 100); } return _cart!.subtotal * (percent / 100); } FeeBreakdown get _feeBreakdown { if (_cart == null) { return const FeeBreakdown( subtotal: 0, tax: 0, tip: 0, deliveryFee: 0, payfritFee: 0, cardFee: 0, total: 0, ); } return StripeService.calculateFees( subtotal: _cart!.subtotal, tax: _cart!.tax, tip: _tipAmount, deliveryFee: _effectiveDeliveryFee, ); } @override void initState() { super.initState(); _loadCart(); } Future _loadCart() async { setState(() { _isLoading = true; _error = null; }); try { final appState = context.read(); final cartOrderId = appState.cartOrderId; if (cartOrderId == null) { setState(() { _isLoading = false; _cart = null; }); return; } // Load cart var cart = await Api.getCart(orderId: cartOrderId); // If cart is not in cart status (0), it's been submitted - clear it and show empty cart if (cart.statusId != 0) { debugPrint('Cart has been submitted (status=${cart.statusId}), clearing cart reference'); appState.clearCart(); setState(() { _cart = null; _isLoading = false; }); return; } // If we're dine-in (beacon detected) but cart has no order type set, update it if (appState.isDineIn && cart.orderTypeId == 0) { try { cart = await Api.setOrderType( orderId: cart.orderId, orderTypeId: 1, // dine-in ); } catch (e) { // Log error but continue - cart will show order type selection if this fails debugPrint('Failed to update order type to dine-in: $e'); } } // Note: Dine-in orders (orderTypeId=1) stay as dine-in - no order type selection needed // Load menu items to get names and prices // Use cart's businessId if appState doesn't have one (e.g., delivery/takeaway flow) final businessId = appState.selectedBusinessId ?? cart.businessId; if (businessId > 0) { final result = await Api.listMenuItems(businessId: businessId); _menuItemsById = {for (var item in result.items) item.itemId: item}; } setState(() { _cart = cart; _isLoading = false; }); // Update item count in app state appState.updateCartItemCount(cart.itemCount); // If cart needs order type selection, pre-load addresses if (cart.orderTypeId == 0) { _loadDeliveryAddresses(); } } catch (e) { // If cart not found (deleted or doesn't exist), clear it from app state if (e.toString().contains('not_found') || e.toString().contains('Order not found')) { final appState = context.read(); appState.clearCart(); setState(() { _cart = null; _isLoading = false; }); } else { setState(() { _error = e.toString(); _isLoading = false; }); } } } Future _loadDeliveryAddresses() async { setState(() => _loadingAddresses = true); try { final addresses = await Api.getDeliveryAddresses(); if (mounted) { setState(() { _addresses = addresses; _loadingAddresses = false; // Auto-select default address if available final defaultAddr = addresses.where((a) => a.isDefault).firstOrNull; if (defaultAddr != null && _selectedAddress == null) { _selectedAddress = defaultAddr; } }); } } catch (e) { if (mounted) { setState(() => _loadingAddresses = false); } } } Future _removeLineItem(OrderLineItem lineItem) async { try { final appState = context.read(); final cartOrderId = appState.cartOrderId; if (cartOrderId == null) return; setState(() => _isLoading = true); // Set IsSelected=false to remove the item await Api.setLineItem( orderId: cartOrderId, parentOrderLineItemId: lineItem.parentOrderLineItemId, itemId: lineItem.itemId, isSelected: false, ); // Reload cart await _loadCart(); } catch (e) { setState(() { _error = e.toString(); _isLoading = false; }); } } Future _updateQuantity(OrderLineItem lineItem, int newQuantity) async { if (newQuantity < 1) return; try { final appState = context.read(); final cartOrderId = appState.cartOrderId; if (cartOrderId == null) return; setState(() => _isLoading = true); await Api.setLineItem( orderId: cartOrderId, parentOrderLineItemId: lineItem.parentOrderLineItemId, itemId: lineItem.itemId, isSelected: true, quantity: newQuantity, remark: lineItem.remark, ); // Reload cart await _loadCart(); } catch (e) { setState(() { _error = e.toString(); _isLoading = false; }); } } Future _showCustomTipDialog() async { final controller = TextEditingController(text: _customTipPercent.toString()); final result = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text("Custom Tip"), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: controller, keyboardType: TextInputType.number, autofocus: true, decoration: const InputDecoration( labelText: "Tip Percentage", suffixText: "%", hintText: "0-200", ), ), const SizedBox(height: 8), Text( "Enter a tip percentage from 0% to 200%", style: TextStyle(fontSize: 12, color: Colors.grey.shade600), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text("Cancel"), ), TextButton( onPressed: () { final value = int.tryParse(controller.text) ?? 0; final clampedValue = value.clamp(0, 200); Navigator.pop(context, clampedValue); }, child: const Text("Apply"), ), ], ), ); if (result != null) { setState(() { _customTipPercent = result; _selectedTipIndex = _tipPercentages.length - 1; // Select "Custom" }); } } Future _processPaymentAndSubmit() async { if (_cart == null) return; final appState = context.read(); final cartOrderId = appState.cartOrderId; // Use cart's businessId if appState doesn't have one (delivery/takeaway without beacon) final businessId = appState.selectedBusinessId ?? _cart!.businessId; if (cartOrderId == null || businessId <= 0) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text("Error: Missing order or business information", style: TextStyle(color: Colors.black)), backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); return; } // Ensure order type is selected for delivery/takeaway orders if (_needsOrderTypeSelection && _selectedOrderType == null) { // Build appropriate message based on what's offered String message = "Please select an order type"; if (_cart!.offersTakeaway && _cart!.offersDelivery) { message = "Please select Delivery or Takeaway"; } else if (_cart!.offersTakeaway) { message = "Please select Takeaway"; } else if (_cart!.offersDelivery) { message = "Please select Delivery"; } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: 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), ), ); return; } // Ensure delivery address is selected for delivery orders if (_needsDeliveryAddress && _selectedAddress == null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text("Please select a delivery address", style: TextStyle(color: Colors.black)), backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); return; } setState(() => _isProcessingPayment = true); try { // 0. Set order type if needed (delivery/takeaway) if (_needsOrderTypeSelection && _selectedOrderType != null) { final updatedCart = await Api.setOrderType( orderId: cartOrderId, orderTypeId: _selectedOrderType!, addressId: _selectedAddress?.addressId, ); setState(() => _cart = updatedCart); } // 1. Process payment with Stripe final paymentResult = await StripeService.processPayment( context: context, businessId: businessId, orderId: cartOrderId, subtotal: _cart!.subtotal, tax: _cart!.tax, tip: _tipAmount, ); if (!paymentResult.success) { if (!mounted) return; setState(() => _isProcessingPayment = false); if (paymentResult.error != 'Payment cancelled') { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Row( children: [ const Icon(Icons.error, color: Colors.black), const SizedBox(width: 8), Expanded(child: Text(paymentResult.error ?? 'Payment failed', style: const TextStyle(color: Colors.black))), ], ), backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } return; } // 2. Payment successful, now submit the order await Api.submitOrder(orderId: cartOrderId); // Set active order for polling (status 1 = submitted) appState.setActiveOrder(orderId: cartOrderId, statusId: 1); // Start polling for status updates OrderPollingService.startPolling( orderId: cartOrderId, initialStatusId: 1, onStatusUpdate: (update) { // Update app state appState.updateActiveOrderStatus(update.statusId); // Clear active order if terminal state (4=Complete, 5=Cancelled) if (update.statusId >= 4) { appState.clearActiveOrder(); } // Show snackbar notification with Payfrit light green rootScaffoldMessengerKey.currentState?.showSnackBar( SnackBar( content: Row( children: [ const Icon(Icons.notifications_active, color: Colors.black), const SizedBox(width: 8), Expanded( child: Text( '${update.statusName}: ${update.message}', style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w500), ), ), ], ), backgroundColor: const Color(0xFF90EE90), // Payfrit light green duration: const Duration(seconds: 5), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); }, ); // Clear cart state appState.clearCart(); if (!mounted) return; // Show success message ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Row( children: [ Icon(Icons.check_circle, color: Colors.black), SizedBox(width: 8), Expanded( child: Text( "Payment successful! Order placed. You'll receive notifications as your order is prepared.", style: TextStyle(color: Colors.black), ), ), ], ), backgroundColor: const Color(0xFF90EE90), duration: const Duration(seconds: 5), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); // Navigate back Navigator.of(context).pop(); } catch (e) { if (!mounted) return; setState(() => _isProcessingPayment = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Row( children: [ const Icon(Icons.error, color: Colors.black), const SizedBox(width: 8), Expanded(child: Text('Error: ${e.toString()}', style: const TextStyle(color: Colors.black))), ], ), backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } } Color _getStatusColor(int statusId) => _getStatusColorStatic(statusId); static Color _getStatusColorStatic(int statusId) { switch (statusId) { case 1: // Submitted return Colors.blue; case 2: // Preparing return Colors.orange; case 3: // Ready return Colors.green; case 4: // Completed return Colors.purple; default: return Colors.grey; } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text("Cart"), backgroundColor: Colors.black, foregroundColor: Colors.white, actions: const [ RescanButton(iconColor: Colors.white), ], ), body: _isLoading ? const Center(child: CircularProgressIndicator()) : _error != null ? Center( child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.error, color: Colors.red, size: 48), const SizedBox(height: 16), Text( "Error loading cart", style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text(_error!, textAlign: TextAlign.center, maxLines: 3, overflow: TextOverflow.ellipsis), const SizedBox(height: 16), ElevatedButton( onPressed: _loadCart, child: const Text("Retry"), ), ], ), ), ) : _cart == null || _cart!.itemCount == 0 ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( Icons.shopping_cart_outlined, size: 80, color: Colors.grey, ), const SizedBox(height: 16), Text( "Your cart is empty", style: Theme.of(context).textTheme.titleLarge, ), ], ), ) : Column( children: [ Expanded( child: _ScrollableCartList( children: _buildCartItems(), ), ), _buildCartSummary(), ], ), ); } List _buildCartItems() { if (_cart == null) return []; // Group line items by root items final rootItems = _cart!.lineItems .where((item) => item.parentOrderLineItemId == 0 && !item.isDeleted) .toList(); final widgets = []; for (final rootItem in rootItems) { widgets.add(_buildRootItemCard(rootItem)); widgets.add(const SizedBox(height: 12)); } return widgets; } Widget _buildRootItemCard(OrderLineItem rootItem) { // Use itemName from line item (from API), fall back to menu item lookup, then to ID final menuItem = _menuItemsById[rootItem.itemId]; final itemName = rootItem.itemName ?? menuItem?.name ?? "Item #${rootItem.itemId}"; // Find ALL modifiers (recursively) and build breadcrumb paths for leaf items only final modifierPaths = _buildModifierPaths(rootItem.orderLineItemId); // Calculate total price for this line item (root + all modifiers) final lineItemTotal = _calculateLineItemTotal(rootItem); final hasModifiers = modifierPaths.isNotEmpty; return Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Main row: quantity, name, price, delete Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ // Quantity badge Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.blue.shade50, borderRadius: BorderRadius.circular(6), border: Border.all(color: Colors.blue.shade200), ), child: Text( "${rootItem.quantity}x", style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Colors.blue.shade700, ), ), ), const SizedBox(width: 10), // Item name Expanded( child: Text( itemName, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), ), // Price Text( "\$${lineItemTotal.toStringAsFixed(2)}", style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), const SizedBox(width: 4), // Delete button IconButton( icon: Icon(Icons.close, color: Colors.grey.shade500, size: 20), onPressed: () => _confirmRemoveItem(rootItem, itemName), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), ), ], ), // Modifiers accordion (if any) if (hasModifiers) _ModifierAccordion( modifierPaths: modifierPaths, itemId: rootItem.orderLineItemId, ), ], ), ), ); } /// Calculate the total price for a root item including all its modifiers double _calculateLineItemTotal(OrderLineItem rootItem) { double total = rootItem.price * rootItem.quantity; total += _sumModifierPrices(rootItem.orderLineItemId, rootItem.quantity); return total; } /// Recursively sum modifier prices for a parent line item double _sumModifierPrices(int parentOrderLineItemId, int rootQuantity) { double total = 0.0; final children = _cart!.lineItems.where( (item) => !item.isDeleted && item.parentOrderLineItemId == parentOrderLineItemId ); for (final child in children) { // Modifier price is multiplied by root item quantity total += child.price * rootQuantity; // Recursively add grandchildren modifier prices total += _sumModifierPrices(child.orderLineItemId, rootQuantity); } return total; } /// Build breadcrumb paths for all leaf modifiers /// Excludes default items - they don't need to be shown in the cart List _buildModifierPaths(int rootOrderLineItemId) { final paths = []; // Get direct children of root final directChildren = _cart!.lineItems .where((item) => item.parentOrderLineItemId == rootOrderLineItemId && !item.isDeleted) .toList(); // Recursively collect leaf items with their paths void collectLeafPaths(OrderLineItem item, String? lastGroupName) { // Skip default items - they don't need to be repeated in the cart if (item.isCheckedByDefault) { return; } final children = _cart!.lineItems .where((child) => child.parentOrderLineItemId == item.orderLineItemId && !child.isDeleted) .toList(); // Use itemName from line item, fall back to menu item lookup final menuItem = _menuItemsById[item.itemId]; final itemName = item.itemName ?? menuItem?.name ?? "Item #${item.itemId}"; if (children.isEmpty) { // This is a leaf - show "GroupName: Selection" format // Use the last group name we saw, or the item's parent name final groupName = lastGroupName ?? item.itemParentName; final displayName = groupName != null && groupName.isNotEmpty ? "$groupName: $itemName" : itemName; paths.add(ModifierPath( names: [displayName], price: item.price, )); } else { // This is a group/category - pass its name down to children for (final child in children) { collectLeafPaths(child, itemName); } } } for (final child in directChildren) { collectLeafPaths(child, null); } return paths; } Widget _buildModifierPathRow(ModifierPath path) { final displayText = path.names.join(' > '); return Padding( padding: const EdgeInsets.only(left: 16, top: 4), child: Row( children: [ const Icon(Icons.add, size: 12, color: Colors.grey), const SizedBox(width: 4), Expanded( child: Text( displayText, style: const TextStyle( fontSize: 14, color: Colors.grey, ), ), ), if (path.price > 0) Text( "+\$${path.price.toStringAsFixed(2)}", style: const TextStyle( fontSize: 14, color: Colors.grey, ), ), ], ), ); } Widget _buildCartSummary() { if (_cart == null) return const SizedBox.shrink(); final fees = _feeBreakdown; return Container( decoration: BoxDecoration( color: Colors.grey[100], boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 4, offset: const Offset(0, -2), ), ], ), // Constrain max height so it doesn't push content off screen constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.6, ), padding: const EdgeInsets.all(16), child: SafeArea( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Order Type Selection (only for delivery/takeaway orders) if (_needsOrderTypeSelection) ...[ Row( children: [ // Only show Takeaway if business offers it if (_cart!.offersTakeaway) Expanded( child: _buildOrderTypeButton( label: "Takeaway", icon: Icons.shopping_bag_outlined, orderTypeId: 2, ), ), // Add spacing only if both are shown if (_cart!.offersTakeaway && _cart!.offersDelivery) const SizedBox(width: 12), // Only show Delivery if business offers it if (_cart!.offersDelivery) Expanded( child: _buildOrderTypeButton( label: "Delivery", icon: Icons.delivery_dining, orderTypeId: 3, ), ), ], ), const SizedBox(height: 16), const Divider(height: 1), const SizedBox(height: 12), ], // Delivery Address Selection (only when Delivery is selected) if (_needsDeliveryAddress) ...[ const Text( "Delivery Address", style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), const SizedBox(height: 8), _buildAddressSelector(), const SizedBox(height: 16), const Divider(height: 1), const SizedBox(height: 12), ], // Tip Selection const Text( "Add a tip", style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Row( children: List.generate(_tipPercentages.length, (index) { final isSelected = _selectedTipIndex == index; final percent = _tipPercentages[index]; final isCustom = percent == null; return Expanded( child: Padding( padding: EdgeInsets.only( left: index == 0 ? 0 : 4, right: index == _tipPercentages.length - 1 ? 0 : 4, ), child: GestureDetector( onTap: () { if (isCustom) { _showCustomTipDialog(); } else { setState(() => _selectedTipIndex = index); } }, child: Container( padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: isSelected ? Colors.black : Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected ? Colors.black : Colors.grey.shade300, ), ), child: Center( child: Text( isCustom ? (isSelected ? "$_customTipPercent%" : "Custom") : (percent == 0 ? "No tip" : "$percent%"), style: TextStyle( fontSize: 14, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, color: isSelected ? Colors.white : Colors.black, ), ), ), ), ), ), ); }), ), const SizedBox(height: 16), const Divider(height: 1), const SizedBox(height: 12), // Subtotal _buildSummaryRow("Subtotal", fees.subtotal), const SizedBox(height: 6), // Tax _buildSummaryRow("Tax (8.25%)", fees.tax), // Show delivery fee: either the confirmed fee (orderTypeId == 3) or preview when Delivery selected if (_effectiveDeliveryFee > 0) ...[ const SizedBox(height: 6), _buildSummaryRow("Delivery Fee", _effectiveDeliveryFee), ], // Tip if (_tipAmount > 0) ...[ const SizedBox(height: 6), _buildSummaryRow("Tip", _tipAmount), ], const SizedBox(height: 6), // Payfrit fee _buildSummaryRow("Service Fee", fees.payfritFee, isGrey: true), const SizedBox(height: 6), // Card processing fee _buildSummaryRow("Card Processing", fees.cardFee, isGrey: true), const SizedBox(height: 12), const Divider(height: 1), const SizedBox(height: 12), // Total Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( "Total", style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), Text( "\$${fees.total.toStringAsFixed(2)}", style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: ElevatedButton( onPressed: (_canProceedToPayment && !_isProcessingPayment) ? _processPaymentAndSubmit : null, style: ElevatedButton.styleFrom( backgroundColor: Colors.black, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), disabledBackgroundColor: Colors.grey, ), child: _isProcessingPayment ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white), ), ) : Text( _needsOrderTypeSelection && _selectedOrderType == null ? "Select order type to continue" : "Pay \$${fees.total.toStringAsFixed(2)}", style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), ), ), ], ), ), ), ); } Widget _buildAddressSelector() { if (_loadingAddresses) { return const Center( child: Padding( padding: EdgeInsets.all(16), child: CircularProgressIndicator(), ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Show existing addresses as selectable options if (_addresses.isNotEmpty) ...[ ..._addresses.map((addr) => _buildAddressOption(addr)), const SizedBox(height: 8), ], // Add new address button OutlinedButton.icon( onPressed: _showAddAddressDialog, icon: const Icon(Icons.add_location_alt), label: const Text("Add New Address"), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), ), ], ); } Widget _buildAddressOption(DeliveryAddress addr) { final isSelected = _selectedAddress?.addressId == addr.addressId; return GestureDetector( onTap: () => setState(() => _selectedAddress = addr), child: Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: isSelected ? Colors.black : Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected ? Colors.black : Colors.grey.shade300, width: isSelected ? 2 : 1, ), ), child: Row( children: [ Icon( Icons.location_on, color: isSelected ? Colors.white : Colors.grey.shade600, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( addr.label, style: TextStyle( fontWeight: FontWeight.w600, color: isSelected ? Colors.white : Colors.black, ), ), const SizedBox(height: 2), Text( addr.displayText, style: TextStyle( fontSize: 13, color: isSelected ? Colors.white70 : Colors.grey.shade600, ), ), ], ), ), if (addr.isDefault) Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: isSelected ? Colors.white24 : Colors.blue.shade50, borderRadius: BorderRadius.circular(4), ), child: Text( "Default", style: TextStyle( fontSize: 11, color: isSelected ? Colors.white : Colors.blue, fontWeight: FontWeight.w500, ), ), ), ], ), ), ); } Future _showAddAddressDialog() async { final line1Controller = TextEditingController(); final line2Controller = TextEditingController(); final cityController = TextEditingController(); final zipController = TextEditingController(); int selectedStateId = 5; // Default to California (CA) bool setAsDefault = _addresses.isEmpty; // Default if first address final result = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (context, setDialogState) => AlertDialog( title: const Text("Add Delivery Address"), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: line1Controller, decoration: const InputDecoration( labelText: "Street Address *", hintText: "123 Main St", ), textCapitalization: TextCapitalization.words, ), const SizedBox(height: 12), TextField( controller: line2Controller, decoration: const InputDecoration( labelText: "Apt, Suite, etc. (optional)", hintText: "Apt 4B", ), textCapitalization: TextCapitalization.words, ), const SizedBox(height: 12), TextField( controller: cityController, decoration: const InputDecoration( labelText: "City *", hintText: "Los Angeles", ), textCapitalization: TextCapitalization.words, ), const SizedBox(height: 12), Row( children: [ Expanded( child: DropdownButtonFormField( value: selectedStateId, decoration: const InputDecoration( labelText: "State *", ), items: const [ DropdownMenuItem(value: 5, child: Text("CA")), DropdownMenuItem(value: 6, child: Text("AZ")), DropdownMenuItem(value: 7, child: Text("NV")), // Add more states as needed ], onChanged: (v) { if (v != null) { setDialogState(() => selectedStateId = v); } }, ), ), const SizedBox(width: 12), Expanded( child: TextField( controller: zipController, decoration: const InputDecoration( labelText: "ZIP Code *", hintText: "90210", ), keyboardType: TextInputType.number, ), ), ], ), const SizedBox(height: 16), CheckboxListTile( value: setAsDefault, onChanged: (v) => setDialogState(() => setAsDefault = v ?? false), title: const Text("Set as default address"), controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel"), ), ElevatedButton( onPressed: () async { // Validate if (line1Controller.text.trim().isEmpty || cityController.text.trim().isEmpty || zipController.text.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text("Please fill in all required fields", 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 newAddr = await Api.addDeliveryAddress( line1: line1Controller.text.trim(), line2: line2Controller.text.trim(), city: cityController.text.trim(), stateId: selectedStateId, zipCode: zipController.text.trim(), setAsDefault: setAsDefault, ); if (mounted) { setState(() { _addresses.insert(0, newAddr); _selectedAddress = newAddr; }); } if (ctx.mounted) Navigator.pop(ctx, true); } catch (e) { if (ctx.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("Error: ${e.toString()}", style: const TextStyle(color: Colors.black)), backgroundColor: const Color(0xFF90EE90), behavior: SnackBarBehavior.floating, margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), ), ); } } }, style: ElevatedButton.styleFrom(backgroundColor: Colors.black), child: const Text("Add Address"), ), ], ), ), ); } Widget _buildOrderTypeButton({ required String label, required IconData icon, required int orderTypeId, }) { final isSelected = _selectedOrderType == orderTypeId; return GestureDetector( onTap: () { setState(() => _selectedOrderType = orderTypeId); }, child: Container( padding: const EdgeInsets.symmetric(vertical: 16), decoration: BoxDecoration( color: isSelected ? Colors.black : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all( color: isSelected ? Colors.black : Colors.grey.shade300, width: 2, ), ), child: Column( children: [ Icon( icon, size: 32, color: isSelected ? Colors.white : Colors.grey.shade700, ), const SizedBox(height: 8), Text( label, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: isSelected ? Colors.white : Colors.black, ), ), ], ), ), ); } Widget _buildSummaryRow(String label, double amount, {bool isGrey = false}) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( label, style: TextStyle( fontSize: 15, color: isGrey ? Colors.grey.shade600 : Colors.black, ), ), Text( "\$${amount.toStringAsFixed(2)}", style: TextStyle( fontSize: 15, color: isGrey ? Colors.grey.shade600 : Colors.black, ), ), ], ); } void _confirmRemoveItem(OrderLineItem item, String itemName) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text("Remove Item"), content: Text("Remove $itemName from cart?"), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text("Cancel"), ), TextButton( onPressed: () { Navigator.of(context).pop(); _removeLineItem(item); }, style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text("Remove"), ), ], ), ); } } /// Scrollable list with fade indicator when content is hidden class _ScrollableCartList extends StatefulWidget { final List children; const _ScrollableCartList({required this.children}); @override State<_ScrollableCartList> createState() => _ScrollableCartListState(); } class _ScrollableCartListState extends State<_ScrollableCartList> { final ScrollController _scrollController = ScrollController(); bool _showBottomFade = false; bool _showTopFade = false; @override void initState() { super.initState(); _scrollController.addListener(_updateFadeVisibility); WidgetsBinding.instance.addPostFrameCallback((_) => _updateFadeVisibility()); } @override void dispose() { _scrollController.removeListener(_updateFadeVisibility); _scrollController.dispose(); super.dispose(); } void _updateFadeVisibility() { if (!_scrollController.hasClients) return; final maxScroll = _scrollController.position.maxScrollExtent; final currentScroll = _scrollController.offset; setState(() { _showTopFade = currentScroll > 10; _showBottomFade = maxScroll > 0 && currentScroll < maxScroll - 10; }); } @override Widget build(BuildContext context) { return Stack( children: [ ListView( controller: _scrollController, padding: const EdgeInsets.all(16), children: widget.children, ), // Top fade gradient if (_showTopFade) Positioned( top: 0, left: 0, right: 0, height: 24, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Theme.of(context).scaffoldBackgroundColor, Theme.of(context).scaffoldBackgroundColor.withOpacity(0), ], ), ), ), ), // Bottom fade gradient with scroll hint if (_showBottomFade) Positioned( bottom: 0, left: 0, right: 0, child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( height: 32, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Theme.of(context).scaffoldBackgroundColor.withOpacity(0), Theme.of(context).scaffoldBackgroundColor, ], ), ), ), Container( color: Theme.of(context).scaffoldBackgroundColor, padding: const EdgeInsets.only(bottom: 4), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.keyboard_arrow_down, size: 16, color: Colors.grey.shade500), const SizedBox(width: 4), Text( "Scroll for more", style: TextStyle( fontSize: 12, color: Colors.grey.shade500, ), ), ], ), ), ], ), ), ], ); } } /// Collapsible accordion widget for showing modifiers class _ModifierAccordion extends StatefulWidget { final List modifierPaths; final int itemId; const _ModifierAccordion({ required this.modifierPaths, required this.itemId, }); @override State<_ModifierAccordion> createState() => _ModifierAccordionState(); } class _ModifierAccordionState extends State<_ModifierAccordion> { bool _isExpanded = false; @override Widget build(BuildContext context) { final count = widget.modifierPaths.length; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ InkWell( onTap: () => setState(() => _isExpanded = !_isExpanded), borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( children: [ Icon( _isExpanded ? Icons.expand_less : Icons.expand_more, size: 20, color: Colors.blue.shade600, ), const SizedBox(width: 4), Text( _isExpanded ? "Hide customizations" : "$count customization${count == 1 ? '' : 's'}", style: TextStyle( fontSize: 13, color: Colors.blue.shade600, fontWeight: FontWeight.w500, ), ), ], ), ), ), if (_isExpanded) Padding( padding: const EdgeInsets.only(left: 8, bottom: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: widget.modifierPaths.map((path) { final displayText = path.names.join(' > '); return Padding( padding: const EdgeInsets.only(top: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.check, size: 14, color: Colors.grey), const SizedBox(width: 6), Expanded( child: Text( displayText, style: const TextStyle( fontSize: 13, color: Colors.grey, ), ), ), if (path.price > 0) Text( "+\$${path.price.toStringAsFixed(2)}", style: TextStyle( fontSize: 12, color: Colors.green.shade700, ), ), ], ), ); }).toList(), ), ), ], ); } }