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:
parent
5bfbf3dd27
commit
5107a9f434
5 changed files with 765 additions and 258 deletions
|
|
@ -102,7 +102,8 @@ class Cart {
|
||||||
.fold(0.0, (sum, item) => sum + (item.price * item.quantity));
|
.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 {
|
int get itemCount {
|
||||||
return lineItems
|
return lineItems
|
||||||
|
|
|
||||||
|
|
@ -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, List<int>> _beaconRssiSamples = {}; // UUID -> List of RSSI values
|
||||||
final Map<String, int> _beaconDetectionCount = {}; // UUID -> detection count
|
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 AnimationController _pulseController;
|
||||||
late Animation<double> _pulseAnimation;
|
late Animation<double> _pulseAnimation;
|
||||||
|
|
||||||
|
|
@ -103,6 +112,10 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
|
||||||
// Initialize beacon monitoring
|
// Initialize beacon monitoring
|
||||||
await flutterBeacon.initializeScanning;
|
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
|
// Create regions for all known UUIDs
|
||||||
final regions = _uuidToBeaconId.keys.map((uuid) {
|
final regions = _uuidToBeaconId.keys.map((uuid) {
|
||||||
// Format UUID with dashes for the plugin
|
// 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');
|
print('[BeaconScan] 🔄 Starting 5 scan cycles of 2 seconds each');
|
||||||
|
|
||||||
for (int scanCycle = 1; scanCycle <= 5; scanCycle++) {
|
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 -----');
|
print('[BeaconScan] ----- Scan cycle $scanCycle/5 -----');
|
||||||
|
|
||||||
StreamSubscription<RangingResult>? subscription;
|
StreamSubscription<RangingResult>? subscription;
|
||||||
|
|
@ -368,21 +385,6 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
|
||||||
onPressed: _retryPermissions,
|
onPressed: _retryPermissions,
|
||||||
child: const Text('Open Settings'),
|
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'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
final Map<int, List<MenuItem>> _itemsByCategory = {};
|
final Map<int, List<MenuItem>> _itemsByCategory = {};
|
||||||
final Map<int, List<MenuItem>> _itemsByParent = {};
|
final Map<int, List<MenuItem>> _itemsByParent = {};
|
||||||
|
|
||||||
|
// Track which category is currently expanded (null = none)
|
||||||
|
int? _expandedCategoryId;
|
||||||
|
|
||||||
int? _asIntNullable(dynamic v) {
|
int? _asIntNullable(dynamic v) {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
if (v is int) return v;
|
if (v is int) return v;
|
||||||
|
|
@ -238,13 +241,24 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
final categoryName = items.isNotEmpty
|
final categoryName = items.isNotEmpty
|
||||||
? items.first.categoryName
|
? items.first.categoryName
|
||||||
: "Category $categoryId";
|
: "Category $categoryId";
|
||||||
|
final isExpanded = _expandedCategoryId == categoryId;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildCategoryHeader(categoryId, categoryName),
|
_buildCategoryHeader(categoryId, categoryName),
|
||||||
...items.map((item) => _buildMenuItem(item)),
|
// Animated expand/collapse for items
|
||||||
const SizedBox(height: 16),
|
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(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 180,
|
height: 180,
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -295,86 +308,42 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Dark gradient overlay
|
// Top edge gradient (sharp, short fade)
|
||||||
Container(
|
Positioned(
|
||||||
decoration: BoxDecoration(
|
top: 0,
|
||||||
gradient: LinearGradient(
|
left: 0,
|
||||||
begin: Alignment.topCenter,
|
right: 0,
|
||||||
end: Alignment.bottomCenter,
|
height: 20,
|
||||||
colors: [
|
child: Container(
|
||||||
Colors.black.withAlpha(0),
|
decoration: BoxDecoration(
|
||||||
Colors.black.withAlpha(179),
|
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(
|
Positioned(
|
||||||
left: 16,
|
bottom: 0,
|
||||||
right: 16,
|
left: 0,
|
||||||
bottom: 16,
|
right: 0,
|
||||||
child: Row(
|
height: 28,
|
||||||
children: [
|
child: Container(
|
||||||
// Logo
|
decoration: BoxDecoration(
|
||||||
ClipRRect(
|
gradient: LinearGradient(
|
||||||
borderRadius: BorderRadius.circular(8),
|
begin: Alignment.topCenter,
|
||||||
child: SizedBox(
|
end: Alignment.bottomCenter,
|
||||||
width: 56,
|
colors: [
|
||||||
height: 56,
|
Colors.black.withAlpha(0),
|
||||||
child: Image.network(
|
Colors.black.withAlpha(200),
|
||||||
"$_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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
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) {
|
Widget _buildCategoryHeader(int categoryId, String categoryName) {
|
||||||
return Container(
|
final isExpanded = _expandedCategoryId == categoryId;
|
||||||
width: double.infinity,
|
|
||||||
height: 120,
|
return Semantics(
|
||||||
decoration: BoxDecoration(
|
label: categoryName,
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
button: true,
|
||||||
),
|
child: GestureDetector(
|
||||||
child: Stack(
|
onTap: () {
|
||||||
fit: StackFit.expand,
|
setState(() {
|
||||||
children: [
|
// Toggle: if already expanded, collapse; otherwise expand this one
|
||||||
// Category image background
|
_expandedCategoryId = isExpanded ? null : categoryId;
|
||||||
Image.network(
|
});
|
||||||
"$_imageBaseUrl/categories/$categoryId.png",
|
},
|
||||||
fit: BoxFit.cover,
|
child: Container(
|
||||||
errorBuilder: (context, error, stackTrace) {
|
width: double.infinity,
|
||||||
return Image.network(
|
height: 120,
|
||||||
"$_imageBaseUrl/categories/$categoryId.jpg",
|
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,
|
fit: BoxFit.cover,
|
||||||
|
semanticLabel: categoryName,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
// No image - just show solid color
|
return Image.network(
|
||||||
return Container(
|
"$_imageBaseUrl/categories/$categoryId.jpg",
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
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),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
// Top edge gradient (sharp, short fade)
|
||||||
),
|
Positioned(
|
||||||
// Category name
|
top: 0,
|
||||||
Positioned(
|
left: 0,
|
||||||
left: 16,
|
right: 0,
|
||||||
bottom: 12,
|
height: 14,
|
||||||
right: 16,
|
child: Container(
|
||||||
child: Text(
|
decoration: BoxDecoration(
|
||||||
categoryName,
|
gradient: LinearGradient(
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
begin: Alignment.topCenter,
|
||||||
fontWeight: FontWeight.bold,
|
end: Alignment.bottomCenter,
|
||||||
color: Colors.white,
|
colors: [
|
||||||
shadows: [
|
Colors.black.withAlpha(180),
|
||||||
const Shadow(
|
Colors.black.withAlpha(0),
|
||||||
offset: Offset(1, 1),
|
],
|
||||||
blurRadius: 3,
|
),
|
||||||
color: Colors.black54,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
// 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> {
|
class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> {
|
||||||
final Set<int> _selectedItemIds = {};
|
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;
|
String? _validationError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -792,6 +813,7 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> {
|
||||||
for (final child in children) {
|
for (final child in children) {
|
||||||
if (child.isCheckedByDefault) {
|
if (child.isCheckedByDefault) {
|
||||||
_selectedItemIds.add(child.itemId);
|
_selectedItemIds.add(child.itemId);
|
||||||
|
_defaultItemIds.add(child.itemId); // Remember this was a default
|
||||||
_initializeDefaults(child.itemId);
|
_initializeDefaults(child.itemId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -852,10 +874,38 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> {
|
||||||
|
|
||||||
void _handleAdd() {
|
void _handleAdd() {
|
||||||
if (_validate()) {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DraggableScrollableSheet(
|
return DraggableScrollableSheet(
|
||||||
|
|
@ -1057,6 +1107,13 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSelectionWidget(MenuItem item, MenuItem parent) {
|
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 isSelected = _selectedItemIds.contains(item.itemId);
|
||||||
final siblings = widget.itemsByParent[parent.itemId] ?? [];
|
final siblings = widget.itemsByParent[parent.itemId] ?? [];
|
||||||
|
|
||||||
|
|
@ -1096,6 +1153,9 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> {
|
||||||
setState(() {
|
setState(() {
|
||||||
_validationError = null;
|
_validationError = null;
|
||||||
|
|
||||||
|
// Mark this parent group as user-modified
|
||||||
|
_userModifiedGroups.add(parent.itemId);
|
||||||
|
|
||||||
final isCurrentlySelected = _selectedItemIds.contains(item.itemId);
|
final isCurrentlySelected = _selectedItemIds.contains(item.itemId);
|
||||||
final siblings = widget.itemsByParent[parent.itemId] ?? [];
|
final siblings = widget.itemsByParent[parent.itemId] ?? [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:provider/provider.dart";
|
import "package:provider/provider.dart";
|
||||||
|
|
||||||
import "../app/app_router.dart";
|
|
||||||
import "../app/app_state.dart";
|
import "../app/app_state.dart";
|
||||||
|
import "../models/menu_item.dart";
|
||||||
import "../models/restaurant.dart";
|
import "../models/restaurant.dart";
|
||||||
import "../models/service_point.dart";
|
import "../models/service_point.dart";
|
||||||
import "../services/api.dart";
|
import "../services/api.dart";
|
||||||
|
|
@ -17,163 +17,599 @@ class RestaurantSelectScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
|
class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
|
||||||
late Future<List<Restaurant>> _future;
|
late Future<List<Restaurant>> _restaurantsFuture;
|
||||||
String? _debugLastRaw;
|
String? _debugLastRaw;
|
||||||
int? _debugLastStatus;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_future = _load();
|
_restaurantsFuture = _loadRestaurants();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Restaurant>> _load() async {
|
Future<List<Restaurant>> _loadRestaurants() async {
|
||||||
final raw = await Api.listRestaurantsRaw();
|
final raw = await Api.listRestaurantsRaw();
|
||||||
_debugLastRaw = raw.rawBody;
|
_debugLastRaw = raw.rawBody;
|
||||||
_debugLastStatus = raw.statusCode;
|
_debugLastStatus = raw.statusCode;
|
||||||
return Api.listRestaurants();
|
return Api.listRestaurants();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _selectBusinessAndContinue(Restaurant r) async {
|
Future<void> _loadMenuForBusiness(int businessId) async {
|
||||||
final appState = context.read<AppState>();
|
if (_menuCache.containsKey(businessId)) return;
|
||||||
|
if (_loadingMenu[businessId] == true) return;
|
||||||
|
|
||||||
// Set selected business
|
setState(() => _loadingMenu[businessId] = true);
|
||||||
appState.setBusiness(r.businessId);
|
|
||||||
|
|
||||||
// Go pick service point, and WAIT for a selection.
|
try {
|
||||||
final sp = await Navigator.of(context).pushNamed(
|
// Load menu items and service points in parallel
|
||||||
AppRoutes.servicePointSelect,
|
final results = await Future.wait([
|
||||||
arguments: {
|
Api.listMenuItems(businessId: businessId),
|
||||||
"BusinessID": r.businessId,
|
Api.listServicePoints(businessId: businessId),
|
||||||
},
|
]);
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
if (sp is ServicePoint) {
|
_menuCache[businessId] = results[0] as List<MenuItem>;
|
||||||
// Store selection in AppState
|
_servicePointCache[businessId] = results[1] as List<ServicePoint>;
|
||||||
appState.setServicePoint(sp.servicePointId);
|
_loadingMenu[businessId] = false;
|
||||||
|
});
|
||||||
// Navigate to Menu Browse - user can browse anonymously
|
}
|
||||||
Navigator.of(context).pushNamed(
|
} catch (e) {
|
||||||
AppRoutes.menuBrowse,
|
debugPrint('[RestaurantSelect] Error loading menu: $e');
|
||||||
arguments: {
|
if (mounted) {
|
||||||
"BusinessID": r.businessId,
|
setState(() => _loadingMenu[businessId] = false);
|
||||||
"ServicePointID": sp.servicePointId,
|
}
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text("Select Business"),
|
backgroundColor: Colors.black,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
title: const Text("Nearby Restaurants"),
|
||||||
|
elevation: 0,
|
||||||
),
|
),
|
||||||
body: FutureBuilder<List<Restaurant>>(
|
body: FutureBuilder<List<Restaurant>>(
|
||||||
future: _future,
|
future: _restaurantsFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(color: Colors.white),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.hasError) {
|
if (snapshot.hasError) {
|
||||||
return _ErrorPane(
|
return _ErrorPane(
|
||||||
title: "Businesses Load Failed",
|
title: "Failed to Load",
|
||||||
message: snapshot.error.toString(),
|
message: snapshot.error.toString(),
|
||||||
statusCode: _debugLastStatus,
|
statusCode: _debugLastStatus,
|
||||||
raw: _debugLastRaw,
|
raw: _debugLastRaw,
|
||||||
onRetry: () => setState(() => _future = _load()),
|
onRetry: () => setState(() => _restaurantsFuture = _loadRestaurants()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final items = snapshot.data ?? const <Restaurant>[];
|
final restaurants = snapshot.data ?? const <Restaurant>[];
|
||||||
if (items.isEmpty) {
|
if (restaurants.isEmpty) {
|
||||||
return _ErrorPane(
|
return _ErrorPane(
|
||||||
title: "No Businesses Returned",
|
title: "No Restaurants Found",
|
||||||
message: "The API returned an empty list.",
|
message: "No Payfrit restaurants nearby.",
|
||||||
statusCode: _debugLastStatus,
|
statusCode: _debugLastStatus,
|
||||||
raw: _debugLastRaw,
|
raw: _debugLastRaw,
|
||||||
onRetry: () => setState(() => _future = _load()),
|
onRetry: () => setState(() => _restaurantsFuture = _loadRestaurants()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
itemCount: items.length,
|
itemCount: restaurants.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, index) {
|
||||||
final r = items[i];
|
final restaurant = restaurants[index];
|
||||||
return Container(
|
final isExpanded = _expandedBusinessId == restaurant.businessId;
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
decoration: BoxDecoration(
|
return _RestaurantBar(
|
||||||
borderRadius: BorderRadius.circular(16),
|
restaurant: restaurant,
|
||||||
boxShadow: [
|
isExpanded: isExpanded,
|
||||||
BoxShadow(
|
onTap: () => _toggleExpand(restaurant),
|
||||||
color: Colors.black.withAlpha(15),
|
menuItems: _menuCache[restaurant.businessId],
|
||||||
blurRadius: 8,
|
servicePoints: _servicePointCache[restaurant.businessId],
|
||||||
offset: const Offset(0, 2),
|
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(
|
// Sharp gradient edges
|
||||||
borderRadius: BorderRadius.circular(16),
|
Positioned(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
left: 0,
|
||||||
child: InkWell(
|
top: 0,
|
||||||
borderRadius: BorderRadius.circular(16),
|
bottom: 0,
|
||||||
onTap: () => _selectBusinessAndContinue(r),
|
width: 20,
|
||||||
child: Padding(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(20),
|
decoration: BoxDecoration(
|
||||||
child: Row(
|
borderRadius: const BorderRadius.only(
|
||||||
children: [
|
topLeft: Radius.circular(12),
|
||||||
Container(
|
bottomLeft: Radius.circular(12),
|
||||||
width: 56,
|
),
|
||||||
height: 56,
|
gradient: LinearGradient(
|
||||||
decoration: BoxDecoration(
|
begin: Alignment.centerLeft,
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
end: Alignment.centerRight,
|
||||||
borderRadius: BorderRadius.circular(12),
|
colors: [
|
||||||
),
|
isExpanded
|
||||||
child: Icon(
|
? Theme.of(context).colorScheme.primaryContainer
|
||||||
Icons.store,
|
: Colors.grey.shade900,
|
||||||
size: 32,
|
Colors.transparent,
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 720),
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
child: Card(
|
child: Card(
|
||||||
|
color: Colors.grey.shade900,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(18),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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),
|
const SizedBox(height: 10),
|
||||||
Text(message),
|
Text(message, style: const TextStyle(color: Colors.white70)),
|
||||||
const SizedBox(height: 14),
|
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) ...[
|
if (raw != null && raw!.trim().isNotEmpty) ...[
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
const Text("Raw response:"),
|
const Text("Raw response:", style: TextStyle(color: Colors.white70)),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(raw!),
|
Text(raw!, style: const TextStyle(color: Colors.white54)),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue