From 6d46d0e0d1cb1dff47f268bfa2fd4b6588708d5a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 28 Aug 2025 23:40:35 +0300 Subject: [PATCH 01/17] Update user avatar design in profile --- .../ProfileFeature/ProfileScreen.swift | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/Modules/Sources/ProfileFeature/ProfileScreen.swift b/Modules/Sources/ProfileFeature/ProfileScreen.swift index b3377deb..646c8e08 100644 --- a/Modules/Sources/ProfileFeature/ProfileScreen.swift +++ b/Modules/Sources/ProfileFeature/ProfileScreen.swift @@ -117,32 +117,35 @@ public struct ProfileScreen: View { @ViewBuilder private func Header(user: User) -> some View { VStack(alignment: .center, spacing: 0) { - LazyImage(url: user.imageUrl) { state in - Group { - if let image = state.image { - image.resizable().scaledToFill() - } else { - Color(.systemBackground) + HStack { + LazyImage(url: user.imageUrl) { state in + Group { + if let image = state.image { + image.resizable().scaledToFill() + } else { + Color(.systemBackground) + } + } + .skeleton(with: state.isLoading, shape: .circle) + } + .frame(width: 56, height: 56) + .clipShape(Circle()) + + VStack(alignment: .leading) { + Text(user.nickname) + .font(.headline) + .foregroundStyle(Color(.Labels.primary)) + + if !user.lastSeenDate.isOnlineHidden() { + Text(user.lastSeenDate.formattedOnlineDate(), bundle: .module) + .font(.footnote) + .foregroundStyle(user.lastSeenDate.isUserOnline() ? Color(.Main.green) : Color(.Labels.teritary)) + .padding(.bottom, 8) } } - .skeleton(with: state.isLoading, shape: .circle) } - .frame(width: 128, height: 128) - .clipShape(Circle()) .padding(.bottom, 10) - Text(user.nickname) - .font(.headline) - .foregroundStyle(Color(.Labels.primary)) - .padding(.bottom, 4) - - if !user.lastSeenDate.isOnlineHidden() { - Text(user.lastSeenDate.formattedOnlineDate(), bundle: .module) - .font(.footnote) - .foregroundStyle(user.lastSeenDate.isUserOnline() ? Color(.Main.green) : Color(.Labels.teritary)) - .padding(.bottom, 8) - } - if let signature = user.signatureAttributed { RichText(text: signature, onUrlTap: { url in send(.deeplinkTapped(url, .signature)) From 693d0313492742b42675ec2d070d459689f17b11 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 29 Aug 2025 12:12:12 +0300 Subject: [PATCH 02/17] Add mock for user devices --- Modules/Sources/Models/Profile/User.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Models/Profile/User.swift b/Modules/Sources/Models/Profile/User.swift index a1ef7354..edeb3bcb 100644 --- a/Modules/Sources/Models/Profile/User.swift +++ b/Modules/Sources/Models/Profile/User.swift @@ -274,7 +274,18 @@ public extension User { gender: .male, userTime: 10800, city: "Moscow", - devDBdevices: [], + devDBdevices: [ + .init( + id: "ip16pro", + name: "iPhone 16 Pro", + main: true + ), + .init( + id: "ip13", + name: "iPhone 13", + main: false + ) + ], karma: 1500, posts: 23, comments: 173, From 5af7f72866a48c884e644deb9307f10c397d82e0 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 29 Aug 2025 12:14:22 +0300 Subject: [PATCH 03/17] Profile last seen date improvements --- Modules/Sources/ProfileFeature/ProfileScreen.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Modules/Sources/ProfileFeature/ProfileScreen.swift b/Modules/Sources/ProfileFeature/ProfileScreen.swift index 646c8e08..9b1a7c79 100644 --- a/Modules/Sources/ProfileFeature/ProfileScreen.swift +++ b/Modules/Sources/ProfileFeature/ProfileScreen.swift @@ -140,7 +140,6 @@ public struct ProfileScreen: View { Text(user.lastSeenDate.formattedOnlineDate(), bundle: .module) .font(.footnote) .foregroundStyle(user.lastSeenDate.isUserOnline() ? Color(.Main.green) : Color(.Labels.teritary)) - .padding(.bottom, 8) } } } From f591f15e2154379abbed333bafd24b1aaf4854bd Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 29 Aug 2025 16:06:07 +0300 Subject: [PATCH 04/17] Add user avatar update endpoint --- Modules/Sources/APIClient/APIClient.swift | 10 ++++++++++ .../Profile/UserAvatarResponseType.swift | 13 ++++++++++++ .../ParsingClient/Parsers/ProfileParser.swift | 20 +++++++++++++++++++ .../Sources/ParsingClient/ParsingClient.swift | 4 ++++ 4 files changed, 47 insertions(+) create mode 100644 Modules/Sources/Models/Profile/UserAvatarResponseType.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 3f792c06..0fbb0362 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -38,6 +38,7 @@ public struct APIClient: Sendable { public var getUser: @Sendable (_ userId: Int, _ policy: CachePolicy) async throws -> AsyncThrowingStream public var getReputationVotes: @Sendable (_ data: ReputationVotesRequest) async throws -> ReputationVotes public var changeReputation: @Sendable (_ data: ReputationChangeRequest) async throws -> ReputationChangeResponseType + public var updateUserAvatar: @Sendable (_ userId: Int, _ image: Data) async throws -> UserAvatarResponseType // Bookmarks public var getBookmarksList: @Sendable () async throws -> [Bookmark] @@ -198,8 +199,14 @@ extension APIClient: DependencyKey { let status = Int(response.getResponseStatus())! return ReputationChangeResponseType(rawValue: status) }, + updateUserAvatar: { userId, image in + let command = MemberCommand.avatar(memberId: userId, avatar: image) + let response = try await api.get(command) + return try await parser.parseAvatarUrl(response: response) + }, // MARK: - Bookmarks + getBookmarksList: { let response = try await api.get(MemberCommand.Bookmarks.list) return try await parser.parseBookmarksList(response) @@ -460,6 +467,9 @@ extension APIClient: DependencyKey { changeReputation: { _ in return .success }, + updateUserAvatar: { _, _ in + return .success(URL(string: "https://github.com/SubvertDev/ForPDA/raw/main/Images/logo.png")!) + }, getBookmarksList: { return [.mockArticle, .mockForum, .mockUser] }, diff --git a/Modules/Sources/Models/Profile/UserAvatarResponseType.swift b/Modules/Sources/Models/Profile/UserAvatarResponseType.swift new file mode 100644 index 00000000..167d4afd --- /dev/null +++ b/Modules/Sources/Models/Profile/UserAvatarResponseType.swift @@ -0,0 +1,13 @@ +// +// UserAvatarResponseType.swift +// ForPDA +// +// Created by Xialtal on 29.08.25. +// + +import Foundation + +public enum UserAvatarResponseType: Sendable { + case error + case success(URL?) +} diff --git a/Modules/Sources/ParsingClient/Parsers/ProfileParser.swift b/Modules/Sources/ParsingClient/Parsers/ProfileParser.swift index 3bf15378..dfafc483 100644 --- a/Modules/Sources/ParsingClient/Parsers/ProfileParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/ProfileParser.swift @@ -83,6 +83,26 @@ public struct ProfileParser { } } + public static func parseAvatarUrl(from string: String) throws -> UserAvatarResponseType { + if let data = string.data(using: .utf8) { + do { + guard let array = try JSONSerialization.jsonObject(with: data, options: []) as? [Any] else { throw ParsingError.failedToCastDataToAny } + let status = array[1] as! Int + + if status == 0 { + let stringUrl = if array.count > 2 { array[2] as! String } else { "" } + return .success(URL(string: stringUrl)) + } else { + return .error + } + } catch { + throw ParsingError.failedToSerializeData(error) + } + } else { + throw ParsingError.failedToCreateDataFromString + } + } + private static func parseUserDevDBDevices(_ array: [[Any]]) -> [User.Device] { return array.map { device in return User.Device( diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index 1bba3c86..c4d76c9f 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -25,6 +25,7 @@ public struct ParsingClient: Sendable { // User public var parseUser: @Sendable (_ response: String) async throws -> User public var parseReputationVotes: @Sendable ( _ response: String) async throws -> ReputationVotes + public var parseAvatarUrl: @Sendable (_ response: String) async throws -> UserAvatarResponseType // Bookmarks public var parseBookmarksList: @Sendable (_ response: String) async throws -> [Bookmark] @@ -80,6 +81,9 @@ extension ParsingClient: DependencyKey { parseReputationVotes: { response in return try ReputationParser.parse(from: response) }, + parseAvatarUrl: { response in + return try ProfileParser.parseAvatarUrl(from: response) + }, parseBookmarksList: { response in return try BookmarksParser.parse(from: response) }, From d6b5ba0139e55e449f1ccba55dc0fb3c1757a8f4 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 29 Aug 2025 22:08:03 +0300 Subject: [PATCH 05/17] Add user device update endpoint --- Modules/Sources/APIClient/APIClient.swift | 20 +++++++++++++++++++ .../Models/Profile/UserDeviceAction.swift | 12 +++++++++++ 2 files changed, 32 insertions(+) create mode 100644 Modules/Sources/Models/Profile/UserDeviceAction.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 0fbb0362..2b73d4a4 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -39,6 +39,7 @@ public struct APIClient: Sendable { public var getReputationVotes: @Sendable (_ data: ReputationVotesRequest) async throws -> ReputationVotes public var changeReputation: @Sendable (_ data: ReputationChangeRequest) async throws -> ReputationChangeResponseType public var updateUserAvatar: @Sendable (_ userId: Int, _ image: Data) async throws -> UserAvatarResponseType + public var updateUserDevice: @Sendable (_ userId: Int, _ action: UserDeviceAction, _ fullTag: String, _ isPrimary: Bool) async throws -> Bool // Bookmarks public var getBookmarksList: @Sendable () async throws -> [Bookmark] @@ -204,6 +205,22 @@ extension APIClient: DependencyKey { let response = try await api.get(command) return try await parser.parseAvatarUrl(response: response) }, + updateUserDevice: { userId, action, fullTag, isPrimary in + let action: MemberDeviceRequest.Action = switch action { + case .add: .add + case .modify: .modify + case .remove: .remove + } + let command = MemberCommand.device(data: MemberDeviceRequest( + memberId: userId, + action: action, + fullTag: fullTag, + primary: isPrimary + )) + let response = try await api.get(command) + let status = Int(response.getResponseStatus())! + return status == 0 + }, // MARK: - Bookmarks @@ -470,6 +487,9 @@ extension APIClient: DependencyKey { updateUserAvatar: { _, _ in return .success(URL(string: "https://github.com/SubvertDev/ForPDA/raw/main/Images/logo.png")!) }, + updateUserDevice: { _, _, _, _ in + return true + }, getBookmarksList: { return [.mockArticle, .mockForum, .mockUser] }, diff --git a/Modules/Sources/Models/Profile/UserDeviceAction.swift b/Modules/Sources/Models/Profile/UserDeviceAction.swift new file mode 100644 index 00000000..52773e7d --- /dev/null +++ b/Modules/Sources/Models/Profile/UserDeviceAction.swift @@ -0,0 +1,12 @@ +// +// UserDeviceAction.swift +// ForPDA +// +// Created by Xialtal on 29.08.25. +// + +public enum UserDeviceAction: Sendable { + case add + case modify + case remove +} From da449cc15b49cdf5ab2f692689eb062dde47b470 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 5 Sep 2025 19:21:21 +0300 Subject: [PATCH 06/17] Fix localization for user gender in profile --- Modules/Sources/Models/Profile/User.swift | 8 ++--- .../Models/Resources/Localizable.xcstrings | 30 +++++++++++++++++++ .../ProfileFeature/ProfileScreen.swift | 8 ++++- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/Models/Profile/User.swift b/Modules/Sources/Models/Profile/User.swift index edeb3bcb..3b093e80 100644 --- a/Modules/Sources/Models/Profile/User.swift +++ b/Modules/Sources/Models/Profile/User.swift @@ -200,14 +200,14 @@ public extension User { case male case female - public var title: String { + public var title: LocalizedStringKey { switch self { case .unknown: - "Неизвестно" + "Not set" case .male: - "Мужчина" + "Male" case .female: - "Женщина" + "Female" } } } diff --git a/Modules/Sources/Models/Resources/Localizable.xcstrings b/Modules/Sources/Models/Resources/Localizable.xcstrings index e33d2281..aad38a6b 100644 --- a/Modules/Sources/Models/Resources/Localizable.xcstrings +++ b/Modules/Sources/Models/Resources/Localizable.xcstrings @@ -41,6 +41,16 @@ } } }, + "Female" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Женский" + } + } + } + }, "First page" : { "localizations" : { "ru" : { @@ -61,6 +71,16 @@ } } }, + "Male" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Мужской" + } + } + } + }, "No comment text" : { "localizations" : { "ru" : { @@ -71,6 +91,16 @@ } } }, + "Not set" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не указано" + } + } + } + }, "openInBrowser" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/ProfileFeature/ProfileScreen.swift b/Modules/Sources/ProfileFeature/ProfileScreen.swift index 9b1a7c79..25040420 100644 --- a/Modules/Sources/ProfileFeature/ProfileScreen.swift +++ b/Modules/Sources/ProfileFeature/ProfileScreen.swift @@ -302,7 +302,7 @@ public struct ProfileScreen: View { Row(title: "Birthdate", type: .description(birthdate)) } if let gender = user.gender, gender != .unknown { - Row(title: "Gender", type: .description(gender.title)) + Row(title: "Gender", type: .localizedDescription(gender.title)) } if let city = user.city { Row(title: "City", type: .description(city)) @@ -503,6 +503,7 @@ public struct ProfileScreen: View { case description(String) case navigation case navigationDescription(String) + case localizedDescription(LocalizedStringKey) } @ViewBuilder @@ -535,6 +536,11 @@ public struct ProfileScreen: View { .font(.body) .foregroundStyle(Color(.Labels.teritary)) + case let .localizedDescription(text): + Text(text, bundle: .module) + .font(.body) + .foregroundStyle(Color(.Labels.teritary)) + case .navigation: Image(systemSymbol: .chevronRight) .font(.system(size: 17, weight: .semibold)) From eaf1c720ec4127f3dd54e90b45d9b50fa365ff96 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 5 Sep 2025 19:25:32 +0300 Subject: [PATCH 07/17] Make some user model fields mutable --- Modules/Sources/Models/Profile/User.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Modules/Sources/Models/Profile/User.swift b/Modules/Sources/Models/Profile/User.swift index 3b093e80..cdf1baf7 100644 --- a/Modules/Sources/Models/Profile/User.swift +++ b/Modules/Sources/Models/Profile/User.swift @@ -6,22 +6,23 @@ // import Foundation +import SwiftUI public struct User: Sendable, Hashable, Codable { public let id: Int public let nickname: String - public let imageUrl: URL - public let group: Group - public let status: String? - public let signature: String? - public let aboutMe: String? + public var imageUrl: URL + public var group: Group + public var status: String? + public var signature: String? + public var aboutMe: String? public let registrationDate: Date public let lastSeenDate: Date public let birthdate: String? - public let gender: Gender? + public var gender: Gender? public let userTime: Int? - public let city: String? - public let devDBdevices: [Device] + public var city: String? + public var devDBdevices: [Device] public let karma: Double public let posts: Int public let comments: Int @@ -217,7 +218,7 @@ public extension User { struct Device: Codable, Hashable, Sendable, Identifiable { public let id: String public let name: String - public let main: Bool + public var main: Bool public init(id: String, name: String, main: Bool) { self.id = id From 071510064a2959f532ac198ea79132bfe85ffab4 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 5 Sep 2025 19:32:34 +0300 Subject: [PATCH 08/17] Add user birthday as Date field to user model --- Modules/Sources/Models/Profile/User.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Modules/Sources/Models/Profile/User.swift b/Modules/Sources/Models/Profile/User.swift index cdf1baf7..f2f66be9 100644 --- a/Modules/Sources/Models/Profile/User.swift +++ b/Modules/Sources/Models/Profile/User.swift @@ -34,6 +34,16 @@ public struct User: Sendable, Hashable, Codable { public let email: String? public let achievements: [Achievement] + public var birthdayDate: Date? { + if let birthdate { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd.MM.yyyy" + return dateFormatter.date(from: birthdate) + } else { + return nil + } + } + public var userTimeFormatted: String? { if let userTime { let currentDate = Date() From 1a19da93511c5111abe33f6ddff786a217964437 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 9 Sep 2025 20:55:16 +0300 Subject: [PATCH 09/17] Add edit profile endpoint --- Modules/Sources/APIClient/APIClient.swift | 20 ++++++++ .../Requests/UserProfileEditRequest.swift | 48 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 Modules/Sources/APIClient/Requests/UserProfileEditRequest.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index c1ee4242..f412f351 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -36,6 +36,7 @@ public struct APIClient: Sendable { // User public var getUser: @Sendable (_ userId: Int, _ policy: CachePolicy) async throws -> AsyncThrowingStream + public var editUserProfile: @Sendable (_ request: UserProfileEditRequest) async throws -> Bool public var getReputationVotes: @Sendable (_ data: ReputationVotesRequest) async throws -> ReputationVotes public var changeReputation: @Sendable (_ data: ReputationChangeRequest) async throws -> ReputationChangeResponseType public var updateUserAvatar: @Sendable (_ userId: Int, _ image: Data) async throws -> UserAvatarResponseType @@ -179,6 +180,22 @@ extension APIClient: DependencyKey { policy: policy ) }, + editUserProfile: { request in + let command = MemberCommand.profile( + data: MemberProfileRequest( + memberId: request.userId, + city: request.city, + gender: request.transferGender, + status: request.status, + about: request.about, + signature: request.signature, + birthday: request.birthdayDate + ) + ) + let response = try await api.get(command) + let status = Int(response.getResponseStatus())! + return status == 0 + }, getReputationVotes: { request in let command = MemberCommand.reputationVotes(data: MemberReputationVotesRequest( memberId: request.userId, @@ -478,6 +495,9 @@ extension APIClient: DependencyKey { getUser: { _, _ in AsyncThrowingStream { $0.yield(.mock) } }, + editUserProfile: { _ in + return true + }, getReputationVotes: { _ in return .mock }, diff --git a/Modules/Sources/APIClient/Requests/UserProfileEditRequest.swift b/Modules/Sources/APIClient/Requests/UserProfileEditRequest.swift new file mode 100644 index 00000000..3a104f8a --- /dev/null +++ b/Modules/Sources/APIClient/Requests/UserProfileEditRequest.swift @@ -0,0 +1,48 @@ +// +// UserProfileEditRequest.swift +// ForPDA +// +// Created by Xialtal on 2.09.25. +// + +import Foundation +import Models +import PDAPI + +public struct UserProfileEditRequest: Sendable { + public let userId: Int + public let city: String + public let about: String + public let gender: User.Gender + public let status: String + public let signature: String + public let birthdayDate: Date? + + public init( + userId: Int, + city: String, + about: String, + gender: User.Gender, + status: String, + signature: String, + birthdayDate: Date? + ) { + self.userId = userId + self.city = city + self.about = about + self.gender = gender + self.status = status + self.signature = signature + self.birthdayDate = birthdayDate + } +} + +extension UserProfileEditRequest { + var transferGender: MemberProfileRequest.Gender { + switch gender { + case .male: return .male + case .female: return .female + case .unknown: return .unknown + } + } +} From 3c9dd4a30855fb4d618ecdb991157820b39061ba Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 27 Sep 2025 22:20:56 +0300 Subject: [PATCH 10/17] Fix user votes section in reputation --- Modules/Sources/ReputationFeature/ReputationScreen.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/ReputationFeature/ReputationScreen.swift b/Modules/Sources/ReputationFeature/ReputationScreen.swift index dd633bd4..b58e129a 100644 --- a/Modules/Sources/ReputationFeature/ReputationScreen.swift +++ b/Modules/Sources/ReputationFeature/ReputationScreen.swift @@ -108,9 +108,9 @@ public struct ReputationScreen: View { VStack(alignment: .leading, spacing: 0) { HStack { Button { - send(.profileTapped(vote.authorId)) + send(.profileTapped(store.pickerSection == .history ? vote.authorId : vote.toId)) } label: { - Text(vote.authorName) + Text(store.pickerSection == .history ? vote.authorName : vote.toName) .foregroundStyle(Color(.Labels.primary)) .font(.callout) .bold() @@ -164,7 +164,7 @@ public struct ReputationScreen: View { Spacer() Menu { - MenuButtons(id: vote.authorId) + MenuButtons(id: store.pickerSection == .history ? vote.authorId : vote.toId) } label: { Image(systemSymbol: .ellipsis) .foregroundStyle(Color(.Labels.teritary)) From 2d72105dced5f90f801f4e4f9895492c04bfc686 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 2 Nov 2025 16:30:47 +0300 Subject: [PATCH 11/17] Post-merge fix --- Modules/Sources/APIClient/APIClient.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 0bd2d949..728a60b6 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -218,7 +218,7 @@ extension APIClient: DependencyKey { birthday: request.birthdayDate ) ) - let response = try await api.get(command) + let response = try await api.send(command) let status = Int(response.getResponseStatus())! return status == 0 }, @@ -246,7 +246,7 @@ extension APIClient: DependencyKey { }, updateUserAvatar: { userId, image in let command = MemberCommand.avatar(memberId: userId, avatar: image) - let response = try await api.get(command) + let response = try await api.send(command) return try await parser.parseAvatarUrl(response: response) }, updateUserDevice: { userId, action, fullTag, isPrimary in @@ -261,7 +261,7 @@ extension APIClient: DependencyKey { fullTag: fullTag, primary: isPrimary )) - let response = try await api.get(command) + let response = try await api.send(command) let status = Int(response.getResponseStatus())! return status == 0 }, From d8f4fcd9c423c0ff1f383214de41bc930bf7e0e5 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 3 Nov 2025 21:34:07 +0300 Subject: [PATCH 12/17] Add profile edit support --- .../AnalyticsClient/Events/ProfileEvent.swift | 1 + .../Models/Resources/Localizable.xcstrings | 30 ++ .../Analytics/ProfileFeature+Analytics.swift | 3 + .../ProfileFeature/Edit/EditFeature.swift | 243 +++++++++++ .../ProfileFeature/Edit/EditScreen.swift | 379 ++++++++++++++++++ .../ProfileFeature/ProfileFeature.swift | 28 ++ .../ProfileFeature/ProfileScreen.swift | 13 + .../Resources/Localizable.xcstrings | 170 ++++++++ 8 files changed, 867 insertions(+) create mode 100644 Modules/Sources/ProfileFeature/Edit/EditFeature.swift create mode 100644 Modules/Sources/ProfileFeature/Edit/EditScreen.swift diff --git a/Modules/Sources/AnalyticsClient/Events/ProfileEvent.swift b/Modules/Sources/AnalyticsClient/Events/ProfileEvent.swift index 0c0e9bc1..ef808a47 100644 --- a/Modules/Sources/AnalyticsClient/Events/ProfileEvent.swift +++ b/Modules/Sources/AnalyticsClient/Events/ProfileEvent.swift @@ -9,6 +9,7 @@ import Foundation public enum ProfileEvent: Event { case qmsTapped + case editTapped case settingsTapped case logoutTapped case historyTapped diff --git a/Modules/Sources/Models/Resources/Localizable.xcstrings b/Modules/Sources/Models/Resources/Localizable.xcstrings index 27dabf51..1c6f64ea 100644 --- a/Modules/Sources/Models/Resources/Localizable.xcstrings +++ b/Modules/Sources/Models/Resources/Localizable.xcstrings @@ -41,6 +41,16 @@ } } }, + "Female" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Женский" + } + } + } + }, "First page" : { "localizations" : { "ru" : { @@ -71,6 +81,16 @@ } } }, + "Male" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Мужской" + } + } + } + }, "No comment text" : { "localizations" : { "ru" : { @@ -81,6 +101,16 @@ } } }, + "Not set" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не указан" + } + } + } + }, "Profile" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift b/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift index 4cd6ac7f..3a4342e2 100644 --- a/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift +++ b/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift @@ -29,6 +29,9 @@ extension ProfileFeature { case .view(.qmsButtonTapped): analyticsClient.log(ProfileEvent.qmsTapped) + case .view(.editButtonTapped): + analyticsClient.log(ProfileEvent.editTapped) + case .view(.settingsButtonTapped): analyticsClient.log(ProfileEvent.settingsTapped) diff --git a/Modules/Sources/ProfileFeature/Edit/EditFeature.swift b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift new file mode 100644 index 00000000..c3321343 --- /dev/null +++ b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift @@ -0,0 +1,243 @@ +// +// EditFeature.swift +// ForPDA +// +// Created by Xialtal on 28.08.25. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models +import ToastClient + +@Reducer +public struct EditFeature: Reducer, Sendable { + + public init() {} + + // MARK: - Localizations + + private enum Localization { + static let avatarUpdated = LocalizedStringResource("Avatar updated", bundle: .module) + static let avatarUpdateError = LocalizedStringResource("Avatar update error", bundle: .module) + static let avatarFileSizeError = LocalizedStringResource("Avatar size more than 32KB", bundle: .module) + static let avatarWidthHeightError = LocalizedStringResource("Avatar must be 100x100", bundle: .module) + } + + // MARK: - Destinations + + @Reducer(state: .equatable) + public enum Destination: Hashable, Equatable { + case alert(AlertState) + case avatarPicker + + public enum Alert { + case yes, no + } + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + @Presents public var destination: Destination.State? + + let user: User + var draftUser: User + var avatarReloadId = UUID() + + var isSending = false + var isAvatarUploading = false + + var birthdayDate: Date? + + var isUserSetAvatar: Bool { + return draftUser.imageUrl != Links.defaultAvatar + } + + var isUserSetBirhdayDate: Bool { + return user.birthdayDate != nil + } + + var isUserCanEditStatus: Bool { + return user.posts >= 250 + } + + var isSavingDisabled: Bool { + return isUserInfoFieldsEqual + && draftUser.devDBdevices == user.devDBdevices + } + + var isUserInfoFieldsEqual: Bool { + return draftUser.city == user.city + && draftUser.aboutMe == user.aboutMe + && draftUser.status == user.status + && draftUser.gender == user.gender + && draftUser.signature == user.signature + && draftUser.birthdayDate == birthdayDate + } + + public init(user: User) { + self.user = user + self.draftUser = user + } + } + + // MARK: - Action + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case destination(PresentationAction) + + case view(View) + public enum View { + case onAppear + case avatarSelected(Data) + case deleteAvatar + case avatarBadWidthHeight + case avatarBadFileSizeTooBig + + case wipeBirthdayDate + case setBirthdayDate + case selectGenderType(User.Gender) + + case saveButtonTapped + case cancelButtonTapped + case addAvatarButtonTapped + } + + case `internal`(Internal) + public enum Internal { + case saveProfile + case updateAvatar(Data) + case updateAvatarResponse(Result) + } + + case delegate(Delegate) + public enum Delegate { + case profileUpdated(Bool) + } + } + + // MARK: - Dependencies + + @Dependency(\.dismiss) private var dismiss + @Dependency(\.apiClient) private var apiClient + @Dependency(\.toastClient) private var toastClient + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .view(.onAppear): + state.birthdayDate = state.draftUser.birthdayDate ?? nil + return .none + + case .view(.avatarSelected(let data)): + return .send(.internal(.updateAvatar(data))) + + case .view(.deleteAvatar): + let empty = Data() + return .send(.internal(.updateAvatar(empty))) + + case .view(.avatarBadWidthHeight): + return showToast(ToastMessage(text: Localization.avatarWidthHeightError, haptic: .error)) + + case .view(.avatarBadFileSizeTooBig): + return showToast(ToastMessage(text: Localization.avatarFileSizeError, haptic: .error)) + + case .view(.selectGenderType(let type)): + state.draftUser.gender = type + return .none + + case .view(.setBirthdayDate): + state.birthdayDate = state.draftUser.birthdayDate ?? Date() + return .none + + case .view(.wipeBirthdayDate): + state.birthdayDate = nil + return .none + + case .view(.saveButtonTapped): + return .send(.internal(.saveProfile)) + + case .delegate(.profileUpdated): + return .run { _ in await dismiss() } + + case .view(.cancelButtonTapped): + return .run { _ in await dismiss() } + + case .view(.addAvatarButtonTapped): + state.destination = .avatarPicker + return .none + + case .destination, .delegate: + return .none + + case .internal(.saveProfile): + return .run { [user = state.draftUser, birthdayDate = state.birthdayDate] send in + let status = try await apiClient.editUserProfile(UserProfileEditRequest( + userId: user.id, + city: user.city ?? "", + about: user.aboutMe?.simplify() ?? "", + gender: user.gender ?? .unknown, + status: user.status ?? "", + signature: user.signature?.simplify() ?? "", + birthdayDate: birthdayDate + )) + await send(.delegate(.profileUpdated(status))) + } + + case .internal(.updateAvatar(let data)): + state.isAvatarUploading = true + return .run { [userId = state.user.id] send in + let response = try await apiClient.updateUserAvatar(userId, data) + await send(.internal(.updateAvatarResponse(.success(response)))) + } + + case let .internal(.updateAvatarResponse(.success(response))): + switch response { + case .success(let url): + state.draftUser.imageUrl = url ?? Links.defaultAvatar + state.isAvatarUploading = false + + return showToast(ToastMessage(text: Localization.avatarUpdated, haptic: .success)) + case .error: + return showToast(ToastMessage(text: Localization.avatarUpdateError, haptic: .error)) + } + + case let .internal(.updateAvatarResponse(.failure(error))): + print("Error: \(error)") + return .none + } + } + .ifLet(\.$destination, action: \.destination) + } + + private func showToast(_ toast: ToastMessage) -> Effect { + return .run { _ in + await toastClient.showToast(toast) + } + } +} + +private extension Date { + func toProfileString() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd-MM-yyyy" + return dateFormatter.string(from: self) + } +} + +private extension String { + func simplify() -> String { + return String(self.debugDescription.dropFirst().dropLast()) + } +} diff --git a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift new file mode 100644 index 00000000..f81e623f --- /dev/null +++ b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift @@ -0,0 +1,379 @@ +// +// EditScreen.swift +// ForPDA +// +// Created by Xialtal on 28.08.25. +// + +import SwiftUI +import ComposableArchitecture +import NukeUI +import SharedUI +import PhotosUI + +@ViewAction(for: EditFeature.self) +public struct EditScreen: View { + + @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor + + @State private var pickerItem: PhotosPickerItem? + + @FocusState var isStatusFocused: Bool + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + List { + AvatarRow() + + if store.isUserCanEditStatus { + Field( + content: Binding(unwrapping: $store.draftUser.status, default: ""), + title: LocalizedStringKey("Status") + ) + } else { + // TODO: Some notify about it? + } + + Field( + content: Binding(unwrapping: $store.draftUser.signature, default: ""), + title: LocalizedStringKey("Signature") + ) + + Field( + content: Binding(unwrapping: $store.draftUser.aboutMe, default: ""), + title: LocalizedStringKey("About me") + ) + + Section { + UserBirthday() + UserGender() + } + .listRowBackground(Color(.Background.teritary)) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + + Field( + content: Binding(unwrapping: $store.draftUser.city, default: ""), + title: LocalizedStringKey("City") + ) + } + .scrollContentBackground(.hidden) + } + .navigationTitle(Text("Edit profile", bundle: .module)) + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + SendButton() + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + send(.cancelButtonTapped) + } label: { + Text("Cancel", bundle: .module) + .foregroundStyle(tintColor) + } + .disabled(store.isSending) + } + } + .onAppear { + send(.onAppear) + } + } + } + + // MARK: - Send Button + + @ViewBuilder + private func SendButton() -> some View { + Button { + send(.saveButtonTapped) + } label: { + if store.isSending { + ProgressView() + .progressViewStyle(.circular) + .frame(maxWidth: .infinity) + .padding(8) + } else { + Text("Send", bundle: .module) + .frame(maxWidth: .infinity) + .padding(8) + } + } + .disabled(store.isSending) + .disabled(store.isSavingDisabled) + .buttonStyle(.borderedProminent) + .tint(tintColor) + .frame(height: 48) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(Color(.Background.primary)) + } + + // MARK: - User Birthday Picker + + @ViewBuilder + private func UserBirthday() -> some View { + if let date = store.birthdayDate { + DatePicker( + selection: Binding(unwrapping: $store.birthdayDate, default: date), + displayedComponents: .date + ) { + Text("Birthday date", bundle: .module) + .font(.body) + .foregroundStyle(Color(.Labels.primary)) + } + .padding(12) + .frame(height: 60) + .cornerRadius(10) + .swipeActions(edge: .trailing) { + Button { + send(.wipeBirthdayDate) + } label: { + Image(systemSymbol: .trash) + } + .tint(.red) + } + } else { + HStack { + Text("Birthday date", bundle: .module) + .font(.body) + .foregroundStyle(Color(.Labels.primary)) + + Spacer() + + Button { + send(.setBirthdayDate) + } label: { + Text("Set", bundle: .module) + .textCase(.uppercase) + } + .cornerRadius(12) + .foregroundStyle(tintColor) + } + .padding(12) + .frame(height: 60) + .cornerRadius(10) + } + } + + // MARK: - User Gender Picker + + @ViewBuilder + private func UserGender() -> some View { + Menu { + Button { + send(.selectGenderType(.unknown)) + } label: { + Text("Not set", bundle: .module) + } + + Button { + send(.selectGenderType(.male)) + } label: { + Text("Male", bundle: .module) + } + + Button { + send(.selectGenderType(.female)) + } label: { + Text("Female", bundle: .module) + } + } label: { + HStack { + Text("Gender", bundle: .module) + .font(.body) + .foregroundStyle(Color(.Labels.primary)) + + Spacer() + + HStack { + let gender = store.draftUser.gender ?? .unknown + Text(gender.title, bundle: .module) + .font(.body) + .foregroundStyle(Color(.Labels.quaternary)) + .padding(.leading, 16) + + Image(systemSymbol: .chevronUpChevronDown) + .foregroundStyle(Color(.Labels.quaternary)) + } + } + .padding(12) + .frame(height: 60) + .cornerRadius(10) + } + } + + // MARK: - Avatar + + @ViewBuilder + private func AvatarRow() -> some View { + VStack { + Circle() + .stroke( + store.isUserSetAvatar ? Color.clear : tintColor, + style: StrokeStyle(lineWidth: 1, dash: [8]) + ) + .overlay(alignment: .bottomTrailing) { + AvatarContextMenu() + } + .background { + if store.isAvatarUploading { + ProgressView() + .progressViewStyle(.circular) + .foregroundStyle(Color(.Labels.quintuple)) + } else { + if store.isUserSetAvatar { + LazyImage(url: store.draftUser.imageUrl) { state in + Group { + if let image = state.image { + image.resizable().scaledToFill() + } else { + Image(systemSymbol: .personCropCircle) + .font(.title) + .foregroundStyle(Color(.Labels.quintuple)) + } + } + .skeleton(with: state.isLoading, shape: .circle) + } + .clipShape(Circle()) + } else { + Image(systemSymbol: .personCropCircle) + .font(.title) + .foregroundStyle(Color(.Labels.quintuple)) + } + } + } + .frame(width: 100, height: 100) + .background(Circle().foregroundColor(Color(.Background.teritary))) + .padding(.bottom, 8) + + Text("File size should not be more that 32 kb and max 100x100 pixels", bundle: .module) + .font(.caption) + .foregroundStyle(Color(.Labels.teritary)) + } + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .photosPicker( + isPresented: Binding($store.destination.avatarPicker), + selection: $pickerItem, + matching: .any(of: [.images, .screenshots]) + ) + .task(id: pickerItem) { + guard let data = try? await pickerItem?.loadTransferable(type: Data.self) else { + return + } + guard let image = UIImage(data: data) else { + return + } + + if data.count <= 32768 { + if image.size.width <= 100, image.size.height <= 100 { + send(.avatarSelected(data)) + } else { + send(.avatarBadWidthHeight) + } + } else { + send(.avatarBadFileSizeTooBig) + } + + // Drop last selected avatar. + // Need, because photosPicker remember last choice. + pickerItem = nil + } + } + + // MARK: - Avatar Context Menu + + @ViewBuilder + private func AvatarContextMenu() -> some View { + Menu { + Button { + send(.addAvatarButtonTapped) + } label: { + HStack { + Text("Add avatar", bundle: .module) + Image(systemSymbol: .plusCircle) + } + } + + if store.isUserSetAvatar { + Button(role: .destructive) { + send(.deleteAvatar) + } label: { + HStack { + Text("Delete avatar", bundle: .module) + Image(systemSymbol: .trash) + } + } + } + } label: { + Image(systemSymbol: .ellipsis) + .font(.body) + .foregroundStyle(Color(.Labels.primaryInvariably)) + .frame(width: 32, height: 32) + .background( + Circle() + .fill(tintColor) + .clipShape(Circle()) + ) + } + .onTapGesture {} // DO NOT DELETE, FIX FOR IOS 17 + } + + // MARK: - Helpers + + @ViewBuilder + private func Field( + content: Binding, + title: LocalizedStringKey + ) -> some View { + Section { + SharedUI.Field( + text: content, + description: "", + guideText: "", + isFocused: $isStatusFocused + ) + } header: { + Header(title: title) + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + + private func Header(title: LocalizedStringKey) -> some View { + Text(title, bundle: .module) + .font(.footnote) + .fontWeight(.semibold) + .foregroundStyle(Color(.Labels.teritary)) + .textCase(nil) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +#Preview("") { + NavigationStack { + EditScreen( + store: Store( + initialState: EditFeature.State(user: .mock) + ) { + EditFeature() + } withDependencies: { + $0.apiClient.updateUserAvatar = { @Sendable _, _ in + try await Task.sleep(for: .seconds(3)) + return .success(URL(string: "https://github.com/SubvertDev/ForPDA/raw/main/Images/logo.png")!) + } + } + ) + } + .environment(\.tintColor, Color(.Theme.primary)) +} diff --git a/Modules/Sources/ProfileFeature/ProfileFeature.swift b/Modules/Sources/ProfileFeature/ProfileFeature.swift index e7bc3d11..caf9d371 100644 --- a/Modules/Sources/ProfileFeature/ProfileFeature.swift +++ b/Modules/Sources/ProfileFeature/ProfileFeature.swift @@ -11,17 +11,26 @@ import APIClient import PersistenceKeys import Models import AnalyticsClient +import ToastClient @Reducer public struct ProfileFeature: Reducer, Sendable { public init() {} + // MARK: - Localizations + + private enum Localization { + static let profileUpdated = LocalizedStringResource("Profile updated", bundle: .module) + static let profileUpdateError = LocalizedStringResource("Profile update error", bundle: .module) + } + // MARK: - Destinations @Reducer(state: .equatable) public enum Destination { case alert(AlertState) + case editProfile(EditFeature) } // MARK: - State @@ -60,6 +69,7 @@ public struct ProfileFeature: Reducer, Sendable { public enum View { case onAppear case qmsButtonTapped + case editButtonTapped case settingsButtonTapped case logoutButtonTapped case historyButtonTapped @@ -92,6 +102,7 @@ public struct ProfileFeature: Reducer, Sendable { @Dependency(\.apiClient) private var apiClient @Dependency(\.analyticsClient) private var analyticsClient @Dependency(\.notificationCenter) private var notificationCenter + @Dependency(\.toastClient) private var toastClient @Dependency(\.dismiss) private var dismiss // MARK: - Body @@ -120,6 +131,12 @@ public struct ProfileFeature: Reducer, Sendable { guard let userId else { return .none } return .send(.delegate(.openReputation(userId))) + case .view(.editButtonTapped): + if let user = state.user { + state.destination = .editProfile(EditFeature.State(user: user)) + } + return .none + case .view(.qmsButtonTapped): return .send(.delegate(.openQms)) @@ -145,6 +162,17 @@ public struct ProfileFeature: Reducer, Sendable { reportFullyDisplayed(&state) return .none + case .destination(.presented(.editProfile(.delegate(.profileUpdated(let status))))): + return .concatenate( + .run { _ in + await toastClient.showToast(ToastMessage( + text: status ? Localization.profileUpdated : Localization.profileUpdateError, + haptic: status ? .success : .error + )) + }, + .send(.view(.onAppear)) + ) + case .destination(.presented(.alert(.logout))): state.$userSession.withLock { $0 = nil } state.isLoading = true diff --git a/Modules/Sources/ProfileFeature/ProfileScreen.swift b/Modules/Sources/ProfileFeature/ProfileScreen.swift index 11b47deb..1828c264 100644 --- a/Modules/Sources/ProfileFeature/ProfileScreen.swift +++ b/Modules/Sources/ProfileFeature/ProfileScreen.swift @@ -70,6 +70,11 @@ public struct ProfileScreen: View { .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) .navigationTitle(Text("Profile", bundle: .module)) ._toolbarTitleDisplayMode(.large) + .fullScreenCover(item: $store.scope(state: \.destination?.editProfile, action: \.destination.editProfile)) { store in + NavigationStack { + EditScreen(store: store) + } + } .toolbar { ToolbarButtons() } @@ -103,6 +108,14 @@ public struct ProfileScreen: View { Image(systemSymbol: .gearshape) } } + + ToolbarItem { + Button { + send(.editButtonTapped) + } label: { + Image(systemSymbol: .pencil) + } + } } } diff --git a/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings b/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings index 43c4bb01..a2559e58 100644 --- a/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings @@ -21,6 +21,16 @@ } } }, + "Add avatar" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить аватар" + } + } + } + }, "Are you sure you want to log out of your profile ?" : { "localizations" : { "ru" : { @@ -31,6 +41,46 @@ } } }, + "Avatar must be 100x100" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Аватар должен быть 100х100" + } + } + } + }, + "Avatar size more than 32KB" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Размер аватара превышает 32КБ" + } + } + } + }, + "Avatar update error" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка обновления аватара" + } + } + } + }, + "Avatar updated" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Аватар обновлен" + } + } + } + }, "Birthdate" : { "localizations" : { "ru" : { @@ -41,6 +91,16 @@ } } }, + "Birthday date" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дата рождения" + } + } + } + }, "Cancel" : { "localizations" : { "ru" : { @@ -71,6 +131,16 @@ } } }, + "Delete avatar" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить аватар" + } + } + } + }, "Devices List" : { "localizations" : { "ru" : { @@ -81,6 +151,16 @@ } } }, + "Edit profile" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить профиль" + } + } + } + }, "Email" : { "localizations" : { "ru" : { @@ -91,6 +171,26 @@ } } }, + "Female" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Женский" + } + } + } + }, + "File size should not be more that 32 kb and max 100x100 pixels" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Размер файла до 32 кб и максимум 100х100 пикселей" + } + } + } + }, "Forum statistics" : { "localizations" : { "ru" : { @@ -191,6 +291,26 @@ } } }, + "Male" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Мужской" + } + } + } + }, + "Not set" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не указан" + } + } + } + }, "Online" : { "localizations" : { "ru" : { @@ -231,6 +351,26 @@ } } }, + "Profile update error" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка редактирования профиля" + } + } + } + }, + "Profile updated" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Профиль обновлен" + } + } + } + }, "QMS" : { "localizations" : { "ru" : { @@ -271,6 +411,36 @@ } } }, + "Send" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить" + } + } + } + }, + "Set" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Указать" + } + } + } + }, + "Signature" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подпись" + } + } + } + }, "Site statistics" : { "localizations" : { "ru" : { From 0e37850a72b7d6070a0f1bb93f48f8c36ac01821 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 3 Nov 2025 21:42:58 +0300 Subject: [PATCH 13/17] Add to user profile edit button auth check --- Modules/Sources/ProfileFeature/ProfileScreen.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/ProfileFeature/ProfileScreen.swift b/Modules/Sources/ProfileFeature/ProfileScreen.swift index 1828c264..6b03f52e 100644 --- a/Modules/Sources/ProfileFeature/ProfileScreen.swift +++ b/Modules/Sources/ProfileFeature/ProfileScreen.swift @@ -109,11 +109,15 @@ public struct ProfileScreen: View { } } - ToolbarItem { - Button { - send(.editButtonTapped) - } label: { - Image(systemSymbol: .pencil) + if let userId = store.userId, + let userSession = store.userSession, + userSession.userId == userId { + ToolbarItem { + Button { + send(.editButtonTapped) + } label: { + Image(systemSymbol: .pencil) + } } } } From bf53a5e81fc267c3dc5bb4092230779fdcd2f437 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 9 Nov 2025 20:34:16 +0300 Subject: [PATCH 14/17] Gender picker improvements in profile edit --- Modules/Sources/Models/Profile/User.swift | 13 +---- .../Models/Resources/Localizable.xcstrings | 30 ----------- .../ProfileFeature/Edit/EditFeature.swift | 5 -- .../ProfileFeature/Edit/EditScreen.swift | 50 ++++--------------- .../Extensions/User.Gender+Extension.swift | 22 ++++++++ 5 files changed, 35 insertions(+), 85 deletions(-) create mode 100644 Modules/Sources/ProfileFeature/Models/Extensions/User.Gender+Extension.swift diff --git a/Modules/Sources/Models/Profile/User.swift b/Modules/Sources/Models/Profile/User.swift index f2f66be9..1968b09c 100644 --- a/Modules/Sources/Models/Profile/User.swift +++ b/Modules/Sources/Models/Profile/User.swift @@ -206,21 +206,12 @@ public extension User { // MARK: Gender - enum Gender: Int, Codable, Hashable, Sendable { + enum Gender: Int, CaseIterable, Codable, Hashable, Sendable, Identifiable { case unknown = 0 case male case female - public var title: LocalizedStringKey { - switch self { - case .unknown: - "Not set" - case .male: - "Male" - case .female: - "Female" - } - } + public var id: Self { self } } // MARK: Device diff --git a/Modules/Sources/Models/Resources/Localizable.xcstrings b/Modules/Sources/Models/Resources/Localizable.xcstrings index 1c6f64ea..27dabf51 100644 --- a/Modules/Sources/Models/Resources/Localizable.xcstrings +++ b/Modules/Sources/Models/Resources/Localizable.xcstrings @@ -41,16 +41,6 @@ } } }, - "Female" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Женский" - } - } - } - }, "First page" : { "localizations" : { "ru" : { @@ -81,16 +71,6 @@ } } }, - "Male" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Мужской" - } - } - } - }, "No comment text" : { "localizations" : { "ru" : { @@ -101,16 +81,6 @@ } } }, - "Not set" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Не указан" - } - } - } - }, "Profile" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/ProfileFeature/Edit/EditFeature.swift b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift index c3321343..d765c3be 100644 --- a/Modules/Sources/ProfileFeature/Edit/EditFeature.swift +++ b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift @@ -100,7 +100,6 @@ public struct EditFeature: Reducer, Sendable { case wipeBirthdayDate case setBirthdayDate - case selectGenderType(User.Gender) case saveButtonTapped case cancelButtonTapped @@ -153,10 +152,6 @@ public struct EditFeature: Reducer, Sendable { case .view(.avatarBadFileSizeTooBig): return showToast(ToastMessage(text: Localization.avatarFileSizeError, haptic: .error)) - case .view(.selectGenderType(let type)): - state.draftUser.gender = type - return .none - case .view(.setBirthdayDate): state.birthdayDate = state.draftUser.birthdayDate ?? Date() return .none diff --git a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift index f81e623f..720ee4cd 100644 --- a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift +++ b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift @@ -8,6 +8,7 @@ import SwiftUI import ComposableArchitecture import NukeUI +import Models import SharedUI import PhotosUI @@ -168,47 +169,18 @@ public struct EditScreen: View { @ViewBuilder private func UserGender() -> some View { - Menu { - Button { - send(.selectGenderType(.unknown)) - } label: { - Text("Not set", bundle: .module) - } - - Button { - send(.selectGenderType(.male)) - } label: { - Text("Male", bundle: .module) + Picker( + LocalizedStringResource("Gender", bundle: .module), + selection: Binding(unwrapping: $store.draftUser.gender, default: .unknown) + ) { + ForEach(User.Gender.allCases) { gender in + Text(gender.title, bundle: .module) + .tag(gender) } - - Button { - send(.selectGenderType(.female)) - } label: { - Text("Female", bundle: .module) - } - } label: { - HStack { - Text("Gender", bundle: .module) - .font(.body) - .foregroundStyle(Color(.Labels.primary)) - - Spacer() - - HStack { - let gender = store.draftUser.gender ?? .unknown - Text(gender.title, bundle: .module) - .font(.body) - .foregroundStyle(Color(.Labels.quaternary)) - .padding(.leading, 16) - - Image(systemSymbol: .chevronUpChevronDown) - .foregroundStyle(Color(.Labels.quaternary)) - } - } - .padding(12) - .frame(height: 60) - .cornerRadius(10) } + .padding(12) + .frame(height: 60) + .cornerRadius(10) } // MARK: - Avatar diff --git a/Modules/Sources/ProfileFeature/Models/Extensions/User.Gender+Extension.swift b/Modules/Sources/ProfileFeature/Models/Extensions/User.Gender+Extension.swift new file mode 100644 index 00000000..0d9b1efa --- /dev/null +++ b/Modules/Sources/ProfileFeature/Models/Extensions/User.Gender+Extension.swift @@ -0,0 +1,22 @@ +// +// User.Gender+Extension.swift +// ForPDA +// +// Created by Xialtal on 9.11.25. +// + +import SwiftUI +import Models + +extension User.Gender { + var title: LocalizedStringKey { + switch self { + case .unknown: + "Not set" + case .male: + "Male" + case .female: + "Female" + } + } +} From fffd63a64635ea764cbe6b2f3b358bf91c3f0210 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 19 Nov 2025 13:40:59 +0300 Subject: [PATCH 15/17] Quickfix for Field borders on IOS 26 --- Modules/Sources/SharedUI/Field.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/SharedUI/Field.swift b/Modules/Sources/SharedUI/Field.swift index 12b02881..84c79a03 100644 --- a/Modules/Sources/SharedUI/Field.swift +++ b/Modules/Sources/SharedUI/Field.swift @@ -49,14 +49,14 @@ public struct Field: View { .padding(.vertical, 15) .padding(.horizontal, 12) .background { - RoundedRectangle(cornerRadius: 14) + RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) .fill(Color(.Background.teritary)) .onTapGesture { isFocused = true } } .overlay { - RoundedRectangle(cornerRadius: 14) + RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) .strokeBorder(Color(.Separator.primary)) } From 4f46fb1625311eeee5df04e5118a9afcf02e7bdc Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 21 Nov 2025 21:58:26 +0300 Subject: [PATCH 16/17] Profile edit improvements --- .../Sources/ProfileFeature/Edit/EditFeature.swift | 13 +++++++------ .../Sources/ProfileFeature/Edit/EditScreen.swift | 6 +++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Modules/Sources/ProfileFeature/Edit/EditFeature.swift b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift index d765c3be..2f925dab 100644 --- a/Modules/Sources/ProfileFeature/Edit/EditFeature.swift +++ b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift @@ -95,8 +95,9 @@ public struct EditFeature: Reducer, Sendable { case onAppear case avatarSelected(Data) case deleteAvatar - case avatarBadWidthHeight - case avatarBadFileSizeTooBig + + case onAvatarBadFileSizeProvided + case onAvatarBadWidthHeightProvided case wipeBirthdayDate case setBirthdayDate @@ -145,13 +146,13 @@ public struct EditFeature: Reducer, Sendable { case .view(.deleteAvatar): let empty = Data() return .send(.internal(.updateAvatar(empty))) - - case .view(.avatarBadWidthHeight): - return showToast(ToastMessage(text: Localization.avatarWidthHeightError, haptic: .error)) - case .view(.avatarBadFileSizeTooBig): + case .view(.onAvatarBadFileSizeProvided): return showToast(ToastMessage(text: Localization.avatarFileSizeError, haptic: .error)) + case .view(.onAvatarBadWidthHeightProvided): + return showToast(ToastMessage(text: Localization.avatarWidthHeightError, haptic: .error)) + case .view(.setBirthdayDate): state.birthdayDate = state.draftUser.birthdayDate ?? Date() return .none diff --git a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift index 720ee4cd..f6f9a0bd 100644 --- a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift +++ b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift @@ -247,14 +247,14 @@ public struct EditScreen: View { return } - if data.count <= 32768 { + if data.count <= 32768 /* should be max 32kb size */ { if image.size.width <= 100, image.size.height <= 100 { send(.avatarSelected(data)) } else { - send(.avatarBadWidthHeight) + send(.onAvatarBadWidthHeightProvided) } } else { - send(.avatarBadFileSizeTooBig) + send(.onAvatarBadFileSizeProvided) } // Drop last selected avatar. From 9f6cad3c0c15621dbc8afa6f944a203e38aa4059 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 21 Nov 2025 22:18:49 +0300 Subject: [PATCH 17/17] Fix dependencies for profile feature --- Project.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Project.swift b/Project.swift index eaed97d1..65c892e0 100644 --- a/Project.swift +++ b/Project.swift @@ -285,9 +285,11 @@ let project = Project( .Internal.AnalyticsClient, .Internal.APIClient, .Internal.BBBuilder, + .Internal.HapticClient, .Internal.Models, .Internal.PersistenceKeys, .Internal.SharedUI, + .Internal.ToastClient, .SPM.NukeUI, .SPM.RichTextKit, .SPM.SFSafeSymbols,