diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 9450950..ed0105d 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -1036,6 +1036,9 @@ } } } + }, + "Назад к входу" : { + }, "Напишите нам через форму обратной связи в разделе \"Поддержка\"." : { "comment" : "FAQ answer: support" @@ -1676,7 +1679,7 @@ } }, "Пароли не совпадают" : { - "comment" : "Заголовок ошибки несовпадения паролей\nПароли не совпадают", + "comment" : "Заголовок ошибки несовпадения паролей", "localizations" : { "en" : { "stringUnit" : { @@ -1815,6 +1818,9 @@ }, "Подключение" : { + }, + "Подтвердите пароль" : { + }, "Подтвердить" : { "comment" : "Кнопка подтверждения кода 2FA" @@ -2441,6 +2447,9 @@ }, "Согласиться с правилами" : { + }, + "Создайте логин и пароль. При желании добавьте инвайт." : { + }, "Создать новые коды" : { "comment" : "Кнопка генерации резервных кодов" diff --git a/yobble/ViewModels/LoginViewModel.swift b/yobble/ViewModels/LoginViewModel.swift index 70f906b..96813a5 100644 --- a/yobble/ViewModels/LoginViewModel.swift +++ b/yobble/ViewModels/LoginViewModel.swift @@ -48,6 +48,7 @@ class LoginViewModel: ObservableObject { case passwordlessRequest case passwordlessVerify case password + case registration } enum ChatLoadingState: Equatable { @@ -230,6 +231,10 @@ class LoginViewModel: ObservableObject { loginFlowStep = .passwordlessRequest } + func showRegistration() { + loginFlowStep = .registration + } + 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 diff --git a/yobble/Views/Login/LoginTopBar.swift b/yobble/Views/Login/LoginTopBar.swift new file mode 100644 index 0000000..c628e39 --- /dev/null +++ b/yobble/Views/Login/LoginTopBar.swift @@ -0,0 +1,67 @@ +import SwiftUI + +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) + } +} diff --git a/yobble/Views/Login/LoginView.swift b/yobble/Views/Login/LoginView.swift index 8697876..be78cb3 100644 --- a/yobble/Views/Login/LoginView.swift +++ b/yobble/Views/Login/LoginView.swift @@ -54,6 +54,9 @@ struct LoginView: View { case .password: PasswordLoginView(viewModel: viewModel) .transition(.opacity) + case .registration: + RegistrationView(viewModel: viewModel) + .transition(.move(edge: .bottom).combined(with: .opacity)) } } } @@ -77,7 +80,6 @@ struct PasswordLoginView: View { private let themeOptions = ThemeOption.ordered @AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false - @State private var isShowingRegistration = false @State private var showLegacySupportNotice = false @State private var isShowingTerms = false @State private var hasResetTermsOnAppear = false @@ -197,17 +199,16 @@ struct PasswordLoginView: View { .disabled(!isLoginButtonEnabled) Button(action: { - isShowingRegistration = true viewModel.hasAcceptedTerms = false + withAnimation { + viewModel.showRegistration() + } }) { Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: "Регистрация")) .foregroundColor(.blue) .frame(maxWidth: .infinity) } .padding(.top, 4) - .sheet(isPresented: $isShowingRegistration) { - RegistrationView(viewModel: viewModel, isPresented: $isShowingRegistration) - } Spacer(minLength: 0) } @@ -637,72 +638,6 @@ private struct OTPInputView: View { } } -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 struct MessengerModePrompt: View { @Binding var selection: Bool let onAccept: () -> Void @@ -816,6 +751,7 @@ struct LoginView_Previews: PreviewProvider { preview(step: .passwordlessRequest) preview(step: .passwordlessVerify) preview(step: .password) + preview(step: .registration) } .environmentObject(ThemeManager()) } diff --git a/yobble/Views/Login/RegistrationView.swift b/yobble/Views/Login/RegistrationView.swift index 58bbdf8..ede5ea7 100644 --- a/yobble/Views/Login/RegistrationView.swift +++ b/yobble/Views/Login/RegistrationView.swift @@ -9,8 +9,6 @@ import SwiftUI struct RegistrationView: View { @ObservedObject var viewModel: LoginViewModel - @Binding var isPresented: Bool - @Environment(\.presentationMode) private var presentationMode @State private var username: String = "" @State private var password: String = "" @@ -49,149 +47,133 @@ struct RegistrationView: View { } var body: some View { - NavigationView { - ScrollView { - ZStack(alignment: .top) { - Color.clear - .contentShape(Rectangle()) - .onTapGesture { focusedField = nil } + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + LoginTopBar(openLanguageSettings: openLanguageSettings) - VStack(alignment: .leading, spacing: 16) { - Group { - HStack { - TextField(NSLocalizedString("Логин", comment: "Логин"), text: $username) - .autocapitalization(.none) - .disableAutocorrection(true) - .focused($focusedField, equals: .username) - Spacer() - if !username.isEmpty { - Image(systemName: isUsernameValid ? "checkmark.circle" : "xmark.circle") - .foregroundColor(isUsernameValid ? .green : .red) - } - } - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(8) + Button(action: goBack) { + HStack(spacing: 6) { + Image(systemName: "arrow.left") + Text(NSLocalizedString("Назад к входу", comment: "")) + } + .font(.footnote) + .foregroundColor(.blue) + } + + VStack(alignment: .leading, spacing: 8) { + Text(NSLocalizedString("Регистрация", comment: "Регистрация")) + .font(.largeTitle).bold() + Text(NSLocalizedString("Создайте логин и пароль. При желании добавьте инвайт.", comment: "")) + .foregroundColor(.secondary) + } + + Group { + VStack(alignment: .leading, spacing: 4) { + TextField(NSLocalizedString("Логин", comment: "Логин"), text: $username) .autocapitalization(.none) .disableAutocorrection(true) + .focused($focusedField, equals: .username) + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) .onChange(of: username) { newValue in if newValue.count > 32 { username = String(newValue.prefix(32)) } } - if !isUsernameValid && !username.isEmpty { - Text(NSLocalizedString("Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)", comment: "Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)")) - .foregroundColor(.red) - .font(.caption) - } + if !isUsernameValid && !username.isEmpty { + Text(NSLocalizedString("Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)", comment: "")) + .foregroundColor(.red) + .font(.caption) + } + } - HStack { - SecureField(NSLocalizedString("Пароль", comment: "Пароль"), text: $password) - .autocapitalization(.none) - .focused($focusedField, equals: .password) - Spacer() - if !password.isEmpty { - Image(systemName: isPasswordValid ? "checkmark.circle" : "xmark.circle") - .foregroundColor(isPasswordValid ? .green : .red) - } - } + VStack(alignment: .leading, spacing: 4) { + SecureField(NSLocalizedString("Пароль", comment: "Пароль"), text: $password) + .autocapitalization(.none) + .focused($focusedField, equals: .password) .padding() .background(Color(.secondarySystemBackground)) - .cornerRadius(8) - .autocapitalization(.none) + .cornerRadius(12) .onChange(of: password) { newValue in if newValue.count > 128 { password = String(newValue.prefix(128)) } } - if !isPasswordValid && !password.isEmpty { - Text(NSLocalizedString("Пароль должен быть от 8 до 128 символов", comment: "Пароль должен быть от 6 до 32 символов")) - .foregroundColor(.red) - .font(.caption) - } + if !isPasswordValid && !password.isEmpty { + Text(NSLocalizedString("Пароль должен быть от 8 до 128 символов", comment: "")) + .foregroundColor(.red) + .font(.caption) + } + } - HStack { - SecureField(NSLocalizedString("Подтверждение пароля", comment: "Подтверждение пароля"), text: $confirmPassword) - .autocapitalization(.none) - .focused($focusedField, equals: .confirmPassword) - Spacer() - if !confirmPassword.isEmpty { - Image(systemName: isConfirmPasswordValid ? "checkmark.circle" : "xmark.circle") - .foregroundColor(isConfirmPasswordValid ? .green : .red) - } - } + VStack(alignment: .leading, spacing: 4) { + SecureField(NSLocalizedString("Подтвердите пароль", comment: ""), text: $confirmPassword) + .autocapitalization(.none) + .focused($focusedField, equals: .confirmPassword) .padding() .background(Color(.secondarySystemBackground)) - .cornerRadius(8) - .autocapitalization(.none) + .cornerRadius(12) .onChange(of: confirmPassword) { newValue in if newValue.count > 32 { confirmPassword = String(newValue.prefix(32)) } } - if !isConfirmPasswordValid && !confirmPassword.isEmpty { - Text(NSLocalizedString("Пароли не совпадают", comment: "Пароли не совпадают")) - .foregroundColor(.red) - .font(.caption) - } - - TextField(NSLocalizedString("Инвайт-код (необязательно)", comment: "Инвайт-код"), text: $inviteCode) - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(8) - .autocapitalization(.none) - .disableAutocorrection(true) - .focused($focusedField, equals: .invite) + if !isConfirmPasswordValid && !confirmPassword.isEmpty { + Text(NSLocalizedString("Пароли не совпадают", comment: "")) + .foregroundColor(.red) + .font(.caption) } - - TermsAgreementCard( - isAccepted: $viewModel.hasAcceptedTerms, - openTerms: { - viewModel.loadTermsIfNeeded() - isShowingTerms = true - } - ) - - Button(action: registerUser) { - if isLoading { - ProgressView() - .padding() - .frame(maxWidth: .infinity) - .background(Color.gray.opacity(0.6)) - .cornerRadius(8) - } else { - Text(NSLocalizedString("Зарегистрироваться", comment: "Зарегистрироваться")) - .foregroundColor(.white) - .padding() - .frame(maxWidth: .infinity) - .background(isFormValid ? Color.blue : Color.gray.opacity(0.6)) - .cornerRadius(8) - } - } - .disabled(!isFormValid) - .padding(.bottom) } - .padding() + + TextField(NSLocalizedString("Инвайт-код (необязательно)", comment: ""), text: $inviteCode) + .autocapitalization(.none) + .disableAutocorrection(true) + .focused($focusedField, equals: .invite) + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) } - } - .navigationTitle(Text(NSLocalizedString("Регистрация", comment: "Регистрация"))) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: dismissSheet) { - Text(NSLocalizedString("Закрыть", comment: "Закрыть")) + + TermsAgreementCard( + isAccepted: $viewModel.hasAcceptedTerms, + openTerms: { + viewModel.loadTermsIfNeeded() + isShowingTerms = true } - } - } - .alert(isPresented: $showError) { - Alert( - title: Text(NSLocalizedString("Ошибка регистрация", comment: "Ошибка")), - message: Text(errorMessage), - dismissButton: .default(Text(NSLocalizedString("OK", comment: ""))) ) + + Button(action: registerUser) { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding() + } else { + Text(NSLocalizedString("Зарегистрироваться", comment: "Зарегистрироваться")) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + } + } + .background(isFormValid ? Color.blue : Color.gray.opacity(0.6)) + .cornerRadius(12) + .disabled(!isFormValid) } + .padding(.vertical, 32) + } + .padding(.horizontal, 24) + .background(Color(.systemBackground).ignoresSafeArea()) + .contentShape(Rectangle()) + .onTapGesture { focusedField = nil } + .alert(isPresented: $showError) { + Alert( + title: Text(NSLocalizedString("Ошибка регистрация", comment: "Ошибка")), + message: Text(errorMessage), + dismissButton: .default(Text(NSLocalizedString("OK", comment: ""))) + ) } .fullScreenCover(isPresented: $isShowingTerms) { TermsFullScreenView( @@ -218,7 +200,7 @@ struct RegistrationView: View { viewModel.registerUser(username: username, password: password, invite: inviteCode.isEmpty ? nil : inviteCode) { success, message in isLoading = false if success { - dismissSheet() + viewModel.hasAcceptedTerms = false } else { errorMessage = message ?? NSLocalizedString("Неизвестная ошибка.", comment: "") showError = true @@ -226,11 +208,17 @@ struct RegistrationView: View { } } - private func dismissSheet() { + private func goBack() { focusedField = nil viewModel.hasAcceptedTerms = false - isPresented = false - presentationMode.wrappedValue.dismiss() + withAnimation { + viewModel.showPasswordLogin() + } + } + + private func openLanguageSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) } } @@ -239,6 +227,6 @@ struct RegistrationView_Previews: PreviewProvider { static var previews: some View { let viewModel = LoginViewModel() viewModel.isLoading = false // чтобы убрать спиннер - return RegistrationView(viewModel: viewModel, isPresented: .constant(true)) + return RegistrationView(viewModel: viewModel) } }