Fix beacon scanning and Bluetooth permissions

- Remove neverForLocation flag from BLUETOOTH_SCAN (was blocking beacon detection)
- Add Bluetooth state check before scanning with prompt to enable
- Add iOS Bluetooth/Location permission descriptions and UIBackgroundModes
- Fix exclusive modifier selection (deselect siblings when max=1)
- Update cart service point when existing cart found
- Add delivery fee support to cart and stripe service

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-08 15:08:50 -08:00
parent ef5609ba57
commit 1d08b18568
11 changed files with 861 additions and 79 deletions

View file

@ -2,17 +2,18 @@
<!-- Internet permission for API calls --> <!-- Internet permission for API calls -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- Beacon scanning permissions --> <!-- Beacon scanning permissions (legacy, still needed by beacon library) -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<!-- Android 12+ Bluetooth permissions --> <!-- Android 12+ Bluetooth permissions (no neverForLocation - beacons need location) -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Location permissions for beacon ranging --> <!-- Location permissions for beacon ranging -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Foreground service for background beacon monitoring --> <!-- Foreground service for background beacon monitoring -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

View file

@ -53,5 +53,20 @@
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Payfrit uses Bluetooth to detect nearby restaurant tables for dine-in ordering.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>Payfrit uses Bluetooth to detect nearby restaurant tables for dine-in ordering.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Payfrit uses your location to find nearby restaurants and for beacon detection.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>Payfrit uses your location to find nearby restaurants and detect when you're at a table.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Payfrit uses your location to find nearby restaurants and detect when you're at a table.</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
<string>location</string>
</array>
</dict> </dict>
</plist> </plist>

View file

