Compare commits
No commits in common. "1cec8aee3eea1bccd3eb85219f2c1baa9cdf2fde" and "e269647d4192029d0536b8b472b8e68adbae9069" have entirely different histories.
1cec8aee3e
...
e269647d41
@ -83,26 +83,6 @@ final class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestLoginCode(identifier: String, completion: @escaping (Bool, String?) -> Void) {
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[AuthService] requestLoginCode placeholder for \(identifier)")
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.global().asyncAfter(deadline: .now() + 0.8) {
|
|
||||||
completion(true, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loginWithCode(identifier: String, code: String, completion: @escaping (Bool, String?) -> Void) {
|
|
||||||
if AppConfig.DEBUG {
|
|
||||||
print("[AuthService] loginWithCode placeholder for \(identifier) using code \(code)")
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
|
|
||||||
completion(false, NSLocalizedString("Вход по коду пока недоступен. Заглушка.", comment: ""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func register(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
|
func register(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
|
||||||
let payload = RegisterRequest(login: username, password: password, invite: invite)
|
let payload = RegisterRequest(login: username, password: password, invite: invite)
|
||||||
guard let body = try? JSONEncoder().encode(payload) else {
|
guard let body = try? JSONEncoder().encode(payload) else {
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "ru",
|
"sourceLanguage" : "ru",
|
||||||
"strings" : {
|
"strings" : {
|
||||||
"" : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"(без текста)" : {
|
"(без текста)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@ -270,24 +267,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Введите код" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Введите код из приложения" : {
|
"Введите код из приложения" : {
|
||||||
"comment" : "Поле ввода кода 2FA"
|
"comment" : "Поле ввода кода 2FA"
|
||||||
},
|
|
||||||
"Введите логин" : {
|
|
||||||
"comment" : "Логин"
|
|
||||||
},
|
|
||||||
"Введите логин и мы отправим шестизначный код подтверждения." : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"Введите логин." : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Введите пароль" : {
|
"Введите пароль" : {
|
||||||
"comment" : "Пароль\nПоле ввода пароля на приложение"
|
"comment" : "Поле ввода пароля на приложение"
|
||||||
},
|
},
|
||||||
"Веб" : {
|
"Веб" : {
|
||||||
"comment" : "Тип сессии — веб"
|
"comment" : "Тип сессии — веб"
|
||||||
@ -362,9 +347,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Войти по паролю" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Все" : {
|
"Все" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -388,18 +370,9 @@
|
|||||||
},
|
},
|
||||||
"Всего сессий" : {
|
"Всего сессий" : {
|
||||||
"comment" : "Сводка по количеству сессий"
|
"comment" : "Сводка по количеству сессий"
|
||||||
},
|
|
||||||
"Вход" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Вход и защита аккаунта (заглушка)" : {
|
"Вход и защита аккаунта (заглушка)" : {
|
||||||
"comment" : "Раздел настроек безопасности для аутентификации"
|
"comment" : "Раздел настроек безопасности для аутентификации"
|
||||||
},
|
|
||||||
"Вход по коду пока недоступен. Заглушка." : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"Вход по паролю" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Вы" : {
|
"Вы" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -540,18 +513,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Если предпочитаете классический вход, используйте логин и пароль." : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Заблокированные" : {
|
"Заблокированные" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Заблокировать контакт" : {
|
"Заблокировать контакт" : {
|
||||||
"comment" : "Contacts context action block"
|
"comment" : "Contacts context action block"
|
||||||
},
|
|
||||||
"Забыли пароль? Сбросить" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Завершить" : {
|
"Завершить" : {
|
||||||
"comment" : "Кнопка завершения конкретной сессии\nПодтверждение завершения других сессий\nПодтверждение завершения конкретной сессии"
|
"comment" : "Кнопка завершения конкретной сессии\nПодтверждение завершения других сессий\nПодтверждение завершения конкретной сессии"
|
||||||
@ -714,9 +681,6 @@
|
|||||||
},
|
},
|
||||||
"Изменить контакт" : {
|
"Изменить контакт" : {
|
||||||
"comment" : "Contacts context action edit"
|
"comment" : "Contacts context action edit"
|
||||||
},
|
|
||||||
"Изменить способ входа" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Изображение" : {
|
"Изображение" : {
|
||||||
"comment" : "Image message placeholder"
|
"comment" : "Image message placeholder"
|
||||||
@ -759,9 +723,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Какой режим попробовать?" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Кастомная" : {
|
"Кастомная" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -778,9 +739,6 @@
|
|||||||
},
|
},
|
||||||
"Код дружбы" : {
|
"Код дружбы" : {
|
||||||
"comment" : "Friend code badge"
|
"comment" : "Friend code badge"
|
||||||
},
|
|
||||||
"Код может прийти по почте, push или в другое подключенное приложение." : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Код принят" : {
|
"Код принят" : {
|
||||||
"comment" : "Заголовок успешного подтверждения кода 2FA"
|
"comment" : "Заголовок успешного подтверждения кода 2FA"
|
||||||
@ -886,9 +844,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Лента, истории, подписки" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Лицо" : {
|
"Лицо" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -947,6 +902,9 @@
|
|||||||
},
|
},
|
||||||
"Мессенджер-режим сейчас проработан примерно на 50%." : {
|
"Мессенджер-режим сейчас проработан примерно на 50%." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Мессенджер-режим сейчас проработан примерно на 60%." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Мини-приложения" : {
|
"Мини-приложения" : {
|
||||||
"comment" : "Applets",
|
"comment" : "Applets",
|
||||||
@ -958,9 +916,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Минимум отвлечений, чистый мессенджер" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Мобильное приложение" : {
|
"Мобильное приложение" : {
|
||||||
"comment" : "Тип сессии — мобильное приложение"
|
"comment" : "Тип сессии — мобильное приложение"
|
||||||
@ -997,9 +952,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Мы отправили код на %@" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Мы отправим код подтверждения на привязанный email каждый раз при входе." : {
|
"Мы отправим код подтверждения на привязанный email каждый раз при входе." : {
|
||||||
"comment" : "Описание работы кодов при входе"
|
"comment" : "Описание работы кодов при входе"
|
||||||
@ -1039,9 +991,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Назад" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Напишите нам через форму обратной связи в разделе \"Поддержка\"." : {
|
"Напишите нам через форму обратной связи в разделе \"Поддержка\"." : {
|
||||||
"comment" : "FAQ answer: support"
|
"comment" : "FAQ answer: support"
|
||||||
@ -1144,9 +1093,6 @@
|
|||||||
},
|
},
|
||||||
"Начальная настройка" : {
|
"Начальная настройка" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Не получили код?" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Не удалось выполнить поиск." : {
|
"Не удалось выполнить поиск." : {
|
||||||
"comment" : "Search error fallback\nSearch service decoding error"
|
"comment" : "Search error fallback\nSearch service decoding error"
|
||||||
@ -1234,9 +1180,6 @@
|
|||||||
},
|
},
|
||||||
"Не удалось открыть чат." : {
|
"Не удалось открыть чат." : {
|
||||||
"comment" : "Chat creation fallback"
|
"comment" : "Chat creation fallback"
|
||||||
},
|
|
||||||
"Не удалось отправить код." : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Не удалось отправить сообщение." : {
|
"Не удалось отправить сообщение." : {
|
||||||
|
|
||||||
@ -1539,9 +1482,6 @@
|
|||||||
},
|
},
|
||||||
"Отображаемое имя" : {
|
"Отображаемое имя" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Отправить код ещё раз" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Отправить отзыв" : {
|
"Отправить отзыв" : {
|
||||||
"comment" : "feedback: submit button",
|
"comment" : "feedback: submit button",
|
||||||
@ -1679,7 +1619,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Пароли не совпадают" : {
|
"Пароли не совпадают" : {
|
||||||
"comment" : "Заголовок ошибки несовпадения паролей",
|
"comment" : "Заголовок ошибки несовпадения паролей\nПароли не совпадают",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -1701,7 +1641,6 @@
|
|||||||
},
|
},
|
||||||
"Пароль" : {
|
"Пароль" : {
|
||||||
"comment" : "Пароль",
|
"comment" : "Пароль",
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -1765,12 +1704,6 @@
|
|||||||
},
|
},
|
||||||
"Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : {
|
"Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : {
|
||||||
"comment" : "FAQ answer: reset password"
|
"comment" : "FAQ answer: reset password"
|
||||||
},
|
|
||||||
"Перейти к входу по коду" : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"По умолчанию это полноценная соцсеть с лентой, историями и подписками. Если нужно только общение без лишнего контента, переключитесь на режим “Только чаты”. Переключить режим можно в любой момент." : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Повторите пароль" : {
|
"Повторите пароль" : {
|
||||||
"comment" : "Поле подтверждения пароля на приложение"
|
"comment" : "Поле подтверждения пароля на приложение"
|
||||||
@ -1822,15 +1755,9 @@
|
|||||||
},
|
},
|
||||||
"Подключение" : {
|
"Подключение" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Подтвердите пароль" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Подтвердить" : {
|
"Подтвердить" : {
|
||||||
"comment" : "Кнопка подтверждения кода 2FA"
|
"comment" : "Кнопка подтверждения кода 2FA"
|
||||||
},
|
|
||||||
"Подтвердить вход" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Подтверждение email" : {
|
"Подтверждение email" : {
|
||||||
"comment" : "Раздел подтверждения email"
|
"comment" : "Раздел подтверждения email"
|
||||||
@ -1856,9 +1783,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Позже" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Поиск" : {
|
"Поиск" : {
|
||||||
|
|
||||||
@ -1911,9 +1835,6 @@
|
|||||||
},
|
},
|
||||||
"Получать коды на email при входе" : {
|
"Получать коды на email при входе" : {
|
||||||
"comment" : "Переключатель отправки кодов при входе"
|
"comment" : "Переключатель отправки кодов при входе"
|
||||||
},
|
|
||||||
"Получить код" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Получить ответ от команды" : {
|
"Получить ответ от команды" : {
|
||||||
"comment" : "feedback: contact toggle",
|
"comment" : "feedback: contact toggle",
|
||||||
@ -1955,9 +1876,6 @@
|
|||||||
},
|
},
|
||||||
"Понятно" : {
|
"Понятно" : {
|
||||||
"comment" : "Chat creation error acknowledgment"
|
"comment" : "Chat creation error acknowledgment"
|
||||||
},
|
|
||||||
"Попробовать снова можно через %d сек" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Попробуйте изменить запрос поиска." : {
|
"Попробуйте изменить запрос поиска." : {
|
||||||
|
|
||||||
@ -2107,9 +2025,6 @@
|
|||||||
},
|
},
|
||||||
"Проверочный код" : {
|
"Проверочный код" : {
|
||||||
"comment" : "Раздел верификации 2FA"
|
"comment" : "Раздел верификации 2FA"
|
||||||
},
|
|
||||||
"Проверьте введённый код и попробуйте снова." : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Проверьте ввод и попробуйте снова." : {
|
"Проверьте ввод и попробуйте снова." : {
|
||||||
"comment" : "Сообщение ошибки несовпадения паролей"
|
"comment" : "Сообщение ошибки несовпадения паролей"
|
||||||
@ -2174,9 +2089,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Прямого сброса пароля нет: сменить его можно только из настроек, уже будучи в аккаунте. Если привязана почта или другое 2FA-устройство, воспользуйтесь входом по коду - он подтвердит вашу личность и пустит в аккаунт. После входа откройте настройки → безопасность и задайте новый пароль." : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Публичная информация" : {
|
"Публичная информация" : {
|
||||||
|
|
||||||
@ -2296,9 +2208,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Режим" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Режим мессенжера" : {
|
"Режим мессенжера" : {
|
||||||
|
|
||||||
@ -2312,9 +2221,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Сброс пароля" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Сбросить по умолчанию" : {
|
"Сбросить по умолчанию" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -2454,9 +2360,6 @@
|
|||||||
},
|
},
|
||||||
"Согласиться с правилами" : {
|
"Согласиться с правилами" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Создайте логин и пароль. При желании добавьте инвайт." : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Создать новые коды" : {
|
"Создать новые коды" : {
|
||||||
"comment" : "Кнопка генерации резервных кодов"
|
"comment" : "Кнопка генерации резервных кодов"
|
||||||
@ -2493,9 +2396,6 @@
|
|||||||
},
|
},
|
||||||
"Сохранить пароль" : {
|
"Сохранить пароль" : {
|
||||||
"comment" : "Кнопка сохранения пароля на приложение"
|
"comment" : "Кнопка сохранения пароля на приложение"
|
||||||
},
|
|
||||||
"Соцсеть" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Спасибо! Мы получили ваш отзыв" : {
|
"Спасибо! Мы получили ваш отзыв" : {
|
||||||
"comment" : "feedback: success title",
|
"comment" : "feedback: success title",
|
||||||
@ -2595,9 +2495,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"Только чаты" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Ты шо ебанутый? А ниче тот факт что новый пароль должен отличаться от старого." : {
|
"Ты шо ебанутый? А ниче тот факт что новый пароль должен отличаться от старого." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@ -23,33 +23,10 @@ class LoginViewModel: ObservableObject {
|
|||||||
@Published var termsContent: String = ""
|
@Published var termsContent: String = ""
|
||||||
@Published var termsErrorMessage: String?
|
@Published var termsErrorMessage: String?
|
||||||
@Published var onboardingDestination: OnboardingDestination?
|
@Published var onboardingDestination: OnboardingDestination?
|
||||||
@Published var loginFlowStep: LoginFlowStep = .passwordlessRequest
|
|
||||||
@Published var passwordlessLogin: String = ""
|
|
||||||
@Published var verificationCode: String = "" {
|
|
||||||
didSet {
|
|
||||||
let filtered = verificationCode
|
|
||||||
.filter { $0.isNumber }
|
|
||||||
.prefix(Constants.verificationCodeLength)
|
|
||||||
if filtered != verificationCode {
|
|
||||||
verificationCode = String(filtered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@Published var isSendingCode: Bool = false
|
|
||||||
@Published var isVerifyingCode: Bool = false
|
|
||||||
@Published var resendSecondsRemaining: Int = 0
|
|
||||||
|
|
||||||
private let authService = AuthService()
|
private let authService = AuthService()
|
||||||
private let socketService = SocketService.shared
|
private let socketService = SocketService.shared
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var resendTimer: Timer?
|
|
||||||
|
|
||||||
enum LoginFlowStep: Equatable {
|
|
||||||
case passwordlessRequest
|
|
||||||
case passwordlessVerify
|
|
||||||
case password
|
|
||||||
case registration
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ChatLoadingState: Equatable {
|
enum ChatLoadingState: Equatable {
|
||||||
case idle
|
case idle
|
||||||
@ -75,10 +52,6 @@ class LoginViewModel: ObservableObject {
|
|||||||
autoLogin()
|
autoLogin()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
resendTimer?.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func observeSocketState() {
|
private func observeSocketState() {
|
||||||
socketService.connectionStatePublisher
|
socketService.connectionStatePublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
@ -152,90 +125,6 @@ class LoginViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestPasswordlessCode() {
|
|
||||||
let trimmedLogin = passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
|
|
||||||
guard !trimmedLogin.isEmpty else {
|
|
||||||
errorMessage = NSLocalizedString("Введите логин.", comment: "")
|
|
||||||
showError = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isSendingCode = true
|
|
||||||
showError = false
|
|
||||||
|
|
||||||
authService.requestLoginCode(identifier: trimmedLogin) { [weak self] success, message in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
self.isSendingCode = false
|
|
||||||
|
|
||||||
if success {
|
|
||||||
self.passwordlessLogin = trimmedLogin
|
|
||||||
self.verificationCode = ""
|
|
||||||
self.loginFlowStep = .passwordlessVerify
|
|
||||||
self.startResendTimer()
|
|
||||||
} else {
|
|
||||||
self.errorMessage = message ?? NSLocalizedString("Не удалось отправить код.", comment: "")
|
|
||||||
self.showError = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyPasswordlessCode() {
|
|
||||||
guard verificationCode.count == Constants.verificationCodeLength,
|
|
||||||
!passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isVerifyingCode = true
|
|
||||||
showError = false
|
|
||||||
|
|
||||||
authService.loginWithCode(identifier: passwordlessLogin, code: verificationCode) { [weak self] success, message in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
self.isVerifyingCode = false
|
|
||||||
|
|
||||||
if success {
|
|
||||||
self.resendTimer?.invalidate()
|
|
||||||
self.loadStoredUser()
|
|
||||||
self.isLoggedIn = true
|
|
||||||
self.socketService.connectForCurrentUser()
|
|
||||||
} else {
|
|
||||||
self.errorMessage = message ?? NSLocalizedString("Проверьте введённый код и попробуйте снова.", comment: "")
|
|
||||||
self.showError = true
|
|
||||||
self.verificationCode = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resendPasswordlessCode() {
|
|
||||||
guard resendSecondsRemaining == 0 else { return }
|
|
||||||
requestPasswordlessCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
func showPasswordLogin() {
|
|
||||||
resendTimer?.invalidate()
|
|
||||||
loginFlowStep = .password
|
|
||||||
}
|
|
||||||
|
|
||||||
func showPasswordlessRequest() {
|
|
||||||
loginFlowStep = .passwordlessRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
func backToPasswordlessRequest() {
|
|
||||||
verificationCode = ""
|
|
||||||
loginFlowStep = .passwordlessRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
func showRegistration() {
|
|
||||||
loginFlowStep = .registration
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func registerUser(username: String, password: String, invite: String?, completion: @escaping (Bool, String?) -> Void) {
|
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
|
authService.register(username: username, password: password, invite: invite) { [weak self] success, message in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -339,45 +228,4 @@ class LoginViewModel: ObservableObject {
|
|||||||
termsErrorMessage = nil
|
termsErrorMessage = nil
|
||||||
loadTermsIfNeeded()
|
loadTermsIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startResendTimer(duration: Int = Constants.defaultResendDelay) {
|
|
||||||
resendTimer?.invalidate()
|
|
||||||
resendSecondsRemaining = duration
|
|
||||||
|
|
||||||
guard duration > 0 else { return }
|
|
||||||
|
|
||||||
resendTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
|
|
||||||
guard let self else {
|
|
||||||
timer.invalidate()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.resendSecondsRemaining > 0 {
|
|
||||||
self.resendSecondsRemaining -= 1
|
|
||||||
} else {
|
|
||||||
timer.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension LoginViewModel {
|
|
||||||
var isVerificationCodeComplete: Bool {
|
|
||||||
verificationCode.count == Constants.verificationCodeLength
|
|
||||||
}
|
|
||||||
|
|
||||||
var canRequestPasswordlessCode: Bool {
|
|
||||||
!passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSendingCode
|
|
||||||
}
|
|
||||||
|
|
||||||
var canVerifyPasswordlessCode: Bool {
|
|
||||||
isVerificationCodeComplete && !isVerifyingCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension LoginViewModel {
|
|
||||||
enum Constants {
|
|
||||||
static let verificationCodeLength = 6
|
|
||||||
static let defaultResendDelay = 60
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct LoginTopBar: View {
|
|
||||||
let openLanguageSettings: () -> Void
|
|
||||||
let onShowModePrompt: (() -> 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()
|
|
||||||
if let onShowModePrompt {
|
|
||||||
Button(action: onShowModePrompt) {
|
|
||||||
Text(NSLocalizedString("Режим", comment: ""))
|
|
||||||
.font(.footnote.bold())
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,98 +9,15 @@ import SwiftUI
|
|||||||
|
|
||||||
struct LoginView: View {
|
struct LoginView: View {
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
@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 {
|
|
||||||
showModePrompt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var content: some View {
|
|
||||||
ZStack {
|
|
||||||
switch viewModel.loginFlowStep {
|
|
||||||
case .passwordlessRequest:
|
|
||||||
PasswordlessRequestView(
|
|
||||||
viewModel: viewModel,
|
|
||||||
shouldAutofocus: !isShowingMessengerPrompt,
|
|
||||||
onShowModePrompt: showModePrompt
|
|
||||||
)
|
|
||||||
.transition(unifiedTransition)
|
|
||||||
case .passwordlessVerify:
|
|
||||||
PasswordlessVerifyView(
|
|
||||||
viewModel: viewModel,
|
|
||||||
shouldAutofocus: !isShowingMessengerPrompt,
|
|
||||||
onShowModePrompt: showModePrompt
|
|
||||||
)
|
|
||||||
.transition(unifiedTransition)
|
|
||||||
case .password:
|
|
||||||
PasswordLoginView(viewModel: viewModel, onShowModePrompt: showModePrompt)
|
|
||||||
.transition(unifiedTransition)
|
|
||||||
case .registration:
|
|
||||||
RegistrationView(viewModel: viewModel, onShowModePrompt: showModePrompt)
|
|
||||||
.transition(unifiedTransition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showModePrompt() {
|
|
||||||
pendingMessengerMode = isMessengerModeEnabled
|
|
||||||
withAnimation {
|
|
||||||
isShowingMessengerPrompt = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var unifiedTransition: AnyTransition {
|
|
||||||
.opacity.combined(with: .scale(scale: 0.98, anchor: .center))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyMessengerModeSelection() {
|
|
||||||
isMessengerModeEnabled = pendingMessengerMode
|
|
||||||
dismissMessengerPrompt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dismissMessengerPrompt() {
|
|
||||||
withAnimation {
|
|
||||||
isShowingMessengerPrompt = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PasswordLoginView: View {
|
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
|
||||||
let onShowModePrompt: () -> Void
|
|
||||||
@EnvironmentObject private var themeManager: ThemeManager
|
@EnvironmentObject private var themeManager: ThemeManager
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
private let themeOptions = ThemeOption.ordered
|
private let themeOptions = ThemeOption.ordered
|
||||||
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
|
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
|
||||||
|
|
||||||
|
@State private var isShowingRegistration = false
|
||||||
@State private var showLegacySupportNotice = false
|
@State private var showLegacySupportNotice = false
|
||||||
@State private var isShowingTerms = false
|
@State private var isShowingTerms = false
|
||||||
@State private var hasResetTermsOnAppear = false
|
@State private var hasResetTermsOnAppear = false
|
||||||
@State private var isShowingForgotPassword = false
|
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
|
|
||||||
private enum Field: Hashable {
|
private enum Field: Hashable {
|
||||||
@ -123,36 +40,45 @@ struct PasswordLoginView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
|
||||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: onShowModePrompt)
|
|
||||||
|
|
||||||
Button {
|
ZStack {
|
||||||
|
Color.clear // чтобы поймать тап
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
focusedField = nil
|
focusedField = nil
|
||||||
withAnimation {
|
}
|
||||||
viewModel.showPasswordlessRequest()
|
|
||||||
|
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: {
|
} label: {
|
||||||
HStack(spacing: 6) {
|
Image(systemName: themeIconName)
|
||||||
Image(systemName: "arrow.left")
|
.padding()
|
||||||
Text(NSLocalizedString("Назад", comment: ""))
|
|
||||||
}
|
}
|
||||||
.font(.footnote)
|
}
|
||||||
.foregroundColor(.blue)
|
.onTapGesture {
|
||||||
|
focusedField = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
Spacer()
|
||||||
Text(NSLocalizedString("Вход по паролю", comment: ""))
|
|
||||||
.font(.largeTitle).bold()
|
|
||||||
// Text(NSLocalizedString("Если предпочитаете классический вход, используйте логин и пароль.", comment: ""))
|
|
||||||
// .foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
TextField(NSLocalizedString("Логин", comment: ""), text: $viewModel.username)
|
||||||
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.username)
|
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color(.secondarySystemBackground))
|
||||||
.cornerRadius(12)
|
.cornerRadius(8)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
.focused($focusedField, equals: .username)
|
.focused($focusedField, equals: .username)
|
||||||
@ -162,16 +88,18 @@ struct PasswordLoginView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Показываем ошибку для логина
|
||||||
if !isUsernameValid && !viewModel.username.isEmpty {
|
if !isUsernameValid && !viewModel.username.isEmpty {
|
||||||
Text(NSLocalizedString("Неверный логин", comment: "Неверный логин"))
|
Text(NSLocalizedString("Неверный логин", comment: "Неверный логин"))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureField(NSLocalizedString("Введите пароль", comment: ""), text: $viewModel.password)
|
// Показываем поле пароля
|
||||||
|
SecureField(NSLocalizedString("Пароль", comment: ""), text: $viewModel.password)
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color(.secondarySystemBackground))
|
||||||
.cornerRadius(12)
|
.cornerRadius(8)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.focused($focusedField, equals: .password)
|
.focused($focusedField, equals: .password)
|
||||||
.onChange(of: viewModel.password) { newValue in
|
.onChange(of: viewModel.password) { newValue in
|
||||||
@ -180,22 +108,32 @@ struct PasswordLoginView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Показываем ошибку для пароля
|
||||||
if !isPasswordValid && !viewModel.password.isEmpty {
|
if !isPasswordValid && !viewModel.password.isEmpty {
|
||||||
Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль"))
|
Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль"))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// VStack(alignment: .leading, spacing: 4) {
|
// TermsAgreementCard(
|
||||||
// Toggle(NSLocalizedString("Режим мессенжера", comment: ""), isOn: $isMessengerModeEnabled)
|
// isAccepted: $viewModel.hasAcceptedTerms,
|
||||||
// .toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
// openTerms: {
|
||||||
// Text(isMessengerModeEnabled
|
// viewModel.loadTermsIfNeeded()
|
||||||
// ? "Мессенджер-режим сейчас проработан примерно на 60%."
|
// isShowingTerms = true
|
||||||
// : "Основной режим находится в ранней разработке (около 10%).")
|
|
||||||
// .font(.footnote)
|
|
||||||
// .foregroundColor(.secondary)
|
|
||||||
// }
|
// }
|
||||||
|
// )
|
||||||
|
// .padding(.vertical, 12)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Toggle(NSLocalizedString("Режим мессенжера", comment: ""), isOn: $isMessengerModeEnabled)
|
||||||
|
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||||
|
Text(isMessengerModeEnabled
|
||||||
|
? "Мессенджер-режим сейчас проработан примерно на 60%."
|
||||||
|
: "Основной режим находится в ранней разработке (около 10%).")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.login()
|
viewModel.login()
|
||||||
@ -203,39 +141,47 @@ struct PasswordLoginView: View {
|
|||||||
if viewModel.isLoading {
|
if viewModel.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(CircularProgressViewStyle())
|
.progressViewStyle(CircularProgressViewStyle())
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color.gray.opacity(0.6))
|
||||||
|
.cornerRadius(8)
|
||||||
} else {
|
} else {
|
||||||
Text(NSLocalizedString("Войти", comment: ""))
|
Text(NSLocalizedString("Войти", comment: ""))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
.padding()
|
||||||
}
|
.frame(maxWidth: .infinity)
|
||||||
}
|
|
||||||
.background(isLoginButtonEnabled ? Color.blue : Color.gray)
|
.background(isLoginButtonEnabled ? Color.blue : Color.gray)
|
||||||
.cornerRadius(12)
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
.disabled(!isLoginButtonEnabled)
|
.disabled(!isLoginButtonEnabled)
|
||||||
|
|
||||||
Button(action: {
|
// Spacer()
|
||||||
isShowingForgotPassword = true
|
|
||||||
}) {
|
|
||||||
Text(NSLocalizedString("Забыли пароль? Сбросить", comment: ""))
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.padding(.top, 4)
|
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
// Кнопка регистрации
|
||||||
|
Button(action: {
|
||||||
|
isShowingRegistration = true
|
||||||
|
viewModel.hasAcceptedTerms = false
|
||||||
|
}) {
|
||||||
|
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: "Регистрация"))
|
||||||
|
.foregroundColor(.blue)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 32)
|
.padding(.top, 10)
|
||||||
|
.sheet(isPresented: $isShowingRegistration) {
|
||||||
|
RegistrationView(viewModel: viewModel, isPresented: $isShowingRegistration)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.background(Color(.systemBackground).ignoresSafeArea())
|
Spacer()
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
}
|
||||||
focusedField = nil
|
.padding()
|
||||||
|
.alert(isPresented: $viewModel.showError) {
|
||||||
|
Alert(
|
||||||
|
title: Text(NSLocalizedString("Ошибка авторизации", comment: "")),
|
||||||
|
message: Text(viewModel.errorMessage),
|
||||||
|
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.loginErrorAlert(viewModel: viewModel)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if !hasResetTermsOnAppear {
|
if !hasResetTermsOnAppear {
|
||||||
viewModel.hasAcceptedTerms = false
|
viewModel.hasAcceptedTerms = false
|
||||||
@ -245,6 +191,15 @@ struct PasswordLoginView: View {
|
|||||||
showLegacySupportNotice = true
|
showLegacySupportNotice = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
focusedField = nil
|
||||||
|
}
|
||||||
|
if showLegacySupportNotice {
|
||||||
|
LegacySupportNoticeView(isPresented: $showLegacySupportNotice)
|
||||||
|
.transition(.opacity)
|
||||||
|
.zIndex(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
.fullScreenCover(isPresented: $isShowingTerms) {
|
.fullScreenCover(isPresented: $isShowingTerms) {
|
||||||
TermsFullScreenView(
|
TermsFullScreenView(
|
||||||
isPresented: $isShowingTerms,
|
isPresented: $isShowingTerms,
|
||||||
@ -262,22 +217,6 @@ struct PasswordLoginView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay(alignment: .center) {
|
|
||||||
if showLegacySupportNotice {
|
|
||||||
LegacySupportNoticeView(isPresented: $showLegacySupportNotice)
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $isShowingForgotPassword) {
|
|
||||||
ForgotPasswordInfoView {
|
|
||||||
isShowingForgotPassword = false
|
|
||||||
withAnimation {
|
|
||||||
viewModel.showPasswordlessRequest()
|
|
||||||
}
|
|
||||||
} onDismiss: {
|
|
||||||
isShowingForgotPassword = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
private var themeIconName: String {
|
private var themeIconName: String {
|
||||||
switch themeManager.theme {
|
switch themeManager.theme {
|
||||||
@ -380,478 +319,10 @@ struct PasswordLoginView: View {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct PasswordlessRequestView: View {
|
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
|
||||||
let shouldAutofocus: Bool
|
|
||||||
let onShowModePrompt: () -> Void
|
|
||||||
@FocusState private var isFieldFocused: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
|
||||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: onShowModePrompt)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(NSLocalizedString("Вход", comment: ""))
|
|
||||||
.font(.largeTitle).bold()
|
|
||||||
// Text(NSLocalizedString("Введите логин и мы отправим шестизначный код подтверждения.", comment: ""))
|
|
||||||
// .foregroundColor(.secondary)
|
|
||||||
// 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()
|
|
||||||
Text(NSLocalizedString("Войти", comment: ""))
|
|
||||||
.bold()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.background(viewModel.canRequestPasswordlessCode ? Color.blue : Color.gray)
|
|
||||||
.cornerRadius(12)
|
|
||||||
.disabled(!viewModel.canRequestPasswordlessCode)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Button {
|
|
||||||
viewModel.hasAcceptedTerms = false
|
|
||||||
withAnimation {
|
|
||||||
viewModel.showRegistration()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: ""))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Button(action: {
|
|
||||||
// viewModel.hasAcceptedTerms = false
|
|
||||||
// withAnimation {
|
|
||||||
// viewModel.showRegistration()
|
|
||||||
// }
|
|
||||||
// }) {
|
|
||||||
// Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: "Регистрация"))
|
|
||||||
// .foregroundColor(.blue)
|
|
||||||
// .frame(maxWidth: .infinity)
|
|
||||||
// }
|
|
||||||
|
|
||||||
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
|
|
||||||
let onShowModePrompt: () -> Void
|
|
||||||
@FocusState private var isCodeFieldFocused: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
|
||||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: onShowModePrompt)
|
|
||||||
|
|
||||||
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 {
|
struct LoginView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
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()
|
let viewModel = LoginViewModel()
|
||||||
viewModel.isLoading = false
|
viewModel.isLoading = false // чтобы убрать спиннер
|
||||||
viewModel.loginFlowStep = step
|
|
||||||
viewModel.passwordlessLogin = "preview@yobble.app"
|
|
||||||
viewModel.verificationCode = "123456"
|
|
||||||
return LoginView(viewModel: viewModel)
|
return LoginView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ForgotPasswordInfoView: View {
|
|
||||||
let onUseCode: () -> Void
|
|
||||||
let onDismiss: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
Text(NSLocalizedString("Сброс пароля", comment: ""))
|
|
||||||
.font(.title2.bold())
|
|
||||||
|
|
||||||
Text(NSLocalizedString("Прямого сброса пароля нет: сменить его можно только из настроек, уже будучи в аккаунте. Если привязана почта или другое 2FA-устройство, воспользуйтесь входом по коду - он подтвердит вашу личность и пустит в аккаунт. После входа откройте настройки → безопасность и задайте новый пароль.", comment: ""))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
Button(action: onUseCode) {
|
|
||||||
Text(NSLocalizedString("Перейти к входу по коду", comment: ""))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(Color.accentColor)
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: onDismiss) {
|
|
||||||
Text(NSLocalizedString("Закрыть", comment: ""))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import SwiftUI
|
|||||||
|
|
||||||
struct RegistrationView: View {
|
struct RegistrationView: View {
|
||||||
@ObservedObject var viewModel: LoginViewModel
|
@ObservedObject var viewModel: LoginViewModel
|
||||||
let onShowModePrompt: (() -> Void)?
|
@Binding var isPresented: Bool
|
||||||
|
@Environment(\.presentationMode) private var presentationMode
|
||||||
|
|
||||||
@State private var username: String = ""
|
@State private var username: String = ""
|
||||||
@State private var password: String = ""
|
@State private var password: String = ""
|
||||||
@ -47,41 +48,32 @@ struct RegistrationView: View {
|
|||||||
isUsernameValid && isPasswordValid && isConfirmPasswordValid && viewModel.hasAcceptedTerms
|
isUsernameValid && isPasswordValid && isConfirmPasswordValid && viewModel.hasAcceptedTerms
|
||||||
}
|
}
|
||||||
|
|
||||||
init(viewModel: LoginViewModel, onShowModePrompt: (() -> Void)? = nil) {
|
|
||||||
self._viewModel = ObservedObject(initialValue: viewModel)
|
|
||||||
self.onShowModePrompt = onShowModePrompt
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
NavigationView {
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
ScrollView {
|
||||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: onShowModePrompt)
|
ZStack(alignment: .top) {
|
||||||
|
Color.clear
|
||||||
Button(action: goBack) {
|
.contentShape(Rectangle())
|
||||||
HStack(spacing: 6) {
|
.onTapGesture { focusedField = nil }
|
||||||
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: 16) {
|
||||||
Group {
|
Group {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
HStack {
|
||||||
TextField(NSLocalizedString("Введите логин", comment: "Логин"), text: $username)
|
TextField(NSLocalizedString("Логин", comment: "Логин"), text: $username)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
.focused($focusedField, equals: .username)
|
.focused($focusedField, equals: .username)
|
||||||
|
Spacer()
|
||||||
|
if !username.isEmpty {
|
||||||
|
Image(systemName: isUsernameValid ? "checkmark.circle" : "xmark.circle")
|
||||||
|
.foregroundColor(isUsernameValid ? .green : .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color(.secondarySystemBackground))
|
||||||
.cornerRadius(12)
|
.cornerRadius(8)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
.onChange(of: username) { newValue in
|
.onChange(of: username) { newValue in
|
||||||
if newValue.count > 32 {
|
if newValue.count > 32 {
|
||||||
username = String(newValue.prefix(32))
|
username = String(newValue.prefix(32))
|
||||||
@ -89,19 +81,25 @@ struct RegistrationView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isUsernameValid && !username.isEmpty {
|
if !isUsernameValid && !username.isEmpty {
|
||||||
Text(NSLocalizedString("Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)", comment: ""))
|
Text(NSLocalizedString("Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)", comment: "Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)"))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
HStack {
|
||||||
SecureField(NSLocalizedString("Введите пароль", comment: "Пароль"), text: $password)
|
SecureField(NSLocalizedString("Пароль", comment: "Пароль"), text: $password)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.focused($focusedField, equals: .password)
|
.focused($focusedField, equals: .password)
|
||||||
|
Spacer()
|
||||||
|
if !password.isEmpty {
|
||||||
|
Image(systemName: isPasswordValid ? "checkmark.circle" : "xmark.circle")
|
||||||
|
.foregroundColor(isPasswordValid ? .green : .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color(.secondarySystemBackground))
|
||||||
.cornerRadius(12)
|
.cornerRadius(8)
|
||||||
|
.autocapitalization(.none)
|
||||||
.onChange(of: password) { newValue in
|
.onChange(of: password) { newValue in
|
||||||
if newValue.count > 128 {
|
if newValue.count > 128 {
|
||||||
password = String(newValue.prefix(128))
|
password = String(newValue.prefix(128))
|
||||||
@ -109,19 +107,25 @@ struct RegistrationView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isPasswordValid && !password.isEmpty {
|
if !isPasswordValid && !password.isEmpty {
|
||||||
Text(NSLocalizedString("Пароль должен быть от 8 до 128 символов", comment: ""))
|
Text(NSLocalizedString("Пароль должен быть от 8 до 128 символов", comment: "Пароль должен быть от 6 до 32 символов"))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
HStack {
|
||||||
SecureField(NSLocalizedString("Подтвердите пароль", comment: ""), text: $confirmPassword)
|
SecureField(NSLocalizedString("Подтверждение пароля", comment: "Подтверждение пароля"), text: $confirmPassword)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.focused($focusedField, equals: .confirmPassword)
|
.focused($focusedField, equals: .confirmPassword)
|
||||||
|
Spacer()
|
||||||
|
if !confirmPassword.isEmpty {
|
||||||
|
Image(systemName: isConfirmPasswordValid ? "checkmark.circle" : "xmark.circle")
|
||||||
|
.foregroundColor(isConfirmPasswordValid ? .green : .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color(.secondarySystemBackground))
|
||||||
.cornerRadius(12)
|
.cornerRadius(8)
|
||||||
|
.autocapitalization(.none)
|
||||||
.onChange(of: confirmPassword) { newValue in
|
.onChange(of: confirmPassword) { newValue in
|
||||||
if newValue.count > 32 {
|
if newValue.count > 32 {
|
||||||
confirmPassword = String(newValue.prefix(32))
|
confirmPassword = String(newValue.prefix(32))
|
||||||
@ -129,19 +133,18 @@ struct RegistrationView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isConfirmPasswordValid && !confirmPassword.isEmpty {
|
if !isConfirmPasswordValid && !confirmPassword.isEmpty {
|
||||||
Text(NSLocalizedString("Пароли не совпадают", comment: ""))
|
Text(NSLocalizedString("Пароли не совпадают", comment: "Пароли не совпадают"))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
TextField(NSLocalizedString("Инвайт-код (необязательно)", comment: ""), text: $inviteCode)
|
TextField(NSLocalizedString("Инвайт-код (необязательно)", comment: "Инвайт-код"), text: $inviteCode)
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.cornerRadius(8)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
.focused($focusedField, equals: .invite)
|
.focused($focusedField, equals: .invite)
|
||||||
.padding()
|
|
||||||
.background(Color(.secondarySystemBackground))
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TermsAgreementCard(
|
TermsAgreementCard(
|
||||||
@ -155,25 +158,33 @@ struct RegistrationView: View {
|
|||||||
Button(action: registerUser) {
|
Button(action: registerUser) {
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color.gray.opacity(0.6))
|
||||||
|
.cornerRadius(8)
|
||||||
} else {
|
} else {
|
||||||
Text(NSLocalizedString("Зарегистрироваться", comment: "Зарегистрироваться"))
|
Text(NSLocalizedString("Зарегистрироваться", comment: "Зарегистрироваться"))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(isFormValid ? Color.blue : Color.gray.opacity(0.6))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!isFormValid)
|
||||||
|
.padding(.bottom)
|
||||||
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(isFormValid ? Color.blue : Color.gray.opacity(0.6))
|
.navigationTitle(Text(NSLocalizedString("Регистрация", comment: "Регистрация")))
|
||||||
.cornerRadius(12)
|
.toolbar {
|
||||||
.disabled(!isFormValid)
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button(action: dismissSheet) {
|
||||||
|
Text(NSLocalizedString("Закрыть", comment: "Закрыть"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 32)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.background(Color(.systemBackground).ignoresSafeArea())
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture { focusedField = nil }
|
|
||||||
.alert(isPresented: $showError) {
|
.alert(isPresented: $showError) {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text(NSLocalizedString("Ошибка регистрация", comment: "Ошибка")),
|
title: Text(NSLocalizedString("Ошибка регистрация", comment: "Ошибка")),
|
||||||
@ -181,6 +192,7 @@ struct RegistrationView: View {
|
|||||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
|
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.fullScreenCover(isPresented: $isShowingTerms) {
|
.fullScreenCover(isPresented: $isShowingTerms) {
|
||||||
TermsFullScreenView(
|
TermsFullScreenView(
|
||||||
isPresented: $isShowingTerms,
|
isPresented: $isShowingTerms,
|
||||||
@ -206,7 +218,7 @@ struct RegistrationView: View {
|
|||||||
viewModel.registerUser(username: username, password: password, invite: inviteCode.isEmpty ? nil : inviteCode) { success, message in
|
viewModel.registerUser(username: username, password: password, invite: inviteCode.isEmpty ? nil : inviteCode) { success, message in
|
||||||
isLoading = false
|
isLoading = false
|
||||||
if success {
|
if success {
|
||||||
viewModel.hasAcceptedTerms = false
|
dismissSheet()
|
||||||
} else {
|
} else {
|
||||||
errorMessage = message ?? NSLocalizedString("Неизвестная ошибка.", comment: "")
|
errorMessage = message ?? NSLocalizedString("Неизвестная ошибка.", comment: "")
|
||||||
showError = true
|
showError = true
|
||||||
@ -214,17 +226,11 @@ struct RegistrationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func goBack() {
|
private func dismissSheet() {
|
||||||
focusedField = nil
|
focusedField = nil
|
||||||
viewModel.hasAcceptedTerms = false
|
viewModel.hasAcceptedTerms = false
|
||||||
withAnimation {
|
isPresented = false
|
||||||
viewModel.showPasswordlessRequest()
|
presentationMode.wrappedValue.dismiss()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openLanguageSettings() {
|
|
||||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,6 +239,6 @@ struct RegistrationView_Previews: PreviewProvider {
|
|||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let viewModel = LoginViewModel()
|
let viewModel = LoginViewModel()
|
||||||
viewModel.isLoading = false // чтобы убрать спиннер
|
viewModel.isLoading = false // чтобы убрать спиннер
|
||||||
return RegistrationView(viewModel: viewModel, onShowModePrompt: nil)
|
return RegistrationView(viewModel: viewModel, isPresented: .constant(true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AppConfig {
|
struct AppConfig {
|
||||||
static var DEBUG: Bool = true
|
static var DEBUG: Bool = false
|
||||||
//static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service"
|
//static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service"
|
||||||
static let PROTOCOL = "https"
|
static let PROTOCOL = "https"
|
||||||
static let API_SERVER = "\(PROTOCOL)://api.yobble.org"
|
static let API_SERVER = "\(PROTOCOL)://api.yobble.org"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user