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:
parent
ef5609ba57
commit
1d08b18568
11 changed files with 861 additions and 79 deletions
|
|
@ -2,17 +2,18 @@
|
|||
<!-- Internet permission for API calls -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- Beacon scanning permissions -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<!-- Beacon scanning permissions (legacy, still needed by beacon library) -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<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_CONNECT" />
|
||||
|
||||
<!-- Location permissions for beacon ranging -->
|
||||
<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_BACKGROUND_LOCATION" />
|
||||
|
||||
<!-- Foreground service for background beacon monitoring -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
|
|
|||
|
|
@ -53,5 +53,20 @@
|
|||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,26 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> 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<BeaconScanScreen> 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<BeaconScanScreen> 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<RangingResult>? subscription;
|
||||
|
||||
|
|
@ -165,26 +184,17 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> 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<BeaconScanScreen> 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<BeaconScanScreen> 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<BeaconScanScreen> 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<BeaconScanScreen> with SingleTickerPr
|
|||
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
|
||||
}
|
||||
|
||||
void _navigateToOrderTypeSelect() {
|
||||
Navigator.of(context).pushReplacementNamed(AppRoutes.orderTypeSelect);
|
||||
}
|
||||
|
||||
void _retryPermissions() async {
|
||||
await BeaconPermissions.openSettings();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,11 +34,46 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
String? _error;
|
||||
Map<int, MenuItem> _menuItemsById = {};
|
||||
|
||||
// Order type selection (for delivery/takeaway - when orderTypeId is 0)
|
||||
// 2 = Takeaway, 3 = Delivery
|
||||
int? _selectedOrderType;
|
||||
|
||||
// Delivery address selection
|
||||
List<DeliveryAddress> _addresses = [];
|
||||
DeliveryAddress? _selectedAddress;
|
||||
bool _loadingAddresses = false;
|
||||
|
||||
// Tip options as percentages (null = custom)
|
||||
static const List<int?> _tipPercentages = [0, 15, 18, 20, null];
|
||||
int _selectedTipIndex = 1; // Default to 15%
|
||||
int _customTipPercent = 25; // Default custom tip if selected
|
||||
|
||||
/// Whether the cart needs order type selection (delivery/takeaway)
|
||||
bool get _needsOrderTypeSelection => _cart != null && _cart!.orderTypeId == 0;
|
||||
|
||||
/// Whether delivery is selected and needs address
|
||||
bool get _needsDeliveryAddress => _selectedOrderType == 3;
|
||||
|
||||
/// Whether we can proceed to payment (order type selected if needed)
|
||||
bool get _canProceedToPayment {
|
||||
if (_cart == null || _cart!.itemCount == 0) return false;
|
||||
if (_needsOrderTypeSelection && _selectedOrderType == null) return false;
|
||||
if (_needsDeliveryAddress && _selectedAddress == null) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Get the effective delivery fee to display and charge
|
||||
/// - If order type is already set to delivery (3), use the order's delivery fee
|
||||
/// - If user selected delivery but hasn't confirmed, show the business's preview fee
|
||||
double get _effectiveDeliveryFee {
|
||||
if (_cart == null) return 0.0;
|
||||
// Order already confirmed as delivery
|
||||
if (_cart!.orderTypeId == 3) return _cart!.deliveryFee;
|
||||
// User selected delivery (preview)
|
||||
if (_selectedOrderType == 3) return _cart!.businessDeliveryFee;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
double get _tipAmount {
|
||||
if (_cart == null) return 0.0;
|
||||
final percent = _tipPercentages[_selectedTipIndex];
|
||||
|
|
@ -55,6 +90,7 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
subtotal: 0,
|
||||
tax: 0,
|
||||
tip: 0,
|
||||
deliveryFee: 0,
|
||||
payfritFee: 0,
|
||||
cardFee: 0,
|
||||
total: 0,
|
||||
|
|
@ -64,6 +100,7 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
subtotal: _cart!.subtotal,
|
||||
tax: _cart!.tax,
|
||||
tip: _tipAmount,
|
||||
deliveryFee: _effectiveDeliveryFee,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -108,6 +145,11 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
|
||||
// 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<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 {
|
||||
try {
|
||||
final appState = context.read<AppState>();
|
||||
|
|
@ -241,9 +305,41 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
|||
|
||||
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<CartViewScreen> {
|
|||
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<CartViewScreen> {
|
|||
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<CartViewScreen> {
|
|||
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<CartViewScreen> {
|
|||
),
|
||||
)
|
||||
: 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<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}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
|
|
|
|||
|
|
@ -205,7 +205,8 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
|||
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<MenuBrowseScreen> {
|
|||
],
|
||||
),
|
||||
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<MenuBrowseScreen> {
|
|||
// 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,
|
||||
|
|
|
|||
|
|
@ -73,17 +73,46 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
|
|||
}
|
||||
|
||||
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>();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@ class _SplashScreenState extends State<SplashScreen> 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();
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
|
@ -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<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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
final locationStatus = await Permission.locationWhenInUse.status;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue