add upload photo

This commit is contained in:
cheykrym 2025-12-10 02:33:49 +03:00
parent 92bdf58cde
commit f1ec7e637b
4 changed files with 177 additions and 18 deletions

View File

@ -204,3 +204,7 @@ struct ProfileUpdateRequestPayload: Encodable {
self.profilePermissions = profilePermissions
}
}
struct UploadAvatarPayload: Decodable {
let fileId: String
}

View File

@ -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<String, Error>) -> 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<UploadAvatarPayload>.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..<index]) + ""
}
private static func makeMultipartBody(
data: Data,
boundary: String,
fieldName: String,
filename: String,
mimeType: String
) -> 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 {

View File

@ -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" : {

View File

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