diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index e73ce80e..728a60b6 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -43,8 +43,11 @@ 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 + public var updateUserDevice: @Sendable (_ userId: Int, _ action: UserDeviceAction, _ fullTag: String, _ isPrimary: Bool) async throws -> Bool // Bookmarks public var getBookmarksList: @Sendable () async throws -> [Bookmark] @@ -203,7 +206,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.send(command) + let status = Int(response.getResponseStatus())! + return status == 0 + }, getReputationVotes: { request in let command = MemberCommand.reputationVotes(data: MemberReputationVotesRequest( memberId: request.userId, @@ -226,8 +244,30 @@ 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.send(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.send(command) + let status = Int(response.getResponseStatus())! + return status == 0 + }, // MARK: - Bookmarks + getBookmarksList: { let response = try await api.send(MemberCommand.Bookmarks.list) return try await parser.parseBookmarksList(response) @@ -519,12 +559,21 @@ extension APIClient: DependencyKey { getUser: { _, _ in AsyncThrowingStream { $0.yield(.mock) } }, + editUserProfile: { _ in + return true + }, getReputationVotes: { _ in return .mock }, changeReputation: { _ in return .success }, + 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/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 + } + } +} 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/Profile/User.swift b/Modules/Sources/Models/Profile/User.swift index a1ef7354..1968b09c 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 @@ -33,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() @@ -195,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: String { - switch self { - case .unknown: - "Неизвестно" - case .male: - "Мужчина" - case .female: - "Женщина" - } - } + public var id: Self { self } } // MARK: Device @@ -217,7 +219,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 @@ -274,7 +276,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, 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/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 +} 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) }, 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..2f925dab --- /dev/null +++ b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift @@ -0,0 +1,239 @@ +// +// 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 onAvatarBadFileSizeProvided + case onAvatarBadWidthHeightProvided + + case wipeBirthdayDate + case setBirthdayDate + + 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(.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 + + 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..f6f9a0bd --- /dev/null +++ b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift @@ -0,0 +1,351 @@ +// +// EditScreen.swift +// ForPDA +// +// Created by Xialtal on 28.08.25. +// + +import SwiftUI +import ComposableArchitecture +import NukeUI +import Models +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 { + 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) + } + } + .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 /* should be max 32kb size */ { + if image.size.width <= 100, image.size.height <= 100 { + send(.avatarSelected(data)) + } else { + send(.onAvatarBadWidthHeightProvided) + } + } else { + send(.onAvatarBadFileSizeProvided) + } + + // 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/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" + } + } +} 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 38a2f276..6b03f52e 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,18 @@ public struct ProfileScreen: View { Image(systemSymbol: .gearshape) } } + + if let userId = store.userId, + let userSession = store.userSession, + userSession.userId == userId { + ToolbarItem { + Button { + send(.editButtonTapped) + } label: { + Image(systemSymbol: .pencil) + } + } + } } } @@ -111,32 +128,34 @@ 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)) } } - .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)) @@ -291,7 +310,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)) @@ -490,6 +509,7 @@ public struct ProfileScreen: View { case description(String) case navigation case navigationDescription(String) + case localizedDescription(LocalizedStringKey) } @ViewBuilder @@ -522,6 +542,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)) 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" : { diff --git a/Modules/Sources/ReputationFeature/ReputationScreen.swift b/Modules/Sources/ReputationFeature/ReputationScreen.swift index 567e7ef0..1aa4bafe 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)) 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)) } 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,