ios_app_v2/yobble/Views/Login/LoginView.swift
2025-12-03 07:27:51 +03:00

768 lines
29 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
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
@State private var isShowingMessengerPrompt: Bool = true
@State private var pendingMessengerMode: Bool = UserDefaults.standard.bool(forKey: "messengerModeEnabled")
var body: some View {
ZStack {
content
.animation(.easeInOut(duration: 0.25), value: viewModel.loginFlowStep)
.allowsHitTesting(!isShowingMessengerPrompt)
.blur(radius: isShowingMessengerPrompt ? 3 : 0)
if isShowingMessengerPrompt {
Color.black.opacity(0.35)
.ignoresSafeArea()
.transition(.opacity)
MessengerModePrompt(
selection: $pendingMessengerMode,
onAccept: applyMessengerModeSelection,
onSkip: dismissMessengerPrompt
)
.padding(.horizontal, 24)
.transition(.scale.combined(with: .opacity))
}
}
.onAppear {
pendingMessengerMode = isMessengerModeEnabled
withAnimation {
isShowingMessengerPrompt = true
}
}
}
private var content: some View {
ZStack {
switch viewModel.loginFlowStep {
case .passwordlessRequest:
PasswordlessRequestView(viewModel: viewModel, shouldAutofocus: !isShowingMessengerPrompt)
.transition(.move(edge: .trailing).combined(with: .opacity))
case .passwordlessVerify:
PasswordlessVerifyView(viewModel: viewModel, shouldAutofocus: !isShowingMessengerPrompt)
.transition(.move(edge: .leading).combined(with: .opacity))
case .password:
PasswordLoginView(viewModel: viewModel)
.transition(.opacity)
case .registration:
RegistrationView(viewModel: viewModel)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
private func applyMessengerModeSelection() {
isMessengerModeEnabled = pendingMessengerMode
dismissMessengerPrompt()
}
private func dismissMessengerPrompt() {
withAnimation {
isShowingMessengerPrompt = false
}
}
}
struct PasswordLoginView: View {
@ObservedObject var viewModel: LoginViewModel
@EnvironmentObject private var themeManager: ThemeManager
@Environment(\.colorScheme) private var colorScheme
private let themeOptions = ThemeOption.ordered
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = 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
!viewModel.isLoading && isUsernameValid && isPasswordValid
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 24) {
LoginTopBar(openLanguageSettings: openLanguageSettings)
Button {
focusedField = nil
withAnimation {
viewModel.showPasswordlessRequest()
}
} label: {
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)
}
VStack(alignment: .leading, spacing: 12) {
TextField(NSLocalizedString("Логин", comment: ""), text: $viewModel.username)
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
.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(12)
.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)
}
}
VStack(alignment: .leading, spacing: 4) {
Toggle(NSLocalizedString("Режим мессенжера", comment: ""), isOn: $isMessengerModeEnabled)
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Text(isMessengerModeEnabled
? "Мессенджер-режим сейчас проработан примерно на 60%."
: "Основной режим находится в ранней разработке (около 10%).")
.font(.footnote)
.foregroundColor(.secondary)
}
Button(action: {
viewModel.login()
}) {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.frame(maxWidth: .infinity)
.padding()
} else {
Text(NSLocalizedString("Войти", comment: ""))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
}
}
.background(isLoginButtonEnabled ? Color.blue : Color.gray)
.cornerRadius(12)
.disabled(!isLoginButtonEnabled)
Button(action: {
viewModel.hasAcceptedTerms = false
withAnimation {
viewModel.showRegistration()
}
}) {
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: "Регистрация"))
.foregroundColor(.blue)
.frame(maxWidth: .infinity)
}
.padding(.top, 4)
Spacer(minLength: 0)
}
.padding(.vertical, 32)
}
.padding(.horizontal, 24)
.background(Color(.systemBackground).ignoresSafeArea())
.contentShape(Rectangle())
.onTapGesture {
focusedField = nil
}
.loginErrorAlert(viewModel: viewModel)
.onAppear {
if !hasResetTermsOnAppear {
viewModel.hasAcceptedTerms = false
hasResetTermsOnAppear = true
}
if shouldShowLegacySupportNotice {
showLegacySupportNotice = true
}
}
.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()
}
}
}
.overlay(alignment: .center) {
if showLegacySupportNotice {
LegacySupportNoticeView(isPresented: $showLegacySupportNotice)
.transition(.opacity)
}
}
}
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)
}
}
private struct PasswordlessRequestView: View {
@ObservedObject var viewModel: LoginViewModel
let shouldAutofocus: Bool
@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(perform: scheduleFocusIfNeeded)
.onChange(of: shouldAutofocus) { newValue in
if newValue {
scheduleFocusIfNeeded()
} else {
isFieldFocused = false
}
}
.loginErrorAlert(viewModel: viewModel)
}
private func openLanguageSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
private func scheduleFocusIfNeeded() {
guard shouldAutofocus else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if shouldAutofocus {
isFieldFocused = true
}
}
}
}
private struct PasswordlessVerifyView: View {
@ObservedObject var viewModel: LoginViewModel
let shouldAutofocus: Bool
@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(perform: scheduleFocusIfNeeded)
.onChange(of: shouldAutofocus) { newValue in
if newValue {
scheduleFocusIfNeeded()
} else {
isCodeFieldFocused = false
}
}
.loginErrorAlert(viewModel: viewModel)
}
private func openLanguageSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
private func scheduleFocusIfNeeded() {
guard shouldAutofocus else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if shouldAutofocus {
isCodeFieldFocused = true
}
}
}
}
private struct OTPInputView: View {
@Binding var code: String
var length: Int = 6
let isFocused: FocusState<Bool>.Binding
var body: some View {
ZStack {
HStack(spacing: 12) {
ForEach(0..<length, id: \.self) { index in
Text(symbol(at: index))
.font(.title2.monospacedDigit())
.frame(width: 48, height: 56)
.background(
RoundedRectangle(cornerRadius: 12)
.stroke(borderColor(for: index), lineWidth: 1.5)
)
}
}
TextField("", text: textBinding)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.focused(isFocused)
.frame(width: 0, height: 0)
.opacity(0.01)
}
.padding(.vertical, 8)
.contentShape(Rectangle())
.onTapGesture {
isFocused.wrappedValue = true
}
}
private var textBinding: Binding<String> {
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 MessengerModePrompt: View {
@Binding var selection: Bool
let onAccept: () -> Void
let onSkip: () -> Void
var body: some View {
VStack(spacing: 20) {
Text(NSLocalizedString("Какой режим попробовать?", comment: ""))
.font(.title3.bold())
.multilineTextAlignment(.center)
Text(NSLocalizedString("По умолчанию это полноценная соцсеть с лентой, историями и подписками. Если нужно только общение без лишнего контента, переключитесь на режим “Только чаты”. Переключить режим можно в любой момент.", comment: ""))
.font(.callout)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
VStack(spacing: 12) {
optionButton(
title: NSLocalizedString("Соцсеть", comment: ""),
subtitle: NSLocalizedString("Лента, истории, подписки", comment: ""),
isMessenger: false
)
optionButton(
title: NSLocalizedString("Только чаты", comment: ""),
subtitle: NSLocalizedString("Минимум отвлечений, чистый мессенджер", comment: ""),
isMessenger: true
)
}
HStack(spacing: 12) {
// Button(action: onSkip) {
// Text(NSLocalizedString("Позже", comment: ""))
// .font(.callout)
// .frame(maxWidth: .infinity)
// .padding()
// .background(
// RoundedRectangle(cornerRadius: 14, style: .continuous)
// .stroke(Color.secondary.opacity(0.3))
// )
// }
Button(action: onAccept) {
Text(NSLocalizedString("Применить", comment: ""))
.font(.callout.bold())
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.accentColor)
)
}
}
}
.padding(24)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color(.systemBackground))
)
.shadow(color: Color.black.opacity(0.2), radius: 30, x: 0, y: 12)
}
private func optionButton(title: String, subtitle: String, isMessenger: Bool) -> some View {
Button {
selection = isMessenger
} label: {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(title)
.font(.headline)
Spacer()
if selection == isMessenger {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
}
}
Text(subtitle)
.font(.footnote)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(selection == isMessenger ? Color.accentColor.opacity(0.15) : Color(.secondarySystemBackground))
)
}
.buttonStyle(.plain)
}
}
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)
preview(step: .registration)
}
.environmentObject(ThemeManager())
}
private static func preview(step: LoginViewModel.LoginFlowStep) -> some View {
let viewModel = LoginViewModel()
viewModel.isLoading = false
viewModel.loginFlowStep = step
viewModel.passwordlessLogin = "preview@yobble.app"
viewModel.verificationCode = "123456"
return LoginView(viewModel: viewModel)
}
}