Display cart modifiers with category breadcrumbs

- OrderLineItem now includes itemName, itemParentName, and
  isCheckedByDefault from API response
- Cart view displays modifiers as "Category: Selection" format
  (e.g., "Select Drink: Coke") instead of just item IDs
- No longer requires menu item lookup for modifier names

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-12 19:06:54 -08:00
parent 867d67c8eb
commit ef8421c88a
6 changed files with 331 additions and 22 deletions

View file

@ -161,6 +161,10 @@ class OrderLineItem {
final String? remark; final String? remark;
final bool isDeleted; final bool isDeleted;
final DateTime addedOn; final DateTime addedOn;
final String? itemName;
final int? itemParentItemId;
final String? itemParentName;
final bool isCheckedByDefault;
const OrderLineItem({ const OrderLineItem({
required this.orderLineItemId, required this.orderLineItemId,
@ -173,6 +177,10 @@ class OrderLineItem {
this.remark, this.remark,
required this.isDeleted, required this.isDeleted,
required this.addedOn, required this.addedOn,
this.itemName,
this.itemParentItemId,
this.itemParentName,
this.isCheckedByDefault = false,
}); });
factory OrderLineItem.fromJson(Map<String, dynamic> json) { factory OrderLineItem.fromJson(Map<String, dynamic> json) {
@ -187,6 +195,10 @@ class OrderLineItem {
remark: json["OrderLineItemRemark"] as String?, remark: json["OrderLineItemRemark"] as String?,
isDeleted: _parseBool(json["OrderLineItemIsDeleted"]), isDeleted: _parseBool(json["OrderLineItemIsDeleted"]),
addedOn: _parseDateTime(json["OrderLineItemAddedOn"]), addedOn: _parseDateTime(json["OrderLineItemAddedOn"]),
itemName: json["ItemName"] as String?,
itemParentItemId: _parseInt(json["ItemParentItemID"]),
itemParentName: json["ItemParentName"] as String?,
isCheckedByDefault: _parseBool(json["ItemIsCheckedByDefault"]),
); );
} }

View file

@ -18,6 +18,7 @@ class OrderDetail {
final OrderCustomer customer; final OrderCustomer customer;
final OrderServicePoint servicePoint; final OrderServicePoint servicePoint;
final List<OrderLineItemDetail> lineItems; final List<OrderLineItemDetail> lineItems;
final List<OrderStaff> staff;
const OrderDetail({ const OrderDetail({
required this.orderId, required this.orderId,
@ -38,10 +39,12 @@ class OrderDetail {
required this.customer, required this.customer,
required this.servicePoint, required this.servicePoint,
required this.lineItems, required this.lineItems,
required this.staff,
}); });
factory OrderDetail.fromJson(Map<String, dynamic> json) { factory OrderDetail.fromJson(Map<String, dynamic> json) {
final lineItemsJson = json['LineItems'] as List<dynamic>? ?? []; final lineItemsJson = json['LineItems'] as List<dynamic>? ?? [];
final staffJson = json['Staff'] as List<dynamic>? ?? [];
return OrderDetail( return OrderDetail(
orderId: _parseInt(json['OrderID']) ?? 0, orderId: _parseInt(json['OrderID']) ?? 0,
@ -64,6 +67,9 @@ class OrderDetail {
lineItems: lineItemsJson lineItems: lineItemsJson
.map((e) => OrderLineItemDetail.fromJson(e as Map<String, dynamic>)) .map((e) => OrderLineItemDetail.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
staff: staffJson
.map((e) => OrderStaff.fromJson(e as Map<String, dynamic>))
.toList(),
); );
} }
@ -163,6 +169,26 @@ class OrderServicePoint {
} }
} }
class OrderStaff {
final int userId;
final String firstName;
final String avatarUrl;
const OrderStaff({
required this.userId,
required this.firstName,
required this.avatarUrl,
});
factory OrderStaff.fromJson(Map<String, dynamic> json) {
return OrderStaff(
userId: (json['UserID'] as num?)?.toInt() ?? 0,
firstName: (json['FirstName'] as String?) ?? '',
avatarUrl: (json['AvatarUrl'] as String?) ?? '',
);
}
}
class OrderLineItemDetail { class OrderLineItemDetail {
final int lineItemId; final int lineItemId;
final int itemId; final int itemId;

View file

@ -129,11 +129,36 @@ class _CartViewScreenState extends State<CartViewScreen> {
} }
// Load cart // Load cart
final cart = await Api.getCart(orderId: cartOrderId); var cart = await Api.getCart(orderId: cartOrderId);
// If cart is not in cart status (0), it's been submitted - clear it and show empty cart
if (cart.statusId != 0) {
debugPrint('Cart has been submitted (status=${cart.statusId}), clearing cart reference');
appState.clearCart();
setState(() {
_cart = null;
_isLoading = false;
});
return;
}
// If we're dine-in (beacon detected) but cart has no order type set, update it
if (appState.isDineIn && cart.orderTypeId == 0) {
try {
cart = await Api.setOrderType(
orderId: cart.orderId,
orderTypeId: 1, // dine-in
);
} catch (e) {
// Log error but continue - cart will show order type selection if this fails
debugPrint('Failed to update order type to dine-in: $e');
}
}
// Load menu items to get names and prices // Load menu items to get names and prices
final businessId = appState.selectedBusinessId; // Use cart's businessId if appState doesn't have one (e.g., delivery/takeaway flow)
if (businessId != null) { final businessId = appState.selectedBusinessId ?? cart.businessId;
if (businessId > 0) {
final menuItems = await Api.listMenuItems(businessId: businessId); final menuItems = await Api.listMenuItems(businessId: businessId);
_menuItemsById = {for (var item in menuItems) item.itemId: item}; _menuItemsById = {for (var item in menuItems) item.itemId: item};
} }
@ -572,8 +597,9 @@ class _CartViewScreenState extends State<CartViewScreen> {
} }
Widget _buildRootItemCard(OrderLineItem rootItem) { Widget _buildRootItemCard(OrderLineItem rootItem) {
// Use itemName from line item (from API), fall back to menu item lookup, then to ID
final menuItem = _menuItemsById[rootItem.itemId]; final menuItem = _menuItemsById[rootItem.itemId];
final itemName = menuItem?.name ?? "Item #${rootItem.itemId}"; final itemName = rootItem.itemName ?? menuItem?.name ?? "Item #${rootItem.itemId}";
print('[Cart] Building card for root item: $itemName (OrderLineItemID=${rootItem.orderLineItemId})'); print('[Cart] Building card for root item: $itemName (OrderLineItemID=${rootItem.orderLineItemId})');
print('[Cart] Total line items in cart: ${_cart!.lineItems.length}'); print('[Cart] Total line items in cart: ${_cart!.lineItems.length}');
@ -688,10 +714,8 @@ class _CartViewScreenState extends State<CartViewScreen> {
// Recursively collect leaf items with their paths // Recursively collect leaf items with their paths
void collectLeafPaths(OrderLineItem item, List<String> currentPath) { void collectLeafPaths(OrderLineItem item, List<String> currentPath) {
final menuItem = _menuItemsById[item.itemId];
// Skip default items - they don't need to be repeated in the cart // Skip default items - they don't need to be repeated in the cart
if (menuItem?.isCheckedByDefault == true) { if (item.isCheckedByDefault) {
return; return;
} }
@ -701,12 +725,18 @@ class _CartViewScreenState extends State<CartViewScreen> {
!child.isDeleted) !child.isDeleted)
.toList(); .toList();
final itemName = menuItem?.name ?? "Item #${item.itemId}"; // Use itemName from line item, fall back to menu item lookup
final menuItem = _menuItemsById[item.itemId];
final itemName = item.itemName ?? menuItem?.name ?? "Item #${item.itemId}";
if (children.isEmpty) { if (children.isEmpty) {
// This is a leaf - add its path // 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"
: itemName;
paths.add(ModifierPath( paths.add(ModifierPath(
names: [...currentPath, itemName], names: [...currentPath, displayName],
price: item.price, price: item.price,
)); ));
} else { } else {
@ -771,8 +801,13 @@ class _CartViewScreenState extends State<CartViewScreen> {
), ),
], ],
), ),
// Constrain max height so it doesn't push content off screen
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: SafeArea( child: SafeArea(
child: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -950,6 +985,7 @@ class _CartViewScreenState extends State<CartViewScreen> {
], ],
), ),
), ),
),
); );
} }

