Files
snail/Snail/Auth/SignInView.swift
T
2026-10-29 13:12:45 +03:00

220 lines
7.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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))
}