payfrit-app/lib/screens/menu_browse_screen.dart
John Mizerek 9995eb2ff7 feat: implement full recursive menu customization system
- Add MenuItem model with hierarchical structure support
- Implement recursive menu browsing with infinite depth support
- Add ExpansionTile for collapsible modifier sections
- Implement radio/checkbox logic based on ItemMaxNumSelectionReq
- Add automatic pre-selection for ItemIsCheckedByDefault items
- Implement validation for ItemRequiresChildSelection and max limits
- Add recursive price calculation across all depth levels
- Support intelligent selection behavior (radio groups, parent/child deselection)
- Add proper error messaging for validation failures
- Connect menu items API endpoint
- Update navigation flow to menu browse after service point selection
2025-12-29 10:32:31 -08:00

586 lines
18 KiB
Dart

import "package:flutter/material.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 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"]);
final u = _asIntNullable(args["UserID"]) ?? _asIntNullable(args["userId"]);
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) {
if (item.isRootItem) {
_itemsByCategory.putIfAbsent(item.categoryId, () => []).add(item);
} else {
_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) {
return Scaffold(
appBar: AppBar(
title: const Text("Menu"),
actions: [
IconButton(
icon: const Icon(Icons.shopping_cart),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Cart view coming soon")),
);
},
),
],
),
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,
itemBuilder: (context, index) {
final categoryId = categoryIds[index];
final items = _itemsByCategory[categoryId] ?? [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
"Category $categoryId",
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...items.map((item) => _buildMenuItem(item)),
const Divider(height: 32),
],
);
},
);
},
),
);
}
Widget _buildMenuItem(MenuItem item) {
final hasModifiers = _itemsByParent.containsKey(item.itemId);
return ListTile(
title: Text(item.name),
subtitle: item.description.isNotEmpty
? Text(
item.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: Text(
"\$${item.price.toStringAsFixed(2)}",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
onTap: () {
if (hasModifiers) {
_showItemCustomization(item);
} else {
_addToCart(item, {});
}
},
);
}
void _showItemCustomization(MenuItem item) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => _ItemCustomizationSheet(
item: item,
itemsByParent: _itemsByParent,
onAdd: (selectedItemIds) {
Navigator.pop(context);
_addToCart(item, selectedItemIds);
},
),
);
}
void _addToCart(MenuItem item, Set<int> selectedModifierIds) {
final message = selectedModifierIds.isEmpty
? "Added ${item.name} to cart"
: "Added ${item.name} with ${selectedModifierIds.length} customizations";
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
}
/// 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(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(25),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
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),
],
const SizedBox(height: 8),
Text(
"Base price: \$${widget.item.price.toStringAsFixed(2)}",
style: const TextStyle(fontWeight: FontWeight.w500),
),
],
),
),
// 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);
// Radio button if max selection is 1
if (parent.maxNumSelectionReq == 1) {
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);
// For radio buttons (max = 1), deselect siblings
if (parent.maxNumSelectionReq == 1) {
final siblings = widget.itemsByParent[parent.itemId] ?? [];
for (final sibling in siblings) {
_selectedItemIds.remove(sibling.itemId);
_deselectDescendants(sibling.itemId);
}
}
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;
}
}