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 int businessId;
final double businessDeliveryMultiplier; final double businessDeliveryMultiplier;
final double businessDeliveryFee; // The business's standard delivery fee (for preview) 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 int orderTypeId;
final double deliveryFee; // The actual delivery fee on this order (set when order type is confirmed) final double deliveryFee; // The actual delivery fee on this order (set when order type is confirmed)
final int statusId; final int statusId;
@ -24,6 +25,7 @@ class Cart {
required this.businessId, required this.businessId,
required this.businessDeliveryMultiplier, required this.businessDeliveryMultiplier,
required this.businessDeliveryFee, required this.businessDeliveryFee,
required this.businessOrderTypes,
required this.orderTypeId, required this.orderTypeId,
required this.deliveryFee, required this.deliveryFee,
required this.statusId, required this.statusId,
@ -37,10 +39,26 @@ class Cart {
required this.lineItems, 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) { factory Cart.fromJson(Map<String, dynamic> json) {
final order = (json["ORDER"] ?? json["Order"]) as Map<String, dynamic>? ?? {}; final order = (json["ORDER"] ?? json["Order"]) as Map<String, dynamic>? ?? {};
final lineItemsJson = (json["ORDERLINEITEMS"] ?? json["OrderLineItems"]) as List? ?? []; 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( return Cart(
orderId: _parseInt(order["OrderID"]) ?? 0, orderId: _parseInt(order["OrderID"]) ?? 0,
orderUuid: (order["OrderUUID"] as String?) ?? "", orderUuid: (order["OrderUUID"] as String?) ?? "",
@ -48,6 +66,7 @@ class Cart {
businessId: _parseInt(order["OrderBusinessID"]) ?? 0, businessId: _parseInt(order["OrderBusinessID"]) ?? 0,
businessDeliveryMultiplier: _parseDouble(order["OrderBusinessDeliveryMultiplier"]) ?? 0.0, businessDeliveryMultiplier: _parseDouble(order["OrderBusinessDeliveryMultiplier"]) ?? 0.0,
businessDeliveryFee: _parseDouble(order["BusinessDeliveryFee"]) ?? 0.0, businessDeliveryFee: _parseDouble(order["BusinessDeliveryFee"]) ?? 0.0,
businessOrderTypes: orderTypes,
orderTypeId: _parseInt(order["OrderTypeID"]) ?? 0, orderTypeId: _parseInt(order["OrderTypeID"]) ?? 0,
deliveryFee: _parseDouble(order["OrderDeliveryFee"]) ?? 0.0, deliveryFee: _parseDouble(order["OrderDeliveryFee"]) ?? 0.0,
statusId: _parseInt(order["OrderStatusID"]) ?? 0, statusId: _parseInt(order["OrderStatusID"]) ?? 0,
@ -251,3 +270,60 @@ class OrderLineItem {
bool get isRootItem => parentOrderLineItemId == 0; bool get isRootItem => parentOrderLineItemId == 0;
bool get isModifier => 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'); 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 // Load menu items to get names and prices
// Use cart's businessId if appState doesn't have one (e.g., delivery/takeaway flow) // 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 // Ensure order type is selected for delivery/takeaway orders
if (_needsOrderTypeSelection && _selectedOrderType == null) { 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( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( 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), backgroundColor: const Color(0xFF90EE90),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16), margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
@ -578,8 +588,7 @@ class _CartViewScreenState extends State<CartViewScreen> {
: Column( : Column(
children: [ children: [
Expanded( Expanded(
child: ListView( child: _ScrollableCartList(
padding: const EdgeInsets.all(16),
children: _buildCartItems(), children: _buildCartItems(),
), ),
), ),
@ -612,16 +621,13 @@ class _CartViewScreenState extends State<CartViewScreen> {
final menuItem = _menuItemsById[rootItem.itemId]; final menuItem = _menuItemsById[rootItem.itemId];
final itemName = rootItem.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}');
// Find ALL modifiers (recursively) and build breadcrumb paths for leaf items only // Find ALL modifiers (recursively) and build breadcrumb paths for leaf items only
final modifierPaths = _buildModifierPaths(rootItem.orderLineItemId); final modifierPaths = _buildModifierPaths(rootItem.orderLineItemId);
// Calculate total price for this line item (root + all modifiers) // Calculate total price for this line item (root + all modifiers)
final lineItemTotal = _calculateLineItemTotal(rootItem); final lineItemTotal = _calculateLineItemTotal(rootItem);
print('[Cart] Found ${modifierPaths.length} modifier paths for this root item'); final hasModifiers = modifierPaths.isNotEmpty;
return Card( return Card(
child: Padding( child: Padding(
@ -629,8 +635,29 @@ class _CartViewScreenState extends State<CartViewScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Main row: quantity, name, price, delete
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ 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( Expanded(
child: Text( child: Text(
itemName, itemName,
@ -640,38 +667,7 @@ class _CartViewScreenState extends State<CartViewScreen> {
), ),
), ),
), ),
IconButton( // Price
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(),
Text( Text(
"\$${lineItemTotal.toStringAsFixed(2)}", "\$${lineItemTotal.toStringAsFixed(2)}",
style: const TextStyle( style: const TextStyle(
@ -679,8 +675,23 @@ class _CartViewScreenState extends State<CartViewScreen> {
fontWeight: FontWeight.bold, 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(); .toList();
// 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, String? lastGroupName) {
// 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 (item.isCheckedByDefault) { if (item.isCheckedByDefault) {
return; return;
@ -741,25 +752,26 @@ class _CartViewScreenState extends State<CartViewScreen> {
final itemName = item.itemName ?? menuItem?.name ?? "Item #${item.itemId}"; final itemName = item.itemName ?? menuItem?.name ?? "Item #${item.itemId}";
if (children.isEmpty) { if (children.isEmpty) {
// This is a leaf - build display text with parent category name // This is a leaf - show "GroupName: Selection" format
// Format: "Category: Selection" (e.g., "Select Drink: Coke") // Use the last group name we saw, or the item's parent name
final displayName = item.itemParentName != null && item.itemParentName!.isNotEmpty final groupName = lastGroupName ?? item.itemParentName;
? "${item.itemParentName}: $itemName" final displayName = groupName != null && groupName.isNotEmpty
? "$groupName: $itemName"
: itemName; : itemName;
paths.add(ModifierPath( paths.add(ModifierPath(
names: [...currentPath, displayName], names: [displayName],
price: item.price, price: item.price,
)); ));
} else { } else {
// This has children - recurse into them // This is a group/category - pass its name down to children
for (final child in children) { for (final child in children) {
collectLeafPaths(child, [...currentPath, itemName]); collectLeafPaths(child, itemName);
} }
} }
} }
for (final child in directChildren) { for (final child in directChildren) {
collectLeafPaths(child, []); collectLeafPaths(child, null);
} }
return paths; return paths;
@ -824,28 +836,29 @@ class _CartViewScreenState extends State<CartViewScreen> {
children: [ children: [
// Order Type Selection (only for delivery/takeaway orders) // Order Type Selection (only for delivery/takeaway orders)
if (_needsOrderTypeSelection) ...[ if (_needsOrderTypeSelection) ...[
const Text(
"How would you like your order?",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Row( Row(
children: [ children: [
Expanded( // Only show Takeaway if business offers it
child: _buildOrderTypeButton( if (_cart!.offersTakeaway)
label: "Takeaway", Expanded(
icon: Icons.shopping_bag_outlined, child: _buildOrderTypeButton(
orderTypeId: 2, label: "Takeaway",
icon: Icons.shopping_bag_outlined,
orderTypeId: 2,
),
), ),
), // Add spacing only if both are shown
const SizedBox(width: 12), if (_cart!.offersTakeaway && _cart!.offersDelivery)
Expanded( const SizedBox(width: 12),
child: _buildOrderTypeButton( // Only show Delivery if business offers it
label: "Delivery", if (_cart!.offersDelivery)
icon: Icons.delivery_dining, Expanded(
orderTypeId: 3, child: _buildOrderTypeButton(
label: "Delivery",
icon: Icons.delivery_dining,
orderTypeId: 3,
),
), ),
),
], ],
), ),
const SizedBox(height: 16), 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_router.dart";
import "../app/app_state.dart"; import "../app/app_state.dart";
import "../models/cart.dart";
import "../services/api.dart"; import "../services/api.dart";
import "../services/auth_storage.dart"; import "../services/auth_storage.dart";
@ -122,7 +123,7 @@ class _LoginScreenState extends State<LoginScreen> {
final appState = context.read<AppState>(); final appState = context.read<AppState>();
appState.setUserId(response.userId); appState.setUserId(response.userId);
// Show success and navigate // Show success
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
@ -135,11 +136,29 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
); );
// Navigate to main app // Check for existing cart
if (Navigator.of(context).canPop()) { ActiveCartInfo? existingCart;
Navigator.of(context).pop(); 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 { } 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) { } catch (e) {
if (!mounted) return; 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 { Future<void> _handleResendOtp() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;

View file

@ -43,6 +43,15 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
return null; 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 @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
@ -82,16 +91,19 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
bool _isCallingServer = false; 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 { 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 // Check for active chat first
int? activeTaskId; int? activeTaskId;
try { try {
activeTaskId = await Api.getActiveChat( activeTaskId = await Api.getActiveChat(
businessId: _businessId!, businessId: _businessId!,
servicePointId: _servicePointId!, servicePointId: servicePointId,
); );
} catch (e) { } catch (e) {
// Continue without active chat // Continue without active chat
@ -99,6 +111,8 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
if (!mounted) return; if (!mounted) return;
final isDineIn = appState.isDineIn;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
@ -119,24 +133,27 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
const Text( Text(
'How can we help?', isDineIn ? 'How can we help?' : 'Contact Us',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ListTile( // Only show "Request Server Visit" for dine-in orders
leading: const CircleAvatar( if (isDineIn && _servicePointId != null) ...[
backgroundColor: Colors.orange, ListTile(
child: Icon(Icons.room_service, color: Colors.white), 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'), const Divider(),
subtitle: const Text('Staff will come to your table'), ],
onTap: () {
Navigator.pop(context);
_sendServerRequest(appState);
},
),
const Divider(),
// Show either "Rejoin Chat" OR "Chat with Staff" - never both // Show either "Rejoin Chat" OR "Chat with Staff" - never both
if (activeTaskId != null) if (activeTaskId != null)
ListTile( ListTile(
@ -455,14 +472,12 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
], ],
), ),
actions: [ actions: [
// Call Server button - only for dine-in orders at a table // Call Server (dine-in) or Chat (non-dine-in) button
if (appState.isDineIn && _servicePointId != null) IconButton(
IconButton( icon: Icon(appState.isDineIn ? Icons.room_service : Icons.chat_bubble_outline),
icon: const Icon(Icons.room_service), tooltip: appState.isDineIn ? "Call Server" : "Chat",
tooltip: "Call Server", onPressed: () => _handleCallServer(appState),
onPressed: () => _handleCallServer(appState), ),
),
// Table change button removed - not allowed currently
IconButton( IconButton(
icon: Badge( icon: Badge(
label: Text("${appState.cartItemCount}"), label: Text("${appState.cartItemCount}"),
@ -940,15 +955,15 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
builder: (context) => _ItemCustomizationSheet( builder: (context) => _ItemCustomizationSheet(
item: item, item: item,
itemsByParent: _itemsByParent, itemsByParent: _itemsByParent,
onAdd: (selectedItemIds) { onAdd: (selectedItemIds, quantity) {
Navigator.pop(context); 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 // Check if user is logged in - if not, navigate to login
if (_userId == null) { if (_userId == null) {
final shouldLogin = await showDialog<bool>( final shouldLogin = await showDialog<bool>(
@ -1039,23 +1054,38 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
orderTypeId: 1, // dine-in 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) // For items with customizations, always create a new line item
final existingItem = cart.lineItems.where( // For items without customizations, increment quantity of existing item
(li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted if (selectedModifierIds.isEmpty) {
).firstOrNull; // 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(
cart = await Api.setLineItem( orderId: cart.orderId,
orderId: cart.orderId, parentOrderLineItemId: 0,
parentOrderLineItemId: 0, itemId: item.itemId,
itemId: item.itemId, isSelected: true,
isSelected: true, quantity: newQuantity,
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 // Find the OrderLineItemID of the root item we just added
final rootLineItem = cart.lineItems.lastWhere( final rootLineItem = cart.lineItems.lastWhere(
@ -1130,6 +1160,10 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
final hasGrandchildren = grandchildren.isNotEmpty; final hasGrandchildren = grandchildren.isNotEmpty;
final hasSelectedDescendants = _hasSelectedDescendants(child.itemId, selectedItemIds); 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) { if (isSelected) {
final cart = await Api.setLineItem( final cart = await Api.setLineItem(
orderId: orderId, orderId: orderId,
@ -1139,7 +1173,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
); );
final childLineItem = cart.lineItems.lastWhere( 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'), orElse: () => throw StateError('Failed to add item'),
); );
@ -1160,7 +1194,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
); );
final childLineItem = cart.lineItems.lastWhere( 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'), orElse: () => throw StateError('Failed to add item'),
); );
@ -1193,7 +1227,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
class _ItemCustomizationSheet extends StatefulWidget { class _ItemCustomizationSheet extends StatefulWidget {
final MenuItem item; final MenuItem item;
final Map<int, List<MenuItem>> itemsByParent; final Map<int, List<MenuItem>> itemsByParent;
final Function(Set<int>) onAdd; final Function(Set<int>, int) onAdd; // (selectedModifierIds, quantity)
const _ItemCustomizationSheet({ const _ItemCustomizationSheet({
required this.item, 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> _defaultItemIds = {}; // Track which items were defaults (not user-selected)
final Set<int> _userModifiedGroups = {}; // Track which parent groups user has interacted with final Set<int> _userModifiedGroups = {}; // Track which parent groups user has interacted with
String? _validationError; String? _validationError;
int _quantity = 1;
@override @override
void initState() { 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 _calculateTotal() {
double total = widget.item.price; double unitPrice = widget.item.price;
void addPriceRecursively(int itemId) { void addPriceRecursively(int itemId) {
final children = widget.itemsByParent[itemId] ?? []; final children = widget.itemsByParent[itemId] ?? [];
for (final child in children) { for (final child in children) {
if (_selectedItemIds.contains(child.itemId)) { if (_selectedItemIds.contains(child.itemId)) {
total += child.price; unitPrice += child.price;
addPriceRecursively(child.itemId); addPriceRecursively(child.itemId);
} }
} }
} }
addPriceRecursively(widget.item.itemId); addPriceRecursively(widget.item.itemId);
return total; return unitPrice * _quantity;
} }
/// Validate selections before adding to cart /// 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( Row(
width: double.infinity, children: [
height: 56, // Quantity selector
child: ElevatedButton( Container(
onPressed: _handleAdd, decoration: BoxDecoration(
style: ElevatedButton.styleFrom( color: Colors.grey.shade100,
backgroundColor: Colors.blue.shade600, borderRadius: BorderRadius.circular(12),
foregroundColor: Colors.white, border: Border.all(color: Colors.grey.shade300),
elevation: 2,
shadowColor: Colors.blue.shade200,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), ),
), child: Row(
child: Row( mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ IconButton(
const Icon(Icons.add_shopping_cart, size: 22), icon: const Icon(Icons.remove, size: 20),
const SizedBox(width: 10), onPressed: _quantity > 1
Text( ? () => setState(() => _quantity--)
"Add to Cart", : null,
style: const TextStyle( visualDensity: VisualDensity.compact,
fontSize: 16, padding: const EdgeInsets.all(8),
fontWeight: FontWeight.w600,
), ),
), Container(
const SizedBox(width: 8), constraints: const BoxConstraints(minWidth: 32),
Container( alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), child: Text(
decoration: BoxDecoration( "$_quantity",
color: Colors.white.withOpacity(0.2), style: const TextStyle(
borderRadius: BorderRadius.circular(12), fontSize: 16,
), fontWeight: FontWeight.bold,
child: Text( ),
"\$${_calculateTotal().toStringAsFixed(2)}",
style: const TextStyle(
fontSize: 15,
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, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
@ -223,26 +249,30 @@ class _RestaurantBar extends StatelessWidget {
), ),
child: Stack( child: Stack(
children: [ children: [
// Background header image (subtle) // Background header image (subtle) - ignorePointer so taps go through
ClipRRect( IgnorePointer(
borderRadius: BorderRadius.circular(12), child: ClipRRect(
child: Opacity( borderRadius: BorderRadius.circular(12),
opacity: 0.3, child: Opacity(
child: SizedBox( opacity: 0.3,
width: double.infinity, child: SizedBox(
height: 80, width: double.infinity,
child: Image.network( height: 80,
"$imageBaseUrl/headers/${restaurant.businessId}.png", child: Image.network(
fit: BoxFit.cover, "$imageBaseUrl/headers/${restaurant.businessId}.png",
errorBuilder: (context, error, stackTrace) { fit: BoxFit.cover,
return Image.network( gaplessPlayback: true,
"$imageBaseUrl/headers/${restaurant.businessId}.jpg", errorBuilder: (context, error, stackTrace) {
fit: BoxFit.cover, return Image.network(
errorBuilder: (context, error, stackTrace) { "$imageBaseUrl/headers/${restaurant.businessId}.jpg",
return const SizedBox.shrink(); fit: BoxFit.cover,
}, gaplessPlayback: true,
); errorBuilder: (context, error, stackTrace) {
}, return const SizedBox.shrink();
},
);
},
),
), ),
), ),
), ),
@ -310,36 +340,32 @@ class _RestaurantBar extends StatelessWidget {
child: Image.network( child: Image.network(
"$imageBaseUrl/logos/${restaurant.businessId}.png", "$imageBaseUrl/logos/${restaurant.businessId}.png",
fit: BoxFit.cover, 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) { errorBuilder: (context, error, stackTrace) {
return Image.network( return Image.network(
"$imageBaseUrl/logos/${restaurant.businessId}.jpg", "$imageBaseUrl/logos/${restaurant.businessId}.jpg",
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) { gaplessPlayback: true,
// Text-based fallback with first letter frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
return Container( return Stack(
decoration: BoxDecoration( children: [
gradient: LinearGradient( _buildLogoPlaceholder(context),
begin: Alignment.topLeft, if (frame != null) child,
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,
),
),
); );
}, },
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_router.dart";
import "../app/app_state.dart"; import "../app/app_state.dart";
import "../models/cart.dart";
import "../services/api.dart"; import "../services/api.dart";
import "../services/auth_storage.dart"; import "../services/auth_storage.dart";
import "../services/beacon_permissions.dart"; import "../services/beacon_permissions.dart";
@ -46,6 +47,19 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
bool _scanComplete = false; bool _scanComplete = false;
BeaconResult? _bestBeacon; 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 = [ static const List<Color> _colors = [
Colors.white, Colors.white,
Colors.red, Colors.red,
@ -62,6 +76,9 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
super.initState(); super.initState();
print('[Splash] 🚀 Starting with bouncing logo + beacon scan'); print('[Splash] 🚀 Starting with bouncing logo + beacon scan');
// Record start time for minimum display duration
_splashStartTime = DateTime.now();
// Start bouncing animation // Start bouncing animation
_bounceController = AnimationController( _bounceController = AnimationController(
vsync: this, vsync: this,
@ -146,6 +163,13 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
// Start beacon scanning in background // Start beacon scanning in background
await _performBeaconScan(); 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 // Navigate based on results
if (!mounted) return; if (!mounted) return;
_navigateToNextScreen(); _navigateToNextScreen();
@ -336,46 +360,264 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
} }
Future<void> _navigateToNextScreen() async { 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) { if (_bestBeacon != null) {
// Auto-select business from beacon
try { try {
final mapping = await Api.getBusinessFromBeacon(beaconId: _bestBeacon!.beaconId); _beaconMapping = await Api.getBusinessFromBeacon(beaconId: _bestBeacon!.beaconId);
print('[Splash] 📍 Beacon maps to: ${_beaconMapping!.businessName}');
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;
} catch (e) { } catch (e) {
print('[Splash] Error mapping beacon to business: $e'); print('[Splash] Error mapping beacon to business: $e');
_beaconMapping = null;
} }
} }
// No beacon or error - go to restaurant select // Check for existing cart if user is logged in
print('[Splash] Going to restaurant select'); 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); 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 @override
void dispose() { void dispose() {
_bounceController.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); 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({ static Future<Cart> setLineItem({
required int orderId, required int orderId,
required int parentOrderLineItemId, required int parentOrderLineItemId,
@ -565,6 +583,7 @@ class Api {
required bool isSelected, required bool isSelected,
int quantity = 1, int quantity = 1,
String? remark, String? remark,
bool forceNew = false,
}) async { }) async {
final raw = await _postRaw( final raw = await _postRaw(
"/orders/setLineItem.cfm", "/orders/setLineItem.cfm",
@ -575,14 +594,20 @@ class Api {
"IsSelected": isSelected, "IsSelected": isSelected,
"Quantity": quantity, "Quantity": quantity,
if (remark != null && remark.isNotEmpty) "Remark": remark, if (remark != null && remark.isNotEmpty) "Remark": remark,
if (forceNew) "ForceNew": true,
}, },
); );
final j = _requireJson(raw, "SetLineItem"); final j = _requireJson(raw, "SetLineItem");
if (!_ok(j)) { if (!_ok(j)) {
// Log debug info if available
final debugItem = j["DEBUG_ITEM"];
if (debugItem != null) {
print("[API] SetLineItem DEBUG_ITEM: $debugItem");
}
throw StateError( 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); 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 { static Future<void> submitOrder({required int orderId}) async {
final raw = await _postRaw( final raw = await _postRaw(
"/orders/submit.cfm", "/orders/submit.cfm",