payfrit-app/lib/screens/cart_view_screen.dart
John Mizerek 4ebfbbc03b Add app branding, splash screen, and fix modifier validation
App Branding:
- New Payfrit app icon (blue gradient with white P logo)
- Custom splash screen with animated logo and tagline
- Android adaptive icon with foreground/background layers
- iOS app icons for all required sizes
- Updated launch screen backgrounds with brand colors

Splash Screen Experience:
- Animated logo with fade-in effect
- "Order. Pay. Go." tagline with staggered animations
- Restaurant list display with search functionality
- Beacon scanning integration from splash
- Smooth transition to menu browse

Modifier Validation Fix:
- Fixed validation to check ALL modifier groups (not just selected items)
- Ensures required selections are enforced for nested modifier groups
- Modifier groups with children now always get validated
- Prevents adding items without required selections

Cart Improvements:
- Better modifier display in cart items
- Improved line item quantity handling
- Enhanced order submission flow

Beacon Scanning:
- Improved beacon detection reliability
- Better handling of multiple beacons
- Enhanced error messaging

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

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

620 lines
18 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';
/// 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;
String? _error;
Map<int, MenuItem> _menuItemsById = {};
@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
final cart = await Api.getCart(orderId: cartOrderId);
// Load menu items to get names and prices
final businessId = appState.selectedBusinessId;
if (businessId != null) {
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);
} 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> _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> _submitOrder() async {
try {
final appState = context.read<AppState>();
final cartOrderId = appState.cartOrderId;
if (cartOrderId == null) return;
setState(() => _isLoading = true);
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);
// Show notification using global scaffold messenger key
// This works even after the cart screen is popped
rootScaffoldMessengerKey.currentState?.showSnackBar(
SnackBar(
content: Text(update.message),
backgroundColor: _getStatusColorStatic(update.statusId),
duration: const Duration(seconds: 5),
behavior: SnackBarBehavior.floating,
),
);
},
);
// Clear cart state
appState.clearCart();
if (!mounted) return;
// Show success message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Order submitted successfully! You'll receive notifications as your order is prepared."),
backgroundColor: Colors.green,
duration: Duration(seconds: 5),
),
);
// Navigate back
Navigator.of(context).pop();
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
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: ListView(
padding: const EdgeInsets.all(16),
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) {
final menuItem = _menuItemsById[rootItem.itemId];
final itemName = menuItem?.name ?? "Item #${rootItem.itemId}";
print('[Cart] Building card for root item: $itemName (OrderLineItemID=${rootItem.orderLineItemId})');
print('[Cart] Total line items in cart: ${_cart!.lineItems.length}');
// 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);
print('[Cart] Found ${modifierPaths.length} modifier paths for this root item');
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
itemName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => _confirmRemoveItem(rootItem, itemName),
),
],
),
if (modifierPaths.isNotEmpty) ...[
const SizedBox(height: 8),
...modifierPaths.map((path) => _buildModifierPathRow(path)),
],
const SizedBox(height: 8),
Row(
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: rootItem.quantity > 1
? () => _updateQuantity(rootItem, rootItem.quantity - 1)
: null,
),
Text(
"${rootItem.quantity}",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () =>
_updateQuantity(rootItem, rootItem.quantity + 1),
),
const Spacer(),
Text(
"\$${lineItemTotal.toStringAsFixed(2)}",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
);
}
/// 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, List<String> currentPath) {
final menuItem = _menuItemsById[item.itemId];
// Skip default items - they don't need to be repeated in the cart
if (menuItem?.isCheckedByDefault == true) {
return;
}
final children = _cart!.lineItems
.where((child) =>
child.parentOrderLineItemId == item.orderLineItemId &&
!child.isDeleted)
.toList();
final itemName = menuItem?.name ?? "Item #${item.itemId}";
if (children.isEmpty) {
// This is a leaf - add its path
paths.add(ModifierPath(
names: [...currentPath, itemName],
price: item.price,
));
} else {
// This has children - recurse into them
for (final child in children) {
collectLeafPaths(child, [...currentPath, itemName]);
}
}
}
for (final child in directChildren) {
collectLeafPaths(child, []);
}
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();
return Container(
decoration: BoxDecoration(
color: Colors.grey[100],
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
padding: const EdgeInsets.all(16),
child: SafeArea(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Subtotal",
style: TextStyle(fontSize: 16),
),
Text(
"\$${_cart!.subtotal.toStringAsFixed(2)}",
style: const TextStyle(fontSize: 16),
),
],
),
// Sales tax
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Tax (8.25%)",
style: TextStyle(fontSize: 16),
),
Text(
"\$${_cart!.tax.toStringAsFixed(2)}",
style: const TextStyle(fontSize: 16),
),
],
),
// Only show delivery fee for delivery orders (OrderTypeID = 3)
if (_cart!.deliveryFee > 0 && _cart!.orderTypeId == 3) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Delivery Fee",
style: TextStyle(fontSize: 16),
),
Text(
"\$${_cart!.deliveryFee.toStringAsFixed(2)}",
style: const TextStyle(fontSize: 16),
),
],
),
],
const Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Total",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
"\$${_cart!.total.toStringAsFixed(2)}",
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _cart!.itemCount > 0 ? _submitOrder : null,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text(
"Submit Order",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
),
],
),
),
);
}
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"),
),
],
),
);
}
}