Improve edit user profile.

This commit is contained in:
Marcin Czachursk 2023-03-25 11:37:02 +01:00
parent 537f344cf2
commit d437478797
13 changed files with 124 additions and 58 deletions

View File

@ -263,6 +263,7 @@
"editProfile.navigationBar.title" = "Edit profile"; "editProfile.navigationBar.title" = "Edit profile";
"editProfile.title.displayName" = "Display name"; "editProfile.title.displayName" = "Display name";
"editProfile.title.bio" = "Bio"; "editProfile.title.bio" = "Bio";
"editProfile.title.website" = "Website";
"editProfile.title.save" = "Save"; "editProfile.title.save" = "Save";
"editProfile.title.accountSaved" = "Profile has been updated."; "editProfile.title.accountSaved" = "Profile has been updated.";
"editProfile.error.saveAccountFailed" = "Saving profile failed."; "editProfile.error.saveAccountFailed" = "Saving profile failed.";

View File

@ -263,6 +263,7 @@
"editProfile.navigationBar.title" = "Edutuj profil"; "editProfile.navigationBar.title" = "Edutuj profil";
"editProfile.title.displayName" = "Wyświetlana nazwa"; "editProfile.title.displayName" = "Wyświetlana nazwa";
"editProfile.title.bio" = "Bio"; "editProfile.title.bio" = "Bio";
"editProfile.title.website" = "Strona";
"editProfile.title.save" = "Zapisz"; "editProfile.title.save" = "Zapisz";
"editProfile.title.accountSaved" = "Profil zaktualizowano."; "editProfile.title.accountSaved" = "Profil zaktualizowano.";
"editProfile.error.saveAccountFailed" = "Błąd podczas aktualizacji profilu."; "editProfile.error.saveAccountFailed" = "Błąd podczas aktualizacji profilu.";

View File

@ -81,6 +81,9 @@ public struct Account: Codable {
/// An extra attribute returned only when an account is silenced. If true, indicates that the account should be hidden behind a warning screen. /// An extra attribute returned only when an account is silenced. If true, indicates that the account should be hidden behind a warning screen.
public let limited: Bool? public let limited: Bool?
/// User website.
public let website: String?
/// When the most recent status was posted. /// When the most recent status was posted.
/// NULLABLE String (ISO 8601 Date), or null if no statuses /// NULLABLE String (ISO 8601 Date), or null if no statuses
public let lastStatusAt: String? public let lastStatusAt: String?
@ -115,6 +118,7 @@ public struct Account: Codable {
case limited case limited
case lastStatusAt = "last_status_at" case lastStatusAt = "last_status_at"
case recentPosts = "recent_posts" case recentPosts = "recent_posts"
case website
} }
} }

View File

