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 double businessDeliveryMultiplier;
|
||||
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 double deliveryFee; // The actual delivery fee on this order (set when order type is confirmed)
|
||||
final int statusId;
|
||||
|
|
@ -24,6 +25,7 @@ class Cart {
|
|||
required this.businessId,
|
||||
required this.businessDeliveryMultiplier,
|
||||
required this.businessDeliveryFee,
|
||||
required this.businessOrderTypes,
|
||||
required this.orderTypeId,
|
||||
required this.deliveryFee,
|
||||
required this.statusId,
|
||||
|
|
@ -37,10 +39,26 @@ class Cart {
|
|||
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) {
|
||||
final order = (json["ORDER"] ?? json["Order"]) as Map<String, dynamic>? ?? {};
|
||||
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(
|
||||
orderId: _parseInt(order["OrderID"]) ?? 0,
|
||||
orderUuid: (order["OrderUUID"] as String?) ?? "",
|
||||
|
|
@ -48,6 +66,7 @@ class Cart {
|
|||
businessId: _parseInt(order["OrderBusinessID"]) ?? 0,
|
||||
businessDeliveryMultiplier: _parseDouble(order["OrderBusinessDeliveryMultiplier"]) ?? 0.0,
|
||||
businessDeliveryFee: _parseDouble(order["BusinessDeliveryFee"]) ?? 0.0,
|
||||
businessOrderTypes: orderTypes,
|
||||
orderTypeId: _parseInt(order["OrderTypeID"]) ?? 0,
|
||||
deliveryFee: _parseDouble(order["OrderDeliveryFee"]) ?? 0.0,
|
||||
statusId: _parseInt(order["OrderStatusID"]) ?? 0,
|
||||
|
|
@ -251,3 +270,60 @@ class OrderLineItem {
|
|||
bool get isRootItem => 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');
|
||||
}
|
||||
}
|
||||
// Note: Dine-in orders (orderTypeId=1) stay as dine-in - no order type selection needed
|
||||
|
||||
// Load menu items to get names and prices
|
||||
// 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
|
||||
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(
|
||||
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),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||
|
|
@ -578,8 +588,7 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _ScrollableCartList(
|
||||
children: _buildCartItems(),
|
||||
),
|
||||
),
|
||||
|
|
@ -612,16 +621,13 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
final menuItem = _menuItemsById[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
|
||||
final modifierPaths = _buildModifierPaths(rootItem.orderLineItemId);
|
||||
|
||||
// Calculate total price for this line item (root + all modifiers)
|
||||
final lineItemTotal = _calculateLineItemTotal(rootItem);
|
||||
|
||||
print('[Cart] Found ${modifierPaths.length} modifier paths for this root item');
|
||||
final hasModifiers = modifierPaths.isNotEmpty;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
|
|
@ -629,8 +635,29 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Main row: quantity, name, price, delete
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
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(
|
||||
child: Text(
|
||||
itemName,
|
||||
|
|
@ -640,38 +667,7 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
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(),
|
||||
// Price
|
||||
Text(
|
||||
"\$${lineItemTotal.toStringAsFixed(2)}",
|
||||
style: const TextStyle(
|
||||
|
|
@ -679,8 +675,23 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
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();
|
||||
|
||||
// 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
|
||||
if (item.isCheckedByDefault) {
|
||||
return;
|
||||
|
|
@ -741,25 +752,26 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
final itemName = item.itemName ?? menuItem?.name ?? "Item #${item.itemId}";
|
||||
|
||||
if (children.isEmpty) {
|
||||
// This is a leaf - build display text with parent category name
|
||||
// Format: "Category: Selection" (e.g., "Select Drink: Coke")
|
||||
final displayName = item.itemParentName != null && item.itemParentName!.isNotEmpty
|
||||
? "${item.itemParentName}: $itemName"
|
||||
// This is a leaf - show "GroupName: Selection" format
|
||||
// Use the last group name we saw, or the item's parent name
|
||||
final groupName = lastGroupName ?? item.itemParentName;
|
||||
final displayName = groupName != null && groupName.isNotEmpty
|
||||
? "$groupName: $itemName"
|
||||
: itemName;
|
||||
paths.add(ModifierPath(
|
||||
names: [...currentPath, displayName],
|
||||
names: [displayName],
|
||||
price: item.price,
|
||||
));
|
||||
} else {
|
||||
// This has children - recurse into them
|
||||
// This is a group/category - pass its name down to children
|
||||
for (final child in children) {
|
||||
collectLeafPaths(child, [...currentPath, itemName]);
|
||||
collectLeafPaths(child, itemName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final child in directChildren) {
|
||||
collectLeafPaths(child, []);
|
||||
collectLeafPaths(child, null);
|
||||
}
|
||||
|
||||
return paths;
|
||||
|
|
@ -824,28 +836,29 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
children: [
|
||||
// Order Type Selection (only for delivery/takeaway orders)
|
||||
if (_needsOrderTypeSelection) ...[
|
||||
const Text(
|
||||
"How would you like your order?",
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildOrderTypeButton(
|
||||
label: "Takeaway",
|
||||
icon: Icons.shopping_bag_outlined,
|
||||
orderTypeId: 2,
|
||||
// Only show Takeaway if business offers it
|
||||
if (_cart!.offersTakeaway)
|
||||
Expanded(
|
||||
child: _buildOrderTypeButton(
|
||||
label: "Takeaway",
|
||||
icon: Icons.shopping_bag_outlined,
|
||||
orderTypeId: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildOrderTypeButton(
|
||||
label: "Delivery",
|
||||
icon: Icons.delivery_dining,
|
||||
orderTypeId: 3,
|
||||
// Add spacing only if both are shown
|
||||
if (_cart!.offersTakeaway && _cart!.offersDelivery)
|
||||
const SizedBox(width: 12),
|
||||
// Only show Delivery if business offers it
|
||||
if (_cart!.offersDelivery)
|
||||
Expanded(
|
||||
child: _buildOrderTypeButton(
|
||||
label: "Delivery",
|
||||
icon: Icons.delivery_dining,
|
||||
orderTypeId: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import "package:provider/provider.dart";
|
|||
|
||||
import "../app/app_router.dart";
|
||||
import "../app/app_state.dart";
|
||||
import "../models/cart.dart";
|
||||
import "../services/api.dart";
|
||||
import "../services/auth_storage.dart";
|
||||
|
||||
|
|
@ -122,7 +123,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
final appState = context.read<AppState>();
|
||||
appState.setUserId(response.userId);
|
||||
|
||||
// Show success and navigate
|
||||
// Show success
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
|
|
@ -135,11 +136,29 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
),
|
||||
);
|
||||
|
||||
// Navigate to main app
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
// 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 {
|
||||
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) {
|
||||
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 {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,15 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
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
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
|
@ -82,16 +91,19 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
|
||||
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 {
|
||||
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
|
||||
int? activeTaskId;
|
||||
try {
|
||||
activeTaskId = await Api.getActiveChat(
|
||||
businessId: _businessId!,
|
||||
servicePointId: _servicePointId!,
|
||||
servicePointId: servicePointId,
|
||||
);
|
||||
} catch (e) {
|
||||
// Continue without active chat
|
||||
|
|
@ -99,6 +111,8 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
|
||||
if (!mounted) return;
|
||||
|
||||
final isDineIn = appState.isDineIn;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
|
|
@ -119,24 +133,27 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'How can we help?',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
Text(
|
||||
isDineIn ? 'How can we help?' : 'Contact Us',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: Colors.orange,
|
||||
child: Icon(Icons.room_service, color: Colors.white),
|
||||
// Only show "Request Server Visit" for dine-in orders
|
||||
if (isDineIn && _servicePointId != null) ...[
|
||||
ListTile(
|
||||
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'),
|
||||
subtitle: const Text('Staff will come to your table'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_sendServerRequest(appState);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
const Divider(),
|
||||
],
|
||||
// Show either "Rejoin Chat" OR "Chat with Staff" - never both
|
||||
if (activeTaskId != null)
|
||||
ListTile(
|
||||
|
|
@ -455,14 +472,12 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
],
|
||||
),
|
||||
actions: [
|
||||
// Call Server button - only for dine-in orders at a table
|
||||
if (appState.isDineIn && _servicePointId != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.room_service),
|
||||
tooltip: "Call Server",
|
||||
onPressed: () => _handleCallServer(appState),
|
||||
),
|
||||
// Table change button removed - not allowed currently
|
||||
// Call Server (dine-in) or Chat (non-dine-in) button
|
||||
IconButton(
|
||||
icon: Icon(appState.isDineIn ? Icons.room_service : Icons.chat_bubble_outline),
|
||||
tooltip: appState.isDineIn ? "Call Server" : "Chat",
|
||||
onPressed: () => _handleCallServer(appState),
|
||||
),
|
||||
IconButton(
|
||||
icon: Badge(
|
||||
label: Text("${appState.cartItemCount}"),
|
||||
|
|
@ -940,15 +955,15 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
builder: (context) => _ItemCustomizationSheet(
|
||||
item: item,
|
||||
itemsByParent: _itemsByParent,
|
||||
onAdd: (selectedItemIds) {
|
||||
onAdd: (selectedItemIds, quantity) {
|
||||
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
|
||||
if (_userId == null) {
|
||||
final shouldLogin = await showDialog<bool>(
|
||||
|
|
@ -1039,23 +1054,38 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
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)
|
||||
final existingItem = cart.lineItems.where(
|
||||
(li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted
|
||||
).firstOrNull;
|
||||
// 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(
|
||||
(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(
|
||||
orderId: cart.orderId,
|
||||
parentOrderLineItemId: 0,
|
||||
itemId: item.itemId,
|
||||
isSelected: true,
|
||||
quantity: newQuantity,
|
||||
);
|
||||
cart = await Api.setLineItem(
|
||||
orderId: cart.orderId,
|
||||
parentOrderLineItemId: 0,
|
||||
itemId: item.itemId,
|
||||
isSelected: true,
|
||||
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
|
||||
final rootLineItem = cart.lineItems.lastWhere(
|
||||
|
|
@ -1130,6 +1160,10 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
final hasGrandchildren = grandchildren.isNotEmpty;
|
||||
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) {
|
||||
final cart = await Api.setLineItem(
|
||||
orderId: orderId,
|
||||
|
|
@ -1139,7 +1173,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
);
|
||||
|
||||
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'),
|
||||
);
|
||||
|
||||
|
|
@ -1160,7 +1194,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
);
|
||||
|
||||
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'),
|
||||
);
|
||||
|
||||
|
|
@ -1193,7 +1227,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
class _ItemCustomizationSheet extends StatefulWidget {
|
||||
final MenuItem item;
|
||||
final Map<int, List<MenuItem>> itemsByParent;
|
||||
final Function(Set<int>) onAdd;
|
||||
final Function(Set<int>, int) onAdd; // (selectedModifierIds, quantity)
|
||||
|
||||
const _ItemCustomizationSheet({
|
||||
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> _userModifiedGroups = {}; // Track which parent groups user has interacted with
|
||||
String? _validationError;
|
||||
int _quantity = 1;
|
||||
|
||||
@override
|
||||
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 total = widget.item.price;
|
||||
double unitPrice = widget.item.price;
|
||||
|
||||
void addPriceRecursively(int itemId) {
|
||||
final children = widget.itemsByParent[itemId] ?? [];
|
||||
for (final child in children) {
|
||||
if (_selectedItemIds.contains(child.itemId)) {
|
||||
total += child.price;
|
||||
unitPrice += child.price;
|
||||
addPriceRecursively(child.itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addPriceRecursively(widget.item.itemId);
|
||||
return total;
|
||||
return unitPrice * _quantity;
|
||||
}
|
||||
|
||||
/// 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(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
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(16),
|
||||
Row(
|
||||
children: [
|
||||
// Quantity selector
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.add_shopping_cart, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
"Add to Cart",
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
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),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
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)}",
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
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(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -205,6 +205,32 @@ class _RestaurantBar extends StatelessWidget {
|
|||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
|
|
@ -223,26 +249,30 @@ class _RestaurantBar extends StatelessWidget {
|
|||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Background header image (subtle)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Opacity(
|
||||
opacity: 0.3,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 80,
|
||||
child: Image.network(
|
||||
"$imageBaseUrl/headers/${restaurant.businessId}.png",
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Image.network(
|
||||
"$imageBaseUrl/headers/${restaurant.businessId}.jpg",
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
},
|
||||
// Background header image (subtle) - ignorePointer so taps go through
|
||||
IgnorePointer(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Opacity(
|
||||
opacity: 0.3,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 80,
|
||||
child: Image.network(
|
||||
"$imageBaseUrl/headers/${restaurant.businessId}.png",
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Image.network(
|
||||
"$imageBaseUrl/headers/${restaurant.businessId}.jpg",
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -310,36 +340,32 @@ class _RestaurantBar extends StatelessWidget {
|
|||
child: Image.network(
|
||||
"$imageBaseUrl/logos/${restaurant.businessId}.png",
|
||||
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) {
|
||||
return Image.network(
|
||||
"$imageBaseUrl/logos/${restaurant.businessId}.jpg",
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Text-based fallback with first letter
|
||||
return Container(
|
||||
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,
|
||||
),
|
||||
),
|
||||
gaplessPlayback: true,
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
return Stack(
|
||||
children: [
|
||||
_buildLogoPlaceholder(context),
|
||||
if (frame != null) child,
|
||||
],
|
||||
);
|
||||
},
|
||||
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_state.dart";
|
||||
import "../models/cart.dart";
|
||||
import "../services/api.dart";
|
||||
import "../services/auth_storage.dart";
|
||||
import "../services/beacon_permissions.dart";
|
||||
|
|
@ -46,6 +47,19 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
|||
bool _scanComplete = false;
|
||||
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 = [
|
||||
Colors.white,
|
||||
Colors.red,
|
||||
|
|
@ -62,6 +76,9 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
|||
super.initState();
|
||||
print('[Splash] 🚀 Starting with bouncing logo + beacon scan');
|
||||
|
||||
// Record start time for minimum display duration
|
||||
_splashStartTime = DateTime.now();
|
||||
|
||||
// Start bouncing animation
|
||||
_bounceController = AnimationController(
|
||||
vsync: this,
|
||||
|
|
@ -146,6 +163,13 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
|||
// Start beacon scanning in background
|
||||
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
|
||||
if (!mounted) return;
|
||||
_navigateToNextScreen();
|
||||
|
|
@ -336,46 +360,264 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
|||
}
|
||||
|
||||
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) {
|
||||
// Auto-select business from beacon
|
||||
try {
|
||||
final mapping = await Api.getBusinessFromBeacon(beaconId: _bestBeacon!.beaconId);
|
||||
|
||||
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;
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
// No beacon or error - go to restaurant select
|
||||
print('[Splash] Going to restaurant select');
|
||||
// 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(
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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
|
||||
void 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);
|
||||
}
|
||||
|
||||
/// 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({
|
||||
required int orderId,
|
||||
required int parentOrderLineItemId,
|
||||
|
|
@ -565,6 +583,7 @@ class Api {
|
|||
required bool isSelected,
|
||||
int quantity = 1,
|
||||
String? remark,
|
||||
bool forceNew = false,
|
||||
}) async {
|
||||
final raw = await _postRaw(
|
||||
"/orders/setLineItem.cfm",
|
||||
|
|
@ -575,14 +594,20 @@ class Api {
|
|||
"IsSelected": isSelected,
|
||||
"Quantity": quantity,
|
||||
if (remark != null && remark.isNotEmpty) "Remark": remark,
|
||||
if (forceNew) "ForceNew": true,
|
||||
},
|
||||
);
|
||||
|
||||
final j = _requireJson(raw, "SetLineItem");
|
||||
|
||||
if (!_ok(j)) {
|
||||
// Log debug info if available
|
||||
final debugItem = j["DEBUG_ITEM"];
|
||||
if (debugItem != null) {
|
||||
print("[API] SetLineItem DEBUG_ITEM: $debugItem");
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final raw = await _postRaw(
|
||||
"/orders/submit.cfm",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue