diff --git a/yobble/Network/ProfileModels.swift b/yobble/Network/ProfileModels.swift index 0e9b1a7..994e72f 100644 --- a/yobble/Network/ProfileModels.swift +++ b/yobble/Network/ProfileModels.swift @@ -204,3 +204,7 @@ struct ProfileUpdateRequestPayload: Encodable { self.profilePermissions = profilePermissions } } + +struct UploadAvatarPayload: Decodable { + let fileId: String +} diff --git a/yobble/Network/ProfileService.swift b/yobble/Network/ProfileService.swift index 9865d21..dada0c4 100644 --- a/yobble/Network/ProfileService.swift +++ b/yobble/Network/ProfileService.swift @@ -1,4 +1,5 @@ import Foundation +import UIKit enum ProfileServiceError: LocalizedError { case unexpectedStatus(String) @@ -133,6 +134,73 @@ final class ProfileService { } } + func uploadAvatar(image: UIImage, completion: @escaping (Result) -> Void) { + guard let imageData = image.jpegData(compressionQuality: 0.9) else { + let message = NSLocalizedString("Не удалось подготовить изображение для загрузки.", comment: "Avatar encoding error") + completion(.failure(ProfileServiceError.encoding(message))) + return + } + + let boundary = "Boundary-\(UUID().uuidString)" + let body = Self.makeMultipartBody( + data: imageData, + boundary: boundary, + fieldName: "file", + filename: "avatar.jpg", + mimeType: "image/jpeg" + ) + + client.request( + path: "/v1/storage/upload/avatar", + method: .post, + body: body, + contentType: "multipart/form-data; boundary=\(boundary)", + requiresAuth: true + ) { result in + switch result { + case .success(let response): + do { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let apiResponse = try decoder.decode(APIResponse.self, from: response.data) + guard apiResponse.status == "fine" else { + let message = apiResponse.detail ?? NSLocalizedString("Не удалось обновить аватар.", comment: "Avatar upload unexpected status") + completion(.failure(ProfileServiceError.unexpectedStatus(message))) + return + } + completion(.success(apiResponse.data.fileId)) + } catch { + let debugMessage = Self.describeDecodingError(error: error, data: response.data) + if AppConfig.DEBUG { + print("[ProfileService] decode upload avatar failed: \(debugMessage)") + } + if AppConfig.DEBUG { + completion(.failure(ProfileServiceError.decoding(debugDescription: debugMessage))) + } else { + let message = NSLocalizedString("Не удалось обработать ответ сервера.", comment: "Avatar upload decode error") + completion(.failure(ProfileServiceError.unexpectedStatus(message))) + } + } + case .failure(let error): + if case let NetworkError.server(_, data) = error, + let data, + let message = Self.errorMessage(from: data) { + completion(.failure(ProfileServiceError.unexpectedStatus(message))) + return + } + completion(.failure(error)) + } + } + } + + func uploadAvatar(image: UIImage) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + uploadAvatar(image: image) { 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) @@ -198,6 +266,31 @@ final class ProfileService { return String(string[string.startIndex.. Data { + var body = Data() + let lineBreak = "\r\n" + if let boundaryData = "--\(boundary)\(lineBreak)".data(using: .utf8) { + body.append(boundaryData) + } + if let dispositionData = "Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(filename)\"\(lineBreak)".data(using: .utf8) { + body.append(dispositionData) + } + if let typeData = "Content-Type: \(mimeType)\(lineBreak)\(lineBreak)".data(using: .utf8) { + body.append(typeData) + } + body.append(data) + if let closingData = "\(lineBreak)--\(boundary)--\(lineBreak)".data(using: .utf8) { + body.append(closingData) + } + return body + } + private static func errorMessage(from data: Data) -> String? { if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) { if let detail = apiError.detail, !detail.isEmpty { diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 9179d7f..4be01d5 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -1230,6 +1230,9 @@ "Не удалось обновить push-токен." : { "comment" : "Sessions service update push unexpected status" }, + "Не удалось обновить аватар." : { + "comment" : "Avatar upload unexpected status" + }, "Не удалось обновить пароль." : { "localizations" : { "en" : { @@ -1251,7 +1254,7 @@ } }, "Не удалось обработать ответ сервера." : { - "comment" : "Profile update decode error", + "comment" : "Avatar upload decode error\nProfile update decode error", "localizations" : { "en" : { "stringUnit" : { @@ -1276,6 +1279,9 @@ "Не удалось подготовить данные запроса." : { "comment" : "Blocked users delete encoding error\nProfile update encoding error" }, + "Не удалось подготовить изображение для загрузки." : { + "comment" : "Avatar encoding error" + }, "Не удалось сериализовать данные запроса." : { "localizations" : { "en" : { diff --git a/yobble/Views/Tab/Settings/EditProfileView.swift b/yobble/Views/Tab/Settings/EditProfileView.swift index 25d600b..18e98bc 100644 --- a/yobble/Views/Tab/Settings/EditProfileView.swift +++ b/yobble/Views/Tab/Settings/EditProfileView.swift @@ -15,6 +15,7 @@ struct EditProfileView: View { // State for loading and errors @State private var isLoading = false @State private var isSaving = false + @State private var isUploadingAvatar = false @State private var alertMessage: String? @State private var showAlert = false @@ -93,22 +94,28 @@ struct EditProfileView: View { .frame(maxWidth: .infinity, alignment: .center) } } - .disabled(!hasProfileChanges || isLoading || isSaving) + .disabled(!hasProfileChanges || isBusy) } .navigationTitle("Профиль") .onAppear(perform: loadProfile) .sheet(isPresented: $showImagePicker) { ImagePicker(image: $avatarImage) } + .onChange(of: avatarImage) { newValue in + guard let image = newValue else { return } + Task { + await uploadAvatarImage(image) + } + } .alert("Ошибка", isPresented: $showAlert, presenting: alertMessage) { _ in Button("OK") {} } message: { message in Text(message) } - if isLoading || isSaving { + if isBusy { Color.black.opacity(0.4).ignoresSafeArea() - ProgressView(isLoading ? "Загрузка..." : "Сохранение...") + ProgressView(busyMessage) .padding() .background(Color.secondary.colorInvert()) .cornerRadius(10) @@ -137,13 +144,7 @@ struct EditProfileView: View { do { let profile = try await profileService.fetchMyProfile() await MainActor.run { - let loadedName = profile.fullName ?? "" - let loadedBio = profile.bio ?? "" - self.profile = profile - self.displayName = loadedName - self.description = loadedBio - self.originalDisplayName = loadedName - self.originalDescription = loadedBio + self.updateForm(with: profile) self.isLoading = false } } catch { @@ -160,6 +161,20 @@ struct EditProfileView: View { displayName != originalDisplayName || description != originalDescription } + private var isBusy: Bool { + isLoading || isSaving || isUploadingAvatar + } + + private var busyMessage: String { + if isUploadingAvatar { + return "Обновление аватара..." + } + if isSaving { + return "Сохранение..." + } + return "Загрузка..." + } + @MainActor private func applyProfileChanges() async { guard !isSaving else { return } @@ -180,13 +195,7 @@ struct EditProfileView: View { do { _ = try await profileService.updateProfile(request) let refreshedProfile = try await profileService.fetchMyProfile() - let updatedName = refreshedProfile.fullName ?? "" - let updatedBio = refreshedProfile.bio ?? "" - profile = refreshedProfile - displayName = updatedName - description = updatedBio - originalDisplayName = updatedName - originalDescription = updatedBio + updateForm(with: refreshedProfile) } catch { let message: String if let error = error as? LocalizedError, let description = error.errorDescription { @@ -200,6 +209,53 @@ struct EditProfileView: View { isSaving = false } + + @MainActor + private func uploadAvatarImage(_ image: UIImage) async { + guard !isUploadingAvatar else { return } + isUploadingAvatar = true + defer { isUploadingAvatar = false } + + do { + _ = try await profileService.uploadAvatar(image: image) + let refreshedProfile = try await profileService.fetchMyProfile() + updateFormPreservingFields(profile: refreshedProfile) + avatarImage = nil + } catch { + let message: String + if let error = error as? LocalizedError, let description = error.errorDescription { + message = description + } else { + message = error.localizedDescription + } + alertMessage = message + showAlert = true + } + } + + @MainActor + private func updateForm(with profile: ProfileDataPayload) { + self.profile = profile + applyProfileTexts(from: profile) + } + + @MainActor + private func updateFormPreservingFields(profile: ProfileDataPayload) { + self.profile = profile + if !hasProfileChanges { + applyProfileTexts(from: profile) + } + } + + @MainActor + private func applyProfileTexts(from profile: ProfileDataPayload) { + let loadedName = profile.fullName ?? "" + let loadedBio = profile.bio ?? "" + self.displayName = loadedName + self.description = loadedBio + self.originalDisplayName = loadedName + self.originalDescription = loadedBio + } } struct ImagePicker: UIViewControllerRepresentable {