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 bool isDeleted;
final DateTime addedOn;
final String? itemName;
final int? itemParentItemId;
final String? itemParentName;
final bool isCheckedByDefault;
const OrderLineItem({
required this.orderLineItemId,
@ -173,6 +177,10 @@ class OrderLineItem {
this.remark,
required this.isDeleted,
required this.addedOn,
this.itemName,
this.itemParentItemId,
this.itemParentName,
this.isCheckedByDefault = false,
});
factory OrderLineItem.fromJson(Map<String, dynamic> json) {
@ -187,6 +195,10 @@ class OrderLineItem {
remark: json["OrderLineItemRemark"] as String?,
isDeleted: _parseBool(json["OrderLineItemIsDeleted"]),
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 OrderServicePoint servicePoint;
final List<OrderLineItemDetail> lineItems;
final List<OrderStaff> staff;
const OrderDetail({
required this.orderId,
@ -38,10 +39,12 @@ class OrderDetail {
required this.customer,
required this.servicePoint,
required this.lineItems,
required this.staff,
});
factory OrderDetail.fromJson(Map<String, dynamic> json) {
final lineItemsJson = json['LineItems'] as List<dynamic>? ?? [];
final staffJson = json['Staff'] as List<dynamic>? ?? [];
return OrderDetail(
orderId: _parseInt(json['OrderID']) ?? 0,
@ -64,6 +67,9 @@ class OrderDetail {
lineItems: lineItemsJson
.map((e) => OrderLineItemDetail.fromJson(e as Map<String, dynamic>))
.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 {
final int lineItemId;
final int itemId;

View file

@ -129,11 +129,36 @@ class _CartViewScreenState extends State<CartViewScreen> {
}
// 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
final businessId = appState.selectedBusinessId;
if (businessId != null) {
// Use cart's businessId if appState doesn't have one (e.g., delivery/takeaway flow)
final businessId = appState.selectedBusinessId ?? cart.businessId;
if (businessId > 0) {
final menuItems = await Api.listMenuItems(businessId: businessId);
_menuItemsById = {for (var item in menuItems) item.itemId: item};
}
@ -572,8 +597,9 @@ class _CartViewScreenState extends State<CartViewScreen> {
}
Widget _buildRootItemCard(OrderLineItem rootItem) {
// Use itemName from line item (from API), fall back to menu item lookup, then to ID
final menuItem = _menuItemsById[rootItem.itemId];
final itemName = 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] Total line items in cart: ${_cart!.lineItems.length}');
@ -688,10 +714,8 @@ class _CartViewScreenState extends State<CartViewScreen> {
// Recursively collect leaf items with their paths
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
if (menuItem?.isCheckedByDefault == true) {
if (item.isCheckedByDefault) {
return;
}
@ -701,12 +725,18 @@ class _CartViewScreenState extends State<CartViewScreen> {
!child.isDeleted)
.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) {
// 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(
names: [...currentPath, itemName],
names: [...currentPath, displayName],
price: item.price,
));
} 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),
child: SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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),
TextFormField(
controller: _phoneController,
decoration: const InputDecoration(
decoration: InputDecoration(
labelText: "Phone Number",
hintText: "(555) 123-4567",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
hintStyle: TextStyle(color: Colors.grey.shade400),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.phone),
prefixText: "+1 ",
),
keyboardType: TextInputType.phone,
@ -311,11 +312,12 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 24),
TextFormField(
controller: _otpController,
decoration: const InputDecoration(
decoration: InputDecoration(
labelText: "Login Code",
hintText: "123456",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock),
hintStyle: TextStyle(color: Colors.grey.shade400),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock),
),
keyboardType: TextInputType.number,
inputFormatters: [

View file

@ -1015,6 +1015,30 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
} else {
// We have an existing cart ID
cart = await Api.getCart(orderId: appState.cartOrderId!);
// If cart is not in cart status (0), it's been submitted - create a new cart
if (cart.statusId != 0) {
debugPrint('Cart has been submitted (status=${cart.statusId}), creating new cart');
appState.clearCart();
final orderTypeId = appState.isDineIn ? 1 : 0;
cart = await Api.getOrCreateCart(
userId: _userId!,
businessId: _businessId!,
servicePointId: _servicePointId!,
orderTypeId: orderTypeId,
);
appState.setCartOrder(
orderId: cart.orderId,
orderUuid: cart.orderUuid,
itemCount: cart.itemCount,
);
} else if (appState.isDineIn && cart.orderTypeId == 0) {
// If we're dine-in (beacon detected) but cart has no order type set, update it
cart = await Api.setOrderType(
orderId: cart.orderId,
orderTypeId: 1, // dine-in
);
}
}
// 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),
const SizedBox(height: 16),
_buildStatusCard(order),
if (order.staff.isNotEmpty) ...[
const SizedBox(height: 16),
_buildStaffCard(order),
],
const SizedBox(height: 16),
_buildItemsCard(order),
const SizedBox(height: 16),
@ -293,11 +297,15 @@ class _OrderDetailScreenState extends State<OrderDetailScreen> {
icon = Icons.check_circle_outline;
color = Colors.green;
break;
case 4: // Completed
case 4: // Out for Delivery
icon = Icons.local_shipping;
color = Colors.blue;
break;
case 5: // Delivered
icon = Icons.check_circle;
color = Colors.green;
break;
case 5: // Cancelled
case 6: // Cancelled
icon = Icons.cancel;
color = Colors.red;
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) {
return Card(
child: Padding(