import SwiftUI import UIKit struct OtpView: View { private let resendDelaySeconds = 45 let email: String let phoneNumber: String @Binding var root: RootFlow @Binding var selectedTab: MainTab let tokenStore: TokenStore @Binding var appState: AppState @State var otp = "" @State var isLoading = false @State var isResending = false @State var resendCountdown = 45 @State var canResend = false @State var errorMessage: String? @State var countdownTask: Task? @FocusState var isOtpFocused: Bool @Environment(\.dismiss) var dismiss var body: some View { ScrollView { VStack(spacing: 0) { SwiftUI.Image("pedifoods") .resizable() .scaledToFit() .frame(width: 74, height: 74) .padding(.top, 140) Text("Verificação") .font(AppTypography.heading1) .foregroundStyle(AppColors.textPrimary) .padding(.top, 26) Text("Insira o código de 8 dígitos enviado") .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) .padding(.top, 14) Text(otpDeliveryMessage) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) .lineLimit(1) .minimumScaleFactor(0.85) .padding(.top, 2) .padding(.horizontal, 24) ZStack { otpGrid TextField("", text: $otp) .appOTPKeyboard() .foregroundStyle(Color.clear) .tint(Color.clear) .focused($isOtpFocused) .frame(maxWidth: CGFloat.greatestFiniteMagnitude, maxHeight: CGFloat.greatestFiniteMagnitude) .opacity(0.02) .onChange(of: otp) { _, newValue in let digits = newValue.filter { $0.isNumber } let trimmed = String(digits.prefix(8)) if trimmed != newValue { otp = trimmed } if trimmed.count == 8 && !isLoading { isOtpFocused = false validateOtp() } } } .frame(height: 204) .onTapGesture { isOtpFocused = true autoFillOtpFromClipboardIfAvailable() } .onLongPressGesture { pasteOtpFromClipboard() } .padding(.horizontal, 24) .padding(.top, 26) HStack(spacing: 8) { Text("Não recebeu o código?") .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) Button(resendButtonTitle) { resendOtp() } .buttonStyle(.plain) .font(AppTypography.heading3) .foregroundStyle(AppColors.primary) .disabled(isResending || !canResend) } .padding(.top, 22) Button("Colar código") { pasteOtpFromClipboard() } .buttonStyle(.plain) .font(AppTypography.heading3) .foregroundStyle(AppColors.primary) .padding(.top, 22) PrimaryButton(title: "Verificar e Entrar") { validateOtp() } .padding(.horizontal, 24) .padding(.top, 28) .disabled(isLoading || otp.count != 8) .opacity((isLoading || otp.count != 8) ? 0.6 : 1.0) HStack(spacing: 8) { Image(systemName: "lock.fill") .font(.caption) Text("Conexão segura e criptografada") .font(AppTypography.body) } .foregroundStyle(AppColors.textMuted.opacity(0.8)) .padding(.top, 120) .padding(.bottom, 18) } } .scrollDismissesKeyboard(.interactively) .background(AppColors.backgroundLight) .ignoresSafeArea() .onAppear { isOtpFocused = true startResendCooldown() } .onDisappear { countdownTask?.cancel() countdownTask = nil } } private var resendButtonTitle: String { if !canResend { return String(format: "Reenviar em 00:%02d", resendCountdown) } return "Reenviar código" } private var otpGrid: some View { VStack(spacing: 16) { HStack(spacing: 14) { otpCell(index: 0) otpCell(index: 1) otpCell(index: 2) otpCell(index: 3) } HStack(spacing: 14) { otpCell(index: 4) otpCell(index: 5) otpCell(index: 6) otpCell(index: 7) } } } private func otpCell(index: Int) -> some View { let char = otpCharacter(at: index) return ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color.white) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(Color(hex: "#EEF3FA"), lineWidth: 2) ) if let char { Text(String(char)) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) } else { Circle() .fill(AppColors.textMuted.opacity(0.8)) .frame(width: 10, height: 10) } } .frame(height: 94) } private func otpCharacter(at index: Int) -> Character? { guard index < otp.count else { return nil } return Array(otp)[index] } private func pasteOtpFromClipboard() { let raw = appReadClipboardText() ?? "" let digits = raw.filter(\.isNumber) let trimmed = String(digits.prefix(8)) if trimmed.isEmpty == false { otp = trimmed } } private func autoFillOtpFromClipboardIfAvailable() { guard otp.isEmpty else { return } let raw = appReadClipboardText() ?? "" let digits = raw.filter(\.isNumber) guard digits.count >= 8 else { return } otp = String(digits.prefix(8)) } private var otpDeliveryMessage: String { "para o seu telefone \(maskedPhoneForDisplay) e seu email \(maskedEmailForDisplay)" } private var maskedPhoneForDisplay: String { let digits = phoneNumber.filter(\.isNumber) guard digits.isEmpty == false else { return "XXXX" } let visibleSuffix = String(digits.suffix(min(4, digits.count))) return "XXXX\(visibleSuffix)" } private var maskedEmailForDisplay: String { let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.isEmpty == false else { return "XXXX" } let parts = trimmed.split(separator: "@", omittingEmptySubsequences: false) guard parts.count == 2 else { return "XXXX" } let domain = String(parts[1]) return "XXXX@\(domain)" } private func validateOtp() { let code = otp.filter(\.isNumber) guard code.count == 8 else { return } isLoading = true errorMessage = nil Task { do { let service = ApiService() let response = try await service.validateOtp(email: email, phoneNumber: phoneNumber, otp: code) await MainActor.run { if response.error { isLoading = false let message = response.message ?? "Codigo invalido." errorMessage = message SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) return } isLoading = false SnackbarCenter.shared.show(title: "Login realizado com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 2.5) appState.session.isAuthenticated = true appState.session.jwt = response.result?.token let hasServerAddress = hydrateUserState(from: response.result?.customer) routeAfterLogin(hasServerAddress: hasServerAddress) Task { await PushNotificationCoordinator.shared.syncCustomerAttributes() } } } catch { await MainActor.run { isLoading = false let message = userFacingAuthErrorMessage(error) errorMessage = message SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) } } } } private func routeAfterLogin(hasServerAddress: Bool) { if hasServerAddress || hasConfiguredAddress() { selectedTab = .home root = .main return } appState.address.onboardingMessage = "Vamos criar um endereço para poder visualizar os estabelecimentos mais próximos de você?" selectedTab = .profile root = .main } private func hydrateUserState(from customer: CustomerProfile?) -> Bool { if let customer { appState.profile.id = customer.id appState.profile.name = customer.name appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? "" appState.profile.profilePicture = customer.profilePicture ?? "" appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false appState.favorites.storeIds = Set(customer.favorites ?? []) SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) if let cachedCart = SessionStateStore.loadCart() { appState.cart = cachedCart } else { appState.cart = CartState() } let addresses = customer.addressBook ?? [] if let preferred = addresses.first { appState.address.selectedId = preferred.id let label = (preferred.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) appState.address.display = label.isEmpty ? "Defina seu endereco" : label if let lat = preferred.latLong?.first, let lng = preferred.latLong?.dropFirst().first { appState.address.latitude = lat appState.address.longitude = lng } else { appState.address.latitude = nil appState.address.longitude = nil } SessionStateStore.saveAddress(appState.address) } else { appState.address = AddressState() SessionStateStore.clearAddress() } return addresses.isEmpty == false } appState.profile.email = email appState.favorites = FavoritesState() SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: nil, email: email) ) if let cachedCart = SessionStateStore.loadCart() { appState.cart = cachedCart } else { appState.cart = CartState() } appState.address = AddressState() return false } private func hasConfiguredAddress() -> Bool { if appState.address.selectedId != nil { return true } let normalized = appState.address.display .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() return normalized.isEmpty == false && normalized != "defina seu endereco" } private func resendOtp() { guard canResend, !isResending else { return } isResending = true startResendCooldown() errorMessage = nil Task { do { let service = ApiService() let response = try await service.requestOtp(email: email, phoneNumber: phoneNumber) await MainActor.run { isResending = false if response.error { canResend = true resendCountdown = 0 let message = response.message ?? "Nao foi possivel reenviar o código." errorMessage = message SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 4.0) } else { SnackbarCenter.shared.show(title: "Codigo reenviado.", style: .info, icon: "paperplane.fill", duration: 2.5) } } } catch { await MainActor.run { isResending = false canResend = true resendCountdown = 0 let message = userFacingAuthErrorMessage(error) errorMessage = message SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0) } } } } private func startResendCooldown() { countdownTask?.cancel() canResend = false resendCountdown = resendDelaySeconds countdownTask = Task { var remaining = resendDelaySeconds while !Task.isCancelled && remaining > 0 { try? await Task.sleep(nanoseconds: 1_000_000_000) remaining -= 1 await MainActor.run { resendCountdown = max(remaining, 0) canResend = remaining == 0 } } } } }