diff --git a/SOOUM/SOOUM.xcodeproj/project.pbxproj b/SOOUM/SOOUM.xcodeproj/project.pbxproj index 1cbb1dd3..75b2a33b 100644 --- a/SOOUM/SOOUM.xcodeproj/project.pbxproj +++ b/SOOUM/SOOUM.xcodeproj/project.pbxproj @@ -34,8 +34,6 @@ 2A5BB7D22CDC7ADC00E1C799 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7D02CDC7ADC00E1C799 /* OnboardingViewController.swift */; }; 2A5BB7D52CDCA5C900E1C799 /* TermsOfServiceAgreeButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7D42CDCA5C900E1C799 /* TermsOfServiceAgreeButtonView.swift */; }; 2A5BB7D62CDCA5C900E1C799 /* TermsOfServiceAgreeButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7D42CDCA5C900E1C799 /* TermsOfServiceAgreeButtonView.swift */; }; - 2A5BB7D92CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7D82CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift */; }; - 2A5BB7DA2CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7D82CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift */; }; 2A5BB7E02CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7DF2CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift */; }; 2A5BB7E12CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7DF2CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift */; }; 2A5BB7E32CDCD97300E1C799 /* JoinRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A5BB7E22CDCD97300E1C799 /* JoinRequest.swift */; }; @@ -118,14 +116,8 @@ 3802BDB92D0AF2F7001256EA /* PushManager+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3802BDB72D0AF2F7001256EA /* PushManager+Rx.swift */; }; 3803CF692D0156BA00FD90DB /* SettingsViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF682D0156BA00FD90DB /* SettingsViewReactor.swift */; }; 3803CF6A2D0156BA00FD90DB /* SettingsViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF682D0156BA00FD90DB /* SettingsViewReactor.swift */; }; - 3803CF6C2D0156FC00FD90DB /* SettingsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF6B2D0156FC00FD90DB /* SettingsRequest.swift */; }; - 3803CF6D2D0156FC00FD90DB /* SettingsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF6B2D0156FC00FD90DB /* SettingsRequest.swift */; }; 3803CF702D0159A500FD90DB /* SettingsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF6F2D0159A500FD90DB /* SettingsResponse.swift */; }; 3803CF712D0159A500FD90DB /* SettingsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF6F2D0159A500FD90DB /* SettingsResponse.swift */; }; - 3803CF742D0166D700FD90DB /* CommentHistoryViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF732D0166D700FD90DB /* CommentHistoryViewCell.swift */; }; - 3803CF752D0166D700FD90DB /* CommentHistoryViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF732D0166D700FD90DB /* CommentHistoryViewCell.swift */; }; - 3803CF772D01685000FD90DB /* CommentHistroyViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF762D01685000FD90DB /* CommentHistroyViewReactor.swift */; }; - 3803CF782D01685000FD90DB /* CommentHistroyViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF762D01685000FD90DB /* CommentHistroyViewReactor.swift */; }; 3803CF7A2D016BDB00FD90DB /* IssueMemberTransferViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF792D016BDB00FD90DB /* IssueMemberTransferViewReactor.swift */; }; 3803CF7B2D016BDB00FD90DB /* IssueMemberTransferViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF792D016BDB00FD90DB /* IssueMemberTransferViewReactor.swift */; }; 3803CF7D2D016DA200FD90DB /* TransferCodeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3803CF7C2D016DA200FD90DB /* TransferCodeResponse.swift */; }; @@ -184,6 +176,20 @@ 381A1D752CC3D799005FDB8E /* SOMTagsDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381A1D732CC3D799005FDB8E /* SOMTagsDelegate.swift */; }; 381A1D772CC3DA99005FDB8E /* SOMTagsLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381A1D762CC3DA99005FDB8E /* SOMTagsLayout.swift */; }; 381A1D782CC3DA99005FDB8E /* SOMTagsLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381A1D762CC3DA99005FDB8E /* SOMTagsLayout.swift */; }; + 381B83DC2EBC707A00C84015 /* ProfileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83DB2EBC707400C84015 /* ProfileInfo.swift */; }; + 381B83DD2EBC707A00C84015 /* ProfileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83DB2EBC707400C84015 /* ProfileInfo.swift */; }; + 381B83DF2EBC72B400C84015 /* FollowInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83DE2EBC72AF00C84015 /* FollowInfo.swift */; }; + 381B83E02EBC72B400C84015 /* FollowInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83DE2EBC72AF00C84015 /* FollowInfo.swift */; }; + 381B83E22EBC736800C84015 /* ProfileInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83E12EBC735F00C84015 /* ProfileInfoResponse.swift */; }; + 381B83E32EBC736800C84015 /* ProfileInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83E12EBC735F00C84015 /* ProfileInfoResponse.swift */; }; + 381B83E52EBC73FC00C84015 /* FollowInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83E42EBC73F800C84015 /* FollowInfoResponse.swift */; }; + 381B83E62EBC73FC00C84015 /* FollowInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83E42EBC73F800C84015 /* FollowInfoResponse.swift */; }; + 381B83E82EBC75D700C84015 /* ProfileCardInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83E72EBC75BF00C84015 /* ProfileCardInfo.swift */; }; + 381B83E92EBC75D700C84015 /* ProfileCardInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83E72EBC75BF00C84015 /* ProfileCardInfo.swift */; }; + 381B83EB2EBC769900C84015 /* ProfileCardInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83EA2EBC769500C84015 /* ProfileCardInfoResponse.swift */; }; + 381B83EC2EBC769900C84015 /* ProfileCardInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83EA2EBC769500C84015 /* ProfileCardInfoResponse.swift */; }; + 381B83F22EBCEC2E00C84015 /* ProfileUserViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83F12EBCEC2900C84015 /* ProfileUserViewCell.swift */; }; + 381B83F32EBCEC2E00C84015 /* ProfileUserViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381B83F12EBCEC2900C84015 /* ProfileUserViewCell.swift */; }; 381DEA8B2CD4BBCB009F1FE9 /* WriteCardTextView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381DEA8A2CD4BBCB009F1FE9 /* WriteCardTextView+Rx.swift */; }; 381DEA8C2CD4BBCB009F1FE9 /* WriteCardTextView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 381DEA8A2CD4BBCB009F1FE9 /* WriteCardTextView+Rx.swift */; }; 381DEA8D2CD4BE4A009F1FE9 /* Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38608B2F2CB5195D0066BB40 /* Card.swift */; }; @@ -313,16 +319,14 @@ 3878D0602CFFD45100F9522F /* FollowerResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D05E2CFFD45100F9522F /* FollowerResponse.swift */; }; 3878D0632CFFD66700F9522F /* FollowViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0622CFFD66700F9522F /* FollowViewController.swift */; }; 3878D0642CFFD66700F9522F /* FollowViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0622CFFD66700F9522F /* FollowViewController.swift */; }; - 3878D0672CFFDAF100F9522F /* OtherFollowViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0662CFFDAF100F9522F /* OtherFollowViewCell.swift */; }; - 3878D0682CFFDAF100F9522F /* OtherFollowViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0662CFFDAF100F9522F /* OtherFollowViewCell.swift */; }; 3878D06B2CFFDF1F00F9522F /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D06A2CFFDF1F00F9522F /* SettingsViewController.swift */; }; 3878D06C2CFFDF1F00F9522F /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D06A2CFFDF1F00F9522F /* SettingsViewController.swift */; }; 3878D06F2CFFDF9600F9522F /* SettingTextCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D06E2CFFDF9600F9522F /* SettingTextCellView.swift */; }; 3878D0702CFFDF9600F9522F /* SettingTextCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D06E2CFFDF9600F9522F /* SettingTextCellView.swift */; }; 3878D0722CFFDFEF00F9522F /* SettingTextCellView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0712CFFDFEF00F9522F /* SettingTextCellView+Rx.swift */; }; 3878D0732CFFDFEF00F9522F /* SettingTextCellView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0712CFFDFEF00F9522F /* SettingTextCellView+Rx.swift */; }; - 3878D0752CFFE01500F9522F /* SettingScrollViewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0742CFFE01500F9522F /* SettingScrollViewHeader.swift */; }; - 3878D0762CFFE01500F9522F /* SettingScrollViewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0742CFFE01500F9522F /* SettingScrollViewHeader.swift */; }; + 3878D0752CFFE01500F9522F /* SettingVersionCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0742CFFE01500F9522F /* SettingVersionCellView.swift */; }; + 3878D0762CFFE01500F9522F /* SettingVersionCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0742CFFE01500F9522F /* SettingVersionCellView.swift */; }; 3878D0792CFFE1E800F9522F /* ResignViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0782CFFE1E800F9522F /* ResignViewController.swift */; }; 3878D07A2CFFE1E800F9522F /* ResignViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0782CFFE1E800F9522F /* ResignViewController.swift */; }; 3878D07D2CFFE6E500F9522F /* IssueMemberTransferViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D07C2CFFE6E500F9522F /* IssueMemberTransferViewController.swift */; }; @@ -337,8 +341,6 @@ 3878D08D2CFFF0BF00F9522F /* AnnouncementViewControler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D08B2CFFF0BF00F9522F /* AnnouncementViewControler.swift */; }; 3878D0902CFFF0E300F9522F /* AnnouncementViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D08F2CFFF0E300F9522F /* AnnouncementViewCell.swift */; }; 3878D0912CFFF0E300F9522F /* AnnouncementViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D08F2CFFF0E300F9522F /* AnnouncementViewCell.swift */; }; - 3878D0972CFFF2B800F9522F /* CommentHistroyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0962CFFF2B800F9522F /* CommentHistroyViewController.swift */; }; - 3878D0982CFFF2B800F9522F /* CommentHistroyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878D0962CFFF2B800F9522F /* CommentHistroyViewController.swift */; }; 3878F4712CA3F03400AA46A2 /* SOMCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878F4702CA3F03400AA46A2 /* SOMCard.swift */; }; 3878F4722CA3F03400AA46A2 /* SOMCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878F4702CA3F03400AA46A2 /* SOMCard.swift */; }; 3878F4742CA3F06C00AA46A2 /* UIStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878F4732CA3F06C00AA46A2 /* UIStackView.swift */; }; @@ -346,6 +348,10 @@ 3878F4772CA3F08300AA46A2 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878F4762CA3F08300AA46A2 /* UIView.swift */; }; 3878F4782CA3F08300AA46A2 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878F4762CA3F08300AA46A2 /* UIView.swift */; }; 3878FE0D2D0365C800D8955C /* SOMNavigationBar+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3878FE0C2D0365C800D8955C /* SOMNavigationBar+Rx.swift */; }; + 3879B4B52EC5AD5E0070846B /* RejoinableDateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B4B42EC5AD580070846B /* RejoinableDateInfo.swift */; }; + 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 */; }; 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 */; }; @@ -378,8 +384,6 @@ 3880EF812EA0DB0900D88608 /* WriteCardTagsDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3880EF7F2EA0DB0300D88608 /* WriteCardTagsDelegate.swift */; }; 38816D9E2D004A5E00EB87D6 /* UpdateProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38816D9D2D004A5E00EB87D6 /* UpdateProfileViewController.swift */; }; 38816D9F2D004A5E00EB87D6 /* UpdateProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38816D9D2D004A5E00EB87D6 /* UpdateProfileViewController.swift */; }; - 38816DA22D004DED00EB87D6 /* UpdateProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38816DA12D004DED00EB87D6 /* UpdateProfileView.swift */; }; - 38816DA32D004DED00EB87D6 /* UpdateProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38816DA12D004DED00EB87D6 /* UpdateProfileView.swift */; }; 388371F92C8C8EB1004212EB /* SooumStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388371F82C8C8EB1004212EB /* SooumStyle.swift */; }; 388371FA2C8C8EB1004212EB /* SooumStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388371F82C8C8EB1004212EB /* SooumStyle.swift */; }; 388371FC2C8C8F11004212EB /* UIColor+SOOUM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388371FB2C8C8F11004212EB /* UIColor+SOOUM.swift */; }; @@ -544,6 +548,8 @@ 38AE77DC2E745FFF00B6FD13 /* EnterMemberTransferTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE77DA2E745FF700B6FD13 /* EnterMemberTransferTextFieldView.swift */; }; 38AE77DE2E7465F500B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE77DD2E7465E600B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift */; }; 38AE77DF2E7465F500B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AE77DD2E7465E600B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift */; }; + 38B35D082EBF7B7300709E53 /* FollowPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B35D072EBF7B6E00709E53 /* FollowPlaceholderViewCell.swift */; }; + 38B35D092EBF7B7300709E53 /* FollowPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B35D072EBF7B6E00709E53 /* FollowPlaceholderViewCell.swift */; }; 38B543DF2D46171300DDF2C5 /* ManagerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B543DE2D46171300DDF2C5 /* ManagerConfiguration.swift */; }; 38B543E02D46171300DDF2C5 /* ManagerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B543DE2D46171300DDF2C5 /* ManagerConfiguration.swift */; }; 38B543E22D46179500DDF2C5 /* AuthManagerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B543E12D46179500DDF2C5 /* AuthManagerConfiguration.swift */; }; @@ -578,14 +584,46 @@ 38B8A58F2CAEB61A000AFE83 /* DetailViewFooterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8A58D2CAEB61A000AFE83 /* DetailViewFooterCell.swift */; }; 38B8BE472D1ECBDA0084569C /* NotificationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8BE462D1ECBDA0084569C /* NotificationInfo.swift */; }; 38B8BE482D1ECBDA0084569C /* NotificationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38B8BE462D1ECBDA0084569C /* NotificationInfo.swift */; }; - 38C2D4112CFE9EF300CEA092 /* OtherProfileViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4102CFE9EF300CEA092 /* OtherProfileViewCell.swift */; }; - 38C2D4122CFE9EF300CEA092 /* OtherProfileViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4102CFE9EF300CEA092 /* OtherProfileViewCell.swift */; }; - 38C2D4142CFEA9CC00CEA092 /* MyProfileViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4132CFEA9CC00CEA092 /* MyProfileViewCell.swift */; }; - 38C2D4152CFEA9CC00CEA092 /* MyProfileViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4132CFEA9CC00CEA092 /* MyProfileViewCell.swift */; }; - 38C2D4172CFEAACA00CEA092 /* ProfileViewFooterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4162CFEAACA00CEA092 /* ProfileViewFooterCell.swift */; }; - 38C2D4182CFEAACA00CEA092 /* ProfileViewFooterCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4162CFEAACA00CEA092 /* ProfileViewFooterCell.swift */; }; - 38C2D41A2CFEAAED00CEA092 /* ProfileViewFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4192CFEAAED00CEA092 /* ProfileViewFooter.swift */; }; - 38C2D41B2CFEAAED00CEA092 /* ProfileViewFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4192CFEAAED00CEA092 /* ProfileViewFooter.swift */; }; + 38C2A7D82EC054C500B941A2 /* SettingVersionCellView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7D72EC054BE00B941A2 /* SettingVersionCellView+Rx.swift */; }; + 38C2A7D92EC054C500B941A2 /* SettingVersionCellView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7D72EC054BE00B941A2 /* SettingVersionCellView+Rx.swift */; }; + 38C2A7DB2EC06ECE00B941A2 /* SettingsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7DA2EC06EC800B941A2 /* SettingsRequest.swift */; }; + 38C2A7DC2EC06ECE00B941A2 /* SettingsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7DA2EC06EC800B941A2 /* SettingsRequest.swift */; }; + 38C2A7DE2EC0704700B941A2 /* SettingsRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7DD2EC0703F00B941A2 /* SettingsRemoteDataSource.swift */; }; + 38C2A7DF2EC0704700B941A2 /* SettingsRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7DD2EC0703F00B941A2 /* SettingsRemoteDataSource.swift */; }; + 38C2A7E12EC0707D00B941A2 /* TransferCodeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E02EC0707700B941A2 /* TransferCodeInfo.swift */; }; + 38C2A7E22EC0707D00B941A2 /* TransferCodeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E02EC0707700B941A2 /* TransferCodeInfo.swift */; }; + 38C2A7E42EC070EE00B941A2 /* TransferCodeInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E32EC070E700B941A2 /* TransferCodeInfoResponse.swift */; }; + 38C2A7E52EC070EE00B941A2 /* TransferCodeInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E32EC070E700B941A2 /* TransferCodeInfoResponse.swift */; }; + 38C2A7E72EC0719200B941A2 /* SettingsRemoteDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E62EC0718900B941A2 /* SettingsRemoteDataSourceImpl.swift */; }; + 38C2A7E82EC0719200B941A2 /* SettingsRemoteDataSourceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E62EC0718900B941A2 /* SettingsRemoteDataSourceImpl.swift */; }; + 38C2A7EA2EC074A200B941A2 /* SettingsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E92EC0749A00B941A2 /* SettingsRepository.swift */; }; + 38C2A7EB2EC074A200B941A2 /* SettingsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7E92EC0749A00B941A2 /* SettingsRepository.swift */; }; + 38C2A7ED2EC074B200B941A2 /* SettingsRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7EC2EC074AE00B941A2 /* SettingsRepositoryImpl.swift */; }; + 38C2A7EE2EC074B200B941A2 /* SettingsRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7EC2EC074AE00B941A2 /* SettingsRepositoryImpl.swift */; }; + 38C2A7F02EC0796700B941A2 /* SettingsUserCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7EF2EC0795F00B941A2 /* SettingsUserCase.swift */; }; + 38C2A7F12EC0796700B941A2 /* SettingsUserCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7EF2EC0795F00B941A2 /* SettingsUserCase.swift */; }; + 38C2A7F32EC0798B00B941A2 /* SettingsUserCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7F22EC0798600B941A2 /* SettingsUserCaseImpl.swift */; }; + 38C2A7F42EC0798B00B941A2 /* SettingsUserCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7F22EC0798600B941A2 /* SettingsUserCaseImpl.swift */; }; + 38C2A7F62EC08FF600B941A2 /* BlockUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7F52EC08FEF00B941A2 /* BlockUserInfo.swift */; }; + 38C2A7F72EC08FF600B941A2 /* BlockUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7F52EC08FEF00B941A2 /* BlockUserInfo.swift */; }; + 38C2A7F92EC090B100B941A2 /* BlockUsersInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7F82EC090AC00B941A2 /* BlockUsersInfoResponse.swift */; }; + 38C2A7FA2EC090B100B941A2 /* BlockUsersInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7F82EC090AC00B941A2 /* BlockUsersInfoResponse.swift */; }; + 38C2A7FC2EC0925C00B941A2 /* WithdrawType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7FB2EC0925700B941A2 /* WithdrawType.swift */; }; + 38C2A7FD2EC0925C00B941A2 /* WithdrawType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A7FB2EC0925700B941A2 /* WithdrawType.swift */; }; + 38C2A8012EC09A5A00B941A2 /* ResignTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8002EC09A5500B941A2 /* ResignTextFieldView.swift */; }; + 38C2A8022EC09A5A00B941A2 /* ResignTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8002EC09A5500B941A2 /* ResignTextFieldView.swift */; }; + 38C2A8042EC09BC400B941A2 /* ResignTextFieldView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8032EC09BBD00B941A2 /* ResignTextFieldView+Rx.swift */; }; + 38C2A8052EC09BC400B941A2 /* ResignTextFieldView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8032EC09BBD00B941A2 /* ResignTextFieldView+Rx.swift */; }; + 38C2A8082EC0BB9800B941A2 /* BlockUserViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8072EC0BB8F00B941A2 /* BlockUserViewCell.swift */; }; + 38C2A8092EC0BB9800B941A2 /* BlockUserViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8072EC0BB8F00B941A2 /* BlockUserViewCell.swift */; }; + 38C2A80B2EC0BC4500B941A2 /* BlockUsersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A80A2EC0BC3F00B941A2 /* BlockUsersViewController.swift */; }; + 38C2A80C2EC0BC4500B941A2 /* BlockUsersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A80A2EC0BC3F00B941A2 /* BlockUsersViewController.swift */; }; + 38C2A80E2EC0BC8900B941A2 /* BlockUserPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A80D2EC0BC8300B941A2 /* BlockUserPlaceholderViewCell.swift */; }; + 38C2A80F2EC0BC8900B941A2 /* BlockUserPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A80D2EC0BC8300B941A2 /* BlockUserPlaceholderViewCell.swift */; }; + 38C2A8112EC0BE0B00B941A2 /* BlockUsersViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8102EC0BE0600B941A2 /* BlockUsersViewReactor.swift */; }; + 38C2A8122EC0BE0B00B941A2 /* BlockUsersViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2A8102EC0BE0600B941A2 /* BlockUsersViewReactor.swift */; }; + 38C2D4172CFEAACA00CEA092 /* ProfileCardViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4162CFEAACA00CEA092 /* ProfileCardViewCell.swift */; }; + 38C2D4182CFEAACA00CEA092 /* ProfileCardViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D4162CFEAACA00CEA092 /* ProfileCardViewCell.swift */; }; 38C2D4202CFEB82400CEA092 /* ProfileViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D41F2CFEB82400CEA092 /* ProfileViewReactor.swift */; }; 38C2D4212CFEB82400CEA092 /* ProfileViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2D41F2CFEB82400CEA092 /* ProfileViewReactor.swift */; }; 38C9AF0B2E965EFB00B401C0 /* DefaultImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF0A2E965EEE00B401C0 /* DefaultImages.swift */; }; @@ -618,6 +656,10 @@ 38C9AF3A2E96AB9100B401C0 /* WriteCardTagFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF382E96AB8800B401C0 /* WriteCardTagFooter.swift */; }; 38C9AF3C2E96ACEB00B401C0 /* WriteCardTagFooterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF3B2E96ACE300B401C0 /* WriteCardTagFooterDelegate.swift */; }; 38C9AF3D2E96ACEB00B401C0 /* WriteCardTagFooterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C9AF3B2E96ACE300B401C0 /* WriteCardTagFooterDelegate.swift */; }; + 38CA91F32EBDCFF2002C261A /* ProfileCardsPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CA91F22EBDCFE6002C261A /* ProfileCardsPlaceholderViewCell.swift */; }; + 38CA91F42EBDCFF2002C261A /* ProfileCardsPlaceholderViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CA91F22EBDCFE6002C261A /* ProfileCardsPlaceholderViewCell.swift */; }; + 38CA91F62EBDD342002C261A /* ProfileViewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CA91F52EBDD336002C261A /* ProfileViewHeader.swift */; }; + 38CA91F72EBDD342002C261A /* ProfileViewHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CA91F52EBDD336002C261A /* ProfileViewHeader.swift */; }; 38CC49822CDE3854007A0145 /* SOMPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CC49812CDE3854007A0145 /* SOMPresentationController.swift */; }; 38CC49832CDE3854007A0145 /* SOMPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CC49812CDE3854007A0145 /* SOMPresentationController.swift */; }; 38CC49852CDE3885007A0145 /* SOMTransitioningDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38CC49842CDE3885007A0145 /* SOMTransitioningDelegate.swift */; }; @@ -664,6 +706,14 @@ 38D869642CF821F900BF87DA /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D869622CF821F900BF87DA /* UserDefaults.swift */; }; 38D8E2912CCD232B00CE2E0A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8E2902CCD232B00CE2E0A /* AuthManager.swift */; }; 38D8E2922CCD232B00CE2E0A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8E2902CCD232B00CE2E0A /* AuthManager.swift */; }; + 38D8F5582EC4D89D00DED428 /* TagNofificationInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8F5572EC4D89400DED428 /* TagNofificationInfoResponse.swift */; }; + 38D8F5592EC4D89D00DED428 /* TagNofificationInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8F5572EC4D89400DED428 /* TagNofificationInfoResponse.swift */; }; + 38D8F55E2EC4F38700DED428 /* SimpleReachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8F55D2EC4F37D00DED428 /* SimpleReachability.swift */; }; + 38D8F55F2EC4F38700DED428 /* SimpleReachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8F55D2EC4F37D00DED428 /* SimpleReachability.swift */; }; + 38D8FE8D2EBE36F800F32D02 /* ProfileCardsViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8FE8C2EBE36F200F32D02 /* ProfileCardsViewCell.swift */; }; + 38D8FE8E2EBE36F800F32D02 /* ProfileCardsViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8FE8C2EBE36F200F32D02 /* ProfileCardsViewCell.swift */; }; + 38D8FE902EBE664C00F32D02 /* SOMNicknameTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8FE8F2EBE663E00F32D02 /* SOMNicknameTextField.swift */; }; + 38D8FE912EBE664D00F32D02 /* SOMNicknameTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D8FE8F2EBE663E00F32D02 /* SOMNicknameTextField.swift */; }; 38DA12B42D4E54EB00AB9468 /* MockAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DA12B32D4E54EB00AB9468 /* MockAuthManager.swift */; }; 38DA12B62D4E642C00AB9468 /* AuthManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DA12B52D4E642C00AB9468 /* AuthManagerTests.swift */; }; 38DA12B92D4E847100AB9468 /* MockPushManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DA12B82D4E847100AB9468 /* MockPushManager.swift */; }; @@ -736,8 +786,10 @@ 38FD4DAF2D032FCE00BF5FF1 /* AnnouncementViewReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DAD2D032FCE00BF5FF1 /* AnnouncementViewReactor.swift */; }; 38FD4DB12D034C1700BF5FF1 /* MyFollowingViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DB02D034C1700BF5FF1 /* MyFollowingViewCell.swift */; }; 38FD4DB22D034C1700BF5FF1 /* MyFollowingViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DB02D034C1700BF5FF1 /* MyFollowingViewCell.swift */; }; - 38FD4DB42D034F6600BF5FF1 /* MyFollowerViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DB32D034F6600BF5FF1 /* MyFollowerViewCell.swift */; }; - 38FD4DB52D034F6600BF5FF1 /* MyFollowerViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DB32D034F6600BF5FF1 /* MyFollowerViewCell.swift */; }; + 38FD4DB42D034F6600BF5FF1 /* FollowerViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DB32D034F6600BF5FF1 /* FollowerViewCell.swift */; }; + 38FD4DB52D034F6600BF5FF1 /* FollowerViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD4DB32D034F6600BF5FF1 /* FollowerViewCell.swift */; }; + 38FD56242EC9FAA400EC6106 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD56232EC9FAA000EC6106 /* String.swift */; }; + 38FD56252EC9FAA400EC6106 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FD56232EC9FAA000EC6106 /* String.swift */; }; 38FDC2B62C9E746B00C094C2 /* BaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FDC2B52C9E746B00C094C2 /* BaseViewController.swift */; }; 38FDC2B72C9E746B00C094C2 /* BaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FDC2B52C9E746B00C094C2 /* BaseViewController.swift */; }; 38FDC2C72C9E764300C094C2 /* BaseNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FDC2C62C9E764300C094C2 /* BaseNavigationViewController.swift */; }; @@ -781,7 +833,6 @@ 2A5BB7CC2CDBB7D100E1C799 /* OnboardingProfileImageSettingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingProfileImageSettingViewController.swift; sourceTree = ""; }; 2A5BB7D02CDC7ADC00E1C799 /* OnboardingViewController.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; tabWidth = 4; }; 2A5BB7D42CDCA5C900E1C799 /* TermsOfServiceAgreeButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsOfServiceAgreeButtonView.swift; sourceTree = ""; }; - 2A5BB7D82CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingNicknameTextFieldView.swift; sourceTree = ""; }; 2A5BB7DF2CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingNicknameSettingViewReactor.swift; sourceTree = ""; }; 2A5BB7E22CDCD97300E1C799 /* JoinRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinRequest.swift; sourceTree = ""; }; 2A5BB7E62CDCDC3600E1C799 /* NicknameValidationResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameValidationResponse.swift; sourceTree = ""; }; @@ -828,10 +879,7 @@ 3802BDB02D0AE900001256EA /* PushManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushManager.swift; sourceTree = ""; }; 3802BDB72D0AF2F7001256EA /* PushManager+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PushManager+Rx.swift"; sourceTree = ""; }; 3803CF682D0156BA00FD90DB /* SettingsViewReactor.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewReactor.swift; sourceTree = ""; tabWidth = 4; }; - 3803CF6B2D0156FC00FD90DB /* SettingsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRequest.swift; sourceTree = ""; }; 3803CF6F2D0159A500FD90DB /* SettingsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsResponse.swift; sourceTree = ""; }; - 3803CF732D0166D700FD90DB /* CommentHistoryViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentHistoryViewCell.swift; sourceTree = ""; }; - 3803CF762D01685000FD90DB /* CommentHistroyViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentHistroyViewReactor.swift; sourceTree = ""; }; 3803CF792D016BDB00FD90DB /* IssueMemberTransferViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueMemberTransferViewReactor.swift; sourceTree = ""; }; 3803CF7C2D016DA200FD90DB /* TransferCodeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferCodeResponse.swift; sourceTree = ""; }; 3803CF812D017DB800FD90DB /* EnterMemberTransferViewController.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = EnterMemberTransferViewController.swift; sourceTree = ""; tabWidth = 4; }; @@ -861,6 +909,13 @@ 381854A82E99573700424D71 /* SelectTypographyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectTypographyView.swift; sourceTree = ""; }; 381A1D732CC3D799005FDB8E /* SOMTagsDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMTagsDelegate.swift; sourceTree = ""; }; 381A1D762CC3DA99005FDB8E /* SOMTagsLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMTagsLayout.swift; sourceTree = ""; }; + 381B83DB2EBC707400C84015 /* ProfileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileInfo.swift; sourceTree = ""; }; + 381B83DE2EBC72AF00C84015 /* FollowInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowInfo.swift; sourceTree = ""; }; + 381B83E12EBC735F00C84015 /* ProfileInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileInfoResponse.swift; sourceTree = ""; }; + 381B83E42EBC73F800C84015 /* FollowInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowInfoResponse.swift; sourceTree = ""; }; + 381B83E72EBC75BF00C84015 /* ProfileCardInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileCardInfo.swift; sourceTree = ""; }; + 381B83EA2EBC769500C84015 /* ProfileCardInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileCardInfoResponse.swift; sourceTree = ""; }; + 381B83F12EBCEC2900C84015 /* ProfileUserViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileUserViewCell.swift; sourceTree = ""; }; 381DEA8A2CD4BBCB009F1FE9 /* WriteCardTextView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WriteCardTextView+Rx.swift"; sourceTree = ""; }; 382D5CF52CFE9B8600BFA23E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; 382E15392D15A67A0097B09C /* NotificationViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewCell.swift; sourceTree = ""; }; @@ -928,11 +983,10 @@ 3878D05B2CFFD10D00F9522F /* FollowingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingResponse.swift; sourceTree = ""; }; 3878D05E2CFFD45100F9522F /* FollowerResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerResponse.swift; sourceTree = ""; }; 3878D0622CFFD66700F9522F /* FollowViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowViewController.swift; sourceTree = ""; }; - 3878D0662CFFDAF100F9522F /* OtherFollowViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherFollowViewCell.swift; sourceTree = ""; }; 3878D06A2CFFDF1F00F9522F /* SettingsViewController.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; tabWidth = 4; }; 3878D06E2CFFDF9600F9522F /* SettingTextCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingTextCellView.swift; sourceTree = ""; }; 3878D0712CFFDFEF00F9522F /* SettingTextCellView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingTextCellView+Rx.swift"; sourceTree = ""; }; - 3878D0742CFFE01500F9522F /* SettingScrollViewHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingScrollViewHeader.swift; sourceTree = ""; }; + 3878D0742CFFE01500F9522F /* SettingVersionCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingVersionCellView.swift; sourceTree = ""; }; 3878D0782CFFE1E800F9522F /* ResignViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResignViewController.swift; sourceTree = ""; }; 3878D07C2CFFE6E500F9522F /* IssueMemberTransferViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueMemberTransferViewController.swift; sourceTree = ""; }; 3878D0802CFFEC6900F9522F /* TermsOfServiceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsOfServiceViewController.swift; sourceTree = ""; }; @@ -940,11 +994,12 @@ 3878D0872CFFEF0F00F9522F /* TermsOfServiceTextCellView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TermsOfServiceTextCellView+Rx.swift"; sourceTree = ""; }; 3878D08B2CFFF0BF00F9522F /* AnnouncementViewControler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementViewControler.swift; sourceTree = ""; }; 3878D08F2CFFF0E300F9522F /* AnnouncementViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementViewCell.swift; sourceTree = ""; }; - 3878D0962CFFF2B800F9522F /* CommentHistroyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentHistroyViewController.swift; sourceTree = ""; }; 3878F4702CA3F03400AA46A2 /* SOMCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMCard.swift; sourceTree = ""; }; 3878F4732CA3F06C00AA46A2 /* UIStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIStackView.swift; sourceTree = ""; }; 3878F4762CA3F08300AA46A2 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -965,7 +1020,6 @@ 3880EF7C2EA0DA6F00D88608 /* WritrCardTextViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WritrCardTextViewDelegate.swift; sourceTree = ""; }; 3880EF7F2EA0DB0300D88608 /* WriteCardTagsDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardTagsDelegate.swift; sourceTree = ""; }; 38816D9D2D004A5E00EB87D6 /* UpdateProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProfileViewController.swift; sourceTree = ""; }; - 38816DA12D004DED00EB87D6 /* UpdateProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateProfileView.swift; sourceTree = ""; }; 388371F82C8C8EB1004212EB /* SooumStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SooumStyle.swift; sourceTree = ""; }; 388371FB2C8C8F11004212EB /* UIColor+SOOUM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+SOOUM.swift"; sourceTree = ""; }; 388372002C8C8FCF004212EB /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; @@ -1050,6 +1104,7 @@ 38AE77D62E7459EA00B6FD13 /* OnboardingCompletedViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCompletedViewReactor.swift; sourceTree = ""; }; 38AE77DA2E745FF700B6FD13 /* EnterMemberTransferTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterMemberTransferTextFieldView.swift; sourceTree = ""; }; 38AE77DD2E7465E600B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnterMemberTransferTextFieldView+Rx.swift"; sourceTree = ""; }; + 38B35D072EBF7B6E00709E53 /* FollowPlaceholderViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowPlaceholderViewCell.swift; sourceTree = ""; }; 38B543DE2D46171300DDF2C5 /* ManagerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagerConfiguration.swift; sourceTree = ""; }; 38B543E12D46179500DDF2C5 /* AuthManagerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManagerConfiguration.swift; sourceTree = ""; }; 38B543E42D4617CB00DDF2C5 /* PushManagerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushManagerConfiguration.swift; sourceTree = ""; }; @@ -1069,10 +1124,26 @@ 38B8BE462D1ECBDA0084569C /* NotificationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationInfo.swift; sourceTree = ""; }; 38B9E4692CE72CC1008A24C8 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; }; 38BCF2302D32BB22004F653A /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/LaunchScreen.strings; sourceTree = ""; }; - 38C2D4102CFE9EF300CEA092 /* OtherProfileViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherProfileViewCell.swift; sourceTree = ""; }; - 38C2D4132CFEA9CC00CEA092 /* MyProfileViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileViewCell.swift; sourceTree = ""; }; - 38C2D4162CFEAACA00CEA092 /* ProfileViewFooterCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewFooterCell.swift; sourceTree = ""; }; - 38C2D4192CFEAAED00CEA092 /* ProfileViewFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewFooter.swift; sourceTree = ""; }; + 38C2A7D72EC054BE00B941A2 /* SettingVersionCellView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingVersionCellView+Rx.swift"; sourceTree = ""; }; + 38C2A7DA2EC06EC800B941A2 /* SettingsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRequest.swift; sourceTree = ""; }; + 38C2A7DD2EC0703F00B941A2 /* SettingsRemoteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRemoteDataSource.swift; sourceTree = ""; }; + 38C2A7E02EC0707700B941A2 /* TransferCodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferCodeInfo.swift; sourceTree = ""; }; + 38C2A7E32EC070E700B941A2 /* TransferCodeInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferCodeInfoResponse.swift; sourceTree = ""; }; + 38C2A7E62EC0718900B941A2 /* SettingsRemoteDataSourceImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRemoteDataSourceImpl.swift; sourceTree = ""; }; + 38C2A7E92EC0749A00B941A2 /* SettingsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRepository.swift; sourceTree = ""; }; + 38C2A7EC2EC074AE00B941A2 /* SettingsRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRepositoryImpl.swift; sourceTree = ""; }; + 38C2A7EF2EC0795F00B941A2 /* SettingsUserCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsUserCase.swift; sourceTree = ""; }; + 38C2A7F22EC0798600B941A2 /* SettingsUserCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsUserCaseImpl.swift; sourceTree = ""; }; + 38C2A7F52EC08FEF00B941A2 /* BlockUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUserInfo.swift; sourceTree = ""; }; + 38C2A7F82EC090AC00B941A2 /* BlockUsersInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUsersInfoResponse.swift; sourceTree = ""; }; + 38C2A7FB2EC0925700B941A2 /* WithdrawType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithdrawType.swift; sourceTree = ""; }; + 38C2A8002EC09A5500B941A2 /* ResignTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResignTextFieldView.swift; sourceTree = ""; }; + 38C2A8032EC09BBD00B941A2 /* ResignTextFieldView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ResignTextFieldView+Rx.swift"; sourceTree = ""; }; + 38C2A8072EC0BB8F00B941A2 /* BlockUserViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUserViewCell.swift; sourceTree = ""; }; + 38C2A80A2EC0BC3F00B941A2 /* BlockUsersViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUsersViewController.swift; sourceTree = ""; }; + 38C2A80D2EC0BC8300B941A2 /* BlockUserPlaceholderViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUserPlaceholderViewCell.swift; sourceTree = ""; }; + 38C2A8102EC0BE0600B941A2 /* BlockUsersViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUsersViewReactor.swift; sourceTree = ""; }; + 38C2D4162CFEAACA00CEA092 /* ProfileCardViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileCardViewCell.swift; sourceTree = ""; }; 38C2D41F2CFEB82400CEA092 /* ProfileViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewReactor.swift; sourceTree = ""; }; 38C9AF0A2E965EEE00B401C0 /* DefaultImages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultImages.swift; sourceTree = ""; }; 38C9AF0D2E96601E00B401C0 /* DefaultImagesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultImagesResponse.swift; sourceTree = ""; }; @@ -1089,6 +1160,8 @@ 38C9AF322E96A82600B401C0 /* WriteCardTags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardTags.swift; sourceTree = ""; }; 38C9AF382E96AB8800B401C0 /* WriteCardTagFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardTagFooter.swift; sourceTree = ""; }; 38C9AF3B2E96ACE300B401C0 /* WriteCardTagFooterDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCardTagFooterDelegate.swift; sourceTree = ""; }; + 38CA91F22EBDCFE6002C261A /* ProfileCardsPlaceholderViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileCardsPlaceholderViewCell.swift; sourceTree = ""; }; + 38CA91F52EBDD336002C261A /* ProfileViewHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewHeader.swift; sourceTree = ""; }; 38CC49812CDE3854007A0145 /* SOMPresentationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMPresentationController.swift; sourceTree = ""; }; 38CC49842CDE3885007A0145 /* SOMTransitioningDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMTransitioningDelegate.swift; sourceTree = ""; }; 38CC49872CDE3972007A0145 /* SOMPresentationController+Show.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SOMPresentationController+Show.swift"; sourceTree = ""; }; @@ -1112,6 +1185,10 @@ 38D6F1822CC243DB00E11530 /* UITextView+Typography.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextView+Typography.swift"; sourceTree = ""; }; 38D869622CF821F900BF87DA /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; 38D8E2902CCD232B00CE2E0A /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; + 38D8F5572EC4D89400DED428 /* TagNofificationInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagNofificationInfoResponse.swift; sourceTree = ""; }; + 38D8F55D2EC4F37D00DED428 /* SimpleReachability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleReachability.swift; sourceTree = ""; }; + 38D8FE8C2EBE36F200F32D02 /* ProfileCardsViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileCardsViewCell.swift; sourceTree = ""; }; + 38D8FE8F2EBE663E00F32D02 /* SOMNicknameTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOMNicknameTextField.swift; sourceTree = ""; }; 38DA12B32D4E54EB00AB9468 /* MockAuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAuthManager.swift; sourceTree = ""; }; 38DA12B52D4E642C00AB9468 /* AuthManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManagerTests.swift; sourceTree = ""; }; 38DA12B82D4E847100AB9468 /* MockPushManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPushManager.swift; sourceTree = ""; }; @@ -1149,7 +1226,8 @@ 38FD4DAA2D032CF000BF5FF1 /* AnnouncementResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementResponse.swift; sourceTree = ""; }; 38FD4DAD2D032FCE00BF5FF1 /* AnnouncementViewReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementViewReactor.swift; sourceTree = ""; }; 38FD4DB02D034C1700BF5FF1 /* MyFollowingViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyFollowingViewCell.swift; sourceTree = ""; }; - 38FD4DB32D034F6600BF5FF1 /* MyFollowerViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyFollowerViewCell.swift; sourceTree = ""; }; + 38FD4DB32D034F6600BF5FF1 /* FollowerViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowerViewCell.swift; sourceTree = ""; }; + 38FD56232EC9FAA000EC6106 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 38FDC2B52C9E746B00C094C2 /* BaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseViewController.swift; sourceTree = ""; }; 38FDC2C62C9E764300C094C2 /* BaseNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseNavigationViewController.swift; sourceTree = ""; }; 38FEBE532E865119002916A8 /* FollowNotificationInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowNotificationInfoResponse.swift; sourceTree = ""; }; @@ -1239,7 +1317,6 @@ children = ( 2A5BB7C82CDBA53E00E1C799 /* OnboardingNicknameSettingViewController.swift */, 2A5BB7DF2CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift */, - 2A5BB7D72CDCBA7C00E1C799 /* Views */, ); path = NicknameSetting; sourceTree = ""; @@ -1273,14 +1350,6 @@ path = Views; sourceTree = ""; }; - 2A5BB7D72CDCBA7C00E1C799 /* Views */ = { - isa = PBXGroup; - children = ( - 2A5BB7D82CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift */, - ); - path = Views; - sourceTree = ""; - }; 2A5BB7E52CDCDC2B00E1C799 /* Join */ = { isa = PBXGroup; children = ( @@ -1482,14 +1551,6 @@ path = Settings; sourceTree = ""; }; - 3803CF722D0166C900FD90DB /* Cells */ = { - isa = PBXGroup; - children = ( - 3803CF732D0166D700FD90DB /* CommentHistoryViewCell.swift */, - ); - path = Cells; - sourceTree = ""; - }; 3803CF7F2D017D7000FD90DB /* Issue */ = { isa = PBXGroup; children = ( @@ -1516,6 +1577,7 @@ 386867A32E9E378000171A5E /* Array.swift */, 38121E302CA6C77500602499 /* Double.swift */, 38121E332CA6DA4000602499 /* Date.swift */, + 38FD56232EC9FAA000EC6106 /* String.swift */, 38D869622CF821F900BF87DA /* UserDefaults.swift */, ); path = Foundation; @@ -1618,6 +1680,7 @@ 38D5CE0A2CBCE8CA0054AB9A /* SimpleDefaults.swift */, 38389B9E2CCCFB7D006728AF /* AuthKeyChain.swift */, 3834FADC2D11C5AC00C9108D /* SimpleCache.swift */, + 38D8F55D2EC4F37D00DED428 /* SimpleReachability.swift */, 38F88EB92D2C1CB8002AD7A8 /* Info.swift */, 389EF8162D2F450000E053AE /* Log.swift */, ); @@ -1828,9 +1891,9 @@ 3878D0652CFFDAE500F9522F /* Cells */ = { isa = PBXGroup; children = ( + 38FD4DB32D034F6600BF5FF1 /* FollowerViewCell.swift */, 38FD4DB02D034C1700BF5FF1 /* MyFollowingViewCell.swift */, - 38FD4DB32D034F6600BF5FF1 /* MyFollowerViewCell.swift */, - 3878D0662CFFDAF100F9522F /* OtherFollowViewCell.swift */, + 38B35D072EBF7B6E00709E53 /* FollowPlaceholderViewCell.swift */, ); path = Cells; sourceTree = ""; @@ -1841,7 +1904,7 @@ 3878D06A2CFFDF1F00F9522F /* SettingsViewController.swift */, 3803CF682D0156BA00FD90DB /* SettingsViewReactor.swift */, 3878D06D2CFFDF8200F9522F /* Views */, - 3878D0952CFFF25000F9522F /* CommentHistory */, + 38C2A7FE2EC098FB00B941A2 /* BlockUsers */, 3878D07B2CFFE6C500F9522F /* MemberTransfer */, 3878D07F2CFFEC4300F9522F /* termsOfService */, 3878D0772CFFE19600F9522F /* Resign */, @@ -1855,7 +1918,8 @@ children = ( 3878D06E2CFFDF9600F9522F /* SettingTextCellView.swift */, 3878D0712CFFDFEF00F9522F /* SettingTextCellView+Rx.swift */, - 3878D0742CFFE01500F9522F /* SettingScrollViewHeader.swift */, + 3878D0742CFFE01500F9522F /* SettingVersionCellView.swift */, + 38C2A7D72EC054BE00B941A2 /* SettingVersionCellView+Rx.swift */, ); path = Views; sourceTree = ""; @@ -1865,6 +1929,7 @@ children = ( 3878D0782CFFE1E800F9522F /* ResignViewController.swift */, 3803CF872D01914200FD90DB /* ResignViewReactor.swift */, + 38C2A7FF2EC09A4D00B941A2 /* Views */, ); path = Resign; sourceTree = ""; @@ -1914,16 +1979,6 @@ path = Cells; sourceTree = ""; }; - 3878D0952CFFF25000F9522F /* CommentHistory */ = { - isa = PBXGroup; - children = ( - 3878D0962CFFF2B800F9522F /* CommentHistroyViewController.swift */, - 3803CF762D01685000FD90DB /* CommentHistroyViewReactor.swift */, - 3803CF722D0166C900FD90DB /* Cells */, - ); - path = CommentHistory; - sourceTree = ""; - }; 3878FE0B2D0365B000D8955C /* SOMNavigationBar */ = { isa = PBXGroup; children = ( @@ -2058,19 +2113,10 @@ children = ( 38816D9D2D004A5E00EB87D6 /* UpdateProfileViewController.swift */, 388A2D322D00D7BF00E2F2F0 /* UpdateProfileViewReactor.swift */, - 38816DA02D004DDE00EB87D6 /* Views */, ); path = UpdateProfile; sourceTree = ""; }; - 38816DA02D004DDE00EB87D6 /* Views */ = { - isa = PBXGroup; - children = ( - 38816DA12D004DED00EB87D6 /* UpdateProfileView.swift */, - ); - path = Views; - sourceTree = ""; - }; 388371F62C8C8E7F004212EB /* DesignSystem */ = { isa = PBXGroup; children = ( @@ -2148,6 +2194,7 @@ 380F42322E884FD4009AC59E /* CardRepositoryImpl.swift */, 3889A28E2E79D8800030F7CA /* NotificationRepositoryImpl.swift */, 38C9AF222E966A1300B401C0 /* TagRepositoryImpl.swift */, + 38C2A7EC2EC074AE00B941A2 /* SettingsRepositoryImpl.swift */, 3889A2612E79BB540030F7CA /* UserRepositoryImpl.swift */, 38899E9F2E799A7E0030F7CA /* Remotes */, 38899E9E2E799A740030F7CA /* Locals */, @@ -2176,6 +2223,12 @@ 38899E632E7938CD0030F7CA /* Responses */ = { isa = PBXGroup; children = ( + 3879B4B72EC5ADBF0070846B /* RejoinableDateInfoResponse.swift */, + 38C2A7F82EC090AC00B941A2 /* BlockUsersInfoResponse.swift */, + 38C2A7E32EC070E700B941A2 /* TransferCodeInfoResponse.swift */, + 381B83EA2EBC769500C84015 /* ProfileCardInfoResponse.swift */, + 381B83E42EBC73F800C84015 /* FollowInfoResponse.swift */, + 381B83E12EBC735F00C84015 /* ProfileInfoResponse.swift */, 38D478062EBBAA080041FF6C /* WriteCardResponse.swift */, 38E928BE2EB72D3600B3F00B /* DetailCardInfoResponse.swift */, 38EBA9102EB3999C008B28F4 /* PostingPermissionResponse.swift */, @@ -2190,6 +2243,7 @@ 38FEBE532E865119002916A8 /* FollowNotificationInfoResponse.swift */, 38899E9B2E7954D70030F7CA /* DeletedNotificationInfoResponse.swift */, 38899E982E7954670030F7CA /* BlockedNotificationInfoResponse.swift */, + 38D8F5572EC4D89400DED428 /* TagNofificationInfoResponse.swift */, 38899E8E2E79511F0030F7CA /* KeyInfoResponse.swift */, 38899E8B2E794E680030F7CA /* AppVersionStatusResponse.swift */, 38899E822E794C330030F7CA /* LoginResponse.swift */, @@ -2214,6 +2268,13 @@ 38899E692E793AEA0030F7CA /* Models */ = { isa = PBXGroup; children = ( + 3879B4B42EC5AD580070846B /* RejoinableDateInfo.swift */, + 38C2A7FB2EC0925700B941A2 /* WithdrawType.swift */, + 38C2A7F52EC08FEF00B941A2 /* BlockUserInfo.swift */, + 38C2A7E02EC0707700B941A2 /* TransferCodeInfo.swift */, + 381B83E72EBC75BF00C84015 /* ProfileCardInfo.swift */, + 381B83DE2EBC72AF00C84015 /* FollowInfo.swift */, + 381B83DB2EBC707400C84015 /* ProfileInfo.swift */, 38D478092EBBABE40041FF6C /* EntranceCardType.swift */, 38E928B82EB715C300B3F00B /* ReortType.swift */, 38E928B52EB711DE00B3F00B /* DetailCardInfo.swift */, @@ -2249,6 +2310,7 @@ 380F422C2E884F35009AC59E /* CardRemoteDataSourceImpl.swift */, 3889A2762E79C2980030F7CA /* NotificationRemoteDataSoruceImpl.swift */, 38C9AF192E96696500B401C0 /* TagRemoteDataSourceImpl.swift */, + 38C2A7E62EC0718900B941A2 /* SettingsRemoteDataSourceImpl.swift */, 3889A2552E79BA0F0030F7CA /* UserRemoteDataSourceImpl.swift */, 38899EA02E799AA30030F7CA /* Interfaces */, ); @@ -2263,6 +2325,7 @@ 380F42232E884ADF009AC59E /* CardRemoteDataSource.swift */, 3889A2732E79C1D30030F7CA /* NotificationRemoteDataSource.swift */, 38C9AF162E96692900B401C0 /* TagRemoteDataSource.swift */, + 38C2A7DD2EC0703F00B941A2 /* SettingsRemoteDataSource.swift */, 3889A24F2E79B3210030F7CA /* UserRemoteDataSource.swift */, ); path = Interfaces; @@ -2275,6 +2338,7 @@ 384972A02CA4DEC00012FCA1 /* CardRequest.swift */, 388698612D1986B100008600 /* NotificationRequest.swift */, 2AFD054B2CFF76CB007C84AD /* TagRequest.swift */, + 38C2A7DA2EC06EC800B941A2 /* SettingsRequest.swift */, 38899EAC2E79A0990030F7CA /* UserRequest.swift */, 38899EA82E799C5D0030F7CA /* VersionRequest.swift */, ); @@ -2289,6 +2353,7 @@ 380F42382E885057009AC59E /* CardUseCaseImpl.swift */, 3889A2942E79D9200030F7CA /* NotificationUseCaseImpl.swift */, 38C9AF282E966A8B00B401C0 /* TagUseCaseImpl.swift */, + 38C2A7F22EC0798600B941A2 /* SettingsUserCaseImpl.swift */, 3889A2672E79BC840030F7CA /* UserUseCaseImpl.swift */, 3889A2482E79AE870030F7CA /* Interfaces */, ); @@ -2303,6 +2368,7 @@ 380F422F2E884FB5009AC59E /* CardRepository.swift */, 3889A28B2E79D8650030F7CA /* NotificationRepository.swift */, 38C9AF1F2E9669F100B401C0 /* TagRepository.swift */, + 38C2A7E92EC0749A00B941A2 /* SettingsRepository.swift */, 3889A25B2E79BB2F0030F7CA /* UserRepository.swift */, ); path = Repositories; @@ -2316,6 +2382,7 @@ 380F42352E88502F009AC59E /* CardUseCase.swift */, 3889A2912E79D8F40030F7CA /* NotificationUseCase.swift */, 38C9AF252E966A6100B401C0 /* TagUseCase.swift */, + 38C2A7EF2EC0795F00B941A2 /* SettingsUserCase.swift */, 3889A2642E79BBC00030F7CA /* UserUseCase.swift */, ); path = Interfaces; @@ -2464,7 +2531,6 @@ 2AE6B1592CBEAEC000FA5C3C /* ReportRequest.swift */, 2A5BB7E22CDCD97300E1C799 /* JoinRequest.swift */, 3878D04D2CFFC5F300F9522F /* ProfileRequest.swift */, - 3803CF6B2D0156FC00FD90DB /* SettingsRequest.swift */, ); path = Request; sourceTree = ""; @@ -2496,13 +2562,42 @@ path = Models; sourceTree = ""; }; + 38C2A7FE2EC098FB00B941A2 /* BlockUsers */ = { + isa = PBXGroup; + children = ( + 38C2A80A2EC0BC3F00B941A2 /* BlockUsersViewController.swift */, + 38C2A8102EC0BE0600B941A2 /* BlockUsersViewReactor.swift */, + 38C2A8062EC0BB8B00B941A2 /* Cells */, + ); + path = BlockUsers; + sourceTree = ""; + }; + 38C2A7FF2EC09A4D00B941A2 /* Views */ = { + isa = PBXGroup; + children = ( + 38C2A8032EC09BBD00B941A2 /* ResignTextFieldView+Rx.swift */, + 38C2A8002EC09A5500B941A2 /* ResignTextFieldView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 38C2A8062EC0BB8B00B941A2 /* Cells */ = { + isa = PBXGroup; + children = ( + 38C2A8072EC0BB8F00B941A2 /* BlockUserViewCell.swift */, + 38C2A80D2EC0BC8300B941A2 /* BlockUserPlaceholderViewCell.swift */, + ); + path = Cells; + sourceTree = ""; + }; 38C2D40F2CFE9ED800CEA092 /* Cells */ = { isa = PBXGroup; children = ( - 38C2D4132CFEA9CC00CEA092 /* MyProfileViewCell.swift */, - 38C2D4102CFE9EF300CEA092 /* OtherProfileViewCell.swift */, - 38C2D4192CFEAAED00CEA092 /* ProfileViewFooter.swift */, - 38C2D4162CFEAACA00CEA092 /* ProfileViewFooterCell.swift */, + 38CA91F52EBDD336002C261A /* ProfileViewHeader.swift */, + 381B83F12EBCEC2900C84015 /* ProfileUserViewCell.swift */, + 38D8FE8C2EBE36F200F32D02 /* ProfileCardsViewCell.swift */, + 38C2D4162CFEAACA00CEA092 /* ProfileCardViewCell.swift */, + 38CA91F22EBDCFE6002C261A /* ProfileCardsPlaceholderViewCell.swift */, ); path = Cells; sourceTree = ""; @@ -2545,6 +2640,7 @@ 3878FE0B2D0365B000D8955C /* SOMNavigationBar */, 38D488C92D0C557300F2D38D /* SOMButton.swift */, 3878F4702CA3F03400AA46A2 /* SOMCard.swift */, + 38D8FE8F2EBE663E00F32D02 /* SOMNicknameTextField.swift */, 38773E7B2CB3ACB2004815CD /* SOMRefreshControl.swift */, 38D055C22CD862FE00E75590 /* SOMActivityIndicatorView.swift */, 38D522672E742F550044911B /* SOMLoadingIndicatorView.swift */, @@ -2730,6 +2826,7 @@ 385441932C870544004E2BB0 /* Frameworks */, 385441942C870544004E2BB0 /* Resources */, 63E8EF9638A3441082507A5C /* [CP] Embed Pods Frameworks */, + 384164332EC9D47A00114AF4 /* Run Crashlytics */, ); buildRules = ( ); @@ -2962,6 +3059,27 @@ shellPath = /bin/sh; shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n${PODS_ROOT}/SwiftLint/swiftlint lint\n"; }; + 384164332EC9D47A00114AF4 /* Run Crashlytics */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}", + "$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", + ); + name = "Run Crashlytics"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n# 프로젝트의 dSYM 파일을 처리하고 파일을 Crashlytics에 업로드\n\"${PODS_ROOT}/FirebaseCrashlytics/run\"\n"; + }; 63E8EF9638A3441082507A5C /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -3031,6 +3149,7 @@ 389EF81F2D2F469B00E053AE /* CocoaLumberjack.swift in Sources */, 381DEA8E2CD4BE55009F1FE9 /* SignInResponse.swift in Sources */, 3802BDB92D0AF2F7001256EA /* PushManager+Rx.swift in Sources */, + 38C2A7F32EC0798B00B941A2 /* SettingsUserCaseImpl.swift in Sources */, 3880EF802EA0DB0900D88608 /* WriteCardTagsDelegate.swift in Sources */, 385620F72CA19EA900E0AB5A /* Alamofire_constants.swift in Sources */, 3817016F2CD882C2005FC220 /* TimeoutInterceptor.swift in Sources */, @@ -3038,7 +3157,7 @@ 38E928CB2EB7402200B3F00B /* WrittenTag.swift in Sources */, 3889A2682E79BC880030F7CA /* UserUseCaseImpl.swift in Sources */, 38C9AF3C2E96ACEB00B401C0 /* WriteCardTagFooterDelegate.swift in Sources */, - 3878D0762CFFE01500F9522F /* SettingScrollViewHeader.swift in Sources */, + 3878D0762CFFE01500F9522F /* SettingVersionCellView.swift in Sources */, 3878D08D2CFFF0BF00F9522F /* AnnouncementViewControler.swift in Sources */, 38601E1C2D313A8200A465A9 /* SOMNavigationBar+Rx.swift in Sources */, 38FD4DB22D034C1700BF5FF1 /* MyFollowingViewCell.swift in Sources */, @@ -3054,10 +3173,9 @@ 386867A72E9E932B00171A5E /* WriteCardSelectImageView+Rx.swift in Sources */, 385C01B12E8E8DD8003C7894 /* SOMPageView.swift in Sources */, 3803CF6A2D0156BA00FD90DB /* SettingsViewReactor.swift in Sources */, + 381B83E52EBC73FC00C84015 /* FollowInfoResponse.swift in Sources */, 3878F4782CA3F08300AA46A2 /* UIView.swift in Sources */, 2ACBD4182CC963390057C013 /* DefaultCardImageResponse.swift in Sources */, - 3803CF782D01685000FD90DB /* CommentHistroyViewReactor.swift in Sources */, - 38816DA32D004DED00EB87D6 /* UpdateProfileView.swift in Sources */, 3816E23B2D3BF402004CC196 /* TermsOfServiceCellView+Rx.swift in Sources */, 2AFD054A2CFF7687007C84AD /* RecommendTagsResponse.swift in Sources */, 38773E7D2CB3ACB2004815CD /* SOMRefreshControl.swift in Sources */, @@ -3067,11 +3185,12 @@ 38CC49862CDE3885007A0145 /* SOMTransitioningDelegate.swift in Sources */, 388DA0FC2C8F521300A9DD56 /* FontContainer.swift in Sources */, 384972A12CA4DEC00012FCA1 /* CardRequest.swift in Sources */, + 381B83E02EBC72B400C84015 /* FollowInfo.swift in Sources */, 38D488CB2D0C557300F2D38D /* SOMButton.swift in Sources */, + 38C2A7F62EC08FF600B941A2 /* BlockUserInfo.swift in Sources */, 388FCAD12CFAC2BF0012C4D6 /* Notification.swift in Sources */, 3802BDB62D0AF16A001256EA /* PushManager.swift in Sources */, 38B6AADC2CA4740B00CE6DB6 /* LaunchScreenViewReactor.swift in Sources */, - 38C2D4122CFE9EF300CEA092 /* OtherProfileViewCell.swift in Sources */, 38B543E02D46171300DDF2C5 /* ManagerConfiguration.swift in Sources */, 3887D0342CC5335200FB52E1 /* WriteCardViewReactor.swift in Sources */, 3878D05D2CFFD10D00F9522F /* FollowingResponse.swift in Sources */, @@ -3087,19 +3206,21 @@ 38E7FBF02D3CF6BC00A359CD /* SOMDialogAction.swift in Sources */, 3878D07E2CFFE6E500F9522F /* IssueMemberTransferViewController.swift in Sources */, 3878B8632D0DC8BD00B3B128 /* UIViewController+Toast.swift in Sources */, + 38C2A7D82EC054C500B941A2 /* SettingVersionCellView+Rx.swift in Sources */, 38E9CE112D376E0E00E85A2D /* PushTokenSet.swift in Sources */, - 3878D0982CFFF2B800F9522F /* CommentHistroyViewController.swift in Sources */, 386867A42E9E378200171A5E /* Array.swift in Sources */, 38899E6F2E79400C0030F7CA /* ImageUrlInfoResponse.swift in Sources */, 38F720AE2CD4F15900DF32B5 /* DetailCardResponse.swift in Sources */, 3889A2772E79C29F0030F7CA /* NotificationRemoteDataSoruceImpl.swift in Sources */, 2AFD056A2D03264C007C84AD /* AddFavoriteTagResponse.swift in Sources */, + 38C2A7EE2EC074B200B941A2 /* SettingsRepositoryImpl.swift in Sources */, 38E928D32EB7624300B3F00B /* FloatingButton.swift in Sources */, 38FEBE552E865121002916A8 /* FollowNotificationInfoResponse.swift in Sources */, 2AFD05532D007F2F007C84AD /* TagSearchViewReactor.swift in Sources */, 38EBA90F2EB39920008B28F4 /* PostingPermission.swift in Sources */, 38899E9C2E7954D90030F7CA /* DeletedNotificationInfoResponse.swift in Sources */, 2A44A42B2CAC09AE00DC463E /* RSAKeyResponse.swift in Sources */, + 38C2A7F02EC0796700B941A2 /* SettingsUserCase.swift in Sources */, 38899E8C2E794E690030F7CA /* AppVersionStatusResponse.swift in Sources */, 38C9AF2D2E96A3E500B401C0 /* WriteCardTagModel.swift in Sources */, 38A721962E73E7140071E1D8 /* View+SwiftEntryKit.swift in Sources */, @@ -3110,15 +3231,16 @@ 2A980BA52D803EEA007DFA45 /* SOMEvent.swift in Sources */, 38E928DA2EB7727400B3F00B /* SOMBottomToastView.swift in Sources */, 38B65E7C2E72ADB900DF6919 /* TermsOfServiceAgreeButtonView+Rx.swift in Sources */, - 38C2D4152CFEA9CC00CEA092 /* MyProfileViewCell.swift in Sources */, 38F720B22CD4F15900DF32B5 /* distanceCardResponse.swift in Sources */, 38D2FBCE2E81B52F006DD739 /* SOMSwipableTabBar.swift in Sources */, - 38FD4DB52D034F6600BF5FF1 /* MyFollowerViewCell.swift in Sources */, + 38FD4DB52D034F6600BF5FF1 /* FollowerViewCell.swift in Sources */, 380F42372E885032009AC59E /* CardUseCase.swift in Sources */, 382E153B2D15A67A0097B09C /* NotificationViewCell.swift in Sources */, + 38C2A80C2EC0BC4500B941A2 /* BlockUsersViewController.swift in Sources */, 388009922CABF855002A9209 /* SOMTagModel.swift in Sources */, 2AFD05502CFF79D8007C84AD /* TagsViewReactor.swift in Sources */, 3862C0E02C9EB6670023C046 /* UIViewController+PushAndPop.swift in Sources */, + 38FD56252EC9FAA400EC6106 /* String.swift in Sources */, 2AFF95712CF5E8DE00CBFB12 /* TagSearchViewController.swift in Sources */, 2AFD05642D00A1E1007C84AD /* TagDetailCardResponse.swift in Sources */, 3836ACB82C8F04CD00A3C566 /* UILabel+Observer.swift in Sources */, @@ -3129,6 +3251,7 @@ 385C01B82E8EA1EF003C7894 /* SOMPageViews.swift in Sources */, 385C01AE2E8E8C6F003C7894 /* SOMPageModel.swift in Sources */, 2A032EFE2CE517DD008326C0 /* OnboardingTermsOfServiceViewReactor.swift in Sources */, + 38C2A8022EC09A5A00B941A2 /* ResignTextFieldView.swift in Sources */, 388C96372CCE41700061C598 /* AuthInfo.swift in Sources */, 3878D0892CFFEF0F00F9522F /* TermsOfServiceTextCellView+Rx.swift in Sources */, 38F720B62CD4F15900DF32B5 /* PopularCardResponse.swift in Sources */, @@ -3153,7 +3276,9 @@ 38899E942E79518F0030F7CA /* CommonNotificationInfo.swift in Sources */, 38F131892CC7B7E0000D0475 /* RelatedTagResponse.swift in Sources */, 2AFD056E2D048CAF007C84AD /* TagRequest.swift in Sources */, + 38CA91F42EBDCFF2002C261A /* ProfileCardsPlaceholderViewCell.swift in Sources */, 38E928D02EB75FA300B3F00B /* PungView.swift in Sources */, + 3879B4B82EC5ADC50070846B /* RejoinableDateInfoResponse.swift in Sources */, 38E928C82EB73FF800B3F00B /* WrittenTagModel.swift in Sources */, 388698602D1984D600008600 /* NotificationViewReactor.swift in Sources */, 38899E862E794CEE0030F7CA /* NetworkManager_FCM.swift in Sources */, @@ -3169,6 +3294,7 @@ 3878D0732CFFDFEF00F9522F /* SettingTextCellView+Rx.swift in Sources */, 3878D0912CFFF0E300F9522F /* AnnouncementViewCell.swift in Sources */, 2AFF956C2CF5E00600CBFB12 /* RecommendTagView.swift in Sources */, + 38C2A8042EC09BC400B941A2 /* ResignTextFieldView+Rx.swift in Sources */, 3880EF7D2EA0DA7400D88608 /* WritrCardTextViewDelegate.swift in Sources */, 38B543E32D46179500DDF2C5 /* AuthManagerConfiguration.swift in Sources */, 38D055C42CD862FE00E75590 /* SOMActivityIndicatorView.swift in Sources */, @@ -3180,14 +3306,18 @@ 2AFF955B2CF3227900CBFB12 /* TagSearchTextFieldView.swift in Sources */, 3889A26E2E79BE9F0030F7CA /* AuthRemoteDataSourceImpl.swift in Sources */, 2A45B36D2CE3A3E30071026A /* OnboardingProfileImageSettingViewReactor.swift in Sources */, + 38C2A7E12EC0707D00B941A2 /* TransferCodeInfo.swift in Sources */, 38D5CE0C2CBCE8CA0054AB9A /* SimpleDefaults.swift in Sources */, 383EC6232E7A56CE00EC2D1E /* AppDIContainer.swift in Sources */, 382D5CF72CFE9B8600BFA23E /* ProfileViewController.swift in Sources */, + 38C2A7EB2EC074A200B941A2 /* SettingsRepository.swift in Sources */, 38899E892E794D620030F7CA /* NetworkManager_Version.swift in Sources */, 385620F32CA19D2D00E0AB5A /* Alamofire_Request.swift in Sources */, 388A2D312D00D6A100E2F2F0 /* FollowViewReactor.swift in Sources */, 2A649ED42CAE990B002D8284 /* SOMDialogViewController.swift in Sources */, 3893B6CF2D36728000F2004C /* ManagerProvider.swift in Sources */, + 38C2A7FC2EC0925C00B941A2 /* WithdrawType.swift in Sources */, + 38C2A7DC2EC06ECE00B941A2 /* SettingsRequest.swift in Sources */, 38A7219A2E73EA6F0071E1D8 /* SOMBottomFloatView.swift in Sources */, 2AFF95692CF5DFF800CBFB12 /* RecommendTagTableViewCell.swift in Sources */, 3866577F2CEF3554009F7F60 /* UIButton+Rx.swift in Sources */, @@ -3200,6 +3330,7 @@ 38AE77DB2E745FFF00B6FD13 /* EnterMemberTransferTextFieldView.swift in Sources */, 3878D0602CFFD45100F9522F /* FollowerResponse.swift in Sources */, 3889A2432E79AD7D0030F7CA /* AppVersionRepository.swift in Sources */, + 381B83E32EBC736800C84015 /* ProfileInfoResponse.swift in Sources */, 381854A02E99340600424D71 /* WriteCardUserImageCell.swift in Sources */, 38C9AF152E9665C900B401C0 /* TagInfoResponse.swift in Sources */, 381701722CD88374005FC220 /* CompositeInterceptor.swift in Sources */, @@ -3212,30 +3343,38 @@ 38C9AF2A2E966A8F00B401C0 /* TagUseCaseImpl.swift in Sources */, 2A5BB7E12CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift in Sources */, 3803CF7E2D016DA200FD90DB /* TransferCodeResponse.swift in Sources */, + 38D8FE8D2EBE36F800F32D02 /* ProfileCardsViewCell.swift in Sources */, + 38C2A7DF2EC0704700B941A2 /* SettingsRemoteDataSource.swift in Sources */, 3834FADE2D11C5AC00C9108D /* SimpleCache.swift in Sources */, 388009982CAC20EC002A9209 /* SOMTags+Rx.swift in Sources */, 38E928DC2EB7921200B3F00B /* UIRefreshControl.swift in Sources */, 38899EAA2E799C630030F7CA /* VersionRequest.swift in Sources */, + 381B83DD2EBC707A00C84015 /* ProfileInfo.swift in Sources */, 38FEBE5B2E8652DE002916A8 /* CompositeNotificationInfoResponse.swift in Sources */, + 38D8F55E2EC4F38700DED428 /* SimpleReachability.swift in Sources */, 2A980BA92D803F04007DFA45 /* GAManager.swift in Sources */, 3880EF782EA0CF2F00D88608 /* RelatedTagsViewLayout.swift in Sources */, + 381B83F32EBCEC2E00C84015 /* ProfileUserViewCell.swift in Sources */, 2AFF95752CF5F08700CBFB12 /* TagPreviewCardCollectionViewCell.swift in Sources */, 38FEBE642E8662A3002916A8 /* NoticeInfoResponse.swift in Sources */, 38899E722E79402C0030F7CA /* ImageUrlInfo.swift in Sources */, 2AFD05472CFF75DD007C84AD /* FavoriteTagsResponse.swift in Sources */, 3889A25C2E79BB340030F7CA /* UserRepository.swift in Sources */, 38899E7E2E794B420030F7CA /* SignUpResponse.swift in Sources */, + 38CA91F72EBDD342002C261A /* ProfileViewHeader.swift in Sources */, 38AA00032CAD1BCC002C5F1E /* LikeAndCommentView.swift in Sources */, 38D2FBD12E81B9B7006DD739 /* SOMSwipableTabBarDelegate.swift in Sources */, 38D2FBC12E812354006DD739 /* HomeViewController.swift in Sources */, 3816C0612CCDE35300C8688C /* ErrorInterceptor.swift in Sources */, 3803CF7B2D016BDB00FD90DB /* IssueMemberTransferViewReactor.swift in Sources */, + 381B83E82EBC75D700C84015 /* ProfileCardInfo.swift in Sources */, 38D5637C2D16D72D006265AA /* SOMStickyTabBar.swift in Sources */, 3878D04F2CFFC5F300F9522F /* ProfileRequest.swift in Sources */, 38FDC2C82C9E764300C094C2 /* BaseNavigationViewController.swift in Sources */, 3880097C2CABEE3D002A9209 /* DetailViewController.swift in Sources */, 2A45B3702CE4C5510071026A /* RegisterUserResponse.swift in Sources */, 3889A2802E79D0250030F7CA /* Token.swift in Sources */, + 3879B4B52EC5AD5E0070846B /* RejoinableDateInfo.swift in Sources */, 388DA0FF2C8F526C00A9DD56 /* UIFont.swift in Sources */, 38C9AF202E9669F600B401C0 /* TagRepository.swift in Sources */, 38E928C22EB73D6B00B3F00B /* MemberInfoView.swift in Sources */, @@ -3251,6 +3390,7 @@ 38899E9A2E7954680030F7CA /* BlockedNotificationInfoResponse.swift in Sources */, 2AE6B14D2CBC160C00FA5C3C /* ReportViewReactor.swift in Sources */, 38FD4DAC2D032CF000BF5FF1 /* AnnouncementResponse.swift in Sources */, + 38D8FE912EBE664D00F32D02 /* SOMNicknameTextField.swift in Sources */, 3889A2892E79D8220030F7CA /* AuthUseCaseImpl.swift in Sources */, 3880098F2CABF4C2002A9209 /* SOMTag.swift in Sources */, 3803CF892D01914200FD90DB /* ResignViewReactor.swift in Sources */, @@ -3267,6 +3407,7 @@ 38C9AF182E96693600B401C0 /* TagRemoteDataSource.swift in Sources */, 38121E322CA6C77500602499 /* Double.swift in Sources */, 380F42252E884AE5009AC59E /* CardRemoteDataSource.swift in Sources */, + 381B83EC2EBC769900C84015 /* ProfileCardInfoResponse.swift in Sources */, 38405CCC2CC611FD00612D1E /* BaseEmptyAndHeader.swift in Sources */, 38E9CE1A2D37FED000E85A2D /* AddingTokenInterceptor.swift in Sources */, 38F88EBB2D2C1CB8002AD7A8 /* Info.swift in Sources */, @@ -3281,22 +3422,23 @@ 38C9AF272E966A6300B401C0 /* TagUseCase.swift in Sources */, 38C9AF0F2E96602300B401C0 /* DefaultImagesResponse.swift in Sources */, 38D563852D1719B1006265AA /* SOMStickyTabBarItem.swift in Sources */, + 38C2A7E72EC0719200B941A2 /* SettingsRemoteDataSourceImpl.swift in Sources */, 389EF8182D2F450000E053AE /* Log.swift in Sources */, 3889A26B2E79BD450030F7CA /* AuthRemoteDataSource.swift in Sources */, 38F720A82CD4F15900DF32B5 /* CommentCardResponse.swift in Sources */, - 38C2D41B2CFEAAED00CEA092 /* ProfileViewFooter.swift in Sources */, 389681112CAFBD6A00FFD89F /* DetailViewReactor.swift in Sources */, 3889A2932E79D8F80030F7CA /* NotificationUseCase.swift in Sources */, 3878D0822CFFEC6900F9522F /* TermsOfServiceViewController.swift in Sources */, + 38C2A8122EC0BE0B00B941A2 /* BlockUsersViewReactor.swift in Sources */, 38F720B92CD4F16500DF32B5 /* CardProtocol.swift in Sources */, 3889A2662E79BBC40030F7CA /* UserUseCase.swift in Sources */, 38121E352CA6DA4000602499 /* Date.swift in Sources */, + 38C2A7FA2EC090B100B941A2 /* BlockUsersInfoResponse.swift in Sources */, 2A34AFB62D144F08007BD7E7 /* EmptyTagDetailTableViewCell.swift in Sources */, 38899E6B2E793AFD0030F7CA /* CheckAvailable.swift in Sources */, 38C9AF242E966A1B00B401C0 /* TagRepositoryImpl.swift in Sources */, - 3803CF752D0166D700FD90DB /* CommentHistoryViewCell.swift in Sources */, 38FCF4192E9F88EA003AC3D8 /* WriteCardTags+Rx.swift in Sources */, - 38C2D4182CFEAACA00CEA092 /* ProfileViewFooterCell.swift in Sources */, + 38C2D4182CFEAACA00CEA092 /* ProfileCardViewCell.swift in Sources */, 381DEA8C2CD4BBCB009F1FE9 /* WriteCardTextView+Rx.swift in Sources */, 3818549D2E992F7D00424D71 /* WriteCardDefaultImageCell.swift in Sources */, 3889A2752E79C1D80030F7CA /* NotificationRemoteDataSource.swift in Sources */, @@ -3308,6 +3450,7 @@ 388693A02CF77FA7005F9EF3 /* UIApplication+Top.swift in Sources */, 38B8BE482D1ECBDA0084569C /* NotificationInfo.swift in Sources */, 38D5637F2D17152F006265AA /* SOMStickyTabBarDelegate.swift in Sources */, + 38C2A80E2EC0BC8900B941A2 /* BlockUserPlaceholderViewCell.swift in Sources */, 388372022C8C8FCF004212EB /* UIColor.swift in Sources */, 38B543E92D4617EA00DDF2C5 /* NetworkManagerConfiguration.swift in Sources */, 38C9AF112E96656600B401C0 /* TagInfo.swift in Sources */, @@ -3324,9 +3467,7 @@ 38E928C02EB72D3D00B3F00B /* DetailCardInfoResponse.swift in Sources */, 38B6AAE02CA4777200CE6DB6 /* UIViewController+Rx.swift in Sources */, 2ACBD41E2CCAB3490057C013 /* PresignedStorageResponse.swift in Sources */, - 2A5BB7DA2CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift in Sources */, 38899EA42E799B260030F7CA /* AppVersionRemoteDataSource.swift in Sources */, - 3878D0682CFFDAF100F9522F /* OtherFollowViewCell.swift in Sources */, 38F006AB2D395A7F001AC5F7 /* SuspensionResponse.swift in Sources */, 38899EA62E799BD60030F7CA /* AppVersionRemoteDataSourceImpl.swift in Sources */, 38AE77D42E74580000B6FD13 /* OnboardingCompletedViewController.swift in Sources */, @@ -3345,7 +3486,7 @@ 380F42222E87ECA3009AC59E /* CompositeNotificationInfo.swift in Sources */, 38899E5E2E7937E50030F7CA /* NicknameValidateResponse.swift in Sources */, 385053532C92DBE200C80B02 /* SOMTabBarItem.swift in Sources */, - 3803CF6D2D0156FC00FD90DB /* SettingsRequest.swift in Sources */, + 38C2A8092EC0BB9800B941A2 /* BlockUserViewCell.swift in Sources */, 3880EF6F2EA0CD7100D88608 /* RelatedTagViewModel.swift in Sources */, 3889A28F2E79D8860030F7CA /* NotificationRepositoryImpl.swift in Sources */, 2A5BB7E42CDCD97300E1C799 /* JoinRequest.swift in Sources */, @@ -3358,6 +3499,7 @@ 385602B72D2FB18400118530 /* NotificationPlaceholderViewCell.swift in Sources */, 383EC6152E7A50EB00EC2D1E /* AuthLocalDataSourceImpl.swift in Sources */, 3878D0542CFFC6C100F9522F /* ProfileResponse.swift in Sources */, + 38D8F5592EC4D89D00DED428 /* TagNofificationInfoResponse.swift in Sources */, 38C9AF302E96A49F00B401C0 /* WriteCardTag.swift in Sources */, 3889A27D2E79C56E0030F7CA /* ToeknResponse.swift in Sources */, 3878D06C2CFFDF1F00F9522F /* SettingsViewController.swift in Sources */, @@ -3367,10 +3509,12 @@ 3889A2842E79D7D40030F7CA /* AuthRepositoryImpl.swift in Sources */, 383EC6202E7A564600EC2D1E /* AppAssembler.swift in Sources */, 381DEA8D2CD4BE4A009F1FE9 /* Card.swift in Sources */, + 38B35D092EBF7B7300709E53 /* FollowPlaceholderViewCell.swift in Sources */, 384972A42CA54DC10012FCA1 /* UIImgeView.swift in Sources */, 38D6F1812CC2413400E11530 /* WriteCardTextView.swift in Sources */, 3816E2382D3BEE7E004CC196 /* TermsOfServiceCellView.swift in Sources */, 38AE77DE2E7465F500B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift in Sources */, + 38C2A7E42EC070EE00B941A2 /* TransferCodeInfoResponse.swift in Sources */, 3878F4722CA3F03400AA46A2 /* SOMCard.swift in Sources */, 3803CF862D017DC700FD90DB /* EnterMemberTransferViewReactor.swift in Sources */, 38B6AAE32CA4787200CE6DB6 /* MainTabBarReactor.swift in Sources */, @@ -3414,6 +3558,7 @@ 3834FADD2D11C5AC00C9108D /* SimpleCache.swift in Sources */, 38D563842D1719B1006265AA /* SOMStickyTabBarItem.swift in Sources */, 2A5BB7BE2CDB870000E1C799 /* OnboardingGuideMessageView.swift in Sources */, + 38C2A7F42EC0798B00B941A2 /* SettingsUserCaseImpl.swift in Sources */, 3880EF812EA0DB0900D88608 /* WriteCardTagsDelegate.swift in Sources */, 3836ACB42C8F045300A3C566 /* Typography.swift in Sources */, 2AFD05632D00A1E1007C84AD /* TagDetailCardResponse.swift in Sources */, @@ -3423,7 +3568,7 @@ 3889A2692E79BC880030F7CA /* UserUseCaseImpl.swift in Sources */, 38C9AF3D2E96ACEB00B401C0 /* WriteCardTagFooterDelegate.swift in Sources */, 3878FE0D2D0365C800D8955C /* SOMNavigationBar+Rx.swift in Sources */, - 38FD4DB42D034F6600BF5FF1 /* MyFollowerViewCell.swift in Sources */, + 38FD4DB42D034F6600BF5FF1 /* FollowerViewCell.swift in Sources */, 3878D04E2CFFC5F300F9522F /* ProfileRequest.swift in Sources */, 383EC6192E7A547900EC2D1E /* BaseAssembler.swift in Sources */, 3878D0722CFFDFEF00F9522F /* SettingTextCellView+Rx.swift in Sources */, @@ -3437,7 +3582,7 @@ 3878F4772CA3F08300AA46A2 /* UIView.swift in Sources */, 385C01B22E8E8DD8003C7894 /* SOMPageView.swift in Sources */, 3830FFA62CEC6E3100ABA9FD /* Kingfisher.swift in Sources */, - 3878D0672CFFDAF100F9522F /* OtherFollowViewCell.swift in Sources */, + 381B83E62EBC73FC00C84015 /* FollowInfoResponse.swift in Sources */, 381701782CD88854005FC220 /* LogginMonitor.swift in Sources */, 3816E23A2D3BF402004CC196 /* TermsOfServiceCellView+Rx.swift in Sources */, 2A649ECF2CAE8970002D8284 /* SOMDialogViewController.swift in Sources */, @@ -3450,7 +3595,9 @@ 38FD4DAB2D032CF000BF5FF1 /* AnnouncementResponse.swift in Sources */, 388FCAD02CFAC2BF0012C4D6 /* Notification.swift in Sources */, 38608B302CB5195D0066BB40 /* Card.swift in Sources */, + 381B83DF2EBC72B400C84015 /* FollowInfo.swift in Sources */, 388C96362CCE41700061C598 /* AuthInfo.swift in Sources */, + 38C2A7F72EC08FF600B941A2 /* BlockUserInfo.swift in Sources */, 2AFF95742CF5F08700CBFB12 /* TagPreviewCardCollectionViewCell.swift in Sources */, 2AFF95642CF33D9F00CBFB12 /* TagsHeaderView.swift in Sources */, 2AE6B15A2CBEAEC000FA5C3C /* ReportRequest.swift in Sources */, @@ -3466,9 +3613,10 @@ 2A5BB7D12CDC7ADC00E1C799 /* OnboardingViewController.swift in Sources */, 3887D0392CC5504500FB52E1 /* UITextField+Typography.swift in Sources */, 3803CF7A2D016BDB00FD90DB /* IssueMemberTransferViewReactor.swift in Sources */, - 38C2D4172CFEAACA00CEA092 /* ProfileViewFooterCell.swift in Sources */, + 38C2D4172CFEAACA00CEA092 /* ProfileCardViewCell.swift in Sources */, 38E7FBEF2D3CF6BB00A359CD /* SOMDialogAction.swift in Sources */, 38AA00022CAD1BCC002C5F1E /* LikeAndCommentView.swift in Sources */, + 38C2A7D92EC054C500B941A2 /* SettingVersionCellView+Rx.swift in Sources */, 38D5CE0B2CBCE8CA0054AB9A /* SimpleDefaults.swift in Sources */, 38E9CE102D376E0E00E85A2D /* PushTokenSet.swift in Sources */, 386867A52E9E378200171A5E /* Array.swift in Sources */, @@ -3476,17 +3624,18 @@ 385053582C92DD2300C80B02 /* SOMTabBarController.swift in Sources */, 38899E6E2E79400C0030F7CA /* ImageUrlInfoResponse.swift in Sources */, 3889A2782E79C29F0030F7CA /* NotificationRemoteDataSoruceImpl.swift in Sources */, + 38C2A7ED2EC074B200B941A2 /* SettingsRepositoryImpl.swift in Sources */, 38FEBE542E865121002916A8 /* FollowNotificationInfoResponse.swift in Sources */, 38E928D42EB7624300B3F00B /* FloatingButton.swift in Sources */, 38899E9D2E7954D90030F7CA /* DeletedNotificationInfoResponse.swift in Sources */, 3878D0792CFFE1E800F9522F /* ResignViewController.swift in Sources */, 38EBA90E2EB39920008B28F4 /* PostingPermission.swift in Sources */, 38899E8D2E794E690030F7CA /* AppVersionStatusResponse.swift in Sources */, + 38C2A7F12EC0796700B941A2 /* SettingsUserCase.swift in Sources */, 38A721952E73E7140071E1D8 /* View+SwiftEntryKit.swift in Sources */, 38C9AF2E2E96A3E500B401C0 /* WriteCardTagModel.swift in Sources */, 38F720B12CD4F15900DF32B5 /* distanceCardResponse.swift in Sources */, 380F422A2E884E9C009AC59E /* HomeCardInfoResponse.swift in Sources */, - 38816DA22D004DED00EB87D6 /* UpdateProfileView.swift in Sources */, 38FDC2B62C9E746B00C094C2 /* BaseViewController.swift in Sources */, 2A980BA42D803EEA007DFA45 /* SOMEvent.swift in Sources */, 38B65E7D2E72ADB900DF6919 /* TermsOfServiceAgreeButtonView+Rx.swift in Sources */, @@ -3496,9 +3645,11 @@ 38601E1B2D3139D000A465A9 /* RecommendTagView.swift in Sources */, 380F42362E885032009AC59E /* CardUseCase.swift in Sources */, 388009942CABFAAA002A9209 /* SOMTags.swift in Sources */, + 38C2A80B2EC0BC4500B941A2 /* BlockUsersViewController.swift in Sources */, 3862C0DF2C9EB6670023C046 /* UIViewController+PushAndPop.swift in Sources */, 38F720B52CD4F15900DF32B5 /* PopularCardResponse.swift in Sources */, 38D6F1832CC243DB00E11530 /* UITextView+Typography.swift in Sources */, + 38FD56242EC9FAA400EC6106 /* String.swift in Sources */, 382D5CF62CFE9B8600BFA23E /* ProfileViewController.swift in Sources */, 38773E7C2CB3ACB2004815CD /* SOMRefreshControl.swift in Sources */, 3817016E2CD882C2005FC220 /* TimeoutInterceptor.swift in Sources */, @@ -3509,6 +3660,7 @@ 385C01B72E8EA1EF003C7894 /* SOMPageViews.swift in Sources */, 385C01AF2E8E8C6F003C7894 /* SOMPageModel.swift in Sources */, 3880098E2CABF4C2002A9209 /* SOMTag.swift in Sources */, + 38C2A8012EC09A5A00B941A2 /* ResignTextFieldView.swift in Sources */, 2A5BB7E32CDCD97300E1C799 /* JoinRequest.swift in Sources */, 38C2D4202CFEB82400CEA092 /* ProfileViewReactor.swift in Sources */, 2A44A4372CAC227300DC463E /* BaseAuthResponse.swift in Sources */, @@ -3517,7 +3669,6 @@ 3802BDAC2D0AC1FB001256EA /* UIImage.swift in Sources */, 389EF81A2D2F454600E053AE /* Log+Extract.swift in Sources */, 38899EAE2E79A09B0030F7CA /* UserRequest.swift in Sources */, - 38C2D4142CFEA9CC00CEA092 /* MyProfileViewCell.swift in Sources */, 3889A2712E79C03B0030F7CA /* AuthRepository.swift in Sources */, 3803CF882D01914200FD90DB /* ResignViewReactor.swift in Sources */, 38AE77D72E7459F400B6FD13 /* OnboardingCompletedViewReactor.swift in Sources */, @@ -3536,6 +3687,8 @@ 38899E872E794CEE0030F7CA /* NetworkManager_FCM.swift in Sources */, 3803CF7D2D016DA200FD90DB /* TransferCodeResponse.swift in Sources */, 2AFF955A2CF3227900CBFB12 /* TagSearchTextFieldView.swift in Sources */, + 3879B4B92EC5ADC50070846B /* RejoinableDateInfoResponse.swift in Sources */, + 38CA91F32EBDCFF2002C261A /* ProfileCardsPlaceholderViewCell.swift in Sources */, 38E928D12EB75FA300B3F00B /* PungView.swift in Sources */, 38E928C72EB73FF800B3F00B /* WrittenTagModel.swift in Sources */, 2A44A42D2CAC14C800DC463E /* SignInResponse.swift in Sources */, @@ -3550,6 +3703,7 @@ 38B543E22D46179500DDF2C5 /* AuthManagerConfiguration.swift in Sources */, 38B8A58B2CAEA79A000AFE83 /* DetailViewFooter.swift in Sources */, 38B543EE2D46506300DDF2C5 /* ManagerType.swift in Sources */, + 38C2A8052EC09BC400B941A2 /* ResignTextFieldView+Rx.swift in Sources */, 2A5BB7E02CDCBE7E00E1C799 /* OnboardingNicknameSettingViewReactor.swift in Sources */, 388371FC2C8C8F11004212EB /* UIColor+SOOUM.swift in Sources */, 3880EF7E2EA0DA7400D88608 /* WritrCardTextViewDelegate.swift in Sources */, @@ -3563,14 +3717,18 @@ 38899E8A2E794D620030F7CA /* NetworkManager_Version.swift in Sources */, 2A45B36F2CE4C5510071026A /* RegisterUserResponse.swift in Sources */, 388698582D1982DE00008600 /* NotificationViewController.swift in Sources */, + 38C2A7E22EC0707D00B941A2 /* TransferCodeInfo.swift in Sources */, 38121E342CA6DA4000602499 /* Date.swift in Sources */, 38F720A52CD4F15900DF32B5 /* CardSummaryResponse.swift in Sources */, - 3878D0752CFFE01500F9522F /* SettingScrollViewHeader.swift in Sources */, + 3878D0752CFFE01500F9522F /* SettingVersionCellView.swift in Sources */, + 38C2A7EA2EC074A200B941A2 /* SettingsRepository.swift in Sources */, 38A721992E73EA6F0071E1D8 /* SOMBottomFloatView.swift in Sources */, 3893B6CE2D36728000F2004C /* ManagerProvider.swift in Sources */, 2AFF95682CF5DFF800CBFB12 /* RecommendTagTableViewCell.swift in Sources */, 3880097B2CABEE3D002A9209 /* DetailViewController.swift in Sources */, 38D522682E742F610044911B /* SOMLoadingIndicatorView.swift in Sources */, + 38C2A7FD2EC0925C00B941A2 /* WithdrawType.swift in Sources */, + 38C2A7DB2EC06ECE00B941A2 /* SettingsRequest.swift in Sources */, 38D869632CF821F900BF87DA /* UserDefaults.swift in Sources */, 380F422E2E884F3D009AC59E /* CardRemoteDataSourceImpl.swift in Sources */, 38899E662E7939600030F7CA /* CheckAvailableResponse.swift in Sources */, @@ -3583,6 +3741,7 @@ 3878B8622D0DC8BD00B3B128 /* UIViewController+Toast.swift in Sources */, 3818549F2E99340600424D71 /* WriteCardUserImageCell.swift in Sources */, 2AFD05522D007F2F007C84AD /* TagSearchViewReactor.swift in Sources */, + 381B83E22EBC736800C84015 /* ProfileInfoResponse.swift in Sources */, 387FBAF02C8702C100A5E139 /* AppDelegate.swift in Sources */, 380F42272E884B80009AC59E /* BaseCardInfo.swift in Sources */, 3889A24A2E79AE960030F7CA /* AppVersionUseCase.swift in Sources */, @@ -3590,34 +3749,41 @@ 38B8A58E2CAEB61A000AFE83 /* DetailViewFooterCell.swift in Sources */, 38C9AF292E966A8F00B401C0 /* TagUseCaseImpl.swift in Sources */, 2A5BB7C92CDBA53E00E1C799 /* OnboardingNicknameSettingViewController.swift in Sources */, - 3803CF6C2D0156FC00FD90DB /* SettingsRequest.swift in Sources */, 2AE6B1492CBC15BF00FA5C3C /* ReportViewController.swift in Sources */, 3887D0332CC5335200FB52E1 /* WriteCardViewReactor.swift in Sources */, 38D6F1802CC2413400E11530 /* WriteCardTextView.swift in Sources */, 38899EA92E799C630030F7CA /* VersionRequest.swift in Sources */, + 38D8FE8E2EBE36F800F32D02 /* ProfileCardsViewCell.swift in Sources */, + 38C2A7DE2EC0704700B941A2 /* SettingsRemoteDataSource.swift in Sources */, 38FEBE5C2E8652DE002916A8 /* CompositeNotificationInfoResponse.swift in Sources */, 2A980BA82D803F04007DFA45 /* GAManager.swift in Sources */, 38E928DD2EB7921200B3F00B /* UIRefreshControl.swift in Sources */, 38FEBE652E8662A3002916A8 /* NoticeInfoResponse.swift in Sources */, + 381B83DC2EBC707A00C84015 /* ProfileInfo.swift in Sources */, + 38D8F55F2EC4F38700DED428 /* SimpleReachability.swift in Sources */, 38899E712E79402C0030F7CA /* ImageUrlInfo.swift in Sources */, 2AFD055D2D009513007C84AD /* TagDetailNavigationBarView.swift in Sources */, 3880EF772EA0CF2F00D88608 /* RelatedTagsViewLayout.swift in Sources */, + 381B83F22EBCEC2E00C84015 /* ProfileUserViewCell.swift in Sources */, 3889A25D2E79BB340030F7CA /* UserRepository.swift in Sources */, 385620EF2CA19C9500E0AB5A /* NetworkManager.swift in Sources */, 38899E7D2E794B420030F7CA /* SignUpResponse.swift in Sources */, 3803CF692D0156BA00FD90DB /* SettingsViewReactor.swift in Sources */, 38F720B82CD4F16500DF32B5 /* CardProtocol.swift in Sources */, 38D2FBD22E81B9B7006DD739 /* SOMSwipableTabBarDelegate.swift in Sources */, + 38CA91F62EBDD342002C261A /* ProfileViewHeader.swift in Sources */, 38D2FBC22E812354006DD739 /* HomeViewController.swift in Sources */, 38F131882CC7B7E0000D0475 /* RelatedTagResponse.swift in Sources */, 38405CCB2CC611FD00612D1E /* BaseEmptyAndHeader.swift in Sources */, 381701712CD88374005FC220 /* CompositeInterceptor.swift in Sources */, 38FDC2C72C9E764300C094C2 /* BaseNavigationViewController.swift in Sources */, + 381B83E92EBC75D700C84015 /* ProfileCardInfo.swift in Sources */, 3803CF702D0159A500FD90DB /* SettingsResponse.swift in Sources */, 38D5637B2D16D72D006265AA /* SOMStickyTabBar.swift in Sources */, 38FD4DB12D034C1700BF5FF1 /* MyFollowingViewCell.swift in Sources */, 3889A2812E79D0250030F7CA /* Token.swift in Sources */, 2ACBD41D2CCAB3490057C013 /* PresignedStorageResponse.swift in Sources */, + 3879B4B62EC5AD5E0070846B /* RejoinableDateInfo.swift in Sources */, 2ACBD4172CC963390057C013 /* DefaultCardImageResponse.swift in Sources */, 38C9AF212E9669F600B401C0 /* TagRepository.swift in Sources */, 3887176E2E7BDBAE00C6143B /* NicknameResponse.swift in Sources */, @@ -3627,13 +3793,13 @@ 382E153A2D15A67A0097B09C /* NotificationViewCell.swift in Sources */, 3886939F2CF77FA7005F9EF3 /* UIApplication+Top.swift in Sources */, 3878D05F2CFFD45100F9522F /* FollowerResponse.swift in Sources */, - 3878D0972CFFF2B800F9522F /* CommentHistroyViewController.swift in Sources */, 2A44A42A2CAC09AE00DC463E /* RSAKeyResponse.swift in Sources */, 2A5BB7B92CDB860D00E1C799 /* OnboardingTermsOfServiceViewController.swift in Sources */, 38899E992E7954680030F7CA /* BlockedNotificationInfoResponse.swift in Sources */, 2AFD05462CFF75DD007C84AD /* FavoriteTagsResponse.swift in Sources */, 2AFD054F2CFF79D8007C84AD /* TagsViewReactor.swift in Sources */, 3889A28A2E79D8220030F7CA /* AuthUseCaseImpl.swift in Sources */, + 38D8FE902EBE664C00F32D02 /* SOMNicknameTextField.swift in Sources */, 388DA0FE2C8F526C00A9DD56 /* UIFont.swift in Sources */, 38F720AD2CD4F15900DF32B5 /* DetailCardResponse.swift in Sources */, 38B543E52D4617CB00DDF2C5 /* PushManagerConfiguration.swift in Sources */, @@ -3650,11 +3816,11 @@ 380F42242E884AE5009AC59E /* CardRemoteDataSource.swift in Sources */, 3802BDB82D0AF2F7001256EA /* PushManager+Rx.swift in Sources */, 3878D05C2CFFD10D00F9522F /* FollowingResponse.swift in Sources */, + 381B83EB2EBC769900C84015 /* ProfileCardInfoResponse.swift in Sources */, 38E9CE132D37711600E85A2D /* OnboardingViewReactor.swift in Sources */, 3836ACBA2C8F050D00A3C566 /* UILabel+Typography.swift in Sources */, 38CC49852CDE3885007A0145 /* SOMTransitioningDelegate.swift in Sources */, 388A2D332D00D7BF00E2F2F0 /* UpdateProfileViewReactor.swift in Sources */, - 38C2D41A2CFEAAED00CEA092 /* ProfileViewFooter.swift in Sources */, 38E928BA2EB715C900B3F00B /* ReortType.swift in Sources */, 3887D0362CC5335D00FB52E1 /* WriteCardView.swift in Sources */, 2A5BB7CD2CDBB7D100E1C799 /* OnboardingProfileImageSettingViewController.swift in Sources */, @@ -3665,18 +3831,19 @@ 38C9AF0E2E96602300B401C0 /* DefaultImagesResponse.swift in Sources */, 3889A26C2E79BD450030F7CA /* AuthRemoteDataSource.swift in Sources */, 3878D0532CFFC6C100F9522F /* ProfileResponse.swift in Sources */, + 38C2A7E82EC0719200B941A2 /* SettingsRemoteDataSourceImpl.swift in Sources */, 3889A2922E79D8F80030F7CA /* NotificationUseCase.swift in Sources */, 2A34AFB52D144F08007BD7E7 /* EmptyTagDetailTableViewCell.swift in Sources */, - 3803CF742D0166D700FD90DB /* CommentHistoryViewCell.swift in Sources */, 2AFF95702CF5E8DE00CBFB12 /* TagSearchViewController.swift in Sources */, 3889A2652E79BBC40030F7CA /* UserUseCase.swift in Sources */, 384972A32CA54DC10012FCA1 /* UIImgeView.swift in Sources */, 38899E6C2E793AFD0030F7CA /* CheckAvailable.swift in Sources */, + 38C2A8112EC0BE0B00B941A2 /* BlockUsersViewReactor.swift in Sources */, 3802BDB12D0AE900001256EA /* PushManager.swift in Sources */, 2A5BB7E72CDCDC3600E1C799 /* NicknameValidationResponse.swift in Sources */, 388372012C8C8FCF004212EB /* UIColor.swift in Sources */, + 38C2A7F92EC090B100B941A2 /* BlockUsersInfoResponse.swift in Sources */, 38C9AF232E966A1B00B401C0 /* TagRepositoryImpl.swift in Sources */, - 3803CF772D01685000FD90DB /* CommentHistroyViewReactor.swift in Sources */, 38FCF41A2E9F88EA003AC3D8 /* WriteCardTags+Rx.swift in Sources */, 3889A2742E79C1D80030F7CA /* NotificationRemoteDataSource.swift in Sources */, 3816C05C2CCDDF3D00C8688C /* ReAuthenticationResponse.swift in Sources */, @@ -3690,16 +3857,15 @@ 2ACBD41A2CCA03790057C013 /* ImageURLWithName.swift in Sources */, 38B8BE472D1ECBDA0084569C /* NotificationInfo.swift in Sources */, 3878F4742CA3F06C00AA46A2 /* UIStackView.swift in Sources */, - 2A5BB7D92CDCBA8400E1C799 /* OnboardingNicknameTextFieldView.swift in Sources */, 38B543E82D4617EA00DDF2C5 /* NetworkManagerConfiguration.swift in Sources */, 3878D0902CFFF0E300F9522F /* AnnouncementViewCell.swift in Sources */, + 38C2A80F2EC0BC8900B941A2 /* BlockUserPlaceholderViewCell.swift in Sources */, 38C9AF122E96656600B401C0 /* TagInfo.swift in Sources */, 2AFD054C2CFF76CB007C84AD /* TagRequest.swift in Sources */, 38B6AADF2CA4777200CE6DB6 /* UIViewController+Rx.swift in Sources */, 389681102CAFBD6A00FFD89F /* DetailViewReactor.swift in Sources */, 38E928B72EB711E200B3F00B /* DetailCardInfo.swift in Sources */, 388DA1042C8F545E00A9DD56 /* Typography+SOOUM.swift in Sources */, - 38C2D4112CFE9EF300CEA092 /* OtherProfileViewCell.swift in Sources */, 2AFF955D2CF328DE00CBFB12 /* FavoriteTagTableViewCell.swift in Sources */, 38899E842E794C360030F7CA /* LoginResponse.swift in Sources */, 385053522C92DBE200C80B02 /* SOMTabBarItem.swift in Sources */, @@ -3729,6 +3895,7 @@ 380F42212E87ECA3009AC59E /* CompositeNotificationInfo.swift in Sources */, 38899E5F2E7937E50030F7CA /* NicknameValidateResponse.swift in Sources */, 38F720A72CD4F15900DF32B5 /* CommentCardResponse.swift in Sources */, + 38C2A8082EC0BB9800B941A2 /* BlockUserViewCell.swift in Sources */, 3880EF6E2EA0CD7100D88608 /* RelatedTagViewModel.swift in Sources */, 3889A2902E79D8860030F7CA /* NotificationRepositoryImpl.swift in Sources */, 38B6AAE22CA4787200CE6DB6 /* MainTabBarReactor.swift in Sources */, @@ -3741,6 +3908,7 @@ 388A2D2D2D00A45800E2F2F0 /* writtenCardResponse.swift in Sources */, 383EC6162E7A50EB00EC2D1E /* AuthLocalDataSourceImpl.swift in Sources */, 3878D0852CFFED7800F9522F /* TermsOfServiceTextCellView.swift in Sources */, + 38D8F5582EC4D89D00DED428 /* TagNofificationInfoResponse.swift in Sources */, 38C9AF312E96A49F00B401C0 /* WriteCardTag.swift in Sources */, 3889A27E2E79C56E0030F7CA /* ToeknResponse.swift in Sources */, 3878D0812CFFEC6900F9522F /* TermsOfServiceViewController.swift in Sources */, @@ -3750,10 +3918,12 @@ 38A627172CECC5A800C37A03 /* SOMTagsLayoutConfigure.swift in Sources */, 3889A2832E79D7D40030F7CA /* AuthRepositoryImpl.swift in Sources */, 383EC6212E7A564600EC2D1E /* AppAssembler.swift in Sources */, + 38B35D082EBF7B7300709E53 /* FollowPlaceholderViewCell.swift in Sources */, 2AFD05552D0082DE007C84AD /* SearchTagsResponse.swift in Sources */, 38B8A5882CAEA5F9000AFE83 /* DetailViewCell.swift in Sources */, 389EF8172D2F450000E053AE /* Log.swift in Sources */, 3816E2372D3BEE7E004CC196 /* TermsOfServiceCellView.swift in Sources */, + 38C2A7E52EC070EE00B941A2 /* TransferCodeInfoResponse.swift in Sources */, 38AE77DF2E7465F500B6FD13 /* EnterMemberTransferTextFieldView+Rx.swift in Sources */, 38738D4B2D2FDCC300C37574 /* WithoutReadNotisCountResponse.swift in Sources */, 388698622D1986B100008600 /* NotificationRequest.swift in Sources */, @@ -3800,7 +3970,7 @@ CODE_SIGN_ENTITLEMENTS = "SOOUM/Resources/SOOUM-Dev.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1017040; + CURRENT_PROJECT_VERSION = 1018040; DEVELOPMENT_TEAM = 99FRG743RX; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SOOUM/Resources/Develop/Info-dev.plist"; @@ -3823,7 +3993,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.17.4; + MARKETING_VERSION = 1.18.4; OTHER_SWIFT_FLAGS = "$(inherited) -D DEVELOP"; PRODUCT_BUNDLE_IDENTIFIER = com.sooum.dev; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -3852,7 +4022,7 @@ CODE_SIGN_ENTITLEMENTS = "SOOUM/Resources/SOOUM-Dev.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1017040; + CURRENT_PROJECT_VERSION = 1018040; DEVELOPMENT_TEAM = 99FRG743RX; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "SOOUM/Resources/Develop/Info-dev.plist"; @@ -3875,7 +4045,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.17.4; + MARKETING_VERSION = 1.18.4; OTHER_SWIFT_FLAGS = "$(inherited) -D DEVELOP"; PRODUCT_BUNDLE_IDENTIFIER = com.sooum.dev; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/SOOUM/SOOUM/Base/BaseNavigationViewController.swift b/SOOUM/SOOUM/Base/BaseNavigationViewController.swift index 9c628c5d..6cfc6d33 100644 --- a/SOOUM/SOOUM/Base/BaseNavigationViewController.swift +++ b/SOOUM/SOOUM/Base/BaseNavigationViewController.swift @@ -33,7 +33,7 @@ class BaseNavigationViewController: BaseViewController { $0.isHidden = true } - private(set) var navigationPopWithBottomBarHidden: Bool = false + private(set) var navigationPopWithBottomBarHidden: Bool = true private(set) var navigationPopGestureEnabled: Bool = true private(set) var navigationBarHeight: CGFloat = SOMNavigationBar.height diff --git a/SOOUM/SOOUM/Base/BaseViewController.swift b/SOOUM/SOOUM/Base/BaseViewController.swift index 95137f10..01855183 100644 --- a/SOOUM/SOOUM/Base/BaseViewController.swift +++ b/SOOUM/SOOUM/Base/BaseViewController.swift @@ -7,6 +7,8 @@ import UIKit +import Network + import RxKeyboard import RxSwift @@ -15,15 +17,24 @@ import Then import Lottie - class BaseViewController: UIViewController { + + enum Text { + static let bottomToastEntryName: String = "bottomToastEntryName" + static let instabilityNetworkToastTitle: String = "네트워크 연결이 원활하지 않습니다. 네트워크 확인 후 재접속해주세요" + } var disposeBag = DisposeBag() + private let monitor = NWPathMonitor() + + private let instabilityNetworkToastView = SOMBottomToastView(title: Text.instabilityNetworkToastTitle, actions: nil) + let activityIndicatorView = SOMActivityIndicatorView() let loadingIndicatorView = SOMLoadingIndicatorView() private(set) var isEndEditingWhenWillDisappear: Bool = true + private(set) var bottomToastMessageOffset: CGFloat = 88 + 8 override var hidesBottomBarWhenPushed: Bool { didSet { @@ -47,7 +58,7 @@ class BaseViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - self.view.backgroundColor = .white + self.view.backgroundColor = .som.v2.white self.setupConstraints() self.activityIndicatorView.color = .black @@ -74,6 +85,19 @@ class BaseViewController: UIViewController { object.updatedKeyboard(withoutBottomSafeInset: withoutBottomSafeInset) } .disposed(by: self.disposeBag) + + SimpleReachability.shared.isConnected + .skip(1) + .filter { $0 == false } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, _ in + guard object.isViewLoaded, object.view.window != nil else { return } + + var wrapper: SwiftEntryKitViewWrapper = object.instabilityNetworkToastView.sek + wrapper.entryName = Text.bottomToastEntryName + wrapper.showBottomToast(verticalOffset: object.bottomToastMessageOffset, displayDuration: 4) + } + .disposed(by: self.disposeBag) } /// Set auto layouts diff --git a/SOOUM/SOOUM/Data/Managers/AuthManager/AuthManager.swift b/SOOUM/SOOUM/Data/Managers/AuthManager/AuthManager.swift index d6cd77c1..fdc6f4fd 100644 --- a/SOOUM/SOOUM/Data/Managers/AuthManager/AuthManager.swift +++ b/SOOUM/SOOUM/Data/Managers/AuthManager/AuthManager.swift @@ -164,6 +164,7 @@ extension AuthManager: AuthManagerDelegate { provider.networkManager.registerFCMToken(from: #function) return .just(true) } + .catchAndReturn(false) } else { return .just(false) } @@ -193,6 +194,7 @@ extension AuthManager: AuthManagerDelegate { provider.networkManager.registerFCMToken(from: #function) return .just(true) } + .catchAndReturn(false) } else { return .just(false) } @@ -265,15 +267,21 @@ extension AuthManager: AuthManagerDelegate { }, onError: { object, error in - let errorCode = (error as NSError).code - if case 403 = errorCode { - - object.certification() - .subscribe(onNext: { isRegistered in - object.excutePendingResults(isRegistered ? .success : .failure(error)) - }) - .disposed(by: object.disposeBag) - } + // TODO: 임시, 리프레쉬 토큰 만료 에러코드가 정의되지 않음 + // let errorCode = (error as NSError).code + // if case 403 = errorCode { + // + // object.certification() + // .subscribe(onNext: { isRegistered in + // object.excutePendingResults(isRegistered ? .success : .failure(error)) + // }) + // .disposed(by: object.disposeBag) + // } + object.certification() + .subscribe(onNext: { isRegistered in + object.excutePendingResults(isRegistered ? .success : .failure(error)) + }) + .disposed(by: object.disposeBag) } ) .disposed(by: self.disposeBag) diff --git a/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/ErrorInterceptor.swift b/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/ErrorInterceptor.swift index 9921a230..be415dd8 100644 --- a/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/ErrorInterceptor.swift +++ b/SOOUM/SOOUM/Data/Managers/NetworkManager/Interceptor/ErrorInterceptor.swift @@ -5,13 +5,31 @@ // Created by 오현식 on 10/27/24. // -import Foundation - import Alamofire - class ErrorInterceptor: RequestInterceptor { + enum Text { + static let networkErrorDialogTitle: String = "네트워크 상태가 불안정해요" + static let networkErrorDialogMessage: String = "네트워크 연결상태를 확인 후 다시 시도해 주세요." + static let confirmActionTitle: String = "확인" + + static let unknownErrorDialogTitle: String = "일시적인 오류가 발생했어요" + static let unknownErrorDialogMessage: String = "같은 문제가 반복된다면 ‘문의하기'를 눌러 숨 팀에 알려주세요." + static let closeActionButtonTitle: String = "닫기" + static let inquiryActionTitle: String = "문의하기" + + static let adminMailStrUrl: String = "sooum1004@gmail.com" + static let identificationInfo: String = "식별 정보: " + static let inquiryMailTitle: String = "[문의하기]" + static let inquiryMailGuideMessage: String = """ + \n + 문의 내용: 식별 정보 삭제에 주의하여 주시고, 이곳에 자유롭게 문의하실 내용을 적어주세요. + 단, 본 양식에 비방, 욕설, 허위 사실 유포 등의 부적절한 내용이 포함될 경우, + 관련 법령에 따라 민·형사상 법적 조치가 이루어질 수 있음을 알려드립니다. + """ + } + private let lock = NSLock() private let retryLimit: Int = 1 @@ -25,29 +43,150 @@ class ErrorInterceptor: RequestInterceptor { func retry(_ request: Request, for session: Session, dueTo error: any Error, completion: @escaping (RetryResult) -> Void) { self.lock.lock(); defer { self.lock.unlock() } - guard let response = request.task?.response as? HTTPURLResponse, - response.statusCode == 401 - else { - completion(.doNotRetryWithError(error)) - return + /// API 호출 중 네트워크 오류 발생 + if let afError = error.asAFError, + case let .sessionTaskFailed(underlyingError) = afError, + let urlError = underlyingError as? URLError { + + let networkErrors = [ + URLError.timedOut, + URLError.notConnectedToInternet, + URLError.networkConnectionLost, + URLError.cannotConnectToHost + ] + if networkErrors.contains(urlError.code) { + self.showNetworkErrorDialog() + completion(.doNotRetry) + return + } } - // 재인증 과정은 1번만 진행한다. - guard request.retryCount < retryLimit else { + guard let response = request.task?.response as? HTTPURLResponse else { completion(.doNotRetryWithError(error)) return } - let token = self.provider.authManager.authInfo.token - self.provider.authManager.reAuthenticate(token) { result in + switch response.statusCode { + /// AccessToken 재인증 + case 401: + // 재인증 과정은 1번만 진행한다. + guard request.retryCount < self.retryLimit else { + let retryError = NSError( + domain: "SOOUM", + code: -99, + userInfo: [ + NSLocalizedDescriptionKey: "Retry error: ReAuthenticate process is performed only once." + ] + ) + completion(.doNotRetryWithError(retryError)) + return + } - switch result { - case .success: - completion(.retry) - case let .failure(error): - Log.error("ReAuthenticate failed. \(error.localizedDescription)") - completion(.doNotRetry) + let token = self.provider.authManager.authInfo.token + self.provider.authManager.reAuthenticate(token) { result in + + switch result { + case .success: + completion(.retry) + case let .failure(error): + Log.error("ReAuthenticate failed. \(error.localizedDescription)") + completion(.doNotRetry) + } } + return + case 403: + self.goToOnboarding() + completion(.doNotRetry) + return + case 500: + self.showUnknownErrorDialog() + completion(.doNotRetry) + return + default: + break + } + + completion(.doNotRetryWithError(error)) + } + + + // MARK: Error handling + + func goToOnboarding() { + + self.provider.authManager.initializeAuthInfo() + + DispatchQueue.main.async { + if let window: UIWindow = UIApplication.currentWindow, + let appDelegate = UIApplication.shared.delegate as? AppDelegate { + + let onboardingViewController = OnboardingViewController() + onboardingViewController.reactor = OnboardingViewReactor(dependencies: appDelegate.appDIContainer) + window.rootViewController = UINavigationController(rootViewController: onboardingViewController) + } + } + } + + func showNetworkErrorDialog() { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + UIApplication.topViewController?.dismiss(animated: true) + } + ) + + DispatchQueue.main.async { + SOMDialogViewController.show( + title: Text.networkErrorDialogTitle, + message: Text.networkErrorDialogMessage, + textAlignment: .left, + actions: [confirmAction] + ) + } + } + + func showUnknownErrorDialog() { + + let closeAction = SOMDialogAction( + title: Text.closeActionButtonTitle, + style: .gray, + action: { + UIApplication.topViewController?.dismiss(animated: true) + } + ) + + let inquireAction = SOMDialogAction( + title: Text.inquiryActionTitle, + style: .primary, + action: { + UIApplication.topViewController?.dismiss(animated: true) { + let subject = Text.inquiryMailTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let guideMessage = """ + \(Text.identificationInfo) + \(self.provider.authManager.authInfo.token.refreshToken)\n + \(Text.inquiryMailGuideMessage) + """ + let body = guideMessage.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let mailToString = "mailto:\(Text.adminMailStrUrl)?subject=\(subject)&body=\(body)" + + if let mailtoUrl = URL(string: mailToString), + UIApplication.shared.canOpenURL(mailtoUrl) { + + UIApplication.shared.open(mailtoUrl, options: [:], completionHandler: nil) + } + } + } + ) + + DispatchQueue.main.async { + SOMDialogViewController.show( + title: Text.unknownErrorDialogTitle, + message: Text.unknownErrorDialogMessage, + textAlignment: .left, + actions: [closeAction, inquireAction] + ) } } } diff --git a/SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManager.swift b/SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManager.swift index 03c0bce1..58ce8009 100644 --- a/SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManager.swift +++ b/SOOUM/SOOUM/Data/Managers/NetworkManager/NetworkManager.swift @@ -18,7 +18,7 @@ protocol NetworkManagerDelegate: AnyObject { func upload( _ data: Data, to url: URLConvertible - ) -> Observable> + ) -> Observable> func fetch(_ object: T.Type, request: BaseRequest) -> Observable func perform(_ request: BaseRequest) -> Observable @@ -139,16 +139,23 @@ extension NetworkManager: NetworkManagerDelegate { func upload( _ data: Data, to url: URLConvertible - ) -> Observable> { + ) -> Observable> { return Observable.create { [weak self] observer -> Disposable in let task = self?.session.upload(data, to: url, method: .put) .validate(statusCode: 200..<500) .response { response in + let statusCode = response.response?.statusCode ?? 0 + switch response.result { case .success: - observer.onNext(.success(())) - observer.onCompleted() + if let nsError = self?.setupError(with: statusCode) { + Log.error(nsError.localizedDescription) + observer.onError(nsError) + } else { + observer.onNext(.success(statusCode)) + observer.onCompleted() + } case let .failure(error): Log.error("Network or response format error: \(error)") observer.onError(error) diff --git a/SOOUM/SOOUM/Data/Models/Responses/BlockUsersInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/BlockUsersInfoResponse.swift new file mode 100644 index 00000000..232c6abd --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/BlockUsersInfoResponse.swift @@ -0,0 +1,32 @@ +// +// BlockUsersInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Alamofire + +struct BlockUsersInfoResponse { + + let blockUsers: [BlockUserInfo] +} + +extension BlockUsersInfoResponse: EmptyResponse { + + static func emptyValue() -> BlockUsersInfoResponse { + BlockUsersInfoResponse(blockUsers: []) + } +} + +extension BlockUsersInfoResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case blockUsers + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.blockUsers = try singleContainer.decode([BlockUserInfo].self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/FollowInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/FollowInfoResponse.swift new file mode 100644 index 00000000..37462924 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/FollowInfoResponse.swift @@ -0,0 +1,32 @@ +// +// FollowInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import Alamofire + +struct FollowInfoResponse { + + let followInfos: [FollowInfo] +} + +extension FollowInfoResponse: EmptyResponse { + + static func emptyValue() -> FollowInfoResponse { + FollowInfoResponse(followInfos: []) + } +} + +extension FollowInfoResponse: Decodable { + + enum CodingKeys: CodingKey { + case followInfos + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.followInfos = try singleContainer.decode([FollowInfo].self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/FollowNotificationInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/FollowNotificationInfoResponse.swift index b429f8ec..bc65da64 100644 --- a/SOOUM/SOOUM/Data/Models/Responses/FollowNotificationInfoResponse.swift +++ b/SOOUM/SOOUM/Data/Models/Responses/FollowNotificationInfoResponse.swift @@ -27,9 +27,9 @@ extension FollowNotificationInfoResponse: EmptyResponse { extension FollowNotificationInfoResponse: Decodable { - enum CodingKeys: CodingKey { + enum CodingKeys: String, CodingKey { case notificationInfo - case nickname + case nickname = "nickName" case userId } diff --git a/SOOUM/SOOUM/Data/Models/Responses/ProfileCardInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/ProfileCardInfoResponse.swift new file mode 100644 index 00000000..44d3edff --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/ProfileCardInfoResponse.swift @@ -0,0 +1,32 @@ +// +// ProfileCardInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import Alamofire + +struct ProfileCardInfoResponse { + + let cardInfos: [ProfileCardInfo] +} + +extension ProfileCardInfoResponse: EmptyResponse { + + static func emptyValue() -> ProfileCardInfoResponse { + ProfileCardInfoResponse(cardInfos: []) + } +} + +extension ProfileCardInfoResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case cardInfos = "cardContents" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.cardInfos = try container.decode([ProfileCardInfo].self, forKey: .cardInfos) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/ProfileInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/ProfileInfoResponse.swift new file mode 100644 index 00000000..5c9a403c --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/ProfileInfoResponse.swift @@ -0,0 +1,32 @@ +// +// ProfileInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import Alamofire + +struct ProfileInfoResponse { + + let profileInfo: ProfileInfo +} + +extension ProfileInfoResponse: EmptyResponse { + + static func emptyValue() -> ProfileInfoResponse { + ProfileInfoResponse(profileInfo: ProfileInfo.defaultValue) + } +} + +extension ProfileInfoResponse: Decodable { + + enum CodingKeys: CodingKey { + case profileInfo + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.profileInfo = try singleContainer.decode(ProfileInfo.self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/RejoinableDateInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/RejoinableDateInfoResponse.swift new file mode 100644 index 00000000..0985897f --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/RejoinableDateInfoResponse.swift @@ -0,0 +1,32 @@ +// +// RejoinableDateInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/13/25. +// + +import Alamofire + +struct RejoinableDateInfoResponse { + + let rejoinableDate: RejoinableDateInfo +} + +extension RejoinableDateInfoResponse: EmptyResponse { + + static func emptyValue() -> RejoinableDateInfoResponse { + RejoinableDateInfoResponse(rejoinableDate: RejoinableDateInfo.defaultValue) + } +} + +extension RejoinableDateInfoResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case rejoinableDate + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.rejoinableDate = try singleContainer.decode(RejoinableDateInfo.self) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/TagNofificationInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/TagNofificationInfoResponse.swift new file mode 100644 index 00000000..363220da --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/TagNofificationInfoResponse.swift @@ -0,0 +1,44 @@ +// +// TagNofificationInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/13/25. +// + +import Alamofire + +struct TagNofificationInfoResponse: Hashable, Equatable { + + let notificationInfo: CommonNotificationInfo + let targetCardId: String + let tagContent: String +} + +extension TagNofificationInfoResponse: EmptyResponse { + + static func emptyValue() -> TagNofificationInfoResponse { + TagNofificationInfoResponse( + notificationInfo: CommonNotificationInfo.defaultValue, + targetCardId: "", + tagContent: "" + ) + } +} + +extension TagNofificationInfoResponse: Decodable { + + enum CodingKeys: CodingKey { + case notificationInfo + case targetCardId + case tagContent + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.notificationInfo = try singleContainer.decode(CommonNotificationInfo.self) + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.targetCardId = String(try container.decode(Int64.self, forKey: .targetCardId)) + self.tagContent = try container.decode(String.self, forKey: .tagContent) + } +} diff --git a/SOOUM/SOOUM/Data/Models/Responses/TransferCodeInfoResponse.swift b/SOOUM/SOOUM/Data/Models/Responses/TransferCodeInfoResponse.swift new file mode 100644 index 00000000..8954fa03 --- /dev/null +++ b/SOOUM/SOOUM/Data/Models/Responses/TransferCodeInfoResponse.swift @@ -0,0 +1,32 @@ +// +// TransferCodeInfoResponse.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Alamofire + +struct TransferCodeInfoResponse { + + let transferInfo: TransferCodeInfo +} + +extension TransferCodeInfoResponse: EmptyResponse { + + static func emptyValue() -> TransferCodeInfoResponse { + TransferCodeInfoResponse(transferInfo: TransferCodeInfo.defaultValue) + } +} + +extension TransferCodeInfoResponse: Decodable { + + enum CodingKeys: String, CodingKey { + case transferInfo + } + + init(from decoder: any Decoder) throws { + let singleContainer = try decoder.singleValueContainer() + self.transferInfo = try singleContainer.decode(TransferCodeInfo.self) + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/AppVersionRepositoryImpl.swift b/SOOUM/SOOUM/Data/Repositories/AppVersionRepositoryImpl.swift index 683f5740..dd8576b2 100644 --- a/SOOUM/SOOUM/Data/Repositories/AppVersionRepositoryImpl.swift +++ b/SOOUM/SOOUM/Data/Repositories/AppVersionRepositoryImpl.swift @@ -21,9 +21,4 @@ class AppVersionRepositoryImpl: AppVersionRepository { return self.remoteDataSource.version() } - - func oldVersion() -> Observable { - - return self.remoteDataSource.oldVersion() - } } diff --git a/SOOUM/SOOUM/Data/Repositories/AuthRepositoryImpl.swift b/SOOUM/SOOUM/Data/Repositories/AuthRepositoryImpl.swift index 415c5d6b..fd4540a5 100644 --- a/SOOUM/SOOUM/Data/Repositories/AuthRepositoryImpl.swift +++ b/SOOUM/SOOUM/Data/Repositories/AuthRepositoryImpl.swift @@ -43,4 +43,9 @@ class AuthRepositoryImpl: AuthRepository { self.localDataSource.tokens() } + + func withdraw(reaseon: String) -> Observable { + + self.remoteDataSource.withdraw(reaseon: reaseon) + } } diff --git a/SOOUM/SOOUM/Data/Repositories/CardRepositoryImpl.swift b/SOOUM/SOOUM/Data/Repositories/CardRepositoryImpl.swift index 3acb017f..148de64d 100644 --- a/SOOUM/SOOUM/Data/Repositories/CardRepositoryImpl.swift +++ b/SOOUM/SOOUM/Data/Repositories/CardRepositoryImpl.swift @@ -58,11 +58,6 @@ class CardRepositoryImpl: CardRepository { return self.remoteDataSource.updateLike(id: id, isLike: isLike) } - func updateBlocked(id: String, isBlocked: Bool) -> Observable { - - return self.remoteDataSource.updateBlocked(id: id, isBlocked: isBlocked) - } - func reportCard(id: String, reportType: String) -> Observable { return self.remoteDataSource.reportCard(id: id, reportType: reportType) @@ -81,7 +76,7 @@ class CardRepositoryImpl: CardRepository { return self.remoteDataSource.presignedURL() } - func uploadImage(_ data: Data, with url: URL) -> Observable> { + func uploadImage(_ data: Data, with url: URL) -> Observable> { return self.remoteDataSource.uploadImage(data, with: url) } diff --git a/SOOUM/SOOUM/Data/Repositories/NotificationRepositoryImpl.swift b/SOOUM/SOOUM/Data/Repositories/NotificationRepositoryImpl.swift index a4c37e10..190e1086 100644 --- a/SOOUM/SOOUM/Data/Repositories/NotificationRepositoryImpl.swift +++ b/SOOUM/SOOUM/Data/Repositories/NotificationRepositoryImpl.swift @@ -32,8 +32,8 @@ class NotificationRepositoryImpl: NotificationRepository { return self.remoteDataSource.requestRead(notificationId: notificationId) } - func notices(lastId: String?, size: Int?) -> Observable { + func notices(lastId: String?, size: Int?, requestType: NotificationRequest.RequestType) -> Observable { - return self.remoteDataSource.notices(lastId: lastId, size: size) + return self.remoteDataSource.notices(lastId: lastId, size: size, requestType: requestType) } } diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/AppVersionRemoteDataSourceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/AppVersionRemoteDataSourceImpl.swift index 355cc68d..88674029 100644 --- a/SOOUM/SOOUM/Data/Repositories/Remotes/AppVersionRemoteDataSourceImpl.swift +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/AppVersionRemoteDataSourceImpl.swift @@ -21,9 +21,4 @@ class AppVersionRemoteDataSourceImpl: AppVersionRemoteDataSource { return self.provider.networkManager.updateCheck() } - - func oldVersion() -> Observable { - - return self.provider.networkManager.fetch(String.self, request: VersionRequest.version) - } } diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/AuthRemoteDataSourceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/AuthRemoteDataSourceImpl.swift index 87de69e8..c274ec1f 100644 --- a/SOOUM/SOOUM/Data/Repositories/Remotes/AuthRemoteDataSourceImpl.swift +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/AuthRemoteDataSourceImpl.swift @@ -26,4 +26,11 @@ class AuthRemoteDataSourceImpl: AuthRemoteDataSource { return self.provider.authManager.certification() } + + func withdraw(reaseon: String) -> Observable { + + let token = self.provider.authManager.authInfo.token + let request: AuthRequest = .withdraw(token: token, reason: reaseon) + return self.provider.networkManager.perform(request) + } } diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/CardRemoteDataSourceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/CardRemoteDataSourceImpl.swift index a8f96462..dff00532 100644 --- a/SOOUM/SOOUM/Data/Repositories/Remotes/CardRemoteDataSourceImpl.swift +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/CardRemoteDataSourceImpl.swift @@ -65,12 +65,6 @@ class CardRemoteDataSourceImpl: CardRemoteDataSource { return self.provider.networkManager.perform(request) } - func updateBlocked(id: String, isBlocked: Bool) -> Observable { - - let request: CardRequest = .updateBlocked(id: id, isBlocked: isBlocked) - return self.provider.networkManager.perform(request) - } - func reportCard(id: String, reportType: String) -> Observable { let request: CardRequest = .reportCard(id: id, reportType: reportType) @@ -92,7 +86,7 @@ class CardRemoteDataSourceImpl: CardRemoteDataSource { return self.provider.networkManager.fetch(ImageUrlInfoResponse.self, request: request) } - func uploadImage(_ data: Data, with url: URL) -> Observable> { + func uploadImage(_ data: Data, with url: URL) -> Observable> { return self.provider.networkManager.upload(data, to: url) } diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/AppVersionRemoteDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/AppVersionRemoteDataSource.swift index 2b41c017..73966508 100644 --- a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/AppVersionRemoteDataSource.swift +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/AppVersionRemoteDataSource.swift @@ -12,5 +12,4 @@ import RxSwift protocol AppVersionRemoteDataSource { func version() -> Observable - func oldVersion() -> Observable } diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/AuthRemoteDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/AuthRemoteDataSource.swift index 32cbd775..01f9aebd 100644 --- a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/AuthRemoteDataSource.swift +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/AuthRemoteDataSource.swift @@ -13,4 +13,5 @@ protocol AuthRemoteDataSource { func signUp(nickname: String, profileImageName: String?) -> Observable func login() -> Observable + func withdraw(reaseon: String) -> Observable } diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/CardRemoteDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/CardRemoteDataSource.swift index 5a76a74b..7a273ff6 100644 --- a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/CardRemoteDataSource.swift +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/CardRemoteDataSource.swift @@ -25,7 +25,6 @@ protocol CardRemoteDataSource { func commentCard(id: String, lastId: String?, latitude: String?, longitude: String?) -> Observable func deleteCard(id: String) -> Observable func updateLike(id: String, isLike: Bool) -> Observable - func updateBlocked(id: String, isBlocked: Bool) -> Observable func reportCard(id: String, reportType: String) -> Observable @@ -33,7 +32,7 @@ protocol CardRemoteDataSource { func defaultImages() -> Observable func presignedURL() -> Observable - func uploadImage(_ data: Data, with url: URL) -> Observable> + func uploadImage(_ data: Data, with url: URL) -> Observable> func writeCard( isDistanceShared: Bool, latitude: String?, diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/NotificationRemoteDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/NotificationRemoteDataSource.swift index c2718807..e0a8e627 100644 --- a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/NotificationRemoteDataSource.swift +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/NotificationRemoteDataSource.swift @@ -14,5 +14,5 @@ protocol NotificationRemoteDataSource { func unreadNotifications(lastId: String?) -> Observable func readNotifications(lastId: String?) -> Observable func requestRead(notificationId: String) -> Observable - func notices(lastId: String?, size: Int?) -> Observable + func notices(lastId: String?, size: Int?, requestType: NotificationRequest.RequestType) -> Observable } diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/SettingsRemoteDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/SettingsRemoteDataSource.swift new file mode 100644 index 00000000..86e26cba --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/SettingsRemoteDataSource.swift @@ -0,0 +1,19 @@ +// +// SettingsRemoteDataSource.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +import RxSwift + +protocol SettingsRemoteDataSource { + + func rejoinableDate() -> Observable + func issue() -> Observable + func enter(code: String, encryptedDeviceId: String) -> Observable + func update() -> Observable + func blockUsers(lastId: String?) -> Observable +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/UserRemoteDataSource.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/UserRemoteDataSource.swift index 8f297187..cd6165a3 100644 --- a/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/UserRemoteDataSource.swift +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/Interfaces/UserRemoteDataSource.swift @@ -16,8 +16,17 @@ protocol UserRemoteDataSource { func validateNickname(nickname: String) -> Observable func updateNickname(nickname: String) -> Observable func presignedURL() -> Observable - func uploadImage(_ data: Data, with url: URL) -> Observable> + func uploadImage(_ data: Data, with url: URL) -> Observable> func updateImage(imageName: String) -> Observable func updateFCMToken(fcmToken: String) -> Observable func postingPermission() -> Observable + func profile(userId: String?) -> Observable + func updateMyProfile(nickname: String?, imageName: String?) -> Observable + func feedCards(userId: String, lastId: String?) -> Observable + func myCommentCards(lastId: String?) -> Observable + func followers(userId: String, lastId: String?) -> Observable + func followings(userId: String, lastId: String?) -> Observable + func updateFollowing(userId: String, isFollow: Bool) -> Observable + func updateBlocked(id: String, isBlocked: Bool) -> Observable + func updateNotify(isAllowNotify: Bool) -> Observable } diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/NotificationRemoteDataSoruceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/NotificationRemoteDataSoruceImpl.swift index aca942e8..051f5be5 100644 --- a/SOOUM/SOOUM/Data/Repositories/Remotes/NotificationRemoteDataSoruceImpl.swift +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/NotificationRemoteDataSoruceImpl.swift @@ -35,9 +35,9 @@ class NotificationRemoteDataSoruceImpl: NotificationRemoteDataSource { return self.provider.networkManager.perform(request) } - func notices(lastId: String?, size: Int?) -> Observable { + func notices(lastId: String?, size: Int?, requestType: NotificationRequest.RequestType) -> Observable { - let request: NotificationRequest = .notices(lastId: lastId, size: size) + let request: NotificationRequest = .notices(lastId: lastId, size: size, requestType: requestType) return self.provider.networkManager.fetch(NoticeInfoResponse.self, request: request) } } diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/SettingsRemoteDataSourceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/SettingsRemoteDataSourceImpl.swift new file mode 100644 index 00000000..f144e56f --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/SettingsRemoteDataSourceImpl.swift @@ -0,0 +1,49 @@ +// +// SettingsRemoteDataSourceImpl.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +import RxSwift + +class SettingsRemoteDataSourceImpl: SettingsRemoteDataSource { + + private let provider: ManagerProviderType + + init(provider: ManagerProviderType) { + self.provider = provider + } + + func rejoinableDate() -> Observable { + + let request: SettingsRequest = .rejoinableDate + return self.provider.networkManager.fetch(RejoinableDateInfoResponse.self, request: request) + } + + func issue() -> Observable { + + let request: SettingsRequest = .transferIssue + return self.provider.networkManager.fetch(TransferCodeInfoResponse.self, request: request) + } + + func enter(code: String, encryptedDeviceId: String) -> Observable { + + let request: SettingsRequest = .transferEnter(code: code, encryptedDeviceId: encryptedDeviceId) + return self.provider.networkManager.perform(request) + } + + func update() -> Observable { + + let request: SettingsRequest = .transferUpdate + return self.provider.networkManager.perform(TransferCodeInfoResponse.self, request: request) + } + + func blockUsers(lastId: String?) -> Observable { + + let request: SettingsRequest = .blockUsers(lastId: lastId) + return self.provider.networkManager.fetch(BlockUsersInfoResponse.self, request: request) + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/Remotes/UserRemoteDataSourceImpl.swift b/SOOUM/SOOUM/Data/Repositories/Remotes/UserRemoteDataSourceImpl.swift index b2906a13..8e26317e 100644 --- a/SOOUM/SOOUM/Data/Repositories/Remotes/UserRemoteDataSourceImpl.swift +++ b/SOOUM/SOOUM/Data/Repositories/Remotes/UserRemoteDataSourceImpl.swift @@ -37,7 +37,7 @@ class UserRemoteDataSourceImpl: UserRemoteDataSource { func updateNickname(nickname: String) -> Observable { let request: UserRequest = .updateNickname(nickname: nickname) - return self.provider.networkManager.perform(Int.self, request: request) + return self.provider.networkManager.perform(request) } func presignedURL() -> Observable { @@ -46,7 +46,7 @@ class UserRemoteDataSourceImpl: UserRemoteDataSource { return self.provider.networkManager.fetch(ImageUrlInfoResponse.self, request: request) } - func uploadImage(_ data: Data, with url: URL) -> Observable> { + func uploadImage(_ data: Data, with url: URL) -> Observable> { return self.provider.networkManager.upload(data, to: url) } @@ -54,13 +54,13 @@ class UserRemoteDataSourceImpl: UserRemoteDataSource { func updateImage(imageName: String) -> Observable { let request: UserRequest = .updateImage(imageName: imageName) - return self.provider.networkManager.perform(Int.self, request: request) + return self.provider.networkManager.perform(request) } func updateFCMToken(fcmToken: String) -> Observable { let request: UserRequest = .updateFCMToken(fcmToken: fcmToken) - return self.provider.networkManager.perform(Int.self, request: request) + return self.provider.networkManager.perform(request) } func postingPermission() -> Observable { @@ -68,4 +68,58 @@ class UserRemoteDataSourceImpl: UserRemoteDataSource { let request: UserRequest = .postingPermission return self.provider.networkManager.fetch(PostingPermissionResponse.self, request: request) } + + func profile(userId: String?) -> Observable { + + let request: UserRequest = .profile(userId: userId) + return self.provider.networkManager.fetch(ProfileInfoResponse.self, request: request) + } + + func updateMyProfile(nickname: String?, imageName: String?) -> Observable { + + let request: UserRequest = .updateMyProfile(nickname: nickname, imageName: imageName) + return self.provider.networkManager.perform(request) + } + + func feedCards(userId: String, lastId: String?) -> Observable { + + let request: UserRequest = .feedCards(userId: userId, lastId: lastId) + return self.provider.networkManager.fetch(ProfileCardInfoResponse.self, request: request) + } + + func myCommentCards(lastId: String?) -> Observable { + + let request: UserRequest = .myCommentCards(lastId: lastId) + return self.provider.networkManager.fetch(ProfileCardInfoResponse.self, request: request) + } + + func followers(userId: String, lastId: String?) -> Observable { + + let request: UserRequest = .followers(userId: userId, lastId: lastId) + return self.provider.networkManager.fetch(FollowInfoResponse.self, request: request) + } + + func followings(userId: String, lastId: String?) -> Observable { + + let request: UserRequest = .followings(userId: userId, lastId: lastId) + return self.provider.networkManager.fetch(FollowInfoResponse.self, request: request) + } + + func updateFollowing(userId: String, isFollow: Bool) -> Observable { + + let request: UserRequest = .updateFollowing(userId: userId, isFollow: isFollow) + return self.provider.networkManager.perform(request) + } + + func updateBlocked(id: String, isBlocked: Bool) -> Observable { + + let request: UserRequest = .updateBlocked(id: id, isBlocked: isBlocked) + return self.provider.networkManager.perform(request) + } + + func updateNotify(isAllowNotify: Bool) -> Observable { + + let request: UserRequest = .updateNotify(isAllowNotify: isAllowNotify) + return self.provider.networkManager.perform(request) + } } diff --git a/SOOUM/SOOUM/Data/Repositories/SettingsRepositoryImpl.swift b/SOOUM/SOOUM/Data/Repositories/SettingsRepositoryImpl.swift new file mode 100644 index 00000000..9660012a --- /dev/null +++ b/SOOUM/SOOUM/Data/Repositories/SettingsRepositoryImpl.swift @@ -0,0 +1,44 @@ +// +// SettingsRepositoryImpl.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +import RxSwift + +class SettingsRepositoryImpl: SettingsRepository { + + private let remoteDataSource: SettingsRemoteDataSource + + init(remoteDataSource: SettingsRemoteDataSource) { + self.remoteDataSource = remoteDataSource + } + + func rejoinableDate() -> Observable { + + return self.remoteDataSource.rejoinableDate() + } + + func issue() -> Observable { + + return self.remoteDataSource.issue() + } + + func enter(code: String, encryptedDeviceId: String) -> Observable { + + return self.remoteDataSource.enter(code: code, encryptedDeviceId: encryptedDeviceId) + } + + func update() -> Observable { + + return self.remoteDataSource.update() + } + + func blockUsers(lastId: String?) -> Observable { + + return self.remoteDataSource.blockUsers(lastId: lastId) + } +} diff --git a/SOOUM/SOOUM/Data/Repositories/UserRepositoryImpl.swift b/SOOUM/SOOUM/Data/Repositories/UserRepositoryImpl.swift index 6c1b59e2..793ef7ee 100644 --- a/SOOUM/SOOUM/Data/Repositories/UserRepositoryImpl.swift +++ b/SOOUM/SOOUM/Data/Repositories/UserRepositoryImpl.swift @@ -42,7 +42,7 @@ class UserRepositoryImpl: UserRepository { return self.remoteDataSource.presignedURL() } - func uploadImage(_ data: Data, with url: URL) -> Observable> { + func uploadImage(_ data: Data, with url: URL) -> Observable> { return self.remoteDataSource.uploadImage(data, with: url) } @@ -61,4 +61,49 @@ class UserRepositoryImpl: UserRepository { return self.remoteDataSource.postingPermission() } + + func profile(userId: String?) -> Observable { + + return self.remoteDataSource.profile(userId: userId) + } + + func updateMyProfile(nickname: String?, imageName: String?) -> Observable { + + return self.remoteDataSource.updateMyProfile(nickname: nickname, imageName: imageName) + } + + func feedCards(userId: String, lastId: String?) -> Observable { + + return self.remoteDataSource.feedCards(userId: userId, lastId: lastId) + } + + func myCommentCards(lastId: String?) -> Observable { + + return self.remoteDataSource.myCommentCards(lastId: lastId) + } + + func followers(userId: String, lastId: String?) -> Observable { + + return self.remoteDataSource.followers(userId: userId, lastId: lastId) + } + + func followings(userId: String, lastId: String?) -> Observable { + + return self.remoteDataSource.followings(userId: userId, lastId: lastId) + } + + func updateFollowing(userId: String, isFollow: Bool) -> Observable { + + return self.remoteDataSource.updateFollowing(userId: userId, isFollow: isFollow) + } + + func updateBlocked(id: String, isBlocked: Bool) -> Observable { + + return self.remoteDataSource.updateBlocked(id: id, isBlocked: isBlocked) + } + + func updateNotify(isAllowNotify: Bool) -> Observable { + + return self.remoteDataSource.updateNotify(isAllowNotify: isAllowNotify) + } } diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMBottomToastView.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMBottomToastView.swift index f9de570a..0575fecc 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMBottomToastView.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMBottomToastView.swift @@ -32,7 +32,7 @@ class SOMBottomToastView: UIView { // MARK: Initalization - convenience init(title: String, actions: [ToastAction]) { + convenience init(title: String, actions: [ToastAction]?) { self.init(frame: .zero) self.actions = actions @@ -61,13 +61,12 @@ private extension SOMBottomToastView { self.addSubview(self.container) self.container.snp.makeConstraints { - $0.centerY.equalToSuperview() + $0.centerY.trailing.equalToSuperview() $0.leading.equalToSuperview().offset(12) - $0.trailing.equalToSuperview() } } - func setupActions(title: String, _ actions: [ToastAction]) { + func setupActions(title: String, _ actions: [ToastAction]?) { self.container.subviews.forEach { $0.removeFromSuperview() } @@ -79,6 +78,8 @@ private extension SOMBottomToastView { $0.centerY.leading.equalToSuperview() } + guard let actions = actions else { return } + actions.forEach { action in let button = SOMButton().then { diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMButton.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMButton.swift index c66e189e..8edf088d 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMButton.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMButton.swift @@ -111,6 +111,7 @@ private extension SOMButton { case .som.v2.black: return .som.v2.gray600 case .som.v2.gray100: return .som.v2.gray200 case .som.v2.white: return .som.v2.gray100 + case .som.v2.rMain: return .som.v2.rDark default: return .clear } } @@ -138,6 +139,7 @@ private extension SOMButton { case .som.v2.black: return .som.v2.gray600 case .som.v2.gray100: return .som.v2.gray200 case .som.v2.white: return .som.v2.gray100 + case .som.v2.rMain: return .som.v2.rDark default: return .clear } } diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMCard.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMCard.swift index e605b540..bee8bdbb 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMCard.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMCard.swift @@ -34,9 +34,9 @@ class SOMCard: UIView { /// 배경 이미지 private let rootContainerImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill $0.layer.cornerRadius = 16 $0.layer.borderWidth = 1 - $0.contentMode = .scaleAspectFill $0.layer.masksToBounds = true } @@ -437,7 +437,6 @@ class SOMCard: UIView { // cardPungTimeBackgroundView.isHidden = true } - // TODO: 카드 본문 배경 블러 뷰 높이 계산 함수, 헌재 사용 X private func updateContentHeight(_ text: String, with typography: Typography) { UIView.performWithoutAnimation { @@ -510,5 +509,6 @@ class SOMCard: UIView { .forEach { $0.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 50c88da6..76bfe96b 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogAction.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogAction.swift @@ -20,7 +20,7 @@ class SOMDialogAction { case .primary: return .som.v2.black case .red: - return .som.v2.rDark + return .som.v2.rMain case .gray: return .som.v2.gray100 } diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController+Show.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController+Show.swift index c285d268..9aaed8a9 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController+Show.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMDialogController/SOMDialogViewController+Show.swift @@ -10,6 +10,8 @@ import UIKit extension SOMDialogViewController { + private static weak var displayedDialogViewController: SOMDialogViewController? + @discardableResult static func show( title: String, @@ -28,6 +30,10 @@ extension SOMDialogViewController { textAlignment: textAlignment, completion: { alertController in window.windowScene = nil + /// Dismiss 된 alertController와 표시되었던 dialog가 같다면 제거 + if alertController == Self.displayedDialogViewController { + Self.displayedDialogViewController = nil + } completion?(alertController) } ) @@ -41,7 +47,7 @@ extension SOMDialogViewController { @discardableResult static func show( title: String, - messageView: UIView, + messageView: UIView?, textAlignment: NSTextAlignment = .center, actions: [SOMDialogAction], dismissesWhenBackgroundTouched: Bool = false, @@ -56,6 +62,10 @@ extension SOMDialogViewController { textAlignment: textAlignment, completion: { alertController in window.windowScene = nil + /// Dismiss 된 alertController와 표시되었던 dialog가 같다면 제거 + if alertController == Self.displayedDialogViewController { + Self.displayedDialogViewController = nil + } completion?(alertController) } ) @@ -80,6 +90,12 @@ extension SOMDialogViewController { } }() + /// 현재 표시된 dialog가 있다면 dismiss + if let displayedDialogViewController = Self.displayedDialogViewController { + displayedDialogViewController.dismiss(animated: false, completion: nil) + Self.displayedDialogViewController = nil + } + window.windowLevel = .alert window.backgroundColor = .clear window.rootViewController = rootViewController @@ -92,6 +108,11 @@ extension SOMDialogViewController { rootViewController.present(dialogViewController, animated: true, completion: nil) + /// 표시될 dialog 저장 + if let willDisplayDialogViewController = dialogViewController as? SOMDialogViewController { + self.displayedDialogViewController = willDisplayDialogViewController + } + return dialogViewController } } diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/Views/OnboardingNicknameTextFieldView.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMNicknameTextField.swift similarity index 88% rename from SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/Views/OnboardingNicknameTextFieldView.swift rename to SOOUM/SOOUM/DesignSystem/Components/SOMNicknameTextField.swift index 7a5fbd08..fbe2c392 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/Views/OnboardingNicknameTextFieldView.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMNicknameTextField.swift @@ -1,8 +1,8 @@ // -// OnboardingNicknameTextFieldView.swift +// SOMNicknameTextField.swift // SOOUM // -// Created by JDeoks on 11/7/24. +// Created by 오현식 on 11/8/25. // import UIKit @@ -10,8 +10,11 @@ import UIKit import SnapKit import Then - -class OnboardingNicknameTextFieldView: UIView { +class SOMNicknameTextField: UIView { + + enum Constants { + static let maxCharacters: Int = 8 + } // MARK: Views @@ -65,7 +68,7 @@ class OnboardingNicknameTextFieldView: UIView { } private lazy var clearButton = SOMButton().then { - $0.image = .init(.icon(.v2(.outlined(.delete)))) + $0.image = .init(.icon(.v2(.outlined(.delete_full)))) $0.foregroundColor = .som.v2.gray500 let gestureRecognizer = UITapGestureRecognizer( @@ -78,11 +81,10 @@ class OnboardingNicknameTextFieldView: UIView { // MARK: Variables - private let maxCharacter: Int = 8 - var text: String? { set { self.textField.text = newValue + self.textField.sendActions(for: .valueChanged) } get { return self.textField.text @@ -147,7 +149,7 @@ class OnboardingNicknameTextFieldView: UIView { @objc private func touch(sender: UIGestureRecognizer) { - if !self.textField.isFirstResponder { + if self.textField.isFirstResponder == false { self.textField.becomeFirstResponder() } } @@ -156,7 +158,10 @@ class OnboardingNicknameTextFieldView: UIView { private func clear() { self.clearButton.isHidden = true self.text = nil - self.textField.sendActions(for: .editingChanged) + self.textField.sendActions(for: .valueChanged) + if self.textField.isFirstResponder == false { + self.textField.becomeFirstResponder() + } } @@ -201,7 +206,7 @@ class OnboardingNicknameTextFieldView: UIView { } } -extension OnboardingNicknameTextFieldView: UITextFieldDelegate { +extension SOMNicknameTextField: UITextFieldDelegate { func textFieldDidBeginEditing(_ textField: UITextField) { self.clearButton.isHidden = self.isTextEmpty @@ -217,11 +222,13 @@ extension OnboardingNicknameTextFieldView: UITextFieldDelegate { replacementString string: String ) -> Bool { - let nsString: NSString? = textField.text as NSString? - let newString: String = nsString?.replacingCharacters(in: range, with: string) ?? "" + self.clearButton.isHidden = self.isTextEmpty - self.clearButton.isHidden = newString.isEmpty - return newString.count < self.maxCharacter + 1 + return textField.shouldChangeCharactersIn( + in: range, + replacementString: string, + maxCharacters: Constants.maxCharacters + ) } func textFieldShouldReturn(_ textField: UITextField) -> Bool { diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageView.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageView.swift index 171133cb..fb923644 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageView.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageView.swift @@ -45,6 +45,17 @@ class SOMPageView: UICollectionViewCell { } + // MARK: Override func + + override func prepareForReuse() { + super.prepareForReuse() + + self.iconView.image = nil + self.titleLabel.text = nil + self.messageLabel.text = nil + } + + // MARK: Private func private func setupConstraints() { diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageViews.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageViews.swift index 3d5f80cd..167a14a8 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageViews.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMPageViews/SOMPageViews.swift @@ -98,6 +98,9 @@ class SOMPageViews: UIView { private(set) var models: [SOMPageModel] = [] + private var scrollTimer: Timer? + private var hasLayoutsubviews: Bool = false + weak var delegate: SOMPageViewsDelegate? @@ -112,6 +115,10 @@ class SOMPageViews: UIView { fatalError("init(coder:) has not been implemented") } + deinit { + self.stopAutoScroll() + } + // MARK: Override func @@ -124,6 +131,20 @@ class SOMPageViews: UIView { blur: 16, offset: .init(width: 0, height: 6) ) + + self.hasLayoutsubviews = true + } + + override func didMoveToWindow() { + super.didMoveToWindow() + /// 화면에서 사라졌을 때, 타이머 중지 + if self.window == nil { + self.stopAutoScroll() + } + + if self.window != nil, self.hasLayoutsubviews { + self.startAutoScroll() + } } @@ -151,6 +172,27 @@ class SOMPageViews: UIView { } } + func startAutoScroll() { + + guard self.models.count > 1 else { return } + + self.stopAutoScroll() + + self.scrollTimer = Timer.scheduledTimer( + timeInterval: 5.0, + target: self, + selector: #selector(self.handelAutoScroll), + userInfo: nil, + repeats: true + ) + } + + func stopAutoScroll() { + + self.scrollTimer?.invalidate() + self.scrollTimer = nil + } + // MARK: Public func @@ -169,6 +211,8 @@ class SOMPageViews: UIView { } } + self.stopAutoScroll() + self.models = models var infiniteModels: [SOMPageModel] { @@ -199,8 +243,32 @@ class SOMPageViews: UIView { animated: false ) } + + self?.startAutoScroll() } } + + + // MARK: Objc func + + @objc + private func handelAutoScroll() { + + let cellWidth = self.collectionView.bounds.width + let currentIndex = Int(round( self.collectionView.contentOffset.x / cellWidth)) + + // 다음 인덱스 (무한 스크롤 배열을 기준으로) + let nextIndex = currentIndex + 1 + + let nextIndexPath = IndexPath(item: nextIndex, section: Section.main.rawValue) + + // 애니메이션과 함께 다음 아이템으로 스크롤 + self.collectionView.scrollToItem( + at: nextIndexPath, + at: .centeredHorizontally, + animated: true + ) + } } @@ -230,9 +298,20 @@ extension SOMPageViews: UICollectionViewDelegateFlowLayout { // MARK: UIScrollViewDelegate - + /// 사용자 인터랙션 시 타이머 중지 + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + + self.stopAutoScroll() + } + /// 사용자 인터랙션이 끝났을 때, 타이머 재시작 func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + self.infiniteScroll(scrollView) + self.startAutoScroll() + } + /// 자동 스크롤이 끝났을 때, 무한 스크롤 로직 수행 + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + self.infiniteScroll(scrollView) } diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMStickyTabBar/SOMStickyTabBar.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMStickyTabBar/SOMStickyTabBar.swift index 84853b87..a014b4cd 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMStickyTabBar/SOMStickyTabBar.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMStickyTabBar/SOMStickyTabBar.swift @@ -45,6 +45,8 @@ class SOMStickyTabBar: UIView { // MARK: Variables + private let itemAlignment: NSTextAlignment + var inset: UIEdgeInsets = .init(top: 0, left: 16, bottom: 0, right: 16) { didSet { self.refreshConstraints() @@ -67,13 +69,19 @@ class SOMStickyTabBar: UIView { // Set item width with text and typography var itemWidths: [CGFloat] { - let itemWidths: [CGFloat] = self.items.enumerated().map { index, item in - let typography: Typography = .som.v2.title2 - /// 실제 텍스트 가로 길이 - return (item as NSString).size(withAttributes: [.font: typography.font]).width + if self.itemAlignment == .left { + return self.items.enumerated().map { index, item in + let typography: Typography = .som.v2.title2 + /// 실제 텍스트 가로 길이 + return (item as NSString).size(withAttributes: [.font: typography.font]).width + } + } else { + let horizontalInset = self.inset.left + self.inset.right + let totalSpacing = self.spacing * CGFloat(self.items.count - 1) + // 항상 화면의 가로 크기를 꽉 채운다고 가정 + let itemWidth = (UIScreen.main.bounds.width - horizontalInset - totalSpacing) / CGFloat(self.items.count) + return Array(repeating: itemWidth, count: self.items.count) } - - return itemWidths } var itemFrames: [CGRect] { @@ -110,7 +118,8 @@ class SOMStickyTabBar: UIView { // MARK: Initialize - init() { + init(alignment: NSTextAlignment = .left) { + self.itemAlignment = alignment super.init(frame: .zero) self.setupConstraints() @@ -188,10 +197,15 @@ class SOMStickyTabBar: UIView { private func setTabBarItems(_ items: [String]) { + self.tabBarItemContainer.arrangedSubviews.forEach { $0.removeFromSuperview() } + items.enumerated().forEach { index, title in let item = SOMStickyTabBarItem(title: title) - item.updateState(color: index == 0 ? Constants.selectedColor : Constants.unSelectedColor) + item.updateState(color: index == self.selectedIndex ? Constants.selectedColor : Constants.unSelectedColor) + item.snp.makeConstraints { + $0.width.equalTo(self.itemWidths[index]) + } self.tabBarItemContainer.addArrangedSubview(item) } diff --git a/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarItem.swift b/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarItem.swift index 8dd5cb80..78bba187 100644 --- a/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarItem.swift +++ b/SOOUM/SOOUM/DesignSystem/Components/SOMTabBarController/SOMTabBarItem.swift @@ -73,6 +73,6 @@ class SOMTabBarItem: UIView { func tabBarItemNotSelected() { self.titleLabel.textColor = .som.v2.gray400 - self.imageView.tintColor = .som.v2.gray400 + self.imageView.tintColor = .som.v2.gray300 } } diff --git a/SOOUM/SOOUM/DesignSystem/Foundations/Typography+SOOUM.swift b/SOOUM/SOOUM/DesignSystem/Foundations/Typography+SOOUM.swift index e8cadf62..ca2eabad 100644 --- a/SOOUM/SOOUM/DesignSystem/Foundations/Typography+SOOUM.swift +++ b/SOOUM/SOOUM/DesignSystem/Foundations/Typography+SOOUM.swift @@ -271,6 +271,14 @@ extension V2Style where Base == Typography { lineHeight: 15, letterSpacing: -0.025 ) + /// Size: 5, Line height: 8 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var caption4: Typography = .init( + fontContainer: BuiltInFont(size: 5, weight: .medium), + lineHeight: 8, + letterSpacing: -0.025 + ) // RIDIBatang @@ -298,6 +306,14 @@ extension V2Style where Base == Typography { lineHeight: 17, letterSpacing: -0.025 ) + /// Size: 5, Line height: 8 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var ridiProfile: Typography = .init( + fontContainer: BuiltInFont(type: .ridi, size: 5, weight: .regular), + lineHeight: 8, + letterSpacing: -0.025 + ) // Yoonwoo /// Size: 20, Line height: 22 @@ -324,6 +340,14 @@ extension V2Style where Base == Typography { lineHeight: 18, letterSpacing: -0.025 ) + /// Size: 7, Line height: 8 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var yoonwooProfile: Typography = .init( + fontContainer: BuiltInFont(type: .yoonwoo, size: 7, weight: .regular), + lineHeight: 8, + letterSpacing: -0.025 + ) // Kkukkukk /// Size: 16, Line height: 22 @@ -350,4 +374,12 @@ extension V2Style where Base == Typography { lineHeight: 17, letterSpacing: -0.025 ) + /// Size: 5, Line height: 7 + /// + /// Weight: [Thin: 100, UltraLight: 200, Light: 300, Regular: 400, Medium: 500, SemiBold: 600, Bold: 700, Heavy: 800, Black: 900] + static var kkookkkookProfile: Typography = .init( + fontContainer: BuiltInFont(type: .kkookkkook, size: 5, weight: .regular), + lineHeight: 7, + letterSpacing: -0.025 + ) } diff --git a/SOOUM/SOOUM/DesignSystem/Foundations/UIImage+SOOUM.swift b/SOOUM/SOOUM/DesignSystem/Foundations/UIImage+SOOUM.swift index 661ac12d..47668991 100644 --- a/SOOUM/SOOUM/DesignSystem/Foundations/UIImage+SOOUM.swift +++ b/SOOUM/SOOUM/DesignSystem/Foundations/UIImage+SOOUM.swift @@ -148,6 +148,7 @@ extension UIImage.SOOUMType { case danger case headset case heart + case hide case home case image case info @@ -223,6 +224,7 @@ extension UIImage.SOOUMType { case placeholder_notification case prev_card_button case profile_large + case profile_medium case profile_small } diff --git a/SOOUM/SOOUM/Domain/Models/BlockUserInfo.swift b/SOOUM/SOOUM/Domain/Models/BlockUserInfo.swift new file mode 100644 index 00000000..649eb281 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/BlockUserInfo.swift @@ -0,0 +1,44 @@ +// +// BlockUserInfo.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +struct BlockUserInfo: Hashable { + + let id: String + let userId: String + let nickname: String + let profileImageUrl: String? +} + +extension BlockUserInfo { + + static var defaultValue: BlockUserInfo = BlockUserInfo( + id: "", + userId: "", + nickname: "", + profileImageUrl: nil + ) +} + +extension BlockUserInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case id = "blockId" + case userId = "blockMemberId" + case nickname = "blockMemberNickname" + case profileImageUrl = "blockMemberProfileImageUrl" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = String(try container.decode(Int64.self, forKey: .id)) + self.userId = String(try container.decode(Int64.self, forKey: .userId)) + self.nickname = try container.decode(String.self, forKey: .nickname) + self.profileImageUrl = try container.decodeIfPresent(String.self, forKey: .profileImageUrl) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/CommonNotificationInfo.swift b/SOOUM/SOOUM/Domain/Models/CommonNotificationInfo.swift index 7139af52..a965e875 100644 --- a/SOOUM/SOOUM/Domain/Models/CommonNotificationInfo.swift +++ b/SOOUM/SOOUM/Domain/Models/CommonNotificationInfo.swift @@ -51,7 +51,7 @@ extension CommonNotificationInfo: Decodable { init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.notificationId = String(try container.decode(Int64.self, forKey: .notificationId)) - self.notificationType = try container.decode(CommonNotificationInfo.NotificationType.self, forKey: .notificationType) + self.notificationType = try container.decode(NotificationType.self, forKey: .notificationType) self.createTime = try container.decode(Date.self, forKey: .createTime) } } diff --git a/SOOUM/SOOUM/Domain/Models/CompositeNotificationInfo.swift b/SOOUM/SOOUM/Domain/Models/CompositeNotificationInfo.swift index 22fef140..de22a37d 100644 --- a/SOOUM/SOOUM/Domain/Models/CompositeNotificationInfo.swift +++ b/SOOUM/SOOUM/Domain/Models/CompositeNotificationInfo.swift @@ -12,6 +12,7 @@ enum CompositeNotificationInfo: Hashable, Equatable { case follow(FollowNotificationInfoResponse) case deleted(DeletedNotificationInfoResponse) case blocked(BlockedNotificationInfoResponse) + case tag(TagNofificationInfoResponse) } extension CompositeNotificationInfo: Decodable { @@ -34,10 +35,13 @@ extension CompositeNotificationInfo: Decodable { case .blocked: let notification = try BlockedNotificationInfoResponse(from: decoder) self = .blocked(notification) + case .tagUsage: + let notification = try TagNofificationInfoResponse(from: decoder) + self = .tag(notification) case .feedLike, .commentLike, .commentWrite: let notification = try NotificationInfoResponse(from: decoder) self = .default(notification) - // TODO: TRANSFER_SUCCESS, TAG_USAGE 는 아직 정해지지 않음 + // TODO: TRANSFER_SUCCESS 는 아직 정해지지 않음 default: throw DecodingError.dataCorrupted( DecodingError.Context( diff --git a/SOOUM/SOOUM/Domain/Models/FollowInfo.swift b/SOOUM/SOOUM/Domain/Models/FollowInfo.swift new file mode 100644 index 00000000..fd6430d8 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/FollowInfo.swift @@ -0,0 +1,52 @@ +// +// FollowInfo.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import Foundation + +struct FollowInfo: Hashable { + + let id: String + let memberId: String + let nickname: String + let profileImageUrl: String? + let isFollowing: Bool + let isRequester: Bool +} + +extension FollowInfo { + + static var defaultValue: FollowInfo = FollowInfo( + id: "", + memberId: "", + nickname: "", + profileImageUrl: nil, + isFollowing: false, + isRequester: false + ) +} + +extension FollowInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case id = "followId" + case memberId + case nickname + case profileImageUrl + case isFollowing + case isRequester + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = String(try container.decode(Int64.self, forKey: .id)) + self.memberId = String(try container.decode(Int64.self, forKey: .memberId)) + self.nickname = try container.decode(String.self, forKey: .nickname) + self.profileImageUrl = try container.decodeIfPresent(String.self, forKey: .profileImageUrl) + self.isFollowing = try container.decode(Bool.self, forKey: .isFollowing) + self.isRequester = try container.decode(Bool.self, forKey: .isRequester) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/ProfileCardInfo.swift b/SOOUM/SOOUM/Domain/Models/ProfileCardInfo.swift new file mode 100644 index 00000000..710ebfe4 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/ProfileCardInfo.swift @@ -0,0 +1,48 @@ +// +// myCardInfo.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import Foundation + +struct ProfileCardInfo: Hashable { + + let id: String + let imgName: String + let imgURL: String + let content: String + let font: BaseCardInfo.Font +} + +extension ProfileCardInfo { + + static var defaultValue: ProfileCardInfo = ProfileCardInfo( + id: "", + imgName: "", + imgURL: "", + content: "", + font: .pretendard + ) +} + +extension ProfileCardInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case id = "cardId" + case imgName = "cardImgName" + case imgURL = "cardImgUrl" + case content = "cardContent" + case font + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = String(try container.decode(Int64.self, forKey: .id)) + self.imgName = try container.decode(String.self, forKey: .imgName) + self.imgURL = try container.decode(String.self, forKey: .imgURL) + self.content = try container.decode(String.self, forKey: .content) + self.font = try container.decode(BaseCardInfo.Font.self, forKey: .font) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/ProfileInfo.swift b/SOOUM/SOOUM/Domain/Models/ProfileInfo.swift new file mode 100644 index 00000000..20fe1784 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/ProfileInfo.swift @@ -0,0 +1,82 @@ +// +// ProfileInfo.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import Foundation + +struct ProfileInfo: Hashable { + + let userId: String + let nickname: String + let profileImgName: String? + let profileImageUrl: String? + let totalVisitCnt: String + let todayVisitCnt: String + let cardCnt: String + let followingCnt: String + let followerCnt: String + // 상대방 프로필 조회 + let isAlreadyFollowing: Bool? + let isBlocked: Bool? +} + +extension ProfileInfo { + + enum Content: String { + case card = "카드" + case follower = "팔로워" + case following = "팔로잉" + } +} + +extension ProfileInfo { + + static var defaultValue: ProfileInfo = ProfileInfo( + userId: "", + nickname: "", + profileImgName: nil, + profileImageUrl: nil, + totalVisitCnt: "", + todayVisitCnt: "", + cardCnt: "0", + followingCnt: "0", + followerCnt: "0", + isAlreadyFollowing: nil, + isBlocked: nil + ) +} + +extension ProfileInfo: Decodable { + + enum CodingKeys: CodingKey { + case userId + case nickname + case profileImgName + case profileImageUrl + case totalVisitCnt + case todayVisitCnt + case cardCnt + case followingCnt + case followerCnt + case isAlreadyFollowing + case isBlocked + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.userId = String(try container.decode(Int64.self, forKey: .userId)) + self.nickname = try container.decode(String.self, forKey: .nickname) + self.profileImgName = try container.decodeIfPresent(String.self, forKey: .profileImgName) + self.profileImageUrl = try container.decodeIfPresent(String.self, forKey: .profileImageUrl) + self.totalVisitCnt = String(try container.decode(Int64.self, forKey: .totalVisitCnt)) + self.todayVisitCnt = String(try container.decode(Int64.self, forKey: .todayVisitCnt)) + self.cardCnt = String(try container.decode(Int64.self, forKey: .cardCnt)) + self.followingCnt = String(try container.decode(Int64.self, forKey: .followingCnt)) + self.followerCnt = String(try container.decode(Int64.self, forKey: .followerCnt)) + self.isAlreadyFollowing = try container.decodeIfPresent(Bool.self, forKey: .isAlreadyFollowing) + self.isBlocked = try container.decodeIfPresent(Bool.self, forKey: .isBlocked) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/RejoinableDateInfo.swift b/SOOUM/SOOUM/Domain/Models/RejoinableDateInfo.swift new file mode 100644 index 00000000..34e47db7 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/RejoinableDateInfo.swift @@ -0,0 +1,36 @@ +// +// RejoinableDateInfo.swift +// SOOUM +// +// Created by 오현식 on 11/13/25. +// + +import Foundation + +struct RejoinableDateInfo: Equatable { + + let rejoinableDate: Date + let isActivityRestricted: Bool +} + +extension RejoinableDateInfo { + + static var defaultValue: RejoinableDateInfo = RejoinableDateInfo( + rejoinableDate: Date(), + isActivityRestricted: false + ) +} + +extension RejoinableDateInfo: Decodable { + + enum CodingKeys: CodingKey { + case rejoinableDate + case isActivityRestricted + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.rejoinableDate = try container.decode(Date.self, forKey: .rejoinableDate) + self.isActivityRestricted = try container.decode(Bool.self, forKey: .isActivityRestricted) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/TransferCodeInfo.swift b/SOOUM/SOOUM/Domain/Models/TransferCodeInfo.swift new file mode 100644 index 00000000..44ebb801 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/TransferCodeInfo.swift @@ -0,0 +1,36 @@ +// +// TransferCodeInfo.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +struct TransferCodeInfo: Equatable { + + let code: String + let expiredAt: Date +} + +extension TransferCodeInfo { + + static var defaultValue: TransferCodeInfo = TransferCodeInfo( + code: "", + expiredAt: Date() + ) +} + +extension TransferCodeInfo: Decodable { + + enum CodingKeys: String, CodingKey { + case code = "transferCode" + case expiredAt + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.code = try container.decode(String.self, forKey: .code) + self.expiredAt = try container.decode(Date.self, forKey: .expiredAt) + } +} diff --git a/SOOUM/SOOUM/Domain/Models/Version.swift b/SOOUM/SOOUM/Domain/Models/Version.swift index 48723d6e..c790a8c7 100644 --- a/SOOUM/SOOUM/Domain/Models/Version.swift +++ b/SOOUM/SOOUM/Domain/Models/Version.swift @@ -10,15 +10,17 @@ import Foundation struct Version: Equatable { let currentVersionStatus: Status + let latestVersion: String } extension Version { - init(status: String) { + init(status: String, latest: String) { self.currentVersionStatus = Status(rawValue: status) ?? .NONE + self.latestVersion = latest } - static var defaultValue: Version = Version(status: "NONE") + static var defaultValue: Version = Version(status: "NONE", latest: "1.0.0") } extension Version { @@ -54,5 +56,6 @@ extension Version: Decodable { enum CodingKeys: String, CodingKey { case currentVersionStatus = "status" + case latestVersion } } diff --git a/SOOUM/SOOUM/Domain/Models/WithdrawType.swift b/SOOUM/SOOUM/Domain/Models/WithdrawType.swift new file mode 100644 index 00000000..67aa2b1b --- /dev/null +++ b/SOOUM/SOOUM/Domain/Models/WithdrawType.swift @@ -0,0 +1,51 @@ +// +// WithdrawType.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +enum WithdrawType: CaseIterable { + case donotUseOfften + case missingFeature + case frequentErrors + case notEasyToUse + case createNewAccount + case other + + var identifier: Int { + switch self { + case .donotUseOfften: + return 0 + case .missingFeature: + return 1 + case .frequentErrors: + return 2 + case .notEasyToUse: + return 3 + case .createNewAccount: + return 4 + case .other: + return 5 + } + } + + var message: String { + switch self { + case .donotUseOfften: + return "자주 사용하지 않아요" + case .missingFeature: + return "원하는 기능이 없어요" + case .frequentErrors: + return "오류가 잦아서 사용하기 어려워요" + case .notEasyToUse: + return "앱 사용법을 모르겠어요" + case .createNewAccount: + return "새로운 계정을 만들고 싶어요" + case .other: + return "기타" + } + } +} diff --git a/SOOUM/SOOUM/Domain/Repositories/AppVersionRepository.swift b/SOOUM/SOOUM/Domain/Repositories/AppVersionRepository.swift index 98648ecd..bb65ee0a 100644 --- a/SOOUM/SOOUM/Domain/Repositories/AppVersionRepository.swift +++ b/SOOUM/SOOUM/Domain/Repositories/AppVersionRepository.swift @@ -12,5 +12,4 @@ import RxSwift protocol AppVersionRepository { func version() -> Observable - func oldVersion() -> Observable } diff --git a/SOOUM/SOOUM/Domain/Repositories/AuthRepository.swift b/SOOUM/SOOUM/Domain/Repositories/AuthRepository.swift index 5b74c4f6..c79b6ac3 100644 --- a/SOOUM/SOOUM/Domain/Repositories/AuthRepository.swift +++ b/SOOUM/SOOUM/Domain/Repositories/AuthRepository.swift @@ -17,4 +17,5 @@ protocol AuthRepository { func initializeAuthInfo() func hasToken() -> Bool func tokens() -> Token + func withdraw(reaseon: String) -> Observable } diff --git a/SOOUM/SOOUM/Domain/Repositories/CardRepository.swift b/SOOUM/SOOUM/Domain/Repositories/CardRepository.swift index 6cf18b6e..f635479f 100644 --- a/SOOUM/SOOUM/Domain/Repositories/CardRepository.swift +++ b/SOOUM/SOOUM/Domain/Repositories/CardRepository.swift @@ -25,7 +25,6 @@ protocol CardRepository { func commentCard(id: String, lastId: String?, latitude: String?, longitude: String?) -> Observable func deleteCard(id: String) -> Observable func updateLike(id: String, isLike: Bool) -> Observable - func updateBlocked(id: String, isBlocked: Bool) -> Observable func reportCard(id: String, reportType: String) -> Observable @@ -33,7 +32,7 @@ protocol CardRepository { func defaultImages() -> Observable func presignedURL() -> Observable - func uploadImage(_ data: Data, with url: URL) -> Observable> + func uploadImage(_ data: Data, with url: URL) -> Observable> func writeCard( isDistanceShared: Bool, latitude: String?, diff --git a/SOOUM/SOOUM/Domain/Repositories/NotificationRepository.swift b/SOOUM/SOOUM/Domain/Repositories/NotificationRepository.swift index f356b525..c14c6bc7 100644 --- a/SOOUM/SOOUM/Domain/Repositories/NotificationRepository.swift +++ b/SOOUM/SOOUM/Domain/Repositories/NotificationRepository.swift @@ -14,5 +14,5 @@ protocol NotificationRepository { func unreadNotifications(lastId: String?) -> Observable func readNotifications(lastId: String?) -> Observable func requestRead(notificationId: String) -> Observable - func notices(lastId: String?, size: Int?) -> Observable + func notices(lastId: String?, size: Int?, requestType: NotificationRequest.RequestType) -> Observable } diff --git a/SOOUM/SOOUM/Domain/Repositories/SettingsRepository.swift b/SOOUM/SOOUM/Domain/Repositories/SettingsRepository.swift new file mode 100644 index 00000000..d54c48f8 --- /dev/null +++ b/SOOUM/SOOUM/Domain/Repositories/SettingsRepository.swift @@ -0,0 +1,19 @@ +// +// SettingsRepository.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +import RxSwift + +protocol SettingsRepository { + + func rejoinableDate() -> Observable + func issue() -> Observable + func enter(code: String, encryptedDeviceId: String) -> Observable + func update() -> Observable + func blockUsers(lastId: String?) -> Observable +} diff --git a/SOOUM/SOOUM/Domain/Repositories/UserRepository.swift b/SOOUM/SOOUM/Domain/Repositories/UserRepository.swift index a8024874..4a46a6d4 100644 --- a/SOOUM/SOOUM/Domain/Repositories/UserRepository.swift +++ b/SOOUM/SOOUM/Domain/Repositories/UserRepository.swift @@ -16,8 +16,17 @@ protocol UserRepository { func validateNickname(nickname: String) -> Observable func updateNickname(nickname: String) -> Observable func presignedURL() -> Observable - func uploadImage(_ data: Data, with url: URL) -> Observable> + func uploadImage(_ data: Data, with url: URL) -> Observable> func updateImage(imageName: String) -> Observable func updateFCMToken(fcmToken: String) -> Observable func postingPermission() -> Observable + func profile(userId: String?) -> Observable + func updateMyProfile(nickname: String?, imageName: String?) -> Observable + func feedCards(userId: String, lastId: String?) -> Observable + func myCommentCards(lastId: String?) -> Observable + func followers(userId: String, lastId: String?) -> Observable + func followings(userId: String, lastId: String?) -> Observable + func updateFollowing(userId: String, isFollow: Bool) -> Observable + func updateBlocked(id: String, isBlocked: Bool) -> Observable + func updateNotify(isAllowNotify: Bool) -> Observable } diff --git a/SOOUM/SOOUM/Domain/UseCases/AppVersionUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/AppVersionUseCaseImpl.swift index 249d4257..1b45c626 100644 --- a/SOOUM/SOOUM/Domain/UseCases/AppVersionUseCaseImpl.swift +++ b/SOOUM/SOOUM/Domain/UseCases/AppVersionUseCaseImpl.swift @@ -21,9 +21,4 @@ class AppVersionUseCaseImpl: AppVersionUseCase { return self.repository.version().map { $0.version } } - - func oldVersion() -> Observable { - - return self.repository.oldVersion().map { Version(status: $0) } - } } diff --git a/SOOUM/SOOUM/Domain/UseCases/AuthUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/AuthUseCaseImpl.swift index ab69f779..6239c06c 100644 --- a/SOOUM/SOOUM/Domain/UseCases/AuthUseCaseImpl.swift +++ b/SOOUM/SOOUM/Domain/UseCases/AuthUseCaseImpl.swift @@ -41,4 +41,9 @@ class AuthUseCaseImpl: AuthUseCase { return self.repository.tokens() } + + func withdraw(reaseon: String) -> Observable { + + return self.repository.withdraw(reaseon: reaseon).map { $0 == 200 } + } } diff --git a/SOOUM/SOOUM/Domain/UseCases/CardUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/CardUseCaseImpl.swift index 7949655b..a7685e61 100644 --- a/SOOUM/SOOUM/Domain/UseCases/CardUseCaseImpl.swift +++ b/SOOUM/SOOUM/Domain/UseCases/CardUseCaseImpl.swift @@ -58,11 +58,6 @@ class CardUseCaseImpl: CardUseCase { return self.repository.updateLike(id: id, isLike: isLike).map { $0 == 200 } } - func updateBlocked(id: String, isBlocked: Bool) -> Observable { - - return self.repository.updateBlocked(id: id, isBlocked: isBlocked).map { $0 == 200 } - } - func reportCard(id: String, reportType: String) -> Observable { return self.repository.reportCard(id: id, reportType: reportType).map { $0 == 200 } @@ -84,8 +79,7 @@ class CardUseCaseImpl: CardUseCase { func uploadImage(_ data: Data, with url: URL) -> Observable { return self.repository.uploadImage(data, with: url) - .map { _ in true } - .catchAndReturn(false) + .map { (try? $0.get()) == 200 } } func writeCard( diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/AppVersionUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/AppVersionUseCase.swift index f9b1c5e3..08e0015f 100644 --- a/SOOUM/SOOUM/Domain/UseCases/Interfaces/AppVersionUseCase.swift +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/AppVersionUseCase.swift @@ -12,5 +12,4 @@ import RxSwift protocol AppVersionUseCase { func version() -> Observable - func oldVersion() -> Observable } diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/AuthUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/AuthUseCase.swift index 9b4cb4e2..cd47d195 100644 --- a/SOOUM/SOOUM/Domain/UseCases/Interfaces/AuthUseCase.swift +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/AuthUseCase.swift @@ -17,4 +17,5 @@ protocol AuthUseCase { func initializeAuthInfo() func hasToken() -> Bool func tokens() -> Token + func withdraw(reaseon: String) -> Observable } diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/CardUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/CardUseCase.swift index 9d3441c5..b873ad91 100644 --- a/SOOUM/SOOUM/Domain/UseCases/Interfaces/CardUseCase.swift +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/CardUseCase.swift @@ -25,7 +25,6 @@ protocol CardUseCase { func commentCard(id: String, lastId: String?, latitude: String?, longitude: String?) -> Observable<[BaseCardInfo]> func deleteCard(id: String) -> Observable func updateLike(id: String, isLike: Bool) -> Observable - func updateBlocked(id: String, isBlocked: Bool) -> Observable func reportCard(id: String, reportType: String) -> Observable diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/NotificationUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/NotificationUseCase.swift index 1223b97f..98fb3339 100644 --- a/SOOUM/SOOUM/Domain/UseCases/Interfaces/NotificationUseCase.swift +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/NotificationUseCase.swift @@ -14,5 +14,5 @@ protocol NotificationUseCase { func unreadNotifications(lastId: String?) -> Observable<[CompositeNotificationInfo]> func readNotifications(lastId: String?) -> Observable<[CompositeNotificationInfo]> func requestRead(notificationId: String) -> Observable - func notices(lastId: String?, size: Int?) -> Observable<[NoticeInfo]> + func notices(lastId: String?, size: Int?, requestType: NotificationRequest.RequestType) -> Observable<[NoticeInfo]> } diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/SettingsUserCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/SettingsUserCase.swift new file mode 100644 index 00000000..c1332393 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/SettingsUserCase.swift @@ -0,0 +1,19 @@ +// +// SettingsUserCase.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +import RxSwift + +protocol SettingsUserCase { + + func rejoinableDate() -> Observable + func issue() -> Observable + func enter(code: String, encryptedDeviceId: String) -> Observable + func update() -> Observable + func blockUsers(lastId: String?) -> Observable<[BlockUserInfo]> +} diff --git a/SOOUM/SOOUM/Domain/UseCases/Interfaces/UserUseCase.swift b/SOOUM/SOOUM/Domain/UseCases/Interfaces/UserUseCase.swift index 6c0b0dc7..4ba3f45b 100644 --- a/SOOUM/SOOUM/Domain/UseCases/Interfaces/UserUseCase.swift +++ b/SOOUM/SOOUM/Domain/UseCases/Interfaces/UserUseCase.swift @@ -20,4 +20,13 @@ protocol UserUseCase { func updateImage(imageName: String) -> Observable func updateFCMToken(fcmToken: String) -> Observable func postingPermission() -> Observable + func profile(userId: String?) -> Observable + func updateMyProfile(nickname: String?, imageName: String?) -> Observable + func feedCards(userId: String, lastId: String?) -> Observable<[ProfileCardInfo]> + func myCommentCards(lastId: String?) -> Observable<[ProfileCardInfo]> + func followers(userId: String, lastId: String?) -> Observable<[FollowInfo]> + func followings(userId: String, lastId: String?) -> Observable<[FollowInfo]> + func updateFollowing(userId: String, isFollow: Bool) -> Observable + func updateBlocked(id: String, isBlocked: Bool) -> Observable + func updateNotify(isAllowNotify: Bool) -> Observable } diff --git a/SOOUM/SOOUM/Domain/UseCases/NotificationUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/NotificationUseCaseImpl.swift index 48ef8eb3..68aeb99d 100644 --- a/SOOUM/SOOUM/Domain/UseCases/NotificationUseCaseImpl.swift +++ b/SOOUM/SOOUM/Domain/UseCases/NotificationUseCaseImpl.swift @@ -32,8 +32,8 @@ class NotificationUseCaseImpl: NotificationUseCase { return self.repository.requestRead(notificationId: notificationId).map { $0 == 200 } } - func notices(lastId: String?, size: Int?) -> Observable<[NoticeInfo]> { + func notices(lastId: String?, size: Int?, requestType: NotificationRequest.RequestType) -> Observable<[NoticeInfo]> { - return self.repository.notices(lastId: lastId, size: size).map { $0.noticeInfos } + return self.repository.notices(lastId: lastId, size: size, requestType: requestType).map { $0.noticeInfos } } } diff --git a/SOOUM/SOOUM/Domain/UseCases/SettingsUserCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/SettingsUserCaseImpl.swift new file mode 100644 index 00000000..44c03f14 --- /dev/null +++ b/SOOUM/SOOUM/Domain/UseCases/SettingsUserCaseImpl.swift @@ -0,0 +1,44 @@ +// +// SettingsUserCaseImpl.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Foundation + +import RxSwift + +class SettingsUserCaseImpl: SettingsUserCase { + + private let repository: SettingsRepository + + init(repository: SettingsRepository) { + self.repository = repository + } + + func rejoinableDate() -> Observable { + + return self.repository.rejoinableDate().map(\.rejoinableDate) + } + + func issue() -> Observable { + + return self.repository.issue().map(\.transferInfo) + } + + func enter(code: String, encryptedDeviceId: String) -> Observable { + + return self.repository.enter(code: code, encryptedDeviceId: encryptedDeviceId).map { $0 == 200 } + } + + func update() -> Observable { + + return self.repository.update().map(\.transferInfo) + } + + func blockUsers(lastId: String?) -> Observable<[BlockUserInfo]> { + + return self.repository.blockUsers(lastId: lastId).map(\.blockUsers) + } +} diff --git a/SOOUM/SOOUM/Domain/UseCases/UserUseCaseImpl.swift b/SOOUM/SOOUM/Domain/UseCases/UserUseCaseImpl.swift index e7d50e56..ef559065 100644 --- a/SOOUM/SOOUM/Domain/UseCases/UserUseCaseImpl.swift +++ b/SOOUM/SOOUM/Domain/UseCases/UserUseCaseImpl.swift @@ -45,8 +45,7 @@ class UserUseCaseImpl: UserUseCase { func uploadImage(_ data: Data, with url: URL) -> Observable { return self.repository.uploadImage(data, with: url) - .map { _ in true } - .catchAndReturn(false) + .map { (try? $0.get()) == 200 } } func updateImage(imageName: String) -> Observable { @@ -63,4 +62,49 @@ class UserUseCaseImpl: UserUseCase { return self.repository.postingPermission().map { $0.postingPermission } } + + func profile(userId: String?) -> Observable { + + return self.repository.profile(userId: userId).map { $0.profileInfo } + } + + func updateMyProfile(nickname: String?, imageName: String?) -> Observable { + + return self.repository.updateMyProfile(nickname: nickname, imageName: imageName).map { $0 == 200 } + } + + func feedCards(userId: String, lastId: String?) -> Observable<[ProfileCardInfo]> { + + return self.repository.feedCards(userId: userId, lastId: lastId).map { $0.cardInfos } + } + + func myCommentCards(lastId: String?) -> Observable<[ProfileCardInfo]> { + + return self.repository.myCommentCards(lastId: lastId).map { $0.cardInfos } + } + + func followers(userId: String, lastId: String?) -> Observable<[FollowInfo]> { + + return self.repository.followers(userId: userId, lastId: lastId).map { $0.followInfos } + } + + func followings(userId: String, lastId: String?) -> Observable<[FollowInfo]> { + + return self.repository.followings(userId: userId, lastId: lastId).map { $0.followInfos } + } + + func updateFollowing(userId: String, isFollow: Bool) -> Observable { + + return self.repository.updateFollowing(userId: userId, isFollow: isFollow).map { $0 == 200 } + } + + func updateBlocked(id: String, isBlocked: Bool) -> Observable { + + return self.repository.updateBlocked(id: id, isBlocked: isBlocked).map { $0 == 200 } + } + + func updateNotify(isAllowNotify: Bool) -> Observable { + + return self.repository.updateNotify(isAllowNotify: isAllowNotify).map { $0 == 200 } + } } diff --git a/SOOUM/SOOUM/Extensions/Cocoa/Kingfisher.swift b/SOOUM/SOOUM/Extensions/Cocoa/Kingfisher.swift index 33f3542c..d06f42a9 100644 --- a/SOOUM/SOOUM/Extensions/Cocoa/Kingfisher.swift +++ b/SOOUM/SOOUM/Extensions/Cocoa/Kingfisher.swift @@ -12,12 +12,12 @@ import Kingfisher extension KingfisherManager { - func download(strUrl: String?, completion: @escaping (UIImage?) -> Void) { + func download(strUrl: String?, with key: String? = nil, completion: @escaping (UIImage?) -> Void) { if let strUrl = strUrl, let url = URL(string: strUrl) { // 캐시 만료 기간 하루로 설정 - let resource = Kingfisher.KF.ImageResource(downloadURL: url, cacheKey: url.absoluteString) - self.retrieveImage(with: resource, options: [.memoryCacheExpiration(.days(1))]) { result in + let resource = KF.ImageResource(downloadURL: url, cacheKey: key ?? strUrl) + self.retrieveImage(with: resource) { result in switch result { case let .success(result): completion(result.image) @@ -32,12 +32,12 @@ extension KingfisherManager { } } - func cancel(strUrl: String?) { + func cancel(strUrl: String?, with key: String? = nil) { if let strUrl = strUrl, let url = URL(string: strUrl) { self.downloader.cancel(url: url) - self.cache.removeImage(forKey: url.absoluteString) + self.cache.removeImage(forKey: key ?? strUrl) } } } diff --git a/SOOUM/SOOUM/Extensions/Cocoa/Notification.swift b/SOOUM/SOOUM/Extensions/Cocoa/Notification.swift index ca7f39af..8a53d5c3 100644 --- a/SOOUM/SOOUM/Extensions/Cocoa/Notification.swift +++ b/SOOUM/SOOUM/Extensions/Cocoa/Notification.swift @@ -21,4 +21,6 @@ extension Notification.Name { static let reloadCommentsData = Notification.Name("reloadCommentsData") /// Updated report state static let updatedReportState = Notification.Name("updatedReportState") + /// Should reload progile + static let reloadProfileData = Notification.Name("reloadProfileData") } diff --git a/SOOUM/SOOUM/Extensions/Cocoa/UITextField.swift b/SOOUM/SOOUM/Extensions/Cocoa/UITextField.swift index 883c95af..38ffc0ab 100644 --- a/SOOUM/SOOUM/Extensions/Cocoa/UITextField.swift +++ b/SOOUM/SOOUM/Extensions/Cocoa/UITextField.swift @@ -30,6 +30,16 @@ extension UITextField { // 텍스트 입력 전에 제한을 벗어남 if isTyped { // 입력 시 더 이상 입력되지 않음 + let lastCharacter = String(text[text.index(before: text.endIndex)]) + let separatedCharacters = lastCharacter.decomposedStringWithCanonicalMapping.unicodeScalars.map { String($0) } + let separatedCharactersCount = separatedCharacters.count + // 마지막 문자를 자음 + 모음으로 나누어 갯수에 따라 판단, + // 갯수가 1일 때, 모음이면 입력 가능 + if separatedCharactersCount == 1 && string.isConsonant == false { return true } + // 갯수가 2일 때, 자음이면 입력 가능 + if separatedCharactersCount == 2 && string.isConsonant { return true } + // TODO: 겹받침일 때는 고려 X + return false } else { // 텍스트 범위가 선택됨 diff --git a/SOOUM/SOOUM/Extensions/Cocoa/UIViewController+PushAndPop.swift b/SOOUM/SOOUM/Extensions/Cocoa/UIViewController+PushAndPop.swift index d44cd27c..e5fc1fd2 100644 --- a/SOOUM/SOOUM/Extensions/Cocoa/UIViewController+PushAndPop.swift +++ b/SOOUM/SOOUM/Extensions/Cocoa/UIViewController+PushAndPop.swift @@ -29,7 +29,7 @@ extension UIViewController { func navigationPop( to: UIViewController.Type? = nil, animated: Bool = true, - bottomBarHidden: Bool = false, + bottomBarHidden: Bool = true, completion: (() -> Void)? = nil ) { CATransaction.begin() @@ -43,12 +43,24 @@ extension UIViewController { destination.hidesBottomBarWhenPushed = bottomBarHidden self.navigationController?.popToViewController(destination, animated: animated) } else { - self.navigationController? - .viewControllers.dropLast().last? - .hidesBottomBarWhenPushed = bottomBarHidden + self.hidesBottomBarWhenPushed = bottomBarHidden self.navigationController?.popViewController(animated: animated) } CATransaction.commit() } + + func navigationPopToRoot( + animated: Bool = true, + bottomBarHidden: Bool = true, + completion: (() -> Void)? = nil + ) { + CATransaction.begin() + CATransaction.setCompletionBlock(completion) + + self.hidesBottomBarWhenPushed = bottomBarHidden + self.navigationController?.popToRootViewController(animated: animated) + + CATransaction.commit() + } } diff --git a/SOOUM/SOOUM/Extensions/Foundation/Date.swift b/SOOUM/SOOUM/Extensions/Foundation/Date.swift index 87a89cb5..e24cefd1 100644 --- a/SOOUM/SOOUM/Extensions/Foundation/Date.swift +++ b/SOOUM/SOOUM/Extensions/Foundation/Date.swift @@ -54,7 +54,7 @@ extension Date { return "\(days / 7)주 전".trimmingCharacters(in: .whitespaces) } - if days > 0 && days < 6 { + if days > 0 && days < 7 { return "\(days)일 전".trimmingCharacters(in: .whitespaces) } @@ -94,6 +94,22 @@ extension Date { return String(format: "%02d : %02d : %02d", hours, minutes, seconds) } + func infoReadableTimeTakenFromThisForPungToHoursAndMinutes(to: Date) -> String { + + let from: TimeInterval = self.timeIntervalSince1970 + let to: TimeInterval = to.timeIntervalSince1970 + let gap: TimeInterval = max(0, to - from) + + let time: Int = .init(gap) + let minutes: Int = .init(time % (60 * 60)) / 60 + let seconds: Int = .init(time % 60) + + if minutes <= 0 && seconds <= 0 { + return "00:00" + } + return String(format: "%02d:%02d", minutes, seconds) + } + func infoReadableTimeTakenFromThisForBanEndPosting(to: Date) -> String { let from: TimeInterval = self.timeIntervalSince1970 @@ -115,7 +131,7 @@ extension Date { } var announcementFormatted: String { - return self.toString("yyyy. MM. dd") + return self.toString("yyyy.MM.dd") } var noticeFormatted: String { diff --git a/SOOUM/SOOUM/Extensions/Foundation/String.swift b/SOOUM/SOOUM/Extensions/Foundation/String.swift new file mode 100644 index 00000000..091b572e --- /dev/null +++ b/SOOUM/SOOUM/Extensions/Foundation/String.swift @@ -0,0 +1,20 @@ +// +// String.swift +// SOOUM +// +// Created by 오현식 on 11/16/25. +// + +import UIKit + +extension String { + /// 자음인지 여부 확인 + var isConsonant: Bool { + guard let scalar = UnicodeScalar(self)?.value else { + return false + } + + let consonantScalarRange: ClosedRange = 12593...12622 + return consonantScalarRange ~= scalar + } +} diff --git a/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewReactor.swift b/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewReactor.swift index f8f6c6b5..2664768e 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Launch/LaunchScreenViewReactor.swift @@ -108,21 +108,6 @@ extension LaunchScreenViewReactor { } private func check() -> Observable { - - #if PRODUCTION - return self.versionUseCase.oldVersion() - .withUnretained(self) - .flatMapLatest { object, version -> Observable in - - UserDefaults.standard.set(version.shouldHideTransfer, forKey: "AppFlag") - - if version.mustUpdate { - return .just(.check(true)) - } else { - return object.authUseCase.hasToken() ? .just(.updateIsRegistered(true)) : object.login() - } - } - #elseif DEVELOP return self.versionUseCase.version() .withUnretained(self) .flatMapLatest { object, version -> Observable in @@ -135,7 +120,6 @@ extension LaunchScreenViewReactor { return object.authUseCase.hasToken() ? .just(.updateIsRegistered(true)) : object.login() } } - #endif } } diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Completed/OnboardingCompletedViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Completed/OnboardingCompletedViewController.swift index 546e36d9..d059a2f5 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Completed/OnboardingCompletedViewController.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Completed/OnboardingCompletedViewController.swift @@ -49,6 +49,14 @@ class OnboardingCompletedViewController: BaseNavigationViewController, View { } + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + next button height + padding + return 34 + 56 + 8 + } + + // MARK: Override func override func setupConstraints() { diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/OnboardingNicknameSettingViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/OnboardingNicknameSettingViewController.swift index 8006531e..a768840d 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/OnboardingNicknameSettingViewController.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/NicknameSetting/OnboardingNicknameSettingViewController.swift @@ -31,7 +31,7 @@ class OnboardingNicknameSettingViewController: BaseNavigationViewController, Vie private let guideMessageView = OnboardingGuideMessageView(title: Text.title, currentNumber: 2) - private let nicknameTextField = OnboardingNicknameTextFieldView() + private let nicknameTextField = SOMNicknameTextField() private let nextButton = SOMButton().then { $0.title = Text.nextButtonTitle @@ -41,6 +41,14 @@ class OnboardingNicknameSettingViewController: BaseNavigationViewController, Vie } + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + next button height + padding + return 34 + 56 + 8 + } + + // MARK: Override func override func setupNaviBar() { @@ -77,7 +85,7 @@ class OnboardingNicknameSettingViewController: BaseNavigationViewController, Vie override func updatedKeyboard(withoutBottomSafeInset height: CGFloat) { super.updatedKeyboard(withoutBottomSafeInset: height) - let height = height + 12 + let height = height == 0 ? 0 : height + 12 self.nextButton.snp.updateConstraints { $0.bottom.equalTo(self.view.safeAreaLayoutGuide).offset(-height) } diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewController.swift index f72e9a15..861a331e 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewController.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewController.swift @@ -82,6 +82,14 @@ class OnboardingViewController: BaseNavigationViewController, View { } + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + old button height + start button height + padding + return 34 + 6 + 21 + 6 + 8 + 56 + 8 + } + + // MARK: Override func override func viewDidLoad() { @@ -165,7 +173,8 @@ class OnboardingViewController: BaseNavigationViewController, View { startButtonTapped .withLatestFrom(checkAvailable) - .filter { $0 == nil } + .filterNil() + .filter { $0.banned == false } .subscribe(with: self) { object, _ in let termsOfServiceViewController = OnboardingTermsOfServiceViewController() termsOfServiceViewController.reactor = reactor.reactorForTermsOfService() @@ -176,6 +185,7 @@ class OnboardingViewController: BaseNavigationViewController, View { startButtonTapped .withLatestFrom(checkAvailable) .filterNil() + .filter { $0.banned == true && $0.rejoinAvailableAt != nil } .subscribe(with: self) { object, checkAvailable in if let rejoinAvailableAt = checkAvailable.rejoinAvailableAt { @@ -196,6 +206,7 @@ class OnboardingViewController: BaseNavigationViewController, View { checkAvailable .filterNil() .take(1) + .filter { $0.banned == true && $0.rejoinAvailableAt != nil } .subscribe(with: self) { object, checkAvailable in if let rejoinAvailableAt = checkAvailable.rejoinAvailableAt { diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewReactor.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewReactor.swift index 67265c2e..20935932 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/Onboarding/OnboardingViewReactor.swift @@ -45,8 +45,7 @@ class OnboardingViewReactor: Reactor { case .landing: return .concat([ - self.check() - .compactMap(Mutation.check), + self.check(), self.pushManager.switchNotification(on: true) .flatMapLatest { _ -> Observable in .empty() } ]) @@ -59,19 +58,16 @@ class OnboardingViewReactor: Reactor { case let .check(checkAvailable): newState.checkAvailable = checkAvailable } - return state + return newState } } extension OnboardingViewReactor { - private func check() -> Observable { + private func check() -> Observable { return self.userUseCase.isAvailableCheck() - .flatMapLatest { checkAvailable -> Observable in - - return checkAvailable == .defaultValue ? .just(nil) : .just(checkAvailable) - } + .map(Mutation.check) } } @@ -82,6 +78,6 @@ extension OnboardingViewReactor { } func reactorForEnterTransfer() -> EnterMemberTransferViewReactor { - EnterMemberTransferViewReactor(dependencies: self.dependencies, entranceType: .onboarding) + EnterMemberTransferViewReactor(dependencies: self.dependencies) } } diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewController.swift index 58ccddca..7b44db9f 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewController.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewController.swift @@ -60,6 +60,7 @@ class OnboardingProfileImageSettingViewController: BaseNavigationViewController, private let profileImageView = UIImageView().then { $0.image = .init(.image(.v2(.profile_large))) + $0.contentMode = .scaleAspectFill $0.backgroundColor = .som.v2.gray300 $0.layer.cornerRadius = 120 * 0.5 $0.layer.borderWidth = 1 @@ -96,6 +97,14 @@ class OnboardingProfileImageSettingViewController: BaseNavigationViewController, private var actions: [SOMBottomFloatView.FloatAction] = [] + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + next button height + padding + return 34 + 56 + 8 + } + + // MARK: Override func override func setupConstraints() { @@ -208,7 +217,9 @@ class OnboardingProfileImageSettingViewController: BaseNavigationViewController, title: Text.inappositeDialogConfirmButtonTitle, style: .primary, action: { - UIApplication.topViewController?.dismiss(animated: true) + UIApplication.topViewController?.dismiss(animated: true) { + reactor.action.onNext(.setDefaultImage) + } } ) ] diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewReactor.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewReactor.swift index 0c00654f..2bb0172c 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/ProfileImageSetting/OnboardingProfileImageSettingViewReactor.swift @@ -133,7 +133,8 @@ extension OnboardingProfileImageSettingViewReactor { let nsError = error as NSError let endProcessing = Observable.concat([ - .just(.updateImageInfo(nil, nil)), + // TODO: 부적절한 사진일 때, `확인` 버튼 탭 시 이미지 변경 + // .just(.updateImageInfo(nil, nil)), .just(.updateIsSignUp(false)), .just(.updateIsLoading(false)), // 부적절한 이미지 업로드 에러 코드 == 422 diff --git a/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/OnboardingTermsOfServiceViewController.swift b/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/OnboardingTermsOfServiceViewController.swift index a70c5b2d..631b7721 100644 --- a/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/OnboardingTermsOfServiceViewController.swift +++ b/SOOUM/SOOUM/Presentations/Intro/Onboarding/TermsOfService/OnboardingTermsOfServiceViewController.swift @@ -82,6 +82,14 @@ class OnboardingTermsOfServiceViewController: BaseNavigationViewController, View } + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + next button height + padding + return 34 + 56 + 8 + } + + // MARK: Override func override func setupNaviBar() { diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Cells/HomePlaceholderViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Cells/HomePlaceholderViewCell.swift index b77a3bf4..5e3f7a9e 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Cells/HomePlaceholderViewCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Cells/HomePlaceholderViewCell.swift @@ -10,7 +10,6 @@ import UIKit import SnapKit import Then - class HomePlaceholderViewCell: UITableViewCell { enum Text { diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewCell.swift index c757c718..375a12a4 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewCell.swift @@ -22,10 +22,11 @@ class DetailViewCell: UICollectionViewCell { // MARK: Views - private let memberInfoView = MemberInfoView() + let memberInfoView = MemberInfoView() /// 상세보기, 전글 배경 private let prevCardBackgroundImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill $0.layer.cornerRadius = 8 $0.layer.masksToBounds = true $0.isHidden = true @@ -45,6 +46,7 @@ class DetailViewCell: UICollectionViewCell { /// 상세보기, 배경 이미지 private let backgroundImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill $0.layer.borderColor = UIColor.som.v2.gray100.cgColor $0.layer.borderWidth = 1 $0.layer.cornerRadius = 16 diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewFooter.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewFooter.swift index 175ba56e..ae61123f 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewFooter.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Cells/DetailViewFooter.swift @@ -123,7 +123,9 @@ class DetailViewFooter: UICollectionReusableView { self.collectionView.isHidden = models.isEmpty self.noContentLabel.isHidden = models.isEmpty == false - self.collectionView.reloadData() + UIView.performWithoutAnimation { + self.collectionView.reloadData() + } } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewController.swift index 4fc50c0a..55afeb31 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewController.swift @@ -30,6 +30,8 @@ class DetailViewController: BaseNavigationViewController, View { static let deletePungDialogTitle: String = "시간 제한 카드를 삭제할까요?" static let deletePungDialogMessage: String = "카드를 삭제하면,\n답카드가 자동으로 삭제되지 않아요" + static let deletedCardDialogTitle: String = "삭제된 카드예요" + static let bottomFloatEntryName: String = "bottomFloatEntryName" static let bottomToastEntryName: String = "bottomToastEntryName" @@ -44,6 +46,7 @@ class DetailViewController: BaseNavigationViewController, View { static let blockDialogTitle: String = "차단하시겠어요?" static let blockDialogMessage: String = "의 모든 카드를 볼 수 없어요." + static let confirmActionTitle: String = "확인" static let cancelActionTitle: String = "취소" } @@ -115,7 +118,15 @@ class DetailViewController: BaseNavigationViewController, View { private var shouldRefreshing: Bool = false private var actions: [SOMBottomFloatView.FloatAction] = [] - + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + floating button height + padding + return 34 + 56 + 8 + } + // MARK: Override func @@ -271,14 +282,14 @@ class DetailViewController: BaseNavigationViewController, View { // 카드 삭제 후 X 버튼 액션 self.rightDeleteButton.rx.throttleTap .subscribe(with: self) { object, _ in - object.navigationPop(to: HomeViewController.self, animated: false) + object.navigationPop(to: HomeViewController.self, animated: false, bottomBarHidden: false) } .disposed(by: self.disposeBag) // 답카드 홈 버튼 액션 self.leftHomeButton.rx.throttleTap .subscribe(with: self) { object, _ in - object.navigationPop(to: HomeViewController.self, animated: false) + object.navigationPop(to: HomeViewController.self, animated: false, bottomBarHidden: false) } .disposed(by: self.disposeBag) @@ -399,31 +410,10 @@ class DetailViewController: BaseNavigationViewController, View { UIView.performWithoutAnimation { object.collectionView.reloadData() } + + object.showDeletedCardDialog() } .disposed(by: self.disposeBag) - - // reactor.state.map(\.hasErrors) - // .filterNil() - // .distinctUntilChanged() - // .subscribe(with: self) { object, hasErrors in - // - // switch reactor.entranceType { - // case .navi: - // object.isDeleted = true - // - // UIView.performWithoutAnimation { - // object.collectionView.reloadData() - // } - // case .push: - // return - // let notificationTabBarController = NotificationTabBarController() - // notificationTabBarController.reactor = reactor.reactorForNoti() - // - // object.navigationPush(notificationTabBarController, animated: false) - // object.navigationController?.viewControllers.removeAll(where: { $0.isKind(of: DetailViewController.self) }) - // } - // } - // .disposed(by: self.disposeBag) } @@ -469,6 +459,27 @@ extension DetailViewController: UICollectionViewDataSource { guard let reactor = self.reactor else { return cell } + cell.memberInfoView.memberBackgroundButton.rx.throttleTap(.seconds(3)) + .subscribe(with: self) { object, _ in + /// 내 프로필일 경우 탭 이동 + if object.detailCard.isOwnCard { + guard let navigationController = object.navigationController, + let tabBarController = navigationController.parent as? SOMTabBarController + else { return } + + tabBarController.didSelectedIndex(3) + navigationController.viewControllers.removeAll(where: { $0.isKind(of: HomeViewController.self) == false }) + } else { + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile( + type: .other, + object.detailCard.memberId + ) + object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) + } + } + .disposed(by: cell.disposeBag) + cell.likeAndCommentView.likeBackgroundButton.rx.throttleTap(.seconds(3)) .withLatestFrom(reactor.state.compactMap(\.detailCard).map(\.isLike)) .subscribe(onNext: { isLike in @@ -502,24 +513,6 @@ extension DetailViewController: UICollectionViewDataSource { } .disposed(by: cell.disposeBag) - // cell.memberBackgroundButton.rx.tap - // .subscribe(with: self) { object, _ in - // if object.detailCard.isOwnCard { - // - // let memberId = object.detailCard.member.id - // let profileViewController = ProfileViewController() - // profileViewController.reactor = object.reactor?.reactorForProfile(type: .myWithNavi, memberId) - // object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) - // } else { - // - // let memberId = object.detailCard.member.id - // let profileViewController = ProfileViewController() - // profileViewController.reactor = object.reactor?.reactorForProfile(type: .other, memberId) - // object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) - // } - // } - // .disposed(by: cell.disposeBag) - return cell } @@ -606,7 +599,7 @@ extension DetailViewController: UICollectionViewDelegateFlowLayout { let pulledOffset = self.initialOffset - offset let refreshingOffset = refreshControl.frame.origin.y + refreshControl.frame.height - self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + 10 } self.currentOffset = offset @@ -687,6 +680,24 @@ private extension DetailViewController { actions: [cancelAction, deleteAction] ) } + + func showDeletedCardDialog() { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + UIApplication.topViewController?.dismiss(animated: true) + } + ) + + SOMDialogViewController.show( + title: Text.deletedCardDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [confirmAction] + ) + } } // extension DetailViewController: SOMTagsDelegate { diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewReactor.swift index c961bc83..c3fe81a7 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/DetailViewReactor.swift @@ -62,6 +62,7 @@ class DetailViewReactor: Reactor { private let dependencies: AppDIContainerable private let cardUseCase: CardUseCase + private let userUseCase: UserUseCase private let locationManager: LocationManagerDelegate @@ -77,6 +78,7 @@ class DetailViewReactor: Reactor { ) { self.dependencies = dependencies self.cardUseCase = dependencies.rootContainer.resolve(CardUseCase.self) + self.userUseCase = dependencies.rootContainer.resolve(UserUseCase.self) self.locationManager = dependencies.rootContainer.resolve(ManagerProviderType.self).locationManager @@ -117,7 +119,7 @@ class DetailViewReactor: Reactor { guard let memberId = self.currentState.detailCard?.memberId else { return .empty() } - return self.cardUseCase.updateBlocked(id: memberId, isBlocked: isBlocked) + return self.userUseCase.updateBlocked(id: memberId, isBlocked: isBlocked) .flatMapLatest { isBlockedSuccess -> Observable in /// isBlocked == true 일 때, 차단 요청 return isBlockedSuccess ? .just(.updateIsBlocked(isBlocked == false)) : .empty() @@ -229,12 +231,12 @@ extension DetailViewReactor { // TagDetailViewrReactor(provider: self.provider, tagID: tagID) // } - // func reactorForProfile( - // type: ProfileViewReactor.EntranceType, - // _ memberId: String - // ) -> ProfileViewReactor { - // ProfileViewReactor(provider: self.provider, type: type, memberId: memberId) - // } + func reactorForProfile( + type: ProfileViewReactor.EntranceType, + _ userId: String + ) -> ProfileViewReactor { + ProfileViewReactor(dependencies: self.dependencies, type: type, with: userId) + } // func reactorForNoti() -> NotificationTabBarReactor { // NotificationTabBarReactor(provider: self.provider) @@ -243,7 +245,7 @@ extension DetailViewReactor { extension DetailViewReactor { - var catchClosure: ((Error) throws -> Observable ) { + var catchClosure: ((Error) throws -> Observable) { return { error in let nsError = error as NSError @@ -254,7 +256,7 @@ extension DetailViewReactor { .just(.updateIsBlocked(false)) ]) } - + // errorCode == 410 일 때, 이미 삭제된 카드 if case 410 = nsError.code { return .concat([ .just(.updateIsRefreshing(false)), @@ -262,7 +264,7 @@ extension DetailViewReactor { ]) } - return .empty() + return .just(.updateIsRefreshing(false)) } } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewController.swift index 8e3c8a54..faa87b61 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Report/ReportViewController.swift @@ -57,6 +57,14 @@ class ReportViewController: BaseNavigationViewController, View { } + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + floating button height + padding + return 34 + 56 + 8 + } + + // MARK: Override func override func setupNaviBar() { diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/MemberInfoView.swift b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/MemberInfoView.swift index e9e2ba95..b0a593e7 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/MemberInfoView.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Detail/Views/MemberInfoView.swift @@ -14,14 +14,16 @@ class MemberInfoView: UIView { enum Text { static let visitedPrefix: String = "조회 " + static let deletedUserNickname: String = "알 수 없는 사용자" } // MARK: Views /// 상세보기, 멤버 이미지 - // let memberBackgroundButton = UIButton() + let memberBackgroundButton = UIButton() private let memberImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill $0.backgroundColor = .som.v2.gray300 $0.layer.borderColor = UIColor.som.v2.gray300.cgColor $0.layer.borderWidth = 1 @@ -160,9 +162,18 @@ class MemberInfoView: UIView { $0.leading.greaterThanOrEqualTo(container.snp.trailing).offset(10) $0.trailing.equalToSuperview().offset(-20) } + + self.addSubview(self.memberBackgroundButton) + self.memberBackgroundButton.snp.makeConstraints { + $0.verticalEdges.equalTo(container.snp.verticalEdges) + $0.leading.equalTo(container.snp.leading) + $0.trailing.equalTo(self.memberLabel.snp.trailing) + } } func updateViewsWhenDeleted() { + self.memberImageView.image = .init(.image(.v2(.profile_small))) + self.memberLabel.text = Text.deletedUserNickname self.distanceBackgroundView.removeFromSuperview() self.timeGapLabel.removeFromSuperview() } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/HomeViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/HomeViewController.swift index 31a96393..e774dc9c 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/HomeViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/HomeViewController.swift @@ -30,8 +30,11 @@ class HomeViewController: BaseNavigationViewController, View { static let dialogTitle: String = "위치 정보 사용 설정" static let dialogMessage: String = "내 위치 확인을 위해 ‘설정 > 앱 > 숨 > 위치’에서 위치 정보 사용을 허용해 주세요." + static let pungedCardDialogTitle: String = "삭제된 카드예요" + static let cancelActionTitle: String = "취소" static let settingActionTitle: String = "설정" + static let confirmActionTitle: String = "확인" } enum Section: Int, CaseIterable { @@ -479,6 +482,24 @@ private extension HomeViewController { actions: [cancelAction, settingAction] ) } + + func showPungedCardDialog() { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + UIApplication.topViewController?.dismiss(animated: true) + } + ) + + SOMDialogViewController.show( + title: Text.pungedCardDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [confirmAction] + ) + } } @@ -554,6 +575,27 @@ extension HomeViewController: UITableViewDelegate { let reactor = self.reactor else { return } + var isPunged: Bool { + switch item { + case let .latest(selectedCard): + guard let expireAt = selectedCard.storyExpirationTime else { return false } + return expireAt < Date() + case let .popular(selectedCard): + guard let expireAt = selectedCard.storyExpirationTime else { return false } + return expireAt < Date() + case let .distance(selectedCard): + guard let expireAt = selectedCard.storyExpirationTime else { return false } + return expireAt < Date() + case .empty: + return false + } + } + + guard isPunged == false else { + self.showPungedCardDialog() + return + } + var selectedId: String { switch item { case let .latest(selectedCard): @@ -647,7 +689,7 @@ extension HomeViewController: UITableViewDelegate { let pulledOffset = self.initialOffset - offset let refreshingOffset = refreshControl.frame.origin.y + refreshControl.frame.height + 16 - self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + 10 } // 당겨서 새로고침 시 무시 diff --git a/SOOUM/SOOUM/Presentations/Main/Home/HomeViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/HomeViewReactor.swift index c1d39f57..33f50ff1 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/HomeViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/HomeViewReactor.swift @@ -234,7 +234,7 @@ private extension HomeViewReactor { func unreadNotifications() -> Observable { - return self.notificationUseCase.notices(lastId: nil, size: 3) + return self.notificationUseCase.notices(lastId: nil, size: 3, requestType: .notification) .flatMapLatest { noticeInfos -> Observable in return .concat([ diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewController.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewController.swift index 666811e2..1ba86f42 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewController.swift @@ -116,6 +116,14 @@ class NotificationViewController: BaseNavigationViewController, View { private var shouldRefreshing: Bool = false + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + padding + return 34 + 8 + } + + // MARK: Override func override func setupNaviBar() { @@ -177,19 +185,6 @@ class NotificationViewController: BaseNavigationViewController, View { } .disposed(by: self.disposeBag) - reactor.state.map(\.pushInfo) - .filterNil() - .distinctUntilChanged(reactor.canUpdatePushInfos) - .subscribe(with: self) { object, pushInfo in - let detailViewController = DetailViewController() - detailViewController.reactor = reactor.reactorForDetail( - entranceType: pushInfo.entranceType, - with: pushInfo.id - ) - object.navigationPush(detailViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: self.disposeBag) - reactor.state.map { NotificationViewReactor.DisplayStates( displayType: $0.displayType, @@ -295,7 +290,9 @@ extension NotificationViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return } + guard let item = self.dataSource.itemIdentifier(for: indexPath), + let reactor = self.reactor + else { return } switch item { case let .notice(notice): @@ -306,107 +303,53 @@ extension NotificationViewController: UITableViewDelegate { } case let .unread(notification): - var pushOrRequestReadInfo: NotificationViewReactor.PushOrRequestReadInfo? { - switch notification { - case let .default(notification): - - if case .feedLike = notification.notificationInfo.notificationType { - return .init( - entranceType: .feed, - notificationId: notification.notificationInfo.notificationId, - targetCardId: notification.targetCardId, - shouldRead: true - ) - } - if case .commentLike = notification.notificationInfo.notificationType { - return .init( - entranceType: .comment, - notificationId: notification.notificationInfo.notificationId, - targetCardId: notification.targetCardId, - shouldRead: true - ) - } - if case .commentWrite = notification.notificationInfo.notificationType { - return .init( - entranceType: .comment, - notificationId: notification.notificationInfo.notificationId, - targetCardId: notification.targetCardId, - shouldRead: true - ) - } - return nil - /// follow, deleted, blocked 는 읽기 API만 호출 - case let .follow(notification): - - return .init( - entranceType: .feed, - notificationId: notification.notificationInfo.notificationId, - targetCardId: nil, - shouldRead: true - ) - case let .deleted(notification): - - return .init( - entranceType: .feed, - notificationId: notification.notificationInfo.notificationId, - targetCardId: nil, - shouldRead: true - ) - case let .blocked(notification): + switch notification { + case let .default(notification): + + reactor.action.onNext(.requestRead(notification.notificationInfo.notificationId)) + + switch notification.notificationInfo.notificationType { + case .feedLike, .commentLike, .commentWrite: - return .init( + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForDetail( entranceType: .feed, - notificationId: notification.notificationInfo.notificationId, - targetCardId: nil, - shouldRead: true + with: notification.targetCardId ) + self.navigationPush(detailViewController, animated: true, bottomBarHidden: true) + default: + return } + case let .follow(notification): + + reactor.action.onNext(.requestRead(notification.notificationInfo.notificationId)) + + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile(with: notification.userId) + self.navigationPush(profileViewController, animated: true, bottomBarHidden: true) + default: + return } - guard let pushOrRequestReadInfo = pushOrRequestReadInfo else { return } - - self.reactor?.action.onNext(.updatePushOrRequestReadInfo(pushOrRequestReadInfo)) case let .read(notification): - var pushOrRequestReadInfo: NotificationViewReactor.PushOrRequestReadInfo? { - switch notification { - case let .default(notification): - - if case .feedLike = notification.notificationInfo.notificationType { - return .init( - entranceType: .feed, - notificationId: notification.notificationInfo.notificationId, - targetCardId: notification.targetCardId, - shouldRead: false - ) - } - if case .commentLike = notification.notificationInfo.notificationType { - return .init( - entranceType: .comment, - notificationId: notification.notificationInfo.notificationId, - targetCardId: notification.targetCardId, - shouldRead: false - ) - } - if case .commentWrite = notification.notificationInfo.notificationType { - return .init( - entranceType: .comment, - notificationId: notification.notificationInfo.notificationId, - targetCardId: notification.targetCardId, - shouldRead: false - ) - } - return nil - default: - - return nil - } + switch notification { + case let .default(notification): + + let detailViewController = DetailViewController() + detailViewController.reactor = reactor.reactorForDetail( + entranceType: .feed, + with: notification.targetCardId + ) + self.navigationPush(detailViewController, animated: true, bottomBarHidden: true) + case let .follow(notification): + + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile(with: notification.userId) + self.navigationPush(profileViewController, animated: true, bottomBarHidden: true) + default: + return } - guard let pushOrRequestReadInfo = pushOrRequestReadInfo else { return } - - self.reactor?.action.onNext(.updatePushOrRequestReadInfo(pushOrRequestReadInfo)) - default: - return } } @@ -569,7 +512,7 @@ extension NotificationViewController: UITableViewDelegate { let pulledOffset = self.initialOffset - offset let refreshingOffset = refreshControl.frame.origin.y + refreshControl.frame.height - self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + 10 } self.currentOffset = offset diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewReactor.swift index a718751a..7389fca6 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Notification/NotificationViewReactor.swift @@ -17,7 +17,6 @@ class NotificationViewReactor: Reactor { case updateDisplayType(DisplayType) case moreFind(lastId: String, displayType: DisplayType) case requestRead(String) - case updatePushOrRequestReadInfo(PushOrRequestReadInfo) } enum Mutation { @@ -26,7 +25,6 @@ class NotificationViewReactor: Reactor { case notices([NoticeInfo]) case moreNotices([NoticeInfo]) case updateDisplayType(DisplayType) - case updatePushOrRequestReadInfo((entranceType: EntranceCardType, id: String)?) case updateIsRefreshing(Bool) case updateIsReadSuccess(Bool) } @@ -36,7 +34,6 @@ class NotificationViewReactor: Reactor { fileprivate(set) var notificationsForUnread: [CompositeNotificationInfo]? fileprivate(set) var notifications: [CompositeNotificationInfo]? fileprivate(set) var notices: [NoticeInfo]? - fileprivate(set) var pushInfo: (entranceType: EntranceCardType, id: String)? fileprivate(set) var isRefreshing: Bool fileprivate(set) var isReadSuccess: Bool } @@ -55,7 +52,6 @@ class NotificationViewReactor: Reactor { notificationsForUnread: nil, notifications: nil, notices: nil, - pushInfo: nil, isRefreshing: false, isReadSuccess: false ) @@ -72,7 +68,7 @@ class NotificationViewReactor: Reactor { ) .map(Mutation.notifications) .catch(self.catchClosureNotis), - self.notificationUseCase.notices(lastId: nil, size: 10) + self.notificationUseCase.notices(lastId: nil, size: 10, requestType: .notification) .map(Mutation.notices) .catch(self.catchClosureNotices) ]) @@ -94,7 +90,7 @@ class NotificationViewReactor: Reactor { case .notice: return .concat([ .just(.updateIsRefreshing(true)), - self.notificationUseCase.notices(lastId: nil, size: 10) + self.notificationUseCase.notices(lastId: nil, size: 10, requestType: .notification) .map(Mutation.notices) .catch(self.catchClosureNotices), .just(.updateIsRefreshing(false)) @@ -113,7 +109,7 @@ class NotificationViewReactor: Reactor { ]) case .notice: return .concat([ - self.notificationUseCase.notices(lastId: lastId, size: 10) + self.notificationUseCase.notices(lastId: lastId, size: 10, requestType: .notification) .map(Mutation.moreNotices) .catch(self.catchClosureNoticesMore) ]) @@ -121,49 +117,21 @@ class NotificationViewReactor: Reactor { case let .requestRead(selectedId): return self.notificationUseCase.requestRead(notificationId: selectedId) - .map(Mutation.updateIsReadSuccess) - - case let .updatePushOrRequestReadInfo(pushOrRequestReadInfo): - - /// 읽은 알림 여부 확인 - if pushOrRequestReadInfo.shouldRead { - /// 읽어야 하는 알림일 경우, 읽음 API 호출 - return self.notificationUseCase.requestRead(notificationId: pushOrRequestReadInfo.notificationId) - .withUnretained(self) - .flatMapLatest { object, _ -> Observable in - /// 알림 화면 리로드 - return Observable.zip( - object.notificationUseCase.unreadNotifications(lastId: nil), - object.notificationUseCase.readNotifications(lastId: nil) - ) - .flatMapLatest { unreads, reads -> Observable in - - if let targetCardId = pushOrRequestReadInfo.targetCardId { - - return .concat([ - .just(.notifications(unreads: unreads, reads: reads)), - .just(.updatePushOrRequestReadInfo(nil)), - .just(.updatePushOrRequestReadInfo((pushOrRequestReadInfo.entranceType, targetCardId))) - ]) - } else { - return .concat([ - .just(.notifications(unreads: unreads, reads: reads)), - .just(.updatePushOrRequestReadInfo(nil)) - ]) - } - } + .flatMapLatest { isReadSuccess -> Observable in + if isReadSuccess { + return .concat([ + Observable.zip( + self.notificationUseCase.unreadNotifications(lastId: nil), + self.notificationUseCase.readNotifications(lastId: nil) + ) + .map(Mutation.notifications) + .catch(self.catchClosureNotis), + .just(.updateIsReadSuccess(true)) + ]) + } else { + return .just(.updateIsReadSuccess(false)) } - } else { - - if let targetCardId = pushOrRequestReadInfo.targetCardId { - return .concat([ - .just(.updatePushOrRequestReadInfo(nil)), - .just(.updatePushOrRequestReadInfo((pushOrRequestReadInfo.entranceType, targetCardId))) - ]) - } else { - return .just(.updatePushOrRequestReadInfo(nil)) } - } } } @@ -182,8 +150,6 @@ class NotificationViewReactor: Reactor { newState.notices? += notices case let .updateDisplayType(displayType): newState.displayType = displayType - case let .updatePushOrRequestReadInfo(pushInfo): - newState.pushInfo = pushInfo case let .updateIsRefreshing(isRefreshing): newState.isRefreshing = isRefreshing case let .updateIsReadSuccess(isReadSuccess): @@ -300,4 +266,8 @@ extension NotificationViewReactor { func reactorForDetail(entranceType: EntranceCardType, with id: String) -> DetailViewReactor { DetailViewReactor(dependencies: self.dependencies, entranceType, type: .navi, with: id) } + + func reactorForProfile(with userId: String) -> ProfileViewReactor { + ProfileViewReactor(dependencies: self.dependencies, type: .other, with: userId) + } } diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NoticeViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NoticeViewCell.swift index f1d90fb6..dcdf9c56 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NoticeViewCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NoticeViewCell.swift @@ -57,6 +57,14 @@ class NoticeViewCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } + override func prepareForReuse() { + super.prepareForReuse() + + self.titleLabel.text = nil + self.timeLabel.text = nil + self.contentLabel.text = nil + } + // MARK: Private func diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationPlaceholderViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationPlaceholderViewCell.swift index 16562323..ab3cc695 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationPlaceholderViewCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationPlaceholderViewCell.swift @@ -24,6 +24,7 @@ class NotificationPlaceholderViewCell: UITableViewCell { private let placeholderImage = UIImageView().then { $0.image = .init(.image(.v2(.placeholder_notification))) + $0.contentMode = .scaleAspectFit } private let placeholderLabel = UILabel().then { diff --git a/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationViewCell.swift index 7184f7d0..eb5217b9 100644 --- a/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationViewCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Home/Notification/cells/NotificationViewCell.swift @@ -15,8 +15,8 @@ class NotificationViewCell: UITableViewCell { enum Text { static let cardTitle: String = "카드" static let feedLikeContents: String = "님이 회원님의 카드에 좋아요를 남겼어요." - static let commentLikeContents: String = "님이 회원님의 답카드에 좋아요를 남겼어요." - static let commentWriteContents: String = "님이 답카드를 남겼어요. 알림을 눌러 대화를 이어가 보세요." + static let commentLikeContents: String = "님이 회원님의 댓글카드에 좋아요를 남겼어요." + static let commentWriteContents: String = "님이 댓글카드를 남겼어요. 알림을 눌러 대화를 이어가 보세요." static let followTitle: String = "팔로우" static let followContents: String = "님이 회원님을 팔로우하기 시작했어요." @@ -25,6 +25,9 @@ class NotificationViewCell: UITableViewCell { static let deletedContents: String = "운영정책 위반으로 인해 작성된 카드가 삭제 처리되었습니다." static let blockedLeadingContents: String = "운영정책 위반으로 인해 " static let blockedTrailingContents: String = "까지 카드추가가 제한됩니다." + + static let tagTitle: String = "태그" + static let tagContents: String = "태그가 포함된 카드가 올라왔어요." } static let cellIdentifier = String(reflecting: NotificationViewCell.self) @@ -64,6 +67,14 @@ class NotificationViewCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } + override func prepareForReuse() { + super.prepareForReuse() + + self.titleLabel.text = nil + self.timeGapLabel.text = nil + self.contentLabel.text = nil + } + // MARK: Private func @@ -116,6 +127,8 @@ class NotificationViewCell: UITableViewCell { return (.init(.icon(.v2(.filled(.users)))), .som.v2.pMain) case .deleted, .blocked: return (.init(.icon(.v2(.filled(.danger)))), .som.v2.yMain) + case .tag: + return (.init(.icon(.v2(.filled(.tag)))), .som.v2.pMain) } } @@ -130,6 +143,8 @@ class NotificationViewCell: UITableViewCell { return (Text.deletedAndBlockedTitle, typography) case .blocked: return (Text.deletedAndBlockedTitle, typography) + case .tag: + return (Text.tagTitle, typography) } } @@ -148,6 +163,9 @@ class NotificationViewCell: UITableViewCell { case let .blocked(notification): let timeGapText = notification.notificationInfo.createTime.toKorea().infoReadableTimeTakenFromThis(to: Date().toKorea()) return (timeGapText, typography) + case let .tag(notification): + let timeGapText = notification.notificationInfo.createTime.toKorea().infoReadableTimeTakenFromThis(to: Date().toKorea()) + return (timeGapText, typography) } } @@ -172,6 +190,8 @@ class NotificationViewCell: UITableViewCell { case let .blocked(notification): let text = "\(Text.blockedLeadingContents)\(notification.blockExpirationDateTime.banEndFormatted)\(Text.blockedTrailingContents)" return (text, typography) + case let .tag(notification): + return ("‘\(notification.tagContent)’ \(Text.tagContents)", typography) } } diff --git a/SOOUM/SOOUM/Presentations/Main/MainTabBarController.swift b/SOOUM/SOOUM/Presentations/Main/MainTabBarController.swift index 895ab285..dba3b3ce 100644 --- a/SOOUM/SOOUM/Presentations/Main/MainTabBarController.swift +++ b/SOOUM/SOOUM/Presentations/Main/MainTabBarController.swift @@ -23,7 +23,7 @@ class MainTabBarController: SOMTabBarController, View { static let homeTitle: String = "홈" static let writeTitle: String = "카드추가" static let tagTitle: String = "태그" - static let userTitle: String = "마이" + static let profileTitle: String = "마이" static let banUserDialogTitle: String = "이용 제한 안내" static let banUserDialogFirstLeadingMessage: String = "신고된 카드로 인해 " @@ -99,9 +99,13 @@ class MainTabBarController: SOMTabBarController, View { tag: 2 ) - let userViewController = UIViewController() - userViewController.tabBarItem = .init( - title: Constants.Text.userTitle, + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile() + let profileNavigationController = UINavigationController( + rootViewController: profileViewController + ) + profileNavigationController.tabBarItem = .init( + title: Constants.Text.profileTitle, image: .init(.icon(.v2(.filled(.user)))), tag: 3 ) @@ -110,7 +114,7 @@ class MainTabBarController: SOMTabBarController, View { mainHomeNavigationController, writeCardViewController, tagViewController, - userViewController + profileNavigationController ] self.rx.viewDidLoad @@ -173,7 +177,7 @@ class MainTabBarController: SOMTabBarController, View { } .disposed(by: self.disposeBag) - let couldPosting = reactor.state.map(\.couldPosting).filterNil() + let couldPosting = reactor.state.map(\.couldPosting).distinctUntilChanged().filterNil() couldPosting .filter { $0.isBaned == false } @@ -221,7 +225,7 @@ extension MainTabBarController: SOMTabBarControllerDelegate { return false } - if viewController.tabBarItem.tag == 2 || viewController.tabBarItem.tag == 3 { + if viewController.tabBarItem.tag == 2 { self.showPrepare() return false diff --git a/SOOUM/SOOUM/Presentations/Main/MainTabBarReactor.swift b/SOOUM/SOOUM/Presentations/Main/MainTabBarReactor.swift index c6619121..7852f96e 100644 --- a/SOOUM/SOOUM/Presentations/Main/MainTabBarReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/MainTabBarReactor.swift @@ -121,9 +121,9 @@ extension MainTabBarReactor { // TagsViewReactor(provider: self.provider) // } - // func reactorForProfile() -> ProfileViewReactor { - // ProfileViewReactor(provider: self.provider, type: .my, memberId: nil) - // } + func reactorForProfile() -> ProfileViewReactor { + ProfileViewReactor(dependencies: self.dependencies, type: .myWithNavi) + } func reactorForNoti() -> NotificationViewReactor { NotificationViewReactor(dependencies: self.dependencies) diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/MyProfileViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/MyProfileViewCell.swift deleted file mode 100644 index 627e3541..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/MyProfileViewCell.swift +++ /dev/null @@ -1,189 +0,0 @@ -// -// MyProfileViewCell.swift -// SOOUM -// -// Created by 오현식 on 12/3/24. -// - -import UIKit - -import SnapKit -import Then - -import RxSwift - - -class MyProfileViewCell: UICollectionViewCell { - - enum Text { - static let cardTitle: String = "카드" - static let follingTitle: String = "팔로잉" - static let followerTitle: String = "팔로워" - - static let updateProfileButtonTitle: String = "프로필 수정" - } - - static let cellIdentifier = String(reflecting: MyProfileViewCell.self) - - private let profileImageView = UIImageView().then { - $0.layer.cornerRadius = 128 * 0.5 - $0.clipsToBounds = true - } - - private let totalCardCountLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.head2WithBold - } - private let cardTitleLabel = UILabel().then { - $0.text = Text.cardTitle - $0.textColor = .som.gray500 - $0.typography = .som.caption - } - - let followingButton = UIButton() - private let totalFollowingCountLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.head2WithBold - } - private let followingTitleLabel = UILabel().then { - $0.text = Text.follingTitle - $0.textColor = .som.gray500 - $0.typography = .som.caption - } - - let followerButton = UIButton() - private let totalFollowerCountLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.head2WithBold - } - private let followerTitleLabel = UILabel().then { - $0.text = Text.followerTitle - $0.textColor = .som.gray500 - $0.typography = .som.caption - } - - let updateProfileButton = SOMButton().then { - $0.title = Text.updateProfileButtonTitle - $0.typography = .som.body2WithBold - $0.foregroundColor = .som.white - - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true - } - - var disposeBag = DisposeBag() - - override init(frame: CGRect) { - super.init(frame: .zero) - - self.backgroundColor = .clear - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - - self.disposeBag = DisposeBag() - } - - private func setupConstraints() { - - self.contentView.addSubview(self.profileImageView) - self.profileImageView.snp.makeConstraints { - $0.top.equalToSuperview() - $0.centerX.equalToSuperview() - $0.size.equalTo(128) - } - - let cardContainer = UIStackView(arrangedSubviews: [ - self.totalCardCountLabel, - self.cardTitleLabel - ]).then { - $0.axis = .vertical - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } - cardContainer.snp.makeConstraints { - $0.width.equalTo(48) - $0.height.equalTo(42) - } - - let followingContainer = UIStackView(arrangedSubviews: [ - self.totalFollowingCountLabel, - self.followingTitleLabel - ]).then { - $0.axis = .vertical - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } - followingContainer.snp.makeConstraints { - $0.width.equalTo(48) - $0.height.equalTo(42) - } - followingContainer.addSubview(self.followingButton) - self.followingButton.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - let followerContainer = UIStackView(arrangedSubviews: [ - self.totalFollowerCountLabel, - self.followerTitleLabel - ]).then { - $0.axis = .vertical - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } - followerContainer.snp.makeConstraints { - $0.width.equalTo(48) - $0.height.equalTo(42) - } - followerContainer.addSubview(self.followerButton) - self.followerButton.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - let totalContainer = UIStackView(arrangedSubviews: [ - cardContainer, - followingContainer, - followerContainer - ]).then { - $0.axis = .horizontal - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 12 - } - self.contentView.addSubview(totalContainer) - totalContainer.snp.makeConstraints { - $0.top.equalTo(self.profileImageView.snp.bottom).offset(16) - $0.centerX.equalToSuperview() - } - - self.contentView.addSubview(self.updateProfileButton) - self.updateProfileButton.snp.makeConstraints { - $0.top.equalTo(totalContainer.snp.bottom).offset(18) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.bottom.equalToSuperview().offset(-30) - $0.height.equalTo(48) - } - } - - func setModel(_ profile: Profile) { - if let profileImg = profile.profileImg { - self.profileImageView.setImage(strUrl: profileImg.url, with: "") - } else { - self.profileImageView.image = .init(.image(.defaultStyle(.sooumLogo))) - } - self.totalCardCountLabel.text = profile.cardCnt - self.totalFollowingCountLabel.text = profile.followingCnt - self.totalFollowerCountLabel.text = profile.followerCnt - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/OtherProfileViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/OtherProfileViewCell.swift deleted file mode 100644 index 38620704..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/OtherProfileViewCell.swift +++ /dev/null @@ -1,209 +0,0 @@ -// -// OtherProfileViewCell.swift -// SOOUM -// -// Created by 오현식 on 12/3/24. -// - -import UIKit - -import SnapKit -import Then - -import RxSwift - - -class OtherProfileViewCell: UICollectionViewCell { - - enum Text { - static let cardTitle: String = "카드" - static let follingTitle: String = "팔로잉" - static let followerTitle: String = "팔로워" - - static let followButtonTitle: String = "팔로우하기" - static let didFollowButtonTitle: String = "팔로우 중" - static let blockedFollowButtonTitle: String = "차단 해제" - } - - static let cellIdentifier = String(reflecting: OtherProfileViewCell.self) - - private let profileImageView = UIImageView().then { - $0.layer.cornerRadius = 128 * 0.5 - $0.clipsToBounds = true - } - - private let totalCardCountLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.head2WithBold - } - private let cardTitleLabel = UILabel().then { - $0.text = Text.cardTitle - $0.textColor = .som.gray500 - $0.typography = .som.caption - } - - let followingButton = UIButton() - private let totalFollowingCountLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.head2WithBold - } - private let followingTitleLabel = UILabel().then { - $0.text = Text.follingTitle - $0.textColor = .som.gray500 - $0.typography = .som.caption - } - - let followerButton = UIButton() - private let totalFollowerCountLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.head2WithBold - } - private let followerTitleLabel = UILabel().then { - $0.text = Text.followerTitle - $0.textColor = .som.gray500 - $0.typography = .som.caption - } - - let followButton = SOMButton().then { - $0.image = .init(.icon(.outlined(.plus)))?.resized(.init(width: 16, height: 16), color: .som.white) - - $0.title = Text.followButtonTitle - $0.typography = .som.body2WithBold - $0.foregroundColor = .som.white - - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true - } - - var disposeBag = DisposeBag() - - override init(frame: CGRect) { - super.init(frame: .zero) - - self.backgroundColor = .clear - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - - self.disposeBag = DisposeBag() - } - - private func setupConstraints() { - - self.contentView.addSubview(self.profileImageView) - self.profileImageView.snp.makeConstraints { - $0.top.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.size.equalTo(128) - } - - let cardContainer = UIStackView(arrangedSubviews: [ - self.totalCardCountLabel, - self.cardTitleLabel - ]).then { - $0.axis = .vertical - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } - cardContainer.snp.makeConstraints { - $0.width.equalTo(48) - $0.height.equalTo(42) - } - - let followingContainer = UIStackView(arrangedSubviews: [ - self.totalFollowingCountLabel, - self.followingTitleLabel - ]).then { - $0.axis = .vertical - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } - followingContainer.snp.makeConstraints { - $0.width.equalTo(48) - $0.height.equalTo(42) - } - followingContainer.addSubview(self.followingButton) - self.followingButton.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - let followerContainer = UIStackView(arrangedSubviews: [ - self.totalFollowerCountLabel, - self.followerTitleLabel - ]).then { - $0.axis = .vertical - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } - followerContainer.snp.makeConstraints { - $0.width.equalTo(48) - $0.height.equalTo(42) - } - followerContainer.addSubview(self.followerButton) - self.followerButton.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - let totalContainer = UIStackView(arrangedSubviews: [ - cardContainer, - followingContainer, - followerContainer - ]).then { - $0.axis = .horizontal - $0.alignment = .fill - $0.distribution = .equalSpacing - $0.spacing = 12 - } - self.contentView.addSubview(totalContainer) - totalContainer.snp.makeConstraints { - $0.top.equalToSuperview().offset(43) - $0.leading.equalTo(self.profileImageView.snp.trailing).offset(24) - $0.trailing.equalToSuperview().offset(-20) - } - - self.contentView.addSubview(self.followButton) - self.followButton.snp.makeConstraints { - $0.top.equalTo(self.profileImageView.snp.bottom).offset(22) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.bottom.equalToSuperview().offset(-22) - $0.height.equalTo(48) - } - } - - func setModel(_ profile: Profile, isBlocked: Bool) { - if let profileImg = profile.profileImg { - self.profileImageView.setImage(strUrl: profileImg.url, with: "") - } else { - self.profileImageView.image = .init(.image(.defaultStyle(.sooumLogo))) - } - self.totalCardCountLabel.text = profile.cardCnt - self.totalFollowingCountLabel.text = profile.followingCnt - self.totalFollowerCountLabel.text = profile.followerCnt - - let isFollowing = profile.isFollowing ?? false - - if isBlocked { - self.followButton.image = nil - self.followButton.title = Text.blockedFollowButtonTitle - self.followButton.foregroundColor = .som.white - self.followButton.backgroundColor = .som.p300 - } else { - let image = UIImage(.icon(.outlined(.plus)))?.resized(.init(width: 16, height: 16), color: .som.white) - self.followButton.image = isFollowing ? nil : image - self.followButton.title = isFollowing ? Text.didFollowButtonTitle : Text.followButtonTitle - self.followButton.foregroundColor = isFollowing ? .som.gray600 : .som.white - self.followButton.backgroundColor = isFollowing ? .som.gray200 : .som.p300 - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardViewCell.swift new file mode 100644 index 00000000..c91be1fa --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardViewCell.swift @@ -0,0 +1,103 @@ +// +// ProfileCardViewCell.swift +// SOOUM +// +// Created by 오현식 on 12/3/24. +// + +import UIKit + +import SnapKit +import Then + +class ProfileCardViewCell: UICollectionViewCell { + + static let cellIdentifier = String(reflecting: ProfileCardViewCell.self) + + + // MARK: Views + + private let backgroundImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.clipsToBounds = true + } + + private let backgroundDimView = UIView().then { + $0.backgroundColor = .som.v2.dim + $0.layer.cornerRadius = 4 + $0.clipsToBounds = true + } + + private let contentLabel = UILabel().then { + $0.textColor = .som.v2.white + $0.textAlignment = .center + $0.typography = .som.v2.caption4 + $0.numberOfLines = 8 + $0.lineBreakMode = .byTruncatingTail + $0.lineBreakStrategy = .hangulWordPriority + } + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.backgroundImageView.image = nil + self.contentLabel.text = nil + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.backgroundImageView) + self.backgroundImageView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + self.backgroundImageView.addSubview(self.backgroundDimView) + self.backgroundDimView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(12) + $0.trailing.equalToSuperview().offset(-12) + } + + self.backgroundDimView.addSubview(self.contentLabel) + self.contentLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(6) + $0.bottom.equalToSuperview().offset(-6) + $0.leading.equalToSuperview().offset(8) + $0.trailing.equalToSuperview().offset(-8) + } + } + + + // MARK: Public func + + func setModel(_ model: ProfileCardInfo) { + + self.backgroundImageView.setImage(strUrl: model.imgURL, with: model.imgName) + self.contentLabel.text = model.content + self.contentLabel.textAlignment = .center + let typography: Typography + switch model.font { + case .pretendard: typography = .som.v2.caption4 + case .ridi: typography = .som.v2.ridiProfile + case .yoonwoo: typography = .som.v2.yoonwooProfile + case .kkookkkook: typography = .som.v2.kkookkkookProfile + } + self.contentLabel.typography = typography + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardsPlaceholderViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardsPlaceholderViewCell.swift new file mode 100644 index 00000000..12c36f9e --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardsPlaceholderViewCell.swift @@ -0,0 +1,72 @@ +// +// ProfileCardsPlaceholderViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/7/25. +// + +import UIKit + +import SnapKit +import Then + +class ProfileCardsPlaceholderViewCell: UICollectionViewCell { + + enum Text { + static let message: String = "카드가 없어요" + } + + static let cellIdentifier = String(reflecting: ProfileCardsPlaceholderViewCell.self) + + + // MARK: Views + + private let placeholderImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.filled(.card)))) + $0.tintColor = .som.v2.gray200 + $0.contentMode = .scaleAspectFit + } + + private let placeholderMessageLabel = UILabel().then { + $0.text = Text.message + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.body1 + } + + + // MARK: Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = .clear + self.isUserInteractionEnabled = false + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.placeholderImageView) + self.placeholderImageView.snp.makeConstraints { + /// (screen height - safe layout guide top - navi height - user cell height) * 0.5 - (icon height + spacing + label height) * 0.5 - tabBar height + let offset = (UIScreen.main.bounds.height - (48 + 84 + 76 + 48 + 16)) * 0.5 - 53 * 0.5 - 88 + $0.top.equalTo(self.safeAreaLayoutGuide.snp.top).offset(offset) + $0.centerX.equalToSuperview() + $0.height.equalTo(24) + } + + self.contentView.addSubview(self.placeholderMessageLabel) + self.placeholderMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.placeholderImageView.snp.bottom).offset(8) + $0.centerX.equalToSuperview() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardsViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardsViewCell.swift new file mode 100644 index 00000000..dfc1da59 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileCardsViewCell.swift @@ -0,0 +1,282 @@ +// +// ProfileCardsViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/7/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa +import RxSwift + +class ProfileCardsViewCell: UICollectionViewCell { + + enum Text { + static let blockedText: String = "차단한 계정입니다" + } + + enum Section: Int, CaseIterable { + case feed + case comment + case empty + } + + enum Item: Hashable { + case feed(ProfileCardInfo) + case comment(ProfileCardInfo) + case empty + } + + static let cellIdentifier = String(reflecting: ProfileCardsViewCell.self) + + + // MARK: Views + + private let flowLayout = UICollectionViewFlowLayout().then { + $0.scrollDirection = .vertical + $0.minimumLineSpacing = 1 + $0.minimumInteritemSpacing = 1 + } + private lazy var collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: self.flowLayout + ).then { + $0.contentInset = .zero + + $0.isScrollEnabled = false + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.register( + ProfileCardViewCell.self, + forCellWithReuseIdentifier: ProfileCardViewCell.cellIdentifier + ) + $0.register( + ProfileCardsPlaceholderViewCell.self, + forCellWithReuseIdentifier: ProfileCardsPlaceholderViewCell.cellIdentifier + ) + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(collectionView: self.collectionView) { [weak self] collectionView, indexPath, item -> UICollectionViewCell? in + + guard let self = self else { return nil } + + switch item { + case let .feed(profileCardInfo): + + let cell: ProfileCardViewCell = self.cell(collectionView, cellForItemAt: indexPath) + cell.setModel(profileCardInfo) + + return cell + case let .comment(profileCardInfo): + + let cell: ProfileCardViewCell = self.cell(collectionView, cellForItemAt: indexPath) + cell.setModel(profileCardInfo) + + return cell + case .empty: + + return self.placeholder(collectionView, cellForItemAt: indexPath) + } + } + + private(set) var feedCardInfos = [ProfileCardInfo]() + private(set) var commentCardInfos: [ProfileCardInfo]? + private(set) var selectedCardType: EntranceCardType = .feed + + + // MARK: Variables + Rx + + var disposeBag = DisposeBag() + + // let cardDidTap = PublishRelay() + let moreFindCards = PublishRelay<(type: EntranceCardType, lastId: String)>() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.disposeBag = DisposeBag() + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.collectionView) + self.collectionView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + + // MARK: Public func + + func setModels( + type selectedCardType: EntranceCardType, + feed feedCardInfos: [ProfileCardInfo], + comment commentCardInfos: [ProfileCardInfo]? + ) { + + self.selectedCardType = selectedCardType + self.feedCardInfos = feedCardInfos + self.commentCardInfos = commentCardInfos + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + + switch selectedCardType { + case .feed: + + guard feedCardInfos.isEmpty == false else { + snapshot.appendItems([.empty], toSection: .empty) + break + } + + let new = feedCardInfos.map { Item.feed($0) } + snapshot.appendItems(new, toSection: .feed) + case .comment: + + guard let commentCardInfos = commentCardInfos else { return } + + guard commentCardInfos.isEmpty == false else { + snapshot.appendItems([.empty], toSection: .empty) + break + } + + let new = commentCardInfos.map { Item.comment($0) } + snapshot.appendItems(new, toSection: .comment) + } + + self.dataSource.apply(snapshot, animatingDifferences: false) + } +} + +extension ProfileCardsViewCell { + + func cell( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> ProfileCardViewCell { + + let cell: ProfileCardViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: ProfileCardViewCell.cellIdentifier, + for: indexPath + ) as! ProfileCardViewCell + + return cell + } + + func placeholder( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> ProfileCardsPlaceholderViewCell { + + let placeholder: ProfileCardsPlaceholderViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: ProfileCardsPlaceholderViewCell.cellIdentifier, + for: indexPath + ) as! ProfileCardsPlaceholderViewCell + + return placeholder + } +} + + +// MARK: UICollectionViewDelegateFlowLayout + +extension ProfileCardsViewCell: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + // TODO: 추후 개발 예정 + // guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return } + // + // var selectedId: String? { + // switch item { + // case let .feed(selectedCard): + // return selectedCard.id + // case let .comment(selectedCard): + // return selectedCard.id + // case .empty: + // return nil + // } + // } + // + // guard let selectedId = selectedId else { return } + // + // self.cardDidTap.accept(selectedId) + } + + func collectionView( + _ collectionView: UICollectionView, + willDisplay cell: UICollectionViewCell, + forItemAt indexPath: IndexPath + ) { + + switch self.selectedCardType { + case .feed: + + let lastItemIndexPath = collectionView.numberOfItems(inSection: Section.feed.rawValue) - 1 + if self.feedCardInfos.isEmpty == false, + indexPath.section == Section.feed.rawValue, + indexPath.item == lastItemIndexPath, + let lastId = self.feedCardInfos.last?.id { + + self.moreFindCards.accept((.feed, lastId)) + } + case .comment: + + let lastItemIndexPath = collectionView.numberOfItems(inSection: Section.comment.rawValue) - 1 + if self.commentCardInfos?.isEmpty == false, + indexPath.section == Section.comment.rawValue, + indexPath.item == lastItemIndexPath, + let lastId = self.commentCardInfos?.last?.id { + + self.moreFindCards.accept((.comment, lastId)) + } + } + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { + return collectionView.bounds.size + } + + switch item { + case .empty: + return collectionView.bounds.size + default: + let width: CGFloat = (collectionView.bounds.width - 2) / 3 + return CGSize(width: width, height: width) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileUserViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileUserViewCell.swift new file mode 100644 index 00000000..192d2f43 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileUserViewCell.swift @@ -0,0 +1,350 @@ +// +// ProfileViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/6/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa +import RxSwift + +class ProfileUserViewCell: UICollectionViewCell { + + enum Text { + static let totalVisitedTitle: String = "Total" + static let todayVisitedTitle: String = "Today" + static let cardCntTitle: String = "카드" + static let followerCntTitle: String = "팔로워" + static let followingCntTitle: String = "팔로잉" + static let updateProfileButtonTitle: String = "프로필 편집" + static let followButtonTitle: String = "팔로우" + static let followingButtonTitle: String = "팔로잉" + static let unBlockButtonTitle: String = "차단 해제" + } + + static let cellIdentifier = String(reflecting: ProfileUserViewCell.self) + + // MARK: Views + + private let visitedAndNicknameContainer = UIStackView().then { + $0.axis = .vertical + $0.alignment = .leading + $0.distribution = .fill + $0.spacing = 2 + } + + private let visitedCountContainer = UIView().then { + $0.isHidden = true + } + + private let totalVisitedTitleLabel = UILabel().then { + $0.text = Text.totalVisitedTitle + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption2 + } + private let totalVisitedCountLabel = UILabel().then { + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption2 + } + + private let dot = UIView().then { + $0.backgroundColor = .som.v2.gray400 + $0.layer.cornerRadius = 3 * 0.5 + } + + private let todayVisitedTitleLabel = UILabel().then { + $0.text = Text.todayVisitedTitle + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption2 + } + private let todayVisitedCountLabel = UILabel().then { + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption2 + } + + private let nicknameLabel = UILabel().then { + $0.textColor = .som.v2.black + $0.typography = .som.v2.head3 + } + + private let profilImageView = UIImageView().then { + $0.image = .init(.image(.v2(.profile_large))) + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .som.v2.gray300 + $0.layer.cornerRadius = 60 * 0.5 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.som.v2.gray300.cgColor + $0.clipsToBounds = true + } + + private let bottomContainer = UIStackView().then { + $0.axis = .horizontal + $0.alignment = .top + $0.distribution = .equalSpacing + $0.spacing = 0 + } + + let updateProfileButton = SOMButton().then { + $0.title = Text.updateProfileButtonTitle + $0.typography = .som.v2.subtitle1 + $0.foregroundColor = .som.v2.gray600 + $0.backgroundColor = .som.v2.gray100 + } + + let followButton = SOMButton().then { + $0.title = Text.followButtonTitle + $0.typography = .som.v2.subtitle1 + $0.foregroundColor = .som.v2.white + $0.backgroundColor = .som.v2.black + + $0.isHidden = true + } + + let unBlockButton = SOMButton().then { + $0.title = Text.unBlockButtonTitle + $0.typography = .som.v2.subtitle1 + $0.foregroundColor = .som.v2.white + $0.backgroundColor = .som.v2.black + + $0.isHidden = true + } + + + // MARK: Variables + + private(set) var model: ProfileInfo = .defaultValue + + + // MARK: Variables + Rx + + var disposeBag = DisposeBag() + + let cardContainerDidTap = PublishRelay() + let followerContainerDidTap = PublishRelay() + let followingContainerDidTap = PublishRelay() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: .zero) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.disposeBag = DisposeBag() + } + + + // MARK: Private func + + private func setupConstraints() { + + let topContainer = UIView() + self.addSubview(topContainer) + topContainer.snp.makeConstraints { + $0.top.horizontalEdges.equalToSuperview() + $0.height.equalTo(84) + } + + topContainer.addSubview(self.visitedAndNicknameContainer) + self.visitedAndNicknameContainer.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + } + + self.visitedCountContainer.addSubview(self.totalVisitedTitleLabel) + self.totalVisitedTitleLabel.snp.makeConstraints { + $0.verticalEdges.leading.equalToSuperview() + } + self.visitedCountContainer.addSubview(self.totalVisitedCountLabel) + self.totalVisitedCountLabel.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.leading.equalTo(self.totalVisitedTitleLabel.snp.trailing).offset(4) + } + + self.visitedCountContainer.addSubview(self.dot) + self.dot.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.totalVisitedCountLabel.snp.trailing).offset(7.5) + $0.size.equalTo(3) + } + + self.visitedCountContainer.addSubview(self.todayVisitedTitleLabel) + self.todayVisitedTitleLabel.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.leading.equalTo(self.dot.snp.trailing).offset(7.5) + } + self.visitedCountContainer.addSubview(self.todayVisitedCountLabel) + self.todayVisitedCountLabel.snp.makeConstraints { + $0.verticalEdges.trailing.equalToSuperview() + $0.leading.equalTo(self.todayVisitedTitleLabel.snp.trailing).offset(4) + } + + self.visitedAndNicknameContainer.addArrangedSubview(self.visitedCountContainer) + self.visitedAndNicknameContainer.addArrangedSubview(self.nicknameLabel) + + topContainer.addSubview(self.profilImageView) + self.profilImageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.visitedAndNicknameContainer.snp.trailing).offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.size.equalTo(60) + } + + self.addSubview(self.bottomContainer) + self.bottomContainer.snp.makeConstraints { + $0.top.equalTo(topContainer.snp.bottom) + $0.leading.equalToSuperview().offset(16) + $0.height.equalTo(76) + } + + self.addSubview(self.updateProfileButton) + self.updateProfileButton.snp.makeConstraints { + $0.top.equalTo(self.bottomContainer.snp.bottom) + $0.bottom.equalToSuperview().offset(-16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(48) + } + + self.addSubview(self.followButton) + self.followButton.snp.makeConstraints { + $0.top.equalTo(self.bottomContainer.snp.bottom) + $0.bottom.equalToSuperview().offset(-16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(48) + } + + self.addSubview(self.unBlockButton) + self.unBlockButton.snp.makeConstraints { + $0.top.equalTo(self.bottomContainer.snp.bottom) + $0.bottom.equalToSuperview().offset(-16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(48) + } + } + + + // MARK: public func + + func setModel(_ model: ProfileInfo) { + + self.model = model + + self.visitedCountContainer.isHidden = model.cardCnt == "0" + self.totalVisitedCountLabel.text = model.totalVisitCnt + self.totalVisitedCountLabel.typography = .som.v2.caption2 + self.todayVisitedCountLabel.text = model.todayVisitCnt + self.todayVisitedCountLabel.typography = .som.v2.caption2 + + self.nicknameLabel.text = model.nickname + self.nicknameLabel.typography = .som.v2.head3 + + if let profileImageUrl = model.profileImageUrl { + self.profilImageView.setImage(strUrl: profileImageUrl, with: model.profileImgName) + } else { + self.profilImageView.image = .init(.image(.v2(.profile_medium))) + } + + var contents: [(content: ProfileInfo.Content, count: String)] { + var contents: [(content: ProfileInfo.Content, count: String)] = [] + + contents.append((.card, model.cardCnt)) + contents.append((.follower, model.followerCnt)) + contents.append((.following, model.followingCnt)) + + return contents + } + self.setupItems(contents) + + self.updateProfileButton.isHidden = model.isAlreadyFollowing != nil + if let isAlreadyFollowing = model.isAlreadyFollowing, let isBlocked = model.isBlocked { + + self.followButton.isHidden = isBlocked + self.unBlockButton.isHidden = isBlocked == false + + self.updateButton(isAlreadyFollowing) + } + } + + /// 상대방 프로필 일 때만 사용 + func updateButton(_ isFollowing: Bool) { + + self.followButton.title = isFollowing ? Text.followingButtonTitle : Text.followButtonTitle + self.followButton.foregroundColor = isFollowing ? .som.v2.gray600 : .som.v2.white + self.followButton.backgroundColor = isFollowing ? .som.v2.gray100 : .som.v2.black + } +} + +private extension ProfileUserViewCell { + + func setupItems(_ items: [(content: ProfileInfo.Content, count: String)]) { + + self.bottomContainer.arrangedSubviews.forEach { $0.removeFromSuperview() } + + items.forEach { item in + + let topSpacing = UIView() + let bottomSpacing = UIView() + + let titleLabel = UILabel().then { + $0.text = item.content.rawValue + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.body1.withAlignment(.left) + } + + let countLabel = UILabel().then { + $0.text = item.count + $0.textColor = .som.v2.black + $0.typography = .som.v2.title1.withAlignment(.left) + } + + let container = UIStackView(arrangedSubviews: [topSpacing, titleLabel, countLabel, bottomSpacing]).then { + $0.axis = .vertical + $0.alignment = .leading + $0.distribution = .equalSpacing + $0.spacing = 0 + } + container.snp.makeConstraints { + $0.width.equalTo(72) + $0.height.equalTo(64) + } + + topSpacing.snp.makeConstraints { + $0.height.equalTo(8) + } + bottomSpacing.snp.makeConstraints { + $0.height.equalTo(8) + } + + container.rx.tapGesture() + .when(.recognized) + .throttle(.seconds(1), scheduler: MainScheduler.instance) + .subscribe(with: self) { object, _ in + switch item.content { + case .card: object.cardContainerDidTap.accept(()) + case .follower: object.followerContainerDidTap.accept(()) + case .following: object.followingContainerDidTap.accept(()) + } + } + .disposed(by: self.disposeBag) + + self.bottomContainer.addArrangedSubview(container) + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewFooter.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewFooter.swift deleted file mode 100644 index 0b93d0b4..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewFooter.swift +++ /dev/null @@ -1,180 +0,0 @@ -// -// ProfileViewFooter.swift -// SOOUM -// -// Created by 오현식 on 12/3/24. -// - -import UIKit - -import SnapKit -import Then - -import RxCocoa -import RxSwift - - -class ProfileViewFooter: UICollectionReusableView { - - enum Text { - static let blockedText: String = "차단한 계정입니다" - } - - private let flowLayout = UICollectionViewFlowLayout().then { - $0.scrollDirection = .vertical - $0.minimumLineSpacing = .zero - $0.minimumInteritemSpacing = .zero - $0.sectionInset = .zero - $0.estimatedItemSize = .zero - } - private lazy var collectionView = UICollectionView( - frame: .zero, - collectionViewLayout: self.flowLayout - ).then { - $0.alwaysBounceVertical = true - - $0.decelerationRate = .fast - - $0.contentInsetAdjustmentBehavior = .never - $0.contentInset = .zero - - $0.showsHorizontalScrollIndicator = false - - $0.register(ProfileViewFooterCell.self, forCellWithReuseIdentifier: ProfileViewFooterCell.cellIdentifier) - - $0.dataSource = self - $0.delegate = self - } - - private let blockedLabel = UILabel().then { - $0.text = Text.blockedText - $0.textColor = .som.gray400 - $0.typography = .som.body1WithBold - $0.isHidden = true - } - - private(set) var writtenCards = [WrittenCard]() - - private var currentOffset: CGFloat = 0 - private var isLoadingMore: Bool = false - - let didTap = PublishRelay() - let moreDisplay = PublishRelay() - - var disposeBag = DisposeBag() - - override init(frame: CGRect) { - super.init(frame: frame) - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - - self.disposeBag = DisposeBag() - } - - private func setupConstraints() { - - self.addSubview(self.collectionView) - self.collectionView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - self.addSubview(self.blockedLabel) - self.blockedLabel.snp.makeConstraints { - $0.center.equalToSuperview() - } - } - - func setModel(_ writtenCards: [WrittenCard], isBlocked: Bool) { - - self.isLoadingMore = false - - self.blockedLabel.isHidden = isBlocked == false - self.collectionView.isHidden = isBlocked - - self.writtenCards = writtenCards - - UIView.performWithoutAnimation { - self.collectionView.performBatchUpdates { - self.collectionView.reloadSections(IndexSet(integer: 0)) - } - } - } -} - -extension ProfileViewFooter: UICollectionViewDataSource { - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return self.writtenCards.count - } - - func collectionView( - _ collectionView: UICollectionView, - cellForItemAt indexPath: IndexPath - ) -> UICollectionViewCell { - - let cell: ProfileViewFooterCell = collectionView.dequeueReusableCell( - withReuseIdentifier: ProfileViewFooterCell.cellIdentifier, - for: indexPath - ) as! ProfileViewFooterCell - let writtenCard = self.writtenCards[indexPath.item] - cell.setModel(writtenCard) - - return cell - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let selectedId = self.writtenCards[indexPath.item].id - self.didTap.accept(selectedId) - } -} - -extension ProfileViewFooter: UICollectionViewDelegateFlowLayout { - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath - ) -> CGSize { - let width: CGFloat = UIScreen.main.bounds.width / 3 - return CGSize(width: width, height: width) - } - - func collectionView( - _ collectionView: UICollectionView, - willDisplay cell: UICollectionViewCell, - forItemAt indexPath: IndexPath - ) { - guard self.writtenCards.isEmpty == false else { return } - - let lastSectionIndex = collectionView.numberOfSections - 1 - let lastRowIndex = collectionView.numberOfItems(inSection: lastSectionIndex) - 1 - - if self.isLoadingMore, indexPath.section == lastSectionIndex, indexPath.item == lastRowIndex { - - self.isLoadingMore = false - - let lastId = self.writtenCards[indexPath.item].id - self.moreDisplay.accept(lastId) - } - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - - let offset = scrollView.contentOffset.y - - // 당겨서 새로고침 상황일 때 - guard offset > 0 else { return } - - // 아래로 스크롤 중일 때, 데이터 추가로드 가능 - self.isLoadingMore = offset > self.currentOffset - self.currentOffset = offset - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewFooterCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewFooterCell.swift deleted file mode 100644 index da39fe2d..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewFooterCell.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// ProfileViewFooterCell.swift -// SOOUM -// -// Created by 오현식 on 12/3/24. -// - -import UIKit - -import SnapKit -import Then - - -class ProfileViewFooterCell: UICollectionViewCell { - - static let cellIdentifier = String(reflecting: ProfileViewFooterCell.self) - - private let backgroundImageView = UIImageView() - - private let backgroundDimView = UIView().then { - $0.backgroundColor = .som.black.withAlphaComponent(0.2) - } - - private let contentLabel = UILabel().then { - $0.textColor = .som.white - $0.textAlignment = .center - $0.typography = .som.body3WithRegular - $0.numberOfLines = 0 - $0.lineBreakMode = .byTruncatingTail - } - - override init(frame: CGRect) { - super.init(frame: frame) - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - - self.backgroundImageView.image = nil - self.contentLabel.text = nil - } - - private func setupConstraints() { - - self.contentView.addSubview(self.backgroundImageView) - self.backgroundImageView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - self.backgroundImageView.addSubview(self.backgroundDimView) - self.backgroundDimView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - self.backgroundImageView.addSubview(self.contentLabel) - self.contentLabel.snp.makeConstraints { - $0.top.leading.equalToSuperview().offset(10) - $0.bottom.trailing.equalToSuperview().offset(-10) - } - } - - func setModel(_ writtenCard: WrittenCard) { - self.backgroundImageView.setImage(strUrl: writtenCard.backgroundImgURL.url, with: "") - // self.contentLabel.typography = writtenCard.font == .pretendard ? .som.body3WithRegular : .som.schoolBody1WithLight - self.contentLabel.typography = .som.body3WithRegular - self.contentLabel.text = writtenCard.content - self.contentLabel.textAlignment = .center - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewHeader.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewHeader.swift new file mode 100644 index 00000000..f56981de --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Cells/ProfileViewHeader.swift @@ -0,0 +1,83 @@ +// +// ProfileViewFooterHeader.swift +// SOOUM +// +// Created by 오현식 on 11/7/25. +// + +import UIKit + +import SnapKit +import Then + +import RxCocoa +import RxSwift + +class ProfileViewHeader: UICollectionReusableView { + + enum Text { + static let tabFeedTitle: String = "카드" + static let tabCommentTitle: String = "댓글카드" + } + + static let cellIdentifier = String(reflecting: ProfileViewHeader.self) + + + // MARK: Views + + private lazy var stickyTabBar = SOMStickyTabBar(alignment: .center).then { + $0.items = [Text.tabFeedTitle, Text.tabCommentTitle] + $0.spacing = 24 + $0.delegate = self + } + + + // MARK: Variables + Rx + + var disposeBag = DisposeBag() + + let tabBarItemDidTap = PublishRelay() + + + // MARK: Initialize + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override func prepareForReuse() { + super.prepareForReuse() + + self.disposeBag = DisposeBag() + } + + + // MARK: Private func + + private func setupConstraints() { + + self.backgroundColor = .som.v2.white + + self.addSubview(self.stickyTabBar) + self.stickyTabBar.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } +} + +extension ProfileViewHeader: SOMStickyTabBarDelegate { + + func tabBar(_ tabBar: SOMStickyTabBar, didSelectTabAt index: Int) { + + self.tabBarItemDidTap.accept(index) + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/FollowPlaceholderViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/FollowPlaceholderViewCell.swift new file mode 100644 index 00000000..8124a1b5 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/FollowPlaceholderViewCell.swift @@ -0,0 +1,80 @@ +// +// FollowPlaceholderViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/8/25. +// + +import UIKit + +import SnapKit +import Then + +class FollowPlaceholderViewCell: UITableViewCell { + + static let cellIdentifier = String(reflecting: FollowPlaceholderViewCell.self) + + + // MARK: Views + + private let placeholderImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.filled(.users)))) + $0.tintColor = .som.v2.gray200 + $0.contentMode = .scaleAspectFit + } + + private let placeholderMessageLabel = UILabel().then { + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.body1 + } + + + // MARK: Variables + + var placeholderText: String? { + set { + self.placeholderMessageLabel.text = newValue + self.placeholderMessageLabel.typography = .som.v2.body1 + } + get { + return self.placeholderMessageLabel.text + } + } + + + // MARK: Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.backgroundColor = .clear + self.selectionStyle = .none + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.placeholderImageView) + self.placeholderImageView.snp.makeConstraints { + /// (screen height - safe layout guide top - navi height - header height) * 0.5 - (icon height + spacing + label height) + let offset = (UIScreen.main.bounds.height - 48 - 56) * 0.5 - (24 + 8 + 21) * 0.5 + $0.top.equalTo(self.safeAreaLayoutGuide.snp.top).offset(offset) + $0.centerX.equalToSuperview() + $0.height.equalTo(24) + } + + self.contentView.addSubview(self.placeholderMessageLabel) + self.placeholderMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.placeholderImageView.snp.bottom).offset(8) + $0.centerX.equalToSuperview() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/FollowerViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/FollowerViewCell.swift new file mode 100644 index 00000000..abb8d0e2 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/FollowerViewCell.swift @@ -0,0 +1,153 @@ +// +// FollowerViewCell.swift +// SOOUM +// +// Created by 오현식 on 12/7/24. +// + +import UIKit + +import SnapKit +import Then + +import RxSwift + +class FollowerViewCell: UITableViewCell { + + enum Text { + static let willFollowButton: String = "팔로우" + static let didFollowButton: String = "팔로잉" + } + + static let cellIdentifier = String(reflecting: FollowerViewCell.self) + + + // MARK: Views + + let profileBackgroundButton = UIButton() + private let profileImageView = UIImageView().then { + $0.image = .init(.image(.v2(.profile_small))) + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .som.v2.gray300 + $0.layer.cornerRadius = 36 * 0.5 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.som.v2.gray300.cgColor + $0.clipsToBounds = true + } + + private let nicknameLabel = UILabel().then { + $0.textColor = .som.v2.gray600 + $0.typography = .som.v2.subtitle2 + } + + let followButton = SOMButton().then { + $0.title = Text.willFollowButton + $0.typography = .som.v2.body1 + $0.foregroundColor = .som.v2.white + + $0.backgroundColor = .som.v2.black + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + } + + + // MARK: Variables + + private(set) var model: FollowInfo = .defaultValue + + + // MARK: Variables + Rx + + var disposeBag = DisposeBag() + + + // MARK: Initalization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.backgroundColor = .clear + self.selectionStyle = .none + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override func prepareForReuse() { + super.prepareForReuse() + + // UI 초기화 + self.profileImageView.image = nil + self.nicknameLabel.text = nil + self.updateButton(false) + + self.disposeBag = DisposeBag() + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.profileImageView) + self.profileImageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.size.equalTo(36) + } + + self.contentView.addSubview(self.nicknameLabel) + self.nicknameLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.profileImageView.snp.trailing).offset(10) + } + + self.contentView.addSubview(self.profileBackgroundButton) + self.profileBackgroundButton.snp.makeConstraints { + $0.top.equalTo(self.profileImageView.snp.top) + $0.bottom.equalTo(self.profileImageView.snp.bottom) + $0.leading.equalTo(self.profileImageView.snp.leading) + $0.trailing.equalTo(self.nicknameLabel.snp.trailing) + } + + self.contentView.addSubview(self.followButton) + self.followButton.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.nicknameLabel.snp.trailing).offset(10) + $0.trailing.equalToSuperview().offset(-16) + $0.width.equalTo(68) + $0.height.equalTo(32) + } + } + + + // MARK: Public func + + func setModel(_ model: FollowInfo) { + + if let profileImageUrl = model.profileImageUrl { + self.profileImageView.setImage(strUrl: profileImageUrl) + } else { + self.profileImageView.image = .init(.image(.v2(.profile_small))) + } + self.nicknameLabel.text = model.nickname + self.nicknameLabel.typography = .som.v2.subtitle2 + + self.followButton.isHidden = model.isRequester + + self.updateButton(model.isFollowing) + } + + func updateButton(_ isFollowing: Bool) { + + self.followButton.title = isFollowing ? Text.didFollowButton : Text.willFollowButton + self.followButton.foregroundColor = isFollowing ? .som.v2.gray600 : .som.v2.white + self.followButton.backgroundColor = isFollowing ? .som.v2.gray100 : .som.v2.black + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/MyFollowerViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/MyFollowerViewCell.swift deleted file mode 100644 index 43688824..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/MyFollowerViewCell.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// MyFollowerViewCell.swift -// SOOUM -// -// Created by 오현식 on 12/7/24. -// - -import UIKit - -import SnapKit -import Then - -import RxSwift - - -class MyFollowerViewCell: UITableViewCell { - - enum Text { - static let didFollowButton: String = "팔로잉" - static let willFollowButton: String = "팔로우" - } - - static let cellIdentifier = String(reflecting: MyFollowerViewCell.self) - - let profilBackgroundButton = UIButton() - - private let profileImageView = UIImageView().then { - $0.layer.cornerRadius = 46 * 0.5 - $0.clipsToBounds = true - } - - private let profileNickname = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.body1WithBold - } - - let followButton = SOMButton().then { - $0.title = Text.willFollowButton - $0.typography = .som.body3WithBold - $0.foregroundColor = .som.white - - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 26 * 0.5 - $0.clipsToBounds = true - } - - var disposeBag = DisposeBag() - - - // MARK: Initalization - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - self.backgroundColor = .clear - self.contentView.clipsToBounds = true - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - - // MARK: Override func - - override func prepareForReuse() { - super.prepareForReuse() - - // UI 초기화 - self.profileImageView.image = nil - self.profileNickname.text = nil - self.updateButton(false) - - self.disposeBag = DisposeBag() - } - - - // MARK: Private func - - private func setupConstraints() { - - self.contentView.addSubview(self.profileImageView) - self.profileImageView.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.size.equalTo(46) - } - - self.contentView.addSubview(self.profileNickname) - self.profileNickname.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalTo(self.profileImageView.snp.trailing).offset(12) - } - - self.contentView.addSubview(self.followButton) - self.followButton.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.greaterThanOrEqualTo(self.profileNickname.snp.trailing).offset(40) - $0.trailing.equalToSuperview().offset(-20) - $0.width.equalTo(72) - $0.height.equalTo(26) - } - - self.contentView.addSubview(self.profilBackgroundButton) - self.profilBackgroundButton.snp.makeConstraints { - $0.top.equalTo(self.profileImageView.snp.top) - $0.bottom.equalTo(self.profileImageView.snp.bottom) - $0.leading.equalTo(self.profileImageView.snp.leading) - $0.trailing.equalTo(self.profileNickname.snp.trailing) - } - } - - - // MARK: Public func - - func setModel(_ follow: Follow) { - - if let url = follow.backgroundImgURL?.url { - self.profileImageView.setImage(strUrl: url, with: "") - } else { - self.profileImageView.image = .init(.image(.defaultStyle(.sooumLogo))) - } - self.profileNickname.text = follow.nickname - } - - func updateButton(_ isFollowing: Bool) { - - self.followButton.title = isFollowing ? Text.didFollowButton : Text.willFollowButton - self.followButton.foregroundColor = isFollowing ? .som.gray600 : .som.white - self.followButton.backgroundColor = isFollowing ? .som.gray200 : .som.p300 - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/MyFollowingViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/MyFollowingViewCell.swift index d4b6b1bc..74d712f3 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/MyFollowingViewCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/MyFollowingViewCell.swift @@ -12,56 +12,61 @@ import Then import RxSwift - class MyFollowingViewCell: UITableViewCell { enum Text { - static let didFollowButton: String = "팔로우 취소" - static let willFollowButton: String = "팔로우" + static let followingButtonTitle: String = "팔로잉" } static let cellIdentifier = String(reflecting: MyFollowingViewCell.self) - let profilBackgroundButton = UIButton() + // MARK: Views + + let profileBackgroundButton = UIButton() private let profileImageView = UIImageView().then { - $0.layer.cornerRadius = 46 * 0.5 + $0.image = .init(.image(.v2(.profile_small))) + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .som.v2.gray300 + $0.layer.cornerRadius = 36 * 0.5 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.som.v2.gray300.cgColor $0.clipsToBounds = true } - private let profileNickname = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.body1WithBold + private let nicknameLabel = UILabel().then { + $0.textColor = .som.v2.gray600 + $0.typography = .som.v2.subtitle2 } let cancelFollowButton = SOMButton().then { - $0.title = Text.didFollowButton - $0.typography = .som.body3WithBold - $0.foregroundColor = .som.gray400 - $0.hasUnderlined = true - } - - let followButton = SOMButton().then { - $0.title = Text.willFollowButton - $0.typography = .som.body3WithBold - $0.foregroundColor = .som.white + $0.title = Text.followingButtonTitle + $0.typography = .som.v2.body1 + $0.foregroundColor = .som.v2.gray600 - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 26 * 0.5 + $0.backgroundColor = .som.v2.gray100 + $0.layer.cornerRadius = 8 $0.clipsToBounds = true - $0.isHidden = true } + + // MARK: Variables + + private(set) var model: FollowInfo = .defaultValue + + + // MARK: Variables + Rx + var disposeBag = DisposeBag() - // MARK: Initalization + // MARK: Initialization override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) self.backgroundColor = .clear - self.contentView.clipsToBounds = true + self.selectionStyle = .none self.setupConstraints() } @@ -77,7 +82,7 @@ class MyFollowingViewCell: UITableViewCell { super.prepareForReuse() self.profileImageView.image = nil - self.profileNickname.text = nil + self.nicknameLabel.text = nil self.disposeBag = DisposeBag() } @@ -90,58 +95,48 @@ class MyFollowingViewCell: UITableViewCell { self.contentView.addSubview(self.profileImageView) self.profileImageView.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.size.equalTo(46) + $0.leading.equalToSuperview().offset(16) + $0.size.equalTo(36) } - self.contentView.addSubview(self.profileNickname) - self.profileNickname.snp.makeConstraints { + self.contentView.addSubview(self.nicknameLabel) + self.nicknameLabel.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.leading.equalTo(self.profileImageView.snp.trailing).offset(12) + $0.leading.equalTo(self.profileImageView.snp.trailing).offset(10) } - self.contentView.addSubview(self.cancelFollowButton) - self.cancelFollowButton.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.greaterThanOrEqualTo(self.profileNickname.snp.trailing).offset(40) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(29) - } - - self.contentView.addSubview(self.followButton) - self.followButton.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.greaterThanOrEqualTo(self.profileNickname.snp.trailing).offset(40) - $0.trailing.equalToSuperview().offset(-20) - $0.width.equalTo(72) - $0.height.equalTo(26) - } - - self.contentView.addSubview(self.profilBackgroundButton) - self.profilBackgroundButton.snp.makeConstraints { + self.contentView.addSubview(self.profileBackgroundButton) + self.profileBackgroundButton.snp.makeConstraints { $0.top.equalTo(self.profileImageView.snp.top) $0.bottom.equalTo(self.profileImageView.snp.bottom) $0.leading.equalTo(self.profileImageView.snp.leading) - $0.trailing.equalTo(self.profileNickname.snp.trailing) + $0.trailing.equalTo(self.nicknameLabel.snp.trailing) + } + + self.contentView.addSubview(self.cancelFollowButton) + self.cancelFollowButton.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.nicknameLabel.snp.trailing).offset(10) + $0.trailing.equalToSuperview().offset(-16) + $0.width.equalTo(68) + $0.height.equalTo(32) } } // MARK: Public func - func setModel(_ follow: Follow) { + func setModel(_ model: FollowInfo) { - if let url = follow.backgroundImgURL?.url { - self.profileImageView.setImage(strUrl: url, with: "") + self.model = model + + if let profileImageUrl = model.profileImageUrl { + self.profileImageView.setImage(strUrl: profileImageUrl) } else { - self.profileImageView.image = .init(.image(.defaultStyle(.sooumLogo))) + self.profileImageView.image = .init(.image(.v2(.profile_small))) } - self.profileNickname.text = follow.nickname - } - - func updateButton(_ isFollowing: Bool) { - self.cancelFollowButton.isHidden = isFollowing == false - self.followButton.isHidden = isFollowing + self.nicknameLabel.text = model.nickname + self.nicknameLabel.typography = .som.v2.subtitle2 } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/OtherFollowViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/OtherFollowViewCell.swift deleted file mode 100644 index b5584621..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/Cells/OtherFollowViewCell.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// OtherFollowViewCell.swift -// SOOUM -// -// Created by 오현식 on 12/4/24. -// - -import UIKit - -import SnapKit -import Then - -import RxSwift - - -class OtherFollowViewCell: UITableViewCell { - - enum Text { - static let didFollowButton: String = "팔로잉" - static let willFollowButton: String = "팔로우" - } - - static let cellIdentifier = String(reflecting: OtherFollowViewCell.self) - - let profilBackgroundButton = UIButton() - - private let profileImageView = UIImageView().then { - $0.layer.cornerRadius = 46 * 0.5 - $0.clipsToBounds = true - } - - private let profileNickname = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.body1WithBold - } - - let followButton = SOMButton().then { - $0.title = Text.willFollowButton - $0.typography = .som.body3WithBold - $0.foregroundColor = .som.white - - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 26 * 0.5 - $0.clipsToBounds = true - } - - var disposeBag = DisposeBag() - - - // MARK: Initalization - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - self.backgroundColor = .clear - self.contentView.clipsToBounds = true - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - - // MARK: Override func - - override func prepareForReuse() { - super.prepareForReuse() - - // UI 초기화 - self.profileImageView.image = nil - self.profileNickname.text = nil - self.updateButton(false) - - self.disposeBag = DisposeBag() - } - - - // MARK: Private func - - private func setupConstraints() { - - self.contentView.addSubview(self.profileImageView) - self.profileImageView.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(20) - $0.size.equalTo(46) - } - - self.contentView.addSubview(self.profileNickname) - self.profileNickname.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalTo(self.profileImageView.snp.trailing).offset(12) - } - - self.contentView.addSubview(self.followButton) - self.followButton.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.greaterThanOrEqualTo(self.profileNickname.snp.trailing).offset(40) - $0.trailing.equalToSuperview().offset(-20) - $0.width.equalTo(72) - $0.height.equalTo(26) - } - - self.contentView.addSubview(self.profilBackgroundButton) - self.profilBackgroundButton.snp.makeConstraints { - $0.top.equalTo(self.profileImageView.snp.top) - $0.bottom.equalTo(self.profileImageView.snp.bottom) - $0.leading.equalTo(self.profileImageView.snp.leading) - $0.trailing.equalTo(self.profileNickname.snp.trailing) - } - } - - - // MARK: Public func - - func setModel(_ follow: Follow) { - - if let url = follow.backgroundImgURL?.url { - self.profileImageView.setImage(strUrl: url, with: "") - } else { - self.profileImageView.image = .init(.image(.defaultStyle(.sooumLogo))) - } - self.profileNickname.text = follow.nickname - - self.followButton.isHidden = follow.isRequester - } - - func updateButton(_ isFollowing: Bool) { - - self.followButton.title = isFollowing ? Text.didFollowButton : Text.willFollowButton - self.followButton.foregroundColor = isFollowing ? .som.gray600 : .som.white - self.followButton.backgroundColor = isFollowing ? .som.gray200 : .som.p300 - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewController.swift index 44af9ed7..902d1dc1 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewController.swift @@ -14,12 +14,40 @@ import ReactorKit import RxCocoa import RxSwift - class FollowViewController: BaseNavigationViewController, View { enum Text { static let followerTitle: String = "팔로워" static let followingTitle: String = "팔로잉" + + static let followerPlaceholderMessage: String = "팔로우하는 사람이 없어요" + static let followingPlaceholderMessage: String = "팔로우하고 있는 사람이 없어요" + + static let deleteFollowingDialogTitle: String = "님을 팔로워에서 삭제하시겠어요?" + + static let cancelActionTitle: String = "취소" + static let deleteActionTitle: String = "삭제하기" + } + + enum Section: Int, CaseIterable { + case follower + case following + case empty + } + + enum Item: Hashable { + case follower(FollowInfo) + case following(FollowInfo) + case empty + } + + + // MARK: Views + + private lazy var stickyTabBar = SOMStickyTabBar(alignment: .center).then { + $0.items = [Text.followerTitle, Text.followingTitle] + $0.spacing = 24 + $0.delegate = self } private lazy var tableView = UITableView().then { @@ -27,27 +55,185 @@ class FollowViewController: BaseNavigationViewController, View { $0.indicatorStyle = .black $0.separatorStyle = .none - $0.decelerationRate = .fast + $0.contentInsetAdjustmentBehavior = .never + $0.alwaysBounceVertical = true + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.register(FollowerViewCell.self, forCellReuseIdentifier: FollowerViewCell.cellIdentifier) $0.register(MyFollowingViewCell.self, forCellReuseIdentifier: MyFollowingViewCell.cellIdentifier) - $0.register(MyFollowerViewCell.self, forCellReuseIdentifier: MyFollowerViewCell.cellIdentifier) - $0.register(OtherFollowViewCell.self, forCellReuseIdentifier: OtherFollowViewCell.cellIdentifier) + $0.register(FollowPlaceholderViewCell.self, forCellReuseIdentifier: FollowPlaceholderViewCell.cellIdentifier) $0.refreshControl = SOMRefreshControl() - $0.dataSource = self $0.delegate = self } - override var navigationBarHeight: CGFloat { - 46 + + // MARK: Variables + + typealias DataSource = UITableViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(tableView: self.tableView) { [weak self] tableView, indexPath, item -> UITableViewCell? in + + guard let self = self, let reactor = self.reactor else { return nil } + + switch item { + case let .follower(follower): + + let cell: FollowerViewCell = tableView.dequeueReusableCell( + withIdentifier: FollowerViewCell.cellIdentifier, + for: indexPath + ) as! FollowerViewCell + + cell.setModel(follower) + + cell.profileBackgroundButton.rx.throttleTap + .subscribe(with: self) { object, _ in + if follower.isRequester { + guard let navigationController = object.navigationController, + let tabBarController = navigationController.parent as? SOMTabBarController + else { return } + + if navigationController.viewControllers.first?.isKind(of: ProfileViewController.self) == true { + + object.navigationPopToRoot(animated: false, bottomBarHidden: false) + } else { + + tabBarController.didSelectedIndex(3) + navigationController.viewControllers.removeAll(where: { $0.isKind(of: HomeViewController.self) == false }) + } + } else { + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile(follower.memberId) + object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) + } + } + .disposed(by: cell.disposeBag) + + cell.followButton.rx.throttleTap + .subscribe(with: self) { object, _ in + if follower.isFollowing { + object.showdeleteFollowingDialog( + nickname: follower.nickname, + with: follower.memberId + ) + } else { + reactor.action.onNext(.updateFollow(follower.memberId, true)) + } + } + .disposed(by: cell.disposeBag) + + return cell + case let .following(following): + + switch reactor.viewType { + case .my: + + let cell: MyFollowingViewCell = tableView.dequeueReusableCell( + withIdentifier: MyFollowingViewCell.cellIdentifier, + for: indexPath + ) as! MyFollowingViewCell + + cell.setModel(following) + + cell.profileBackgroundButton.rx.throttleTap + .subscribe(with: self) { object, _ in + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile(following.memberId) + object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) + } + .disposed(by: cell.disposeBag) + + cell.cancelFollowButton.rx.throttleTap + .subscribe(with: self) { object, _ in + object.showdeleteFollowingDialog( + nickname: following.nickname, + with: following.memberId + ) + } + .disposed(by: cell.disposeBag) + + return cell + case .other: + + let cell: FollowerViewCell = tableView.dequeueReusableCell( + withIdentifier: FollowerViewCell.cellIdentifier, + for: indexPath + ) as! FollowerViewCell + + cell.setModel(following) + + cell.profileBackgroundButton.rx.throttleTap + .subscribe(with: self) { object, _ in + if following.isRequester { + guard let navigationController = object.navigationController, + let tabBarController = navigationController.parent as? SOMTabBarController + else { return } + + if navigationController.viewControllers.first?.isKind(of: ProfileViewController.self) == true { + + object.navigationPopToRoot(animated: false, bottomBarHidden: false) + } else { + + tabBarController.didSelectedIndex(3) + navigationController.viewControllers.removeAll(where: { $0.isKind(of: HomeViewController.self) == false }) + } + } else { + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile(following.memberId) + object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) + } + } + .disposed(by: cell.disposeBag) + + cell.followButton.rx.throttleTap + .subscribe(with: self) { object, _ in + if following.isFollowing { + object.showdeleteFollowingDialog( + nickname: following.nickname, + with: following.memberId + ) + } else { + reactor.action.onNext(.updateFollow(following.memberId, true)) + } + } + .disposed(by: cell.disposeBag) + + return cell + } + case .empty: + + let placeholder: FollowPlaceholderViewCell = tableView.dequeueReusableCell( + withIdentifier: FollowPlaceholderViewCell.cellIdentifier, + for: indexPath + ) as! FollowPlaceholderViewCell + + placeholder.placeholderText = reactor.entranceType == .follower ? + Text.followerPlaceholderMessage : + Text.followingPlaceholderMessage + + return placeholder + } } - private(set) var follows = [Follow]() + private(set) var followers: [FollowInfo] = [] + private(set) var followings: [FollowInfo] = [] + private var initialOffset: CGFloat = 0 private var currentOffset: CGFloat = 0 private var isRefreshEnabled: Bool = true - private var isLoadingMore: Bool = false + private var shouldRefreshing: Bool = false + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + padding + return 34 + 8 + } // MARK: Override func @@ -55,123 +241,189 @@ class FollowViewController: BaseNavigationViewController, View { override func setupNaviBar() { super.setupNaviBar() - let title = self.reactor?.entranceType == .following ? Text.followingTitle : Text.followerTitle - self.navigationBar.title = title + " (0)" + guard let reactor = self.reactor else { return } + + self.navigationBar.title = reactor.nickname } override func setupConstraints() { super.setupConstraints() + self.view.addSubview(self.stickyTabBar) + self.stickyTabBar.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) + $0.horizontalEdges.equalToSuperview() + } + self.view.addSubview(self.tableView) self.tableView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) - $0.bottom.leading.trailing.equalToSuperview() + $0.top.equalTo(self.stickyTabBar.snp.bottom) + $0.bottom.horizontalEdges.equalToSuperview() } } + override func bind() { + // 뒤로가기 시 상대 팔로우 화면이면 하단 네비바 숨김 + self.navigationBar.backButton.rx.throttleTap + .subscribe(with: self) { object, _ in + object.navigationPop( + animated: true, + bottomBarHidden: object.reactor?.viewType == .other + ) + } + .disposed(by: self.disposeBag) + } + // MARK: ReactorKit - bind func bind(reactor: FollowViewReactor) { + // 팔로우 == 0, 팔로잉 == 1 + let viewDidLoad = self.rx.viewDidLoad.share() + viewDidLoad + .subscribe(with: self.stickyTabBar) { stickyTabBar, _ in + stickyTabBar.didSelectTabBarItem(reactor.entranceType == .follower ? 0 : 1) + } + .disposed(by: self.disposeBag) + // Action - self.rx.viewDidLoad + viewDidLoad .map { _ in Reactor.Action.landing } .bind(to: reactor.action) .disposed(by: self.disposeBag) - let isLoading = reactor.state.map(\.isLoading).distinctUntilChanged().share() + let isRefreshing = reactor.state.map(\.isRefreshing).distinctUntilChanged().share() self.tableView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(isLoading) + .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 - isLoading - .do(onNext: { [weak self] isLoading in - if isLoading { self?.isLoadingMore = false } - }) - .subscribe(with: self.tableView) { tableView, isLoading in - if isLoading { - // tableView.refreshControl?.beginRefreshingFromTop() - } else { - tableView.refreshControl?.endRefreshing() - } + isRefreshing + .filter { $0 == false } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self.tableView) { tableView, _ in + tableView.refreshControl?.endRefreshing() } .disposed(by: self.disposeBag) - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .do(onNext: { [weak self] isProcessing in - if isProcessing { self?.isLoadingMore = false } - }) - .bind(to: self.activityIndicatorView.rx.isAnimating) - .disposed(by: self.disposeBag) - - let follows = reactor.state.map(\.follows).distinctUntilChanged().share() - follows - .map { - let title = reactor.entranceType == .following ? Text.followingTitle : Text.followerTitle - return title + "(\($0.count))" + reactor.state.map { + FollowViewReactor.DisplayStates( + followType: $0.followType, + followers: $0.followers, + followings: $0.followings + ) + } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, displayStates in + + var followerTabItem: String { + if displayStates.followers.isEmpty == false { + return Text.followerTitle + " \(displayStates.followers.count)" + } + return Text.followerTitle } - .bind(to: self.navigationBar.rx.title) - .disposed(by: self.disposeBag) - follows - .subscribe(with: self) { object, follows in - object.follows = follows - object.tableView.reloadData() + var followingTabItem: String { + if displayStates.followings.isEmpty == false { + return Text.followingTitle + " \(displayStates.followings.count)" + } + return Text.followingTitle } - .disposed(by: self.disposeBag) - - reactor.state.map(\.isRequest) - .distinctUntilChanged() - .filter { $0 } - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) + + object.stickyTabBar.items = [followerTabItem, followingTabItem] + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + + switch displayStates.followType { + case .follower: + + guard displayStates.followers.isEmpty == false else { + snapshot.appendItems([.empty], toSection: .empty) + break + } + + let new = displayStates.followers.map { Item.follower($0) } + snapshot.appendItems(new, toSection: .follower) + case .following: + + guard displayStates.followings.isEmpty == false else { + snapshot.appendItems([.empty], toSection: .empty) + break + } + + let new = displayStates.followings.map { Item.following($0) } + snapshot.appendItems(new, toSection: .following) + } + + object.dataSource.apply(snapshot, animatingDifferences: false) + } + .disposed(by: self.disposeBag) - reactor.state.map(\.isCancel) - .distinctUntilChanged() + reactor.pulse(\.$isUpdated) + .filterNil() .filter { $0 } - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) + .subscribe(with: self) { object, _ in + reactor.action.onNext(.landing) + NotificationCenter.default.post(name: .reloadProfileData, object: nil, userInfo: nil) + } .disposed(by: self.disposeBag) } } -extension FollowViewController: UITableViewDataSource { - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - self.follows.count - } + + +// MARK: show Dialog + +private extension FollowViewController { - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - guard let reactor = self.reactor else { return .init(frame: .zero) } + func showdeleteFollowingDialog(nickname: String, with userId: String) { - switch reactor.viewType { - case .my: - switch reactor.entranceType { - case .following: - - return self.cellForMyFollowing(indexPath, reactor: reactor) - case .follower: - - return self.cellForMyFollower(indexPath, reactor: reactor) + let cancelAction = SOMDialogAction( + title: Text.cancelActionTitle, + style: .gray, + action: { + UIApplication.topViewController?.dismiss(animated: true) } - case .other: - - return self.cellForOtherFollow(indexPath, reactor: reactor) - } + ) + let deleteAction = SOMDialogAction( + title: Text.deleteActionTitle, + style: .red, + action: { + UIApplication.topViewController?.dismiss(animated: true) { + self.reactor?.action.onNext(.updateFollow(userId, false)) + } + } + ) + + SOMDialogViewController.show( + title: nickname + Text.deleteFollowingDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [cancelAction, deleteAction] + ) } } + +// MARK: UITableViewDelegate + extension FollowViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return 74 + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return 0 } + + switch item { + case .empty: + return tableView.bounds.height + default: + return 60 + } } func tableView( @@ -179,170 +431,79 @@ extension FollowViewController: UITableViewDelegate { willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath ) { - guard self.follows.isEmpty == false else { return } - let lastSectionIndex = tableView.numberOfSections - 1 - let lastRowIndex = tableView.numberOfRows(inSection: lastSectionIndex) - 1 + guard let reactor = self.reactor else { return } - if self.isLoadingMore, indexPath.section == lastSectionIndex, indexPath.row == lastRowIndex { - let lastId = self.follows[indexPath.row].id - self.reactor?.action.onNext(.moreFind(lastId: lastId)) + switch reactor.currentState.followType { + case .follower: + + let lastItemIndexPath = tableView.numberOfRows(inSection: Section.follower.rawValue) - 1 + if self.followers.isEmpty == false, + indexPath.section == Section.follower.rawValue, + indexPath.row == lastItemIndexPath, + let lastId = self.followers.last?.id { + + reactor.action.onNext(.moreFind(type: .follower, lastId: lastId)) + } + case .following: + + let lastItemIndexPath = tableView.numberOfRows(inSection: Section.following.rawValue) - 1 + if self.followings.isEmpty == false, + indexPath.section == Section.following.rawValue, + indexPath.item == lastItemIndexPath, + let lastId = self.followings.last?.id { + + reactor.action.onNext(.moreFind(type: .following, lastId: lastId)) + } } } + + // MARK: UIScrollViewDelegate + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y - // currentOffset <= 0 && isLoading == false 일 때, 테이블 뷰 새로고침 가능 - self.isRefreshEnabled = (offset <= 0 && self.reactor?.currentState.isLoading == false) + // currentOffset <= 0 && isRefreshing == false 일 때, 테이블 뷰 새로고침 가능 + self.isRefreshEnabled = (offset <= 0) && (self.reactor?.currentState.isRefreshing == false) } func scrollViewDidScroll(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y - // 당겨서 새로고침 상황일 때 - guard offset > 0 else { return } + // 당겨서 새로고침 + if self.isRefreshEnabled, offset < self.initialOffset { + guard let refreshControl = self.tableView.refreshControl else { + self.currentOffset = offset + return + } + + let pulledOffset = self.initialOffset - offset + let refreshingOffset = refreshControl.frame.origin.y + refreshControl.frame.height + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + 10 + } - // 아래로 스크롤 중일 때, 데이터 추가로드 가능 - self.isLoadingMore = offset > self.currentOffset self.currentOffset = offset } - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - - let offset = scrollView.contentOffset.y + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if self.isRefreshEnabled, - let refreshControl = self.tableView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { - - // refreshControl.beginRefreshingFromTop() + if self.shouldRefreshing { + self.tableView.refreshControl?.beginRefreshing() } } } -extension FollowViewController { - - private func cellForMyFollowing(_ indexPath: IndexPath, reactor: FollowViewReactor) -> MyFollowingViewCell { - - let model = self.follows[indexPath.row] - - let cell: MyFollowingViewCell = self.tableView.dequeueReusableCell( - withIdentifier: MyFollowingViewCell.cellIdentifier, - for: indexPath - ) as! MyFollowingViewCell - cell.selectionStyle = .none - cell.setModel(model) - cell.updateButton(model.isFollowing) - - cell.profilBackgroundButton.rx.tap - .subscribe(with: self) { object, _ in - - if model.isRequester { - - let profileViewController = ProfileViewController() - profileViewController.reactor = reactor.reactorForProfile(type: .myWithNavi, model.id) - object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) - } else { - - let profileViewController = ProfileViewController() - profileViewController.reactor = reactor.reactorForProfile(type: .other, model.id) - object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) - } - } - .disposed(by: cell.disposeBag) - - cell.cancelFollowButton.rx.throttleTap(.seconds(1)) - .subscribe(onNext: { _ in - reactor.action.onNext(.cancel(model.id)) - }) - .disposed(by: cell.disposeBag) - - cell.followButton.rx.throttleTap(.seconds(1)) - .subscribe(onNext: { _ in - reactor.action.onNext(.request(model.id)) - }) - .disposed(by: cell.disposeBag) - - return cell - } +extension FollowViewController: SOMStickyTabBarDelegate { - private func cellForMyFollower(_ indexPath: IndexPath, reactor: FollowViewReactor) -> MyFollowerViewCell { - - let model = self.follows[indexPath.row] - - let cell: MyFollowerViewCell = self.tableView.dequeueReusableCell( - withIdentifier: MyFollowerViewCell.cellIdentifier, - for: indexPath - ) as! MyFollowerViewCell - cell.selectionStyle = .none - cell.setModel(model) - cell.updateButton(model.isFollowing) - - cell.profilBackgroundButton.rx.tap - .subscribe(with: self) { object, _ in - - if model.isRequester { - - let profileViewController = ProfileViewController() - profileViewController.reactor = reactor.reactorForProfile(type: .myWithNavi, model.id) - object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) - } else { - - let profileViewController = ProfileViewController() - profileViewController.reactor = reactor.reactorForProfile(type: .other, model.id) - object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) - } - } - .disposed(by: cell.disposeBag) - - cell.followButton.rx.throttleTap(.seconds(1)) - .subscribe(onNext: { _ in - reactor.action.onNext(model.isFollowing ? .cancel(model.id) : .request(model.id)) - }) - .disposed(by: cell.disposeBag) - - return cell - } - - private func cellForOtherFollow(_ indexPath: IndexPath, reactor: FollowViewReactor) -> OtherFollowViewCell { - - let model = self.follows[indexPath.row] - - let cell: OtherFollowViewCell = self.tableView.dequeueReusableCell( - withIdentifier: OtherFollowViewCell.cellIdentifier, - for: indexPath - ) as! OtherFollowViewCell - cell.selectionStyle = .none - cell.setModel(model) - cell.updateButton(model.isFollowing) - - cell.profilBackgroundButton.rx.tap - .subscribe(with: self) { object, _ in - - if model.isRequester { - - let profileViewController = ProfileViewController() - profileViewController.reactor = reactor.reactorForProfile(type: .myWithNavi, model.id) - object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) - } else { - - let profileViewController = ProfileViewController() - profileViewController.reactor = reactor.reactorForProfile(type: .other, model.id) - object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) - } - } - .disposed(by: cell.disposeBag) - - cell.followButton.rx.throttleTap(.seconds(1)) - .subscribe(with: self) { object, _ in - reactor.action.onNext(model.isFollowing ? .cancel(model.id) : .request(model.id)) - } - .disposed(by: cell.disposeBag) + func tabBar(_ tabBar: SOMStickyTabBar, didSelectTabAt index: Int) { - return cell + self.reactor?.action.onNext(.updateFollowType(index == 0 ? .follower : .following)) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewReactor.swift index 3c41963c..d6ffb5fc 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Follow/FollowViewReactor.swift @@ -9,230 +9,174 @@ import ReactorKit import Alamofire - class FollowViewReactor: Reactor { - enum EntranceType { - case following - case follower - } - - enum ViewType { - case my - case other + struct DisplayStates { + let followType: EntranceType + let followers: [FollowInfo] + let followings: [FollowInfo] } enum Action: Equatable { case landing case refresh - case moreFind(lastId: String) - case request(String) - case cancel(String) + case moreFind(type: EntranceType, lastId: String) + case updateFollowType(EntranceType) + case updateFollow(String, Bool) } enum Mutation { - case follows([Follow]) - case more([Follow]) - case updateIsRequest(Bool) - case updateIsCancel(Bool) - case updateIsProcessing(Bool) - case updateIsLoading(Bool) + case followers([FollowInfo]) + case followings([FollowInfo]) + case moreFollowers([FollowInfo]) + case moreFollowings([FollowInfo]) + case updateFollowType(EntranceType) + case updateIsUpdated(Bool?) + case updateIsRefreshing(Bool) } struct State { - var follows: [Follow] - var isRequest: Bool - var isCancel: Bool - var isProcessing: Bool - var isLoading: Bool + fileprivate(set) var followers: [FollowInfo] + fileprivate(set) var followings: [FollowInfo] + fileprivate(set) var followType: EntranceType + @Pulse fileprivate(set) var isUpdated: Bool? + fileprivate(set) var isRefreshing: Bool } var initialState: State = .init( - follows: [], - isRequest: false, - isCancel: false, - isProcessing: false, - isLoading: false + followers: [], + followings: [], + followType: .follower, + isUpdated: nil, + isRefreshing: false ) - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let userUseCase: UserUseCase + let entranceType: EntranceType let viewType: ViewType - let memberId: String? + let nickname: String + private let userId: String init( - provider: ManagerProviderType, + dependencies: AppDIContainerable, type entranceType: EntranceType, view viewType: ViewType, - memberId: String? = nil + nickname: String, + with userId: String ) { - self.provider = provider + self.dependencies = dependencies + self.userUseCase = dependencies.rootContainer.resolve(UserUseCase.self) self.entranceType = entranceType self.viewType = viewType - self.memberId = memberId + self.nickname = nickname + self.userId = userId } func mutate(action: Action) -> Observable { switch action { case .landing: - return .concat([ - .just(.updateIsProcessing(true)), - self.refresh() - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsProcessing(false)) - ]) + + return self.refresh() case .refresh: + + let emit = self.currentState.followType == .follower ? + self.userUseCase.followers(userId: self.userId, lastId: nil) + .map(Mutation.followers) + .catch { _ in + return .concat([ + .just(.updateIsRefreshing(false)), + .just(.followers([])) + ]) + } : + self.userUseCase.followings(userId: self.userId, lastId: nil) + .map(Mutation.followings) + .catch { _ in + return .concat([ + .just(.updateIsRefreshing(false)), + .just(.followings([])) + ]) + } + return .concat([ - .just(.updateIsLoading(true)), - self.refresh() - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsLoading(false)) - ]) - case let .moreFind(lastId): - return .concat([ - .just(.updateIsProcessing(true)), - self.more(lastId: lastId) - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsProcessing(false)) + .just(.updateIsRefreshing(true)), + emit, + .just(.updateIsRefreshing(false)) ]) - case let .request(memberId): - let request: ProfileRequest = .requestFollow(memberId: memberId) + case let .moreFind(type, lastId): + + let emit = type == .follower ? + self.userUseCase.followers(userId: self.userId, lastId: lastId) + .map(Mutation.moreFollowers) : + self.userUseCase.followings(userId: self.userId, lastId: lastId) + .map(Mutation.moreFollowings) - return self.provider.networkManager.request(Empty.self, request: request) - .flatMapLatest { _ -> Observable in - return .just(.updateIsRequest(true)) - } + return emit + case let .updateFollowType(followType): - case let .cancel(memberId): - let request: ProfileRequest = .cancelFollow(memberId: memberId) + return .just(.updateFollowType(followType)) + case let .updateFollow(userId, isFollow): - return self.provider.networkManager.request(Empty.self, request: request) - .flatMapLatest { _ -> Observable in - return .just(.updateIsCancel(true)) - } + return .concat([ + .just(.updateIsUpdated(nil)), + self.userUseCase.updateFollowing(userId: userId, isFollow: isFollow) + .map(Mutation.updateIsUpdated) + ]) } } func reduce(state: State, mutation: Mutation) -> State { - var state: State = state + var newState: State = state switch mutation { - case let .follows(follows): - state.follows = follows - state.isRequest = false - state.isCancel = false - case let .more(follows): - state.follows += follows - state.isRequest = false - state.isCancel = false - case let .updateIsRequest(isRequest): - state.isRequest = isRequest - case let .updateIsCancel(isCancel): - state.isCancel = isCancel - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing - case let .updateIsLoading(isLoading): - state.isLoading = isLoading + case let .followers(followers): + newState.followers = followers + case let .followings(followings): + newState.followings = followings + case let .moreFollowers(followers): + newState.followers += followers + case let .moreFollowings(followings): + newState.followings += followings + case let .updateFollowType(followType): + newState.followType = followType + case let .updateIsUpdated(isUpdated): + newState.isUpdated = isUpdated + case let .updateIsRefreshing(isRefreshing): + newState.isRefreshing = isRefreshing } - return state + return newState } } -extension FollowViewReactor { +private extension FollowViewReactor { - private func refresh() -> Observable { + func refresh() -> Observable { - var request: ProfileRequest { - switch self.entranceType { - case .following: - switch self.viewType { - case .my: - return .myFollowing(lastId: nil) - case .other: - return .otherFollowing(memberId: self.memberId ?? "", lastId: nil) - } - case .follower: - switch self.viewType { - case .my: - return .myFollower(lastId: nil) - case .other: - return .otherFollower(memberId: self.memberId ?? "", lastId: nil) - } - } - } - - switch self.entranceType { - case .following: - return self.provider.networkManager.request(FollowingResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.follows(response.embedded.followings)) - } - .catch(self.catchClosure) - case .follower: - return self.provider.networkManager.request(FollowerResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.follows(response.embedded.followers)) - } - .catch(self.catchClosure) - } - } - - private func more(lastId: String) -> Observable { - - var request: ProfileRequest { - switch self.entranceType { - case .following: - switch self.viewType { - case .my: - return .myFollowing(lastId: lastId) - case .other: - return .otherFollowing(memberId: self.memberId ?? "", lastId: lastId) - } - case .follower: - switch self.viewType { - case .my: - return .myFollower(lastId: lastId) - case .other: - return .otherFollower(memberId: self.memberId ?? "", lastId: lastId) - } - } - } - - switch self.entranceType { - case .following: - return self.provider.networkManager.request(FollowingResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.more(response.embedded.followings)) - } - .catch(self.catchClosure) - case .follower: - return self.provider.networkManager.request(FollowerResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.more(response.embedded.followers)) - } - .catch(self.catchClosure) - } + return .concat([ + self.userUseCase.followers(userId: self.userId, lastId: nil) + .map(Mutation.followers), + self.userUseCase.followings(userId: self.userId, lastId: nil) + .map(Mutation.followings) + ]) } } extension FollowViewReactor { - func reactorForProfile(type: ProfileViewReactor.EntranceType, _ memberId: String) -> ProfileViewReactor { - ProfileViewReactor(provider: self.provider, type: type, memberId: memberId) + func reactorForProfile( _ userId: String) -> ProfileViewReactor { + ProfileViewReactor(dependencies: self.dependencies, type: .other, with: userId) } - - // func reactorForMainTabBar() -> MainTabBarReactor { - // MainTabBarReactor(provider: self.provider) - // } } extension FollowViewReactor { - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.updateIsProcessing(false)), - .just(.updateIsLoading(false)) - ]) - } + enum EntranceType { + case follower + case following + } + + enum ViewType { + case my + case other } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewController.swift index 85a15294..b19a2712 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewController.swift @@ -5,12 +5,13 @@ // Created by 오현식 on 12/3/24. // - import UIKit import SnapKit import Then +import Kingfisher + import ReactorKit import RxCocoa import RxSwift @@ -19,37 +20,45 @@ import RxSwift class ProfileViewController: BaseNavigationViewController, View { enum Text { - static let blockButtonTitle: String = "차단하기" + static let navigationTitle: String = "마이" + static let navigationBlockButtonTitle: String = "차단" + + static let blockDialogTitle: String = "차단하시겠어요?" + static let blockDialogMessage: String = "님의 모든 카드를 볼 수 없어요." + + static let unBlockUserDialogTitle: String = "차단 해제하시겠어요?" + static let unBlockUserDialogMessage: String = "님을 팔로우하고, 카드를 볼 수 있어요." - static let blockDialogTitle: String = "정말 차단하시겠어요?" - static let blockDialogMessage: String = "해당 사용자의 카드와 답카드를 볼 수 없어요" + static let deleteFollowingDialogTitle: String = "님을 팔로워에서 삭제하시겠어요?" static let cancelActionTitle: String = "취소" - static let confirmActionTitle: String = "확인" + static let blockActionTitle: String = "차단하기" + static let unBlockActionTitle: String = "차단 해제" + static let deleteActionTitle: String = "삭제하기" } - - // MARK: Navi Views - - private let titleView = UILabel().then { - $0.textColor = .som.gray800 - $0.typography = .som.body1WithBold + enum Section: Int, CaseIterable { + case user + case card } - private let subTitleView = UILabel().then { - $0.textColor = .som.gray400 - $0.typography = .som.body3WithRegular + enum Item: Hashable { + case user(ProfileInfo) + case card(type: EntranceCardType, feed: [ProfileCardInfo], comment: [ProfileCardInfo]?) } + + // MARK: Navi Views + private let rightBlockButton = SOMButton().then { - $0.title = Text.blockButtonTitle - $0.typography = .som.body3WithBold - $0.foregroundColor = .som.gray500 + $0.title = Text.navigationBlockButtonTitle + $0.typography = .som.v2.subtitle1 + $0.foregroundColor = .som.v2.black } private let rightSettingButton = SOMButton().then { - $0.image = .init(.icon(.outlined(.menu))) - $0.foregroundColor = .som.black + $0.image = .init(.icon(.v2(.outlined(.settings)))) + $0.foregroundColor = .som.v2.black } @@ -57,12 +66,19 @@ class ProfileViewController: BaseNavigationViewController, View { private let flowLayout = UICollectionViewFlowLayout().then { $0.scrollDirection = .vertical + $0.sectionHeadersPinToVisibleBounds = true + $0.sectionInset = .zero } private lazy var collectionView = UICollectionView( frame: .zero, collectionViewLayout: self.flowLayout ).then { - $0.backgroundColor = .som.white + $0.backgroundColor = .som.v2.white + + $0.contentInset.top = 0 + $0.contentInset.bottom = 54 + 16 + + $0.contentInsetAdjustmentBehavior = .never $0.alwaysBounceVertical = true $0.showsVerticalScrollIndicator = false @@ -70,30 +86,173 @@ class ProfileViewController: BaseNavigationViewController, View { $0.refreshControl = SOMRefreshControl() - $0.register(MyProfileViewCell.self, forCellWithReuseIdentifier: MyProfileViewCell.cellIdentifier) - $0.register(OtherProfileViewCell.self, forCellWithReuseIdentifier: OtherProfileViewCell.cellIdentifier) + $0.register(ProfileUserViewCell.self, forCellWithReuseIdentifier: ProfileUserViewCell.cellIdentifier) + $0.register(ProfileCardsViewCell.self, forCellWithReuseIdentifier: ProfileCardsViewCell.cellIdentifier) $0.register( - ProfileViewFooter.self, - forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, - withReuseIdentifier: "footer" + ProfileViewHeader.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: ProfileViewHeader.cellIdentifier ) - $0.dataSource = self $0.delegate = self } // MARK: Variables - private(set) var profile = Profile() - private(set) var writtenCards = [WrittenCard]() - private(set) var isBlocked = false + typealias DataSource = UICollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot - override var navigationBarHeight: CGFloat { - 68 - } + private lazy var dataSource: DataSource = { + + let dataSource = DataSource(collectionView: self.collectionView) { [weak self] collectionView, indexPath, item -> UICollectionViewCell? in + + guard let self = self, let reactor = self.reactor else { return nil } + + switch item { + case let .user(profileInfo): + + let cell: ProfileUserViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: ProfileUserViewCell.cellIdentifier, + for: indexPath + ) as! ProfileUserViewCell + + cell.setModel(profileInfo) + + cell.cardContainerDidTap + .subscribe(with: self) { object, _ in + object.collectionView.setContentOffset(CGPoint(x: 0, y: 84 + 76 + 48 + 16), animated: true) + } + .disposed(by: cell.disposeBag) + + cell.followerContainerDidTap + .subscribe(with: self) { object, _ in + let followViewController = FollowViewController() + followViewController.reactor = reactor.reactorForFollow( + type: .follower, + view: profileInfo.isAlreadyFollowing == nil ? .my : .other, + nickname: profileInfo.nickname, + with: profileInfo.userId + ) + object.navigationPush(followViewController, animated: true, bottomBarHidden: true) + } + .disposed(by: cell.disposeBag) + + cell.followingContainerDidTap + .subscribe(with: self) { object, _ in + let followViewController = FollowViewController() + followViewController.reactor = reactor.reactorForFollow( + type: .following, + view: profileInfo.isAlreadyFollowing == nil ? .my : .other, + nickname: profileInfo.nickname, + with: profileInfo.userId + ) + object.navigationPush(followViewController, animated: true, bottomBarHidden: true) + } + .disposed(by: cell.disposeBag) + + cell.updateProfileButton.rx.throttleTap + .subscribe(with: self) { object, _ in + KingfisherManager.shared.download( + strUrl: profileInfo.profileImageUrl, + with: profileInfo.profileImgName + ) { [weak object] profileImage in + let updateProfileViewController = UpdateProfileViewController() + updateProfileViewController.reactor = reactor.reactorForUpdate( + nickname: profileInfo.nickname, + image: profileImage + ) + object?.navigationPush(updateProfileViewController, animated: true, bottomBarHidden: true) + } + } + .disposed(by: cell.disposeBag) + + cell.followButton.rx.throttleTap + .subscribe(with: self) { object, _ in + if profileInfo.isAlreadyFollowing == true { + object.showdeleteFollowingDialog(with: profileInfo.nickname) + } else { + reactor.action.onNext(.follow) + } + } + .disposed(by: cell.disposeBag) + + cell.unBlockButton.rx.throttleTap + .subscribe(with: self) { object, _ in + object.showUnblockDialog(nickname: profileInfo.nickname, with: profileInfo.userId) + } + .disposed(by: cell.disposeBag) + + return cell + case let .card(type, feeds, comments): + + let cell: ProfileCardsViewCell = collectionView.dequeueReusableCell( + withReuseIdentifier: ProfileCardsViewCell.cellIdentifier, + for: indexPath + ) as! ProfileCardsViewCell + + if case .other = reactor.entranceType { + cell.setModels(type: .feed, feed: feeds, comment: nil) + } else { + cell.setModels(type: type, feed: feeds, comment: comments ?? []) + } + + // TODO: 상세화면 이동 + // cell.cardDidTap + // .subscribe(with: self) { object, selectedId in + // + // } + // .disposed(by: cell.disposeBag) + + cell.moreFindCards + .subscribe(with: self) { object, moreInfo in + reactor.action.onNext(.moreFind(moreInfo.type, moreInfo.lastId)) + } + .disposed(by: cell.disposeBag) + + return cell + } + } + + dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath -> UICollectionReusableView? in + + guard let self = self else { return nil } + + if kind == UICollectionView.elementKindSectionHeader { + + let header: ProfileViewHeader = collectionView.dequeueReusableSupplementaryView( + ofKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: ProfileViewHeader.cellIdentifier, + for: indexPath + ) as! ProfileViewHeader + + header.tabBarItemDidTap + .subscribe(with: self) { object, selectedIndex in + object.reactor?.action.onNext(.updateCardType(selectedIndex == 0 ? .feed : .comment)) + } + .disposed(by: header.disposeBag) + + return header + } else { + return nil + } + } + + return dataSource + }() + private var initialOffset: CGFloat = 0 + private var currentOffset: CGFloat = 0 private var isRefreshEnabled: Bool = true + private var shouldRefreshing: Bool = false + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + floating button height + padding + return self.reactor?.entranceType == .other ? 34 + 8 : 88 + } // MARK: Override func @@ -101,37 +260,15 @@ class ProfileViewController: BaseNavigationViewController, View { override func setupNaviBar() { super.setupNaviBar() - let isMine = self.reactor?.entranceType == .my || self.reactor?.entranceType == .myWithNavi - - let titleContainer = UIView() - titleContainer.addSubview(self.titleView) - self.titleView.snp.makeConstraints { - $0.top.equalToSuperview() - $0.centerX.equalToSuperview() - } - - titleContainer.addSubview(self.subTitleView) - self.subTitleView.snp.makeConstraints { - $0.top.equalTo(self.titleView.snp.bottom).offset(2) - $0.bottom.leading.trailing.equalToSuperview() - } + guard let reactor = self.reactor else { return } - self.navigationBar.hidesBackButton = self.reactor?.entranceType == .my - self.navigationBar.titleView = titleContainer - if isMine { - self.navigationBar.setRightButtons([self.rightSettingButton]) - } else { - self.navigationBar.setRightButtons([self.rightBlockButton]) - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) + let isMine = reactor.entranceType == .my || reactor.entranceType == .myWithNavi - self.navigationController?.delegate = self + self.navigationBar.hidesBackButton = isMine + self.navigationBar.title = isMine ? Text.navigationTitle : nil + self.navigationBar.titlePosition = .left - // 탭바 표시 - self.hidesBottomBarWhenPushed = self.reactor?.entranceType == .my ? false : true + self.navigationBar.setRightButtons(isMine ? [self.rightSettingButton] : [self.rightBlockButton]) } override func setupConstraints() { @@ -140,32 +277,42 @@ class ProfileViewController: BaseNavigationViewController, View { self.view.addSubview(self.collectionView) self.collectionView.snp.makeConstraints { $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) - $0.bottom.leading.trailing.equalToSuperview() + $0.bottom.horizontalEdges.equalToSuperview() } } - override func bind() { + override func viewDidLoad() { + super.viewDidLoad() - self.backButton.rx.tap - .subscribe(with: self) { object, _ in - // if object.isBlocked { - // object.navigationPop(to: MainHomeTabBarController.self, animated: true) - // } else { - // object.navigationPop() - // } - object.navigationPop() - } - .disposed(by: self.disposeBag) + // 제스처 뒤로가기를 위한 델리게이트 설정 + if self.reactor?.entranceType != .other { + self.navigationController?.interactivePopGestureRecognizer?.delegate = self + } - self.rightSettingButton.rx.tap + NotificationCenter.default.addObserver( + self, + selector: #selector(self.reloadProfileData(_:)), + name: .reloadProfileData, + object: nil + ) + } + + + // MARK: ReactorKit - Bind + + func bind(reactor: ProfileViewReactor) { + + // 설정 화면 전환 + self.rightSettingButton.rx.throttleTap .subscribe(with: self) { object, _ in let settingsViewController = SettingsViewController() - settingsViewController.reactor = object.reactor?.reactorForSettings() + settingsViewController.reactor = reactor.reactorForSettings() object.navigationPush(settingsViewController, animated: true, bottomBarHidden: true) } .disposed(by: self.disposeBag) - self.rightBlockButton.rx.tap + // 상대방 차단 요청 + self.rightBlockButton.rx.throttleTap .subscribe(with: self) { object, _ in let cancelAction = SOMDialogAction( title: Text.cancelActionTitle, @@ -175,279 +322,311 @@ class ProfileViewController: BaseNavigationViewController, View { } ) let confirmAction = SOMDialogAction( - title: Text.confirmActionTitle, - style: .primary, + title: Text.blockActionTitle, + style: .red, action: { - object.reactor?.action.onNext(.block) + UIApplication.topViewController?.dismiss(animated: true) { + + reactor.action.onNext(.block) + } } ) SOMDialogViewController.show( title: Text.blockDialogTitle, message: Text.blockDialogMessage, + textAlignment: .left, actions: [cancelAction, confirmAction] ) } .disposed(by: self.disposeBag) - } - - - // MARK: Bind - - func bind(reactor: ProfileViewReactor) { + + // tabBar 표시 + self.rx.viewDidAppear + .subscribe(with: self) { object, _ in + object.hidesBottomBarWhenPushed = reactor.entranceType == .other + } + .disposed(by: self.disposeBag) // Action - self.rx.viewWillAppear + 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(reactor.state.map(\.isLoading)) + .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 - reactor.state.map(\.isLoading) - .distinctUntilChanged() - .subscribe(with: self.collectionView) { collectionView, isLoading in - if isLoading { - // collectionView.refreshControl?.beginRefreshingFromTop() - } else { - collectionView.refreshControl?.endRefreshing() - } + isRefreshing + .filter { $0 == false } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self.collectionView) { collectionView, _ in + collectionView.refreshControl?.endRefreshing() } .disposed(by: self.disposeBag) - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .bind(to: self.activityIndicatorView.rx.isAnimating) - .disposed(by: self.disposeBag) + reactor.state.map { + ProfileViewReactor.DisplayStates( + cardType: $0.cardType, + profileInfo: $0.profileInfo, + feedCardInfos: $0.feedCardInfos, + commCardInfos: $0.commentCardInfos + ) + } + .distinctUntilChanged(reactor.canUpdateCells) + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, displayStates in + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + + guard let profileInfo = displayStates.profileInfo else { return } + + if reactor.entranceType == .other, let isBlocked = profileInfo.isBlocked { + object.rightBlockButton.isHidden = isBlocked + } + + let profileItem = Item.user(profileInfo) + snapshot.appendItems([profileItem], toSection: .user) + + let cardItem = Item.card( + type: displayStates.cardType, + feed: displayStates.feedCardInfos, + comment: displayStates.commCardInfos.isEmpty ? nil : displayStates.commCardInfos + ) + snapshot.appendItems([cardItem], toSection: .card) + + object.dataSource.apply(snapshot, animatingDifferences: false) + } + .disposed(by: self.disposeBag) - reactor.state.map(\.profile) - .distinctUntilChanged() - .subscribe(with: self) { object, profile in - object.profile = profile - object.titleView.text = profile.nickname - object.subTitleView.text = "TOTAL \(profile.totalVisitorCnt) TODAY \(profile.currentDayVisitors)" - - UIView.performWithoutAnimation { - object.collectionView.performBatchUpdates { - object.collectionView.reloadSections(IndexSet(integer: 0)) - } - } + reactor.pulse(\.$isBlocked) + .filterNil() + .filter { $0 } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, _ in + reactor.action.onNext(.updateCards) } .disposed(by: self.disposeBag) - reactor.state.map(\.writtenCards) - .distinctUntilChanged() - .subscribe(with: self) { object, writtenCards in - object.writtenCards = writtenCards - - UIView.performWithoutAnimation { - object.collectionView.performBatchUpdates { - object.collectionView.reloadSections(IndexSet(integer: 0)) - } - } + reactor.pulse(\.$isFollowing) + .filterNil() + .filter { $0 } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, _ in + reactor.action.onNext(.updateProfile) } .disposed(by: self.disposeBag) + } + + + // MARK: Private func + + private func updateCollectionViewHeight(numberOfItems: Int) -> CGFloat { + + let lineSpacing: CGFloat = 1.0 + // TODO: 임시, 행 개수 3 고정 + let itemsPerRow: CGFloat = 3.0 + let numberOfRows = ceil(CGFloat(numberOfItems) / itemsPerRow) - reactor.state.map(\.isBlocked) - .distinctUntilChanged() - .subscribe(with: self) { object, isBlocked in + let itemHeight = (self.collectionView.bounds.width - 2) / 3 + let newHeight = (numberOfRows * itemHeight) + ((numberOfRows - 1) * lineSpacing) + + return max(newHeight, self.collectionView.bounds.height - 56) + } + + + // MARK: Objc + + @objc + private func reloadProfileData(_ notification: Notification) { + + self.reactor?.action.onNext(.updateProfile) + } +} + + +// MARK: show Dialog + +private extension ProfileViewController { + + func showdeleteFollowingDialog(with nickname: String) { + + let cancelAction = SOMDialogAction( + title: Text.cancelActionTitle, + style: .gray, + action: { + UIApplication.topViewController?.dismiss(animated: true) + } + ) + let deleteAction = SOMDialogAction( + title: Text.deleteActionTitle, + style: .red, + action: { UIApplication.topViewController?.dismiss(animated: true) { - object.isBlocked = isBlocked - object.rightBlockButton.isHidden = isBlocked - - UIView.performWithoutAnimation { - object.collectionView.performBatchUpdates { - object.collectionView.reloadSections(IndexSet(integer: 0)) - } - } + self.reactor?.action.onNext(.follow) } } - .disposed(by: self.disposeBag) + ) - reactor.state.map(\.isFollow) - .distinctUntilChanged() - .filter { $0 != nil } - .subscribe(onNext: { _ in - reactor.action.onNext(.landing) - }) - .disposed(by: self.disposeBag) + SOMDialogViewController.show( + title: nickname + Text.deleteFollowingDialogTitle, + messageView: nil, + textAlignment: .left, + actions: [cancelAction, deleteAction] + ) + } + + func showUnblockDialog(nickname: String, with userId: String) { + + let cancelAction = SOMDialogAction( + title: Text.cancelActionTitle, + style: .gray, + action: { + UIApplication.topViewController?.dismiss(animated: true) + } + ) + + let unBlockAction = SOMDialogAction( + title: Text.unBlockActionTitle, + style: .red, + action: { + UIApplication.topViewController?.dismiss(animated: true) { + self.reactor?.action.onNext(.block) + } + } + ) + + SOMDialogViewController.show( + title: Text.unBlockUserDialogTitle, + message: nickname + Text.unBlockUserDialogMessage, + textAlignment: .left, + actions: [cancelAction, unBlockAction] + ) } } -extension ProfileViewController: UICollectionViewDataSource { + +// MARK: UICollectionViewDelegateFlowLayout + +extension ProfileViewController: UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return 1 + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + referenceSizeForHeaderInSection section: Int + ) -> CGSize { + + if self.reactor?.entranceType == .other { + return .zero + } + + guard let section = self.dataSource.sectionIdentifier(for: section) else { return .zero } + + if case .card = section { + return CGSize(width: collectionView.bounds.width, height: 56) + } else { + return .zero + } } - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let entranceType = self.reactor?.entranceType ?? .my - switch entranceType { - case .my, .myWithNavi: - let myCell: MyProfileViewCell = collectionView.dequeueReusableCell( - withReuseIdentifier: MyProfileViewCell.cellIdentifier, - for: indexPath - ) as! MyProfileViewCell - myCell.setModel(self.profile) - - myCell.updateProfileButton.rx.tap - .subscribe(with: self) { object, _ in - let updateProfileViewController = UpdateProfileViewController() - updateProfileViewController.reactor = object.reactor?.reactorForUpdate() - object.navigationPush(updateProfileViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: myCell.disposeBag) - - myCell.followingButton.rx.tap - .subscribe(with: self) { object, _ in - let followViewController = FollowViewController() - followViewController.reactor = object.reactor?.reactorForFollow(type: .following) - object.navigationPush(followViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: myCell.disposeBag) - - myCell.followerButton.rx.tap - .subscribe(with: self) { object, _ in - let followViewController = FollowViewController() - followViewController.reactor = object.reactor?.reactorForFollow(type: .follower) - object.navigationPush(followViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: myCell.disposeBag) - - return myCell - case .other: - let otherCell: OtherProfileViewCell = collectionView.dequeueReusableCell( - withReuseIdentifier: OtherProfileViewCell.cellIdentifier, - for: indexPath - ) as! OtherProfileViewCell - otherCell.setModel(self.profile, isBlocked: self.isBlocked) - - otherCell.followButton.rx.throttleTap(.seconds(1)) - .subscribe(with: self) { object, _ in - if object.isBlocked { - object.reactor?.action.onNext(.block) - } else { - object.reactor?.action.onNext(.follow) - } - } - .disposed(by: otherCell.disposeBag) - - otherCell.followingButton.rx.tap - .subscribe(with: self) { object, _ in - let followViewController = FollowViewController() - followViewController.reactor = object.reactor?.reactorForFollow(type: .following) - object.navigationPush(followViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: otherCell.disposeBag) - - otherCell.followerButton.rx.tap - .subscribe(with: self) { object, _ in - let followViewController = FollowViewController() - followViewController.reactor = object.reactor?.reactorForFollow(type: .follower) - object.navigationPush(followViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: otherCell.disposeBag) - - return otherCell - } + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + referenceSizeForFooterInSection section: Int + ) -> CGSize { + + return .zero } func collectionView( _ collectionView: UICollectionView, - viewForSupplementaryElementOfKind kind: String, - at indexPath: IndexPath - ) -> UICollectionReusableView { + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { - if kind == UICollectionView.elementKindSectionFooter { - - let footer: ProfileViewFooter = collectionView - .dequeueReusableSupplementaryView( - ofKind: kind, - withReuseIdentifier: "footer", - for: indexPath - ) as! ProfileViewFooter - footer.setModel(self.writtenCards, isBlocked: self.isBlocked) + guard let section = self.dataSource.sectionIdentifier(for: indexPath.section), + let reactor = self.reactor + else { return .zero } + + let width: CGFloat = collectionView.bounds.width + switch section { + case .user: + /// top container height + bottom container height + button height + padding + let height: CGFloat = 84 + 76 + 48 + 16 + return CGSize(width: width, height: height) + case .card: - footer.didTap - .subscribe(with: self) { object, selectedId in - // let detailViewController = DetailViewController() - // detailViewController.reactor = object.reactor?.ractorForDetail(selectedId) - // object.navigationPush(detailViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: footer.disposeBag) + let feeds = reactor.currentState.feedCardInfos + let comments = reactor.currentState.commentCardInfos - footer.moreDisplay - .subscribe(with: self) { object, lastId in - object.reactor?.action.onNext(.moreFind(lastId)) + var height: CGFloat { + let cellHeight: CGFloat = 84 + 76 + 48 + 16 + let headerHeight: CGFloat = 56 + let defaultHeight: CGFloat = collectionView.bounds.height - cellHeight - headerHeight + switch reactor.currentState.cardType { + case .feed: + + return feeds.isEmpty ? + defaultHeight : + self.updateCollectionViewHeight(numberOfItems: feeds.count) + case .comment: + + return comments.isEmpty ? + defaultHeight : + self.updateCollectionViewHeight(numberOfItems: comments.count) } - .disposed(by: footer.disposeBag) + } - return footer - } else { - return .init(frame: .zero) + return CGSize(width: width, height: height) } } -} - -extension ProfileViewController: UICollectionViewDelegateFlowLayout { + + + // MARK: UIScrollViewDelegate func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y - // currentOffset <= 0 일 때, 테이블 뷰 새로고침 가능 - self.isRefreshEnabled = (offset <= 0 && self.reactor?.currentState.isLoading == false) + // currentOffset <= 0 && isRefreshing == false 일 때, 테이블 뷰 새로고침 가능 + self.isRefreshEnabled = (offset <= 0) && (self.reactor?.currentState.isRefreshing == false) } - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + func scrollViewDidScroll(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if self.isRefreshEnabled, - let refreshControl = self.collectionView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { + // 당겨서 새로고침 + if self.isRefreshEnabled, offset < self.initialOffset { + guard let refreshControl = self.collectionView.refreshControl else { + self.currentOffset = offset + return + } - // refreshControl.beginRefreshingFromTop() + let pulledOffset = self.initialOffset - offset + let refreshingOffset = refreshControl.frame.origin.y + refreshControl.frame.height + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + 10 } + + self.currentOffset = offset } - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath - ) -> CGSize { - let width: CGFloat = UIScreen.main.bounds.width - // 내 프로필 일 때, 프로필 - 컨텐츠 + 컨텐츠 - 버튼 + 버튼 - 하단 - // 상대 프로필 일 때, 프로필 - 버튼 + 버튼 - 하단 - let isMine = self.reactor?.entranceType == .my || self.reactor?.entranceType == .myWithNavi - let spacing: CGFloat = isMine ? (16 + 18 + 30) : (22 + 22) - // 내 프로필 일 떄, 프로필 + 간격 + 컨텐츠 + 버튼 - // 상대 프로필 일 때, 프로필 + 간격 + 버튼 - let height: CGFloat = isMine ? (128 + spacing + 42 + 48) : (128 + spacing + 48) - return CGSize(width: width, height: height) - } - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - referenceSizeForFooterInSection section: Int - ) -> CGSize { - let width: CGFloat = UIScreen.main.bounds.width - // 내 프로필 일 때, 프로필 - 컨텐츠 + 컨텐츠 - 버튼 + 버튼 - 하단 - // 상대 프로필 일 때, 프로필 - 버튼 + 버튼 - 하단 - let isMine = self.reactor?.entranceType == .my || self.reactor?.entranceType == .myWithNavi - let spacing: CGFloat = isMine ? (16 + 18 + 30) : (22 + 22) - // 내 프로필 일 떄, 프로필 + 간격 + 컨텐츠 + 버튼 - // 상대 프로필 일 때, 프로필 + 간격 + 버튼 - let cellHeight: CGFloat = isMine ? (128 + spacing + 42 + 48) : (128 + spacing + 48) - let height: CGFloat = collectionView.bounds.height - cellHeight - return CGSize(width: width, height: height) + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { + + if self.shouldRefreshing { + self.collectionView.refreshControl?.beginRefreshing() + } } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewReactor.swift index d48e8ad5..8933e16d 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/ProfileViewReactor.swift @@ -9,255 +9,270 @@ import ReactorKit import Alamofire - class ProfileViewReactor: Reactor { - enum EntranceType { - case my - case myWithNavi - case other + struct DisplayStates { + let cardType: EntranceCardType + let profileInfo: ProfileInfo? + let feedCardInfos: [ProfileCardInfo] + let commCardInfos: [ProfileCardInfo] } enum Action: Equatable { case landing case refresh - case moreFind(String) + case moreFind(EntranceCardType, String) case block case follow + case updateProfile + case updateCards + case updateCardType(EntranceCardType) } enum Mutation { - case profile(Profile) - case writtenCards([WrittenCard]) - case moreWrittenCards([WrittenCard]) - case updateIsBlocked(Bool) - case updateIsFollow(Bool) - case updateIsLoading(Bool) - case updateIsProcessing(Bool) + case profile(ProfileInfo) + case feedCardInfos([ProfileCardInfo]) + case moreFeedCardInfos([ProfileCardInfo]) + case commentCardInfos([ProfileCardInfo]) + case moreCommentCardInfos([ProfileCardInfo]) + case updateCardType(EntranceCardType) + case updateIsBlocked(Bool?) + case updateIsFollowing(Bool?) + case updateIsRefreshing(Bool) } struct State { - var profile: Profile - var writtenCards: [WrittenCard] - var isBlocked: Bool - var isFollow: Bool? - var isLoading: Bool - var isProcessing: Bool + fileprivate(set) var profileInfo: ProfileInfo? + fileprivate(set) var feedCardInfos: [ProfileCardInfo] + fileprivate(set) var commentCardInfos: [ProfileCardInfo] + fileprivate(set) var cardType: EntranceCardType + @Pulse fileprivate(set) var isBlocked: Bool? + @Pulse fileprivate(set) var isFollowing: Bool? + fileprivate(set) var isRefreshing: Bool } var initialState: State = .init( - profile: .init(), - writtenCards: [], - isBlocked: false, - isFollow: nil, - isLoading: false, - isProcessing: false + profileInfo: nil, + feedCardInfos: [], + commentCardInfos: [], + cardType: .feed, + isBlocked: nil, + isFollowing: nil, + isRefreshing: false ) - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let userUseCase: UserUseCase let entranceType: EntranceType - private let memberId: String? + private let userId: String? - init(provider: ManagerProviderType, type entranceType: EntranceType, memberId: String?) { - self.provider = provider + init(dependencies: AppDIContainerable, type entranceType: EntranceType, with userId: String? = nil) { + self.dependencies = dependencies + self.userUseCase = dependencies.rootContainer.resolve(UserUseCase.self) self.entranceType = entranceType - self.memberId = memberId + self.userId = userId } func mutate(action: Action) -> Observable { switch action { case .landing: - let combined = Observable.concat([ - self.profile(), - self.writtenCards() - ]) - .delay(.milliseconds(500), scheduler: MainScheduler.instance) - - return .concat([ - .just(.updateIsProcessing(true)), - combined, - .just(.updateIsProcessing(false)) - ]) + return self.userUseCase.profile(userId: self.userId) + .withUnretained(self) + .flatMapLatest { object, profileInfo -> Observable in + + if object.entranceType == .other { + + return .concat([ + .just(.profile(profileInfo)), + object.userUseCase.feedCards(userId: profileInfo.userId, lastId: nil) + .map(Mutation.feedCardInfos) + ]) + } else { + + return .concat([ + .just(.profile(profileInfo)), + object.userUseCase.feedCards(userId: profileInfo.userId, lastId: nil) + .map(Mutation.feedCardInfos), + object.userUseCase.myCommentCards(lastId: nil) + .map(Mutation.commentCardInfos) + ]) + } + } case .refresh: - let combined = Observable.concat([ - self.profile(), - self.writtenCards() - ]) - .delay(.milliseconds(500), scheduler: MainScheduler.instance) + return self.userUseCase.profile(userId: self.userId) + .withUnretained(self) + .flatMapLatest { object, profileInfo -> Observable in + + if object.currentState.cardType == .feed { + + return .concat([ + .just(.updateIsRefreshing(true)), + .just(.profile(profileInfo)) + .catch(self.catchClosure), + object.userUseCase.feedCards(userId: profileInfo.userId, lastId: nil) + .map(Mutation.feedCardInfos) + .catch(self.catchClosure), + .just(.updateIsRefreshing(false)) + ]) + } else { + + return .concat([ + .just(.updateIsRefreshing(true)), + .just(.profile(profileInfo)) + .catch(self.catchClosure), + object.userUseCase.myCommentCards(lastId: nil) + .map(Mutation.commentCardInfos) + .catch(self.catchClosure), + .just(.updateIsRefreshing(false)) + ]) + } + } + .catch(self.catchClosure) + case let .moreFind(cardType, lastId): - return .concat([ - .just(.updateIsLoading(true)), - combined, - .just(.updateIsLoading(false)) - ]) + guard let userId = self.currentState.profileInfo?.userId else { return .empty() } + + if cardType == .feed { + + return self.userUseCase.feedCards(userId: userId, lastId: lastId) + .map(Mutation.moreFeedCardInfos) + } else { + + return self.userUseCase.myCommentCards(lastId: lastId) + .map(Mutation.moreCommentCardInfos) + } + case .updateProfile: - case let .moreFind(lastId): + return self.userUseCase.profile(userId: self.userId) + .map(Mutation.profile) + case .updateCards: - return .concat([ - .just(.updateIsProcessing(true)), - self.moreWrittenCards(lastId: lastId) - .delay(.milliseconds(500), scheduler: MainScheduler.instance), - .just(.updateIsProcessing(false)) - ]) + if self.entranceType == .other, let userId = self.currentState.profileInfo?.userId { + + return .concat([ + self.userUseCase.profile(userId: userId) + .map(Mutation.profile), + self.userUseCase.feedCards(userId: userId, lastId: nil) + .map(Mutation.feedCardInfos) + ]) + } + return .empty() + case let .updateCardType(cardType): + + return .just(.updateCardType(cardType)) case .block: - if self.currentState.isBlocked { - let request: ReportRequest = .cancelBlockMember(id: self.memberId ?? "") - return self.provider.networkManager.request(Empty.self, request: request) - .flatMapLatest { _ -> Observable in - return .just(.updateIsBlocked(false)) - } - } else { - let request: ReportRequest = .blockMember(id: self.memberId ?? "") - return self.provider.networkManager.request(Status.self, request: request) - .map { .updateIsBlocked($0.httpCode == 201) } - } + guard let userId = self.currentState.profileInfo?.userId, + let isBlocked = self.currentState.profileInfo?.isBlocked + else { return .empty() } + return .concat([ + .just(.updateIsBlocked(nil)), + self.userUseCase.updateBlocked(id: userId, isBlocked: !isBlocked) + .map(Mutation.updateIsBlocked) + ]) case .follow: - if self.currentState.isFollow == true { - let request: ProfileRequest = .cancelFollow(memberId: self.memberId ?? "") - - return self.provider.networkManager.request(Empty.self, request: request) - .flatMapLatest { _ -> Observable in - return .just(.updateIsFollow(false)) - } - } else { - let request: ProfileRequest = .requestFollow(memberId: self.memberId ?? "") - - return self.provider.networkManager.request(Empty.self, request: request) - .flatMapLatest { _ -> Observable in - return .just(.updateIsFollow(true)) - } - } + guard let userId = self.currentState.profileInfo?.userId, + let isFollowing = self.currentState.profileInfo?.isAlreadyFollowing + else { return .empty() } + + return .concat([ + .just(.updateIsFollowing(nil)), + self.userUseCase.updateFollowing(userId: userId, isFollow: !isFollowing) + .map(Mutation.updateIsFollowing) + ]) } } func reduce(state: State, mutation: Mutation) -> State { - var state: State = state + var newState: State = state switch mutation { - case let .profile(profile): - state.profile = profile - case let .writtenCards(writtenCards): - state.writtenCards = writtenCards - case let .moreWrittenCards(writtenCards): - state.writtenCards += writtenCards + case let .profile(profileInfo): + newState.profileInfo = profileInfo + case let .feedCardInfos(feedCardInfos): + newState.feedCardInfos = feedCardInfos + case let .moreFeedCardInfos(feedCardInfos): + newState.feedCardInfos += feedCardInfos + case let .commentCardInfos(commentCardInfos): + newState.commentCardInfos = commentCardInfos + case let .moreCommentCardInfos(commentCardInfos): + newState.commentCardInfos += commentCardInfos + case let .updateCardType(cardType): + newState.cardType = cardType case let .updateIsBlocked(isBlocked): - state.isBlocked = isBlocked - case let .updateIsFollow(isFollow): - state.isFollow = isFollow - case let .updateIsLoading(isLoading): - state.isLoading = isLoading - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing + newState.isBlocked = isBlocked + case let .updateIsFollowing(isFollowing): + newState.isFollowing = isFollowing + case let .updateIsRefreshing(isRefreshing): + newState.isRefreshing = isRefreshing } - return state + return newState } } extension ProfileViewReactor { - private func profile() -> Observable { - - var request: ProfileRequest { - switch self.entranceType { - case .my, .myWithNavi: - return .myProfile - case .other: - return .otherProfile(memberId: self.memberId ?? "") - } - } - - return self.provider.networkManager.request(ProfileResponse.self, request: request) - .flatMapLatest { response -> Observable in - if (200...204).contains(response.status.httpCode) { - return .just(.profile(response.profile)) - } else { - return .just(.profile(.init())) - } - } - .catch(self.catchClosure) - } - - private func writtenCards() -> Observable { - - var request: ProfileRequest { - switch self.entranceType { - case .my, .myWithNavi: - return .myCards(lastId: nil) - case .other: - return .otherCards(memberId: self.memberId ?? "", lastId: nil) - } - } - - return self.provider.networkManager.request(WrittenCardResponse.self, request: request) - .flatMapLatest { response -> Observable in - if (200...204).contains(response.status.httpCode) { - return .just(.writtenCards(response.embedded.writtenCards)) - } else { - return .just(.writtenCards(.init())) - } - } - .catch(self.catchClosure) - } - - private func moreWrittenCards(lastId: String) -> Observable { - - var request: ProfileRequest { - switch self.entranceType { - case .my, .myWithNavi: - return .myCards(lastId: lastId) - case .other: - return .otherCards(memberId: self.memberId ?? "", lastId: lastId) - } - } - - return self.provider.networkManager.request(WrittenCardResponse.self, request: request) - .flatMapLatest { response -> Observable in - if (200...204).contains(response.status.httpCode) { - return .just(.moreWrittenCards(response.embedded.writtenCards)) - } else { - return .just(.moreWrittenCards(.init())) - } - } - .catch(self.catchClosure) + var catchClosure: ((Error) throws -> Observable ) { + return { _ in .just(.updateIsRefreshing(false)) } } - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.updateIsProcessing(false)), - .just(.updateIsLoading(false)) - ]) - } + func canUpdateCells( + prev prevDisplayState: DisplayStates, + curr currDisplayState: DisplayStates + ) -> Bool { + return prevDisplayState.cardType == currDisplayState.cardType && + prevDisplayState.profileInfo == currDisplayState.profileInfo && + prevDisplayState.feedCardInfos == currDisplayState.feedCardInfos && + prevDisplayState.commCardInfos == currDisplayState.commCardInfos } } extension ProfileViewReactor { func reactorForSettings() -> SettingsViewReactor { - SettingsViewReactor(provider: self.provider) + SettingsViewReactor(dependencies: self.dependencies) } - func reactorForUpdate() -> UpdateProfileViewReactor { - UpdateProfileViewReactor(provider: self.provider, self.currentState.profile) + func reactorForUpdate(nickname: String, image profileImage: UIImage?) -> UpdateProfileViewReactor { + UpdateProfileViewReactor(dependencies: self.dependencies, nickname: nickname, image: profileImage) } - // func ractorForDetail(_ selectedId: String) -> DetailViewReactor { - // DetailViewReactor(provider: self.provider, selectedId) - // } + func reactorForDetail(_ selectedId: String) -> DetailViewReactor { + DetailViewReactor( + dependencies: self.dependencies, + self.currentState.cardType, + type: .navi, + with: selectedId + ) + } - func reactorForFollow(type entranceType: FollowViewReactor.EntranceType) -> FollowViewReactor { + func reactorForFollow( + type entranceType: FollowViewReactor.EntranceType, + view viewType: FollowViewReactor.ViewType, + nickname: String, + with userId: String + ) -> FollowViewReactor { FollowViewReactor( - provider: self.provider, + dependencies: self.dependencies, type: entranceType, - view: self.entranceType == .my ? .my : .other, - memberId: self.memberId + view: viewType, + nickname: nickname, + with: userId ) } } + +extension ProfileViewReactor { + + enum EntranceType { + case my + case myWithNavi + case other + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewControler.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewControler.swift index 805856ee..765c1732 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewControler.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewControler.swift @@ -21,11 +21,16 @@ class AnnouncementViewController: BaseNavigationViewController, View { static let navigationTitle: String = "공지사항" } + + // MARK: Views + private lazy var tableView = UITableView().then { $0.backgroundColor = .clear $0.indicatorStyle = .black $0.separatorStyle = .none + $0.rowHeight = UITableView.automaticDimension + $0.register(AnnouncementViewCell.self, forCellReuseIdentifier: "cell") $0.refreshControl = SOMRefreshControl() @@ -34,7 +39,18 @@ class AnnouncementViewController: BaseNavigationViewController, View { $0.delegate = self } - private(set) var announcements = [Announcement]() + + // MARK: Variables + + private(set) var announcements = [NoticeInfo]() + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + padding + return 34 + 8 + } // MARK: Override func @@ -58,7 +74,10 @@ class AnnouncementViewController: BaseNavigationViewController, View { // MARK: Variables + private var initialOffset: CGFloat = 0 + private var currentOffset: CGFloat = 0 private var isRefreshEnabled: Bool = true + private var shouldRefreshing: Bool = false // MARK: ReactorKit - bind @@ -71,22 +90,21 @@ class AnnouncementViewController: BaseNavigationViewController, View { .bind(to: reactor.action) .disposed(by: self.disposeBag) + let isRefreshing = reactor.state.map(\.isRefreshing).distinctUntilChanged().share() self.tableView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(reactor.state.map(\.isLoading)) + .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 - reactor.state.map(\.isLoading) - .distinctUntilChanged() - .subscribe(with: self.tableView) { tableView, isLoading in - if isLoading { - // tableView.refreshControl?.beginRefreshingFromTop() - } else { - tableView.refreshControl?.endRefreshing() - } + isRefreshing + .filter { $0 == false } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self.tableView) { tableView, _ in + tableView.refreshControl?.endRefreshing() } .disposed(by: self.disposeBag) @@ -94,7 +112,10 @@ class AnnouncementViewController: BaseNavigationViewController, View { .distinctUntilChanged() .subscribe(with: self) { object, announcements in object.announcements = announcements - object.tableView.reloadData() + + UIView.performWithoutAnimation { + object.tableView.reloadData() + } } .disposed(by: self.disposeBag) } @@ -114,6 +135,7 @@ extension AnnouncementViewController: UITableViewDataSource { withIdentifier: "cell", for: indexPath ) as! AnnouncementViewCell + cell.selectionStyle = .none cell.setModel(announcement) @@ -123,38 +145,56 @@ extension AnnouncementViewController: UITableViewDataSource { extension AnnouncementViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + + + if let strUrl = self.announcements[indexPath.row].url, let url = URL(string: strUrl) { + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } + } + + + // MARK: UIScrollViewDelegate + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y // currentOffset <= 0 && isLoading == false 일 때, 테이블 뷰 새로고침 가능 - self.isRefreshEnabled = (offset <= 0 && self.reactor?.currentState.isLoading == false) + self.isRefreshEnabled = (offset <= 0) && (self.reactor?.currentState.isRefreshing == false) + self.shouldRefreshing = false + self.initialOffset = offset } - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + func scrollViewDidScroll(_ scrollView: UIScrollView) { let offset = scrollView.contentOffset.y - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if self.isRefreshEnabled, - let refreshControl = self.tableView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { + // 당겨서 새로고침 + if self.isRefreshEnabled, offset < self.initialOffset { + guard let refreshControl = self.tableView.refreshControl else { + self.currentOffset = offset + return + } - // refreshControl.beginRefreshingFromTop() + let pulledOffset = self.initialOffset - offset + let refreshingOffset = refreshControl.frame.origin.y + refreshControl.frame.height + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + 10 } + + self.currentOffset = offset } - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { - let link = self.announcements[indexPath.row].link - if let url = URL(string: link) { - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } + if self.shouldRefreshing { + self.tableView.refreshControl?.beginRefreshing() } } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return 73 - } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewReactor.swift index 98e76f5f..a97b9180 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/AnnouncementViewReactor.swift @@ -7,75 +7,70 @@ import ReactorKit - class AnnouncementViewReactor: Reactor { enum Action: Equatable { case landing case refresh + case more(lastId: String) } enum Mutation { - case announcements([Announcement]) - case updateIsLoading(Bool) + case announcements([NoticeInfo]) + case more([NoticeInfo]) + case updateIsRefreshing(Bool) } struct State { - var announcements: [Announcement] - var isLoading: Bool + var announcements: [NoticeInfo] + var isRefreshing: Bool } var initialState: State = .init( announcements: [], - isLoading: false + isRefreshing: false ) - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let notificationUseCase: NotificationUseCase - init(provider: ManagerProviderType) { - self.provider = provider + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.notificationUseCase = dependencies.rootContainer.resolve(NotificationUseCase.self) } func mutate(action: Action) -> Observable { switch action { case .landing: - let request: SettingsRequest = .announcement - return self.provider.networkManager.request(AnnouncementResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.announcements(response.embedded.announcements)) - } + return self.notificationUseCase.notices(lastId: nil, size: 10, requestType: .settings) + .map(Mutation.announcements) case .refresh: - let request: SettingsRequest = .announcement return .concat([ - .just(.updateIsLoading(true)), - self.provider.networkManager.request(AnnouncementResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.announcements(response.embedded.announcements)) - } - .delay(.milliseconds(500), scheduler: MainScheduler.instance) - .catch(self.catchClosure), - .just(.updateIsLoading(false)) + .just(.updateIsRefreshing(true)), + self.notificationUseCase.notices(lastId: nil, size: 10, requestType: .settings) + .map(Mutation.announcements) + .catch { _ in .just(.updateIsRefreshing(false)) }, + .just(.updateIsRefreshing(false)) ]) + case let .more(lastId): + + return self.notificationUseCase.notices(lastId: lastId, size: 10, requestType: .settings) + .map(Mutation.more) } } func reduce(state: State, mutation: Mutation) -> State { - var state = state + var newState: State = state switch mutation { case let .announcements(announcements): - state.announcements = announcements - case let .updateIsLoading(isLoading): - state.isLoading = isLoading + newState.announcements = announcements + case let .more(announcements): + newState.announcements += announcements + case let .updateIsRefreshing(isRefreshing): + newState.isRefreshing = isRefreshing } - return state - } -} - -extension AnnouncementViewReactor { - - var catchClosure: ((Error) throws -> Observable ) { - return { _ in .just(.updateIsLoading(false)) } + return newState } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/Cells/AnnouncementViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/Cells/AnnouncementViewCell.swift index b7c9a62f..8789b412 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/Cells/AnnouncementViewCell.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Announcement/Cells/AnnouncementViewCell.swift @@ -13,36 +13,30 @@ import Then class AnnouncementViewCell: UITableViewCell { - enum Text { - static let announcementText: String = "공지사항" - static let maintenanceText: String = "점검안내" - } - private let announcementTypeLabel = UILabel().then { - $0.textColor = .som.p300 - $0.typography = .som.body2WithBold - } + // MARK: Views private let titleLabel = UILabel().then { - $0.textColor = .som.gray500 - $0.typography = .som.body2WithBold + $0.textColor = .som.v2.black + $0.typography = .som.v2.subtitle3 + $0.numberOfLines = 0 + $0.lineBreakMode = .byTruncatingTail + $0.lineBreakStrategy = .hangulWordPriority } private let dateLabel = UILabel().then { - $0.textColor = .som.gray500 - $0.typography = .som.body3WithRegular + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption1 } - private let arrowImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.next))) - $0.tintColor = .som.gray400 - } + + // MARK: Initialize override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) self.backgroundColor = .clear - self.contentView.clipsToBounds = true + self.selectionStyle = .none self.setupConstraints() } @@ -51,31 +45,23 @@ class AnnouncementViewCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } + + // MARK: Private func + private func setupConstraints() { - self.addSubview(self.announcementTypeLabel) - self.announcementTypeLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(16) - $0.leading.equalToSuperview().offset(20) - } - self.addSubview(self.titleLabel) self.titleLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(16) - $0.leading.equalTo(self.announcementTypeLabel.snp.trailing).offset(6) + $0.top.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } self.addSubview(self.dateLabel) self.dateLabel.snp.makeConstraints { - $0.bottom.equalToSuperview().offset(-10) - $0.leading.equalToSuperview().offset(20) - } - - self.addSubview(self.arrowImageView) - self.arrowImageView.snp.makeConstraints { - $0.top.equalToSuperview().offset(14) - $0.trailing.equalToSuperview().offset(-24) - $0.size.equalTo(24) + $0.top.equalTo(self.titleLabel.snp.bottom).offset(8) + $0.leading.equalToSuperview().offset(16) + $0.bottom.equalToSuperview().offset(-16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } let bottomSeperator = UIView().then { @@ -83,15 +69,26 @@ class AnnouncementViewCell: UITableViewCell { } self.addSubview(bottomSeperator) bottomSeperator.snp.makeConstraints { - $0.bottom.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) $0.height.equalTo(1) } } - func setModel(_ model: Announcement) { + + // MARK: Public func + + func setModel(_ model: NoticeInfo) { + + var leadingTitle: String { + switch model.noticeType { + case .news: return "" + default: return "[공지] " + } + } - self.announcementTypeLabel.text = model.noticeType == .announcement ? Text.announcementText : Text.maintenanceText - self.titleLabel.text = model.title - self.dateLabel.text = Date(from: model.noticeDate)?.announcementFormatted + self.titleLabel.text = leadingTitle + model.message + self.dateLabel.text = model.createdAt.announcementFormatted } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewController.swift new file mode 100644 index 00000000..4af46e4a --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewController.swift @@ -0,0 +1,310 @@ +// +// BlockUsersViewController.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import UIKit + +import SnapKit +import Then + +import ReactorKit +import RxCocoa +import RxSwift + +class BlockUsersViewController: BaseNavigationViewController, View { + + enum Text { + static let navigationTitle: String = "차단 사용자 관리" + + static let unBlockUserDialogTitle: String = "차단 해제하시겠어요?" + static let unBlockUserDialogMessage: String = "님을 팔로우하고, 카드를 볼 수 있어요." + + static let cancelActionButtonTitle: String = "취소" + static let unBlockActionButtonTitle: String = "차단 해제" + } + + enum Section: Int, CaseIterable { + case main + case empty + } + + enum Item: Hashable { + case main(BlockUserInfo) + case empty + } + + + // MARK: Views + + private lazy var tableView = UITableView().then { + $0.backgroundColor = .clear + $0.indicatorStyle = .black + $0.separatorStyle = .none + + $0.contentInsetAdjustmentBehavior = .never + + $0.alwaysBounceVertical = true + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + + $0.register(BlockUserViewCell.self, forCellReuseIdentifier: BlockUserViewCell.cellIdentifier) + $0.register(BlockUserPlaceholderViewCell.self, forCellReuseIdentifier: BlockUserPlaceholderViewCell.cellIdentifier) + + $0.refreshControl = SOMRefreshControl() + + $0.delegate = self + } + + + // MARK: Variables + + typealias DataSource = UITableViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + private lazy var dataSource = DataSource(tableView: self.tableView) { [weak self] tableView, indexPath, item -> UITableViewCell? in + + guard let self = self, let reactor = self.reactor else { return nil } + + switch item { + case let .main(blockUserInfo): + + let cell: BlockUserViewCell = tableView.dequeueReusableCell( + withIdentifier: BlockUserViewCell.cellIdentifier, + for: indexPath + ) as! BlockUserViewCell + + cell.setModel(blockUserInfo) + + cell.profileBackgroundButton.rx.throttleTap + .subscribe(with: self) { object, _ in + let profileViewController = ProfileViewController() + profileViewController.reactor = reactor.reactorForProfile(blockUserInfo.userId) + object.navigationPush(profileViewController, animated: true, bottomBarHidden: true) + } + .disposed(by: cell.disposeBag) + + cell.unBlockUserButton.rx.throttleTap + .subscribe(with: self) { object, _ in + object.showUnblockDialog( + nickname: blockUserInfo.nickname, + with: blockUserInfo.userId + ) + } + .disposed(by: cell.disposeBag) + + return cell + case .empty: + + let placeholder: BlockUserPlaceholderViewCell = tableView.dequeueReusableCell( + withIdentifier: BlockUserPlaceholderViewCell.cellIdentifier, + for: indexPath + ) as! BlockUserPlaceholderViewCell + + return placeholder + } + } + + private(set) var blockUserInfos: [BlockUserInfo] = [] + + private var initialOffset: CGFloat = 0 + private var currentOffset: CGFloat = 0 + private var isRefreshEnabled: Bool = true + private var shouldRefreshing: Bool = false + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + padding + return 34 + 8 + } + + + // MARK: Override func + + override func setupNaviBar() { + super.setupNaviBar() + + self.navigationBar.title = Text.navigationTitle + } + + override func setupConstraints() { + super.setupConstraints() + + self.view.addSubview(self.tableView) + self.tableView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) + $0.bottom.horizontalEdges.equalToSuperview() + } + } + + + // MARK: ReactorKit - bind + + func bind(reactor: BlockUsersViewReactor) { + + // 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.tableView.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 + .filter { $0 == false } + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self.tableView) { tableView, _ in + tableView.refreshControl?.endRefreshing() + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.blockUserInfos) + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, blockUserInfos in + + var snapshot = Snapshot() + snapshot.appendSections(Section.allCases) + + if blockUserInfos.isEmpty { + snapshot.appendItems([.empty], toSection: .empty) + } else { + let new = blockUserInfos.map { Item.main($0) } + snapshot.appendItems(new, toSection: .main) + } + + object.dataSource.apply(snapshot, animatingDifferences: false) + } + .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) + } +} + + +// MARK: Show dialog + +extension BlockUsersViewController { + + func showUnblockDialog(nickname: String, with userId: String) { + + let cancelAction = SOMDialogAction( + title: Text.cancelActionButtonTitle, + style: .gray, + action: { + UIApplication.topViewController?.dismiss(animated: true) + } + ) + + let unBlockAction = SOMDialogAction( + title: Text.unBlockActionButtonTitle, + style: .red, + action: { + UIApplication.topViewController?.dismiss(animated: true) { + self.reactor?.action.onNext(.cancelBlock(userId: userId)) + } + } + ) + + SOMDialogViewController.show( + title: Text.unBlockUserDialogTitle, + message: nickname + Text.unBlockUserDialogMessage, + textAlignment: .left, + actions: [cancelAction, unBlockAction] + ) + } +} + + +// MARK: UITableViewDelegate + +extension BlockUsersViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return 0 } + + switch item { + case .empty: + return tableView.bounds.height + default: + return 60 + } + } + + func tableView( + _ tableView: UITableView, + willDisplay cell: UITableViewCell, + forRowAt indexPath: IndexPath + ) { + + guard let reactor = self.reactor else { return } + + let lastItemIndexPath = tableView.numberOfRows(inSection: Section.main.rawValue) - 1 + if self.blockUserInfos.isEmpty == false, + indexPath.section == Section.main.rawValue, + indexPath.row == lastItemIndexPath, + let lastId = self.blockUserInfos.last?.userId { + + reactor.action.onNext(.moreFind(lastId: lastId)) + } + } + + + // MARK: UIScrollViewDelegate + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + + let offset = scrollView.contentOffset.y + + // currentOffset <= 0 && isRefreshing == false 일 때, 테이블 뷰 새로고침 가능 + self.isRefreshEnabled = (offset <= 0) && (self.reactor?.currentState.isRefreshing == false) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + + let offset = scrollView.contentOffset.y + + // 당겨서 새로고침 + if self.isRefreshEnabled, offset < self.initialOffset { + guard let refreshControl = self.tableView.refreshControl else { + self.currentOffset = offset + return + } + + let pulledOffset = self.initialOffset - offset + let refreshingOffset = refreshControl.frame.origin.y + refreshControl.frame.height + self.shouldRefreshing = abs(pulledOffset) >= refreshingOffset + 10 + } + + self.currentOffset = offset + } + + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { + + if self.shouldRefreshing { + self.tableView.refreshControl?.beginRefreshing() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewReactor.swift new file mode 100644 index 00000000..bf7c50bb --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/BlockUsersViewReactor.swift @@ -0,0 +1,98 @@ +// +// BlockUsersViewReactor.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import ReactorKit + +class BlockUsersViewReactor: Reactor { + + enum Action: Equatable { + case landing + case refresh + case moreFind(lastId: String) + case cancelBlock(userId: String) + } + + enum Mutation { + case blockUserInfos([BlockUserInfo]) + case more([BlockUserInfo]) + case updateIsCanceled(Bool?) + case updateIsRefreshing(Bool) + } + + struct State { + fileprivate(set) var blockUserInfos: [BlockUserInfo] + fileprivate(set) var isCanceled: Bool? + fileprivate(set) var isRefreshing: Bool + } + + var initialState: State = .init( + blockUserInfos: [], + isCanceled: nil, + isRefreshing: false + ) + + private let dependencies: AppDIContainerable + private let settingsUseCase: SettingsUserCase + private let userUseCase: UserUseCase + + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.settingsUseCase = dependencies.rootContainer.resolve(SettingsUserCase.self) + self.userUseCase = dependencies.rootContainer.resolve(UserUseCase.self) + } + + func mutate(action: Action) -> Observable { + switch action { + case .landing: + + return self.settingsUseCase.blockUsers(lastId: nil) + .map(Mutation.blockUserInfos) + case .refresh: + + return .concat([ + .just(.updateIsRefreshing(true)), + self.settingsUseCase.blockUsers(lastId: nil) + .map(Mutation.blockUserInfos), + .just(.updateIsRefreshing(false)) + ]) + case let .moreFind(lastId): + + return self.settingsUseCase.blockUsers(lastId: lastId) + .map(Mutation.more) + case let .cancelBlock(userId): + + return self.userUseCase.updateBlocked(id: userId, isBlocked: false) + .map(Mutation.updateIsCanceled) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState: State = state + switch mutation { + case let .blockUserInfos(blockUserInfos): + newState.blockUserInfos = blockUserInfos + case let .more(blockUserInfos): + newState.blockUserInfos += blockUserInfos + case let .updateIsCanceled(isCanceled): + newState.isCanceled = isCanceled + case let .updateIsRefreshing(isRefreshing): + newState.isRefreshing = isRefreshing + } + return newState + } +} + +extension BlockUsersViewReactor { + + func reactorForProfile(_ userId: String) -> ProfileViewReactor { + ProfileViewReactor(dependencies: self.dependencies, type: .other, with: userId) + } + + // func reactorForMainTabBar() -> MainTabBarReactor { + // MainTabBarReactor(provider: self.provider) + // } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/Cells/BlockUserPlaceholderViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/Cells/BlockUserPlaceholderViewCell.swift new file mode 100644 index 00000000..6bdbc539 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/Cells/BlockUserPlaceholderViewCell.swift @@ -0,0 +1,72 @@ +// +// BlockUserPlaceholderViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import UIKit + +import SnapKit +import Then + +class BlockUserPlaceholderViewCell: UITableViewCell { + + enum Text { + static let placeholderText: String = "차단한 사용자가 없어요" + } + + static let cellIdentifier = String(reflecting: BlockUserPlaceholderViewCell.self) + + + // MARK: Views + + private let placeholderImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.filled(.hide)))) + $0.tintColor = .som.v2.gray200 + $0.contentMode = .scaleAspectFit + } + + private let placeholderMessageLabel = UILabel().then { + $0.text = Text.placeholderText + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.body1 + } + + + // MARK: Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.backgroundColor = .clear + self.selectionStyle = .none + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.placeholderImageView) + self.placeholderImageView.snp.makeConstraints { + /// (screen height - safe layout guide top - navi height - header height) * 0.5 - (icon height + spacing + label height) + let offset = (UIScreen.main.bounds.height - 48 - 56) * 0.5 - (24 + 8 + 21) * 0.5 + $0.top.equalTo(self.safeAreaLayoutGuide.snp.top).offset(offset) + $0.centerX.equalToSuperview() + $0.height.equalTo(24) + } + + self.contentView.addSubview(self.placeholderMessageLabel) + self.placeholderMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.placeholderImageView.snp.bottom).offset(8) + $0.centerX.equalToSuperview() + } + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/Cells/BlockUserViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/Cells/BlockUserViewCell.swift new file mode 100644 index 00000000..ea36a962 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/BlockUsers/Cells/BlockUserViewCell.swift @@ -0,0 +1,142 @@ +// +// BlockUserViewCell.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import UIKit + +import SnapKit +import Then + +import RxSwift + +class BlockUserViewCell: UITableViewCell { + + enum Text { + static let unBlockUserButtonTitle: String = "차단 해제" + } + + static let cellIdentifier = String(reflecting: BlockUserViewCell.self) + + + // MARK: Views + + let profileBackgroundButton = UIButton() + private let profileImageView = UIImageView().then { + $0.image = .init(.image(.v2(.profile_small))) + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .som.v2.gray300 + $0.layer.cornerRadius = 36 * 0.5 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.som.v2.gray300.cgColor + $0.clipsToBounds = true + } + + private let nicknameLabel = UILabel().then { + $0.textColor = .som.v2.gray600 + $0.typography = .som.v2.subtitle2 + } + + let unBlockUserButton = SOMButton().then { + $0.title = Text.unBlockUserButtonTitle + $0.typography = .som.v2.body1 + $0.foregroundColor = .som.v2.white + + $0.backgroundColor = .som.v2.black + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + } + + + // MARK: Variables + + private(set) var model: BlockUserInfo = .defaultValue + + + // MARK: Variables + Rx + + var disposeBag = DisposeBag() + + + // MARK: Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.backgroundColor = .clear + self.selectionStyle = .none + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override func prepareForReuse() { + super.prepareForReuse() + + self.profileImageView.image = nil + self.nicknameLabel.text = nil + + self.disposeBag = DisposeBag() + } + + + // MARK: Private func + + private func setupConstraints() { + + self.contentView.addSubview(self.profileImageView) + self.profileImageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.size.equalTo(36) + } + + self.contentView.addSubview(self.nicknameLabel) + self.nicknameLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalTo(self.profileImageView.snp.trailing).offset(10) + } + + self.contentView.addSubview(self.profileBackgroundButton) + self.profileBackgroundButton.snp.makeConstraints { + $0.top.equalTo(self.profileImageView.snp.top) + $0.bottom.equalTo(self.profileImageView.snp.bottom) + $0.leading.equalTo(self.profileImageView.snp.leading) + $0.trailing.equalTo(self.nicknameLabel.snp.trailing) + } + + self.contentView.addSubview(self.unBlockUserButton) + self.unBlockUserButton.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.nicknameLabel.snp.trailing).offset(10) + $0.trailing.equalToSuperview().offset(-16) + $0.width.equalTo(83) + $0.height.equalTo(32) + } + } + + + // MARK: Public func + + func setModel(_ model: BlockUserInfo) { + + self.model = model + + if let profileImageUrl = model.profileImageUrl { + self.profileImageView.setImage(strUrl: profileImageUrl) + } else { + self.profileImageView.image = .init(.image(.v2(.profile_small))) + } + + self.nicknameLabel.text = model.nickname + self.nicknameLabel.typography = .som.v2.subtitle2 + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/Cells/CommentHistoryViewCell.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/Cells/CommentHistoryViewCell.swift deleted file mode 100644 index 207585df..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/Cells/CommentHistoryViewCell.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// CommentHistoryViewCell.swift -// SOOUM -// -// Created by 오현식 on 12/5/24. -// - -import UIKit - -import SnapKit -import Then - - -class CommentHistoryViewCell: UICollectionViewCell { - - static let cellIdentifier = String(reflecting: CommentHistoryViewCell.self) - - private let backgroundImageView = UIImageView() - - private let backgroundDimView = UIView().then { - $0.backgroundColor = .som.black.withAlphaComponent(0.2) - } - - private let contentLabel = UILabel().then { - $0.textColor = .som.white - $0.textAlignment = .center - $0.typography = .init( - fontContainer: BuiltInFont(size: 12, weight: .bold), - lineHeight: 21, - letterSpacing: -0.04 - ) - $0.numberOfLines = 0 - $0.lineBreakMode = .byTruncatingTail - } - - override init(frame: CGRect) { - super.init(frame: frame) - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupConstraints() { - - self.contentView.addSubview(self.backgroundImageView) - self.backgroundImageView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - self.backgroundImageView.addSubview(self.backgroundDimView) - self.backgroundDimView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - self.backgroundImageView.addSubview(self.contentLabel) - self.contentLabel.snp.makeConstraints { - $0.top.leading.equalToSuperview().offset(10) - $0.bottom.trailing.equalToSuperview().offset(-10) - } - } - - func setModel(_ strUrl: String, content: String) { - self.backgroundImageView.setImage(strUrl: strUrl, with: "") - self.contentLabel.text = content - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/CommentHistroyViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/CommentHistroyViewController.swift deleted file mode 100644 index 06fdd282..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/CommentHistroyViewController.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// CommentHistroyViewController.swift -// SOOUM -// -// Created by 오현식 on 12/4/24. -// - -import UIKit - -import SnapKit -import Then - -import ReactorKit -import RxCocoa -import RxSwift - - -class CommentHistroyViewController: BaseNavigationViewController, View { - - enum Text { - static let navigationTitle: String = "답카드 히스토리" - } - - private let flowLayout = UICollectionViewFlowLayout().then { - $0.scrollDirection = .vertical - $0.minimumLineSpacing = .zero - $0.minimumInteritemSpacing = .zero - $0.sectionInset = .zero - } - private lazy var collectionView = UICollectionView( - frame: .zero, - collectionViewLayout: self.flowLayout - ).then { - $0.alwaysBounceVertical = true - - $0.contentInsetAdjustmentBehavior = .never - $0.contentInset = .zero - - $0.decelerationRate = .fast - - $0.showsHorizontalScrollIndicator = false - - $0.refreshControl = SOMRefreshControl() - - $0.register(CommentHistoryViewCell.self, forCellWithReuseIdentifier: CommentHistoryViewCell.cellIdentifier) - - $0.dataSource = self - $0.delegate = self - } - - private(set) var commentHistroies = [CommentHistory]() - - private var currentOffset: CGFloat = 0 - private var isRefreshEnabled: Bool = true - private var isLoadingMore: Bool = true - - override var navigationBarHeight: CGFloat { - 46 - } - - - // MARK: Override func - - override func setupNaviBar() { - super.setupNaviBar() - - self.navigationBar.title = Text.navigationTitle - } - - override func setupConstraints() { - super.setupConstraints() - - self.view.addSubview(self.collectionView) - self.collectionView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(12) - $0.bottom.leading.trailing.equalToSuperview() - } - } - - - // MARK: ReactorKit - bind - - func bind(reactor: CommentHistroyViewReactor) { - - // Action - self.rx.viewWillAppear - .map { _ in Reactor.Action.landing } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - self.collectionView.refreshControl?.rx.controlEvent(.valueChanged) - .withLatestFrom(reactor.state.map(\.isLoading)) - .filter { $0 == false } - .map { _ in Reactor.Action.refresh } - .bind(to: reactor.action) - .disposed(by: self.disposeBag) - - // State - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .do(onNext: { [weak self] isProcessing in - if isProcessing { self?.isLoadingMore = false } - }) - .bind(to: self.activityIndicatorView.rx.isAnimating) - .disposed(by: self.disposeBag) - - reactor.state.map(\.isLoading) - .distinctUntilChanged() - .do(onNext: { [weak self] isLoading in - if isLoading { self?.isLoadingMore = false } - }) - .subscribe(with: self.collectionView) { collectionView, isLoading in - if isLoading { - // collectionView.refreshControl?.beginRefreshingFromTop() - } else { - collectionView.refreshControl?.endRefreshing() - } - } - .disposed(by: self.disposeBag) - - reactor.state.map(\.commentHistories) - .distinctUntilChanged() - .subscribe(with: self) { object, commentHistories in - object.commentHistroies = commentHistories - object.collectionView.reloadData() - } - .disposed(by: self.disposeBag) - } -} - -extension CommentHistroyViewController: UICollectionViewDataSource { - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return self.commentHistroies.count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell: CommentHistoryViewCell = collectionView.dequeueReusableCell( - withReuseIdentifier: CommentHistoryViewCell.cellIdentifier, - for: indexPath - ) as! CommentHistoryViewCell - let commentHistory = self.commentHistroies[indexPath.row] - cell.setModel(commentHistory.backgroundImgURL.url, content: commentHistory.content) - - return cell - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let selectedId = self.commentHistroies[indexPath.row].id - - // let detailViewController = DetailViewController() - // detailViewController.reactor = self.reactor?.reactorForDetail(selectedId) - // self.navigationPush(detailViewController, animated: true, bottomBarHidden: true) - } -} - -extension CommentHistroyViewController: UICollectionViewDelegateFlowLayout { - - func collectionView( - _ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath - ) -> CGSize { - let width: CGFloat = UIScreen.main.bounds.width / 3 - return CGSize(width: width, height: width) - } - - func collectionView( - _ collectionView: UICollectionView, - willDisplay cell: UICollectionViewCell, - forItemAt indexPath: IndexPath - ) { - guard self.commentHistroies.isEmpty == false else { return } - - let lastSectionIndex = collectionView.numberOfSections - 1 - let lastRowIndex = collectionView.numberOfItems(inSection: lastSectionIndex) - 1 - - if self.isLoadingMore, indexPath.section == lastSectionIndex, indexPath.item == lastRowIndex { - let lastId = self.commentHistroies[indexPath.item].id - self.reactor?.action.onNext(.moreFind(lastId)) - } - } - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - - let offset = scrollView.contentOffset.y - - // currentOffset <= 0 && isLoading == false 일 때, 테이블 뷰 새로고침 가능 - self.isRefreshEnabled = (offset <= 0 && self.reactor?.currentState.isLoading == false) - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - - let offset = scrollView.contentOffset.y - - // 당겨서 새로고침 상황일 때 - guard offset > 0 else { return } - - // 아래로 스크롤 중일 때, 데이터 추가로드 가능 - self.isLoadingMore = offset > self.currentOffset - self.currentOffset = offset - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - - let offset = scrollView.contentOffset.y - - // isRefreshEnabled == true 이고, 스크롤이 끝났을 경우에만 테이블 뷰 새로고침 - if self.isRefreshEnabled, - let refreshControl = self.collectionView.refreshControl, - offset <= -(refreshControl.frame.origin.y + 40) { - - // refreshControl.beginRefreshingFromTop() - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/CommentHistroyViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/CommentHistroyViewReactor.swift deleted file mode 100644 index 11737031..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/CommentHistory/CommentHistroyViewReactor.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// CommentHistroyViewReactor.swift -// SOOUM -// -// Created by 오현식 on 12/5/24. -// - -import ReactorKit - - -class CommentHistroyViewReactor: Reactor { - - enum Action: Equatable { - case landing - case refresh - case moreFind(String) - } - - enum Mutation { - case commentHistories([CommentHistory]) - case more([CommentHistory]) - case updateIsProcessing(Bool) - case updateIsLoading(Bool) - } - - struct State { - var commentHistories: [CommentHistory] - var isProcessing: Bool - var isLoading: Bool - } - - var initialState: State = .init( - commentHistories: [], - isProcessing: false, - isLoading: false - ) - - let provider: ManagerProviderType - - init(provider: ManagerProviderType) { - self.provider = provider - } - - func mutate(action: Action) -> Observable { - switch action { - case .landing: - let request: SettingsRequest = .commentHistory(lastId: nil) - - return .concat([ - .just(.updateIsProcessing(true)), - self.provider.networkManager.request(CommentHistoryResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.commentHistories(response.embedded.commentHistories)) - } - .delaySubscription(.milliseconds(500), scheduler: MainScheduler.instance) - .catch(self.catchClosure), - .just(.updateIsProcessing(false)) - ]) - case .refresh: - - let request: SettingsRequest = .commentHistory(lastId: nil) - - return .concat([ - .just(.updateIsLoading(true)), - self.provider.networkManager.request(CommentHistoryResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.commentHistories(response.embedded.commentHistories)) - } - .delaySubscription(.milliseconds(500), scheduler: MainScheduler.instance) - .catch(self.catchClosure), - .just(.updateIsLoading(false)) - ]) - case let .moreFind(lastId): - let request: SettingsRequest = .commentHistory(lastId: lastId) - - return .concat([ - .just(.updateIsProcessing(true)), - self.provider.networkManager.request(CommentHistoryResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.more(response.embedded.commentHistories)) - } - .delaySubscription(.milliseconds(500), scheduler: MainScheduler.instance) - .catch(self.catchClosure), - .just(.updateIsProcessing(false)) - ]) - } - } - - func reduce(state: State, mutation: Mutation) -> State { - var state = state - switch mutation { - case let .commentHistories(commentHistories): - state.commentHistories = commentHistories - case let .more(commentHistories): - state.commentHistories += commentHistories - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing - case let .updateIsLoading(isLoading): - state.isLoading = isLoading - } - return state - } -} - -extension CommentHistroyViewReactor { - - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.updateIsProcessing(false)), - .just(.updateIsLoading(false)) - ]) - } - } -} - -extension CommentHistroyViewReactor { - - // func reactorForDetail(_ selectedId: String) -> DetailViewReactor { - // DetailViewReactor(provider: self.provider, selectedId) - // } -} 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 3ceee1d4..e6a080be 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewController.swift @@ -18,36 +18,48 @@ import RxSwift class EnterMemberTransferViewController: BaseNavigationViewController, View { enum Text { - static let navigationTitle: String = "내 계정 가져오기" + static let navigationTitle: String = "이전 계정 불러오기" - static let title: String = "기존 계정이 있으신가요?" + static let transferEnterTitle: String = "기존 계정이 있으신가요?" + static let transferEnterGuideMessage: String = "이전에 사용하던 기기에서 로그인 코드를 입력해주세요." static let placeholderText: String = "코드 입력" - static let textfieldGuideMessage: String = "코드는 발급 후 24시간 동안 유효해요" - static let guideTitle: String = "내 계정 가져오기 안내" - static let guideMessage: String = "기존 휴대폰의 숨 앱 [설정>내 계정 내보내기]에서 발급한 코드를 입력하면, 기존 계정을 현재 휴대폰에서 그대로 사용할 수 있어요" + static let bottomGuideTitle: String = "이전 계정 불러오기란?" + static let bottomGuideMessage: String = "기존 휴대폰의 숨 앱[설정>다른 기기에서 로그인하기]에서 발급한 코드를 입력하면, 기존 계정을 현재 휴대폰에서 그대로 사용할 수 있어요" - static let dialogTitle: String = "유효하지 않은 코드예요" + static let dialogTitle: String = "잘못된 코드예요" static let dialogMessage: String = "코드를 확인한 뒤 다시 시도해주세요." - static let dialogConfirmButtonTitle: String = "확인" + + static let transferSuccessDialogTitle: String = "이전 계정 불러오기 완료" + static let transferSuccessDialogMessage: String = "이전 계정 불러오기가 성공적으로 완료되었습니다." static let confirmButtonTitle: String = "확인" } - private let titleLabel = UILabel().then { - $0.text = Text.title + + // MARK: Views + + private let transferEnterTitleLabel = UILabel().then { + $0.text = Text.transferEnterTitle $0.textColor = .som.v2.black $0.typography = .som.v2.head2 } + private let transferEnterGuideMessageLabel = UILabel().then { + $0.text = Text.transferEnterGuideMessage + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.title2 + } + private let transferTextField = EnterMemberTransferTextFieldView().then { $0.placeholder = Text.placeholderText - $0.guideMessage = Text.textfieldGuideMessage } - private let container = UIStackView().then { + private let bottomContainer = UIStackView().then { $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .equalSpacing $0.spacing = 16 } @@ -55,11 +67,20 @@ class EnterMemberTransferViewController: BaseNavigationViewController, View { $0.title = Text.confirmButtonTitle $0.typography = .som.v2.title1 $0.foregroundColor = .som.v2.white + $0.backgroundColor = .som.v2.black $0.isEnabled = false } + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + confirm button height + padding + return 34 + 56 + 8 + } + + // MARK: Override func override func setupNaviBar() { @@ -71,16 +92,23 @@ class EnterMemberTransferViewController: BaseNavigationViewController, View { override func setupConstraints() { super.setupConstraints() - self.view.addSubview(self.titleLabel) - self.titleLabel.snp.makeConstraints { + self.view.addSubview(self.transferEnterTitleLabel) + self.transferEnterTitleLabel.snp.makeConstraints { $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(16) $0.leading.equalToSuperview().offset(16) $0.trailing.lessThanOrEqualToSuperview().offset(-16) } + self.view.addSubview(self.transferEnterGuideMessageLabel) + self.transferEnterGuideMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.transferEnterTitleLabel.snp.bottom).offset(4) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) + } + self.view.addSubview(self.transferTextField) self.transferTextField.snp.makeConstraints { - $0.top.equalTo(self.titleLabel.snp.bottom).offset(32) + $0.top.equalTo(self.transferEnterGuideMessageLabel.snp.bottom).offset(32) $0.leading.trailing.equalToSuperview() } @@ -90,35 +118,37 @@ class EnterMemberTransferViewController: BaseNavigationViewController, View { $0.tintColor = .som.v2.black } let guideTitleLabel = UILabel().then { - $0.text = Text.guideTitle + $0.text = Text.bottomGuideTitle $0.textColor = .som.v2.black - $0.typography = .som.v2.subtitle2 + $0.typography = .som.v2.subtitle3 } guideTitleView.addSubview(guideTitleImageView) guideTitleImageView.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview() + $0.centerY.leading.equalToSuperview() $0.size.equalTo(16) } guideTitleView.addSubview(guideTitleLabel) guideTitleLabel.snp.makeConstraints { - $0.top.bottom.equalToSuperview() + $0.verticalEdges.equalToSuperview() $0.leading.equalTo(guideTitleImageView.snp.trailing).offset(6) $0.trailing.lessThanOrEqualToSuperview() } let guideMessageLabel = UILabel().then { - $0.text = Text.guideMessage + $0.text = Text.bottomGuideMessage $0.textColor = .som.v2.gray500 $0.typography = .som.v2.caption2.withAlignment(.left) $0.numberOfLines = 0 $0.lineBreakMode = .byCharWrapping + $0.lineBreakStrategy = .hangulWordPriority } let guideView = UIView().then { $0.backgroundColor = .som.v2.pLight1 $0.layer.cornerRadius = 10 } + + self.bottomContainer.addArrangedSubview(guideView) guideView.addSubview(guideTitleView) guideTitleView.snp.makeConstraints { $0.top.equalToSuperview().offset(14) @@ -133,15 +163,14 @@ class EnterMemberTransferViewController: BaseNavigationViewController, View { $0.leading.equalToSuperview().offset(16) $0.trailing.equalToSuperview().offset(-16) } - self.container.addArrangedSubview(guideView) + self.bottomContainer.addArrangedSubview(self.confirmButton) self.confirmButton.snp.makeConstraints { $0.height.equalTo(56) } - self.container.addArrangedSubview(self.confirmButton) - self.view.addSubview(self.container) - self.container.snp.makeConstraints { + self.view.addSubview(self.bottomContainer) + self.bottomContainer.snp.makeConstraints { $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom) $0.leading.equalToSuperview().offset(16) $0.trailing.equalToSuperview().offset(-16) @@ -152,7 +181,7 @@ class EnterMemberTransferViewController: BaseNavigationViewController, View { super.updatedKeyboard(withoutBottomSafeInset: height) let margin: CGFloat = height + 12 - self.container.snp.updateConstraints { + self.bottomContainer.snp.updateConstraints { $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-margin) } } @@ -176,48 +205,68 @@ class EnterMemberTransferViewController: BaseNavigationViewController, View { .disposed(by: self.disposeBag) // State - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .bind(to: self.activityIndicatorView.rx.isAnimating) - .disposed(by: self.disposeBag) - - let isSuccess = reactor.state.map(\.isSuccess).distinctUntilChanged().share() - + let isSuccess = reactor.state.map(\.isSuccess).filterNil().share() isSuccess .filter { $0 } .subscribe(with: self) { object, _ in - let launchScreenViewController = LaunchScreenViewController() - launchScreenViewController.reactor = reactor.reactorForLaunch() - object.view.window?.rootViewController = launchScreenViewController + guard let window = object.view.window else { return } + + object.showSuccessDialog { + + let launchSrceenViewController = LaunchScreenViewController() + launchSrceenViewController.reactor = reactor.reactorForLaunch() + launchSrceenViewController.modalTransitionStyle = .crossDissolve + + let navigationViewController = UINavigationController(rootViewController: launchSrceenViewController) + window.rootViewController = navigationViewController + } } .disposed(by: self.disposeBag) isSuccess .filter { $0 == false } - .subscribe(onNext: { _ in - let confirmAction = SOMDialogAction( - title: Text.dialogConfirmButtonTitle, - style: .primary, - action: { - UIApplication.topViewController?.dismiss(animated: true) - } - ) - - SOMDialogViewController.show( - title: Text.dialogTitle, - message: Text.dialogMessage, - textAlignment: .left, - actions: [confirmAction] - ) - }) + .subscribe(with: self) { object, _ in + object.showErrorDialog() + } .disposed(by: self.disposeBag) } } -extension EnterMemberTransferViewController: UITextFieldDelegate { +extension EnterMemberTransferViewController { - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - return true + func showErrorDialog() { + + let confirmAction = SOMDialogAction( + title: Text.confirmButtonTitle, + style: .primary, + action: { + UIApplication.topViewController?.dismiss(animated: true) + } + ) + + SOMDialogViewController.show( + title: Text.dialogTitle, + message: Text.dialogMessage, + textAlignment: .left, + actions: [confirmAction] + ) + } + + func showSuccessDialog(completion: @escaping (() -> Void)) { + + let confirmAction = SOMDialogAction( + title: Text.confirmButtonTitle, + style: .primary, + action: { + UIApplication.topViewController?.dismiss(animated: true) { completion() } + } + ) + + SOMDialogViewController.show( + title: Text.transferSuccessDialogTitle, + message: Text.transferSuccessDialogMessage, + textAlignment: .left, + actions: [confirmAction] + ) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewReactor.swift index 8ed9a40f..95b1d907 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/EnterMemberTransferViewReactor.swift @@ -22,66 +22,47 @@ class EnterMemberTransferViewReactor: Reactor { } enum Mutation { - case enterTransferCode(Bool) - case updateIsProcessing(Bool) + case enterTransferCode(Bool?) } struct State { - var isSuccess: Bool - var isProcessing: Bool - let entranceType: EntranceType + var isSuccess: Bool? } - var initialState: State + var initialState: State = State(isSuccess: nil) private let dependencies: AppDIContainerable + private let settingsUseCase: SettingsUserCase + + private let authManager: AuthManagerDelegate - init(dependencies: AppDIContainerable, entranceType: EntranceType) { + init(dependencies: AppDIContainerable) { self.dependencies = dependencies - - self.initialState = .init( - isSuccess: false, - isProcessing: false, - entranceType: entranceType - ) + self.settingsUseCase = dependencies.rootContainer.resolve(SettingsUserCase.self) + self.authManager = dependencies.rootContainer.resolve(ManagerProviderType.self).authManager } func mutate(action: Action) -> Observable { switch action { case let .enterTransferCode(transferCode): -// return .concat([ -// .just(.updateIsProcessing(true)), -// -// self.provider.networkManager.request(RSAKeyResponse.self, request: AuthRequest.getPublicKey) -// .map(\.publicKey) -// .withUnretained(self) -// .flatMapLatest { object, publicKey -> Observable in -// -// if let secKey = object.provider.authManager.convertPEMToSecKey(pemString: publicKey), -// let encryptedDeviceId = object.provider.authManager.encryptUUIDWithPublicKey(publicKey: secKey) { -// -// let request: SettingsRequest = .transferMember( -// transferId: transferCode, -// encryptedDeviceId: encryptedDeviceId -// ) -// -// return self.provider.networkManager.request(Status.self, request: request) -// .withUnretained(self) -// .flatMapLatest { object, response -> Observable in -// object.provider.authManager.initializeAuthInfo() -// -// return .just(.enterTransferCode(response.httpCode != 400)) -// } -// } else { -// return .just(.enterTransferCode(false)) -// } -// } -// .catch(self.catchClosure), -// -// .just(.updateIsProcessing(false)) -// ]) - return .empty() + return .concat([ + .just(.enterTransferCode(nil)), + self.authManager.publicKey() + .withUnretained(self) + .flatMapLatest { object, publicKey -> Observable in + + if let publicKey = publicKey, + let secKey = object.authManager.convertPEMToSecKey(pemString: publicKey), + let encryptedDeviceId = object.authManager.encryptUUIDWithPublicKey(publicKey: secKey) { + + return object.settingsUseCase.enter(code: transferCode, encryptedDeviceId: encryptedDeviceId) + .map(Mutation.enterTransferCode) + } else { + return .just(.enterTransferCode(false)) + } + } + ]) } } @@ -90,25 +71,11 @@ class EnterMemberTransferViewReactor: Reactor { switch mutation { case let .enterTransferCode(isSuccess): state.isSuccess = isSuccess - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing } return state } } -extension EnterMemberTransferViewReactor { - - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.enterTransferCode(false)), - .just(.updateIsProcessing(false)) - ]) - } - } -} - extension EnterMemberTransferViewReactor { func reactorForLaunch() -> LaunchScreenViewReactor { diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/Views/EnterMemberTransferTextFieldView+Rx.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/Views/EnterMemberTransferTextFieldView+Rx.swift index 07a846bc..ff92bdab 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/Views/EnterMemberTransferTextFieldView+Rx.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/Views/EnterMemberTransferTextFieldView+Rx.swift @@ -10,7 +10,6 @@ import UIKit import RxCocoa import RxSwift - extension Reactive where Base: EnterMemberTransferTextFieldView { var text: ControlProperty { diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/Views/EnterMemberTransferTextFieldView.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/Views/EnterMemberTransferTextFieldView.swift index 7f31ec98..1f78f97b 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/Views/EnterMemberTransferTextFieldView.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Enter/Views/EnterMemberTransferTextFieldView.swift @@ -10,7 +10,6 @@ import UIKit import SnapKit import Then - class EnterMemberTransferTextFieldView: UIView { @@ -84,15 +83,6 @@ class EnterMemberTransferTextFieldView: UIView { } } - var guideMessage: String? { - set { - self.guideMessageLabel.text = newValue - } - get { - return self.guideMessageLabel.text - } - } - var isTextEmpty: Bool { return self.text?.isEmpty ?? false } 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 e742696d..dede243a 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewController.swift @@ -19,61 +19,64 @@ import RxSwift class IssueMemberTransferViewController: BaseNavigationViewController, View { enum Text { - static let navigationTitle: String = "계정 이관 코드 발급" - static let topTransferIssueMessage: String = "계정을 다른 기기로 이관하기 위한" - static let bottomTransferIssueMessage: String = "코드를 발급합니다" - static let firstTransferIssueGuide: String = "발급된 코드는 24시간만 유효합니다" - static let topSecondTransferIssueGuide: String = "코드가 유출되면 타인이 해당 계정을" - static let bottomSecondTransferIssueGuide: String = "가져갈 수 있으니 주의하세요" + static let navigationTitle: String = "다른 기기에서 로그인하기" + static let transferIssueTitle: String = "다른 기기로 계정을 옮길 수 있는 코드를 드릴게요" + static let transferIssueGuideMessage: String = "코드는 1시간 동안 유효해요" static let transferReIssueButtonTitle: String = "코드 재발급하기" - - static let toastMessage: String = "코드가 복사되었습니다" } - private let topTransferIssueMessageLabel = UILabel().then { - $0.text = Text.topTransferIssueMessage - $0.textColor = .som.gray800 - $0.typography = .som.body1WithBold - } - private let bottomTransferIssueMessageLabel = UILabel().then { - $0.text = Text.bottomTransferIssueMessage - $0.textColor = .som.gray800 - $0.typography = .som.body1WithBold + + // MARK: Views + + private let transferIssueTitleLabel = UILabel().then { + $0.text = Text.transferIssueTitle + $0.textColor = .som.v2.black + $0.typography = .som.v2.head2.withAlignment(.left) + $0.numberOfLines = 0 + $0.lineBreakMode = .byWordWrapping + $0.lineBreakStrategy = .hangulWordPriority } private let transferCodeLabel = UILabel().then { - $0.textColor = .som.black - $0.typography = .som.body1WithBold + $0.textColor = .som.v2.black + $0.typography = .som.v2.subtitle1 } - private let firstTransferIssueGuideLabel = UILabel().then { - $0.text = Text.firstTransferIssueGuide - $0.textColor = .som.gray700 - $0.typography = .som.body1WithBold + private let transferIssueGuideMessageLabel = UILabel().then { + $0.text = Text.transferIssueGuideMessage + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption2 } - private let topSecondTransferIssueGuideLabel = UILabel().then { - $0.text = Text.topSecondTransferIssueGuide - $0.textColor = .som.red - $0.typography = .som.body3WithBold - } - private let bottomSecondTransferIssueGuideLabel = UILabel().then { - $0.text = Text.bottomSecondTransferIssueGuide - $0.textColor = .som.red - $0.typography = .som.body3WithBold + private let transferExpireLabel = UILabel().then { + $0.textColor = .som.v2.pDark + $0.typography = .som.v2.body2 } private let updateTransferCodeButton = SOMButton().then { $0.title = Text.transferReIssueButtonTitle - $0.typography = .som.body1WithBold - $0.foregroundColor = .som.white + $0.typography = .som.v2.title1 + $0.foregroundColor = .som.v2.white - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 12 + $0.backgroundColor = .som.v2.black + $0.layer.cornerRadius = 10 $0.clipsToBounds = true } + // MARK: Variables + + private var serialTimer: Disposable? + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + update transfer button height + padding + return 34 + 56 + 8 + } + + // MARK: Override func override func setupNaviBar() { @@ -85,72 +88,46 @@ class IssueMemberTransferViewController: BaseNavigationViewController, View { override func setupConstraints() { super.setupConstraints() - let transferBackgroundView = UIView().then { - $0.backgroundColor = .som.gray50 - $0.layer.cornerRadius = 22 - $0.clipsToBounds = true - } - self.view.addSubview(transferBackgroundView) - transferBackgroundView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(149) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - } - - transferBackgroundView.addSubview(self.topTransferIssueMessageLabel) - self.topTransferIssueMessageLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(34) - $0.centerX.equalToSuperview() - } - transferBackgroundView.addSubview(self.bottomTransferIssueMessageLabel) - self.bottomTransferIssueMessageLabel.snp.makeConstraints { - $0.top.equalTo(self.topTransferIssueMessageLabel.snp.bottom) - $0.centerX.equalToSuperview() + self.view.addSubview(self.transferIssueTitleLabel) + self.transferIssueTitleLabel.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(8) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } let transferCodeBackgroundView = UIView().then { - $0.backgroundColor = .som.white - $0.layer.borderColor = UIColor.som.p300.cgColor - $0.layer.borderWidth = 2 - $0.layer.cornerRadius = 12 + $0.backgroundColor = .som.v2.gray100 + $0.layer.cornerRadius = 10 $0.clipsToBounds = true } - transferBackgroundView.addSubview(transferCodeBackgroundView) + + self.view.addSubview(transferCodeBackgroundView) transferCodeBackgroundView.snp.makeConstraints { - $0.top.equalTo(self.bottomTransferIssueMessageLabel.snp.bottom).offset(32) - $0.bottom.trailing.equalToSuperview().offset(-20) - $0.leading.equalToSuperview().offset(20) - $0.height.equalTo(64) + $0.top.equalTo(self.transferIssueTitleLabel.snp.bottom).offset(32) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(54) } transferCodeBackgroundView.addSubview(self.transferCodeLabel) self.transferCodeLabel.snp.makeConstraints { - $0.center.equalToSuperview() + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(24) } - self.view.addSubview(self.firstTransferIssueGuideLabel) - self.firstTransferIssueGuideLabel.snp.makeConstraints { - $0.top.equalTo(transferBackgroundView.snp.bottom).offset(28) - $0.centerX.equalToSuperview() - } - - self.view.addSubview(self.topSecondTransferIssueGuideLabel) - self.topSecondTransferIssueGuideLabel.snp.makeConstraints { - $0.top.equalTo(self.firstTransferIssueGuideLabel.snp.bottom).offset(8) - $0.centerX.equalToSuperview() - } - self.view.addSubview(self.bottomSecondTransferIssueGuideLabel) - self.bottomSecondTransferIssueGuideLabel.snp.makeConstraints { - $0.top.equalTo(self.topSecondTransferIssueGuideLabel.snp.bottom) - $0.centerX.equalToSuperview() + transferCodeBackgroundView.addSubview(self.transferExpireLabel) + self.transferExpireLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.greaterThanOrEqualTo(self.transferCodeLabel.snp.trailing).offset(10) + $0.trailing.equalToSuperview().offset(-20) } self.view.addSubview(self.updateTransferCodeButton) self.updateTransferCodeButton.snp.makeConstraints { - $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-12) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(48) + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(56) } } @@ -165,7 +142,7 @@ class IssueMemberTransferViewController: BaseNavigationViewController, View { .bind(to: reactor.action) .disposed(by: self.disposeBag) - self.updateTransferCodeButton.rx.throttleTap(.seconds(1)) + self.updateTransferCodeButton.rx.throttleTap(.seconds(3)) .map { _ in Reactor.Action.updateTransferCode } .bind(to: reactor.action) .disposed(by: self.disposeBag) @@ -173,25 +150,66 @@ class IssueMemberTransferViewController: BaseNavigationViewController, View { // State reactor.state.map(\.isProcessing) .distinctUntilChanged() - .bind(to: self.activityIndicatorView.rx.isAnimating) + .subscribe(with: self.loadingIndicatorView) { loadingIndicatorView, isLoading in + if isLoading { + loadingIndicatorView.startAnimating() + } else { + loadingIndicatorView.stopAnimating() + } + } .disposed(by: self.disposeBag) - let transferCode = reactor.state.map(\.trnsferCode).distinctUntilChanged().share() - transferCode + let trnsferCodeInfo = reactor.state.map(\.trnsferCodeInfo).filterNil().distinctUntilChanged().share() + trnsferCodeInfo + .map(\.code) .bind(to: self.transferCodeLabel.rx.text) .disposed(by: self.disposeBag) - self.transferCodeLabel.rx.tapGesture() - .when(.recognized) - .withLatestFrom(transferCode) - .filter { $0.isEmpty == false } - .subscribe(with: self) { object, transferCode in - - // 계정 이관 코드 클립보드에 저장 - UIPasteboard.general.string = transferCode - // Toast 표시, offset == 코드 재발급하기 버튼 height + margin - self.showToast(message: Text.toastMessage, offset: 12 + 48 + 8) + trnsferCodeInfo + .map(\.expiredAt) + .subscribe(with: self) { object, expiredAt in + object.subscribePungTime(expiredAt) } .disposed(by: self.disposeBag) + + // TODO: 임시, 현재 복사 허용 X + // self.transferCodeLabel.rx.tapGesture() + // .when(.recognized) + // .withLatestFrom(transferCode) + // .filter { $0.isEmpty == false } + // .subscribe(with: self) { object, transferCode in + // + // // 계정 이관 코드 클립보드에 저장 + // UIPasteboard.general.string = transferCode + // // Toast 표시, offset == 코드 재발급하기 버튼 height + margin + // self.showToast(message: Text.toastMessage, offset: 12 + 48 + 8) + // } + // .disposed(by: self.disposeBag) + } + + + // MARK: Private func + + private func subscribePungTime(_ pungTime: Date?) { + self.serialTimer?.dispose() + self.serialTimer = Observable.interval(.seconds(1), scheduler: MainScheduler.instance) + .withUnretained(self) + .startWith((self, 0)) + .map { object, _ in + guard let pungTime = pungTime else { + object.serialTimer?.dispose() + return "00:00" + } + + let currentDate = Date() + let remainingTime = currentDate.infoReadableTimeTakenFromThisForPungToHoursAndMinutes(to: pungTime) + if remainingTime == "00 : 00" { + object.serialTimer?.dispose() + object.transferExpireLabel.text = remainingTime + } + + return remainingTime + } + .bind(to: self.transferExpireLabel.rx.text) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewReactor.swift index def2bec5..0ec3e98a 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/MemberTransfer/Issue/IssueMemberTransferViewReactor.swift @@ -16,50 +16,42 @@ class IssueMemberTransferViewReactor: Reactor { } enum Mutation { - case updateTransferCode(String) + case updateTransferInfo(TransferCodeInfo) case updateIsProcessing(Bool) } struct State { - var trnsferCode: String + var trnsferCodeInfo: TransferCodeInfo? var isProcessing: Bool } var initialState: State = .init( - trnsferCode: "", + trnsferCodeInfo: nil, isProcessing: false ) - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let settingsUseCase: SettingsUserCase - init(provider: ManagerProviderType) { - self.provider = provider + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.settingsUseCase = dependencies.rootContainer.resolve(SettingsUserCase.self) } func mutate(action: Action) -> Observable { switch action { case .landing: - let request: SettingsRequest = .transferCode(isUpdate: false) - return .concat([ - .just(.updateIsProcessing(true)), - self.provider.networkManager.request(TransferCodeResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.updateTransferCode(response.transferCode)) - } - .catch(self.catchClosure), - .just(.updateIsProcessing(false)) - ]) + return self.settingsUseCase.issue() + .map(Mutation.updateTransferInfo) case .updateTransferCode: - let request: SettingsRequest = .transferCode(isUpdate: true) return .concat([ .just(.updateIsProcessing(true)), - self.provider.networkManager.request(TransferCodeResponse.self, request: request) - .flatMapLatest { response -> Observable in - return .just(.updateTransferCode(response.transferCode)) - } - .catch(self.catchClosure), + self.settingsUseCase.update() + .map(Mutation.updateTransferInfo) + .catch(self.catchClosure) + .delay(.milliseconds(1000), scheduler: MainScheduler.instance), .just(.updateIsProcessing(false)) ]) } @@ -68,8 +60,8 @@ class IssueMemberTransferViewReactor: Reactor { func reduce(state: State, mutation: Mutation) -> State { var state = state switch mutation { - case let .updateTransferCode(transferCode): - state.trnsferCode = transferCode + case let .updateTransferInfo(trnsferCodeInfo): + state.trnsferCodeInfo = trnsferCodeInfo case let .updateIsProcessing(isProcessing): state.isProcessing = isProcessing } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewController.swift index 74fed458..7eccb11b 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewController.swift @@ -14,90 +14,70 @@ import ReactorKit import RxCocoa import RxSwift - class ResignViewController: BaseNavigationViewController, View { enum Text { - static let navigationTitle: String = "계정 탈퇴" - static let firstResignTitle: String = "탈퇴하기 전" - static let secondResignTitle: String = "몇가지 안내가 있어요" - static let dot: String = "•" - static let firstResignGuide: String = "지금까지 작성한 카드와 정보들이 모두 삭제될 예정이에요" - static let secondResignGuide: String = "재가입은 탈퇴 일자를 기준으로 일주일이후 가능해요" - static let secondResignGuideWithBanFrom: String = "계정이 정지 상태 이므로, 정지 해지 날짜인" - static let secondResignGuideWithBanTo: String = "까지 재가입이 불가능해요" - static let checkResignGuide: String = "위 안내사항을 모두 확인했습니다" + static let navigationTitle: String = "탈퇴하기" + + static let placeholderText: String = "계정을 삭제하려는 이유를 알려주세요" + + static let resignGuideMessage: String = "탈퇴하려는 이유가 무엇인가요?" static let resignButtonTitle: String = "탈퇴하기" - static let dialogTitle: String = "계정이 이전된 기기입니다" - static let dialogMessge: String = "탈퇴 요청은 계정 이관코드를 입력한 기기에서 진행해주세요" + static let successDialogTitle: String = "탈퇴 완료" + static let successDialogMessage: String = "탈퇴 처리가 성공적으로 완료되었습니다." static let confirmActionTitle: String = "확인" } - private let firstResignTitleLabel = UILabel().then { - $0.text = Text.firstResignTitle - $0.textColor = .som.gray800 - $0.typography = .som.head2WithBold - } - private let secondResignTitleLabel = UILabel().then { - $0.text = Text.secondResignTitle - $0.textColor = .som.gray800 - $0.typography = .som.head2WithBold - } + // MARK: Views - private let firstDotLabel = UILabel().then { - $0.text = Text.dot - $0.textColor = .som.gray600 - $0.typography = .som.body2WithRegular - } - private let firstResignGuideLabel = UILabel().then { - $0.text = Text.firstResignGuide - $0.textColor = .som.gray600 - $0.typography = .som.body2WithRegular.withAlignment(.left) - $0.lineBreakMode = .byWordWrapping - $0.lineBreakStrategy = .hangulWordPriority - $0.numberOfLines = 0 + private let scrollView = UIScrollView().then { + $0.isScrollEnabled = true + $0.alwaysBounceVertical = true + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false } - private let secondDotLabel = UILabel().then { - $0.text = Text.dot - $0.textColor = .som.gray600 - $0.typography = .som.body2WithRegular - } - private let secondResignGuideLabel = UILabel().then { - $0.text = Text.secondResignGuide - $0.textColor = .som.gray600 - $0.typography = .som.body2WithRegular.withAlignment(.left) - $0.lineBreakMode = .byWordWrapping - $0.lineBreakStrategy = .hangulWordPriority - $0.numberOfLines = 0 + private let resignGuideMessage = UILabel().then { + $0.text = Text.resignGuideMessage + $0.textColor = .som.v2.black + $0.typography = .som.v2.head2.withAlignment(.left) } - private let checkBoxButton = UIButton() - private let checkBox = UIImageView().then { - $0.image = .init(.icon(.outlined(.checkBox))) - $0.tintColor = .som.gray500 + private let container = UIStackView().then { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .equalSpacing + $0.spacing = 10 } - private let checkResignGuideLabel = UILabel().then { - $0.text = Text.checkResignGuide - $0.textColor = .som.gray600 - $0.typography = .som.body1WithRegular + + private let resignTextField = ResignTextFieldView().then { + $0.placeholder = Text.placeholderText + $0.isHidden = true } private let resignButton = SOMButton().then { $0.title = Text.resignButtonTitle - $0.typography = .som.body1WithBold - $0.foregroundColor = .som.gray600 + $0.typography = .som.v2.title1 + $0.foregroundColor = .som.v2.white - $0.backgroundColor = .som.gray300 - $0.layer.cornerRadius = 12 + $0.backgroundColor = .som.v2.black + $0.layer.cornerRadius = 10 $0.clipsToBounds = true $0.isEnabled = false } + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + resign button height + padding + return 34 + 56 + 8 + } + + // MARK: Override func override func setupNaviBar() { @@ -109,80 +89,52 @@ class ResignViewController: BaseNavigationViewController, View { override func setupConstraints() { super.setupConstraints() - self.view.addSubview(self.firstResignTitleLabel) - self.firstResignTitleLabel.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(137) - $0.centerX.equalToSuperview() - } - self.view.addSubview(self.secondResignTitleLabel) - self.secondResignTitleLabel.snp.makeConstraints { - $0.top.equalTo(self.firstResignTitleLabel.snp.bottom) - $0.centerX.equalToSuperview() + self.view.addSubview(self.resignButton) + self.resignButton.snp.makeConstraints { + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(48) } - let resignGuideBackgroundView = UIView().then { - $0.backgroundColor = .som.gray50 - $0.layer.cornerRadius = 13 - $0.clipsToBounds = true - } - self.view.addSubview(resignGuideBackgroundView) - resignGuideBackgroundView.snp.makeConstraints { - $0.top.equalTo(self.secondResignTitleLabel.snp.bottom).offset(24) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) + self.view.addSubview(self.scrollView) + self.scrollView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) + $0.bottom.equalTo(self.resignButton.snp.top).offset(-16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) } - resignGuideBackgroundView.addSubview(self.firstDotLabel) - self.firstDotLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(20) - $0.leading.equalToSuperview().offset(19) - } - resignGuideBackgroundView.addSubview(self.firstResignGuideLabel) - self.firstResignGuideLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(20) - $0.leading.equalTo(self.firstDotLabel.snp.trailing).offset(4) - $0.trailing.equalToSuperview().offset(-19) + let guideContainer = UIView() + guideContainer.addSubview(self.resignGuideMessage) + self.resignGuideMessage.snp.makeConstraints { + $0.top.equalToSuperview().offset(16) + $0.bottom.trailing.equalToSuperview().offset(-16) + $0.leading.equalToSuperview() } - resignGuideBackgroundView.addSubview(self.secondDotLabel) - self.secondDotLabel.snp.makeConstraints { - $0.top.equalTo(self.firstResignGuideLabel.snp.bottom) - $0.leading.equalToSuperview().offset(19) - } - resignGuideBackgroundView.addSubview(self.secondResignGuideLabel) - self.secondResignGuideLabel.snp.makeConstraints { - $0.top.equalTo(self.firstResignGuideLabel.snp.bottom) - $0.bottom.equalToSuperview().offset(-20) - $0.leading.equalTo(self.secondDotLabel.snp.trailing).offset(4) - $0.trailing.equalToSuperview().offset(-19) - } + self.container.addArrangedSubview(guideContainer) + self.container.setCustomSpacing(16, after: guideContainer) - self.view.addSubview(self.checkBox) - self.checkBox.snp.makeConstraints { - $0.top.equalTo(resignGuideBackgroundView.snp.bottom).offset(28) - $0.leading.equalToSuperview().offset(24) - $0.size.equalTo(24) - } - self.view.addSubview(self.checkResignGuideLabel) - self.checkResignGuideLabel.snp.makeConstraints { - $0.top.equalTo(resignGuideBackgroundView.snp.bottom).offset(28) - $0.leading.equalTo(self.checkBox.snp.trailing).offset(11) - $0.trailing.lessThanOrEqualToSuperview().offset(-20) + self.setupReportButtons() + + self.scrollView.addSubview(self.container) + self.container.snp.makeConstraints { + $0.edges.equalToSuperview() } - self.view.addSubview(self.checkBoxButton) - self.checkBoxButton.snp.makeConstraints { - $0.top.equalTo(self.checkBox.snp.top) - $0.leading.equalTo(self.checkBox.snp.leading) - $0.trailing.equalTo(self.checkResignGuideLabel.snp.trailing) - $0.height.equalTo(24) + } + + override func updatedKeyboard(withoutBottomSafeInset height: CGFloat) { + super.updatedKeyboard(withoutBottomSafeInset: height) + + let height = height == 0 ? 0 : height + 12 + self.resignButton.snp.updateConstraints { + $0.bottom.equalTo(self.view.safeAreaLayoutGuide).offset(-height) } - self.view.addSubview(self.resignButton) - self.resignButton.snp.makeConstraints { - $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-12) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(48) + let newHeight = height == 0 ? 0 : 48 + self.container.snp.updateConstraints { + $0.bottom.equalToSuperview().offset(-newHeight) } } @@ -191,16 +143,17 @@ class ResignViewController: BaseNavigationViewController, View { func bind(reactor: ResignViewReactor) { - if let banEndAt = reactor.banEndAt { - self.secondResignGuideLabel.text = "\(Text.secondResignGuideWithBanFrom) \(banEndAt.banEndFormatted)\(Text.secondResignGuideWithBanTo)" - } - // Action - self.checkBoxButton.rx.throttleTap(.seconds(1)) - .withLatestFrom(reactor.state.map(\.isCheck)) - .map(Reactor.Action.check) + let reason = self.resignTextField.rx.text.orEmpty.distinctUntilChanged() + reason + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) + .map(Reactor.Action.updateOtherReason) .bind(to: reactor.action) .disposed(by: self.disposeBag) + reason + .map { $0.isEmpty == false } + .bind(to: self.resignButton.rx.isEnabled) + .disposed(by: self.disposeBag) self.resignButton.rx.throttleTap(.seconds(3)) .map { _ in Reactor.Action.resign } @@ -208,54 +161,108 @@ class ResignViewController: BaseNavigationViewController, View { .disposed(by: self.disposeBag) // State - reactor.state.map(\.isCheck) + reactor.state.map(\.isSuccess) + .filterNil() .distinctUntilChanged() - .subscribe(with: self) { object, isCheck in - object.checkBox.image = isCheck ? .init(.icon(.filled(.checkBox))) : .init(.icon(.outlined(.checkBox))) + .filter { $0 } + .subscribe(with: self) { object, _ in + guard let window = object.view.window else { return } - object.resignButton.isEnabled = isCheck - object.resignButton.foregroundColor = isCheck ? .som.white : .som.gray600 - object.resignButton.backgroundColor = isCheck ? .som.p300 : .som.gray300 + object.showSuccessReportedDialog { + + let onboardingViewController = OnboardingViewController() + onboardingViewController.reactor = reactor.reactorForOnboarding() + onboardingViewController.modalTransitionStyle = .crossDissolve + + let navigationViewController = UINavigationController(rootViewController: onboardingViewController) + window.rootViewController = navigationViewController + } } .disposed(by: self.disposeBag) - reactor.state.map(\.isSuccess) + reactor.state.map(\.reason) + .filterNil() .distinctUntilChanged() - .filter { $0 } - .subscribe(with: self) { object, _ in - guard let window = object.view.window else { return } + .subscribe(with: self) { object, reason in - // let onboardingViewController = OnboardingViewController() - // onboardingViewController.reactor = reactor.reactorForOnboarding() - // onboardingViewController.modalTransitionStyle = .crossDissolve + let items = object.container.arrangedSubviews.compactMap { $0 as? SOMButton } - // let navigationViewController = UINavigationController(rootViewController: onboardingViewController) - // window.rootViewController = navigationViewController + items.forEach { item in + item.isSelected = reason.identifier == item.tag + } - object.navigationController?.viewControllers = [] + if reason != .other { + object.resignButton.isEnabled = true + } } .disposed(by: self.disposeBag) + } +} + + +// MARK: setup buttons and show dialog + +private extension ResignViewController { + + func setupReportButtons() { - reactor.state.map(\.isError) - .distinctUntilChanged() - .filter { $0 } - .subscribe(with: self) { object, _ in - let confirmAction = SOMDialogAction( - title: Text.confirmActionTitle, - style: .primary, - action: { - UIApplication.topViewController?.dismiss(animated: true) { - object.navigationPop() - } - } - ) + guard let reactor = self.reactor else { return } + + WithdrawType.allCases.forEach { withdrawType in + + let item = SOMButton().then { + + $0.title = withdrawType.message + $0.typography = .som.v2.subtitle1 + $0.foregroundColor = .som.v2.gray600 + $0.backgroundColor = .som.v2.gray100 + + $0.inset = .init(top: 0, left: 16, bottom: 0, right: 0) + $0.contentHorizontalAlignment = .left - SOMDialogViewController.show( - title: Text.dialogTitle, - message: Text.dialogMessge, - actions: [confirmAction] - ) + $0.tag = withdrawType.identifier } - .disposed(by: self.disposeBag) + item.snp.makeConstraints { + $0.width.equalTo(UIScreen.main.bounds.width - 16 * 2) + $0.height.equalTo(48) + } + item.rx.throttleTap + .subscribe(with: self) { object, _ in + + object.resignTextField.isHidden = withdrawType != .other + if withdrawType == .other { + object.resignTextField.becomeFirstResponder() + } else { + object.resignTextField.resignFirstResponder() + } + reactor.action.onNext(.updateReason(withdrawType)) + } + .disposed(by: self.disposeBag) + + self.container.addArrangedSubview(item) + } + + self.container.addArrangedSubview(self.resignTextField) + self.resignTextField.snp.makeConstraints { + $0.bottom.lessThanOrEqualToSuperview().offset(-16) + } + } + + func showSuccessReportedDialog(completion: @escaping (() -> Void)) { + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + UIApplication.topViewController?.dismiss(animated: true) { completion() } + } + ) + + SOMDialogViewController.show( + title: Text.successDialogTitle, + message: Text.successDialogMessage, + textAlignment: .left, + actions: [confirmAction] + ) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewReactor.swift index ca8055a3..271b9f5a 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/ResignViewReactor.swift @@ -13,107 +13,75 @@ import Alamofire class ResignViewReactor: Reactor { enum Action: Equatable { - case check(Bool) case resign + case updateReason(WithdrawType) + case updateOtherReason(String) } enum Mutation { - case updateCheck(Bool) + case updateReason(WithdrawType) + case updateOtherReason(String?) case updateIsSuccess(Bool) - case updateIsProcessing(Bool) - case updateError(Bool) } struct State { - var isCheck: Bool - var isSuccess: Bool - var isProcessing: Bool - var isError: Bool + fileprivate(set) var reason: WithdrawType? + fileprivate(set) var otherReason: String? + fileprivate(set) var isSuccess: Bool? } var initialState: State = .init( - isCheck: false, - isSuccess: false, - isProcessing: false, - isError: false + reason: nil, + otherReason: nil, + isSuccess: nil ) - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let authUseCase: AuthUseCase - let banEndAt: Date? - - init(provider: ManagerProviderType, banEndAt: Date? = nil) { - self.provider = provider - self.banEndAt = banEndAt + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.authUseCase = dependencies.rootContainer.resolve(AuthUseCase.self) } func mutate(action: Action) -> Observable { switch action { - case let .check(isCheck): - return .just(.updateCheck(!isCheck)) case .resign: - let requset: SettingsRequest = .resign(token: self.provider.authManager.authInfo.token) - return .concat([ - .just(.updateIsProcessing(true)), - self.provider.networkManager.request(Status.self, request: requset) - .withUnretained(self) - .flatMapLatest { object, response -> Observable in - switch response.httpCode { - case 418: - return .just(.updateError(true)) - case 0: - object.provider.authManager.initializeAuthInfo() - SimpleDefaults.shared.initRemoteNotificationActivation() - - return .concat([ - object.provider.pushManager.switchNotification(on: false) - .flatMapLatest { error -> Observable in .empty() }, - .just(.updateIsSuccess(true)) - ]) - default: - return .empty() - } - } - .catch(self.catchClosure), - .just(.updateIsProcessing(false)) - ]) + guard let reason = self.currentState.reason else { return .empty() } + + return self.authUseCase.withdraw( + reaseon: reason == .other ? + (self.currentState.otherReason ?? reason.message) : + reason.message + ) + .map(Mutation.updateIsSuccess) + case let .updateReason(reason): + + return .just(.updateReason(reason)) + case let .updateOtherReason(otherReason): + + return .just(.updateOtherReason(otherReason)) } } func reduce(state: State, mutation: Mutation) -> State { - var state = state + var newState: State = state switch mutation { - case let .updateCheck(isCheck): - state.isCheck = isCheck + case let .updateReason(reason): + newState.reason = reason + case let .updateOtherReason(otherReason): + newState.otherReason = otherReason case let .updateIsSuccess(isSuccess): - state.isSuccess = isSuccess - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing - case let .updateError(isError): - state.isError = isError + newState.isSuccess = isSuccess } - return state + return newState } } extension ResignViewReactor { - // func reactorForOnboarding() -> OnboardingViewReactor { - // OnboardingViewReactor(provider: self.provider) - // } -} - -extension ResignViewReactor { - - var catchClosure: ((Error) throws -> Observable ) { - return { error in - - let nsError = error as NSError - return .concat([ - .just(.updateError(nsError.code == 418)), - .just(.updateIsProcessing(false)) - ]) - } + func reactorForOnboarding() -> OnboardingViewReactor { + OnboardingViewReactor(dependencies: self.dependencies) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/Views/ResignTextFieldView+Rx.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/Views/ResignTextFieldView+Rx.swift new file mode 100644 index 00000000..5dd637dc --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/Views/ResignTextFieldView+Rx.swift @@ -0,0 +1,18 @@ +// +// ResignTextFieldView+Rx.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import UIKit + +import RxCocoa +import RxSwift + +extension Reactive where Base: ResignTextFieldView { + + var text: ControlProperty { + self.base.textField.rx.text + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/Views/ResignTextFieldView.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/Views/ResignTextFieldView.swift new file mode 100644 index 00000000..5ec0f6d0 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Resign/Views/ResignTextFieldView.swift @@ -0,0 +1,167 @@ +// +// ResignTextFieldView.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import UIKit + +import SnapKit +import Then + +class ResignTextFieldView: UIView { + + enum Constants { + static let maxCharacters: Int = 8 + } + + + // MARK: Views + + private lazy var textFieldBackgroundView = UIView().then { + $0.backgroundColor = .som.v2.gray100 + $0.layer.cornerRadius = 10 + + let gestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(self.touch) + ) + $0.addGestureRecognizer(gestureRecognizer) + } + + lazy var textField = UITextField().then { + let paragraphStyle = NSMutableParagraphStyle() + $0.defaultTextAttributes[.paragraphStyle] = paragraphStyle + $0.defaultTextAttributes[.foregroundColor] = UIColor.som.v2.black + $0.defaultTextAttributes[.font] = Typography.som.v2.subtitle1.font + $0.tintColor = UIColor.som.v2.black + + $0.enablesReturnKeyAutomatically = true + $0.returnKeyType = .go + + $0.autocapitalizationType = .none + $0.autocorrectionType = .no + $0.spellCheckingType = .no + + $0.setContentHuggingPriority(.defaultLow, for: .horizontal) + $0.setContentCompressionResistancePriority(.defaultHigh + 1, for: .vertical) + + $0.delegate = self + } + + + // MARK: Variables + + var text: String? { + set { + self.textField.text = newValue + } + get { + return self.textField.text + } + } + + var placeholder: String? { + set { + if let string: String = newValue { + self.textField.attributedPlaceholder = NSAttributedString( + string: string, + attributes: [ + .foregroundColor: UIColor.som.v2.gray500, + .font: Typography.som.v2.subtitle1.font + ] + ) + } else { + self.textField.attributedPlaceholder = nil + } + } + + get { + return self.textField.attributedPlaceholder?.string + } + } + + var isTextEmpty: Bool { + return self.text?.isEmpty ?? false + } + + + // MARK: Initalization + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Override func + + override var isFirstResponder: Bool { + return self.textField.isFirstResponder + } + + @discardableResult + override func becomeFirstResponder() -> Bool { + return self.textField.becomeFirstResponder() + } + + @discardableResult + override func resignFirstResponder() -> Bool { + return self.textField.resignFirstResponder() + } + + + // MARK: Objc func + + @objc + private func touch(sender: UIGestureRecognizer) { + if !self.textField.isFirstResponder { + self.textField.becomeFirstResponder() + } + } + + + // MARK: Private func + + private func setupConstraints() { + + self.addSubview(self.textFieldBackgroundView) + self.textFieldBackgroundView.snp.makeConstraints { + $0.top.horizontalEdges.equalToSuperview() + $0.height.equalTo(54) + } + self.textFieldBackgroundView.addSubview(self.textField) + self.textField.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalToSuperview().offset(-24) + } + } +} + +extension ResignTextFieldView: UITextFieldDelegate { + + func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + + return textField.shouldChangeCharactersIn( + in: range, + replacementString: string, + maxCharacters: Constants.maxCharacters + ) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewController.swift index cd04f959..41cb6c97 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewController.swift @@ -20,23 +20,32 @@ class SettingsViewController: BaseNavigationViewController, View { enum Text { static let navigationTitle: String = "설정" - static let appSettingTitle: String = "앱 설정" static let notificationSettingTitle: String = "알림 설정" - static let commentHistoryTitle: String = "작성된 답카드 히스토리" - static let userSettingTitle: String = "계정 설정" - static let issueUserTransferCodeTitle: String = "계정 이관 코드 발급" - static let enterUserTransferCodeTitle: String = "계정 이관 코드 입력" + static let issueUserTransferCodeTitle: String = "다른 기기에서 로그인하기" + static let enterUserTransferCodeTitle: String = "이전 계정 불러오기" + + static let blockUsersTitle: String = "차단 사용자 관리" + + static let announcementTitle: String = "공지사항" + static let inquiryTitle: String = "문의하기" + static let acceptTermsTitle: String = "이용약관 및 개인정보 처리 방침" - static let resignTitle: String = "계정 탈퇴" + + static let appVersionTitle: String = "최신버전 업데이트" + static let latestVersionTitle: String = "최신버전 : " + + static let resignTitle: String = "탈퇴하기" static let serviceCenterTitle: String = "고객센터" - static let announcementTitle: String = "공지사항" - static let inquiryTitle: String = "1:1 문의하기" - static let suggestionTitle: String = "제안하기" - static let userBlockedGuideMessage: String = "계정이 정지된 상태에요" - static let unBlockDate: String = "차단 해제 날짜 : " + static let postingBlockedTitle: String = "이용 제한 안내" + static let postingBlockedLeadingGuideMessage: String = """ + 신고된 카드 인해 카드 추가 기능이 제한된 계정입니다. + 필요한 경우 아래 ‘문의하기’를 이용해 주세요. + 제한 기간 : + """ + static let postingBlockedTrailingGuideMessage: String = "까지" static let adminMailStrUrl: String = "sooum1004@gmail.com" static let identificationInfo: String = "식별 정보: " @@ -55,8 +64,24 @@ class SettingsViewController: BaseNavigationViewController, View { 단, 본 양식에 비방, 욕설, 허위 사실 유포 등의 부적절한 내용이 포함될 경우, 관련 법령에 따라 민·형사상 법적 조치가 이루어질 수 있음을 알려드립니다. """ + + static let bottomToastEntryName: String = "bottomToastEntryName" + static let latestVersionToastTitle: String = "현재 최신버전을 사용중입니다" + + static let testFlightStrUrl: String = "itms-beta://testflight.apple.com/v1/app" + static let appStoreStrUrl: String = "itms-apps://itunes.apple.com/app/id" + + static let resignDialogTitle: String = "정말 탈퇴하시겠습니까?" + static let resignDialogMessage: String = "계정이 삭제되면 모든 정보가 영구적으로 삭제되며, 탈퇴일 기준 7일 후부터 재가입이 가능합니다." + static let resignDialogBannedLeadingMessage: String = "계정이 삭제되면 모든 정보가 영구 삭제되며, 재가입은 이용 제한 해지 날짜인 " + static let resignDialogBannedTrailingMessage: String = "부터 가능합니다." + + static let cancelActionButtonTitle: String = "취소" } + + // MARK: views + private let scrollView = UIScrollView().then { $0.isScrollEnabled = true $0.alwaysBounceVertical = true @@ -64,40 +89,42 @@ class SettingsViewController: BaseNavigationViewController, View { $0.showsHorizontalScrollIndicator = false } - private let appSettingHeader = SettingScrollViewHeader(title: Text.appSettingTitle) private let notificationSettingCellView = SettingTextCellView(buttonStyle: .toggle, title: Text.notificationSettingTitle) - private let commentHistoryCellView = SettingTextCellView(title: Text.commentHistoryTitle) - private let userSettingHeader = SettingScrollViewHeader(title: Text.userSettingTitle) private let issueUserTransferCodeCellView = SettingTextCellView(title: Text.issueUserTransferCodeTitle) private let enterUserTransferCodeCellView = SettingTextCellView(title: Text.enterUserTransferCodeTitle) - private let acceptTermsCellView = SettingTextCellView(title: Text.acceptTermsTitle) - private let resignCellView = SettingTextCellView(title: Text.resignTitle, titleColor: .som.red) - private let serviceCenterHeader = SettingScrollViewHeader(title: Text.serviceCenterTitle) + private let blockUsersCellView = SettingTextCellView(title: Text.blockUsersTitle) + private let announcementCellView = SettingTextCellView(title: Text.announcementTitle) private let inquiryCellView = SettingTextCellView(title: Text.inquiryTitle) - private let suggestionCellView = SettingTextCellView(title: Text.suggestionTitle) - private let userBlockedBackgroundView = UIView() - private let userBlockedLabel = UILabel().then { - let range = (Text.userBlockedGuideMessage as NSString).range(of: "정지") - let typography = Typography.som.body1WithBold - let attributedString = NSMutableAttributedString( - string: Text.userBlockedGuideMessage, - attributes: typography.attributes - ) - attributedString.addAttribute(.foregroundColor, value: UIColor.som.red, range: range) - $0.attributedText = attributedString + private let acceptTermsCellView = SettingTextCellView(title: Text.acceptTermsTitle) + + private let appVersionCellView = SettingVersionCellView(title: Text.appVersionTitle) + + private let resignCellView = SettingTextCellView(title: Text.resignTitle) + + private let postingBlockedBackgroundView = UIView().then { + $0.isHidden = true + } + private let postingBlockedTitleLabel = UILabel().then { + $0.text = Text.postingBlockedTitle + $0.textColor = .som.v2.black + $0.typography = .som.v2.caption1 } - private let unBlockDateLabel = UILabel().then { - $0.text = Text.unBlockDate - $0.textColor = .som.gray500 - $0.typography = .som.body2WithRegular + private let postingBlockedMessageLabel = UILabel().then { + $0.text = Text.postingBlockedLeadingGuideMessage + $0.textColor = .som.v2.gray500 + $0.typography = .som.v2.caption3 } - override var navigationBarHeight: CGFloat { - 46 + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + padding + return 34 + 8 } @@ -111,72 +138,69 @@ class SettingsViewController: BaseNavigationViewController, View { override func setupConstraints() { - self.view.backgroundColor = .som.white + self.view.backgroundColor = .som.v2.gray100 self.view.addSubview(self.scrollView) self.scrollView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(12) + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) $0.bottom.leading.trailing.equalToSuperview() } let container = UIStackView(arrangedSubviews: [ - self.appSettingHeader, self.notificationSettingCellView, - self.commentHistoryCellView, - self.userSettingHeader, self.issueUserTransferCodeCellView, self.enterUserTransferCodeCellView, - self.acceptTermsCellView, - self.resignCellView, - self.serviceCenterHeader, + self.blockUsersCellView, self.announcementCellView, self.inquiryCellView, - self.suggestionCellView, - self.userBlockedBackgroundView + self.acceptTermsCellView, + self.appVersionCellView, + self.resignCellView, + self.postingBlockedBackgroundView ]).then { $0.axis = .vertical $0.alignment = .fill - $0.setCustomSpacing(18, after: self.commentHistoryCellView) - $0.setCustomSpacing(18, after: self.resignCellView) - $0.setCustomSpacing(20, after: self.suggestionCellView) + $0.setCustomSpacing(16, after: self.notificationSettingCellView) + $0.setCustomSpacing(16, after: self.enterUserTransferCodeCellView) + $0.setCustomSpacing(16, after: self.blockUsersCellView) + $0.setCustomSpacing(16, after: self.inquiryCellView) + $0.setCustomSpacing(16, after: self.acceptTermsCellView) + $0.setCustomSpacing(16, after: self.appVersionCellView) } self.scrollView.addSubview(container) container.snp.makeConstraints { $0.edges.equalToSuperview() } - let userBlockContainer = UIStackView(arrangedSubviews: [ - self.userBlockedLabel, - self.unBlockDateLabel - ]).then { - $0.axis = .vertical - $0.spacing = 5 - $0.alignment = .center - $0.distribution = .equalSpacing + self.postingBlockedBackgroundView.addSubview(self.postingBlockedTitleLabel) + self.postingBlockedTitleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } - self.userBlockedBackgroundView.addSubview(userBlockContainer) - userBlockContainer.snp.makeConstraints { - $0.top.equalToSuperview().offset(22) - $0.bottom.equalToSuperview().offset(-22) - $0.leading.trailing.equalToSuperview() + self.postingBlockedBackgroundView.addSubview(self.postingBlockedMessageLabel) + self.postingBlockedMessageLabel.snp.makeConstraints { + $0.top.equalTo(self.postingBlockedTitleLabel.snp.bottom).offset(6) + $0.bottom.equalToSuperview().offset(-16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.lessThanOrEqualToSuperview().offset(-16) } } override func bind() { - super.bind() -#if DEVELOP - self.setupDebugging() -#endif + self.navigationBar.backButton.rx.tap + .subscribe(with: self) { object, _ in + object.navigationPop(bottomBarHidden: false) + } + .disposed(by: self.disposeBag) } - // MARK: ReactorKit bind + // MARK: ReactorKit - bind func bind(reactor: SettingsViewReactor) { - self.notificationSettingCellView.toggleSwitch.isOn = reactor.initialState.notificationStatus - // Action self.rx.viewWillAppear .map { _ in Reactor.Action.landing } @@ -190,14 +214,6 @@ class SettingsViewController: BaseNavigationViewController, View { .bind(to: reactor.action) .disposed(by: self.disposeBag) - self.commentHistoryCellView.rx.didSelect - .subscribe(with: self) { object, _ in - let commentHistoryViewController = CommentHistroyViewController() - commentHistoryViewController.reactor = reactor.reactorForCommentHistory() - object.navigationPush(commentHistoryViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: self.disposeBag) - self.issueUserTransferCodeCellView.rx.didSelect .subscribe(with: self) { object, _ in let issueMemberTransferViewController = IssueMemberTransferViewController() @@ -208,24 +224,17 @@ class SettingsViewController: BaseNavigationViewController, View { self.enterUserTransferCodeCellView.rx.didSelect .subscribe(with: self) { object, _ in - // let enterMemberTransferViewController = EnterMemberTransferViewController() - // enterMemberTransferViewController.reactor = reactor.reactorForTransferEnter() - // object.navigationPush(enterMemberTransferViewController, animated: true, bottomBarHidden: true) - } - .disposed(by: self.disposeBag) - - self.acceptTermsCellView.rx.didSelect - .subscribe(with: self) { object, _ in - let rermsOfServiceViewController = TermsOfServiceViewController() - object.navigationPush(rermsOfServiceViewController, animated: true, bottomBarHidden: true) + let enterMemberTransferViewController = EnterMemberTransferViewController() + enterMemberTransferViewController.reactor = reactor.reactorForTransferEnter() + object.navigationPush(enterMemberTransferViewController, animated: true, bottomBarHidden: true) } .disposed(by: self.disposeBag) - self.resignCellView.rx.didSelect + self.blockUsersCellView.rx.didSelect .subscribe(with: self) { object, _ in - let resignViewController = ResignViewController() - resignViewController.reactor = reactor.reactorForResign() - object.navigationPush(resignViewController, animated: true, bottomBarHidden: true) + let blockUsersViewController = BlockUsersViewController() + blockUsersViewController.reactor = reactor.reactorForBlock() + object.navigationPush(blockUsersViewController, animated: true, bottomBarHidden: true) } .disposed(by: self.disposeBag) @@ -239,53 +248,80 @@ class SettingsViewController: BaseNavigationViewController, View { self.inquiryCellView.rx.didSelect .subscribe(onNext: { _ in + let subject = Text.inquiryMailTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let guideMessage = """ \(Text.identificationInfo) - \(reactor.provider.authManager.authInfo.token.refreshToken)\n + \(reactor.authManager.authInfo.token.refreshToken)\n \(Text.inquiryMailGuideMessage) """ let body = guideMessage.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let mailToString = "mailto:\(Text.adminMailStrUrl)?subject=\(subject)&body=\(body)" - + if let mailtoUrl = URL(string: mailToString), UIApplication.shared.canOpenURL(mailtoUrl) { - + UIApplication.shared.open(mailtoUrl, options: [:], completionHandler: nil) } }) .disposed(by: self.disposeBag) - self.suggestionCellView.rx.didSelect - .subscribe(onNext: { _ in - let subject = Text.suggestMailTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" - let guideMessage = """ - \(Text.identificationInfo) - \(reactor.provider.authManager.authInfo.token.refreshToken)\n - \(Text.suggestMailGuideMessage) - """ - let body = guideMessage.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" - let mailToString = "mailto:\(Text.adminMailStrUrl)?subject=\(subject)&body=\(body)" - - if let mailtoUrl = URL(string: mailToString), - UIApplication.shared.canOpenURL(mailtoUrl) { + self.acceptTermsCellView.rx.didSelect + .subscribe(with: self) { object, _ in + let rermsOfServiceViewController = TermsOfServiceViewController() + object.navigationPush(rermsOfServiceViewController, animated: true, bottomBarHidden: true) + } + .disposed(by: self.disposeBag) + + let version = reactor.state.map(\.version).filterNil().distinctUntilChanged().share() + self.appVersionCellView.rx.didSelect + .withLatestFrom(version) + .subscribe(with: self) { object, version in + if version.mustUpdate { + #if DEVELOP + // 개발 버전일 때 testFlight로 전환 + let strUrl = "\(Text.testFlightStrUrl)/\(Info.appId)" + if let testFlightUrl = URL(string: strUrl) { + UIApplication.shared.open(testFlightUrl, options: [:], completionHandler: nil) + } + #elseif PRODUCTION + // 운영 버전일 때 app store로 전환 + let strUrl = "\(Text.appStoreStrUrl)\(Info.appId)" + if let appStoreUrl = URL(string: strUrl) { + UIApplication.shared.open(appStoreUrl, options: [:], completionHandler: nil) + } + #endif + } else { + let bottomFloatView = SOMBottomToastView(title: Text.latestVersionToastTitle, actions: nil) - UIApplication.shared.open(mailtoUrl, options: [:], completionHandler: nil) + var wrapper: SwiftEntryKitViewWrapper = bottomFloatView.sek + wrapper.entryName = Text.bottomToastEntryName + // TODO: 임시, 하단 네비바 높이를 34로 가정 후 사용 + wrapper.showBottomToast(verticalOffset: 34 + 8, displayDuration: 4) } - }) + } .disposed(by: self.disposeBag) - // State - reactor.state.map(\.isProcessing) - .distinctUntilChanged() - .bind(to: self.activityIndicatorView.rx.isAnimating) + self.resignCellView.rx.didSelect + .map { _ in Reactor.Action.rejoinableDate } + .bind(to: reactor.action) .disposed(by: self.disposeBag) + // State reactor.state.map(\.banEndAt) .distinctUntilChanged() .subscribe(with: self) { object, banEndAt in - object.userBlockedBackgroundView.isHidden = (banEndAt == nil) - object.unBlockDateLabel.text = "\(Text.unBlockDate) \(banEndAt?.banEndFormatted ?? "")" + object.postingBlockedBackgroundView.isHidden = (banEndAt == nil) + object.postingBlockedMessageLabel.text = Text.postingBlockedLeadingGuideMessage + + (banEndAt?.banEndDetailFormatted ?? "") + + Text.postingBlockedTrailingGuideMessage + } + .disposed(by: self.disposeBag) + + reactor.state.map(\.rejoinableDate) + .filterNil() + .subscribe(with: self) { object, rejoinableDate in + object.showResignDialog(rejoinableDate: rejoinableDate) } .disposed(by: self.disposeBag) @@ -294,6 +330,12 @@ class SettingsViewController: BaseNavigationViewController, View { .bind(to: self.notificationSettingCellView.toggleSwitch.rx.isOn) .disposed(by: self.disposeBag) + version + .subscribe(with: self) { object, version in + object.appVersionCellView.setLatestVersion(Text.latestVersionTitle + version.latestVersion) + } + .disposed(by: self.disposeBag) + reactor.state.map(\.shouldHideTransfer) .distinctUntilChanged() .subscribe(with: self) { object, shouldHide in @@ -304,24 +346,58 @@ class SettingsViewController: BaseNavigationViewController, View { } } + +// MARK: Show dialog + extension SettingsViewController { - private func setupDebugging() { - - let longPressRecognizer = UILongPressGestureRecognizer() - self.appSettingHeader.addGestureRecognizer(longPressRecognizer) - - longPressRecognizer.rx.event - .flatMapLatest { _ in Log.extract() } - .subscribe( - with: self, - onNext: { object, viewController in - object.navigationController?.present(viewController, animated: true) - }, - onError: { _, error in - Log.error(error.localizedDescription) + func showResignDialog(rejoinableDate: RejoinableDateInfo) { + + guard let reactor = self.reactor else { return } + + let cancelAction = SOMDialogAction( + title: Text.cancelActionButtonTitle, + style: .gray, + action: { + UIApplication.topViewController?.dismiss(animated: true) { + reactor.action.onNext(.resetState) } - ) - .disposed(by: self.disposeBag) + } + ) + + let resignAction = SOMDialogAction( + title: Text.resignTitle, + style: .red, + action: { + UIApplication.topViewController?.dismiss(animated: true) { + let resignViewController = ResignViewController() + resignViewController.reactor = reactor.reactorForResign() + self.navigationPush( + resignViewController, + animated: true, + bottomBarHidden: true + ) { _ in + reactor.action.onNext(.resetState) + } + } + } + ) + + var message: String { + if rejoinableDate.isActivityRestricted == false { + return Text.resignDialogMessage + } else { + return Text.resignDialogBannedLeadingMessage + + rejoinableDate.rejoinableDate.banEndFormatted + + Text.resignDialogBannedTrailingMessage + } + } + + SOMDialogViewController.show( + title: Text.resignDialogTitle, + message: message, + textAlignment: .left, + actions: [cancelAction, resignAction] + ) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewReactor.swift index c1ab4775..04607d55 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/SettingsViewReactor.swift @@ -15,34 +15,48 @@ class SettingsViewReactor: Reactor { enum Action: Equatable { case landing case updateNotificationStatus(Bool) + case rejoinableDate + case resetState } enum Mutation { case updateBanEndAt(Date?) + case updateVersion(Version?) case updateNotificationStatus(Bool) - case updateIsProcessing(Bool) + case rejoinableDate(RejoinableDateInfo?) + case resetState } struct State { fileprivate(set) var banEndAt: Date? + fileprivate(set) var version: Version? fileprivate(set) var notificationStatus: Bool - fileprivate(set) var isProcessing: Bool fileprivate(set) var shouldHideTransfer: Bool + fileprivate(set) var rejoinableDate: RejoinableDateInfo? } var initialState: State - let provider: ManagerProviderType + private let dependencies: AppDIContainerable + private let appVersionUseCase: AppVersionUseCase + private let userUseCase: UserUseCase + private let settingsUseCase: SettingsUserCase + private let pushManager: PushManagerDelegate - private let disposeBag = DisposeBag() + let authManager: AuthManagerDelegate - init(provider: ManagerProviderType) { - self.provider = provider + init(dependencies: AppDIContainerable) { + self.dependencies = dependencies + self.appVersionUseCase = dependencies.rootContainer.resolve(AppVersionUseCase.self) + self.userUseCase = dependencies.rootContainer.resolve(UserUseCase.self) + self.settingsUseCase = dependencies.rootContainer.resolve(SettingsUserCase.self) + self.pushManager = dependencies.rootContainer.resolve(ManagerProviderType.self).pushManager + self.authManager = dependencies.rootContainer.resolve(ManagerProviderType.self).authManager self.initialState = .init( banEndAt: nil, - notificationStatus: provider.pushManager.notificationStatus, - isProcessing: false, + version: nil, + notificationStatus: self.pushManager.notificationStatus, shouldHideTransfer: UserDefaults.standard.bool(forKey: "AppFlag") ) } @@ -52,78 +66,66 @@ class SettingsViewReactor: Reactor { case .landing: return .concat([ - .just(.updateIsProcessing(true)), - self.provider.networkManager.request(SettingsResponse.self, request: SettingsRequest.activate) - .flatMapLatest { response -> Observable in - return .just(.updateBanEndAt(response.banEndAt)) - } - .catch(self.catchClosure), - self.provider.networkManager.request( - NotificationAllowResponse.self, - request: SettingsRequest.notificationAllow(isAllowNotify: nil) - ) - .flatMapLatest { response -> Observable in - return .just(.updateNotificationStatus(response.isAllowNotify)) - } - .catch(self.catchClosure), - .just(.updateIsProcessing(false)) + self.appVersionUseCase.version() + .map(Mutation.updateVersion), + self.userUseCase.postingPermission() + .map(\.expiredAt) + .map(Mutation.updateBanEndAt), + self.userUseCase.updateNotify(isAllowNotify: self.initialState.notificationStatus) + .map(Mutation.updateNotificationStatus) ]) case let .updateNotificationStatus(state): - return self.provider.networkManager.request( - Empty.self, - request: SettingsRequest.notificationAllow(isAllowNotify: state) - ) - .flatMapLatest { _ -> Observable in - return .just(.updateNotificationStatus(state)) - } - .catchAndReturn(.updateNotificationStatus(!state)) + + return self.userUseCase.updateNotify(isAllowNotify: state) + .map { _ in state } + .map(Mutation.updateNotificationStatus) + case .rejoinableDate: + + return self.settingsUseCase.rejoinableDate() + .map(Mutation.rejoinableDate) + case .resetState: + + return .just(.resetState) } } func reduce(state: State, mutation: Mutation) -> State { - var state = state + var newState: State = state switch mutation { case let .updateBanEndAt(banEndAt): - state.banEndAt = banEndAt + newState.banEndAt = banEndAt + case let .updateVersion(version): + newState.version = version case let .updateNotificationStatus(notificationStatus): - state.notificationStatus = notificationStatus - case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing + newState.notificationStatus = notificationStatus + case let .rejoinableDate(rejoinableDate): + newState.rejoinableDate = rejoinableDate + case .resetState: + newState.rejoinableDate = nil } - return state + return newState } } extension SettingsViewReactor { - var catchClosure: ((Error) throws -> Observable ) { - return { _ in - .concat([ - .just(.updateIsProcessing(false)) - ]) - } + func reactorForTransferIssue() -> IssueMemberTransferViewReactor { + IssueMemberTransferViewReactor(dependencies: self.dependencies) } -} - -extension SettingsViewReactor { - func reactorForCommentHistory() -> CommentHistroyViewReactor { - CommentHistroyViewReactor(provider: self.provider) + func reactorForTransferEnter() -> EnterMemberTransferViewReactor { + EnterMemberTransferViewReactor(dependencies: self.dependencies) } - func reactorForTransferIssue() -> IssueMemberTransferViewReactor { - IssueMemberTransferViewReactor(provider: self.provider) + func reactorForBlock() -> BlockUsersViewReactor { + BlockUsersViewReactor(dependencies: self.dependencies) } - // func reactorForTransferEnter() -> EnterMemberTransferViewReactor { - // EnterMemberTransferViewReactor(provider: self.provider, entranceType: .settings) - // } - func reactorForResign() -> ResignViewReactor { - ResignViewReactor(provider: self.provider, banEndAt: self.currentState.banEndAt) + ResignViewReactor(dependencies: self.dependencies) } func reactorForAnnouncement() -> AnnouncementViewReactor { - AnnouncementViewReactor(provider: self.provider) + AnnouncementViewReactor(dependencies: self.dependencies) } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingScrollViewHeader.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingScrollViewHeader.swift deleted file mode 100644 index 4777aa6b..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingScrollViewHeader.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// SettingScrollViewHeader.swift -// SOOUM -// -// Created by 오현식 on 12/4/24. -// - -import UIKit - -import SnapKit -import Then - - -class SettingScrollViewHeader: UIView { - - private let titleLabel = UILabel().then { - $0.textColor = .som.gray700 - $0.typography = .som.body1WithBold - } - - convenience init(title: String) { - self.init(frame: .zero) - - self.titleLabel.text = title - } - - override init(frame: CGRect) { - super.init(frame: frame) - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupConstraints() { - - self.snp.makeConstraints { - $0.width.equalTo(UIScreen.main.bounds.width) - $0.height.equalTo(36) - } - - self.addSubview(self.titleLabel) - self.titleLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(4) - $0.bottom.equalToSuperview().offset(-8) - $0.leading.equalToSuperview().offset(20) - $0.trailing.lessThanOrEqualToSuperview().offset(-20) - } - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView+Rx.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView+Rx.swift index 5feef60f..ebafb811 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView+Rx.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView+Rx.swift @@ -8,7 +8,6 @@ import RxCocoa import RxSwift - extension Reactive where Base: SettingTextCellView { var isOn: ControlProperty { diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView.swift index f4602ea4..ae321a2b 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingTextCellView.swift @@ -10,7 +10,6 @@ import UIKit import SnapKit import Then - class SettingTextCellView: UIView { enum ButtonStyle { @@ -18,37 +17,45 @@ class SettingTextCellView: UIView { case toggle } + + // MARK: Views + + let backgroundButton = UIButton() + private let titleLabel = UILabel().then { - $0.typography = .som.body2WithBold + $0.textColor = .som.v2.black + $0.typography = .som.v2.body1 } private let arrowImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.next))) - $0.tintColor = .som.gray400 + $0.image = .init(.icon(.v2(.outlined(.right)))) + $0.tintColor = .som.gray300 } let toggleSwitch = UISwitch().then { $0.isOn = false - $0.onTintColor = .som.p300 - $0.thumbTintColor = .som.white + $0.onTintColor = .som.v2.pMain + $0.tintColor = .som.v2.gray200 + $0.thumbTintColor = .som.v2.white - $0.transform = CGAffineTransform(scaleX: 0.75, y: 0.75) + if let thumb = $0.subviews.first?.subviews.last?.subviews.last { + thumb.transform = CGAffineTransform(scaleX: 0.85, y: 0.85) + } } - let backgroundButton = UIButton() - var buttonStyle: ButtonStyle? + // MARK: Variables + + private(set) var buttonStyle: ButtonStyle? - convenience init( - buttonStyle: ButtonStyle = .arrow, - title: String, - titleColor: UIColor = .som.gray500 - ) { + + // MARK: Initialize + + convenience init(buttonStyle: ButtonStyle = .arrow, title: String) { self.init(frame: .zero) self.buttonStyle = buttonStyle self.titleLabel.text = title - self.titleLabel.textColor = titleColor self.setupConstraints() } @@ -61,11 +68,16 @@ class SettingTextCellView: UIView { fatalError("init(coder:) has not been implemented") } + + // MARK: Private func + private func setupConstraints() { + self.backgroundColor = .som.v2.white + self.snp.makeConstraints { $0.width.equalTo(UIScreen.main.bounds.width) - $0.height.equalTo(46) + $0.height.equalTo(48) } switch self.buttonStyle { @@ -73,17 +85,14 @@ class SettingTextCellView: UIView { self.addSubview(self.titleLabel) self.titleLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(13) - $0.bottom.equalToSuperview().offset(-13) - $0.leading.equalToSuperview().offset(20) + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(16) } self.addSubview(self.toggleSwitch) self.toggleSwitch.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.trailing.equalToSuperview().offset(-20) - $0.width.equalTo(40) - $0.height.equalTo(24) + $0.trailing.equalToSuperview().offset(-16) } self.addSubview(self.backgroundButton) @@ -96,23 +105,20 @@ class SettingTextCellView: UIView { let backgroundView = UIView() self.addSubview(backgroundView) backgroundView.snp.makeConstraints { - $0.top.equalToSuperview().offset(7) - $0.bottom.equalToSuperview().offset(-7) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) + $0.verticalEdges.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) } backgroundView.addSubview(self.titleLabel) self.titleLabel.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview() + $0.centerY.leading.equalToSuperview() } backgroundView.addSubview(self.arrowImageView) self.arrowImageView.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.trailing.equalToSuperview() - $0.size.equalTo(24) + $0.centerY.trailing.equalToSuperview() + $0.size.equalTo(16) } self.addSubview(self.backgroundButton) diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingVersionCellView+Rx.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingVersionCellView+Rx.swift new file mode 100644 index 00000000..2bed1124 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingVersionCellView+Rx.swift @@ -0,0 +1,16 @@ +// +// SettingVersionCellView+Rx.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import RxCocoa +import RxSwift + +extension Reactive where Base: SettingVersionCellView { + + var didSelect: ControlEvent { + self.base.backgroundButton.rx.tap + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingVersionCellView.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingVersionCellView.swift new file mode 100644 index 00000000..08551ac8 --- /dev/null +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/Views/SettingVersionCellView.swift @@ -0,0 +1,114 @@ +// +// SettingVersionCellView.swift +// SOOUM +// +// Created by 오현식 on 12/4/24. +// + +import UIKit + +import SnapKit +import Then + +class SettingVersionCellView: UIView { + + + // MARK: Views + + let backgroundButton = UIButton() + + private let titleLabel = UILabel().then { + $0.textColor = .som.v2.black + $0.typography = .som.v2.body1 + } + + private let latestVersionLabel = UILabel().then { + $0.textColor = .som.v2.gray400 + $0.typography = .som.v2.caption3 + } + + private let currentVersionLabel = UILabel().then { + $0.text = Info.appVersion + $0.textColor = .som.v2.pDark + $0.typography = .som.v2.body1 + } + + private let arrowImageView = UIImageView().then { + $0.image = .init(.icon(.v2(.outlined(.right)))) + $0.tintColor = .som.gray300 + } + + + // MARK: Initialize + + convenience init(title: String) { + self.init(frame: .zero) + + self.titleLabel.text = title + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: Private func + + private func setupConstraints() { + + self.backgroundColor = .som.v2.white + + self.snp.makeConstraints { + $0.width.equalTo(UIScreen.main.bounds.width) + $0.height.equalTo(48) + } + + self.addSubview(self.titleLabel) + self.titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(6) + $0.leading.equalToSuperview().offset(16) + } + + self.addSubview(self.latestVersionLabel) + self.latestVersionLabel.snp.makeConstraints { + $0.top.equalTo(self.titleLabel.snp.bottom) + $0.bottom.equalToSuperview().offset(-6) + $0.leading.equalToSuperview().offset(16) + } + + self.addSubview(self.arrowImageView) + self.arrowImageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.trailing.equalToSuperview().offset(-16) + $0.size.equalTo(16) + } + + self.addSubview(self.currentVersionLabel) + self.currentVersionLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.trailing.equalTo(self.arrowImageView.snp.leading).offset(-10) + } + + self.addSubview(self.backgroundButton) + self.backgroundButton.snp.makeConstraints { + $0.top.equalTo(self.titleLabel.snp.top) + $0.bottom.equalTo(self.latestVersionLabel.snp.bottom) + $0.leading.equalTo(self.titleLabel.snp.leading) + $0.trailing.equalTo(self.arrowImageView.snp.trailing) + } + } + + + // MAKR: Public func + func setLatestVersion(_ latestVersion: String) { + + self.latestVersionLabel.text = latestVersion + self.latestVersionLabel.typography = .som.v2.caption3 + } +} diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/TermsOfServiceViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/TermsOfServiceViewController.swift index b6fe15a4..65e67fe9 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/TermsOfServiceViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/TermsOfServiceViewController.swift @@ -17,7 +17,7 @@ import RxSwift class TermsOfServiceViewController: BaseNavigationViewController { enum Text { - static let navigationTitle: String = "이용약관 및 개인정보 처리 방침" + static let navigationTitle: String = "약관 및 개인정보 처리 동의" static let privacyPolicyTitle: String = "개인정보처리방침" static let termsOfServiceTitle: String = "서비스 이용약관" static let termsOfLocationInfoTitle: String = "위치정보 이용약관" @@ -34,8 +34,12 @@ class TermsOfServiceViewController: BaseNavigationViewController { private let termsOfServiceCellView = TermsOfServiceTextCellView(title: Text.termsOfServiceTitle) private let termsOfLocationInfoCellView = TermsOfServiceTextCellView(title: Text.termsOfLocationInfoTitle) - override var navigationBarHeight: CGFloat { - 46 + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + padding + return 34 + 8 } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/Views/TermsOfServiceTextCellView.swift b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/Views/TermsOfServiceTextCellView.swift index 2f09d5ec..c6e79a14 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/Views/TermsOfServiceTextCellView.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/Settings/termsOfService/Views/TermsOfServiceTextCellView.swift @@ -10,21 +10,26 @@ import UIKit import SnapKit import Then - class TermsOfServiceTextCellView: UIView { + + // MARK: Views + private let titleLabel = UILabel().then { - $0.textColor = .som.gray500 - $0.typography = .som.body2WithBold + $0.textColor = .som.v2.black + $0.typography = .som.v2.body1 } private let arrowImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.next))) - $0.tintColor = .som.gray400 + $0.image = .init(.icon(.v2(.outlined(.right)))) + $0.tintColor = .som.v2.gray300 } let backgroundButton = UIButton() + + // MARK: Initialize + convenience init(title: String) { self.init(frame: .zero) @@ -41,24 +46,27 @@ class TermsOfServiceTextCellView: UIView { fatalError("init(coder:) has not been implemented") } + + // MARK: Private func + private func setupConstraints() { self.snp.makeConstraints { $0.width.equalTo(UIScreen.main.bounds.width) - $0.height.equalTo(46) + $0.height.equalTo(48) } self.addSubview(self.titleLabel) self.titleLabel.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(20) + $0.leading.equalToSuperview().offset(16) } self.addSubview(self.arrowImageView) self.arrowImageView.snp.makeConstraints { $0.centerY.equalToSuperview() - $0.trailing.equalToSuperview().offset(-20) - $0.size.equalTo(24) + $0.trailing.equalToSuperview().offset(-16) + $0.size.equalTo(16) } self.addSubview(self.backgroundButton) diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewController.swift b/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewController.swift index a7577505..3f88528f 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewController.swift @@ -7,42 +7,101 @@ import UIKit -import Kingfisher import SnapKit import Then + +import Photos +import SwiftEntryKit import YPImagePicker import ReactorKit import RxCocoa +import RxGesture import RxSwift - class UpdateProfileViewController: BaseNavigationViewController, View { enum Text { - static let navigationTitle: String = "프로필 수정" - static let textFieldPlaceholder: String = "8글자 이내 닉네임을 입력해주세요" + static let navigationTitle: String = "프로필 편집" + static let guideMessage: String = "최대 8자까지 입력할 수 있어요" + static let saveButtonTitle: String = "저장" + + static let cancelActionTitle: String = "취소" + static let settingActionTitle: String = "설정" static let completeButtonTitle: String = "완료" + static let passButtonTitle: String = "건너뛰기" + + static let libraryDialogTitle: String = "앱 접근 권한 안내" + static let libraryDialogMessage: String = "사진첨부를 위해 접근 권한이 필요해요. [설정 > 앱 > 숨 > 사진]에서 사진 보관함 접근 권한을 허용해 주세요." + + static let inappositeDialogTitle: String = "부적절한 사진으로 보여져요" + static let inappositeDialogMessage: String = "다른 사진으로 변경하거나 기본 이미지를 사용해 주세요." + static let inappositeDialogConfirmButtonTitle: String = "확인" + + static let selectProfileEntryName: String = "SOMBottomFloatView" + + static let selectProfileFirstButtonTitle: String = "앨범에서 사진 선택" + static let selectProfileSecondButtonTitle: String = "사진 찍기" + static let selectProfileThirdButtonTitle: String = "기본 이미지 적용" + + static let selectPhotoFullScreenNextTitle: String = "다음" + static let selectPhotoFullScreenCancelTitle: String = "취소" + static let selectPhotoFullScreenSaveTitle: String = "저장" + static let selectPhotoFullScreenAlbumsTitle: String = "앨범" + static let selectPhotoFullScreenCameraTitle: String = "카메라" + static let selectPhotoFullScreenLibraryTitle: String = "갤러리" + static let selectPhotoFullScreenCropTitle: String = "자르기" } - private let updateProfileView = UpdateProfileView().then { - $0.placeholder = Text.textFieldPlaceholder + + // MARK: Views + + private let profileImageView = UIImageView().then { + $0.image = .init(.image(.v2(.profile_large))) + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .som.v2.gray300 + $0.layer.cornerRadius = 120 * 0.5 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.som.v2.gray300.cgColor + $0.clipsToBounds = true + } + private let cameraButton = SOMButton().then { + $0.image = .init(.icon(.v2(.filled(.camera)))) + $0.foregroundColor = .som.v2.gray400 + + $0.backgroundColor = .som.v2.white + $0.layer.borderColor = UIColor.som.v2.gray200.cgColor + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 32 * 0.5 + } + + private let nicknameTextField = SOMNicknameTextField().then { + $0.guideMessage = Text.guideMessage } - private let completeButton = SOMButton().then { - $0.title = Text.completeButtonTitle - $0.typography = .som.body2WithBold - $0.foregroundColor = .som.white + private let saveButton = SOMButton().then { + $0.title = Text.saveButtonTitle + $0.typography = .som.v2.title1 + $0.foregroundColor = .som.v2.white - $0.backgroundColor = .som.p300 - $0.layer.cornerRadius = 12 + $0.backgroundColor = .som.v2.black + $0.layer.cornerRadius = 10 $0.clipsToBounds = true $0.isEnabled = false } - override var navigationBarHeight: CGFloat { - 46 + + // MARK: Variables + + private var actions: [SOMBottomFloatView.FloatAction] = [] + + + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + save button height + padding + return 34 + 56 + 8 } @@ -54,35 +113,48 @@ class UpdateProfileViewController: BaseNavigationViewController, View { self.navigationBar.title = Text.navigationTitle } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - self.updateProfileView.becomeFirstResponder() - } - override func setupConstraints() { super.setupConstraints() - self.view.addSubview(self.updateProfileView) - self.updateProfileView.snp.makeConstraints { - $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top) - $0.leading.trailing.equalToSuperview() + self.view.addSubview(self.profileImageView) + self.profileImageView.snp.makeConstraints { + $0.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(24) + $0.centerX.equalToSuperview() + $0.size.equalTo(120) + } + self.view.addSubview(self.cameraButton) + self.cameraButton.snp.makeConstraints { + $0.bottom.equalTo(self.profileImageView.snp.bottom) + $0.trailing.equalTo(self.profileImageView.snp.trailing) + $0.size.equalTo(32) } - self.view.addSubview(self.completeButton) - self.completeButton.snp.makeConstraints { - $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-12) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(48) + self.view.addSubview(self.nicknameTextField) + self.nicknameTextField.snp.makeConstraints { + $0.top.equalTo(self.profileImageView.snp.bottom).offset(40) + $0.horizontalEdges.equalToSuperview() + } + + self.view.addSubview(self.saveButton) + self.saveButton.snp.makeConstraints { + $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(56) } } + override func viewDidLoad() { + super.viewDidLoad() + + PHPhotoLibrary.requestAuthorization(for: .readWrite) { _ in } + } + override func updatedKeyboard(withoutBottomSafeInset height: CGFloat) { super.updatedKeyboard(withoutBottomSafeInset: height) - let margin: CGFloat = height + 24 - self.completeButton.snp.updateConstraints { + let margin: CGFloat = height == 0 ? 0 : height + 12 + self.saveButton.snp.updateConstraints { $0.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-margin) } } @@ -92,109 +164,258 @@ class UpdateProfileViewController: BaseNavigationViewController, View { func bind(reactor: UpdateProfileViewReactor) { - KingfisherManager.shared.download(strUrl: reactor.profile.profileImg?.url) { [weak self] image in - self?.updateProfileView.image = image ?? .init(.image(.defaultStyle(.sooumLogo))) - } - self.updateProfileView.text = reactor.profile.nickname - - // Action - self.updateProfileView.changeProfileButton.rx.throttleTap(.seconds(3)) + self.rx.viewDidLoad .subscribe(with: self) { object, _ in - object.showPicker(reactor) + + var actions: [SOMBottomFloatView.FloatAction] = [ + .init( + title: Text.selectProfileFirstButtonTitle, + action: { [weak object] in + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + object?.showPicker(for: .library) + } + } + ), + .init( + title: Text.selectProfileSecondButtonTitle, + action: { [weak object] in + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + object?.showPicker(for: .photo) + } + } + ) + ] + + if let profileImage = reactor.initialState.profileImage { + + object.profileImageView.image = profileImage + + actions.append( + .init( + title: Text.selectProfileThirdButtonTitle, + action: { + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + reactor.action.onNext(.setInitialImage) + } + } + ) + ) + } + + object.actions = actions + object.nicknameTextField.text = reactor.nickname } .disposed(by: self.disposeBag) - let nickname = self.updateProfileView.textField.rx.text.orEmpty.distinctUntilChanged() + // Action + Observable.merge( + self.profileImageView.rx.tapGesture().when(.ended).map { _ in }, + self.cameraButton.rx.tap.asObservable() + ) + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, _ in + + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + if status == .authorized || status == .limited { + + let selectProfileBottomFloatView = SOMBottomFloatView(actions: object.actions) + + var wrapper: SwiftEntryKitViewWrapper = selectProfileBottomFloatView.sek + wrapper.entryName = Text.selectProfileEntryName + wrapper.showBottomFloat(screenInteraction: .dismiss) + } else { + + object.showLibraryPermissionDialog() + } + } + .disposed(by: self.disposeBag) + + let nickname = self.nicknameTextField.textField.rx.text.orEmpty.distinctUntilChanged().share() nickname - .debounce(.seconds(1), scheduler: MainScheduler.instance) + .skip(1) + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) .map(Reactor.Action.checkValidate) .bind(to: reactor.action) .disposed(by: self.disposeBag) - self.completeButton.rx.throttleTap(.seconds(3)) + self.saveButton.rx.throttleTap(.seconds(3)) .withLatestFrom(nickname) .map(Reactor.Action.updateProfile) .bind(to: reactor.action) .disposed(by: self.disposeBag) - + // State - reactor.state.map(\.errorMessage) - .distinctUntilChanged() - .bind(to: self.updateProfileView.rx.errorMessage) - .disposed(by: self.disposeBag) - - reactor.state.map(\.isValid) - .distinctUntilChanged() - .subscribe(with: self) { object, isValid in - object.completeButton.foregroundColor = isValid ? .som.white : .som.gray600 - object.completeButton.backgroundColor = isValid ? .som.p300 : .som.gray300 - object.completeButton.isEnabled = isValid + let profileImage = reactor.state.map(\.profileImage).distinctUntilChanged().share() + profileImage + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, profileImage in + object.profileImageView.image = profileImage ?? .init(.image(.v2(.profile_large))) + + var actions: [SOMBottomFloatView.FloatAction] = [ + .init( + title: Text.selectProfileFirstButtonTitle, + action: { [weak object] in + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + object?.showPicker(for: .library) + } + } + ), + .init( + title: Text.selectProfileSecondButtonTitle, + action: { [weak object] in + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + object?.showPicker(for: .photo) + } + } + ) + ] + + if profileImage != nil { + actions.append(.init( + title: Text.selectProfileThirdButtonTitle, + action: { + SwiftEntryKit.dismiss(.specific(entryName: Text.selectProfileEntryName)) { + reactor.action.onNext(.setInitialImage) + } + } + )) + } + + object.actions = actions } .disposed(by: self.disposeBag) - - reactor.state.map(\.isSuccess) + + // State + reactor.state.map(\.isUpdatedSuccess) .distinctUntilChanged() + .filter { $0 } .subscribe(with: self) { object, _ in - object.navigationPop() + object.navigationPop(bottomBarHidden: false) { + NotificationCenter.default.post(name: .reloadProfileData, object: nil, userInfo: nil) + } } .disposed(by: self.disposeBag) - reactor.state.map(\.isProcessing) + Observable.combineLatest( + reactor.state.map(\.isValid).distinctUntilChanged(), + profileImage.startWith(reactor.initialState.profileImage), + resultSelector: { $0 || $1 != reactor.initialState.profileImage } + ) + .observe(on: MainScheduler.asyncInstance) + .bind(to: self.saveButton.rx.isEnabled) + .disposed(by: self.disposeBag) + + reactor.state.map(\.hasErrors) + .filterNil() + .filter { $0 } + .observe(on: MainScheduler.asyncInstance) + .subscribe(onNext: { _ in + + let actions: [SOMDialogAction] = [ + .init( + title: Text.inappositeDialogConfirmButtonTitle, + style: .primary, + action: { + UIApplication.topViewController?.dismiss(animated: true) { + reactor.action.onNext(.setDefaultImage) + } + } + ) + ] + + SOMDialogViewController.show( + title: Text.inappositeDialogTitle, + message: Text.inappositeDialogMessage, + textAlignment: .left, + actions: actions + ) + }) + .disposed(by: self.disposeBag) + + reactor.state.map(\.errorMessage) .distinctUntilChanged() - .bind(to: self.activityIndicatorView.rx.isAnimating) + .observe(on: MainScheduler.asyncInstance) + .subscribe(with: self) { object, errorMessage in + object.nicknameTextField.guideMessage = errorMessage == nil ? Text.guideMessage : errorMessage + object.nicknameTextField.hasError = errorMessage != nil + } .disposed(by: self.disposeBag) } } -extension UpdateProfileViewController { +private extension UpdateProfileViewController { - private func showPicker(_ reactor: UpdateProfileViewReactor) { + func showLibraryPermissionDialog() { + + let cancelAction = SOMDialogAction( + title: Text.cancelActionTitle, + style: .gray, + action: { + UIApplication.topViewController?.dismiss(animated: true) + } + ) + 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) + } + + UIApplication.topViewController?.dismiss(animated: true) + } + ) + + SOMDialogViewController.show( + title: Text.libraryDialogTitle, + message: Text.libraryDialogMessage, + actions: [cancelAction, settingAction] + ) + } + + func showPicker(for screen: YPPickerScreen) { var config = YPImagePickerConfiguration() + config.library.options = nil - config.library.onlySquare = false - config.library.isSquareByDefault = true config.library.minWidthForItem = nil - config.library.mediaType = YPlibraryMediaType.photo - config.library.defaultMultipleSelection = false - config.library.maxNumberOfItems = 1 - config.library.minNumberOfItems = 1 - config.library.numberOfItemsInRow = 4 - config.library.spacingBetweenItems = 1.0 - config.showsCrop = .rectangle(ratio: 1) + config.showsCrop = .rectangle(ratio: 1.0) config.showsPhotoFilters = false - config.library.skipSelectionsGallery = false config.library.preselectedItems = nil - config.library.preSelectItemOnMultipleSelection = true - config.startOnScreen = .library + config.screens = [screen] + config.startOnScreen = screen config.shouldSaveNewPicturesToAlbum = false - config.wordings.next = "다음" - config.wordings.cancel = "취소" - config.wordings.save = "저장" - config.wordings.albumsTitle = "앨범" - config.wordings.cameraTitle = "카메라" - config.wordings.libraryTitle = "갤러리" - config.wordings.crop = "자르기" + config.wordings.next = Text.selectPhotoFullScreenNextTitle + config.wordings.cancel = Text.selectPhotoFullScreenCancelTitle + config.wordings.save = Text.selectPhotoFullScreenSaveTitle + config.wordings.albumsTitle = Text.selectPhotoFullScreenAlbumsTitle + config.wordings.cameraTitle = Text.selectPhotoFullScreenCameraTitle + config.wordings.libraryTitle = Text.selectPhotoFullScreenLibraryTitle + config.wordings.crop = Text.selectPhotoFullScreenCropTitle let picker = YPImagePicker(configuration: config) - picker.didFinishPicking { [weak self] items, cancelled in - - guard let self = self, let reactor = self.reactor else { return } + picker.didFinishPicking { [weak self, weak picker] items, cancelled in if cancelled { - Log.error("Picker was canceled") - picker.dismiss(animated: true, completion: nil) + Log.debug("Picker was canceled") + picker?.dismiss(animated: true, completion: nil) return } - if let image = items.singlePhoto?.image { - self.updateProfileView.image = image - reactor.action.onNext(.updateImage(image)) + if let image = items.singlePhoto?.image, let reactor = self?.reactor { + reactor.action.onNext(.uploadImage(image)) + } else { + Log.error("Error occured while picking an image") } - picker.dismiss(animated: true, completion: nil) + picker?.dismiss(animated: true, completion: nil) } - self.present(picker, animated: true, completion: nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + self.present(picker, animated: true, completion: nil) + } } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewReactor.swift index 8d631b2c..53634035 100644 --- a/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/UpdateProfileViewReactor.swift @@ -7,7 +7,7 @@ import ReactorKit -import Alamofire +import Kingfisher class UpdateProfileViewReactor: Reactor { @@ -18,131 +18,180 @@ class UpdateProfileViewReactor: Reactor { } enum Action: Equatable { - case updateImage(UIImage) + case uploadImage(UIImage) + case setDefaultImage + case setInitialImage case checkValidate(String) case updateProfile(String) } enum Mutation { - case updateImage(UIImage) + case updateImageInfo(UIImage?, String?) case updateIsValid(Bool) case updateIsSuccess(Bool) case updateIsProcessing(Bool) + case updateErrors(Bool?) case updateErrorMessage(String?) } struct State { var profileImage: UIImage? + var profileImageName: String? var isValid: Bool - var isSuccess: Bool + var isUpdatedSuccess: Bool var isProcessing: Bool + var hasErrors: Bool? var errorMessage: String? } - var initialState: State = .init( - profileImage: nil, - isValid: false, - isSuccess: false, - isProcessing: false, - errorMessage: nil - ) + var initialState: State private var imageName: String? - let provider: ManagerProviderType - var profile: Profile + private let dependencies: AppDIContainerable + private let userUseCase: UserUseCase - init(provider: ManagerProviderType, _ profile: Profile) { - self.provider = provider - self.profile = profile + let nickname: String + + init( + dependencies: AppDIContainerable, + nickname: String, + image profileImage: UIImage? + ) { + self.dependencies = dependencies + self.userUseCase = dependencies.rootContainer.resolve(UserUseCase.self) + self.nickname = nickname + + self.initialState = .init( + profileImage: profileImage, + isValid: false, + isUpdatedSuccess: false, + isProcessing: false, + hasErrors: nil, + errorMessage: nil + ) } func mutate(action: Action) -> Observable { switch action { + case let .uploadImage(image): + + return .concat([ + .just(.updateErrors(nil)), + self.uploadImage(image) + .catch(self.catchClosure) + ]) + case .setDefaultImage: + + return .just(.updateImageInfo(self.initialState.profileImage, nil)) + case .setInitialImage: + + return .just(.updateImageInfo(nil, nil)) case let .checkValidate(nickname): + + guard nickname != self.nickname else { + return .concat([ + .just(.updateIsValid(false)), + .just(.updateErrorMessage(nil)) + ]) + } + if nickname.isEmpty { return .concat([ .just(.updateIsValid(false)), .just(.updateErrorMessage(ErrorMessages.isEmpty.rawValue)) ]) } - let request: JoinRequest = .validateNickname(nickname: nickname) return .concat([ .just(.updateErrorMessage(nil)), - self.provider.networkManager.request(NicknameValidationResponse.self, request: request) - .flatMapLatest { response -> Observable in - let isAvailable = response.isAvailable - let errorMessage = isAvailable ? nil : ErrorMessages.inValid.rawValue + self.userUseCase.isNicknameValid(nickname: nickname) + .withUnretained(self) + .flatMapLatest { object, isValid -> Observable in + + let errorMessage = isValid ? nil : ErrorMessages.inValid.rawValue return .concat([ - .just(.updateIsValid(isAvailable)), + .just(.updateIsValid(isValid)), .just(.updateErrorMessage(errorMessage)) ]) } ]) - case let .updateImage(image): - return self.updateImage(image) case let .updateProfile(nickname): - let trimedNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines) - let request: ProfileRequest = .updateProfile(nickname: trimedNickname, profileImg: self.imageName) - return .concat([ - .just(.updateIsProcessing(true)), - - self.provider.networkManager.request(Empty.self, request: request) - .flatMapLatest { _ -> Observable in - return .just(.updateIsSuccess(true)) - }, - - .just(.updateIsProcessing(false)) - ]) + let trimedNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + let updatedNickname = trimedNickname == self.nickname ? nil : trimedNickname + return self.userUseCase.updateMyProfile( + nickname: updatedNickname, + imageName: self.currentState.profileImageName + ) + .map(Mutation.updateIsSuccess) } } func reduce(state: State, mutation: Mutation) -> State { - var state = state + var newState: State = state switch mutation { + case let .updateImageInfo(profileImage, profileImageName): + newState.profileImage = profileImage + newState.profileImageName = profileImageName case let .updateIsValid(isValid): - state.isValid = isValid - case let .updateImage(profileImage): - state.profileImage = profileImage - case let .updateIsSuccess(isSuccess): - state.isSuccess = isSuccess + newState.isValid = isValid + case let .updateIsSuccess(isUpdatedSuccess): + newState.isUpdatedSuccess = isUpdatedSuccess case let .updateIsProcessing(isProcessing): - state.isProcessing = isProcessing + newState.isProcessing = isProcessing + case let .updateErrors(hasErrors): + newState.hasErrors = hasErrors case let .updateErrorMessage(errorMessage): - state.errorMessage = errorMessage + newState.errorMessage = errorMessage } - return state + return newState } } extension UpdateProfileViewReactor { - private func updateImage(_ image: UIImage) -> Observable { + private func uploadImage(_ image: UIImage) -> Observable { + return self.presignedURL() .withUnretained(self) - .flatMapLatest { object, presignedResponse -> Observable in + .flatMapLatest { object, presignedInfo -> Observable in if let imageData = image.jpegData(compressionQuality: 0.5), - let url = URL(string: presignedResponse.strUrl) { - return object.provider.networkManager.upload(imageData, to: url) - .flatMapLatest { _ -> Observable in - return .empty() + let url = URL(string: presignedInfo.imgUrl) { + + return object.userUseCase.uploadImage(imageData, with: url) + .flatMapLatest { isSuccess -> Observable in + + let image = isSuccess ? image : nil + let imageName = isSuccess ? presignedInfo.imgName : nil + + return .just(.updateImageInfo(image, imageName)) } + } else { + return .empty() } - return .empty() } + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) } - private func presignedURL() -> Observable<(strUrl: String, imageName: String)> { - let request: JoinRequest = .profileImagePresignedURL + private func presignedURL() -> Observable { - return self.provider.networkManager.request(PresignedStorageResponse.self, request: request) - .withUnretained(self) - .flatMapLatest { object, response -> Observable<(strUrl: String, imageName: String)> in - object.imageName = response.imgName - let result = (response.url.url, response.imgName) - return .just(result) - } + return self.userUseCase.presignedURL() + } + + private var catchClosure: ((Error) throws -> Observable ) { + return { error in + + let nsError = error as NSError + let endProcessing = Observable.concat([ + // TODO: 부적절한 사진일 때, `확인` 버튼 탭 시 이미지 변경 + // .just(.updateImageInfo(nil, nil)), + .just(.updateIsProcessing(false)), + // 부적절한 이미지 업로드 에러 코드 == 422 + .just(.updateErrors(nsError.code == 422)) + ]) + + return endProcessing + } } } diff --git a/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/Views/UpdateProfileView.swift b/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/Views/UpdateProfileView.swift deleted file mode 100644 index 3dd6e800..00000000 --- a/SOOUM/SOOUM/Presentations/Main/Profile/UpdateProfile/Views/UpdateProfileView.swift +++ /dev/null @@ -1,311 +0,0 @@ -// -// UpdateProfileView.swift -// SOOUM -// -// Created by 오현식 on 12/4/24. -// - -import UIKit - -import SnapKit -import Then - - -class UpdateProfileView: UIView { - - enum Text { - static let textFieldTitle: String = "닉네임" - static let errorMessage: String = "한글자 이상 입력해주세요" - } - - private let profileImageView = UIImageView().then { - $0.image = .init(.image(.defaultStyle(.sooumLogo))) - $0.layer.cornerRadius = 128 * 0.5 - $0.clipsToBounds = true - } - - let changeProfileButton = UIButton() - private let cameraBackgroundView = UIView().then { - $0.backgroundColor = UIColor(hex: "#B4B4B4") - $0.layer.cornerRadius = 32 * 0.5 - $0.clipsToBounds = true - } - private let cameraImageView = UIImageView().then { - $0.image = .init(.icon(.outlined(.camera))) - $0.tintColor = .som.white - } - - private let textFieldTitleLabel = UILabel().then { - $0.text = Text.textFieldTitle - $0.textColor = .som.gray700 - $0.typography = .som.body1WithBold - } - - private lazy var textFieldBackgroundView = UIView().then { - $0.backgroundColor = .som.gray50 - $0.layer.borderColor = UIColor.som.gray50.cgColor - $0.layer.borderWidth = 1 - $0.layer.cornerRadius = 12 - - let gestureRecognizer = UITapGestureRecognizer( - target: self, - action: #selector(self.touch) - ) - $0.addGestureRecognizer(gestureRecognizer) - } - lazy var textField = UITextField().then { - let paragraphStyle = NSMutableParagraphStyle() - $0.defaultTextAttributes[.paragraphStyle] = paragraphStyle - $0.defaultTextAttributes[.foregroundColor] = UIColor.som.gray500 - $0.defaultTextAttributes[.font] = Typography.som.body1WithRegular.font - $0.tintColor = .som.p300 - - $0.enablesReturnKeyAutomatically = true - $0.returnKeyType = .go - - $0.autocapitalizationType = .none - $0.autocorrectionType = .no - $0.spellCheckingType = .no - - $0.setContentHuggingPriority(.defaultLow, for: .horizontal) - $0.setContentCompressionResistancePriority(.defaultHigh + 1, for: .vertical) - - $0.delegate = self - } - - private let errorMessageContainer = UIStackView().then { - $0.axis = .horizontal - $0.alignment = .center - $0.distribution = .equalSpacing - $0.spacing = 4 - } - - private let errorImageView = UIImageView().then { - $0.image = .init(.image(.defaultStyle(.errorTriangle))) - } - - private let errorMessageLabel = UILabel().then { - $0.text = Text.errorMessage - $0.textColor = .som.red - $0.typography = .som.body2WithBold - } - - private let characterLabel = UILabel().then { - $0.text = "0/8" - $0.textColor = .som.gray500 - $0.typography = .init( - fontContainer: BuiltInFont(size: 14, weight: .medium), - lineHeight: 14, - letterSpacing: 0.04 - ) - } - - private let maxCharacter: Int = 8 - - var image: UIImage? { - didSet { - self.profileImageView.image = self.image - } - } - - var text: String? { - set { - self.textField.text = newValue - } - get { - return self.textField.text - } - } - - var placeholder: String? { - set { - if let string: String = newValue { - self.textField.attributedPlaceholder = NSAttributedString( - string: string, - attributes: [ - .foregroundColor: UIColor.som.gray500, - .font: Typography.som.body1WithRegular.font - ] - ) - } else { - self.textField.attributedPlaceholder = nil - } - } - - get { - return self.textField.attributedPlaceholder?.string - } - } - - var errorMessage: String? { - set { - self.errorMessageContainer.isHidden = newValue == nil - self.errorMessageLabel.text = newValue - } - get { - return self.errorMessageLabel.text - } - } - - override var isFirstResponder: Bool { - return self.textField.isFirstResponder - } - - @discardableResult - override func becomeFirstResponder() -> Bool { - return self.textField.becomeFirstResponder() - } - - @discardableResult - override func resignFirstResponder() -> Bool { - return self.textField.resignFirstResponder() - } - - @objc - private func touch(sender: UIGestureRecognizer) { - if !self.textField.isFirstResponder { - self.textField.becomeFirstResponder() - } - } - - convenience init() { - self.init(frame: .zero) - } - - override init(frame: CGRect) { - super.init(frame: frame) - - self.setupConstraints() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupConstraints() { - - self.addSubview(self.profileImageView) - self.profileImageView.snp.makeConstraints { - $0.top.equalToSuperview().offset(42) - $0.centerX.equalToSuperview() - $0.size.equalTo(128) - } - - self.addSubview(self.cameraBackgroundView) - self.cameraBackgroundView.snp.makeConstraints { - $0.top.equalTo(self.profileImageView.snp.top).offset(100) - $0.leading.equalTo(self.profileImageView.snp.leading).offset(95) - $0.size.equalTo(32) - } - self.cameraBackgroundView.addSubview(self.cameraImageView) - self.cameraImageView.snp.makeConstraints { - $0.center.equalToSuperview() - $0.size.equalTo(24) - } - self.cameraBackgroundView.addSubview(self.changeProfileButton) - self.changeProfileButton.snp.makeConstraints { - $0.edges.equalTo(self.cameraBackgroundView) - } - - self.addSubview(self.textFieldTitleLabel) - self.textFieldTitleLabel.snp.makeConstraints { - $0.top.equalTo(self.profileImageView.snp.bottom).offset(32) - $0.leading.equalToSuperview().offset(20) - $0.trailing.lessThanOrEqualToSuperview().offset(-20) - } - - self.addSubview(self.textFieldBackgroundView) - self.textFieldBackgroundView.snp.makeConstraints { - $0.top.equalTo(self.textFieldTitleLabel.snp.bottom).offset(12) - $0.leading.equalToSuperview().offset(20) - $0.trailing.equalToSuperview().offset(-20) - $0.height.equalTo(52) - } - self.textFieldBackgroundView.addSubview(self.textField) - self.textField.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.leading.equalToSuperview().offset(12) - $0.trailing.equalToSuperview().offset(-12) - } - - self.addSubviews(self.errorMessageContainer) - self.errorMessageContainer.snp.makeConstraints { - $0.top.equalTo(self.textFieldBackgroundView.snp.bottom).offset(4) - $0.leading.equalToSuperview().offset(20) - $0.height.equalTo(24) - } - self.errorMessageContainer.addArrangedSubview(self.errorImageView) - self.errorImageView.snp.makeConstraints { - $0.size.equalTo(24) - } - self.errorMessageContainer.addArrangedSubview(self.errorMessageLabel) - - self.addSubview(self.characterLabel) - self.characterLabel.snp.makeConstraints { - $0.top.equalTo(self.textFieldBackgroundView.snp.bottom).offset(10) - $0.bottom.equalToSuperview() - $0.leading.greaterThanOrEqualTo(self.errorMessageContainer.snp.trailing).offset(20) - $0.trailing.equalToSuperview().offset(-20) - } - } - - private func animate(outlineColor: UIColor) { - - UIView.animate( - withDuration: 0.25, - delay: 0, - options: [ - .allowUserInteraction, - .beginFromCurrentState, - .curveEaseOut - ] - ) { - self.textFieldBackgroundView.layer.borderColor = outlineColor.cgColor - } - } -} - -extension UpdateProfileView: UITextFieldDelegate { - - func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { - self.animate(outlineColor: .som.p300) - return true - } - - func textFieldShouldEndEditing(_ textField: UITextField) -> Bool { - self.animate(outlineColor: .som.gray50) - return true - } - - func textField( - _ textField: UITextField, - shouldChangeCharactersIn range: NSRange, - replacementString string: String - ) -> Bool { - - let nsString: NSString? = textField.text as NSString? - let newString: String = nsString?.replacingCharacters(in: range, with: string) ?? "" - - return newString.count < self.maxCharacter + 1 - } - - func textFieldDidChangeSelection(_ textField: UITextField) { - - let text = textField.text ?? "" - self.characterLabel.text = text.count.description + "/" + self.maxCharacter.description - } - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - return true - } -} - -extension String { - - var isConsonant: Bool { - guard let scalar = UnicodeScalar(self)?.value else { return false } - let consonantScalarRange: ClosedRange = 12593...12622 - return consonantScalarRange ~= scalar - } -} diff --git a/SOOUM/SOOUM/Presentations/Main/Write/Views/TextView/WriteCardTextView.swift b/SOOUM/SOOUM/Presentations/Main/Write/Views/TextView/WriteCardTextView.swift index 4b914b82..80c30ac5 100644 --- a/SOOUM/SOOUM/Presentations/Main/Write/Views/TextView/WriteCardTextView.swift +++ b/SOOUM/SOOUM/Presentations/Main/Write/Views/TextView/WriteCardTextView.swift @@ -23,6 +23,7 @@ class WriteCardTextView: UIView { private lazy var backgroundImageView = UIImageView().then { $0.backgroundColor = .clear + $0.contentMode = .scaleAspectFill $0.layer.borderColor = UIColor.som.v2.white.cgColor $0.layer.borderWidth = 1 $0.layer.cornerRadius = 16 diff --git a/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewController.swift b/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewController.swift index b58314b3..483b09b7 100644 --- a/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewController.swift +++ b/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewController.swift @@ -40,6 +40,12 @@ class WriteCardViewController: BaseNavigationViewController, View { static let inappositeDialogTitle: String = "부적절한 사진으로 보여져요" static let inappositeDialogMessage: String = "다른 사진으로 변경하거나 기본 이미지를 사용해 주세요." + static let banUserDialogTitle: String = "이용 제한 안내" + static let banUserDialogFirstLeadingMessage: String = "신고된 카드로 인해 " + static let banUserDialogFirstTrailingMessage: String = " 카드 추가가 제한됩니다." + static let banUserDialogSecondLeadingMessage: String = " 카드 추가는 " + static let banUserDialogSecondTrailingMessage: String = "부터 가능합니다." + static let cancelActionTitle: String = "취소" static let settingActionTitle: String = "설정" static let confirmActionTitle: String = "확인" @@ -117,6 +123,14 @@ class WriteCardViewController: BaseNavigationViewController, View { private var relatedTagsViewBottomConstraint: Constraint? + // MARK: Override variables + + override var bottomToastMessageOffset: CGFloat { + /// bottom safe layout guide + padding + return 34 + 8 + } + + // MARK: Override func override func touchesBegan(_ touches: Set, with event: UIEvent?) { } @@ -246,7 +260,7 @@ class WriteCardViewController: BaseNavigationViewController, View { let writeCardtext = self.writeCardView.writeCardTextView.rx.text.orEmpty.distinctUntilChanged().share() let selectedImageInfo = self.selectImageView.selectedImageInfo.share() writeCardtext - .map { $0.isEmpty == false } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } .withLatestFrom(selectedImageInfo, resultSelector: { $0 && $1 != nil }) .observe(on: MainScheduler.asyncInstance) .bind(to: self.writeButton.rx.isEnabled) @@ -478,13 +492,24 @@ class WriteCardViewController: BaseNavigationViewController, View { .distinctUntilChanged() .observe(on: MainScheduler.asyncInstance) .subscribe(with: self) { object, hasErrors in - if hasErrors == 422 { - + if case 422 = hasErrors { object.showInappositeDialog() } } .disposed(by: self.disposeBag) + reactor.state.map(\.couldPosting) + .filterNil() + .filter { $0.isBaned } + .subscribe(with: self) { object, postingPermission in + + let banEndGapToDays = postingPermission.expiredAt?.infoReadableTimeTakenFromThisForBanEndPosting(to: Date().toKorea()) + let banEndToString = postingPermission.expiredAt?.banEndDetailFormatted + + object.showWriteCardPermissionDialog(gapDays: banEndGapToDays ?? "", banEndFormatted: banEndToString ?? "") + } + .disposed(by: self.disposeBag) + reactor.state.map(\.defaultImages) .filterNil() .distinctUntilChanged() @@ -608,6 +633,33 @@ extension WriteCardViewController { actions: actions ) } + + func showWriteCardPermissionDialog(gapDays: String, banEndFormatted: String) { + + let dialogFirstMessage = Text.banUserDialogFirstLeadingMessage + + gapDays + + Text.banUserDialogFirstTrailingMessage + let dialogSecondMessage = Text.banUserDialogSecondLeadingMessage + + banEndFormatted + + Text.banUserDialogSecondTrailingMessage + + let confirmAction = SOMDialogAction( + title: Text.confirmActionTitle, + style: .primary, + action: { + UIApplication.topViewController?.dismiss(animated: true) { + self.navigationPop() + } + } + ) + + SOMDialogViewController.show( + title: Text.banUserDialogTitle, + message: dialogFirstMessage + dialogSecondMessage, + textAlignment: .left, + actions: [confirmAction] + ) + } } diff --git a/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewReactor.swift b/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewReactor.swift index 21c6bff9..8ebec352 100644 --- a/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewReactor.swift +++ b/SOOUM/SOOUM/Presentations/Main/Write/WriteCardViewReactor.swift @@ -24,6 +24,7 @@ class WriteCardViewReactor: Reactor { ) case relatedTags(keyword: String) case updateRelatedTags + case postingPermission } enum Mutation { @@ -31,6 +32,7 @@ class WriteCardViewReactor: Reactor { case updateUserImage(UIImage?, Bool) case writeCard(String?) case relatedTags([TagInfo]) + case updatePostingPermission(PostingPermission?) case updateIsProcessing(Bool) case updateErrors(Int?) } @@ -40,6 +42,7 @@ class WriteCardViewReactor: Reactor { fileprivate(set) var defaultImages: DefaultImages? fileprivate(set) var userImage: UIImage? fileprivate(set) var relatedTags: [TagInfo]? + fileprivate(set) var couldPosting: PostingPermission? fileprivate(set) var writtenCardId: String? fileprivate(set) var isDownloaded: Bool? fileprivate(set) var isProcessing: Bool @@ -51,6 +54,7 @@ class WriteCardViewReactor: Reactor { defaultImages: nil, userImage: nil, relatedTags: nil, + couldPosting: nil, writtenCardId: nil, isDownloaded: nil, isProcessing: false, @@ -60,6 +64,7 @@ class WriteCardViewReactor: Reactor { private let dependencies: AppDIContainerable private let cardUseCase: CardUseCase private let tagUseCase: TagUseCase + private let userUseCase: UserUseCase let locationManager: LocationManagerDelegate @@ -75,6 +80,7 @@ class WriteCardViewReactor: Reactor { self.dependencies = dependencies self.cardUseCase = dependencies.rootContainer.resolve(CardUseCase.self) self.tagUseCase = dependencies.rootContainer.resolve(TagUseCase.self) + self.userUseCase = dependencies.rootContainer.resolve(UserUseCase.self) self.locationManager = dependencies.rootContainer.resolve(ManagerProviderType.self).locationManager self.entranceType = entranceType self.parentCardId = parentCardId @@ -121,6 +127,10 @@ class WriteCardViewReactor: Reactor { case .updateRelatedTags: return .just(.relatedTags([])) + case .postingPermission: + + return self.userUseCase.postingPermission() + .map(Mutation.updatePostingPermission) } } @@ -136,6 +146,8 @@ class WriteCardViewReactor: Reactor { newState.writtenCardId = writtenCardId case let .relatedTags(relatedTags): newState.relatedTags = relatedTags + case let .updatePostingPermission(couldPosting): + newState.couldPosting = couldPosting case let .updateIsProcessing(isProcessing): newState.isProcessing = isProcessing case let .updateErrors(hasErrors): @@ -259,6 +271,15 @@ private extension WriteCardViewReactor { return { error in let nsError = error as NSError + if case 400 = nsError.code { + return .concat([ + .just(.writeCard(nil)), + .just(.updateIsProcessing(false)), + self.userUseCase.postingPermission() + .map(Mutation.updatePostingPermission) + ]) + } + return .concat([ .just(.writeCard(nil)), .just(.updateIsProcessing(false)), diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/SettingsRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/SettingsRequest.swift deleted file mode 100644 index 2ad392c9..00000000 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/SettingsRequest.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// SettingsRequest.swift -// SOOUM -// -// Created by 오현식 on 12/5/24. -// - -import Foundation - -import Alamofire - - -enum SettingsRequest: BaseRequest { - - case activate - case notificationAllow(isAllowNotify: Bool?) - case commentHistory(lastId: String?) - case transferCode(isUpdate: Bool) - case transferMember(transferId: String, encryptedDeviceId: String) - case resign(token: Token) - case announcement - - - var path: String { - switch self { - case .activate: - return "/settings/status" - case .notificationAllow: - return "/members/notify" - case .commentHistory: - return "/members/comment-cards" - case .resign: - return "/members" - case .announcement: - return "/notices" - default: - return "/settings/transfer" - } - } - - var method: HTTPMethod { - switch self { - case let .notificationAllow(isAllowNotify): - return isAllowNotify == nil ? .get : .patch - case let .transferCode(isUpdate): - return isUpdate ? .patch : .get - case .transferMember: - return .post - case .resign: - return .delete - default: - return .get - } - } - - var parameters: Parameters { - switch self { - case let .notificationAllow(isAllowNotify): - if let isAllowNotify = isAllowNotify { - return ["isAllowNotify": isAllowNotify] - } else { - return [:] - } - case let .transferMember(transferId, encryptedDeviceId): - return [ - "deviceType": "IOS", - "transferId": transferId, - "encryptedDeviceId": encryptedDeviceId - ] - case let .commentHistory(lastId): - if let lastId = lastId { - return ["lastId": lastId] - } else { - return [:] - } - case let .resign(token): - return ["accessToken": token.accessToken, "refreshToken": token.refreshToken] - default: - return [:] - } - } - - var encoding: ParameterEncoding { - switch self { - case let .notificationAllow(isAllowNotify): - return isAllowNotify == nil ? URLEncoding.default : JSONEncoding.default - case .transferMember, .resign: - return JSONEncoding.default - default: - return URLEncoding.default - } - } - - var authorizationType: AuthorizationType { - return .access - } - - func asURLRequest() throws -> URLRequest { - - if let url = URL(string: Constants.endpoint)?.appendingPathComponent(self.path) { - var request = URLRequest(url: url) - request.method = self.method - - request.setValue(self.authorizationType.rawValue, forHTTPHeaderField: "AuthorizationType") - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.contentType.rawValue - ) - request.setValue( - Constants.ContentType.json.rawValue, - forHTTPHeaderField: Constants.HTTPHeader.acceptType.rawValue - ) - let encoded = try encoding.encode(request, with: self.parameters) - return encoded - } else { - return URLRequest(url: URL(string: "")!) - } - } -} - diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/AuthRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/AuthRequest.swift index 1bf65a58..993c5b07 100644 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/AuthRequest.swift +++ b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/AuthRequest.swift @@ -23,10 +23,8 @@ enum AuthRequest: BaseRequest { case login(encryptedDeviceId: String) /// 재인증 case reAuthenticationWithRefreshSession(token: Token) - #if DEVELOP - /// 테스트 용 계정 삭제 - case withdraw(token: Token) - #endif + /// 회원탈퇴 + case withdraw(token: Token, reason: String) var path: String { switch self { @@ -38,10 +36,8 @@ enum AuthRequest: BaseRequest { return "/api/auth/login" case .reAuthenticationWithRefreshSession: return "/api/auth/token/reissue" - #if DEVELOP case .withdraw: return "/api/auth/withdrawal" - #endif } } @@ -51,10 +47,8 @@ enum AuthRequest: BaseRequest { return .get case .login, .signUp, .reAuthenticationWithRefreshSession: return .post - #if DEVELOP case .withdraw: return .delete - #endif } } @@ -102,13 +96,12 @@ enum AuthRequest: BaseRequest { "accessToken": token.accessToken, "refreshToken": token.refreshToken ] - #if DEVELOP - case let .withdraw(token): + case let .withdraw(token, reaseon): return [ "accessToken": token.accessToken, - "refreshToken": token.refreshToken + "refreshToken": token.refreshToken, + "reason": reaseon ] - #endif default: return [:] } @@ -125,10 +118,8 @@ enum AuthRequest: BaseRequest { var authorizationType: AuthorizationType { switch self { - #if DEVELOP case .withdraw: return .access - #endif default: return .none } diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/CardRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/CardRequest.swift index 7f7f324b..b75cff73 100644 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/CardRequest.swift +++ b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/CardRequest.swift @@ -32,8 +32,6 @@ enum CardRequest: BaseRequest { case deleteCard(id: String) /// 상세보기 - 좋아요 업데이트 case updateLike(id: String, isLike: Bool) - /// 상세보기 - 차단 - case updateBlocked(id: String, isBlocked: Bool) /// 상세보기 - 신고 case reportCard(id: String, reportType: String) @@ -106,12 +104,9 @@ enum CardRequest: BaseRequest { case let .updateLike(id, _): return "/api/cards/\(id)/like" - case let .updateBlocked(id, _): - - return "/api/blocks/\(id)" case let .reportCard(id, _): - return "/api/reports/cards/\(id)" + return "/api/reports/cards/\(id)" case .defaultImages: return "/api/images/defaults" @@ -138,8 +133,6 @@ enum CardRequest: BaseRequest { return .post case let .updateLike(_, isLike): return isLike ? .post : .delete - case let .updateBlocked(_, isBlocked): - return isBlocked ? .post : .delete default: return .get } diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/NotificationRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/NotificationRequest.swift index 5d5f96d3..aff9a6cc 100644 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/NotificationRequest.swift +++ b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/NotificationRequest.swift @@ -10,6 +10,11 @@ import Alamofire enum NotificationRequest: BaseRequest { + enum RequestType: String { + case notification = "NOTIFICATION" + case settings = "SETTINGS" + } + /// 읽지 않은 알림 전체 조회 case unreadNotifications(lastId: String?) /// 읽은 알림 전체 조회 @@ -17,7 +22,7 @@ enum NotificationRequest: BaseRequest { /// 알림 읽음 요청 case requestRead(notificationId: String) /// 공지 조회 - case notices(lastId: String?, size: Int?) + case notices(lastId: String?, size: Int?, requestType: RequestType) var path: String { switch self { @@ -38,7 +43,7 @@ enum NotificationRequest: BaseRequest { case let .requestRead(notificationId): return "/api/notifications/\(notificationId)/read" - case let .notices(lastId, _): + case let .notices(lastId, _, _): if let lastId = lastId { return "/api/notices/\(lastId)" } else { @@ -58,11 +63,14 @@ enum NotificationRequest: BaseRequest { var parameters: Parameters { switch self { - case let .notices(_, size): + case let .notices(_, size, requestType): if let size = size { - return ["pageSize": size] + return [ + "pageSize": size, + "source": requestType.rawValue + ] } else { - return [:] + return ["source": requestType.rawValue] } default: return [:] diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/SettingsRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/SettingsRequest.swift new file mode 100644 index 00000000..f9445f55 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/SettingsRequest.swift @@ -0,0 +1,98 @@ +// +// SettingsRequest.swift +// SOOUM +// +// Created by 오현식 on 11/9/25. +// + +import Alamofire + +enum SettingsRequest: BaseRequest { + + case rejoinableDate + case transferIssue + case transferEnter(code: String, encryptedDeviceId: String) + case transferUpdate + case blockUsers(lastId: String?) + + var path: String { + switch self { + case .rejoinableDate: + return "/api/members/rejoinable-date" + case .transferIssue: + return "/api/members/account/transfer-code" + case .transferEnter: + return "/api/members/account/transfer" + case .transferUpdate: + return "/api/members/account/transfer-code" + case let .blockUsers(lastId): + if let lastId = lastId { + return "/api/blocks/\(lastId)" + } else { + return "/api/blocks" + } + } + } + + var method: HTTPMethod { + switch self { + case .rejoinableDate, .transferIssue, .blockUsers: + return .get + case .transferEnter: + return .post + case .transferUpdate: + return .patch + } + } + + var parameters: Parameters { + switch self { + case let .transferEnter(code, encryptedDeviceId): + return [ + "transferCode": code, + "encryptedDeviceId": encryptedDeviceId, + "deviceType": "IOS", + "deviceModel": Info.deviceModel, + "deviceOsVersion": Info.iOSVersion + ] + default: + return [:] + } + } + + var encoding: ParameterEncoding { + switch self { + case .transferEnter: + return JSONEncoding.default + default: + return URLEncoding.default + } + } + + var authorizationType: AuthorizationType { + return .access + } + + func asURLRequest() throws -> URLRequest { + + if let url = URL(string: Constants.endpoint)?.appendingPathComponent(self.path) { + var request = URLRequest(url: url) + request.method = self.method + + request.setValue(self.authorizationType.rawValue, forHTTPHeaderField: "AuthorizationType") + request.setValue( + Constants.ContentType.json.rawValue, + forHTTPHeaderField: Constants.HTTPHeader.contentType.rawValue + ) + request.setValue( + Constants.ContentType.json.rawValue, + forHTTPHeaderField: Constants.HTTPHeader.acceptType.rawValue + ) + + let encoded = try self.encoding.encode(request, with: self.parameters) + return encoded + } else { + return .init(url: URL(string: "")!) + } + } +} diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/UserRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/UserRequest.swift index 32e197e7..8cb8b064 100644 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/UserRequest.swift +++ b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/UserRequest.swift @@ -25,25 +25,98 @@ enum UserRequest: BaseRequest { case updateFCMToken(fcmToken: String) /// 카드추가 가능 여부 확인 case postingPermission + /// 프로필 조회 + case profile(userId: String?) + /// 나의 프로필 업데이트 + case updateMyProfile(nickname: String?, imageName: String?) + /// 나의 피드 카드 조회 + case feedCards(userId: String, lastId: String?) + /// 나의 답카드 조회 + case myCommentCards(lastId: String?) + /// 팔로워 조회 + case followers(userId: String, lastId: String?) + /// 팔로우 조회 + case followings(userId: String, lastId: String?) + /// 팔로우 요청 및 취소 + case updateFollowing(userId: String, isFollow: Bool) + /// 상대방 차단 + case updateBlocked(id: String, isBlocked: Bool) + /// 푸시 알림 여부 요청 + case updateNotify(isAllowNotify: Bool) var path: String { switch self { case .checkAvailable: + return "/api/members/check-available" case .nickname: + return "/api/members/generate-nickname" case .validateNickname: + return "/api/members/validate-nickname" case .updateNickname: + return "/api/members/nickname" case .presignedURL: + return "/api/images/profile" case .updateImage: + return "/api/members/profile-img" case .updateFCMToken: + return "/api/members/fcm" case .postingPermission: + return "/api/members/permissions/posting" + case let .profile(userId): + + if let userId = userId { + return "/api/members/profile/info/\(userId)" + } else { + return "/api/members/profile/info/me" + } + case .updateMyProfile: + + return "/api/members/profile/info/me" + case let .feedCards(userId, lastId): + + if let lastId = lastId { + return "/api/members/\(userId)/cards/feed/\(lastId)" + } else { + return "/api/members/\(userId)/cards/feed" + } + case .myCommentCards: + + return "/api/members/me/cards/comment" + case let .followers(userId, lastId): + + if let lastId = lastId { + return "/api/members/\(userId)/followers/\(lastId)" + } else { + return "/api/members/\(userId)/followers" + } + case let .followings(userId, lastId): + + if let lastId = lastId { + return "/api/members/\(userId)/following/\(lastId)" + } else { + return "/api/members/\(userId)/following" + } + case let .updateFollowing(userId, isFollow): + + if isFollow { + return "/api/members/follow" + } else { + return "/api/members/\(userId)/unfollow" + } + case let .updateBlocked(id, _): + + return "/api/blocks/\(id)" + case .updateNotify: + + return "/api/members/notify" } } @@ -51,9 +124,13 @@ enum UserRequest: BaseRequest { switch self { case .checkAvailable, .validateNickname: return .post - case .updateNickname, .updateImage, .updateFCMToken: + case .updateNickname, .updateImage, .updateFCMToken, .updateMyProfile, .updateNotify: return .patch - case .nickname, .presignedURL, .postingPermission: + case let .updateFollowing(_, isFollow): + return isFollow ? .post : .delete + case let .updateBlocked(_, isBlocked): + return isBlocked ? .post : .delete + default: return .get } } @@ -70,6 +147,19 @@ enum UserRequest: BaseRequest { return ["name": imageName] case let .updateFCMToken(fcmToken): return ["fcmToken": fcmToken] + case let .updateMyProfile(nickname, imageName): + var dictionary: [String: Any] = [:] + if let nickname = nickname { + dictionary["nickname"] = nickname + } + if let imageName = imageName { + dictionary["profileImgName"] = imageName + } + return dictionary + case let .updateFollowing(userId, isFollow): + return isFollow ? ["userId": userId] : [:] + case let .updateNotify(isAllowNotify): + return ["isAllowNotify": isAllowNotify] default: return [:] } @@ -77,20 +167,23 @@ enum UserRequest: BaseRequest { var encoding: ParameterEncoding { switch self { - case .nickname, .presignedURL, .postingPermission: - return URLEncoding.default - default: + case .checkAvailable, + .updateNickname, + .validateNickname, + .updateImage, + .updateFCMToken, + .updateMyProfile, + .updateNotify: return JSONEncoding.default + case .updateFollowing: + return URLEncoding.queryString + default: + return URLEncoding.default } } var authorizationType: AuthorizationType { - switch self{ - case .updateFCMToken, .postingPermission: - return .access - default: - return .none - } + return .access } func asURLRequest() throws -> URLRequest { diff --git a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/VersionRequest.swift b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/VersionRequest.swift index 5e8eae36..ac6ae388 100644 --- a/SOOUM/SOOUM/Resources/Alamofire/Request/V2/VersionRequest.swift +++ b/SOOUM/SOOUM/Resources/Alamofire/Request/V2/VersionRequest.swift @@ -12,12 +12,7 @@ enum VersionRequest: BaseRequest { case version var path: String { - #if PRODUCTION - /// 구버전 업데이트를 위한 API - return "app/version/ios/v2" - #elseif DEVELOP return "/api/version/IOS" - #endif } var method: HTTPMethod { diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_hide_filled.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_hide_filled.imageset/Contents.json new file mode 100644 index 00000000..e099b949 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_hide_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_hide_filled.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/Filled/v2_hide_filled.imageset/v2_hide_filled.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_hide_filled.imageset/v2_hide_filled.svg new file mode 100644 index 00000000..db8d044c --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Icons/Filled/v2_hide_filled.imageset/v2_hide_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_medium.imageset/Contents.json b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_medium.imageset/Contents.json new file mode 100644 index 00000000..51caedca --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_medium.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "v2_profile_medium.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_medium.imageset/v2_profile_medium.svg b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_medium.imageset/v2_profile_medium.svg new file mode 100644 index 00000000..d721e427 --- /dev/null +++ b/SOOUM/SOOUM/Resources/Assets.xcassets/DesignSystem/V2/Images/v2_profile_medium.imageset/v2_profile_medium.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/SOOUM/SOOUM/Utilities/SimpleReachability.swift b/SOOUM/SOOUM/Utilities/SimpleReachability.swift new file mode 100644 index 00000000..6463c137 --- /dev/null +++ b/SOOUM/SOOUM/Utilities/SimpleReachability.swift @@ -0,0 +1,45 @@ +// +// SimpleReachability.swift +// SOOUM +// +// Created by 오현식 on 11/13/25. +// + +import Network + +import RxCocoa +import RxSwift + +final class SimpleReachability { + + enum Text { + static let networkMoniterQueueLabel: String = "com.sooum.network.monitor.queue" + } + + static let shared = SimpleReachability() + + private let monitor = NWPathMonitor() + private let isConnect = BehaviorRelay(value: false) + + lazy var isConnected: Observable = { + return self.isConnect + .delay(.milliseconds(1000), scheduler: MainScheduler.instance) + .distinctUntilChanged() + .asObservable() + .share(replay: 1, scope: .forever) + }() + + private init() { + + self.monitor.pathUpdateHandler = { [weak self] path in + let isAvailable = path.status == .satisfied + Log.info("Network is \(isAvailable ? "available" : "unavailable")") + self?.isConnect.accept(isAvailable) + } + self.monitor.start(queue: DispatchQueue(label: Text.networkMoniterQueueLabel, qos: .background)) + } + + deinit { + self.monitor.cancel() + } +} diff --git a/SOOUM/SOOUM/Utilities/SwiftEntryKit/View+SwiftEntryKit.swift b/SOOUM/SOOUM/Utilities/SwiftEntryKit/View+SwiftEntryKit.swift index 1a5fbbda..fdd3d30c 100644 --- a/SOOUM/SOOUM/Utilities/SwiftEntryKit/View+SwiftEntryKit.swift +++ b/SOOUM/SOOUM/Utilities/SwiftEntryKit/View+SwiftEntryKit.swift @@ -93,6 +93,7 @@ extension SwiftEntryKitViewWrapper where Base == UIView { func showBottomToast( verticalOffset: CGFloat, + displayDuration: CGFloat = 7, useSafeArea: Bool = true, workAtWillAppear: (() -> Void)? = nil, completion: (() -> Void)? = nil @@ -111,7 +112,7 @@ extension SwiftEntryKitViewWrapper where Base == UIView { attributes.entryBackground = .color(color: .init(.som.v2.gray500)) - attributes.displayDuration = 7 + attributes.displayDuration = displayDuration attributes.entranceAnimation = .init(translate: .init(duration: 0.25)) attributes.exitAnimation = .init(translate: .init(duration: 0.25))