373 lines
12 KiB
Swift
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())
|
|
}
|