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
This commit is contained in:
John Mizerek 2025-12-29 10:32:31 -08:00
parent 33f7128b40
commit 9995eb2ff7
5 changed files with 686 additions and 8 deletions

View file

@ -1,7 +1,7 @@
import "package:flutter/material.dart";
import "../screens/login_screen.dart";
import "../screens/order_home_screen.dart";
import "../screens/menu_browse_screen.dart";
import "../screens/restaurant_select_screen.dart";
import "../screens/service_point_select_screen.dart";
import "../screens/splash_screen.dart";
@ -11,13 +11,13 @@ class AppRoutes {
static const String login = "/login";
static const String restaurantSelect = "/restaurants";
static const String servicePointSelect = "/service-points";
static const String orderHome = "/order";
static const String menuBrowse = "/menu";
static Map<String, WidgetBuilder> get routes => {
splash: (_) => const SplashScreen(),
login: (_) => const LoginScreen(),
restaurantSelect: (_) => const RestaurantSelectScreen(),
servicePointSelect: (_) => const ServicePointSelectScreen(),
orderHome: (_) => const OrderHomeScreen(),
menuBrowse: (_) => const MenuBrowseScreen(),
};
}

60
lib/models/menu_item.dart Normal file
View file

@ -0,0 +1,60 @@
class MenuItem {
final int itemId;
final int categoryId;
final String name;
final String description;
final int parentItemId;
final double price;
final bool isActive;
final bool isCheckedByDefault;
final bool requiresChildSelection;
final int maxNumSelectionReq;
final bool isCollapsible;
final int sortOrder;
const MenuItem({
required this.itemId,
required this.categoryId,
required this.name,
required this.description,
required this.parentItemId,
required this.price,
required this.isActive,
required this.isCheckedByDefault,
required this.requiresChildSelection,
required this.maxNumSelectionReq,
required this.isCollapsible,
required this.sortOrder,
});
factory MenuItem.fromJson(Map<String, dynamic> json) {
return MenuItem(
itemId: (json["ItemID"] as num).toInt(),
categoryId: (json["ItemCategoryID"] as num).toInt(),
name: (json["ItemName"] as String?) ?? "",
description: (json["ItemDescription"] as String?) ?? "",
parentItemId: (json["ItemParentItemID"] as num?)?.toInt() ?? 0,
price: (json["ItemPrice"] as num?)?.toDouble() ?? 0.0,
isActive: _parseBool(json["ItemIsActive"]),
isCheckedByDefault: _parseBool(json["ItemIsCheckedByDefault"]),
requiresChildSelection: _parseBool(json["ItemRequiresChildSelection"]),
maxNumSelectionReq: (json["ItemMaxNumSelectionReq"] as num?)?.toInt() ?? 0,
isCollapsible: _parseBool(json["ItemIsCollapsible"]),
sortOrder: (json["ItemSortOrder"] as num?)?.toInt() ?? 0,
);
}
static bool _parseBool(dynamic value) {
if (value == null) return false;
if (value is bool) return value;
if (value is num) return value != 0;
if (value is String) {
final lower = value.toLowerCase();
return lower == "true" || lower == "1";
}
return false;
}
bool get isRootItem => parentItemId == 0;
bool get isModifier => parentItemId != 0;
}

View file

@ -0,0 +1,586 @@
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;
}
}

View file

@ -65,9 +65,9 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
// Store selection in AppState
appState.setServicePoint(sp.servicePointId);
// Navigate to Order, passing IDs so OrderHomeScreen can DISPLAY them.
// Navigate to Menu Browse
Navigator.of(context).pushNamed(
AppRoutes.orderHome,
AppRoutes.menuBrowse,
arguments: {
"BusinessID": r.businessId,
"ServicePointID": sp.servicePointId,

View file

@ -1,6 +1,7 @@
import "dart:convert";
import "package:http/http.dart" as http;
import "../models/menu_item.dart";
import "../models/restaurant.dart";
import "../models/service_point.dart";
@ -271,13 +272,44 @@ class Api {
}
// -------------------------
// Ordering API (stubs referenced by OrderHomeScreen)
// Menu Items
// -------------------------
static Future<dynamic> listMenuItems({required int businessId}) async {
throw StateError("endpoint_not_implemented: Api.listMenuItems");
static Future<List<MenuItem>> listMenuItems({required int businessId}) async {
final raw = await _postRaw(
"/menu/items.cfm",
{"BusinessID": businessId},
businessIdOverride: businessId,
);
final j = _requireJson(raw, "MenuItems");
if (!_ok(j)) {
throw StateError(
"MenuItems API returned OK=false\nERROR: ${_err(j)}\nDETAIL: ${(j["DETAIL"] ?? "").toString()}\nHTTP Status: ${raw.statusCode}",
);
}
final arr = _pickArray(j, const ["Items", "ITEMS"]);
if (arr == null) {
throw StateError("MenuItems JSON missing Items array.\nRaw: ${raw.rawBody}");
}
final out = <MenuItem>[];
for (final e in arr) {
if (e is Map<String, dynamic>) {
out.add(MenuItem.fromJson(e));
} else if (e is Map) {
out.add(MenuItem.fromJson(e.cast<String, dynamic>()));
}
}
return out;
}
// -------------------------
// Ordering API (stubs referenced by OrderHomeScreen)
// -------------------------
static Future<dynamic> getOrCreateCart({
required int userId,
required int businessId,