Edit profile: Update avatar & header

This commit is contained in:
Thomas Ricouard 2024-01-02 21:16:27 +01:00
parent f699c33dfb
commit 1eb33466ca
7 changed files with 209 additions and 22 deletions

View file

@ -3,6 +3,7 @@ import Env
import Models import Models
import Network import Network
import SwiftUI import SwiftUI
import NukeUI
@MainActor @MainActor
public struct EditAccountView: View { public struct EditAccountView: View {
@ -12,8 +13,8 @@ public struct EditAccountView: View {
@Environment(UserPreferences.self) private var userPrefs @Environment(UserPreferences.self) private var userPrefs
@State private var viewModel = EditAccountViewModel() @State private var viewModel = EditAccountViewModel()
public init() {} public init() { }
public var body: some View { public var body: some View {
NavigationStack { NavigationStack {
@ -21,7 +22,8 @@ public struct EditAccountView: View {
if viewModel.isLoading { if viewModel.isLoading {
loadingSection loadingSection
} else { } else {
aboutSections imagesSection
aboutSection
fieldsSection fieldsSection
postSettingsSection postSettingsSection
accountSection accountSection
@ -62,9 +64,77 @@ public struct EditAccountView: View {
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
#endif #endif
} }
private var imagesSection: some View {
Section {
ZStack(alignment: .center) {
if let header = viewModel.header {
ZStack(alignment: .topTrailing) {
LazyImage(url: header) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 150)
.clipShape(RoundedRectangle(cornerRadius: 8))
.clipped()
} else {
RoundedRectangle(cornerRadius: 8)
.foregroundStyle(theme.primaryBackgroundColor)
.frame(height: 150)
}
}
.frame(height: 150)
Button {
viewModel.isChangingHeader = true
viewModel.isPhotoPickerPresented = true
} label: {
Image(systemName: "photo.badge.plus")
.foregroundStyle(.white)
}
.buttonStyle(.borderedProminent)
.clipShape(Circle())
.offset(y: 4)
}
}
if let avatar = viewModel.avatar {
ZStack(alignment: .bottomLeading) {
AvatarView(avatar, config: .account)
Button {
viewModel.isChangingAvatar = true
viewModel.isPhotoPickerPresented = true
} label: {
Image(systemName: "photo.badge.plus")
.foregroundStyle(.white)
}
.buttonStyle(.borderedProminent)
.clipShape(Circle())
.offset(x: -8, y: 8)
}
}
}
.overlay {
if viewModel.isChangingAvatar || viewModel.isChangingHeader {
ZStack(alignment: .center) {
RoundedRectangle(cornerRadius: 8)
.foregroundStyle(Color.black.opacity(0.40))
ProgressView()
}
}
}
.listRowInsets(EdgeInsets())
}
.listRowBackground(theme.secondaryBackgroundColor)
.photosPicker(isPresented: $viewModel.isPhotoPickerPresented,
selection: $viewModel.mediaPickers,
maxSelectionCount: 1,
matching: .any(of: [.images]),
photoLibrary: .shared())
}
@ViewBuilder @ViewBuilder
private var aboutSections: some View { private var aboutSection: some View {
Section("account.edit.display-name") { Section("account.edit.display-name") {
TextField("account.edit.display-name", text: $viewModel.displayName) TextField("account.edit.display-name", text: $viewModel.displayName)
} }

View file

@ -2,6 +2,8 @@ import Models
import Network import Network
import Observation import Observation
import SwiftUI import SwiftUI
import PhotosUI
import Status
@MainActor @MainActor
@Observable class EditAccountViewModel { @Observable class EditAccountViewModel {
@ -26,10 +28,43 @@ import SwiftUI
var isLocked: Bool = false var isLocked: Bool = false
var isDiscoverable: Bool = false var isDiscoverable: Bool = false
var fields: [FieldEditViewModel] = [] var fields: [FieldEditViewModel] = []
var avatar: URL?
var header: URL?
var isPhotoPickerPresented: Bool = false {
didSet {
if !isPhotoPickerPresented && mediaPickers.isEmpty {
isChangingAvatar = false
isChangingHeader = false
}
}
}
var isChangingAvatar: Bool = false
var isChangingHeader: Bool = false
var isLoading: Bool = true var isLoading: Bool = true
var isSaving: Bool = false var isSaving: Bool = false
var saveError: Bool = false var saveError: Bool = false
var mediaPickers: [PhotosPickerItem] = [] {
didSet {
if let item = mediaPickers.first {
Task {
if let data = await getItemImageData(item: item) {
if isChangingAvatar {
await uploadAvatar(data: data)
} else if isChangingHeader {
await uploadHeader(data: data)
}
await fetchAccount()
isChangingAvatar = false
isChangingHeader = false
mediaPickers = []
}
}
}
}
}
init() {} init() {}
@ -44,6 +79,8 @@ import SwiftUI
isBot = account.bot isBot = account.bot
isLocked = account.locked isLocked = account.locked
isDiscoverable = account.discoverable ?? false isDiscoverable = account.discoverable ?? false
avatar = account.avatar
header = account.header
fields = account.source?.fields.map { .init(name: $0.name, value: $0.value.asRawText) } ?? [] fields = account.source?.fields.map { .init(name: $0.name, value: $0.value.asRawText) } ?? []
withAnimation { withAnimation {
isLoading = false isLoading = false
@ -71,4 +108,47 @@ import SwiftUI
saveError = true saveError = true
} }
} }
private func uploadHeader(data: Data) async -> Bool {
guard let client else { return false }
do {
let response = try await client.mediaUpload(endpoint: Accounts.updateCredentialsMedia,
version: .v1,
method: "PATCH",
mimeType: "image/jpeg",
filename: "header",
data: data)
return response?.statusCode == 200
} catch {
return false
}
}
private func uploadAvatar(data: Data) async -> Bool {
guard let client else { return false }
do {
let response = try await client.mediaUpload(endpoint: Accounts.updateCredentialsMedia,
version: .v1,
method: "PATCH",
mimeType: "image/jpeg",
filename: "avatar",
data: data)
return response?.statusCode == 200
} catch {
return false
}
}
private func getItemImageData(item: PhotosPickerItem) async -> Data? {
guard let imageFile = try? await item.loadTransferable(type: ImageFileTranseferable.self) else { return nil }
let compressor = StatusEditorCompressor()
guard let compressedData = await compressor.compressImageFrom(url: imageFile.url),
let image = UIImage(data: compressedData),
let uploadData = try? await compressor.compressImageForUpload(image)
else { return nil }
return uploadData
}
} }

View file

@ -14,7 +14,9 @@ public final class Account: Codable, Identifiable, Hashable, Sendable, Equatable
lhs.lastStatusAt == rhs.lastStatusAt && lhs.lastStatusAt == rhs.lastStatusAt &&
lhs.discoverable == rhs.discoverable && lhs.discoverable == rhs.discoverable &&
lhs.bot == rhs.bot && lhs.bot == rhs.bot &&
lhs.locked == rhs.locked lhs.locked == rhs.locked &&
lhs.avatar == rhs.avatar &&
lhs.header == rhs.header
} }
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {

View file

@ -240,6 +240,47 @@ import SwiftUI
filename: String, filename: String,
data: Data) async throws -> Entity data: Data) async throws -> Entity
{ {
let request = try makeFormDataRequest(endpoint: endpoint,
version: version,
method: method,
mimeType: mimeType,
filename: filename,
data: data)
let (data, httpResponse) = try await urlSession.data(for: request)
logResponseOnError(httpResponse: httpResponse, data: data)
do {
return try decoder.decode(Entity.self, from: data)
} catch {
if let serverError = try? decoder.decode(ServerError.self, from: data) {
throw serverError
}
throw error
}
}
public func mediaUpload(endpoint: Endpoint,
version: Version,
method: String,
mimeType: String,
filename: String,
data: Data) async throws -> HTTPURLResponse?
{
let request = try makeFormDataRequest(endpoint: endpoint,
version: version,
method: method,
mimeType: mimeType,
filename: filename,
data: data)
let (_, httpResponse) = try await urlSession.data(for: request)
return httpResponse as? HTTPURLResponse
}
private func makeFormDataRequest(endpoint: Endpoint,
version: Version,
method: String,
mimeType: String,
filename: String,
data: Data) throws -> URLRequest {
let url = try makeURL(endpoint: endpoint, forceVersion: version) let url = try makeURL(endpoint: endpoint, forceVersion: version)
var request = makeURLRequest(url: url, endpoint: endpoint, httpMethod: method) var request = makeURLRequest(url: url, endpoint: endpoint, httpMethod: method)
let boundary = UUID().uuidString let boundary = UUID().uuidString
@ -252,16 +293,7 @@ import SwiftUI
httpBody.append(data) httpBody.append(data)
httpBody.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) httpBody.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = httpBody as Data request.httpBody = httpBody as Data
let (data, httpResponse) = try await urlSession.data(for: request) return request
logResponseOnError(httpResponse: httpResponse, data: data)
do {
return try decoder.decode(Entity.self, from: data)
} catch {
if let serverError = try? decoder.decode(ServerError.self, from: data) {
throw serverError
}
throw error
}
} }
private func logResponseOnError(httpResponse: URLResponse, data: Data) { private func logResponseOnError(httpResponse: URLResponse, data: Data) {

View file

@ -9,6 +9,7 @@ public enum Accounts: Endpoint {
case followedTags case followedTags
case featuredTags(id: String) case featuredTags(id: String)
case verifyCredentials case verifyCredentials
case updateCredentialsMedia
case updateCredentials(json: UpdateCredentialsData) case updateCredentials(json: UpdateCredentialsData)
case statuses(id: String, case statuses(id: String,
sinceId: String?, sinceId: String?,
@ -47,7 +48,7 @@ public enum Accounts: Endpoint {
"accounts/\(id)/featured_tags" "accounts/\(id)/featured_tags"
case .verifyCredentials: case .verifyCredentials:
"accounts/verify_credentials" "accounts/verify_credentials"
case .updateCredentials: case .updateCredentials, .updateCredentialsMedia:
"accounts/update_credentials" "accounts/update_credentials"
case let .statuses(id, _, _, _, _, _): case let .statuses(id, _, _, _, _, _):
"accounts/\(id)/statuses" "accounts/\(id)/statuses"

View file

@ -2,12 +2,14 @@ import AVFoundation
import Foundation import Foundation
import UIKit import UIKit
actor StatusEditorCompressor { public actor StatusEditorCompressor {
public init() { }
enum CompressorError: Error { enum CompressorError: Error {
case noData case noData
} }
func compressImageFrom(url: URL) async -> Data? { public func compressImageFrom(url: URL) async -> Data? {
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else {
@ -54,7 +56,7 @@ actor StatusEditorCompressor {
} }
} }
func compressImageForUpload(_ image: UIImage) async throws -> Data { public func compressImageForUpload(_ image: UIImage) async throws -> Data {
var image = image var image = image
if image.size.height > 5000 || image.size.width > 5000 { if image.size.height > 5000 || image.size.width > 5000 {
image = image.resized(to: .init(width: image.size.width / 4, image = image.resized(to: .init(width: image.size.width / 4,

View file

@ -151,10 +151,10 @@ struct MovieFileTranseferable: Transferable {
} }
} }
struct ImageFileTranseferable: Transferable { public struct ImageFileTranseferable: Transferable, Sendable {
let url: URL public let url: URL
static var transferRepresentation: some TransferRepresentation { public static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .image) { image in FileRepresentation(contentType: .image) { image in
SentTransferredFile(image.url) SentTransferredFile(image.url)
} importing: { received in } importing: { received in