From 60aa49499c732f63d6cbc633e6ea479baeae452e Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 13 Oct 2023 12:54:15 +0300 Subject: [PATCH] Add user profile view (#105) * add Discussion Profile View * add tests * return the old preview for tests fixing * Update ProfileRepository.swift --- Core/Core/Data/Model/Data_UserProfile.swift | 5 +- .../Data/Network/DiscussionRepository.swift | 2 +- .../Comments/Base/CommentCell.swift | 11 ++ .../Comments/Base/ParentCommentView.swift | 8 + .../Comments/Responses/ResponsesView.swift | 9 +- .../Comments/Thread/ThreadView.swift | 9 +- .../Presentation/DiscussionRouter.swift | 4 + .../DiscussionMock.generated.swift | 18 +++ OpenEdX/DI/ScreenAssembly.swift | 10 +- OpenEdX/Router.swift | 10 ++ Profile/Profile.xcodeproj/project.pbxproj | 16 ++ Profile/Profile/Data/ProfileRepository.swift | 23 ++- .../Profile/Domain/ProfileInteractor.swift | 5 + .../Profile/UserProfile/UserProfileView.swift | 146 ++++++++++++++++++ .../UserProfile/UserProfileViewModel.swift | 52 +++++++ .../Profile/ProfileViewModelTests.swift | 80 +++++++++- .../ProfileTests/ProfileMock.generated.swift | 41 +++++ 17 files changed, 430 insertions(+), 19 deletions(-) create mode 100644 Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift create mode 100644 Profile/Profile/Presentation/Profile/UserProfile/UserProfileViewModel.swift diff --git a/Core/Core/Data/Model/Data_UserProfile.swift b/Core/Core/Data/Model/Data_UserProfile.swift index d0207ea95..d3541cfd2 100644 --- a/Core/Core/Data/Model/Data_UserProfile.swift +++ b/Core/Core/Data/Model/Data_UserProfile.swift @@ -12,7 +12,7 @@ import Foundation // MARK: - UserProfile public extension DataLayer { struct UserProfile: Codable { - public let id: Int + public let id: Int? public let accountPrivacy: AccountPrivacy? public let profileImage: ProfileImage? public let username: String? @@ -70,12 +70,13 @@ public extension DataLayer { public enum AccountPrivacy: String, Codable { case privateAccess = "private" case allUsers = "all_users" + case allUsersBig = "ALL_USERS" public var boolValue: Bool { switch self { case .privateAccess: return false - case .allUsers: + case .allUsers, .allUsersBig: return true } } diff --git a/Discussion/Discussion/Data/Network/DiscussionRepository.swift b/Discussion/Discussion/Data/Network/DiscussionRepository.swift index 18530e784..7e395da51 100644 --- a/Discussion/Discussion/Data/Network/DiscussionRepository.swift +++ b/Discussion/Discussion/Data/Network/DiscussionRepository.swift @@ -181,7 +181,7 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { #if DEBUG // swiftlint:disable all public class DiscussionRepositoryMock: DiscussionRepositoryProtocol { - + var comments = [ UserComment(authorName: "Bill", authorAvatar: "", diff --git a/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift b/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift index f818590d2..a00ecb57f 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift @@ -13,6 +13,7 @@ public struct CommentCell: View { private let comment: Post private let addCommentAvailable: Bool + private var onAvatarTap: ((String) -> Void) private var onLikeTap: (() -> Void) private var onReportTap: (() -> Void) private var onCommentsTap: (() -> Void) @@ -25,6 +26,7 @@ public struct CommentCell: View { comment: Post, addCommentAvailable: Bool, leftLineEnabled: Bool = false, + onAvatarTap: @escaping (String) -> Void, onLikeTap: @escaping () -> Void, onReportTap: @escaping () -> Void, onCommentsTap: @escaping () -> Void, @@ -33,6 +35,7 @@ public struct CommentCell: View { self.comment = comment self.addCommentAvailable = addCommentAvailable self.leftLineEnabled = leftLineEnabled + self.onAvatarTap = onAvatarTap self.onLikeTap = onLikeTap self.onReportTap = onReportTap self.onCommentsTap = onCommentsTap @@ -42,11 +45,15 @@ public struct CommentCell: View { public var body: some View { VStack(alignment: .leading) { HStack { + Button(action: { + onAvatarTap(comment.authorName) + }, label: { KFImage(URL(string: comment.authorAvatar)) .onFailureImage(KFCrossPlatformImage(systemName: "person.circle")) .resizable() .frame(width: 32, height: 32) .cornerRadius(16) + }) VStack(alignment: .leading) { Text(comment.authorName) @@ -170,6 +177,7 @@ struct CommentView_Previews: PreviewProvider { comment: comment, addCommentAvailable: true, leftLineEnabled: false, + onAvatarTap: {_ in}, onLikeTap: {}, onReportTap: {}, onCommentsTap: {}, @@ -178,6 +186,7 @@ struct CommentView_Previews: PreviewProvider { comment: comment, addCommentAvailable: true, leftLineEnabled: false, + onAvatarTap: {_ in}, onLikeTap: {}, onReportTap: {}, onCommentsTap: {}, @@ -192,6 +201,7 @@ struct CommentView_Previews: PreviewProvider { comment: comment, addCommentAvailable: true, leftLineEnabled: false, + onAvatarTap: {_ in}, onLikeTap: {}, onReportTap: {}, onCommentsTap: {}, @@ -200,6 +210,7 @@ struct CommentView_Previews: PreviewProvider { comment: comment, addCommentAvailable: true, leftLineEnabled: false, + onAvatarTap: {_ in}, onLikeTap: {}, onReportTap: {}, onCommentsTap: {}, diff --git a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift index 0e8a35828..75770707a 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift @@ -13,6 +13,7 @@ public struct ParentCommentView: View { private let comments: Post private var isThread: Bool + private var onAvatarTap: ((String) -> Void) private var onLikeTap: (() -> Void) private var onReportTap: (() -> Void) private var onFollowTap: (() -> Void) @@ -22,12 +23,14 @@ public struct ParentCommentView: View { public init( comments: Post, isThread: Bool, + onAvatarTap: @escaping (String) -> Void, onLikeTap: @escaping () -> Void, onReportTap: @escaping () -> Void, onFollowTap: @escaping () -> Void ) { self.comments = comments self.isThread = isThread + self.onAvatarTap = onAvatarTap self.onLikeTap = onLikeTap self.onReportTap = onReportTap self.onFollowTap = onFollowTap @@ -36,12 +39,16 @@ public struct ParentCommentView: View { public var body: some View { VStack(alignment: .leading) { HStack { + Button(action: { + onAvatarTap(comments.authorName) + }, label: { KFImage(URL(string: comments.authorAvatar)) .onFailureImage(KFCrossPlatformImage(systemName: "person")) .resizable() .background(Color.gray) .frame(width: 48, height: 48) .cornerRadius(isThread ? 8 : 24) + }) VStack(alignment: .leading) { Text(comments.authorName) .font(Theme.Fonts.titleMedium) @@ -157,6 +164,7 @@ struct ParentCommentView_Previews: PreviewProvider { ParentCommentView( comments: comment, isThread: true, + onAvatarTap: {_ in}, onLikeTap: {}, onReportTap: {}, onFollowTap: {} diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 69e666844..fe0e43058 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -52,7 +52,9 @@ public struct ResponsesView: View { if let comments = viewModel.postComments { ParentCommentView( comments: comments, - isThread: false, + isThread: false, onAvatarTap: { username in + viewModel.router.showUserDetails(username: username) + }, onLikeTap: { Task { if await viewModel.vote( @@ -93,7 +95,10 @@ public struct ResponsesView: View { ) { index, comment in CommentCell( comment: comment, - addCommentAvailable: false, leftLineEnabled: true, + addCommentAvailable: false, leftLineEnabled: true, + onAvatarTap: { username in + viewModel.router.showUserDetails(username: username) + }, onLikeTap: { Task { await viewModel.vote( diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index bdc5ae96a..0ca873e41 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -41,7 +41,10 @@ public struct ThreadView: View { if let comments = viewModel.postComments { ParentCommentView( comments: comments, - isThread: true, + isThread: true, + onAvatarTap: { username in + viewModel.router.showUserDetails(username: username) + }, onLikeTap: { Task { if await viewModel.vote( @@ -91,7 +94,9 @@ public struct ThreadView: View { CommentCell( comment: comment, addCommentAvailable: true, - onLikeTap: { + onAvatarTap: { username in + viewModel.router.showUserDetails(username: username) + }, onLikeTap: { Task { await viewModel.vote( id: comment.commentID, diff --git a/Discussion/Discussion/Presentation/DiscussionRouter.swift b/Discussion/Discussion/Presentation/DiscussionRouter.swift index f57e883b9..5cc0fed94 100644 --- a/Discussion/Discussion/Presentation/DiscussionRouter.swift +++ b/Discussion/Discussion/Presentation/DiscussionRouter.swift @@ -12,6 +12,8 @@ import Combine //sourcery: AutoMockable public protocol DiscussionRouter: BaseRouter { + func showUserDetails(username: String) + func showThreads(courseID: String, topics: Topics, title: String, type: ThreadType) func showThread(thread: UserThread, postStateSubject: CurrentValueSubject) @@ -33,6 +35,8 @@ public class DiscussionRouterMock: BaseRouterMock, DiscussionRouter { public override init() {} + public func showUserDetails(username: String) {} + public func showThreads(courseID: String, topics: Topics, title: String, type: ThreadType) {} public func showThread(thread: UserThread, postStateSubject: CurrentValueSubject) {} diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index bcb9ac4df..424aa9aaf 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -1967,6 +1967,12 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { + open func showUserDetails(username: String) { + addInvocation(.m_showUserDetails__username_username(Parameter.value(`username`))) + let perform = methodPerformValue(.m_showUserDetails__username_username(Parameter.value(`username`))) as? (String) -> Void + perform?(`username`) + } + open func showThreads(courseID: String, topics: Topics, title: String, type: ThreadType) { addInvocation(.m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(Parameter.value(`courseID`), Parameter.value(`topics`), Parameter.value(`title`), Parameter.value(`type`))) let perform = methodPerformValue(.m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(Parameter.value(`courseID`), Parameter.value(`topics`), Parameter.value(`title`), Parameter.value(`type`))) as? (String, Topics, String, ThreadType) -> Void @@ -2077,6 +2083,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { fileprivate enum MethodType { + case m_showUserDetails__username_username(Parameter) case m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(Parameter, Parameter, Parameter, Parameter) case m_showThread__thread_threadpostStateSubject_postStateSubject(Parameter, Parameter>) case m_showDiscussionsSearch__courseID_courseID(Parameter) @@ -2098,6 +2105,11 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { + case (.m_showUserDetails__username_username(let lhsUsername), .m_showUserDetails__username_username(let rhsUsername)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUsername, rhs: rhsUsername, with: matcher), lhsUsername, rhsUsername, "username")) + return Matcher.ComparisonResult(results) + case (.m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(let lhsCourseid, let lhsTopics, let lhsTitle, let lhsType), .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(let rhsCourseid, let rhsTopics, let rhsTitle, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) @@ -2200,6 +2212,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { func intValue() -> Int { switch self { + case let .m_showUserDetails__username_username(p0): return p0.intValue case let .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue case let .m_showThread__thread_threadpostStateSubject_postStateSubject(p0, p1): return p0.intValue + p1.intValue case let .m_showDiscussionsSearch__courseID_courseID(p0): return p0.intValue @@ -2222,6 +2235,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { } func assertionName() -> String { switch self { + case .m_showUserDetails__username_username: return ".showUserDetails(username:)" case .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type: return ".showThreads(courseID:topics:title:type:)" case .m_showThread__thread_threadpostStateSubject_postStateSubject: return ".showThread(thread:postStateSubject:)" case .m_showDiscussionsSearch__courseID_courseID: return ".showDiscussionsSearch(courseID:)" @@ -2258,6 +2272,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public struct Verify { fileprivate var method: MethodType + public static func showUserDetails(username: Parameter) -> Verify { return Verify(method: .m_showUserDetails__username_username(`username`))} public static func showThreads(courseID: Parameter, topics: Parameter, title: Parameter, type: Parameter) -> Verify { return Verify(method: .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(`courseID`, `topics`, `title`, `type`))} public static func showThread(thread: Parameter, postStateSubject: Parameter>) -> Verify { return Verify(method: .m_showThread__thread_threadpostStateSubject_postStateSubject(`thread`, `postStateSubject`))} public static func showDiscussionsSearch(courseID: Parameter) -> Verify { return Verify(method: .m_showDiscussionsSearch__courseID_courseID(`courseID`))} @@ -2282,6 +2297,9 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { fileprivate var method: MethodType var performs: Any + public static func showUserDetails(username: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_showUserDetails__username_username(`username`), performs: perform) + } public static func showThreads(courseID: Parameter, topics: Parameter, title: Parameter, type: Parameter, perform: @escaping (String, Topics, String, ThreadType) -> Void) -> Perform { return Perform(method: .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(`courseID`, `topics`, `title`, `type`), performs: perform) } diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 1dd9ddddb..c5c52df4c 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -134,14 +134,14 @@ class ScreenAssembly: Assembly { config: r.resolve(Config.self)! ) } - container.register(ProfileInteractor.self) { r in + container.register(ProfileInteractorProtocol.self) { r in ProfileInteractor( repository: r.resolve(ProfileRepositoryProtocol.self)! ) } container.register(ProfileViewModel.self) { r in ProfileViewModel( - interactor: r.resolve(ProfileInteractor.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)!, router: r.resolve(ProfileRouter.self)!, analytics: r.resolve(ProfileAnalytics.self)!, config: r.resolve(Config.self)!, @@ -151,7 +151,7 @@ class ScreenAssembly: Assembly { container.register(EditProfileViewModel.self) { r, userModel in EditProfileViewModel( userModel: userModel, - interactor: r.resolve(ProfileInteractor.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)!, router: r.resolve(ProfileRouter.self)!, analytics: r.resolve(ProfileAnalytics.self)! @@ -160,14 +160,14 @@ class ScreenAssembly: Assembly { container.register(SettingsViewModel.self) { r in SettingsViewModel( - interactor: r.resolve(ProfileInteractor.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)!, router: r.resolve(ProfileRouter.self)! ) } container.register(DeleteAccountViewModel.self) { r in DeleteAccountViewModel( - interactor: r.resolve(ProfileInteractor.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)!, router: r.resolve(ProfileRouter.self)!, connectivity: r.resolve(ConnectivityProtocol.self)! ) diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 9be51948e..58413b862 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -352,6 +352,16 @@ public class Router: AuthorizationRouter, navigationController.pushViewController(controller, animated: true) } + public func showUserDetails(username: String) { + let interactor = container.resolve(ProfileInteractorProtocol.self)! + + let vm = UserProfileViewModel(interactor: interactor, + username: username) + let view = UserProfileView(viewModel: vm) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } + public func showEditProfile( userModel: Core.UserProfile, avatar: UIImage?, diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index 3ba3e0531..28dc8a604 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -30,6 +30,8 @@ 02A9A91E2978194A00B55797 /* Profile.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 020F834A28DB4CCD0062FA70 /* Profile.framework */; platformFilter = ios; }; 02A9A92B29781A6300B55797 /* ProfileMock.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A9A92A29781A6300B55797 /* ProfileMock.generated.swift */; }; 02B089432A9F832200754BD4 /* ProfileStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B089422A9F832200754BD4 /* ProfileStorage.swift */; }; + 02D0FD092AD698380020D752 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D0FD082AD698380020D752 /* UserProfileView.swift */; }; + 02D0FD0B2AD6984D0020D752 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D0FD0A2AD6984D0020D752 /* UserProfileViewModel.swift */; }; 02F175352A4DAD030019CD70 /* ProfileAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */; }; 02F3BFE7292539850051930C /* ProfileRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFE6292539850051930C /* ProfileRouter.swift */; }; 0796C8C929B7905300444B05 /* ProfileBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0796C8C829B7905300444B05 /* ProfileBottomSheet.swift */; }; @@ -72,6 +74,8 @@ 02A9A91C2978194A00B55797 /* ProfileViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModelTests.swift; sourceTree = ""; }; 02A9A92A29781A6300B55797 /* ProfileMock.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileMock.generated.swift; sourceTree = ""; }; 02B089422A9F832200754BD4 /* ProfileStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStorage.swift; sourceTree = ""; }; + 02D0FD082AD698380020D752 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; + 02D0FD0A2AD6984D0020D752 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = ""; }; 02ED50CE29A64BAD008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAnalytics.swift; sourceTree = ""; }; 02F3BFE6292539850051930C /* ProfileRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRouter.swift; sourceTree = ""; }; @@ -131,6 +135,7 @@ 0203DC3D29AE79F80017BD05 /* Profile */ = { isa = PBXGroup; children = ( + 02D0FD072AD695E10020D752 /* UserProfile */, 021D924528DC634300ACC565 /* ProfileView.swift */, 021D925128DC918D00ACC565 /* ProfileViewModel.swift */, ); @@ -287,6 +292,15 @@ path = Data; sourceTree = ""; }; + 02D0FD072AD695E10020D752 /* UserProfile */ = { + isa = PBXGroup; + children = ( + 02D0FD082AD698380020D752 /* UserProfileView.swift */, + 02D0FD0A2AD6984D0020D752 /* UserProfileViewModel.swift */, + ); + path = UserProfile; + sourceTree = ""; + }; 0766DFD3299AD9D800EBEF6A /* Presentation */ = { isa = PBXGroup; children = ( @@ -551,6 +565,7 @@ 021D924C28DC884A00ACC565 /* ProfileEndpoint.swift in Sources */, 020306C82932B13F000949EA /* EditProfileView.swift in Sources */, 0262149229AE57A1008BD75A /* DeleteAccountView.swift in Sources */, + 02D0FD092AD698380020D752 /* UserProfileView.swift in Sources */, 0259104629C39CCF004B5A55 /* SettingsViewModel.swift in Sources */, 021D925528DC92F800ACC565 /* ProfileInteractor.swift in Sources */, 029301DA2938948500E99AB8 /* ProfileType.swift in Sources */, @@ -562,6 +577,7 @@ 0259104829C3A5F0004B5A55 /* VideoQualityView.swift in Sources */, 021D924628DC634300ACC565 /* ProfileView.swift in Sources */, 02F3BFE7292539850051930C /* ProfileRouter.swift in Sources */, + 02D0FD0B2AD6984D0020D752 /* UserProfileViewModel.swift in Sources */, 02F175352A4DAD030019CD70 /* ProfileAnalytics.swift in Sources */, 0262149429AE57B1008BD75A /* DeleteAccountViewModel.swift in Sources */, ); diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index 9c95c9a7a..bf1a02ec2 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -10,6 +10,7 @@ import Core import Alamofire public protocol ProfileRepositoryProtocol { + func getUserProfile(username: String) async throws -> UserProfile func getMyProfile() async throws -> UserProfile func getMyProfileOffline() throws -> UserProfile func logOut() async throws @@ -45,9 +46,15 @@ public class ProfileRepository: ProfileRepositoryProtocol { self.config = config } + public func getUserProfile(username: String) async throws -> UserProfile { + let user = try await api.requestData( + ProfileEndpoint.getUserProfile(username: username) + ).mapResponse(DataLayer.UserProfile.self) + return user.domain + } + public func getMyProfile() async throws -> UserProfile { - let user = - try await api.requestData( + let user = try await api.requestData( ProfileEndpoint.getUserProfile(username: storage.user?.username ?? "") ).mapResponse(DataLayer.UserProfile.self) storage.userProfile = user @@ -154,6 +161,18 @@ public class ProfileRepository: ProfileRepositoryProtocol { #if DEBUG // swiftlint:disable all class ProfileRepositoryMock: ProfileRepositoryProtocol { + + public func getUserProfile(username: String) async throws -> Core.UserProfile { + return Core.UserProfile(avatarUrl: "", + name: "", + username: "", + dateJoined: Date(), + yearOfBirth: 0, + country: "", + shortBiography: "", + isFullProfile: false) + } + func getMyProfileOffline() throws -> Core.UserProfile { return UserProfile( avatarUrl: "", diff --git a/Profile/Profile/Domain/ProfileInteractor.swift b/Profile/Profile/Domain/ProfileInteractor.swift index a29d04ad2..0f8fd4708 100644 --- a/Profile/Profile/Domain/ProfileInteractor.swift +++ b/Profile/Profile/Domain/ProfileInteractor.swift @@ -11,6 +11,7 @@ import UIKit //sourcery: AutoMockable public protocol ProfileInteractorProtocol { + func getUserProfile(username: String) async throws -> UserProfile func getMyProfile() async throws -> UserProfile func getMyProfileOffline() throws -> UserProfile func logOut() async throws @@ -32,6 +33,10 @@ public class ProfileInteractor: ProfileInteractorProtocol { self.repository = repository } + public func getUserProfile(username: String) async throws -> UserProfile { + return try await repository.getUserProfile(username: username) + } + public func getMyProfile() async throws -> UserProfile { return try await repository.getMyProfile() } diff --git a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift new file mode 100644 index 000000000..da5a7f9dc --- /dev/null +++ b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift @@ -0,0 +1,146 @@ +// +// UserProfileView.swift +// Profile +// +// Created by  Stepanok Ivan on 10.10.2023. +// + +import SwiftUI +import Core +import Kingfisher + +public struct UserProfileView: View { + + @ObservedObject private var viewModel: UserProfileViewModel + + public init(viewModel: UserProfileViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + ZStack(alignment: .top) { + // MARK: - Page Body + RefreshableScrollViewCompat(action: { + await viewModel.getUserProfile(withProgress: false) + }) { + VStack { + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + } else { + ProfileAvatar(url: viewModel.userModel?.avatarUrl ?? "") + if let name = viewModel.userModel?.name, name != "" { + Text(name) + .font(Theme.Fonts.headlineSmall) + .padding(.top, 20) + } + Text("@\(viewModel.userModel?.username ?? "")") + .font(Theme.Fonts.labelLarge) + .padding(.top, 4) + .foregroundColor(Theme.Colors.textSecondary) + .padding(.bottom, 10) + + // MARK: - Profile Info + if viewModel.userModel?.yearOfBirth != 0 || viewModel.userModel?.shortBiography != "" { + VStack(alignment: .leading, spacing: 14) { + Text(ProfileLocalization.info) + .padding(.horizontal, 24) + .font(Theme.Fonts.labelLarge) + + VStack(alignment: .leading, spacing: 16) { + if viewModel.userModel?.yearOfBirth != 0 { + HStack { + Text(ProfileLocalization.Edit.Fields.yearOfBirth) + .foregroundColor(Theme.Colors.textSecondary) + Text(String(viewModel.userModel?.yearOfBirth ?? 0)) + } + } + if let bio = viewModel.userModel?.shortBiography, bio != "" { + HStack(alignment: .top) { + Text(ProfileLocalization.bio + " ") + .foregroundColor(Theme.Colors.textSecondary) + + Text(bio) + } + } + } + .cardStyle( + bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear + ) + }.padding(.bottom, 16) + } + } + Spacer() + } + }.frameLimit(sizePortrait: 420) + .padding(.top, 8) + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } + } + } + } + .onFirstAppear { + Task { + await viewModel.getUserProfile() + } + } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + } +} + +struct ProfileAvatar: View { + + private var url: URL? + + init(url: String) { + if let rightUrl = URL(string: url) { + self.url = rightUrl + } else { + self.url = nil + } + } + + var body: some View { + ZStack { + Circle() + .foregroundColor(Theme.Colors.avatarStroke) + .frame(width: 104, height: 104) + KFImage(url) + .onFailureImage(CoreAssets.noCourseImage.image) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .cornerRadius(50) + } + } +} + +#if DEBUG +struct UserProfileView_Previews: PreviewProvider { + static var previews: some View { + + let vm = UserProfileViewModel( + interactor: ProfileInteractor.mock, + username: "demo" + ) + + return UserProfileView(viewModel: vm) + } +} +#endif diff --git a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileViewModel.swift b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileViewModel.swift new file mode 100644 index 000000000..6a723c800 --- /dev/null +++ b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileViewModel.swift @@ -0,0 +1,52 @@ +// +// UserProfileViewModel.swift +// Discussion +// +// Created by  Stepanok Ivan on 10.10.2023. +// + +import Core +import SwiftUI + +public class UserProfileViewModel: ObservableObject { + + @Published public var userModel: UserProfile? + @Published private(set) var isShowProgress = false + @Published var showError: Bool = false + var errorMessage: String? { + didSet { + withAnimation { + showError = errorMessage != nil + } + } + } + + private let username: String + + private let interactor: ProfileInteractorProtocol + + public init( + interactor: ProfileInteractorProtocol, + username: String + ) { + self.interactor = interactor + self.username = username + } + + @MainActor + func getUserProfile(withProgress: Bool = true) async { + isShowProgress = withProgress + do { + userModel = try await interactor.getUserProfile(username: username) + isShowProgress = false + } catch let error { + isShowProgress = false + if error.isInternetError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError + } + + } + } +} diff --git a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift index ddc0f356f..3c9a7d0f4 100644 --- a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift @@ -14,6 +14,77 @@ import SwiftUI final class ProfileViewModelTests: XCTestCase { + func testGetUserProfileSuccess() async throws { + let interactor = ProfileInteractorProtocolMock() + + let viewModel = UserProfileViewModel( + interactor: interactor, + username: "Steve" + ) + + let user = UserProfile( + avatarUrl: "", + name: "Steve", + username: "Steve", + dateJoined: Date(), + yearOfBirth: 2000, + country: "Ua", + shortBiography: "Bio", + isFullProfile: false + ) + + Given(interactor, .getUserProfile(username: .value("Steve"), willReturn: user)) + + await viewModel.getUserProfile() + + Verify(interactor, 1, .getUserProfile(username: .value("Steve"))) + + XCTAssertEqual(viewModel.userModel, user) + XCTAssertFalse(viewModel.isShowProgress) + XCTAssertFalse(viewModel.showError) + XCTAssertNil(viewModel.errorMessage) + } + + func testGetUserProfileNoInternetError() async throws { + let interactor = ProfileInteractorProtocolMock() + + let viewModel = UserProfileViewModel( + interactor: interactor, + username: "Steve" + ) + + let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) + + Given(interactor, .getUserProfile(username: .value("Steve"), willThrow: noInternetError)) + + await viewModel.getUserProfile() + + Verify(interactor, 1, .getUserProfile(username: .value("Steve"))) + + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) + XCTAssertFalse(viewModel.isShowProgress) + XCTAssertTrue(viewModel.showError) + } + + func testGetUserProfileUnknownError() async throws { + let interactor = ProfileInteractorProtocolMock() + + let viewModel = UserProfileViewModel( + interactor: interactor, + username: "Steve" + ) + + Given(interactor, .getUserProfile(username: .value("Steve"), willThrow: NSError())) + + await viewModel.getUserProfile() + + Verify(interactor, 1, .getUserProfile(username: .value("Steve"))) + + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) + XCTAssertFalse(viewModel.isShowProgress) + XCTAssertTrue(viewModel.showError) + } + func testGetMyProfileSuccess() async throws { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() @@ -102,7 +173,7 @@ final class ProfileViewModelTests: XCTestCase { ) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) - + Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .getMyProfile(willThrow: noInternetError) ) @@ -204,7 +275,7 @@ final class ProfileViewModelTests: XCTestCase { Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .logOut(willThrow: noInternetError)) - + await viewModel.logOut() XCTAssertTrue(viewModel.showError) @@ -223,10 +294,10 @@ final class ProfileViewModelTests: XCTestCase { config: ConfigMock(), connectivity: connectivity ) - + Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .logOut(willThrow: NSError())) - + await viewModel.logOut() XCTAssertTrue(viewModel.showError) @@ -322,5 +393,4 @@ final class ProfileViewModelTests: XCTestCase { Verify(analytics, 1, .profileEditClicked()) } - } diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 8ebac614f..e8f39508d 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1738,6 +1738,22 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { + open func getUserProfile(username: String) throws -> UserProfile { + addInvocation(.m_getUserProfile__username_username(Parameter.value(`username`))) + let perform = methodPerformValue(.m_getUserProfile__username_username(Parameter.value(`username`))) as? (String) -> Void + perform?(`username`) + var __value: UserProfile + do { + __value = try methodReturnValue(.m_getUserProfile__username_username(Parameter.value(`username`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getUserProfile(username: String). Use given") + Failure("Stub return value not specified for getUserProfile(username: String). Use given") + } catch { + throw error + } + return __value + } + open func getMyProfile() throws -> UserProfile { addInvocation(.m_getMyProfile) let perform = methodPerformValue(.m_getMyProfile) as? () -> Void @@ -1894,6 +1910,7 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { fileprivate enum MethodType { + case m_getUserProfile__username_username(Parameter) case m_getMyProfile case m_getMyProfileOffline case m_logOut @@ -1908,6 +1925,11 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { + case (.m_getUserProfile__username_username(let lhsUsername), .m_getUserProfile__username_username(let rhsUsername)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUsername, rhs: rhsUsername, with: matcher), lhsUsername, rhsUsername, "username")) + return Matcher.ComparisonResult(results) + case (.m_getMyProfile, .m_getMyProfile): return .match case (.m_getMyProfileOffline, .m_getMyProfileOffline): return .match @@ -1947,6 +1969,7 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { func intValue() -> Int { switch self { + case let .m_getUserProfile__username_username(p0): return p0.intValue case .m_getMyProfile: return 0 case .m_getMyProfileOffline: return 0 case .m_logOut: return 0 @@ -1962,6 +1985,7 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { } func assertionName() -> String { switch self { + case .m_getUserProfile__username_username: return ".getUserProfile(username:)" case .m_getMyProfile: return ".getMyProfile()" case .m_getMyProfileOffline: return ".getMyProfileOffline()" case .m_logOut: return ".logOut()" @@ -1986,6 +2010,9 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { } + public static func getUserProfile(username: Parameter, willReturn: UserProfile...) -> MethodStub { + return Given(method: .m_getUserProfile__username_username(`username`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func getMyProfile(willReturn: UserProfile...) -> MethodStub { return Given(method: .m_getMyProfile, products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -2031,6 +2058,16 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { willProduce(stubber) return given } + public static func getUserProfile(username: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getUserProfile__username_username(`username`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getUserProfile(username: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getUserProfile__username_username(`username`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (UserProfile).self) + willProduce(stubber) + return given + } public static func getMyProfile(willThrow: Error...) -> MethodStub { return Given(method: .m_getMyProfile, products: willThrow.map({ StubProduct.throw($0) })) } @@ -2106,6 +2143,7 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public struct Verify { fileprivate var method: MethodType + public static func getUserProfile(username: Parameter) -> Verify { return Verify(method: .m_getUserProfile__username_username(`username`))} public static func getMyProfile() -> Verify { return Verify(method: .m_getMyProfile)} public static func getMyProfileOffline() -> Verify { return Verify(method: .m_getMyProfileOffline)} public static func logOut() -> Verify { return Verify(method: .m_logOut)} @@ -2123,6 +2161,9 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { fileprivate var method: MethodType var performs: Any + public static func getUserProfile(username: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getUserProfile__username_username(`username`), performs: perform) + } public static func getMyProfile(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_getMyProfile, performs: perform) }