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