When user customizes an item, add selections directly under the root item instead of creating an intermediate group node. This matches how attachDefaultChildren works on the server and prevents duplicate modifiers showing in KDS. Before: Double Double → Customize Lettuce → Light, Extra After: Double Double → Light, Extra Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1863 lines
65 KiB
Dart
1863 lines
65 KiB
Dart
import "package:flutter/material.dart";
|
|
import "package:provider/provider.dart";
|
|
|
|
import "../app/app_router.dart";
|
|
import "../app/app_state.dart";
|
|
import "../models/cart.dart";
|
|
import "../models/menu_item.dart";
|
|
import "../services/api.dart";
|
|
import "../services/auth_storage.dart";
|
|
import "chat_screen.dart";
|
|
|
|
class MenuBrowseScreen extends StatefulWidget {
|
|
const MenuBrowseScreen({super.key});
|
|
|
|
@override
|
|
State<MenuBrowseScreen> createState() => _MenuBrowseScreenState();
|
|
}
|
|
|
|
class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|
Future<List<MenuItem>>? _future;
|
|
int? _businessId;
|
|
int? _servicePointId;
|
|
int? _userId;
|
|
|
|
List<MenuItem> _allItems = [];
|
|
final Map<int, List<MenuItem>> _itemsByCategory = {};
|
|
final Map<int, List<MenuItem>> _itemsByParent = {};
|
|
final Map<int, int> _categorySortOrder = {}; // categoryId -> sortOrder
|
|
final Map<int, String> _categoryNames = {}; // categoryId -> categoryName
|
|
|
|
// 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;
|
|
if (v is num) return v.toInt();
|
|
if (v is String) {
|
|
final s = v.trim();
|
|
if (s.isEmpty) return null;
|
|
return int.tryParse(s);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Decode virtual ID back to real ItemID
|
|
/// Virtual IDs are formatted as: menuItemID * 100000 + realItemID
|
|
int _decodeVirtualId(int id) {
|
|
if (id > 100000) {
|
|
return id % 100000;
|
|
}
|
|
return id;
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
|
|
final appState = context.watch<AppState>();
|
|
final u = appState.userId;
|
|
|
|
final args = ModalRoute.of(context)?.settings.arguments;
|
|
if (args is Map) {
|
|
final b = _asIntNullable(args["BusinessID"]) ?? _asIntNullable(args["businessId"]);
|
|
final sp = _asIntNullable(args["ServicePointID"]) ?? _asIntNullable(args["servicePointId"]);
|
|
|
|
if (_businessId != b || _servicePointId != sp || _userId != u || _future == null) {
|
|
_businessId = b;
|
|
_servicePointId = sp;
|
|
_userId = u;
|
|
|
|
if (_businessId != null && _businessId! > 0) {
|
|
_future = _loadMenu();
|
|
} else {
|
|
_future = Future.value(<MenuItem>[]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<List<MenuItem>> _loadMenu() async {
|
|
final items = await Api.listMenuItems(businessId: _businessId!);
|
|
|
|
setState(() {
|
|
_allItems = items;
|
|
_organizeItems();
|
|
});
|
|
|
|
return items;
|
|
}
|
|
|
|
bool _isCallingServer = false;
|
|
|
|
/// Show bottom sheet with choice: Server Visit or Chat (dine-in) or just Chat (non-dine-in)
|
|
Future<void> _handleCallServer(AppState appState) async {
|
|
if (_businessId == null) return;
|
|
|
|
// For non-dine-in without a service point, use 0 as placeholder
|
|
final servicePointId = _servicePointId ?? 0;
|
|
|
|
// Check for active chat first
|
|
int? activeTaskId;
|
|
try {
|
|
activeTaskId = await Api.getActiveChat(
|
|
businessId: _businessId!,
|
|
servicePointId: servicePointId,
|
|
);
|
|
} catch (e) {
|
|
// Continue without active chat
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
final isDineIn = appState.isDineIn;
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (context) => SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade300,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
Text(
|
|
isDineIn ? 'How can we help?' : 'Contact Us',
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Only show "Request Server Visit" for dine-in orders
|
|
if (isDineIn && _servicePointId != null) ...[
|
|
ListTile(
|
|
leading: const CircleAvatar(
|
|
backgroundColor: Colors.orange,
|
|
child: Icon(Icons.room_service, color: Colors.white),
|
|
),
|
|
title: const Text('Request Server Visit'),
|
|
subtitle: const Text('Staff will come to your table'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_sendServerRequest(appState);
|
|
},
|
|
),
|
|
const Divider(),
|
|
],
|
|
// Show either "Rejoin Chat" OR "Chat with Staff" - never both
|
|
if (activeTaskId != null)
|
|
ListTile(
|
|
leading: const CircleAvatar(
|
|
backgroundColor: Colors.green,
|
|
child: Icon(Icons.chat_bubble, color: Colors.white),
|
|
),
|
|
title: const Text('Rejoin Chat'),
|
|
subtitle: const Text('Continue your conversation'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_rejoinChat(activeTaskId!);
|
|
},
|
|
)
|
|
else
|
|
ListTile(
|
|
leading: const CircleAvatar(
|
|
backgroundColor: Colors.blue,
|
|
child: Icon(Icons.chat, color: Colors.white),
|
|
),
|
|
title: const Text('Chat with Staff'),
|
|
subtitle: const Text('Send a message to our team'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_startChat(appState);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Check if user is logged in, prompt login if not
|
|
/// Returns true if logged in, false if user needs to log in
|
|
Future<bool> _ensureLoggedIn() async {
|
|
final auth = await AuthStorage.loadAuth();
|
|
if (auth != null && auth.userId > 0) {
|
|
return true;
|
|
}
|
|
|
|
if (!mounted) return false;
|
|
|
|
// Show login prompt
|
|
final shouldLogin = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Sign In Required'),
|
|
content: const Text('Please sign in to use the chat feature.'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: const Text('Sign In'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (shouldLogin == true && mounted) {
|
|
Navigator.pushNamed(context, AppRoutes.login);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// Rejoin an existing active chat
|
|
Future<void> _rejoinChat(int taskId) async {
|
|
if (!await _ensureLoggedIn()) return;
|
|
|
|
if (!mounted) return;
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ChatScreen(
|
|
taskId: taskId,
|
|
userType: 'customer',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Send a server visit request (ping)
|
|
Future<void> _sendServerRequest(AppState appState) async {
|
|
if (_isCallingServer) return;
|
|
setState(() => _isCallingServer = true);
|
|
|
|
try {
|
|
await Api.callServer(
|
|
businessId: _businessId!,
|
|
servicePointId: _servicePointId!,
|
|
orderId: appState.cartOrderId,
|
|
userId: appState.userId,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Row(
|
|
children: [
|
|
Icon(Icons.check_circle, color: Colors.black),
|
|
SizedBox(width: 8),
|
|
Expanded(child: Text("Server has been notified", style: TextStyle(color: Colors.black))),
|
|
],
|
|
),
|
|
backgroundColor: const Color(0xFF90EE90),
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.error, color: Colors.black),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text("Failed to call server: $e", style: const TextStyle(color: Colors.black))),
|
|
],
|
|
),
|
|
backgroundColor: Colors.red.shade100,
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
),
|
|
);
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isCallingServer = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Start a new chat with staff
|
|
Future<void> _startChat(AppState appState) async {
|
|
if (_isCallingServer) return;
|
|
|
|
// Check login first
|
|
if (!await _ensureLoggedIn()) return;
|
|
|
|
setState(() => _isCallingServer = true);
|
|
|
|
try {
|
|
// Reload auth to get userId
|
|
final auth = await AuthStorage.loadAuth();
|
|
final userId = auth?.userId;
|
|
|
|
// Create new chat
|
|
final taskId = await Api.createChatTask(
|
|
businessId: _businessId!,
|
|
servicePointId: _servicePointId!,
|
|
orderId: appState.cartOrderId,
|
|
userId: userId,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
// Navigate to chat screen
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ChatScreen(
|
|
taskId: taskId,
|
|
userType: 'customer',
|
|
),
|
|
),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.error, color: Colors.black),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text("Failed to start chat: $e", style: const TextStyle(color: Colors.black))),
|
|
],
|
|
),
|
|
backgroundColor: Colors.red.shade100,
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
),
|
|
);
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isCallingServer = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _organizeItems() {
|
|
_itemsByCategory.clear();
|
|
_itemsByParent.clear();
|
|
_categorySortOrder.clear();
|
|
_categoryNames.clear();
|
|
|
|
// First pass: identify category items (root items where itemId == categoryId)
|
|
final categoryItemIds = <int>{};
|
|
for (final item in _allItems) {
|
|
if (item.isRootItem && item.itemId == item.categoryId) {
|
|
categoryItemIds.add(item.itemId);
|
|
_itemsByCategory.putIfAbsent(item.itemId, () => []);
|
|
_categorySortOrder[item.itemId] = item.sortOrder;
|
|
_categoryNames[item.itemId] = item.name;
|
|
}
|
|
}
|
|
|
|
// Second pass: organize menu items and modifiers
|
|
for (final item in _allItems) {
|
|
if (!item.isActive) continue;
|
|
if (categoryItemIds.contains(item.itemId)) continue;
|
|
|
|
if (categoryItemIds.contains(item.parentItemId)) {
|
|
_itemsByCategory.putIfAbsent(item.parentItemId, () => []).add(item);
|
|
} else {
|
|
if (item.itemId != item.parentItemId) {
|
|
_itemsByParent.putIfAbsent(item.parentItemId, () => []).add(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort items within each category by sortOrder
|
|
for (final list in _itemsByCategory.values) {
|
|
list.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
|
|
}
|
|
|
|
// Sort modifiers within each parent
|
|
for (final list in _itemsByParent.values) {
|
|
list.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
|
|
}
|
|
}
|
|
|
|
List<int> _getUniqueCategoryIds() {
|
|
final categoryIds = _itemsByCategory.keys.toList();
|
|
// Sort by sortOrder (from _categorySortOrder), not by ItemID
|
|
categoryIds.sort((a, b) {
|
|
final orderA = _categorySortOrder[a] ?? 0;
|
|
final orderB = _categorySortOrder[b] ?? 0;
|
|
return orderA.compareTo(orderB);
|
|
});
|
|
return categoryIds;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final appState = context.watch<AppState>();
|
|
final businessName = appState.selectedBusinessName ?? "Menu";
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Row(
|
|
children: [
|
|
// Business logo
|
|
if (_businessId != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 12),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(6),
|
|
child: SizedBox(
|
|
width: 36,
|
|
height: 36,
|
|
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(
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Icon(
|
|
Icons.store,
|
|
size: 20,
|
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
businessName,
|
|
style: const TextStyle(fontSize: 18),
|
|
),
|
|
// Only show table name for dine-in orders (beacon detected)
|
|
if (appState.isDineIn && appState.selectedServicePointName != null)
|
|
Text(
|
|
appState.selectedServicePointName!,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
// Call Server (dine-in) or Chat (non-dine-in) button
|
|
IconButton(
|
|
icon: Icon(appState.isDineIn ? Icons.room_service : Icons.chat_bubble_outline),
|
|
tooltip: appState.isDineIn ? "Call Server" : "Chat",
|
|
onPressed: () => _handleCallServer(appState),
|
|
),
|
|
IconButton(
|
|
icon: Badge(
|
|
label: Text("${appState.cartItemCount}"),
|
|
isLabelVisible: appState.cartItemCount > 0,
|
|
child: const Icon(Icons.shopping_cart),
|
|
),
|
|
onPressed: () {
|
|
Navigator.of(context).pushNamed(AppRoutes.cartView);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.person_outline),
|
|
tooltip: "Account",
|
|
onPressed: () {
|
|
Navigator.of(context).pushNamed(AppRoutes.account);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
body: (_businessId == null || _businessId! <= 0)
|
|
? const Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Text("Missing BusinessID"),
|
|
)
|
|
: FutureBuilder<List<MenuItem>>(
|
|
future: _future,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
Text("Error loading menu: ${snapshot.error}"),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
onPressed: () => setState(() => _future = _loadMenu()),
|
|
child: const Text("Retry"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_allItems.isEmpty) {
|
|
return const Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Text("No menu items available"),
|
|
);
|
|
}
|
|
|
|
final categoryIds = _getUniqueCategoryIds();
|
|
|
|
return ListView.builder(
|
|
itemCount: categoryIds.length + 1, // +1 for header
|
|
itemBuilder: (context, index) {
|
|
// First item is the business header
|
|
if (index == 0) {
|
|
return _buildBusinessHeader();
|
|
}
|
|
|
|
final categoryIndex = index - 1;
|
|
final categoryId = categoryIds[categoryIndex];
|
|
final items = _itemsByCategory[categoryId] ?? [];
|
|
// Use stored category name from the category item itself
|
|
final categoryName = _categoryNames[categoryId] ?? "Category $categoryId";
|
|
final isExpanded = _expandedCategoryId == categoryId;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildCategoryHeader(categoryId, categoryName),
|
|
// Animated expand/collapse for items
|
|
AnimatedCrossFade(
|
|
firstChild: const SizedBox.shrink(),
|
|
secondChild: Container(
|
|
// Slightly darker background to distinguish from category bar
|
|
color: const Color(0xFFF0F0F0),
|
|
child: Column(
|
|
children: [
|
|
// Top gradient transition from category bar
|
|
Container(
|
|
height: 12,
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
const Color(0xFF1B4D3E).withAlpha(60),
|
|
const Color(0xFFF0F0F0),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
...items.map((item) => _buildMenuItem(item)),
|
|
// Bottom fade-out gradient to show end of expanded section
|
|
Container(
|
|
height: 24,
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
const Color(0xFFF0F0F0),
|
|
const Color(0xFF1B4D3E).withAlpha(60),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
crossFadeState: isExpanded
|
|
? CrossFadeState.showSecond
|
|
: CrossFadeState.showFirst,
|
|
duration: const Duration(milliseconds: 300),
|
|
sizeCurve: Curves.easeInOut,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
static const String _imageBaseUrl = "https://biz.payfrit.com/uploads";
|
|
|
|
Widget _buildBusinessHeader() {
|
|
if (_businessId == null) return const SizedBox.shrink();
|
|
|
|
final appState = context.read<AppState>();
|
|
final businessName = appState.selectedBusinessName ?? "Restaurant";
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
height: 180,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
// Header background image
|
|
Image.network(
|
|
"$_imageBaseUrl/headers/$_businessId.png",
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Image.network(
|
|
"$_imageBaseUrl/headers/$_businessId.jpg",
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
// No header image - show gradient background
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
Theme.of(context).colorScheme.primary,
|
|
Theme.of(context).colorScheme.secondary,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
// Top edge gradient
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 16,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.black.withAlpha(180),
|
|
Colors.black.withAlpha(0),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Bottom edge gradient
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 16,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.black.withAlpha(0),
|
|
Colors.black.withAlpha(200),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildItemImage(int itemId) {
|
|
return Container(
|
|
width: 90,
|
|
height: 90,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.1),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Image.network(
|
|
"$_imageBaseUrl/items/$itemId.png",
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
// Try jpg if png fails
|
|
return Image.network(
|
|
"$_imageBaseUrl/items/$itemId.jpg",
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Colors.grey.shade200, Colors.grey.shade300],
|
|
),
|
|
),
|
|
child: Center(
|
|
child: Icon(
|
|
Icons.restaurant_menu,
|
|
color: Colors.grey.shade500,
|
|
size: 36,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Builds category background - styled text only (no images)
|
|
Widget _buildCategoryBackground(int categoryId, String categoryName) {
|
|
const darkForestGreen = Color(0xFF1B4D3E);
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.white,
|
|
Colors.grey.shade100,
|
|
],
|
|
),
|
|
),
|
|
alignment: Alignment.center,
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Text(
|
|
categoryName,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.bold,
|
|
color: darkForestGreen,
|
|
letterSpacing: 1.2,
|
|
shadows: [
|
|
Shadow(
|
|
offset: Offset(1, 1),
|
|
blurRadius: 2,
|
|
color: Colors.black26,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCategoryHeader(int categoryId, String categoryName) {
|
|
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 or styled text fallback
|
|
_buildCategoryBackground(categoryId, categoryName),
|
|
// Top edge gradient (subtle forest green)
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 16,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
const Color(0xFF1B4D3E).withAlpha(120),
|
|
const Color(0xFF1B4D3E).withAlpha(0),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Bottom edge gradient (subtle forest green)
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 16,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
const Color(0xFF1B4D3E).withAlpha(0),
|
|
const Color(0xFF1B4D3E).withAlpha(150),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMenuItem(MenuItem item) {
|
|
final hasModifiers = _itemsByParent.containsKey(item.itemId);
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.08),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.04),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(16),
|
|
onTap: () {
|
|
if (hasModifiers) {
|
|
_showItemCustomization(item);
|
|
} else {
|
|
_addToCart(item, {});
|
|
}
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Item image with shadow
|
|
_buildItemImage(item.itemId),
|
|
const SizedBox(width: 14),
|
|
// Item details
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.name,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
if (item.description.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
item.description,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
if (hasModifiers) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
"Customizable",
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
// Price and add button
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
"\$${item.price.toStringAsFixed(2)}",
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Icon(
|
|
Icons.add_circle,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
size: 20,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showItemCustomization(MenuItem item) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (context) => _ItemCustomizationSheet(
|
|
item: item,
|
|
itemsByParent: _itemsByParent,
|
|
onAdd: (selectedItemIds, quantity) {
|
|
Navigator.pop(context);
|
|
_addToCart(item, selectedItemIds, quantity: quantity);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _addToCart(MenuItem item, Set<int> selectedModifierIds, {int quantity = 1}) async {
|
|
// Check if user is logged in - if not, navigate to login
|
|
if (_userId == null) {
|
|
final shouldLogin = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text("Login Required"),
|
|
content: const Text("Please login to add items to your cart."),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text("Cancel"),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: const Text("Login"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (shouldLogin == true && mounted) {
|
|
Navigator.of(context).pushNamed(AppRoutes.login);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (_businessId == null || _servicePointId == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Row(
|
|
children: [
|
|
Icon(Icons.warning, color: Colors.black),
|
|
SizedBox(width: 8),
|
|
Text("Missing required information", style: TextStyle(color: Colors.black)),
|
|
],
|
|
),
|
|
backgroundColor: const Color(0xFF90EE90),
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final appState = context.read<AppState>();
|
|
|
|
// Get or create cart
|
|
Cart cart;
|
|
if (appState.cartOrderId == null) {
|
|
// Determine order type: 1=dine-in (beacon), 0=undecided (no beacon, will choose at checkout)
|
|
final orderTypeId = appState.isDineIn ? 1 : 0;
|
|
cart = await Api.getOrCreateCart(
|
|
userId: _userId!,
|
|
businessId: _businessId!,
|
|
servicePointId: _servicePointId!,
|
|
orderTypeId: orderTypeId,
|
|
);
|
|
appState.setCartOrder(
|
|
orderId: cart.orderId,
|
|
orderUuid: cart.orderUuid,
|
|
itemCount: cart.itemCount,
|
|
);
|
|
} else {
|
|
// We have an existing cart ID
|
|
cart = await Api.getCart(orderId: appState.cartOrderId!);
|
|
|
|
// If cart is not in cart status (0), it's been submitted - create a new cart
|
|
if (cart.statusId != 0) {
|
|
debugPrint('Cart has been submitted (status=${cart.statusId}), creating new cart');
|
|
appState.clearCart();
|
|
final orderTypeId = appState.isDineIn ? 1 : 0;
|
|
cart = await Api.getOrCreateCart(
|
|
userId: _userId!,
|
|
businessId: _businessId!,
|
|
servicePointId: _servicePointId!,
|
|
orderTypeId: orderTypeId,
|
|
);
|
|
appState.setCartOrder(
|
|
orderId: cart.orderId,
|
|
orderUuid: cart.orderUuid,
|
|
itemCount: cart.itemCount,
|
|
);
|
|
} else if (appState.isDineIn && cart.orderTypeId == 0) {
|
|
// If we're dine-in (beacon detected) but cart has no order type set, update it
|
|
cart = await Api.setOrderType(
|
|
orderId: cart.orderId,
|
|
orderTypeId: 1, // dine-in
|
|
);
|
|
}
|
|
// Note: Dine-in orders (orderTypeId=1) stay as dine-in - no order type selection needed
|
|
}
|
|
|
|
// For items with customizations, always create a new line item
|
|
// For items without customizations, increment quantity of existing item
|
|
if (selectedModifierIds.isEmpty) {
|
|
// No customizations - find existing and increment quantity
|
|
final existingItem = cart.lineItems.where(
|
|
(li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted
|
|
).firstOrNull;
|
|
|
|
final newQuantity = (existingItem?.quantity ?? 0) + 1;
|
|
|
|
cart = await Api.setLineItem(
|
|
orderId: cart.orderId,
|
|
parentOrderLineItemId: 0,
|
|
itemId: item.itemId,
|
|
isSelected: true,
|
|
quantity: newQuantity,
|
|
);
|
|
} else {
|
|
// Has customizations - always create a new line item with specified quantity
|
|
// Use a special flag or approach to force new line item creation
|
|
cart = await Api.setLineItem(
|
|
orderId: cart.orderId,
|
|
parentOrderLineItemId: 0,
|
|
itemId: item.itemId,
|
|
isSelected: true,
|
|
quantity: quantity,
|
|
forceNew: true,
|
|
);
|
|
}
|
|
|
|
// Find the OrderLineItemID of the root item we just added
|
|
final rootLineItem = cart.lineItems.lastWhere(
|
|
(li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted,
|
|
orElse: () => throw StateError('Root line item not found for ItemID=${item.itemId}'),
|
|
);
|
|
|
|
// Add all selected modifiers recursively
|
|
await _addModifiersRecursively(
|
|
cart.orderId,
|
|
rootLineItem.orderLineItemId,
|
|
item.itemId,
|
|
selectedModifierIds,
|
|
);
|
|
|
|
// Refresh cart to get final state
|
|
cart = await Api.getCart(orderId: cart.orderId);
|
|
|
|
appState.updateCartItemCount(cart.itemCount);
|
|
|
|
if (!mounted) return;
|
|
|
|
final message = selectedModifierIds.isEmpty
|
|
? "Added ${item.name} to cart (${cart.itemCount} item${cart.itemCount == 1 ? '' : 's'})"
|
|
: "Added ${item.name} with ${selectedModifierIds.length} customizations (${cart.itemCount} item${cart.itemCount == 1 ? '' : 's'})";
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.check_circle, color: Colors.black),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(message, style: const TextStyle(color: Colors.black))),
|
|
],
|
|
),
|
|
backgroundColor: const Color(0xFF90EE90),
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.error, color: Colors.black),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text("Error adding to cart: $e", style: const TextStyle(color: Colors.black))),
|
|
],
|
|
),
|
|
backgroundColor: const Color(0xFF90EE90),
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _addModifiersRecursively(
|
|
int orderId,
|
|
int parentOrderLineItemId,
|
|
int parentItemId,
|
|
Set<int> selectedItemIds,
|
|
) async {
|
|
final children = _itemsByParent[parentItemId] ?? [];
|
|
|
|
for (final child in children) {
|
|
final isSelected = selectedItemIds.contains(child.itemId);
|
|
final grandchildren = _itemsByParent[child.itemId] ?? [];
|
|
final hasGrandchildren = grandchildren.isNotEmpty;
|
|
final hasSelectedDescendants = _hasSelectedDescendants(child.itemId, selectedItemIds);
|
|
|
|
// The cart returns real ItemIDs, but child.itemId may be a virtual ID
|
|
// Decode the virtual ID to match against the cart's real ItemID
|
|
final realChildItemId = _decodeVirtualId(child.itemId);
|
|
|
|
if (isSelected) {
|
|
// This item is directly selected - add it
|
|
final cart = await Api.setLineItem(
|
|
orderId: orderId,
|
|
parentOrderLineItemId: parentOrderLineItemId,
|
|
itemId: child.itemId,
|
|
isSelected: true,
|
|
);
|
|
|
|
final childLineItem = cart.lineItems.lastWhere(
|
|
(li) => li.itemId == realChildItemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted,
|
|
orElse: () => throw StateError('Failed to add item'),
|
|
);
|
|
|
|
if (hasGrandchildren) {
|
|
await _addModifiersRecursively(
|
|
orderId,
|
|
childLineItem.orderLineItemId,
|
|
child.itemId,
|
|
selectedItemIds,
|
|
);
|
|
}
|
|
} else if (hasSelectedDescendants) {
|
|
// This item is NOT selected, but has selected descendants
|
|
// DON'T add this intermediate group - just recurse to add the actual selections
|
|
// The selections will be added directly under the current parent
|
|
// This matches how attachDefaultChildren works on the server
|
|
await _addModifiersRecursively(
|
|
orderId,
|
|
parentOrderLineItemId, // Keep the same parent - don't create intermediate node
|
|
child.itemId,
|
|
selectedItemIds,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if any descendants of this item are selected
|
|
bool _hasSelectedDescendants(int itemId, Set<int> selectedItemIds) {
|
|
final children = _itemsByParent[itemId] ?? [];
|
|
for (final child in children) {
|
|
if (selectedItemIds.contains(child.itemId)) {
|
|
return true;
|
|
}
|
|
if (_hasSelectedDescendants(child.itemId, selectedItemIds)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Recursive item customization sheet with full rule support
|
|
class _ItemCustomizationSheet extends StatefulWidget {
|
|
final MenuItem item;
|
|
final Map<int, List<MenuItem>> itemsByParent;
|
|
final Function(Set<int>, int) onAdd; // (selectedModifierIds, quantity)
|
|
|
|
const _ItemCustomizationSheet({
|
|
required this.item,
|
|
required this.itemsByParent,
|
|
required this.onAdd,
|
|
});
|
|
|
|
@override
|
|
State<_ItemCustomizationSheet> createState() => _ItemCustomizationSheetState();
|
|
}
|
|
|
|
class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> {
|
|
final Set<int> _selectedItemIds = {};
|
|
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;
|
|
int _quantity = 1;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initializeDefaults(widget.item.itemId);
|
|
}
|
|
|
|
/// Recursively initialize default selections for the ENTIRE tree
|
|
/// This ensures defaults are pre-selected even for nested items
|
|
void _initializeDefaults(int parentId) {
|
|
final children = widget.itemsByParent[parentId] ?? [];
|
|
for (final child in children) {
|
|
if (child.isCheckedByDefault) {
|
|
_selectedItemIds.add(child.itemId);
|
|
_defaultItemIds.add(child.itemId);
|
|
}
|
|
// Always recurse into all children to find nested defaults
|
|
_initializeDefaults(child.itemId);
|
|
}
|
|
}
|
|
|
|
/// Calculate total price including all selected items recursively (multiplied by quantity)
|
|
double _calculateTotal() {
|
|
double unitPrice = widget.item.price;
|
|
|
|
void addPriceRecursively(int itemId) {
|
|
final children = widget.itemsByParent[itemId] ?? [];
|
|
for (final child in children) {
|
|
if (_selectedItemIds.contains(child.itemId)) {
|
|
unitPrice += child.price;
|
|
addPriceRecursively(child.itemId);
|
|
}
|
|
}
|
|
}
|
|
|
|
addPriceRecursively(widget.item.itemId);
|
|
return unitPrice * _quantity;
|
|
}
|
|
|
|
/// Validate selections before adding to cart
|
|
bool _validate() {
|
|
setState(() => _validationError = null);
|
|
|
|
// Helper to check if a modifier group has any selected descendants
|
|
bool hasSelectedDescendant(int itemId) {
|
|
if (_selectedItemIds.contains(itemId)) return true;
|
|
final children = widget.itemsByParent[itemId] ?? [];
|
|
return children.any((c) => hasSelectedDescendant(c.itemId));
|
|
}
|
|
|
|
bool validateRecursive(int parentId, MenuItem parent) {
|
|
final children = widget.itemsByParent[parentId] ?? [];
|
|
if (children.isEmpty) return true;
|
|
|
|
// A child is "selected" if it's directly selected OR if it's a group with selected descendants
|
|
final selectedChildren = children.where((c) =>
|
|
_selectedItemIds.contains(c.itemId) || hasSelectedDescendant(c.itemId)
|
|
).toList();
|
|
|
|
// Check if child selection is required
|
|
if (parent.requiresChildSelection && selectedChildren.isEmpty) {
|
|
setState(() => _validationError = "Please select an option for: ${parent.name}");
|
|
return false;
|
|
}
|
|
|
|
// Check max selection limit
|
|
if (parent.maxNumSelectionReq > 0 && selectedChildren.length > parent.maxNumSelectionReq) {
|
|
setState(() => _validationError = "${parent.name}: Max ${parent.maxNumSelectionReq} selections allowed");
|
|
return false;
|
|
}
|
|
|
|
// Recursively validate ALL children that are modifier groups (have their own children)
|
|
// This ensures we check required selections in nested modifier groups
|
|
for (final child in children) {
|
|
final hasGrandchildren = widget.itemsByParent.containsKey(child.itemId) &&
|
|
(widget.itemsByParent[child.itemId]?.isNotEmpty ?? false);
|
|
|
|
if (hasGrandchildren) {
|
|
// This is a modifier group - always validate it
|
|
if (!validateRecursive(child.itemId, child)) {
|
|
return false;
|
|
}
|
|
} else if (_selectedItemIds.contains(child.itemId)) {
|
|
// This is a leaf option that's selected - validate its children (if any)
|
|
if (!validateRecursive(child.itemId, child)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return validateRecursive(widget.item.itemId, widget.item);
|
|
}
|
|
|
|
void _handleAdd() {
|
|
if (_validate()) {
|
|
// Filter out default items in groups that user never modified
|
|
final itemsToSubmit = <int>{};
|
|
|
|
for (final itemId in _selectedItemIds) {
|
|
final parentId = _findParentId(itemId);
|
|
final isDefault = _defaultItemIds.contains(itemId);
|
|
final groupWasModified = parentId != null && _userModifiedGroups.contains(parentId);
|
|
|
|
// Include if: not a default, OR user modified this group
|
|
if (!isDefault || groupWasModified) {
|
|
itemsToSubmit.add(itemId);
|
|
}
|
|
}
|
|
|
|
widget.onAdd(itemsToSubmit, _quantity);
|
|
}
|
|
}
|
|
|
|
/// 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(
|
|
initialChildSize: 0.75,
|
|
minChildSize: 0.5,
|
|
maxChildSize: 0.95,
|
|
expand: false,
|
|
builder: (context, scrollController) {
|
|
return Column(
|
|
children: [
|
|
// Header with item image
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
// Drag handle
|
|
Center(
|
|
child: Container(
|
|
width: 40,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(top: 12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade300,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Item image
|
|
Container(
|
|
width: 100,
|
|
height: 100,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.1),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: Image.network(
|
|
"https://biz.payfrit.com/uploads/items/${widget.item.itemId}.png",
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Image.network(
|
|
"https://biz.payfrit.com/uploads/items/${widget.item.itemId}.jpg",
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Colors.blue.shade100, Colors.blue.shade200],
|
|
),
|
|
),
|
|
child: Center(
|
|
child: Icon(
|
|
Icons.restaurant_menu,
|
|
color: Colors.blue.shade400,
|
|
size: 40,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
// Item details
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.item.name,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
if (widget.item.description.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
widget.item.description,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Colors.grey.shade600,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: Colors.green.shade50,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: Colors.green.shade200),
|
|
),
|
|
child: Text(
|
|
"\$${widget.item.price.toStringAsFixed(2)}",
|
|
style: TextStyle(
|
|
color: Colors.green.shade700,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Scrollable content
|
|
Expanded(
|
|
child: ListView(
|
|
controller: scrollController,
|
|
padding: const EdgeInsets.all(16),
|
|
children: _buildModifierTree(widget.item.itemId, 0),
|
|
),
|
|
),
|
|
|
|
// Footer with validation error and add button
|
|
Container(
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.1),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, -4),
|
|
),
|
|
],
|
|
),
|
|
child: SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (_validationError != null) ...[
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.shade50,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.red.shade200),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: Colors.red.shade600, size: 20),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
_validationError!,
|
|
style: TextStyle(
|
|
color: Colors.red.shade800,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
Row(
|
|
children: [
|
|
// Quantity selector
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade100,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.grey.shade300),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.remove, size: 20),
|
|
onPressed: _quantity > 1
|
|
? () => setState(() => _quantity--)
|
|
: null,
|
|
visualDensity: VisualDensity.compact,
|
|
padding: const EdgeInsets.all(8),
|
|
),
|
|
Container(
|
|
constraints: const BoxConstraints(minWidth: 32),
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
"$_quantity",
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.add, size: 20),
|
|
onPressed: () => setState(() => _quantity++),
|
|
visualDensity: VisualDensity.compact,
|
|
padding: const EdgeInsets.all(8),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
// Add to cart button
|
|
Expanded(
|
|
child: SizedBox(
|
|
height: 52,
|
|
child: ElevatedButton(
|
|
onPressed: _handleAdd,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.blue.shade600,
|
|
foregroundColor: Colors.white,
|
|
elevation: 2,
|
|
shadowColor: Colors.blue.shade200,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.add_shopping_cart, size: 20),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
"Add",
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
"\$${_calculateTotal().toStringAsFixed(2)}",
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Recursively build modifier tree
|
|
List<Widget> _buildModifierTree(int parentId, int depth) {
|
|
final children = widget.itemsByParent[parentId] ?? [];
|
|
if (children.isEmpty) return [];
|
|
|
|
final parent = _findItemById(parentId);
|
|
if (parent == null) return [];
|
|
|
|
final widgets = <Widget>[];
|
|
|
|
for (final child in children) {
|
|
final hasGrandchildren = widget.itemsByParent.containsKey(child.itemId);
|
|
|
|
if (hasGrandchildren && child.isCollapsible) {
|
|
// Collapsible section with ExpansionTile
|
|
widgets.add(_buildExpansionTile(child, parent, depth));
|
|
} else {
|
|
// Regular checkbox/radio item
|
|
widgets.add(_buildSelectableItem(child, parent, depth));
|
|
|
|
// Recursively add grandchildren
|
|
if (hasGrandchildren && _selectedItemIds.contains(child.itemId)) {
|
|
widgets.addAll(_buildModifierTree(child.itemId, depth + 1));
|
|
}
|
|
}
|
|
}
|
|
|
|
return widgets;
|
|
}
|
|
|
|
Widget _buildExpansionTile(MenuItem item, MenuItem parent, int depth) {
|
|
final isSelected = _selectedItemIds.contains(item.itemId);
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.only(left: depth * 16.0),
|
|
child: ExpansionTile(
|
|
title: Text(item.name),
|
|
subtitle: item.price > 0
|
|
? Text("+\$${item.price.toStringAsFixed(2)}")
|
|
: null,
|
|
initiallyExpanded: isSelected || item.isCheckedByDefault,
|
|
leading: _buildSelectionWidget(item, parent),
|
|
children: _buildModifierTree(item.itemId, depth + 1),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSelectableItem(MenuItem item, MenuItem parent, int depth) {
|
|
final isSelected = _selectedItemIds.contains(item.itemId);
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.only(left: depth * 16.0, bottom: 8),
|
|
child: Material(
|
|
color: isSelected ? Colors.blue.shade50 : Colors.grey.shade50,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(12),
|
|
onTap: () => _toggleSelection(item, parent),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: isSelected ? Colors.blue.shade300 : Colors.grey.shade200,
|
|
width: isSelected ? 2 : 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
_buildSelectionWidget(item, parent),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
item.name,
|
|
style: TextStyle(
|
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
|
color: isSelected ? Colors.blue.shade800 : Colors.black87,
|
|
),
|
|
),
|
|
),
|
|
if (item.price > 0)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.green.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
"+\$${item.price.toStringAsFixed(2)}",
|
|
style: TextStyle(
|
|
color: Colors.green.shade700,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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] ?? [];
|
|
|
|
// Determine if this should behave as a radio button group:
|
|
// 1. Explicit: maxNumSelectionReq == 1
|
|
// 2. Inferred: Group has exactly one default-checked item (implies single selection)
|
|
final isRadioGroup = parent.maxNumSelectionReq == 1 ||
|
|
(siblings.where((s) => s.isCheckedByDefault).length == 1 &&
|
|
siblings.every((s) => !s.requiresChildSelection));
|
|
|
|
if (isRadioGroup) {
|
|
return Radio<int>(
|
|
value: item.itemId,
|
|
groupValue: _getSelectedInGroup(parent.itemId),
|
|
onChanged: (_) => _toggleSelection(item, parent),
|
|
);
|
|
}
|
|
|
|
// Checkbox for multiple or unlimited selections
|
|
return Checkbox(
|
|
value: isSelected,
|
|
onChanged: (_) => _toggleSelection(item, parent),
|
|
);
|
|
}
|
|
|
|
int? _getSelectedInGroup(int parentId) {
|
|
final children = widget.itemsByParent[parentId] ?? [];
|
|
for (final child in children) {
|
|
if (_selectedItemIds.contains(child.itemId)) {
|
|
return child.itemId;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
void _toggleSelection(MenuItem item, MenuItem parent) {
|
|
setState(() {
|
|
_validationError = null;
|
|
|
|
// Mark this parent group as user-modified
|
|
_userModifiedGroups.add(parent.itemId);
|
|
|
|
final isCurrentlySelected = _selectedItemIds.contains(item.itemId);
|
|
final siblings = widget.itemsByParent[parent.itemId] ?? [];
|
|
|
|
// Determine if this should behave as a radio button group:
|
|
// 1. Explicit: maxNumSelectionReq == 1
|
|
// 2. Inferred: Group has exactly one default-checked item (implies single selection)
|
|
final isRadioGroup = parent.maxNumSelectionReq == 1 ||
|
|
(siblings.where((s) => s.isCheckedByDefault).length == 1 &&
|
|
siblings.every((s) => !s.requiresChildSelection));
|
|
|
|
// For radio buttons, deselect siblings and always select the clicked item
|
|
if (isRadioGroup) {
|
|
for (final sibling in siblings) {
|
|
_selectedItemIds.remove(sibling.itemId);
|
|
_deselectDescendants(sibling.itemId);
|
|
}
|
|
// Always select the clicked item (radio buttons can't be deselected)
|
|
_selectedItemIds.add(item.itemId);
|
|
return;
|
|
}
|
|
|
|
// For checkboxes, allow toggle on/off
|
|
if (isCurrentlySelected) {
|
|
// Deselect this item and all descendants
|
|
_selectedItemIds.remove(item.itemId);
|
|
_deselectDescendants(item.itemId);
|
|
} else {
|
|
// Check max selection limit
|
|
if (parent.maxNumSelectionReq > 0) {
|
|
final siblings = widget.itemsByParent[parent.itemId] ?? [];
|
|
final selectedSiblings = siblings.where((s) => _selectedItemIds.contains(s.itemId)).length;
|
|
|
|
if (selectedSiblings >= parent.maxNumSelectionReq) {
|
|
_validationError = "${parent.name}: Max ${parent.maxNumSelectionReq} selections allowed";
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Select this item
|
|
_selectedItemIds.add(item.itemId);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _deselectDescendants(int parentId) {
|
|
final children = widget.itemsByParent[parentId] ?? [];
|
|
for (final child in children) {
|
|
_selectedItemIds.remove(child.itemId);
|
|
_deselectDescendants(child.itemId);
|
|
}
|
|
}
|
|
|
|
MenuItem? _findItemById(int itemId) {
|
|
for (final list in widget.itemsByParent.values) {
|
|
for (final item in list) {
|
|
if (item.itemId == itemId) return item;
|
|
}
|
|
}
|
|
return widget.item.itemId == itemId ? widget.item : null;
|
|
}
|
|
}
|