payfrit-app/lib/screens/cart_view_screen.dart
John Mizerek 9a489f20bb Cart and order flow improvements
- Fix login to check for existing cart after OTP verification
- Add abandonOrder API call for Start Fresh functionality
- Fix stale service point showing for non-dine-in orders
- Add Chat button for non-dine-in orders (was only Call Server)
- Add quantity selector in item customization sheet
- Compact cart layout with quantity badge, accordion modifiers
- Add scroll indicator when cart has hidden content
- Fix restaurant list tappable before images load
- Add ForceNew parameter to setLineItem for customized items

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:26:58 -08:00

1563 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';
/// 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 menuItems = await Api.listMenuItems(businessId: businessId);
_menuItemsById = {for (var item in menuItems) 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,
),
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),
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(),
),
),
],
);
}
}