diff --git a/yobble/Network/AuthService.swift b/yobble/Network/AuthService.swift index 3c38c95..338cf90 100644 --- a/yobble/Network/AuthService.swift +++ b/yobble/Network/AuthService.swift @@ -83,6 +83,26 @@ final class AuthService { } } + func requestLoginCode(identifier: String, completion: @escaping (Bool, String?) -> Void) { + if AppConfig.DEBUG { + print("[AuthService] requestLoginCode placeholder for \(identifier)") + } + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.8) { + completion(true, nil) + } + } + + func loginWithCode(identifier: String, code: String, completion: @escaping (Bool, String?) -> Void) { + if AppConfig.DEBUG { + print("[AuthService] loginWithCode placeholder for \(identifier) using code \(code)") + } + + DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { + completion(false, NSLocalizedString("Вход по коду пока недоступен. Заглушка.", comment: "")) + } + } + func register(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) { let payload = RegisterRequest(login: username, password: password, invite: invite) guard let body = try? JSONEncoder().encode(payload) else { diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 174915b..dc2c030 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "ru", "strings" : { + "" : { + + }, "(без текста)" : { }, @@ -267,9 +270,21 @@ } } } + }, + "Введите код" : { + }, "Введите код из приложения" : { "comment" : "Поле ввода кода 2FA" + }, + "Введите логин" : { + + }, + "Введите логин и мы отправим шестизначный код подтверждения." : { + + }, + "Введите логин." : { + }, "Введите пароль" : { "comment" : "Поле ввода пароля на приложение" @@ -347,6 +362,12 @@ } } } + }, + "Войти по коду" : { + + }, + "Войти по паролю" : { + }, "Все" : { "localizations" : { @@ -370,9 +391,15 @@ }, "Всего сессий" : { "comment" : "Сводка по количеству сессий" + }, + "Вход" : { + }, "Вход и защита аккаунта (заглушка)" : { "comment" : "Раздел настроек безопасности для аутентификации" + }, + "Вход по коду пока недоступен. Заглушка." : { + }, "Вы" : { "localizations" : { @@ -681,6 +708,9 @@ }, "Изменить контакт" : { "comment" : "Contacts context action edit" + }, + "Изменить способ входа" : { + }, "Изображение" : { "comment" : "Image message placeholder" @@ -739,6 +769,9 @@ }, "Код дружбы" : { "comment" : "Friend code badge" + }, + "Код может прийти по почте, push или в другое подключенное приложение." : { + }, "Код принят" : { "comment" : "Заголовок успешного подтверждения кода 2FA" @@ -952,6 +985,9 @@ } } } + }, + "Мы отправили код на %@" : { + }, "Мы отправим код подтверждения на привязанный email каждый раз при входе." : { "comment" : "Описание работы кодов при входе" @@ -1093,6 +1129,9 @@ }, "Начальная настройка" : { + }, + "Не получили код?" : { + }, "Не удалось выполнить поиск." : { "comment" : "Search error fallback\nSearch service decoding error" @@ -1180,6 +1219,9 @@ }, "Не удалось открыть чат." : { "comment" : "Chat creation fallback" + }, + "Не удалось отправить код." : { + }, "Не удалось отправить сообщение." : { @@ -1482,6 +1524,9 @@ }, "Отображаемое имя" : { + }, + "Отправить код ещё раз" : { + }, "Отправить отзыв" : { "comment" : "feedback: submit button", @@ -1758,6 +1803,9 @@ }, "Подтвердить" : { "comment" : "Кнопка подтверждения кода 2FA" + }, + "Подтвердить вход" : { + }, "Подтверждение email" : { "comment" : "Раздел подтверждения email" @@ -1835,6 +1883,9 @@ }, "Получать коды на email при входе" : { "comment" : "Переключатель отправки кодов при входе" + }, + "Получить код" : { + }, "Получить ответ от команды" : { "comment" : "feedback: contact toggle", @@ -1876,6 +1927,9 @@ }, "Понятно" : { "comment" : "Chat creation error acknowledgment" + }, + "Попробовать снова можно через %d сек" : { + }, "Попробуйте изменить запрос поиска." : { @@ -2025,6 +2079,9 @@ }, "Проверочный код" : { "comment" : "Раздел верификации 2FA" + }, + "Проверьте введённый код и попробуйте снова." : { + }, "Проверьте ввод и попробуйте снова." : { "comment" : "Сообщение ошибки несовпадения паролей" diff --git a/yobble/ViewModels/LoginViewModel.swift b/yobble/ViewModels/LoginViewModel.swift index 53d009b..70f906b 100644 --- a/yobble/ViewModels/LoginViewModel.swift +++ b/yobble/ViewModels/LoginViewModel.swift @@ -23,10 +23,32 @@ class LoginViewModel: ObservableObject { @Published var termsContent: String = "" @Published var termsErrorMessage: String? @Published var onboardingDestination: OnboardingDestination? + @Published var loginFlowStep: LoginFlowStep = .passwordlessRequest + @Published var passwordlessLogin: String = "" + @Published var verificationCode: String = "" { + didSet { + let filtered = verificationCode + .filter { $0.isNumber } + .prefix(Constants.verificationCodeLength) + if filtered != verificationCode { + verificationCode = String(filtered) + } + } + } + @Published var isSendingCode: Bool = false + @Published var isVerifyingCode: Bool = false + @Published var resendSecondsRemaining: Int = 0 private let authService = AuthService() private let socketService = SocketService.shared private var cancellables = Set() + private var resendTimer: Timer? + + enum LoginFlowStep: Equatable { + case passwordlessRequest + case passwordlessVerify + case password + } enum ChatLoadingState: Equatable { case idle @@ -52,6 +74,10 @@ class LoginViewModel: ObservableObject { autoLogin() } + deinit { + resendTimer?.invalidate() + } + private func observeSocketState() { socketService.connectionStatePublisher .receive(on: DispatchQueue.main) @@ -124,6 +150,86 @@ class LoginViewModel: ObservableObject { } } } + + func requestPasswordlessCode() { + let trimmedLogin = passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmedLogin.isEmpty else { + errorMessage = NSLocalizedString("Введите логин.", comment: "") + showError = true + return + } + + isSendingCode = true + showError = false + + authService.requestLoginCode(identifier: trimmedLogin) { [weak self] success, message in + DispatchQueue.main.async { + guard let self else { return } + + self.isSendingCode = false + + if success { + self.passwordlessLogin = trimmedLogin + self.verificationCode = "" + self.loginFlowStep = .passwordlessVerify + self.startResendTimer() + } else { + self.errorMessage = message ?? NSLocalizedString("Не удалось отправить код.", comment: "") + self.showError = true + } + } + } + } + + func verifyPasswordlessCode() { + guard verificationCode.count == Constants.verificationCodeLength, + !passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return + } + + isVerifyingCode = true + showError = false + + authService.loginWithCode(identifier: passwordlessLogin, code: verificationCode) { [weak self] success, message in + DispatchQueue.main.async { + guard let self else { return } + + self.isVerifyingCode = false + + if success { + self.resendTimer?.invalidate() + self.loadStoredUser() + self.isLoggedIn = true + self.socketService.connectForCurrentUser() + } else { + self.errorMessage = message ?? NSLocalizedString("Проверьте введённый код и попробуйте снова.", comment: "") + self.showError = true + self.verificationCode = "" + } + } + } + } + + func resendPasswordlessCode() { + guard resendSecondsRemaining == 0 else { return } + requestPasswordlessCode() + } + + func showPasswordLogin() { + resendTimer?.invalidate() + loginFlowStep = .password + } + + func showPasswordlessRequest() { + loginFlowStep = .passwordlessRequest + } + + func backToPasswordlessRequest() { + verificationCode = "" + loginFlowStep = .passwordlessRequest + } + func registerUser(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) { authService.register(username: username, password: password, invite: invite) { [weak self] success, message in @@ -228,4 +334,45 @@ class LoginViewModel: ObservableObject { termsErrorMessage = nil loadTermsIfNeeded() } + + private func startResendTimer(duration: Int = Constants.defaultResendDelay) { + resendTimer?.invalidate() + resendSecondsRemaining = duration + + guard duration > 0 else { return } + + resendTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in + guard let self else { + timer.invalidate() + return + } + + if self.resendSecondsRemaining > 0 { + self.resendSecondsRemaining -= 1 + } else { + timer.invalidate() + } + } + } +} + +extension LoginViewModel { + var isVerificationCodeComplete: Bool { + verificationCode.count == Constants.verificationCodeLength + } + + var canRequestPasswordlessCode: Bool { + !passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSendingCode + } + + var canVerifyPasswordlessCode: Bool { + isVerificationCodeComplete && !isVerifyingCode + } +} + +private extension LoginViewModel { + enum Constants { + static let verificationCodeLength = 6 + static let defaultResendDelay = 60 + } } diff --git a/yobble/Views/Login/LoginView.swift b/yobble/Views/Login/LoginView.swift index 7c9f91b..41251dc 100644 --- a/yobble/Views/Login/LoginView.swift +++ b/yobble/Views/Login/LoginView.swift @@ -9,6 +9,27 @@ import SwiftUI struct LoginView: View { @ObservedObject var viewModel: LoginViewModel + + var body: some View { + ZStack { + switch viewModel.loginFlowStep { + case .passwordlessRequest: + PasswordlessRequestView(viewModel: viewModel) + .transition(.move(edge: .trailing).combined(with: .opacity)) + case .passwordlessVerify: + PasswordlessVerifyView(viewModel: viewModel) + .transition(.move(edge: .leading).combined(with: .opacity)) + case .password: + PasswordLoginView(viewModel: viewModel) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.25), value: viewModel.loginFlowStep) + } +} + +struct PasswordLoginView: View { + @ObservedObject var viewModel: LoginViewModel @EnvironmentObject private var themeManager: ThemeManager @Environment(\.colorScheme) private var colorScheme private let themeOptions = ThemeOption.ordered @@ -73,6 +94,22 @@ struct LoginView: View { focusedField = nil } + Button { + focusedField = nil + withAnimation { + viewModel.showPasswordlessRequest() + } + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + Text(NSLocalizedString("Войти по коду", comment: "")) + } + .font(.footnote) + .foregroundColor(.blue) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + } + Spacer() TextField(NSLocalizedString("Логин", comment: ""), text: $viewModel.username) @@ -175,13 +212,7 @@ struct LoginView: View { } .padding() - .alert(isPresented: $viewModel.showError) { - Alert( - title: Text(NSLocalizedString("Ошибка авторизации", comment: "")), - message: Text(viewModel.errorMessage), - dismissButton: .default(Text(NSLocalizedString("OK", comment: ""))) - ) - } + .loginErrorAlert(viewModel: viewModel) .onAppear { if !hasResetTermsOnAppear { viewModel.hasAcceptedTerms = false @@ -319,10 +350,360 @@ struct LoginView: View { } +private struct PasswordlessRequestView: View { + @ObservedObject var viewModel: LoginViewModel + @FocusState private var isFieldFocused: Bool + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + LoginTopBar(openLanguageSettings: openLanguageSettings) + + VStack(alignment: .leading, spacing: 8) { + Text(NSLocalizedString("Вход", comment: "")) + .font(.largeTitle).bold() +// Text(NSLocalizedString("Введите логин и мы отправим шестизначный код подтверждения.", comment: "")) +// .foregroundColor(.secondary) + } + + VStack(alignment: .leading, spacing: 8) { +// Text(NSLocalizedString("Логин", comment: "")) +// .font(.subheadline) +// .foregroundColor(.secondary) + TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.passwordlessLogin) + .textContentType(.username) + .keyboardType(.default) + .autocapitalization(.none) + .disableAutocorrection(true) + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + .focused($isFieldFocused) + .onChange(of: viewModel.passwordlessLogin) { newValue in + if newValue.count > 64 { + viewModel.passwordlessLogin = String(newValue.prefix(64)) + } + } + } + + Button { + withAnimation { + viewModel.requestPasswordlessCode() + } + } label: { + if viewModel.isSendingCode { + ProgressView() + .frame(maxWidth: .infinity) + .padding() + } else { + Text(NSLocalizedString("Получить код", comment: "")) + .bold() + .frame(maxWidth: .infinity) + .padding() + } + } + .foregroundColor(.white) + .background(viewModel.canRequestPasswordlessCode ? Color.blue : Color.gray) + .cornerRadius(12) + .disabled(!viewModel.canRequestPasswordlessCode) + + Divider() + + Button { + withAnimation { + viewModel.showPasswordLogin() + } + } label: { + Text(NSLocalizedString("Войти по паролю", comment: "")) + .font(.body) + .frame(maxWidth: .infinity) + } + .padding(.vertical, 4) + + Text(NSLocalizedString("Код может прийти по почте, push или в другое подключенное приложение.", comment: "")) + .font(.footnote) + .foregroundColor(.secondary) + } + .padding(.vertical, 32) + } + .padding(.horizontal, 24) + .background(Color(.systemBackground).ignoresSafeArea()) + .contentShape(Rectangle()) + .onTapGesture { + isFieldFocused = false + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + isFieldFocused = true + } + } + .loginErrorAlert(viewModel: viewModel) + } + + private func openLanguageSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } +} + +private struct PasswordlessVerifyView: View { + @ObservedObject var viewModel: LoginViewModel + @FocusState private var isCodeFieldFocused: Bool + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + LoginTopBar(openLanguageSettings: openLanguageSettings) + + VStack(alignment: .leading, spacing: 8) { + Text(NSLocalizedString("Введите код", comment: "")) + .font(.largeTitle).bold() +// Text(String(format: NSLocalizedString("Мы отправили код на %@", comment: ""), viewModel.passwordlessLogin)) +// .foregroundColor(.secondary) + } + + OTPInputView(code: $viewModel.verificationCode, isFocused: $isCodeFieldFocused) + + Button { + withAnimation { + viewModel.verifyPasswordlessCode() + } + } label: { + if viewModel.isVerifyingCode { + ProgressView() + .frame(maxWidth: .infinity) + .padding() + } else { + Text(NSLocalizedString("Подтвердить вход", comment: "")) + .bold() + .frame(maxWidth: .infinity) + .padding() + } + } + .foregroundColor(.white) + .background(viewModel.canVerifyPasswordlessCode ? Color.blue : Color.gray) + .cornerRadius(12) + .disabled(!viewModel.canVerifyPasswordlessCode) + + VStack(alignment: .leading, spacing: 8) { + Text(NSLocalizedString("Не получили код?", comment: "")) + .font(.subheadline) + if viewModel.resendSecondsRemaining > 0 { + Text(String(format: NSLocalizedString("Попробовать снова можно через %d сек", comment: ""), viewModel.resendSecondsRemaining)) + .foregroundColor(.secondary) + } + + Button { + withAnimation { + viewModel.resendPasswordlessCode() + } + } label: { + if viewModel.isSendingCode { + ProgressView() + .padding(.vertical, 8) + } else { + Text(NSLocalizedString("Отправить код ещё раз", comment: "")) + } + } + .disabled(viewModel.resendSecondsRemaining > 0 || viewModel.isSendingCode) + } + + Divider() + + Button { + withAnimation { + viewModel.backToPasswordlessRequest() + } + } label: { + Text(NSLocalizedString("Изменить способ входа", comment: "")) + .frame(maxWidth: .infinity) + } + + Button { + withAnimation { + viewModel.showPasswordLogin() + } + } label: { + Text(NSLocalizedString("Войти по паролю", comment: "")) + .frame(maxWidth: .infinity) + } + } + .padding(.vertical, 32) + } + .padding(.horizontal, 24) + .background(Color(.systemBackground).ignoresSafeArea()) + .contentShape(Rectangle()) + .onTapGesture { + isCodeFieldFocused = true + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + isCodeFieldFocused = true + } + } + .loginErrorAlert(viewModel: viewModel) + } + + private func openLanguageSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } +} + +private struct OTPInputView: View { + @Binding var code: String + var length: Int = 6 + let isFocused: FocusState.Binding + + var body: some View { + ZStack { + HStack(spacing: 12) { + ForEach(0.. { + Binding( + get: { code }, + set: { newValue in + let filtered = newValue.filter { $0.isNumber } + code = String(filtered.prefix(length)) + } + ) + } + + private func symbol(at index: Int) -> String { + guard index < code.count else { return "" } + let idx = code.index(code.startIndex, offsetBy: index) + return String(code[idx]) + } + + private func borderColor(for index: Int) -> Color { + if index == code.count && code.count < length { + return .blue + } + return .gray.opacity(0.6) + } +} + +private struct LoginTopBar: View { + let openLanguageSettings: () -> Void + @EnvironmentObject private var themeManager: ThemeManager + @Environment(\.colorScheme) private var colorScheme + private let themeOptions = ThemeOption.ordered + + var body: some View { + HStack { + Button(action: openLanguageSettings) { + Text("🌍") + .padding(8) + } + Spacer() + Menu { + ForEach(themeOptions) { option in + Button(action: { selectTheme(option) }) { + themeMenuContent(for: option) + .opacity(option.isEnabled ? 1.0 : 0.5) + } + .disabled(!option.isEnabled) + } + } label: { + Image(systemName: themeIconName) + .padding(8) + } + } + } + + private var selectedThemeOption: ThemeOption { + ThemeOption.option(for: themeManager.theme) + } + + private var themeIconName: String { + switch themeManager.theme { + case .system: + return colorScheme == .dark ? "moon.fill" : "sun.max.fill" + case .light: + return "sun.max.fill" + case .oledDark: + return "moon.fill" + } + } + + private func themeMenuContent(for option: ThemeOption) -> some View { + let isSelected = option == selectedThemeOption + + return HStack(spacing: 8) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundColor(isSelected ? .accentColor : .secondary) + VStack(alignment: .leading, spacing: 2) { + Text(option.title) + if let note = option.note { + Text(note) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + + private func selectTheme(_ option: ThemeOption) { + guard let mappedTheme = option.mappedTheme else { return } + themeManager.setTheme(mappedTheme) + } +} + +private extension View { + func loginErrorAlert(viewModel: LoginViewModel) -> some View { + alert(isPresented: Binding( + get: { viewModel.showError }, + set: { viewModel.showError = $0 } + )) { + Alert( + title: Text(NSLocalizedString("Ошибка авторизации", comment: "")), + message: Text(viewModel.errorMessage.isEmpty ? NSLocalizedString("Произошла ошибка.", comment: "") : viewModel.errorMessage), + dismissButton: .default(Text(NSLocalizedString("OK", comment: ""))) + ) + } + } +} + struct LoginView_Previews: PreviewProvider { static var previews: some View { + Group { + preview(step: .passwordlessRequest) + preview(step: .passwordlessVerify) + preview(step: .password) + } + .environmentObject(ThemeManager()) + } + + private static func preview(step: LoginViewModel.LoginFlowStep) -> some View { let viewModel = LoginViewModel() - viewModel.isLoading = false // чтобы убрать спиннер + viewModel.isLoading = false + viewModel.loginFlowStep = step + viewModel.passwordlessLogin = "preview@yobble.app" + viewModel.verificationCode = "123456" return LoginView(viewModel: viewModel) } } diff --git a/yobble/config.swift b/yobble/config.swift index 5c6f7f0..07d12a1 100644 --- a/yobble/config.swift +++ b/yobble/config.swift @@ -1,7 +1,7 @@ import SwiftUI struct AppConfig { - static var DEBUG: Bool = false + static var DEBUG: Bool = true //static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service" static let PROTOCOL = "https" static let API_SERVER = "\(PROTOCOL)://api.yobble.org"