Major UI overhaul: restaurant selector, gradients, and scan UX

Restaurant Select Screen:
- Horizontal bars with logos (text fallback with first letter)
- Tap to expand and preview menu with horizontal item cards
- Dark theme with subtle header backgrounds

Menu Browse Screen:
- Removed redundant business info overlay from header
- Sharp gradient bars on top/bottom edges of headers
- Accordion categories with animated expand/collapse
- Hide checkboxes on container/interim items
- Track user-modified selections separately from defaults

Beacon Scan Screen:
- Rotating status messages during 5 scan cycles
- Removed manual selection link for cleaner UX

Cart/Checkout:
- Only show delivery fee for delivery orders (OrderTypeID=3)
- Fixed total calculation to exclude fee for dine-in

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-01 18:18:59 -08:00
parent 5bfbf3dd27
commit 5107a9f434
5 changed files with 765 additions and 258 deletions

View file

@ -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

View file

@ -24,6 +24,15 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
final Map<String, List<int>> _beaconRssiSamples = {}; // UUID -> List of RSSI values
final Map<String, int> _beaconDetectionCount = {}; // UUID -> detection count
// Rotating scan messages
static const List<String> _scanMessages = [
'Looking for your table...',
'Scanning nearby...',
'Almost there...',
'Checking signal strength...',
'Finalizing...',
];
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
@ -103,6 +112,10 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> 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<BeaconScanScreen> 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<RangingResult>? subscription;
@ -368,21 +385,6 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> 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'),
),
],
],
),

View file

@ -484,7 +484,8 @@ class _CartViewScreenState extends State<CartViewScreen> {
),
],
),
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,

View file

@ -24,6 +24,9 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
final Map<int, List<MenuItem>> _itemsByCategory = {};
final Map<int, List<MenuItem>> _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<MenuBrowseScreen> {
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<MenuBrowseScreen> {
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<MenuBrowseScreen> {
);
},
),
// 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<MenuBrowseScreen> {
}
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<int> _selectedItemIds = {};
final Set<int> _defaultItemIds = {}; // Track which items were defaults (not user-selected)
final Set<int> _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 = <int>{};
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] ?? [];

View file

@ -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<RestaurantSelectScreen> {
late Future<List<Restaurant>> _future;
late Future<List<Restaurant>> _restaurantsFuture;
String? _debugLastRaw;
int? _debugLastStatus;
// Which restaurant is currently expanded (shows menu)
int? _expandedBusinessId;
// Cache for loaded menus and service points
final Map<int, List<MenuItem>> _menuCache = {};
final Map<int, List<ServicePoint>> _servicePointCache = {};
final Map<int, bool> _loadingMenu = {};
static const String _imageBaseUrl = "https://biz.payfrit.com/uploads";
@override
void initState() {
super.initState();
_future = _load();
_restaurantsFuture = _loadRestaurants();
}
Future<List<Restaurant>> _load() async {
Future<List<Restaurant>> _loadRestaurants() async {
final raw = await Api.listRestaurantsRaw();
_debugLastRaw = raw.rawBody;
_debugLastStatus = raw.statusCode;
return Api.listRestaurants();
}
Future<void> _selectBusinessAndContinue(Restaurant r) async {
final appState = context.read<AppState>();
Future<void> _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<MenuItem>;
_servicePointCache[businessId] = results[1] as List<ServicePoint>;
_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<List<Restaurant>>(
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 <Restaurant>[];
if (items.isEmpty) {
final restaurants = snapshot.data ?? const <Restaurant>[];
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<MenuItem>? menuItems;
final List<ServicePoint>? 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 = <int, List<MenuItem>>{};
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<MenuItem> items;
final String imageBaseUrl;
final Restaurant restaurant;
final List<ServicePoint>? 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>();
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(