add upload photo
This commit is contained in:
parent
92bdf58cde
commit
f1ec7e637b
@ -204,3 +204,7 @@ struct ProfileUpdateRequestPayload: Encodable {
|
|||||||
self.profilePermissions = profilePermissions
|
self.profilePermissions = profilePermissions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct UploadAvatarPayload: Decodable {
|
||||||
|
let fileId: String
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
enum ProfileServiceError: LocalizedError {
|
enum ProfileServiceError: LocalizedError {
|
||||||
case unexpectedStatus(String)
|
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 {
|
private static func decodeDate(from decoder: Decoder) throws -> Date {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
let string = try container.decode(String.self)
|
let string = try container.decode(String.self)
|
||||||
@ -198,6 +266,31 @@ final class ProfileService {
|
|||||||
return String(string[string.startIndex..<index]) + "…"
|
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? {
|
private static func errorMessage(from data: Data) -> String? {
|
||||||
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
|
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
|
||||||
if let detail = apiError.detail, !detail.isEmpty {
|
if let detail = apiError.detail, !detail.isEmpty {
|
||||||
|
|||||||
@ -1230,6 +1230,9 @@
|
|||||||
"Не удалось обновить push-токен." : {
|
"Не удалось обновить push-токен." : {
|
||||||
"comment" : "Sessions service update push unexpected status"
|
"comment" : "Sessions service update push unexpected status"
|
||||||
},
|
},
|
||||||
|
"Не удалось обновить аватар." : {
|
||||||
|
"comment" : "Avatar upload unexpected status"
|
||||||
|
},
|
||||||
"Не удалось обновить пароль." : {
|
"Не удалось обновить пароль." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -1251,7 +1254,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Не удалось обработать ответ сервера." : {
|
"Не удалось обработать ответ сервера." : {
|
||||||
"comment" : "Profile update decode error",
|
"comment" : "Avatar upload decode error\nProfile update decode error",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -1276,6 +1279,9 @@
|
|||||||
"Не удалось подготовить данные запроса." : {
|
"Не удалось подготовить данные запроса." : {
|
||||||
"comment" : "Blocked users delete encoding error\nProfile update encoding error"
|
"comment" : "Blocked users delete encoding error\nProfile update encoding error"
|
||||||
},
|
},
|
||||||
|
"Не удалось подготовить изображение для загрузки." : {
|
||||||
|
"comment" : "Avatar encoding error"
|
||||||
|
},
|
||||||
"Не удалось сериализовать данные запроса." : {
|
"Не удалось сериализовать данные запроса." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ struct EditProfileView: View {
|
|||||||
// State for loading and errors
|
// State for loading and errors
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var isSaving = false
|
@State private var isSaving = false
|
||||||
|
@State private var isUploadingAvatar = false
|
||||||
@State private var alertMessage: String?
|
@State private var alertMessage: String?
|
||||||
@State private var showAlert = false
|
@State private var showAlert = false
|
||||||
|
|
||||||
@ -93,22 +94,28 @@ struct EditProfileView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(!hasProfileChanges || isLoading || isSaving)
|
.disabled(!hasProfileChanges || isBusy)
|
||||||
}
|
}
|
||||||
.navigationTitle("Профиль")
|
.navigationTitle("Профиль")
|
||||||
.onAppear(perform: loadProfile)
|
.onAppear(perform: loadProfile)
|
||||||
.sheet(isPresented: $showImagePicker) {
|
.sheet(isPresented: $showImagePicker) {
|
||||||
ImagePicker(image: $avatarImage)
|
ImagePicker(image: $avatarImage)
|
||||||
}
|
}
|
||||||
|
.onChange(of: avatarImage) { newValue in
|
||||||
|
guard let image = newValue else { return }
|
||||||
|
Task {
|
||||||
|
await uploadAvatarImage(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
.alert("Ошибка", isPresented: $showAlert, presenting: alertMessage) { _ in
|
.alert("Ошибка", isPresented: $showAlert, presenting: alertMessage) { _ in
|
||||||
Button("OK") {}
|
Button("OK") {}
|
||||||
} message: { message in
|
} message: { message in
|
||||||
Text(message)
|
Text(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isLoading || isSaving {
|
if isBusy {
|
||||||
Color.black.opacity(0.4).ignoresSafeArea()
|
Color.black.opacity(0.4).ignoresSafeArea()
|
||||||
ProgressView(isLoading ? "Загрузка..." : "Сохранение...")
|
ProgressView(busyMessage)
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.secondary.colorInvert())
|
.background(Color.secondary.colorInvert())
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
@ -137,13 +144,7 @@ struct EditProfileView: View {
|
|||||||
do {
|
do {
|
||||||
let profile = try await profileService.fetchMyProfile()
|
let profile = try await profileService.fetchMyProfile()
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
let loadedName = profile.fullName ?? ""
|
self.updateForm(with: profile)
|
||||||
let loadedBio = profile.bio ?? ""
|
|
||||||
self.profile = profile
|
|
||||||
self.displayName = loadedName
|
|
||||||
self.description = loadedBio
|
|
||||||
self.originalDisplayName = loadedName
|
|
||||||
self.originalDescription = loadedBio
|
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -160,6 +161,20 @@ struct EditProfileView: View {
|
|||||||
displayName != originalDisplayName || description != originalDescription
|
displayName != originalDisplayName || description != originalDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isBusy: Bool {
|
||||||
|
isLoading || isSaving || isUploadingAvatar
|
||||||
|
}
|
||||||
|
|
||||||
|
private var busyMessage: String {
|
||||||
|
if isUploadingAvatar {
|
||||||
|
return "Обновление аватара..."
|
||||||
|
}
|
||||||
|
if isSaving {
|
||||||
|
return "Сохранение..."
|
||||||
|
}
|
||||||
|
return "Загрузка..."
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func applyProfileChanges() async {
|
private func applyProfileChanges() async {
|
||||||
guard !isSaving else { return }
|
guard !isSaving else { return }
|
||||||
@ -180,13 +195,7 @@ struct EditProfileView: View {
|
|||||||
do {
|
do {
|
||||||
_ = try await profileService.updateProfile(request)
|
_ = try await profileService.updateProfile(request)
|
||||||
let refreshedProfile = try await profileService.fetchMyProfile()
|
let refreshedProfile = try await profileService.fetchMyProfile()
|
||||||
let updatedName = refreshedProfile.fullName ?? ""
|
updateForm(with: refreshedProfile)
|
||||||
let updatedBio = refreshedProfile.bio ?? ""
|
|
||||||
profile = refreshedProfile
|
|
||||||
displayName = updatedName
|
|
||||||
description = updatedBio
|
|
||||||
originalDisplayName = updatedName
|
|
||||||
originalDescription = updatedBio
|
|
||||||
} catch {
|
} catch {
|
||||||
let message: String
|
let message: String
|
||||||
if let error = error as? LocalizedError, let description = error.errorDescription {
|
if let error = error as? LocalizedError, let description = error.errorDescription {
|
||||||
@ -200,6 +209,53 @@ struct EditProfileView: View {
|
|||||||
|
|
||||||
isSaving = false
|
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 {
|
struct ImagePicker: UIViewControllerRepresentable {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user