diff --git a/lib/models/cart.dart b/lib/models/cart.dart index 371d6f7..9f30f96 100644 --- a/lib/models/cart.dart +++ b/lib/models/cart.dart @@ -5,6 +5,7 @@ class Cart { final int businessId; final double businessDeliveryMultiplier; final double businessDeliveryFee; // The business's standard delivery fee (for preview) + final List businessOrderTypes; // Which order types the business offers (1=dine-in, 2=takeaway, 3=delivery) final int orderTypeId; final double deliveryFee; // The actual delivery fee on this order (set when order type is confirmed) final int statusId; @@ -24,6 +25,7 @@ class Cart { required this.businessId, required this.businessDeliveryMultiplier, required this.businessDeliveryFee, + required this.businessOrderTypes, required this.orderTypeId, required this.deliveryFee, required this.statusId, @@ -37,10 +39,26 @@ class Cart { required this.lineItems, }); + // Helper methods for checking available order types + bool get offersDineIn => businessOrderTypes.contains(1); + bool get offersTakeaway => businessOrderTypes.contains(2); + bool get offersDelivery => businessOrderTypes.contains(3); + factory Cart.fromJson(Map json) { final order = (json["ORDER"] ?? json["Order"]) as Map? ?? {}; final lineItemsJson = (json["ORDERLINEITEMS"] ?? json["OrderLineItems"]) as List? ?? []; + // Parse business order types - can be array of ints or strings + List orderTypes = [1, 2, 3]; // Default to all types + final rawOrderTypes = order["BusinessOrderTypes"]; + if (rawOrderTypes != null && rawOrderTypes is List) { + orderTypes = rawOrderTypes + .map((e) => e is int ? e : int.tryParse(e.toString()) ?? 0) + .where((e) => e > 0) + .toList(); + if (orderTypes.isEmpty) orderTypes = [1, 2, 3]; + } + return Cart( orderId: _parseInt(order["OrderID"]) ?? 0, orderUuid: (order["OrderUUID"] as String?) ?? "", @@ -48,6 +66,7 @@ class Cart { businessId: _parseInt(order["OrderBusinessID"]) ?? 0, businessDeliveryMultiplier: _parseDouble(order["OrderBusinessDeliveryMultiplier"]) ?? 0.0, businessDeliveryFee: _parseDouble(order["BusinessDeliveryFee"]) ?? 0.0, + businessOrderTypes: orderTypes, orderTypeId: _parseInt(order["OrderTypeID"]) ?? 0, deliveryFee: _parseDouble(order["OrderDeliveryFee"]) ?? 0.0, statusId: _parseInt(order["OrderStatusID"]) ?? 0, @@ -251,3 +270,60 @@ class OrderLineItem { bool get isRootItem => parentOrderLineItemId == 0; bool get isModifier => parentOrderLineItemId != 0; } + +/// Lightweight cart info returned by getActiveCart API +/// Used at app startup to check if user has an existing order +class ActiveCartInfo { + final int orderId; + final String orderUuid; + final int businessId; + final String businessName; + final int orderTypeId; + final String orderTypeName; + final int servicePointId; + final String servicePointName; + final int itemCount; + + const ActiveCartInfo({ + required this.orderId, + required this.orderUuid, + required this.businessId, + required this.businessName, + required this.orderTypeId, + required this.orderTypeName, + required this.servicePointId, + required this.servicePointName, + required this.itemCount, + }); + + factory ActiveCartInfo.fromJson(Map json) { + return ActiveCartInfo( + orderId: _parseInt(json["OrderID"]) ?? 0, + orderUuid: json["OrderUUID"]?.toString() ?? "", + businessId: _parseInt(json["BusinessID"]) ?? 0, + businessName: json["BusinessName"]?.toString() ?? "", + orderTypeId: _parseInt(json["OrderTypeID"]) ?? 0, + orderTypeName: json["OrderTypeName"]?.toString() ?? "", + servicePointId: _parseInt(json["ServicePointID"]) ?? 0, + servicePointName: json["ServicePointName"]?.toString() ?? "", + itemCount: _parseInt(json["ItemCount"]) ?? 0, + ); + } + + static int? _parseInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) { + if (value.isEmpty) return null; + return int.tryParse(value); + } + return null; + } + + bool get hasItems => itemCount > 0; + bool get isDineIn => orderTypeId == 1; + bool get isTakeaway => orderTypeId == 2; + bool get isDelivery => orderTypeId == 3; + bool get isUndecided => orderTypeId == 0; +} diff --git a/lib/screens/cart_view_screen.dart b/lib/screens/cart_view_screen.dart index aced334..d9e579e 100644 --- a/lib/screens/cart_view_screen.dart +++ b/lib/screens/cart_view_screen.dart @@ -154,6 +154,7 @@ class _CartViewScreenState extends State { 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) @@ -343,9 +344,18 @@ class _CartViewScreenState extends State { // 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: const Text("Please select Delivery or Takeaway", style: TextStyle(color: Colors.black)), + 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), @@ -578,8 +588,7 @@ class _CartViewScreenState extends State { : Column( children: [ Expanded( - child: ListView( - padding: const EdgeInsets.all(16), + child: _ScrollableCartList( children: _buildCartItems(), ), ), @@ -612,16 +621,13 @@ class _CartViewScreenState extends State { final menuItem = _menuItemsById[rootItem.itemId]; final itemName = rootItem.itemName ?? menuItem?.name ?? "Item #${rootItem.itemId}"; - print('[Cart] Building card for root item: $itemName (OrderLineItemID=${rootItem.orderLineItemId})'); - print('[Cart] Total line items in cart: ${_cart!.lineItems.length}'); - // 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); - print('[Cart] Found ${modifierPaths.length} modifier paths for this root item'); + final hasModifiers = modifierPaths.isNotEmpty; return Card( child: Padding( @@ -629,8 +635,29 @@ class _CartViewScreenState extends State { 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, @@ -640,38 +667,7 @@ class _CartViewScreenState extends State { ), ), ), - IconButton( - icon: const Icon(Icons.delete, color: Colors.red), - onPressed: () => _confirmRemoveItem(rootItem, itemName), - ), - ], - ), - if (modifierPaths.isNotEmpty) ...[ - const SizedBox(height: 8), - ...modifierPaths.map((path) => _buildModifierPathRow(path)), - ], - const SizedBox(height: 8), - Row( - children: [ - IconButton( - icon: const Icon(Icons.remove_circle_outline), - onPressed: rootItem.quantity > 1 - ? () => _updateQuantity(rootItem, rootItem.quantity - 1) - : null, - ), - Text( - "${rootItem.quantity}", - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - IconButton( - icon: const Icon(Icons.add_circle_outline), - onPressed: () => - _updateQuantity(rootItem, rootItem.quantity + 1), - ), - const Spacer(), + // Price Text( "\$${lineItemTotal.toStringAsFixed(2)}", style: const TextStyle( @@ -679,8 +675,23 @@ class _CartViewScreenState extends State { 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, + ), ], ), ), @@ -724,7 +735,7 @@ class _CartViewScreenState extends State { .toList(); // Recursively collect leaf items with their paths - void collectLeafPaths(OrderLineItem item, List currentPath) { + void collectLeafPaths(OrderLineItem item, String? lastGroupName) { // Skip default items - they don't need to be repeated in the cart if (item.isCheckedByDefault) { return; @@ -741,25 +752,26 @@ class _CartViewScreenState extends State { final itemName = item.itemName ?? menuItem?.name ?? "Item #${item.itemId}"; if (children.isEmpty) { - // This is a leaf - build display text with parent category name - // Format: "Category: Selection" (e.g., "Select Drink: Coke") - final displayName = item.itemParentName != null && item.itemParentName!.isNotEmpty - ? "${item.itemParentName}: $itemName" + // 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: [...currentPath, displayName], + names: [displayName], price: item.price, )); } else { - // This has children - recurse into them + // This is a group/category - pass its name down to children for (final child in children) { - collectLeafPaths(child, [...currentPath, itemName]); + collectLeafPaths(child, itemName); } } } for (final child in directChildren) { - collectLeafPaths(child, []); + collectLeafPaths(child, null); } return paths; @@ -824,28 +836,29 @@ class _CartViewScreenState extends State { children: [ // Order Type Selection (only for delivery/takeaway orders) if (_needsOrderTypeSelection) ...[ - const Text( - "How would you like your order?", - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), Row( children: [ - Expanded( - child: _buildOrderTypeButton( - label: "Takeaway", - icon: Icons.shopping_bag_outlined, - orderTypeId: 2, + // Only show Takeaway if business offers it + if (_cart!.offersTakeaway) + Expanded( + child: _buildOrderTypeButton( + label: "Takeaway", + icon: Icons.shopping_bag_outlined, + orderTypeId: 2, + ), ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildOrderTypeButton( - label: "Delivery", - icon: Icons.delivery_dining, - orderTypeId: 3, + // 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), @@ -1339,3 +1352,212 @@ class _CartViewScreenState extends State { ); } } + +/// 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(), + ), + ), + ], + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index d066d95..2d65a53 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -4,6 +4,7 @@ import "package:provider/provider.dart"; import "../app/app_router.dart"; import "../app/app_state.dart"; +import "../models/cart.dart"; import "../services/api.dart"; import "../services/auth_storage.dart"; @@ -122,7 +123,7 @@ class _LoginScreenState extends State { final appState = context.read(); appState.setUserId(response.userId); - // Show success and navigate + // Show success ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -135,11 +136,29 @@ class _LoginScreenState extends State { ), ); - // Navigate to main app - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); + // Check for existing cart + ActiveCartInfo? existingCart; + try { + existingCart = await Api.getActiveCart(userId: response.userId); + if (existingCart != null && !existingCart.hasItems) { + existingCart = null; + } + } catch (e) { + // Ignore - treat as no cart + } + + if (!mounted) return; + + if (existingCart != null) { + // Show continue or start fresh dialog + _showExistingCartDialog(existingCart); } else { - Navigator.of(context).pushReplacementNamed(AppRoutes.splash); + // No existing cart - just pop back + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } else { + Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); + } } } catch (e) { if (!mounted) return; @@ -150,6 +169,49 @@ class _LoginScreenState extends State { } } + void _showExistingCartDialog(ActiveCartInfo cart) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Text("Existing Order Found"), + content: Text( + "You have ${cart.itemCount} item${cart.itemCount == 1 ? '' : 's'} in your cart at ${cart.businessName}.\n\nWould you like to continue that order or start fresh?", + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + // Start fresh - go to restaurant select + Navigator.of(this.context).pushReplacementNamed(AppRoutes.restaurantSelect); + }, + child: const Text("Start Fresh"), + ), + ElevatedButton( + onPressed: () async { + Navigator.of(context).pop(); + // Continue existing order - load cart and go to menu + final appState = this.context.read(); + appState.setBusinessAndServicePoint( + cart.businessId, + cart.servicePointId, + businessName: cart.businessName, + servicePointName: cart.servicePointName, + ); + appState.setCartOrder( + orderId: cart.orderId, + orderUuid: cart.orderUuid, + itemCount: cart.itemCount, + ); + Navigator.of(this.context).pushReplacementNamed(AppRoutes.menuBrowse); + }, + child: const Text("Continue Order"), + ), + ], + ), + ); + } + Future _handleResendOtp() async { setState(() { _isLoading = true; diff --git a/lib/screens/menu_browse_screen.dart b/lib/screens/menu_browse_screen.dart index 36c1f86..a4397b0 100644 --- a/lib/screens/menu_browse_screen.dart +++ b/lib/screens/menu_browse_screen.dart @@ -43,6 +43,15 @@ class _MenuBrowseScreenState extends State { 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(); @@ -82,16 +91,19 @@ class _MenuBrowseScreenState extends State { bool _isCallingServer = false; - /// Show bottom sheet with choice: Server Visit or Chat + /// 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 || _servicePointId == null) return; + 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!, + servicePointId: servicePointId, ); } catch (e) { // Continue without active chat @@ -99,6 +111,8 @@ class _MenuBrowseScreenState extends State { if (!mounted) return; + final isDineIn = appState.isDineIn; + showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( @@ -119,24 +133,27 @@ class _MenuBrowseScreenState extends State { borderRadius: BorderRadius.circular(2), ), ), - const Text( - 'How can we help?', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + Text( + isDineIn ? 'How can we help?' : 'Contact Us', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 16), - ListTile( - leading: const CircleAvatar( - backgroundColor: Colors.orange, - child: Icon(Icons.room_service, color: Colors.white), + // 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); + }, ), - title: const Text('Request Server Visit'), - subtitle: const Text('Staff will come to your table'), - onTap: () { - Navigator.pop(context); - _sendServerRequest(appState); - }, - ), - const Divider(), + const Divider(), + ], // Show either "Rejoin Chat" OR "Chat with Staff" - never both if (activeTaskId != null) ListTile( @@ -455,14 +472,12 @@ class _MenuBrowseScreenState extends State { ], ), actions: [ - // Call Server button - only for dine-in orders at a table - if (appState.isDineIn && _servicePointId != null) - IconButton( - icon: const Icon(Icons.room_service), - tooltip: "Call Server", - onPressed: () => _handleCallServer(appState), - ), - // Table change button removed - not allowed currently + // 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}"), @@ -940,15 +955,15 @@ class _MenuBrowseScreenState extends State { builder: (context) => _ItemCustomizationSheet( item: item, itemsByParent: _itemsByParent, - onAdd: (selectedItemIds) { + onAdd: (selectedItemIds, quantity) { Navigator.pop(context); - _addToCart(item, selectedItemIds); + _addToCart(item, selectedItemIds, quantity: quantity); }, ), ); } - Future _addToCart(MenuItem item, Set selectedModifierIds) async { + 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( @@ -1039,23 +1054,38 @@ class _MenuBrowseScreenState extends State { orderTypeId: 1, // dine-in ); } + // Note: Dine-in orders (orderTypeId=1) stay as dine-in - no order type selection needed } - // 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; + // 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; + 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, - ); + 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( @@ -1130,6 +1160,10 @@ class _MenuBrowseScreenState extends State { 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, @@ -1139,7 +1173,7 @@ class _MenuBrowseScreenState extends State { ); final childLineItem = cart.lineItems.lastWhere( - (li) => li.itemId == child.itemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted, + (li) => li.itemId == realChildItemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted, orElse: () => throw StateError('Failed to add item'), ); @@ -1160,7 +1194,7 @@ class _MenuBrowseScreenState extends State { ); final childLineItem = cart.lineItems.lastWhere( - (li) => li.itemId == child.itemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted, + (li) => li.itemId == realChildItemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted, orElse: () => throw StateError('Failed to add item'), ); @@ -1193,7 +1227,7 @@ class _MenuBrowseScreenState extends State { class _ItemCustomizationSheet extends StatefulWidget { final MenuItem item; final Map> itemsByParent; - final Function(Set) onAdd; + final Function(Set, int) onAdd; // (selectedModifierIds, quantity) const _ItemCustomizationSheet({ required this.item, @@ -1210,6 +1244,7 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> { 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() { @@ -1231,22 +1266,22 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> { } } - /// Calculate total price including all selected items recursively + /// Calculate total price including all selected items recursively (multiplied by quantity) double _calculateTotal() { - double total = widget.item.price; + double unitPrice = 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; + unitPrice += child.price; addPriceRecursively(child.itemId); } } } addPriceRecursively(widget.item.itemId); - return total; + return unitPrice * _quantity; } /// Validate selections before adding to cart @@ -1322,7 +1357,7 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> { } } - widget.onAdd(itemsToSubmit); + widget.onAdd(itemsToSubmit, _quantity); } } @@ -1525,50 +1560,88 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> { ), ), ], - 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), + Row( + children: [ + // Quantity selector + Container( + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), ), - ), - 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, + 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), ), - ), - 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, + 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, + ), + ), + ], + ), + ), + ), + ), + ], ), ], ), diff --git a/lib/screens/restaurant_select_screen.dart b/lib/screens/restaurant_select_screen.dart index 1f16714..46c51d6 100644 --- a/lib/screens/restaurant_select_screen.dart +++ b/lib/screens/restaurant_select_screen.dart @@ -205,6 +205,32 @@ class _RestaurantBar extends StatelessWidget { required this.imageBaseUrl, }); + Widget _buildLogoPlaceholder(BuildContext context) { + return Container( + width: 56, + height: 56, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Theme.of(context).colorScheme.primary, + Theme.of(context).colorScheme.tertiary, + ], + ), + ), + alignment: Alignment.center, + child: Text( + restaurant.name.isNotEmpty ? restaurant.name[0].toUpperCase() : "?", + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ); + } + @override Widget build(BuildContext context) { return Column( @@ -223,26 +249,30 @@ class _RestaurantBar extends StatelessWidget { ), child: Stack( children: [ - // Background header image (subtle) - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Opacity( - opacity: 0.3, - child: SizedBox( - width: double.infinity, - height: 80, - child: Image.network( - "$imageBaseUrl/headers/${restaurant.businessId}.png", - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Image.network( - "$imageBaseUrl/headers/${restaurant.businessId}.jpg", - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return const SizedBox.shrink(); - }, - ); - }, + // Background header image (subtle) - ignorePointer so taps go through + IgnorePointer( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Opacity( + opacity: 0.3, + child: SizedBox( + width: double.infinity, + height: 80, + child: Image.network( + "$imageBaseUrl/headers/${restaurant.businessId}.png", + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (context, error, stackTrace) { + return Image.network( + "$imageBaseUrl/headers/${restaurant.businessId}.jpg", + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (context, error, stackTrace) { + return const SizedBox.shrink(); + }, + ); + }, + ), ), ), ), @@ -310,36 +340,32 @@ class _RestaurantBar extends StatelessWidget { child: Image.network( "$imageBaseUrl/logos/${restaurant.businessId}.png", fit: BoxFit.cover, + gaplessPlayback: true, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + // Show placeholder immediately, image loads on top + return Stack( + children: [ + _buildLogoPlaceholder(context), + if (frame != null) child, + ], + ); + }, errorBuilder: (context, error, stackTrace) { return Image.network( "$imageBaseUrl/logos/${restaurant.businessId}.jpg", fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - // Text-based fallback with first letter - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Theme.of(context).colorScheme.primary, - Theme.of(context).colorScheme.tertiary, - ], - ), - ), - alignment: Alignment.center, - child: Text( - restaurant.name.isNotEmpty - ? restaurant.name[0].toUpperCase() - : "?", - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), + gaplessPlayback: true, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + return Stack( + children: [ + _buildLogoPlaceholder(context), + if (frame != null) child, + ], ); }, + errorBuilder: (context, error, stackTrace) { + return _buildLogoPlaceholder(context); + }, ); }, ), diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index fbaa7b5..c1b417d 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -6,6 +6,7 @@ import "package:dchs_flutter_beacon/dchs_flutter_beacon.dart"; import "../app/app_router.dart"; import "../app/app_state.dart"; +import "../models/cart.dart"; import "../services/api.dart"; import "../services/auth_storage.dart"; import "../services/beacon_permissions.dart"; @@ -46,6 +47,19 @@ class _SplashScreenState extends State with TickerProviderStateMix bool _scanComplete = false; BeaconResult? _bestBeacon; + // Existing cart state + ActiveCartInfo? _existingCart; + BeaconBusinessMapping? _beaconMapping; + + // Skip scan state + bool _scanSkipped = false; + + // Navigation state - true once we start navigating away + bool _navigating = false; + + // Minimum display time for splash screen + late DateTime _splashStartTime; + static const List _colors = [ Colors.white, Colors.red, @@ -62,6 +76,9 @@ class _SplashScreenState extends State with TickerProviderStateMix super.initState(); print('[Splash] 🚀 Starting with bouncing logo + beacon scan'); + // Record start time for minimum display duration + _splashStartTime = DateTime.now(); + // Start bouncing animation _bounceController = AnimationController( vsync: this, @@ -146,6 +163,13 @@ class _SplashScreenState extends State with TickerProviderStateMix // Start beacon scanning in background await _performBeaconScan(); + // Ensure minimum 3 seconds display time so user can see/use skip button + if (!mounted) return; + final elapsed = DateTime.now().difference(_splashStartTime); + if (elapsed < const Duration(seconds: 3)) { + await Future.delayed(const Duration(seconds: 3) - elapsed); + } + // Navigate based on results if (!mounted) return; _navigateToNextScreen(); @@ -336,46 +360,264 @@ class _SplashScreenState extends State with TickerProviderStateMix } Future _navigateToNextScreen() async { - if (!mounted) return; + if (!mounted || _navigating) return; + setState(() { + _navigating = true; + }); + + final appState = context.read(); + + // Get beacon mapping if we found a beacon if (_bestBeacon != null) { - // Auto-select business from beacon try { - final mapping = await Api.getBusinessFromBeacon(beaconId: _bestBeacon!.beaconId); - - if (!mounted) return; - - final appState = context.read(); - appState.setBusinessAndServicePoint( - mapping.businessId, - mapping.servicePointId, - businessName: mapping.businessName, - servicePointName: mapping.servicePointName, - ); - // Beacon detected = dine-in at a table - appState.setOrderType(OrderType.dineIn); - Api.setBusinessId(mapping.businessId); - - print('[Splash] 🎉 Auto-selected: ${mapping.businessName}'); - - Navigator.of(context).pushReplacementNamed( - AppRoutes.menuBrowse, - arguments: { - 'businessId': mapping.businessId, - 'servicePointId': mapping.servicePointId, - }, - ); - return; + _beaconMapping = await Api.getBusinessFromBeacon(beaconId: _bestBeacon!.beaconId); + print('[Splash] 📍 Beacon maps to: ${_beaconMapping!.businessName}'); } catch (e) { print('[Splash] Error mapping beacon to business: $e'); + _beaconMapping = null; } } - // No beacon or error - go to restaurant select - print('[Splash] Going to restaurant select'); + // Check for existing cart if user is logged in + final userId = appState.userId; + if (userId != null && userId > 0) { + try { + _existingCart = await Api.getActiveCart(userId: userId); + if (_existingCart != null && _existingCart!.hasItems) { + print('[Splash] 🛒 Found existing cart: ${_existingCart!.itemCount} items at ${_existingCart!.businessName}'); + } else { + _existingCart = null; + } + } catch (e) { + print('[Splash] Error checking for existing cart: $e'); + _existingCart = null; + } + } + + if (!mounted) return; + + // DECISION TREE: + // 1. Beacon found? + // - Yes: Is there an existing cart? + // - Yes: Same restaurant? + // - Yes: Continue order as dine-in, update service point + // - No: Start fresh with beacon's restaurant (dine-in) + // - No: Start fresh with beacon's restaurant (dine-in) + // - No: Is there an existing cart? + // - Yes: Show "Continue or Start Fresh?" popup + // - No: Go to restaurant select + + if (_beaconMapping != null) { + // BEACON FOUND + if (_existingCart != null && _existingCart!.businessId == _beaconMapping!.businessId) { + // Same restaurant - continue order, update to dine-in with new service point + print('[Splash] ✅ Continuing existing order at same restaurant (dine-in)'); + await _continueExistingOrderWithBeacon(); + } else { + // Different restaurant or no cart - start fresh with beacon + print('[Splash] 🆕 Starting fresh dine-in order at ${_beaconMapping!.businessName}'); + _startFreshWithBeacon(); + } + } else { + // NO BEACON + if (_existingCart != null) { + // Has existing cart - ask user what to do + print('[Splash] ❓ No beacon, but has existing cart - showing choice dialog'); + _showContinueOrStartFreshDialog(); + } else { + // No cart, no beacon - go to restaurant select + print('[Splash] 📋 No beacon, no cart - going to restaurant select'); + Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); + } + } + } + + /// Continue existing order and update to dine-in with beacon's service point + Future _continueExistingOrderWithBeacon() async { + if (!mounted || _existingCart == null || _beaconMapping == null) return; + + final appState = context.read(); + + // Update order type to dine-in and set service point + try { + await Api.setOrderType( + orderId: _existingCart!.orderId, + orderTypeId: 1, // dine-in + ); + } catch (e) { + print('[Splash] Error updating order type: $e'); + } + + // Set app state + appState.setBusinessAndServicePoint( + _beaconMapping!.businessId, + _beaconMapping!.servicePointId, + businessName: _beaconMapping!.businessName, + servicePointName: _beaconMapping!.servicePointName, + ); + appState.setOrderType(OrderType.dineIn); + appState.setCartOrder( + orderId: _existingCart!.orderId, + orderUuid: _existingCart!.orderUuid, + itemCount: _existingCart!.itemCount, + ); + Api.setBusinessId(_beaconMapping!.businessId); + + Navigator.of(context).pushReplacementNamed( + AppRoutes.menuBrowse, + arguments: { + 'businessId': _beaconMapping!.businessId, + 'servicePointId': _beaconMapping!.servicePointId, + }, + ); + } + + /// Start fresh dine-in order with beacon + void _startFreshWithBeacon() { + if (!mounted || _beaconMapping == null) return; + + final appState = context.read(); + + // Clear any existing cart reference + appState.clearCart(); + + appState.setBusinessAndServicePoint( + _beaconMapping!.businessId, + _beaconMapping!.servicePointId, + businessName: _beaconMapping!.businessName, + servicePointName: _beaconMapping!.servicePointName, + ); + appState.setOrderType(OrderType.dineIn); + Api.setBusinessId(_beaconMapping!.businessId); + + Navigator.of(context).pushReplacementNamed( + AppRoutes.menuBrowse, + arguments: { + 'businessId': _beaconMapping!.businessId, + 'servicePointId': _beaconMapping!.servicePointId, + }, + ); + } + + /// Show dialog asking user to continue existing order or start fresh + void _showContinueOrStartFreshDialog() { + if (!mounted || _existingCart == null) return; + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Text("Existing Order Found"), + content: Text( + "You have an existing order at ${_existingCart!.businessName} " + "with ${_existingCart!.itemCount} item${_existingCart!.itemCount == 1 ? '' : 's'}.\n\n" + "Would you like to continue with this order or start fresh?", + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _startFresh(); + }, + child: const Text("Start Fresh"), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + _continueExistingOrder(); + }, + child: const Text("Continue Order"), + ), + ], + ), + ); + } + + /// Continue with existing order (no beacon) + void _continueExistingOrder() { + if (!mounted || _existingCart == null) return; + + final appState = context.read(); + + // Only use service point if this is actually a dine-in order + // Otherwise clear it to avoid showing stale table info + final isDineIn = _existingCart!.isDineIn; + appState.setBusinessAndServicePoint( + _existingCart!.businessId, + isDineIn && _existingCart!.servicePointId > 0 ? _existingCart!.servicePointId : 0, + businessName: _existingCart!.businessName, + servicePointName: isDineIn ? _existingCart!.servicePointName : null, + ); + + // Set order type based on existing cart + if (isDineIn) { + appState.setOrderType(OrderType.dineIn); + } else if (_existingCart!.isTakeaway) { + appState.setOrderType(OrderType.takeaway); + } else if (_existingCart!.isDelivery) { + appState.setOrderType(OrderType.delivery); + } else { + appState.setOrderType(null); // Undecided - will choose at checkout + } + + appState.setCartOrder( + orderId: _existingCart!.orderId, + orderUuid: _existingCart!.orderUuid, + itemCount: _existingCart!.itemCount, + ); + Api.setBusinessId(_existingCart!.businessId); + + Navigator.of(context).pushReplacementNamed( + AppRoutes.menuBrowse, + arguments: { + 'businessId': _existingCart!.businessId, + 'servicePointId': _existingCart!.servicePointId, + }, + ); + } + + /// Start fresh - abandon existing order and go to restaurant select + Future _startFresh() async { + if (!mounted) return; + + final appState = context.read(); + + // Abandon the existing order on the server + if (_existingCart != null) { + print('[Splash] Abandoning order ${_existingCart!.orderId}...'); + try { + await Api.abandonOrder(orderId: _existingCart!.orderId); + print('[Splash] Order abandoned successfully'); + } catch (e) { + // Ignore errors - just proceed with clearing local state + print('[Splash] Failed to abandon order: $e'); + } + } else { + print('[Splash] No existing cart to abandon'); + } + + appState.clearCart(); + + if (!mounted) return; Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); } + /// Skip the beacon scan and proceed without dine-in detection + void _skipScan() { + if (_scanSkipped || _navigating) return; + + print('[Splash] ⏭️ User skipped beacon scan'); + setState(() { + _scanSkipped = true; + _scanComplete = true; + _bestBeacon = null; // No beacon since we skipped + }); + + // Proceed with navigation (will check for existing cart) + _navigateToNextScreen(); + } + @override void dispose() { _bounceController.dispose(); @@ -430,6 +672,30 @@ class _SplashScreenState extends State with TickerProviderStateMix ), ), ), + // Skip button at bottom - show until we start navigating away + if (!_navigating) + Positioned( + bottom: 50, + left: 0, + right: 0, + child: Center( + child: TextButton( + onPressed: _skipScan, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), + ), + child: const Text( + "Skip Scan", + style: TextStyle( + color: Colors.white70, + fontSize: 16, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ), + ), + ), + ), + ), ], ), ); diff --git a/lib/services/api.dart b/lib/services/api.dart index 6d3f0d3..67e2a79 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -558,6 +558,24 @@ class Api { return Cart.fromJson(j); } + /// Check if user has an active cart (status=0) - used at app startup + static Future getActiveCart({required int userId}) async { + final raw = await _getRaw("/orders/getActiveCart.cfm?UserID=$userId"); + + final j = _requireJson(raw, "GetActiveCart"); + + if (!_ok(j)) { + throw StateError( + "GetActiveCart API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}", + ); + } + + if (j["HAS_CART"] == true && j["CART"] != null) { + return ActiveCartInfo.fromJson(j["CART"]); + } + return null; + } + static Future setLineItem({ required int orderId, required int parentOrderLineItemId, @@ -565,6 +583,7 @@ class Api { required bool isSelected, int quantity = 1, String? remark, + bool forceNew = false, }) async { final raw = await _postRaw( "/orders/setLineItem.cfm", @@ -575,14 +594,20 @@ class Api { "IsSelected": isSelected, "Quantity": quantity, if (remark != null && remark.isNotEmpty) "Remark": remark, + if (forceNew) "ForceNew": true, }, ); final j = _requireJson(raw, "SetLineItem"); if (!_ok(j)) { + // Log debug info if available + final debugItem = j["DEBUG_ITEM"]; + if (debugItem != null) { + print("[API] SetLineItem DEBUG_ITEM: $debugItem"); + } throw StateError( - "SetLineItem failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}", + "SetLineItem failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}${debugItem != null ? ' | DEBUG: $debugItem' : ''}", ); } @@ -615,6 +640,22 @@ class Api { return Cart.fromJson(j); } + /// Abandon an order (mark as abandoned, clear items) + static Future abandonOrder({required int orderId}) async { + final raw = await _postRaw( + "/orders/abandonOrder.cfm", + {"OrderID": orderId}, + ); + + final j = _requireJson(raw, "AbandonOrder"); + + if (!_ok(j)) { + throw StateError( + "AbandonOrder failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}", + ); + } + } + static Future submitOrder({required int orderId}) async { final raw = await _postRaw( "/orders/submit.cfm",