@ -4,8 +4,9 @@ class Cart {
final int userId; final int userId;
final int businessId; final int businessId;
final double businessDeliveryMultiplier; final double businessDeliveryMultiplier;
final double businessDeliveryFee; // The business's standard delivery fee (for preview)
final int orderTypeId; final int orderTypeId;
final double deliveryFee; final double deliveryFee; // The actual delivery fee on this order (set when order type is confirmed)
final int statusId; final int statusId;
final int? addressId; final int? addressId;
final int? paymentId; final int? paymentId;
@ -22,6 +23,7 @@ class Cart {
required this.userId, required this.userId,
required this.businessId, required this.businessId,
required this.businessDeliveryMultiplier, required this.businessDeliveryMultiplier,
required this.businessDeliveryFee,
required this.orderTypeId, required this.orderTypeId,
required this.deliveryFee, required this.deliveryFee,
required this.statusId, required this.statusId,
@ -45,6 +47,7 @@ class Cart {
userId: _parseInt(order["OrderUserID"]) ?? 0, userId: _parseInt(order["OrderUserID"]) ?? 0,
businessId: _parseInt(order["OrderBusinessID"]) ?? 0, businessId: _parseInt(order["OrderBusinessID"]) ?? 0,
businessDeliveryMultiplier: _parseDouble(order["OrderBusinessDeliveryMultiplier"]) ?? 0.0, businessDeliveryMultiplier: _parseDouble(order["OrderBusinessDeliveryMultiplier"]) ?? 0.0,
businessDeliveryFee: _parseDouble(order["BusinessDeliveryFee"]) ?? 0.0,
orderTypeId: _parseInt(order["OrderTypeID"]) ?? 0, orderTypeId: _parseInt(order["OrderTypeID"]) ?? 0,
deliveryFee: _parseDouble(order["OrderDeliveryFee"]) ?? 0.0, deliveryFee: _parseDouble(order["OrderDeliveryFee"]) ?? 0.0,
statusId: _parseInt(order["OrderStatusID"]) ?? 0, statusId: _parseInt(order["OrderStatusID"]) ?? 0,

View file

@ -74,6 +74,26 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
print('[BeaconScan] ✅ Permissions GRANTED'); print('[BeaconScan] ✅ Permissions GRANTED');
setState(() => _permissionsGranted = true); setState(() => _permissionsGranted = true);
// Step 1.5: Check if Bluetooth is ON
setState(() => _status = 'Checking Bluetooth...');
print('[BeaconScan] 📶 Checking Bluetooth state...');
final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled();
if (!bluetoothOn) {
print('[BeaconScan] ❌ Bluetooth is OFF');
setState(() {
_status = 'Please turn on Bluetooth to scan for tables';
_scanning = false;
});
// Wait and retry, or let user manually proceed
await Future.delayed(const Duration(seconds: 2));
if (mounted) _navigateToRestaurantSelect();
return;
}
print('[BeaconScan] ✅ Bluetooth is ON');
// Step 2: Fetch all active beacons from server // Step 2: Fetch all active beacons from server
setState(() => _status = 'Loading beacon data...'); setState(() => _status = 'Loading beacon data...');
@ -93,8 +113,8 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
} }
if (_uuidToBeaconId.isEmpty) { if (_uuidToBeaconId.isEmpty) {
print('[BeaconScan] ⚠️ No beacons in database, going to order type select'); print('[BeaconScan] ⚠️ No beacons in database, going to restaurant select');
if (mounted) _navigateToOrderTypeSelect(); if (mounted) _navigateToRestaurantSelect();
return; return;
} }
@ -131,16 +151,15 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
return; return;
} }
// Perform up to 5 scans of 2 seconds each, but exit early if readings are consistent // Perform scan cycles - always complete all cycles for dine-in beacon detection
print('[BeaconScan] 🔄 Starting scan cycles (max 5 x 2 seconds, early exit if stable)'); print('[BeaconScan] 🔄 Starting scan cycles');
bool earlyExit = false;
for (int scanCycle = 1; scanCycle <= 3; scanCycle++) { for (int scanCycle = 1; scanCycle <= 3; scanCycle++) {
// Update status message for each cycle // Update status message for each cycle
if (mounted) { if (mounted) {
setState(() => _status = _scanMessages[scanCycle - 1]); setState(() => _status = _scanMessages[scanCycle - 1]);
} }
print('[BeaconScan] ----- Scan cycle $scanCycle/5 -----'); print('[BeaconScan] ----- Scan cycle $scanCycle/3 -----');
StreamSubscription<RangingResult>? subscription; StreamSubscription<RangingResult>? subscription;
@ -165,26 +184,17 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
} }
}); });
// Wait 2 seconds for this scan cycle to collect beacon data // Wait for this scan cycle to collect beacon data
await Future.delayed(const Duration(milliseconds: 1500)); await Future.delayed(const Duration(milliseconds: 2000));
await subscription.cancel(); await subscription.cancel();
// After 3 cycles, check if we can exit early
if (scanCycle >= 2 && _beaconRssiSamples.isNotEmpty) {
if (_canExitEarly()) {
print('[BeaconScan] ⚡ Early exit after $scanCycle cycles - readings are stable!');
earlyExit = true;
break;
}
}
// Short pause between scan cycles // Short pause between scan cycles
if (scanCycle < 3) { if (scanCycle < 3) {
await Future.delayed(const Duration(milliseconds: 1500)); await Future.delayed(const Duration(milliseconds: 500));
} }
} }
print('[BeaconScan] ✔️ Scan complete${earlyExit ? ' (early exit)' : ''}'); print('[BeaconScan] ✔️ Scan complete');
if (!mounted) return; if (!mounted) return;
@ -232,9 +242,15 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
print('[BeaconScan] =========================='); print('[BeaconScan] ==========================');
if (beaconScores.isEmpty) { if (beaconScores.isEmpty) {
setState(() => _status = 'No beacons nearby'); // No Payfrit beacons found - stop scanning and go to business list
await Future.delayed(const Duration(milliseconds: 800)); print('[BeaconScan] 🚫 No Payfrit beacons found, navigating to restaurant select');
if (mounted) _navigateToOrderTypeSelect(); setState(() {
_scanning = false;
_status = 'No nearby tables detected';
});
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) _navigateToRestaurantSelect();
return;
} else { } else {
// Find beacon with highest average RSSI and minimum detections // Find beacon with highest average RSSI and minimum detections
final best = _findBestBeacon(beaconScores); final best = _findBestBeacon(beaconScores);
@ -244,17 +260,23 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
await _autoSelectBusinessFromBeacon(best.beaconId); await _autoSelectBusinessFromBeacon(best.beaconId);
} else { } else {
print('[BeaconScan] ⚠️ No beacon met minimum confidence threshold'); print('[BeaconScan] ⚠️ No beacon met minimum confidence threshold');
setState(() => _status = 'No strong beacon signal'); setState(() {
await Future.delayed(const Duration(milliseconds: 800)); _scanning = false;
if (mounted) _navigateToOrderTypeSelect(); _status = 'No strong beacon signal';
});
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) _navigateToRestaurantSelect();
} }
} }
} catch (e) { } catch (e) {
print('[BeaconScan] ❌ ERROR during scan: $e'); print('[BeaconScan] ❌ ERROR during scan: $e');
print('[BeaconScan] Stack trace: ${StackTrace.current}'); print('[BeaconScan] Stack trace: ${StackTrace.current}');
if (mounted) { if (mounted) {
setState(() => _status = 'Scan error - continuing to manual selection'); setState(() {
await Future.delayed(const Duration(seconds: 1)); _scanning = false;
_status = 'Scan error - continuing to manual selection';
});
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) _navigateToRestaurantSelect(); if (mounted) _navigateToRestaurantSelect();
} }
} }
@ -275,6 +297,9 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
servicePointName: mapping.servicePointName, servicePointName: mapping.servicePointName,
); );
// Set order type to dine-in since beacon was detected
appState.setOrderType(OrderType.dineIn);
// Update API business ID for headers // Update API business ID for headers
Api.setBusinessId(mapping.businessId); Api.setBusinessId(mapping.businessId);
@ -384,10 +409,6 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
} }
void _navigateToOrderTypeSelect() {
Navigator.of(context).pushReplacementNamed(AppRoutes.orderTypeSelect);
}
void _retryPermissions() async { void _retryPermissions() async {
await BeaconPermissions.openSettings(); await BeaconPermissions.openSettings();
} }

