From 9995eb2ff75a884437e9eec1544d71950afe08bc Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Mon, 29 Dec 2025 10:32:31 -0800 Subject: [PATCH] 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 --- lib/app/app_router.dart | 6 +- lib/models/menu_item.dart | 60 +++ lib/screens/menu_browse_screen.dart | 586 ++++++++++++++++++++++ lib/screens/restaurant_select_screen.dart | 4 +- lib/services/api.dart | 38 +- 5 files changed, 686 insertions(+), 8 deletions(-) create mode 100644 lib/models/menu_item.dart create mode 100644 lib/screens/menu_browse_screen.dart diff --git a/lib/app/app_router.dart b/lib/app/app_router.dart index f1ff21e..a6fad0d 100644 --- a/lib/app/app_router.dart +++ b/lib/app/app_router.dart @@ -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 get routes => { splash: (_) => const SplashScreen(), login: (_) => const LoginScreen(), restaurantSelect: (_) => const RestaurantSelectScreen(), servicePointSelect: (_) => const ServicePointSelectScreen(), - orderHome: (_) => const OrderHomeScreen(), + menuBrowse: (_) => const MenuBrowseScreen(), }; } diff --git a/lib/models/menu_item.dart b/lib/models/menu_item.dart new file mode 100644 index 0000000..74a928e --- /dev/null +++ b/lib/models/menu_item.dart @@ -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 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; +} diff --git a/lib/screens/menu_browse_screen.dart b/lib/screens/menu_browse_screen.dart new file mode 100644 index 0000000..386aa37 --- /dev/null +++ b/lib/screens/menu_browse_screen.dart @@ -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 createState() => _MenuBrowseScreenState(); +} + +class _MenuBrowseScreenState extends State { + Future>? _future; + int? _businessId; + int? _servicePointId; + int? _userId; + + List _allItems = []; + final Map> _itemsByCategory = {}; + final Map> _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([]); + } + } + } + } + + Future> _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 _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>( + 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 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> itemsByParent; + final Function(Set) onAdd; + + const _ItemCustomizationSheet({ + required this.item, + required this.itemsByParent, + required this.onAdd, + }); + + @override + State<_ItemCustomizationSheet> createState() => _ItemCustomizationSheetState(); +} + +class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> { + final Set _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 _buildModifierTree(int parentId, int depth) { + final children = widget.itemsByParent[parentId] ?? []; + if (children.isEmpty) return []; + + final parent = _findItemById(parentId); + if (parent == null) return []; + + final widgets = []; + + 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( + 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; + } +} diff --git a/lib/screens/restaurant_select_screen.dart b/lib/screens/restaurant_select_screen.dart index b9c4ae5..91d35aa 100644 --- a/lib/screens/restaurant_select_screen.dart +++ b/lib/screens/restaurant_select_screen.dart @@ -65,9 +65,9 @@ class _RestaurantSelectScreenState extends State { // 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, diff --git a/lib/services/api.dart b/lib/services/api.dart index 17cad03..a1af3d0 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -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 listMenuItems({required int businessId}) async { - throw StateError("endpoint_not_implemented: Api.listMenuItems"); + static Future> 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 = []; + for (final e in arr) { + if (e is Map) { + out.add(MenuItem.fromJson(e)); + } else if (e is Map) { + out.add(MenuItem.fromJson(e.cast())); + } + } + return out; } + // ------------------------- + // Ordering API (stubs referenced by OrderHomeScreen) + // ------------------------- + static Future getOrCreateCart({ required int userId, required int businessId,