// // LoginView.swift // VolnahubApp // // Created by cheykrym on 09/06/2025. // import SwiftUI struct LoginView: View { @ObservedObject var viewModel: LoginViewModel @EnvironmentObject private var themeManager: ThemeManager @Environment(\.colorScheme) private var colorScheme private let themeOptions = ThemeOption.ordered @State private var isShowingRegistration = false @State private var showLegacySupportNotice = false @State private var isShowingTerms = false @State private var hasResetTermsOnAppear = false @FocusState private var focusedField: Field? private enum Field: Hashable { case username case password } private var isUsernameValid: Bool { let pattern = "^[A-Za-z0-9_]{3,32}$" return viewModel.username.range(of: pattern, options: .regularExpression) != nil } private var isPasswordValid: Bool { return viewModel.password.count >= 8 && viewModel.password.count <= 128 } private var isLoginButtonEnabled: Bool { !viewModel.isLoading && isUsernameValid && isPasswordValid && viewModel.hasAcceptedTerms } var body: some View { ZStack { Color.clear // чтобы поймать тап .contentShape(Rectangle()) .onTapGesture { focusedField = nil } VStack { HStack { Button(action: openLanguageSettings) { Text("🌍") .padding() } 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() } } .onTapGesture { focusedField = nil } Spacer() TextField(NSLocalizedString("Логин", comment: ""), text: $viewModel.username) .padding() .background(Color(.secondarySystemBackground)) .cornerRadius(8) .autocapitalization(.none) .disableAutocorrection(true) .focused($focusedField, equals: .username) .onChange(of: viewModel.username) { newValue in if newValue.count > 32 { viewModel.username = String(newValue.prefix(32)) } } // Показываем ошибку для логина if !isUsernameValid && !viewModel.username.isEmpty { Text(NSLocalizedString("Неверный логин", comment: "Неверный логин")) .foregroundColor(.red) .font(.caption) } // Показываем поле пароля SecureField(NSLocalizedString("Пароль", comment: ""), text: $viewModel.password) .padding() .background(Color(.secondarySystemBackground)) .cornerRadius(8) .autocapitalization(.none) .focused($focusedField, equals: .password) .onChange(of: viewModel.password) { newValue in if newValue.count > 32 { viewModel.password = String(newValue.prefix(32)) } } // Показываем ошибку для пароля if !isPasswordValid && !viewModel.password.isEmpty { Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль")) .foregroundColor(.red) .font(.caption) } TermsAgreementCard( isAccepted: $viewModel.hasAcceptedTerms, openTerms: { viewModel.loadTermsIfNeeded() isShowingTerms = true } ) .padding(.vertical, 12) Button(action: { viewModel.login() }) { if viewModel.isLoading { ProgressView() .progressViewStyle(CircularProgressViewStyle()) .padding() .frame(maxWidth: .infinity) .background(Color.gray.opacity(0.6)) .cornerRadius(8) } else { Text(NSLocalizedString("Войти", comment: "")) .foregroundColor(.white) .padding() .frame(maxWidth: .infinity) .background(isLoginButtonEnabled ? Color.blue : Color.gray) .cornerRadius(8) } } .disabled(!isLoginButtonEnabled) // Spacer() // Кнопка регистрации Button(action: { isShowingRegistration = true }) { Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: "Регистрация")) .foregroundColor(.blue) } .padding(.top, 10) .sheet(isPresented: $isShowingRegistration) { RegistrationView(viewModel: viewModel, isPresented: $isShowingRegistration) } Spacer() } .padding() .alert(isPresented: $viewModel.showError) { Alert( title: Text(NSLocalizedString("Ошибка авторизации", comment: "")), message: Text(viewModel.errorMessage), dismissButton: .default(Text(NSLocalizedString("OK", comment: ""))) ) } .onAppear { if !hasResetTermsOnAppear { viewModel.hasAcceptedTerms = false hasResetTermsOnAppear = true } if shouldShowLegacySupportNotice { showLegacySupportNotice = true } } .onTapGesture { focusedField = nil } if showLegacySupportNotice { LegacySupportNoticeView(isPresented: $showLegacySupportNotice) .transition(.opacity) .zIndex(1) } } .fullScreenCover(isPresented: $isShowingTerms) { TermsFullScreenView( isPresented: $isShowingTerms, title: NSLocalizedString("Правила сервиса", comment: ""), content: viewModel.termsContent, isLoading: viewModel.isLoadingTerms, errorMessage: viewModel.termsErrorMessage, onRetry: { viewModel.reloadTerms() } ) .onAppear { if viewModel.termsContent.isEmpty { viewModel.loadTermsIfNeeded() } } } } 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 var shouldShowLegacySupportNotice: Bool { #if os(iOS) let requiredVersion = OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0) return !ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion) #else return false #endif } private func openLanguageSettings() { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } private struct LegacySupportNoticeView: View { @Binding var isPresented: Bool var body: some View { ZStack { Color.black.opacity(0.5) .ignoresSafeArea() .onTapGesture { isPresented = false } VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 40, weight: .bold)) .foregroundColor(.yellow) Text("Экспериментальная поддержка iOS 15") .font(.headline) .multilineTextAlignment(.center) Text("Поддержка iOS 15 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 16+.") .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) Button { isPresented = false } label: { Text("Понятно") .bold() .frame(maxWidth: .infinity) .padding() .background(Color.blue) .foregroundColor(.white) .cornerRadius(12) } } .padding(24) .background( RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(Color(.systemBackground)) ) .frame(maxWidth: 320) .shadow(radius: 10) } } } private var selectedThemeOption: ThemeOption { ThemeOption.option(for: themeManager.theme) } 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) } } struct LoginView_Previews: PreviewProvider { static var previews: some View { let viewModel = LoginViewModel() viewModel.isLoading = false // чтобы убрать спиннер return LoginView(viewModel: viewModel) } }