View file

@ -34,11 +34,46 @@ class _CartViewScreenState extends State<CartViewScreen> {
String? _error; String? _error;
Map<int, MenuItem> _menuItemsById = {}; 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) // Tip options as percentages (null = custom)
static const List<int?> _tipPercentages = [0, 15, 18, 20, null]; static const List<int?> _tipPercentages = [0, 15, 18, 20, null];
int _selectedTipIndex = 1; // Default to 15% int _selectedTipIndex = 1; // Default to 15%
int _customTipPercent = 25; // Default custom tip if selected 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 { double get _tipAmount {
if (_cart == null) return 0.0; if (_cart == null) return 0.0;
final percent = _tipPercentages[_selectedTipIndex]; final percent = _tipPercentages[_selectedTipIndex];
@ -55,6 +90,7 @@ class _CartViewScreenState extends State<CartViewScreen> {
subtotal: 0, subtotal: 0,
tax: 0, tax: 0,
tip: 0, tip: 0,
deliveryFee: 0,
payfritFee: 0, payfritFee: 0,
cardFee: 0, cardFee: 0,
total: 0, total: 0,
@ -64,6 +100,7 @@ class _CartViewScreenState extends State<CartViewScreen> {
subtotal: _cart!.subtotal, subtotal: _cart!.subtotal,
tax: _cart!.tax, tax: _cart!.tax,
tip: _tipAmount, tip: _tipAmount,
deliveryFee: _effectiveDeliveryFee,
); );
} }
@ -108,6 +145,11 @@ class _CartViewScreenState extends State<CartViewScreen> {
// Update item count in app state // Update item count in app state
appState.updateCartItemCount(cart.itemCount); appState.updateCartItemCount(cart.itemCount);
// If cart needs order type selection, pre-load addresses
if (cart.orderTypeId == 0) {
_loadDeliveryAddresses();
}
} catch (e) { } catch (e) {
// If cart not found (deleted or doesn't exist), clear it from app state // 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')) { if (e.toString().contains('not_found') || e.toString().contains('Order not found')) {
@ -126,6 +168,28 @@ class _CartViewScreenState extends State<CartViewScreen> {
} }
} }
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 { Future<void> _removeLineItem(OrderLineItem lineItem) async {
try { try {
final appState = context.read<AppState>(); final appState = context.read<AppState>();
@ -241,9 +305,41 @@ class _CartViewScreenState extends State<CartViewScreen> {
if (cartOrderId == null || businessId == null) return; if (cartOrderId == null || businessId == null) return;
// Ensure order type is selected for delivery/takeaway orders
if (_needsOrderTypeSelection && _selectedOrderType == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Please select Delivery or Takeaway"),
backgroundColor: Colors.orange,
),
);
return;
}
// Ensure delivery address is selected for delivery orders
if (_needsDeliveryAddress && _selectedAddress == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Please select a delivery address"),
backgroundColor: Colors.orange,
),
);
return;
}
setState(() => _isProcessingPayment = true); setState(() => _isProcessingPayment = true);
try { 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 // 1. Process payment with Stripe
final paymentResult = await StripeService.processPayment( final paymentResult = await StripeService.processPayment(
context: context, context: context,
@ -666,6 +762,48 @@ class _CartViewScreenState extends State<CartViewScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Order Type Selection (only for delivery/takeaway orders)
if (_needsOrderTypeSelection) ...[
const Text(
"How would you like your order?",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _buildOrderTypeButton(
label: "Takeaway",
icon: Icons.shopping_bag_outlined,
orderTypeId: 2,
),
),
const SizedBox(width: 12),
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 // Tip Selection
const Text( const Text(
"Add a tip", "Add a tip",
@ -726,10 +864,10 @@ class _CartViewScreenState extends State<CartViewScreen> {
const SizedBox(height: 6), const SizedBox(height: 6),
// Tax // Tax
_buildSummaryRow("Tax (8.25%)", fees.tax), _buildSummaryRow("Tax (8.25%)", fees.tax),
// Only show delivery fee for delivery orders (OrderTypeID = 3) // Show delivery fee: either the confirmed fee (orderTypeId == 3) or preview when Delivery selected
if (_cart!.deliveryFee > 0 && _cart!.orderTypeId == 3) ...[ if (_effectiveDeliveryFee > 0) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
_buildSummaryRow("Delivery Fee", _cart!.deliveryFee), _buildSummaryRow("Delivery Fee", _effectiveDeliveryFee),
], ],
// Tip // Tip
if (_tipAmount > 0) ...[ if (_tipAmount > 0) ...[
@ -769,7 +907,7 @@ class _CartViewScreenState extends State<CartViewScreen> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
onPressed: (_cart!.itemCount > 0 && !_isProcessingPayment) onPressed: (_canProceedToPayment && !_isProcessingPayment)
? _processPaymentAndSubmit ? _processPaymentAndSubmit
: null, : null,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@ -788,7 +926,9 @@ class _CartViewScreenState extends State<CartViewScreen> {
), ),
) )
: Text( : Text(
"Pay \$${fees.total.toStringAsFixed(2)}", _needsOrderTypeSelection && _selectedOrderType == null
? "Select order type to continue"
: "Pay \$${fees.total.toStringAsFixed(2)}",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
), ),
), ),
@ -799,6 +939,295 @@ class _CartViewScreenState extends State<CartViewScreen> {
); );
} }
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(
const SnackBar(
content: Text("Please fill in all required fields"),
backgroundColor: Colors.orange,
),
);
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()}"),
backgroundColor: Colors.red,
),
);
}
}
},
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}) { Widget _buildSummaryRow(String label, double amount, {bool isGrey = false}) {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,

View file

@ -205,7 +205,8 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
businessName, businessName,
style: const TextStyle(fontSize: 18), style: const TextStyle(fontSize: 18),
), ),
if (appState.selectedServicePointName != null) // Only show table name for dine-in orders (beacon detected)
if (appState.isDineIn && appState.selectedServicePointName != null)
Text( Text(
appState.selectedServicePointName!, appState.selectedServicePointName!,
style: const TextStyle( style: const TextStyle(
@ -219,30 +220,32 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
], ],
), ),
actions: [ actions: [
IconButton( // Only show table change button for dine-in orders
icon: const Icon(Icons.table_restaurant), if (appState.isDineIn)
tooltip: "Change Table", IconButton(
onPressed: () { icon: const Icon(Icons.table_restaurant),
// Prevent changing tables if there's an active order (dine and dash prevention) tooltip: "Change Table",
if (appState.activeOrderId != null) { onPressed: () {
showDialog( // Prevent changing tables if there's an active order (dine and dash prevention)
context: context, if (appState.activeOrderId != null) {
builder: (context) => AlertDialog( showDialog(
title: const Text("Cannot Change Table"), context: context,
content: const Text("Please complete or cancel your current order before changing tables."), builder: (context) => AlertDialog(
actions: [ title: const Text("Cannot Change Table"),
TextButton( content: const Text("Please complete or cancel your current order before changing tables."),
onPressed: () => Navigator.pop(context), actions: [
child: const Text("OK"), TextButton(
), onPressed: () => Navigator.pop(context),
], child: const Text("OK"),
), ),
); ],
return; ),
} );
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); return;
}, }
), Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
},
),
IconButton( IconButton(
icon: Badge( icon: Badge(
label: Text("${appState.cartItemCount}"), label: Text("${appState.cartItemCount}"),
@ -743,11 +746,13 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
// Get or create cart // Get or create cart
Cart cart; Cart cart;
if (appState.cartOrderId == null) { if (appState.cartOrderId == null) {
// Determine order type: 1=dine-in (beacon), 0=undecided (no beacon, will choose at checkout)
final orderTypeId = appState.isDineIn ? 1 : 0;
cart = await Api.getOrCreateCart( cart = await Api.getOrCreateCart(
userId: _userId!, userId: _userId!,
businessId: _businessId!, businessId: _businessId!,
servicePointId: _servicePointId!, servicePointId: _servicePointId!,
orderTypeId: 1, // Dine-in orderTypeId: orderTypeId,
); );
appState.setCartOrder( appState.setCartOrder(
orderId: cart.orderId, orderId: cart.orderId,

View file

@ -73,17 +73,46 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
} }
void _toggleExpand(Restaurant restaurant) { void _toggleExpand(Restaurant restaurant) {
setState(() { // For delivery/takeaway flow (no beacon), go directly to menu
if (_expandedBusinessId == restaurant.businessId) { // No need to select table - just pick the first service point
// Collapse _navigateToMenu(restaurant);
_expandedBusinessId = null; }
} else {
// Expand this one void _navigateToMenu(Restaurant restaurant) async {
_expandedBusinessId = restaurant.businessId; // Load service points if not cached
// Start loading menu if not cached if (!_servicePointCache.containsKey(restaurant.businessId)) {
_loadMenuForBusiness(restaurant.businessId); try {
final servicePoints = await Api.listServicePoints(businessId: restaurant.businessId);
_servicePointCache[restaurant.businessId] = servicePoints;
} catch (e) {
debugPrint('[RestaurantSelect] Error loading service points: $e');
} }
}); }
if (!mounted) return;
// Default to first service point (for delivery/takeaway, table doesn't matter)
final servicePoints = _servicePointCache[restaurant.businessId];
final servicePointId = servicePoints?.firstOrNull?.servicePointId ?? 1;
// Set app state
final appState = context.read<AppState>();
appState.setBusinessAndServicePoint(
restaurant.businessId,
servicePointId,
businessName: restaurant.name,
servicePointName: servicePoints?.firstOrNull?.name,
);
Api.setBusinessId(restaurant.businessId);
// Navigate to full menu browse screen
Navigator.of(context).pushReplacementNamed(
'/menu_browse',
arguments: {
'businessId': restaurant.businessId,
'servicePointId': servicePointId,
},
);
} }
@override @override

View file

@ -149,6 +149,16 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
return; return;
} }
// Check if Bluetooth is ON
print('[Splash] 📶 Checking Bluetooth state...');
final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled();
if (!bluetoothOn) {
print('[Splash] ❌ Bluetooth is OFF - cannot scan for beacons');
_scanComplete = true;
return;
}
print('[Splash] ✅ Bluetooth is ON');
// Fetch beacon list from server // Fetch beacon list from server
try { try {
_uuidToBeaconId = await Api.listAllBeacons(); _uuidToBeaconId = await Api.listAllBeacons();

View file

@ -372,10 +372,37 @@ class Api {
); );
final j = _requireJson(raw, "SetLineItem"); final j = _requireJson(raw, "SetLineItem");
print('[API] setLineItem response: OK=${j["OK"]}, ERROR=${_err(j)}, orderId=$orderId, itemId=$itemId, parentLI=$parentOrderLineItemId');
if (!_ok(j)) { if (!_ok(j)) {
throw StateError( throw StateError(
"SetLineItem API returned OK=false\nERROR: ${_err(j)}\nDETAIL: ${(j["MESSAGE"] ?? "").toString()}\nHTTP Status: ${raw.statusCode}", "SetLineItem failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}",
);
}
return Cart.fromJson(j);
}
/// Set the order type (delivery/takeaway) on an existing cart
static Future<Cart> setOrderType({
required int orderId,
required int orderTypeId,
int? addressId,
}) async {
final raw = await _postRaw(
"/orders/setOrderType.cfm",
{
"OrderID": orderId,
"OrderTypeID": orderTypeId,
if (addressId != null) "AddressID": addressId,
},
);
final j = _requireJson(raw, "SetOrderType");
if (!_ok(j)) {
throw StateError(
"SetOrderType failed: ${_err(j)} - ${(j["MESSAGE"] ?? "").toString()}",
); );
} }
@ -493,6 +520,77 @@ class Api {
} }
return null; return null;
} }
/// Check if user has pending orders at a business (for pickup detection)
static Future<List<PendingOrder>> getPendingOrdersForUser({
required int userId,
required int businessId,
}) async {
final raw = await _getRaw(
"/orders/getPendingForUser.cfm?UserID=$userId&BusinessID=$businessId",
);
final j = _requireJson(raw, "GetPendingOrdersForUser");
if (!_ok(j)) {
return []; // Return empty list on error
}
final arr = _pickArray(j, const ["ORDERS", "orders"]);
if (arr == null) return [];
return arr.map((e) {
final item = e is Map<String, dynamic> ? e : (e as Map).cast<String, dynamic>();
return PendingOrder.fromJson(item);
}).toList();
}
/// Get user's delivery addresses
static Future<List<DeliveryAddress>> getDeliveryAddresses() async {
final raw = await _getRaw("/addresses/list.cfm");
final j = _requireJson(raw, "GetDeliveryAddresses");
if (!_ok(j)) {
return [];
}
final arr = _pickArray(j, const ["ADDRESSES", "addresses"]);
if (arr == null) return [];
return arr.map((e) {
final item = e is Map<String, dynamic> ? e : (e as Map).cast<String, dynamic>();
return DeliveryAddress.fromJson(item);
}).toList();
}
/// Add a new delivery address
static Future<DeliveryAddress> addDeliveryAddress({
required String line1,
required String city,
required int stateId,
required String zipCode,
String? line2,
String? label,
bool setAsDefault = false,
}) async {
final raw = await _postRaw("/addresses/add.cfm", {
"Line1": line1,
"City": city,
"StateID": stateId,
"ZIPCode": zipCode,
if (line2 != null) "Line2": line2,
if (label != null) "Label": label,
"SetAsDefault": setAsDefault,
});
final j = _requireJson(raw, "AddDeliveryAddress");
if (!_ok(j)) {
throw StateError("AddDeliveryAddress failed: ${_err(j)}");
}
final addressData = j["ADDRESS"] as Map<String, dynamic>? ?? {};
return DeliveryAddress.fromJson(addressData);
}
} }
class BeaconBusinessMapping { class BeaconBusinessMapping {
@ -512,3 +610,95 @@ class BeaconBusinessMapping {
required this.servicePointName, required this.servicePointName,
}); });
} }
class PendingOrder {
final int orderId;
final String orderUuid;
final int orderTypeId;
final String orderTypeName;
final int statusId;
final String statusName;
final String submittedOn;
final int servicePointId;
final String servicePointName;
final String businessName;
final double subtotal;
const PendingOrder({
required this.orderId,
required this.orderUuid,
required this.orderTypeId,
required this.orderTypeName,
required this.statusId,
required this.statusName,
required this.submittedOn,
required this.servicePointId,
required this.servicePointName,
required this.businessName,
required this.subtotal,
});
factory PendingOrder.fromJson(Map<String, dynamic> json) {
return PendingOrder(
orderId: (json["OrderID"] ?? json["ORDERID"] ?? 0) as int,
orderUuid: (json["OrderUUID"] ?? json["ORDERUUID"] ?? "") as String,
orderTypeId: (json["OrderTypeID"] ?? json["ORDERTYPEID"] ?? 0) as int,
orderTypeName: (json["OrderTypeName"] ?? json["ORDERTYPENAME"] ?? "") as String,
statusId: (json["OrderStatusID"] ?? json["ORDERSTATUSID"] ?? 0) as int,
statusName: (json["StatusName"] ?? json["STATUSNAME"] ?? "") as String,
submittedOn: (json["SubmittedOn"] ?? json["SUBMITTEDON"] ?? "") as String,
servicePointId: (json["ServicePointID"] ?? json["SERVICEPOINTID"] ?? 0) as int,
servicePointName: (json["ServicePointName"] ?? json["SERVICEPOINTNAME"] ?? "") as String,
businessName: (json["BusinessName"] ?? json["BUSINESSNAME"] ?? "") as String,
subtotal: ((json["Subtotal"] ?? json["SUBTOTAL"] ?? 0) as num).toDouble(),
);
}
bool get isReady => statusId == 3;
bool get isPreparing => statusId == 2;
bool get isSubmitted => statusId == 1;
}
class DeliveryAddress {
final int addressId;
final String label;
final bool isDefault;
final String line1;
final String line2;
final String city;
final int stateId;
final String stateAbbr;
final String stateName;
final String zipCode;
final String displayText;
const DeliveryAddress({
required this.addressId,
required this.label,
required this.isDefault,
required this.line1,
required this.line2,
required this.city,
required this.stateId,
required this.stateAbbr,
required this.stateName,
required this.zipCode,
required this.displayText,
});
factory DeliveryAddress.fromJson(Map<String, dynamic> json) {
return DeliveryAddress(
addressId: (json["AddressID"] ?? json["ADDRESSID"] ?? 0) as int,
label: (json["Label"] ?? json["LABEL"] ?? "Address") as String,
isDefault: (json["IsDefault"] ?? json["ISDEFAULT"] ?? false) == true,
line1: (json["Line1"] ?? json["LINE1"] ?? "") as String,
line2: (json["Line2"] ?? json["LINE2"] ?? "") as String,
city: (json["City"] ?? json["CITY"] ?? "") as String,
stateId: (json["StateID"] ?? json["STATEID"] ?? 0) as int,
stateAbbr: (json["StateAbbr"] ?? json["STATEABBR"] ?? "") as String,
stateName: (json["StateName"] ?? json["STATENAME"] ?? "") as String,
zipCode: (json["ZIPCode"] ?? json["ZIPCODE"] ?? "") as String,
displayText: (json["DisplayText"] ?? json["DISPLAYTEXT"] ?? "") as String,
);
}
}

