100% fresh codebase — no legacy code carried over. Built against the Android beacon app as the behavioral spec. Architecture: - App: SwiftUI @main, AppState-driven navigation, Keychain storage - Views: LoginView (OTP + biometric), BusinessListView, ScanView (provisioning hub) - Models: Business, ServicePoint, BeaconConfig, BeaconType, DiscoveredBeacon - Services: APIClient (actor, async/await), BLEManager (CoreBluetooth scanner) - Provisioners: KBeacon, DXSmart (2-step auth + flashing), BlueCharm - Utils: UUIDFormatting, BeaconBanList, BeaconShardPool (64 shards) Matches Android feature parity: - 4-screen flow: Login → Business Select → Scan/Provision - 3 beacon types with correct GATT protocols and timeouts - Namespace allocation via beacon-sharding API - Smart service point naming (Table N auto-increment) - DXSmart special flow (connect → flash → user confirms → write) - Biometric auth, dev/prod build configs, DEV banner overlay Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
141 lines
4.3 KiB
Swift
141 lines
4.3 KiB
Swift
import SwiftUI
|
|
import LocalAuthentication
|
|
|
|
/// OTP Login screen + biometric auth (matches Android LoginActivity)
|
|
struct LoginView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
|
|
@State private var phone = ""
|
|
@State private var otpCode = ""
|
|
@State private var otpUUID = ""
|
|
@State private var showOTPField = false
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 32) {
|
|
Spacer()
|
|
|
|
// Logo
|
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
|
.font(.system(size: 64))
|
|
.foregroundStyle(.blue)
|
|
|
|
Text("Payfrit Beacon")
|
|
.font(.title.bold())
|
|
|
|
Text("Provision beacons for your business")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
// Phone input
|
|
VStack(spacing: 16) {
|
|
TextField("Phone number", text: $phone)
|
|
.keyboardType(.phonePad)
|
|
.textContentType(.telephoneNumber)
|
|
.padding()
|
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
|
|
.disabled(showOTPField)
|
|
|
|
if showOTPField {
|
|
TextField("Enter OTP code", text: $otpCode)
|
|
.keyboardType(.numberPad)
|
|
.textContentType(.oneTimeCode)
|
|
.padding()
|
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
if let error = errorMessage {
|
|
Text(error)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
|
|
Button(action: handleAction) {
|
|
if isLoading {
|
|
ProgressView()
|
|
.tint(.white)
|
|
} else {
|
|
Text(showOTPField ? "Verify OTP" : "Send OTP")
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.large)
|
|
.disabled(isLoading || (showOTPField ? otpCode.isEmpty : phone.isEmpty))
|
|
}
|
|
.padding(.horizontal, 32)
|
|
|
|
Spacer()
|
|
Spacer()
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
.task {
|
|
// Try biometric login on appear
|
|
await tryBiometricLogin()
|
|
}
|
|
}
|
|
|
|
private func handleAction() {
|
|
Task {
|
|
if showOTPField {
|
|
await verifyOTP()
|
|
} else {
|
|
await sendOTP()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendOTP() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
do {
|
|
let uuid = try await APIClient.shared.sendOTP(phone: phone)
|
|
otpUUID = uuid
|
|
showOTPField = true
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
private func verifyOTP() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
do {
|
|
let result = try await APIClient.shared.verifyOTP(uuid: otpUUID, code: otpCode)
|
|
appState.didLogin(token: result.token, userId: result.userId)
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
private func tryBiometricLogin() async {
|
|
guard let session = SecureStorage.loadSession() else { return }
|
|
|
|
let context = LAContext()
|
|
var authError: NSError?
|
|
|
|
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &authError) else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
let success = try await context.evaluatePolicy(
|
|
.deviceOwnerAuthenticationWithBiometrics,
|
|
localizedReason: "Log in to Payfrit Beacon"
|
|
)
|
|
if success {
|
|
appState.didLogin(token: session.token, userId: session.userId)
|
|
}
|
|
} catch {
|
|
// Biometric failed — show manual login
|
|
}
|
|
}
|
|
}
|