View file

@ -239,11 +239,12 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
TextFormField( TextFormField(
controller: _phoneController, controller: _phoneController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: "Phone Number", labelText: "Phone Number",
hintText: "(555) 123-4567", hintText: "(555) 123-4567",
border: OutlineInputBorder(), hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: Icon(Icons.phone), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.phone),
prefixText: "+1 ", prefixText: "+1 ",
), ),
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
@ -311,11 +312,12 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
TextFormField( TextFormField(
controller: _otpController, controller: _otpController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: "Login Code", labelText: "Login Code",
hintText: "123456", hintText: "123456",
border: OutlineInputBorder(), hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: Icon(Icons.lock), border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock),
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [ inputFormatters: [

View file

@ -1015,6 +1015,30 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
} else { } else {
// We have an existing cart ID // We have an existing cart ID
cart = await Api.getCart(orderId: appState.cartOrderId!); cart = await Api.getCart(orderId: appState.cartOrderId!);
// If cart is not in cart status (0), it's been submitted - create a new cart
if (cart.statusId != 0) {
debugPrint('Cart has been submitted (status=${cart.statusId}), creating new cart');
appState.clearCart();
final orderTypeId = appState.isDineIn ? 1 : 0;
cart = await Api.getOrCreateCart(
userId: _userId!,
businessId: _businessId!,
servicePointId: _servicePointId!,
orderTypeId: orderTypeId,
);
appState.setCartOrder(
orderId: cart.orderId,
orderUuid: cart.orderUuid,
itemCount: cart.itemCount,
);
} else if (appState.isDineIn && cart.orderTypeId == 0) {
// If we're dine-in (beacon detected) but cart has no order type set, update it
cart = await Api.setOrderType(
orderId: cart.orderId,
orderTypeId: 1, // dine-in
);
}
} }
// Check if this item already exists in the cart (as a root item) // Check if this item already exists in the cart (as a root item)

View file

@ -115,6 +115,10 @@ class _OrderDetailScreenState extends State<OrderDetailScreen> {
_buildOrderHeader(order), _buildOrderHeader(order),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildStatusCard(order), _buildStatusCard(order),
if (order.staff.isNotEmpty) ...[
const SizedBox(height: 16),
_buildStaffCard(order),
],
const SizedBox(height: 16), const SizedBox(height: 16),
_buildItemsCard(order), _buildItemsCard(order),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -293,11 +297,15 @@ class _OrderDetailScreenState extends State<OrderDetailScreen> {
icon = Icons.check_circle_outline; icon = Icons.check_circle_outline;
color = Colors.green; color = Colors.green;
break; break;
case 4: // Completed case 4: // Out for Delivery
icon = Icons.local_shipping;
color = Colors.blue;
break;
case 5: // Delivered
icon = Icons.check_circle; icon = Icons.check_circle;
color = Colors.green; color = Colors.green;
break; break;
case 5: // Cancelled case 6: // Cancelled
icon = Icons.cancel; icon = Icons.cancel;
color = Colors.red; color = Colors.red;
break; break;
@ -316,6 +324,207 @@ class _OrderDetailScreenState extends State<OrderDetailScreen> {
); );
} }
Widget _buildStaffCard(OrderDetail order) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.people,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Your Server${order.staff.length > 1 ? 's' : ''}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 16),
Wrap(
spacing: 16,
runSpacing: 16,
children: order.staff.map((staff) => _buildStaffItem(staff)).toList(),
),
],
),
),
);
}
Widget _buildStaffItem(OrderStaff staff) {
return GestureDetector(
onTap: () => _showTipDialog(staff),
child: Column(
children: [
CircleAvatar(
radius: 32,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
backgroundImage: staff.avatarUrl.isNotEmpty
? NetworkImage(staff.avatarUrl)
: null,
onBackgroundImageError: staff.avatarUrl.isNotEmpty
? (_, __) {} // Silently handle missing images
: null,
child: staff.avatarUrl.isEmpty
? Icon(
Icons.person,
size: 32,
color: Theme.of(context).colorScheme.onSurfaceVariant,
)
: null,
),
const SizedBox(height: 8),
Text(
staff.firstName.isNotEmpty ? staff.firstName : 'Staff',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Tip',
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
void _showTipDialog(OrderStaff staff) {
showModalBottomSheet(
context: context,
builder: (context) => Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 40,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
backgroundImage: staff.avatarUrl.isNotEmpty
? NetworkImage(staff.avatarUrl)
: null,
child: staff.avatarUrl.isEmpty
? Icon(
Icons.person,
size: 40,
color: Theme.of(context).colorScheme.onSurfaceVariant,
)
: null,
),
const SizedBox(height: 12),
Text(
'Tip ${staff.firstName.isNotEmpty ? staff.firstName : "Staff"}',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildTipButton(staff, 2),
_buildTipButton(staff, 5),
_buildTipButton(staff, 10),
],
),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => _showCustomTipDialog(staff),
child: const Text('Custom Amount'),
),
const SizedBox(height: 16),
],
),
),
);
}
Widget _buildTipButton(OrderStaff staff, int amount) {
return FilledButton(
onPressed: () {
Navigator.pop(context);
_processTip(staff, amount.toDouble());
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
child: Text('\$$amount'),
);
}
void _showCustomTipDialog(OrderStaff staff) {
Navigator.pop(context);
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Tip ${staff.firstName}'),
content: TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
prefixText: '\$ ',
hintText: '0.00',
border: OutlineInputBorder(),
),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final amount = double.tryParse(controller.text);
if (amount != null && amount > 0) {
Navigator.pop(context);
_processTip(staff, amount);
}
},
child: const Text('Send Tip'),
),
],
),
);
}
void _processTip(OrderStaff staff, double amount) {
// TODO: Implement actual tip processing via API
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Tip of \$${amount.toStringAsFixed(2)} for ${staff.firstName} - Coming soon!',
style: const TextStyle(color: Colors.black),
),
backgroundColor: const Color(0xFF90EE90),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
}
Widget _buildItemsCard(OrderDetail order) { Widget _buildItemsCard(OrderDetail order) {
return Card( return Card(
child: Padding( child: Padding(