// // SignInView.swift // Snail // import SwiftUI /// Вход в два шага: адрес почты → одноразовый код из письма. struct SignInView: View { @Environment(AuthStore.self) private var auth private enum Step { case email case code } private enum Field { case email case code } /// Длина кода и пауза перед повторной отправкой (совпадают с настройками better-auth по умолчанию). private static let codeLength = 6 private static let resendDelay: TimeInterval = 60 @AppStorage("lastSignInEmail") private var rememberedEmail = "" @State private var step: Step = .email @State private var email = "" @State private var code = "" @State private var isLoading = false @State private var errorMessage: String? @State private var resendAvailableAt = Date.distantPast @FocusState private var focusedField: Field? private var trimmedEmail: String { email.trimmingCharacters(in: .whitespacesAndNewlines) } private var isEmailValid: Bool { trimmedEmail.wholeMatch(of: /[^@\s]+@[^@\s]+\.[^@\s]+/) != nil } var body: some View { NavigationStack { Form { switch step { case .email: emailSection case .code: codeSection } } .navigationTitle(step == .email ? "Вход" : "Код из письма") .scrollDismissesKeyboard(.interactively) .disabled(isLoading) .animation(.default, value: step) .onAppear { if email.isEmpty { email = rememberedEmail } focusedField = .email } } } // MARK: - Шаг 1: почта @ViewBuilder private var emailSection: some View { Section { TextField("Электронная почта", text: $email, prompt: Text(verbatim: "name@example.com")) .textContentType(.emailAddress) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) .autocorrectionDisabled() .submitLabel(.continue) .focused($focusedField, equals: .email) .onSubmit(sendCode) .onChange(of: email) { errorMessage = nil } } footer: { footer(hint: "Мы отправим на этот адрес одноразовый код для входа.") } Section { Button(action: sendCode) { ProgressButtonLabel(title: "Получить код", isLoading: isLoading) } .disabled(!isEmailValid) } } // MARK: - Шаг 2: код @ViewBuilder private var codeSection: some View { Section { TextField("Код", text: $code, prompt: Text(verbatim: String(repeating: "0", count: Self.codeLength))) .textContentType(.oneTimeCode) .keyboardType(.numberPad) .font(.title2.monospacedDigit()) .focused($focusedField, equals: .code) .onChange(of: code) { _, newValue in // Оставляем только цифры и отправляем, как только код введён полностью // (в том числе при автозаполнении из письма). let digits = String(newValue.filter(\.isNumber).prefix(Self.codeLength)) if digits != newValue { code = digits return } if !digits.isEmpty { errorMessage = nil } if digits.count == Self.codeLength { verifyCode() } } } footer: { footer(hint: "Код отправлен на \(trimmedEmail). Если письма нет, проверьте папку «Спам».") } Section { Button(action: verifyCode) { ProgressButtonLabel(title: "Войти", isLoading: isLoading) } .disabled(code.count != Self.codeLength) TimelineView(.periodic(from: .now, by: 1)) { context in let remaining = Int(resendAvailableAt.timeIntervalSince(context.date).rounded(.up)) Button(action: sendCode) { if remaining > 0 { Text("Отправить повторно через \(remaining) с") } else { Text("Отправить код повторно") } } .disabled(remaining > 0) } Button("Изменить адрес почты") { code = "" errorMessage = nil step = .email focusedField = .email } } } @ViewBuilder private func footer(hint: LocalizedStringKey) -> some View { if let errorMessage { Label(errorMessage, systemImage: "exclamationmark.circle") .foregroundStyle(.red) } else { Text(hint) } } // MARK: - Действия private func sendCode() { guard isEmailValid, !isLoading else { return } Task { await perform { try await auth.sendCode(to: trimmedEmail) rememberedEmail = trimmedEmail resendAvailableAt = .now.addingTimeInterval(Self.resendDelay) code = "" step = .code focusedField = .code } } } private func verifyCode() { guard code.count == Self.codeLength, !isLoading else { return } Task { await perform { try await auth.signIn(email: trimmedEmail, code: code) } if errorMessage != nil { code = "" focusedField = .code } } } private func perform(_ action: () async throws -> Void) async { isLoading = true errorMessage = nil defer { isLoading = false } do { try await action() } catch { errorMessage = error.localizedDescription if case APIError.server(_, "TOO_MANY_ATTEMPTS", _) = error { // Код больше не примут — сразу разрешаем запросить новый. resendAvailableAt = .distantPast } AccessibilityNotification.Announcement(error.localizedDescription).post() } } } /// Подпись кнопки с индикатором загрузки, не меняющая ширину строки. private struct ProgressButtonLabel: View { let title: LocalizedStringKey let isLoading: Bool var body: some View { HStack { Text(title) Spacer() if isLoading { ProgressView() } } .accessibilityElement(children: .combine) .accessibilityValue(isLoading ? Text("Загрузка") : Text(verbatim: "")) } } #Preview { SignInView() .environment(AuthStore(state: .signedOut)) }