payfrit-works-ios/PayfritWorks/Views/LoginScreen.swift
John Pinkyfloyd c71b9f7dea Add ios-marketing idiom for App Store icon display
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 19:37:59 -08:00

373 lines
12 KiB
Swift

import SwiftUI
struct LoginScreen: View {
@EnvironmentObject var appState: AppState
// Login mode
@State private var usePasswordLogin = false
// OTP flow
@State private var step: OtpStep = .phone
@State private var phoneNumber = ""
@State private var otpCode = ""
@State private var otpUuid = ""
// Password flow
@State private var username = ""
@State private var password = ""
@State private var showPassword = false
// Shared state
@State private var isLoading = false
@State private var error: String?
private enum OtpStep {
case phone
case otp
}
var body: some View {
GeometryReader { geo in
ScrollView {
VStack(spacing: 16) {
Spacer().frame(height: 40)
Image("PayfritLogoLight")
.resizable()
.scaledToFit()
.frame(width: 180)
Text("Payfrit Works")
.font(.system(size: 28, weight: .bold))
Text("Sign in to view and claim tasks")
.foregroundColor(.secondary)
.font(.subheadline)
if IS_DEV {
Text("DEV MODE — OTP: 123456")
.font(.caption)
.foregroundColor(.red)
.fontWeight(.medium)
}
Spacer().frame(height: 8)
// Error message
if let error = error {
HStack {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(.red)
Text(error)
.foregroundColor(.red)
.font(.callout)
Spacer()
}
.padding(12)
.background(Color.red.opacity(0.1))
.cornerRadius(8)
}
// Main form
if usePasswordLogin {
passwordLoginView
} else {
switch step {
case .phone: phoneEntryView
case .otp: otpEntryView
}
}
Spacer().frame(height: 16)
// Toggle login mode
Button {
withAnimation {
usePasswordLogin.toggle()
error = nil
// Reset states
step = .phone
otpCode = ""
password = ""
}
} label: {
Text(usePasswordLogin ? "Use phone number instead" : "Use email & password instead")
.font(.subheadline)
.foregroundColor(.payfritGreen)
}
Spacer()
}
.padding(.horizontal, 24)
.frame(minHeight: geo.size.height)
}
.scrollDismissesKeyboard(.interactively)
}
.background(Color(.systemGroupedBackground))
}
// MARK: - Phone Entry View
private var phoneEntryView: some View {
VStack(spacing: 16) {
Text("Phone Number")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 0) {
Text("+1")
.font(.system(size: 17, weight: .medium))
.padding(.leading, 16)
Divider()
.frame(height: 20)
.padding(.horizontal, 12)
TextField("(555) 555-1234", text: $phoneNumber)
.font(.system(size: 17))
.keyboardType(.phonePad)
.textContentType(.telephoneNumber)
.padding(.trailing, 16)
}
.frame(height: 52)
.background(Color(.systemBackground))
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color(.systemGray4), lineWidth: 1)
)
let canSend = phoneNumber.filter { $0.isNumber }.count >= 10 && !isLoading
Button(action: sendOtp) {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity, minHeight: 50)
} else {
Text("Send Login Code")
.font(.headline)
.frame(maxWidth: .infinity, minHeight: 50)
}
}
.buttonStyle(.borderedProminent)
.tint(canSend ? .payfritGreen : .payfritGreen.opacity(0.5))
.disabled(!canSend)
}
}
// MARK: - OTP Entry View
private var otpEntryView: some View {
VStack(spacing: 16) {
VStack(spacing: 4) {
Text("We sent a code to")
.font(.subheadline)
.foregroundColor(.secondary)
Text(formattedPhone)
.font(.headline)
}
TextField("000000", text: $otpCode)
.font(.system(size: 28, weight: .semibold, design: .monospaced))
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.tracking(8)
.frame(height: 56)
.background(Color(.systemBackground))
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color(.systemGray4), lineWidth: 1)
)
.onChange(of: otpCode) { newValue in
otpCode = String(newValue.prefix(6))
}
let canVerify = otpCode.count == 6 && !isLoading
Button(action: verifyOtp) {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity, minHeight: 50)
} else {
Text("Sign In")
.font(.headline)
.frame(maxWidth: .infinity, minHeight: 50)
}
}
.buttonStyle(.borderedProminent)
.tint(canVerify ? .payfritGreen : .payfritGreen.opacity(0.5))
.disabled(!canVerify)
HStack(spacing: 24) {
Button("Resend Code") {
sendOtp()
}
.foregroundColor(.payfritGreen)
Button("Change Number") {
step = .phone
otpCode = ""
error = nil
}
.foregroundColor(.secondary)
}
.font(.subheadline)
}
}
// MARK: - Password Login View
private var passwordLoginView: some View {
VStack(spacing: 12) {
TextField("Email or Phone", text: $username)
.textFieldStyle(.roundedBorder)
.textContentType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
ZStack(alignment: .trailing) {
Group {
if showPassword {
TextField("Password", text: $password)
.textContentType(.password)
} else {
SecureField("Password", text: $password)
.textContentType(.password)
}
}
.textFieldStyle(.roundedBorder)
.onSubmit { loginWithPassword() }
Button {
showPassword.toggle()
} label: {
Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
.foregroundColor(.secondary)
.font(.subheadline)
}
.padding(.trailing, 8)
}
Button(action: loginWithPassword) {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity, minHeight: 44)
} else {
Text("Sign In")
.font(.headline)
.frame(maxWidth: .infinity, minHeight: 44)
}
}
.buttonStyle(.borderedProminent)
.tint(.payfritGreen)
.disabled(isLoading)
}
}
// MARK: - Helpers
private var formattedPhone: String {
let digits = phoneNumber.filter { $0.isNumber }
guard digits.count >= 10 else { return "+1 \(phoneNumber)" }
let area = String(digits.prefix(3))
let mid = String(digits.dropFirst(3).prefix(3))
let last = String(digits.dropFirst(6).prefix(4))
return "+1 (\(area)) \(mid)-\(last)"
}
// MARK: - API Calls
private func sendOtp() {
let cleanPhone = phoneNumber.filter { $0.isNumber }
guard cleanPhone.count >= 10 else {
error = "Please enter a valid phone number"
return
}
isLoading = true
error = nil
Task {
do {
let response = try await APIService.shared.sendLoginOtp(phone: "+1\(cleanPhone)")
otpUuid = response.uuid
step = .otp
} catch {
self.error = "Failed to send code. Please try again."
}
isLoading = false
}
}
private func verifyOtp() {
isLoading = true
error = nil
Task {
do {
let response = try await APIService.shared.verifyLoginOtp(uuid: otpUuid, otp: otpCode)
let resolvedPhoto = APIService.resolvePhotoUrl(response.photoUrl)
await AuthStorage.shared.saveAuth(
userId: response.userId,
token: response.token,
userName: response.userFirstName,
photoUrl: resolvedPhoto
)
appState.setAuth(
userId: response.userId,
token: response.token,
userName: response.userFirstName,
photoUrl: resolvedPhoto
)
} catch {
self.error = "Invalid code. Please try again."
}
isLoading = false
}
}
private func loginWithPassword() {
let user = username.trimmingCharacters(in: .whitespaces)
let pass = password
guard !user.isEmpty, !pass.isEmpty else {
error = "Please enter username and password"
return
}
isLoading = true
error = nil
Task {
do {
let response = try await APIService.shared.login(username: user, password: pass)
let resolvedPhoto = APIService.resolvePhotoUrl(response.photoUrl)
await AuthStorage.shared.saveAuth(
userId: response.userId,
token: response.token,
userName: response.userFirstName,
photoUrl: resolvedPhoto
)
appState.setAuth(
userId: response.userId,
token: response.token,
userName: response.userFirstName,
photoUrl: resolvedPhoto
)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
}
#Preview {
LoginScreen()
.environmentObject(AppState())
}