Handle string/int/null values safely in JSON parsing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1567 lines
52 KiB
Dart
1567 lines
52 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../app/app_state.dart';
|
|
import '../main.dart' show rootScaffoldMessengerKey;
|
|
import '../models/cart.dart';
|
|
import '../models/menu_item.dart';
|
|
import '../services/api.dart';
|
|
import '../services/order_polling_service.dart';
|
|
import '../services/stripe_service.dart';
|
|
import '../widgets/rescan_button.dart';
|
|
|
|
/// Helper class to store modifier breadcrumb paths
|
|
class ModifierPath {
|
|
final List<String> names;
|
|
final double price;
|
|
|
|
const ModifierPath({
|
|
required this.names,
|
|
required this.price,
|
|
});
|
|
}
|
|
|
|
class CartViewScreen extends StatefulWidget {
|
|
const CartViewScreen({super.key});
|
|
|
|
@override
|
|
State<CartViewScreen> createState() => _CartViewScreenState();
|
|
}
|
|
|
|
class _CartViewScreenState extends State<CartViewScreen> {
|
|
Cart? _cart;
|
|
bool _isLoading = true;
|
|
bool _isProcessingPayment = false;
|
|
String? _error;
|
|
Map<int, MenuItem> _menuItemsById = {};
|
|
|
|
// Order type selection (for delivery/takeaway - when orderTypeId is 0)
|
|
// 2 = Takeaway, 3 = Delivery
|
|
int? _selectedOrderType;
|
|
|
|
// Delivery address selection
|
|
List<DeliveryAddress> _addresses = [];
|
|
DeliveryAddress? _selectedAddress;
|
|
bool _loadingAddresses = false;
|
|
|
|
// Tip options as percentages (null = custom)
|
|
static const List<int?> _tipPercentages = [0, 15, 18, 20, null];
|
|
int _selectedTipIndex = 1; // Default to 15%
|
|
int _customTipPercent = 25; // Default custom tip if selected
|
|
|
|
/// Whether the cart needs order type selection (delivery/takeaway)
|
|
bool get _needsOrderTypeSelection => _cart != null && _cart!.orderTypeId == 0;
|
|
|
|
/// Whether delivery is selected and needs address
|
|
bool get _needsDeliveryAddress => _selectedOrderType == 3;
|
|
|
|
/// Whether we can proceed to payment (order type selected if needed)
|
|
bool get _canProceedToPayment {
|
|
if (_cart == null || _cart!.itemCount == 0) return false;
|
|
if (_needsOrderTypeSelection && _selectedOrderType == null) return false;
|
|
if (_needsDeliveryAddress && _selectedAddress == null) return false;
|
|
return true;
|
|
}
|
|
|
|
/// Get the effective delivery fee to display and charge
|
|
/// - If order type is already set to delivery (3), use the order's delivery fee
|
|
/// - If user selected delivery but hasn't confirmed, show the business's preview fee
|
|
double get _effectiveDeliveryFee {
|
|
if (_cart == null) return 0.0;
|
|
// Order already confirmed as delivery
|
|
if (_cart!.orderTypeId == 3) return _cart!.deliveryFee;
|
|
// User selected delivery (preview)
|
|
if (_selectedOrderType == 3) return _cart!.businessDeliveryFee;
|
|
return 0.0;
|
|
}
|
|
|
|
double get _tipAmount {
|
|
if (_cart == null) return 0.0;
|
|
final percent = _tipPercentages[_selectedTipIndex];
|
|
if (percent == null) {
|
|
// Custom tip
|
|
return _cart!.subtotal * (_customTipPercent / 100);
|
|
}
|
|
return _cart!.subtotal * (percent / 100);
|
|
}
|
|
|
|
FeeBreakdown get _feeBreakdown {
|
|
if (_cart == null) {
|
|
return const FeeBreakdown(
|
|
subtotal: 0,
|
|
tax: 0,
|
|
tip: 0,
|
|
deliveryFee: 0,
|
|
payfritFee: 0,
|
|
cardFee: 0,
|
|
total: 0,
|
|
);
|
|
}
|
|
return StripeService.calculateFees(
|
|
subtotal: _cart!.subtotal,
|
|
tax: _cart!.tax,
|
|
tip: _tipAmount,
|
|
deliveryFee: _effectiveDeliveryFee,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadCart();
|
|
}
|
|
|
|
Future<void> _loadCart() async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
});
|
|
|
|
try {
|
|
final appState = context.read<AppState>();
|
|
final cartOrderId = appState.cartOrderId;
|
|
|
|
if (cartOrderId == null) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
_cart = null;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Load cart
|
|
var cart = await Api.getCart(orderId: cartOrderId);
|
|
|
|
// If cart is not in cart status (0), it's been submitted - clear it and show empty cart
|
|
if (cart.statusId != 0) {
|
|
debugPrint('Cart has been submitted (status=${cart.statusId}), clearing cart reference');
|
|
appState.clearCart();
|
|
setState(() {
|
|
_cart = null;
|
|
_isLoading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// If we're dine-in (beacon detected) but cart has no order type set, update it
|
|
if (appState.isDineIn && cart.orderTypeId == 0) {
|
|
try {
|
|
cart = await Api.setOrderType(
|
|
orderId: cart.orderId,
|
|
orderTypeId: 1, // dine-in
|
|
);
|
|
} catch (e) {
|
|
// Log error but continue - cart will show order type selection if this fails
|
|
debugPrint('Failed to update order type to dine-in: $e');
|
|
}
|
|
}
|
|
// Note: Dine-in orders (orderTypeId=1) stay as dine-in - no order type selection needed
|
|
|
|
// Load menu items to get names and prices
|
|
// Use cart's businessId if appState doesn't have one (e.g., delivery/takeaway flow)
|
|
final businessId = appState.selectedBusinessId ?? cart.businessId;
|
|
if (businessId > 0) {
|
|
final result = await Api.listMenuItems(businessId: businessId);
|
|
_menuItemsById = {for (var item in result.items) item.itemId: item};
|
|
}
|
|
|
|
setState(() {
|
|
_cart = cart;
|
|
_isLoading = false;
|
|
});
|
|
|
|
// Update item count in app state
|
|
appState.updateCartItemCount(cart.itemCount);
|
|
|
|
// If cart needs order type selection, pre-load addresses
|
|
if (cart.orderTypeId == 0) {
|
|
_loadDeliveryAddresses();
|
|
}
|
|
} catch (e) {
|
|
// If cart not found (deleted or doesn't exist), clear it from app state
|
|
if (e.toString().contains('not_found') || e.toString().contains('Order not found')) {
|
|
final appState = context.read<AppState>();
|
|
appState.clearCart();
|
|
setState(() {
|
|
_cart = null;
|
|
_isLoading = false;
|
|
});
|
|
} else {
|
|
setState(() {
|
|
_error = e.toString();
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadDeliveryAddresses() async {
|
|
setState(() => _loadingAddresses = true);
|
|
try {
|
|
final addresses = await Api.getDeliveryAddresses();
|
|
if (mounted) {
|
|
setState(() {
|
|
_addresses = addresses;
|
|
_loadingAddresses = false;
|
|
// Auto-select default address if available
|
|
final defaultAddr = addresses.where((a) => a.isDefault).firstOrNull;
|
|
if (defaultAddr != null && _selectedAddress == null) {
|
|
_selectedAddress = defaultAddr;
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() => _loadingAddresses = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _removeLineItem(OrderLineItem lineItem) async {
|
|
try {
|
|
final appState = context.read<AppState>();
|
|
final cartOrderId = appState.cartOrderId;
|
|
if (cartOrderId == null) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
// Set IsSelected=false to remove the item
|
|
await Api.setLineItem(
|
|
orderId: cartOrderId,
|
|
parentOrderLineItemId: lineItem.parentOrderLineItemId,
|
|
itemId: lineItem.itemId,
|
|
isSelected: false,
|
|
);
|
|
|
|
// Reload cart
|
|
await _loadCart();
|
|
} catch (e) {
|
|
setState(() {
|
|
_error = e.toString();
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _updateQuantity(OrderLineItem lineItem, int newQuantity) async {
|
|
if (newQuantity < 1) return;
|
|
|
|
try {
|
|
final appState = context.read<AppState>();
|
|
final cartOrderId = appState.cartOrderId;
|
|
if (cartOrderId == null) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
await Api.setLineItem(
|
|
orderId: cartOrderId,
|
|
parentOrderLineItemId: lineItem.parentOrderLineItemId,
|
|
itemId: lineItem.itemId,
|
|
isSelected: true,
|
|
quantity: newQuantity,
|
|
remark: lineItem.remark,
|
|
);
|
|
|
|
// Reload cart
|
|
await _loadCart();
|
|
} catch (e) {
|
|
setState(() {
|
|
_error = e.toString();
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _showCustomTipDialog() async {
|
|
final controller = TextEditingController(text: _customTipPercent.toString());
|
|
final result = await showDialog<int>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text("Custom Tip"),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: controller,
|
|
keyboardType: TextInputType.number,
|
|
autofocus: true,
|
|
decoration: const InputDecoration(
|
|
labelText: "Tip Percentage",
|
|
suffixText: "%",
|
|
hintText: "0-200",
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
"Enter a tip percentage from 0% to 200%",
|
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text("Cancel"),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
final value = int.tryParse(controller.text) ?? 0;
|
|
final clampedValue = value.clamp(0, 200);
|
|
Navigator.pop(context, clampedValue);
|
|
},
|
|
child: const Text("Apply"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (result != null) {
|
|
setState(() {
|
|
_customTipPercent = result;
|
|
_selectedTipIndex = _tipPercentages.length - 1; // Select "Custom"
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _processPaymentAndSubmit() async {
|
|
if (_cart == null) return;
|
|
|
|
final appState = context.read<AppState>();
|
|
final cartOrderId = appState.cartOrderId;
|
|
// Use cart's businessId if appState doesn't have one (delivery/takeaway without beacon)
|
|
final businessId = appState.selectedBusinessId ?? _cart!.businessId;
|
|
|
|
if (cartOrderId == null || businessId <= 0) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text("Error: Missing order or business information", style: TextStyle(color: Colors.black)),
|
|
backgroundColor: const Color(0xFF90EE90),
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Ensure order type is selected for delivery/takeaway orders
|
|
if (_needsOrderTypeSelection && _selectedOrderType == null) {
|
|
// Build appropriate message based on what's offered
|
|
String message = "Please select an order type";
|
|
if (_cart!.offersTakeaway && _cart!.offersDelivery) {
|
|
message = "Please select Delivery or Takeaway";
|
|
} else if (_cart!.offersTakeaway) {
|
|
message = "Please select Takeaway";
|
|
} else if (_cart!.offersDelivery) {
|
|
message = "Please select Delivery";
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: 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),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Ensure delivery address is selected for delivery orders
|
|
if (_needsDeliveryAddress && _selectedAddress == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text("Please select a delivery address", style: TextStyle(color: Colors.black)),
|
|
backgroundColor: const Color(0xFF90EE90),
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(() => _isProcessingPayment = true);
|
|
|
|
try {
|
|
// 0. Set order type if needed (delivery/takeaway)
|
|
if (_needsOrderTypeSelection && _selectedOrderType != null) {
|
|
final updatedCart = await Api.setOrderType(
|
|
orderId: cartOrderId,
|
|
orderTypeId: _selectedOrderType!,
|
|
addressId: _selectedAddress?.addressId,
|
|
);
|
|
setState(() => _cart = updatedCart);
|
|
}
|
|
|
|
// 1. Process payment with Stripe
|
|
final paymentResult = await StripeService.processPayment(
|
|
context: context,
|
|
businessId: businessId,
|
|
orderId: cartOrderId,
|
|
subtotal: _cart!.subtotal,
|
|
tax: _cart!.tax,
|
|
tip: _tipAmount,
|
|
);
|
|
|
|
if (!paymentResult.success) {
|
|
if (!mounted) return;
|
|
setState(() => _isProcessingPayment = false);
|
|
|
|
if (paymentResult.error != 'Payment cancelled') {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.error, color: Colors.black),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(paymentResult.error ?? 'Payment failed', style: const TextStyle(color: Colors.black))),
|
|
],
|
|
),
|
|
backgroundColor: const Color(0xFF90EE90),
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 2. Payment successful, now submit the order
|
|
await Api.submitOrder(orderId: cartOrderId);
|
|
|
|
// Set active order for polling (status 1 = submitted)
|
|
appState.setActiveOrder(orderId: cartOrderId, statusId: 1);
|
|
|
|
// Start polling for status updates
|
|
OrderPollingService.startPolling(
|
|
orderId: cartOrderId,
|
|
initialStatusId: 1,
|
|
onStatusUpdate: (update) {
|
|
// Update app state
|
|
appState.updateActiveOrderStatus(update.statusId);
|
|
|
|
// Clear active order if terminal state (4=Complete, 5=Cancelled)
|
|
if (update.statusId >= 4) {
|
|
appState.clearActiveOrder();
|
|
}
|
|
|
|
// Show snackbar notification with Payfrit light green
|
|
rootScaffoldMessengerKey.currentState?.showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.notifications_active, color: Colors.black),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'${update.statusName}: ${update.message}',
|
|
style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w500),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
backgroundColor: const Color(0xFF90EE90), // Payfrit light green
|
|
duration: const Duration(seconds: 5),
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
// Clear cart state
|
|
appState.clearCart();
|
|
|
|
if (!mounted) return;
|
|
|
|
// Show success message
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Row(
|
|
children: [
|
|
Icon(Icons.check_circle, color: Colors.black),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
"Payment successful! Order placed. You'll receive notifications as your order is prepared.",
|
|
style: TextStyle(color: Colors.black),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
backgroundColor: const Color(0xFF90EE90),
|
|
duration: const Duration(seconds: 5),
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
),
|
|
);
|
|
|
|
// Navigate back
|
|
Navigator.of(context).pop();
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _isProcessingPayment = false);
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.error, color: Colors.black),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text('Error: ${e.toString()}', style: const TextStyle(color: Colors.black))),
|
|
],
|
|
),
|
|
backgroundColor: const Color(0xFF90EE90),
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Color _getStatusColor(int statusId) => _getStatusColorStatic(statusId);
|
|
|
|
static Color _getStatusColorStatic(int statusId) {
|
|
switch (statusId) {
|
|
case 1: // Submitted
|
|
return Colors.blue;
|
|
case 2: // Preparing
|
|
return Colors.orange;
|
|
case 3: // Ready
|
|
return Colors.green;
|
|
case 4: // Completed
|
|
return Colors.purple;
|
|
default:
|
|
return Colors.grey;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text("Cart"),
|
|
backgroundColor: Colors.black,
|
|
foregroundColor: Colors.white,
|
|
actions: const [
|
|
RescanButton(iconColor: Colors.white),
|
|
],
|
|
),
|
|
body: _isLoading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _error != null
|
|
? Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.error, color: Colors.red, size: 48),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
"Error loading cart",
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(_error!, textAlign: TextAlign.center, maxLines: 3, overflow: TextOverflow.ellipsis),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
onPressed: _loadCart,
|
|
child: const Text("Retry"),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
: _cart == null || _cart!.itemCount == 0
|
|
? Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(
|
|
Icons.shopping_cart_outlined,
|
|
size: 80,
|
|
color: Colors.grey,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
"Your cart is empty",
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: Column(
|
|
children: [
|
|
Expanded(
|
|
child: _ScrollableCartList(
|
|
children: _buildCartItems(),
|
|
),
|
|
),
|
|
_buildCartSummary(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildCartItems() {
|
|
if (_cart == null) return [];
|
|
|
|
// Group line items by root items
|
|
final rootItems = _cart!.lineItems
|
|
.where((item) => item.parentOrderLineItemId == 0 && !item.isDeleted)
|
|
.toList();
|
|
|
|
final widgets = <Widget>[];
|
|
|
|
for (final rootItem in rootItems) {
|
|
widgets.add(_buildRootItemCard(rootItem));
|
|
widgets.add(const SizedBox(height: 12));
|
|
}
|
|
|
|
return widgets;
|
|
}
|
|
|
|
Widget _buildRootItemCard(OrderLineItem rootItem) {
|
|
// Use itemName from line item (from API), fall back to menu item lookup, then to ID
|
|
final menuItem = _menuItemsById[rootItem.itemId];
|
|
final itemName = rootItem.itemName ?? menuItem?.name ?? "Item #${rootItem.itemId}";
|
|
|
|
// Find ALL modifiers (recursively) and build breadcrumb paths for leaf items only
|
|
final modifierPaths = _buildModifierPaths(rootItem.orderLineItemId);
|
|
|
|
// Calculate total price for this line item (root + all modifiers)
|
|
final lineItemTotal = _calculateLineItemTotal(rootItem);
|
|
|
|
final hasModifiers = modifierPaths.isNotEmpty;
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Main row: quantity, name, price, delete
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
// Quantity badge
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.shade50,
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(color: Colors.blue.shade200),
|
|
),
|
|
child: Text(
|
|
"${rootItem.quantity}x",
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.blue.shade700,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
// Item name
|
|
Expanded(
|
|
child: Text(
|
|
itemName,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
// Price
|
|
Text(
|
|
"\$${lineItemTotal.toStringAsFixed(2)}",
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
// Delete button
|
|
IconButton(
|
|
icon: Icon(Icons.close, color: Colors.grey.shade500, size: 20),
|
|
onPressed: () => _confirmRemoveItem(rootItem, itemName),
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
|
),
|
|
],
|
|
),
|
|
// Modifiers accordion (if any)
|
|
if (hasModifiers)
|
|
_ModifierAccordion(
|
|
modifierPaths: modifierPaths,
|
|
itemId: rootItem.orderLineItemId,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Calculate the total price for a root item including all its modifiers
|
|
double _calculateLineItemTotal(OrderLineItem rootItem) {
|
|
double total = rootItem.price * rootItem.quantity;
|
|
total += _sumModifierPrices(rootItem.orderLineItemId, rootItem.quantity);
|
|
return total;
|
|
}
|
|
|
|
/// Recursively sum modifier prices for a parent line item
|
|
double _sumModifierPrices(int parentOrderLineItemId, int rootQuantity) {
|
|
double total = 0.0;
|
|
final children = _cart!.lineItems.where(
|
|
(item) => !item.isDeleted && item.parentOrderLineItemId == parentOrderLineItemId
|
|
);
|
|
|
|
for (final child in children) {
|
|
// Modifier price is multiplied by root item quantity
|
|
total += child.price * rootQuantity;
|
|
// Recursively add grandchildren modifier prices
|
|
total += _sumModifierPrices(child.orderLineItemId, rootQuantity);
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
/// Build breadcrumb paths for all leaf modifiers
|
|
/// Excludes default items - they don't need to be shown in the cart
|
|
List<ModifierPath> _buildModifierPaths(int rootOrderLineItemId) {
|
|
final paths = <ModifierPath>[];
|
|
|
|
// Get direct children of root
|
|
final directChildren = _cart!.lineItems
|
|
.where((item) =>
|
|
item.parentOrderLineItemId == rootOrderLineItemId &&
|
|
!item.isDeleted)
|
|
.toList();
|
|
|
|
// Recursively collect leaf items with their paths
|
|
void collectLeafPaths(OrderLineItem item, String? lastGroupName) {
|
|
// Skip default items - they don't need to be repeated in the cart
|
|
if (item.isCheckedByDefault) {
|
|
return;
|
|
}
|
|
|
|
final children = _cart!.lineItems
|
|
.where((child) =>
|
|
child.parentOrderLineItemId == item.orderLineItemId &&
|
|
!child.isDeleted)
|
|
.toList();
|
|
|
|
// Use itemName from line item, fall back to menu item lookup
|
|
final menuItem = _menuItemsById[item.itemId];
|
|
final itemName = item.itemName ?? menuItem?.name ?? "Item #${item.itemId}";
|
|
|
|
if (children.isEmpty) {
|
|
// This is a leaf - show "GroupName: Selection" format
|
|
// Use the last group name we saw, or the item's parent name
|
|
final groupName = lastGroupName ?? item.itemParentName;
|
|
final displayName = groupName != null && groupName.isNotEmpty
|
|
? "$groupName: $itemName"
|
|
: itemName;
|
|
paths.add(ModifierPath(
|
|
names: [displayName],
|
|
price: item.price,
|
|
));
|
|
} else {
|
|
// This is a group/category - pass its name down to children
|
|
for (final child in children) {
|
|
collectLeafPaths(child, itemName);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (final child in directChildren) {
|
|
collectLeafPaths(child, null);
|
|
}
|
|
|
|
return paths;
|
|
}
|
|
|
|
Widget _buildModifierPathRow(ModifierPath path) {
|
|
final displayText = path.names.join(' > ');
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 16, top: 4),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.add, size: 12, color: Colors.grey),
|
|
const SizedBox(width: 4),
|
|
Expanded(
|
|
child: Text(
|
|
displayText,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
),
|
|
if (path.price > 0)
|
|
Text(
|
|
"+\$${path.price.toStringAsFixed(2)}",
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCartSummary() {
|
|
if (_cart == null) return const SizedBox.shrink();
|
|
|
|
final fees = _feeBreakdown;
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[100],
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.1),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, -2),
|
|
),
|
|
],
|
|
),
|
|
// Constrain max height so it doesn't push content off screen
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
|
),
|
|
padding: const EdgeInsets.all(16),
|
|
child: SafeArea(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Order Type Selection (only for delivery/takeaway orders)
|
|
if (_needsOrderTypeSelection) ...[
|
|
Row(
|
|
children: [
|
|
// Only show Takeaway if business offers it
|
|
if (_cart!.offersTakeaway)
|
|
Expanded(
|
|
child: _buildOrderTypeButton(
|
|
label: "Takeaway",
|
|
icon: Icons.shopping_bag_outlined,
|
|
orderTypeId: 2,
|
|
),
|
|
),
|
|
// Add spacing only if both are shown
|
|
if (_cart!.offersTakeaway && _cart!.offersDelivery)
|
|
const SizedBox(width: 12),
|
|
// Only show Delivery if business offers it
|
|
if (_cart!.offersDelivery)
|
|
Expanded(
|
|
child: _buildOrderTypeButton(
|
|
label: "Delivery",
|
|
icon: Icons.delivery_dining,
|
|
orderTypeId: 3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Divider(height: 1),
|
|
const SizedBox(height: 12),
|
|
],
|
|
// Delivery Address Selection (only when Delivery is selected)
|
|
if (_needsDeliveryAddress) ...[
|
|
const Text(
|
|
"Delivery Address",
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildAddressSelector(),
|
|
const SizedBox(height: 16),
|
|
const Divider(height: 1),
|
|
const SizedBox(height: 12),
|
|
],
|
|
// Tip Selection
|
|
const Text(
|
|
"Add a tip",
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: List.generate(_tipPercentages.length, (index) {
|
|
final isSelected = _selectedTipIndex == index;
|
|
final percent = _tipPercentages[index];
|
|
final isCustom = percent == null;
|
|
return Expanded(
|
|
child: Padding(
|
|
padding: EdgeInsets.only(
|
|
left: index == 0 ? 0 : 4,
|
|
right: index == _tipPercentages.length - 1 ? 0 : 4,
|
|
),
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
if (isCustom) {
|
|
_showCustomTipDialog();
|
|
} else {
|
|
setState(() => _selectedTipIndex = index);
|
|
}
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? Colors.black : Colors.white,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: isSelected ? Colors.black : Colors.grey.shade300,
|
|
),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
isCustom
|
|
? (isSelected ? "$_customTipPercent%" : "Custom")
|
|
: (percent == 0 ? "No tip" : "$percent%"),
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
color: isSelected ? Colors.white : Colors.black,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Divider(height: 1),
|
|
const SizedBox(height: 12),
|
|
// Subtotal
|
|
_buildSummaryRow("Subtotal", fees.subtotal),
|
|
const SizedBox(height: 6),
|
|
// Tax
|
|
_buildSummaryRow("Tax (8.25%)", fees.tax),
|
|
// Show delivery fee: either the confirmed fee (orderTypeId == 3) or preview when Delivery selected
|
|
if (_effectiveDeliveryFee > 0) ...[
|
|
const SizedBox(height: 6),
|
|
_buildSummaryRow("Delivery Fee", _effectiveDeliveryFee),
|
|
],
|
|
// Tip
|
|
if (_tipAmount > 0) ...[
|
|
const SizedBox(height: 6),
|
|
_buildSummaryRow("Tip", _tipAmount),
|
|
],
|
|
const SizedBox(height: 6),
|
|
// Payfrit fee
|
|
_buildSummaryRow("Service Fee", fees.payfritFee, isGrey: true),
|
|
const SizedBox(height: 6),
|
|
// Card processing fee
|
|
_buildSummaryRow("Card Processing", fees.cardFee, isGrey: true),
|
|
const SizedBox(height: 12),
|
|
const Divider(height: 1),
|
|
const SizedBox(height: 12),
|
|
// Total
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text(
|
|
"Total",
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Text(
|
|
"\$${fees.total.toStringAsFixed(2)}",
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: (_canProceedToPayment && !_isProcessingPayment)
|
|
? _processPaymentAndSubmit
|
|
: null,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.black,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
disabledBackgroundColor: Colors.grey,
|
|
),
|
|
child: _isProcessingPayment
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
),
|
|
)
|
|
: Text(
|
|
_needsOrderTypeSelection && _selectedOrderType == null
|
|
? "Select order type to continue"
|
|
: "Pay \$${fees.total.toStringAsFixed(2)}",
|
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAddressSelector() {
|
|
if (_loadingAddresses) {
|
|
return const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Show existing addresses as selectable options
|
|
if (_addresses.isNotEmpty) ...[
|
|
..._addresses.map((addr) => _buildAddressOption(addr)),
|
|
const SizedBox(height: 8),
|
|
],
|
|
// Add new address button
|
|
OutlinedButton.icon(
|
|
onPressed: _showAddAddressDialog,
|
|
icon: const Icon(Icons.add_location_alt),
|
|
label: const Text("Add New Address"),
|
|
style: OutlinedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildAddressOption(DeliveryAddress addr) {
|
|
final isSelected = _selectedAddress?.addressId == addr.addressId;
|
|
return GestureDetector(
|
|
onTap: () => setState(() => _selectedAddress = addr),
|
|
child: Container(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? Colors.black : Colors.white,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: isSelected ? Colors.black : Colors.grey.shade300,
|
|
width: isSelected ? 2 : 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.location_on,
|
|
color: isSelected ? Colors.white : Colors.grey.shade600,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
addr.label,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: isSelected ? Colors.white : Colors.black,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
addr.displayText,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: isSelected ? Colors.white70 : Colors.grey.shade600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (addr.isDefault)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? Colors.white24 : Colors.blue.shade50,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
"Default",
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: isSelected ? Colors.white : Colors.blue,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showAddAddressDialog() async {
|
|
final line1Controller = TextEditingController();
|
|
final line2Controller = TextEditingController();
|
|
final cityController = TextEditingController();
|
|
final zipController = TextEditingController();
|
|
int selectedStateId = 5; // Default to California (CA)
|
|
bool setAsDefault = _addresses.isEmpty; // Default if first address
|
|
|
|
final result = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => StatefulBuilder(
|
|
builder: (context, setDialogState) => AlertDialog(
|
|
title: const Text("Add Delivery Address"),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: line1Controller,
|
|
decoration: const InputDecoration(
|
|
labelText: "Street Address *",
|
|
hintText: "123 Main St",
|
|
),
|
|
textCapitalization: TextCapitalization.words,
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: line2Controller,
|
|
decoration: const InputDecoration(
|
|
labelText: "Apt, Suite, etc. (optional)",
|
|
hintText: "Apt 4B",
|
|
),
|
|
textCapitalization: TextCapitalization.words,
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: cityController,
|
|
decoration: const InputDecoration(
|
|
labelText: "City *",
|
|
hintText: "Los Angeles",
|
|
),
|
|
textCapitalization: TextCapitalization.words,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: DropdownButtonFormField<int>(
|
|
value: selectedStateId,
|
|
decoration: const InputDecoration(
|
|
labelText: "State *",
|
|
),
|
|
items: const [
|
|
DropdownMenuItem(value: 5, child: Text("CA")),
|
|
DropdownMenuItem(value: 6, child: Text("AZ")),
|
|
DropdownMenuItem(value: 7, child: Text("NV")),
|
|
// Add more states as needed
|
|
],
|
|
onChanged: (v) {
|
|
if (v != null) {
|
|
setDialogState(() => selectedStateId = v);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: zipController,
|
|
decoration: const InputDecoration(
|
|
labelText: "ZIP Code *",
|
|
hintText: "90210",
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
CheckboxListTile(
|
|
value: setAsDefault,
|
|
onChanged: (v) => setDialogState(() => setAsDefault = v ?? false),
|
|
title: const Text("Set as default address"),
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text("Cancel"),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
// Validate
|
|
if (line1Controller.text.trim().isEmpty ||
|
|
cityController.text.trim().isEmpty ||
|
|
zipController.text.trim().isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text("Please fill in all required fields", 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 newAddr = await Api.addDeliveryAddress(
|
|
line1: line1Controller.text.trim(),
|
|
line2: line2Controller.text.trim(),
|
|
city: cityController.text.trim(),
|
|
stateId: selectedStateId,
|
|
zipCode: zipController.text.trim(),
|
|
setAsDefault: setAsDefault,
|
|
);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_addresses.insert(0, newAddr);
|
|
_selectedAddress = newAddr;
|
|
});
|
|
}
|
|
|
|
if (ctx.mounted) Navigator.pop(ctx, true);
|
|
} catch (e) {
|
|
if (ctx.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text("Error: ${e.toString()}", style: const TextStyle(color: Colors.black)),
|
|
backgroundColor: const Color(0xFF90EE90),
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.black),
|
|
child: const Text("Add Address"),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildOrderTypeButton({
|
|
required String label,
|
|
required IconData icon,
|
|
required int orderTypeId,
|
|
}) {
|
|
final isSelected = _selectedOrderType == orderTypeId;
|
|
return GestureDetector(
|
|
onTap: () {
|
|
setState(() => _selectedOrderType = orderTypeId);
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? Colors.black : Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: isSelected ? Colors.black : Colors.grey.shade300,
|
|
width: 2,
|
|
),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
size: 32,
|
|
color: isSelected ? Colors.white : Colors.grey.shade700,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: isSelected ? Colors.white : Colors.black,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSummaryRow(String label, double amount, {bool isGrey = false}) {
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
color: isGrey ? Colors.grey.shade600 : Colors.black,
|
|
),
|
|
),
|
|
Text(
|
|
"\$${amount.toStringAsFixed(2)}",
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
color: isGrey ? Colors.grey.shade600 : Colors.black,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _confirmRemoveItem(OrderLineItem item, String itemName) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text("Remove Item"),
|
|
content: Text("Remove $itemName from cart?"),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text("Cancel"),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
_removeLineItem(item);
|
|
},
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
child: const Text("Remove"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Scrollable list with fade indicator when content is hidden
|
|
class _ScrollableCartList extends StatefulWidget {
|
|
final List<Widget> children;
|
|
|
|
const _ScrollableCartList({required this.children});
|
|
|
|
@override
|
|
State<_ScrollableCartList> createState() => _ScrollableCartListState();
|
|
}
|
|
|
|
class _ScrollableCartListState extends State<_ScrollableCartList> {
|
|
final ScrollController _scrollController = ScrollController();
|
|
bool _showBottomFade = false;
|
|
bool _showTopFade = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scrollController.addListener(_updateFadeVisibility);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _updateFadeVisibility());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.removeListener(_updateFadeVisibility);
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _updateFadeVisibility() {
|
|
if (!_scrollController.hasClients) return;
|
|
|
|
final maxScroll = _scrollController.position.maxScrollExtent;
|
|
final currentScroll = _scrollController.offset;
|
|
|
|
setState(() {
|
|
_showTopFade = currentScroll > 10;
|
|
_showBottomFade = maxScroll > 0 && currentScroll < maxScroll - 10;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
children: [
|
|
ListView(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.all(16),
|
|
children: widget.children,
|
|
),
|
|
// Top fade gradient
|
|
if (_showTopFade)
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 24,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Theme.of(context).scaffoldBackgroundColor,
|
|
Theme.of(context).scaffoldBackgroundColor.withOpacity(0),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Bottom fade gradient with scroll hint
|
|
if (_showBottomFade)
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Theme.of(context).scaffoldBackgroundColor.withOpacity(0),
|
|
Theme.of(context).scaffoldBackgroundColor,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.keyboard_arrow_down, size: 16, color: Colors.grey.shade500),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
"Scroll for more",
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Collapsible accordion widget for showing modifiers
|
|
class _ModifierAccordion extends StatefulWidget {
|
|
final List<ModifierPath> modifierPaths;
|
|
final int itemId;
|
|
|
|
const _ModifierAccordion({
|
|
required this.modifierPaths,
|
|
required this.itemId,
|
|
});
|
|
|
|
@override
|
|
State<_ModifierAccordion> createState() => _ModifierAccordionState();
|
|
}
|
|
|
|
class _ModifierAccordionState extends State<_ModifierAccordion> {
|
|
bool _isExpanded = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final count = widget.modifierPaths.length;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
InkWell(
|
|
onTap: () => setState(() => _isExpanded = !_isExpanded),
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
_isExpanded ? Icons.expand_less : Icons.expand_more,
|
|
size: 20,
|
|
color: Colors.blue.shade600,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
_isExpanded ? "Hide customizations" : "$count customization${count == 1 ? '' : 's'}",
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: Colors.blue.shade600,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (_isExpanded)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 8, bottom: 4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: widget.modifierPaths.map((path) {
|
|
final displayText = path.names.join(' > ');
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Icon(Icons.check, size: 14, color: Colors.grey),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
displayText,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
),
|
|
if (path.price > 0)
|
|
Text(
|
|
"+\$${path.price.toStringAsFixed(2)}",
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.green.shade700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|