diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d0c77f5..34ab1e1 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,17 +2,18 @@
-
-
-
+
+
+
-
+
+
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 7e63138..151846f 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -53,5 +53,20 @@
UIApplicationSupportsIndirectInputEvents
+ NSBluetoothAlwaysUsageDescription
+ Payfrit uses Bluetooth to detect nearby restaurant tables for dine-in ordering.
+ NSBluetoothPeripheralUsageDescription
+ Payfrit uses Bluetooth to detect nearby restaurant tables for dine-in ordering.
+ NSLocationWhenInUseUsageDescription
+ Payfrit uses your location to find nearby restaurants and for beacon detection.
+ NSLocationAlwaysUsageDescription
+ Payfrit uses your location to find nearby restaurants and detect when you're at a table.
+ NSLocationAlwaysAndWhenInUseUsageDescription
+ Payfrit uses your location to find nearby restaurants and detect when you're at a table.
+ UIBackgroundModes
+
+ bluetooth-central
+ location
+
diff --git a/lib/models/cart.dart b/lib/models/cart.dart
index 19eafa4..256cda8 100644
--- a/lib/models/cart.dart
+++ b/lib/models/cart.dart
@@ -4,8 +4,9 @@ class Cart {
final int userId;
final int businessId;
final double businessDeliveryMultiplier;
+ final double businessDeliveryFee; // The business's standard delivery fee (for preview)
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? addressId;
final int? paymentId;
@@ -22,6 +23,7 @@ class Cart {
required this.userId,
required this.businessId,
required this.businessDeliveryMultiplier,
+ required this.businessDeliveryFee,
required this.orderTypeId,
required this.deliveryFee,
required this.statusId,
@@ -45,6 +47,7 @@ class Cart {
userId: _parseInt(order["OrderUserID"]) ?? 0,
businessId: _parseInt(order["OrderBusinessID"]) ?? 0,
businessDeliveryMultiplier: _parseDouble(order["OrderBusinessDeliveryMultiplier"]) ?? 0.0,
+ businessDeliveryFee: _parseDouble(order["BusinessDeliveryFee"]) ?? 0.0,
orderTypeId: _parseInt(order["OrderTypeID"]) ?? 0,
deliveryFee: _parseDouble(order["OrderDeliveryFee"]) ?? 0.0,
statusId: _parseInt(order["OrderStatusID"]) ?? 0,
diff --git a/lib/screens/beacon_scan_screen.dart b/lib/screens/beacon_scan_screen.dart
index 7874a75..6318ec1 100644
--- a/lib/screens/beacon_scan_screen.dart
+++ b/lib/screens/beacon_scan_screen.dart
@@ -74,6 +74,26 @@ class _BeaconScanScreenState extends State with SingleTickerPr
print('[BeaconScan] ✅ Permissions GRANTED');
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
setState(() => _status = 'Loading beacon data...');
@@ -93,8 +113,8 @@ class _BeaconScanScreenState extends State with SingleTickerPr
}
if (_uuidToBeaconId.isEmpty) {
- print('[BeaconScan] ⚠️ No beacons in database, going to order type select');
- if (mounted) _navigateToOrderTypeSelect();
+ print('[BeaconScan] ⚠️ No beacons in database, going to restaurant select');
+ if (mounted) _navigateToRestaurantSelect();
return;
}
@@ -131,16 +151,15 @@ class _BeaconScanScreenState extends State with SingleTickerPr
return;
}
- // Perform up to 5 scans of 2 seconds each, but exit early if readings are consistent
- print('[BeaconScan] 🔄 Starting scan cycles (max 5 x 2 seconds, early exit if stable)');
+ // Perform scan cycles - always complete all cycles for dine-in beacon detection
+ print('[BeaconScan] 🔄 Starting scan cycles');
- bool earlyExit = false;
for (int scanCycle = 1; scanCycle <= 3; scanCycle++) {
// Update status message for each cycle
if (mounted) {
setState(() => _status = _scanMessages[scanCycle - 1]);
}
- print('[BeaconScan] ----- Scan cycle $scanCycle/5 -----');
+ print('[BeaconScan] ----- Scan cycle $scanCycle/3 -----');
StreamSubscription? subscription;
@@ -165,26 +184,17 @@ class _BeaconScanScreenState extends State with SingleTickerPr
}
});
- // Wait 2 seconds for this scan cycle to collect beacon data
- await Future.delayed(const Duration(milliseconds: 1500));
+ // Wait for this scan cycle to collect beacon data
+ await Future.delayed(const Duration(milliseconds: 2000));
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
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;
@@ -232,9 +242,15 @@ class _BeaconScanScreenState extends State with SingleTickerPr
print('[BeaconScan] ==========================');
if (beaconScores.isEmpty) {
- setState(() => _status = 'No beacons nearby');
- await Future.delayed(const Duration(milliseconds: 800));
- if (mounted) _navigateToOrderTypeSelect();
+ // No Payfrit beacons found - stop scanning and go to business list
+ print('[BeaconScan] 🚫 No Payfrit beacons found, navigating to restaurant select');
+ setState(() {
+ _scanning = false;
+ _status = 'No nearby tables detected';
+ });
+ await Future.delayed(const Duration(milliseconds: 500));
+ if (mounted) _navigateToRestaurantSelect();
+ return;
} else {
// Find beacon with highest average RSSI and minimum detections
final best = _findBestBeacon(beaconScores);
@@ -244,17 +260,23 @@ class _BeaconScanScreenState extends State with SingleTickerPr
await _autoSelectBusinessFromBeacon(best.beaconId);
} else {
print('[BeaconScan] ⚠️ No beacon met minimum confidence threshold');
- setState(() => _status = 'No strong beacon signal');
- await Future.delayed(const Duration(milliseconds: 800));
- if (mounted) _navigateToOrderTypeSelect();
+ setState(() {
+ _scanning = false;
+ _status = 'No strong beacon signal';
+ });
+ await Future.delayed(const Duration(milliseconds: 500));
+ if (mounted) _navigateToRestaurantSelect();
}
}
} catch (e) {
print('[BeaconScan] ❌ ERROR during scan: $e');
print('[BeaconScan] Stack trace: ${StackTrace.current}');
if (mounted) {
- setState(() => _status = 'Scan error - continuing to manual selection');
- await Future.delayed(const Duration(seconds: 1));
+ setState(() {
+ _scanning = false;
+ _status = 'Scan error - continuing to manual selection';
+ });
+ await Future.delayed(const Duration(milliseconds: 500));
if (mounted) _navigateToRestaurantSelect();
}
}
@@ -275,6 +297,9 @@ class _BeaconScanScreenState extends State with SingleTickerPr
servicePointName: mapping.servicePointName,
);
+ // Set order type to dine-in since beacon was detected
+ appState.setOrderType(OrderType.dineIn);
+
// Update API business ID for headers
Api.setBusinessId(mapping.businessId);
@@ -384,10 +409,6 @@ class _BeaconScanScreenState extends State with SingleTickerPr
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
}
- void _navigateToOrderTypeSelect() {
- Navigator.of(context).pushReplacementNamed(AppRoutes.orderTypeSelect);
- }
-
void _retryPermissions() async {
await BeaconPermissions.openSettings();
}
diff --git a/lib/screens/cart_view_screen.dart b/lib/screens/cart_view_screen.dart
index 24d390b..56f6977 100644
--- a/lib/screens/cart_view_screen.dart
+++ b/lib/screens/cart_view_screen.dart
@@ -34,11 +34,46 @@ class _CartViewScreenState extends State {
String? _error;
Map _menuItemsById = {};
+ // Order type selection (for delivery/takeaway - when orderTypeId is 0)
+ // 2 = Takeaway, 3 = Delivery
+ int? _selectedOrderType;
+
+ // Delivery address selection
+ List _addresses = [];
+ DeliveryAddress? _selectedAddress;
+ bool _loadingAddresses = false;
+
// Tip options as percentages (null = custom)
static const List _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];
@@ -55,6 +90,7 @@ class _CartViewScreenState extends State {
subtotal: 0,
tax: 0,
tip: 0,
+ deliveryFee: 0,
payfritFee: 0,
cardFee: 0,
total: 0,
@@ -64,6 +100,7 @@ class _CartViewScreenState extends State {
subtotal: _cart!.subtotal,
tax: _cart!.tax,
tip: _tipAmount,
+ deliveryFee: _effectiveDeliveryFee,
);
}
@@ -108,6 +145,11 @@ class _CartViewScreenState extends State {
// 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')) {
@@ -126,6 +168,28 @@ class _CartViewScreenState extends State {
}
}
+ Future _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 _removeLineItem(OrderLineItem lineItem) async {
try {
final appState = context.read();
@@ -241,9 +305,41 @@ class _CartViewScreenState extends State {
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);
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,
@@ -666,6 +762,48 @@ class _CartViewScreenState extends State {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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
const Text(
"Add a tip",
@@ -726,10 +864,10 @@ class _CartViewScreenState extends State {
const SizedBox(height: 6),
// Tax
_buildSummaryRow("Tax (8.25%)", fees.tax),
- // Only show delivery fee for delivery orders (OrderTypeID = 3)
- if (_cart!.deliveryFee > 0 && _cart!.orderTypeId == 3) ...[
+ // Show delivery fee: either the confirmed fee (orderTypeId == 3) or preview when Delivery selected
+ if (_effectiveDeliveryFee > 0) ...[
const SizedBox(height: 6),
- _buildSummaryRow("Delivery Fee", _cart!.deliveryFee),
+ _buildSummaryRow("Delivery Fee", _effectiveDeliveryFee),
],
// Tip
if (_tipAmount > 0) ...[
@@ -769,7 +907,7 @@ class _CartViewScreenState extends State {
SizedBox(
width: double.infinity,
child: ElevatedButton(
- onPressed: (_cart!.itemCount > 0 && !_isProcessingPayment)
+ onPressed: (_canProceedToPayment && !_isProcessingPayment)
? _processPaymentAndSubmit
: null,
style: ElevatedButton.styleFrom(
@@ -788,7 +926,9 @@ class _CartViewScreenState extends State {
),
)
: 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),
),
),
@@ -799,6 +939,295 @@ class _CartViewScreenState extends State {
);
}
+ 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 _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(
+ 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(
+ 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}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
diff --git a/lib/screens/menu_browse_screen.dart b/lib/screens/menu_browse_screen.dart
index 0291028..3868211 100644
--- a/lib/screens/menu_browse_screen.dart
+++ b/lib/screens/menu_browse_screen.dart
@@ -205,7 +205,8 @@ class _MenuBrowseScreenState extends State {
businessName,
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(
appState.selectedServicePointName!,
style: const TextStyle(
@@ -219,30 +220,32 @@ class _MenuBrowseScreenState extends State {
],
),
actions: [
- IconButton(
- icon: const Icon(Icons.table_restaurant),
- tooltip: "Change Table",
- onPressed: () {
- // Prevent changing tables if there's an active order (dine and dash prevention)
- if (appState.activeOrderId != null) {
- showDialog(
- context: context,
- builder: (context) => AlertDialog(
- title: const Text("Cannot Change Table"),
- content: const Text("Please complete or cancel your current order before changing tables."),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(context),
- child: const Text("OK"),
- ),
- ],
- ),
- );
- return;
- }
- Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
- },
- ),
+ // Only show table change button for dine-in orders
+ if (appState.isDineIn)
+ IconButton(
+ icon: const Icon(Icons.table_restaurant),
+ tooltip: "Change Table",
+ onPressed: () {
+ // Prevent changing tables if there's an active order (dine and dash prevention)
+ if (appState.activeOrderId != null) {
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text("Cannot Change Table"),
+ content: const Text("Please complete or cancel your current order before changing tables."),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text("OK"),
+ ),
+ ],
+ ),
+ );
+ return;
+ }
+ Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
+ },
+ ),
IconButton(
icon: Badge(
label: Text("${appState.cartItemCount}"),
@@ -743,11 +746,13 @@ class _MenuBrowseScreenState extends State {
// Get or create cart
Cart cart;
if (appState.cartOrderId == null) {
+ // Determine order type: 1=dine-in (beacon), 0=undecided (no beacon, will choose at checkout)
+ final orderTypeId = appState.isDineIn ? 1 : 0;
cart = await Api.getOrCreateCart(
userId: _userId!,
businessId: _businessId!,
servicePointId: _servicePointId!,
- orderTypeId: 1, // Dine-in
+ orderTypeId: orderTypeId,
);
appState.setCartOrder(
orderId: cart.orderId,
diff --git a/lib/screens/restaurant_select_screen.dart b/lib/screens/restaurant_select_screen.dart
index 95a11d3..25be821 100644
--- a/lib/screens/restaurant_select_screen.dart
+++ b/lib/screens/restaurant_select_screen.dart
@@ -73,17 +73,46 @@ class _RestaurantSelectScreenState extends State {
}
void _toggleExpand(Restaurant restaurant) {
- setState(() {
- if (_expandedBusinessId == restaurant.businessId) {
- // Collapse
- _expandedBusinessId = null;
- } else {
- // Expand this one
- _expandedBusinessId = restaurant.businessId;
- // Start loading menu if not cached
- _loadMenuForBusiness(restaurant.businessId);
+ // For delivery/takeaway flow (no beacon), go directly to menu
+ // No need to select table - just pick the first service point
+ _navigateToMenu(restaurant);
+ }
+
+ void _navigateToMenu(Restaurant restaurant) async {
+ // Load service points if not cached
+ if (!_servicePointCache.containsKey(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.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
diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart
index 4b47ccd..597a160 100644
--- a/lib/screens/splash_screen.dart
+++ b/lib/screens/splash_screen.dart
@@ -149,6 +149,16 @@ class _SplashScreenState extends State with TickerProviderStateMix
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
try {
_uuidToBeaconId = await Api.listAllBeacons();
diff --git a/lib/services/api.dart b/lib/services/api.dart
index ce398dd..a93301b 100644
--- a/lib/services/api.dart
+++ b/lib/services/api.dart
@@ -372,10 +372,37 @@ class Api {
);
final j = _requireJson(raw, "SetLineItem");
+ print('[API] setLineItem response: OK=${j["OK"]}, ERROR=${_err(j)}, orderId=$orderId, itemId=$itemId, parentLI=$parentOrderLineItemId');
if (!_ok(j)) {
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 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;
}
+
+ /// Check if user has pending orders at a business (for pickup detection)
+ static Future> 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 ? e : (e as Map).cast();
+ return PendingOrder.fromJson(item);
+ }).toList();
+ }
+
+ /// Get user's delivery addresses
+ static Future> 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 ? e : (e as Map).cast();
+ return DeliveryAddress.fromJson(item);
+ }).toList();
+ }
+
+ /// Add a new delivery address
+ static Future 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? ?? {};
+ return DeliveryAddress.fromJson(addressData);
+ }
}
class BeaconBusinessMapping {
@@ -512,3 +610,95 @@ class BeaconBusinessMapping {
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 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 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,
+ );
+ }
+}
diff --git a/lib/services/beacon_permissions.dart b/lib/services/beacon_permissions.dart
index cbd4746..4317b6f 100644
--- a/lib/services/beacon_permissions.dart
+++ b/lib/services/beacon_permissions.dart
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
+import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart';
class BeaconPermissions {
static Future requestPermissions() async {
@@ -39,6 +40,79 @@ class BeaconPermissions {
}
}
+ /// Check if Bluetooth is enabled - returns current state without prompting
+ static Future 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 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 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 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 checkPermissions() async {
final locationStatus = await Permission.locationWhenInUse.status;
diff --git a/lib/services/stripe_service.dart b/lib/services/stripe_service.dart
index d1d25cc..bc42e8c 100644
--- a/lib/services/stripe_service.dart
+++ b/lib/services/stripe_service.dart
@@ -23,6 +23,7 @@ class FeeBreakdown {
final double subtotal;
final double tax;
final double tip;
+ final double deliveryFee;
final double payfritFee;
final double cardFee;
final double total;
@@ -31,6 +32,7 @@ class FeeBreakdown {
required this.subtotal,
required this.tax,
required this.tip,
+ required this.deliveryFee,
required this.payfritFee,
required this.cardFee,
required this.total,
@@ -41,6 +43,7 @@ class FeeBreakdown {
subtotal: (json['SUBTOTAL'] as num?)?.toDouble() ?? 0.0,
tax: (json['TAX'] 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,
cardFee: (json['CARD_FEE'] as num?)?.toDouble() ?? 0.0,
total: (json['TOTAL'] as num?)?.toDouble() ?? 0.0,
@@ -117,13 +120,14 @@ class StripeService {
required double subtotal,
required double tax,
double tip = 0.0,
+ double deliveryFee = 0.0,
}) {
const customerFeePercent = 0.05; // 5% Payfrit fee
const cardFeePercent = 0.029; // 2.9% Stripe fee
const cardFeeFixed = 0.30; // $0.30 Stripe fixed fee
final payfritFee = subtotal * customerFeePercent;
- final totalBeforeCardFee = subtotal + tax + tip + payfritFee;
+ final totalBeforeCardFee = subtotal + tax + tip + deliveryFee + payfritFee;
final cardFee = (totalBeforeCardFee * cardFeePercent) + cardFeeFixed;
final total = totalBeforeCardFee + cardFee;
@@ -131,6 +135,7 @@ class StripeService {
subtotal: subtotal,
tax: tax,
tip: tip,
+ deliveryFee: deliveryFee,
payfritFee: payfritFee,
cardFee: cardFee,
total: total,