View file

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart';
class BeaconPermissions { class BeaconPermissions {
static Future<bool> requestPermissions() async { static Future<bool> requestPermissions() async {
@ -39,6 +40,79 @@ class BeaconPermissions {
} }
} }
/// Check if Bluetooth is enabled - returns current state without prompting
static Future<bool> isBluetoothEnabled() async {
try {
final bluetoothState = await flutterBeacon.bluetoothState;
return bluetoothState == BluetoothState.stateOn;
} catch (e) {
debugPrint('[BeaconPermissions] Error checking Bluetooth state: $e');
return false;
}
}
/// Request to enable Bluetooth via system prompt (Android only)
static Future<bool> requestEnableBluetooth() async {
try {
debugPrint('[BeaconPermissions] 📶 Requesting Bluetooth enable...');
// This opens a system dialog on Android asking user to turn on Bluetooth
final result = await flutterBeacon.requestAuthorization;
debugPrint('[BeaconPermissions] Request authorization result: $result');
return result;
} catch (e) {
debugPrint('[BeaconPermissions] Error requesting Bluetooth enable: $e');
return false;
}
}
/// Open Bluetooth settings
static Future<bool> openBluetoothSettings() async {
try {
debugPrint('[BeaconPermissions] Opening Bluetooth settings...');
final opened = await flutterBeacon.openBluetoothSettings;
debugPrint('[BeaconPermissions] Open Bluetooth settings result: $opened');
return opened;
} catch (e) {
debugPrint('[BeaconPermissions] Error opening Bluetooth settings: $e');
return false;
}
}
/// Check if Bluetooth is enabled, and try to enable it if not
static Future<bool> ensureBluetoothEnabled() async {
try {
// Check current Bluetooth state
final bluetoothState = await flutterBeacon.bluetoothState;
debugPrint('[BeaconPermissions] 📶 Bluetooth state: $bluetoothState');
if (bluetoothState == BluetoothState.stateOn) {
debugPrint('[BeaconPermissions] ✅ Bluetooth is ON');
return true;
}
// Request to enable Bluetooth via system prompt
debugPrint('[BeaconPermissions] ⚠️ Bluetooth is OFF, requesting enable...');
await requestEnableBluetooth();
// Poll for Bluetooth state change (wait up to 10 seconds)
for (int i = 0; i < 20; i++) {
await Future.delayed(const Duration(milliseconds: 500));
final newState = await flutterBeacon.bluetoothState;
debugPrint('[BeaconPermissions] 📶 Polling Bluetooth state ($i): $newState');
if (newState == BluetoothState.stateOn) {
debugPrint('[BeaconPermissions] ✅ Bluetooth is now ON');
return true;
}
}
debugPrint('[BeaconPermissions] ❌ Bluetooth still OFF after waiting');
return false;
} catch (e) {
debugPrint('[BeaconPermissions] Error checking Bluetooth state: $e');
return false;
}
}
static Future<bool> checkPermissions() async { static Future<bool> checkPermissions() async {
final locationStatus = await Permission.locationWhenInUse.status; final locationStatus = await Permission.locationWhenInUse.status;

View file

@ -23,6 +23,7 @@ class FeeBreakdown {
final double subtotal; final double subtotal;
final double tax; final double tax;
final double tip; final double tip;
final double deliveryFee;
final double payfritFee; final double payfritFee;
final double cardFee; final double cardFee;
final double total; final double total;
@ -31,6 +32,7 @@ class FeeBreakdown {
required this.subtotal, required this.subtotal,
required this.tax, required this.tax,
required this.tip, required this.tip,
required this.deliveryFee,
required this.payfritFee, required this.payfritFee,
required this.cardFee, required this.cardFee,
required this.total, required this.total,
@ -41,6 +43,7 @@ class FeeBreakdown {
subtotal: (json['SUBTOTAL'] as num?)?.toDouble() ?? 0.0, subtotal: (json['SUBTOTAL'] as num?)?.toDouble() ?? 0.0,
tax: (json['TAX'] as num?)?.toDouble() ?? 0.0, tax: (json['TAX'] as num?)?.toDouble() ?? 0.0,
tip: (json['TIP'] as num?)?.toDouble() ?? 0.0, tip: (json['TIP'] as num?)?.toDouble() ?? 0.0,
deliveryFee: (json['DELIVERY_FEE'] as num?)?.toDouble() ?? 0.0,
payfritFee: (json['PAYFRIT_FEE'] as num?)?.toDouble() ?? 0.0, payfritFee: (json['PAYFRIT_FEE'] as num?)?.toDouble() ?? 0.0,
cardFee: (json['CARD_FEE'] as num?)?.toDouble() ?? 0.0, cardFee: (json['CARD_FEE'] as num?)?.toDouble() ?? 0.0,
total: (json['TOTAL'] as num?)?.toDouble() ?? 0.0, total: (json['TOTAL'] as num?)?.toDouble() ?? 0.0,
@ -117,13 +120,14 @@ class StripeService {
required double subtotal, required double subtotal,
required double tax, required double tax,
double tip = 0.0, double tip = 0.0,
double deliveryFee = 0.0,
}) { }) {
const customerFeePercent = 0.05; // 5% Payfrit fee const customerFeePercent = 0.05; // 5% Payfrit fee
const cardFeePercent = 0.029; // 2.9% Stripe fee const cardFeePercent = 0.029; // 2.9% Stripe fee
const cardFeeFixed = 0.30; // $0.30 Stripe fixed fee const cardFeeFixed = 0.30; // $0.30 Stripe fixed fee
final payfritFee = subtotal * customerFeePercent; final payfritFee = subtotal * customerFeePercent;
final totalBeforeCardFee = subtotal + tax + tip + payfritFee; final totalBeforeCardFee = subtotal + tax + tip + deliveryFee + payfritFee;
final cardFee = (totalBeforeCardFee * cardFeePercent) + cardFeeFixed; final cardFee = (totalBeforeCardFee * cardFeePercent) + cardFeeFixed;
final total = totalBeforeCardFee + cardFee; final total = totalBeforeCardFee + cardFee;
@ -131,6 +135,7 @@ class StripeService {
subtotal: subtotal, subtotal: subtotal,
tax: tax, tax: tax,
tip: tip, tip: tip,
deliveryFee: deliveryFee,
payfritFee: payfritFee, payfritFee: payfritFee,
cardFee: cardFee, cardFee: cardFee,
total: total, total: total,