- Fixed App Store icon display with ios-marketing idiom - Added iPad orientation support for multitasking - Added UILaunchScreen for iPad requirements - Removed unused BLE permissions and files from build Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
490 lines
18 KiB
Swift
490 lines
18 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - ScanView (Beacon Provisioning)
|
|
|
|
struct ScanView: View {
|
|
let businessId: Int
|
|
let businessName: String
|
|
var onBack: () -> Void
|
|
|
|
@StateObject private var bleScanner = BLEBeaconScanner()
|
|
@StateObject private var provisioner = BeaconProvisioner()
|
|
|
|
@State private var servicePoints: [ServicePoint] = []
|
|
@State private var nextTableNumber: Int = 1
|
|
@State private var provisionedCount: Int = 0
|
|
|
|
// UI State
|
|
@State private var snackMessage: String?
|
|
@State private var showAssignSheet = false
|
|
@State private var selectedBeacon: DiscoveredBeacon?
|
|
@State private var assignName = ""
|
|
@State private var isProvisioning = false
|
|
@State private var provisioningProgress = ""
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Toolbar
|
|
HStack {
|
|
Button(action: onBack) {
|
|
Image(systemName: "chevron.left")
|
|
.font(.title3)
|
|
}
|
|
Text("Beacon Setup")
|
|
.font(.headline)
|
|
Spacer()
|
|
if provisionedCount > 0 {
|
|
Text("\(provisionedCount) done")
|
|
.font(.caption)
|
|
.foregroundColor(.payfritGreen)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.systemBackground))
|
|
|
|
// Info bar
|
|
HStack {
|
|
Text(businessName)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
Spacer()
|
|
Text("Next: Table \(nextTableNumber)")
|
|
.font(.subheadline.bold())
|
|
.foregroundColor(.payfritGreen)
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
|
|
Divider()
|
|
|
|
// Bluetooth status
|
|
if bleScanner.bluetoothState != .poweredOn {
|
|
bluetoothWarning
|
|
}
|
|
|
|
// Beacon list
|
|
if bleScanner.discoveredBeacons.isEmpty {
|
|
Spacer()
|
|
if bleScanner.isScanning {
|
|
VStack(spacing: 12) {
|
|
ProgressView()
|
|
Text("Scanning for beacons...")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
} else {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
|
.font(.largeTitle)
|
|
.foregroundColor(.secondary)
|
|
Text("No beacons found")
|
|
.foregroundColor(.secondary)
|
|
Text("Make sure beacons are powered on\nand in configuration mode")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
Spacer()
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(spacing: 8) {
|
|
ForEach(bleScanner.discoveredBeacons) { beacon in
|
|
beaconRow(beacon)
|
|
.onTapGesture { selectBeacon(beacon) }
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.top, 8)
|
|
}
|
|
}
|
|
|
|
// Bottom action bar
|
|
VStack(spacing: 8) {
|
|
Button(action: startScan) {
|
|
HStack {
|
|
if bleScanner.isScanning {
|
|
ProgressView()
|
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
.scaleEffect(0.8)
|
|
}
|
|
Text(bleScanner.isScanning ? "Scanning..." : "Scan for Beacons")
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.background(bleScanner.isScanning ? Color.gray : Color.payfritGreen)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(8)
|
|
}
|
|
.disabled(bleScanner.isScanning || bleScanner.bluetoothState != .poweredOn)
|
|
|
|
Button(action: onBack) {
|
|
Text("Done")
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Color.payfritGreen, lineWidth: 1)
|
|
)
|
|
.foregroundColor(.payfritGreen)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.modifier(DevBanner())
|
|
.overlay(snackOverlay, alignment: .bottom)
|
|
.sheet(isPresented: $showAssignSheet) { assignSheet }
|
|
.onAppear { loadServicePoints() }
|
|
}
|
|
|
|
// MARK: - Bluetooth Warning
|
|
|
|
private var bluetoothWarning: some View {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.warningOrange)
|
|
Text(bluetoothMessage)
|
|
.font(.caption)
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
.background(Color.warningOrange.opacity(0.1))
|
|
}
|
|
|
|
private var bluetoothMessage: String {
|
|
switch bleScanner.bluetoothState {
|
|
case .poweredOff:
|
|
return "Bluetooth is turned off"
|
|
case .unauthorized:
|
|
return "Bluetooth permission denied"
|
|
case .unsupported:
|
|
return "Bluetooth not supported"
|
|
default:
|
|
return "Bluetooth not ready"
|
|
}
|
|
}
|
|
|
|
// MARK: - Beacon Row
|
|
|
|
private func beaconRow(_ beacon: DiscoveredBeacon) -> some View {
|
|
HStack(spacing: 12) {
|
|
// Signal strength indicator
|
|
Rectangle()
|
|
.fill(signalColor(beacon.rssi))
|
|
.frame(width: 4)
|
|
.cornerRadius(2)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(beacon.displayName)
|
|
.font(.system(.body, design: .default))
|
|
.lineLimit(1)
|
|
|
|
HStack(spacing: 8) {
|
|
Text(beacon.type.rawValue)
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(beacon.type == .kbeacon ? Color.blue : Color.purple)
|
|
.cornerRadius(4)
|
|
|
|
Text("\(beacon.rssi) dBm")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(12)
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(8)
|
|
.shadow(color: .black.opacity(0.05), radius: 2, y: 1)
|
|
}
|
|
|
|
private func signalColor(_ rssi: Int) -> Color {
|
|
if rssi >= -60 { return .signalStrong }
|
|
if rssi >= -75 { return .signalMedium }
|
|
return .signalWeak
|
|
}
|
|
|
|
// MARK: - Assignment Sheet
|
|
|
|
private var assignSheet: some View {
|
|
NavigationStack {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
if let beacon = selectedBeacon {
|
|
// Beacon info
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Beacon")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
HStack {
|
|
Text(beacon.displayName)
|
|
.font(.headline)
|
|
Spacer()
|
|
Text(beacon.type.rawValue)
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(beacon.type == .kbeacon ? Color.blue : Color.purple)
|
|
.cornerRadius(4)
|
|
}
|
|
Text("Signal: \(beacon.rssi) dBm")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding()
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(8)
|
|
|
|
// Service point name
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Service Point Name")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
TextField("e.g., Table 1", text: $assignName)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.title3)
|
|
}
|
|
|
|
// KBeacon warning
|
|
if beacon.type == .kbeacon {
|
|
HStack {
|
|
Image(systemName: "info.circle.fill")
|
|
.foregroundColor(.infoBlue)
|
|
Text("KBeacon requires their app for provisioning. Config will be copied to clipboard.")
|
|
.font(.caption)
|
|
}
|
|
.padding()
|
|
.background(Color.infoBlue.opacity(0.1))
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
// Provisioning progress
|
|
if isProvisioning {
|
|
HStack {
|
|
ProgressView()
|
|
Text(provisioningProgress)
|
|
.font(.callout)
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
.padding()
|
|
.navigationTitle("Assign Beacon")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
showAssignSheet = false
|
|
selectedBeacon = nil
|
|
}
|
|
.disabled(isProvisioning)
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Save Beacon") {
|
|
saveBeacon()
|
|
}
|
|
.disabled(assignName.trimmingCharacters(in: .whitespaces).isEmpty || isProvisioning)
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium])
|
|
.interactiveDismissDisabled(isProvisioning)
|
|
}
|
|
|
|
// MARK: - Snack Overlay
|
|
|
|
@ViewBuilder
|
|
private var snackOverlay: some View {
|
|
if let message = snackMessage {
|
|
Text(message)
|
|
.font(.callout)
|
|
.foregroundColor(.white)
|
|
.padding()
|
|
.background(Color(.darkGray))
|
|
.cornerRadius(8)
|
|
.padding(.bottom, 100)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
.onAppear {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
|
withAnimation { snackMessage = nil }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func showSnack(_ message: String) {
|
|
withAnimation { snackMessage = message }
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func loadServicePoints() {
|
|
Task {
|
|
do {
|
|
servicePoints = try await Api.shared.listServicePoints(businessId: businessId)
|
|
// Find next table number
|
|
let maxNumber = servicePoints.compactMap { sp -> Int? in
|
|
guard let match = sp.name.range(of: #"Table\s+(\d+)"#,
|
|
options: [.regularExpression, .caseInsensitive]) else {
|
|
return nil
|
|
}
|
|
let numberStr = sp.name[match].split(separator: " ").last.flatMap { String($0) } ?? ""
|
|
return Int(numberStr)
|
|
}.max() ?? 0
|
|
nextTableNumber = maxNumber + 1
|
|
} catch {
|
|
// Silently continue
|
|
}
|
|
|
|
// Auto-start scan
|
|
startScan()
|
|
}
|
|
}
|
|
|
|
private func startScan() {
|
|
guard bleScanner.isBluetoothReady else {
|
|
showSnack("Bluetooth not available")
|
|
return
|
|
}
|
|
bleScanner.startScanning()
|
|
}
|
|
|
|
private func selectBeacon(_ beacon: DiscoveredBeacon) {
|
|
selectedBeacon = beacon
|
|
assignName = "Table \(nextTableNumber)"
|
|
showAssignSheet = true
|
|
}
|
|
|
|
private func saveBeacon() {
|
|
guard let beacon = selectedBeacon else { return }
|
|
let name = assignName.trimmingCharacters(in: .whitespaces)
|
|
guard !name.isEmpty else { return }
|
|
|
|
isProvisioning = true
|
|
provisioningProgress = "Creating service point..."
|
|
|
|
Task {
|
|
do {
|
|
// 1. Create or get service point
|
|
let servicePoint = try await Api.shared.createServicePoint(businessId: businessId, name: name)
|
|
provisioningProgress = "Getting beacon config..."
|
|
|
|
// 2. Get beacon config from backend
|
|
let config = try await Api.shared.getBeaconConfig(businessId: businessId, servicePointId: servicePoint.servicePointId)
|
|
provisioningProgress = "Provisioning beacon..."
|
|
|
|
// 3. Provision the beacon
|
|
let beaconConfig = BeaconConfig(
|
|
uuid: config.uuid,
|
|
major: config.major,
|
|
minor: config.minor,
|
|
txPower: Int8(config.txPower),
|
|
interval: UInt16(config.interval)
|
|
)
|
|
|
|
if beacon.type == .kbeacon {
|
|
// Copy config to clipboard for KBeacon app
|
|
let clipboardText = """
|
|
UUID: \(formatUuidWithDashes(config.uuid))
|
|
Major: \(config.major)
|
|
Minor: \(config.minor)
|
|
TxPower: \(config.txPower)
|
|
"""
|
|
UIPasteboard.general.string = clipboardText
|
|
showSnack("Config copied! Use KBeacon app to program.")
|
|
|
|
// Register in backend
|
|
try await Api.shared.registerBeaconHardware(
|
|
businessId: businessId,
|
|
servicePointId: servicePoint.servicePointId,
|
|
uuid: config.uuid,
|
|
major: config.major,
|
|
minor: config.minor,
|
|
macAddress: nil
|
|
)
|
|
|
|
finishProvisioning(name: name)
|
|
} else {
|
|
// BlueCharm - provision directly via GATT
|
|
provisioner.provision(beacon: beacon, config: beaconConfig) { result in
|
|
Task { @MainActor in
|
|
switch result {
|
|
case .success:
|
|
// Register in backend
|
|
do {
|
|
try await Api.shared.registerBeaconHardware(
|
|
businessId: businessId,
|
|
servicePointId: servicePoint.servicePointId,
|
|
uuid: config.uuid,
|
|
major: config.major,
|
|
minor: config.minor,
|
|
macAddress: nil
|
|
)
|
|
finishProvisioning(name: name)
|
|
} catch {
|
|
failProvisioning(error.localizedDescription)
|
|
}
|
|
case .failure(let error):
|
|
failProvisioning(error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
failProvisioning(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func finishProvisioning(name: String) {
|
|
isProvisioning = false
|
|
provisioningProgress = ""
|
|
showAssignSheet = false
|
|
selectedBeacon = nil
|
|
|
|
// Update table number
|
|
if let match = name.range(of: #"Table\s+(\d+)"#, options: [.regularExpression, .caseInsensitive]) {
|
|
let numberStr = name[match].split(separator: " ").last.flatMap { String($0) } ?? ""
|
|
if let savedNumber = Int(numberStr), savedNumber >= nextTableNumber {
|
|
nextTableNumber = savedNumber + 1
|
|
} else {
|
|
nextTableNumber += 1
|
|
}
|
|
} else {
|
|
nextTableNumber += 1
|
|
}
|
|
|
|
provisionedCount += 1
|
|
showSnack("Saved \"\(name)\"")
|
|
|
|
// Remove beacon from list
|
|
if let beacon = selectedBeacon {
|
|
bleScanner.discoveredBeacons.removeAll { $0.id == beacon.id }
|
|
}
|
|
}
|
|
|
|
private func failProvisioning(_ error: String) {
|
|
isProvisioning = false
|
|
provisioningProgress = ""
|
|
showSnack("Error: \(error)")
|
|
}
|
|
|
|
private func formatUuidWithDashes(_ raw: String) -> String {
|
|
let clean = raw.replacingOccurrences(of: "-", with: "").uppercased()
|
|
guard clean.count == 32 else { return raw }
|
|
let chars = Array(clean)
|
|
return "\(String(chars[0..<8]))-\(String(chars[8..<12]))-\(String(chars[12..<16]))-\(String(chars[16..<20]))-\(String(chars[20..<32]))"
|
|
}
|
|
}
|