- Flatten project structure: remove Models/, Services/, ViewModels/, Views/ subdirs - Replace APIService actor with simpler Api class, IS_DEV flag controls dev vs prod URL - Rewrite BeaconScanner to use CoreLocation (CLBeaconRegion ranging) instead of CoreBluetooth — iOS blocks iBeacon data from CBCentralManager - Add SVG logo on login page with proper scaling (was showing green square) - Make login page scrollable, add "enter 6-digit code" OTP instruction - Fix text input visibility (white on white) with .foregroundColor(.primary) - Add diagonal orange DEV ribbon banner (lower-left corner), gated on Api.IS_DEV - Update app icon: logo 10% larger, wifi icon closer - Add en.lproj/InfoPlist.strings for display name localization - Fix scan flash: keep isScanning=true until enrichment completes - Add Podfile with SVGKit, Kingfisher, CocoaLumberjack dependencies Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
236 lines
8.1 KiB
Swift
236 lines
8.1 KiB
Swift
import SwiftUI
|
|
import LocalAuthentication
|
|
import SVGKit
|
|
|
|
struct LoginView: View {
|
|
var onLoginSuccess: (String, Int) -> Void
|
|
|
|
@State private var otpUuid: String?
|
|
@State private var phone = ""
|
|
@State private var otp = ""
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
@State private var showPhoneInput = false
|
|
@State private var showOtpInput = false
|
|
@State private var hasCheckedAuth = false
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
Spacer(minLength: 80)
|
|
|
|
// SVG Logo
|
|
SVGLogoView(width: 200)
|
|
.frame(width: 200, height: 117)
|
|
|
|
Text("Payfrit Beacon")
|
|
.font(.title2.bold())
|
|
.foregroundColor(.payfritGreen)
|
|
|
|
if isLoading {
|
|
ProgressView()
|
|
.padding()
|
|
}
|
|
|
|
if let error = errorMessage {
|
|
Text(error)
|
|
.foregroundColor(.errorRed)
|
|
.font(.callout)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
if showPhoneInput {
|
|
VStack(spacing: 12) {
|
|
TextField("Phone number", text: $phone)
|
|
.keyboardType(.phonePad)
|
|
.textContentType(.telephoneNumber)
|
|
.foregroundColor(.primary)
|
|
.padding()
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(8)
|
|
.padding(.horizontal)
|
|
|
|
Button(action: sendOtp) {
|
|
Text("Send Code")
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(Color.payfritGreen)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(8)
|
|
}
|
|
.disabled(isLoading)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
|
|
if showOtpInput {
|
|
VStack(spacing: 12) {
|
|
Text("Please enter the 6-digit code sent to your phone")
|
|
.font(.callout)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
|
|
TextField("Verification code", text: $otp)
|
|
.keyboardType(.numberPad)
|
|
.textContentType(.oneTimeCode)
|
|
.foregroundColor(.primary)
|
|
.padding()
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(8)
|
|
.padding(.horizontal)
|
|
|
|
Button(action: verifyOtp) {
|
|
Text("Verify")
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(Color.payfritGreen)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(8)
|
|
}
|
|
.disabled(isLoading)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 40)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.onAppear {
|
|
guard !hasCheckedAuth else { return }
|
|
hasCheckedAuth = true
|
|
checkSavedAuth()
|
|
}
|
|
}
|
|
|
|
private func checkSavedAuth() {
|
|
let defaults = UserDefaults.standard
|
|
let savedToken = defaults.string(forKey: "token")
|
|
let savedUserId = defaults.integer(forKey: "userId")
|
|
|
|
if let token = savedToken, !token.isEmpty, savedUserId > 0 {
|
|
if canUseBiometrics() {
|
|
showBiometricPrompt(token: token, userId: savedUserId)
|
|
} else {
|
|
loginSuccess(token: token, userId: savedUserId)
|
|
}
|
|
} else {
|
|
showPhoneInput = true
|
|
}
|
|
}
|
|
|
|
private func canUseBiometrics() -> Bool {
|
|
let context = LAContext()
|
|
var error: NSError?
|
|
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
|
}
|
|
|
|
private func showBiometricPrompt(token: String, userId: Int) {
|
|
let context = LAContext()
|
|
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
|
|
localizedReason: "Sign in to Payfrit Beacon") { success, _ in
|
|
DispatchQueue.main.async {
|
|
if success {
|
|
loginSuccess(token: token, userId: userId)
|
|
} else {
|
|
showPhoneInput = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendOtp() {
|
|
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard trimmed.count >= 10 else {
|
|
errorMessage = "Enter a valid phone number"
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
let response = try await Api.shared.sendLoginOtp(phone: trimmed)
|
|
otpUuid = response.uuid
|
|
|
|
showPhoneInput = false
|
|
showOtpInput = true
|
|
isLoading = false
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
isLoading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func verifyOtp() {
|
|
guard let uuid = otpUuid else { return }
|
|
let trimmed = otp.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard trimmed.count >= 4 else {
|
|
errorMessage = "Enter the verification code"
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
let response = try await Api.shared.verifyLoginOtp(uuid: uuid, otp: trimmed)
|
|
|
|
let defaults = UserDefaults.standard
|
|
defaults.set(response.token, forKey: "token")
|
|
defaults.set(response.userId, forKey: "userId")
|
|
defaults.set(response.userFirstName, forKey: "firstName")
|
|
|
|
loginSuccess(token: response.token, userId: response.userId)
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
isLoading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loginSuccess(token: String, userId: Int) {
|
|
Api.shared.setAuthToken(token)
|
|
onLoginSuccess(token, userId)
|
|
}
|
|
}
|
|
|
|
// MARK: - SVG Logo UIKit wrapper
|
|
|
|
struct SVGLogoView: UIViewRepresentable {
|
|
var width: CGFloat = 200
|
|
|
|
func makeUIView(context: Context) -> UIView {
|
|
let container = UIView()
|
|
container.clipsToBounds = true
|
|
|
|
let svgPath = Bundle.main.path(forResource: "payfrit-favicon-light-outlines", ofType: "svg") ?? ""
|
|
guard let svgImage = SVGKImage(contentsOfFile: svgPath) else { return container }
|
|
|
|
// Scale SVG proportionally based on width
|
|
let nativeW = svgImage.size.width
|
|
let nativeH = svgImage.size.height
|
|
let scale = width / nativeW
|
|
svgImage.size = CGSize(width: width, height: nativeH * scale)
|
|
|
|
let imageView = SVGKLayeredImageView(svgkImage: svgImage) ?? UIImageView()
|
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
container.addSubview(imageView)
|
|
|
|
NSLayoutConstraint.activate([
|
|
imageView.widthAnchor.constraint(equalToConstant: svgImage.size.width),
|
|
imageView.heightAnchor.constraint(equalToConstant: svgImage.size.height),
|
|
imageView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
|
imageView.centerYAnchor.constraint(equalTo: container.centerYAnchor),
|
|
])
|
|
|
|
return container
|
|
}
|
|
|
|
func updateUIView(_ uiView: UIView, context: Context) {}
|
|
}
|