diff --git a/SOOUM/.swiftlint.yml b/SOOUM/.swiftlint.yml index 36b8548f..31a18bd0 100644 --- a/SOOUM/.swiftlint.yml +++ b/SOOUM/.swiftlint.yml @@ -35,7 +35,7 @@ disabled_rules: - inclusive_language # - inert_defer # - is_disjoint - # - large_tuple + - large_tuple # - leading_whitespace ⭕️ # - legacy_cggeometry_functions ⭕️ # - legacy_constant ⭕️ @@ -158,6 +158,7 @@ excluded: - SOOUM/Resources - SOOUM/Models - SOOUM-DevTests + - SOOUM/DesignSystem/Foundations # 👉🏻 Options # line_length: 100 diff --git a/SOOUM/SOOUM.xcodeproj/project.pbxproj b/SOOUM/SOOUM.xcodeproj/project.pbxproj index 7116e355..8f589bed 100644 --- a/SOOUM/SOOUM.xcodeproj/project.pbxproj +++ b/SOOUM/SOOUM.xcodeproj/project.pbxproj @@ -33,10 +33,8 @@ 2A980B9E2D803E9D007DFA45 /* FirebaseLoggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980B9C2D803E9D007DFA45 /* FirebaseLoggable.swift */; }; 2A980BA02D803EB1007DFA45 /* AnalyticsEventProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980B9F2D803EB1007DFA45 /* AnalyticsEventProtocol.swift */; }; 2A980BA12D803EB1007DFA45 /* AnalyticsEventProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980B9F2D803EB1007DFA45 /* AnalyticsEventProtocol.swift */; }; - 2A980BA42D803EEA007DFA45 /* SOMEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980BA32D803EE2007DFA45 /* SOMEvent.swift */; }; - 2A980BA52D803EEA007DFA45 /* SOMEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980BA32D803EE2007DFA45 /* SOMEvent.swift */; }; - 2A980BA82D803F04007DFA45 /* GAManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980BA72D803F04007DFA45 /* GAManager.swift */; }; - 2A980BA92D803F04007DFA45 /* GAManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980BA72D803F04007DFA45 /* GAManager.swift */; }; + 2A980BA82D803F04007DFA45 /* GAHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980BA72D803F04007DFA45 /* GAHelper.swift */; }; + 2A980BA92D803F04007DFA45 /* GAHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A980BA72D803F04007DFA45 /* GAHelper.swift */; }; 2AE6B1492CBC15BF00FA5C3C /* ReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1482CBC15BF00FA5C3C /* ReportViewController.swift */; }; 2AE6B14A2CBC15BF00FA5C3C /* ReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B1482CBC15BF00FA5C3C /* ReportViewController.swift */; }; 2AE6B14C2CBC160C00FA5C3C /* ReportViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE6B14B2CBC160C00FA5C3C /* ReportViewReactor.swift */; }; @@ -296,6 +294,8 @@ 3879B4B62EC5AD5E0070846B /* RejoinableDateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B4B42EC5AD580070846B /* RejoinableDateInfo.swift */; }; 3879B4B82EC5ADC50070846B /* RejoinableDateInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B4B72EC5ADBF0070846B /* RejoinableDateInfoResponse.swift */; }; 3879B4B92EC5ADC50070846B /* RejoinableDateInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B4B72EC5ADBF0070846B /* RejoinableDateInfoResponse.swift */; }; + 387B73892EED71510055E384 /* GAEvent+SOOUM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387B73882EED71470055E384 /* GAEvent+SOOUM.swift */; }; + 387B738A2EED71510055E384 /* GAEvent+SOOUM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387B73882EED71470055E384 /* GAEvent+SOOUM.swift */; }; 387FA11D2E88DDC1004DF7CE /* HomeViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387FA11C2E88DDBD004DF7CE /* HomeViewReactor.swift */; }; 387FA11E2E88DDC1004DF7CE /* HomeViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387FA11C2E88DDBD004DF7CE /* HomeViewReactor.swift */; }; 387FBAF02C8702C100A5E139 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387FBAEF2C8702C100A5E139 /* AppDelegate.swift */; }; @@ -863,8 +863,7 @@ 2A649ECE2CAE8970002D8284 /* SOMDialogViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMDialogViewController.swift; sourceTree = ""; }; 2A980B9C2D803E9D007DFA45 /* FirebaseLoggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseLoggable.swift; sourceTree = ""; }; 2A980B9F2D803EB1007DFA45 /* AnalyticsEventProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEventProtocol.swift; sourceTree = ""; }; - 2A980BA32D803EE2007DFA45 /* SOMEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMEvent.swift; sourceTree = ""; }; - 2A980BA72D803F04007DFA45 /* GAManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GAManager.swift; sourceTree = ""; }; + 2A980BA72D803F04007DFA45 /* GAHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GAHelper.swift; sourceTree = ""; }; 2AE6B1482CBC15BF00FA5C3C /* ReportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewController.swift; sourceTree = ""; }; 2AE6B14B2CBC160C00FA5C3C /* ReportViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewReactor.swift; sourceTree = ""; }; 2AFD054B2CFF76CB007C84AD /* TagRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagRequest.swift; sourceTree = ""; }; @@ -1000,6 +999,7 @@ 3878FE0C2D0365C800D8955C /* SOMNavigationBar+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SOMNavigationBar+Rx.swift"; sourceTree = ""; }; 3879B4B42EC5AD580070846B /* RejoinableDateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejoinableDateInfo.swift; sourceTree = ""; }; 3879B4B72EC5ADBF0070846B /* RejoinableDateInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejoinableDateInfoResponse.swift; sourceTree = ""; }; + 387B73882EED71470055E384 /* GAEvent+SOOUM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GAEvent+SOOUM.swift"; sourceTree = ""; }; 387FA11C2E88DDBD004DF7CE /* HomeViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewReactor.swift; sourceTree = ""; }; 387FBAEC2C8702C100A5E139 /* SOOUM.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SOOUM.app; sourceTree = BUILT_PRODUCTS_DIR; }; 387FBAEF2C8702C100A5E139 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -1384,15 +1384,14 @@ path = Views; sourceTree = ""; }; - 2A980B9B2D803E8B007DFA45 /* GAManager */ = { + 2A980B9B2D803E8B007DFA45 /* GAHelper */ = { isa = PBXGroup; children = ( - 2A980BA72D803F04007DFA45 /* GAManager.swift */, - 2A980BA32D803EE2007DFA45 /* SOMEvent.swift */, + 2A980BA72D803F04007DFA45 /* GAHelper.swift */, 2A980B9F2D803EB1007DFA45 /* AnalyticsEventProtocol.swift */, 2A980B9C2D803E9D007DFA45 /* FirebaseLoggable.swift */, ); - path = GAManager; + path = GAHelper; sourceTree = ""; }; 2AE6B1472CBC157200FA5C3C /* Report */ = { @@ -1615,6 +1614,7 @@ isa = PBXGroup; children = ( 385620F42CA19E8600E0AB5A /* Alamofire */, + 2A980B9B2D803E8B007DFA45 /* GAHelper */, 388D8ADD2E73E60B0044BA79 /* SwiftEntryKit */, 3836ACB22C8F043500A3C566 /* Typography */, 38D5CE0A2CBCE8CA0054AB9A /* SimpleDefaults.swift */, @@ -1717,7 +1717,6 @@ 385620EC2CA19C0D00E0AB5A /* Managers */ = { isa = PBXGroup; children = ( - 2A980B9B2D803E8B007DFA45 /* GAManager */, 3893B6D02D36739500F2004C /* CompositeManager.swift */, 38B543ED2D46506300DDF2C5 /* ManagerType.swift */, 3893B6CD2D36728000F2004C /* ManagerProvider.swift */, @@ -2068,6 +2067,7 @@ 388DA1032C8F545E00A9DD56 /* Typography+SOOUM.swift */, 388371FB2C8C8F11004212EB /* UIColor+SOOUM.swift */, 38A5D1532C8CB11E00B68363 /* UIImage+SOOUM.swift */, + 387B73882EED71470055E384 /* GAEvent+SOOUM.swift */, ); path = Foundations; sourceTree = ""; @@ -3170,7 +3170,6 @@ 38A721962E73E7140071E1D8 /* View+SwiftEntryKit.swift in Sources */, 380F422B2E884E9C009AC59E /* HomeCardInfoResponse.swift in Sources */, 388698632D1986B100008600 /* NotificationRequest.swift in Sources */, - 2A980BA52D803EEA007DFA45 /* SOMEvent.swift in Sources */, 38E928DA2EB7727400B3F00B /* SOMBottomToastView.swift in Sources */, 3803B9232ECF52CE009D14B9 /* TagCollectCardViewCell.swift in Sources */, 38B65E7C2E72ADB900DF6919 /* TermsOfServiceAgreeButtonView+Rx.swift in Sources */, @@ -3296,7 +3295,7 @@ 381E7C1A2ECCB1A700E80249 /* TagViewController.swift in Sources */, 38FEBE5B2E8652DE002916A8 /* CompositeNotificationInfoResponse.swift in Sources */, 38D8F55E2EC4F38700DED428 /* SimpleReachability.swift in Sources */, - 2A980BA92D803F04007DFA45 /* GAManager.swift in Sources */, + 2A980BA92D803F04007DFA45 /* GAHelper.swift in Sources */, 38B21C082ECEF7D400990F49 /* PopularTagViewCell.swift in Sources */, 3880EF782EA0CF2F00D88608 /* RelatedTagsViewLayout.swift in Sources */, 381B83F32EBCEC2E00C84015 /* ProfileUserViewCell.swift in Sources */, @@ -3454,6 +3453,7 @@ 389E59E12EDEEFA500D0946D /* UpdateTagFavoriteUseCase.swift in Sources */, 383EC6152E7A50EB00EC2D1E /* AuthLocalDataSourceImpl.swift in Sources */, 38D8F5592EC4D89D00DED428 /* TagNofificationInfoResponse.swift in Sources */, + 387B738A2EED71510055E384 /* GAEvent+SOOUM.swift in Sources */, 38C9AF302E96A49F00B401C0 /* WriteCardTag.swift in Sources */, 38AE85312EDFFA9500029E4C /* UpdateCardLikeUseCaseImpl.swift in Sources */, 389E59D52EDEEE6B00D0946D /* UpdateUserInfoUseCase.swift in Sources */, @@ -3596,7 +3596,6 @@ 380F422A2E884E9C009AC59E /* HomeCardInfoResponse.swift in Sources */, 38FDC2B62C9E746B00C094C2 /* BaseViewController.swift in Sources */, 3803B9242ECF52CE009D14B9 /* TagCollectCardViewCell.swift in Sources */, - 2A980BA42D803EEA007DFA45 /* SOMEvent.swift in Sources */, 38B65E7D2E72ADB900DF6919 /* TermsOfServiceAgreeButtonView+Rx.swift in Sources */, 38E928D92EB7727400B3F00B /* SOMBottomToastView.swift in Sources */, 389E59A92EDEE6F600D0946D /* ValidateNicknameUseCase.swift in Sources */, @@ -3722,7 +3721,7 @@ 38C2A7DE2EC0704700B941A2 /* SettingsRemoteDataSource.swift in Sources */, 38B21C092ECEF7D400990F49 /* PopularTagViewCell.swift in Sources */, 38FEBE5C2E8652DE002916A8 /* CompositeNotificationInfoResponse.swift in Sources */, - 2A980BA82D803F04007DFA45 /* GAManager.swift in Sources */, + 2A980BA82D803F04007DFA45 /* GAHelper.swift in Sources */, 38E928DD2EB7921200B3F00B /* UIRefreshControl.swift in Sources */, 38FEBE652E8662A3002916A8 /* NoticeInfoResponse.swift in Sources */, 381B83DC2EBC707A00C84015 /* ProfileInfo.swift in Sources */, @@ -3877,6 +3876,7 @@ 389E59E22EDEEFA500D0946D /* UpdateTagFavoriteUseCase.swift in Sources */, 38B543EB2D461B1A00DDF2C5 /* LocationManagerConfigruation.swift in Sources */, 38026E3F2CA2B45A0045E1CE /* LocationManager.swift in Sources */, + 387B73892EED71510055E384 /* GAEvent+SOOUM.swift in Sources */, 383EC6162E7A50EB00EC2D1E /* AuthLocalDataSourceImpl.swift in Sources */, 38AE85302EDFFA9500029E4C /* UpdateCardLikeUseCaseImpl.swift in Sources */, 389E59D62EDEEE6B00D0946D /* UpdateUserInfoUseCase.swift in Sources */, @@ -3942,7 +3942,7 @@ CODE_SIGN_ENTITLEMENTS = "SOOUM/Resources/SOOUM-Dev.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1022030; + CURRENT_PROJECT_VERSION = 1023000; DEVELOPMENT_TEAM = 99FRG743RX; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SOOUM/Resources/Develop/Info-dev.plist"; @@ -3965,13 +3965,13 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.22.3; + MARKETING_VERSION = 1.23.0; OTHER_SWIFT_FLAGS = "$(inherited) -D DEVELOP"; PRODUCT_BUNDLE_IDENTIFIER = com.sooum.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SOOUM_APP_ID = 99FRG743RX.com.sooum.dev; - SOOUM_CLARITY_ID = qrggvyniav; + SOOUM_CLARITY_ID = ukvzck1lyo; SOOUM_DISPLAY_NAME = "[D]SOOUM"; SOOUM_LOCATION_DESCRIPTION = "사용자의 위치 기반으로 피드 정보를 제공하기 위해 권한이 필요합니다."; SOOUM_SERVER_ENDPOINT = "test-core.sooum.org:555"; @@ -3994,7 +3994,7 @@ CODE_SIGN_ENTITLEMENTS = "SOOUM/Resources/SOOUM-Dev.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1022030; + CURRENT_PROJECT_VERSION = 1023000; DEVELOPMENT_TEAM = 99FRG743RX; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SOOUM/Resources/Develop/Info-dev.plist"; @@ -4017,13 +4017,13 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.22.3; + MARKETING_VERSION = 1.23.0; OTHER_SWIFT_FLAGS = "$(inherited) -D DEVELOP"; PRODUCT_BUNDLE_IDENTIFIER = com.sooum.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SOOUM_APP_ID = 99FRG743RX.com.sooum.dev; - SOOUM_CLARITY_ID = qrggvyniav; + SOOUM_CLARITY_ID = ukvzck1lyo; SOOUM_DISPLAY_NAME = "[D]SOOUM"; SOOUM_LOCATION_DESCRIPTION = "사용자의 위치 기반으로 피드 정보를 제공하기 위해 권한이 필요합니다."; SOOUM_SERVER_ENDPOINT = "test-core.sooum.org:555"; @@ -4260,7 +4260,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.sooum.prod; PRODUCT_NAME = "$(TARGET_NAME)"; SOOUM_APP_ID = 6740403078; - SOOUM_CLARITY_ID = qrghkiok4a; + SOOUM_CLARITY_ID = ukvz4wjnqm; SOOUM_DISPLAY_NAME = "숨"; SOOUM_LOCATION_DESCRIPTION = "사용자의 위치 기반으로 피드 정보를 제공하기 위해 권한이 필요합니다."; SOOUM_SERVER_ENDPOINT = api.sooum.org; @@ -4308,7 +4308,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.sooum.prod; PRODUCT_NAME = "$(TARGET_NAME)"; SOOUM_APP_ID = 6740403078; - SOOUM_CLARITY_ID = qrghkiok4a; + SOOUM_CLARITY_ID = ukvz4wjnqm; SOOUM_DISPLAY_NAME = "숨"; SOOUM_LOCATION_DESCRIPTION = "사용자의 위치 기반으로 피드 정보를 제공하기 위해 권한이 필요합니다."; SOOUM_SERVER_ENDPOINT = api.sooum.org; diff --git a/SOOUM/SOOUM/Data/Managers/GAManager/AnalyticsEventProtocol.swift b/SOOUM/SOOUM/Data/Managers/GAManager/AnalyticsEventProtocol.swift deleted file mode 100644 index d943f8be..00000000 --- a/SOOUM/SOOUM/Data/Managers/GAManager/AnalyticsEventProtocol.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// AnalyticsEventProtocol.swift -// SOOUM -// -// Created by JDeoks on 3/11/25. -// - -protocol AnalyticsEventProtocol { - var eventName: String { get } - var parameters: [String: FirebaseLoggable]? { get } -} - -extension AnalyticsEventProtocol { - - var eventName: String { - let enumName = String(describing: type(of: self)) // "Home" - let caseName = "\(self)".components(separatedBy: "(").first ?? "" // "fetchDefectList" - return "\(enumName)_\(caseName)" // "Home_fetchDefectList" - } - - var parameters: [String: FirebaseLoggable]? { - // (1) 우선 "self"를 미러링 -> enum의 유일한 자식(child)이 "someEvent"라는 케이스 - let paramDict = Mirror(reflecting: self).children.reduce(into: [String: FirebaseLoggable]()) { dict, child in - guard let caseLabel = child.label else { - return - } - - let caseValue = child.value - let caseMirror = Mirror(reflecting: caseValue) - if caseMirror.displayStyle == .tuple { - // 🔑 "someEvent(num: 2, text: \"테스트\")" 이런 형태로 들어옴 - - // (3) 튜플 안에 있는 각 연관값( num: 2, text: "테스트" )을 순회 - for paramChild in caseMirror.children { - guard let paramLabel = paramChild.label else { continue } - let paramValue = paramChild.value - - // (4) FirebaseLoggable 등 타입 검사 - if paramValue is FirebaseLoggable { - dict[paramLabel] = paramValue as? any FirebaseLoggable - } - } - } else if caseValue is FirebaseLoggable { - // (단일 파라미터인 경우) - dict[caseLabel] = caseValue as? any FirebaseLoggable - } - } - - return paramDict.isEmpty ? nil : paramDict - } -} diff --git a/SOOUM/SOOUM/Data/Managers/GAManager/GAManager.swift b/SOOUM/SOOUM/Data/Managers/GAManager/GAManager.swift deleted file mode 100644 index ecaca0b4..00000000 --- a/SOOUM/SOOUM/Data/Managers/GAManager/GAManager.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// GAManager.swift -// SOOUM -// -// Created by JDeoks on 3/11/25. -// - -import FirebaseAnalytics - -class GAManager { - - static let shared = GAManager() - - private init() { } - - func logEvent(event: AnalyticsEventProtocol) { - Analytics.logEvent(event.eventName, parameters: event.parameters) - } -} diff --git a/SOOUM/SOOUM/Data/Managers/GAManager/SOMEvent.swift b/SOOUM/SOOUM/Data/Managers/GAManager/SOMEvent.swift deleted file mode 100644 index 38ed1d43..00000000 --- a/SOOUM/SOOUM/Data/Managers/GAManager/SOMEvent.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// SOMEvent.swift -// SOOUM -// -// Created by JDeoks on 3/11/25. -// - -enum SOMEvent { - enum WriteCard: AnalyticsEventProtocol { - /// 글쓰기 화면에서 태그를 추가하고 글을 작성하지 않음 - case dismiss_with_tag(tag_count: Int, tag_texts: [String]) - /// 글쓰기 화면에서 태그를 추가하고 글을 작성 - case add_tag(tag_count: Int, tag_texts: [String]) - } - - enum Tag: AnalyticsEventProtocol { - /// 태그를 클릭한 위치 - enum ClickPositionKey { - /// 카드 상세화면에서 태그 클릭 - static let post = "post" - /// 즐겨찾기 태그 목록에서 태그 클릭 - static let favorite = "favorite" - /// 즐겨찾기 태그 목록의 미리보기 카드 클릭 - static let favorite_preview = "favorite_preview" - /// 추천 태그 목록에서 태그 클릭 - static let recommendation = "recommendation" - /// 태그 검색 결과에서 태그 클릭 - static let search_result = "search_result" - } - /// 태그를 클릭 - case tag_click(tag_text: String, click_position: String) - } - - enum Comment: AnalyticsEventProtocol { - /// 사용자가 댓글을 작성 - /// - /// - Parameters: - /// - comment_length: 댓글 길이 - /// - parent_post_id: 부모 글 ID - /// - image_attached: 이미지 첨부 여부 - case add_comment(comment_length: Int, parent_post_id: String, image_attached: Bool) - } -} diff --git a/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/ErrorInterceptor.swift b/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/ErrorInterceptor.swift index cdaf67d8..4b857a38 100644 --- a/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/ErrorInterceptor.swift +++ b/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/ErrorInterceptor.swift @@ -127,7 +127,7 @@ final class ErrorInterceptor: RequestInterceptor { title: Text.confirmActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) @@ -147,7 +147,7 @@ final class ErrorInterceptor: RequestInterceptor { title: Text.closeActionButtonTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) @@ -155,7 +155,7 @@ final class ErrorInterceptor: RequestInterceptor { title: Text.inquiryActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { let subject = Text.inquiryMailTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let guideMessage = """ \(Text.identificationInfo) diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMCard.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMCard.swift index 00fecaff..00de5313 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMCard.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMCard.swift @@ -32,12 +32,16 @@ class SOMCard: UIView { $0.layer.cornerRadius = 16 } + private let borderBackgroundView = UIView().then { + $0.backgroundColor = .som.v2.white + $0.layer.cornerRadius = 16 + $0.layer.borderWidth = 1 + $0.clipsToBounds = true + } + /// 배경 이미지 private let rootContainerImageView = UIImageView().then { $0.contentMode = .scaleAspectFill - $0.layer.cornerRadius = 16 - $0.layer.borderWidth = 1 - $0.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] $0.layer.masksToBounds = true } @@ -61,10 +65,6 @@ class SOMCard: UIView { /// 펑 시간, 거리, 시간, 좋아요 수, 답글 수 정보를 담는 뷰 private let cardInfoContainer = UIView().then { $0.backgroundColor = .som.v2.white - $0.layer.cornerRadius = 16 - $0.layer.borderWidth = 1 - $0.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] - $0.layer.masksToBounds = true } /// 펑 시간, 거리, 시간을 담는 스택 뷰 private let cardInfoLeadingStackView = UIStackView().then { @@ -109,7 +109,7 @@ class SOMCard: UIView { } /// 펑 남은시간 표시 아이콘 private let cardPungTimeImageView = UIImageView().then { - $0.image = .init(.icon(.v2(.filled(.bomb)))) + $0.image = .init(.icon(.v2(.outlined(.timer)))) $0.tintColor = .som.v2.pMain } /// 펑 남은시간 표시 라벨 @@ -236,15 +236,20 @@ class SOMCard: UIView { $0.edges.equalToSuperview() } + self.addSubview(self.borderBackgroundView) + self.borderBackgroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + // 배경 이미지 뷰 - self.addSubview(self.rootContainerImageView) + self.borderBackgroundView.addSubview(self.rootContainerImageView) self.rootContainerImageView.snp.makeConstraints { $0.top.horizontalEdges.equalToSuperview() $0.bottom.equalToSuperview().offset(-34) } // 하단 카드 정보 컨테이너 - self.addSubview(self.cardInfoContainer) + self.borderBackgroundView.addSubview(self.cardInfoContainer) self.cardInfoContainer.snp.makeConstraints { $0.bottom.horizontalEdges.equalToSuperview() $0.height.equalTo(34) @@ -361,9 +366,10 @@ class SOMCard: UIView { self.model = model let borderColor = model.isAdminCard ? UIColor.som.v2.pMain : UIColor.som.v2.gray100 + self.borderBackgroundView.layer.borderColor = borderColor.cgColor + // 카드 배경 이미지 self.rootContainerImageView.setImage(strUrl: model.cardImgURL, with: model.cardImgName) - self.rootContainerImageView.layer.borderColor = borderColor.cgColor // 카드 본문 let typography: Typography @@ -379,8 +385,6 @@ class SOMCard: UIView { // 하단 정보 // 어드민, 펑 시간, 거리, 시간 - self.cardInfoContainer.layer.borderColor = borderColor.cgColor - self.adminStackView.isHidden = model.isAdminCard == false self.firstDot.isHidden = model.isAdminCard == false self.cardPungTimeStackView.isHidden = model.storyExpirationTime == nil @@ -446,12 +450,12 @@ class SOMCard: UIView { .map { object, _ in guard let pungTime = pungTime else { object.serialTimer?.dispose() - return "00 : 00 : 00" + return "00:00:00" } let currentDate = Date() let remainingTime = currentDate.infoReadableTimeTakenFromThisForPung(to: pungTime) - if remainingTime == "00 : 00 : 00" { + if remainingTime == "00:00:00" { object.serialTimer?.dispose() object.updatePungUI() } @@ -463,16 +467,15 @@ class SOMCard: UIView { /// 펑 ui 즉각적으로 업데이트 private func updatePungUI() { - self.cardPungTimeLabel.text = "00 : 00 : 00" - self.rootContainerImageView.layer.borderWidth = 0 + self.cardPungTimeLabel.text = "00:00:00" self.rootContainerImageView.image = UIColor.som.v2.gray200.toImage - self.cardInfoContainer.layer.borderWidth = 0 self.cardInfoContainer.subviews .filter { $0 != self.cardInfoLeadingStackView } .forEach { $0.removeFromSuperview() } self.cardInfoLeadingStackView.subviews .filter { $0 != self.cardPungTimeStackView } .forEach { $0.removeFromSuperview() } + self.borderBackgroundView.removeFromSuperview() self.cardTextContentLabel.text = Text.pungedCardText self.updateContentHeight(Text.pungedCardText, with: .som.v2.body1) diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogAction.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogAction.swift index 76bfe96b..115265c6 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogAction.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogAction.swift @@ -7,8 +7,7 @@ import UIKit - -class SOMDialogAction { +final class SOMDialogAction { enum Style { case primary diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController+Show.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController+Show.swift index 9aaed8a9..bac0b68a 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController+Show.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController+Show.swift @@ -8,9 +8,11 @@ import UIKit +// MARK: Show + extension SOMDialogViewController { - private static weak var displayedDialogViewController: SOMDialogViewController? + fileprivate static weak var displayedDialogViewController: SOMDialogViewController? @discardableResult static func show( @@ -116,3 +118,21 @@ extension SOMDialogViewController { return dialogViewController } } + + +// MARK: Dismiss + +extension SOMDialogViewController { + + static func dismiss(animated: Bool = true, completion: (() -> Void)? = nil) { + + guard let dialog = self.displayedDialogViewController else { + completion?() + return + } + + self.displayedDialogViewController = nil + + dialog.dismiss(animated: animated) { completion?() } + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController.swift index 551033e4..bf869995 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController.swift @@ -10,8 +10,7 @@ import UIKit import SnapKit import Then - -class SOMDialogViewController: UIViewController { +final class SOMDialogViewController: UIViewController { // MARK: Views diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarController.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarController.swift index 45dfd04e..1f91632a 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarController.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarController.swift @@ -57,7 +57,7 @@ class SOMTabBarController: UIViewController { var hasFirstLaunchGuide: Bool = true { didSet { - if hasFirstLaunchGuide == false { + if self.hasFirstLaunchGuide == false { self.messageBubbleView.removeFromSuperview() } } diff --git a/SOOUM/SOOUM/DesignSystem/Foundations/GAEvent+SOOUM.swift b/SOOUM/SOOUM/DesignSystem/Foundations/GAEvent+SOOUM.swift new file mode 100644 index 00000000..e1d8e431 --- /dev/null +++ b/SOOUM/SOOUM/DesignSystem/Foundations/GAEvent+SOOUM.swift @@ -0,0 +1,130 @@ +// +// GAEvent+SOOUM.swift +// SOOUM +// +// Created by 오현식 on 12/13/25. +// + +enum GAEvent { + + + // MARK: SOOUM v1 + + // enum WriteCard: AnalyticsEventProtocol { + // /// 글쓰기 화면에서 태그를 추가하고 글을 작성하지 않음 + // case dismiss_with_tag(tag_count: Int, tag_texts: [String]) + // /// 글쓰기 화면에서 태그를 추가하고 글을 작성 + // case add_tag(tag_count: Int, tag_texts: [String]) + // } + + // enum Tag: AnalyticsEventProtocol { + // /// 태그를 클릭한 위치 + // enum ClickPositionKey { + // /// 카드 상세화면에서 태그 클릭 + // static let post = "post" + // /// 즐겨찾기 태그 목록에서 태그 클릭 + // static let favorite = "favorite" + // /// 즐겨찾기 태그 목록의 미리보기 카드 클릭 + // static let favorite_preview = "favorite_preview" + // /// 추천 태그 목록에서 태그 클릭 + // static let recommendation = "recommendation" + // /// 태그 검색 결과에서 태그 클릭 + // static let search_result = "search_result" + // } + // /// 태그를 클릭 + // case tag_click(tag_text: String, click_position: String) + // } + + // enum Comment: AnalyticsEventProtocol { + // /// 사용자가 댓글을 작성 + // /// + // /// - Parameters: + // /// - comment_length: 댓글 길이 + // /// - parent_post_id: 부모 글 ID + // /// - image_attached: 이미지 첨부 여부 + // case add_comment(comment_length: Int, parent_post_id: String, image_attached: Bool) + // } + + + // MARK: SOOUM v2 + + enum TabBar: AnalyticsEventProtocol { + /// 바텀 네비게이션에서 ‘카드추가’ 버튼 클릭 이벤트 + case moveToCreateFeedCardView_btn_click + } + + enum HomeView: AnalyticsEventProtocol { + /// 피드에서 홈 버튼을 클릭하여 피드 최상단으로 이동하는 이벤트 + case feedMoveToTop_home_btn_click + /// 피드 화면에서 카드 상세보기 이동 이벤트 + case feedToCardDetailView_card_click + /// 피드 화면에서 이벤트 이미지를 사용한 카드 상세보기 이동 이벤트 + case feedToCardDetailView_cardWithEventImg_click + } + + enum DetailView: AnalyticsEventProtocol { + /// 카드 상세 조회 클릭 이벤트 (파라미터로 어디서 조회 하는건지 넘겨주기 feed, comment, profile) + case cardDetailView_tracePath_click(previous_path: ScreenPath) + /// 카드 상세보기에서 댓글카드 작성 버튼(아이콘 버튼과 플로팅 버튼 모두 포함) 클릭 이벤트 + case moveToCreateCommentCardView_btn_click + /// 카드 상세보기에서 댓글카드 작성 버튼(좋아요 옆에 있는 버튼) 클릭 이벤트 + case moveToCreateCommentCardView_icon_btn_click + /// 카드 상세보기에서 우측 하단에 동그란 버튼(플로팅된 댓글카드 작성 버튼) 클릭 이벤트 + case moveToCreateCommentCardView_floating_btn_click + /// 이벤트 카드의 플로팅 버튼 클릭 이벤트 + case moveToCreateCommentCardView_withEventImg_floating_btn_click + /// 카드 상세보기 화면에서 태그 영역(특정 태그) 클릭 이벤트 + case cardDetailTag_btn_click(tag_name: String) + } + + enum WriteCardView: AnalyticsEventProtocol { + /// 피드 카드 작성 뷰에서 뒤로가기 버튼 클릭 이벤트 + case moveToCreateFeedCardView_cancel_btn_click + /// 댓글카드 작성 뷰에서 뒤로가기 버튼 클릭 이벤트 + case moveToCreateCommentCardView_cancel_btn_click + /// 피드 카드 작성 뷰에서 태그 추가를 키보드 엔터(완료)버튼 클릭 이벤트 + case multipleFeedTagCreation_enter_btn_click + /// 피드 카드 작성 뷰에서 기본 배경 이미지 카테고리 변경 클릭 이벤트 + case feedBackgroundCategory_tab_click + /// 카드 만들기(피드 카드 작성 뷰)에서 ‘이벤트’ 카테고리 버튼 클릭 이벤트 + case createFeedCardEventCategory_btn_click + /// 댓글카드 작성 뷰에서 기본 배경 이미지 카테고리 변경 클릭 이벤트 + case commentBackgroundCategory_tab_click + /// 피드 카드 작성 완료 버튼 클릭 이벤트 + case createFeedCard_btn_click + /// 거리 공유 옵션을 끈 상태로 피드 카드 작성 완료 버튼 클릭 이벤트 + case createFeedCardWithoutDistanceSharedOpt_btn_click + } + + enum TagView: AnalyticsEventProtocol { + /// 태그 즐겨찾기 등록 버튼 클릭 이벤트(즐겨찾기 취소는 해당 이벤트에 제외) + case favoriteTagRegister_btn_click + /// 태그 메뉴 화면에서 검색바 클릭 이벤트 + case tagMenuSearchBar_click + /// 인기 태그 영역에서 특정 태그 클릭 이벤트 + case popularTag_item_click + } + + enum TransferView: AnalyticsEventProtocol { + /// 계정이관코드 입력 후 완료 버튼 클릭하여 계정이관 성공한 경우의 이벤트 + case accountTransferSuccess + } +} + +extension GAEvent.DetailView { + + enum ScreenPath: String { + case home + case detail + case notification + case writeCard + case tag_collect + case tag_search_collect + case profile + } + + enum EnterTo: String { + case icon + case floating + } +} diff --git a/SOOUM/SOOUM/DesignSystem/Foundations/UIImage+SOOUM.swift b/SOOUM/SOOUM/DesignSystem/Foundations/UIImage+SOOUM.swift index 660747f6..5cac3e9e 100644 --- a/SOOUM/SOOUM/DesignSystem/Foundations/UIImage+SOOUM.swift +++ b/SOOUM/SOOUM/DesignSystem/Foundations/UIImage+SOOUM.swift @@ -199,6 +199,7 @@ extension UIImage.SOOUMType { case swap case tag case time + case timer case trash case up case user diff --git a/SOOUM/SOOUM/Domain/Models/BaseCardInfo.swift b/SOOUM/SOOUM/Domain/Models/BaseCardInfo.swift index 76abfbe1..300ec4aa 100644 --- a/SOOUM/SOOUM/Domain/Models/BaseCardInfo.swift +++ b/SOOUM/SOOUM/Domain/Models/BaseCardInfo.swift @@ -21,6 +21,43 @@ struct BaseCardInfo: Hashable { let isAdminCard: Bool } +extension BaseCardInfo { + + func updateLikeCnt(_ likeCnt: Int) -> BaseCardInfo { + + return BaseCardInfo( + id: self.id, + likeCnt: likeCnt, + commentCnt: self.commentCnt, + cardImgName: self.cardImgName, + cardImgURL: self.cardImgURL, + cardContent: self.cardContent, + font: self.font, + distance: self.distance, + createdAt: self.createdAt, + storyExpirationTime: self.storyExpirationTime, + isAdminCard: self.isAdminCard + ) + } + + func updateCommentCnt(_ commentCnt: Int) -> BaseCardInfo { + + return BaseCardInfo( + id: self.id, + likeCnt: self.likeCnt, + commentCnt: commentCnt, + cardImgName: self.cardImgName, + cardImgURL: self.cardImgURL, + cardContent: self.cardContent, + font: self.font, + distance: self.distance, + createdAt: self.createdAt, + storyExpirationTime: self.storyExpirationTime, + isAdminCard: self.isAdminCard + ) + } +} + extension BaseCardInfo { /// 사용하는 폰트 enum Font: String, Decodable { diff --git a/SOOUM/SOOUM/Domain/Models/DetailCardInfo.swift b/SOOUM/SOOUM/Domain/Models/DetailCardInfo.swift index dffb98ea..a309e057 100644 --- a/SOOUM/SOOUM/Domain/Models/DetailCardInfo.swift +++ b/SOOUM/SOOUM/Domain/Models/DetailCardInfo.swift @@ -60,6 +60,33 @@ extension DetailCardInfo { prevCardInfo: self.prevCardInfo ) } + + func updateCommentCnt(_ commentCnt: Int) -> DetailCardInfo { + + return DetailCardInfo( + id: self.id, + likeCnt: self.likeCnt, + commentCnt: commentCnt, + cardImgName: self.cardImgName, + cardImgURL: self.cardImgURL, + cardContent: self.cardContent, + font: self.font, + distance: self.distance, + createdAt: self.createdAt, + storyExpirationTime: self.storyExpirationTime, + isAdminCard: self.isAdminCard, + isReported: self.isReported, + memberId: self.memberId, + nickname: self.nickname, + profileImgURL: self.profileImgURL, + isLike: self.isLike, + isCommentWritten: self.isCommentWritten, + tags: self.tags, + isOwnCard: self.isOwnCard, + visitedCnt: self.visitedCnt, + prevCardInfo: self.prevCardInfo + ) + } } extension DetailCardInfo { diff --git a/SOOUM/SOOUM/Extensions/Cocoa/Notification.swift b/SOOUM/SOOUM/Extensions/Cocoa/Notification.swift index 15aee465..fcedc5c9 100644 --- a/SOOUM/SOOUM/Extensions/Cocoa/Notification.swift +++ b/SOOUM/SOOUM/Extensions/Cocoa/Notification.swift @@ -16,6 +16,20 @@ extension Notification.Name { static let changedLocationAuthorization = Notification.Name("changedLocationAuthorization") /// Should scroll to top static let scollingToTopWithAnimation = Notification.Name("scollingToTopWithAnimation") + /// Updated favorite + static let addedFavoriteWithCardId = Notification.Name("addedFavoriteWithCardId") + /// Added comment + static let addedCommentWithCardId = Notification.Name("addedCommentWithCardId") + /// Deleted feed card + static let deletedFeedCardWithId = Notification.Name("deletedFeedCardWithId") + /// Deleted comment card + static let deletedCommentCardWithId = Notification.Name("deletedCommentCardWithId") + /// Updated block user + static let updatedBlockUser = Notification.Name("updatedBlockUser") + /// Updated hasUnreads + static let updatedHasUnreadNotification = Notification.Name("updatedHasUnreadNotification") + /// Should reload home + static let reloadHomeData = Notification.Name("reloadHomeData") /// Should reload detail static let reloadDetailData = Notification.Name("reloadDetailData") /// Updated report state diff --git a/SOOUM/SOOUM/Extensions/Cocoa/UIApplication+Top.swift b/SOOUM/SOOUM/Extensions/Cocoa/UIApplication+Top.swift index 7868ca1e..da19e1a2 100644 --- a/SOOUM/SOOUM/Extensions/Cocoa/UIApplication+Top.swift +++ b/SOOUM/SOOUM/Extensions/Cocoa/UIApplication+Top.swift @@ -7,11 +7,12 @@ import UIKit - extension UIApplication { var currentWindow: UIWindow? { - let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene + let scenes: Set = UIApplication.shared.connectedScenes + let activeScene: UIScene? = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first + let windowScene = activeScene as? UIWindowScene return windowScene?.windows.first { $0.isKeyWindow } } diff --git a/SOOUM/SOOUM/Extensions/Foundation/Date.swift b/SOOUM/SOOUM/Extensions/Foundation/Date.swift index b4083af5..40fd2c2e 100644 --- a/SOOUM/SOOUM/Extensions/Foundation/Date.swift +++ b/SOOUM/SOOUM/Extensions/Foundation/Date.swift @@ -89,9 +89,9 @@ extension Date { let seconds: Int = .init(time % 60) if hours <= 0 && minutes <= 0 && seconds <= 0 { - return "00 : 00 : 00" + return "00:00:00" } - return String(format: "%02d : %02d : %02d", hours, minutes, seconds) + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) } func infoReadableTimeTakenFromThisForPungToHoursAndMinutes(to: Date) -> String { diff --git a/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewController.swift index caa03d24..112e0dc3 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewController.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewController.swift @@ -77,7 +77,7 @@ class LaunchScreenViewController: BaseNavigationViewController, View { title: Text.updateActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { #if DEVELOP // 개발 버전일 때 testFlight로 전환 let strUrl = "\(Text.testFlightStrUrl)/\(Info.appId)" diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewController.swift index 356c7698..921a331b 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewController.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewController.swift @@ -259,7 +259,7 @@ extension OnboardingViewController { title: Text.confirmActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) @@ -280,7 +280,7 @@ extension OnboardingViewController { title: Text.confirmActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewController.swift index bc00f594..5af7bb4d 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewController.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewController.swift @@ -14,6 +14,8 @@ import Photos import SwiftEntryKit import YPImagePicker +import Clarity + import ReactorKit import RxCocoa import RxGesture @@ -273,14 +275,14 @@ extension OnboardingProfileImageSettingViewController { title: Text.cancelActionTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) let settingAction = SOMDialogAction( title: Text.settingActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { let application = UIApplication.shared let openSettingsURLString: String = UIApplication.openSettingsURLString @@ -306,7 +308,7 @@ extension OnboardingProfileImageSettingViewController { title: Text.inappositeDialogConfirmButtonTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { reactor.action.onNext(.setDefaultImage) } } @@ -356,11 +358,11 @@ extension OnboardingProfileImageSettingViewController { } else { Log.error("Error occured while picking an image") } - picker?.dismiss(animated: true, completion: nil) + picker?.dismiss(animated: true) { ClaritySDK.resume() } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.present(picker, animated: true, completion: nil) + self?.present(picker, animated: true) { ClaritySDK.pause() } } } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewCell.swift index 4515fc02..99d304c2 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewCell.swift @@ -61,25 +61,20 @@ class DetailViewCell: UICollectionViewCell { $0.clipsToBounds = true } /// 상세보기, 본문 - private let contentScrollView = UITextView().then { - $0.textColor = .som.v2.white - $0.typography = .som.v2.body1 - - $0.backgroundColor = .clear - $0.tintColor = .clear - - $0.textAlignment = .center - $0.textContainerInset = .init(top: 20, left: 24, bottom: 20, right: 24) - $0.textContainer.lineFragmentPadding = 0 - - $0.indicatorStyle = .white - $0.scrollIndicatorInsets = .init(top: 20, left: 24, bottom: 20, right: 24) - + private let contentScrollView = UIScrollView().then { $0.isScrollEnabled = false $0.showsVerticalScrollIndicator = true $0.showsHorizontalScrollIndicator = false - - $0.isEditable = false + $0.indicatorStyle = .white + $0.scrollIndicatorInsets = .zero + } + private let contentLabelView = UILabel().then { + $0.textColor = .som.v2.white + $0.typography = .som.v2.body1 + $0.textAlignment = .center + $0.numberOfLines = 0 + $0.lineBreakMode = .byWordWrapping + $0.lineBreakStrategy = .hangulWordPriority } /// 상세보기, 카드 삭제 됐을 때 배경 @@ -186,7 +181,15 @@ class DetailViewCell: UICollectionViewCell { self.contentBackgroundDimView.addSubview(self.contentScrollView) self.contentScrollView.snp.makeConstraints { + $0.top.equalToSuperview().offset(20) + $0.bottom.equalToSuperview().offset(-20) + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalToSuperview().offset(-24) + } + self.contentScrollView.addSubview(self.contentLabelView) + self.contentLabelView.snp.makeConstraints { $0.edges.equalToSuperview() + $0.width.equalTo(self.contentScrollView.snp.width) } self.contentView.addSubview(self.tags) @@ -231,13 +234,11 @@ class DetailViewCell: UICollectionViewCell { ) let size: CGSize = .init(width: self.contentScrollView.bounds.width, height: .greatestFiniteMagnitude) - let textSize: CGSize = self.contentScrollView.sizeThatFits(size) - var boundingHeight = attributedText.boundingRect( - with: textSize, - options: [.usesLineFragmentOrigin, .usesFontLeading], + let boundingHeight = attributedText.boundingRect( + with: size, + options: [.usesLineFragmentOrigin], context: nil ).height - boundingHeight += 1.0 let lines: CGFloat = boundingHeight / typography.lineHeight let isScrollEnabled: Bool = lines > 8 @@ -284,8 +285,8 @@ class DetailViewCell: UICollectionViewCell { case .yoonwoo: typography = .som.v2.yoonwooCard case .kkookkkook: typography = .som.v2.kkookkkookCard } - self.contentScrollView.text = model.cardContent - self.contentScrollView.typography = typography + self.contentLabelView.text = model.cardContent + self.contentLabelView.typography = typography self.updateTextContainerInsetAndHeight(model.cardContent, typography: typography) let tagModels: [WrittenTagModel] = model.tags.map { tag in diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewController.swift index f8834cff..2cefeb68 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewController.swift @@ -48,6 +48,8 @@ class DetailViewController: BaseNavigationViewController, View { static let confirmActionTitle: String = "확인" static let cancelActionTitle: String = "취소" + + static let eventCardTitle: String = "event" } @@ -137,6 +139,13 @@ class DetailViewController: BaseNavigationViewController, View { object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.deletedCommentCardWithId(_:)), + name: .deletedCommentCardWithId, + object: nil + ) + NotificationCenter.default.addObserver( self, selector: #selector(self.updatedReportState(_:)), @@ -176,27 +185,29 @@ class DetailViewController: BaseNavigationViewController, View { // MARK: - Bind - func bind(reactor: DetailViewReactor) { - - // 답카드 작성 전환 - self.floatingButton.backgoundButton.rx.throttleTap(.seconds(3)) - .map { _ in Reactor.Action.willPushToWrite } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - let detailCard = reactor.state.map(\.detailCard).filterNil().distinctUntilChanged() - let isBlocked = reactor.state.map(\.isBlocked).distinctUntilChanged() - let isReported = reactor.state.map(\.isReported).distinctUntilChanged() - - let rightMoreButtonDidTap = self.rightMoreButton.rx.throttleTap.share() - // 더보기 버튼 액션 - rightMoreButtonDidTap - .withLatestFrom(detailCard) - .filter { $0.isOwnCard } - .observe(on: MainScheduler.instance) - .subscribe(with: self) { object, _ in - - object.actions = [ + func bind(reactor: DetailViewReactor) { + + // 댓글카드 작성 전환 + self.floatingButton.backgoundButton.rx.throttleTap(.seconds(3)) + .map { _ in Reactor.Action.willPushToWrite(.floating) } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + let detailCard = reactor.state.map(\.detailCard).distinctUntilChanged().filterNil().share() + let commentCards = reactor.state.map(\.commentCards).distinctUntilChanged().filterNil().share() + let isFeed = reactor.state.map(\.isFeed).share() + let isBlocked = reactor.state.map(\.isBlocked).distinctUntilChanged().share() + let isReported = reactor.state.map(\.isReported).distinctUntilChanged().share() + + let rightMoreButtonDidTap = self.rightMoreButton.rx.throttleTap.share() + // 더보기 버튼 액션 + rightMoreButtonDidTap + .withLatestFrom(detailCard) + .filter { $0.isOwnCard } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + + object.actions = [ .init( title: Text.deleteButtonFloatActionTitle, image: .init(.icon(.v2(.outlined(.trash)))), @@ -208,26 +219,26 @@ class DetailViewController: BaseNavigationViewController, View { } } ) - ] - - let bottomFloatView = SOMBottomFloatView(actions: object.actions) - - var wrapper: SwiftEntryKitViewWrapper = bottomFloatView.sek - wrapper.entryName = Text.bottomFloatEntryName - wrapper.showBottomFloat(screenInteraction: .dismiss) - } - .disposed(by: self.disposeBag) - - rightMoreButtonDidTap - .withLatestFrom(Observable.combineLatest(detailCard, isBlocked, isReported)) - .filter { $0.0.isOwnCard == false } - .map { ($0.0.nickname, $0.1, $0.2) } - .observe(on: MainScheduler.instance) - .subscribe(with: self) { object, combined in - - let (nickname, isBlocked, isReported) = combined - - object.actions = [ + ] + + let bottomFloatView = SOMBottomFloatView(actions: object.actions) + + var wrapper: SwiftEntryKitViewWrapper = bottomFloatView.sek + wrapper.entryName = Text.bottomFloatEntryName + wrapper.showBottomFloat(screenInteraction: .dismiss) + } + .disposed(by: self.disposeBag) + + rightMoreButtonDidTap + .withLatestFrom(Observable.combineLatest(detailCard, isBlocked, isReported)) + .filter { $0.0.isOwnCard == false } + .map { ($0.0.nickname, $0.1, $0.2) } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, combined in + + let (nickname, isBlocked, isReported) = combined + + object.actions = [ .init( title: isBlocked ? Text.blockButtonFloatActionTitle : Text.unblockButtonFloatActionTitle, image: .init(.icon(.v2(.outlined(isBlocked ? .hide : .eye)))), @@ -258,188 +269,284 @@ class DetailViewController: BaseNavigationViewController, View { } } ) - ] - - let bottomFloatView = SOMBottomFloatView(actions: object.actions) - - var wrapper: SwiftEntryKitViewWrapper = bottomFloatView.sek - wrapper.entryName = Text.bottomFloatEntryName - wrapper.showBottomFloat(screenInteraction: .dismiss) - } - .disposed(by: self.disposeBag) - - // 카드 삭제 후 X 버튼 액션 - self.rightDeleteButton.rx.throttleTap(.seconds(3)) - .subscribe(with: self) { object, _ in - object.navigationPop(animated: false) - } - .disposed(by: self.disposeBag) - - // 댓글카드 홈 버튼 액션 - self.leftHomeButton.rx.throttleTap(.seconds(3)) - .subscribe(with: self) { object, _ in - object.navigationPopToRoot(animated: false) - } - .disposed(by: self.disposeBag) - - - // Action - self.rx.viewDidLoad - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - let isRefreshing = reactor.state.map(\.isRefreshing).distinctUntilChanged().share() - self.collectionView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(isRefreshing) - .filter { $0 == false } - .delay(.milliseconds(1000), scheduler: MainScheduler.instance) - .map { _ in Reactor.Action.refresh } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - // State - isRefreshing - .observe(on: MainScheduler.asyncInstance) - .filter { $0 == false } - .subscribe(with: self.collectionView) { collectionView, _ in - collectionView.refreshControl?.endRefreshing() - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.isFeed) - .filterNil() - .observe(on: MainScheduler.asyncInstance) - .subscribe(with: self) { object, isFeed in - object.navigationBar.title = isFeed ? - Text.feedDetailNavigationTitle : - Text.commentDetailNavigationTitle - - if isFeed == false { - object.navigationBar.setLeftButtons([object.leftHomeButton]) - } - } - .disposed(by: self.disposeBag) - - detailCard - .observe(on: MainScheduler.asyncInstance) - .subscribe(with: self) { object, detailCard in - object.detailCard = detailCard - - object.pungView.subscribePungTime(detailCard.storyExpirationTime) - object.pungView.isHidden = detailCard.storyExpirationTime == nil - - UIView.performWithoutAnimation { - object.collectionView.reloadData() - } - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.commentCards) - .distinctUntilChanged() - .observe(on: MainScheduler.asyncInstance) - .subscribe(with: self) { object, commentCards in - object.commentCards = commentCards - - UIView.performWithoutAnimation { - object.collectionView.reloadData() - } - } - .disposed(by: self.disposeBag) - - let willPushEnabled = reactor.state.map(\.willPushEnabled).distinctUntilChanged().filterNil() - willPushEnabled - .filter { $0 == false } - .observe(on: MainScheduler.instance) - .subscribe(with: self) { object, _ in - let writeCardViewController = WriteCardViewController() - writeCardViewController.reactor = reactor.reactorForWriteCard() - object.navigationPush( + ] + + let bottomFloatView = SOMBottomFloatView(actions: object.actions) + + var wrapper: SwiftEntryKitViewWrapper = bottomFloatView.sek + wrapper.entryName = Text.bottomFloatEntryName + wrapper.showBottomFloat(screenInteraction: .dismiss) + } + .disposed(by: self.disposeBag) + + // 카드 삭제 후 X 버튼 액션 + self.rightDeleteButton.rx.throttleTap(.seconds(3)) + .subscribe(with: self) { object, _ in + object.navigationPop(animated: false) + } + .disposed(by: self.disposeBag) + + // 댓글카드 홈 버튼 액션 + self.leftHomeButton.rx.throttleTap(.seconds(3)) + .subscribe(with: self) { object, _ in + object.navigationPopToRoot(animated: false) + } + .disposed(by: self.disposeBag) + + // 카드 정보 업데이트 시 전역으로 알림 + self.rx.viewDidDisappear + .subscribe(with: self) { object, _ in + /// 좋아요 업데이트 후 뒤로갔을 때, 좋아요 업데이트 알림 + if reactor.currentState.isLiked { + NotificationCenter.default.post( + name: .addedFavoriteWithCardId, + object: nil, + userInfo: [ + "cardId": object.detailCard.id, + "addedFavorite": object.detailCard.isLike + ] + ) + } + /// 사용자 차단 후 뒤로 갔을 때, 차단된 사용자 카드 숨김 알림 + if reactor.initialState.isBlocked != reactor.currentState.isBlocked { + NotificationCenter.default.post( + name: .updatedBlockUser, + object: nil, + userInfo: ["isBlocked": !reactor.currentState.isBlocked] + ) + } + } + .disposed(by: self.disposeBag) + + + // Action + self.rx.viewDidLoad + .map { _ in Reactor.Action.landing } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + let isRefreshing = reactor.state.map(\.isRefreshing).distinctUntilChanged().share() + self.collectionView.refreshControl?.rx.controlEvent(.valueChanged) + .withLatestFrom(isRefreshing) + .filter { $0 == false } + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) + .map { _ in Reactor.Action.refresh } + .bind(to: reactor.action) + .disposed(by: self.disposeBag) + + // State + isRefreshing + .observe(on: MainScheduler.asyncInstance) + .filter { $0 == false } + .subscribe(with: self.collectionView) { collectionView, _ in + collectionView.refreshControl?.endRefreshing() + } + .disposed(by: self.disposeBag) + + isFeed + .distinctUntilChanged() + .filterNil() + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, isFeed in + object.navigationBar.title = isFeed ? + Text.feedDetailNavigationTitle : + Text.commentDetailNavigationTitle + + if isFeed == false { + object.navigationBar.setLeftButtons([object.leftHomeButton]) + } + } + .disposed(by: self.disposeBag) + + detailCard + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, detailCard in + object.detailCard = detailCard + + object.pungView.subscribePungTime(detailCard.storyExpirationTime) + object.pungView.isHidden = detailCard.storyExpirationTime == nil + + UIView.performWithoutAnimation { + object.collectionView.reloadData() + } + } + .disposed(by: self.disposeBag) + + commentCards + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, commentCards in + object.commentCards = commentCards + + UIView.performWithoutAnimation { + object.collectionView.reloadData() + } + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.willPushToDetailEnabled) + .distinctUntilChanged(reactor.canPushToDetail) + .filterNil() + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, willPushToDetailEnabled in + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForPush( + willPushToDetailEnabled.prevCardId, + hasDeleted: willPushToDetailEnabled.isDeleted + ) + object.navigationPush(detailViewController, animated: true) { _ in + reactor.action.onNext(.cleanup) + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click( + previous_path: .detail + ) + ) + } + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.willPushToWriteEnabled) + .distinctUntilChanged(reactor.canPushToWrite) + .filterNil() + .filter { $0.isDeleted } + .map(\.enterTo) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, enterTo in + let writeCardViewController = WriteCardViewController() + writeCardViewController.reactor = reactor.reactorForWriteCard() + object.navigationPush( writeCardViewController, animated: true - ) { _ in - reactor.action.onNext(.resetPushState) - } - } - .disposed(by: self.disposeBag) - willPushEnabled - .filter { $0 } - .map { _ in Reactor.Action.resetPushState } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - reactor.state.map(\.isLiked) - .distinctUntilChanged() - .filter { $0 } - .observe(on: MainScheduler.asyncInstance) - .subscribe(with: self) { object, _ in - - let updated: DetailCardInfo - if object.detailCard.isLike { - - let updatedLikeCnt = object.detailCard.likeCnt - 1 - updated = object.detailCard.updateLikeCnt(updatedLikeCnt, with: false) - } else { - - let updatedLikeCnt = object.detailCard.likeCnt + 1 - updated = object.detailCard.updateLikeCnt(updatedLikeCnt, with: true) - } - - object.detailCard = updated - - UIView.performWithoutAnimation { - object.collectionView.reloadData() - } - } - .disposed(by: self.disposeBag) - - isBlocked - .filter { $0 == false } - .observe(on: MainScheduler.instance) - .subscribe(with: self) { object, _ in - - let title = Text.blockToastLeadingTitle + object.detailCard.nickname + Text.blockToastTrailingTitle - let actions = [ + ) { _ in + reactor.action.onNext(.cleanup) + + if enterTo == .icon { + GAHelper.shared.logEvent( + event: GAEvent.DetailView.moveToCreateCommentCardView_icon_btn_click + ) + } else { + GAHelper.shared.logEvent( + event: GAEvent.DetailView.moveToCreateCommentCardView_floating_btn_click + ) + if reactor.currentState.detailCard? + .cardImgName + .contains(Text.eventCardTitle) == true { + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.moveToCreateCommentCardView_withEventImg_floating_btn_click + ) + } + } + } + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.isLiked) + .distinctUntilChanged() + .filter { $0 } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + + let updated: DetailCardInfo + if object.detailCard.isLike { + + let updatedLikeCnt = object.detailCard.likeCnt - 1 + updated = object.detailCard.updateLikeCnt(updatedLikeCnt, with: false) + } else { + + let updatedLikeCnt = object.detailCard.likeCnt + 1 + updated = object.detailCard.updateLikeCnt(updatedLikeCnt, with: true) + } + + object.detailCard = updated + + UIView.performWithoutAnimation { + object.collectionView.reloadData() + } + } + .disposed(by: self.disposeBag) + + isBlocked + .filter { $0 == false } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + + let title = Text.blockToastLeadingTitle + object.detailCard.nickname + Text.blockToastTrailingTitle + let actions = [ SOMBottomToastView.ToastAction(title: Text.cancelActionTitle, action: { SwiftEntryKit.dismiss(.specific(entryName: Text.bottomToastEntryName)) { reactor.action.onNext(.block(isBlocked: false)) } }) - ] - let bottomToastView = SOMBottomToastView(title: title, actions: actions) - - var wrapper: SwiftEntryKitViewWrapper = bottomToastView.sek - wrapper.entryName = Text.bottomToastEntryName - wrapper.showBottomToast(verticalOffset: 34 + 56 + 8) - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.isDeleted) - .distinctUntilChanged() - .filter { $0 } - .observe(on: MainScheduler.asyncInstance) - .subscribe(with: self) { object, _ in - if reactor.currentState.isFeed == false { - NotificationCenter.default.post(name: .reloadDetailData, object: nil, userInfo: nil) - } - - object.navigationBar.title = Text.deletedNavigationTitle - object.navigationBar.setRightButtons([object.rightDeleteButton]) - - object.floatingButton.removeFromSuperview() - - object.isDeleted = true - - UIView.performWithoutAnimation { - object.collectionView.reloadData() - } - - object.showDeletedCardDialog { - object.navigationPop() - } - } - .disposed(by: self.disposeBag) - } + ] + let bottomToastView = SOMBottomToastView(title: title, actions: actions) + + var wrapper: SwiftEntryKitViewWrapper = bottomToastView.sek + wrapper.entryName = Text.bottomToastEntryName + wrapper.showBottomToast(verticalOffset: 34 + 56 + 8) + } + .disposed(by: self.disposeBag) + + Observable.combineLatest( + reactor.state.map(\.isDeleted).distinctUntilChanged().filter { $0 }, + commentCards.map(\.isEmpty), + isFeed, + reactor.state.map(\.hasErrors) + ) + .map { ($0.1, $0.2, $0.3) } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, combined in + + object.navigationBar.title = Text.deletedNavigationTitle + object.navigationBar.setRightButtons([object.rightDeleteButton]) + + object.floatingButton.removeFromSuperview() + + object.isDeleted = true + + UIView.performWithoutAnimation { + object.collectionView.reloadData() + } + + let (isCommentEmpty, isFeed, errors) = combined + + if let isFeed = isFeed, isFeed { + NotificationCenter.default.post( + name: .deletedFeedCardWithId, + object: nil, + userInfo: ["cardId": reactor.selectedCardId, "isDeleted": true] + ) + } else { + NotificationCenter.default.post( + name: .addedCommentWithCardId, + object: nil, + userInfo: ["cardId": reactor.selectedCardId, "addedComment": false] + ) + } + + if case 410 = errors { + object.showDeletedCardDialog { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak object] in + object?.navigationPopToRoot() + } + } + return + } + + if isCommentEmpty { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak object] in + object?.navigationPop() + } + } else { + NotificationCenter.default.post( + name: .deletedCommentCardWithId, + object: nil, + userInfo: ["cardId": reactor.selectedCardId, "isDeleted": true] + ) + } + } + .disposed(by: self.disposeBag) + } // MARK: Objc func @@ -450,6 +557,24 @@ class DetailViewController: BaseNavigationViewController, View { self.reactor?.action.onNext(.landing) } + @objc + private func deletedCommentCardWithId(_ notification: Notification) { + + guard let cardId = notification.userInfo?["cardId"] as? String, + notification.userInfo?["isDeleted"] as? Bool == true + else { return } + + if let detailCard = self.reactor?.currentState.detailCard { + let commentCnt = detailCard.commentCnt > 0 ? detailCard.commentCnt - 1 : 0 + self.reactor?.action.onNext(.updateDetail(detailCard.updateCommentCnt(commentCnt))) + } + + var commentCards = self.reactor?.currentState.commentCards ?? [] + commentCards.removeAll(where: { $0.id == cardId }) + + self.reactor?.action.onNext(.updateComments(commentCards)) + } + @objc private func updatedReportState(_ notification: Notification) { @@ -477,6 +602,7 @@ extension DetailViewController: UICollectionViewDataSource { guard self.isDeleted == false else { cell.isDeleted() + self.pungView.isDeleted() return cell } @@ -513,7 +639,11 @@ extension DetailViewController: UICollectionViewDataSource { with: tagInfo.id, title: tagInfo.text ) - object.navigationPush(tagCollectViewController, animated: true) + object.navigationPush(tagCollectViewController, animated: true) { _ in + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailTag_btn_click(tag_name: tagInfo.text) + ) + } } .disposed(by: cell.disposeBag) @@ -525,24 +655,42 @@ extension DetailViewController: UICollectionViewDataSource { .disposed(by: cell.disposeBag) cell.likeAndCommentView.commentBackgroundButton.rx.throttleTap(.seconds(3)) - .map { _ in Reactor.Action.willPushToWrite } + .map { _ in Reactor.Action.willPushToWrite(.icon) } .bind(to: reactor.action) .disposed(by: cell.disposeBag) cell.prevCardBackgroundButton.rx.throttleTap(.seconds(3)) .subscribe(with: self) { object, _ in + guard let prevCardInfo = reactor.currentState.detailCard?.prevCardInfo else { + object.navigationPop() + return + } /// 현재 쌓인 viewControllers 중 바로 이전 viewController가 전환해야 할 전글이라면 naviPop if let naviStackCount = object.navigationController?.viewControllers.count, - let prevViewController = object.navigationController?.viewControllers[naviStackCount - 2] as? DetailViewController, - prevViewController.reactor?.selectedCardId == object.detailCard.prevCardInfo?.prevCardId { + let prevViewController = object.navigationController?.viewControllers[naviStackCount - 2] as? Self, + prevViewController.reactor?.selectedCardId == prevCardInfo.prevCardId { object.navigationPop() } else { - /// 없다면 새로운 viewController로 naviPush - guard let prevCardId = object.detailCard.prevCardInfo?.prevCardId else { return } - let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForPush(prevCardId) - object.navigationPush(detailViewController, animated: true) + + if prevCardInfo.isPrevCardDeleted { + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForPush( + prevCardInfo.prevCardId, + hasDeleted: true + ) + object.navigationPush(detailViewController, animated: true) { _ in + reactor.action.onNext(.cleanup) + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click( + previous_path: .detail + ) + ) + } + } else { + reactor.action.onNext(.willPushToDetail(prevCardInfo.prevCardId)) + } } } .disposed(by: cell.disposeBag) @@ -672,14 +820,14 @@ private extension DetailViewController { title: Text.cancelActionTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) let blockAction = SOMDialogAction( title: Text.blockButtonFloatActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { completion?() } } @@ -701,14 +849,14 @@ private extension DetailViewController { title: Text.cancelActionTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) let deleteAction = SOMDialogAction( title: Text.deleteButtonFloatActionTitle, style: .red, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { reactor.action.onNext(.delete) } @@ -729,7 +877,7 @@ private extension DetailViewController { title: Text.confirmActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { completion?() } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewReactor.swift index 32014ac8..1ab6c3e9 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewReactor.swift @@ -14,12 +14,15 @@ class DetailViewReactor: Reactor { case landing case refresh case moreFindForComment(lastId: String) + case updateDetail(DetailCardInfo) + case updateComments([BaseCardInfo]) case delete case block(isBlocked: Bool) case updateLike(Bool) case updateReport(Bool) - case willPushToWrite - case resetPushState + case willPushToDetail(String) + case willPushToWrite(GAEvent.DetailView.EnterTo) + case cleanup } enum Mutation { @@ -33,34 +36,25 @@ class DetailViewReactor: Reactor { case updateReported(Bool) case updateIsBlocked(Bool) case updateErrors(Int?) - case willPushToWrite(Bool?) + case willPushToDetail((String, Bool)?) + case willPushToWrite((Bool, GAEvent.DetailView.EnterTo)?) } struct State { fileprivate(set) var isFeed: Bool? fileprivate(set) var detailCard: DetailCardInfo? - fileprivate(set) var commentCards: [BaseCardInfo] + fileprivate(set) var commentCards: [BaseCardInfo]? fileprivate(set) var isRefreshing: Bool fileprivate(set) var isLiked: Bool fileprivate(set) var isDeleted: Bool fileprivate(set) var isReported: Bool fileprivate(set) var isBlocked: Bool fileprivate(set) var hasErrors: Int? - fileprivate(set) var willPushEnabled: Bool? + fileprivate(set) var willPushToDetailEnabled: (prevCardId: String, isDeleted: Bool)? + fileprivate(set) var willPushToWriteEnabled: (isDeleted: Bool, enterTo: GAEvent.DetailView.EnterTo)? } - var initialState: State = .init( - isFeed: nil, - detailCard: nil, - commentCards: [], - isRefreshing: false, - isLiked: false, - isDeleted: false, - isReported: false, - isBlocked: true, - hasErrors: nil, - willPushEnabled: nil - ) + var initialState: State private let dependencies: AppDIContainerable private let fetchCardDetailUseCase: FetchCardDetailUseCase @@ -71,7 +65,11 @@ class DetailViewReactor: Reactor { let selectedCardId: String - init(dependencies: AppDIContainerable, with selectedCardId: String) { + init( + dependencies: AppDIContainerable, + with selectedCardId: String, + hasDeleted: Bool = false + ) { self.dependencies = dependencies self.fetchCardDetailUseCase = dependencies.rootContainer.resolve(FetchCardDetailUseCase.self) self.deleteCardUseCase = dependencies.rootContainer.resolve(DeleteCardUseCase.self) @@ -80,17 +78,35 @@ class DetailViewReactor: Reactor { self.locationUseCase = dependencies.rootContainer.resolve(LocationUseCase.self) self.selectedCardId = selectedCardId + + self.initialState = .init( + isFeed: nil, + detailCard: nil, + commentCards: nil, + isRefreshing: false, + isLiked: false, + isDeleted: hasDeleted, + isReported: false, + isBlocked: true, + hasErrors: nil, + willPushToDetailEnabled: nil, + willPushToWriteEnabled: nil + ) } func mutate(action: Action) -> Observable { switch action { case .landing: + // 삭제된 카드의 경우, 댓글 카드만 조회 + guard self.initialState.isDeleted == false else { return self.commentCards()} + let coordinate = self.locationUseCase.coordinate() let latitude = coordinate.latitude let longitude = coordinate.longitude return .concat([ + .just(.updateErrors(nil)), self.fetchCardDetailUseCase.detailCard( id: self.selectedCardId, latitude: latitude, @@ -110,6 +126,7 @@ class DetailViewReactor: Reactor { return .concat([ .just(.updateIsRefreshing(true)), + .just(.updateErrors(nil)), self.detailCard() .catch(self.catchClosure), self.commentCards(), @@ -118,6 +135,12 @@ class DetailViewReactor: Reactor { case let .moreFindForComment(lastId): return self.fetchMoreCommentCards(lastId) + case let .updateDetail(detailCard): + + return .just(.detailCard(detailCard)) + case let .updateComments(commentCards): + + return .just(.commentCards(commentCards)) case .delete: return self.deleteCardUseCase.delete(cardId: self.selectedCardId) @@ -126,15 +149,19 @@ class DetailViewReactor: Reactor { guard let memberId = self.currentState.detailCard?.memberId else { return .empty() } - return self.blockUserUseCase.updateBlocked(userId: memberId, isBlocked: isBlocked) - .flatMapLatest { isBlockedSuccess -> Observable in - /// isBlocked == true 일 때, 차단 요청 - return isBlockedSuccess ? .just(.updateIsBlocked(isBlocked == false)) : .empty() - } - .catch(self.catchClosure) + return .concat([ + .just(.updateErrors(nil)), + self.blockUserUseCase.updateBlocked(userId: memberId, isBlocked: isBlocked) + .flatMapLatest { isBlockedSuccess -> Observable in + /// isBlocked == true 일 때, 차단 요청 + return isBlockedSuccess ? .just(.updateIsBlocked(isBlocked == false)) : .empty() + } + .catch(self.catchClosure) + ]) case let .updateLike(isLike): return .concat([ + .just(.updateErrors(nil)), .just(.updateIsLiked(false)), self.updateCardLikeUseCase.updateLike(cardId: self.selectedCardId, isLike: isLike) .filter { $0 } @@ -147,18 +174,26 @@ class DetailViewReactor: Reactor { case let .updateReport(isReported): return .just(.updateReported(isReported)) - case .willPushToWrite: + case let .willPushToDetail(prevCardId): + + return self.fetchCardDetailUseCase.isDeleted(cardId: prevCardId) + .map { (prevCardId, $0) } + .map(Mutation.willPushToDetail) + case let .willPushToWrite(enterTo): return self.fetchCardDetailUseCase.isDeleted(cardId: self.selectedCardId) - .flatMapLatest { isDeleted -> Observable in - return .concat([ - .just(.willPushToWrite(isDeleted)), - .just(.updateIsDeleted(isDeleted)) - ]) - } - case .resetPushState: + .flatMapLatest { isDeleted -> Observable in + return .concat([ + .just(.willPushToWrite((!isDeleted, enterTo))), + .just(.updateIsDeleted(isDeleted)) + ]) + } + case .cleanup: - return .just(.willPushToWrite(nil)) + return .concat([ + .just(.willPushToDetail(nil)), + .just(.willPushToWrite(nil)) + ]) } } @@ -172,7 +207,7 @@ class DetailViewReactor: Reactor { case let .commentCards(commentCards): newState.commentCards = commentCards case let .moreComment(commentCards): - newState.commentCards += commentCards + newState.commentCards? += commentCards case let .updateIsRefreshing(isRefreshing): newState.isRefreshing = isRefreshing case let .updateIsLiked(isLiked): @@ -185,8 +220,10 @@ class DetailViewReactor: Reactor { newState.isBlocked = isBlocked case let .updateErrors(hasErrors): newState.hasErrors = hasErrors - case let .willPushToWrite(willPushEnabled): - newState.willPushEnabled = willPushEnabled + case let .willPushToDetail(willPushToDetailEnabled): + newState.willPushToDetailEnabled = willPushToDetailEnabled + case let .willPushToWrite(willPushToWriteEnabled): + newState.willPushToWriteEnabled = willPushToWriteEnabled } return newState } @@ -238,8 +275,8 @@ class DetailViewReactor: Reactor { extension DetailViewReactor { - func reactorForPush(_ selectedId: String) -> DetailViewReactor { - DetailViewReactor(dependencies: self.dependencies, with: selectedId) + func reactorForPush(_ selectedId: String, hasDeleted: Bool = false) -> DetailViewReactor { + DetailViewReactor(dependencies: self.dependencies, with: selectedId, hasDeleted: hasDeleted) } func reactorForReport() -> ReportViewReactor { @@ -283,6 +320,7 @@ extension DetailViewReactor { if case 410 = nsError.code { return .concat([ .just(.updateIsRefreshing(false)), + .just(.updateErrors(410)), .just(.updateIsDeleted(true)) ]) } @@ -290,4 +328,20 @@ extension DetailViewReactor { return .just(.updateIsRefreshing(false)) } } + + func canPushToDetail( + prev prevCardIsDeleted: (prevCardId: String, isDeleted: Bool)?, + curr currCardIsDeleted: (prevCardId: String, isDeleted: Bool)? + ) -> Bool { + return prevCardIsDeleted?.prevCardId == currCardIsDeleted?.prevCardId && + prevCardIsDeleted?.isDeleted == currCardIsDeleted?.isDeleted + } + + func canPushToWrite( + prev prevCardIsDelete: (isDeleted: Bool, enterTo: GAEvent.DetailView.EnterTo)?, + curr currCardIsDelete: (isDeleted: Bool, enterTo: GAEvent.DetailView.EnterTo)? + ) -> Bool { + return prevCardIsDelete?.isDeleted == currCardIsDelete?.isDeleted && + prevCardIsDelete?.enterTo == currCardIsDelete?.enterTo + } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewController.swift index 4f147962..99694a1b 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewController.swift @@ -120,8 +120,8 @@ class ReportViewController: BaseNavigationViewController, View { private func bindState(reactor: ReportViewReactor) { reactor.state.map(\.reportReason) - .filterNil() .distinctUntilChanged() + .filterNil() .observe(on: MainScheduler.asyncInstance) .subscribe(with: self) { object, reportReason in @@ -196,7 +196,7 @@ private extension ReportViewController { title: Text.confirmButtonTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { NotificationCenter.default.post(name: .updatedReportState, object: nil, userInfo: nil) self.navigationPop() } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/PungView.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/PungView.swift index ad4ae940..d2fd4279 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/PungView.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/PungView.swift @@ -74,12 +74,12 @@ class PungView: UIView { .map { object, _ in guard let pungTime = pungTime else { object.serialTimer?.dispose() - return "00 : 00 : 00" + return "00:00:00" } let currentDate = Date() let remainingTime = currentDate.infoReadableTimeTakenFromThisForPung(to: pungTime) - if remainingTime == "00 : 00 : 00" { + if remainingTime == "00:00:00" { object.serialTimer?.dispose() object.isPunged.accept(()) } @@ -88,4 +88,13 @@ class PungView: UIView { } .bind(to: self.pungTimeLabel.rx.text) } + + func isDeleted() { + + self.serialTimer?.dispose() + self.serialTimer = nil + + self.pungTimeLabel.removeFromSuperview() + self.removeFromSuperview() + } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/HomeViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/HomeViewController.swift index 3871773c..b66f6ea9 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/HomeViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/HomeViewController.swift @@ -35,6 +35,8 @@ class HomeViewController: BaseNavigationViewController, View { static let cancelActionTitle: String = "취소" static let settingActionTitle: String = "설정" static let confirmActionTitle: String = "확인" + + static let eventCardTitle: String = "event" } enum Section: Int, CaseIterable { @@ -187,6 +189,48 @@ class HomeViewController: BaseNavigationViewController, View { // 제스처 뒤로가기를 위한 델리게이트 설정 self.parent?.navigationController?.interactivePopGestureRecognizer?.delegate = self + NotificationCenter.default.addObserver( + self, + selector: #selector(self.reloadHomeData(_:)), + name: .reloadHomeData, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.addedFavoriteWithCardId(_:)), + name: .addedFavoriteWithCardId, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.addedCommentWithCardId(_:)), + name: .addedCommentWithCardId, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.deletedFeedCardWithId(_:)), + name: .deletedFeedCardWithId, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.updatedBlockUser(_:)), + name: .updatedBlockUser, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.updatedHasUnreadNotification(_:)), + name: .updatedHasUnreadNotification, + object: nil + ) + NotificationCenter.default.addObserver( self, selector: #selector(self.scollingToTopWithAnimation(_:)), @@ -266,7 +310,7 @@ class HomeViewController: BaseNavigationViewController, View { .disposed(by: self.disposeBag) // Action - self.rx.viewDidAppear + self.rx.viewDidLoad .map { _ in Reactor.Action.landing } .bind(to: reactor.action) .disposed(by: self.disposeBag) @@ -297,8 +341,8 @@ class HomeViewController: BaseNavigationViewController, View { .disposed(by: self.disposeBag) reactor.state.map(\.noticeInfos) - .filterNil() .distinctUntilChanged() + .filterNil() .observe(on: MainScheduler.asyncInstance) .subscribe(with: self) { object, noticeInfos in let models: [SOMPageModel] = noticeInfos.map { SOMPageModel(data: $0) } @@ -316,19 +360,26 @@ class HomeViewController: BaseNavigationViewController, View { .filterNil() cardIsDeleted .filter { $0.isDeleted } + .map { $0.selectedId } .observe(on: MainScheduler.instance) - .subscribe(with: self) { object, _ in - object.showPungedCardDialog() + .subscribe(with: self) { object, selectedId in + object.showPungedCardDialog(reactor, with: selectedId) } .disposed(by: self.disposeBag) cardIsDeleted .filter { $0.isDeleted == false } + .map { $0.selectedId } .observe(on: MainScheduler.instance) - .subscribe(with: self) { object, cardIsDeleted in + .subscribe(with: self) { object, selectedId in let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForDetail(with: cardIsDeleted.selectedId) + detailViewController.reactor = reactor.reactorForDetail(with: selectedId) object.parent?.navigationPush(detailViewController, animated: true) { _ in - reactor.action.onNext(.resetPushState) + reactor.action.onNext(.cleanup) + + GAHelper.shared.logEvent(event: GAEvent.HomeView.feedToCardDetailView_card_click) + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click(previous_path: .home) + ) } } .disposed(by: self.disposeBag) @@ -341,7 +392,7 @@ class HomeViewController: BaseNavigationViewController, View { distances: $0.distanceCards ) } - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, displayStats in var snapshot = Snapshot() @@ -393,6 +444,127 @@ class HomeViewController: BaseNavigationViewController, View { // MARK: Objc func + @objc + private func reloadHomeData(_ notification: Notification) { + + self.reactor?.action.onNext(.landing) + } + + /// 피드카드 좋아요 업데이트 시, 최신/인기/거리 해당 카드만 업데이트 + @objc + private func addedFavoriteWithCardId(_ notification: Notification) { + + guard let cardId = notification.userInfo?["cardId"] as? String, + let addedFavorite = notification.userInfo?["addedFavorite"] as? Bool + else { return } + + var latests = self.reactor?.currentState.latestCards ?? [] + var populars = self.reactor?.currentState.popularCards ?? [] + var distances = self.reactor?.currentState.distanceCards ?? [] + + if let index = latests.firstIndex(where: { $0.id == cardId }) { + let curr = latests[index].likeCnt + let new = addedFavorite ? curr + 1 : curr - 1 + latests[index] = latests[index].updateLikeCnt(new) + } + + if let index = populars.firstIndex(where: { $0.id == cardId }) { + let curr = populars[index].likeCnt + let new = addedFavorite ? curr + 1 : curr - 1 + populars[index] = populars[index].updateLikeCnt(new) + } + + if let index = distances.firstIndex(where: { $0.id == cardId }) { + let curr = distances[index].likeCnt + let new = addedFavorite ? curr + 1 : curr - 1 + distances[index] = distances[index].updateLikeCnt(new) + } + + self.reactor?.action.onNext( + .updateCards( + latests: latests, + populars: populars, + distances: distances + ) + ) + } + /// 피드카드 댓글카드 작성 및 삭제 시, 최신/인기/거리 해당 카드만 업데이트 + @objc + private func addedCommentWithCardId(_ notification: Notification) { + + guard let cardId = notification.userInfo?["cardId"] as? String, + let addedComment = notification.userInfo?["addedComment"] as? Bool + else { return } + + var latests = self.reactor?.currentState.latestCards ?? [] + var populars = self.reactor?.currentState.popularCards ?? [] + var distances = self.reactor?.currentState.distanceCards ?? [] + + if let index = latests.firstIndex(where: { $0.id == cardId }) { + let curr = latests[index].commentCnt + let new = addedComment ? curr + 1 : curr - 1 + latests[index] = latests[index].updateCommentCnt(new) + } + + if let index = populars.firstIndex(where: { $0.id == cardId }) { + let curr = populars[index].commentCnt + let new = addedComment ? curr + 1 : curr - 1 + populars[index] = populars[index].updateCommentCnt(new) + } + + if let index = distances.firstIndex(where: { $0.id == cardId }) { + let curr = distances[index].commentCnt + let new = addedComment ? curr + 1 : curr - 1 + distances[index] = distances[index].updateCommentCnt(new) + } + + self.reactor?.action.onNext( + .updateCards( + latests: latests, + populars: populars, + distances: distances + ) + ) + } + /// 피드카드 삭제 시, 최신/인기/거리 해당 카드만 업데이트 + @objc + private func deletedFeedCardWithId(_ notification: Notification) { + + guard let cardId = notification.userInfo?["cardId"] as? String, + notification.userInfo?["isDeleted"] as? Bool == true + else { return } + + var latests = self.reactor?.currentState.latestCards ?? [] + var populars = self.reactor?.currentState.popularCards ?? [] + var distances = self.reactor?.currentState.distanceCards ?? [] + + latests.removeAll(where: { $0.id == cardId }) + populars.removeAll(where: { $0.id == cardId }) + distances.removeAll(where: { $0.id == cardId }) + + self.reactor?.action.onNext( + .updateCards( + latests: latests, + populars: populars, + distances: distances + ) + ) + } + /// 특정 사용자 차단 시, 최신/인기/거리 특정 사용자 카드 숨김 처리 + @objc + private func updatedBlockUser(_ notification: Notification) { + + guard notification.userInfo?["isBlocked"] as? Bool != nil else { return } + + self.reactor?.action.onNext(.landing) + } + + @objc + private func updatedHasUnreadNotification(_ notification: Notification) { + + self.reactor?.action.onNext(.updateHasUnReadNotifications(false)) + } + @objc private func scollingToTopWithAnimation(_ notification: Notification) { @@ -408,6 +580,8 @@ class HomeViewController: BaseNavigationViewController, View { let toTop = CGPoint(x: 0, y: -(self.tableView.contentInset.top)) self.tableView.setContentOffset(toTop, animated: true) + + GAHelper.shared.logEvent(event: GAEvent.HomeView.feedMoveToTop_home_btn_click) } @objc @@ -456,7 +630,7 @@ private extension HomeViewController { title: Text.cancelActionTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { let prevIdx = self.stickyTabBar.previousIndex let currInx = self.stickyTabBar.selectedIndex @@ -468,7 +642,7 @@ private extension HomeViewController { title: Text.settingActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { let application = UIApplication.shared let openSettingsURLString: String = UIApplication.openSettingsURLString if let settingsURL = URL(string: openSettingsURLString), @@ -487,15 +661,22 @@ private extension HomeViewController { ) } - func showPungedCardDialog() { + func showPungedCardDialog(_ reactor: HomeViewReactor, with selectedId: String) { let confirmAction = SOMDialogAction( title: Text.confirmActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { - self.reactor?.action.onNext(.landing) - self.reactor?.action.onNext(.resetPushState) + SOMDialogViewController.dismiss { + reactor.action.onNext(.cleanup) + + reactor.action.onNext( + .updateCards( + latests: (reactor.currentState.latestCards ?? []).filter { $0.id != selectedId }, + populars: (reactor.currentState.popularCards ?? []).filter { $0.id != selectedId }, + distances: (reactor.currentState.distanceCards ?? []).filter { $0.id != selectedId } + ) + ) } } ) @@ -598,11 +779,6 @@ extension HomeViewController: UITableViewDelegate { } } - guard isPunged == false else { - self.showPungedCardDialog() - return - } - var selectedId: String? { switch item { case let .latest(selectedCard): @@ -618,7 +794,25 @@ extension HomeViewController: UITableViewDelegate { guard let selectedId = selectedId else { return } - reactor.action.onNext(.detailCard(selectedId)) + guard isPunged == false else { + self.showPungedCardDialog(reactor, with: selectedId) + return + } + + var isEventCard: Bool { + switch item { + case let .latest(selectedCard): + return selectedCard.cardImgName.contains(Text.eventCardTitle) + case let .popular(selectedCard): + return selectedCard.cardImgName.contains(Text.eventCardTitle) + case let .distance(selectedCard): + return selectedCard.cardImgName.contains(Text.eventCardTitle) + case .empty: + return false + } + } + + reactor.action.onNext(.hasDetailCard(selectedId, isEventCard)) } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { diff --git a/SOOUM/SOOUM/Presentations/Main/Home/HomeViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/HomeViewReactor.swift index 05ba28c4..f8869781 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/HomeViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/HomeViewReactor.swift @@ -31,14 +31,16 @@ class HomeViewReactor: Reactor { case moreFind(String) case updateDisplayType(DisplayType) case updateDistanceFilter(String) - case detailCard(String) - case resetPushState + case hasDetailCard(String, Bool) + case updateCards(latests: [BaseCardInfo], populars: [BaseCardInfo], distances: [BaseCardInfo]) + case updateHasUnReadNotifications(Bool) + case cleanup } enum Mutation { case updateLocationPermission(Bool) - case cards([BaseCardInfo]) - case more([BaseCardInfo]) + case cards(latests: [BaseCardInfo], populars: [BaseCardInfo], distances: [BaseCardInfo]) + case more(latests: [BaseCardInfo], distances: [BaseCardInfo]) case updateHasUnreadNotifications(Bool) case notices([NoticeInfo]) case cardIsDeleted((String, Bool)?) @@ -128,32 +130,27 @@ class HomeViewReactor: Reactor { var emitObservable: Observable { switch displayType { case .latest: - if let latestCards = self.currentState.latestCards { - return .just(.cards(latestCards)) - } else { + if self.currentState.latestCards?.isEmpty == true { return self.refresh(.latest, distanceFilter) - .catch(self.catchClosureForCards) } + return .empty() case .popular: - if let popularCards = self.currentState.popularCards { - return .just(.cards(popularCards)) - } else { + if self.currentState.popularCards?.isEmpty == true { return self.refresh(.popular, distanceFilter) - .catch(self.catchClosureForCards) } + return .empty() case .distance: - if let distanceCards = self.currentState.distanceCards { - return .just(.cards(distanceCards)) - } else { + if self.currentState.distanceCards?.isEmpty == true { return self.refresh(.distance, distanceFilter) - .catch(self.catchClosureForCards) } + return .empty() } } return .concat([ - .just(.updateDisplayType(displayType)), emitObservable + .catch(self.catchClosureForCards), + .just(.updateDisplayType(displayType)) ]) case let .updateDistanceFilter(distanceFilter): @@ -163,15 +160,28 @@ class HomeViewReactor: Reactor { self.refresh(displayType, distanceFilter) .catch(self.catchClosureForCards) ]) - case let .detailCard(selectedId): + case let .hasDetailCard(selectedId, isEventCard): return .concat([ .just(.cardIsDeleted(nil)), self.fetchCardDetailUseCase.isDeleted(cardId: selectedId) - .map { (selectedId, $0) } - .map(Mutation.cardIsDeleted) + .do(onNext: { + if isEventCard, $0 == false { + GAHelper.shared.logEvent( + event: GAEvent.HomeView.feedToCardDetailView_cardWithEventImg_click + ) + } + }) + .map { (selectedId, $0) } + .map(Mutation.cardIsDeleted) ]) - case .resetPushState: + case let .updateCards(latest, populars, distances): + + return .just(.cards(latests: latest, populars: populars, distances: distances)) + case let .updateHasUnReadNotifications(hasUnReads): + + return .just(.updateHasUnreadNotifications(hasUnReads)) + case .cleanup: return .just(.cardIsDeleted(nil)) } @@ -182,18 +192,13 @@ class HomeViewReactor: Reactor { switch mutation { case let .updateLocationPermission(hasPermission): newState.hasPermission = hasPermission - case let .cards(cards): - switch newState.displayType { - case .latest: newState.latestCards = cards - case .popular: newState.popularCards = cards - case .distance: newState.distanceCards = cards - } - case let .more(cards): - switch newState.displayType { - case .latest: newState.latestCards? += cards - case .distance: newState.distanceCards? += cards - default: break - } + case let .cards(latest, popular, distance): + newState.latestCards = latest + newState.popularCards = popular + newState.distanceCards = distance + case let .more(latest, distance): + newState.latestCards? += latest + newState.distanceCards? += distance case let .notices(noticeInfos): newState.noticeInfos = noticeInfos case let .updateHasUnreadNotifications(hasUnreadNotifications): @@ -226,10 +231,22 @@ private extension HomeViewReactor { latitude: latitude, longitude: longitude ) - .map(Mutation.cards) + .map { + return .cards( + latests: $0, + populars: self.currentState.popularCards ?? [], + distances: self.currentState.distanceCards ?? [] + ) + } case .popular: return self.fetchCardUseCase.popularCards(latitude: latitude, longitude: longitude) - .map(Mutation.cards) + .map { + return .cards( + latests: self.currentState.latestCards ?? [], + populars: $0, + distances: self.currentState.distanceCards ?? [] + ) + } case .distance: let distanceFilter = distanceFilter.replacingOccurrences(of: "km", with: "") return self.fetchCardUseCase.distanceCards( @@ -238,7 +255,13 @@ private extension HomeViewReactor { longitude: longitude, distanceFilter: distanceFilter ) - .map(Mutation.cards) + .map { + return .cards( + latests: self.currentState.latestCards ?? [], + populars: self.currentState.popularCards ?? [], + distances: $0 + ) + } } } @@ -255,7 +278,12 @@ private extension HomeViewReactor { latitude: latitude, longitude: longitude ) - .map(Mutation.more) + .map { + return .more( + latests: $0, + distances: self.currentState.distanceCards ?? [] + ) + } case .distance: let distanceFilter = self.currentState.distanceFilter.replacingOccurrences(of: "km", with: "") return self.fetchCardUseCase.distanceCards( @@ -264,7 +292,12 @@ private extension HomeViewReactor { longitude: longitude, distanceFilter: distanceFilter ) - .map(Mutation.more) + .map { + return .more( + latests: self.currentState.latestCards ?? [], + distances: $0 + ) + } default: return .empty() } @@ -289,8 +322,33 @@ extension HomeViewReactor { var catchClosureForCards: ((Error) throws -> Observable ) { return { _ in - .concat([ - .just(.cards([])), + + let displayType = self.currentState.displayType + var emitObservable: Observable { + switch displayType { + case .latest: + return .just(.cards( + latests: [], + populars: self.currentState.popularCards ?? [], + distances: self.currentState.distanceCards ?? [] + )) + case .popular: + return .just(.cards( + latests: self.currentState.latestCards ?? [], + populars: [], + distances: self.currentState.distanceCards ?? [] + )) + case .distance: + return .just(.cards( + latests: self.currentState.latestCards ?? [], + populars: self.currentState.popularCards ?? [], + distances: [] + )) + } + } + + return .concat([ + emitObservable, .just(.updateIsRefreshing(false)) ]) } @@ -298,8 +356,27 @@ extension HomeViewReactor { var catchClosureForMore: ((Error) throws -> Observable ) { return { _ in - .concat([ - .just(.more([])), + + let displayType = self.currentState.displayType + var emitObservable: Observable { + switch displayType { + case .latest: + return .just(.more( + latests: [], + distances: self.currentState.distanceCards ?? [] + )) + case .distance: + return .just(.more( + latests: self.currentState.latestCards ?? [], + distances: [] + )) + default: + return .empty() + } + } + + return .concat([ + emitObservable, .just(.updateIsRefreshing(false)) ]) } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewController.swift index 7041ac1b..2f176e68 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewController.swift @@ -23,6 +23,9 @@ class NotificationViewController: BaseNavigationViewController, View { static let noticeTitle: String = "공지사항" static let headerTextForRead: String = "지난 알림" + + static let pungedCardDialogTitle: String = "삭제된 카드예요" + static let confirmActionTitle: String = "확인" } enum Section: Int, CaseIterable { @@ -180,7 +183,7 @@ class NotificationViewController: BaseNavigationViewController, View { reactor.state.map(\.displayType) .filter { $0 == .notice } .take(1) - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self.headerView) { headerView, _ in headerView.didSelectTabBarItem(1, onlyUpdateApperance: true) } @@ -194,7 +197,13 @@ class NotificationViewController: BaseNavigationViewController, View { notices: $0.notices ) } - .observe(on: MainScheduler.asyncInstance) + /// 읽지 않은 알림이 없을 때, 홈에 알림 + .do(onNext: { + if $0.unreads?.isEmpty == true { + NotificationCenter.default.post(name: .updatedHasUnreadNotification, object: nil, userInfo: nil) + } + }) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, displayStats in var snapshot = Snapshot() @@ -233,15 +242,41 @@ class NotificationViewController: BaseNavigationViewController, View { object.tableView.isHidden = false } .disposed(by: self.disposeBag) - } - - - // MARK: Objc func - - @objc - private func reloadData(_ notification: Notification) { - self.reactor?.action.onNext(.landing) + let cardIsDeleted = reactor.state.map(\.cardIsDeleted) + .distinctUntilChanged(reactor.canPushToDetail) + .filterNil() + cardIsDeleted + .filter { $0.isDeleted } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, _ in + object.showPungedCardDialog(reactor) + } + .disposed(by: self.disposeBag) + cardIsDeleted + .filter { $0.isDeleted == false } + .map { ($0.selectedId, $0.selectedNotiId) } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, combined in + + let (selectedId, selectedNotiId) = combined + if let selectedNotiId = selectedNotiId { + reactor.action.onNext(.requestRead(selectedNotiId)) + } + + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForDetail(with: selectedId) + object.navigationPush(detailViewController, animated: true) { _ in + reactor.action.onNext(.cleanup) + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click( + previous_path: .notification + ) + ) + } + } + .disposed(by: self.disposeBag) } } @@ -284,6 +319,31 @@ private extension NotificationViewController { } } +private extension NotificationViewController { + + func showPungedCardDialog(_ reactor: NotificationViewReactor) { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + reactor.action.onNext(.cleanup) + + reactor.action.onNext(.updateNotifications) + } + } + ) + + SOMDialogViewController.show( + title: Text.pungedCardDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [confirmAction] + ) + } +} + // MARK: UITableViewDelegate @@ -307,28 +367,20 @@ extension NotificationViewController: UITableViewDelegate { switch notification { case let .default(notification): - reactor.action.onNext(.requestRead(notification.notificationInfo.notificationId)) - - switch notification.notificationInfo.notificationType { - case .feedLike, .commentLike, .commentWrite: - - let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForDetail( - with: notification.targetCardId + reactor.action.onNext( + .hasDetailCard( + selectedId: notification.targetCardId, + selectedNotiId: notification.notificationInfo.notificationId ) - self.navigationPush(detailViewController, animated: true) - default: - return - } + ) case let .tag(notification): - reactor.action.onNext(.requestRead(notification.notificationInfo.notificationId)) - - let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForDetail( - with: notification.targetCardId + reactor.action.onNext( + .hasDetailCard( + selectedId: notification.targetCardId, + selectedNotiId: notification.notificationInfo.notificationId + ) ) - self.navigationPush(detailViewController, animated: true) case let .follow(notification): reactor.action.onNext(.requestRead(notification.notificationInfo.notificationId)) @@ -344,18 +396,20 @@ extension NotificationViewController: UITableViewDelegate { switch notification { case let .default(notification): - let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForDetail( - with: notification.targetCardId + reactor.action.onNext( + .hasDetailCard( + selectedId: notification.targetCardId, + selectedNotiId: nil + ) ) - self.navigationPush(detailViewController, animated: true) case let .tag(notification): - let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForDetail( - with: notification.targetCardId + reactor.action.onNext( + .hasDetailCard( + selectedId: notification.targetCardId, + selectedNotiId: nil + ) ) - self.navigationPush(detailViewController, animated: true) case let .follow(notification): let profileViewController = ProfileViewController() diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewReactor.swift index 21bd7c5d..14c0e4ab 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewReactor.swift @@ -16,7 +16,10 @@ class NotificationViewReactor: Reactor { case refresh case updateDisplayType(DisplayType) case moreFind(lastId: String, displayType: DisplayType) + case hasDetailCard(selectedId: String, selectedNotiId: String?) + case updateNotifications case requestRead(String) + case cleanup } enum Mutation { @@ -25,6 +28,7 @@ class NotificationViewReactor: Reactor { case notices([NoticeInfo]) case moreNotices([NoticeInfo]) case updateDisplayType(DisplayType) + case cardIsDeleted((String, String?, Bool)?) case updateIsRefreshing(Bool) case updateIsReadSuccess(Bool) } @@ -34,6 +38,7 @@ class NotificationViewReactor: Reactor { fileprivate(set) var notificationsForUnread: [CompositeNotificationInfo]? fileprivate(set) var notifications: [CompositeNotificationInfo]? fileprivate(set) var notices: [NoticeInfo]? + fileprivate(set) var cardIsDeleted: (selectedId: String, selectedNotiId: String?, isDeleted: Bool)? fileprivate(set) var isRefreshing: Bool fileprivate(set) var isReadSuccess: Bool } @@ -43,17 +48,20 @@ class NotificationViewReactor: Reactor { private let dependencies: AppDIContainerable private let notificationUseCase: NotificationUseCase private let fetchNoticeUseCase: FetchNoticeUseCase + private let fetchCardDetailUseCase: FetchCardDetailUseCase init(dependencies: AppDIContainerable, displayType: DisplayType = .activity(.unread)) { self.dependencies = dependencies self.notificationUseCase = dependencies.rootContainer.resolve(NotificationUseCase.self) self.fetchNoticeUseCase = dependencies.rootContainer.resolve(FetchNoticeUseCase.self) + self.fetchCardDetailUseCase = dependencies.rootContainer.resolve(FetchCardDetailUseCase.self) self.initialState = State( displayType: displayType, notificationsForUnread: nil, notifications: nil, notices: nil, + cardIsDeleted: nil, isRefreshing: false, isReadSuccess: false ) @@ -116,6 +124,22 @@ class NotificationViewReactor: Reactor { .catch(self.catchClosureNoticesMore) ]) } + case let .hasDetailCard(selectedId, selectedNotiId): + + return .concat([ + .just(.cardIsDeleted(nil)), + self.fetchCardDetailUseCase.isDeleted(cardId: selectedId) + .map { (selectedId, selectedNotiId, $0) } + .map(Mutation.cardIsDeleted) + ]) + case .updateNotifications: + + return Observable.zip( + self.notificationUseCase.unreadNotifications(lastId: nil), + self.notificationUseCase.readNotifications(lastId: nil) + ) + .map(Mutation.notifications) + .catch(self.catchClosureNotis) case let .requestRead(selectedId): return self.notificationUseCase.requestRead(notificationId: selectedId) @@ -134,6 +158,9 @@ class NotificationViewReactor: Reactor { return .just(.updateIsReadSuccess(false)) } } + case .cleanup: + + return .just(.cardIsDeleted(nil)) } } @@ -152,6 +179,8 @@ class NotificationViewReactor: Reactor { newState.notices? += notices case let .updateDisplayType(displayType): newState.displayType = displayType + case let .cardIsDeleted(cardIsDeleted): + newState.cardIsDeleted = cardIsDeleted case let .updateIsRefreshing(isRefreshing): newState.isRefreshing = isRefreshing case let .updateIsReadSuccess(isReadSuccess): @@ -261,6 +290,15 @@ extension NotificationViewReactor { prevStates.reads == currStates.reads && prevStates.notices == currStates.notices } + + func canPushToDetail( + prev prevCardIsDeleted: (selectedId: String, selectedNotiId: String?, isDeleted: Bool)?, + curr currCardIsDeleted: (selectedId: String, selectedNotiId: String?, isDeleted: Bool)? + ) -> Bool { + return prevCardIsDeleted?.selectedId == currCardIsDeleted?.selectedId && + prevCardIsDeleted?.selectedNotiId == currCardIsDeleted?.selectedNotiId && + prevCardIsDeleted?.isDeleted == currCardIsDeleted?.isDeleted + } } extension NotificationViewReactor { diff --git a/SOOUM/SOOUM/Presentations/Main/MainTabBarController.swift b/SOOUM/SOOUM/Presentations/Main/MainTabBarController.swift index 10dfb1e9..cd9c691c 100644 --- a/SOOUM/SOOUM/Presentations/Main/MainTabBarController.swift +++ b/SOOUM/SOOUM/Presentations/Main/MainTabBarController.swift @@ -149,7 +149,7 @@ class MainTabBarController: SOMTabBarController, View { // State reactor.pulse(\.$profileInfo) .filterNil() - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, profileInfo in switch reactor.currentState.entranceType { @@ -165,7 +165,7 @@ class MainTabBarController: SOMTabBarController, View { object?.setupDetailViewController( selectedViewController, with: reactor.reactorForDetail(targetCardId), - completion: { reactor.action.onNext(.resetEntrance) } + completion: { reactor.action.onNext(.cleanup) } ) } case .pushToNotification: @@ -178,7 +178,7 @@ class MainTabBarController: SOMTabBarController, View { object?.setupNotificationViewController( selectedViewController, with: reactor.reactorForNoti(), - completion: { reactor.action.onNext(.resetEntrance) } + completion: { reactor.action.onNext(.cleanup) } ) } case .pushToTagDetail: @@ -193,7 +193,7 @@ class MainTabBarController: SOMTabBarController, View { object?.setupTagDetailViewController( selectedViewController, with: reactor.reactorForDetail(targetCardId), - completion: { reactor.action.onNext(.resetEntrance) } + completion: { reactor.action.onNext(.cleanup) } ) } case .pushToFollow: @@ -206,7 +206,7 @@ class MainTabBarController: SOMTabBarController, View { object?.setupFollowViewController( selectedViewController, with: reactor.reactorForFollow(nickname: profileInfo.nickname, with: profileInfo.userId), - completion: { reactor.action.onNext(.resetEntrance) } + completion: { reactor.action.onNext(.cleanup) } ) } case .pushToLaunchScreen: @@ -243,12 +243,11 @@ class MainTabBarController: SOMTabBarController, View { writeCardViewController, animated: true ) { _ in - reactor.action.onNext(.resetCouldPosting) + reactor.action.onNext(.cleanup) } } } .disposed(by: self.disposeBag) - couldPosting .filter { $0.isBaned } .observe(on: MainScheduler.instance) @@ -276,6 +275,9 @@ extension MainTabBarController: SOMTabBarControllerDelegate { if viewController.tabBarItem.tag == 1 { self.willPushWriteCard.accept(()) + + GAHelper.shared.logEvent(event: GAEvent.TabBar.moveToCreateFeedCardView_btn_click) + return false } @@ -305,8 +307,8 @@ private extension MainTabBarController { title: Constants.Text.confirmActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { - self.reactor?.action.onNext(.resetCouldPosting) + SOMDialogViewController.dismiss { + self.reactor?.action.onNext(.cleanup) } } ) diff --git a/SOOUM/SOOUM/Presentations/Main/MainTabBarReactor.swift b/SOOUM/SOOUM/Presentations/Main/MainTabBarReactor.swift index 2e0a6567..656722cc 100644 --- a/SOOUM/SOOUM/Presentations/Main/MainTabBarReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/MainTabBarReactor.swift @@ -29,15 +29,13 @@ class MainTabBarReactor: Reactor { case requestLocationPermission case judgeEntrance case postingPermission - case resetCouldPosting - case resetEntrance + case cleanup } enum Mutation { case updateEntrance(ProfileInfo) case updatePostingPermission(PostingPermission?) - case resetCouldPosting - case resetEntrance + case cleanup } struct State { @@ -114,12 +112,9 @@ class MainTabBarReactor: Reactor { return self.validateUserUseCase.postingPermission() .map(Mutation.updatePostingPermission) - case .resetCouldPosting: + case .cleanup: - return .just(.resetCouldPosting) - case .resetEntrance: - - return .just(.resetEntrance) + return .just(.cleanup) } } @@ -130,10 +125,9 @@ class MainTabBarReactor: Reactor { newState.profileInfo = profileInfo case let .updatePostingPermission(couldPosting): newState.couldPosting = couldPosting - case .resetCouldPosting: - newState.couldPosting = nil - case .resetEntrance: + case .cleanup: newState.entranceType = .none + newState.couldPosting = nil newState.profileInfo = nil self.pushInfo = nil } @@ -181,3 +175,12 @@ extension MainTabBarReactor { LaunchScreenViewReactor(dependencies: self.dependencies, pushInfo: self.pushInfo) } } + +extension MainTabBarReactor.State: Equatable { + + static func == (lhs: MainTabBarReactor.State, rhs: MainTabBarReactor.State) -> Bool { + return lhs.entranceType == rhs.entranceType && + lhs.couldPosting == rhs.couldPosting && + lhs.profileInfo == rhs.profileInfo + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewController.swift index 8419a5f2..7e5e619d 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewController.swift @@ -309,7 +309,7 @@ class FollowViewController: BaseNavigationViewController, View { followings: $0.followings ) } - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, displayStates in var followerTabItem: String { @@ -378,14 +378,14 @@ private extension FollowViewController { title: Text.cancelActionTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) let deleteAction = SOMDialogAction( title: Text.deleteActionTitle, style: .red, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { self.reactor?.action.onNext(.updateFollow(userId, false)) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewController.swift index 0e1917bf..aac08adb 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewController.swift @@ -31,6 +31,9 @@ class ProfileViewController: BaseNavigationViewController, View { static let deleteFollowingDialogTitle: String = "님을 팔로워에서 삭제하시겠어요?" + static let pungedCardDialogTitle: String = "삭제된 카드예요" + + static let confirmActionTitle: String = "확인" static let cancelActionTitle: String = "취소" static let blockActionTitle: String = "차단하기" static let unBlockActionTitle: String = "차단 해제" @@ -214,12 +217,8 @@ class ProfileViewController: BaseNavigationViewController, View { cell.cardDidTap .throttle(.seconds(3), scheduler: MainScheduler.instance) - .subscribe(with: self) { object, selectedId in - let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForDetail(selectedId) - let base = reactor.entranceType == .my ? object.parent : object - base?.navigationPush(detailViewController, animated: true) - } + .map(Reactor.Action.hasDetailCard) + .bind(to: reactor.action) .disposed(by: cell.disposeBag) cell.moreFindCards @@ -311,6 +310,20 @@ class ProfileViewController: BaseNavigationViewController, View { name: .reloadProfileData, object: nil ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.reloadCardsData(_:)), + name: .reloadHomeData, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.reloadCardsData(_:)), + name: .deletedFeedCardWithId, + object: nil + ) } @@ -334,14 +347,14 @@ class ProfileViewController: BaseNavigationViewController, View { title: Text.cancelActionTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) let confirmAction = SOMDialogAction( title: Text.blockActionTitle, style: .red, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { reactor.action.onNext(.block) } @@ -381,7 +394,7 @@ class ProfileViewController: BaseNavigationViewController, View { } .disposed(by: self.disposeBag) - reactor.state.map { + let displayStates = reactor.state.map { ProfileViewReactor.DisplayStates( cardType: $0.cardType, profileInfo: $0.profileInfo, @@ -389,8 +402,41 @@ class ProfileViewController: BaseNavigationViewController, View { commentCardInfos: $0.commentCardInfos ) } + let cardIsDeleted = reactor.state.map(\.cardIsDeleted) + .distinctUntilChanged(reactor.canPushToDetail) + .filterNil() + cardIsDeleted + .filter { $0.isDeleted } + .withLatestFrom(displayStates.map(\.cardType)) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, cardType in + object.showPungedCardDialog(reactor, with: cardType) + } + .disposed(by: self.disposeBag) + + cardIsDeleted + .filter { $0.isDeleted == false } + .map(\.selectedId) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, selectedId in + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForDetail(selectedId) + let base = reactor.entranceType == .my ? object.parent : object + base?.navigationPush(detailViewController, animated: true) { _ in + reactor.action.onNext(.cleanup) + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click( + previous_path: .profile + ) + ) + } + } + .disposed(by: self.disposeBag) + + displayStates .distinctUntilChanged(reactor.canUpdateCells) - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, displayStates in var snapshot = Snapshot() @@ -416,21 +462,14 @@ class ProfileViewController: BaseNavigationViewController, View { } .disposed(by: self.disposeBag) - reactor.pulse(\.$isBlocked) - .filterNil() - .filter { $0 } - .subscribe(with: self) { object, _ in - reactor.action.onNext(.updateCards) - } - .disposed(by: self.disposeBag) - - reactor.pulse(\.$isFollowing) - .filterNil() - .filter { $0 } - .subscribe(with: self) { object, _ in - reactor.action.onNext(.updateProfile) - } - .disposed(by: self.disposeBag) + Observable.merge( + reactor.pulse(\.$isBlocked).filterNil().filter { $0 }, + reactor.pulse(\.$isFollowing).filterNil().filter { $0 } + ) + .subscribe(with: self) { object, _ in + reactor.action.onNext(.updateProfile) + } + .disposed(by: self.disposeBag) } @@ -461,6 +500,12 @@ class ProfileViewController: BaseNavigationViewController, View { self.reactor?.action.onNext(.updateProfile) } + + @objc + private func reloadCardsData(_ notification: Notification) { + + self.reactor?.action.onNext(.updateCards) + } } @@ -474,14 +519,14 @@ private extension ProfileViewController { title: Text.cancelActionTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) let deleteAction = SOMDialogAction( title: Text.deleteActionTitle, style: .red, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { self.reactor?.action.onNext(.follow) } } @@ -501,7 +546,7 @@ private extension ProfileViewController { title: Text.cancelActionTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) @@ -509,7 +554,7 @@ private extension ProfileViewController { title: Text.unBlockActionTitle, style: .red, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { self.reactor?.action.onNext(.block) } } @@ -522,6 +567,27 @@ private extension ProfileViewController { actions: [cancelAction, unBlockAction] ) } + + func showPungedCardDialog(_ reactor: ProfileViewReactor, with cardType: EntranceCardType) { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + reactor.action.onNext(.cleanup) + reactor.action.onNext(.updateCards) + } + } + ) + + SOMDialogViewController.show( + title: Text.pungedCardDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [confirmAction] + ) + } } @@ -586,7 +652,9 @@ extension ProfileViewController: UICollectionViewDelegateFlowLayout { case .feed: let newHeight = self.updateCollectionViewHeight(numberOfItems: feeds.count) - collectionView.contentInset.bottom = defaultHeight <= newHeight ? 88 + 16 : 0 + if reactor.entranceType == .my { + collectionView.contentInset.bottom = defaultHeight <= newHeight ? 88 + 16 : 0 + } return feeds.isEmpty ? defaultHeight : newHeight case .comment: diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewReactor.swift index 29c274a8..a906bde6 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewReactor.swift @@ -27,6 +27,8 @@ class ProfileViewReactor: Reactor { case updateProfile case updateCards case updateCardType(EntranceCardType) + case hasDetailCard(String) + case cleanup } enum Mutation { @@ -36,6 +38,7 @@ class ProfileViewReactor: Reactor { case commentCardInfos([ProfileCardInfo]) case moreCommentCardInfos([ProfileCardInfo]) case updateCardType(EntranceCardType) + case cardIsDeleted((String, Bool)?) case updateIsBlocked(Bool?) case updateIsFollowing(Bool?) case updateIsRefreshing(Bool) @@ -46,6 +49,7 @@ class ProfileViewReactor: Reactor { fileprivate(set) var feedCardInfos: [ProfileCardInfo] fileprivate(set) var commentCardInfos: [ProfileCardInfo] fileprivate(set) var cardType: EntranceCardType + fileprivate(set) var cardIsDeleted: (selectedId: String, isDeleted: Bool)? @Pulse fileprivate(set) var isBlocked: Bool? @Pulse fileprivate(set) var isFollowing: Bool? fileprivate(set) var isRefreshing: Bool @@ -56,6 +60,7 @@ class ProfileViewReactor: Reactor { feedCardInfos: [], commentCardInfos: [], cardType: .feed, + cardIsDeleted: nil, isBlocked: nil, isFollowing: nil, isRefreshing: false @@ -64,6 +69,7 @@ class ProfileViewReactor: Reactor { private let dependencies: AppDIContainerable private let fetchUserInfoUseCase: FetchUserInfoUseCase private let fetchCardUseCase: FetchCardUseCase + private let fetchCardDetailUseCase: FetchCardDetailUseCase private let blockUserUseCase: BlockUserUseCase private let updateFollowUseCase: UpdateFollowUseCase @@ -75,6 +81,7 @@ class ProfileViewReactor: Reactor { self.dependencies = dependencies self.fetchUserInfoUseCase = dependencies.rootContainer.resolve(FetchUserInfoUseCase.self) self.fetchCardUseCase = dependencies.rootContainer.resolve(FetchCardUseCase.self) + self.fetchCardDetailUseCase = dependencies.rootContainer.resolve(FetchCardDetailUseCase.self) self.blockUserUseCase = dependencies.rootContainer.resolve(BlockUserUseCase.self) self.updateFollowUseCase = dependencies.rootContainer.resolve(UpdateFollowUseCase.self) @@ -172,20 +179,36 @@ class ProfileViewReactor: Reactor { .map(Mutation.profile) case .updateCards: - if self.entranceType == .other, let userId = self.currentState.profileInfo?.userId { + guard let userId = self.currentState.profileInfo?.userId else { return .empty() } + + switch self.entranceType { + case .my: return .concat([ - self.fetchUserInfoUseCase.userInfo(userId: userId) - .map(Mutation.profile), self.fetchCardUseCase.writtenFeedCards(userId: userId, lastId: nil) - .map(Mutation.feedCardInfos) + .map(Mutation.feedCardInfos), + self.fetchCardUseCase.writtenCommentCards(lastId: nil) + .map(Mutation.commentCardInfos) ]) + case .other: + + return self.fetchCardUseCase.writtenFeedCards(userId: userId, lastId: nil) + .map(Mutation.feedCardInfos) } - - return .empty() case let .updateCardType(cardType): return .just(.updateCardType(cardType)) + case let .hasDetailCard(selectedId): + + return .concat([ + .just(.cardIsDeleted(nil)), + self.fetchCardDetailUseCase.isDeleted(cardId: selectedId) + .map { (selectedId, $0) } + .map(Mutation.cardIsDeleted) + ]) + case .cleanup: + + return .just(.cardIsDeleted(nil)) case .block: guard let userId = self.currentState.profileInfo?.userId, @@ -226,6 +249,8 @@ class ProfileViewReactor: Reactor { newState.commentCardInfos += commentCardInfos case let .updateCardType(cardType): newState.cardType = cardType + case let .cardIsDeleted(cardIsDeleted): + newState.cardIsDeleted = cardIsDeleted case let .updateIsBlocked(isBlocked): newState.isBlocked = isBlocked case let .updateIsFollowing(isFollowing): @@ -252,6 +277,14 @@ extension ProfileViewReactor { prevDisplayState.feedCardInfos == currDisplayState.feedCardInfos && prevDisplayState.commentCardInfos == currDisplayState.commentCardInfos } + + func canPushToDetail( + prev prevCardIsDeleted: (selectedId: String, isDeleted: Bool)?, + curr currCardIsDeleted: (selectedId: String, isDeleted: Bool)? + ) -> Bool { + return prevCardIsDeleted?.selectedId == currCardIsDeleted?.selectedId && + prevCardIsDeleted?.isDeleted == currCardIsDeleted?.isDeleted + } } extension ProfileViewReactor { diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewControler.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewControler.swift index c57dcc8d..a3d192ac 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewControler.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewControler.swift @@ -110,7 +110,7 @@ class AnnouncementViewController: BaseNavigationViewController, View { reactor.state.map(\.announcements) .distinctUntilChanged() - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, announcements in object.announcements = announcements diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewController.swift index 727438a9..7d0aa893 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewController.swift @@ -170,8 +170,9 @@ class BlockUsersViewController: BaseNavigationViewController, View { } .disposed(by: self.disposeBag) - reactor.state.map(\.blockUserInfos) - .observe(on: MainScheduler.asyncInstance) + let blockUserInfos = reactor.state.map(\.blockUserInfos).distinctUntilChanged() + blockUserInfos + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, blockUserInfos in var snapshot = Snapshot() @@ -188,13 +189,26 @@ class BlockUsersViewController: BaseNavigationViewController, View { } .disposed(by: self.disposeBag) - reactor.state.map(\.isCanceled) - .filterNil() - .distinctUntilChanged() - .filter { $0 } - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) + Observable.combineLatest( + blockUserInfos, + reactor.state.map(\.isCanceledWithId) + .distinctUntilChanged(reactor.canUpdateCanceledWithId) + .filterNil() + .filter { $0.isCanceled } + .map(\.userId) + ) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { combined in + + NotificationCenter.default.post(name: .reloadHomeData, object: nil, userInfo: nil) + + let (blockuserInfos, userId) = combined + + reactor.action.onNext( + .updateBlockUserInfos(blockuserInfos.filter { $0.userId != userId }) + ) + }) + .disposed(by: self.disposeBag) } } @@ -209,7 +223,7 @@ extension BlockUsersViewController { title: Text.cancelActionButtonTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) @@ -217,7 +231,7 @@ extension BlockUsersViewController { title: Text.unBlockActionButtonTitle, style: .red, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { self.reactor?.action.onNext(.cancelBlock(userId: userId)) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewReactor.swift index b221652e..a6382774 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewReactor.swift @@ -14,24 +14,25 @@ class BlockUsersViewReactor: Reactor { case refresh case moreFind(lastId: String) case cancelBlock(userId: String) + case updateBlockUserInfos([BlockUserInfo]) } enum Mutation { case blockUserInfos([BlockUserInfo]) case more([BlockUserInfo]) - case updateIsCanceled(Bool?) + case updateIsCanceled((isCanceled: Bool, userId: String)?) case updateIsRefreshing(Bool) } struct State { fileprivate(set) var blockUserInfos: [BlockUserInfo] - fileprivate(set) var isCanceled: Bool? + fileprivate(set) var isCanceledWithId: (isCanceled: Bool, userId: String)? fileprivate(set) var isRefreshing: Bool } var initialState: State = .init( blockUserInfos: [], - isCanceled: nil, + isCanceledWithId: nil, isRefreshing: false ) @@ -66,7 +67,11 @@ class BlockUsersViewReactor: Reactor { case let .cancelBlock(userId): return self.blockUserUseCase.updateBlocked(userId: userId, isBlocked: false) + .map { ($0, userId) } .map(Mutation.updateIsCanceled) + case let .updateBlockUserInfos(blockUserInfos): + + return .just(.blockUserInfos(blockUserInfos)) } } @@ -77,8 +82,8 @@ class BlockUsersViewReactor: Reactor { newState.blockUserInfos = blockUserInfos case let .more(blockUserInfos): newState.blockUserInfos += blockUserInfos - case let .updateIsCanceled(isCanceled): - newState.isCanceled = isCanceled + case let .updateIsCanceled(isCanceledWithId): + newState.isCanceledWithId = isCanceledWithId case let .updateIsRefreshing(isRefreshing): newState.isRefreshing = isRefreshing } @@ -86,6 +91,17 @@ class BlockUsersViewReactor: Reactor { } } +extension BlockUsersViewReactor { + + func canUpdateCanceledWithId( + prev prevIsCanceledWithId: (isCanceled: Bool, userId: String)?, + curr currIsCanceledWithId: (isCanceled: Bool, userId: String)? + ) -> Bool { + return prevIsCanceledWithId?.isCanceled == currIsCanceledWithId?.isCanceled && + prevIsCanceledWithId?.userId == currIsCanceledWithId?.userId + } +} + extension BlockUsersViewReactor { func reactorForProfile(_ userId: String) -> ProfileViewReactor { diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewController.swift index 6281c15e..86c1d77f 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewController.swift @@ -212,6 +212,8 @@ class EnterMemberTransferViewController: BaseNavigationViewController, View { .subscribe(with: self) { object, _ in guard let window = object.view.window else { return } + GAHelper.shared.logEvent(event: GAEvent.TransferView.accountTransferSuccess) + object.showSuccessDialog { let launchScreenViewController = LaunchScreenViewController() @@ -240,7 +242,7 @@ extension EnterMemberTransferViewController { title: Text.confirmButtonTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) @@ -258,7 +260,7 @@ extension EnterMemberTransferViewController { title: Text.confirmButtonTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { completion() } + SOMDialogViewController.dismiss { completion() } } ) diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewController.swift index d082cc19..c9cff304 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewController.swift @@ -160,10 +160,10 @@ class IssueMemberTransferViewController: BaseNavigationViewController, View { } .disposed(by: self.disposeBag) - let trnsferCodeInfo = reactor.state.map(\.trnsferCodeInfo).filterNil().distinctUntilChanged().share() + let trnsferCodeInfo = reactor.state.map(\.trnsferCodeInfo).distinctUntilChanged().filterNil().share() trnsferCodeInfo .map(\.code) - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .bind(to: self.transferCodeLabel.rx.text) .disposed(by: self.disposeBag) diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewController.swift index d797c7ca..c0b4d90b 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewController.swift @@ -197,7 +197,7 @@ class ResignViewController: BaseNavigationViewController, View { reactor.state.map(\.reason) .distinctUntilChanged() .filterNil() - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, reason in let items = object.container.arrangedSubviews.compactMap { $0 as? SOMButton } @@ -266,7 +266,7 @@ private extension ResignViewController { title: Text.confirmActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { completion() } + SOMDialogViewController.dismiss { completion() } } ) diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewController.swift index 496d5ed5..930ef1cf 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewController.swift @@ -270,7 +270,7 @@ class SettingsViewController: BaseNavigationViewController, View { } .disposed(by: self.disposeBag) - let version = reactor.state.map(\.version).filterNil().distinctUntilChanged().share() + let version = reactor.state.map(\.version).distinctUntilChanged().filterNil().share() self.appVersionCellView.rx.didSelect .throttle(.seconds(1), scheduler: MainScheduler.instance) .withLatestFrom(version) @@ -308,7 +308,7 @@ class SettingsViewController: BaseNavigationViewController, View { // State reactor.state.map(\.banEndAt) .distinctUntilChanged() - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, banEndAt in object.postingBlockedBackgroundView.isHidden = (banEndAt == nil) object.postingBlockedMessageLabel.text = Text.postingBlockedLeadingGuideMessage + @@ -319,7 +319,7 @@ class SettingsViewController: BaseNavigationViewController, View { reactor.state.map(\.rejoinableDate) .filterNil() - .observe(on: MainScheduler.instance) + .observe(on: MainScheduler.asyncInstance) .subscribe(with: self) { object, rejoinableDate in object.showResignDialog(rejoinableDate: rejoinableDate) } @@ -340,7 +340,7 @@ class SettingsViewController: BaseNavigationViewController, View { reactor.state.map(\.shouldHideTransfer) .distinctUntilChanged() - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, shouldHide in object.issueUserTransferCodeCellView.isHidden = shouldHide object.enterUserTransferCodeCellView.isHidden = shouldHide @@ -362,8 +362,8 @@ extension SettingsViewController { title: Text.cancelActionButtonTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) { - reactor.action.onNext(.resetState) + SOMDialogViewController.dismiss { + reactor.action.onNext(.cleanup) } } ) @@ -372,14 +372,14 @@ extension SettingsViewController { title: Text.resignTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { let resignViewController = ResignViewController() resignViewController.reactor = reactor.reactorForResign() self.navigationPush( resignViewController, animated: true ) { _ in - reactor.action.onNext(.resetState) + reactor.action.onNext(.cleanup) } } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewReactor.swift index ef096cc4..29a25ff6 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewReactor.swift @@ -16,7 +16,7 @@ class SettingsViewReactor: Reactor { case landing case updateNotificationStatus(Bool) case rejoinableDate - case resetState + case cleanup } enum Mutation { @@ -24,7 +24,7 @@ class SettingsViewReactor: Reactor { case updateVersion(Version?) case updateNotificationStatus(Bool) case rejoinableDate(RejoinableDateInfo?) - case resetState + case cleanup } struct State { @@ -82,9 +82,9 @@ class SettingsViewReactor: Reactor { return self.validateUserUseCase.iswithdrawn() .map(Mutation.rejoinableDate) - case .resetState: + case .cleanup: - return .just(.resetState) + return .just(.cleanup) } } @@ -99,7 +99,7 @@ class SettingsViewReactor: Reactor { newState.notificationStatus = notificationStatus case let .rejoinableDate(rejoinableDate): newState.rejoinableDate = rejoinableDate - case .resetState: + case .cleanup: newState.rejoinableDate = nil } return newState diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewController.swift index ad7d748c..13837b3e 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewController.swift @@ -14,6 +14,8 @@ import Photos import SwiftEntryKit import YPImagePicker +import Clarity + import ReactorKit import RxCocoa import RxGesture @@ -247,7 +249,7 @@ class UpdateProfileViewController: BaseNavigationViewController, View { // State let profileImage = reactor.state.map(\.profileImage).distinctUntilChanged().share() profileImage - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, profileImage in object.profileImageView.image = profileImage ?? .init(.image(.v2(.profile_large))) @@ -334,14 +336,14 @@ private extension UpdateProfileViewController { title: Text.cancelActionTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) let settingAction = SOMDialogAction( title: Text.settingActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { let application = UIApplication.shared let openSettingsURLString: String = UIApplication.openSettingsURLString @@ -367,7 +369,7 @@ private extension UpdateProfileViewController { title: Text.inappositeDialogConfirmButtonTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { reactor.action.onNext(.setDefaultImage) } } @@ -417,11 +419,11 @@ private extension UpdateProfileViewController { } else { Log.error("Error occured while picking an image") } - picker?.dismiss(animated: true, completion: nil) + picker?.dismiss(animated: true) { ClaritySDK.resume() } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.present(picker, animated: true, completion: nil) + self?.present(picker, animated: true) { ClaritySDK.pause() } } } } diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Collect/TagCollectViewController.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Collect/TagCollectViewController.swift index d3fa9cbc..f30ccd80 100644 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Collect/TagCollectViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Collect/TagCollectViewController.swift @@ -29,6 +29,9 @@ class TagCollectViewController: BaseNavigationViewController, View { static let bottomToastEntryNameWithoutAction: String = "bottomToastEntryNameWithoutAction" static let addAdditionalLimitedFloatMessage: String = "관심 태그는 9개까지 추가할 수 있어요" + + static let pungedCardDialogTitle: String = "삭제된 카드예요" + static let confirmActionTitle: String = "확인" } enum Section: Int, CaseIterable { @@ -98,12 +101,10 @@ class TagCollectViewController: BaseNavigationViewController, View { // 상세화면 전환 self.tagCollectCardsView.cardDidTapped + .map(\.id) .throttle(.seconds(3), scheduler: MainScheduler.instance) - .subscribe(with: self) { object, model in - let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForDetail(model.id) - object.navigationPush(detailViewController, animated: true) - } + .map(Reactor.Action.hasDetailCard) + .bind(to: reactor.action) .disposed(by: self.disposeBag) // Action @@ -135,15 +136,15 @@ class TagCollectViewController: BaseNavigationViewController, View { // State isRefreshing - .observe(on: MainScheduler.asyncInstance) .filter { $0 == false } + .observe(on: MainScheduler.asyncInstance) .subscribe(with: self.tagCollectCardsView) { tagCollectCardsView, _ in tagCollectCardsView.isRefreshing = false } .disposed(by: self.disposeBag) isFavorite - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, isFavorite in object.rightFavoriteButton.foregroundColor = isFavorite ? .som.v2.yMain : .som.v2.gray200 } @@ -153,9 +154,13 @@ class TagCollectViewController: BaseNavigationViewController, View { isUpdated .filter { $0 } .withLatestFrom(isFavorite) - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, isFavorite in + if isFavorite { + GAHelper.shared.logEvent(event: GAEvent.TagView.favoriteTagRegister_btn_click) + } + let message = isFavorite ? Text.addToastMessage : Text.deleteToastMessage let bottomToastView = SOMBottomToastView( title: "‘\(reactor.title)’" + message, @@ -171,7 +176,7 @@ class TagCollectViewController: BaseNavigationViewController, View { isUpdated .filter { $0 == false } .withLatestFrom(isFavorite) - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, isFavorite in let actions = [ @@ -193,6 +198,7 @@ class TagCollectViewController: BaseNavigationViewController, View { .distinctUntilChanged() .filterNil() .filter { $0 } + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, _ in let bottomToastView = SOMBottomToastView(title: Text.addAdditionalLimitedFloatMessage, actions: nil) @@ -205,11 +211,70 @@ class TagCollectViewController: BaseNavigationViewController, View { reactor.state.map(\.tagCardInfos) .distinctUntilChanged() - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, tagCardInfos in object.tagCollectCardsView.setModels(tagCardInfos) } .disposed(by: self.disposeBag) + + let cardIsDeleted = reactor.state.map(\.cardIsDeleted) + .distinctUntilChanged(reactor.canPushToDetail) + .filterNil() + cardIsDeleted + .filter { $0.isDeleted } + .map(\.selectedId) + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, selectedId in + object.showPungedCardDialog(reactor, with: selectedId) + } + .disposed(by: self.disposeBag) + cardIsDeleted + .filter { $0.isDeleted == false } + .map { $0.selectedId } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, selectedId in + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForDetail(selectedId) + object.navigationPush(detailViewController, animated: true) { _ in + reactor.action.onNext(.cleanup) + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click( + previous_path: .tag_collect + ) + ) + } + } + .disposed(by: self.disposeBag) + } +} + +extension TagCollectViewController { + + func showPungedCardDialog(_ reactor: TagCollectViewReactor, with selectedId: String) { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + reactor.action.onNext(.cleanup) + + reactor.action.onNext( + .updateTagCards( + reactor.currentState.tagCardInfos.filter { $0.id != selectedId } + ) + ) + } + } + ) + + SOMDialogViewController.show( + title: Text.pungedCardDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [confirmAction] + ) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Collect/TagCollectViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Collect/TagCollectViewReactor.swift index 2c952d31..d257a20e 100644 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Collect/TagCollectViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Collect/TagCollectViewReactor.swift @@ -13,12 +13,16 @@ class TagCollectViewReactor: Reactor { case landing case refresh case more(String) + case updateTagCards([ProfileCardInfo]) + case hasDetailCard(String) case updateIsFavorite(Bool) + case cleanup } enum Mutation { case tagCardInfos([ProfileCardInfo]) case moreFind([ProfileCardInfo]) + case cardIsDeleted((String, Bool)?) case updateIsFavorite(Bool) case updateIsUpdate(Bool?) case updateIsRefreshing(Bool) @@ -27,6 +31,7 @@ class TagCollectViewReactor: Reactor { struct State { fileprivate(set) var tagCardInfos: [ProfileCardInfo] + fileprivate(set) var cardIsDeleted: (selectedId: String, isDeleted: Bool)? fileprivate(set) var isFavorite: Bool fileprivate(set) var isUpdated: Bool? fileprivate(set) var isRefreshing: Bool @@ -37,6 +42,7 @@ class TagCollectViewReactor: Reactor { private let dependencies: AppDIContainerable private let fetchCardUseCase: FetchCardUseCase + private let fetchCardDetailUseCase: FetchCardDetailUseCase private let updateTagFavoriteUseCase: UpdateTagFavoriteUseCase private let id: String @@ -45,6 +51,7 @@ class TagCollectViewReactor: Reactor { init(dependencies: AppDIContainerable, with id: String, title: String, isFavorite: Bool) { self.dependencies = dependencies self.fetchCardUseCase = dependencies.rootContainer.resolve(FetchCardUseCase.self) + self.fetchCardDetailUseCase = dependencies.rootContainer.resolve(FetchCardDetailUseCase.self) self.updateTagFavoriteUseCase = dependencies.rootContainer.resolve(UpdateTagFavoriteUseCase.self) self.id = id @@ -52,6 +59,7 @@ class TagCollectViewReactor: Reactor { self.initialState = .init( tagCardInfos: [], + cardIsDeleted: nil, isFavorite: isFavorite, isUpdated: nil, isRefreshing: false, @@ -96,6 +104,17 @@ class TagCollectViewReactor: Reactor { return self.fetchCardUseCase.cardsWithTag(tagId: self.id, lastId: lastId) .map(\.cardInfos) .map(Mutation.moreFind) + case let .updateTagCards(tagCardInfos): + + return .just(.tagCardInfos(tagCardInfos)) + case let .hasDetailCard(selectedId): + + return .concat([ + .just(.cardIsDeleted(nil)), + self.fetchCardDetailUseCase.isDeleted(cardId: selectedId) + .map { (selectedId, $0) } + .map(Mutation.cardIsDeleted) + ]) case let .updateIsFavorite(isFavorite): return .concat([ @@ -112,6 +131,9 @@ class TagCollectViewReactor: Reactor { } .catch(self.catchClosure) ]) + case .cleanup: + + return .just(.cardIsDeleted(nil)) } } @@ -122,6 +144,8 @@ class TagCollectViewReactor: Reactor { newState.tagCardInfos = tagCardInfos case let .moreFind(tagCardInfos): newState.tagCardInfos += tagCardInfos + case let .cardIsDeleted(cardIsDeleted): + newState.cardIsDeleted = cardIsDeleted case let .updateIsFavorite(isFavorite): newState.isFavorite = isFavorite case let .updateIsUpdate(isUpdated): @@ -135,7 +159,7 @@ class TagCollectViewReactor: Reactor { } } -private extension TagCollectViewReactor { +extension TagCollectViewReactor { var catchClosure: ((Error) throws -> Observable) { return { error in @@ -150,6 +174,14 @@ private extension TagCollectViewReactor { return .just(.updateIsUpdate(false)) } } + + func canPushToDetail( + prev prevCardIsDeleted: (selectedId: String, isDeleted: Bool)?, + curr currCardIsDeleted: (selectedId: String, isDeleted: Bool)? + ) -> Bool { + return prevCardIsDeleted?.selectedId == currCardIsDeleted?.selectedId && + prevCardIsDeleted?.isDeleted == currCardIsDeleted?.isDeleted + } } extension TagCollectViewReactor { diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Search/Search+Collect/TagSearchCollectViewController.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Search+Collect/TagSearchCollectViewController.swift index 294f875f..fd70de41 100644 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Search/Search+Collect/TagSearchCollectViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Search+Collect/TagSearchCollectViewController.swift @@ -29,6 +29,9 @@ class TagSearchCollectViewController: BaseNavigationViewController, View { static let bottomToastEntryNameWithoutAction: String = "bottomToastEntryNameWithoutAction" static let addAdditionalLimitedFloatMessage: String = "관심 태그는 9개까지 추가할 수 있어요" + + static let pungedCardDialogTitle: String = "삭제된 카드예요" + static let confirmActionTitle: String = "확인" } @@ -83,12 +86,10 @@ class TagSearchCollectViewController: BaseNavigationViewController, View { // 상세 화면 전환 self.tagCollectCardsView.cardDidTapped + .map(\.id) .throttle(.seconds(3), scheduler: MainScheduler.instance) - .subscribe(with: self) { object, model in - let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForDetail(with: model.id) - object.navigationPush(detailViewController, animated: true) - } + .map(Reactor.Action.hasDetailCard) + .bind(to: reactor.action) .disposed(by: self.disposeBag) let viewDidLoad = self.rx.viewDidLoad @@ -136,7 +137,7 @@ class TagSearchCollectViewController: BaseNavigationViewController, View { .disposed(by: self.disposeBag) isFavorite - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, isFavorite in object.rightFavoriteButton.foregroundColor = isFavorite ? .som.v2.yMain : .som.v2.gray200 } @@ -146,9 +147,13 @@ class TagSearchCollectViewController: BaseNavigationViewController, View { isUpdated .filter { $0 } .withLatestFrom(isFavorite) - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, isFavorite in + if isFavorite { + GAHelper.shared.logEvent(event: GAEvent.TagView.favoriteTagRegister_btn_click) + } + let message = isFavorite ? Text.addToastMessage : Text.deleteToastMessage let bottomToastView = SOMBottomToastView( title: "‘\(reactor.title)’" + message, @@ -164,7 +169,7 @@ class TagSearchCollectViewController: BaseNavigationViewController, View { isUpdated .filter { $0 == false } .withLatestFrom(isFavorite) - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, isFavorite in let actions = [ @@ -186,6 +191,7 @@ class TagSearchCollectViewController: BaseNavigationViewController, View { .distinctUntilChanged() .filterNil() .filter { $0 } + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, _ in let bottomToastView = SOMBottomToastView(title: Text.addAdditionalLimitedFloatMessage, actions: nil) @@ -198,11 +204,70 @@ class TagSearchCollectViewController: BaseNavigationViewController, View { reactor.state.map(\.tagCardInfos) .distinctUntilChanged() - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, tagCardInfos in object.tagCollectCardsView.setModels(tagCardInfos) } .disposed(by: self.disposeBag) + + let cardIsDeleted = reactor.state.map(\.cardIsDeleted) + .distinctUntilChanged(reactor.canPushToDetail) + .filterNil() + cardIsDeleted + .filter { $0.isDeleted } + .map { $0.selectedId } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, selectedId in + object.showPungedCardDialog(reactor, with: selectedId) + } + .disposed(by: self.disposeBag) + cardIsDeleted + .filter { $0.isDeleted == false } + .map { $0.selectedId } + .observe(on: MainScheduler.instance) + .subscribe(with: self) { object, selectedId in + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForDetail(with: selectedId) + object.navigationPush(detailViewController, animated: true) { _ in + reactor.action.onNext(.cleanup) + + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click( + previous_path: .tag_search_collect + ) + ) + } + } + .disposed(by: self.disposeBag) + } +} + +private extension TagSearchCollectViewController { + + func showPungedCardDialog(_ reactor: TagSearchCollectViewReactor, with selectedId: String) { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + SOMDialogViewController.dismiss { + reactor.action.onNext(.cleanup) + + reactor.action.onNext( + .updateTagCards( + reactor.currentState.tagCardInfos.filter { $0.id != selectedId } + ) + ) + } + } + ) + + SOMDialogViewController.show( + title: Text.pungedCardDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [confirmAction] + ) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Search/Search+Collect/TagSearchCollectViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Search+Collect/TagSearchCollectViewReactor.swift index e0de4618..5be7ac67 100644 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Search/Search+Collect/TagSearchCollectViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Search/Search+Collect/TagSearchCollectViewReactor.swift @@ -13,12 +13,16 @@ class TagSearchCollectViewReactor: Reactor { case landing case refresh case more(String) + case updateTagCards([ProfileCardInfo]) + case hasDetailCard(String) case updateIsFavorite(Bool) + case cleanup } enum Mutation { case tagCardInfos([ProfileCardInfo]) case moreFind([ProfileCardInfo]) + case cardIsDeleted((String, Bool)?) case updateIsUpdate(Bool?) case updateIsFavorite(Bool) case updateIsRefreshing(Bool) @@ -27,6 +31,7 @@ class TagSearchCollectViewReactor: Reactor { struct State { fileprivate(set) var tagCardInfos: [ProfileCardInfo] + fileprivate(set) var cardIsDeleted: (selectedId: String, isDeleted: Bool)? fileprivate(set) var isUpdated: Bool? fileprivate(set) var isFavorite: Bool fileprivate(set) var isRefreshing: Bool @@ -35,6 +40,7 @@ class TagSearchCollectViewReactor: Reactor { var initialState: State = .init( tagCardInfos: [], + cardIsDeleted: nil, isUpdated: nil, isFavorite: false, isRefreshing: false, @@ -43,6 +49,7 @@ class TagSearchCollectViewReactor: Reactor { private let dependencies: AppDIContainerable private let fetchCardUseCase: FetchCardUseCase + private let fetchCardDetailUseCase: FetchCardDetailUseCase private let fetchTagUseCase: FetchTagUseCase private let updateTagFavoriteUseCase: UpdateTagFavoriteUseCase @@ -52,6 +59,7 @@ class TagSearchCollectViewReactor: Reactor { init(dependencies: AppDIContainerable, with tagId: String, title: String) { self.dependencies = dependencies self.fetchCardUseCase = dependencies.rootContainer.resolve(FetchCardUseCase.self) + self.fetchCardDetailUseCase = dependencies.rootContainer.resolve(FetchCardDetailUseCase.self) self.fetchTagUseCase = dependencies.rootContainer.resolve(FetchTagUseCase.self) self.updateTagFavoriteUseCase = dependencies.rootContainer.resolve(UpdateTagFavoriteUseCase.self) @@ -78,6 +86,17 @@ class TagSearchCollectViewReactor: Reactor { return self.fetchCardUseCase.cardsWithTag(tagId: self.tagId, lastId: lastId) .map(\.cardInfos) .map(Mutation.moreFind) + case let .updateTagCards(tagCardInfos): + + return .just(.tagCardInfos(tagCardInfos)) + case let .hasDetailCard(selectedId): + + return .concat([ + .just(.cardIsDeleted(nil)), + self.fetchCardDetailUseCase.isDeleted(cardId: selectedId) + .map { (selectedId, $0) } + .map(Mutation.cardIsDeleted) + ]) case let .updateIsFavorite(isFavorite): return .concat([ @@ -94,6 +113,9 @@ class TagSearchCollectViewReactor: Reactor { } .catch(self.catchClosure) ]) + case .cleanup: + + return .just(.cardIsDeleted(nil)) } } @@ -104,6 +126,8 @@ class TagSearchCollectViewReactor: Reactor { newState.tagCardInfos = tagCardInfos case let .moreFind(tagCardInfos): newState.tagCardInfos += tagCardInfos + case let .cardIsDeleted(cardIsDeleted): + newState.cardIsDeleted = cardIsDeleted case let .updateIsUpdate(isUpdated): newState.isUpdated = isUpdated case let .updateIsFavorite(isFavorite): @@ -159,6 +183,17 @@ private extension TagSearchCollectViewReactor { } } +extension TagSearchCollectViewReactor { + + func canPushToDetail( + prev prevCardIsDeleted: (selectedId: String, isDeleted: Bool)?, + curr currCardIsDeleted: (selectedId: String, isDeleted: Bool)? + ) -> Bool { + return prevCardIsDeleted?.selectedId == currCardIsDeleted?.selectedId && + prevCardIsDeleted?.isDeleted == currCardIsDeleted?.isDeleted + } +} + extension TagSearchCollectViewReactor { func reactorForDetail(with id: String) -> DetailViewReactor { diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/Search/TagSearchViewController.swift b/SOOUM/SOOUM/Presentations/Main/Tags/Search/TagSearchViewController.swift index 04f40d30..238a8486 100644 --- a/SOOUM/SOOUM/Presentations/Main/Tags/Search/TagSearchViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Tags/Search/TagSearchViewController.swift @@ -121,7 +121,7 @@ class TagSearchViewController: BaseNavigationViewController, View { // 스크롤 시 키보드 내림 self.searchTermsView.didScrolled.asObservable() - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, _ in object.view.endEditing(true) } .disposed(by: self.disposeBag) @@ -138,7 +138,7 @@ class TagSearchViewController: BaseNavigationViewController, View { // State searchTerms .filter { $0 == nil } - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, _ in object.searchTermsView.isHidden = true } @@ -146,7 +146,7 @@ class TagSearchViewController: BaseNavigationViewController, View { searchTerms .filterNil() - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, searchTerms in object.searchTermsView.setModels(searchTerms) object.searchTermsView.isHidden = false @@ -158,7 +158,7 @@ class TagSearchViewController: BaseNavigationViewController, View { self.searchTextFieldView.textFieldDidReturn, resultSelector: { ($0, $1) } ) - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, searchTermInfos in let (searchTerms, returnKeyDidTap) = searchTermInfos diff --git a/SOOUM/SOOUM/Presentations/Main/Tags/TagViewController.swift b/SOOUM/SOOUM/Presentations/Main/Tags/TagViewController.swift index 74ea69e5..6b400a79 100644 --- a/SOOUM/SOOUM/Presentations/Main/Tags/TagViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Tags/TagViewController.swift @@ -144,7 +144,9 @@ class TagViewController: BaseNavigationViewController, View { .subscribe(with: self) { object, _ in let tagSearchViewController = TagSearchViewController() tagSearchViewController.reactor = reactor.reactorForSearch() - object.parent?.navigationPush(tagSearchViewController, animated: true) + object.parent?.navigationPush(tagSearchViewController, animated: true) { _ in + GAHelper.shared.logEvent(event: GAEvent.TagView.tagMenuSearchBar_click) + } } .disposed(by: self.disposeBag) @@ -169,7 +171,9 @@ class TagViewController: BaseNavigationViewController, View { title: model.name, isFavorite: reactor.currentState.favoriteTags.contains(where: { $0.id == model.id }) ) - object.parent?.navigationPush(tagCollectViewController, animated: true) + object.parent?.navigationPush(tagCollectViewController, animated: true) { _ in + GAHelper.shared.logEvent(event: GAEvent.TagView.popularTag_item_click) + } } .disposed(by: self.disposeBag) @@ -209,7 +213,7 @@ class TagViewController: BaseNavigationViewController, View { ) } .distinctUntilChanged(reactor.canUpdateCells) - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, displayStats in object.favoriteTagHeaderView.title = (UserDefaults.standard.nickname ?? "") + Text.favoriteTagHeaderTitle diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardSelectImageView+Rx.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardSelectImageView+Rx.swift index e77d57d4..71531ca5 100644 --- a/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardSelectImageView+Rx.swift +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardSelectImageView+Rx.swift @@ -9,9 +9,9 @@ import RxSwift extension Reactive where Base: WriteCardSelectImageView { - var setModels: Binder { - return Binder(self.base) { imgaeView, models in - imgaeView.setModels(models) + var setModels: Binder<(DefaultImages, EntranceCardType)> { + return Binder(self.base) { imgaeView, tuple in + imgaeView.setModels(tuple.0, cardType: tuple.1) } } } diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardSelectImageView.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardSelectImageView.swift index 070838ee..3e2834a3 100644 --- a/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardSelectImageView.swift +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/SelectImage/WriteCardSelectImageView.swift @@ -159,6 +159,7 @@ class WriteCardSelectImageView: UIView { // MARK: Variables private(set) var models: DefaultImages = .defaultValue + private(set) var cardType: EntranceCardType = .feed var selectedImageInfo = BehaviorRelay<(type: BaseCardInfo.ImageType, info: ImageUrlInfo)?>(value: nil) var selectedUseUserImageCell = PublishRelay() @@ -206,9 +207,10 @@ class WriteCardSelectImageView: UIView { // MARK: Public func - func setModels(_ models: DefaultImages) { + func setModels(_ models: DefaultImages, cardType: EntranceCardType) { self.models = models + self.cardType = cardType guard let initialImage = models.color.first else { return } @@ -355,8 +357,32 @@ extension WriteCardSelectImageView: SOMSwipableTabBarDelegate { let .sensitivity(imageInfo), let .food(imageInfo), let .abstract(imageInfo), - let .memo(imageInfo), - let .event(imageInfo): + let .memo(imageInfo): + + if self.cardType == .feed { + GAHelper.shared.logEvent( + event: GAEvent.WriteCardView.feedBackgroundCategory_tab_click + ) + } else { + GAHelper.shared.logEvent( + event: GAEvent.WriteCardView.commentBackgroundCategory_tab_click + ) + } + + return imageInfo == self.selectedImageInfo.value?.info + case let .event(imageInfo): + + if self.cardType == .feed { + GAHelper.shared.logEvent( + event: GAEvent.WriteCardView.feedBackgroundCategory_tab_click + ) + } else { + GAHelper.shared.logEvent( + event: GAEvent.WriteCardView.commentBackgroundCategory_tab_click + ) + GAHelper.shared.logEvent(event: GAEvent.WriteCardView.createFeedCardEventCategory_btn_click) + } + return imageInfo == self.selectedImageInfo.value?.info case .user: return false diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTags.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTags.swift index 6002b8a0..f0f77e13 100644 --- a/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTags.swift +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/Tags/WriteCardTags.swift @@ -344,6 +344,8 @@ extension WriteCardTags: WriteCardTagFooterDelegate { textField.text = nil textField.sendActionsToTextField(for: .editingChanged) + GAHelper.shared.logEvent(event: GAEvent.WriteCardView.multipleFeedTagCreation_enter_btn_click) + return false } } diff --git a/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewController.swift b/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewController.swift index 1a3a100b..08e33944 100644 --- a/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewController.swift @@ -14,6 +14,8 @@ import Photos import SwiftEntryKit import YPImagePicker +import Clarity + import ReactorKit import RxCocoa import RxKeyboard @@ -209,6 +211,29 @@ class WriteCardViewController: BaseNavigationViewController, View { self.relatedTagsViewBottomConstraint?.update(offset: -height) } + override func bind() { + + self.navigationBar.backButton.rx.tap + .subscribe(with: self) { object, _ in + + object.navigationPop { + + if case .feed = object.reactor?.entranceType { + GAHelper.shared.logEvent( + event: GAEvent.WriteCardView.moveToCreateFeedCardView_cancel_btn_click + ) + } + + if case .comment = object.reactor?.entranceType { + GAHelper.shared.logEvent( + event: GAEvent.WriteCardView.moveToCreateCommentCardView_cancel_btn_click + ) + } + } + } + .disposed(by: self.disposeBag) + } + // MARK: ReactorKit - bind @@ -286,9 +311,9 @@ class WriteCardViewController: BaseNavigationViewController, View { .disposed(by: self.disposeBag) self.writeCardView.writeCardTags.updateWrittenTags - .filterNil() .distinctUntilChanged() - .observe(on: MainScheduler.asyncInstance) + .filterNil() + .observe(on: MainScheduler.instance) .bind(to: self.writeCardView.writeCardTags.rx.models()) .disposed(by: self.disposeBag) @@ -363,11 +388,11 @@ class WriteCardViewController: BaseNavigationViewController, View { .disposed(by: self.disposeBag) let selectedTypography = self.selectTypographyView.selectedTypography - .filterNil() .distinctUntilChanged() + .filterNil() .share(replay: 1) selectedTypography - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self.writeCardView) { writeCardView, selectedTypography in var typograhpyToTextView: Typography { switch selectedTypography { @@ -392,12 +417,12 @@ class WriteCardViewController: BaseNavigationViewController, View { .disposed(by: self.disposeBag) let selectedOptions = self.selectOptionsView.selectedOptions - .filterNil() .distinctUntilChanged() + .filterNil() .share() selectedOptions .filter { $0.contains(.distanceShare) } - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self) { object, options in // 선택된 옵션 중 `거리공유` 옵션이 존재하고, 위치 권한이 허용되지 않았을 때 guard reactor.initialState.hasPermission == false else { return } @@ -430,8 +455,8 @@ class WriteCardViewController: BaseNavigationViewController, View { let enteredTag = self.writeCardView.textDidChanged.share() enteredTag - .filterNil() .distinctUntilChanged() + .filterNil() .debounce(.milliseconds(500), scheduler: MainScheduler.instance) .map(Reactor.Action.relatedTags) .bind(to: reactor.action) @@ -450,6 +475,14 @@ class WriteCardViewController: BaseNavigationViewController, View { .map { object, combined in let (content, imageInfo, typography, options, enteredTag) = combined + GAHelper.shared.logEvent(event: GAEvent.WriteCardView.createFeedCard_btn_click) + + if options.contains(.distanceShare) == false { + GAHelper.shared.logEvent( + event: GAEvent.WriteCardView.createFeedCardWithoutDistanceSharedOpt_btn_click + ) + } + var enteredTagTexts = object.writeCardView.writeCardTags.models.map { $0.originalText } if let enteredTag = enteredTag, enteredTag.isEmpty == false { enteredTagTexts.append(enteredTag) @@ -461,7 +494,7 @@ class WriteCardViewController: BaseNavigationViewController, View { imageType: imageInfo.type, imageName: imageInfo.info.imgName, isStory: options.contains(.story), - tags: enteredTagTexts + tags: enteredTagTexts.reduce(into: []) { if !$0.contains($1) { $0.append($1) } } ) } .bind(to: reactor.action) @@ -483,10 +516,11 @@ class WriteCardViewController: BaseNavigationViewController, View { .disposed(by: self.disposeBag) reactor.state.map(\.writtenCardId) - .filterNil() .distinctUntilChanged() + .filterNil() .observe(on: MainScheduler.instance) .subscribe(with: self) { object, writtenCardId in + NotificationCenter.default.post(name: .reloadHomeData, object: nil, userInfo: nil) if reactor.entranceType == .comment { NotificationCenter.default.post(name: .reloadDetailData, object: nil, userInfo: nil) } @@ -499,6 +533,12 @@ class WriteCardViewController: BaseNavigationViewController, View { var viewControllers = navigationController.viewControllers if (viewControllers.popLast() as? Self) != nil { + GAHelper.shared.logEvent( + event: GAEvent.DetailView.cardDetailView_tracePath_click( + previous_path: .writeCard + ) + ) + viewControllers.append(detailViewController) navigationController.setViewControllers(viewControllers, animated: true) } else { @@ -511,8 +551,8 @@ class WriteCardViewController: BaseNavigationViewController, View { .disposed(by: self.disposeBag) reactor.state.map(\.hasErrors) - .filterNil() .distinctUntilChanged() + .filterNil() .observe(on: MainScheduler.instance) .subscribe(with: self) { object, hasErrors in if case 422 = hasErrors { @@ -541,15 +581,16 @@ class WriteCardViewController: BaseNavigationViewController, View { .disposed(by: self.disposeBag) reactor.state.map(\.defaultImages) - .filterNil() .distinctUntilChanged() - .observe(on: MainScheduler.asyncInstance) + .filterNil() + .map { ($0, reactor.entranceType) } + .observe(on: MainScheduler.instance) .bind(to: self.selectImageView.rx.setModels) .disposed(by: self.disposeBag) reactor.state.map(\.userImage) - .filterNil() .distinctUntilChanged() + .filterNil() .observe(on: MainScheduler.asyncInstance) .subscribe(with: self.writeCardView.writeCardTextView) { writeCardTextView, userImage in writeCardTextView.image = userImage @@ -558,13 +599,13 @@ class WriteCardViewController: BaseNavigationViewController, View { reactor.state.map(\.isDownloaded) .filter { $0 == true } - .observe(on: MainScheduler.asyncInstance) + .observe(on: MainScheduler.instance) .subscribe(with: self.selectImageView) { selectImageView, _ in selectImageView.updatedByUser() } .disposed(by: self.disposeBag) - let relatedTags = reactor.state.map(\.relatedTags).filterNil().distinctUntilChanged().share() + let relatedTags = reactor.state.map(\.relatedTags).distinctUntilChanged().filterNil().share() relatedTags .map { $0.isEmpty } .observe(on: MainScheduler.asyncInstance) @@ -590,21 +631,22 @@ extension WriteCardViewController { title: Text.cancelActionTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) let settingAction = SOMDialogAction( title: Text.settingActionTitle, style: .primary, action: { - let application = UIApplication.shared - let openSettingsURLString: String = UIApplication.openSettingsURLString - if let settingsURL = URL(string: openSettingsURLString), - application.canOpenURL(settingsURL) { - application.open(settingsURL) + SOMDialogViewController.dismiss { + + let application = UIApplication.shared + let openSettingsURLString: String = UIApplication.openSettingsURLString + if let settingsURL = URL(string: openSettingsURLString), + application.canOpenURL(settingsURL) { + application.open(settingsURL) + } } - - UIApplication.topViewController?.dismiss(animated: true) } ) @@ -621,21 +663,22 @@ extension WriteCardViewController { title: Text.cancelActionTitle, style: .gray, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) let settingAction = SOMDialogAction( title: Text.settingActionTitle, style: .primary, action: { - let application = UIApplication.shared - let openSettingsURLString: String = UIApplication.openSettingsURLString - if let settingsURL = URL(string: openSettingsURLString), - application.canOpenURL(settingsURL) { - application.open(settingsURL) + SOMDialogViewController.dismiss { + + let application = UIApplication.shared + let openSettingsURLString: String = UIApplication.openSettingsURLString + if let settingsURL = URL(string: openSettingsURLString), + application.canOpenURL(settingsURL) { + application.open(settingsURL) + } } - - UIApplication.topViewController?.dismiss(animated: true) } ) @@ -653,7 +696,7 @@ extension WriteCardViewController { title: Text.confirmActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) + SOMDialogViewController.dismiss() } ) ] @@ -679,7 +722,7 @@ extension WriteCardViewController { title: Text.confirmActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { self.navigationPop() } } @@ -699,7 +742,7 @@ extension WriteCardViewController { title: Text.confirmActionTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) { + SOMDialogViewController.dismiss { DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in self?.navigationPopToRoot() @@ -758,11 +801,11 @@ extension WriteCardViewController { self?.reactor?.action.onNext(.updateUserImage(nil, false)) Log.error("Error occured while picking an image") } - picker?.dismiss(animated: true, completion: nil) + picker?.dismiss(animated: true) { ClaritySDK.resume() } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.present(picker, animated: true, completion: nil) + self?.present(picker, animated: true) { ClaritySDK.pause() } } } } diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_timer_outlined.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_timer_outlined.imageset/Contents.json new file mode 100644 index 00000000..0d3a2317 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_timer_outlined.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_timer_outlined.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_timer_outlined.imageset/v2_timer_outlined.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_timer_outlined.imageset/v2_timer_outlined.svg new file mode 100644 index 00000000..cd0f74a2 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Outlined/v2_timer_outlined.imageset/v2_timer_outlined.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/SOOUM/SOOUM/Utilities/GAHelper/AnalyticsEventProtocol.swift b/SOOUM/SOOUM/Utilities/GAHelper/AnalyticsEventProtocol.swift new file mode 100644 index 00000000..9d4ffe15 --- /dev/null +++ b/SOOUM/SOOUM/Utilities/GAHelper/AnalyticsEventProtocol.swift @@ -0,0 +1,58 @@ +// +// AnalyticsEventProtocol.swift +// SOOUM +// +// Created by JDeoks on 3/11/25. +// + +protocol AnalyticsEventProtocol { + var eventName: String { get } + var parameters: [String: FirebaseLoggable]? { get } +} + +extension AnalyticsEventProtocol { + + var eventName: String { + let components = String(describing: type(of: self)).split(separator: ".").map { String($0) } + + let parentEnumName = components.first ?? "" + let childEnumName = components.last ?? "" + + let caseName = "\(self)".components(separatedBy: "(").first ?? "" + + return "\(parentEnumName)_\(childEnumName)_\(caseName)" + } + + var parameters: [String: FirebaseLoggable]? { + let mirror = Mirror(reflecting: self) + + guard let child = mirror.children.first else { return nil } + + // 튜플의 경우 + if Mirror(reflecting: child.value).displayStyle == .tuple { + + var dict = [String: FirebaseLoggable]() + let tupleMirror = Mirror(reflecting: child.value) + + // 튜플 안의 각 파라미터를 순회 + for tupleChild in tupleMirror.children { + guard let paramLabel = tupleChild.label else { continue } + // FirebaseLoggable 타입 검사 + if let loggableValue = tupleChild.value as? any FirebaseLoggable { + dict[paramLabel] = loggableValue + } + } + return dict.isEmpty ? nil : dict + // 단일 파라미터인 경우 + } else { + + guard let label = child.label else { return nil } + // FirebaseLoggable 타입 검사 + if let loggableValue = child.value as? any FirebaseLoggable { + return [label: loggableValue] + } + } + + return nil + } +} diff --git a/SOOUM/SOOUM/Data/Managers/GAManager/FirebaseLoggable.swift b/SOOUM/SOOUM/Utilities/GAHelper/FirebaseLoggable.swift similarity index 79% rename from SOOUM/SOOUM/Data/Managers/GAManager/FirebaseLoggable.swift rename to SOOUM/SOOUM/Utilities/GAHelper/FirebaseLoggable.swift index 2c106c1f..d97174e5 100644 --- a/SOOUM/SOOUM/Data/Managers/GAManager/FirebaseLoggable.swift +++ b/SOOUM/SOOUM/Utilities/GAHelper/FirebaseLoggable.swift @@ -12,4 +12,5 @@ extension String: FirebaseLoggable {} extension Int: FirebaseLoggable {} extension Double: FirebaseLoggable {} extension Bool: FirebaseLoggable {} -extension Array: FirebaseLoggable where Element == String {} // [String]만 허용 +/// [String]만 허용 +extension Array: FirebaseLoggable where Element == String {} diff --git a/SOOUM/SOOUM/Utilities/GAHelper/GAHelper.swift b/SOOUM/SOOUM/Utilities/GAHelper/GAHelper.swift new file mode 100644 index 00000000..a9e5dfdd --- /dev/null +++ b/SOOUM/SOOUM/Utilities/GAHelper/GAHelper.swift @@ -0,0 +1,18 @@ +// +// GAHelper.swift +// SOOUM +// +// Created by JDeoks on 3/11/25. +// + +import FirebaseAnalytics + +final class GAHelper { + + static let shared = GAHelper() + private init() { } + + func logEvent(event: AnalyticsEventProtocol) { + Analytics.logEvent(event.eventName, parameters: event.parameters) + } +}