@ -162,10 +162,10 @@ public extension PixelfedClientAuthenticated {
return try await downloadJson([Status].self, request: request) return try await downloadJson([Status].self, request: request)
} }
func update(displayName: String, bio: String, image: Data?) async throws -> Account { func update(displayName: String, bio: String, website: String, image: Data?) async throws -> Account {
let request = try Self.request( let request = try Self.request(
for: baseURL, for: baseURL,
target: Pixelfed.Account.updateCredentials(displayName, bio, image), target: Pixelfed.Account.updateCredentials(displayName, bio, website, image),
withBearerToken: token) withBearerToken: token)
return try await downloadJson(Account.self, request: request) return try await downloadJson(Account.self, request: request)

View File

@ -6,8 +6,6 @@
import Foundation import Foundation
fileprivate let multipartBoundary = UUID().uuidString
extension Pixelfed { extension Pixelfed {
public enum Account { public enum Account {
case account(EntityId) case account(EntityId)
@ -23,12 +21,13 @@ extension Pixelfed {
case unmute(EntityId) case unmute(EntityId)
case relationships([EntityId]) case relationships([EntityId])
case search(SearchQuery, Int) case search(SearchQuery, Int)
case updateCredentials(String, String, Data?) case updateCredentials(String, String, String, Data?)
} }
} }
extension Pixelfed.Account: TargetType { extension Pixelfed.Account: TargetType {
fileprivate var apiPath: String { return "/api/v1/accounts" } private var apiPath: String { return "/api/v1/accounts" }
private var multipartBoundary: String { "d76a15ab-d0d4-499a-a3c6-62a86d0d2a74" }
public var path: String { public var path: String {
switch self { switch self {
@ -58,7 +57,7 @@ extension Pixelfed.Account: TargetType {
return "\(apiPath)/relationships" return "\(apiPath)/relationships"
case .search(_, _): case .search(_, _):
return "\(apiPath)/search" return "\(apiPath)/search"
case .updateCredentials(_, _, _): case .updateCredentials(_, _, _, _):
return "\(apiPath)/update_credentials" return "\(apiPath)/update_credentials"
} }
} }
@ -67,7 +66,7 @@ extension Pixelfed.Account: TargetType {
switch self { switch self {
case .follow(_), .unfollow(_), .block(_), .unblock(_), .mute(_), .unmute(_): case .follow(_), .unfollow(_), .block(_), .unblock(_), .mute(_), .unmute(_):
return .post return .post
case .updateCredentials(_, _, _): case .updateCredentials(_, _, _, _):
return .patch return .patch
default: default:
return .get return .get
@ -114,10 +113,22 @@ extension Pixelfed.Account: TargetType {
minId = _minId minId = _minId
limit = _limit limit = _limit
page = _page page = _page
case .updateCredentials(let displayName, let bio, _): case .updateCredentials(let displayName, let bio, let website, let image):
if image == nil {
return [ return [
("display_name", displayName), ("display_name", displayName),
("note", bio) ("note", bio),
("website", website),
("_pe", "1")
]
} else {
return [
("_pe", "1")
]
}
case .account(_), .verifyCredentials:
return [
("_pe", "1")
] ]
default: default:
return nil return nil
@ -144,7 +155,7 @@ extension Pixelfed.Account: TargetType {
public var headers: [String: String]? { public var headers: [String: String]? {
switch self { switch self {
case .updateCredentials(_, _, let image): case .updateCredentials(_, _, _, let image):
if image != nil { if image != nil {
return ["content-type": "multipart/form-data; boundary=\(multipartBoundary)"] return ["content-type": "multipart/form-data; boundary=\(multipartBoundary)"]
} else { } else {
@ -157,10 +168,15 @@ extension Pixelfed.Account: TargetType {
public var httpBody: Data? { public var httpBody: Data? {
switch self { switch self {
case .updateCredentials(_, _, let image): case .updateCredentials(let displayName, let bio, let website, let image):
if let image { if let image {
let formDataBuilder = MultipartFormData(boundary: multipartBoundary) let formDataBuilder = MultipartFormData(boundary: multipartBoundary)
formDataBuilder.addDataField(named: "file", fileName: "avatar.jpg", data: image, mimeType: "image/jpeg")
formDataBuilder.addTextField(named: "display_name", value: displayName)
formDataBuilder.addTextField(named: "note", value: bio)
formDataBuilder.addTextField(named: "website", value: website)
formDataBuilder.addDataField(named: "avatar", fileName: "avatar.jpg", data: image, mimeType: "image/jpeg")
return formDataBuilder.build() return formDataBuilder.build()
} else { } else {
return nil return nil

View File

@ -1278,7 +1278,7 @@
CODE_SIGN_ENTITLEMENTS = VernissageWidget/VernissageWidgetExtension.entitlements; CODE_SIGN_ENTITLEMENTS = VernissageWidget/VernissageWidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 81; CURRENT_PROJECT_VERSION = 82;
DEVELOPMENT_TEAM = B2U9FEKYP8; DEVELOPMENT_TEAM = B2U9FEKYP8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = VernissageWidget/Info.plist; INFOPLIST_FILE = VernissageWidget/Info.plist;
@ -1306,7 +1306,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = VernissageWidget/VernissageWidgetExtension.entitlements; CODE_SIGN_ENTITLEMENTS = VernissageWidget/VernissageWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 81; CURRENT_PROJECT_VERSION = 82;
DEVELOPMENT_TEAM = B2U9FEKYP8; DEVELOPMENT_TEAM = B2U9FEKYP8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = VernissageWidget/Info.plist; INFOPLIST_FILE = VernissageWidget/Info.plist;

View File

@ -82,6 +82,9 @@ public class ApplicationState: ObservableObject {
/// Status which should be shown from URL. /// Status which should be shown from URL.
@Published var showStatusId: String? = nil @Published var showStatusId: String? = nil
/// Updated user profile.
@Published var updatedProfile: Account?
public func changeApplicationState(accountModel: AccountModel, instance: Instance?, lastSeenStatusId: String?) { public func changeApplicationState(accountModel: AccountModel, instance: Instance?, lastSeenStatusId: String?) {
self.account = accountModel self.account = accountModel
self.lastSeenStatusId = lastSeenStatusId self.lastSeenStatusId = lastSeenStatusId

View File

@ -83,8 +83,8 @@ extension Client {
return try await pixelfedClient.bookmarks(limit: limit, page: page) return try await pixelfedClient.bookmarks(limit: limit, page: page)
} }
func update(displayName: String, bio: String, image: Data?) async throws -> Account { func update(displayName: String, bio: String, website: String, image: Data?) async throws -> Account {
return try await pixelfedClient.update(displayName: displayName, bio: bio, image: image) return try await pixelfedClient.update(displayName: displayName, bio: bio, website: website, image: image)
} }
} }
} }

View File

@ -18,6 +18,7 @@ struct EditProfileView: View {
@State private var saveDisabled = false @State private var saveDisabled = false
@State private var displayName: String = "" @State private var displayName: String = ""
@State private var bio: String = "" @State private var bio: String = ""
@State private var website: String = ""
@State private var avatarData: Data? @State private var avatarData: Data?
private let account: Account private let account: Account
@ -28,7 +29,6 @@ struct EditProfileView: View {
var body: some View { var body: some View {
Form { Form {
Section {
HStack { HStack {
Spacer() Spacer()
VStack { VStack {
@ -37,23 +37,30 @@ struct EditProfileView: View {
Image(uiImage: uiAvatar) Image(uiImage: uiAvatar)
.resizable() .resizable()
.clipShape(applicationState.avatarShape.shape()) .clipShape(applicationState.avatarShape.shape())
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fill)
.frame(width: 96, height: 96) .frame(width: 120, height: 120)
} else { } else {
UserAvatar(accountAvatar: account.avatar, size: .profile) UserAvatar(accountAvatar: account.avatar, size: .large)
} }
LoadingIndicator(isVisible: $saveDisabled)
BottomRight { BottomRight {
Button { Button {
self.photosPickerVisible = true self.photosPickerVisible = true
} label: { } label: {
ZStack {
Circle()
.foregroundColor(.accentColor.opacity(0.8))
.frame(width: 40, height: 40)
Image(systemName: "person.crop.circle.badge.plus") Image(systemName: "person.crop.circle.badge.plus")
.font(.title) .font(.title2)
.foregroundColor(.accentColor) .foregroundColor(.white)
}
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
} }
.frame(width: 96, height: 96) .frame(width: 130, height: 130)
} }
Text("@\(self.account.acct)") Text("@\(self.account.acct)")
@ -63,11 +70,11 @@ struct EditProfileView: View {
Spacer() Spacer()
} }
} .padding(-10)
.padding(0)
.listRowBackground(Color(UIColor.systemGroupedBackground)) .listRowBackground(Color(UIColor.systemGroupedBackground))
.listRowSeparator(Visibility.hidden) .listRowSeparator(Visibility.hidden)
Section("editProfile.title.displayName") { Section("editProfile.title.displayName") {
TextField("", text: $displayName) TextField("", text: $displayName)
} }
@ -76,6 +83,13 @@ struct EditProfileView: View {
TextField("", text: $bio, axis: .vertical) TextField("", text: $bio, axis: .vertical)
.lineLimit(5, reservesSpace: true) .lineLimit(5, reservesSpace: true)
} }
Section("editProfile.title.website") {
TextField("", text: $website)
.autocapitalization(.none)
.keyboardType(.URL)
.autocorrectionDisabled()
}
} }
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
@ -91,6 +105,7 @@ struct EditProfileView: View {
.navigationTitle("editProfile.navigationBar.title") .navigationTitle("editProfile.navigationBar.title")
.onAppear { .onAppear {
self.displayName = self.account.displayName ?? String.empty() self.displayName = self.account.displayName ?? String.empty()
self.website = self.account.website ?? String.empty()
let markdownBio = self.account.note?.asMarkdown ?? String.empty() let markdownBio = self.account.note?.asMarkdown ?? String.empty()
if let attributedString = try? AttributedString(markdown: markdownBio) { if let attributedString = try? AttributedString(markdown: markdownBio) {
@ -110,7 +125,12 @@ struct EditProfileView: View {
private func saveProfile() async { private func saveProfile() async {
do { do {
_ = try await self.client.accounts?.update(displayName: self.displayName, bio: self.bio, image: self.avatarData) let savedAccount = try await self.client.accounts?.update(displayName: self.displayName,
bio: self.bio,
website: self.website,
image: self.avatarData)
self.applicationState.updatedProfile = savedAccount
ToastrService.shared.showSuccess("editProfile.title.accountSaved", imageSystemName: "person.crop.circle") ToastrService.shared.showSuccess("editProfile.title.accountSaved", imageSystemName: "person.crop.circle")
dismiss() dismiss()
} catch { } catch {
@ -137,7 +157,7 @@ struct EditProfileView: View {
} }
guard let data = image guard let data = image
.resized(to: .init(width: 400, height: 400)) .resized(to: .init(width: 800, height: 800))
.getJpegData() else { .getJpegData() else {
return return
} }

View File

@ -13,6 +13,7 @@ private struct OffsetPreferenceKey: PreferenceKey {
struct HomeFeedView: View { struct HomeFeedView: View {
@Environment(\.managedObjectContext) private var viewContext @Environment(\.managedObjectContext) private var viewContext
@Environment(\.refresh) private var refresh
@EnvironmentObject var applicationState: ApplicationState @EnvironmentObject var applicationState: ApplicationState
@EnvironmentObject var routerPath: RouterPath @EnvironmentObject var routerPath: RouterPath
@ -89,9 +90,7 @@ struct HomeFeedView: View {
.opacity(self.opacity) .opacity(self.opacity)
.onTapGesture { .onTapGesture {
Task { Task {
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3)) await self.refresh?()
await self.refreshData()
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
} }
} }
} }

View File

@ -59,11 +59,11 @@ struct UserProfileHeaderView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(account.displayNameWithoutEmojis) Text(account.displayNameWithoutEmojis)
.foregroundColor(.mainTextColor) .foregroundColor(.mainTextColor)
.font(.footnote) .font(.title3)
.fontWeight(.bold) .fontWeight(.bold)
Text("@\(account.acct)") Text("@\(account.acct)")
.foregroundColor(.lightGrayColor) .foregroundColor(.lightGrayColor)
.font(.footnote) .font(.subheadline)
} }
Spacer() Spacer()
@ -78,12 +78,22 @@ struct UserProfileHeaderView: View {
.environment(\.openURL, OpenURLAction { url in .environment(\.openURL, OpenURLAction { url in
routerPath.handle(url: url) routerPath.handle(url: url)
}) })
.padding(.vertical, 4)
}
if let website = account.website, let url = URL(string: website) {
HStack {
Image(systemName: "link")
Link(website, destination: url)
Spacer()
}
.padding(.bottom, 2)
.font(.footnote)
} }
Text(String(format: NSLocalizedString("userProfile.title.joined", comment: "Joined"), account.createdAt.toRelative(.isoDateTimeMilliSec))) Text(String(format: NSLocalizedString("userProfile.title.joined", comment: "Joined"), account.createdAt.toRelative(.isoDateTimeMilliSec)))
.foregroundColor(.lightGrayColor.opacity(0.5)) .foregroundColor(.lightGrayColor.opacity(0.5))
.font(.footnote) .font(.footnote)
} }
.padding() .padding()
} }

View File

@ -20,6 +20,7 @@ struct UserProfileView: View {
@State private var account: Account? = nil @State private var account: Account? = nil
@State private var relationship: Relationship? = nil @State private var relationship: Relationship? = nil
@State private var state: ViewState = .loading @State private var state: ViewState = .loading
@State private var viewId = UUID().uuidString
var body: some View { var body: some View {
self.mainBody() self.mainBody()
@ -47,8 +48,17 @@ struct UserProfileView: View {
if let account = self.account { if let account = self.account {
ScrollView { ScrollView {
UserProfileHeaderView(account: account, relationship: relationship) UserProfileHeaderView(account: account, relationship: relationship)
.id(self.viewId)
UserProfileStatusesView(accountId: account.id) UserProfileStatusesView(accountId: account.id)
} }
.onAppear {
if let updatedProfile = self.applicationState.updatedProfile {
self.account = nil
self.account = updatedProfile
self.applicationState.updatedProfile = nil
self.viewId = UUID().uuidString
}
}
} }
case .error(let error): case .error(let error):
ErrorView(error: error) { ErrorView(error: error) {

View File

@ -11,7 +11,7 @@ struct UserAvatar: View {
@EnvironmentObject var applicationState: ApplicationState @EnvironmentObject var applicationState: ApplicationState
public enum Size { public enum Size {
case mini, list, comment, profile case mini, list, comment, profile, large
public var size: CGSize { public var size: CGSize {
switch self { switch self {
@ -23,6 +23,8 @@ struct UserAvatar: View {
return .init(width: 48, height: 48) return .init(width: 48, height: 48)
case .profile: case .profile:
return .init(width: 96, height: 96) return .init(width: 96, height: 96)
case .large:
return .init(width: 120, height: 120)
} }
} }
} }