Cart and order flow improvements

- Fix login to check for existing cart after OTP verification
- Add abandonOrder API call for Start Fresh functionality
- Fix stale service point showing for non-dine-in orders
- Add Chat button for non-dine-in orders (was only Call Server)
- Add quantity selector in item customization sheet
- Compact cart layout with quantity badge, accordion modifiers
- Add scroll indicator when cart has hidden content
- Fix restaurant list tappable before images load
- Add ForceNew parameter to setLineItem for customized items

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-13 20:26:58 -08:00
parent 9ebcd0b223
commit 9a489f20bb
7 changed files with 1000 additions and 234 deletions

View file

@ -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<int> 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<String, dynamic> json) {
final order = (json["ORDER"] ?? json["Order"]) as Map<String, dynamic>? ?? {};
final lineItemsJson = (json["ORDERLINEITEMS"] ?? json["OrderLineItems"]) as List? ?? [];
// Parse business order types - can be array of ints or strings
List<int> 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<String, dynamic> 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;
}

View file

@ -154,6 +154,7 @@ class _CartViewScreenState extends State<CartViewScreen> {
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<CartViewScreen> {
// 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<CartViewScreen> {
: Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
child: _ScrollableCartList(
children: _buildCartItems(),
),
),
@ -612,16 +621,13 @@ class _CartViewScreenState extends State<CartViewScreen> {
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<CartViewScreen> {
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<CartViewScreen> {
),
),
),
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<CartViewScreen> {
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<CartViewScreen> {
.toList();
// Recursively collect leaf items with their paths
void collectLeafPaths(OrderLineItem item, List<String> 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<CartViewScreen> {
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<CartViewScreen> {
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<CartViewScreen> {
);
}
}
/// Scrollable list with fade indicator when content is hidden
class _ScrollableCartList extends StatefulWidget {
final List<Widget> 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<ModifierPath> 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(),
),
),
],
);
}
}

View file

@ -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<LoginScreen> {
final appState = context.read<AppState>();
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<LoginScreen> {
),
);
// 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<LoginScreen> {
}
}
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>();
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<void> _handleResendOtp() async {
setState(() {
_isLoading = true;

View file

@ -43,6 +43,15 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
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<MenuBrowseScreen> {
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<void> _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<MenuBrowseScreen> {
if (!mounted) return;
final isDineIn = appState.isDineIn;
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
@ -119,24 +133,27 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
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<MenuBrowseScreen> {
],
),
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<MenuBrowseScreen> {
builder: (context) => _ItemCustomizationSheet(
item: item,
itemsByParent: _itemsByParent,
onAdd: (selectedItemIds) {
onAdd: (selectedItemIds, quantity) {
Navigator.pop(context);
_addToCart(item, selectedItemIds);
_addToCart(item, selectedItemIds, quantity: quantity);
},
),
);
}
Future<void> _addToCart(MenuItem item, Set<int> selectedModifierIds) async {
Future<void> _addToCart(MenuItem item, Set<int> selectedModifierIds, {int quantity = 1}) async {
// Check if user is logged in - if not, navigate to login
if (_userId == null) {
final shouldLogin = await showDialog<bool>(
@ -1039,23 +1054,38 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
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<MenuBrowseScreen> {
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<MenuBrowseScreen> {
);
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<MenuBrowseScreen> {
);
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<MenuBrowseScreen> {
class _ItemCustomizationSheet extends StatefulWidget {
final MenuItem item;
final Map<int, List<MenuItem>> itemsByParent;
final Function(Set<int>) onAdd;
final Function(Set<int>, int) onAdd; // (selectedModifierIds, quantity)
const _ItemCustomizationSheet({
required this.item,
@ -1210,6 +1244,7 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> {
final Set<int> _defaultItemIds = {}; // Track which items were defaults (not user-selected)
final Set<int> _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,
),
),
],
),
),
),
),
],
),
],
),

View file

@ -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);
},
);
},
),

View file

@ -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<SplashScreen> 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<Color> _colors = [
Colors.white,
Colors.red,
@ -62,6 +76,9 @@ class _SplashScreenState extends State<SplashScreen> 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<SplashScreen> 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<SplashScreen> with TickerProviderStateMix
}
Future<void> _navigateToNextScreen() async {
if (!mounted) return;
if (!mounted || _navigating) return;
setState(() {
_navigating = true;
});
final appState = context.read<AppState>();
// 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>();
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<void> _continueExistingOrderWithBeacon() async {
if (!mounted || _existingCart == null || _beaconMapping == null) return;
final appState = context.read<AppState>();
// 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<AppState>();
// 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<AppState>();
// 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<void> _startFresh() async {
if (!mounted) return;
final appState = context.read<AppState>();
// 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<SplashScreen> 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,
),
),
),
),
),
],
),
);

View file

@ -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<ActiveCartInfo?> 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<Cart> 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<void> 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<void> submitOrder({required int orderId}) async {
final raw = await _postRaw(
"/orders/submit.cfm",