diff --git a/Wable-iOS.xcodeproj/project.pbxproj b/Wable-iOS.xcodeproj/project.pbxproj index 0af4916..c11775c 100644 --- a/Wable-iOS.xcodeproj/project.pbxproj +++ b/Wable-iOS.xcodeproj/project.pbxproj @@ -93,6 +93,7 @@ 0573B8C42CEC63EC00B5A434 /* FlattenReplyModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0573B8C32CEC63EC00B5A434 /* FlattenReplyModel.swift */; }; 0586D89B2D09A68200436080 /* Pretendard-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = 0586D8992D09A68200436080 /* Pretendard-Regular.otf */; }; 0586D89C2D09A68200436080 /* Pretendard-SemiBold.otf in Resources */ = {isa = PBXBuildFile; fileRef = 0586D89A2D09A68200436080 /* Pretendard-SemiBold.otf */; }; + 0589AECF2D38311A004F531E /* PopupType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0589AECE2D383119004F531E /* PopupType.swift */; }; 0593F6D62C96D6C100FFAD82 /* WablePhotoDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0593F6D52C96D6C100FFAD82 /* WablePhotoDetailView.swift */; }; 0593F6DB2C96E75600FFAD82 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0593F6DA2C96E75600FFAD82 /* FirebaseAnalytics */; }; 0593F6DD2C96E75600FFAD82 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 0593F6DC2C96E75600FFAD82 /* FirebaseMessaging */; }; @@ -103,6 +104,12 @@ 05B4F47D2CF8BE360033FF67 /* Array+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05B4F47C2CF8BE360033FF67 /* Array+.swift */; }; 05B4F47F2CF8C2450033FF67 /* BanRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05B4F47E2CF8C2450033FF67 /* BanRequestDTO.swift */; }; 05F1FF422D11C95F00982033 /* BanTargetInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F1FF412D11C95F00982033 /* BanTargetInfo.swift */; }; + 05F1FF442D17EA1D00982033 /* MigratedHomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F1FF432D17EA1D00982033 /* MigratedHomeViewController.swift */; }; + 05F1FF462D1AAAE300982033 /* MigratedHomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F1FF452D1AAAE300982033 /* MigratedHomeViewModel.swift */; }; + 05F1FF482D1AB39000982033 /* MigratedHomeFeedCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F1FF472D1AB39000982033 /* MigratedHomeFeedCell.swift */; }; + 05F1FF4A2D1AB42A00982033 /* MigratedHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F1FF492D1AB42A00982033 /* MigratedHomeView.swift */; }; + 05F1FF4C2D32685A00982033 /* PopupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F1FF4B2D32685A00982033 /* PopupViewModel.swift */; }; + 05F1FF502D33DF9600982033 /* HomePopupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F1FF4F2D33DF9600982033 /* HomePopupViewController.swift */; }; 05FBEED22C886A0200E4BF17 /* HomeFeedContentDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05FBEED12C886A0200E4BF17 /* HomeFeedContentDTO.swift */; }; 3C3531822C6F15D30015A8FA /* KeychainWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3531812C6F15D30015A8FA /* KeychainWrapper.swift */; }; 3C3531842C6F16D00015A8FA /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3531832C6F16D00015A8FA /* Config.swift */; }; @@ -309,6 +316,7 @@ 0573B8C32CEC63EC00B5A434 /* FlattenReplyModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlattenReplyModel.swift; sourceTree = ""; }; 0586D8992D09A68200436080 /* Pretendard-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Pretendard-Regular.otf"; sourceTree = ""; }; 0586D89A2D09A68200436080 /* Pretendard-SemiBold.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Pretendard-SemiBold.otf"; sourceTree = ""; }; + 0589AECE2D383119004F531E /* PopupType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupType.swift; sourceTree = ""; }; 0593F6D52C96D6C100FFAD82 /* WablePhotoDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WablePhotoDetailView.swift; sourceTree = ""; }; 0593F6E32C9AFC1B00FFAD82 /* WablePushAlarmHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WablePushAlarmHelper.swift; sourceTree = ""; }; 05AD1EB42CE4C1D900F36D6B /* Dev.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Dev.xcconfig; sourceTree = ""; }; @@ -317,6 +325,12 @@ 05B4F47C2CF8BE360033FF67 /* Array+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+.swift"; sourceTree = ""; }; 05B4F47E2CF8C2450033FF67 /* BanRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanRequestDTO.swift; sourceTree = ""; }; 05F1FF412D11C95F00982033 /* BanTargetInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanTargetInfo.swift; sourceTree = ""; }; + 05F1FF432D17EA1D00982033 /* MigratedHomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedHomeViewController.swift; sourceTree = ""; }; + 05F1FF452D1AAAE300982033 /* MigratedHomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedHomeViewModel.swift; sourceTree = ""; }; + 05F1FF472D1AB39000982033 /* MigratedHomeFeedCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedHomeFeedCell.swift; sourceTree = ""; }; + 05F1FF492D1AB42A00982033 /* MigratedHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedHomeView.swift; sourceTree = ""; }; + 05F1FF4B2D32685A00982033 /* PopupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupViewModel.swift; sourceTree = ""; }; + 05F1FF4F2D33DF9600982033 /* HomePopupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePopupViewController.swift; sourceTree = ""; }; 05FBEED12C886A0200E4BF17 /* HomeFeedContentDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeFeedContentDTO.swift; sourceTree = ""; }; 3C3531812C6F15D30015A8FA /* KeychainWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainWrapper.swift; sourceTree = ""; }; 3C3531832C6F16D00015A8FA /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; @@ -489,6 +503,7 @@ 050E0CB42C74B85800326EEA /* HomeView.swift */, 050E0CB62C74B87400326EEA /* FeedDetailView.swift */, 3C8B4E522C83FB7C00174943 /* HomeBottomSheetView.swift */, + 05F1FF492D1AB42A00982033 /* MigratedHomeView.swift */, ); path = Views; sourceTree = ""; @@ -499,6 +514,8 @@ 050E0CC82C74B92A00326EEA /* HomeViewModel.swift */, 3C8B4E472C80E1C900174943 /* FeedDetailViewModel.swift */, 3C8B4E4E2C83F01500174943 /* LikeViewModel.swift */, + 05F1FF452D1AAAE300982033 /* MigratedHomeViewModel.swift */, + 05F1FF4B2D32685A00982033 /* PopupViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -507,6 +524,7 @@ isa = PBXGroup; children = ( 050E0CCC2C74B95B00326EEA /* Team.swift */, + 0589AECE2D383119004F531E /* PopupType.swift */, ); path = Supports; sourceTree = ""; @@ -516,6 +534,7 @@ children = ( 050E0CC42C74B8F000326EEA /* HomeFeedTableViewCell.swift */, 050E0CC62C74B8FD00326EEA /* FeedDetailTableViewCell.swift */, + 05F1FF472D1AB39000982033 /* MigratedHomeFeedCell.swift */, ); path = Cells; sourceTree = ""; @@ -862,6 +881,8 @@ children = ( 0547F4F42C64C76A001E3039 /* HomeViewController.swift */, 050E0CCA2C74B94800326EEA /* FeedDetailViewController.swift */, + 05F1FF432D17EA1D00982033 /* MigratedHomeViewController.swift */, + 05F1FF4F2D33DF9600982033 /* HomePopupViewController.swift */, ); path = ViewController; sourceTree = ""; @@ -1660,6 +1681,7 @@ 3C8B4E182C78E21900174943 /* SocialLoginResponseDTO.swift in Sources */, 0547F49A2C60D968001E3039 /* AppDelegate.swift in Sources */, 050E0CC32C74B8B600326EEA /* FeedBottomView.swift in Sources */, + 0589AECF2D38311A004F531E /* PopupType.swift in Sources */, 0547F4DF2C62486C001E3039 /* APIConstants.swift in Sources */, 050E0CD92C74BFC200326EEA /* InfoLogoView.swift in Sources */, DE54C3E42CF4EB6B00753129 /* NoticeCell.swift in Sources */, @@ -1691,6 +1713,7 @@ 3CF344DE2C74AB3B0038BB53 /* MyPageAccountInfoViewModel.swift in Sources */, 050E0CF12C750F6300326EEA /* NotificationContentView.swift in Sources */, 3C8B4E1B2C78E55200174943 /* TokenManager.swift in Sources */, + 05F1FF4C2D32685A00982033 /* PopupViewModel.swift in Sources */, 3CDE2E1D2C723325004A84CB /* NotificationSegmentedControl.swift in Sources */, 050E0CB52C74B85800326EEA /* HomeView.swift in Sources */, DE9D0E342CF087B30024BB1F /* LCKGameTypeDTO.swift in Sources */, @@ -1702,6 +1725,7 @@ 050E0D162C8252B200326EEA /* NotificationAPI.swift in Sources */, 050E0CDF2C74C8B400326EEA /* MatchSessionTableViewCell.swift in Sources */, 3C8B4E3B2C79119700174943 /* MyPageMemberContentResponseDTO.swift in Sources */, + 05F1FF442D17EA1D00982033 /* MigratedHomeViewController.swift in Sources */, 3C8B4E352C79117C00174943 /* MyPageMemberDeleteDTO.swift in Sources */, 05FBEED22C886A0200E4BF17 /* HomeFeedContentDTO.swift in Sources */, 0547F4C02C62166C001E3039 /* Adjusted+.swift in Sources */, @@ -1744,6 +1768,7 @@ 0547F4FC2C64C797001E3039 /* MyPageViewController.swift in Sources */, 0547F4FA2C64C78C001E3039 /* NotificationViewController.swift in Sources */, 3CF344E72C750DBD0038BB53 /* MyPageSignOutReasonViewModel.swift in Sources */, + 05F1FF502D33DF9600982033 /* HomePopupViewController.swift in Sources */, 3C8B4E1D2C78E55C00174943 /* TokenReissueResponseDTO.swift in Sources */, 3C8B4E372C79118500174943 /* MyPageAccountInfoResponseDTO.swift in Sources */, 050E0CCD2C74B95B00326EEA /* Team.swift in Sources */, @@ -1773,10 +1798,12 @@ 050E0CEA2C74DC4B00326EEA /* MatchProgress.swift in Sources */, 3C8B4E262C78E61B00174943 /* HttpMethod.swift in Sources */, DE8001B32CF323B100D9DAD9 /* InfoNewsViewController.swift in Sources */, + 05F1FF4A2D1AB42A00982033 /* MigratedHomeView.swift in Sources */, 05B4F47D2CF8BE360033FF67 /* Array+.swift in Sources */, 05B4F47F2CF8C2450033FF67 /* BanRequestDTO.swift in Sources */, DE8001B62CF3250800D9DAD9 /* NewsCell.swift in Sources */, DE8001A82CF31ECE00D9DAD9 /* InfoNewsViewModel.swift in Sources */, + 05F1FF462D1AAAE300982033 /* MigratedHomeViewModel.swift in Sources */, 050E0CD12C74B9A700326EEA /* WriteViewController.swift in Sources */, 050E0CAA2C74B7A900326EEA /* FeedDetailReplyDTO.swift in Sources */, 3C35319C2C6F22050015A8FA /* ViewModelType.swift in Sources */, @@ -1789,6 +1816,7 @@ DEAD9CBD2CF2618F00D5CD11 /* SessionCell.swift in Sources */, 050E0CA42C74B35500326EEA /* UIApplication+.swift in Sources */, 050E0CB72C74B87400326EEA /* FeedDetailView.swift in Sources */, + 05F1FF482D1AB39000982033 /* MigratedHomeFeedCell.swift in Sources */, DEFF2F7E2D13052500DC1A16 /* AnyPublisher+.swift in Sources */, 050E0CF92C7516C900326EEA /* InfoNotificationDTO.swift in Sources */, 3C8B4E442C80886800174943 /* WriteContentRequestDTO.swift in Sources */, diff --git a/Wable-iOS/Global/Extention/UIView+.swift b/Wable-iOS/Global/Extention/UIView+.swift index 90d5558..3a8d12f 100644 --- a/Wable-iOS/Global/Extention/UIView+.swift +++ b/Wable-iOS/Global/Extention/UIView+.swift @@ -5,6 +5,7 @@ // Created by 박윤빈 on 8/6/24. // +import Combine import UIKit extension UIView { @@ -30,3 +31,61 @@ extension UIView { return superview as? T ?? superview?.superview(of: type) } } + +// MARK: - UIGestureRecognizer Combine Publisher + +extension UIView { + func gesturePublisher(_ gestureRecognizer: T) -> AnyPublisher { + GesturePublisher(view: self, gestureRecognizer: gestureRecognizer).eraseToAnyPublisher() + } +} + +// MARK: - GesturePublisher 정의 + +struct GesturePublisher: Publisher { + typealias Output = T + typealias Failure = Never + + private let view: UIView + private let gestureRecognizer: T + + init(view: UIView, gestureRecognizer: T) { + self.view = view + self.gestureRecognizer = gestureRecognizer + } + + func receive(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { + let subscription = GestureSubscription(subscriber: subscriber, view: view, gestureRecognizer: gestureRecognizer) + subscriber.receive(subscription: subscription) + } +} + +// MARK: - GestureSubscription 정의 + +final class GestureSubscription: Subscription where S.Input == T { + private var subscriber: S? + private let gestureRecognizer: T + private weak var view: UIView? + + init(subscriber: S, view: UIView, gestureRecognizer: T) { + self.subscriber = subscriber + self.gestureRecognizer = gestureRecognizer + self.view = view + + self.view?.isUserInteractionEnabled = true + self.view?.addGestureRecognizer(gestureRecognizer) + self.gestureRecognizer.addTarget(self, action: #selector(handleGesture)) + } + + func request(_ demand: Subscribers.Demand) { + } + + func cancel() { + subscriber = nil + view?.removeGestureRecognizer(gestureRecognizer) + } + + @objc private func handleGesture() { + _ = subscriber?.receive(gestureRecognizer) + } +} diff --git a/Wable-iOS/Global/Literals/ImageLiterals.swift b/Wable-iOS/Global/Literals/ImageLiterals.swift index ec28be8..6bb03e9 100644 --- a/Wable-iOS/Global/Literals/ImageLiterals.swift +++ b/Wable-iOS/Global/Literals/ImageLiterals.swift @@ -96,6 +96,7 @@ enum ImageLiterals { static var toastSuccess: UIImage { .load(name: "toast_success") } static var toastWarning: UIImage { .load(name: "toast_warning") } static var toastGhost: UIImage { .load(name: "toast_ghost") } + static var toastBan: UIImage { .load(name: "toast_ban") } static var toastReport: UIImage { .load(name: "toast_report") } static var toastAgreementLoading: UIImage { .load(name: "toast_agreement_loading") } } diff --git a/Wable-iOS/Global/Literals/StringLiterals.swift b/Wable-iOS/Global/Literals/StringLiterals.swift index 9054fd6..bdf47bf 100644 --- a/Wable-iOS/Global/Literals/StringLiterals.swift +++ b/Wable-iOS/Global/Literals/StringLiterals.swift @@ -203,6 +203,17 @@ enum StringLiterals { return "v3/content/\(contentID)/comment" } static let postBan = "v1/report/ban" + static func postFeedLike(contentID: Int) -> String { + return "v1/content/\(contentID)/liked" + } + static func deleteFeedLike(contentID: Int) -> String { + return "v1/content/\(contentID)/unliked" + } + static func deleteFeed(contentID: Int) -> String { + return "v1/content/\(contentID)" + } + static let postOpacityDown = "v1/ghost2" + static let postReport = "v1/report/slack" } enum Info { diff --git a/Wable-iOS/Global/Resources/Assets.xcassets/Toast/toast_ban.imageset/Contents.json b/Wable-iOS/Global/Resources/Assets.xcassets/Toast/toast_ban.imageset/Contents.json new file mode 100644 index 0000000..e31b951 --- /dev/null +++ b/Wable-iOS/Global/Resources/Assets.xcassets/Toast/toast_ban.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "toast.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "toast@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "toast@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Wable-iOS/Global/Resources/Assets.xcassets/Toast/toast_ban.imageset/toast.png b/Wable-iOS/Global/Resources/Assets.xcassets/Toast/toast_ban.imageset/toast.png new file mode 100644 index 0000000..ad28bd2 Binary files /dev/null and b/Wable-iOS/Global/Resources/Assets.xcassets/Toast/toast_ban.imageset/toast.png differ diff --git a/Wable-iOS/Global/Resources/Assets.xcassets/Toast/toast_ban.imageset/toast@2x.png b/Wable-iOS/Global/Resources/Assets.xcassets/Toast/toast_ban.imageset/toast@2x.png new file mode 100644 index 0000000..ed379b2 Binary files /dev/null and b/Wable-iOS/Global/Resources/Assets.xcassets/Toast/toast_ban.imageset/toast@2x.png differ diff --git a/Wable-iOS/Global/Resources/Assets.xcassets/Toast/toast_ban.imageset/toast@3x.png b/Wable-iOS/Global/Resources/Assets.xcassets/Toast/toast_ban.imageset/toast@3x.png new file mode 100644 index 0000000..ddab024 Binary files /dev/null and b/Wable-iOS/Global/Resources/Assets.xcassets/Toast/toast_ban.imageset/toast@3x.png differ diff --git a/Wable-iOS/Network/Home/HomeAPI.swift b/Wable-iOS/Network/Home/HomeAPI.swift index 0a55a59..6b86728 100644 --- a/Wable-iOS/Network/Home/HomeAPI.swift +++ b/Wable-iOS/Network/Home/HomeAPI.swift @@ -27,6 +27,71 @@ extension HomeAPI { } } + func migratedGetHomeFeed(cursor: Int) -> AnyPublisher<[HomeFeedDTO]?, WableNetworkError> { + homeProvider.requestPublisher(.getContent(param: cursor)) + .tryMap { [weak self] response -> [HomeFeedDTO]? in + return try self?.parseResponse(statusCode: response.statusCode, data: response.data) + } + .mapError { $0 as? WableNetworkError ?? .unknownError($0.localizedDescription) } + .eraseToAnyPublisher() + } + + func postFeedLike(contentID: Int) -> AnyPublisher { + homeProvider.requestPublisher(.postFeedLike(contentID: contentID)) + .tryMap { [weak self] response -> EmptyDTO? in + return try self?.parseResponse(statusCode: response.statusCode, data: response.data) + } + .mapError { $0 as? WableNetworkError ?? .unknownError($0.localizedDescription) } + .eraseToAnyPublisher() + } + + func deleteFeedLike(contentID: Int) -> AnyPublisher { + homeProvider.requestPublisher(.deleteFeedLike(contentID: contentID)) + .tryMap { [weak self] response -> EmptyDTO? in + return try self?.parseResponse(statusCode: response.statusCode, data: response.data) + } + .mapError { $0 as? WableNetworkError ?? .unknownError($0.localizedDescription) } + .eraseToAnyPublisher() + } + + func deleteFeed(contentID: Int) -> AnyPublisher { + homeProvider.requestPublisher(.deleteFeed(contentID: contentID)) + .tryMap { [weak self] response -> EmptyDTO? in + return try self?.parseResponse(statusCode: response.statusCode, data: response.data) + } + .mapError { $0 as? WableNetworkError ?? .unknownError($0.localizedDescription) } + .eraseToAnyPublisher() + } + + func postReport(nickname: String, relateText: String) -> AnyPublisher { + homeProvider.requestPublisher(.postReport(param: ReportRequestDTO( + reportTargetNickname: nickname, + relateText: relateText + ))) + .tryMap { [weak self] response -> EmptyDTO? in + return try self?.parseResponse(statusCode: response.statusCode, data: response.data) + } + .mapError { $0 as? WableNetworkError ?? + .unknownError($0.localizedDescription) } + .eraseToAnyPublisher() + } + + func postBeGhost(triggerType: String, memberID: Int, triggerID: Int) -> AnyPublisher { + let param = PostTransparencyRequestDTO( + alarmTriggerType: triggerType, + targetMemberId: memberID, + alarmTriggerId: triggerID, + ghostReason: "" + ) + return homeProvider.requestPublisher(.postBeGhost(param: param)) + .tryMap { [weak self] response -> EmptyDTO? in + return try self?.parseResponse(statusCode: response.statusCode, data: response.data) + } + .mapError { $0 as? WableNetworkError ?? + .unknownError($0.localizedDescription) } + .eraseToAnyPublisher() + } + func postReply(contentID: Int, requestBody: WriteReplyRequestV3DTO) -> AnyPublisher { homeProvider.requestPublisher(.postReply(param: contentID, requestBody: requestBody)) .tryMap { [weak self] response -> EmptyDTO? in diff --git a/Wable-iOS/Network/Home/HomeRouter.swift b/Wable-iOS/Network/Home/HomeRouter.swift index b0daade..c4d6427 100644 --- a/Wable-iOS/Network/Home/HomeRouter.swift +++ b/Wable-iOS/Network/Home/HomeRouter.swift @@ -14,6 +14,11 @@ enum HomeRouter { case patchFCMToken(param: UserProfileRequestDTO) case postReply(param: Int, requestBody: WriteReplyRequestV3DTO) case postBan(requestBody: BanRequestDTO) + case postFeedLike(contentID: Int) + case deleteFeedLike(contentID: Int) + case deleteFeed(contentID: Int) + case postBeGhost(param: PostTransparencyRequestDTO) + case postReport(param: ReportRequestDTO) } extension HomeRouter: BaseTargetType { @@ -27,6 +32,16 @@ extension HomeRouter: BaseTargetType { return StringLiterals.Endpoint.Home.postReply(contentID: contentID) case .postBan: return StringLiterals.Endpoint.Home.postBan + case .postFeedLike(let contentID): + return StringLiterals.Endpoint.Home.postFeedLike(contentID: contentID) + case .deleteFeedLike(let contentID): + return StringLiterals.Endpoint.Home.deleteFeedLike(contentID: contentID) + case .deleteFeed(let contentID): + return StringLiterals.Endpoint.Home.deleteFeed(contentID: contentID) + case .postBeGhost(let param): + return StringLiterals.Endpoint.Home.postOpacityDown + case .postReport: + return StringLiterals.Endpoint.Home.postReport } } @@ -36,10 +51,10 @@ extension HomeRouter: BaseTargetType { return .get case .patchFCMToken: return .patch - case .postReply: - return .post - case .postBan: + case .postReply, .postBan, .postFeedLike, .postBeGhost, .postReport: return .post + case .deleteFeed, .deleteFeedLike: + return .delete } } @@ -47,8 +62,8 @@ extension HomeRouter: BaseTargetType { switch self { case .getContent(let cursor): return .requestParameters(parameters: ["cursor": cursor], encoding: URLEncoding.queryString) + case .patchFCMToken(let data): - var formData = [MultipartFormData]() // fcmToken 추가 @@ -61,7 +76,6 @@ extension HomeRouter: BaseTargetType { let pushAlarmData = String(describing: data.isPushAlarmAllowed).data(using: .utf8) ?? Data() let pushAlarmPart = MultipartFormData(provider: .data(pushAlarmData), name: "isPushAlarmAllowed") formData.append(pushAlarmPart) - return .uploadMultipart(formData) case .postReply(_, let requestBody): @@ -69,12 +83,25 @@ extension HomeRouter: BaseTargetType { case .postBan(let requestBody): return .requestJSONEncodable(requestBody) + + case .postFeedLike: + let requestBody = ContentLikeRequestDTO(alarmTriggerType: "contentLiked") + return .requestJSONEncodable(requestBody) + + case .deleteFeedLike, .deleteFeed: + return .requestPlain + + case .postBeGhost(let requestBody): + return .requestJSONEncodable(requestBody) + + case .postReport(let requestBody): + return .requestJSONEncodable(requestBody) } } var headers: [String : String]? { switch self { - case .getContent, .postReply, .postBan: + case .getContent, .postReply, .postBan, .postFeedLike, .deleteFeedLike, .postBeGhost, .deleteFeed, .postReport: return APIConstants.hasTokenHeader case .patchFCMToken: return APIConstants.multipartHeader diff --git a/Wable-iOS/Network/Home/ResponseDTO/HomeFeedListDTO.swift b/Wable-iOS/Network/Home/ResponseDTO/HomeFeedListDTO.swift index f125c28..998961d 100644 --- a/Wable-iOS/Network/Home/ResponseDTO/HomeFeedListDTO.swift +++ b/Wable-iOS/Network/Home/ResponseDTO/HomeFeedListDTO.swift @@ -8,7 +8,6 @@ import Foundation struct HomeFeedDTO: Codable { - // 공통 속성 let memberID: Int let memberProfileURL, memberNickname: String let isGhost: Bool @@ -17,8 +16,6 @@ struct HomeFeedDTO: Codable { let time: String let likedNumber: Int let memberFanTeam: String - - // 선택적 속성 let contentID: Int? let contentTitle: String? let contentText: String? @@ -39,3 +36,9 @@ struct HomeFeedDTO: Codable { case isBlind } } + +extension HomeFeedDTO: Hashable { + static func == (lhs: HomeFeedDTO, rhs: HomeFeedDTO) -> Bool { + lhs.contentID == rhs.contentID + } +} diff --git a/Wable-iOS/Presentation/Home/Supports/PopupType.swift b/Wable-iOS/Presentation/Home/Supports/PopupType.swift new file mode 100644 index 0000000..f3ce885 --- /dev/null +++ b/Wable-iOS/Presentation/Home/Supports/PopupType.swift @@ -0,0 +1,51 @@ +// +// PopupType.swift +// Wable-iOS +// +// Created by 박윤빈 on 1/16/25. +// + +import Foundation + +enum PopupViewType { + case delete + case report + case ghost + case ban + + var title: String { + switch self { + case .delete: return StringLiterals.Home.deletePopupTitle + case .report: return StringLiterals.Home.reportPopupTitle + case .ghost: return StringLiterals.Home.ghostPopupTitle + case .ban: return "밴하기 ㅋㅋ" + } + } + + var content: String { + switch self { + case .delete: return StringLiterals.Home.deletePopupContent + case .report: return StringLiterals.Home.reportPopupContent + case .ghost: return "" + case .ban: return "너이놈밴머거랏!!!" + } + } + + var leftButtonTitle: String { + switch self { + case .delete: return StringLiterals.Home.deletePopupUndo + case .report: return StringLiterals.Home.reportPopupUndo + case .ghost: return StringLiterals.Home.ghostPopupUndo + case .ban: return "함봐줌" + } + } + + var rightButtonTitle: String { + switch self { + case .delete: return StringLiterals.Home.deletePopupDo + case .report: return StringLiterals.Home.reportPopupDo + case .ghost: return StringLiterals.Home.ghostPopupDo + case .ban: return "밴ㄱㄱ" + } + } +} diff --git a/Wable-iOS/Presentation/Home/ViewController/FeedDetailViewController.swift b/Wable-iOS/Presentation/Home/ViewController/FeedDetailViewController.swift index 9578731..5ab61aa 100644 --- a/Wable-iOS/Presentation/Home/ViewController/FeedDetailViewController.swift +++ b/Wable-iOS/Presentation/Home/ViewController/FeedDetailViewController.swift @@ -837,7 +837,7 @@ extension FeedDetailViewController { extension FeedDetailViewController: WablePopupDelegate { - func cancleButtonTapped() { + func cancelButtonTapped() { if nowShowingPopup == "ghost" { AmplitudeManager.shared.trackEvent(tag: "click_withdrawghost_popup") self.ghostPopupView?.removeFromSuperview() diff --git a/Wable-iOS/Presentation/Home/ViewController/HomePopupViewController.swift b/Wable-iOS/Presentation/Home/ViewController/HomePopupViewController.swift new file mode 100644 index 0000000..dbd6cda --- /dev/null +++ b/Wable-iOS/Presentation/Home/ViewController/HomePopupViewController.swift @@ -0,0 +1,131 @@ +// +// HomePopupViewController.swift +// Wable-iOS +// +// Created by 박윤빈 on 1/12/25. +// + +import Combine +import UIKit + +import CombineCocoa +import SnapKit + +final class HomePopupViewController: UIViewController { + + // MARK: - Properties + + var deleteButtonDidTapAction: ((Int) -> Void)? + var ghostButtonDidTapAction: ((Int) -> Void)? + var banButtonDidTapAction: ((Int) -> Void)? + var reportButtonDidTapAction: (() -> Void)? + + private let viewModel: PopupViewModel + private let rootView: WablePopupView + private let cancelBag = CancelBag() + + private let deleteButtonTapSubject = PassthroughSubject() + private let reportButtonDidTapSubject = PassthroughSubject() + private let banButtonDidTapSubject = PassthroughSubject() + private let ghostButtonDidTapSubject = PassthroughSubject() + + // MARK: - Initializer + + init(viewModel: PopupViewModel, popupType: PopupViewType) { + self.viewModel = viewModel + self.rootView = WablePopupView(popupType: popupType) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycles + + override func loadView() { + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + setupBinding() + rootView.delegate = self + } +} + +private extension HomePopupViewController { + func setupBinding() { + let input = PopupViewModel.Input( + deleteButtonDidTap: deleteButtonTapSubject.eraseToAnyPublisher(), + reportButtonDidTap: reportButtonDidTapSubject.eraseToAnyPublisher(), + banButtonDidTap: banButtonDidTapSubject.eraseToAnyPublisher(), + ghostButtonDidTap: ghostButtonDidTapSubject.eraseToAnyPublisher() + ) + + let output = viewModel.transform(from: input, cancelBag: cancelBag) + + output.dismissView + .receive(on: RunLoop.main) + .sink { [weak self] data, type in + guard let self else { return } + dismiss(animated: true) + switch type { + case .delete: + deleteButtonDidTap() + case .report: + reportButtonDidTap() + case .ghost: + ghostButtonDidTap() + case .ban: + banButtonDidTap() + } + } + .store(in: cancelBag) + } +} + +extension HomePopupViewController { + + func deleteButtonDidTap() { + deleteButtonDidTapAction?(viewModel.data.contentID ?? -1) + } + + func reportButtonDidTap() { + reportButtonDidTapAction?() + } + + func ghostButtonDidTap() { + ghostButtonDidTapAction?(viewModel.data.memberID) + } + + func banButtonDidTap() { + banButtonDidTapAction?(viewModel.data.memberID) + } +} + +// MARK: - WablePopupDelegate + +extension HomePopupViewController: WablePopupDelegate { + func cancelButtonTapped() { + self.dismiss(animated: true) + } + + func confirmButtonTapped() { + switch rootView.popupType { + case .delete: + deleteButtonTapSubject.send(()) + case .report: + reportButtonDidTapSubject.send(()) + case .ghost: + ghostButtonDidTapSubject.send(()) + case .ban: + banButtonDidTapSubject.send(()) + } + } + + func singleButtonTapped() { + self.dismiss(animated: true) + } + +} diff --git a/Wable-iOS/Presentation/Home/ViewController/HomeViewController.swift b/Wable-iOS/Presentation/Home/ViewController/HomeViewController.swift index 2510229..68a45bf 100644 --- a/Wable-iOS/Presentation/Home/ViewController/HomeViewController.swift +++ b/Wable-iOS/Presentation/Home/ViewController/HomeViewController.swift @@ -577,7 +577,7 @@ extension HomeViewController: UITableViewDelegate, UITableViewDataSource { extension HomeViewController: WablePopupDelegate { - func cancleButtonTapped() { + func cancelButtonTapped() { if nowShowingPopup == "ghost" { AmplitudeManager.shared.trackEvent(tag: "click_withdrawghost_popup") self.ghostPopupView?.removeFromSuperview() diff --git a/Wable-iOS/Presentation/Home/ViewController/MigratedHomeViewController.swift b/Wable-iOS/Presentation/Home/ViewController/MigratedHomeViewController.swift new file mode 100644 index 0000000..2efc620 --- /dev/null +++ b/Wable-iOS/Presentation/Home/ViewController/MigratedHomeViewController.swift @@ -0,0 +1,512 @@ +// +// MigratedHomeViewController.swift +// Wable-iOS +// +// Created by 박윤빈 on 12/22/24. +// + +import UIKit +import Combine + +import CombineCocoa + +final class MigratedHomeViewController: UIViewController { + + typealias Item = HomeFeedDTO + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + enum Section: CaseIterable { + case feed + } + + // MARK: - Properties + + private var dataSource: DataSource? + + private let viewModel: MigratedHomeViewModel + + private let viewDidLoadSubject = PassthroughSubject() + private let collectionViewDidRefreshSubject = PassthroughSubject() + private let collectionViewDidSelectedSubject = PassthroughSubject() + private let collectionViewDidEndDragSubject = PassthroughSubject() + private let profileImageTapSubject = PassthroughSubject() + private let menuButtonTapSubject = PassthroughSubject() + private let feedImageTapSubject = PassthroughSubject() + private let heartButtonTapSubject = PassthroughSubject() + private let commentButtonTapSubject = PassthroughSubject() + + private let feedDeleteButtonDidTap = PassthroughSubject() + private let feedGhostButtonDidTap = PassthroughSubject() + private let feedBanButtonDidTap = PassthroughSubject() + + private let cancelBag = CancelBag() + private let rootView = MigratedHomeView() + private var photoDetailView: WablePhotoDetailView? + private let homeBottomsheetView = HomeBottomSheetView() + private let reportToastImageView = UIImageView(image: ImageLiterals.Toast.toastReport) + + // MARK: - Initializer + + init(viewModel: MigratedHomeViewModel) { + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + + override func loadView() { + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupCollectionView() + setupDataSource() + setupAction() + setupBinding() + popupEventBinding() + showLoadView() + + viewDidLoadSubject.send(()) + + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.navigationController?.navigationBar.isHidden = true + } +} + +// MARK: - UICollectionViewDelegate + +extension MigratedHomeViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + collectionViewDidSelectedSubject.send(indexPath.item) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + guard scrollView == rootView.collectionView, + (scrollView.contentOffset.y + scrollView.frame.size.height) >= scrollView.contentSize.height + else { + return + } + + collectionViewDidEndDragSubject.send(()) + } +} + +// MARK: - Private Method + +private extension MigratedHomeViewController { + func setupCollectionView() { + rootView.collectionView.setCollectionViewLayout(collectionViewLayout, animated: false) + + rootView.collectionView.delegate = self + } + + func setupDataSource() { + let homeFeedCellRegistration = UICollectionView.CellRegistration { cell, indexPath, item in + cell.bind(data: item) + cell.onMenuButtonTap = { [weak self] in + self?.menuButtonTapSubject.send(indexPath.item) + } + + cell.onProfileImageTap = { [weak self] in + self?.profileImageTapSubject.send(indexPath.item) + } + + cell.onFeedImageTap = { [weak self] in + self?.feedImageTapSubject.send(indexPath.item) + } + + cell.onHeartButtonTap = { [weak self] in + self?.heartButtonTapSubject.send(indexPath.item) + } + + cell.onCommentButtonTap = { [weak self] in + self?.commentButtonTapSubject.send(indexPath.item) + } + + cell.onGhostButtonTap = { [weak self] in + self?.presentPopup(popupType: .ghost, data: item) + } + } + + dataSource = DataSource(collectionView: rootView.collectionView) { collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell( + using: homeFeedCellRegistration, + for: indexPath, + item: item + ) + } + } + + func applySnapshot(items: [Item], to section: Section) { + var snapshot = Snapshot() + snapshot.appendSections([.feed]) + snapshot.appendItems(items, toSection: section) + dataSource?.apply(snapshot, animatingDifferences: false) + } + + func setupAction() { + let refreshAction = UIAction { [weak self] _ in + self?.collectionViewDidRefreshSubject.send(()) + } + + rootView.collectionView.refreshControl?.addAction(refreshAction, for: .valueChanged) + + rootView.writeFeedButton.tapPublisher + .sink { [weak self] _ in + let writeViewController = WriteViewController(viewModel: WriteViewModel(networkProvider: NetworkService())) + writeViewController.hidesBottomBarWhenPushed = true + writeViewController.writeViewDidDisappear = { [weak self] in + self?.viewDidLoadSubject.send(()) + } + self?.navigationController?.pushViewController(writeViewController, animated: true) + } + .store(in: cancelBag) + } + + func setupBinding() { + let input = MigratedHomeViewModel.Input( + viewDidLoad: viewDidLoadSubject.eraseToAnyPublisher(), + collectionViewDidRefresh: collectionViewDidRefreshSubject.eraseToAnyPublisher(), + collectionViewDidSelect: collectionViewDidSelectedSubject.eraseToAnyPublisher(), + collectionViewDidEndDrag: collectionViewDidEndDragSubject.eraseToAnyPublisher(), + menuButtonDidTap: menuButtonTapSubject.eraseToAnyPublisher(), + profileImageDidTap: profileImageTapSubject.eraseToAnyPublisher(), + feedImageURL: feedImageTapSubject.eraseToAnyPublisher(), + heartButtonDidTap: heartButtonTapSubject.eraseToAnyPublisher(), + commentButtonDidTap: commentButtonTapSubject.eraseToAnyPublisher() + ) + + let output = viewModel.transform(from: input, cancelBag: cancelBag) + + output.feedData + .receive(on: RunLoop.main) + .handleEvents(receiveOutput: { [weak self] _ in + self?.endRefreshing() + }) + .removeDuplicates() + .sink { [weak self] feed in + self?.applySnapshot(items: feed, to: .feed) + } + .store(in: cancelBag) + + output.profileImageTapped + .receive(on: RunLoop.main) + .sink { [weak self] memberID in + if memberID == loadUserData()?.memberId { + self?.tabBarController?.selectedIndex = 3 + } else { + let viewController = MyPageViewController( + viewModel: MyPageViewModel(networkProvider: NetworkService()), + likeViewModel: LikeViewModel(networkProvider: NetworkService()) + ) + viewController.memberId = memberID + self?.navigationController?.pushViewController(viewController, animated: true) + } + } + .store(in: cancelBag) + + output.feedImageTapped + .receive(on: RunLoop.main) + .sink { [weak self] imageURL in + self?.makePhotoDetailView(imageURL: imageURL) + } + .store(in: cancelBag) + + output.selectedFeed + .receive(on: RunLoop.main) + .sink { [weak self] feed in + self?.pushToDetailView(feed: feed) + } + .store(in: cancelBag) + + output.toggleHeartButton + .receive(on: RunLoop.main) + .sink { [weak self] datas, index in + guard let self = self else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.feed]) + snapshot.appendItems(datas, toSection: .feed) + dataSource?.apply(snapshot, animatingDifferences: false) + } + .store(in: cancelBag) + + output.showBottomSheet + .receive(on: RunLoop.main) + .sink { [weak self] data in + let isMine = loadUserData()?.memberId == data.memberID + let isAdmin = loadUserData()?.isAdmin + self?.setBottomSheetButton(isMine: isMine, isAdmin: isAdmin ?? false, data: data) + } + .store(in: cancelBag) + } + + func popupEventBinding() { + feedDeleteButtonDidTap.sink { [weak self] contentID in + guard let self else { return } + viewModel.deleteFeed(at: contentID) + var snapshot = dataSource?.snapshot() + if let itemToDelete = snapshot?.itemIdentifiers.first(where: { $0.contentID == contentID }) { + snapshot?.deleteItems([itemToDelete]) + dataSource?.apply(snapshot ?? NSDiffableDataSourceSnapshot(), animatingDifferences: true) + } + } + .store(in: cancelBag) + + feedGhostButtonDidTap.sink { [weak self] memberID in + guard let self else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.feed]) + snapshot.appendItems(viewModel.updateGhostState(for: memberID), toSection: .feed) + dataSource?.apply(snapshot, animatingDifferences: true) + + makeToast(toastImage: ImageLiterals.Toast.toastGhost) + } + .store(in: cancelBag) + + feedBanButtonDidTap.sink { [weak self] memberID in + guard let self else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.feed]) + snapshot.appendItems(viewModel.updateBanState(for: memberID), toSection: .feed) + dataSource?.apply(snapshot, animatingDifferences: true) + + makeToast(toastImage: ImageLiterals.Toast.toastBan) + } + .store(in: cancelBag) + } + + func endRefreshing() { + guard let refreshControl = rootView.collectionView.refreshControl, + refreshControl.isRefreshing else { return } + refreshControl.endRefreshing() + } + + func makePhotoDetailView(imageURL: String) { + + self.photoDetailView = WablePhotoDetailView() + + guard let photoDetailView = self.photoDetailView, + let window = UIApplication.shared.keyWindowInConnectedScenes else { return } + + window.addSubview(photoDetailView) + + photoDetailView.removePhotoButton.tapPublisher + .sink { [weak self] in + self?.photoDetailView?.removeFromSuperview() + self?.photoDetailView = nil + } + .store(in: self.cancelBag) + + photoDetailView.photoImageView.loadContentImage(url: imageURL) { [weak self] image in + DispatchQueue.main.async { + self?.photoDetailView?.updateImageViewHeight(with: image) + } + } + + photoDetailView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + func makeToast(toastImage: UIImage) { + let toastImageView = UIImageView(image: toastImage) + toastImageView.contentMode = .scaleAspectFit + + if let window = UIApplication.shared.keyWindowInConnectedScenes { + window.addSubviews(toastImageView) + } + + toastImageView.snp.makeConstraints { + $0.top.equalToSuperview().inset(75.adjusted) + $0.centerX.equalToSuperview() + $0.width.equalTo(343.adjusted) + } + + UIView.animate(withDuration: 1, delay: 1, options: .curveEaseIn) { + toastImageView.alpha = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + toastImageView.removeFromSuperview() + } + } +} + +private extension MigratedHomeViewController { + var collectionViewLayout: UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(170.adjustedH)) + + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(170.adjusted)) + + let group = NSCollectionLayoutGroup.vertical( + layoutSize: groupSize, + subitems: [item] + ) + + let section = NSCollectionLayoutSection(group: group) + + let sectionKind = Section.allCases[sectionIndex] + switch sectionKind { + case .feed: + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + } + return section + } + } +} + +extension MigratedHomeViewController { + func scrollToTop() { + self.rootView.collectionView.setContentOffset(CGPoint(x: 0, y: -self.rootView.collectionView.contentInset.top), animated: true) + } + + func showLoadView() { + displayLoadingView() + } + + func pushToDetailView(feed: HomeFeedDTO) { + let detailViewController = FeedDetailViewController( + viewModel: FeedDetailViewModel(networkProvider: NetworkService()), + likeViewModel: LikeViewModel(networkProvider: NetworkService()) + ) + detailViewController.hidesBottomBarWhenPushed = true + detailViewController.getFeedData(data: feed) + detailViewController.memberId = feed.memberID + self.navigationController?.pushViewController(detailViewController, animated: true) + } + + func displayLoadingView() { + tabBarController?.tabBar.isHidden = true + self.rootView.loadingView.alpha = 1.0 + self.rootView.loadingView.isHidden = false + self.rootView.loadingView.loadingLabel.setTextWithLineHeight( + text: self.rootView.loadingView.loadingText.randomElement(), + lineHeight: 32.adjusted, + alignment: .center + ) + self.rootView.loadingView.lottieLoadingView.play( + fromProgress: 0, + toProgress: 0.7, + loopMode: .playOnce + ) { [weak self] _ in + guard let self else { return } + self.fadeLoadingView() + } + } + + func fadeLoadingView() { + UIView.animate(withDuration: 0.3, animations: { + self.tabBarController?.tabBar.isHidden = false + self.rootView.loadingView.alpha = 0.0 + }) + } + + func removeBottomsheetView() { + if UIApplication.shared.keyWindowInConnectedScenes != nil { + UIView.animate( + withDuration: 0.3, + delay: 0, + usingSpringWithDamping: 1, + initialSpringVelocity: 1, + options: .curveEaseOut, + animations: { + self.homeBottomsheetView.dimView.alpha = 0 + if let window = UIApplication.shared.keyWindowInConnectedScenes { + self.homeBottomsheetView.bottomsheetView.frame = CGRect( + x: 0, + y: window.frame.height, + width: self.homeBottomsheetView.frame.width, + height: self.homeBottomsheetView.bottomsheetView.frame.height + ) + } + } + ) + homeBottomsheetView.dimView.removeFromSuperview() + homeBottomsheetView.bottomsheetView.removeFromSuperview() + } + } + + func setBottomSheetButton(isMine: Bool, isAdmin: Bool, data: HomeFeedDTO) { + let bottomSheetHeight = isAdmin ? 178.adjusted : 122.adjusted + homeBottomsheetView.bottomsheetView.snp.remakeConstraints { + $0.height.equalTo(bottomSheetHeight) + } + homeBottomsheetView.showSettings() + homeBottomsheetView.deleteButton.isHidden = !isMine + homeBottomsheetView.reportButton.isHidden = isMine + homeBottomsheetView.banButton.isHidden = !isAdmin + + setBottomSheetButtonAction(isMine: isMine, data: data) + } + + func setBottomSheetButtonAction(isMine: Bool, data: HomeFeedDTO) { + + let bottomSheetCancelBag = CancelBag() + homeBottomsheetView.cancelBag = bottomSheetCancelBag + + if isMine { + homeBottomsheetView.deleteButton.tapPublisher + .sink { [weak self] in + self?.presentPopup(popupType: .delete, data: data) + } + .store(in: bottomSheetCancelBag) + } else { + homeBottomsheetView.reportButton.tapPublisher + .sink { [weak self] in + self?.presentPopup(popupType: .report, data: data) + } + .store(in: bottomSheetCancelBag) + } + + if loadUserData()?.isAdmin ?? false { + homeBottomsheetView.banButton.tapPublisher + .sink { [weak self] in + self?.presentPopup(popupType: .ban, data: data) + } + .store(in: bottomSheetCancelBag) + } + } + + private func presentPopup(popupType: PopupViewType, data: HomeFeedDTO) { + removeBottomsheetView() + let popupViewController = HomePopupViewController( + viewModel: PopupViewModel(data: data), + popupType: popupType + ) + popupViewController.deleteButtonDidTapAction = { [weak self] contentID in + self?.feedDeleteButtonDidTap.send(contentID) + } + + popupViewController.reportButtonDidTapAction = { [weak self] in + self?.makeToast(toastImage: ImageLiterals.Toast.toastReport) + } + + popupViewController.ghostButtonDidTapAction = { [weak self] memberID in + self?.feedGhostButtonDidTap.send(memberID) + } + + popupViewController.banButtonDidTapAction = { [weak self] memberID in + self?.feedBanButtonDidTap.send(memberID) + } + + popupViewController.modalPresentationStyle = .overFullScreen + popupViewController.modalTransitionStyle = .crossDissolve + present(popupViewController, animated: true) + } +} diff --git a/Wable-iOS/Presentation/Home/ViewModels/MigratedHomeViewModel.swift b/Wable-iOS/Presentation/Home/ViewModels/MigratedHomeViewModel.swift new file mode 100644 index 0000000..111db54 --- /dev/null +++ b/Wable-iOS/Presentation/Home/ViewModels/MigratedHomeViewModel.swift @@ -0,0 +1,272 @@ +// +// MigratedHomeViewModel.swift +// Wable-iOS +// +// Created by 박윤빈 on 12/24/24. +// + +import Foundation +import Combine + +final class MigratedHomeViewModel { + private var cursor: Int = -1 + private var deletedFeedCount: Int = 0 + let feedSubject = CurrentValueSubject<[HomeFeedDTO], Never>([]) + + private let service: HomeAPI + + init(service: HomeAPI = HomeAPI.shared) { + self.service = service + } +} + +extension MigratedHomeViewModel: ViewModelType { + struct Input { + let viewDidLoad: AnyPublisher + let collectionViewDidRefresh: AnyPublisher + let collectionViewDidSelect: AnyPublisher + let collectionViewDidEndDrag: AnyPublisher + let menuButtonDidTap: AnyPublisher + let profileImageDidTap: AnyPublisher + let feedImageURL: AnyPublisher + let heartButtonDidTap: AnyPublisher + let commentButtonDidTap: AnyPublisher + } + + struct Output { + let feedData: AnyPublisher<[HomeFeedDTO], Never> + let selectedFeed: AnyPublisher + let showBottomSheet: AnyPublisher + let profileImageTapped: AnyPublisher + let feedImageTapped: AnyPublisher + let toggleHeartButton: AnyPublisher<([HomeFeedDTO], Int), Never> + } + + func transform(from input: Input, cancelBag: CancelBag) -> Output { + + input.viewDidLoad + .merge(with: input.collectionViewDidRefresh) + .flatMap { [weak self] _ -> AnyPublisher<[HomeFeedDTO], Never> in + guard let self else { + return Just([]).eraseToAnyPublisher() + } + + return resetCursorAndGetHomeFeed() + } + .subscribe(feedSubject) + .store(in: cancelBag) + + let lastContentIDPublisher = input.collectionViewDidEndDrag + .compactMap { + self.feedSubject.value.last?.contentID + } + + let feedPublisher = lastContentIDPublisher + .filter { [weak self] lastContentID in // 페이지네이션 조건 필터링 + guard let self else { return false } + let count = feedSubject.value.count + deletedFeedCount + return count % 20 == 0 && + lastContentID != -1 && + lastContentID != cursor + } + .flatMap { [weak self] lastContentID -> AnyPublisher<[HomeFeedDTO], Never> in + guard let self else { + return Just([]).eraseToAnyPublisher() + } + cursor = lastContentID + return self.getHomeFeed(cursor: lastContentID) + } + .map { feeds in + var previousFeeds = self.feedSubject.value + previousFeeds.append(contentsOf: feeds) + return previousFeeds + } + + // feedPublisher를 feedSubject에 구독 + feedPublisher + .subscribe(feedSubject) + .store(in: cancelBag) + + let feed = feedSubject + .filter { !$0.isEmpty } + .eraseToAnyPublisher() + + // 인덱스 초과를 방지하기 위해서 filter로 검사해준 뒤, 인덱스에 맞는 값 찾아줌 + let selectedFeed = input.collectionViewDidSelect + .merge(with: input.commentButtonDidTap) + .filter { $0 < self.feedSubject.value.count} + .map { self.feedSubject.value[$0] } + .eraseToAnyPublisher() + + let profileImageDidTap = input.profileImageDidTap + .compactMap { $0 } + .filter { $0 < self.feedSubject.value.count } + .map { self.feedSubject.value[$0].memberID } + .eraseToAnyPublisher() + + let feedImageURL = input.feedImageURL + .compactMap { $0 } + .filter { $0 < self.feedSubject.value.count } + .map { self.feedSubject.value[$0].contentImageURL ?? String() } + .eraseToAnyPublisher() + + let bottomSheetInfo = input.menuButtonDidTap + .compactMap { $0 } + .filter { $0 < self.feedSubject.value.count } + .map { self.feedSubject.value[$0] } + .eraseToAnyPublisher() + + + let heartButtonState = input.heartButtonDidTap + .throttle(for: .milliseconds(500), scheduler: RunLoop.main, latest: false) + .compactMap { $0 } + .filter { $0 < self.feedSubject.value.count } + .map { index -> (Bool?, Int?, Int) in + let item = self.feedSubject.value[index] + return (item.isLiked, item.contentID, index) + } + .flatMap { [weak self] state -> AnyPublisher<(EmptyDTO?, Int), Never> in + guard let self = self else { return Just((nil, state.2)).eraseToAnyPublisher() } + + if state.0 ?? false { + return service.deleteFeedLike(contentID: state.1 ?? Int()) + .replaceError(with: nil) + .map { return ($0, state.2) } + .eraseToAnyPublisher() + } else { + return service.postFeedLike(contentID: state.1 ?? Int()) + .replaceError(with: nil) + .map { ($0, state.2) } + .eraseToAnyPublisher() + } + } + + let toggleHeart = heartButtonState + .map { [weak self] apiResult, index -> ([HomeFeedDTO], Int) in + guard let self else { + return ([], index) + } + self.updateHeartButtonState(at: index) + return (self.feedSubject.value, index) + } + .eraseToAnyPublisher() + + + return Output( + feedData: feed, + selectedFeed: selectedFeed, + showBottomSheet: bottomSheetInfo, + profileImageTapped: profileImageDidTap, + feedImageTapped: feedImageURL, + toggleHeartButton: toggleHeart + ) + } + + func deleteFeed(at contentID: Int) { + feedSubject.value.removeAll { $0.contentID == contentID } + deletedFeedCount += 1 + } + + func updateGhostState(for memberID: Int) -> [HomeFeedDTO] { + let updatedDatas = feedSubject.value.map { item in + guard item.memberID == memberID else { return item } + + return HomeFeedDTO( + memberID: item.memberID, + memberProfileURL: item.memberProfileURL, + memberNickname: item.memberNickname, + isGhost: true, + memberGhost: item.memberGhost - 1 , + isLiked: item.isLiked, + time: item.time, + likedNumber: item.likedNumber, + memberFanTeam: item.memberFanTeam, + contentID: item.contentID, + contentTitle: item.contentTitle, + contentText: item.contentText, + commentNumber: item.commentNumber, + isDeleted: item.isDeleted, + commnetNumber: item.commnetNumber, + contentImageURL: item.contentImageURL, + isBlind: item.isBlind + ) + } + feedSubject.send(updatedDatas) + return updatedDatas + } + + func updateBanState(for memberID: Int) -> [HomeFeedDTO] { + let updatedDatas = feedSubject.value.map { item in + guard item.memberID == memberID else { return item } + + return HomeFeedDTO( + memberID: item.memberID, + memberProfileURL: item.memberProfileURL, + memberNickname: item.memberNickname, + isGhost: item.isGhost, + memberGhost: item.memberGhost, + isLiked: item.isLiked, + time: item.time, + likedNumber: item.likedNumber, + memberFanTeam: item.memberFanTeam, + contentID: item.contentID, + contentTitle: item.contentTitle, + contentText: item.contentText, + commentNumber: item.commentNumber, + isDeleted: item.isDeleted, + commnetNumber: item.commnetNumber, + contentImageURL: item.contentImageURL, + isBlind: true + ) + } + feedSubject.send(updatedDatas) + return updatedDatas + } + + func updateHeartButtonState(at index: Int){ + var updatedDatas = feedSubject.value + + guard updatedDatas.indices.contains(index) else { return } + + let item = updatedDatas[index] + let newData = HomeFeedDTO( + memberID: item.memberID, + memberProfileURL: item.memberProfileURL, + memberNickname: item.memberNickname, + isGhost: item.isGhost, + memberGhost: item.memberGhost, + isLiked: !item.isLiked, + time: item.time, + likedNumber: item.isLiked ? item.likedNumber - 1 : item.likedNumber + 1, + memberFanTeam: item.memberFanTeam, + contentID: item.contentID, + contentTitle: item.contentTitle, + contentText: item.contentText, + commentNumber: item.commentNumber, + isDeleted: item.isDeleted, + commnetNumber: item.commnetNumber, + contentImageURL: item.contentImageURL, + isBlind: item.isBlind + ) + + updatedDatas[index] = newData + + feedSubject.send(updatedDatas) + } +} + +private extension MigratedHomeViewModel { + func getHomeFeed(cursor: Int) -> AnyPublisher<[HomeFeedDTO], Never> { + service.migratedGetHomeFeed(cursor: cursor) + .mapWableNetworkError() + .replaceError(with: []) + .compactMap { $0 } + .eraseToAnyPublisher() + } + + func resetCursorAndGetHomeFeed() -> AnyPublisher<[HomeFeedDTO], Never> { + cursor = -1 + deletedFeedCount = 0 + return getHomeFeed(cursor: cursor) + } +} diff --git a/Wable-iOS/Presentation/Home/ViewModels/PopupViewModel.swift b/Wable-iOS/Presentation/Home/ViewModels/PopupViewModel.swift new file mode 100644 index 0000000..3b316dc --- /dev/null +++ b/Wable-iOS/Presentation/Home/ViewModels/PopupViewModel.swift @@ -0,0 +1,119 @@ +// +// PopupViewModel.swift +// Wable-iOS +// +// Created by 박윤빈 on 1/11/25. +// + +import Foundation +import Combine + +final class PopupViewModel { + private let service: HomeAPI + let data: HomeFeedDTO + init(service: HomeAPI = HomeAPI.shared, data: HomeFeedDTO) { + self.service = service + self.data = data + } +} + +extension PopupViewModel: ViewModelType { + struct Input { + let deleteButtonDidTap: AnyPublisher + let reportButtonDidTap: AnyPublisher + let banButtonDidTap: AnyPublisher + let ghostButtonDidTap: AnyPublisher + } + + struct Output { + let dismissView: AnyPublisher<(HomeFeedDTO, PopupViewType), Never> + } + + func transform(from input: Input, cancelBag: CancelBag) -> Output { + let dismissViewSubject = PassthroughSubject<(HomeFeedDTO, PopupViewType), Never>() + input.deleteButtonDidTap + .flatMap { [weak self] _ -> AnyPublisher in + guard let self else { + return Just(EmptyDTO()).eraseToAnyPublisher() + } + + return service.deleteFeed(contentID: data.contentID ?? -1) + .mapWableNetworkError() + .replaceError(with: nil) + .compactMap { $0 } + .eraseToAnyPublisher() + } + .sink { _ in + let popupViewType = PopupViewType.delete + dismissViewSubject.send((self.data, popupViewType)) + } + .store(in: cancelBag) + + input.banButtonDidTap + .flatMap { [weak self] _ -> AnyPublisher in + guard let self else { + return Just(EmptyDTO()).eraseToAnyPublisher() + } + let memberID = data.memberID + let triggerID = data.contentID ?? -1 + return service.postBan( + memberID: memberID, + triggerType: "content", + triggerID: triggerID + ) + .mapWableNetworkError() + .replaceError(with: nil) + .compactMap { $0 } + .eraseToAnyPublisher() + } + .sink { _ in + let popupViewType = PopupViewType.ban + dismissViewSubject.send((self.data, popupViewType)) + } + .store(in: cancelBag) + + input.reportButtonDidTap + .flatMap { [weak self] _ -> AnyPublisher in + guard let self else { + return Just(EmptyDTO()).eraseToAnyPublisher() + } + let nickname = data.memberNickname + let titleText = data.contentTitle ?? "" + return service.postReport(nickname: nickname, relateText: titleText) + .mapWableNetworkError() + .replaceError(with: nil) + .compactMap { $0 } + .eraseToAnyPublisher() + } + .sink { _ in + let popupViewType = PopupViewType.report + dismissViewSubject.send((self.data, popupViewType)) + } + .store(in: cancelBag) + + input.ghostButtonDidTap + .flatMap { [weak self] _ -> AnyPublisher in + guard let self else { + return Just(EmptyDTO()).eraseToAnyPublisher() + } + let memberID = data.memberID + let triggerID = data.contentID ?? -1 + return service.postBeGhost( + triggerType: "contentGhost", + memberID: memberID, + triggerID: triggerID + ) + .mapWableNetworkError() + .replaceError(with: nil) + .compactMap { $0 } + .eraseToAnyPublisher() + } + .sink { _ in + let popupViewType = PopupViewType.ghost + dismissViewSubject.send((self.data, popupViewType)) + } + .store(in: cancelBag) + + return Output(dismissView: dismissViewSubject.eraseToAnyPublisher()) + } +} diff --git a/Wable-iOS/Presentation/Home/Views/Cells/HomeFeedTableViewCell.swift b/Wable-iOS/Presentation/Home/Views/Cells/HomeFeedTableViewCell.swift index 25c43b8..6e309f9 100644 --- a/Wable-iOS/Presentation/Home/Views/Cells/HomeFeedTableViewCell.swift +++ b/Wable-iOS/Presentation/Home/Views/Cells/HomeFeedTableViewCell.swift @@ -202,7 +202,9 @@ final class HomeFeedTableViewCell: UITableViewCell{ isBlind: data.isBlind) bottomView.bind(heart: data.likedNumber, - comment: data.commentNumber ?? Int()) + comment: data.commentNumber ?? Int(), + memberID: data.memberID + ) bottomView.isLiked = data.isLiked diff --git a/Wable-iOS/Presentation/Home/Views/Cells/MigratedHomeFeedCell.swift b/Wable-iOS/Presentation/Home/Views/Cells/MigratedHomeFeedCell.swift new file mode 100644 index 0000000..41933c2 --- /dev/null +++ b/Wable-iOS/Presentation/Home/Views/Cells/MigratedHomeFeedCell.swift @@ -0,0 +1,252 @@ +// +// MigratedHomeFeedCell.swift +// Wable-iOS +// +// Created by 박윤빈 on 12/24/24. +// + +import Combine +import UIKit + +import SnapKit +import CombineCocoa + +final class MigratedHomeFeedCell: UICollectionViewCell{ + + // MARK: - Properties + + var cancelBag = CancelBag() + + var onMenuButtonTap: (() -> Void)? + var onProfileImageTap: (() -> Void)? + var onFeedImageTap: (() -> Void)? + var onHeartButtonTap: (() -> Void)? + var onCommentButtonTap: (() -> Void)? + var onGhostButtonTap: (() -> Void)? + + // MARK: - Components + + let infoView = FeedInfoView() + let feedContentView = FeedContentView() + let bottomView = FeedBottomView() + let divideLine = UIView().makeDivisionLine() + let grayView: UIView = { + let view = UIView() + view.backgroundColor = .wableWhite + view.alpha = 0 + view.isUserInteractionEnabled = false + return view + }() + + let profileImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = ImageLiterals.Image.imgProfileSmall + imageView.isUserInteractionEnabled = true + return imageView + }() + + let seperateLineView: UIView = { + let view = UIView() + view.backgroundColor = .gray200 + view.isHidden = true + return view + }() + + private let menuButton: UIButton = { + let button = UIButton() + button.setImage(ImageLiterals.Icon.icMeatball, for: .normal) + return button + }() + + // MARK: - inits + + override init(frame: CGRect) { + super.init(frame: frame) + + setupView() + setLayout() + setEventPublisher() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + DispatchQueue.main.async { + self.profileImageView.contentMode = .scaleAspectFill + self.profileImageView.layer.cornerRadius = self.profileImageView.frame.size.width / 2 + self.profileImageView.clipsToBounds = true + } + } + + override func prepareForReuse() { + super.prepareForReuse() + self.profileImageView.image = UIImage() + self.feedContentView.blindImageView.isHidden = true + self.feedContentView.titleLabel.isHidden = false + self.feedContentView.contentLabel.isHidden = false + self.feedContentView.photoImageView.isHidden = false + self.feedContentView.titleLabel.attributedText = nil + self.feedContentView.titleLabel.textColor = .wableBlack + self.feedContentView.contentLabel.attributedText = nil + self.feedContentView.contentLabel.textColor = .gray800 + self.grayView.alpha = 0 + } + + // MARK: - Functions + + private func setupView() { + self.backgroundColor = .wableWhite + self.contentView.addSubviews( + profileImageView, + menuButton, + infoView, + feedContentView, + bottomView, + divideLine, + seperateLineView, + grayView + ) + + self.profileImageView.contentMode = .scaleAspectFill + self.profileImageView.layer.cornerRadius = self.profileImageView.frame.size.width / 2 + self.profileImageView.clipsToBounds = true + } + + private func setLayout() { + grayView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.bottom.equalTo(bottomView.snp.top) + } + + profileImageView.snp.makeConstraints { + $0.height.width.equalTo(36.adjusted) + $0.leading.equalToSuperview().inset(16.adjusted) + $0.centerY.equalTo(infoView) + } + + infoView.snp.makeConstraints { + $0.top.equalToSuperview().inset(18.adjusted) + $0.leading.equalTo(profileImageView.snp.trailing).offset(10.adjusted) + $0.height.equalTo(43.adjusted) + } + + menuButton.snp.makeConstraints { + $0.height.width.equalTo(32.adjusted) + $0.top.equalTo(infoView) + $0.trailing.equalToSuperview().inset(16.adjusted) + } + + feedContentView.snp.makeConstraints { + $0.top.equalTo(infoView.snp.bottom).offset(12.adjusted) + $0.leading.trailing.equalToSuperview().inset(16.adjusted) + } + + bottomView.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(16.adjusted) + $0.height.equalTo(31.adjusted) + $0.top.equalTo(feedContentView.snp.bottom).offset(20.adjusted).priority(.low) + $0.bottom.equalToSuperview().inset(20.adjusted) + } + + divideLine.snp.makeConstraints { + $0.height.equalTo(1) + $0.leading.trailing.bottom.equalToSuperview() + } + + seperateLineView.snp.makeConstraints { + $0.height.equalTo(8.adjusted) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview() + } + } + + func bind(data: HomeFeedDTO) { + data.memberProfileURL.isEmpty ? + (profileImageView.image = ImageLiterals.Image.imgProfile3) : + profileImageView.load(url: data.memberProfileURL) + + infoView.bind( + nickname: data.memberNickname, + team: Team(rawValue: data.memberFanTeam) ?? .TBD, + ghostPercent: data.memberGhost, + time: data.time + ) + + feedContentView.bind( + title: data.contentTitle ?? "", + content: data.contentText ?? "", + image: data.contentImageURL, + isBlind: data.isBlind + ) + + bottomView.bind( + heart: data.likedNumber, + comment: data.commentNumber ?? Int(), + memberID: data.memberID + ) + + bottomView.isLiked = data.isLiked + + if data.isGhost || data.isBlind ?? false { + bottomView.ghostButton.setImage(ImageLiterals.Button.btnGhostDisabledLarge, for: .normal) + bottomView.ghostButton.isEnabled = false + } else { + bottomView.ghostButton.setImage(ImageLiterals.Button.btnGhostDefaultLarge, for: .normal) + bottomView.ghostButton.isEnabled = true + } + + let memberGhost = adjustGhostValue(data.memberGhost) + + grayView.alpha = data.isGhost ? 0.85 : CGFloat(Double(-memberGhost) / 100) + + } + + func changeButtonState(isLiked: Bool) { + bottomView.isLiked = isLiked + } + + private func setEventPublisher() { + let profileImageTapGesture = UITapGestureRecognizer() + let feedImageTapGesture = UITapGestureRecognizer() + + menuButton.tapPublisher + .sink { [weak self] in + self?.onMenuButtonTap?() + } + .store(in: cancelBag) + + profileImageView.gesturePublisher(profileImageTapGesture) + .sink { [weak self] _ in + self?.onProfileImageTap?() + } + .store(in: cancelBag) + + feedContentView.photoImageView.gesturePublisher(feedImageTapGesture) + .sink { [weak self] _ in + self?.onFeedImageTap?() + } + .store(in: cancelBag) + + bottomView.heartButton.tapPublisher + .sink { [weak self] in + self?.onHeartButtonTap?() + } + .store(in: cancelBag) + + bottomView.commentButton.tapPublisher + .sink { [weak self] in + self?.onCommentButtonTap?() + } + .store(in: cancelBag) + + bottomView.ghostButton.tapPublisher + .sink { [weak self] in + self?.onGhostButtonTap?() + } + .store(in: cancelBag) + } +} diff --git a/Wable-iOS/Presentation/Home/Views/HomeBottomSheetView.swift b/Wable-iOS/Presentation/Home/Views/HomeBottomSheetView.swift index d8a64f1..1fb2ed1 100644 --- a/Wable-iOS/Presentation/Home/Views/HomeBottomSheetView.swift +++ b/Wable-iOS/Presentation/Home/Views/HomeBottomSheetView.swift @@ -15,6 +15,7 @@ final class HomeBottomSheetView: UIView { var initialPosition: CGPoint = CGPoint(x: 0, y: 0) var isUser: Bool = true + var cancelBag: CancelBag? // MARK: - UI Components diff --git a/Wable-iOS/Presentation/Home/Views/MigratedHomeView.swift b/Wable-iOS/Presentation/Home/Views/MigratedHomeView.swift new file mode 100644 index 0000000..6b47a6e --- /dev/null +++ b/Wable-iOS/Presentation/Home/Views/MigratedHomeView.swift @@ -0,0 +1,93 @@ +// +// MigratedHomeView.swift +// Wable-iOS +// +// Created by 박윤빈 on 12/24/24. +// + +import UIKit + +import SnapKit + +final class MigratedHomeView: UIView { + + // MARK: - UI Components + + private let homeTabView = HomeTabView() + let loadingView = HomeLoadingView() + let writeFeedButton: UIButton = { + let button = UIButton() + button.setImage(ImageLiterals.Button.btnWrite, for: .normal) + return button + }() + + let collectionView: UICollectionView = { + let collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewLayout() + ) + + collectionView.showsVerticalScrollIndicator = false + collectionView.showsHorizontalScrollIndicator = false + collectionView.backgroundColor = .wableWhite + collectionView.refreshControl = UIRefreshControl() + return collectionView + }() + + // MARK: - Life Cycles + + override init(frame: CGRect) { + super.init(frame: frame) + + setUI() + setHierarchy() + setLayout() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Extensions + +private extension MigratedHomeView { + func setUI() { + backgroundColor = .wableWhite + loadingView.isHidden = true + } + + func setHierarchy() { + self.addSubviews( + homeTabView, + collectionView, + writeFeedButton, + loadingView + ) + } + + func setLayout() { + loadingView.snp.makeConstraints { + $0.horizontalEdges.equalToSuperview() + $0.top.equalToSuperview() + $0.height.equalTo(UIScreen.main.bounds.height) + } + + homeTabView.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.top.equalTo(safeAreaLayoutGuide) + } + + collectionView.snp.makeConstraints { + $0.top.equalTo(homeTabView.snp.bottom).offset(-2) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(safeAreaLayoutGuide) + } + + writeFeedButton.snp.makeConstraints { + $0.height.width.equalTo(60.adjusted) + $0.bottom.trailing.equalToSuperview().inset(16.adjusted) + } + } +} diff --git a/Wable-iOS/Presentation/Home/Views/Subviews/FeedBottomView.swift b/Wable-iOS/Presentation/Home/Views/Subviews/FeedBottomView.swift index 1dad694..3599aa4 100644 --- a/Wable-iOS/Presentation/Home/Views/Subviews/FeedBottomView.swift +++ b/Wable-iOS/Presentation/Home/Views/Subviews/FeedBottomView.swift @@ -119,8 +119,10 @@ extension FeedBottomView { commentButtonTapped?() } - func bind(heart: Int, comment: Int) { + func bind(heart: Int, comment: Int, memberID: Int) { heartButton.setTitleWithConfiguration("\(heart)", font: .caption1, textColor: .wableBlack) commentButton.setTitleWithConfiguration("\(comment)", font: .caption1, textColor: .wableBlack) + let isMine = memberID == loadUserData()?.memberId + ghostButton.isHidden = isMine } } diff --git a/Wable-iOS/Presentation/Home/Views/Subviews/FeedContentView.swift b/Wable-iOS/Presentation/Home/Views/Subviews/FeedContentView.swift index 1a90ccc..1ea6b76 100644 --- a/Wable-iOS/Presentation/Home/Views/Subviews/FeedContentView.swift +++ b/Wable-iOS/Presentation/Home/Views/Subviews/FeedContentView.swift @@ -229,7 +229,6 @@ extension FeedContentView { } } - // 탭 제스처 처리 함수 @objc func handleTitleLabelTap(_ gesture: UITapGestureRecognizer) { guard let attributedText = titleLabel.attributedText else { return } @@ -251,11 +250,13 @@ extension FeedContentView { } } - // 하이퍼링크가 아닌 부분을 클릭한 경우에만 `didSelectRowAt` 호출 - if !isLinkTapped, let tableView = self.superview(of: UITableView.self), let cell = self.superview(of: UITableViewCell.self), let indexPath = tableView.indexPath(for: cell) { - tableView.delegate?.tableView?(tableView, didSelectRowAt: indexPath) + if !isLinkTapped, + let collectionView = self.superview(of: UICollectionView.self), + let cell = self.superview(of: UICollectionViewCell.self), + let indexPath = collectionView.indexPath(for: cell) { + + collectionView.delegate?.collectionView?(collectionView, didSelectItemAt: indexPath) } - } @objc func handleContentLabelTap(_ gesture: UITapGestureRecognizer) { @@ -279,9 +280,12 @@ extension FeedContentView { } } - // 하이퍼링크가 아닌 부분을 클릭한 경우에만 `didSelectRowAt` 호출 - if !isLinkTapped, let tableView = self.superview(of: UITableView.self), let cell = self.superview(of: UITableViewCell.self), let indexPath = tableView.indexPath(for: cell) { - tableView.delegate?.tableView?(tableView, didSelectRowAt: indexPath) + if !isLinkTapped, + let collectionView = self.superview(of: UICollectionView.self), + let cell = self.superview(of: UICollectionViewCell.self), + let indexPath = collectionView.indexPath(for: cell) { + + collectionView.delegate?.collectionView?(collectionView, didSelectItemAt: indexPath) } } } diff --git a/Wable-iOS/Presentation/Login/ViewController/LoginViewController.swift b/Wable-iOS/Presentation/Login/ViewController/LoginViewController.swift index 50b59c7..3892092 100644 --- a/Wable-iOS/Presentation/Login/ViewController/LoginViewController.swift +++ b/Wable-iOS/Presentation/Login/ViewController/LoginViewController.swift @@ -194,7 +194,7 @@ extension LoginViewController { } extension LoginViewController: WablePopupDelegate { - func cancleButtonTapped() { + func cancelButtonTapped() { } diff --git a/Wable-iOS/Presentation/MyPage/ViewController/MyPagePostViewController.swift b/Wable-iOS/Presentation/MyPage/ViewController/MyPagePostViewController.swift index f12f89f..621c399 100644 --- a/Wable-iOS/Presentation/MyPage/ViewController/MyPagePostViewController.swift +++ b/Wable-iOS/Presentation/MyPage/ViewController/MyPagePostViewController.swift @@ -435,7 +435,7 @@ extension MyPagePostViewController: UIScrollViewDelegate { extension MyPagePostViewController: WablePopupDelegate { - func cancleButtonTapped() { + func cancelButtonTapped() { // if ghostPopupView != nil { // self.ghostPopupView?.removeFromSuperview() // } diff --git a/Wable-iOS/Presentation/MyPage/ViewController/MyPageReplyViewController.swift b/Wable-iOS/Presentation/MyPage/ViewController/MyPageReplyViewController.swift index f092328..4e9c4f9 100644 --- a/Wable-iOS/Presentation/MyPage/ViewController/MyPageReplyViewController.swift +++ b/Wable-iOS/Presentation/MyPage/ViewController/MyPageReplyViewController.swift @@ -414,7 +414,7 @@ extension MyPageReplyViewController: UIScrollViewDelegate { extension MyPageReplyViewController: WablePopupDelegate { - func cancleButtonTapped() { + func cancelButtonTapped() { // if ghostPopupView != nil { // self.ghostPopupView?.removeFromSuperview() // } diff --git a/Wable-iOS/Presentation/MyPage/ViewController/MyPageSignOutConfirmViewController.swift b/Wable-iOS/Presentation/MyPage/ViewController/MyPageSignOutConfirmViewController.swift index 3171e80..a6a8af0 100644 --- a/Wable-iOS/Presentation/MyPage/ViewController/MyPageSignOutConfirmViewController.swift +++ b/Wable-iOS/Presentation/MyPage/ViewController/MyPageSignOutConfirmViewController.swift @@ -202,7 +202,7 @@ extension MyPageSignOutConfirmViewController { } extension MyPageSignOutConfirmViewController: WablePopupDelegate { - func cancleButtonTapped() { + func cancelButtonTapped() { self.signOutPopupView.isHidden = true } diff --git a/Wable-iOS/Presentation/MyPage/ViewController/MyPageViewController.swift b/Wable-iOS/Presentation/MyPage/ViewController/MyPageViewController.swift index 2ff993c..4981de1 100644 --- a/Wable-iOS/Presentation/MyPage/ViewController/MyPageViewController.swift +++ b/Wable-iOS/Presentation/MyPage/ViewController/MyPageViewController.swift @@ -667,7 +667,7 @@ extension MyPageViewController: UICollectionViewDelegate, UITableViewDelegate { } extension MyPageViewController: WablePopupDelegate { - func cancleButtonTapped() { + func cancelButtonTapped() { if ghostPopupView != nil { self.ghostPopupView?.removeFromSuperview() } diff --git a/Wable-iOS/Presentation/TabBar/WableTabBarController.swift b/Wable-iOS/Presentation/TabBar/WableTabBarController.swift index a831e3b..521a115 100644 --- a/Wable-iOS/Presentation/TabBar/WableTabBarController.swift +++ b/Wable-iOS/Presentation/TabBar/WableTabBarController.swift @@ -129,7 +129,7 @@ extension WableTabBarController: UITabBarControllerDelegate { case 0: if let navController = viewController as? UINavigationController, - let homeVC = navController.viewControllers.first as? HomeViewController { + let homeVC = navController.viewControllers.first as? MigratedHomeViewController { homeVC.scrollToTop() if previousTabIndex == 3 { homeVC.showLoadView() diff --git a/Wable-iOS/Presentation/TabBar/WableTabBarItem.swift b/Wable-iOS/Presentation/TabBar/WableTabBarItem.swift index a635faf..e0f2db3 100644 --- a/Wable-iOS/Presentation/TabBar/WableTabBarItem.swift +++ b/Wable-iOS/Presentation/TabBar/WableTabBarItem.swift @@ -43,10 +43,14 @@ enum WableTabBarItem: CaseIterable { var targetViewController: UIViewController? { switch self { - case .home: return HomeViewController(viewModel: HomeViewModel(networkProvider: NetworkService()), likeViewModel: LikeViewModel(networkProvider: NetworkService())) - case .info: return InfoViewController() - case .noti: return NotificationViewController() - case .my: return MyPageViewController(viewModel: MyPageViewModel(networkProvider: NetworkService()), likeViewModel: LikeViewModel(networkProvider: NetworkService())) + case .home: + return MigratedHomeViewController(viewModel: MigratedHomeViewModel()) + case .info: + return InfoViewController() + case .noti: + return NotificationViewController() + case .my: + return MyPageViewController(viewModel: MyPageViewModel(networkProvider: NetworkService()), likeViewModel: LikeViewModel(networkProvider: NetworkService())) } } } diff --git a/Wable-iOS/Presentation/UIComponents/WablePopupView.swift b/Wable-iOS/Presentation/UIComponents/WablePopupView.swift index 9d993d8..094ddf9 100644 --- a/Wable-iOS/Presentation/UIComponents/WablePopupView.swift +++ b/Wable-iOS/Presentation/UIComponents/WablePopupView.swift @@ -10,7 +10,7 @@ import UIKit import SnapKit protocol WablePopupDelegate: AnyObject { - func cancleButtonTapped() + func cancelButtonTapped() func confirmButtonTapped() func singleButtonTapped() } @@ -20,6 +20,8 @@ final class WablePopupView: UIView { // MARK: - Properties weak var delegate: WablePopupDelegate? + private var cancelBag = CancelBag() + var popupType: PopupViewType // MARK: - UI Components @@ -85,6 +87,8 @@ final class WablePopupView: UIView { // MARK: - Life Cycles init(popupTitle: String, popupContent: String, leftButtonTitle: String, rightButtonTitle: String) { + self.popupType = .ban + super.init(frame: .zero) popupTitleLabel.setTextWithLineHeight(text: popupTitle, lineHeight: 28.8.adjusted, alignment: .center) @@ -99,6 +103,8 @@ final class WablePopupView: UIView { } init(popupTitle: String, popupContent: String, singleButtonTitle: String) { + self.popupType = .ban + super.init(frame: .zero) popupTitleLabel.setTextWithLineHeight(text: popupTitle, lineHeight: 28.8.adjusted, alignment: .center) @@ -111,6 +117,17 @@ final class WablePopupView: UIView { setSingleAddTarget() } + init(popupType: PopupViewType) { + self.popupType = popupType + + super.init(frame: .zero) + configurePopup(type: popupType) + setUI() + setHierarchy() + setLayout() + setAddTarget() + } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") @@ -119,7 +136,7 @@ final class WablePopupView: UIView { // MARK: - Extensions -extension WablePopupView { +private extension WablePopupView { func setUI() { self.backgroundColor = .wableBlack.withAlphaComponent(0.5) } @@ -127,7 +144,6 @@ extension WablePopupView { func setHierarchy() { self.addSubview(container) - // 팝업뷰 내용이 없는 경우 if popupContentLabel.text == "" { container.addSubviews(popupTitleLabel, buttonStackView) } else { @@ -149,7 +165,6 @@ extension WablePopupView { $0.height.equalTo(48.adjusted) } - // 팝업뷰 내용이 없는 경우 if popupContentLabel.text == "" { popupTitleLabel.snp.makeConstraints { $0.top.equalToSuperview().inset(32.adjusted) @@ -177,14 +192,24 @@ extension WablePopupView { } func setAddTarget() { - self.cancleButton.addTarget(self, action: #selector(cancleButtonTapped), for: .touchUpInside) + self.cancleButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) self.confirmButton.addTarget(self, action: #selector(confirmButtonTapped), for: .touchUpInside) } + func configurePopup(type: PopupViewType) { + popupTitleLabel.setTextWithLineHeight( + text: type.title, + lineHeight: 28.8.adjusted, + alignment: .center + ) + popupContentLabel.text = type.content + cancleButton.setTitle(type.leftButtonTitle, for: .normal) + confirmButton.setTitle(type.rightButtonTitle, for: .normal) + } + func setSingleHierarchy() { self.addSubview(container) - // 팝업뷰 내용이 없는 경우 if popupContentLabel.text == "" { container.addSubviews(popupTitleLabel, buttonStackView) } else { @@ -237,8 +262,8 @@ extension WablePopupView { } @objc - func cancleButtonTapped() { - delegate?.cancleButtonTapped() + func cancelButtonTapped() { + delegate?.cancelButtonTapped() } @objc diff --git a/Wable-iOS/Presentation/Write/ViewController/WriteViewController.swift b/Wable-iOS/Presentation/Write/ViewController/WriteViewController.swift index 0ac16f5..a2ddbd1 100644 --- a/Wable-iOS/Presentation/Write/ViewController/WriteViewController.swift +++ b/Wable-iOS/Presentation/Write/ViewController/WriteViewController.swift @@ -20,6 +20,7 @@ final class WriteViewController: UIViewController { private var cancelBag = CancelBag() private let viewModel: WriteViewModel private var transparency: Int = 0 + var writeViewDidDisappear: (() -> Void)? private lazy var postButtonTapped = self.rootView.writeTextView.postButton.publisher(for: .touchUpInside) .debounce(for: .seconds(0.5), scheduler: RunLoop.main) @@ -93,7 +94,7 @@ final class WriteViewController: UIViewController { self.tabBarController?.tabBar.isTranslucent = false NotificationCenter.default.removeObserver(self, name: UITextView.textDidChangeNotification, object: nil) - + writeViewDidDisappear?() } } @@ -279,7 +280,7 @@ extension WriteViewController: PHPickerViewControllerDelegate { } extension WriteViewController: WablePopupDelegate { - func cancleButtonTapped() { + func cancelButtonTapped() { self.writeCanclePopupView?.removeFromSuperview() }