diff --git a/lib/models/cart.dart b/lib/models/cart.dart index cf86612..7361513 100644 --- a/lib/models/cart.dart +++ b/lib/models/cart.dart @@ -102,7 +102,8 @@ class Cart { .fold(0.0, (sum, item) => sum + (item.price * item.quantity)); } - double get total => subtotal + deliveryFee; + // Only include delivery fee for delivery orders (orderTypeId == 3) + double get total => subtotal + (orderTypeId == 3 ? deliveryFee : 0); int get itemCount { return lineItems diff --git a/lib/screens/beacon_scan_screen.dart b/lib/screens/beacon_scan_screen.dart index 0e50f10..c4d8e00 100644 --- a/lib/screens/beacon_scan_screen.dart +++ b/lib/screens/beacon_scan_screen.dart @@ -24,6 +24,15 @@ class _BeaconScanScreenState extends State with SingleTickerPr final Map> _beaconRssiSamples = {}; // UUID -> List of RSSI values final Map _beaconDetectionCount = {}; // UUID -> detection count + // Rotating scan messages + static const List _scanMessages = [ + 'Looking for your table...', + 'Scanning nearby...', + 'Almost there...', + 'Checking signal strength...', + 'Finalizing...', + ]; + late AnimationController _pulseController; late Animation _pulseAnimation; @@ -103,6 +112,10 @@ class _BeaconScanScreenState extends State with SingleTickerPr // Initialize beacon monitoring await flutterBeacon.initializeScanning; + // Brief delay to let Bluetooth subsystem fully initialize + // Without this, the first scan cycle may complete immediately with no results + await Future.delayed(const Duration(milliseconds: 500)); + // Create regions for all known UUIDs final regions = _uuidToBeaconId.keys.map((uuid) { // Format UUID with dashes for the plugin @@ -122,6 +135,10 @@ class _BeaconScanScreenState extends State with SingleTickerPr print('[BeaconScan] 🔄 Starting 5 scan cycles of 2 seconds each'); for (int scanCycle = 1; scanCycle <= 5; scanCycle++) { + // Update status message for each cycle + if (mounted) { + setState(() => _status = _scanMessages[scanCycle - 1]); + } print('[BeaconScan] ----- Scan cycle $scanCycle/5 -----'); StreamSubscription? subscription; @@ -368,21 +385,6 @@ class _BeaconScanScreenState extends State with SingleTickerPr onPressed: _retryPermissions, child: const Text('Open Settings'), ), - const SizedBox(height: 12), - TextButton( - onPressed: _navigateToRestaurantSelect, - style: TextButton.styleFrom(foregroundColor: Colors.white70), - child: const Text('Skip and select manually'), - ), - ], - - if (_permissionsGranted && _scanning) ...[ - const SizedBox(height: 24), - TextButton( - onPressed: _navigateToRestaurantSelect, - style: TextButton.styleFrom(foregroundColor: Colors.white70), - child: const Text('Skip and select manually'), - ), ], ], ), diff --git a/lib/screens/cart_view_screen.dart b/lib/screens/cart_view_screen.dart index b649cbb..e81e9d1 100644 --- a/lib/screens/cart_view_screen.dart +++ b/lib/screens/cart_view_screen.dart @@ -484,7 +484,8 @@ class _CartViewScreenState extends State { ), ], ), - if (_cart!.deliveryFee > 0) ...[ + // Only show delivery fee for delivery orders (OrderTypeID = 3) + if (_cart!.deliveryFee > 0 && _cart!.orderTypeId == 3) ...[ const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/lib/screens/menu_browse_screen.dart b/lib/screens/menu_browse_screen.dart index 2d2e9df..5ea97e6 100644 --- a/lib/screens/menu_browse_screen.dart +++ b/lib/screens/menu_browse_screen.dart @@ -24,6 +24,9 @@ class _MenuBrowseScreenState extends State { final Map> _itemsByCategory = {}; final Map> _itemsByParent = {}; + // 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; @@ -238,13 +241,24 @@ class _MenuBrowseScreenState extends State { final categoryName = items.isNotEmpty ? items.first.categoryName : "Category $categoryId"; + final isExpanded = _expandedCategoryId == categoryId; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildCategoryHeader(categoryId, categoryName), - ...items.map((item) => _buildMenuItem(item)), - const SizedBox(height: 16), + // Animated expand/collapse for items + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Column( + children: items.map((item) => _buildMenuItem(item)).toList(), + ), + crossFadeState: isExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 300), + sizeCurve: Curves.easeInOut, + ), ], ); }, @@ -265,7 +279,6 @@ class _MenuBrowseScreenState extends State { return Container( width: double.infinity, height: 180, - margin: const EdgeInsets.only(bottom: 8), child: Stack( fit: StackFit.expand, children: [ @@ -295,86 +308,42 @@ class _MenuBrowseScreenState extends State { ); }, ), - // Dark gradient overlay - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withAlpha(0), - Colors.black.withAlpha(179), - ], + // Top edge gradient (sharp, short fade) + Positioned( + top: 0, + left: 0, + right: 0, + height: 20, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withAlpha(180), + Colors.black.withAlpha(0), + ], + ), ), ), ), - // Business info overlay + // Bottom edge gradient (sharp, short fade) 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, - ), - ); - }, - ); - }, - ), - ), + bottom: 0, + left: 0, + right: 0, + height: 28, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withAlpha(0), + Colors.black.withAlpha(200), + ], ), - 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, - ), - ), - ], - ), - ), - ], + ), ), ), ], @@ -418,66 +387,116 @@ class _MenuBrowseScreenState extends State { } 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", + 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 + Image.network( + "$_imageBaseUrl/categories/$categoryId.png", fit: BoxFit.cover, + semanticLabel: categoryName, errorBuilder: (context, error, stackTrace) { - // No image - just show solid color - return Container( - color: Theme.of(context).colorScheme.primaryContainer, + return Image.network( + "$_imageBaseUrl/categories/$categoryId.jpg", + fit: BoxFit.cover, + semanticLabel: categoryName, + errorBuilder: (context, error, stackTrace) { + // No image - show category name as fallback + return Container( + color: Theme.of(context).colorScheme.primaryContainer, + alignment: Alignment.center, + child: Text( + categoryName, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ); + }, ); }, - ); - }, - ), - // 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, - ), - ], + // Top edge gradient (sharp, short fade) + Positioned( + top: 0, + left: 0, + right: 0, + height: 14, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withAlpha(180), + Colors.black.withAlpha(0), + ], + ), ), - ), + ), + ), + // Bottom edge gradient (sharp, short fade) + Positioned( + bottom: 0, + left: 0, + right: 0, + height: 24, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withAlpha(0), + Colors.black.withAlpha(200), + ], + ), + ), + ), + ), + // Expand/collapse indicator + Positioned( + right: 16, + bottom: 12, + child: AnimatedRotation( + turns: isExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 300), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.black.withAlpha(100), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.keyboard_arrow_down, + color: Colors.white, + size: 24, + ), + ), + ), + ), + ], ), - ], + ), ), ); } @@ -778,6 +797,8 @@ class _ItemCustomizationSheet extends StatefulWidget { class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> { final Set _selectedItemIds = {}; + final Set _defaultItemIds = {}; // Track which items were defaults (not user-selected) + final Set _userModifiedGroups = {}; // Track which parent groups user has interacted with String? _validationError; @override @@ -792,6 +813,7 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> { for (final child in children) { if (child.isCheckedByDefault) { _selectedItemIds.add(child.itemId); + _defaultItemIds.add(child.itemId); // Remember this was a default _initializeDefaults(child.itemId); } } @@ -852,10 +874,38 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> { void _handleAdd() { if (_validate()) { - widget.onAdd(_selectedItemIds); + // Filter out default items in groups that user never modified + final itemsToSubmit = {}; + + for (final itemId in _selectedItemIds) { + // Find which parent group this item belongs to + final parentId = _findParentId(itemId); + + // Include if: not a default, OR user modified this group + if (!_defaultItemIds.contains(itemId) || _userModifiedGroups.contains(parentId)) { + itemsToSubmit.add(itemId); + } + } + + print('[Customization] Selected: $_selectedItemIds'); + print('[Customization] Defaults: $_defaultItemIds'); + print('[Customization] Modified groups: $_userModifiedGroups'); + print('[Customization] Submitting: $itemsToSubmit'); + + 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( @@ -1057,6 +1107,13 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> { } 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] ?? []; @@ -1096,6 +1153,9 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> { 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] ?? []; diff --git a/lib/screens/restaurant_select_screen.dart b/lib/screens/restaurant_select_screen.dart index f2fb0f1..95a11d3 100644 --- a/lib/screens/restaurant_select_screen.dart +++ b/lib/screens/restaurant_select_screen.dart @@ -3,8 +3,8 @@ import "package:flutter/material.dart"; import "package:provider/provider.dart"; -import "../app/app_router.dart"; import "../app/app_state.dart"; +import "../models/menu_item.dart"; import "../models/restaurant.dart"; import "../models/service_point.dart"; import "../services/api.dart"; @@ -17,163 +17,599 @@ class RestaurantSelectScreen extends StatefulWidget { } class _RestaurantSelectScreenState extends State { - late Future> _future; + late Future> _restaurantsFuture; String? _debugLastRaw; int? _debugLastStatus; + // Which restaurant is currently expanded (shows menu) + int? _expandedBusinessId; + + // Cache for loaded menus and service points + final Map> _menuCache = {}; + final Map> _servicePointCache = {}; + final Map _loadingMenu = {}; + + static const String _imageBaseUrl = "https://biz.payfrit.com/uploads"; + @override void initState() { super.initState(); - _future = _load(); + _restaurantsFuture = _loadRestaurants(); } - Future> _load() async { + Future> _loadRestaurants() async { final raw = await Api.listRestaurantsRaw(); _debugLastRaw = raw.rawBody; _debugLastStatus = raw.statusCode; return Api.listRestaurants(); } - Future _selectBusinessAndContinue(Restaurant r) async { - final appState = context.read(); + Future _loadMenuForBusiness(int businessId) async { + if (_menuCache.containsKey(businessId)) return; + if (_loadingMenu[businessId] == true) return; - // Set selected business - appState.setBusiness(r.businessId); + setState(() => _loadingMenu[businessId] = true); - // Go pick service point, and WAIT for a selection. - final sp = await Navigator.of(context).pushNamed( - AppRoutes.servicePointSelect, - arguments: { - "BusinessID": r.businessId, - }, - ); + try { + // Load menu items and service points in parallel + final results = await Future.wait([ + Api.listMenuItems(businessId: businessId), + Api.listServicePoints(businessId: businessId), + ]); - if (!mounted) return; - - if (sp is ServicePoint) { - // Store selection in AppState - appState.setServicePoint(sp.servicePointId); - - // Navigate to Menu Browse - user can browse anonymously - Navigator.of(context).pushNamed( - AppRoutes.menuBrowse, - arguments: { - "BusinessID": r.businessId, - "ServicePointID": sp.servicePointId, - }, - ); + if (mounted) { + setState(() { + _menuCache[businessId] = results[0] as List; + _servicePointCache[businessId] = results[1] as List; + _loadingMenu[businessId] = false; + }); + } + } catch (e) { + debugPrint('[RestaurantSelect] Error loading menu: $e'); + if (mounted) { + setState(() => _loadingMenu[businessId] = false); + } } } + void _toggleExpand(Restaurant restaurant) { + setState(() { + if (_expandedBusinessId == restaurant.businessId) { + // Collapse + _expandedBusinessId = null; + } else { + // Expand this one + _expandedBusinessId = restaurant.businessId; + // Start loading menu if not cached + _loadMenuForBusiness(restaurant.businessId); + } + }); + } + @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: Colors.black, appBar: AppBar( - title: const Text("Select Business"), + backgroundColor: Colors.black, + foregroundColor: Colors.white, + title: const Text("Nearby Restaurants"), + elevation: 0, ), body: FutureBuilder>( - future: _future, + future: _restaurantsFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); } if (snapshot.hasError) { return _ErrorPane( - title: "Businesses Load Failed", + title: "Failed to Load", message: snapshot.error.toString(), statusCode: _debugLastStatus, raw: _debugLastRaw, - onRetry: () => setState(() => _future = _load()), + onRetry: () => setState(() => _restaurantsFuture = _loadRestaurants()), ); } - final items = snapshot.data ?? const []; - if (items.isEmpty) { + final restaurants = snapshot.data ?? const []; + if (restaurants.isEmpty) { return _ErrorPane( - title: "No Businesses Returned", - message: "The API returned an empty list.", + title: "No Restaurants Found", + message: "No Payfrit restaurants nearby.", statusCode: _debugLastStatus, raw: _debugLastRaw, - onRetry: () => setState(() => _future = _load()), + onRetry: () => setState(() => _restaurantsFuture = _loadRestaurants()), ); } return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: items.length, - itemBuilder: (context, i) { - final r = items[i]; - return Container( - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withAlpha(15), - blurRadius: 8, - offset: const Offset(0, 2), + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: restaurants.length, + itemBuilder: (context, index) { + final restaurant = restaurants[index]; + final isExpanded = _expandedBusinessId == restaurant.businessId; + + return _RestaurantBar( + restaurant: restaurant, + isExpanded: isExpanded, + onTap: () => _toggleExpand(restaurant), + menuItems: _menuCache[restaurant.businessId], + servicePoints: _servicePointCache[restaurant.businessId], + isLoading: _loadingMenu[restaurant.businessId] == true, + imageBaseUrl: _imageBaseUrl, + ); + }, + ); + }, + ), + ); + } +} + +class _RestaurantBar extends StatelessWidget { + final Restaurant restaurant; + final bool isExpanded; + final VoidCallback onTap; + final List? menuItems; + final List? servicePoints; + final bool isLoading; + final String imageBaseUrl; + + const _RestaurantBar({ + required this.restaurant, + required this.isExpanded, + required this.onTap, + required this.menuItems, + required this.servicePoints, + required this.isLoading, + required this.imageBaseUrl, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Restaurant header bar with logo + GestureDetector( + onTap: onTap, + child: Container( + height: 80, + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: isExpanded + ? Theme.of(context).colorScheme.primaryContainer + : Colors.grey.shade900, + ), + child: Stack( + children: [ + // Background header image (subtle) + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Opacity( + opacity: 0.3, + child: SizedBox( + width: double.infinity, + height: 80, + child: Image.network( + "$imageBaseUrl/headers/${restaurant.businessId}.png", + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Image.network( + "$imageBaseUrl/headers/${restaurant.businessId}.jpg", + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const SizedBox.shrink(); + }, + ); + }, + ), ), - ], + ), ), - child: Material( - borderRadius: BorderRadius.circular(16), - color: Theme.of(context).colorScheme.surface, - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () => _selectBusinessAndContinue(r), - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.store, - size: 32, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - r.name, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - "Tap to view menu", - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - Icon( - Icons.arrow_forward_ios, - color: Theme.of(context).colorScheme.onSurfaceVariant, - size: 20, - ), + // Sharp gradient edges + Positioned( + left: 0, + top: 0, + bottom: 0, + width: 20, + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + bottomLeft: Radius.circular(12), + ), + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + isExpanded + ? Theme.of(context).colorScheme.primaryContainer + : Colors.grey.shade900, + Colors.transparent, ], ), ), ), ), + Positioned( + right: 0, + top: 0, + bottom: 0, + width: 20, + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topRight: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + gradient: LinearGradient( + begin: Alignment.centerRight, + end: Alignment.centerLeft, + colors: [ + isExpanded + ? Theme.of(context).colorScheme.primaryContainer + : Colors.grey.shade900, + Colors.transparent, + ], + ), + ), + ), + ), + // Content + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + // Logo (56x56 recommended, or 112x112 for 2x) + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 56, + height: 56, + child: Image.network( + "$imageBaseUrl/logos/${restaurant.businessId}.png", + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Image.network( + "$imageBaseUrl/logos/${restaurant.businessId}.jpg", + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + // Text-based fallback with first letter + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Theme.of(context).colorScheme.primary, + Theme.of(context).colorScheme.tertiary, + ], + ), + ), + alignment: Alignment.center, + child: Text( + restaurant.name.isNotEmpty + ? restaurant.name[0].toUpperCase() + : "?", + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ); + }, + ); + }, + ), + ), + ), + const SizedBox(width: 16), + // Name + Expanded( + child: Text( + restaurant.name, + style: TextStyle( + color: isExpanded + ? Theme.of(context).colorScheme.onPrimaryContainer + : Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + // Expand indicator + AnimatedRotation( + turns: isExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 300), + child: Icon( + Icons.keyboard_arrow_down, + color: isExpanded + ? Theme.of(context).colorScheme.onPrimaryContainer + : Colors.white70, + size: 28, + ), + ), + ], + ), + ), + ], + ), + ), + ), + + // Expanded menu content + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: _buildExpandedContent(context), + crossFadeState: isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 300), + sizeCurve: Curves.easeInOut, + ), + ], + ); + } + + Widget _buildExpandedContent(BuildContext context) { + if (isLoading) { + return Container( + padding: const EdgeInsets.all(32), + child: const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ); + } + + if (menuItems == null || menuItems!.isEmpty) { + return Container( + padding: const EdgeInsets.all(24), + margin: const EdgeInsets.symmetric(horizontal: 12), + child: const Center( + child: Text( + "No menu available", + style: TextStyle(color: Colors.white70), + ), + ), + ); + } + + // Organize menu items by category + final itemsByCategory = >{}; + for (final item in menuItems!) { + if (item.isRootItem && item.isActive) { + itemsByCategory.putIfAbsent(item.categoryId, () => []).add(item); + } + } + + // Sort within categories + for (final list in itemsByCategory.values) { + list.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); + } + + final categoryIds = itemsByCategory.keys.toList()..sort(); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey.shade900.withAlpha(200), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Service point selector (if multiple) + if (servicePoints != null && servicePoints!.length > 1) + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + "Select a table to order", + style: TextStyle( + color: Colors.white.withAlpha(180), + fontSize: 14, + ), + ), + ), + + // Category sections with items + ...categoryIds.map((categoryId) { + final items = itemsByCategory[categoryId]!; + final categoryName = items.first.categoryName; + + return _CategorySection( + categoryId: categoryId, + categoryName: categoryName, + items: items, + imageBaseUrl: imageBaseUrl, + restaurant: restaurant, + servicePoints: servicePoints, + ); + }), + + const SizedBox(height: 12), + ], + ), + ); + } +} + +class _CategorySection extends StatelessWidget { + final int categoryId; + final String categoryName; + final List items; + final String imageBaseUrl; + final Restaurant restaurant; + final List? servicePoints; + + const _CategorySection({ + required this.categoryId, + required this.categoryName, + required this.items, + required this.imageBaseUrl, + required this.restaurant, + required this.servicePoints, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category header + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + categoryName, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + // Horizontal scroll of items + SizedBox( + height: 140, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return _MenuItemCard( + item: item, + imageBaseUrl: imageBaseUrl, + onTap: () => _handleItemTap(context, item), ); }, - ); - }, + ), + ), + ], + ); + } + + void _handleItemTap(BuildContext context, MenuItem item) { + // Default to first service point if available + final servicePointId = servicePoints?.firstOrNull?.servicePointId ?? 1; + + // Set app state + final appState = context.read(); + appState.setBusinessAndServicePoint( + restaurant.businessId, + servicePointId, + businessName: restaurant.name, + servicePointName: servicePoints?.firstOrNull?.name, + ); + Api.setBusinessId(restaurant.businessId); + + // Navigate to full menu browse screen + Navigator.of(context).pushReplacementNamed( + '/menu_browse', + arguments: { + 'businessId': restaurant.businessId, + 'servicePointId': servicePointId, + }, + ); + } +} + +class _MenuItemCard extends StatelessWidget { + final MenuItem item; + final String imageBaseUrl; + final VoidCallback onTap; + + const _MenuItemCard({ + required this.item, + required this.imageBaseUrl, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 120, + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Item image + ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(10), + topRight: Radius.circular(10), + ), + child: SizedBox( + height: 80, + width: 120, + child: Image.network( + "$imageBaseUrl/items/${item.itemId}.png", + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Image.network( + "$imageBaseUrl/items/${item.itemId}.jpg", + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Theme.of(context).colorScheme.primaryContainer, + child: Center( + child: Icon( + Icons.restaurant, + color: Theme.of(context).colorScheme.onPrimaryContainer, + size: 32, + ), + ), + ); + }, + ); + }, + ), + ), + ), + // Item details + Expanded( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const Spacer(), + Text( + "\$${item.price.toStringAsFixed(2)}", + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ), ), ); } @@ -202,21 +638,28 @@ class _ErrorPane extends StatelessWidget { child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 720), child: Card( + color: Colors.grey.shade900, child: Padding( padding: const EdgeInsets.all(18), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: Theme.of(context).textTheme.titleLarge), + Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.white, + ), + ), const SizedBox(height: 10), - Text(message), + Text(message, style: const TextStyle(color: Colors.white70)), const SizedBox(height: 14), - if (statusCode != null) Text("HTTP: $statusCode"), + if (statusCode != null) + Text("HTTP: $statusCode", style: const TextStyle(color: Colors.white70)), if (raw != null && raw!.trim().isNotEmpty) ...[ const SizedBox(height: 10), - const Text("Raw response:"), + const Text("Raw response:", style: TextStyle(color: Colors.white70)), const SizedBox(height: 6), - Text(raw!), + Text(raw!, style: const TextStyle(color: Colors.white54)), ], const SizedBox(height: 14), FilledButton(