new login screen
This commit is contained in:
parent
e269647d41
commit
92e4c30c7f
@ -83,6 +83,26 @@ 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) {
|
||||
let payload = RegisterRequest(login: username, password: password, invite: invite)
|
||||
guard let body = try? JSONEncoder().encode(payload) else {
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"sourceLanguage" : "ru",
|
||||
"strings" : {
|
||||
"" : {
|
||||
|
||||
},
|
||||
"(без текста)" : {
|
||||
|
||||
},
|
||||
@ -267,9 +270,21 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Введите код" : {
|
||||
|
||||
},
|
||||
"Введите код из приложения" : {
|
||||
"comment" : "Поле ввода кода 2FA"
|
||||
},
|
||||
"Введите логин" : {
|
||||
|
||||
},
|
||||
"Введите логин и мы отправим шестизначный код подтверждения." : {
|
||||
|
||||
},
|
||||
"Введите логин." : {
|
||||
|
||||
},
|
||||
"Введите пароль" : {
|
||||
"comment" : "Поле ввода пароля на приложение"
|
||||
@ -347,6 +362,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Войти по коду" : {
|
||||
|
||||
},
|
||||
"Войти по паролю" : {
|
||||
|
||||
},
|
||||
"Все" : {
|
||||
"localizations" : {
|
||||
@ -370,9 +391,15 @@
|
||||
},
|
||||
"Всего сессий" : {
|
||||
"comment" : "Сводка по количеству сессий"
|
||||
},
|
||||
"Вход" : {
|
||||
|
||||
},
|
||||
"Вход и защита аккаунта (заглушка)" : {
|
||||
"comment" : "Раздел настроек безопасности для аутентификации"
|
||||
},
|
||||
"Вход по коду пока недоступен. Заглушка." : {
|
||||
|
||||
},
|
||||
"Вы" : {
|
||||
"localizations" : {
|
||||
@ -681,6 +708,9 @@
|
||||
},
|
||||
"Изменить контакт" : {
|
||||
"comment" : "Contacts context action edit"
|
||||
},
|
||||
"Изменить способ входа" : {
|
||||
|
||||
},
|
||||
"Изображение" : {
|
||||
"comment" : "Image message placeholder"
|
||||
@ -739,6 +769,9 @@
|
||||
},
|
||||
"Код дружбы" : {
|
||||
"comment" : "Friend code badge"
|
||||
},
|
||||
"Код может прийти по почте, push или в другое подключенное приложение." : {
|
||||
|
||||
},
|
||||
"Код принят" : {
|
||||
"comment" : "Заголовок успешного подтверждения кода 2FA"
|
||||
@ -952,6 +985,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Мы отправили код на %@" : {
|
||||
|
||||
},
|
||||
"Мы отправим код подтверждения на привязанный email каждый раз при входе." : {
|
||||
"comment" : "Описание работы кодов при входе"
|
||||
@ -1093,6 +1129,9 @@
|
||||
},
|
||||
"Начальная настройка" : {
|
||||
|
||||
},
|
||||
"Не получили код?" : {
|
||||
|
||||
},
|
||||
"Не удалось выполнить поиск." : {
|
||||
"comment" : "Search error fallback\nSearch service decoding error"
|
||||
@ -1180,6 +1219,9 @@
|
||||
},
|
||||
"Не удалось открыть чат." : {
|
||||
"comment" : "Chat creation fallback"
|
||||
},
|
||||
"Не удалось отправить код." : {
|
||||
|
||||
},
|
||||
"Не удалось отправить сообщение." : {
|
||||
|
||||
@ -1482,6 +1524,9 @@
|
||||
},
|
||||
"Отображаемое имя" : {
|
||||
|
||||
},
|
||||
"Отправить код ещё раз" : {
|
||||
|
||||
},
|
||||
"Отправить отзыв" : {
|
||||
"comment" : "feedback: submit button",
|
||||
@ -1758,6 +1803,9 @@
|
||||
},
|
||||
"Подтвердить" : {
|
||||
"comment" : "Кнопка подтверждения кода 2FA"
|
||||
},
|
||||
"Подтвердить вход" : {
|
||||
|
||||
},
|
||||
"Подтверждение email" : {
|
||||
"comment" : "Раздел подтверждения email"
|
||||
@ -1835,6 +1883,9 @@
|
||||
},
|
||||
"Получать коды на email при входе" : {
|
||||
"comment" : "Переключатель отправки кодов при входе"
|
||||
},
|
||||
"Получить код" : {
|
||||
|
||||
},
|
||||
"Получить ответ от команды" : {
|
||||
"comment" : "feedback: contact toggle",
|
||||
@ -1876,6 +1927,9 @@
|
||||
},
|
||||
"Понятно" : {
|
||||
"comment" : "Chat creation error acknowledgment"
|
||||
},
|
||||
"Попробовать снова можно через %d сек" : {
|
||||
|
||||
},
|
||||
"Попробуйте изменить запрос поиска." : {
|
||||
|
||||
@ -2025,6 +2079,9 @@
|
||||
},
|
||||
"Проверочный код" : {
|
||||
"comment" : "Раздел верификации 2FA"
|
||||
},
|
||||
"Проверьте введённый код и попробуйте снова." : {
|
||||
|
||||
},
|
||||
"Проверьте ввод и попробуйте снова." : {
|
||||
"comment" : "Сообщение ошибки несовпадения паролей"
|
||||
|
||||
@ -23,10 +23,32 @@ class LoginViewModel: ObservableObject {
|
||||
@Published var termsContent: String = ""
|
||||
@Published var termsErrorMessage: String?
|
||||
@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 socketService = SocketService.shared
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var resendTimer: Timer?
|
||||
|
||||
enum LoginFlowStep: Equatable {
|
||||
case passwordlessRequest
|
||||
case passwordlessVerify
|
||||
case password
|
||||
}
|
||||
|
||||
enum ChatLoadingState: Equatable {
|
||||
case idle
|
||||
@ -52,6 +74,10 @@ class LoginViewModel: ObservableObject {
|
||||
autoLogin()
|
||||
}
|
||||
|
||||
deinit {
|
||||
resendTimer?.invalidate()
|
||||
}
|
||||
|
||||
private func observeSocketState() {
|
||||
socketService.connectionStatePublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
@ -125,6 +151,86 @@ 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 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
|
||||
DispatchQueue.main.async {
|
||||
@ -228,4 +334,45 @@ class LoginViewModel: ObservableObject {
|
||||
termsErrorMessage = nil
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,27 @@ import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
switch viewModel.loginFlowStep {
|
||||
case .passwordlessRequest:
|
||||
PasswordlessRequestView(viewModel: viewModel)
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
case .passwordlessVerify:
|
||||
PasswordlessVerifyView(viewModel: viewModel)
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
case .password:
|
||||
PasswordLoginView(viewModel: viewModel)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: viewModel.loginFlowStep)
|
||||
}
|
||||
}
|
||||
|
||||
struct PasswordLoginView: View {
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
@EnvironmentObject private var themeManager: ThemeManager
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
private let themeOptions = ThemeOption.ordered
|
||||
@ -73,6 +94,22 @@ struct LoginView: View {
|
||||
focusedField = nil
|
||||
}
|
||||
|
||||
Button {
|
||||
focusedField = nil
|
||||
withAnimation {
|
||||
viewModel.showPasswordlessRequest()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.left")
|
||||
Text(NSLocalizedString("Войти по коду", comment: ""))
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundColor(.blue)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
TextField(NSLocalizedString("Логин", comment: ""), text: $viewModel.username)
|
||||
@ -175,13 +212,7 @@ struct LoginView: View {
|
||||
|
||||
}
|
||||
.padding()
|
||||
.alert(isPresented: $viewModel.showError) {
|
||||
Alert(
|
||||
title: Text(NSLocalizedString("Ошибка авторизации", comment: "")),
|
||||
message: Text(viewModel.errorMessage),
|
||||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
|
||||
)
|
||||
}
|
||||
.loginErrorAlert(viewModel: viewModel)
|
||||
.onAppear {
|
||||
if !hasResetTermsOnAppear {
|
||||
viewModel.hasAcceptedTerms = false
|
||||
@ -319,10 +350,360 @@ struct LoginView: View {
|
||||
|
||||
}
|
||||
|
||||
private struct PasswordlessRequestView: View {
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
@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 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
isFieldFocused = true
|
||||
}
|
||||
}
|
||||
.loginErrorAlert(viewModel: viewModel)
|
||||
}
|
||||
|
||||
private func openLanguageSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PasswordlessVerifyView: View {
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
@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 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
isCodeFieldFocused = true
|
||||
}
|
||||
}
|
||||
.loginErrorAlert(viewModel: viewModel)
|
||||
}
|
||||
|
||||
private func openLanguageSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
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 LoginTopBar: View {
|
||||
let openLanguageSettings: () -> 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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
.environmentObject(ThemeManager())
|
||||
}
|
||||
|
||||
private static func preview(step: LoginViewModel.LoginFlowStep) -> some View {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.isLoading = false // чтобы убрать спиннер
|
||||
viewModel.isLoading = false
|
||||
viewModel.loginFlowStep = step
|
||||
viewModel.passwordlessLogin = "preview@yobble.app"
|
||||
viewModel.verificationCode = "123456"
|
||||
return LoginView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppConfig {
|
||||
static var DEBUG: Bool = false
|
||||
static var DEBUG: Bool = true
|
||||
//static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service"
|
||||
static let PROTOCOL = "https"
|
||||
static let API_SERVER = "\(PROTOCOL)://api.yobble.org"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user