diff --git a/yobble/Network/ChatModels.swift b/yobble/Network/ChatModels.swift index 0d42395..718048a 100644 --- a/yobble/Network/ChatModels.swift +++ b/yobble/Network/ChatModels.swift @@ -331,6 +331,20 @@ struct RelationshipStatus: Decodable { } } +extension RelationshipStatus { + init( + isTargetInContactsOfCurrentUser: Bool, + isCurrentUserInContactsOfTarget: Bool, + isTargetUserBlockedByCurrentUser: Bool, + isCurrentUserInBlacklistOfTarget: Bool + ) { + self.isTargetInContactsOfCurrentUser = isTargetInContactsOfCurrentUser + self.isCurrentUserInContactsOfTarget = isCurrentUserInContactsOfTarget + self.isTargetUserBlockedByCurrentUser = isTargetUserBlockedByCurrentUser + self.isCurrentUserInBlacklistOfTarget = isCurrentUserInBlacklistOfTarget + } +} + enum JSONValue: Decodable { case string(String) case int(Int) diff --git a/yobble/Network/ContactsService.swift b/yobble/Network/ContactsService.swift index 130800d..ea2eb6b 100644 --- a/yobble/Network/ContactsService.swift +++ b/yobble/Network/ContactsService.swift @@ -3,6 +3,7 @@ import Foundation enum ContactsServiceError: LocalizedError { case unexpectedStatus(String) case decoding(debugDescription: String) + case encoding(String) var errorDescription: String? { switch self { @@ -12,6 +13,8 @@ enum ContactsServiceError: LocalizedError { return AppConfig.DEBUG ? debugDescription : NSLocalizedString("Не удалось загрузить контакты.", comment: "Contacts service decoding error") + case .encoding(let message): + return message } } } @@ -30,15 +33,26 @@ struct ContactsListPayload: Decodable { let hasMore: Bool } +private struct ContactCreateRequestPayload: Encodable { + let userId: UUID? + let login: String? + let friendCode: String? + let customName: String? +} + final class ContactsService { private let client: NetworkClient private let decoder: JSONDecoder + private let encoder: JSONEncoder init(client: NetworkClient = .shared) { self.client = client self.decoder = JSONDecoder() self.decoder.keyDecodingStrategy = .convertFromSnakeCase self.decoder.dateDecodingStrategy = .custom(Self.decodeDate) + + self.encoder = JSONEncoder() + self.encoder.keyEncodingStrategy = .convertToSnakeCase } func fetchContacts(limit: Int, offset: Int, completion: @escaping (Result) -> Void) { @@ -88,6 +102,63 @@ final class ContactsService { } } + func addContact(userId: UUID, customName: String?, completion: @escaping (Result) -> Void) { + let request = ContactCreateRequestPayload( + userId: userId, + login: nil, + friendCode: nil, + customName: customName + ) + + guard let body = try? encoder.encode(request) else { + let message = NSLocalizedString("Не удалось подготовить данные запроса.", comment: "Contacts service encoding error") + completion(.failure(ContactsServiceError.encoding(message))) + return + } + + client.request( + path: "/v1/user/contact/add", + method: .post, + body: body, + requiresAuth: true + ) { [decoder] result in + switch result { + case .success(let response): + do { + let apiResponse = try decoder.decode(APIResponse.self, from: response.data) + guard apiResponse.status == "fine" else { + let message = apiResponse.detail ?? NSLocalizedString("Не удалось добавить контакт.", comment: "Contacts service add unexpected status") + completion(.failure(ContactsServiceError.unexpectedStatus(message))) + return + } + completion(.success(apiResponse.data)) + } catch { + let debugMessage = Self.describeDecodingError(error: error, data: response.data) + if AppConfig.DEBUG { + print("[ContactsService] decode contact add failed: \(debugMessage)") + } + completion(.failure(ContactsServiceError.decoding(debugDescription: debugMessage))) + } + case .failure(let error): + if case let NetworkError.server(_, data) = error, + let data, + let message = Self.errorMessage(from: data) { + completion(.failure(ContactsServiceError.unexpectedStatus(message))) + return + } + completion(.failure(error)) + } + } + } + + func addContact(userId: UUID, customName: String?) async throws -> ContactPayload { + try await withCheckedThrowingContinuation { continuation in + addContact(userId: userId, customName: customName) { result in + continuation.resume(with: result) + } + } + } + private static func decodeDate(from decoder: Decoder) throws -> Date { let container = try decoder.singleValueContainer() let string = try container.decode(String.self) diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index a0815d7..aad5f36 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -581,9 +581,6 @@ "Добавить контакт" : { "comment" : "Message profile add contact alert title" }, - "Добавление контакта появится позже." : { - "comment" : "Contact add placeholder message" - }, "Добавление новых блокировок появится позже." : { "comment" : "Add blocked user placeholder message" }, @@ -856,6 +853,9 @@ }, "Имя в чате" : { + }, + "Имя не может быть пустым." : { + "comment" : "Contact add empty name error" }, "Имя, логин и статус — как в профиле Telegram." : { "comment" : "Message profile about description" @@ -1337,6 +1337,9 @@ "Не удалось выполнить поиск." : { "comment" : "Search error fallback\nSearch service decoding error" }, + "Не удалось добавить контакт." : { + "comment" : "Contacts service add unexpected status" + }, "Не удалось заблокировать пользователя." : { "comment" : "Blocked users create unexpected status" }, @@ -1441,6 +1444,9 @@ "Не удалось определить пользователя для блокировки." : { "comment" : "Message profile missing user id error" }, + "Не удалось определить пользователя для добавления." : { + "comment" : "Contact add invalid user id error" + }, "Не удалось открыть чат" : { "comment" : "Chat creation error title" }, @@ -1454,7 +1460,7 @@ }, "Не удалось подготовить данные запроса." : { - "comment" : "Blocked users create encoding error\nBlocked users delete encoding error\nProfile update encoding error" + "comment" : "Blocked users create encoding error\nBlocked users delete encoding error\nContacts service encoding error\nProfile update encoding error" }, "Не удалось подготовить изображение для загрузки." : { "comment" : "Avatar encoding error" diff --git a/yobble/Views/Chat/MessageProfileView.swift b/yobble/Views/Chat/MessageProfileView.swift index 9fe9e47..8c44e74 100644 --- a/yobble/Views/Chat/MessageProfileView.swift +++ b/yobble/Views/Chat/MessageProfileView.swift @@ -289,7 +289,9 @@ struct MessageProfileView: View { rowDivider if let profile = currentChatProfile { NavigationLink { - ContactAddView(contact: ContactEditInfo(profile: profile)) + ContactAddView(contact: ContactEditInfo(profile: profile)) { payload in + handleContactAdded(payload) + } } label: { filledActionLabel( title: NSLocalizedString("Добавить в контакты", comment: "Message profile add to contacts title"), @@ -563,6 +565,37 @@ struct MessageProfileView: View { ) } + private func handleContactAdded(_ payload: ContactPayload) { + guard let profile = currentChatProfile else { return } + + let existingRelationship = profile.relationship + let updatedRelationship = RelationshipStatus( + isTargetInContactsOfCurrentUser: true, + isCurrentUserInContactsOfTarget: existingRelationship?.isCurrentUserInContactsOfTarget ?? false, + isTargetUserBlockedByCurrentUser: existingRelationship?.isTargetUserBlockedByCurrentUser ?? false, + isCurrentUserInBlacklistOfTarget: existingRelationship?.isCurrentUserInBlacklistOfTarget ?? false + ) + + let updatedProfile = ChatProfile( + userId: profile.userId, + login: profile.login, + fullName: profile.fullName, + customName: payload.customName ?? profile.customName, + bio: profile.bio, + lastSeen: profile.lastSeen, + createdAt: profile.createdAt, + avatars: profile.avatars, + stories: profile.stories, + permissions: profile.permissions, + profilePermissions: profile.profilePermissions, + relationship: updatedRelationship, + rating: profile.rating, + isOfficial: profile.isOfficial + ) + + chatProfile = updatedProfile + } + private func handleEditContactTap() { showPlaceholderAction( title: NSLocalizedString("Ошибка", comment: "Common error title"), diff --git a/yobble/Views/Contacts/ContactAddView.swift b/yobble/Views/Contacts/ContactAddView.swift index d5c6f6c..9ced035 100644 --- a/yobble/Views/Contacts/ContactAddView.swift +++ b/yobble/Views/Contacts/ContactAddView.swift @@ -2,12 +2,18 @@ import SwiftUI struct ContactAddView: View { let contact: ContactEditInfo + let onContactAdded: ((ContactPayload) -> Void)? + + @Environment(\.dismiss) private var dismiss + private let contactsService = ContactsService() @State private var displayName: String @State private var activeAlert: ContactAddAlert? + @State private var isSaving = false - init(contact: ContactEditInfo) { + init(contact: ContactEditInfo, onContactAdded: ((ContactPayload) -> Void)? = nil) { self.contact = contact + self.onContactAdded = onContactAdded let initialName = contact.preferredName _displayName = State(initialValue: initialName) } @@ -18,16 +24,21 @@ struct ContactAddView: View { Section(header: Text(NSLocalizedString("Публичная информация", comment: "Contact add public info section title"))) { TextField(NSLocalizedString("Отображаемое имя", comment: "Display name field placeholder"), text: $displayName) + .disabled(isSaving) } } .navigationTitle(NSLocalizedString("Новый контакт", comment: "Contact add title")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(NSLocalizedString("Сохранить", comment: "Contact add save button")) { - handleSaveTap() + if isSaving { + ProgressView() + } else { + Button(NSLocalizedString("Сохранить", comment: "Contact add save button")) { + handleSaveTap() + } + .disabled(!hasChanges) } - .disabled(!hasChanges) } } .alert(item: $activeAlert) { item in @@ -116,10 +127,46 @@ struct ContactAddView: View { } private func handleSaveTap() { - activeAlert = ContactAddAlert( - title: NSLocalizedString("Скоро", comment: "Common soon title"), - message: NSLocalizedString("Добавление контакта появится позже.", comment: "Contact add placeholder message") - ) + guard !isSaving else { return } + + guard let userId = UUID(uuidString: contact.userId) else { + activeAlert = ContactAddAlert( + title: NSLocalizedString("Ошибка", comment: "Common error title"), + message: NSLocalizedString("Не удалось определить пользователя для добавления.", comment: "Contact add invalid user id error") + ) + return + } + + let trimmedName = displayName.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + activeAlert = ContactAddAlert( + title: NSLocalizedString("Ошибка", comment: "Common error title"), + message: NSLocalizedString("Имя не может быть пустым.", comment: "Contact add empty name error") + ) + return + } + + isSaving = true + let customName = trimmedName + + Task { + do { + let payload = try await contactsService.addContact(userId: userId, customName: customName) + await MainActor.run { + isSaving = false + onContactAdded?(payload) + dismiss() + } + } catch { + await MainActor.run { + isSaving = false + activeAlert = ContactAddAlert( + title: NSLocalizedString("Ошибка", comment: "Common error title"), + message: error.localizedDescription + ) + } + } + } } }