- App icon now uses white bg + PAYFRIT text + bluetooth beacon icon (matches Android) - AccentColor set to Payfrit Green (#22B24B) - Added BrandColors.swift with full Android color palette parity - All views updated: payfritGreen replaces .blue, proper status colors throughout - Signal strength, beacon type badges, QR scanner corners all use brand colors - DevBanner uses warningOrange matching Android's #FF9800 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(.payfritGreen)
|
|
|
|
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(.errorRed)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|