payfrit-beacon-ios/PayfritBeacon/LoginView.swift
John Pinkyfloyd 962a767863 Rewrite app: simplified architecture, CoreLocation beacon scanning, UI fixes
- 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>
2026-02-04 22:07:39 -08:00

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) {}
}