payfrit-app/lib/screens/menu_browse_screen.dart
John Mizerek 5bfbf3dd27 Fix radio button inference for modifier groups
- Infer radio button behavior when group has exactly one default-checked item
- Both visual (Radio widget) and behavior (_toggleSelection) now consistent
- Fixes issue where selecting different option kept both default and selected

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

1159 lines
39 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 = {};
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();
for (final item in _allItems) {
// Skip inactive items
if (!item.isActive) continue;
if (item.isRootItem) {
_itemsByCategory.putIfAbsent(item.categoryId, () => []).add(item);
} else {
// Prevent an item from being its own child
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();
categoryIds.sort();
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),
),
if (appState.selectedServicePointName != null)
Text(
appState.selectedServicePointName!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
),
),
],
),
),
],
),
actions: [
IconButton(
icon: Badge(
label: Text("${appState.cartItemCount}"),
isLabelVisible: appState.cartItemCount > 0,
child: const Icon(Icons.shopping_cart),
),
onPressed: () {
Navigator.of(context).pushNamed(AppRoutes.cartView);
},
),
],
),
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] ?? [];
final categoryName = items.isNotEmpty
? items.first.categoryName
: "Category $categoryId";
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCategoryHeader(categoryId, categoryName),
...items.map((item) => _buildMenuItem(item)),
const SizedBox(height: 16),
],
);
},
);
},
),
);
}
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,
margin: const EdgeInsets.only(bottom: 8),
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,
],
),
),
);
},
);
},
),
// Dark gradient overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withAlpha(0),
Colors.black.withAlpha(179),
],
),
),
),
// Business info overlay
Positioned(
left: 16,
right: 16,
bottom: 16,
child: Row(
children: [
// Logo
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 56,
height: 56,
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(
color: Colors.white24,
child: const Icon(
Icons.store,
size: 32,
color: Colors.white,
),
);
},
);
},
),
),
),
const SizedBox(width: 12),
// Business name and info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
businessName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
shadows: [
const Shadow(
offset: Offset(1, 1),
blurRadius: 3,
color: Colors.black54,
),
],
),
),
if (appState.selectedServicePointName != null)
Text(
appState.selectedServicePointName!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white70,
),
),
],
),
),
],
),
),
],
),
);
}
Widget _buildItemImage(int itemId) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 64,
height: 64,
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) {
// Show placeholder if no image
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.restaurant,
color: Theme.of(context).colorScheme.onPrimaryContainer,
size: 28,
),
);
},
);
},
),
),
);
}
Widget _buildCategoryHeader(int categoryId, String categoryName) {
return Container(
width: double.infinity,
height: 120,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child: Stack(
fit: StackFit.expand,
children: [
// Category image background
Image.network(
"$_imageBaseUrl/categories/$categoryId.png",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.network(
"$_imageBaseUrl/categories/$categoryId.jpg",
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
// No image - just show solid color
return Container(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
);
},
),
// Dark gradient overlay for text readability
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withAlpha(0),
Colors.black.withAlpha(179),
],
),
),
),
// Category name
Positioned(
left: 16,
bottom: 12,
right: 16,
child: Text(
categoryName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
const Shadow(
offset: Offset(1, 1),
blurRadius: 3,
color: Colors.black54,
),
],
),
),
),
],
),
);
}
Widget _buildMenuItem(MenuItem item) {
final hasModifiers = _itemsByParent.containsKey(item.itemId);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant.withAlpha(128),
),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
if (hasModifiers) {
_showItemCustomization(item);
} else {
_addToCart(item, {});
}
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Item image or placeholder
_buildItemImage(item.itemId),
const SizedBox(width: 16),
// 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(
const SnackBar(content: Text("Missing required information")),
);
return;
}
try {
final appState = context.read<AppState>();
// Get or create cart
Cart cart;
if (appState.cartOrderId == null) {
cart = await Api.getOrCreateCart(
userId: _userId!,
businessId: _businessId!,
servicePointId: _servicePointId!,
orderTypeId: 1, // Dine-in
);
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: Text(message)),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error adding to cart: $e")),
);
}
}
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');
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');
// Add this item if it's selected OR if it has selected descendants (to maintain hierarchy)
if (isSelected || hasSelectedDescendants) {
print('[MenuBrowse] Adding ${isSelected ? "selected" : "container"} 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,
);
}
}
}
}
/// 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 = {};
String? _validationError;
@override
void initState() {
super.initState();
_initializeDefaults(widget.item.itemId);
}
/// Recursively initialize default selections
void _initializeDefaults(int parentId) {
final children = widget.itemsByParent[parentId] ?? [];
for (final child in children) {
if (child.isCheckedByDefault) {
_selectedItemIds.add(child.itemId);
_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);
bool validateRecursive(int parentId, MenuItem parent) {
final children = widget.itemsByParent[parentId] ?? [];
if (children.isEmpty) return true;
final selectedChildren = children.where((c) => _selectedItemIds.contains(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 selected children
for (final child in selectedChildren) {
if (!validateRecursive(child.itemId, child)) {
return false;
}
}
return true;
}
return validateRecursive(widget.item.itemId, widget.item);
}
void _handleAdd() {
if (_validate()) {
widget.onAdd(_selectedItemIds);
}
}
@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
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 1,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 12,
height: 4,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSurfaceVariant,
borderRadius: BorderRadius.circular(2),
),
),
],
),
const SizedBox(height: 16),
Text(
widget.item.name,
style: Theme.of(context).textTheme.headlineSmall?.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: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
"Base: \$${widget.item.price.toStringAsFixed(2)}",
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
// 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.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(25),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
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(8),
border: Border.all(color: Colors.red.shade300),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700),
const SizedBox(width: 12),
Expanded(
child: Text(
_validationError!,
style: TextStyle(color: Colors.red.shade900),
),
),
],
),
),
],
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _handleAdd,
child: Text("Add to Cart - \$${_calculateTotal().toStringAsFixed(2)}"),
),
),
],
),
),
),
],
);
},
);
}
/// 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) {
return Padding(
padding: EdgeInsets.only(left: depth * 16.0),
child: ListTile(
leading: _buildSelectionWidget(item, parent),
title: Text(item.name),
subtitle: item.price > 0
? Text("+\$${item.price.toStringAsFixed(2)}")
: null,
onTap: () => _toggleSelection(item, parent),
),
);
}
Widget _buildSelectionWidget(MenuItem item, MenuItem parent) {
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;
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;
}
}