From 4433d7f6476cf59fb246a3bcca66e5bfcc502849 Mon Sep 17 00:00:00 2001 From: stepanokdev <100592747+Stepanokdev@users.noreply.github.com> Date: Tue, 10 Oct 2023 19:19:41 +0300 Subject: [PATCH 1/4] add Discussion Profile View --- Core/Core/Data/Model/Data_UserProfile.swift | 5 +- .../Discussion.xcodeproj/project.pbxproj | 16 ++ .../Data/Network/DiscussionEndpoint.swift | 10 +- .../Data/Network/DiscussionRepository.swift | 20 +++ .../Domain/DiscussionInteractor.swift | 6 + .../Comments/Base/CommentCell.swift | 11 ++ .../Comments/Base/ParentCommentView.swift | 8 + .../Comments/Responses/ResponsesView.swift | 9 +- .../Comments/Thread/ThreadView.swift | 9 +- .../Presentation/DiscussionRouter.swift | 4 + .../UserDetails/UserDetailsView.swift | 142 ++++++++++++++++++ .../UserDetails/UserDetailsViewModel.swift | 90 +++++++++++ OpenEdX/Router.swift | 11 ++ 13 files changed, 334 insertions(+), 7 deletions(-) create mode 100644 Discussion/Discussion/Presentation/UserDetails/UserDetailsView.swift create mode 100644 Discussion/Discussion/Presentation/UserDetails/UserDetailsViewModel.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.xcodeproj/project.pbxproj b/Discussion/Discussion.xcodeproj/project.pbxproj index 9a659e4a7..02f3aa206 100644 --- a/Discussion/Discussion.xcodeproj/project.pbxproj +++ b/Discussion/Discussion.xcodeproj/project.pbxproj @@ -19,6 +19,8 @@ 0218197228F735B300202564 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0218197128F735B300202564 /* Strings.swift */; }; 0218197828F7363000202564 /* DiscussionEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0218197728F7363000202564 /* DiscussionEndpoint.swift */; }; 0218197A28F7369A00202564 /* DiscussionInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0218197928F7369A00202564 /* DiscussionInteractor.swift */; }; + 022042052AD59F4F002E81C8 /* UserDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022042042AD59F4F002E81C8 /* UserDetailsView.swift */; }; + 022042072AD59FA4002E81C8 /* UserDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022042062AD59FA4002E81C8 /* UserDetailsViewModel.swift */; }; 023F14A9291BC02200FD0EFF /* ParentCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023F14A8291BC02200FD0EFF /* ParentCommentView.swift */; }; 023F14AB291BF30300FD0EFF /* CommentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023F14AA291BF30300FD0EFF /* CommentCell.swift */; }; 0240D8D22987FE1F003CFE50 /* PostViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0240D8D12987FE1F003CFE50 /* PostViewModelTests.swift */; }; @@ -85,6 +87,8 @@ 0218197128F735B300202564 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; 0218197728F7363000202564 /* DiscussionEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionEndpoint.swift; sourceTree = ""; }; 0218197928F7369A00202564 /* DiscussionInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionInteractor.swift; sourceTree = ""; }; + 022042042AD59F4F002E81C8 /* UserDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsView.swift; sourceTree = ""; }; + 022042062AD59FA4002E81C8 /* UserDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsViewModel.swift; sourceTree = ""; }; 023F14A8291BC02200FD0EFF /* ParentCommentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParentCommentView.swift; sourceTree = ""; }; 023F14AA291BF30300FD0EFF /* CommentCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentCell.swift; sourceTree = ""; }; 0240D8CF2987FE1F003CFE50 /* DiscussionTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DiscussionTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -241,6 +245,7 @@ 0218197428F735C800202564 /* Presentation */ = { isa = PBXGroup; children = ( + 022042032AD59F3E002E81C8 /* UserDetails */, 029A941A2913EF3D00DADA5B /* CreateNewThread */, 02F28A5C28FF23BD00AFDE1B /* Comments */, 0282DA5D28F893B7003C3F07 /* Posts */, @@ -272,6 +277,15 @@ path = Network; sourceTree = ""; }; + 022042032AD59F3E002E81C8 /* UserDetails */ = { + isa = PBXGroup; + children = ( + 022042042AD59F4F002E81C8 /* UserDetailsView.swift */, + 022042062AD59FA4002E81C8 /* UserDetailsViewModel.swift */, + ); + path = UserDetails; + sourceTree = ""; + }; 0240D8D02987FE1F003CFE50 /* DiscussionTests */ = { isa = PBXGroup; children = ( @@ -671,6 +685,7 @@ 029A941E2913EF7800DADA5B /* CreateNewThreadViewModel.swift in Sources */, 029A941C2913EF6800DADA5B /* CreateNewThreadView.swift in Sources */, 027BD39A2908256200392132 /* ThreadList.swift in Sources */, + 022042072AD59FA4002E81C8 /* UserDetailsViewModel.swift in Sources */, 029B78F3292518FA0097ACD8 /* ResponsesView.swift in Sources */, 02D1267428F75BB700C8E689 /* Data_TopicsResponse.swift in Sources */, 02D1266E28F73BA700C8E689 /* DiscussionRepository.swift in Sources */, @@ -684,6 +699,7 @@ 0282DA6128F893E9003C3F07 /* PostsViewModel.swift in Sources */, 0766DFC4299AA2C200EBEF6A /* Post.swift in Sources */, 02F175392A4DD5AB0019CD70 /* DiscussionAnalytics.swift in Sources */, + 022042052AD59F4F002E81C8 /* UserDetailsView.swift in Sources */, 021078E929A50BA30000938D /* DiscussionSearchTopicsViewModel.swift in Sources */, 02F3BFEB2926A5B50051930C /* Data_CommentsResponse.swift in Sources */, 075DBBB329267D1D00E56134 /* PostState.swift in Sources */, diff --git a/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift b/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift index 88e8d698f..683b10877 100644 --- a/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift +++ b/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift @@ -10,6 +10,7 @@ import Core import Alamofire enum DiscussionEndpoint: EndPointType { + case getUserProfile(username: String) case getCourseDiscussionInfo(courseID: String) case getThreads(courseID: String, type: ThreadType, sort: SortType, filter: ThreadsFilter, page: Int) case getTopics(courseID: String) @@ -28,6 +29,8 @@ enum DiscussionEndpoint: EndPointType { var path: String { switch self { + case .getUserProfile(let username): + return "api/user/v1/accounts/\(username)" case let .getCourseDiscussionInfo(courseID): return "/api/discussion/v1/courses/\(courseID)" case .getThreads: @@ -64,6 +67,8 @@ enum DiscussionEndpoint: EndPointType { var httpMethod: HTTPMethod { switch self { + case .getUserProfile: + return .get case .getCourseDiscussionInfo: return .get case .getThreads: @@ -99,7 +104,8 @@ enum DiscussionEndpoint: EndPointType { var headers: HTTPHeaders? { switch self { - case .getCourseDiscussionInfo, + case .getUserProfile, + .getCourseDiscussionInfo, .getThreads, .getTopics, .getDiscussionComments, @@ -116,6 +122,8 @@ enum DiscussionEndpoint: EndPointType { var task: HTTPTask { switch self { + case .getUserProfile: + return .request case .getCourseDiscussionInfo: return .requestParameters(encoding: URLEncoding.queryString) case let .getThreads(courseID, type, sort, filter, page): diff --git a/Discussion/Discussion/Data/Network/DiscussionRepository.swift b/Discussion/Discussion/Data/Network/DiscussionRepository.swift index 18530e784..d84408a1b 100644 --- a/Discussion/Discussion/Data/Network/DiscussionRepository.swift +++ b/Discussion/Discussion/Data/Network/DiscussionRepository.swift @@ -10,6 +10,7 @@ import Core import Combine public protocol DiscussionRepositoryProtocol { + func getUserProfile(username: String) async throws -> UserProfile func getThreads(courseID: String, type: ThreadType, sort: SortType, @@ -47,6 +48,14 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { self.router = router } + public func getUserProfile(username: String) async throws -> UserProfile { + let user = + try await api.requestData( + DiscussionEndpoint.getUserProfile(username: username) + ).mapResponse(DataLayer.UserProfile.self) + return user.domain + } + public func getThreads(courseID: String, type: ThreadType, sort: SortType, @@ -181,6 +190,17 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { #if DEBUG // swiftlint:disable all public class DiscussionRepositoryMock: DiscussionRepositoryProtocol { + public func getUserProfile(username: String) async throws -> Core.UserProfile { + return Core.UserProfile(avatarUrl: "", + name: "", + username: "", + dateJoined: Date(), + yearOfBirth: 0, + country: "", + shortBiography: "", + isFullProfile: false) + } + var comments = [ UserComment(authorName: "Bill", diff --git a/Discussion/Discussion/Domain/DiscussionInteractor.swift b/Discussion/Discussion/Domain/DiscussionInteractor.swift index ee6de6716..2f2cb238f 100644 --- a/Discussion/Discussion/Domain/DiscussionInteractor.swift +++ b/Discussion/Discussion/Domain/DiscussionInteractor.swift @@ -10,6 +10,8 @@ import Core //sourcery: AutoMockable public protocol DiscussionInteractorProtocol { + func getUserProfile(username: String) async throws -> UserProfile + func getThreadsList(courseID: String, type: ThreadType, sort: SortType, @@ -37,6 +39,10 @@ public class DiscussionInteractor: DiscussionInteractorProtocol { public init(repository: DiscussionRepositoryProtocol) { self.repository = repository } + + public func getUserProfile(username: String) async throws -> UserProfile { + return try await repository.getUserProfile(username: username) + } public func getThreadsList(courseID: String, type: ThreadType, 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 ab6aa3455..6c68e1d80 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) @@ -156,6 +163,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/Discussion/Presentation/UserDetails/UserDetailsView.swift b/Discussion/Discussion/Presentation/UserDetails/UserDetailsView.swift new file mode 100644 index 000000000..5351dba25 --- /dev/null +++ b/Discussion/Discussion/Presentation/UserDetails/UserDetailsView.swift @@ -0,0 +1,142 @@ +// +// UserDetailsView.swift +// Discussion +// +// Created by  Stepanok Ivan on 10.10.2023. +// + +import SwiftUI +import Core +import Profile +import Kingfisher + +public struct UserDetailsView: View { + + @StateObject private var viewModel: UserDetailsViewModel + + public init(viewModel: UserDetailsViewModel) { + self._viewModel = StateObject(wrappedValue: { 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 { + UserAvatar(url: viewModel.userModel?.avatarUrl ?? "") + Text(viewModel.userModel?.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() + ) + } +} + +#Preview { + + let router = DiscussionRouterMock() + let vm = UserDetailsViewModel(interactor: DiscussionInteractor.mock, + analytics: DiscussionAnalyticsMock(), + username: "demo") + + return UserDetailsView(viewModel: vm) +} + +struct UserAvatar: 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) + } + } +} diff --git a/Discussion/Discussion/Presentation/UserDetails/UserDetailsViewModel.swift b/Discussion/Discussion/Presentation/UserDetails/UserDetailsViewModel.swift new file mode 100644 index 000000000..0550c910a --- /dev/null +++ b/Discussion/Discussion/Presentation/UserDetails/UserDetailsViewModel.swift @@ -0,0 +1,90 @@ +// +// UserDetailsViewModel.swift +// Discussion +// +// Created by  Stepanok Ivan on 10.10.2023. +// + +import Core +import SwiftUI + +public class UserDetailsViewModel: 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: DiscussionInteractorProtocol + private let analytics: DiscussionAnalytics + + public init( + interactor: DiscussionInteractorProtocol, + analytics: DiscussionAnalytics, + username: String + ) { + self.interactor = interactor + self.analytics = analytics + 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 + } + + } + } + +// @MainActor +// func logOut() async { +// do { +// try await interactor.logOut() +// router.showLoginScreen() +// analytics.userLogout(force: false) +// } catch let error { +// if error.isInternetError { +// errorMessage = CoreLocalization.Error.slowOrNoInternetConnection +// } else { +// errorMessage = CoreLocalization.Error.unknownError +// } +// } +// } +// +// func trackProfileVideoSettingsClicked() { +// analytics.profileVideoSettingsClicked() +// } +// +// func trackEmailSupportClicked() { +// analytics.emailSupportClicked() +// } +// +// func trackCookiePolicyClicked() { +// analytics.cookiePolicyClicked() +// } +// +// func trackPrivacyPolicyClicked() { +// analytics.privacyPolicyClicked() +// } +// +// func trackProfileEditClicked() { +// analytics.profileEditClicked() +// } +} diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 9be51948e..2d4e548f3 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -352,6 +352,17 @@ public class Router: AuthorizationRouter, navigationController.pushViewController(controller, animated: true) } + public func showUserDetails(username: String) { + let interactor = container.resolve(DiscussionInteractorProtocol.self)! + let analytics = container.resolve(DiscussionAnalytics.self)! + let vm = UserDetailsViewModel(interactor: interactor, + analytics: analytics, + username: username) + let view = UserDetailsView(viewModel: vm) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } + public func showEditProfile( userModel: Core.UserProfile, avatar: UIImage?, From cd22e29cc6f1d38a38891c528e4882f9ed1b5edd Mon Sep 17 00:00:00 2001 From: stepanokdev <100592747+Stepanokdev@users.noreply.github.com> Date: Wed, 11 Oct 2023 12:42:39 +0300 Subject: [PATCH 2/4] add tests --- .../Discussion.xcodeproj/project.pbxproj | 16 ---- .../Data/Network/DiscussionEndpoint.swift | 10 +-- .../Data/Network/DiscussionRepository.swift | 22 +---- .../Domain/DiscussionInteractor.swift | 6 -- .../UserDetails/UserDetailsViewModel.swift | 90 ------------------- .../DiscussionMock.generated.swift | 18 ++++ OpenEdX/DI/ScreenAssembly.swift | 10 +-- OpenEdX/Router.swift | 9 +- Profile/Profile.xcodeproj/project.pbxproj | 16 ++++ Profile/Profile/Data/ProfileRepository.swift | 21 +++++ .../Profile/Domain/ProfileInteractor.swift | 5 ++ .../Profile/UserProfile/UserProfileView.swift | 48 +++++----- .../UserProfile/UserProfileViewModel.swift | 52 +++++++++++ .../Profile/ProfileViewModelTests.swift | 80 +++++++++++++++-- .../ProfileTests/ProfileMock.generated.swift | 41 +++++++++ 15 files changed, 264 insertions(+), 180 deletions(-) delete mode 100644 Discussion/Discussion/Presentation/UserDetails/UserDetailsViewModel.swift rename Discussion/Discussion/Presentation/UserDetails/UserDetailsView.swift => Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift (83%) create mode 100644 Profile/Profile/Presentation/Profile/UserProfile/UserProfileViewModel.swift diff --git a/Discussion/Discussion.xcodeproj/project.pbxproj b/Discussion/Discussion.xcodeproj/project.pbxproj index 02f3aa206..9a659e4a7 100644 --- a/Discussion/Discussion.xcodeproj/project.pbxproj +++ b/Discussion/Discussion.xcodeproj/project.pbxproj @@ -19,8 +19,6 @@ 0218197228F735B300202564 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0218197128F735B300202564 /* Strings.swift */; }; 0218197828F7363000202564 /* DiscussionEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0218197728F7363000202564 /* DiscussionEndpoint.swift */; }; 0218197A28F7369A00202564 /* DiscussionInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0218197928F7369A00202564 /* DiscussionInteractor.swift */; }; - 022042052AD59F4F002E81C8 /* UserDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022042042AD59F4F002E81C8 /* UserDetailsView.swift */; }; - 022042072AD59FA4002E81C8 /* UserDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022042062AD59FA4002E81C8 /* UserDetailsViewModel.swift */; }; 023F14A9291BC02200FD0EFF /* ParentCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023F14A8291BC02200FD0EFF /* ParentCommentView.swift */; }; 023F14AB291BF30300FD0EFF /* CommentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023F14AA291BF30300FD0EFF /* CommentCell.swift */; }; 0240D8D22987FE1F003CFE50 /* PostViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0240D8D12987FE1F003CFE50 /* PostViewModelTests.swift */; }; @@ -87,8 +85,6 @@ 0218197128F735B300202564 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; 0218197728F7363000202564 /* DiscussionEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionEndpoint.swift; sourceTree = ""; }; 0218197928F7369A00202564 /* DiscussionInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionInteractor.swift; sourceTree = ""; }; - 022042042AD59F4F002E81C8 /* UserDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsView.swift; sourceTree = ""; }; - 022042062AD59FA4002E81C8 /* UserDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsViewModel.swift; sourceTree = ""; }; 023F14A8291BC02200FD0EFF /* ParentCommentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParentCommentView.swift; sourceTree = ""; }; 023F14AA291BF30300FD0EFF /* CommentCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentCell.swift; sourceTree = ""; }; 0240D8CF2987FE1F003CFE50 /* DiscussionTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DiscussionTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -245,7 +241,6 @@ 0218197428F735C800202564 /* Presentation */ = { isa = PBXGroup; children = ( - 022042032AD59F3E002E81C8 /* UserDetails */, 029A941A2913EF3D00DADA5B /* CreateNewThread */, 02F28A5C28FF23BD00AFDE1B /* Comments */, 0282DA5D28F893B7003C3F07 /* Posts */, @@ -277,15 +272,6 @@ path = Network; sourceTree = ""; }; - 022042032AD59F3E002E81C8 /* UserDetails */ = { - isa = PBXGroup; - children = ( - 022042042AD59F4F002E81C8 /* UserDetailsView.swift */, - 022042062AD59FA4002E81C8 /* UserDetailsViewModel.swift */, - ); - path = UserDetails; - sourceTree = ""; - }; 0240D8D02987FE1F003CFE50 /* DiscussionTests */ = { isa = PBXGroup; children = ( @@ -685,7 +671,6 @@ 029A941E2913EF7800DADA5B /* CreateNewThreadViewModel.swift in Sources */, 029A941C2913EF6800DADA5B /* CreateNewThreadView.swift in Sources */, 027BD39A2908256200392132 /* ThreadList.swift in Sources */, - 022042072AD59FA4002E81C8 /* UserDetailsViewModel.swift in Sources */, 029B78F3292518FA0097ACD8 /* ResponsesView.swift in Sources */, 02D1267428F75BB700C8E689 /* Data_TopicsResponse.swift in Sources */, 02D1266E28F73BA700C8E689 /* DiscussionRepository.swift in Sources */, @@ -699,7 +684,6 @@ 0282DA6128F893E9003C3F07 /* PostsViewModel.swift in Sources */, 0766DFC4299AA2C200EBEF6A /* Post.swift in Sources */, 02F175392A4DD5AB0019CD70 /* DiscussionAnalytics.swift in Sources */, - 022042052AD59F4F002E81C8 /* UserDetailsView.swift in Sources */, 021078E929A50BA30000938D /* DiscussionSearchTopicsViewModel.swift in Sources */, 02F3BFEB2926A5B50051930C /* Data_CommentsResponse.swift in Sources */, 075DBBB329267D1D00E56134 /* PostState.swift in Sources */, diff --git a/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift b/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift index 683b10877..88e8d698f 100644 --- a/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift +++ b/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift @@ -10,7 +10,6 @@ import Core import Alamofire enum DiscussionEndpoint: EndPointType { - case getUserProfile(username: String) case getCourseDiscussionInfo(courseID: String) case getThreads(courseID: String, type: ThreadType, sort: SortType, filter: ThreadsFilter, page: Int) case getTopics(courseID: String) @@ -29,8 +28,6 @@ enum DiscussionEndpoint: EndPointType { var path: String { switch self { - case .getUserProfile(let username): - return "api/user/v1/accounts/\(username)" case let .getCourseDiscussionInfo(courseID): return "/api/discussion/v1/courses/\(courseID)" case .getThreads: @@ -67,8 +64,6 @@ enum DiscussionEndpoint: EndPointType { var httpMethod: HTTPMethod { switch self { - case .getUserProfile: - return .get case .getCourseDiscussionInfo: return .get case .getThreads: @@ -104,8 +99,7 @@ enum DiscussionEndpoint: EndPointType { var headers: HTTPHeaders? { switch self { - case .getUserProfile, - .getCourseDiscussionInfo, + case .getCourseDiscussionInfo, .getThreads, .getTopics, .getDiscussionComments, @@ -122,8 +116,6 @@ enum DiscussionEndpoint: EndPointType { var task: HTTPTask { switch self { - case .getUserProfile: - return .request case .getCourseDiscussionInfo: return .requestParameters(encoding: URLEncoding.queryString) case let .getThreads(courseID, type, sort, filter, page): diff --git a/Discussion/Discussion/Data/Network/DiscussionRepository.swift b/Discussion/Discussion/Data/Network/DiscussionRepository.swift index d84408a1b..7e395da51 100644 --- a/Discussion/Discussion/Data/Network/DiscussionRepository.swift +++ b/Discussion/Discussion/Data/Network/DiscussionRepository.swift @@ -10,7 +10,6 @@ import Core import Combine public protocol DiscussionRepositoryProtocol { - func getUserProfile(username: String) async throws -> UserProfile func getThreads(courseID: String, type: ThreadType, sort: SortType, @@ -48,14 +47,6 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { self.router = router } - public func getUserProfile(username: String) async throws -> UserProfile { - let user = - try await api.requestData( - DiscussionEndpoint.getUserProfile(username: username) - ).mapResponse(DataLayer.UserProfile.self) - return user.domain - } - public func getThreads(courseID: String, type: ThreadType, sort: SortType, @@ -190,18 +181,7 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { #if DEBUG // swiftlint:disable all public class DiscussionRepositoryMock: DiscussionRepositoryProtocol { - public func getUserProfile(username: String) async throws -> Core.UserProfile { - return Core.UserProfile(avatarUrl: "", - name: "", - username: "", - dateJoined: Date(), - yearOfBirth: 0, - country: "", - shortBiography: "", - isFullProfile: false) - } - - + var comments = [ UserComment(authorName: "Bill", authorAvatar: "", diff --git a/Discussion/Discussion/Domain/DiscussionInteractor.swift b/Discussion/Discussion/Domain/DiscussionInteractor.swift index 2f2cb238f..ee6de6716 100644 --- a/Discussion/Discussion/Domain/DiscussionInteractor.swift +++ b/Discussion/Discussion/Domain/DiscussionInteractor.swift @@ -10,8 +10,6 @@ import Core //sourcery: AutoMockable public protocol DiscussionInteractorProtocol { - func getUserProfile(username: String) async throws -> UserProfile - func getThreadsList(courseID: String, type: ThreadType, sort: SortType, @@ -39,10 +37,6 @@ public class DiscussionInteractor: DiscussionInteractorProtocol { public init(repository: DiscussionRepositoryProtocol) { self.repository = repository } - - public func getUserProfile(username: String) async throws -> UserProfile { - return try await repository.getUserProfile(username: username) - } public func getThreadsList(courseID: String, type: ThreadType, diff --git a/Discussion/Discussion/Presentation/UserDetails/UserDetailsViewModel.swift b/Discussion/Discussion/Presentation/UserDetails/UserDetailsViewModel.swift deleted file mode 100644 index 0550c910a..000000000 --- a/Discussion/Discussion/Presentation/UserDetails/UserDetailsViewModel.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// UserDetailsViewModel.swift -// Discussion -// -// Created by  Stepanok Ivan on 10.10.2023. -// - -import Core -import SwiftUI - -public class UserDetailsViewModel: 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: DiscussionInteractorProtocol - private let analytics: DiscussionAnalytics - - public init( - interactor: DiscussionInteractorProtocol, - analytics: DiscussionAnalytics, - username: String - ) { - self.interactor = interactor - self.analytics = analytics - 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 - } - - } - } - -// @MainActor -// func logOut() async { -// do { -// try await interactor.logOut() -// router.showLoginScreen() -// analytics.userLogout(force: false) -// } catch let error { -// if error.isInternetError { -// errorMessage = CoreLocalization.Error.slowOrNoInternetConnection -// } else { -// errorMessage = CoreLocalization.Error.unknownError -// } -// } -// } -// -// func trackProfileVideoSettingsClicked() { -// analytics.profileVideoSettingsClicked() -// } -// -// func trackEmailSupportClicked() { -// analytics.emailSupportClicked() -// } -// -// func trackCookiePolicyClicked() { -// analytics.cookiePolicyClicked() -// } -// -// func trackPrivacyPolicyClicked() { -// analytics.privacyPolicyClicked() -// } -// -// func trackProfileEditClicked() { -// analytics.profileEditClicked() -// } -} 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 2d4e548f3..58413b862 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -353,12 +353,11 @@ public class Router: AuthorizationRouter, } public func showUserDetails(username: String) { - let interactor = container.resolve(DiscussionInteractorProtocol.self)! - let analytics = container.resolve(DiscussionAnalytics.self)! - let vm = UserDetailsViewModel(interactor: interactor, - analytics: analytics, + let interactor = container.resolve(ProfileInteractorProtocol.self)! + + let vm = UserProfileViewModel(interactor: interactor, username: username) - let view = UserDetailsView(viewModel: vm) + let view = UserProfileView(viewModel: vm) let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } 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..237ff830a 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,6 +46,14 @@ 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( @@ -154,6 +163,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/Discussion/Discussion/Presentation/UserDetails/UserDetailsView.swift b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift similarity index 83% rename from Discussion/Discussion/Presentation/UserDetails/UserDetailsView.swift rename to Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift index 5351dba25..e18ff867c 100644 --- a/Discussion/Discussion/Presentation/UserDetails/UserDetailsView.swift +++ b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift @@ -1,21 +1,20 @@ // -// UserDetailsView.swift -// Discussion +// UserProfileView.swift +// Profile // // Created by  Stepanok Ivan on 10.10.2023. // import SwiftUI import Core -import Profile import Kingfisher -public struct UserDetailsView: View { +public struct UserProfileView: View { - @StateObject private var viewModel: UserDetailsViewModel + @ObservedObject private var viewModel: UserProfileViewModel - public init(viewModel: UserDetailsViewModel) { - self._viewModel = StateObject(wrappedValue: { viewModel }()) + public init(viewModel: UserProfileViewModel) { + self.viewModel = viewModel } public var body: some View { @@ -30,11 +29,12 @@ public struct UserDetailsView: View { .padding(.top, 200) .padding(.horizontal) } else { - UserAvatar(url: viewModel.userModel?.avatarUrl ?? "") - Text(viewModel.userModel?.name ?? "") - .font(Theme.Fonts.headlineSmall) - .padding(.top, 20) - + 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) @@ -104,17 +104,7 @@ public struct UserDetailsView: View { } } -#Preview { - - let router = DiscussionRouterMock() - let vm = UserDetailsViewModel(interactor: DiscussionInteractor.mock, - analytics: DiscussionAnalyticsMock(), - username: "demo") - - return UserDetailsView(viewModel: vm) -} - -struct UserAvatar: View { +struct ProfileAvatar: View { private var url: URL? @@ -140,3 +130,15 @@ struct UserAvatar: View { } } } + +#if DEBUG +#Preview { + + 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) } From 2bdd6af57f1b34aac456e632ab7383b52abe1df2 Mon Sep 17 00:00:00 2001 From: stepanokdev <100592747+Stepanokdev@users.noreply.github.com> Date: Wed, 11 Oct 2023 15:29:25 +0300 Subject: [PATCH 3/4] return the old preview for tests fixing --- .../Profile/UserProfile/UserProfileView.swift | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift index e18ff867c..da5a7f9dc 100644 --- a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift +++ b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift @@ -132,13 +132,15 @@ struct ProfileAvatar: View { } #if DEBUG -#Preview { - - let vm = UserProfileViewModel( - interactor: ProfileInteractor.mock, - username: "demo" - ) - - return UserProfileView(viewModel: vm) +struct UserProfileView_Previews: PreviewProvider { + static var previews: some View { + + let vm = UserProfileViewModel( + interactor: ProfileInteractor.mock, + username: "demo" + ) + + return UserProfileView(viewModel: vm) + } } #endif From 43d368838031c7cc2ac1c14fe6ffc041a85a91ba Mon Sep 17 00:00:00 2001 From: stepanokdev <100592747+Stepanokdev@users.noreply.github.com> Date: Fri, 13 Oct 2023 12:31:01 +0300 Subject: [PATCH 4/4] Update ProfileRepository.swift --- Profile/Profile/Data/ProfileRepository.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index 237ff830a..bf1a02ec2 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -47,16 +47,14 @@ public class ProfileRepository: ProfileRepositoryProtocol { } public func getUserProfile(username: String) async throws -> UserProfile { - let user = - try await api.requestData( + 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