mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-22 16:31:00 +00:00
Edit profile: Update avatar & header
This commit is contained in:
parent
f699c33dfb
commit
1eb33466ca
7 changed files with 209 additions and 22 deletions
|
@ -3,6 +3,7 @@ import Env
|
|||
import Models
|
||||
import Network
|
||||
import SwiftUI
|
||||
import NukeUI
|
||||
|
||||
@MainActor
|
||||
public struct EditAccountView: View {
|
||||
|
@ -12,8 +13,8 @@ public struct EditAccountView: View {
|
|||
@Environment(UserPreferences.self) private var userPrefs
|
||||
|
||||
@State private var viewModel = EditAccountViewModel()
|
||||
|
||||
public init() {}
|
||||
|
||||
public init() { }
|
||||
|
||||
public var body: some View {
|
||||
NavigationStack {
|
||||
|
@ -21,7 +22,8 @@ public struct EditAccountView: View {
|
|||
if viewModel.isLoading {
|
||||
loadingSection
|
||||
} else {
|
||||
aboutSections
|
||||
imagesSection
|
||||
aboutSection
|
||||
fieldsSection
|
||||
postSettingsSection
|
||||
accountSection
|
||||
|
@ -62,9 +64,77 @@ public struct EditAccountView: View {
|
|||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#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
|
||||
private var aboutSections: some View {
|
||||
private var aboutSection: some View {
|
||||
Section("account.edit.display-name") {
|
||||
TextField("account.edit.display-name", text: $viewModel.displayName)
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ import Models
|
|||
import Network
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import Status
|
||||
|
||||
@MainActor
|
||||
@Observable class EditAccountViewModel {
|
||||
|
@ -26,10 +28,43 @@ import SwiftUI
|
|||
var isLocked: Bool = false
|
||||
var isDiscoverable: Bool = false
|
||||
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 isSaving: 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() {}
|
||||
|
||||
|
@ -44,6 +79,8 @@ import SwiftUI
|
|||
isBot = account.bot
|
||||
isLocked = account.locked
|
||||
isDiscoverable = account.discoverable ?? false
|
||||
avatar = account.avatar
|
||||
header = account.header
|
||||
fields = account.source?.fields.map { .init(name: $0.name, value: $0.value.asRawText) } ?? []
|
||||
withAnimation {
|
||||
isLoading = false
|
||||
|
@ -71,4 +108,47 @@ import SwiftUI
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,9 @@ public final class Account: Codable, Identifiable, Hashable, Sendable, Equatable
|
|||
lhs.lastStatusAt == rhs.lastStatusAt &&
|
||||
lhs.discoverable == rhs.discoverable &&
|
||||
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) {
|
||||
|
|
|
@ -240,6 +240,47 @@ import SwiftUI
|
|||
filename: String,
|
||||
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)
|
||||
var request = makeURLRequest(url: url, endpoint: endpoint, httpMethod: method)
|
||||
let boundary = UUID().uuidString
|
||||
|
@ -252,16 +293,7 @@ import SwiftUI
|
|||
httpBody.append(data)
|
||||
httpBody.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
request.httpBody = httpBody as 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
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
private func logResponseOnError(httpResponse: URLResponse, data: Data) {
|
||||
|
|
|
@ -9,6 +9,7 @@ public enum Accounts: Endpoint {
|
|||
case followedTags
|
||||
case featuredTags(id: String)
|
||||
case verifyCredentials
|
||||
case updateCredentialsMedia
|
||||
case updateCredentials(json: UpdateCredentialsData)
|
||||
case statuses(id: String,
|
||||
sinceId: String?,
|
||||
|
@ -47,7 +48,7 @@ public enum Accounts: Endpoint {
|
|||
"accounts/\(id)/featured_tags"
|
||||
case .verifyCredentials:
|
||||
"accounts/verify_credentials"
|
||||
case .updateCredentials:
|
||||
case .updateCredentials, .updateCredentialsMedia:
|
||||
"accounts/update_credentials"
|
||||
case let .statuses(id, _, _, _, _, _):
|
||||
"accounts/\(id)/statuses"
|
||||
|
|
|
@ -2,12 +2,14 @@ import AVFoundation
|
|||
import Foundation
|
||||
import UIKit
|
||||
|
||||
actor StatusEditorCompressor {
|
||||
public actor StatusEditorCompressor {
|
||||
public init() { }
|
||||
|
||||
enum CompressorError: Error {
|
||||
case noData
|
||||
}
|
||||
|
||||
func compressImageFrom(url: URL) async -> Data? {
|
||||
public func compressImageFrom(url: URL) async -> Data? {
|
||||
await withCheckedContinuation { continuation in
|
||||
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
|
||||
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
|
||||
if image.size.height > 5000 || image.size.width > 5000 {
|
||||
image = image.resized(to: .init(width: image.size.width / 4,
|
||||
|
|
|
@ -151,10 +151,10 @@ struct MovieFileTranseferable: Transferable {
|
|||
}
|
||||
}
|
||||
|
||||
struct ImageFileTranseferable: Transferable {
|
||||
let url: URL
|
||||
public struct ImageFileTranseferable: Transferable, Sendable {
|
||||
public let url: URL
|
||||
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
public static var transferRepresentation: some TransferRepresentation {
|
||||
FileRepresentation(contentType: .image) { image in
|
||||
SentTransferredFile(image.url)
|
||||
} importing: { received in
|
||||
|
|
Loading…
Reference in a new issue