diff --git a/Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj b/Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj index 32a3eacc..23f283d7 100644 --- a/Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj +++ b/Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj @@ -13,11 +13,16 @@ 23EE06C92AC1DED100CB3FF8 /* GesturePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EE06C82AC1DED100CB3FF8 /* GesturePublisher.swift */; }; 23EE06CB2AC2AF3E00CB3FF8 /* KakaoAddressSearchingResponseDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EE06CA2AC2AF3E00CB3FF8 /* KakaoAddressSearchingResponseDto.swift */; }; 23EE06D12AC2F44E00CB3FF8 /* TmapAddressSearchingResponseDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EE06D02AC2F44E00CB3FF8 /* TmapAddressSearchingResponseDto.swift */; }; + 71288ECF2B26ECDB00D6C921 /* UserInfoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71288ECE2B26ECDB00D6C921 /* UserInfoCell.swift */; }; + 71288ED12B26ECF600D6C921 /* UserProgressCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71288ED02B26ECF600D6C921 /* UserProgressCell.swift */; }; + 71288ED32B26ED2500D6C921 /* UserUploadedLabelCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71288ED22B26ED2500D6C921 /* UserUploadedLabelCell.swift */; }; 712F661D2A7B7BAB00D9539B /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 712F661C2A7B7BAB00D9539B /* Config.swift */; }; 7136BF8A2AF921A900679364 /* CustomBottomSheetVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7136BF892AF921A900679364 /* CustomBottomSheetVC.swift */; }; 713BA40B2B218AF8009091A8 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 713BA40A2B218AF8009091A8 /* GoogleService-Info.plist */; }; 717916DA2B13613B009CEF97 /* MarathonListResponseDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717916D92B13613B009CEF97 /* MarathonListResponseDto.swift */; }; 717916DE2B137DC3009CEF97 /* TotalPageCountDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717916DD2B137DC3009CEF97 /* TotalPageCountDto.swift */; }; + 71BAD06A2B24CECC0061E31D /* UserProfileDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BAD0692B24CECC0061E31D /* UserProfileDto.swift */; }; + 71BAD06C2B24D1F70061E31D /* UserProfileVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BAD06B2B24D1F70061E31D /* UserProfileVC.swift */; }; 71F7804E2B0893B600B53253 /* MarathonTitleCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71F7804D2B0893B600B53253 /* MarathonTitleCollectionViewCell.swift */; }; 71F780502B0893D700B53253 /* MarathonMapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71F7804F2B0893D700B53253 /* MarathonMapCollectionViewCell.swift */; }; 71F7BF072B0CDFE300B752B3 /* MarathonCourseListCVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71F7BF062B0CDFE300B752B3 /* MarathonCourseListCVC.swift */; }; @@ -180,11 +185,16 @@ 23EE06D02AC2F44E00CB3FF8 /* TmapAddressSearchingResponseDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmapAddressSearchingResponseDto.swift; sourceTree = ""; }; 3C3033C911343B5C57EB68E7 /* Pods-Runnect-iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runnect-iOS.debug.xcconfig"; path = "Target Support Files/Pods-Runnect-iOS/Pods-Runnect-iOS.debug.xcconfig"; sourceTree = ""; }; 7110A6032AA337DD009A7E99 /* Runnect-iOSDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Runnect-iOSDebug.entitlements"; sourceTree = ""; }; + 71288ECE2B26ECDB00D6C921 /* UserInfoCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInfoCell.swift; sourceTree = ""; }; + 71288ED02B26ECF600D6C921 /* UserProgressCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgressCell.swift; sourceTree = ""; }; + 71288ED22B26ED2500D6C921 /* UserUploadedLabelCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserUploadedLabelCell.swift; sourceTree = ""; }; 712F661C2A7B7BAB00D9539B /* Config.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; 7136BF892AF921A900679364 /* CustomBottomSheetVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomBottomSheetVC.swift; sourceTree = ""; }; 713BA40A2B218AF8009091A8 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 717916D92B13613B009CEF97 /* MarathonListResponseDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarathonListResponseDto.swift; sourceTree = ""; }; 717916DD2B137DC3009CEF97 /* TotalPageCountDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TotalPageCountDto.swift; sourceTree = ""; }; + 71BAD0692B24CECC0061E31D /* UserProfileDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileDto.swift; sourceTree = ""; }; + 71BAD06B2B24D1F70061E31D /* UserProfileVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileVC.swift; sourceTree = ""; }; 71F7804D2B0893B600B53253 /* MarathonTitleCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarathonTitleCollectionViewCell.swift; sourceTree = ""; }; 71F7804F2B0893D700B53253 /* MarathonMapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarathonMapCollectionViewCell.swift; sourceTree = ""; }; 71F7BF062B0CDFE300B752B3 /* MarathonCourseListCVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarathonCourseListCVC.swift; sourceTree = ""; }; @@ -388,6 +398,25 @@ name = Frameworks; sourceTree = ""; }; + 71288ED42B26EDF300D6C921 /* CollectionViewCell */ = { + isa = PBXGroup; + children = ( + 71288ECE2B26ECDB00D6C921 /* UserInfoCell.swift */, + 71288ED02B26ECF600D6C921 /* UserProgressCell.swift */, + 71288ED22B26ED2500D6C921 /* UserUploadedLabelCell.swift */, + ); + path = CollectionViewCell; + sourceTree = ""; + }; + 71BAD06D2B24D2020061E31D /* UserProfile */ = { + isa = PBXGroup; + children = ( + 71288ED42B26EDF300D6C921 /* CollectionViewCell */, + 71BAD06B2B24D1F70061E31D /* UserProfileVC.swift */, + ); + path = UserProfile; + sourceTree = ""; + }; A3BC2F292962C39F00198261 /* InfoVC */ = { isa = PBXGroup; children = ( @@ -497,6 +526,7 @@ A3F67AE9296E4936001598A2 /* ActivityRecordInfoDto.swift */, A3305A96296EF58C000B1A10 /* GoalRewardInfoDto.swift */, CEFA9A2E29FC263700F2D0CF /* UserDeleteResponseDto.swift */, + 71BAD0692B24CECC0061E31D /* UserProfileDto.swift */, ); path = MyPageDto; sourceTree = ""; @@ -898,6 +928,7 @@ CE17F03B2961C2F700E1DED0 /* MyPage */, CE17F03E2961C38100E1DED0 /* CourseDetail */, CE14676C296568C000DCEA1B /* Running */, + 71BAD06D2B24D2020061E31D /* UserProfile */, ); path = Presentation; sourceTree = ""; @@ -1399,6 +1430,7 @@ CE9291292965E01D0010959C /* RNTimeFormatter.swift in Sources */, CE0C23792966D6AF00B45063 /* ViewPager.swift in Sources */, CE6B63D02967230D003F900F /* PrivateCourseListView.swift in Sources */, + 71288ED32B26ED2500D6C921 /* UserUploadedLabelCell.swift in Sources */, CE6655D7295D86F900C64E12 /* String+.swift in Sources */, CE58759E29601476005D967E /* LoadingIndicator.swift in Sources */, CE5875A2296015A2005D967E /* NetworkLoggerPlugin.swift in Sources */, @@ -1435,6 +1467,7 @@ A3F67AE2296D33AC001598A2 /* MyPageDto.swift in Sources */, CE591EA1296D5EB5000FCBB3 /* PrivateCourseResponseDto.swift in Sources */, A3BC2F3F2964706100198261 /* UploadedCourseInfoCVC.swift in Sources */, + 71288ED12B26ECF600D6C921 /* UserProgressCell.swift in Sources */, DA0587F42A05DEC000B72869 /* CourseEditVC.swift in Sources */, CE6655FE295D912300C64E12 /* calculateTopInset.swift in Sources */, CEEC6B492961C5E200D00E1E /* SplashVC.swift in Sources */, @@ -1473,6 +1506,8 @@ CE6B63D3296725E6003F900F /* CourseListCVC.swift in Sources */, CEF3CD9A296DB305002723A1 /* CourseDetailResponseDto.swift in Sources */, A3E55BA029C815B10000D85D /* SignInSocialLoginVC.swift in Sources */, + 71BAD06A2B24CECC0061E31D /* UserProfileDto.swift in Sources */, + 71288ECF2B26ECDB00D6C921 /* UserInfoCell.swift in Sources */, CE29D584296416D800F47542 /* caculateStatusBarHeight.swift in Sources */, CE66560C295D928300C64E12 /* setRootViewController.swift in Sources */, CE6655D9295D871B00C64E12 /* URL+.swift in Sources */, @@ -1500,6 +1535,7 @@ CE40BB2D296808B00030ABCA /* DepartureSearchingRouter.swift in Sources */, CE15F5A4296C932E0023827C /* RunningModel.swift in Sources */, CE17F02D2961BBA100E1DED0 /* ColorLiterals.swift in Sources */, + 71BAD06C2B24D1F70061E31D /* UserProfileVC.swift in Sources */, CEC2A68E2962AF2C00160BF7 /* RNMarker.swift in Sources */, CE6655D2295D862A00C64E12 /* Publisher+Driver.swift in Sources */, DA0587F22A05D54100B72869 /* EditCourseRequestDto.swift in Sources */, diff --git a/Runnect-iOS/Runnect-iOS/Global/Literal/ColorLiterals.swift b/Runnect-iOS/Runnect-iOS/Global/Literal/ColorLiterals.swift index f98c942d..59b9fc16 100644 --- a/Runnect-iOS/Runnect-iOS/Global/Literal/ColorLiterals.swift +++ b/Runnect-iOS/Runnect-iOS/Global/Literal/ColorLiterals.swift @@ -51,6 +51,10 @@ extension UIColor { static var m5: UIColor { return UIColor(hex: "#D5D4FF") } + + static var m6: UIColor { + return UIColor(hex: "D1C9FF") + } } extension UIColor { diff --git a/Runnect-iOS/Runnect-iOS/Global/Literal/FontLiterals.swift b/Runnect-iOS/Runnect-iOS/Global/Literal/FontLiterals.swift index a39df7d3..15e12936 100644 --- a/Runnect-iOS/Runnect-iOS/Global/Literal/FontLiterals.swift +++ b/Runnect-iOS/Runnect-iOS/Global/Literal/FontLiterals.swift @@ -69,7 +69,7 @@ extension UIFont { } @nonobjc class var b9: UIFont { - return UIFont.font(.pretendardSemiBold, ofSize: 10) + return UIFont.font(.pretendardSemiBold, ofSize: 11) } } diff --git a/Runnect-iOS/Runnect-iOS/Global/Supports/SceneDelegate.swift b/Runnect-iOS/Runnect-iOS/Global/Supports/SceneDelegate.swift index e6c7520c..b0c4de39 100644 --- a/Runnect-iOS/Runnect-iOS/Global/Supports/SceneDelegate.swift +++ b/Runnect-iOS/Runnect-iOS/Global/Supports/SceneDelegate.swift @@ -123,7 +123,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } extension CourseDetailVC { - func getUploadedCourseDetail(courseId: Int?) { guard let publicCourseId = courseId else { return } LoadingIndicator.showLoading() diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/CourseDetailDto/ResponseDto/UploadedCourseDetailResponseDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/CourseDetailDto/ResponseDto/UploadedCourseDetailResponseDto.swift index 0c1de183..684cd60c 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Dto/CourseDetailDto/ResponseDto/UploadedCourseDetailResponseDto.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/CourseDetailDto/ResponseDto/UploadedCourseDetailResponseDto.swift @@ -17,6 +17,7 @@ struct UploadedCourseDetailResponseDto: Codable { // MARK: - UploadUser struct UploadUser: Codable { + let id: Int // userProfile path 추가 let nickname: String let level: Int let image: String diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/CourseDiscoveryDto/ResponseDto/MarathonListResponseDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/CourseDiscoveryDto/ResponseDto/MarathonListResponseDto.swift index f84dc7f0..7f25ef11 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Dto/CourseDiscoveryDto/ResponseDto/MarathonListResponseDto.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/CourseDiscoveryDto/ResponseDto/MarathonListResponseDto.swift @@ -20,12 +20,12 @@ struct marathonCourse: Codable { let title: String let image: String let scrap: Bool? - let departure: Departure + let departure: MarathonDeparture } // MARK: - CourseDiscoveryDeparture -struct Departure: Codable { +struct MarathonDeparture: Codable { let region: String let city: String } diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/CourseDiscoveryDto/ResponseDto/PickedMapListResponseDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/CourseDiscoveryDto/ResponseDto/PickedMapListResponseDto.swift index b1d3ec2d..c242cb62 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Dto/CourseDiscoveryDto/ResponseDto/PickedMapListResponseDto.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/CourseDiscoveryDto/ResponseDto/PickedMapListResponseDto.swift @@ -22,6 +22,7 @@ struct PublicCourse: Codable { let title: String let image: String var scrap: Bool? + var scrapCount: Int? let description: String? let distance: Float? let departure: CourseDiscoveryDeparture diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/MyPageDto/UserProfileDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/MyPageDto/UserProfileDto.swift new file mode 100644 index 00000000..f08cd3ca --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/MyPageDto/UserProfileDto.swift @@ -0,0 +1,37 @@ +// +// UserProfileDto.swift +// Runnect-iOS +// +// Created by 이명진 on 12/10/23. +// + +import Foundation + +// MARK: - UserProfileDto +struct UserProfileDto: Codable { + let user: UserInfo + let courses: [UserCourseInfo] +} + +// MARK: - UserInfo +struct UserInfo: Codable { + let userId: Int + let nickname, latestStamp: String + let level, levelPercent: Int +} + +// MARK: - UserCourseInfo +struct UserCourseInfo: Codable { + let publicCourseId, courseId: Int + let title: String + let image: String + let departure: Departure + let scrapTF: Bool +} + +// MARK: - Departure +struct Departure: Codable { + let region, city, town: String + let detail: String? + let name: String? +} diff --git a/Runnect-iOS/Runnect-iOS/Network/Router/UserRouter.swift b/Runnect-iOS/Runnect-iOS/Network/Router/UserRouter.swift index cd391b6c..52882072 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Router/UserRouter.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Router/UserRouter.swift @@ -11,6 +11,7 @@ import Moya enum UserRouter { case getMyPageInfo + case getUserProfileInfo(userId: Int) case updateUserNickname(nickname: String) case deleteUser(appleToken: String?) } @@ -28,12 +29,14 @@ extension UserRouter: TargetType { switch self { case .getMyPageInfo, .updateUserNickname, .deleteUser: return "/user" + case .getUserProfileInfo(let userId): + return "/user/\(userId)" } } var method: Moya.Method { switch self { - case .getMyPageInfo: + case .getMyPageInfo, .getUserProfileInfo: return .get case .updateUserNickname: return .patch @@ -44,7 +47,7 @@ extension UserRouter: TargetType { var task: Moya.Task { switch self { - case .getMyPageInfo, .deleteUser: + case .getMyPageInfo, .getUserProfileInfo, .deleteUser: return .requestPlain case .updateUserNickname(let nickname): return .requestParameters(parameters: ["nickname": nickname], encoding: JSONEncoding.default) diff --git a/Runnect-iOS/Runnect-iOS/Presentation/CourseDetail/VC/CourseDetailVC.swift b/Runnect-iOS/Runnect-iOS/Presentation/CourseDetail/VC/CourseDetailVC.swift index 28548e56..156e75bc 100644 --- a/Runnect-iOS/Runnect-iOS/Presentation/CourseDetail/VC/CourseDetailVC.swift +++ b/Runnect-iOS/Runnect-iOS/Presentation/CourseDetail/VC/CourseDetailVC.swift @@ -37,8 +37,11 @@ final class CourseDetailVC: UIViewController { private var courseId: Int? private var publicCourseId: Int? + private var userId: Int? private var isMyCourse: Bool? + private var scrapCount: Int = 0 + // MARK: - UI Components private lazy var navibar = CustomNavigationBar(self, type: .titleWithLeftButton) @@ -82,6 +85,7 @@ final class CourseDetailVC: UIViewController { $0.text = "닉네임" $0.textColor = .g1 $0.font = .h5 + $0.isUserInteractionEnabled = true } private let runningLevelLabel = UILabel().then { @@ -90,6 +94,13 @@ final class CourseDetailVC: UIViewController { $0.font = .b5 } + private let scrapCountLabel = UILabel().then { + $0.text = "0" + $0.font = .b9 + $0.textColor = UIColor(hex: "#9E9E9E", alpha: 1.0) + $0.textAlignment = .center + } + private let courseTitleLabel = UILabel().then { $0.text = "제목" $0.textColor = .g1 @@ -126,6 +137,7 @@ final class CourseDetailVC: UIViewController { setUI() setLayout() setAddTarget() + setupRefreshControl() self.hideTabBar(wantsToHide: true) } @@ -148,10 +160,15 @@ extension CourseDetailVC { scrapCourse(scrapTF: !sender.isSelected) delegate?.didUpdateScrapState(publicCourseId: publicCourseId, isScrapped: !sender.isSelected) - print("CourseDetailVC 스크랩 탭🔥publicCourseId=\(publicCourseId), isScrapped은 \(!sender.isSelected)요렇게 변경 ") + + /// 누른상태(true)에서 누르면 스크랩 취소(false) 하는 이벤트, 즉 -1 + let toggle = sender.isSelected ? -1 : 1 + self.scrapCount += toggle + self.scrapCountLabel.text = "\(self.scrapCount)" + /// print("CourseDetailVC 스크랩 탭🔥publicCourseId=\(publicCourseId), isScrapped은 \(!sender.isSelected) 요렇게 변경 ") } - @objc func shareButtonTapped() { + @objc private func shareButtonTapped() { guard let model = self.uploadedCourseDetailModel else { return } @@ -204,12 +221,25 @@ extension CourseDetailVC { } } - @objc func startButtonDidTap() { + @objc private func pushToUserProfileVC() { + guard UserManager.shared.userType != .visitor else { + // 방문자일 경우 토스트 메세지만 + self.showToast(message: "회원만 조회 가능 합니다.") + return + } + guard let userId = self.userId else {return} + let userProfile = UserProfileVC() + userProfile.setUserId(userId: userId) + self.navigationController?.pushViewController(userProfile, animated: true) + } + + @objc private func startButtonDidTap() { guard handleVisitor() else { return } guard let courseId = self.courseId else { return } getCourseDetailWithPath(courseId: courseId) } - @objc func moreButtonDidTap() { + + @objc private func moreButtonDidTap() { guard let isMyCourse = self.isMyCourse, let uploadedCourseDetailModel = self.uploadedCourseDetailModel else { return } let items = isMyCourse ? ["수정하기", "삭제하기"] : ["신고하기"] @@ -241,25 +271,8 @@ extension CourseDetailVC { menu.show() } - private func pushToCountDownVC() { - guard let courseModel = self.courseModel, - let path = courseModel.path, - let distance = courseModel.distance - else { return } - - let countDownVC = CountDownVC() - let locations = path.map { NMGLatLng(lat: $0[0], lng: $0[1]) } - - let runningModel = RunningModel(courseId: self.courseId, - publicCourseId: self.publicCourseId, - locations: locations, - distance: String(distance), - imageUrl: courseModel.image, - region: courseModel.departure.region, - city: courseModel.departure.city) - - countDownVC.setData(runningModel: runningModel) - self.navigationController?.pushViewController(countDownVC, animated: true) + @objc private func didBeginRefresh() { + refresh() } } @@ -277,6 +290,7 @@ extension CourseDetailVC { func setData(model: UploadedCourseDetailResponseDto) { self.uploadedCourseDetailModel = model + self.userId = model.user.id self.mapImageView.setImage(with: model.publicCourse.image) self.profileImageView.image = GoalRewardInfoModel.stampNameImageDictionary[model.user.image] // 탈퇴 유저 처리 @@ -286,6 +300,9 @@ extension CourseDetailVC { self.isMyCourse = model.user.isNowUser guard let scrap = model.publicCourse.scrap else { return } self.likeButton.isSelected = scrap + guard let scrapCount = model.publicCourse.scrapCount else {return} + self.scrapCount = scrapCount + self.scrapCountLabel.text = "\(self.scrapCount)" guard let distance = model.publicCourse.distance else { return } self.courseDistanceInfoView.setDescriptionText(description: "\(distance)km") @@ -298,6 +315,30 @@ extension CourseDetailVC { likeButton.addTarget(self, action: #selector(likeButtonDidTap), for: .touchUpInside) moreButton.addTarget(self, action: #selector(moreButtonDidTap), for: .touchUpInside) shareButton.addTarget(self, action: #selector(shareButtonTapped), for: .touchUpInside) + + let profileTouch = UITapGestureRecognizer(target: self, action: #selector(pushToUserProfileVC)) + profileNameLabel.addGestureRecognizer(profileTouch) + } + + private func pushToCountDownVC() { + guard let courseModel = self.courseModel, + let path = courseModel.path, + let distance = courseModel.distance + else { return } + + let countDownVC = CountDownVC() + let locations = path.map { NMGLatLng(lat: $0[0], lng: $0[1]) } + + let runningModel = RunningModel(courseId: self.courseId, + publicCourseId: self.publicCourseId, + locations: locations, + distance: String(distance), + imageUrl: courseModel.image, + region: courseModel.departure.region, + city: courseModel.departure.city) + + countDownVC.setData(runningModel: runningModel) + self.navigationController?.pushViewController(countDownVC, animated: true) } private func setNullUser() { @@ -307,6 +348,19 @@ extension CourseDetailVC { self.runningLevelLabel.isHidden = true } + private func setupRefreshControl() { + let refreshControl = UIRefreshControl() + refreshControl.addTarget( + self, + action: #selector(didBeginRefresh), + for: .valueChanged + ) + middleScorollView.refreshControl = refreshControl + } + + private func refresh() { + self.getUploadedCourseDetail() + } } // MARK: - Layout Helpers @@ -354,15 +408,21 @@ extension CourseDetailVC { make.height.equalTo(0.5) } - bottomView.addSubviews(likeButton, startButton) + bottomView.addSubviews(likeButton, startButton, scrapCountLabel) likeButton.snp.makeConstraints { make in - make.top.equalToSuperview().offset(18) + make.top.equalToSuperview().offset(13) make.leading.equalToSuperview().offset(26) make.width.equalTo(24) make.height.equalTo(22) } + scrapCountLabel.snp.makeConstraints { make in + make.top.equalTo(likeButton.snp.bottom).offset(2) + make.leading.equalToSuperview().offset(26) + make.width.equalTo(20) + make.height.equalTo(13) + } startButton.snp.makeConstraints { make in make.leading.equalTo(likeButton.snp.trailing).offset(20) make.top.equalToSuperview().offset(10) @@ -463,6 +523,7 @@ extension CourseDetailVC { print(error.localizedDescription) self.showNetworkFailureToast() } + self.middleScorollView.refreshControl?.endRefreshing() } } diff --git a/Runnect-iOS/Runnect-iOS/Presentation/UserProfile/CollectionViewCell/UserInfoCell.swift b/Runnect-iOS/Runnect-iOS/Presentation/UserProfile/CollectionViewCell/UserInfoCell.swift new file mode 100644 index 00000000..ebc265be --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Presentation/UserProfile/CollectionViewCell/UserInfoCell.swift @@ -0,0 +1,66 @@ +// +// UserInfoCell.swift +// Runnect-iOS +// +// Created by 이명진 on 12/11/23. +// + +import UIKit + +class UserInfoCell: UICollectionViewCell { + + // MARK: - properties + + private let stampNameImageDictionary: [String: UIImage] = GoalRewardInfoModel.stampNameImageDictionary + + // MARK: - UI Components + + private let myProfileInfoView = UIView() + private var myProfileImage = UIImageView() + private var myProfileStamp: String? + private var myProfileNameLabel = UILabel().then { + $0.textColor = .m1 + $0.font = .h4 + $0.text = "ㅎㅇ" + } + + // MARK: - Life cycle + + override init(frame: CGRect) { + super.init(frame: frame) + setUI() + setLayout() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Layout Helpers + +extension UserInfoCell { + + private func setUI() { + contentView.addSubview(myProfileInfoView) + myProfileInfoView.backgroundColor = .clear + } + private func setLayout() { + myProfileInfoView.addSubviews(myProfileImage, myProfileNameLabel) + + myProfileImage.snp.makeConstraints { + $0.top.equalToSuperview().offset(11) + $0.leading.equalToSuperview().offset(23) + $0.width.height.equalTo(63) + } + + myProfileNameLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(32) + $0.leading.equalTo(myProfileImage.snp.trailing).offset(10) + } + } + func setData(model: UserProfileDto) { + guard let profileImage = stampNameImageDictionary[model.user.latestStamp] else { return } + self.myProfileImage.image = profileImage + self.myProfileNameLabel.text = model.user.nickname + } +} diff --git a/Runnect-iOS/Runnect-iOS/Presentation/UserProfile/CollectionViewCell/UserProgressCell.swift b/Runnect-iOS/Runnect-iOS/Presentation/UserProfile/CollectionViewCell/UserProgressCell.swift new file mode 100644 index 00000000..8a5e8b30 --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Presentation/UserProfile/CollectionViewCell/UserProgressCell.swift @@ -0,0 +1,95 @@ +// +// UserProgressCell.swift +// Runnect-iOS +// +// Created by 이명진 on 12/11/23. +// + +import UIKit + +class UserProgressCell: UICollectionViewCell { + + // MARK: - properties + + // MARK: - UI Components + private let runningProgressInfoView = UIView() + private let userRunningLevelLabel = UILabel().then { + $0.textColor = .m1 + $0.font = .h4 + } + private lazy var myRunningProgressBar = UIProgressView(progressViewStyle: .bar).then { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.setProgress(0, animated: false) + $0.progressTintColor = .m1 + $0.trackTintColor = .m6 + $0.layer.cornerRadius = 6 + $0.clipsToBounds = true + $0.layer.sublayers![1].cornerRadius = 6 + $0.subviews[1].clipsToBounds = true + } + private let myRunnigProgressPercentLabel = UILabel() + + // MARK: - Life cycle + + override init(frame: CGRect) { + super.init(frame: frame) + setUI() + setLayout() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Layout Helpers + +extension UserProgressCell { + + private func setUI() { + contentView.addSubview(runningProgressInfoView) + runningProgressInfoView.backgroundColor = .m3 + + runningProgressInfoView.snp.makeConstraints { + $0.top.leading.trailing.bottom.equalToSuperview() + $0.height.equalTo(100) + } + } + private func setLayout() { + runningProgressInfoView.addSubviews(userRunningLevelLabel, myRunningProgressBar, myRunnigProgressPercentLabel) + + userRunningLevelLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(20) + $0.leading.equalToSuperview().offset(36) + } + + myRunningProgressBar.snp.makeConstraints { + $0.top.equalTo(userRunningLevelLabel.snp.bottom).offset(6) + $0.leading.equalToSuperview().offset(36.53) + $0.trailing.equalToSuperview().inset(31.6) + $0.height.equalTo(11) + } + + myRunnigProgressPercentLabel.snp.makeConstraints { + $0.bottom.equalToSuperview().inset(20) + $0.trailing.equalToSuperview().inset(31.6) + } + } + + func setData(model: UserProfileDto) { + setMyRunningLevelLabel(model: model) + myRunningProgressBar.setProgress(Float(model.user.levelPercent) / 100, animated: false) + setMyRunningProgressPercentLabel(model: model) + } + + private func setMyRunningLevelLabel(model: UserProfileDto) { + let attributedString = NSMutableAttributedString(string: "LV ", attributes: [.font: UIFont.h5, .foregroundColor: UIColor.g1]) + attributedString.append(NSAttributedString(string: String(model.user.level), attributes: [.font: UIFont.h5, .foregroundColor: UIColor.g1])) + userRunningLevelLabel.attributedText = attributedString + } + + private func setMyRunningProgressPercentLabel(model: UserProfileDto) { + let attributedString = NSMutableAttributedString(string: String(model.user.levelPercent), attributes: [.font: UIFont.b5, .foregroundColor: UIColor.g1]) + attributedString.append(NSAttributedString(string: " /100", attributes: [.font: UIFont.b5, .foregroundColor: UIColor.g2])) + myRunnigProgressPercentLabel.attributedText = attributedString + } +} diff --git a/Runnect-iOS/Runnect-iOS/Presentation/UserProfile/CollectionViewCell/UserUploadedLabelCell.swift b/Runnect-iOS/Runnect-iOS/Presentation/UserProfile/CollectionViewCell/UserUploadedLabelCell.swift new file mode 100644 index 00000000..9e62bb13 --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Presentation/UserProfile/CollectionViewCell/UserUploadedLabelCell.swift @@ -0,0 +1,69 @@ +// +// UserUploadedLabelCell.swift +// Runnect-iOS +// +// Created by 이명진 on 12/11/23. +// + +import UIKit + +class UserUploadedLabelCell: UICollectionViewCell { + + // MARK: - UI Components + private lazy var uploadedCourseInfoLabelView = makeInfoView(title: "업로드한 코스") + + // MARK: - Life cycle + + override init(frame: CGRect) { + super.init(frame: frame) + setUI() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + + +// MARK: - Layout Helpers + +extension UserUploadedLabelCell { + + private func setUI() { + contentView.addSubview(uploadedCourseInfoLabelView) + uploadedCourseInfoLabelView.backgroundColor = .clear + + uploadedCourseInfoLabelView.snp.makeConstraints { + $0.top.leading.trailing.bottom.equalToSuperview() + $0.height.equalTo(62) + } + } + + private func makeInfoView(title: String) -> UIView { + let containerView = UIView() + let icStar = UIImageView().then { + $0.image = ImageLiterals.icStar + } + + let label = UILabel().then { + $0.text = title + $0.textColor = .g1 + $0.font = .b2 + } + + containerView.addSubviews(icStar, label) + + icStar.snp.makeConstraints { + $0.top.equalToSuperview().offset(22) + $0.leading.equalToSuperview().offset(17) + $0.width.height.equalTo(14) + } + + label.snp.makeConstraints { + $0.top.equalToSuperview().offset(21) + $0.leading.equalTo(icStar.snp.trailing).offset(8) + } + + return containerView + } +} + diff --git a/Runnect-iOS/Runnect-iOS/Presentation/UserProfile/UserProfileVC.swift b/Runnect-iOS/Runnect-iOS/Presentation/UserProfile/UserProfileVC.swift new file mode 100644 index 00000000..6b25dc48 --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Presentation/UserProfile/UserProfileVC.swift @@ -0,0 +1,330 @@ +// +// UserProfileVC.swift +// Runnect-iOS +// +// Created by 이명진 on 12/10/23. +// + +import UIKit + +import SnapKit +import Then +import Moya + +final class UserProfileVC: UIViewController { + + // MARK: - Properties + + private let userProvider = Providers.userProvider + private let scrapProvider = Providers.scrapProvider + + private var userProfileModel: UserProfileDto? + private var uploadedCourseList = [UserCourseInfo]() + private var userId: Int? + + // MARK: - UI Components + + private lazy var navibar = CustomNavigationBar(self, type: .titleWithLeftButton).setTitle("프로필") + + private lazy var userProfileCollectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .vertical + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .clear + collectionView.isScrollEnabled = true + collectionView.showsVerticalScrollIndicator = false + return collectionView + }() + + // MARK: - View Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + setUI() + register() + setNavigationBar() + setDelegate() + setLayout() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + getMyPageInfo() + } +} + +// MARK: - Methods +extension UserProfileVC { + func setUserId(userId: Int) { + self.userId = userId + } + + private func setData(model: UserProfileDto) { + self.userProfileModel = model + self.uploadedCourseList.removeAll() // 유저 프로필 데이터 페이지네이션으로 불러오면 변경해야합니다. + self.uploadedCourseList = model.courses + self.userProfileCollectionView.reloadData() + } + + private func setDelegate() { + userProfileCollectionView.delegate = self + userProfileCollectionView.dataSource = self + } + + private func register() { + let cellTypes: [UICollectionViewCell.Type] = [UserInfoCell.self, + UserProgressCell.self, + UserUploadedLabelCell.self, + CourseListCVC.self] + cellTypes.forEach { cellType in + userProfileCollectionView.register(cellType, forCellWithReuseIdentifier: cellType.className) + } + } +} + +// MARK: - Constants + +extension UserProfileVC { + private enum Section { + static let userInfo = 0 // 유저 이름 + static let userProgress = 1 // 유저 레벨 진행 상황 + static let uploadedCourselabel = 2 // 업로드한 코스 Label + static let userCourses = 3 // 유저가 업로드한 코스들 + } + + private struct Constants { + static let cellSpacing: CGFloat = 20 + static let cellPadding: CGFloat = 10 + static let sectionInsets = UIEdgeInsets(top: 0, left: 16, bottom: 20, right: 16) + } +} + +// MARK: - UICollectionViewDelegate, UICollectionViewDataSource +extension UserProfileVC: UICollectionViewDelegate, UICollectionViewDataSource { + func numberOfSections(in collectionView: UICollectionView) -> Int { + return 4 + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + switch section { + case Section.userInfo, Section.userProgress, Section.uploadedCourselabel: + return 1 + case Section.userCourses: + return uploadedCourseList.count + default: + return 0 + } + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + switch indexPath.section { + case Section.userInfo: + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: UserInfoCell.className, for: indexPath) as? UserInfoCell else { return UICollectionViewCell() } + if let userProfileModel = userProfileModel { + cell.setData(model: userProfileModel) + } + return cell + case Section.userProgress: + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: UserProgressCell.className, for: indexPath) as? UserProgressCell else { return UICollectionViewCell() } + if let userProfileModel = userProfileModel { + cell.setData(model: userProfileModel) + } + return cell + case Section.uploadedCourselabel: + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: UserUploadedLabelCell.className, for: indexPath) as? UserUploadedLabelCell else { return UICollectionViewCell() } + return cell + case Section.userCourses: + return courseListCell(collectionView: collectionView, indexPath: indexPath) + default: + return UICollectionViewCell() + } + } + + private func courseListCell(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CourseListCVC.className, for: indexPath) as? CourseListCVC else { return UICollectionViewCell() } + cell.setCellType(type: .all) + cell.delegate = self + let model = self.uploadedCourseList[indexPath.item] + let location = "\(model.departure.region) \(model.departure.city)" + cell.setData(imageURL: model.image, title: model.title, location: location, didLike: model.scrapTF, indexPath: indexPath.item) + return cell + } +} + +// MARK: - UICollectionViewDelegateFlowLayout +extension UserProfileVC: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let screenWidth = UIScreen.main.bounds.width + switch indexPath.section { + case Section.userInfo: + return CGSize(width: screenWidth, height: 93) + case Section.userProgress: + return CGSize(width: screenWidth, height: 101) + case Section.uploadedCourselabel: + return CGSize(width: screenWidth, height: 62) + case Section.userCourses: + let cellWidth = (screenWidth - 2 * Constants.sectionInsets.left - Constants.cellSpacing) / 2 + let cellHeight = CourseListCVCType.getCellHeight(type: .all, cellWidth: cellWidth) + return CGSize(width: cellWidth, height: cellHeight) + default: + return CGSize(width: 0, height: 0) + } + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { + return section == Section.userCourses ? Constants.cellSpacing : 0 + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + return section == Section.userCourses ? Constants.cellPadding : 0 + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { + return section == Section.userCourses ? Constants.sectionInsets : .zero + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard indexPath.section == Section.userCourses else { return } + pushToCourseDetail(at: indexPath) + } + + private func pushToCourseDetail(at indexPath: IndexPath) { + let courseDetailVC = CourseDetailVC() + let courseModel = uploadedCourseList[indexPath.item] + courseDetailVC.setCourseId(courseId: courseModel.courseId, publicCourseId: courseModel.publicCourseId) + courseDetailVC.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(courseDetailVC, animated: true) + } +} + +// MARK: - CourseListCVCDeleagte +extension UserProfileVC: CourseListCVCDeleagte { + func likeButtonTapped(wantsTolike: Bool, index: Int) { + guard UserManager.shared.userType != .visitor else { + showToastOnWindow(text: "러넥트에 가입하면 코스를 스크랩할 수 있어요") + return + } + + let publicCourseId = uploadedCourseList[index].publicCourseId + scrapCourse(publicCourseId: publicCourseId, scrapTF: wantsTolike) + } +} + +// MARK: - UI & Layout +extension UserProfileVC { + + private func setUI() { + view.backgroundColor = .w1 + } + + private func setNavigationBar() { + view.addSubview(navibar) + + navibar.snp.makeConstraints { + $0.leading.top.trailing.equalTo(view.safeAreaLayoutGuide) + $0.height.equalTo(56) + } + } + + private func setLayout() { + view.addSubview(userProfileCollectionView) + + userProfileCollectionView.snp.makeConstraints { + $0.top.equalTo(navibar.snp.bottom) + $0.leading.bottom.trailing.equalTo(view.safeAreaLayoutGuide) + } + } +} + +// MARK: - Network +extension UserProfileVC { + private func getMyPageInfo() { + guard let userId = self.userId else { return } + LoadingIndicator.showLoading() + userProvider.request(.getUserProfileInfo(userId: userId)) { [weak self] response in + LoadingIndicator.hideLoading() + guard let self = self else { return } + + switch response { + case .success(let result): + let status = result.statusCode + if 200..<300 ~= status { + do { + let responseDto = try result.map(BaseResponse.self) + guard let data = responseDto.data else { return } + self.setData(model: data) + self.userProfileCollectionView.reloadData() + } catch { + print(error.localizedDescription) + } + } + if status >= 400 { + print("400 error") + self.showNetworkFailureToast() + } + case .failure(let error): + print(error.localizedDescription) + self.showToast(message: "탈퇴한 유저 입니다.", duration: 1.2) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self.navigationController?.popViewController(animated: true) + } + } + } + } + + private func scrapCourse(publicCourseId: Int, scrapTF: Bool) { + LoadingIndicator.showLoading() + scrapProvider.request(.createAndDeleteScrap(publicCourseId: publicCourseId, scrapTF: scrapTF)) { [weak self] response in + LoadingIndicator.hideLoading() + guard let self = self else { return } + switch response { + case .success(let result): + let status = result.statusCode + if 200..<300 ~= status { + print("스크랩 성공") + } + if status >= 400 { + print("400 error") + self.showNetworkFailureToast() + } + case .failure(let error): + print(error.localizedDescription) + self.showNetworkFailureToast() + } + } + } +} + +extension UserProfileVC { + private func showToast(message: String, duration: TimeInterval = 1.0) { + let toastLabel = UILabel().then { + $0.backgroundColor = .g1.withAlphaComponent(0.6) + $0.textColor = UIColor.white + $0.textAlignment = .center + $0.font = UIFont.systemFont(ofSize: 12) + $0.text = message + $0.alpha = 0.0 + $0.layer.cornerRadius = 10 + $0.clipsToBounds = true + } + + view.addSubview(toastLabel) + + toastLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalToSuperview().offset(-100) + make.width.equalTo(300) + make.height.equalTo(35) + } + + UIView.animate(withDuration: 0.5, delay: 0.0, options: .curveEaseOut, animations: { + toastLabel.alpha = 1.0 + }, completion: { _ in + UIView.animate(withDuration: 0.5, delay: duration, options: .curveEaseIn, animations: { + toastLabel.alpha = 0.0 + }, completion: { _ in + toastLabel.removeFromSuperview() + }) + }) + } +}