mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-26 10:11: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 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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue