payfrit-beacon-ios/PayfritBeacon/ScanView.swift
John Pinkyfloyd 8c2320da44 Add ios-marketing idiom, iPad orientations, launch screen
- 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>
2026-02-10 19:38:11 -08:00

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]))"
}
}