payfrit-app/lib/screens/menu_browse_screen.dart
John Mizerek f6428a14ff Fix modifier nesting - don't add intermediate groups
When user customizes an item, add selections directly under the root
item instead of creating an intermediate group node. This matches how
attachDefaultChildren works on the server and prevents duplicate
modifiers showing in KDS.

Before: Double Double → Customize Lettuce → Light, Extra
After:  Double Double → Light, Extra

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:51:59 -08:00

1863 lines
65 KiB
Dart

import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "../app/app_router.dart";
import "../app/app_state.dart";
import "../models/cart.dart";
import "../models/menu_item.dart";
import "../services/api.dart";
import "../services/auth_storage.dart";
import "chat_screen.dart";
class MenuBrowseScreen extends StatefulWidget {
const MenuBrowseScreen({super.key});
@override
State<MenuBrowseScreen> createState() => _MenuBrowseScreenState();
}
class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
Future<List<MenuItem>>? _future;
int? _businessId;
int? _servicePointId;
int? _userId;
List<MenuItem> _allItems = [];
final Map<int, List<MenuItem>> _itemsByCategory = {};
final Map<int, List<MenuItem>> _itemsByParent = {};
final Map<int, int> _categorySortOrder = {}; // categoryId -> sortOrder
final Map<int, String> _categoryNames = {}; // categoryId -> categoryName
// Track which category is currently expanded (null = none)
int? _expandedCategoryId;
int? _asIntNullable(dynamic v) {
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) {
final s = v.trim();
if (s.isEmpty) return null;
return int.tryParse(s);
}
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();
final appState = context.watch<AppState>();
final u = appState.userId;
final args = ModalRoute.of(context)?.settings.arguments;
if (args is Map) {
final b = _asIntNullable(args["BusinessID"]) ?? _asIntNullable(args["businessId"]);
final sp = _asIntNullable(args["ServicePointID"]) ?? _asIntNullable(args["servicePointId"]);
if (_businessId != b || _servicePointId != sp || _userId != u || _future == null) {
_businessId = b;
_servicePointId = sp;
_userId = u;
if (_businessId != null && _businessId! > 0) {
_future = _loadMenu();
} else {
_future = Future.value(<MenuItem>[]);
}
}
}
}
Future<List<MenuItem>> _loadMenu() async {
final items = await Api.listMenuItems(businessId: _businessId!);
setState(() {
_allItems = items;
_organizeItems();
});
return items;
}
bool _isCallingServer = false;
/// 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) 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,
);
} catch (e) {
// Continue without active chat
}
if (!mounted) return;
final isDineIn = appState.isDineIn;
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
Text(
isDineIn ? 'How can we help?' : 'Contact Us',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// 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);
},
),
const Divider(),
],
// Show either "Rejoin Chat" OR "Chat with Staff" - never both
if (activeTaskId != null)
ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.green,
child: Icon(Icons.chat_bubble, color: Colors.white),
),
title: const Text('Rejoin Chat'),
subtitle: const Text('Continue your conversation'),
onTap: () {
Navigator.pop(context);
_rejoinChat(activeTaskId!);
},
)
else
ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.blue,
child: Icon(Icons.chat, color: Colors.white),
),
title: const Text('Chat with Staff'),
subtitle: const Text('Send a message to our team'),
onTap: () {
Navigator.pop(context);
_startChat(appState);
},
),
],
),
),
),
);
}
/// Check if user is logged in, prompt login if not
/// Returns true if logged in, false if user needs to log in
Future<bool> _ensureLoggedIn() async {
final auth = await AuthStorage.loadAuth();
if (auth != null && auth.userId > 0) {
return true;
}
if (!mounted) return false;
// Show login prompt
final shouldLogin = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sign In Required'),
content: const Text('Please sign in to use the chat feature.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Sign In'),
),
],
),
);
if (shouldLogin == true && mounted) {
Navigator.pushNamed(context, AppRoutes.login);
}
return false;
}
/// Rejoin an existing active chat
Future<void> _rejoinChat(int taskId) async {
if (!await _ensureLoggedIn()) return;
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(
taskId: taskId,
userType: 'customer',
),
),
);
}
/// Send a server visit request (ping)
Future<void> _sendServerRequest(AppState appState) async {
if (_isCallingServer) return;
setState(() => _isCallingServer = true);
try {
await Api.callServer(
businessId: _businessId!,
servicePointId: _servicePointId!,
orderId: appState.cartOrderId,
userId: appState.userId,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Row(
children: [
Icon(Icons.check_circle, color: Colors.black),
SizedBox(width: 8),
Expanded(child: Text("Server has been notified", style: TextStyle(color: Colors.black))),
],
),
backgroundColor: const Color(0xFF90EE90),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error, color: Colors.black),
const SizedBox(width: 8),
Expanded(child: Text("Failed to call server: $e", style: const TextStyle(color: Colors.black))),
],
),
backgroundColor: Colors.red.shade100,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
} finally {
if (mounted) {
setState(() => _isCallingServer = false);
}
}
}
/// Start a new chat with staff
Future<void> _startChat(AppState appState) async {
if (_isCallingServer) return;
// Check login first
if (!await _ensureLoggedIn()) return;
setState(() => _isCallingServer = true);
try {
// Reload auth to get userId
final auth = await AuthStorage.loadAuth();
final userId = auth?.userId;
// Create new chat
final taskId = await Api.createChatTask(
businessId: _businessId!,
servicePointId: _servicePointId!,
orderId: appState.cartOrderId,
userId: userId,
);
if (!mounted) return;
// Navigate to chat screen
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(
taskId: taskId,
userType: 'customer',
),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error, color: Colors.black),
const SizedBox(width: 8),
Expanded(child: Text("Failed to start chat: $e", style: const TextStyle(color: Colors.black))),
],
),
backgroundColor: Colors.red.shade100,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
} finally {
if (mounted) {
setState(() => _isCallingServer = false);
}
}
}
void _organizeItems() {
_itemsByCategory.clear();
_itemsByParent.clear();
_categorySortOrder.clear();
_categoryNames.clear();
// First pass: identify category items (root items where itemId == categoryId)
final categoryItemIds = <int>{};
for (final item in _allItems) {
if (item.isRootItem && item.itemId == item.categoryId) {
categoryItemIds.add(item.itemId);
_itemsByCategory.putIfAbsent(item.itemId, () => []);
_categorySortOrder[item.itemId] = item.sortOrder;
_categoryNames[item.itemId] = item.name;
}
}
// Second pass: organize menu items and modifiers
for (final item in _allItems) {
if (!item.isActive) continue;
if (categoryItemIds.contains(item.itemId)) continue;
if (categoryItemIds.contains(item.parentItemId)) {
_itemsByCategory.putIfAbsent(item.parentItemId, () => []).add(item);
} else {
if (item.itemId != item.parentItemId) {
_itemsByParent.putIfAbsent(item.parentItemId, () => []).add(item);
}
}
}
// Sort items within each category by sortOrder
for (final list in _itemsByCategory.values) {
list.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
}
// Sort modifiers within each parent
for (final list in _itemsByParent.values) {
list.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
}
}
List<int> _getUniqueCategoryIds() {
final categoryIds = _itemsByCategory.keys.toList();
// Sort by sortOrder (from _categorySortOrder), not by ItemID
categoryIds.sort((a, b) {
final orderA = _categorySortOrder[a] ?? 0;
final orderB = _categorySortOrder[b] ?? 0;
return orderA.compareTo(orderB);
});
return categoryIds;
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final businessName = appState.selectedBusinessName ?? "Menu";
return Scaffold(
appBar: AppBar(
title: Row(
children: [
// Business logo
if (_businessId != null)
Padding(
padding: const EdgeInsets.only(right: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 36,
height: 36,
child: Image.network(
"$_imageBaseUrl/logos/$_businessId.png",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$_imageBaseUrl/logos/$_businessId.jpg",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.store,
size: 20,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
);
},
);
},
),
),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
businessName,
style: const TextStyle(fontSize: 18),
),
// Only show table name for dine-in orders (beacon detected)
if (appState.isDineIn && appState.selectedServicePointName != null)
Text(
appState.selectedServicePointName!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
),
),
],
),
),
],
),
actions: [
// 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}"),
isLabelVisible: appState.cartItemCount > 0,
child: const Icon(Icons.shopping_cart),
),
onPressed: () {
Navigator.of(context).pushNamed(AppRoutes.cartView);
},
),
IconButton(
icon: const Icon(Icons.person_outline),
tooltip: "Account",
onPressed: () {
Navigator.of(context).pushNamed(AppRoutes.account);
},
),
],
),
body: (_businessId == null || _businessId! <= 0)
? const Padding(
padding: EdgeInsets.all(16),
child: Text("Missing BusinessID"),
)
: FutureBuilder<List<MenuItem>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text("Error loading menu: ${snapshot.error}"),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => setState(() => _future = _loadMenu()),
child: const Text("Retry"),
),
],
),
);
}
if (_allItems.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Text("No menu items available"),
);
}
final categoryIds = _getUniqueCategoryIds();
return ListView.builder(
itemCount: categoryIds.length + 1, // +1 for header
itemBuilder: (context, index) {
// First item is the business header
if (index == 0) {
return _buildBusinessHeader();
}
final categoryIndex = index - 1;
final categoryId = categoryIds[categoryIndex];
final items = _itemsByCategory[categoryId] ?? [];
// Use stored category name from the category item itself
final categoryName = _categoryNames[categoryId] ?? "Category $categoryId";
final isExpanded = _expandedCategoryId == categoryId;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCategoryHeader(categoryId, categoryName),
// Animated expand/collapse for items
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Container(
// Slightly darker background to distinguish from category bar
color: const Color(0xFFF0F0F0),
child: Column(
children: [
// Top gradient transition from category bar
Container(
height: 12,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFF1B4D3E).withAlpha(60),
const Color(0xFFF0F0F0),
],
),
),
),
...items.map((item) => _buildMenuItem(item)),
// Bottom fade-out gradient to show end of expanded section
Container(
height: 24,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFFF0F0F0),
const Color(0xFF1B4D3E).withAlpha(60),
],
),
),
),
],
),
),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 300),
sizeCurve: Curves.easeInOut,
),
],
);
},
);
},
),
);
}
static const String _imageBaseUrl = "https://biz.payfrit.com/uploads";
Widget _buildBusinessHeader() {
if (_businessId == null) return const SizedBox.shrink();
final appState = context.read<AppState>();
final businessName = appState.selectedBusinessName ?? "Restaurant";
return Container(
width: double.infinity,
height: 180,
child: Stack(
fit: StackFit.expand,
children: [
// Header background image
Image.network(
"$_imageBaseUrl/headers/$_businessId.png",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$_imageBaseUrl/headers/$_businessId.jpg",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
// No header image - show gradient background
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.secondary,
],
),
),
);
},
);
},
),
// Top edge gradient
Positioned(
top: 0,
left: 0,
right: 0,
height: 16,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withAlpha(180),
Colors.black.withAlpha(0),
],
),
),
),
),
// Bottom edge gradient
Positioned(
bottom: 0,
left: 0,
right: 0,
height: 16,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withAlpha(0),
Colors.black.withAlpha(200),
],
),
),
),
),
],
),
);
}
Widget _buildItemImage(int itemId) {
return Container(
width: 90,
height: 90,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
"$_imageBaseUrl/items/$itemId.png",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
// Try jpg if png fails
return Image.network(
"$_imageBaseUrl/items/$itemId.jpg",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.grey.shade200, Colors.grey.shade300],
),
),
child: Center(
child: Icon(
Icons.restaurant_menu,
color: Colors.grey.shade500,
size: 36,
),
),
);
},
);
},
),
),
);
}
/// Builds category background - styled text only (no images)
Widget _buildCategoryBackground(int categoryId, String categoryName) {
const darkForestGreen = Color(0xFF1B4D3E);
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white,
Colors.grey.shade100,
],
),
),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
categoryName,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: darkForestGreen,
letterSpacing: 1.2,
shadows: [
Shadow(
offset: Offset(1, 1),
blurRadius: 2,
color: Colors.black26,
),
],
),
),
);
}
Widget _buildCategoryHeader(int categoryId, String categoryName) {
final isExpanded = _expandedCategoryId == categoryId;
return Semantics(
label: categoryName,
button: true,
child: GestureDetector(
onTap: () {
setState(() {
// Toggle: if already expanded, collapse; otherwise expand this one
_expandedCategoryId = isExpanded ? null : categoryId;
});
},
child: Container(
width: double.infinity,
height: 120,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child: Stack(
fit: StackFit.expand,
children: [
// Category image background or styled text fallback
_buildCategoryBackground(categoryId, categoryName),
// Top edge gradient (subtle forest green)
Positioned(
top: 0,
left: 0,
right: 0,
height: 16,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFF1B4D3E).withAlpha(120),
const Color(0xFF1B4D3E).withAlpha(0),
],
),
),
),
),
// Bottom edge gradient (subtle forest green)
Positioned(
bottom: 0,
left: 0,
right: 0,
height: 16,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFF1B4D3E).withAlpha(0),
const Color(0xFF1B4D3E).withAlpha(150),
],
),
),
),
),
],
),
),
),
);
}
Widget _buildMenuItem(MenuItem item) {
final hasModifiers = _itemsByParent.containsKey(item.itemId);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
if (hasModifiers) {
_showItemCustomization(item);
} else {
_addToCart(item, {});
}
},
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Item image with shadow
_buildItemImage(item.itemId),
const SizedBox(width: 14),
// Item details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (item.description.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
item.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
if (hasModifiers) ...[
const SizedBox(height: 4),
Text(
"Customizable",
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
const SizedBox(width: 12),
// Price and add button
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"\$${item.price.toStringAsFixed(2)}",
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
Icon(
Icons.add_circle,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
],
),
],
),
),
),
),
);
}
void _showItemCustomization(MenuItem item) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => _ItemCustomizationSheet(
item: item,
itemsByParent: _itemsByParent,
onAdd: (selectedItemIds, quantity) {
Navigator.pop(context);
_addToCart(item, selectedItemIds, quantity: quantity);
},
),
);
}
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>(
context: context,
builder: (context) => AlertDialog(
title: const Text("Login Required"),
content: const Text("Please login to add items to your cart."),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text("Cancel"),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text("Login"),
),
],
),
);
if (shouldLogin == true && mounted) {
Navigator.of(context).pushNamed(AppRoutes.login);
}
return;
}
if (_businessId == null || _servicePointId == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Row(
children: [
Icon(Icons.warning, color: Colors.black),
SizedBox(width: 8),
Text("Missing required information", style: TextStyle(color: Colors.black)),
],
),
backgroundColor: const Color(0xFF90EE90),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
return;
}
try {
final appState = context.read<AppState>();
// Get or create cart
Cart cart;
if (appState.cartOrderId == null) {
// Determine order type: 1=dine-in (beacon), 0=undecided (no beacon, will choose at checkout)
final orderTypeId = appState.isDineIn ? 1 : 0;
cart = await Api.getOrCreateCart(
userId: _userId!,
businessId: _businessId!,
servicePointId: _servicePointId!,
orderTypeId: orderTypeId,
);
appState.setCartOrder(
orderId: cart.orderId,
orderUuid: cart.orderUuid,
itemCount: cart.itemCount,
);
} else {
// We have an existing cart ID
cart = await Api.getCart(orderId: appState.cartOrderId!);
// If cart is not in cart status (0), it's been submitted - create a new cart
if (cart.statusId != 0) {
debugPrint('Cart has been submitted (status=${cart.statusId}), creating new cart');
appState.clearCart();
final orderTypeId = appState.isDineIn ? 1 : 0;
cart = await Api.getOrCreateCart(
userId: _userId!,
businessId: _businessId!,
servicePointId: _servicePointId!,
orderTypeId: orderTypeId,
);
appState.setCartOrder(
orderId: cart.orderId,
orderUuid: cart.orderUuid,
itemCount: cart.itemCount,
);
} else if (appState.isDineIn && cart.orderTypeId == 0) {
// If we're dine-in (beacon detected) but cart has no order type set, update it
cart = await Api.setOrderType(
orderId: cart.orderId,
orderTypeId: 1, // dine-in
);
}
// Note: Dine-in orders (orderTypeId=1) stay as dine-in - no order type selection needed
}
// 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;
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(
(li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted,
orElse: () => throw StateError('Root line item not found for ItemID=${item.itemId}'),
);
// Add all selected modifiers recursively
await _addModifiersRecursively(
cart.orderId,
rootLineItem.orderLineItemId,
item.itemId,
selectedModifierIds,
);
// Refresh cart to get final state
cart = await Api.getCart(orderId: cart.orderId);
appState.updateCartItemCount(cart.itemCount);
if (!mounted) return;
final message = selectedModifierIds.isEmpty
? "Added ${item.name} to cart (${cart.itemCount} item${cart.itemCount == 1 ? '' : 's'})"
: "Added ${item.name} with ${selectedModifierIds.length} customizations (${cart.itemCount} item${cart.itemCount == 1 ? '' : 's'})";
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: Colors.black),
const SizedBox(width: 8),
Expanded(child: 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),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error, color: Colors.black),
const SizedBox(width: 8),
Expanded(child: Text("Error adding to cart: $e", style: const TextStyle(color: Colors.black))),
],
),
backgroundColor: const Color(0xFF90EE90),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
),
);
}
}
Future<void> _addModifiersRecursively(
int orderId,
int parentOrderLineItemId,
int parentItemId,
Set<int> selectedItemIds,
) async {
final children = _itemsByParent[parentItemId] ?? [];
for (final child in children) {
final isSelected = selectedItemIds.contains(child.itemId);
final grandchildren = _itemsByParent[child.itemId] ?? [];
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) {
// This item is directly selected - add it
final cart = await Api.setLineItem(
orderId: orderId,
parentOrderLineItemId: parentOrderLineItemId,
itemId: child.itemId,
isSelected: true,
);
final childLineItem = cart.lineItems.lastWhere(
(li) => li.itemId == realChildItemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted,
orElse: () => throw StateError('Failed to add item'),
);
if (hasGrandchildren) {
await _addModifiersRecursively(
orderId,
childLineItem.orderLineItemId,
child.itemId,
selectedItemIds,
);
}
} else if (hasSelectedDescendants) {
// This item is NOT selected, but has selected descendants
// DON'T add this intermediate group - just recurse to add the actual selections
// The selections will be added directly under the current parent
// This matches how attachDefaultChildren works on the server
await _addModifiersRecursively(
orderId,
parentOrderLineItemId, // Keep the same parent - don't create intermediate node
child.itemId,
selectedItemIds,
);
}
}
}
/// Check if any descendants of this item are selected
bool _hasSelectedDescendants(int itemId, Set<int> selectedItemIds) {
final children = _itemsByParent[itemId] ?? [];
for (final child in children) {
if (selectedItemIds.contains(child.itemId)) {
return true;
}
if (_hasSelectedDescendants(child.itemId, selectedItemIds)) {
return true;
}
}
return false;
}
}
/// Recursive item customization sheet with full rule support
class _ItemCustomizationSheet extends StatefulWidget {
final MenuItem item;
final Map<int, List<MenuItem>> itemsByParent;
final Function(Set<int>, int) onAdd; // (selectedModifierIds, quantity)
const _ItemCustomizationSheet({
required this.item,
required this.itemsByParent,
required this.onAdd,
});
@override
State<_ItemCustomizationSheet> createState() => _ItemCustomizationSheetState();
}
class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> {
final Set<int> _selectedItemIds = {};
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() {
super.initState();
_initializeDefaults(widget.item.itemId);
}
/// Recursively initialize default selections for the ENTIRE tree
/// This ensures defaults are pre-selected even for nested items
void _initializeDefaults(int parentId) {
final children = widget.itemsByParent[parentId] ?? [];
for (final child in children) {
if (child.isCheckedByDefault) {
_selectedItemIds.add(child.itemId);
_defaultItemIds.add(child.itemId);
}
// Always recurse into all children to find nested defaults
_initializeDefaults(child.itemId);
}
}
/// Calculate total price including all selected items recursively (multiplied by quantity)
double _calculateTotal() {
double unitPrice = widget.item.price;
void addPriceRecursively(int itemId) {
final children = widget.itemsByParent[itemId] ?? [];
for (final child in children) {
if (_selectedItemIds.contains(child.itemId)) {
unitPrice += child.price;
addPriceRecursively(child.itemId);
}
}
}
addPriceRecursively(widget.item.itemId);
return unitPrice * _quantity;
}
/// Validate selections before adding to cart
bool _validate() {
setState(() => _validationError = null);
// Helper to check if a modifier group has any selected descendants
bool hasSelectedDescendant(int itemId) {
if (_selectedItemIds.contains(itemId)) return true;
final children = widget.itemsByParent[itemId] ?? [];
return children.any((c) => hasSelectedDescendant(c.itemId));
}
bool validateRecursive(int parentId, MenuItem parent) {
final children = widget.itemsByParent[parentId] ?? [];
if (children.isEmpty) return true;
// A child is "selected" if it's directly selected OR if it's a group with selected descendants
final selectedChildren = children.where((c) =>
_selectedItemIds.contains(c.itemId) || hasSelectedDescendant(c.itemId)
).toList();
// Check if child selection is required
if (parent.requiresChildSelection && selectedChildren.isEmpty) {
setState(() => _validationError = "Please select an option for: ${parent.name}");
return false;
}
// Check max selection limit
if (parent.maxNumSelectionReq > 0 && selectedChildren.length > parent.maxNumSelectionReq) {
setState(() => _validationError = "${parent.name}: Max ${parent.maxNumSelectionReq} selections allowed");
return false;
}
// Recursively validate ALL children that are modifier groups (have their own children)
// This ensures we check required selections in nested modifier groups
for (final child in children) {
final hasGrandchildren = widget.itemsByParent.containsKey(child.itemId) &&
(widget.itemsByParent[child.itemId]?.isNotEmpty ?? false);
if (hasGrandchildren) {
// This is a modifier group - always validate it
if (!validateRecursive(child.itemId, child)) {
return false;
}
} else if (_selectedItemIds.contains(child.itemId)) {
// This is a leaf option that's selected - validate its children (if any)
if (!validateRecursive(child.itemId, child)) {
return false;
}
}
}
return true;
}
return validateRecursive(widget.item.itemId, widget.item);
}
void _handleAdd() {
if (_validate()) {
// Filter out default items in groups that user never modified
final itemsToSubmit = <int>{};
for (final itemId in _selectedItemIds) {
final parentId = _findParentId(itemId);
final isDefault = _defaultItemIds.contains(itemId);
final groupWasModified = parentId != null && _userModifiedGroups.contains(parentId);
// Include if: not a default, OR user modified this group
if (!isDefault || groupWasModified) {
itemsToSubmit.add(itemId);
}
}
widget.onAdd(itemsToSubmit, _quantity);
}
}
/// Find which parent contains this item
int? _findParentId(int itemId) {
for (final entry in widget.itemsByParent.entries) {
if (entry.value.any((item) => item.itemId == itemId)) {
return entry.key;
}
}
return null;
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.75,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
// Header with item image
Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
// Drag handle
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(top: 12),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Item image
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.network(
"https://biz.payfrit.com/uploads/items/${widget.item.itemId}.png",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"https://biz.payfrit.com/uploads/items/${widget.item.itemId}.jpg",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.blue.shade100, Colors.blue.shade200],
),
),
child: Center(
child: Icon(
Icons.restaurant_menu,
color: Colors.blue.shade400,
size: 40,
),
),
);
},
);
},
),
),
),
const SizedBox(width: 16),
// Item details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.item.name,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (widget.item.description.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
widget.item.description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.green.shade200),
),
child: Text(
"\$${widget.item.price.toStringAsFixed(2)}",
style: TextStyle(
color: Colors.green.shade700,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
],
),
),
],
),
),
],
),
),
// Scrollable content
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.all(16),
children: _buildModifierTree(widget.item.itemId, 0),
),
),
// Footer with validation error and add button
Container(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -4),
),
],
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_validationError != null) ...[
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade600, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
_validationError!,
style: TextStyle(
color: Colors.red.shade800,
fontSize: 13,
),
),
),
],
),
),
],
Row(
children: [
// 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(
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,
),
),
],
),
),
),
),
],
),
],
),
),
),
],
);
},
);
}
/// Recursively build modifier tree
List<Widget> _buildModifierTree(int parentId, int depth) {
final children = widget.itemsByParent[parentId] ?? [];
if (children.isEmpty) return [];
final parent = _findItemById(parentId);
if (parent == null) return [];
final widgets = <Widget>[];
for (final child in children) {
final hasGrandchildren = widget.itemsByParent.containsKey(child.itemId);
if (hasGrandchildren && child.isCollapsible) {
// Collapsible section with ExpansionTile
widgets.add(_buildExpansionTile(child, parent, depth));
} else {
// Regular checkbox/radio item
widgets.add(_buildSelectableItem(child, parent, depth));
// Recursively add grandchildren
if (hasGrandchildren && _selectedItemIds.contains(child.itemId)) {
widgets.addAll(_buildModifierTree(child.itemId, depth + 1));
}
}
}
return widgets;
}
Widget _buildExpansionTile(MenuItem item, MenuItem parent, int depth) {
final isSelected = _selectedItemIds.contains(item.itemId);
return Padding(
padding: EdgeInsets.only(left: depth * 16.0),
child: ExpansionTile(
title: Text(item.name),
subtitle: item.price > 0
? Text("+\$${item.price.toStringAsFixed(2)}")
: null,
initiallyExpanded: isSelected || item.isCheckedByDefault,
leading: _buildSelectionWidget(item, parent),
children: _buildModifierTree(item.itemId, depth + 1),
),
);
}
Widget _buildSelectableItem(MenuItem item, MenuItem parent, int depth) {
final isSelected = _selectedItemIds.contains(item.itemId);
return Padding(
padding: EdgeInsets.only(left: depth * 16.0, bottom: 8),
child: Material(
color: isSelected ? Colors.blue.shade50 : Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _toggleSelection(item, parent),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? Colors.blue.shade300 : Colors.grey.shade200,
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
_buildSelectionWidget(item, parent),
const SizedBox(width: 12),
Expanded(
child: Text(
item.name,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? Colors.blue.shade800 : Colors.black87,
),
),
),
if (item.price > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Text(
"+\$${item.price.toStringAsFixed(2)}",
style: TextStyle(
color: Colors.green.shade700,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
),
],
),
),
),
),
);
}
Widget _buildSelectionWidget(MenuItem item, MenuItem parent) {
// If this item has children, it's a container/category - don't show selection widget
final hasChildren = widget.itemsByParent.containsKey(item.itemId) &&
(widget.itemsByParent[item.itemId]?.isNotEmpty ?? false);
if (hasChildren) {
return const SizedBox(width: 48); // Maintain spacing alignment
}
final isSelected = _selectedItemIds.contains(item.itemId);
final siblings = widget.itemsByParent[parent.itemId] ?? [];
// Determine if this should behave as a radio button group:
// 1. Explicit: maxNumSelectionReq == 1
// 2. Inferred: Group has exactly one default-checked item (implies single selection)
final isRadioGroup = parent.maxNumSelectionReq == 1 ||
(siblings.where((s) => s.isCheckedByDefault).length == 1 &&
siblings.every((s) => !s.requiresChildSelection));
if (isRadioGroup) {
return Radio<int>(
value: item.itemId,
groupValue: _getSelectedInGroup(parent.itemId),
onChanged: (_) => _toggleSelection(item, parent),
);
}
// Checkbox for multiple or unlimited selections
return Checkbox(
value: isSelected,
onChanged: (_) => _toggleSelection(item, parent),
);
}
int? _getSelectedInGroup(int parentId) {
final children = widget.itemsByParent[parentId] ?? [];
for (final child in children) {
if (_selectedItemIds.contains(child.itemId)) {
return child.itemId;
}
}
return null;
}
void _toggleSelection(MenuItem item, MenuItem parent) {
setState(() {
_validationError = null;
// Mark this parent group as user-modified
_userModifiedGroups.add(parent.itemId);
final isCurrentlySelected = _selectedItemIds.contains(item.itemId);
final siblings = widget.itemsByParent[parent.itemId] ?? [];
// Determine if this should behave as a radio button group:
// 1. Explicit: maxNumSelectionReq == 1
// 2. Inferred: Group has exactly one default-checked item (implies single selection)
final isRadioGroup = parent.maxNumSelectionReq == 1 ||
(siblings.where((s) => s.isCheckedByDefault).length == 1 &&
siblings.every((s) => !s.requiresChildSelection));
// For radio buttons, deselect siblings and always select the clicked item
if (isRadioGroup) {
for (final sibling in siblings) {
_selectedItemIds.remove(sibling.itemId);
_deselectDescendants(sibling.itemId);
}
// Always select the clicked item (radio buttons can't be deselected)
_selectedItemIds.add(item.itemId);
return;
}
// For checkboxes, allow toggle on/off
if (isCurrentlySelected) {
// Deselect this item and all descendants
_selectedItemIds.remove(item.itemId);
_deselectDescendants(item.itemId);
} else {
// Check max selection limit
if (parent.maxNumSelectionReq > 0) {
final siblings = widget.itemsByParent[parent.itemId] ?? [];
final selectedSiblings = siblings.where((s) => _selectedItemIds.contains(s.itemId)).length;
if (selectedSiblings >= parent.maxNumSelectionReq) {
_validationError = "${parent.name}: Max ${parent.maxNumSelectionReq} selections allowed";
return;
}
}
// Select this item
_selectedItemIds.add(item.itemId);
}
});
}
void _deselectDescendants(int parentId) {
final children = widget.itemsByParent[parentId] ?? [];
for (final child in children) {
_selectedItemIds.remove(child.itemId);
_deselectDescendants(child.itemId);
}
}
MenuItem? _findItemById(int itemId) {
for (final list in widget.itemsByParent.values) {
for (final item in list) {
if (item.itemId == itemId) return item;
}
}
return widget.item.itemId == itemId ? widget.item : null;
}
}