add upload photo
This commit is contained in:
parent
92bdf58cde
commit
f1ec7e637b
@ -204,3 +204,7 @@ struct ProfileUpdateRequestPayload: Encodable {
|
||||
self.profilePermissions = profilePermissions
|
||||
}
|
||||
}
|
||||
|
||||
struct UploadAvatarPayload: Decodable {
|
||||
let fileId: String
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user