215 lines
7.7 KiB
Swift
215 lines
7.7 KiB
Swift
//
|
||
// 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
|
||
@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
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
var isButtonEnabled: Bool {
|
||
!viewModel.isLoading && isUsernameValid && isPasswordValid
|
||
}
|
||
|
||
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(isButtonEnabled ? Color.blue : Color.gray)
|
||
.cornerRadius(8)
|
||
}
|
||
}
|
||
.disabled(!isButtonEnabled)
|
||
|
||
// 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: "")))
|
||
)
|
||
}
|
||
.onTapGesture {
|
||
focusedField = nil
|
||
}
|
||
}
|
||
}
|
||
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 openLanguageSettings() {
|
||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||
UIApplication.shared.open(url)
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|