From 1d08b185689b5094d63b7964572c380bd102ffdb Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Thu, 8 Jan 2026 15:08:50 -0800 Subject: [PATCH] Fix beacon scanning and Bluetooth permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- android/app/src/main/AndroidManifest.xml | 9 +- ios/Runner/Info.plist | 15 + lib/models/cart.dart | 5 +- lib/screens/beacon_scan_screen.dart | 83 ++-- lib/screens/cart_view_screen.dart | 439 +++++++++++++++++++++- lib/screens/menu_browse_screen.dart | 57 +-- lib/screens/restaurant_select_screen.dart | 49 ++- lib/screens/splash_screen.dart | 10 + lib/services/api.dart | 192 +++++++++- lib/services/beacon_permissions.dart | 74 ++++ lib/services/stripe_service.dart | 7 +- 11 files changed, 861 insertions(+), 79 deletions(-) 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,