ios_app_v2/yobble/Views/Login/LoginView.swift
2025-10-23 19:22:40 +03:00

316 lines
12 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.

//
// 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
viewModel.hasAcceptedTerms = false
}) {
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)
}
}