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:
parent
9ebcd0b223
commit
9a489f20bb
7 changed files with 1000 additions and 234 deletions
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,13 +836,10 @@ 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: [
|
||||||
|
// Only show Takeaway if business offers it
|
||||||
|
if (_cart!.offersTakeaway)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildOrderTypeButton(
|
child: _buildOrderTypeButton(
|
||||||
label: "Takeaway",
|
label: "Takeaway",
|
||||||
|
|
@ -838,7 +847,11 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
orderTypeId: 2,
|
orderTypeId: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Add spacing only if both are shown
|
||||||
|
if (_cart!.offersTakeaway && _cart!.offersDelivery)
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
// Only show Delivery if business offers it
|
||||||
|
if (_cart!.offersDelivery)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildOrderTypeButton(
|
child: _buildOrderTypeButton(
|
||||||
label: "Delivery",
|
label: "Delivery",
|
||||||
|
|
@ -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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
ActiveCartInfo? existingCart;
|
||||||
|
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 {
|
||||||
|
// No existing cart - just pop back
|
||||||
if (Navigator.of(context).canPop()) {
|
if (Navigator.of(context).canPop()) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
} else {
|
} else {
|
||||||
Navigator.of(context).pushReplacementNamed(AppRoutes.splash);
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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,11 +133,13 @@ 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),
|
||||||
|
// Only show "Request Server Visit" for dine-in orders
|
||||||
|
if (isDineIn && _servicePointId != null) ...[
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const CircleAvatar(
|
leading: const CircleAvatar(
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: Colors.orange,
|
||||||
|
|
@ -137,6 +153,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(),
|
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: const Icon(Icons.room_service),
|
icon: Icon(appState.isDineIn ? Icons.room_service : Icons.chat_bubble_outline),
|
||||||
tooltip: "Call Server",
|
tooltip: appState.isDineIn ? "Call Server" : "Chat",
|
||||||
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,16 +1054,19 @@ 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
|
||||||
|
// For items without customizations, increment quantity of existing item
|
||||||
|
if (selectedModifierIds.isEmpty) {
|
||||||
|
// No customizations - find existing and increment quantity
|
||||||
final existingItem = cart.lineItems.where(
|
final existingItem = cart.lineItems.where(
|
||||||
(li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted
|
(li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted
|
||||||
).firstOrNull;
|
).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,
|
||||||
|
|
@ -1056,6 +1074,18 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
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,9 +1560,51 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
SizedBox(
|
Row(
|
||||||
width: double.infinity,
|
children: [
|
||||||
height: 56,
|
// Quantity selector
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove, size: 20),
|
||||||
|
onPressed: _quantity > 1
|
||||||
|
? () => setState(() => _quantity--)
|
||||||
|
: null,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(minWidth: 32),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
"$_quantity",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
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(
|
child: ElevatedButton(
|
||||||
onPressed: _handleAdd,
|
onPressed: _handleAdd,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
|
|
@ -1536,40 +1613,36 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> {
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
shadowColor: Colors.blue.shade200,
|
shadowColor: Colors.blue.shade200,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.add_shopping_cart, size: 22),
|
const Icon(Icons.add_shopping_cart, size: 20),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
"Add to Cart",
|
"Add",
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Text(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withOpacity(0.2),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
"\$${_calculateTotal().toStringAsFixed(2)}",
|
"\$${_calculateTotal().toStringAsFixed(2)}",
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,8 +249,9 @@ 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(
|
||||||
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
|
|
@ -234,10 +261,12 @@ class _RestaurantBar extends StatelessWidget {
|
||||||
child: Image.network(
|
child: Image.network(
|
||||||
"$imageBaseUrl/headers/${restaurant.businessId}.png",
|
"$imageBaseUrl/headers/${restaurant.businessId}.png",
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
return Image.network(
|
return Image.network(
|
||||||
"$imageBaseUrl/headers/${restaurant.businessId}.jpg",
|
"$imageBaseUrl/headers/${restaurant.businessId}.jpg",
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
},
|
},
|
||||||
|
|
@ -247,6 +276,7 @@ class _RestaurantBar extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
// Sharp gradient edges
|
// Sharp gradient edges
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
if (_bestBeacon != null) {
|
setState(() {
|
||||||
// Auto-select business from beacon
|
_navigating = true;
|
||||||
try {
|
});
|
||||||
final mapping = await Api.getBusinessFromBeacon(beaconId: _bestBeacon!.beaconId);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
final appState = context.read<AppState>();
|
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}');
|
// Get beacon mapping if we found a beacon
|
||||||
|
if (_bestBeacon != null) {
|
||||||
|
try {
|
||||||
|
_beaconMapping = await Api.getBusinessFromBeacon(beaconId: _bestBeacon!.beaconId);
|
||||||
|
print('[Splash] 📍 Beacon maps to: ${_beaconMapping!.businessName}');
|
||||||
|
} catch (e) {
|
||||||
|
print('[Splash] Error mapping beacon to business: $e');
|
||||||
|
_beaconMapping = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing cart if user is logged in
|
||||||
|
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(
|
Navigator.of(context).pushReplacementNamed(
|
||||||
AppRoutes.menuBrowse,
|
AppRoutes.menuBrowse,
|
||||||
arguments: {
|
arguments: {
|
||||||
'businessId': mapping.businessId,
|
'businessId': _beaconMapping!.businessId,
|
||||||
'servicePointId': mapping.servicePointId,
|
'servicePointId': _beaconMapping!.servicePointId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
print('[Splash] Error mapping beacon to business: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No beacon or error - go to restaurant select
|
/// Start fresh dine-in order with beacon
|
||||||
print('[Splash] Going to restaurant select');
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue