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:
parent
867d67c8eb
commit
ef8421c88a
6 changed files with 331 additions and 22 deletions
|
|
@ -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"]),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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> {
|
|||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue