payfrit-beacon-ios/_backup/Views/ScannerScreen.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

225 lines
7.7 KiB
Swift

import SwiftUI
import CoreLocation
struct ScannerScreen: View {
@State private var beacons: [Beacon] = []
@State private var selectedBeacon: Beacon?
@State private var isLoading = true
// Scanner state
@StateObject private var scanner = ScannerViewModel()
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Beacon selector
if isLoading {
ProgressView()
.padding()
} else {
Picker("Select Beacon", selection: $selectedBeacon) {
Text("Choose a beacon...").tag(nil as Beacon?)
ForEach(beacons) { beacon in
Text(beacon.name).tag(beacon as Beacon?)
}
}
.pickerStyle(.menu)
.padding()
}
Divider()
// Scanner display
VStack(spacing: 24) {
Spacer()
// Status indicator
ZStack {
Circle()
.fill(scanner.statusColor.opacity(0.15))
.frame(width: 160, height: 160)
Circle()
.fill(scanner.statusColor.opacity(0.3))
.frame(width: 120, height: 120)
Image(systemName: scanner.statusIcon)
.font(.system(size: 48))
.foregroundColor(scanner.statusColor)
}
Text(scanner.statusText)
.font(.title3.bold())
if scanner.isScanning {
VStack(spacing: 8) {
if scanner.rssi != 0 {
HStack {
Text("RSSI:")
.foregroundColor(.secondary)
Text("\(scanner.rssi) dBm")
.font(.system(.body, design: .monospaced))
.bold()
}
HStack {
Text("Samples:")
.foregroundColor(.secondary)
Text("\(scanner.sampleCount)/\(scanner.requiredSamples)")
.font(.system(.body, design: .monospaced))
}
// Signal strength bar
SignalStrengthBar(rssi: scanner.rssi)
.frame(height: 20)
.padding(.horizontal, 40)
} else {
Text("Searching for beacon signal...")
.foregroundColor(.secondary)
}
}
}
Spacer()
// Start/Stop button
Button {
if scanner.isScanning {
scanner.stop()
} else if let beacon = selectedBeacon {
scanner.start(uuid: beacon.uuid)
}
} label: {
HStack {
Image(systemName: scanner.isScanning ? "stop.fill" : "play.fill")
Text(scanner.isScanning ? "Stop Scanning" : "Start Scanning")
}
.font(.headline)
.frame(maxWidth: .infinity, minHeight: 50)
}
.buttonStyle(.borderedProminent)
.tint(scanner.isScanning ? .red : .payfritGreen)
.disabled(selectedBeacon == nil && !scanner.isScanning)
.padding(.horizontal, 24)
.padding(.bottom, 24)
}
}
.navigationTitle("Beacon Scanner")
}
.task {
do {
beacons = try await APIService.shared.listBeacons()
} catch {
// Silently fail user can still see the scanner
}
isLoading = false
}
.onChange(of: selectedBeacon) { _ in
if scanner.isScanning {
scanner.stop()
}
}
}
}
// MARK: - Scanner ViewModel
@MainActor
final class ScannerViewModel: ObservableObject {
@Published var isScanning = false
@Published var statusText = "Select a beacon to scan"
@Published var statusColor: Color = .secondary
@Published var statusIcon = "sensor.tag.radiowaves.forward.fill"
@Published var rssi: Int = 0
@Published var sampleCount = 0
let requiredSamples = 5
private var beaconScanner: BeaconScanner?
func start(uuid: String) {
beaconScanner?.dispose()
beaconScanner = BeaconScanner(
targetUUID: uuid,
onBeaconDetected: { [weak self] avgRssi in
self?.statusText = "Beacon Detected! (avg \(Int(avgRssi)) dBm)"
self?.statusColor = .green
self?.statusIcon = "checkmark.circle.fill"
},
onRSSIUpdate: { [weak self] currentRssi, samples in
self?.rssi = currentRssi
self?.sampleCount = samples
},
onBluetoothOff: { [weak self] in
self?.statusText = "Bluetooth is OFF"
self?.statusColor = .orange
self?.statusIcon = "bluetooth.slash"
},
onPermissionDenied: { [weak self] in
self?.statusText = "Location Permission Denied"
self?.statusColor = .red
self?.statusIcon = "location.slash.fill"
self?.isScanning = false
},
onError: { [weak self] message in
self?.statusText = message
self?.statusColor = .red
self?.statusIcon = "exclamationmark.triangle.fill"
self?.isScanning = false
}
)
beaconScanner?.startScanning()
isScanning = true
statusText = "Scanning..."
statusColor = .blue
statusIcon = "antenna.radiowaves.left.and.right"
rssi = 0
sampleCount = 0
}
func stop() {
beaconScanner?.dispose()
beaconScanner = nil
isScanning = false
statusText = "Select a beacon to scan"
statusColor = .secondary
statusIcon = "sensor.tag.radiowaves.forward.fill"
rssi = 0
sampleCount = 0
}
deinit {
// Ensure cleanup if view is removed while scanning
// Note: deinit runs on main actor since class is @MainActor
}
}
// MARK: - Signal Strength Bar
struct SignalStrengthBar: View {
let rssi: Int
private var strength: Double {
// Map RSSI from -100..-30 to 0..1
let clamped = max(-100, min(-30, rssi))
return Double(clamped + 100) / 70.0
}
private var barColor: Color {
if strength > 0.7 { return .green }
if strength > 0.4 { return .yellow }
return .red
}
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.2))
RoundedRectangle(cornerRadius: 4)
.fill(barColor)
.frame(width: geo.size.width * strength)
}
}
}
}