From ef8421c88ae3ffa5d6af7b8a773bbe8668823454 Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Mon, 12 Jan 2026 19:06:54 -0800 Subject: [PATCH] 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 --- lib/models/cart.dart | 12 ++ lib/models/order_detail.dart | 26 ++++ lib/screens/cart_view_screen.dart | 64 ++++++-- lib/screens/login_screen.dart | 14 +- lib/screens/menu_browse_screen.dart | 24 +++ lib/screens/order_detail_screen.dart | 213 ++++++++++++++++++++++++++- 6 files changed, 331 insertions(+), 22 deletions(-) diff --git a/lib/models/cart.dart b/lib/models/cart.dart index 256cda8..371d6f7 100644 --- a/lib/models/cart.dart +++ b/lib/models/cart.dart @@ -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 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"]), ); } diff --git a/lib/models/order_detail.dart b/lib/models/order_detail.dart index 7f6233a..b0a5a34 100644 --- a/lib/models/order_detail.dart +++ b/lib/models/order_detail.dart @@ -18,6 +18,7 @@ class OrderDetail { final OrderCustomer customer; final OrderServicePoint servicePoint; final List lineItems; + final List 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 json) { final lineItemsJson = json['LineItems'] as List? ?? []; + final staffJson = json['Staff'] as List? ?? []; return OrderDetail( orderId: _parseInt(json['OrderID']) ?? 0, @@ -64,6 +67,9 @@ class OrderDetail { lineItems: lineItemsJson .map((e) => OrderLineItemDetail.fromJson(e as Map)) .toList(), + staff: staffJson + .map((e) => OrderStaff.fromJson(e as Map)) + .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 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; diff --git a/lib/screens/cart_view_screen.dart b/lib/screens/cart_view_screen.dart index 836a5a2..be6c29a 100644 --- a/lib/screens/cart_view_screen.dart +++ b/lib/screens/cart_view_screen.dart @@ -129,11 +129,36 @@ class _CartViewScreenState extends State { } // 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 { } 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 { // Recursively collect leaf items with their paths void collectLeafPaths(OrderLineItem item, List 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 { !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,11 +801,16 @@ class _CartViewScreenState extends State { ), ], ), + // 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: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ // Order Type Selection (only for delivery/takeaway orders) if (_needsOrderTypeSelection) ...[ const Text( @@ -947,7 +982,8 @@ class _CartViewScreenState extends State { ), ), ), - ], + ], + ), ), ), ); diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 6dec46a..d066d95 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -239,11 +239,12 @@ class _LoginScreenState extends State { 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 { 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: [ diff --git a/lib/screens/menu_browse_screen.dart b/lib/screens/menu_browse_screen.dart index 79f32b5..36c1f86 100644 --- a/lib/screens/menu_browse_screen.dart +++ b/lib/screens/menu_browse_screen.dart @@ -1015,6 +1015,30 @@ class _MenuBrowseScreenState extends State { } 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) diff --git a/lib/screens/order_detail_screen.dart b/lib/screens/order_detail_screen.dart index 0070898..250e16a 100644 --- a/lib/screens/order_detail_screen.dart +++ b/lib/screens/order_detail_screen.dart @@ -115,6 +115,10 @@ class _OrderDetailScreenState extends State { _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 { 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 { ); } + 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(