Compare commits

...

6 Commits

Author SHA1 Message Date
98ea7bcf02 login patch 2025-12-04 02:41:45 +03:00
0a162a5b2d patch login 2025-12-04 02:17:23 +03:00
0311e0f5b1 update 2025-12-04 02:14:34 +03:00
0617d1bd9c 9 2025-12-04 01:04:39 +03:00
e9b43e76fa fix contact 2025-12-03 23:10:36 +03:00
e44d56e71b push disable in app 2025-12-03 23:00:17 +03:00
8 changed files with 208 additions and 131 deletions

View File

@ -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;

View File

@ -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,

View File

@ -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)
} }
} }

View File

@ -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"
},
"Проверяем код…" : {
}, },
"Продолжить" : { "Продолжить" : {

View File

@ -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

View File

@ -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)
@ -478,31 +459,6 @@ private struct PasswordlessRequestView: View {
.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)
} }
@ -554,35 +510,45 @@ private struct PasswordlessVerifyView: View {
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()

View File

@ -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

View File

@ -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)") }
} }