Compare commits
6 Commits
71fb0551fe
...
98ea7bcf02
| Author | SHA1 | Date | |
|---|---|---|---|
| 98ea7bcf02 | |||
| 0a162a5b2d | |||
| 0311e0f5b1 | |||
| 0617d1bd9c | |||
| e9b43e76fa | |||
| e44d56e71b |
@ -434,7 +434,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
|
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 8;
|
CURRENT_PROJECT_VERSION = 9;
|
||||||
DEVELOPMENT_TEAM = V22H44W47J;
|
DEVELOPMENT_TEAM = V22H44W47J;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@ -475,7 +475,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
|
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 8;
|
CURRENT_PROJECT_VERSION = 9;
|
||||||
DEVELOPMENT_TEAM = V22H44W47J;
|
DEVELOPMENT_TEAM = V22H44W47J;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|||||||
@ -34,8 +34,8 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
willPresent notification: UNNotification,
|
willPresent notification: UNNotification,
|
||||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
) {
|
) {
|
||||||
completionHandler([.banner, .sound, .badge]) // push
|
// completionHandler([.banner, .sound, .badge]) // push
|
||||||
// completionHandler([]) // no push
|
completionHandler([]) // no push
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ application: UIApplication,
|
func application(_ application: UIApplication,
|
||||||
|
|||||||
@ -25,6 +25,11 @@ struct ContactPayload: Decodable {
|
|||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ContactsListPayload: Decodable {
|
||||||
|
let items: [ContactPayload]
|
||||||
|
let hasMore: Bool
|
||||||
|
}
|
||||||
|
|
||||||
final class ContactsService {
|
final class ContactsService {
|
||||||
private let client: NetworkClient
|
private let client: NetworkClient
|
||||||
private let decoder: JSONDecoder
|
private let decoder: JSONDecoder
|
||||||
@ -36,16 +41,20 @@ final class ContactsService {
|
|||||||
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
|
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchContacts(completion: @escaping (Result<[ContactPayload], Error>) -> Void) {
|
func fetchContacts(limit: Int, offset: Int, completion: @escaping (Result<ContactsListPayload, Error>) -> Void) {
|
||||||
client.request(
|
client.request(
|
||||||
path: "/v1/user/contact/list",
|
path: "/v1/user/contact/list",
|
||||||
method: .get,
|
method: .get,
|
||||||
|
query: [
|
||||||
|
"limit": String(limit),
|
||||||
|
"offset": String(offset)
|
||||||
|
],
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
) { [decoder] result in
|
) { [decoder] result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let response):
|
case .success(let response):
|
||||||
do {
|
do {
|
||||||
let apiResponse = try decoder.decode(APIResponse<[ContactPayload]>.self, from: response.data)
|
let apiResponse = try decoder.decode(APIResponse<ContactsListPayload>.self, from: response.data)
|
||||||
guard apiResponse.status == "fine" else {
|
guard apiResponse.status == "fine" else {
|
||||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить контакты.", comment: "Contacts service unexpected status")
|
let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить контакты.", comment: "Contacts service unexpected status")
|
||||||
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
|
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
|
||||||
@ -71,9 +80,9 @@ final class ContactsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchContacts() async throws -> [ContactPayload] {
|
func fetchContacts(limit: Int, offset: Int) async throws -> ContactsListPayload {
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
fetchContacts { result in
|
fetchContacts(limit: limit, offset: offset) { result in
|
||||||
continuation.resume(with: result)
|
continuation.resume(with: result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -198,6 +198,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Yobble Passport" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Автоудаление аккаунта" : {
|
"Автоудаление аккаунта" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -285,9 +288,6 @@
|
|||||||
},
|
},
|
||||||
"Введите логин" : {
|
"Введите логин" : {
|
||||||
"comment" : "Логин"
|
"comment" : "Логин"
|
||||||
},
|
|
||||||
"Введите логин и мы отправим шестизначный код подтверждения." : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Введите пароль" : {
|
"Введите пароль" : {
|
||||||
"comment" : "Пароль\nПоле ввода пароля на приложение"
|
"comment" : "Пароль\nПоле ввода пароля на приложение"
|
||||||
@ -392,7 +392,7 @@
|
|||||||
"Всего сессий" : {
|
"Всего сессий" : {
|
||||||
"comment" : "Сводка по количеству сессий"
|
"comment" : "Сводка по количеству сессий"
|
||||||
},
|
},
|
||||||
"Вход" : {
|
"Вход в аккаунт" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Вход и защита аккаунта (заглушка)" : {
|
"Вход и защита аккаунта (заглушка)" : {
|
||||||
@ -778,9 +778,6 @@
|
|||||||
},
|
},
|
||||||
"Код дружбы" : {
|
"Код дружбы" : {
|
||||||
"comment" : "Friend code badge"
|
"comment" : "Friend code badge"
|
||||||
},
|
|
||||||
"Код может прийти по почте, push или в другое подключенное приложение." : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Код отправлен. Аккаунт: @%@" : {
|
"Код отправлен. Аккаунт: @%@" : {
|
||||||
|
|
||||||
@ -905,6 +902,7 @@
|
|||||||
},
|
},
|
||||||
"Логин" : {
|
"Логин" : {
|
||||||
"comment" : "Логин",
|
"comment" : "Логин",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -1777,9 +1775,6 @@
|
|||||||
},
|
},
|
||||||
"Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : {
|
"Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : {
|
||||||
"comment" : "FAQ answer: reset password"
|
"comment" : "FAQ answer: reset password"
|
||||||
},
|
|
||||||
"Перейти к входу по коду" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"По умолчанию это полноценная соцсеть с лентой, историями и подписками. Если нужно только общение без лишнего контента, переключитесь на режим “Только чаты”. Переключить режим можно в любой момент." : {
|
"По умолчанию это полноценная соцсеть с лентой, историями и подписками. Если нужно только общение без лишнего контента, переключитесь на режим “Только чаты”. Переключить режим можно в любой момент." : {
|
||||||
|
|
||||||
@ -1840,9 +1835,6 @@
|
|||||||
},
|
},
|
||||||
"Подтвердить" : {
|
"Подтвердить" : {
|
||||||
"comment" : "Кнопка подтверждения кода 2FA"
|
"comment" : "Кнопка подтверждения кода 2FA"
|
||||||
},
|
|
||||||
"Подтвердить вход" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Подтверждение email" : {
|
"Подтверждение email" : {
|
||||||
"comment" : "Раздел подтверждения email"
|
"comment" : "Раздел подтверждения email"
|
||||||
@ -1923,9 +1915,6 @@
|
|||||||
},
|
},
|
||||||
"Получать коды на email при входе" : {
|
"Получать коды на email при входе" : {
|
||||||
"comment" : "Переключатель отправки кодов при входе"
|
"comment" : "Переключатель отправки кодов при входе"
|
||||||
},
|
|
||||||
"Получить код" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Получить ответ от команды" : {
|
"Получить ответ от команды" : {
|
||||||
"comment" : "feedback: contact toggle",
|
"comment" : "feedback: contact toggle",
|
||||||
@ -2141,6 +2130,9 @@
|
|||||||
},
|
},
|
||||||
"Проверьте цифры и попробуйте снова." : {
|
"Проверьте цифры и попробуйте снова." : {
|
||||||
"comment" : "Описание ошибки неверного кода 2FA"
|
"comment" : "Описание ошибки неверного кода 2FA"
|
||||||
|
},
|
||||||
|
"Проверяем код…" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Продолжить" : {
|
"Продолжить" : {
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,13 @@ class LoginViewModel: ObservableObject {
|
|||||||
@Published var termsErrorMessage: String?
|
@Published var termsErrorMessage: String?
|
||||||
@Published var onboardingDestination: OnboardingDestination?
|
@Published var onboardingDestination: OnboardingDestination?
|
||||||
@Published var loginFlowStep: LoginFlowStep = .passwordlessRequest
|
@Published var loginFlowStep: LoginFlowStep = .passwordlessRequest
|
||||||
@Published var passwordlessLogin: String = ""
|
@Published var passwordlessLogin: String = "" {
|
||||||
|
didSet {
|
||||||
|
if passwordlessLogin.count > 32 {
|
||||||
|
passwordlessLogin = String(passwordlessLogin.prefix(32))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@Published var verificationCode: String = "" {
|
@Published var verificationCode: String = "" {
|
||||||
didSet {
|
didSet {
|
||||||
let filtered = verificationCode
|
let filtered = verificationCode
|
||||||
@ -137,8 +143,13 @@ class LoginViewModel: ObservableObject {
|
|||||||
func login() {
|
func login() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
showError = false
|
showError = false
|
||||||
|
let trimmedLogin = passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmedLogin != passwordlessLogin {
|
||||||
|
passwordlessLogin = trimmedLogin
|
||||||
|
}
|
||||||
|
username = trimmedLogin
|
||||||
|
|
||||||
authService.login(username: username, password: password) { [weak self] success, error in
|
authService.login(username: trimmedLogin, password: password) { [weak self] success, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self?.isLoading = false
|
self?.isLoading = false
|
||||||
if success {
|
if success {
|
||||||
@ -178,6 +189,9 @@ class LoginViewModel: ObservableObject {
|
|||||||
self.loginFlowStep = .passwordlessVerify
|
self.loginFlowStep = .passwordlessVerify
|
||||||
self.startResendTimer()
|
self.startResendTimer()
|
||||||
} else {
|
} else {
|
||||||
|
if self.handlePasswordlessRedirect(message: message, login: trimmedLogin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.errorMessage = message ?? NSLocalizedString("Не удалось отправить код.", comment: "")
|
self.errorMessage = message ?? NSLocalizedString("Не удалось отправить код.", comment: "")
|
||||||
self.showError = true
|
self.showError = true
|
||||||
}
|
}
|
||||||
@ -205,10 +219,11 @@ class LoginViewModel: ObservableObject {
|
|||||||
self.loadStoredUser()
|
self.loadStoredUser()
|
||||||
self.isLoggedIn = true
|
self.isLoggedIn = true
|
||||||
self.socketService.connectForCurrentUser()
|
self.socketService.connectForCurrentUser()
|
||||||
|
self.verificationCode = ""
|
||||||
} else {
|
} else {
|
||||||
self.errorMessage = message ?? NSLocalizedString("Проверьте введённый код и попробуйте снова.", comment: "")
|
self.errorMessage = message ?? NSLocalizedString("Проверьте введённый код и попробуйте снова.", comment: "")
|
||||||
self.showError = true
|
self.showError = true
|
||||||
self.verificationCode = ""
|
// self.verificationCode = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -385,6 +400,26 @@ extension LoginViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension LoginViewModel {
|
private extension LoginViewModel {
|
||||||
|
func handlePasswordlessRedirect(message: String?, login: String) -> Bool {
|
||||||
|
guard let message else { return false }
|
||||||
|
|
||||||
|
switch message {
|
||||||
|
case "otp_not_found":
|
||||||
|
username = login
|
||||||
|
passwordlessLogin = login
|
||||||
|
loginFlowStep = .password
|
||||||
|
return true
|
||||||
|
case "account_not_found":
|
||||||
|
username = login
|
||||||
|
passwordlessLogin = login
|
||||||
|
hasAcceptedTerms = false
|
||||||
|
loginFlowStep = .registration
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum Constants {
|
enum Constants {
|
||||||
static let verificationCodeLength = 6
|
static let verificationCodeLength = 6
|
||||||
static let defaultResendDelay = 60
|
static let defaultResendDelay = 60
|
||||||
|
|||||||
@ -109,7 +109,7 @@ struct PasswordLoginView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var isUsernameValid: Bool {
|
private var isUsernameValid: Bool {
|
||||||
LoginViewModel.isLoginValid(viewModel.username)
|
LoginViewModel.isLoginValid(viewModel.passwordlessLogin)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isPasswordValid: Bool {
|
private var isPasswordValid: Bool {
|
||||||
@ -151,21 +151,16 @@ struct PasswordLoginView: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text("@")
|
Text("@")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.username)
|
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.passwordlessLogin)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
.focused($focusedField, equals: .username)
|
.focused($focusedField, equals: .username)
|
||||||
.onChange(of: viewModel.username) { newValue in
|
|
||||||
if newValue.count > 32 {
|
|
||||||
viewModel.username = String(newValue.prefix(32))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color(.secondarySystemBackground))
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
|
|
||||||
if !isUsernameValid && !viewModel.username.isEmpty {
|
if !isUsernameValid && !viewModel.passwordlessLogin.isEmpty {
|
||||||
Text(NSLocalizedString("Неверный логин", comment: "Неверный логин"))
|
Text(NSLocalizedString("Неверный логин", comment: "Неверный логин"))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@ -400,22 +395,17 @@ private struct PasswordlessRequestView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
|
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(NSLocalizedString("Вход", comment: ""))
|
|
||||||
|
Text(NSLocalizedString("Yobble Passport", comment: ""))
|
||||||
.font(.largeTitle).bold()
|
.font(.largeTitle).bold()
|
||||||
// Text(NSLocalizedString("Введите логин и мы отправим шестизначный код подтверждения.", comment: ""))
|
|
||||||
// .foregroundColor(.secondary)
|
|
||||||
// Text(NSLocalizedString("Введите логин и мы отправим шестизначный код подтверждения.", comment: ""))
|
|
||||||
// .foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
// Text(NSLocalizedString("Логин", comment: ""))
|
|
||||||
// .font(.subheadline)
|
|
||||||
// .foregroundColor(.secondary)
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text("@")
|
Text("@")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@ -434,11 +424,6 @@ private struct PasswordlessRequestView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color(.secondarySystemBackground))
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
if !isLoginValid && !viewModel.passwordlessLogin.isEmpty {
|
|
||||||
Text(NSLocalizedString("Неверный логин", comment: ""))
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
@ -451,10 +436,6 @@ private struct PasswordlessRequestView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
} else {
|
} else {
|
||||||
// Text(NSLocalizedString("Получить код", comment: ""))
|
|
||||||
// .bold()
|
|
||||||
// .frame(maxWidth: .infinity)
|
|
||||||
// .padding()
|
|
||||||
Text(NSLocalizedString("Войти", comment: ""))
|
Text(NSLocalizedString("Войти", comment: ""))
|
||||||
.bold()
|
.bold()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -477,32 +458,7 @@ private struct PasswordlessRequestView: View {
|
|||||||
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: ""))
|
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: ""))
|
||||||
.frame(maxWidth: .infinity)
|
.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(.vertical, 32)
|
||||||
}
|
}
|
||||||
@ -553,36 +509,46 @@ private struct PasswordlessVerifyView: View {
|
|||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
|
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
// focusedField = nil
|
||||||
|
withAnimation {
|
||||||
|
viewModel.showPasswordlessRequest()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "arrow.left")
|
||||||
|
Text(NSLocalizedString("Назад", comment: ""))
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(NSLocalizedString("Введите код", comment: ""))
|
Text(NSLocalizedString("Вход в аккаунт", comment: ""))
|
||||||
.font(.largeTitle).bold()
|
.font(.largeTitle).bold()
|
||||||
Text(String(format: NSLocalizedString("Код отправлен. Аккаунт: @%@", comment: ""), viewModel.passwordlessLogin))
|
Text(String(format: NSLocalizedString("@%@", comment: ""), viewModel.passwordlessLogin))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
// Text(NSLocalizedString("Введите код", comment: ""))
|
||||||
|
// .font(.largeTitle).bold()
|
||||||
|
//
|
||||||
|
// Text(String(format: NSLocalizedString("Код отправлен. Аккаунт: @%@", comment: ""), viewModel.passwordlessLogin))
|
||||||
|
// .foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
OTPInputView(code: $viewModel.verificationCode, isFocused: $isCodeFieldFocused)
|
OTPInputView(code: $viewModel.verificationCode, isFocused: $isCodeFieldFocused)
|
||||||
|
|
||||||
Button {
|
if viewModel.isVerifyingCode {
|
||||||
withAnimation {
|
HStack(spacing: 8) {
|
||||||
viewModel.verifyPasswordlessCode()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
if viewModel.isVerifyingCode {
|
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(maxWidth: .infinity)
|
Text(NSLocalizedString("Проверяем код…", comment: ""))
|
||||||
.padding()
|
.font(.subheadline)
|
||||||
} else {
|
|
||||||
Text(NSLocalizedString("Подтвердить вход", comment: ""))
|
|
||||||
.bold()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.foregroundColor(.white)
|
|
||||||
.background(viewModel.canVerifyPasswordlessCode ? Color.blue : Color.gray)
|
|
||||||
.cornerRadius(12)
|
|
||||||
.disabled(!viewModel.canVerifyPasswordlessCode)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(NSLocalizedString("Не получили код?", comment: ""))
|
Text(NSLocalizedString("Не получили код?", comment: ""))
|
||||||
@ -609,14 +575,14 @@ private struct PasswordlessVerifyView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button {
|
// Button {
|
||||||
withAnimation {
|
// withAnimation {
|
||||||
viewModel.backToPasswordlessRequest()
|
// viewModel.backToPasswordlessRequest()
|
||||||
}
|
// }
|
||||||
} label: {
|
// } label: {
|
||||||
Text(NSLocalizedString("Изменить способ входа", comment: ""))
|
// Text(NSLocalizedString("Изменить способ входа", comment: ""))
|
||||||
.frame(maxWidth: .infinity)
|
// .frame(maxWidth: .infinity)
|
||||||
}
|
// }
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
@ -643,6 +609,12 @@ private struct PasswordlessVerifyView: View {
|
|||||||
isCodeFieldFocused = false
|
isCodeFieldFocused = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
triggerAutoVerificationIfNeeded()
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.verificationCode) { _ in
|
||||||
|
triggerAutoVerificationIfNeeded()
|
||||||
|
}
|
||||||
.loginErrorAlert(viewModel: viewModel)
|
.loginErrorAlert(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -664,6 +636,11 @@ private struct PasswordlessVerifyView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func triggerAutoVerificationIfNeeded() {
|
||||||
|
guard viewModel.canVerifyPasswordlessCode else { return }
|
||||||
|
viewModel.verifyPasswordlessCode()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct OTPInputView: View {
|
private struct OTPInputView: View {
|
||||||
@ -704,7 +681,17 @@ private struct OTPInputView: View {
|
|||||||
get: { code },
|
get: { code },
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
let filtered = newValue.filter { $0.isNumber }
|
let filtered = newValue.filter { $0.isNumber }
|
||||||
code = String(filtered.prefix(length))
|
let trimmed = String(filtered.prefix(length))
|
||||||
|
|
||||||
|
// избегаем nested updates
|
||||||
|
if code != trimmed {
|
||||||
|
// отключаем анимации и делаем обновление вне view update фазы
|
||||||
|
var transaction = Transaction()
|
||||||
|
transaction.disablesAnimations = true
|
||||||
|
withTransaction(transaction) {
|
||||||
|
code = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -866,7 +853,7 @@ private struct ForgotPasswordInfoView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
Button(action: onUseCode) {
|
Button(action: onUseCode) {
|
||||||
Text(NSLocalizedString("Перейти к входу по коду", comment: ""))
|
Text(NSLocalizedString("Войти", comment: ""))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
|
|||||||
@ -11,7 +11,6 @@ struct RegistrationView: View {
|
|||||||
@ObservedObject var viewModel: LoginViewModel
|
@ObservedObject var viewModel: LoginViewModel
|
||||||
let onShowModePrompt: (() -> Void)?
|
let onShowModePrompt: (() -> Void)?
|
||||||
|
|
||||||
@State private var username: String = ""
|
|
||||||
@State private var password: String = ""
|
@State private var password: String = ""
|
||||||
@State private var confirmPassword: String = ""
|
@State private var confirmPassword: String = ""
|
||||||
@State private var inviteCode: String = ""
|
@State private var inviteCode: String = ""
|
||||||
@ -32,7 +31,7 @@ struct RegistrationView: View {
|
|||||||
|
|
||||||
private var isUsernameValid: Bool {
|
private var isUsernameValid: Bool {
|
||||||
let pattern = "^[A-Za-z0-9_]{3,32}$"
|
let pattern = "^[A-Za-z0-9_]{3,32}$"
|
||||||
return username.range(of: pattern, options: .regularExpression) != nil
|
return viewModel.passwordlessLogin.range(of: pattern, options: .regularExpression) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isPasswordValid: Bool {
|
private var isPasswordValid: Bool {
|
||||||
@ -78,21 +77,16 @@ struct RegistrationView: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text("@")
|
Text("@")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
TextField(NSLocalizedString("Введите логин", comment: "Логин"), text: $username)
|
TextField(NSLocalizedString("Введите логин", comment: "Логин"), text: $viewModel.passwordlessLogin)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
.focused($focusedField, equals: .username)
|
.focused($focusedField, equals: .username)
|
||||||
.onChange(of: username) { newValue in
|
|
||||||
if newValue.count > 32 {
|
|
||||||
username = String(newValue.prefix(32))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color(.secondarySystemBackground))
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
|
|
||||||
if !isUsernameValid && !username.isEmpty {
|
if !isUsernameValid && !viewModel.passwordlessLogin.isEmpty {
|
||||||
Text(NSLocalizedString("Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)", comment: ""))
|
Text(NSLocalizedString("Логин должен быть от 3 до 32 символов (английские буквы, цифры, _)", comment: ""))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@ -207,7 +201,9 @@ struct RegistrationView: View {
|
|||||||
private func registerUser() {
|
private func registerUser() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = ""
|
errorMessage = ""
|
||||||
viewModel.registerUser(username: username, password: password, invite: inviteCode.isEmpty ? nil : inviteCode) { success, message in
|
let trimmedLogin = viewModel.passwordlessLogin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
viewModel.passwordlessLogin = trimmedLogin
|
||||||
|
viewModel.registerUser(username: trimmedLogin, password: password, invite: inviteCode.isEmpty ? nil : inviteCode) { success, message in
|
||||||
isLoading = false
|
isLoading = false
|
||||||
if success {
|
if success {
|
||||||
viewModel.hasAcceptedTerms = false
|
viewModel.hasAcceptedTerms = false
|
||||||
|
|||||||
@ -5,9 +5,13 @@ struct ContactsTab: View {
|
|||||||
@State private var contacts: [Contact] = []
|
@State private var contacts: [Contact] = []
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var loadError: String?
|
@State private var loadError: String?
|
||||||
|
@State private var pagingError: String?
|
||||||
@State private var activeAlert: ContactsAlert?
|
@State private var activeAlert: ContactsAlert?
|
||||||
|
@State private var hasMore = true
|
||||||
|
@State private var offset = 0
|
||||||
|
|
||||||
private let contactsService = ContactsService()
|
private let contactsService = ContactsService()
|
||||||
|
private let pageSize = 25
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
@ -20,7 +24,7 @@ struct ContactsTab: View {
|
|||||||
} else if contacts.isEmpty {
|
} else if contacts.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
ForEach(contacts) { contact in
|
ForEach(Array(contacts.enumerated()), id: \.element.id) { index, contact in
|
||||||
Button {
|
Button {
|
||||||
showContactPlaceholder(for: contact)
|
showContactPlaceholder(for: contact)
|
||||||
} label: {
|
} label: {
|
||||||
@ -57,16 +61,29 @@ struct ContactsTab: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12))
|
.listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12))
|
||||||
|
.onAppear {
|
||||||
|
if index >= contacts.count - 5 {
|
||||||
|
Task {
|
||||||
|
await loadContacts(reset: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLoading && !contacts.isEmpty {
|
||||||
|
loadingState
|
||||||
|
} else if let pagingError, !contacts.isEmpty {
|
||||||
|
pagingErrorState(pagingError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color(UIColor.systemBackground))
|
.background(Color(UIColor.systemBackground))
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.task {
|
.task {
|
||||||
await loadContacts()
|
await loadContacts(reset: false)
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await loadContacts()
|
await refreshContacts()
|
||||||
}
|
}
|
||||||
.alert(item: $activeAlert) { alert in
|
.alert(item: $activeAlert) { alert in
|
||||||
switch alert {
|
switch alert {
|
||||||
@ -106,7 +123,25 @@ struct ContactsTab: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: { Task { await loadContacts() } }) {
|
Button(action: { Task { await refreshContacts() } }) {
|
||||||
|
Text(NSLocalizedString("Обновить", comment: "Contacts retry button"))
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.listRowInsets(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pagingErrorState(_ message: String) -> some View {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
Spacer()
|
||||||
|
Button(action: { Task { await loadContacts(reset: false) } }) {
|
||||||
Text(NSLocalizedString("Обновить", comment: "Contacts retry button"))
|
Text(NSLocalizedString("Обновить", comment: "Contacts retry button"))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
}
|
}
|
||||||
@ -136,20 +171,43 @@ struct ContactsTab: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func loadContacts() async {
|
private func refreshContacts() async {
|
||||||
if isLoading {
|
hasMore = true
|
||||||
return
|
offset = 0
|
||||||
}
|
pagingError = nil
|
||||||
|
loadError = nil
|
||||||
|
contacts.removeAll()
|
||||||
|
await loadContacts(reset: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadContacts(reset: Bool) async {
|
||||||
|
if isLoading { return }
|
||||||
|
if !reset && !hasMore { return }
|
||||||
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
loadError = nil
|
if offset == 0 {
|
||||||
|
loadError = nil
|
||||||
|
}
|
||||||
|
pagingError = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let payloads = try await contactsService.fetchContacts()
|
let payload = try await contactsService.fetchContacts(limit: pageSize, offset: offset)
|
||||||
contacts = payloads.map(Contact.init)
|
let newContacts = payload.items.map(Contact.init)
|
||||||
|
if reset {
|
||||||
|
contacts = newContacts
|
||||||
|
} else {
|
||||||
|
contacts.append(contentsOf: newContacts)
|
||||||
|
}
|
||||||
|
offset += newContacts.count
|
||||||
|
hasMore = payload.hasMore
|
||||||
} catch {
|
} catch {
|
||||||
loadError = error.localizedDescription
|
let message = error.localizedDescription
|
||||||
// activeAlert = .error(message: error.localizedDescription)
|
if contacts.isEmpty {
|
||||||
|
loadError = message
|
||||||
|
} else {
|
||||||
|
pagingError = message
|
||||||
|
}
|
||||||
if AppConfig.DEBUG { print("[ContactsTab] load contacts failed: \(error)") }
|
if AppConfig.DEBUG { print("[ContactsTab] load contacts failed: \(error)") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user