payfrit-app/lib/screens/menu_browse_screen.dart
John Mizerek 2522970078 Version 3.0.0+9: Fix beacon scanning and SnackBar styling
- Restore FOREGROUND_SERVICE permission for beacon scanning
- Remove FOREGROUND_SERVICE_LOCATION (no video required)
- Update all SnackBars to Payfrit green (#90EE90) with black text
- Float SnackBars with 80px bottom margin to avoid buttons
- Add signup screen with OTP verification flow
- Fix build.gradle.kts to use Flutter version system

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 23:16:10 -08:00

1595 lines
58 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";
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;
}
@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;
}
void _organizeItems() {
_itemsByCategory.clear();
_itemsByParent.clear();
_categorySortOrder.clear();
_categoryNames.clear();
print('[MenuBrowse] _organizeItems: ${_allItems.length} total items');
// First pass: identify category items (root items where itemId == categoryId)
// These are the category headers themselves, NOT menu items
final categoryItemIds = <int>{};
for (final item in _allItems) {
if (item.isRootItem && item.itemId == item.categoryId) {
categoryItemIds.add(item.itemId);
// Just register the category key (empty list for now)
_itemsByCategory.putIfAbsent(item.itemId, () => []);
// Store the sort order and name for this category
_categorySortOrder[item.itemId] = item.sortOrder;
_categoryNames[item.itemId] = item.name;
print('[MenuBrowse] Category found: ${item.name} (ID=${item.itemId}, sortOrder=${item.sortOrder})');
}
}
print('[MenuBrowse] Found ${categoryItemIds.length} categories: $categoryItemIds');
// Second pass: organize menu items and modifiers
for (final item in _allItems) {
// Skip inactive items
if (!item.isActive) continue;
// Skip category header items (they're not menu items to display)
if (categoryItemIds.contains(item.itemId)) continue;
// Check if parent is a category
if (categoryItemIds.contains(item.parentItemId)) {
// Direct child of a category = menu item (goes in _itemsByCategory)
_itemsByCategory.putIfAbsent(item.parentItemId, () => []).add(item);
print('[MenuBrowse] Menu item: ${item.name} -> category ${item.parentItemId}');
} else {
// Child of a menu item = modifier (goes in _itemsByParent)
if (item.itemId != item.parentItemId) {
_itemsByParent.putIfAbsent(item.parentItemId, () => []).add(item);
print('[MenuBrowse] Modifier: ${item.name} -> parent ${item.parentItemId}');
}
}
}
// 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));
}
// Debug: print final counts
for (final entry in _itemsByCategory.entries) {
print('[MenuBrowse] Category ${entry.key}: ${entry.value.length} items');
}
}
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: [
// Only show table change button for dine-in orders
if (appState.isDineIn)
IconButton(
icon: const Icon(Icons.table_restaurant),
tooltip: "Change Table",
onPressed: () {
// Prevent changing tables if there's an active order (dine and dash prevention)
if (appState.activeOrderId != null) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("Cannot Change Table"),
content: const Text("Please complete or cancel your current order before changing tables."),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("OK"),
),
],
),
);
return;
}
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
},
),
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;
// Debug: Print which items are being shown for which category
if (items.isNotEmpty) {
print('[MenuBrowse] DISPLAY: Category "$categoryName" (ID=$categoryId) showing items: ${items.map((i) => i.name).join(", ")}');
}
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) {
Navigator.pop(context);
_addToCart(item, selectedItemIds);
},
),
);
}
Future<void> _addToCart(MenuItem item, Set<int> selectedModifierIds) 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!);
}
// 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;
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,
);
// 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
print('[MenuBrowse] Adding ${selectedModifierIds.length} modifiers to root item OrderLineItemID=${rootLineItem.orderLineItemId}');
await _addModifiersRecursively(
cart.orderId,
rootLineItem.orderLineItemId,
item.itemId,
selectedModifierIds,
);
// Refresh cart to get final state
print('[MenuBrowse] Refreshing cart to get final state');
cart = await Api.getCart(orderId: cart.orderId);
print('[MenuBrowse] Final cart has ${cart.lineItems.length} total line items');
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] ?? [];
print('[MenuBrowse] _addModifiersRecursively: parentItemId=$parentItemId has ${children.length} children');
print('[MenuBrowse] selectedItemIds passed in: $selectedItemIds');
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);
print('[MenuBrowse] Child ${child.name} (ItemID=${child.itemId}): selected=$isSelected, hasChildren=$hasGrandchildren, hasSelectedDescendants=$hasSelectedDescendants');
// Only add this item if it's explicitly selected
// Container items (parents) should only be added if they themselves are in selectedItemIds
// This prevents default items from being submitted when the user hasn't modified them
if (isSelected) {
print('[MenuBrowse] ADDING selected item ${child.name} with ParentOrderLineItemID=$parentOrderLineItemId');
final cart = await Api.setLineItem(
orderId: orderId,
parentOrderLineItemId: parentOrderLineItemId,
itemId: child.itemId,
isSelected: true,
);
// Find the OrderLineItemID of this item we just added
final childLineItem = cart.lineItems.lastWhere(
(li) => li.itemId == child.itemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted,
orElse: () => throw StateError('Child line item not found for ItemID=${child.itemId}'),
);
// Recursively add children with this item as the new parent
if (hasGrandchildren) {
await _addModifiersRecursively(
orderId,
childLineItem.orderLineItemId,
child.itemId,
selectedItemIds,
);
}
} else if (hasSelectedDescendants) {
// This item itself is not selected, but it has selected descendants
// We need to add it as a container to maintain hierarchy
print('[MenuBrowse] ADDING container item ${child.name} (has selected descendants) with ParentOrderLineItemID=$parentOrderLineItemId');
final cart = await Api.setLineItem(
orderId: orderId,
parentOrderLineItemId: parentOrderLineItemId,
itemId: child.itemId,
isSelected: true,
);
// Find the OrderLineItemID of this item we just added
final childLineItem = cart.lineItems.lastWhere(
(li) => li.itemId == child.itemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted,
orElse: () => throw StateError('Child line item not found for ItemID=${child.itemId}'),
);
// Recursively add children with this item as the new parent
await _addModifiersRecursively(
orderId,
childLineItem.orderLineItemId,
child.itemId,
selectedItemIds,
);
} else {
print('[MenuBrowse] SKIPPING ${child.name} (not selected, no selected descendants)');
}
}
}
/// 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>) onAdd;
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;
@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
double _calculateTotal() {
double total = 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;
addPriceRecursively(child.itemId);
}
}
}
addPriceRecursively(widget.item.itemId);
return total;
}
/// 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>{};
print('[Customization] ========== FILTERING LOGIC ==========');
print('[Customization] All selected items: $_selectedItemIds');
print('[Customization] Default items: $_defaultItemIds');
print('[Customization] User-modified groups: $_userModifiedGroups');
for (final itemId in _selectedItemIds) {
// Find which parent group this item belongs to
final parentId = _findParentId(itemId);
final isDefault = _defaultItemIds.contains(itemId);
final groupWasModified = parentId != null && _userModifiedGroups.contains(parentId);
print('[Customization] Item $itemId: isDefault=$isDefault, parentId=$parentId, groupWasModified=$groupWasModified');
// Include if: not a default, OR user modified this group
if (!isDefault || groupWasModified) {
print('[Customization] -> INCLUDED (not default or group was modified)');
itemsToSubmit.add(itemId);
} else {
print('[Customization] -> EXCLUDED (is default and group was not modified)');
}
}
print('[Customization] Final items to submit: $itemsToSubmit');
print('[Customization] =====================================');
widget.onAdd(itemsToSubmit);
}
}
/// 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,
),
),
),
],
),
),
],
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),
),
),
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,
),
),
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,
),
),
),
],
),
),
),
],
),
),
),
],
);
},
);
}
/// 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;
}
}