diff --git a/Boolti/Boolti.xcodeproj/project.pbxproj b/Boolti/Boolti.xcodeproj/project.pbxproj index 445d0a38..039de7f1 100644 --- a/Boolti/Boolti.xcodeproj/project.pbxproj +++ b/Boolti/Boolti.xcodeproj/project.pbxproj @@ -10,10 +10,24 @@ 220013D92BE39F62007FF9E6 /* TicketingErrorType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220013D82BE39F62007FF9E6 /* TicketingErrorType.swift */; }; 2201AC942C2A8AD600CEBD31 /* SelectCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2201AC932C2A8AD600CEBD31 /* SelectCardView.swift */; }; 2201AC972C2AB35100CEBD31 /* CardImageCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2201AC962C2AB35100CEBD31 /* CardImageCollectionViewCell.swift */; }; + 221393562C88995F00459A20 /* EditProfileDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221393552C88995F00459A20 /* EditProfileDIContainer.swift */; }; + 221393582C88996A00459A20 /* EditProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221393572C88996A00459A20 /* EditProfileViewController.swift */; }; + 2213935A2C88997600459A20 /* EditProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221393592C88997600459A20 /* EditProfileViewModel.swift */; }; + 2213935D2C889FA200459A20 /* EditProfileImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2213935C2C889FA200459A20 /* EditProfileImageView.swift */; }; + 2213935F2C88A6DB00459A20 /* EditNicknameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2213935E2C88A6DB00459A20 /* EditNicknameView.swift */; }; + 221393612C89C17300459A20 /* EditIntroductionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221393602C89C17300459A20 /* EditIntroductionView.swift */; }; + 221393632C89CC7E00459A20 /* EditLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221393622C89CC7E00459A20 /* EditLinkView.swift */; }; + 221393662C89CECB00459A20 /* EditLinkCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221393652C89CECB00459A20 /* EditLinkCollectionViewCell.swift */; }; + 221393682C89D97F00459A20 /* AddLinkHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221393672C89D97F00459A20 /* AddLinkHeaderView.swift */; }; + 2213936A2C89EBA800459A20 /* EditProfileRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221393692C89EBA800459A20 /* EditProfileRequestDTO.swift */; }; 221D09FC2BA1E49B0035B0E6 /* BusinessInfoCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221D09FB2BA1E49B0035B0E6 /* BusinessInfoCollectionViewCell.swift */; }; 221D09FE2BA1E6C30035B0E6 /* BusinessInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221D09FD2BA1E6C30035B0E6 /* BusinessInfoViewController.swift */; }; 2233EAAB2BD7A1E200A315BF /* OrderPaymentResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2233EAAA2BD7A1E200A315BF /* OrderPaymentResponseDTO.swift */; }; 2233EAAF2BE142C900A315BF /* TicketingErrorResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2233EAAE2BE142C900A315BF /* TicketingErrorResponseDTO.swift */; }; + 223698F92C8B36B6002C8081 /* GetUploadURLReponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 223698F82C8B36B6002C8081 /* GetUploadURLReponseDTO.swift */; }; + 223698FB2C8B3BA3002C8081 /* UploadProfileImageRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 223698FA2C8B3BA3002C8081 /* UploadProfileImageRequestDTO.swift */; }; + 223698FF2C8B4886002C8081 /* EditLinkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 223698FE2C8B4886002C8081 /* EditLinkViewModel.swift */; }; + 223699012C8B5268002C8081 /* ProfileEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 223699002C8B5268002C8081 /* ProfileEntity.swift */; }; 224BEEDF2C4A0B7700863970 /* TicketingType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 224BEEDE2C4A0B7700863970 /* TicketingType.swift */; }; 224BEEFF2C4CC9BA00863970 /* GiftingConfirmDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 224BEEFE2C4CC9BA00863970 /* GiftingConfirmDIContainer.swift */; }; 224BEF012C4CCA9A00863970 /* GiftingConfirmViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 224BEF002C4CCA9A00863970 /* GiftingConfirmViewController.swift */; }; @@ -53,6 +67,12 @@ 22DEF7812C28396100EA492A /* GiftingDetailDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DEF7802C28396100EA492A /* GiftingDetailDIContainer.swift */; }; 22DEF7832C28396D00EA492A /* GiftingDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DEF7822C28396D00EA492A /* GiftingDetailViewController.swift */; }; 22DEF7852C28397600EA492A /* GiftingDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DEF7842C28397600EA492A /* GiftingDetailViewModel.swift */; }; + 22DFC54E2C79B1E000C8433D /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DFC54D2C79B1E000C8433D /* ProfileViewController.swift */; }; + 22DFC5502C79B1E800C8433D /* ProfileDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DFC54F2C79B1E800C8433D /* ProfileDIContainer.swift */; }; + 22DFC5522C79B1F000C8433D /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DFC5512C79B1F000C8433D /* ProfileViewModel.swift */; }; + 22DFC5582C79C1B900C8433D /* ProfileMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DFC5572C79C1B900C8433D /* ProfileMainView.swift */; }; + 22DFC55F2C79C7A400C8433D /* ProfileLinkCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DFC55E2C79C7A400C8433D /* ProfileLinkCollectionViewCell.swift */; }; + 22DFC5632C7A00E900C8433D /* ProfileLinkHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DFC5622C7A00E900C8433D /* ProfileLinkHeaderView.swift */; }; 22DFC56D2C7F60E600C8433D /* BannerCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DFC56C2C7F60E600C8433D /* BannerCollectionViewCell.swift */; }; 22FA98232C3D0BE500831F64 /* ConcertTicketInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22FA98222C3D0BE500831F64 /* ConcertTicketInfoView.swift */; }; 840B39552B7667D500E7F8C8 /* ConcertCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840B39542B7667D500E7F8C8 /* ConcertCollectionViewCell.swift */; }; @@ -278,6 +298,9 @@ 8746AD122B680A3E0037A1B1 /* TicketTypeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8746AD112B680A3E0037A1B1 /* TicketTypeView.swift */; }; 8746AD142B680A5C0037A1B1 /* TicketTypeTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8746AD132B680A5C0037A1B1 /* TicketTypeTableViewCell.swift */; }; 8746AD202B6932B30037A1B1 /* ConcertEnterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8746AD1F2B6932B30037A1B1 /* ConcertEnterView.swift */; }; + 8749F5832C88B90C00FC4365 /* EditLinkViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8749F5822C88B90C00FC4365 /* EditLinkViewController.swift */; }; + 8749F5852C8A105A00FC4365 /* ButtonTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8749F5842C8A105A00FC4365 /* ButtonTextField.swift */; }; + 8749F5872C8A16B700FC4365 /* BooltiInputStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8749F5862C8A16B700FC4365 /* BooltiInputStackView.swift */; }; 8753CA692B70DD00002871C7 /* UIButton+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8753CA682B70DD00002871C7 /* UIButton+.swift */; }; 8753CA6C2B70E17B002871C7 /* TicketEntryCodeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8753CA6B2B70E17B002871C7 /* TicketEntryCodeViewController.swift */; }; 8753CA6E2B70E30D002871C7 /* TicketEntryCodeDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8753CA6D2B70E30D002871C7 /* TicketEntryCodeDIContainer.swift */; }; @@ -392,10 +415,24 @@ 220013D82BE39F62007FF9E6 /* TicketingErrorType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TicketingErrorType.swift; sourceTree = ""; }; 2201AC932C2A8AD600CEBD31 /* SelectCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectCardView.swift; sourceTree = ""; }; 2201AC962C2AB35100CEBD31 /* CardImageCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardImageCollectionViewCell.swift; sourceTree = ""; }; + 221393552C88995F00459A20 /* EditProfileDIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileDIContainer.swift; sourceTree = ""; }; + 221393572C88996A00459A20 /* EditProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileViewController.swift; sourceTree = ""; }; + 221393592C88997600459A20 /* EditProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileViewModel.swift; sourceTree = ""; }; + 2213935C2C889FA200459A20 /* EditProfileImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileImageView.swift; sourceTree = ""; }; + 2213935E2C88A6DB00459A20 /* EditNicknameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditNicknameView.swift; sourceTree = ""; }; + 221393602C89C17300459A20 /* EditIntroductionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditIntroductionView.swift; sourceTree = ""; }; + 221393622C89CC7E00459A20 /* EditLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditLinkView.swift; sourceTree = ""; }; + 221393652C89CECB00459A20 /* EditLinkCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditLinkCollectionViewCell.swift; sourceTree = ""; }; + 221393672C89D97F00459A20 /* AddLinkHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddLinkHeaderView.swift; sourceTree = ""; }; + 221393692C89EBA800459A20 /* EditProfileRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileRequestDTO.swift; sourceTree = ""; }; 221D09FB2BA1E49B0035B0E6 /* BusinessInfoCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessInfoCollectionViewCell.swift; sourceTree = ""; }; 221D09FD2BA1E6C30035B0E6 /* BusinessInfoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessInfoViewController.swift; sourceTree = ""; }; 2233EAAA2BD7A1E200A315BF /* OrderPaymentResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderPaymentResponseDTO.swift; sourceTree = ""; }; 2233EAAE2BE142C900A315BF /* TicketingErrorResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TicketingErrorResponseDTO.swift; sourceTree = ""; }; + 223698F82C8B36B6002C8081 /* GetUploadURLReponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetUploadURLReponseDTO.swift; sourceTree = ""; }; + 223698FA2C8B3BA3002C8081 /* UploadProfileImageRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadProfileImageRequestDTO.swift; sourceTree = ""; }; + 223698FE2C8B4886002C8081 /* EditLinkViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditLinkViewModel.swift; sourceTree = ""; }; + 223699002C8B5268002C8081 /* ProfileEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEntity.swift; sourceTree = ""; }; 224BEEDE2C4A0B7700863970 /* TicketingType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TicketingType.swift; sourceTree = ""; }; 224BEEFE2C4CC9BA00863970 /* GiftingConfirmDIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiftingConfirmDIContainer.swift; sourceTree = ""; }; 224BEF002C4CCA9A00863970 /* GiftingConfirmViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiftingConfirmViewController.swift; sourceTree = ""; }; @@ -434,6 +471,12 @@ 22DEF7802C28396100EA492A /* GiftingDetailDIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiftingDetailDIContainer.swift; sourceTree = ""; }; 22DEF7822C28396D00EA492A /* GiftingDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiftingDetailViewController.swift; sourceTree = ""; }; 22DEF7842C28397600EA492A /* GiftingDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiftingDetailViewModel.swift; sourceTree = ""; }; + 22DFC54D2C79B1E000C8433D /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; + 22DFC54F2C79B1E800C8433D /* ProfileDIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDIContainer.swift; sourceTree = ""; }; + 22DFC5512C79B1F000C8433D /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; + 22DFC5572C79C1B900C8433D /* ProfileMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileMainView.swift; sourceTree = ""; }; + 22DFC55E2C79C7A400C8433D /* ProfileLinkCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileLinkCollectionViewCell.swift; sourceTree = ""; }; + 22DFC5622C7A00E900C8433D /* ProfileLinkHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileLinkHeaderView.swift; sourceTree = ""; }; 22DFC56C2C7F60E600C8433D /* BannerCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerCollectionViewCell.swift; sourceTree = ""; }; 22FA98222C3D0BE500831F64 /* ConcertTicketInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcertTicketInfoView.swift; sourceTree = ""; }; 840B39542B7667D500E7F8C8 /* ConcertCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcertCollectionViewCell.swift; sourceTree = ""; }; @@ -635,6 +678,9 @@ 8746AD112B680A3E0037A1B1 /* TicketTypeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TicketTypeView.swift; sourceTree = ""; }; 8746AD132B680A5C0037A1B1 /* TicketTypeTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TicketTypeTableViewCell.swift; sourceTree = ""; }; 8746AD1F2B6932B30037A1B1 /* ConcertEnterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcertEnterView.swift; sourceTree = ""; }; + 8749F5822C88B90C00FC4365 /* EditLinkViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditLinkViewController.swift; sourceTree = ""; }; + 8749F5842C8A105A00FC4365 /* ButtonTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonTextField.swift; sourceTree = ""; }; + 8749F5862C8A16B700FC4365 /* BooltiInputStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BooltiInputStackView.swift; sourceTree = ""; }; 8753CA682B70DD00002871C7 /* UIButton+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+.swift"; sourceTree = ""; }; 8753CA6B2B70E17B002871C7 /* TicketEntryCodeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TicketEntryCodeViewController.swift; sourceTree = ""; }; 8753CA6D2B70E30D002871C7 /* TicketEntryCodeDIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TicketEntryCodeDIContainer.swift; sourceTree = ""; }; @@ -802,6 +848,50 @@ path = Cells; sourceTree = ""; }; + 221393532C88990A00459A20 /* Main */ = { + isa = PBXGroup; + children = ( + 22DFC5642C7A00EC00C8433D /* Views */, + 22DFC55D2C79C79200C8433D /* Cells */, + 22DFC54F2C79B1E800C8433D /* ProfileDIContainer.swift */, + 22DFC54D2C79B1E000C8433D /* ProfileViewController.swift */, + 22DFC5512C79B1F000C8433D /* ProfileViewModel.swift */, + ); + path = Main; + sourceTree = ""; + }; + 221393542C88991200459A20 /* EditProfile */ = { + isa = PBXGroup; + children = ( + 2213935B2C889F9800459A20 /* Views */, + 221393642C89CEB900459A20 /* Cells */, + 221393552C88995F00459A20 /* EditProfileDIContainer.swift */, + 221393572C88996A00459A20 /* EditProfileViewController.swift */, + 221393592C88997600459A20 /* EditProfileViewModel.swift */, + ); + path = EditProfile; + sourceTree = ""; + }; + 2213935B2C889F9800459A20 /* Views */ = { + isa = PBXGroup; + children = ( + 2213935C2C889FA200459A20 /* EditProfileImageView.swift */, + 2213935E2C88A6DB00459A20 /* EditNicknameView.swift */, + 221393602C89C17300459A20 /* EditIntroductionView.swift */, + 221393622C89CC7E00459A20 /* EditLinkView.swift */, + 221393672C89D97F00459A20 /* AddLinkHeaderView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 221393642C89CEB900459A20 /* Cells */ = { + isa = PBXGroup; + children = ( + 221393652C89CECB00459A20 /* EditLinkCollectionViewCell.swift */, + ); + path = Cells; + sourceTree = ""; + }; 224BEEFB2C4CC98C00863970 /* Confirm */ = { isa = PBXGroup; children = ( @@ -977,6 +1067,33 @@ path = GiftingDetail; sourceTree = ""; }; + 22DFC54C2C79B13300C8433D /* Profile */ = { + isa = PBXGroup; + children = ( + 221393532C88990A00459A20 /* Main */, + 221393542C88991200459A20 /* EditProfile */, + 8749F5812C88B8E200FC4365 /* EditLink */, + ); + path = Profile; + sourceTree = ""; + }; + 22DFC55D2C79C79200C8433D /* Cells */ = { + isa = PBXGroup; + children = ( + 22DFC55E2C79C7A400C8433D /* ProfileLinkCollectionViewCell.swift */, + ); + path = Cells; + sourceTree = ""; + }; + 22DFC5642C7A00EC00C8433D /* Views */ = { + isa = PBXGroup; + children = ( + 22DFC5572C79C1B900C8433D /* ProfileMainView.swift */, + 22DFC5622C7A00E900C8433D /* ProfileLinkHeaderView.swift */, + ); + path = Views; + sourceTree = ""; + }; 840B39532B7667B100E7F8C8 /* Cells */ = { isa = PBXGroup; children = ( @@ -1298,6 +1415,8 @@ 84781C7D2B5BEC0700D37921 /* LoginRequestDTO.swift */, 876BF16B2B6A43BB00DB4BCB /* TokenRefreshRequestDTO.swift */, 845D88582B877B0E00179F60 /* ResignRequestDTO.swift */, + 221393692C89EBA800459A20 /* EditProfileRequestDTO.swift */, + 223698FA2C8B3BA3002C8081 /* UploadProfileImageRequestDTO.swift */, ); path = Request; sourceTree = ""; @@ -1309,6 +1428,7 @@ 8757BAB72B600B1B008503B5 /* SignUpResponseDTO.swift */, 876BF16D2B6A45AD00DB4BCB /* TokenRefreshResponseDTO.swift */, 84D1C3822B79B71600527998 /* UserResponseDTO.swift */, + 223698F82C8B36B6002C8081 /* GetUploadURLReponseDTO.swift */, ); path = Response; sourceTree = ""; @@ -1338,7 +1458,6 @@ 22CDB2C42BA2633600D2077D /* BusinessInfo */, 2250FABB2C020B8A00CCF487 /* Contact */, 84781CCA2B5C002D00D37921 /* Concert */, - 84BAB8232B8CA7C800DEA35C /* Ticketing */, 84781CC32B5BF9C600D37921 /* Ticket */, 84781CBB2B5BF94100D37921 /* MyPage */, 84781CAB2B5BF79000D37921 /* TabBar */, @@ -1385,10 +1504,11 @@ 84781CBB2B5BF94100D37921 /* MyPage */ = { isa = PBXGroup; children = ( + 8710D9412B74F9F600309FBF /* Main */, + 22DFC54C2C79B13300C8433D /* Profile */, 22739A392C4F86AC000A357B /* Setting */, 8710D9432B74FA1300309FBF /* TicketReservations */, 871F3D122C4E88C200AB1BB9 /* ReservationDetail */, - 8710D9412B74F9F600309FBF /* Main */, 8730F00A2B7B0FEC00D4F339 /* TicketRefund */, 8710D9442B74FA1B00309FBF /* QRScanner */, 8710D9422B74FA0C00309FBF /* Logout */, @@ -1416,6 +1536,7 @@ 845F25412B89F9CC00F6C328 /* PosterExpand */, 84DF59DF2B726C34000816DA /* ConcertContentExpand */, 843918F82B7A827F00CC62AA /* Report */, + 84BAB8232B8CA7C800DEA35C /* Ticketing */, ); path = Concert; sourceTree = ""; @@ -1454,6 +1575,7 @@ isa = PBXGroup; children = ( 848F9F4C2B8ED9400026F527 /* SignupConditionEntity.swift */, + 223699002C8B5268002C8081 /* ProfileEntity.swift */, ); path = Auth; sourceTree = ""; @@ -1534,8 +1656,7 @@ 22DD8C932BD405800083622C /* TossPayments */, 87F63D632C3EE9E2001D7A56 /* Completion */, ); - name = Ticketing; - path = Concert/Ticketing; + path = Ticketing; sourceTree = ""; }; 84BAB8242B8CA8BD00DEA35C /* TicketingConfirm */ = { @@ -1976,6 +2097,15 @@ path = Views; sourceTree = ""; }; + 8749F5812C88B8E200FC4365 /* EditLink */ = { + isa = PBXGroup; + children = ( + 8749F5822C88B90C00FC4365 /* EditLinkViewController.swift */, + 223698FE2C8B4886002C8081 /* EditLinkViewModel.swift */, + ); + path = EditLink; + sourceTree = ""; + }; 8753CA6A2B70E15A002871C7 /* TicketEntryCode */ = { isa = PBXGroup; children = ( @@ -2065,6 +2195,8 @@ 847AA70D2B62B55500E5B55C /* BooltiNavigationBar.swift */, 84625A172B63E05700CC9077 /* BooltiPaddingLabel.swift */, 226D42BE2B9EBA3E00680198 /* BooltiBusinessInfoView.swift */, + 8749F5842C8A105A00FC4365 /* ButtonTextField.swift */, + 8749F5862C8A16B700FC4365 /* BooltiInputStackView.swift */, ); path = Common; sourceTree = ""; @@ -2369,6 +2501,7 @@ 84D1C38B2B7A448100527998 /* InvitationTicketingRequestDTO.swift in Sources */, 87101EF72B5DF8FE004BD418 /* RootDestination.swift in Sources */, 84781CC92B5BFA1C00D37921 /* TicketListViewModel.swift in Sources */, + 22DFC54E2C79B1E000C8433D /* ProfileViewController.swift in Sources */, 87F63D6F2C3F8298001D7A56 /* GiftReservationAPI.swift in Sources */, 84781CCC2B5C003700D37921 /* ConcertListDIContainer.swift in Sources */, 8753CA692B70DD00002871C7 /* UIButton+.swift in Sources */, @@ -2383,12 +2516,15 @@ 8769BF962B625DB200DA9A67 /* TermsAgreementViewController.swift in Sources */, 22739A3B2C4F86BA000A357B /* SettingViewController.swift in Sources */, 84A024902B7B99830095A56E /* QRScannerViewModel.swift in Sources */, + 2213935F2C88A6DB00459A20 /* EditNicknameView.swift in Sources */, 22739A482C511058000A357B /* GiftInfoRequestDTO.swift in Sources */, 877CAFCB2B7BC30A004799C8 /* TicketRefundConfirmDIContainer.swift in Sources */, 84E5124D2B6E7736002658D1 /* ConcertDetailViewController.swift in Sources */, + 221393632C89CC7E00459A20 /* EditLinkView.swift in Sources */, 84FBBDF32B673877009462E9 /* ConcertInfoView.swift in Sources */, 84E5124B2B6E71FD002658D1 /* ConcertDetailDIContainer.swift in Sources */, 870099AE2B77434A001779FB /* TicketDetailRequestDTO.swift in Sources */, + 223698FB2C8B3BA3002C8081 /* UploadProfileImageRequestDTO.swift in Sources */, 22DD8C8C2BD2897E0083622C /* TicketingAPI.swift in Sources */, 22739A462C510585000A357B /* RegisterGiftRequestDTO.swift in Sources */, 84781CB12B5BF85400D37921 /* HomeTabBarController.swift in Sources */, @@ -2415,10 +2551,13 @@ 84781C9F2B5BF6A000D37921 /* SplashViewModelDelegate.swift in Sources */, 848CBF032B65458900239303 /* Int+.swift in Sources */, 84886E902B70A7D4005D2329 /* ContentInfoView.swift in Sources */, + 2213936A2C89EBA800459A20 /* EditProfileRequestDTO.swift in Sources */, 84975BE92B60E04500F903E7 /* AuthAPI.swift in Sources */, 2233EAAF2BE142C900A315BF /* TicketingErrorResponseDTO.swift in Sources */, 8730F0142B7B10A300D4F339 /* TicketRefundReasonDIContainer.swift in Sources */, 871F3D1A2C4E896300AB1BB9 /* GiftReservationDetailDIContainer.swift in Sources */, + 221393562C88995F00459A20 /* EditProfileDIContainer.swift in Sources */, + 221393662C89CECB00459A20 /* EditLinkCollectionViewCell.swift in Sources */, 840B39552B7667D500E7F8C8 /* ConcertCollectionViewCell.swift in Sources */, 84781CA32B5BF6BF00D37921 /* RootViewModel.swift in Sources */, 84FBBDFF2B67677A009462E9 /* UILabel+.swift in Sources */, @@ -2467,6 +2606,7 @@ 8726054D2B79CDF3005CD0D4 /* ConcertInformationView.swift in Sources */, 8757BA9E2B5FD7D3008503B5 /* LoginEnterView.swift in Sources */, 224FE70F2C3E520A00C09D28 /* GiftCardImageEntity.swift in Sources */, + 22DFC5632C7A00E900C8433D /* ProfileLinkHeaderView.swift in Sources */, 22739A422C4F8D92000A357B /* SettingContentView.swift in Sources */, 840D84042B6F5C610078D352 /* TicketingPeriodView.swift in Sources */, 84781CA82B5BF71900D37921 /* SplashViewController.swift in Sources */, @@ -2477,6 +2617,7 @@ 224BEF082C4CCE5D00863970 /* GiftingRepository.swift in Sources */, 8753CA772B71F004002871C7 /* TicketListItemResponseDTO.swift in Sources */, 224BEF012C4CCA9A00863970 /* GiftingConfirmViewController.swift in Sources */, + 22DFC5502C79B1E800C8433D /* ProfileDIContainer.swift in Sources */, 849FBD512B5E920A006EB865 /* AppInfo.swift in Sources */, 221D09FC2BA1E49B0035B0E6 /* BusinessInfoCollectionViewCell.swift in Sources */, 870C33362BBC29B600ED212C /* UICollectionReusableView+.swift in Sources */, @@ -2511,7 +2652,9 @@ 87F63D752C3F85DB001D7A56 /* GiftReservationDetailEntity.swift in Sources */, 87D2FAB62B6E97870027FBE1 /* TicketDetailViewModel.swift in Sources */, 84781C6D2B5BEA3800D37921 /* AuthInterceptor.swift in Sources */, + 8749F5832C88B90C00FC4365 /* EditLinkViewController.swift in Sources */, 84D1C37F2B79379200527998 /* FreeTicketingRequestDTO.swift in Sources */, + 223698FF2C8B4886002C8081 /* EditLinkViewModel.swift in Sources */, 84BAB8262B8CA8DB00DEA35C /* TicketingConfirmDIContainer.swift in Sources */, 22DD8C882BD289100083622C /* SavePaymentInfoRequestDTO.swift in Sources */, 871F3D202C4FFFFA00AB1BB9 /* GiftRefundRequestDTO.swift in Sources */, @@ -2561,6 +2704,7 @@ 84A0248E2B7B997A0095A56E /* QRScannerViewController.swift in Sources */, 846F5F3E2B7BBF60000F86AE /* QRRepository.swift in Sources */, 8757BAA62B5FDED7008503B5 /* OAuthRepository.swift in Sources */, + 221393582C88996A00459A20 /* EditProfileViewController.swift in Sources */, 848CBF0A2B65481500239303 /* TicketingDetailDIContainer.swift in Sources */, 87CE4F712B8DF7CE007A0C8F /* PushNotificationRepository.swift in Sources */, 8753CA732B71EA68002871C7 /* TicketAPI.swift in Sources */, @@ -2575,10 +2719,12 @@ 22DD8C952BD408200083622C /* TossPaymentsDIContainer.swift in Sources */, 8753CA702B70E322002871C7 /* TicketEntryCodeViewModel.swift in Sources */, 846F5F372B7BBDBD000F86AE /* QRScannerListResponseDTO.swift in Sources */, + 8749F5872C8A16B700FC4365 /* BooltiInputStackView.swift in Sources */, 84DF59E52B726E3F000816DA /* ConcertContentExpandViewModel.swift in Sources */, 84DF59E12B726E0A000816DA /* ConcertContentExpandDIContainer.swift in Sources */, 84781C7E2B5BEC0700D37921 /* LoginRequestDTO.swift in Sources */, 87EFEEDB2C7E1C4300A8993C /* Preview.swift in Sources */, + 223699012C8B5268002C8081 /* ProfileEntity.swift in Sources */, 8710D9462B74FA5700309FBF /* QRScannerListViewController.swift in Sources */, 876E41572B5F972A006BEEB7 /* LoginResponseDTO.swift in Sources */, 870099BE2B7767E7001779FB /* EmptyReservationsView.swift in Sources */, @@ -2598,6 +2744,8 @@ 84A713452B7C83AE000BABCB /* QRMaker.swift in Sources */, 8710D94E2B74FA8B00309FBF /* TicketReservationsViewModel.swift in Sources */, 84781CA62B5BF6F500D37921 /* SplashDIContainer.swift in Sources */, + 8749F5852C8A105A00FC4365 /* ButtonTextField.swift in Sources */, + 2213935D2C889FA200459A20 /* EditProfileImageView.swift in Sources */, 22DD8C802BD211790083622C /* AgreeView.swift in Sources */, 87CE4F6A2B8DD9A8007A0C8F /* DeviceTokenRegisterRequestDTO.swift in Sources */, 22DD8C922BD348CD0083622C /* TossPaymentViewController.swift in Sources */, @@ -2613,8 +2761,10 @@ 871F3D162C4E892200AB1BB9 /* GiftReservationDetailViewModel.swift in Sources */, 84781CD02B5C006500D37921 /* ConcertViewModel.swift in Sources */, 8730F0162B7B29EC00D4F339 /* TicketRefundRequestViewController.swift in Sources */, + 221393682C89D97F00459A20 /* AddLinkHeaderView.swift in Sources */, 84625A0B2B63B2AE00CC9077 /* TicketSelectionViewModel.swift in Sources */, 870099B52B77476E001779FB /* TicketReservationAPI.swift in Sources */, + 22DFC5582C79C1B900C8433D /* ProfileMainView.swift in Sources */, 841D77D22B73B0C10068B1C5 /* ConcertAPI.swift in Sources */, 84D1C3892B7A17C500527998 /* InvitationCodeStateEntity.swift in Sources */, 876667542B73A82300DDB2B5 /* EntryCodeInputView.swift in Sources */, @@ -2628,7 +2778,10 @@ 872605492B793A3F005CD0D4 /* ReservationCollapsableStackView.swift in Sources */, 8710D9482B74FA6600309FBF /* QRScannerListViewModel.swift in Sources */, 846F5F3A2B7BBEC4000F86AE /* QRAPI.swift in Sources */, + 22DFC5522C79B1F000C8433D /* ProfileViewModel.swift in Sources */, + 221393612C89C17300459A20 /* EditIntroductionView.swift in Sources */, 84EC6A642B6CF973009AC6BB /* BooltiToastView.swift in Sources */, + 2213935A2C88997600459A20 /* EditProfileViewModel.swift in Sources */, 87A3716D2B76497B0061814E /* TicketReservationsTableViewCell.swift in Sources */, 84625A162B63C33D00CC9077 /* UITableViewCell+.swift in Sources */, 840B39572B76688C00E7F8C8 /* ConcertCollectionViewFlowLayout.swift in Sources */, @@ -2637,6 +2790,7 @@ 8453D7FE2B7539770048F103 /* SalesTicketRequestDTO.swift in Sources */, 8710D9502B74FA9A00309FBF /* TicketReservationsDIContainer.swift in Sources */, 87C7594C2BA93EA40009A83E /* NotificationMessage.swift in Sources */, + 22DFC55F2C79C7A400C8433D /* ProfileLinkCollectionViewCell.swift in Sources */, 84EF916C2B8C4B5F0073C89A /* AppleOAuthUserInfo.swift in Sources */, 224BEF032C4CCAA300863970 /* GiftingConfirmViewModel.swift in Sources */, 22CF3A312BB586B50094711C /* SelectedInvitationTicketView.swift in Sources */, @@ -2644,6 +2798,7 @@ 84A7134A2B7C8999000BABCB /* QRExpandViewController.swift in Sources */, 877CAFC92B7BC2FB004799C8 /* TicketRefundConfirmViewModel.swift in Sources */, 84625A092B63A01100CC9077 /* TicketSelectionViewController.swift in Sources */, + 223698F92C8B36B6002C8081 /* GetUploadURLReponseDTO.swift in Sources */, 8757BAAA2B5FDF29008503B5 /* KakaoOAuth.swift in Sources */, 84E5124F2B6E773F002658D1 /* ConcertDetailViewModel.swift in Sources */, 877CAFC72B7BC2ED004799C8 /* TicketRefundConfirmViewController.swift in Sources */, @@ -2819,7 +2974,7 @@ CODE_SIGN_ENTITLEMENTS = Boolti/Boolti.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 83B9Y749K7; GENERATE_INFOPLIST_FILE = YES; @@ -2836,7 +2991,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.6.1; + MARKETING_VERSION = 1.6.3; OTHER_LDFLAGS = "-ObjC"; PRODUCT_BUNDLE_IDENTIFIER = com.nexters.boolti; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2861,7 +3016,7 @@ CODE_SIGN_ENTITLEMENTS = Boolti/Boolti.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 83B9Y749K7; GENERATE_INFOPLIST_FILE = YES; @@ -2878,7 +3033,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.6.1; + MARKETING_VERSION = 1.6.3; OTHER_LDFLAGS = "-ObjC"; PRODUCT_BUNDLE_IDENTIFIER = com.nexters.boolti; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Boolti/Boolti/Application/AppDelegate.swift b/Boolti/Boolti/Application/AppDelegate.swift index a1a3c06d..ae573179 100644 --- a/Boolti/Boolti/Application/AppDelegate.swift +++ b/Boolti/Boolti/Application/AppDelegate.swift @@ -14,13 +14,13 @@ import FirebaseMessaging @main class AppDelegate: UIResponder, UIApplicationDelegate { - + private let pushNotificationRepository = PushNotificationRepository(networkService: NetworkProvider()) - + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { RxKakaoSDK.initSDK(appKey: Environment.KAKAO_NATIVE_APP_KEY) FirebaseApp.configure() - + /// 앱 실행 시 알림 허용 권한 받기 및 필요한 권한 설정 UNUserNotificationCenter.current().delegate = self let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] @@ -29,59 +29,59 @@ class AppDelegate: UIResponder, UIApplicationDelegate { completionHandler: { _, _ in } ) application.registerForRemoteNotifications() - + /// 메시지 대리자 설정 Messaging.messaging().delegate = self - + /// 자동 초기화 방지 Messaging.messaging().isAutoInitEnabled = true - + /// 탭 Bar index 초기화하기/destination 초기화하기 UserDefaults.tabBarIndex = 0 UserDefaults.landingDestination = nil - + return true } - + /// fcm에 device token 등록 func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { Messaging.messaging().apnsToken = deviceToken } - + // MARK: UISceneSession Lifecycle - + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } - + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { } } // MARK: - MessagingDelegate extension AppDelegate: MessagingDelegate { - + /// 토큰 갱신 모니터링 & 토큰 가져오기 func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { guard let fcmToken else { return } debugPrint(fcmToken) self.registerSubject() - + // 토큰이 갱신될 경우 self.pushNotificationRepository.registerDeviceToken() } - + private func registerSubject() { /// 주제 구독 let defaultTopic: String - - #if DEBUG + +#if DEBUG defaultTopic = "dev" - #elseif RELEASE +#elseif RELEASE defaultTopic = "prod" - #endif - +#endif + Messaging.messaging().subscribe(toTopic: defaultTopic) { error in if let error { debugPrint(error) @@ -95,30 +95,30 @@ extension AppDelegate: MessagingDelegate { // MARK: - UNUserNotificationCenterDelegate extension AppDelegate: UNUserNotificationCenterDelegate { - + /// 푸시 클릭시 func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - + let userInfo = response.notification.request.content.userInfo - + if let notificationMessage = titleData(from: userInfo) { self.configureDestination(with: notificationMessage) } completionHandler() } - + /// Foreground에 푸시알림이 올 때 실행되는 메서드 func userNotificationCenter(_ center: UNUserNotificationCenter,willPresent notification: UNNotification,withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler([.badge, .sound, .list, .banner]) } - + private func titleData(from userInfo: [AnyHashable : Any]) -> NotificationMessage? { guard let messageType = userInfo["type"] as? String else { return nil } return NotificationMessage(messageType) } - + private func configureDestination(with notificationMessage: NotificationMessage) { UserDefaults.tabBarIndex = notificationMessage.tabBarIndex NotificationCenter.default.post( diff --git a/Boolti/Boolti/Resources/Assets.xcassets/add_circle.imageset/Contents.json b/Boolti/Boolti/Resources/Assets.xcassets/add_circle.imageset/Contents.json new file mode 100644 index 00000000..5d4908d7 --- /dev/null +++ b/Boolti/Boolti/Resources/Assets.xcassets/add_circle.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "add_circle.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Boolti/Boolti/Resources/Assets.xcassets/add_circle.imageset/add_circle.pdf b/Boolti/Boolti/Resources/Assets.xcassets/add_circle.imageset/add_circle.pdf new file mode 100644 index 00000000..f60ba715 Binary files /dev/null and b/Boolti/Boolti/Resources/Assets.xcassets/add_circle.imageset/add_circle.pdf differ diff --git a/Boolti/Boolti/Resources/Assets.xcassets/camera.imageset/Contents.json b/Boolti/Boolti/Resources/Assets.xcassets/camera.imageset/Contents.json new file mode 100644 index 00000000..abe22917 --- /dev/null +++ b/Boolti/Boolti/Resources/Assets.xcassets/camera.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "camera.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Boolti/Boolti/Resources/Assets.xcassets/camera.imageset/camera.pdf b/Boolti/Boolti/Resources/Assets.xcassets/camera.imageset/camera.pdf new file mode 100644 index 00000000..254fac8f Binary files /dev/null and b/Boolti/Boolti/Resources/Assets.xcassets/camera.imageset/camera.pdf differ diff --git a/Boolti/Boolti/Resources/Assets.xcassets/delete.imageset/Contents.json b/Boolti/Boolti/Resources/Assets.xcassets/delete.imageset/Contents.json new file mode 100644 index 00000000..add923b2 --- /dev/null +++ b/Boolti/Boolti/Resources/Assets.xcassets/delete.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Boolti/Boolti/Resources/Assets.xcassets/delete.imageset/icon.png b/Boolti/Boolti/Resources/Assets.xcassets/delete.imageset/icon.png new file mode 100644 index 00000000..7d4aea0d Binary files /dev/null and b/Boolti/Boolti/Resources/Assets.xcassets/delete.imageset/icon.png differ diff --git a/Boolti/Boolti/Resources/Assets.xcassets/delete.imageset/icon@2x.png b/Boolti/Boolti/Resources/Assets.xcassets/delete.imageset/icon@2x.png new file mode 100644 index 00000000..03d5f616 Binary files /dev/null and b/Boolti/Boolti/Resources/Assets.xcassets/delete.imageset/icon@2x.png differ diff --git a/Boolti/Boolti/Resources/Assets.xcassets/delete.imageset/icon@3x.png b/Boolti/Boolti/Resources/Assets.xcassets/delete.imageset/icon@3x.png new file mode 100644 index 00000000..e2052ac1 Binary files /dev/null and b/Boolti/Boolti/Resources/Assets.xcassets/delete.imageset/icon@3x.png differ diff --git a/Boolti/Boolti/Resources/Assets.xcassets/link.imageset/Contents.json b/Boolti/Boolti/Resources/Assets.xcassets/link.imageset/Contents.json new file mode 100644 index 00000000..942d2618 --- /dev/null +++ b/Boolti/Boolti/Resources/Assets.xcassets/link.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "link.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Boolti/Boolti/Resources/Assets.xcassets/link.imageset/link.pdf b/Boolti/Boolti/Resources/Assets.xcassets/link.imageset/link.pdf new file mode 100644 index 00000000..8d003c29 Binary files /dev/null and b/Boolti/Boolti/Resources/Assets.xcassets/link.imageset/link.pdf differ diff --git a/Boolti/Boolti/Resources/Assets.xcassets/pencil.imageset/Contents.json b/Boolti/Boolti/Resources/Assets.xcassets/pencil.imageset/Contents.json new file mode 100644 index 00000000..d39fed8d --- /dev/null +++ b/Boolti/Boolti/Resources/Assets.xcassets/pencil.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "pencil.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Boolti/Boolti/Resources/Assets.xcassets/pencil.imageset/pencil.pdf b/Boolti/Boolti/Resources/Assets.xcassets/pencil.imageset/pencil.pdf new file mode 100644 index 00000000..d400eeee Binary files /dev/null and b/Boolti/Boolti/Resources/Assets.xcassets/pencil.imageset/pencil.pdf differ diff --git a/Boolti/Boolti/Sources/Entities/Auth/ProfileEntity.swift b/Boolti/Boolti/Sources/Entities/Auth/ProfileEntity.swift new file mode 100644 index 00000000..4b879473 --- /dev/null +++ b/Boolti/Boolti/Sources/Entities/Auth/ProfileEntity.swift @@ -0,0 +1,13 @@ +// +// ProfileEntity.swift +// Boolti +// +// Created by Juhyeon Byun on 9/7/24. +// + +struct ProfileEntity { + let profileImageURL: String + let nickname: String + let introduction: String + var links: [LinkEntity] +} diff --git a/Boolti/Boolti/Sources/Network/APIs/AuthAPI.swift b/Boolti/Boolti/Sources/Network/APIs/AuthAPI.swift index eeacc0ea..dc082549 100644 --- a/Boolti/Boolti/Sources/Network/APIs/AuthAPI.swift +++ b/Boolti/Boolti/Sources/Network/APIs/AuthAPI.swift @@ -17,10 +17,22 @@ enum AuthAPI { case refresh(requestDTO: TokenRefreshRequestDTO) case resign(requestDTO: ResignRequestDTO) case user + case editProfile(requestDTO: EditProfileRequestDTO) + case getUploadImageURL + case uploadProfileImage(data: UploadProfileImageRequestDTO) } extension AuthAPI: ServiceAPI { + var baseURL: URL { + switch self { + case .uploadProfileImage(let data): + return URL(string: data.uploadUrl)! + default: + return URL(string: Environment.BASE_URL)! + } + } + var path: String { switch self { case .login(let provider, _): @@ -31,8 +43,12 @@ extension AuthAPI: ServiceAPI { return "/papi/v1/signup/sns" case .refresh: return "/papi/v1/login/refresh" - case .resign, .user: + case .resign, .user, .editProfile: return "/api/v1/user" + case .getUploadImageURL: + return "/api/v1/user/profile-images/upload-urls" + case .uploadProfileImage: + return "" } } @@ -42,6 +58,10 @@ extension AuthAPI: ServiceAPI { return .delete case .user: return .get + case .editProfile: + return .patch + case .uploadProfileImage: + return .put default: return .post } @@ -49,10 +69,12 @@ extension AuthAPI: ServiceAPI { var headers: [String : String]? { switch self { - case .logout, .user: + case .logout, .user, .getUploadImageURL: return nil - case .login, .refresh, .signup, .resign: + case .login, .refresh, .signup, .resign, .editProfile: return ["Content-Type": "application/json"] + case .uploadProfileImage: + return ["Content-Type": "image/jpeg"] } } @@ -67,7 +89,7 @@ extension AuthAPI: ServiceAPI { params = ["idToken": DTO.accessToken] } return .requestParameters(parameters: params, encoding: JSONEncoding.prettyPrinted) - case .logout, .user: + case .logout, .user, .getUploadImageURL: return .requestPlain case .signup(let DTO): return .requestJSONEncodable(DTO) @@ -75,6 +97,10 @@ extension AuthAPI: ServiceAPI { return .requestJSONEncodable(DTO) case .resign(let DTO): return .requestJSONEncodable(DTO) + case .editProfile(let DTO): + return .requestJSONEncodable(DTO) + case .uploadProfileImage(let DTO): + return .requestData(DTO.imageData.jpegData(compressionQuality: 0.8) ?? Data()) } } } diff --git a/Boolti/Boolti/Sources/Network/DTO/Auth/Request/EditProfileRequestDTO.swift b/Boolti/Boolti/Sources/Network/DTO/Auth/Request/EditProfileRequestDTO.swift new file mode 100644 index 00000000..c601036b --- /dev/null +++ b/Boolti/Boolti/Sources/Network/DTO/Auth/Request/EditProfileRequestDTO.swift @@ -0,0 +1,13 @@ +// +// EditProfileRequestDTO.swift +// Boolti +// +// Created by Juhyeon Byun on 9/5/24. +// + +struct EditProfileRequestDTO: Encodable { + let nickname: String + let profileImagePath: String + let introduction: String + let link: [LinkEntity] +} diff --git a/Boolti/Boolti/Sources/Network/DTO/Auth/Request/UploadProfileImageRequestDTO.swift b/Boolti/Boolti/Sources/Network/DTO/Auth/Request/UploadProfileImageRequestDTO.swift new file mode 100644 index 00000000..c2db7922 --- /dev/null +++ b/Boolti/Boolti/Sources/Network/DTO/Auth/Request/UploadProfileImageRequestDTO.swift @@ -0,0 +1,20 @@ +// +// UploadProfileImageRequestDTO.swift +// Boolti +// +// Created by Juhyeon Byun on 9/6/24. +// + +import UIKit + +struct UploadProfileImageRequestDTO { + + let uploadUrl: String + let imageData: UIImage + + init(uploadUrl: String, image: UIImage) { + self.uploadUrl = uploadUrl + self.imageData = image + } + +} diff --git a/Boolti/Boolti/Sources/Network/DTO/Auth/Response/GetUploadURLReponseDTO.swift b/Boolti/Boolti/Sources/Network/DTO/Auth/Response/GetUploadURLReponseDTO.swift new file mode 100644 index 00000000..8c1f7b1f --- /dev/null +++ b/Boolti/Boolti/Sources/Network/DTO/Auth/Response/GetUploadURLReponseDTO.swift @@ -0,0 +1,13 @@ +// +// GetUploadURLReponseDTO.swift +// Boolti +// +// Created by Juhyeon Byun on 9/6/24. +// + +struct GetUploadURLReponseDTO: Decodable { + + let uploadUrl: String + let expectedUrl: String + +} diff --git a/Boolti/Boolti/Sources/Network/DTO/Auth/Response/UserResponseDTO.swift b/Boolti/Boolti/Sources/Network/DTO/Auth/Response/UserResponseDTO.swift index 64405502..403637a0 100644 --- a/Boolti/Boolti/Sources/Network/DTO/Auth/Response/UserResponseDTO.swift +++ b/Boolti/Boolti/Sources/Network/DTO/Auth/Response/UserResponseDTO.swift @@ -14,4 +14,12 @@ struct UserResponseDTO: Decodable { let userCode: String? let email: String? let imgPath: String? + let introduction: String? + let link: [LinkEntity]? + +} + +struct LinkEntity: Codable, Equatable { + let title: String + let link: String } diff --git a/Boolti/Boolti/Sources/Network/Foundation/AuthInterceptor.swift b/Boolti/Boolti/Sources/Network/Foundation/AuthInterceptor.swift index 3848f72c..b3063adb 100644 --- a/Boolti/Boolti/Sources/Network/Foundation/AuthInterceptor.swift +++ b/Boolti/Boolti/Sources/Network/Foundation/AuthInterceptor.swift @@ -21,10 +21,14 @@ final class AuthInterceptor: RequestInterceptor { var urlRequest = urlRequest - urlRequest.headers.add(.authorization(bearerToken: UserDefaults.accessToken)) - - debugPrint("🔥 요청한 AccessToken: \(UserDefaults.accessToken) 🔥") - debugPrint("🔥 요청한 userId: \(UserDefaults.userId) 🔥") + if urlRequest.method == .put { + debugPrint("🔥 이미지 업로드 요청 🔥") + } else { + urlRequest.headers.add(.authorization(bearerToken: UserDefaults.accessToken)) + + debugPrint("🔥 요청한 AccessToken: \(UserDefaults.accessToken) 🔥") + debugPrint("🔥 요청한 userId: \(UserDefaults.userId) 🔥") + } completion(.success(urlRequest)) } diff --git a/Boolti/Boolti/Sources/Network/Foundation/NetworkProvider.swift b/Boolti/Boolti/Sources/Network/Foundation/NetworkProvider.swift index 525be614..d1f851b9 100644 --- a/Boolti/Boolti/Sources/Network/Foundation/NetworkProvider.swift +++ b/Boolti/Boolti/Sources/Network/Foundation/NetworkProvider.swift @@ -23,6 +23,7 @@ final class NetworkProvider: NetworkProviderType { } func request(_ api: ServiceAPI) -> Single { + let baseURL = "\(api.baseURL)" let requestString = "\(api.path)" let endpoint = MultiTarget.target(api) @@ -100,7 +101,7 @@ final class NetworkProvider: NetworkProviderType { } }, onSubscribed: { - debugPrint("❓ REQUEST: [\(api.method.rawValue)] \(requestString)") + debugPrint("❓ REQUEST: [\(api.method.rawValue)] \(baseURL)\(requestString)") } ) } diff --git a/Boolti/Boolti/Sources/Network/Repositories/Auth/AuthRepository.swift b/Boolti/Boolti/Sources/Network/Repositories/Auth/AuthRepository.swift index a857cca7..379b503a 100644 --- a/Boolti/Boolti/Sources/Network/Repositories/Auth/AuthRepository.swift +++ b/Boolti/Boolti/Sources/Network/Repositories/Auth/AuthRepository.swift @@ -5,7 +5,7 @@ // Created by Miro on 1/23/24. // -import Foundation +import UIKit import RxSwift import KakaoSDKUser @@ -20,7 +20,11 @@ protocol AuthRepositoryType { func signUp(provider: OAuthProvider, identityToken: String?) -> Single func logout() -> Single func userInfo() -> Single + func userProfile() -> Single func resign(reason: String, appleIdAuthorizationCode: String?) -> Single + func editProfile(profileImageUrl: String, nickname: String, introduction: String, links: [LinkEntity]) -> Single + func getUploadImageURL() -> Single + func uploadProfileImage(uploadURL: String, imageData: UIImage) -> Single } final class AuthRepository: AuthRepositoryType { @@ -169,6 +173,18 @@ final class AuthRepository: AuthRepositoryType { }) } + func userProfile() -> Single { + let api = AuthAPI.user + return self.networkService.request(api) + .map(UserResponseDTO.self) + .flatMap({ user -> Single in + UserDefaults.userName = user.nickname ?? "" + UserDefaults.userImageURLPath = user.imgPath ?? "" + + return .just(user) + }) + } + func resign(reason: String, appleIdAuthorizationCode: String?) -> Single { let api = AuthAPI.resign(requestDTO: ResignRequestDTO(reason: reason, appleIdAuthorizationCode: appleIdAuthorizationCode)) @@ -179,4 +195,33 @@ final class AuthRepository: AuthRepositoryType { }) .map { _ in return () } } + + func editProfile(profileImageUrl: String, nickname: String, introduction: String, links: [LinkEntity]) -> Single { + let api = AuthAPI.editProfile(requestDTO: EditProfileRequestDTO(nickname: nickname, + profileImagePath: profileImageUrl, + introduction: introduction, + link: links)) + return self.networkService.request(api) + .map(UserResponseDTO.self) + .flatMap({ user -> Single in + UserDefaults.userName = user.nickname ?? "" + UserDefaults.userImageURLPath = user.imgPath ?? "" + return .just(()) + }) + } + + func getUploadImageURL() -> Single { + let api = AuthAPI.getUploadImageURL + + return self.networkService.request(api) + .map(GetUploadURLReponseDTO.self) + } + + func uploadProfileImage(uploadURL: String, imageData: UIImage) -> Single { + let api = AuthAPI.uploadProfileImage(data: UploadProfileImageRequestDTO(uploadUrl: uploadURL, image: imageData)) + + return self.networkService.request(api) + .map { _ in return uploadURL } + } + } diff --git a/Boolti/Boolti/Sources/UILayer/Common/BooltiInputStackView.swift b/Boolti/Boolti/Sources/UILayer/Common/BooltiInputStackView.swift new file mode 100644 index 00000000..437d8c36 --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/Common/BooltiInputStackView.swift @@ -0,0 +1,48 @@ +// +// BooltiInputStackView.swift +// Boolti +// +// Created by Miro on 9/6/24. +// + +import UIKit + +final class BooltiInputStackView: UIStackView { + + private let titleLabel: BooltiUILabel = { + let label = BooltiUILabel() + label.font = .body1 + label.textColor = .grey30 + + return label + }() + private let textField: BooltiTextField + + init(title: String, textField: BooltiTextField) { + self.textField = textField + self.titleLabel.text = title + + super.init(frame: .zero) + self.configureUI() + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureUI() { + self.axis = .horizontal + self.spacing = 12 + + self.addArrangedSubviews([self.titleLabel, self.textField]) + + self.configureConstraints() + } + + private func configureConstraints() { + self.titleLabel.snp.makeConstraints { make in + make.width.equalTo(64) + } + } +} + diff --git a/Boolti/Boolti/Sources/UILayer/Common/BooltiNavigationBar.swift b/Boolti/Boolti/Sources/UILayer/Common/BooltiNavigationBar.swift index 2a2ba570..617721c1 100644 --- a/Boolti/Boolti/Sources/UILayer/Common/BooltiNavigationBar.swift +++ b/Boolti/Boolti/Sources/UILayer/Common/BooltiNavigationBar.swift @@ -17,6 +17,8 @@ enum NavigationType { case concertDetail case ticketingCompletion case tossPaymentsWidget + case addLink + case editProfile } final class BooltiNavigationBar: UIView { @@ -48,6 +50,8 @@ final class BooltiNavigationBar: UIView { private lazy var shareButton = self.makeButton(image: .share) private lazy var moreButton = self.makeButton(image: .more) + + lazy var completeButton = self.makeButton(title: "완료") // MARK: Init @@ -64,6 +68,8 @@ final class BooltiNavigationBar: UIView { case .concertDetail: self.configureConcertDetailUI() case .ticketingCompletion: self.configureTicketingCompletionUI() case .tossPaymentsWidget: self.configureTossPaymentsWidgetUI() + case .addLink: self.configureAddLink() + case .editProfile: self.configureEditProfileUI() } } @@ -75,7 +81,12 @@ final class BooltiNavigationBar: UIView { // MARK: - Methods extension BooltiNavigationBar { - + + // 임시로 추가 + func changeTitle(to title: String) { + self.titleLabel.text = title + } + func setBackgroundColor(with color: UIColor) { self.backgroundColor = color } @@ -86,6 +97,15 @@ extension BooltiNavigationBar { button.tintColor = .grey10 return button } + + private func makeButton(title: String) -> UIButton { + let button = UIButton() + button.setTitle(title, for: .normal) + button.titleLabel?.font = .subhead2 + button.titleLabel?.textColor = .grey10 + return button + } + } // MARK: - UI @@ -97,7 +117,7 @@ extension BooltiNavigationBar { make.height.equalTo(self.statusBarHeight + 44) } } - + private func configureBackButtonUI() { self.addSubview(self.backButton) @@ -110,7 +130,7 @@ extension BooltiNavigationBar { private func configureBackButtonWithTitleUI(_ title: String) { self.titleLabel.text = title - + self.addSubviews([self.backButton, self.titleLabel]) self.backButton.snp.makeConstraints { make in @@ -146,7 +166,7 @@ extension BooltiNavigationBar { private func configureConcertDetailUI() { self.backgroundColor = .grey90 - + self.addSubviews([self.backButton, self.homeButton, self.shareButton, self.moreButton]) self.backButton.snp.makeConstraints { make in @@ -202,6 +222,37 @@ extension BooltiNavigationBar { make.bottom.equalToSuperview().inset(10) } } + + private func configureAddLink() { + self.titleLabel.text = "링크 추가" + self.configureUIWithCompleteButton() + } + + private func configureEditProfileUI() { + self.titleLabel.text = "프로필 편집" + self.configureUIWithCompleteButton() + } + + private func configureUIWithCompleteButton() { + self.addSubviews([self.backButton, self.titleLabel, self.completeButton]) + + self.backButton.snp.makeConstraints { make in + make.left.equalToSuperview().inset(20) + make.size.equalTo(24) + make.bottom.equalToSuperview().inset(10) + } + + self.titleLabel.snp.makeConstraints { make in + make.left.equalTo(self.backButton.snp.right).offset(12) + make.bottom.equalToSuperview().inset(10) + } + + self.completeButton.snp.makeConstraints { make in + make.right.equalToSuperview().inset(20) + make.bottom.equalToSuperview().inset(10) + } + } + } // MARK: - Methods @@ -227,4 +278,9 @@ extension BooltiNavigationBar { func didMoreButtonTap() -> Signal { return moreButton.rx.tap.asSignal() } + + func didCompleteButtonTap() -> Signal { + return completeButton.rx.tap.asSignal() + } + } diff --git a/Boolti/Boolti/Sources/UILayer/Common/BooltiPopupView.swift b/Boolti/Boolti/Sources/UILayer/Common/BooltiPopupView.swift index b4199761..b15c544a 100644 --- a/Boolti/Boolti/Sources/UILayer/Common/BooltiPopupView.swift +++ b/Boolti/Boolti/Sources/UILayer/Common/BooltiPopupView.swift @@ -10,6 +10,8 @@ import UIKit import RxSwift import RxCocoa +// TODO: Close Button 있는 거까지 고려 및 모든 팝업 체크해서 재사용성 높게 변경하기 + final class BooltiPopupView: UIView { // MARK: Properties @@ -24,14 +26,16 @@ final class BooltiPopupView: UIView { case registerGift case registerMyGift case registerGiftError + case deleteLink + case saveProfile var title: String { switch self { - case .networkError: + case .networkError: "네트워크 오류가 발생했습니다\n잠시후 다시 시도해주세요" - case .refreshTokenHasExpired: + case .refreshTokenHasExpired: "로그인 세션이 만료되었습니다\n앱을 다시 시작해주세요" - case .accountRemoveCancelled: + case .accountRemoveCancelled: "30일 내에 로그인하여\n계정 삭제가 취소되었어요.\n불티를 다시 찾아주셔서 감사해요!" case .soldoutBeforePayment, .ticketingFailed: "결제에 실패했어요" @@ -43,6 +47,10 @@ final class BooltiPopupView: UIView { "본인이 결제한 선물입니다.\n선물을 등록하면 다른 분께 보낼 수\n없습니다. 등록하시겠습니까?" case .registerGiftError: "선물 등록에 실패했어요" + case .deleteLink: + "링크를 삭제하시겠어요?" + case .saveProfile: + "저장하지 않고 이 페이지를 나가면\n작성한 정보가 손실됩니다.\n변경된 정보를 저장할까요?" } } @@ -67,10 +75,32 @@ final class BooltiPopupView: UIView { "등록하기" case .registerGiftError: "닫기" + case .deleteLink: + "삭제하기" + case .saveProfile: + "저장하기" default: "확인" } } + + var cancelButtonTitle: String { + switch self { + case .saveProfile: + "나가기" + default: + "취소하기" + } + } + + var withCloseButton: Bool { + switch self { + case .deleteLink, .saveProfile: + true + default: + false + } + } } let disposeBag = DisposeBag() @@ -114,6 +144,12 @@ final class BooltiPopupView: UIView { return button }() + private let closeButton: UIButton = { + let button = UIButton() + button.setImage(.closeButton, for: .normal) + return button + }() + private let confirmButton = BooltiButton(title: "확인") private lazy var buttonStackView: UIStackView = { @@ -124,7 +160,7 @@ final class BooltiPopupView: UIView { stackView.addArrangedSubviews([self.cancelButton, self.confirmButton]) return stackView }() - + init() { super.init(frame: .zero) self.configureUI() @@ -149,7 +185,12 @@ extension BooltiPopupView { self.confirmButton.setTitle(type.buttonTitle, for: .normal) self.cancelButton.isHidden = !withCancelButton - + + if type.withCloseButton { + self.cancelButton.setTitle(type.cancelButtonTitle, for: .normal) + self.configureCloseButton() + } + self.popupType = type self.isHidden = false } @@ -161,7 +202,10 @@ extension BooltiPopupView { func didConfirmButtonTap() -> Signal { return self.confirmButton.rx.tap.asSignal() } - + + func didCloseButtonTap() -> Signal { + return self.closeButton.rx.tap.asSignal() + } } // MARK: - UI @@ -173,10 +217,26 @@ extension BooltiPopupView { self.popupBackgroundView, self.titleLabel, self.descriptionLabel, - self.buttonStackView]) + self.buttonStackView, + ]) self.isHidden = true } + private func configureCloseButton() { + self.addSubview(self.closeButton) + + self.closeButton.snp.makeConstraints { make in + make.size.equalTo(24) + make.top.equalTo(self.popupBackgroundView).inset(12) + make.right.equalTo(self.popupBackgroundView.snp.right).inset(20) + } + + self.titleLabel.snp.remakeConstraints { make in + make.top.equalTo(self.closeButton.snp.bottom).offset(12) + make.horizontalEdges.equalTo(self.popupBackgroundView).inset(20) + } + } + private func configureConstraints() { self.dimmedBackgroundView.snp.makeConstraints { make in make.edges.equalToSuperview() diff --git a/Boolti/Boolti/Sources/UILayer/Common/BooltiTextField.swift b/Boolti/Boolti/Sources/UILayer/Common/BooltiTextField.swift index ae6cabf8..3a748a16 100644 --- a/Boolti/Boolti/Sources/UILayer/Common/BooltiTextField.swift +++ b/Boolti/Boolti/Sources/UILayer/Common/BooltiTextField.swift @@ -7,7 +7,7 @@ import UIKit -final class BooltiTextField: UITextField { +class BooltiTextField: UITextField { // MARK: Init @@ -15,7 +15,9 @@ final class BooltiTextField: UITextField { super.init(frame: .zero) self.configureUI(backgroundColor: backgroundColor) self.configureConstraints() - + self.autocorrectionType = .no + self.spellCheckingType = .no + if withRightButton { self.addRightPadding() } } @@ -46,7 +48,7 @@ extension BooltiTextField { self.textColor = .grey15 self.backgroundColor = backgroundColor - self.addLeftPadding() + self.addPadding() } private func configureConstraints() { @@ -55,10 +57,12 @@ extension BooltiTextField { } } - private func addLeftPadding() { + private func addPadding() { let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: self.frame.height)) self.leftView = paddingView self.leftViewMode = ViewMode.always + self.rightView = paddingView + self.rightViewMode = ViewMode.always } private func addRightPadding() { diff --git a/Boolti/Boolti/Sources/UILayer/Common/ButtonTextField.swift b/Boolti/Boolti/Sources/UILayer/Common/ButtonTextField.swift new file mode 100644 index 00000000..fb4fab21 --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/Common/ButtonTextField.swift @@ -0,0 +1,49 @@ +// +// ButtonTextField.swift +// Boolti +// +// Created by Miro on 9/6/24. +// +import UIKit + +import RxCocoa + +final class ButtonTextField: BooltiTextField { + + var isButtonHidden = true { + didSet { + self.button.isHidden = isButtonHidden + } + } + + var didButtonTap: ControlEvent { + return self.button.rx.tap + } + + private let button: UIButton = { + let button = UIButton() + return button + }() + + init(with image: UIImage, placeHolder: String) { + super.init(withRightButton: true) + super.setPlaceHolderText(placeholder: placeHolder) + self.button.setImage(image, for: .normal) + self.button.isHidden = true + + self.configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureUI() { + self.addSubview(self.button) + + self.button.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.right.equalToSuperview().offset(-12) + } + } +} diff --git a/Boolti/Boolti/Sources/UILayer/Concert/ConcertDetail/ConcertDetailViewController.swift b/Boolti/Boolti/Sources/UILayer/Concert/ConcertDetail/ConcertDetailViewController.swift index deea8105..ba66e529 100644 --- a/Boolti/Boolti/Sources/UILayer/Concert/ConcertDetail/ConcertDetailViewController.swift +++ b/Boolti/Boolti/Sources/UILayer/Concert/ConcertDetail/ConcertDetailViewController.swift @@ -347,18 +347,6 @@ extension ConcertDetailViewController { link: link, domainURIPrefix: dynamicLinksDomainURIPrefix ) else { return nil } - - // iOS - linkBuilder.iOSParameters = DynamicLinkIOSParameters(bundleID: AppInfo.bundleID) - - // Android - #if DEBUG - linkBuilder.androidParameters = DynamicLinkAndroidParameters(packageName: AppInfo.androidDebugPackageName) - #elseif RELEASE - linkBuilder.androidParameters = DynamicLinkAndroidParameters(packageName: AppInfo.bundleID) - #endif - linkBuilder.androidParameters?.fallbackURL = link - return linkBuilder.url } } diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Main/MyPageDIContainer.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Main/MyPageDIContainer.swift index 66d367d7..33738f40 100644 --- a/Boolti/Boolti/Sources/UILayer/MyPage/Main/MyPageDIContainer.swift +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Main/MyPageDIContainer.swift @@ -19,8 +19,8 @@ final class MyPageDIContainer { let loginViewControllerFactory: () -> LoginViewController = { let DIContainer = self.createLoginViewDIContainer() - let viewController = DIContainer.createLoginViewController() + return viewController } @@ -44,13 +44,21 @@ final class MyPageDIContainer { return viewController } + + let profileViewControllerFactory = { + let DIContainer = ProfileDIContainer(authRepository: self.authRepository) + let viewController = DIContainer.createProfileViewController() + + return viewController + } let viewController = MyPageViewController( viewModel: self.createMyPageViewModel(), loginViewControllerFactory: loginViewControllerFactory, ticketReservationsViewControllerFactory: ticketReservationsViewControllerFactory, qrScanViewControllerFactory: QRScannerListViewControllerFactory, - settingViewControllerFactory: settingViewControllerFactory + settingViewControllerFactory: settingViewControllerFactory, + profileViewControllerFactory: profileViewControllerFactory ) let navigationController = UINavigationController(rootViewController: viewController) @@ -76,6 +84,6 @@ final class MyPageDIContainer { } private func createMyPageViewModel() -> MyPageViewModel { - return MyPageViewModel(authRepository: self.authRepository, networkService: self.authRepository.networkService) + return MyPageViewModel(authRepository: self.authRepository) } } diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Main/MyPageDestination.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Main/MyPageDestination.swift index 95154dd8..30533b29 100644 --- a/Boolti/Boolti/Sources/UILayer/MyPage/Main/MyPageDestination.swift +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Main/MyPageDestination.swift @@ -12,4 +12,5 @@ enum MyPageDestination { case qrScannerList case ticketReservations case setting + case profile } diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Main/MyPageViewModel.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Main/MyPageViewModel.swift index 8c302cdb..4ea558f3 100644 --- a/Boolti/Boolti/Sources/UILayer/MyPage/Main/MyPageViewModel.swift +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Main/MyPageViewModel.swift @@ -13,7 +13,6 @@ import RxRelay final class MyPageViewModel { private let authRepository: AuthRepositoryType - private let networkService: NetworkProviderType struct Input { var viewDidAppearEvent = PublishSubject() @@ -21,6 +20,7 @@ final class MyPageViewModel { var didSettingViewTapEvent = PublishSubject() var didTicketingReservationsViewTapEvent = PublishSubject() var didQRScannerListViewTapEvent = PublishSubject() + var didProfileButtonTapEvent = PublishSubject() } struct Output { @@ -33,8 +33,7 @@ final class MyPageViewModel { private let disposeBag = DisposeBag() - init(authRepository: AuthRepositoryType, networkService: NetworkProviderType) { - self.networkService = networkService + init(authRepository: AuthRepositoryType) { self.authRepository = authRepository self.input = Input() @@ -86,6 +85,15 @@ final class MyPageViewModel { owner.output.navigation.accept(.qrScannerList) } .disposed(by: self.disposeBag) + + self.input.didProfileButtonTapEvent + .subscribe(with: self) { owner, _ in + guard owner.output.isAccessTokenLoaded.value else { + return owner.output.navigation.accept(.login) + } + owner.output.navigation.accept(.profile) + } + .disposed(by: self.disposeBag) } private func isAccessTokenAvailable() -> Bool { diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Main/MypageViewController.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Main/MypageViewController.swift index 83f9df01..ffd25336 100644 --- a/Boolti/Boolti/Sources/UILayer/MyPage/Main/MypageViewController.swift +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Main/MypageViewController.swift @@ -18,6 +18,7 @@ final class MyPageViewController: BooltiViewController { private let ticketReservationsViewControllerFactory: () -> TicketReservationsViewController private let qrScanViewControllerFactory: () -> QRScannerListViewController private let settingViewControllerFactory: () -> SettingViewController + private let profileViewControllerFactory: () -> ProfileViewController private let disposeBag = DisposeBag() private let viewModel: MyPageViewModel @@ -80,14 +81,16 @@ final class MyPageViewController: BooltiViewController { loginViewControllerFactory: @escaping () -> LoginViewController, ticketReservationsViewControllerFactory: @escaping () -> TicketReservationsViewController, qrScanViewControllerFactory: @escaping () -> QRScannerListViewController, - settingViewControllerFactory: @escaping () -> SettingViewController + settingViewControllerFactory: @escaping () -> SettingViewController, + profileViewControllerFactory: @escaping () -> ProfileViewController ) { self.viewModel = viewModel self.loginViewControllerFactory = loginViewControllerFactory self.ticketReservationsViewControllerFactory = ticketReservationsViewControllerFactory self.qrScanViewControllerFactory = qrScanViewControllerFactory self.settingViewControllerFactory = settingViewControllerFactory - + self.profileViewControllerFactory = profileViewControllerFactory + super.init() } @@ -165,6 +168,14 @@ final class MyPageViewController: BooltiViewController { } .disposed(by: self.disposeBag) + self.profileView.didShowProfileButtonTap() + .filter{ _ in self.viewModel.output.isAccessTokenLoaded.value } + .asDriver(onErrorDriveWith: .never()) + .drive(with: self) { owner, _ in + owner.viewModel.input.didProfileButtonTapEvent.onNext(()) + } + .disposed(by: self.disposeBag) + self.settingNavigationView.rx.tapGesture() .when(.recognized) .asDriver(onErrorDriveWith: .never()) @@ -233,7 +244,7 @@ final class MyPageViewController: BooltiViewController { case .login: viewController.modalPresentationStyle = .fullScreen owner.present(viewController, animated: true) - case .setting, .ticketReservations, .qrScannerList: + case .setting, .ticketReservations, .qrScannerList, .profile: owner.navigationController?.pushViewController(viewController, animated: true) } }) @@ -246,6 +257,7 @@ final class MyPageViewController: BooltiViewController { case .qrScannerList: return qrScanViewControllerFactory() case .ticketReservations: return ticketReservationsViewControllerFactory() case .setting: return settingViewControllerFactory() + case .profile: return profileViewControllerFactory() } } diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Main/Views/MypageProfileView.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Main/Views/MypageProfileView.swift index 65162324..f7c1eae8 100644 --- a/Boolti/Boolti/Sources/UILayer/MyPage/Main/Views/MypageProfileView.swift +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Main/Views/MypageProfileView.swift @@ -24,6 +24,7 @@ final class MypageProfileView: UIView { private let profileImageView: UIImageView = { let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill imageView.backgroundColor = .grey80 imageView.layer.cornerRadius = 18 imageView.clipsToBounds = true @@ -51,19 +52,9 @@ final class MypageProfileView: UIView { return stackView }() - private let loginButton: UIButton = { - var config = UIButton.Configuration.plain() - config.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12) - config.title = "로그인" - config.attributedTitle?.font = .pretendardR(12) - config.background.backgroundColor = .grey80 - config.baseForegroundColor = .grey05 - config.background.cornerRadius = 4 - - let button = UIButton(configuration: config) - return button - }() - + private lazy var loginButton = self.makeRightButton(title: "로그인") + + private lazy var showProfileButton = self.makeRightButton(title: "프로필 보기") // MARK: Init @@ -81,11 +72,27 @@ final class MypageProfileView: UIView { // MARK: - Methods extension MypageProfileView { + + private func makeRightButton(title: String) -> UIButton { + var config = UIButton.Configuration.plain() + config.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12) + config.title = title + config.attributedTitle?.font = .pretendardR(12) + config.background.backgroundColor = .grey80 + config.baseForegroundColor = .grey05 + config.background.cornerRadius = 4 + + let button = UIButton(configuration: config) + return button + } + func updateProfileUI() { self.profileImageView.isHidden = false self.profileNameLabel.text = UserDefaults.userName.isEmpty ? "불티 유저" : UserDefaults.userName - + + // TODO: 🚨 이렇게 userDefaults에 넣으면 앱을 깔았다가 다시 들어올 때 카톡 이미지가 보이게된다. + /// 그래서 여기서 유저 API를 한번 더 찌르게 구현하기! let profileImageURLPath = UserDefaults.userImageURLPath if profileImageURLPath.isEmpty { @@ -97,17 +104,23 @@ extension MypageProfileView { } self.loginButton.isHidden = true + self.showProfileButton.isHidden = false } func resetProfileUI() { self.profileImageView.isHidden = true self.profileNameLabel.text = "로그인하고 이용해 보세요" self.loginButton.isHidden = false + self.showProfileButton.isHidden = true } func didLoginButtonTap() -> Signal { return self.loginButton.rx.tap.asSignal() } + + func didShowProfileButtonTap() -> Signal { + return self.showProfileButton.rx.tap.asSignal() + } } // MARK: - UI @@ -116,7 +129,8 @@ extension MypageProfileView { private func configureUI() { self.addSubviews([self.profileStackView, - self.loginButton]) + self.loginButton, + self.showProfileButton]) self.backgroundColor = .grey90 self.layer.maskedCorners = CACornerMask( @@ -135,12 +149,18 @@ extension MypageProfileView { } self.profileStackView.snp.makeConstraints { make in - make.horizontalEdges.equalToSuperview().inset(20) + make.leading.equalToSuperview().inset(20) make.bottom.equalToSuperview().inset(29) } self.loginButton.snp.makeConstraints { make in - make.trailing.equalToSuperview().inset(20) + make.leading.equalTo(self.profileStackView.snp.trailing).offset(24) + make.trailing.equalToSuperview().inset(28) + make.centerY.equalTo(self.profileStackView) + } + + self.showProfileButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(28) make.centerY.equalTo(self.profileStackView) } } diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/MyPageDIContainer.swift b/Boolti/Boolti/Sources/UILayer/MyPage/MyPageDIContainer.swift deleted file mode 100644 index 6dd5d900..00000000 --- a/Boolti/Boolti/Sources/UILayer/MyPage/MyPageDIContainer.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// MyPageDIContainer.swift -// Boolti -// -// Created by Miro on 1/20/24. -// - -import Foundation - -final class MyPageDIContainer { - - private let authRepository: AuthRepositoryType - - init(authRepository: AuthRepositoryType) { - self.authRepository = authRepository - } - - func createMyPageViewController() -> MyPageViewController { - return MyPageViewController() - } -} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditLink/EditLinkViewController.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditLink/EditLinkViewController.swift new file mode 100644 index 00000000..d1e1896b --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditLink/EditLinkViewController.swift @@ -0,0 +1,247 @@ +import UIKit + +import RxSwift +import RxCocoa +import RxKeyboard + +protocol EditLinkViewControllerDelegate: AnyObject { + func editLinkViewController(_ viewController: UIViewController, didChangedLink entity: LinkEntity) + func editLinkViewController(_ viewController: UIViewController, didAddedLink entity: LinkEntity) + func editLinkDidDeleted(_ viewController: UIViewController) +} + +final class EditLinkViewController: BooltiViewController { + + /// EditType + // - add => 소셜 링크 추가 + // - edit => 편집 및 삭제 + // TODO: LinkEntity Data 타입과 Domain 객체 분리하기 + enum EditType { + case add + case edit(LinkEntity) + } + + private let navigationBar = BooltiNavigationBar(type: .addLink) + + private let linkNameTextField = ButtonTextField(with: .delete, placeHolder: "링크 이름을 입력해 주세요") + private lazy var linkNameStackView = BooltiInputStackView( + title: "링크 이름", + textField: linkNameTextField + ) + + private let URLTextField = ButtonTextField(with: .delete, placeHolder: "URL을 첨부해 주세요") + private lazy var URLStackView = BooltiInputStackView( + title: "URL", + textField: URLTextField + ) + + // TODO: BooltiButton 더 재사용성 높게 변경하기 + private let deleteLinkButton: BooltiButton = { + let button = BooltiButton(title: "링크 삭제") + button.setTitleColor(.grey90, for: .normal) + button.backgroundColor = .grey15 + button.isHidden = true + + return button + }() + + + // TODO: BooltiPopUpView도 더 재사용성 높게 변경하기 -> Init에서 설정하게! + // 항상 PopUpView를 띄어두고 있는 것(메모리)이 아니라 present하는 방식도 고민해보기 + private let deleteLinkPopUpView = BooltiPopupView() + + private let editType: EditType + + weak var delegate: EditLinkViewControllerDelegate? + + private let disposeBag = DisposeBag() + + init(editType: EditType) { + self.editType = editType + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + self.configureUI() + self.configureConstraints() + self.bindUIComponents() + } + + private func configureUI() { + self.view.addSubviews([navigationBar, linkNameStackView, URLStackView, deleteLinkButton, deleteLinkPopUpView]) + self.view.backgroundColor = .grey95 + + if case .edit(let link) = self.editType { + self.configureEditCase(with: link) + } + } + + private func configureConstraints() { + self.navigationBar.snp.makeConstraints { make in + make.top.equalToSuperview() + make.horizontalEdges.equalToSuperview() + } + + self.linkNameStackView.snp.makeConstraints { make in + make.top.equalTo(self.navigationBar.snp.bottom).offset(20) + make.horizontalEdges.equalToSuperview().inset(20) + } + + self.URLStackView.snp.makeConstraints { make in + make.top.equalTo(self.linkNameStackView.snp.bottom).offset(16) + make.horizontalEdges.equalTo(self.linkNameStackView) + } + + self.deleteLinkButton.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(20) + make.bottom.equalToSuperview().inset(42) + } + + self.deleteLinkPopUpView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private func configureEditCase(with link: LinkEntity) { + self.linkNameTextField.text = link.title + self.URLTextField.text = link.link + self.deleteLinkButton.isHidden = false + self.navigationBar.changeTitle(to: "링크 편집") + } + + private func bindUIComponents() { + // 링크 네임 + let linkNameTextFieldObservable = self.linkNameTextField.rx.text.orEmpty + + linkNameTextFieldObservable + .skip(1) + .distinctUntilChanged() + .bind(with: self) { owner, text in + owner.linkNameTextField.isButtonHidden = text.isEmpty + } + .disposed(by: self.disposeBag) + + self.linkNameTextField.didButtonTap + .bind(with: self) { owner, _ in + owner.linkNameTextField.text = "" + owner.linkNameTextField.isButtonHidden = true + owner.navigationBar.completeButton.isEnabled = false + } + .disposed(by: self.disposeBag) + + self.linkNameTextField.rx.controlEvent([.editingDidBegin]) + .bind(with: self) { owner, _ in + guard let nameText = owner.linkNameTextField.text else { return } + owner.linkNameTextField.isButtonHidden = nameText.isEmpty + } + .disposed(by: self.disposeBag) + + // URL 설정 + let URLTextFieldObservable = self.URLTextField.rx.text.orEmpty + + URLTextFieldObservable + .skip(1) + .distinctUntilChanged() + .bind(with: self) { owner, text in + owner.URLTextField.isButtonHidden = text.isEmpty + } + .disposed(by: self.disposeBag) + + self.URLTextField.didButtonTap + .bind(with: self) { owner, _ in + owner.URLTextField.text = "" + owner.URLTextField.isButtonHidden = true + owner.navigationBar.completeButton.isEnabled = false + } + .disposed(by: self.disposeBag) + + // textField를 선택하면 empty인 지 판단하여 button을 보여줄 지 말지 결정한다. + self.URLTextField.rx.controlEvent([.editingDidBegin]) + .bind(with: self) { owner, _ in + guard let urlText = owner.URLTextField.text else { return } + owner.URLTextField.isButtonHidden = urlText.isEmpty + } + .disposed(by: self.disposeBag) + + Observable.combineLatest( + linkNameTextFieldObservable.distinctUntilChanged(), + URLTextFieldObservable.distinctUntilChanged() + ) + .map { urlText, linkNameText in + return !urlText.isEmpty && !linkNameText.isEmpty + } + .bind(to: self.navigationBar.completeButton.rx.isEnabled) + .disposed(by: self.disposeBag) + + // 완료 버튼 + self.navigationBar.didCompleteButtonTap() + .emit(with: self) { owner, _ in + guard let title = owner.linkNameTextField.text else { return } + guard let link = owner.URLTextField.text else { return } + switch owner.editType { + case .add: + owner.delegate?.editLinkViewController(self, didAddedLink: LinkEntity(title: title, link: link)) + case .edit: + owner.delegate?.editLinkViewController(self, didChangedLink: LinkEntity(title: title, link: link)) + } + owner.navigationController?.popViewController(animated: true) + } + .disposed(by: self.disposeBag) + + // 삭제 버튼 + self.deleteLinkButton.rx.tap + .asDriver() + .drive(with: self) { owner, _ in + owner.deleteLinkPopUpView.showPopup(with: .deleteLink, withCancelButton: true) + } + .disposed(by: self.disposeBag) + + // 네비게이션 바 구현 + self.navigationBar.didBackButtonTap() + .emit(with: self) { owner, _ in + owner.navigationController?.popViewController(animated: true) + } + .disposed(by: self.disposeBag) + + self.bindPopUpViewComponents() + + // 키보드 + /// 키보드가 내려갈 때는 무조건 button을 숨긴다. + RxKeyboard.instance.isHidden + .skip(1) + .drive(with: self) { owner, isHidden in + if isHidden { + owner.linkNameTextField.isButtonHidden = true + owner.URLTextField.isButtonHidden = true + } + } + .disposed(by: self.disposeBag) + } + + private func bindPopUpViewComponents() { + self.deleteLinkPopUpView.didCancelButtonTap() + .emit(with: self) { owner, _ in + owner.deleteLinkPopUpView.isHidden = true + } + .disposed(by: self.disposeBag) + + self.deleteLinkPopUpView.didCloseButtonTap() + .emit(with: self) { owner, _ in + owner.deleteLinkPopUpView.isHidden = true + } + .disposed(by: self.disposeBag) + + self.deleteLinkPopUpView.didConfirmButtonTap() + .emit(with: self) { owner, _ in + owner.delegate?.editLinkDidDeleted(self) + owner.deleteLinkPopUpView.isHidden = true + owner.navigationController?.popViewController(animated: true) + } + .disposed(by: self.disposeBag) + } +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditLink/EditLinkViewModel.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditLink/EditLinkViewModel.swift new file mode 100644 index 00000000..3c8a0df4 --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditLink/EditLinkViewModel.swift @@ -0,0 +1,73 @@ +// +// EditLinkViewModel.swift +// Boolti +// +// Created by Juhyeon Byun on 9/6/24. +// +// +//import Foundation +// +//import RxSwift +// +//final class EditLinkViewModel { +// +// // MARK: Properties +// +// private let disposeBag = DisposeBag() +// private let authRepository: AuthRepositoryType +// +// struct Output { +// let didLinkSave = PublishSubject() +// let didLinkRemove = PublishSubject() +// } +// +// var output: Output +// +// var profile: ProfileEntity +// +// // MARK: Initailizer +// +// init(authRepository: AuthRepositoryType, +// profileEntity: ProfileEntity) { +// self.output = Output() +// self.authRepository = authRepository +// self.profile = profileEntity +// } +// +//} +// +//// MARK: - Network +// +//extension EditLinkViewModel { +// +// func addLink(title: String, link: String) { +// self.profile.links.insert(LinkEntity(title: title, link: link), at: 0) +// self.saveProfile() +// } +// +// func editLink(title: String, link: String, indexPath: IndexPath) { +// self.profile.links[indexPath.row] = LinkEntity(title: title, link: link) +// self.saveProfile() +// } +// +// func removeLink(indexPath: IndexPath) { +// self.profile.links.remove(at: indexPath.row) +// self.saveProfile(isRemoveAction: true) +// } +// +// func saveProfile(isRemoveAction: Bool = false) { +// self.authRepository.fetchProfile(profileImageUrl: self.profile.profileImageURL, +// nickname: self.profile.nickname, +// introduction: self.profile.introduction, +// links: self.profile.links) +// .subscribe(with: self) { owner, _ in +// if isRemoveAction { +// owner.output.didLinkRemove.onNext(()) +// } else { +// owner.output.didLinkSave.onNext(()) +// } +// } +// .disposed(by: self.disposeBag) +// } +// +//} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Cells/EditLinkCollectionViewCell.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Cells/EditLinkCollectionViewCell.swift new file mode 100644 index 00000000..0ddac676 --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Cells/EditLinkCollectionViewCell.swift @@ -0,0 +1,105 @@ +// +// EditLinkCollectionViewCell.swift +// Boolti +// +// Created by Juhyeon Byun on 9/5/24. +// + +import UIKit + +final class EditLinkCollectionViewCell: UICollectionViewCell { + + // MARK: UI Components + + private let titleLabel: BooltiUILabel = { + let label = BooltiUILabel() + label.font = .subhead1 + label.textColor = .grey15 + return label + }() + + private let urlLabel: BooltiUILabel = { + let label = BooltiUILabel() + label.font = .body1 + label.textColor = .grey30 + return label + }() + + private lazy var labelStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .leading + stackView.spacing = 2 + stackView.addArrangedSubviews([self.titleLabel, + self.urlLabel]) + return stackView + }() + + private let editImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = .pencil + imageView.tintColor = .grey50 + return imageView + }() + + // MARK: Init + + override init(frame: CGRect) { + super.init(frame: frame) + + self.configureUI() + self.configureConstraints() + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: - Override + + override func prepareForReuse() { + self.resetData() + } + +} + +// MARK: - Methods + +extension EditLinkCollectionViewCell { + + private func resetData() { + self.titleLabel.text = nil + self.urlLabel.text = nil + } + + func setData(title: String, url: String) { + self.titleLabel.text = title + self.urlLabel.text = url + } + +} + +// MARK: - UI + +extension EditLinkCollectionViewCell { + + private func configureUI() { + self.contentView.backgroundColor = .grey90 + self.contentView.addSubviews([self.labelStackView, + self.editImageView]) + } + + private func configureConstraints() { + self.labelStackView.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalToSuperview() + make.trailing.equalToSuperview().inset(40) + } + + self.editImageView.snp.makeConstraints { make in + make.trailing.equalToSuperview() + make.centerY.equalTo(self.labelStackView) + make.size.equalTo(20) + } + } +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/EditProfileDIContainer.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/EditProfileDIContainer.swift new file mode 100644 index 00000000..c8ff76fb --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/EditProfileDIContainer.swift @@ -0,0 +1,25 @@ +// +// EditProfileDIContainer.swift +// Boolti +// +// Created by Juhyeon Byun on 9/4/24. +// + +import UIKit + +final class EditProfileDIContainer { + + private let authRepository: AuthRepositoryType + + init(authRepository: AuthRepositoryType) { + self.authRepository = authRepository + } + + func createEditProfileViewController() -> EditProfileViewController { + let viewModel = EditProfileViewModel(authRepository: self.authRepository) + let viewController = EditProfileViewController(viewModel: viewModel) + + return viewController + } + +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/EditProfileViewController.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/EditProfileViewController.swift new file mode 100644 index 00000000..5d2972d4 --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/EditProfileViewController.swift @@ -0,0 +1,450 @@ +// +// EditProfileViewController.swift +// Boolti +// +// Created by Juhyeon Byun on 9/4/24. +// + +import UIKit + +import RxSwift +import RxCocoa + +final class EditProfileViewController: BooltiViewController { + + // MARK: Properties + + private let disposeBag = DisposeBag() + private let viewModel: EditProfileViewModel + + private var isScrollViewOffsetChanged: Bool = false + private var changedScrollViewOffsetY: CGFloat = 0 + + private var selectedItemIndex = 0 + + // MARK: UI Components + + private let navigationBar = BooltiNavigationBar(type: .editProfile) + + private lazy var mainScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.contentInset = .init(top: 0, left: 0, bottom: 12, right: 0) + scrollView.keyboardDismissMode = .onDrag + scrollView.addSubview(self.stackView) + scrollView.delegate = self + scrollView.isHidden = true + + return scrollView + }() + + private lazy var stackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 12 + stackView.addArrangedSubviews([self.editProfileImageView, + self.editNicknameView, + self.editIntroductionView, + self.editLinkView]) + stackView.setCustomSpacing(0, after: self.editProfileImageView) + + return stackView + }() + + private let editProfileImageView = EditProfileImageView() + + private let editNicknameView = EditNicknameView() + + private let editIntroductionView = EditIntroductionView() + + private let editLinkView = EditLinkView() + + private let popupView = BooltiPopupView() + + private let imagePickerController: UIImagePickerController = { + let imagePickerController = UIImagePickerController() + imagePickerController.sourceType = .photoLibrary + return imagePickerController + }() + + // MARK: Initailizer + + init(viewModel: EditProfileViewModel) { + self.viewModel = viewModel + super.init() + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + + self.configureUI() + self.bindUIComponents() + self.bindViewModel() + self.configureGesture() + self.configureKeyboardNotification() + self.configureToastView(isButtonExisted: false) + self.configureLinkCollectionView() + self.configureImagePickerController() + self.viewModel.fetchProfile() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.navigationController?.interactivePopGestureRecognizer?.isEnabled = false + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + self.navigationController?.interactivePopGestureRecognizer?.isEnabled = true + } + +} + +// MARK: - Methods + +extension EditProfileViewController { + + private func reloadLinks() { + self.editLinkView.linkCollectionView.reloadData() + self.updateCollectionViewHeight() + } + + private func bindViewModel() { + self.viewModel.output.fetchedProfile + .subscribe(with: self) { owner, profile in + owner.editProfileImageView.setImage(imageURL: profile?.imageURL ?? "") + owner.editNicknameView.setData(with: profile?.nickName ?? "") + owner.editIntroductionView.setData(with: profile?.introduction ?? "") + // TODO: 추후에 RxCocoa Datasource로 끌고 가는게 더 좋을듯 + /// 그래서 links를 그냥 input으로 넣어줘서 바로 해당 array로 collection view를 만들 수 있도록.. + owner.reloadLinks() + + owner.mainScrollView.isHidden = false + } + .disposed(by: self.disposeBag) + + self.viewModel.output.didProfileSaved + .subscribe(with: self) { owner, _ in + owner.showToast(message: "프로필 편집을 완료했어요") + owner.navigationController?.popViewController(animated: true) + } + .disposed(by: self.disposeBag) + + self.viewModel.output.isProfileChanged + .subscribe(with: self) { owner, isChanged in + if isChanged { + owner.popupView.showPopup(with: .saveProfile, withCancelButton: true) + } else { + owner.navigationController?.popViewController(animated: true) + } + } + .disposed(by: self.disposeBag) + } + + private func bindUIComponents() { + self.editProfileImageView.profileImageView.rx.tapGesture() + .when(.recognized) + .asDriver(onErrorDriveWith: .never()) + .drive(with: self) { owner, _ in + owner.present(owner.imagePickerController, animated: true) + } + .disposed(by: self.disposeBag) + + self.editNicknameView.nicknameTextField.rx.text + .orEmpty + .asDriver() + .drive(with: self) { owner, text in + owner.navigationBar.completeButton.isEnabled = !text.isEmpty + owner.viewModel.input.didNickNameTyped.accept(text) + } + .disposed(by: self.disposeBag) + + self.editIntroductionView.introductionTextView.rx.text + .orEmpty + .asDriver() + .drive(with: self, onNext: { owner, text in + // TODO: 아래와 같이 placeholder 판단하는 로직 변경하기 + owner.navigationBar.completeButton.isEnabled = !text.isEmpty && (text != "예) 재즈와 펑크락을 좋아해요") + owner.viewModel.input.didIntroductionTyped.accept(text) + }) + .disposed(by: self.disposeBag) + + self.navigationBar.didBackButtonTap() + .emit(to: self.viewModel.input.didBackButtonTapped) + .disposed(by: self.disposeBag) + + self.navigationBar.didCompleteButtonTap() + .emit(with: self, onNext: { owner, _ in + // TODO: 아래와 같이 url이랑 image 따로 보내는 거 해결하기 (vm 참고) + let image = owner.editProfileImageView.profileImageView.image ?? UIImage() + owner.viewModel.input.didProfileImageSelected.accept(image) + owner.viewModel.input.didNavigationBarCompleteButtonTapped.onNext(()) + }) + .disposed(by: self.disposeBag) + + self.popupView.didConfirmButtonTap() + .emit(with: self, onNext: { owner, _ in + let image = owner.editProfileImageView.profileImageView.image ?? UIImage() + owner.viewModel.input.didProfileImageSelected.accept(image) + owner.viewModel.input.didPopUpConfirmButtonTapped.onNext(()) + }) + .disposed(by: self.disposeBag) + + self.popupView.didCancelButtonTap() + .emit(with: self) { owner, _ in + owner.popupView.isHidden = true + owner.navigationController?.popViewController(animated: true) + } + .disposed(by: self.disposeBag) + + self.popupView.didCloseButtonTap() + .emit(with: self) { owner, _ in + owner.popupView.isHidden = true + } + .disposed(by: self.disposeBag) + } + + private func configureLinkCollectionView() { + self.editLinkView.linkCollectionView.dataSource = self + self.editLinkView.linkCollectionView.delegate = self + + self.editLinkView.linkCollectionView.register(AddLinkHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: AddLinkHeaderView.className) + self.editLinkView.linkCollectionView.register(EditLinkCollectionViewCell.self, + forCellWithReuseIdentifier: EditLinkCollectionViewCell.className) + } + + private func configureImagePickerController() { + self.imagePickerController.delegate = self + } + + private func updateCollectionViewHeight() { + self.editLinkView.linkCollectionView.layoutIfNeeded() + let height = self.editLinkView.linkCollectionView.contentSize.height + self.editLinkView.linkCollectionView.snp.updateConstraints { make in + make.height.equalTo(height) + } + } + + private func configureKeyboardNotification() { + NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: nil) { notification in + guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue, + let currentTextView = UIResponder.currentResponder as? UITextView else { return } + + let keyboardTopY = keyboardFrame.cgRectValue.origin.y + let convertedTextViewFrame = self.view.convert(currentTextView.frame, + from: currentTextView.superview) + let textViewBottomY = convertedTextViewFrame.origin.y + convertedTextViewFrame.size.height + if textViewBottomY > keyboardTopY * 0.9 { + let changeOffset = textViewBottomY - keyboardTopY + convertedTextViewFrame.size.height + self.mainScrollView.setContentOffset(CGPoint(x: 0, y: self.mainScrollView.contentOffset.y + changeOffset), animated: true) + + self.isScrollViewOffsetChanged = true + self.changedScrollViewOffsetY = changeOffset + } + } + } + + private func configureGesture() { + let tapGesture = UITapGestureRecognizer() + tapGesture.cancelsTouchesInView = false + self.view.addGestureRecognizer(tapGesture) + + tapGesture.rx.event + .bind(with: self, onNext: { owner, _ in + owner.view.endEditing(true) + if owner.isScrollViewOffsetChanged { + owner.mainScrollView.setContentOffset(CGPoint(x: 0, y: owner.mainScrollView.contentOffset.y - owner.changedScrollViewOffsetY), animated: true) + owner.isScrollViewOffsetChanged = false + } + }) + .disposed(by: self.disposeBag) + } + +} + +// MARK: - UIImagePickerControllerDelegate, UINavigationControllerDelegate + +extension EditProfileViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + guard let selectedImage = info[.originalImage] as? UIImage else { return } + picker.dismiss(animated: true) { + self.editProfileImageView.profileImageView.image = selectedImage + self.viewModel.input.didProfileImageSelected.accept(selectedImage) + } + } +} + +// MARK: - UIScrollViewDelegate + +extension EditProfileViewController: UIScrollViewDelegate { + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + self.isScrollViewOffsetChanged = false + } + +} + + +// MARK: - UICollectionViewDataSource + +extension EditProfileViewController: UICollectionViewDataSource { + + func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard let links = self.viewModel.output.profile.links else { return 0 } + return links.count + } + + /// 헤더를 결정하는 메서드 + func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + guard kind == UICollectionView.elementKindSectionHeader, + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: AddLinkHeaderView.className, + for: indexPath + ) as? AddLinkHeaderView else { return UICollectionReusableView() } + + header.rx.tapGesture() + .when(.recognized) + .bind(with: self) { owner, _ in + let viewController = EditLinkViewController(editType: .add) + viewController.delegate = self + owner.navigationController?.pushViewController(viewController, animated: true) + } + .disposed(by: header.disposeBag) + + return header + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: EditLinkCollectionViewCell.className, + for: indexPath) as? EditLinkCollectionViewCell else { return UICollectionViewCell() } + guard let links = self.viewModel.output.profile.links else { return UICollectionViewCell() } + + let data = links[indexPath.row] + + cell.setData(title: data.title, url: data.link) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let links = self.viewModel.output.profile.links else { return } + + let linkEntity = links[indexPath.row] + self.selectedItemIndex = indexPath.row + + let viewController = EditLinkViewController(editType: .edit(linkEntity)) + viewController.delegate = self + self.navigationController?.pushViewController(viewController, animated: true) + } + + func collectionView(_ collectionView: UICollectionView, didEndDisplayingSupplementaryView view: UICollectionReusableView, forElementOfKind elementKind: String, at indexPath: IndexPath) { + guard let header = collectionView.dequeueReusableSupplementaryView( + ofKind: elementKind, + withReuseIdentifier: AddLinkHeaderView.className, + for: indexPath + ) as? AddLinkHeaderView else { return } + + header.disposeBag = DisposeBag() + } + +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension EditProfileViewController: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + CGSize(width: UIScreen.main.bounds.width - 40, height: 56) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { + return .init(top: 12, left: 0, bottom: 0, right: 0) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { + return 12 + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { + return CGSize(width: UIScreen.main.bounds.width - 40, height: 56) + } +} + +// MARK: - UI + +extension EditProfileViewController { + + private func configureUI() { + self.view.backgroundColor = .grey95 + self.view.addSubviews([self.navigationBar, + self.mainScrollView, + self.popupView]) + self.configureConstraints() + } + + private func configureConstraints() { + self.navigationBar.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + } + + self.mainScrollView.snp.makeConstraints { make in + make.top.equalTo(self.navigationBar.snp.bottom) + make.width.equalToSuperview() + make.bottom.equalToSuperview() + } + + self.stackView.snp.makeConstraints { make in + make.verticalEdges.equalTo(self.mainScrollView) + make.width.equalTo(self.mainScrollView) + } + + self.popupView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + self.editLinkView.linkCollectionView.snp.makeConstraints { make in + make.height.equalTo(0) + } + } +} + +// MARK: EditLinkViewControllerDelegate + +extension EditProfileViewController: EditLinkViewControllerDelegate { + func editLinkDidDeleted(_ viewController: UIViewController) { + guard var links = self.viewModel.output.profile.links else { return } + links.remove(at: self.selectedItemIndex) + + self.viewModel.input.didLinkChanged.accept(links) + self.reloadLinks() + } + + func editLinkViewController(_ viewController: UIViewController, didChangedLink entity: LinkEntity) { + guard var links = self.viewModel.output.profile.links else { return } + links[self.selectedItemIndex] = entity + + self.viewModel.input.didLinkChanged.accept(links) + self.reloadLinks() + } + + func editLinkViewController(_ viewController: UIViewController, didAddedLink entity: LinkEntity) { + guard var links = self.viewModel.output.profile.links else { return } + links.insert(entity, at: 0) + + self.viewModel.input.didLinkChanged.accept(links) + self.reloadLinks() + } +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/EditProfileViewModel.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/EditProfileViewModel.swift new file mode 100644 index 00000000..f52b0346 --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/EditProfileViewModel.swift @@ -0,0 +1,152 @@ +// +// EditProfileViewModel.swift +// Boolti +// +// Created by Juhyeon Byun on 9/4/24. +// + +import UIKit + +import RxSwift +import RxRelay + +final class EditProfileViewModel { + + // TODO: DOMAIN 객체 제대로 정의하기 + // - Domain 객체 + struct Profile: Equatable { + var image: UIImage? = UIImage() // TODO: URL 뽑아오는 로직 변경하기!.. + var imageURL: String? = "" + var nickName: String? = "" + var introduction: String? = "" + var links: [LinkEntity]? = [] + } + + // MARK: Properties + + private let disposeBag = DisposeBag() + private let authRepository: AuthRepositoryType + + struct Input { + let didNickNameTyped = PublishRelay() + let didIntroductionTyped = PublishRelay() + let didProfileImageSelected = PublishRelay() + let didLinkChanged = PublishRelay<[LinkEntity]>() + + let didNavigationBarCompleteButtonTapped = PublishSubject() + let didPopUpConfirmButtonTapped = PublishSubject() + let didBackButtonTapped = PublishSubject() + } + + struct Output { + // Links - RxCocoa Datasource로 변경하기 + let fetchedProfile = BehaviorRelay(value: nil) + // Edit이된 Profile + // TODO: 아래의 로직을 Rx로 바꾸기.. + // Profile이 변경되었는 지 아닌 지를 판단하는 로직 변경하기.. -> 현재는 두 개의 객체를 생성해서 Equatable로 비교 + var profile = Profile() + var initialProfile = Profile() + let didProfileSaved = PublishSubject() + let isProfileChanged = PublishSubject() + } + + var input: Input + var output: Output + + // MARK: Initailizer + + init(authRepository: AuthRepositoryType) { + self.input = Input() + self.output = Output() + self.authRepository = authRepository + self.bindInputs() + } + +} + +// MARK: - Inputs + +extension EditProfileViewModel { + private func bindInputs() { + + // CombineLatest는 모든 요구사항을 다 만족했을 때 넘길 때 활용할 것! + self.input.didNickNameTyped + .subscribe(with: self, onNext: { owner, nickName in + owner.output.profile.nickName = nickName + }) + .disposed(by: self.disposeBag) + + self.input.didIntroductionTyped + .subscribe(with: self, onNext: { owner, introduction in + owner.output.profile.introduction = introduction + }) + .disposed(by: self.disposeBag) + + self.input.didProfileImageSelected + .subscribe(with: self, onNext: { owner, image in + owner.output.profile.image = image + }) + .disposed(by: self.disposeBag) + + self.input.didLinkChanged + .subscribe(with: self, onNext: { owner, links in + owner.output.profile.links = links + }) + .disposed(by: self.disposeBag) + + self.input.didBackButtonTapped + .subscribe(with: self) { owner, _ in + let isChanged = !(owner.output.profile == owner.output.initialProfile) + owner.output.isProfileChanged.onNext(isChanged) + } + .disposed(by: self.disposeBag) + + Observable.merge(self.input.didNavigationBarCompleteButtonTapped, self.input.didPopUpConfirmButtonTapped) + .subscribe(with: self) { owner, _ in + owner.save(owner.output.profile) + } + .disposed(by: self.disposeBag) + } +} + +// MARK: - Network + +extension EditProfileViewModel { + + // TODO: 추후에 View Life Cycle에 맞게 호출하도록 변경하기 + func fetchProfile() { + // TODO: 추후에 Domain 객체로 변경 + self.authRepository.userProfile() + .map({ [weak self] DTO in + let fetchedProfile = Profile(image: nil, imageURL: DTO.imgPath, nickName: DTO.nickname, introduction: DTO.introduction, links: DTO.link) + self?.output.initialProfile = fetchedProfile + return fetchedProfile + }) + .subscribe(with: self) { owner, profile in + owner.output.profile = profile + owner.output.fetchedProfile.accept(profile) + } + .disposed(by: self.disposeBag) + } + + func save(_ profile: Profile) { + + self.authRepository.getUploadImageURL() + .flatMap({ [weak self] response -> Single in + guard let self = self else { return .just("") } + return self.authRepository.uploadProfileImage(uploadURL: response.uploadUrl, imageData: profile.image ?? UIImage()) + .map { _ in response.expectedUrl } + }) + .flatMap({ [weak self] profileImageUrl -> Single in + guard let self = self else { return .just(()) } + return self.authRepository.editProfile(profileImageUrl: profileImageUrl, + nickname: profile.nickName ?? "", + introduction: profile.introduction ?? "", + links: profile.links ?? []) + }) + .subscribe(with: self) { owner, _ in + owner.output.didProfileSaved.onNext(()) + } + .disposed(by: self.disposeBag) + } +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/AddLinkHeaderView.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/AddLinkHeaderView.swift new file mode 100644 index 00000000..3c789757 --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/AddLinkHeaderView.swift @@ -0,0 +1,69 @@ +// +// AddLinkHeaderView.swift +// Boolti +// +// Created by Juhyeon Byun on 9/5/24. +// + +import UIKit + +import RxSwift + +final class AddLinkHeaderView: UICollectionReusableView { + + // MARK: UI Components + + var disposeBag = DisposeBag() + + private let addLinkImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = .addCircle + imageView.clipsToBounds = true + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private let titleLabel: BooltiUILabel = { + let label = BooltiUILabel() + label.font = .subhead1 + label.textColor = .grey15 + label.text = "링크 추가" + return label + }() + + // MARK: Initailizer + + override init(frame: CGRect) { + super.init(frame: .zero) + + self.configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - UI + +extension AddLinkHeaderView { + + private func configureUI() { + self.addSubviews([self.addLinkImageView, + self.titleLabel]) + self.configureConstraints() + } + + private func configureConstraints() { + self.addLinkImageView.snp.makeConstraints { make in + make.centerY.leading.equalToSuperview() + } + + self.titleLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalTo(self.addLinkImageView.snp.trailing).offset(16) + } + } + +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/EditIntroductionView.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/EditIntroductionView.swift new file mode 100644 index 00000000..9dbb956f --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/EditIntroductionView.swift @@ -0,0 +1,164 @@ +// +// EditIntroductionView.swift +// Boolti +// +// Created by Juhyeon Byun on 9/5/24. +// + +import UIKit + +import RxSwift +import RxCocoa + +// TODO: EditIntroductionView - TextView의 PlaceHolder등 추가해서 리팩토링 진행하기 +final class EditIntroductionView: UIView { + + // MARK: Properties + + private let disposeBag = DisposeBag() + + // MARK: UI Components + private let introductionLabel: BooltiUILabel = { + let label = BooltiUILabel() + label.font = .subhead2 + label.textColor = .grey10 + label.text = "소개" + return label + }() + + let introductionTextView: UITextView = { + let textView = UITextView() + textView.backgroundColor = .grey85 + textView.font = .body3 + textView.text = "예) 재즈와 펑크락을 좋아해요" + textView.textColor = .grey70 + textView.isScrollEnabled = true + return textView + }() + + private let backgroundView: UIView = { + let view = UIView() + view.backgroundColor = .grey85 + view.layer.cornerRadius = 4 + return view + }() + + private let textCountLabel: BooltiUILabel = { + let label = BooltiUILabel() + label.font = .caption + label.textColor = .grey70 + return label + }() + + // MARK: Initailizer + + override init(frame: CGRect) { + super.init(frame: .zero) + + self.configureUI() + self.bindTextView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - Methods + +extension EditIntroductionView { + + func setData(with introduction: String?) { + guard let introduction = introduction else { return } + + if !introduction.isEmpty { + self.introductionTextView.textColor = .grey10 + self.introductionTextView.text = introduction + self.textCountLabel.text = "\(introduction.count)/60자" + } + } + + private func bindTextView() { + self.introductionTextView.rx.didBeginEditing + .bind(with: self) { owner, _ in + if owner.introductionTextView.textColor == .grey70 { + owner.introductionTextView.text = nil + owner.introductionTextView.textColor = .grey10 + } + } + .disposed(by: self.disposeBag) + + self.introductionTextView.rx.didEndEditing + .bind(with: self) { owner, _ in + guard let changedText = owner.introductionTextView.text?.trimmingCharacters(in: .whitespacesAndNewlines) else { return } + + if changedText.isEmpty { + owner.introductionTextView.textColor = .grey70 + owner.introductionTextView.text = "예) 재즈와 펑크락을 좋아해요" + } + } + .disposed(by: self.disposeBag) + + self.introductionTextView.rx.text + .asDriver() + .drive(with: self) { owner, changedText in + guard let changedText = changedText else { return } + + if changedText.count > 60 { + owner.introductionTextView.deleteBackward() + } + + if owner.introductionTextView.textColor == .grey10 { + owner.textCountLabel.text = "\(changedText.count)/60자" + } else { + owner.textCountLabel.text = "0/60자" + } + + owner.introductionTextView.setLineHeight(alignment: .left) + } + .disposed(by: self.disposeBag) + } + +} + +// MARK: - UI + +extension EditIntroductionView { + + private func configureUI() { + self.backgroundColor = .grey90 + self.addSubviews([self.introductionLabel, + self.backgroundView, + self.introductionTextView, + self.textCountLabel]) + self.configureConstraints() + } + + private func configureConstraints() { + self.introductionLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().inset(20) + } + + self.backgroundView.snp.makeConstraints { make in + make.height.equalTo(122) + make.horizontalEdges.equalToSuperview().inset(20) + make.top.equalTo(self.introductionLabel.snp.bottom).offset(16) + } + + self.introductionTextView.snp.makeConstraints { make in + make.horizontalEdges.equalTo(self.backgroundView.snp.horizontalEdges).inset(12) + make.top.equalTo(self.backgroundView.snp.top).inset(12) + make.height.equalTo(72) + } + + self.textCountLabel.snp.makeConstraints { make in + make.top.equalTo(self.introductionTextView.snp.bottom).offset(8) + make.trailing.equalTo(self.backgroundView).inset(12) + } + + self.snp.makeConstraints { make in + make.height.equalTo(204) + } + } +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/EditLinkView.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/EditLinkView.swift new file mode 100644 index 00000000..cba47de4 --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/EditLinkView.swift @@ -0,0 +1,77 @@ +// +// EditLinkView.swift +// Boolti +// +// Created by Juhyeon Byun on 9/5/24. +// + +import UIKit + +import RxSwift + +final class EditLinkView: UIView { + + // MARK: Properties + + private let disposeBag = DisposeBag() + + // MARK: UI Components + + private let linkLabel: BooltiUILabel = { + let label = BooltiUILabel() + label.font = .subhead2 + label.textColor = .grey10 + label.text = "링크" + return label + }() + + let linkCollectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .vertical + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.showsVerticalScrollIndicator = false + collectionView.backgroundColor = .clear + collectionView.contentInset = .zero + collectionView.isScrollEnabled = false + return collectionView + }() + + // MARK: Initailizer + + override init(frame: CGRect) { + super.init(frame: .zero) + + self.configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - UI + +extension EditLinkView { + + private func configureUI() { + self.backgroundColor = .grey90 + self.addSubviews([self.linkLabel, + self.linkCollectionView]) + + self.configureConstraints() + } + + private func configureConstraints() { + self.linkLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().inset(20) + } + + self.linkCollectionView.snp.makeConstraints { make in + make.top.equalTo(self.linkLabel.snp.bottom).offset(20) + make.horizontalEdges.bottom.equalToSuperview().inset(20) + } + } + +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/EditNicknameView.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/EditNicknameView.swift new file mode 100644 index 00000000..72bc77d9 --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/EditNicknameView.swift @@ -0,0 +1,129 @@ +// +// EditNicknameView.swift +// Boolti +// +// Created by Juhyeon Byun on 9/4/24. +// + +import UIKit + +import RxSwift +import RxCocoa + +final class EditNicknameView: UIView { + + // MARK: Properties + + private let disposeBag = DisposeBag() + + // MARK: UI Components + + private let nicknameLabel: BooltiUILabel = { + let label = BooltiUILabel() + label.font = .subhead2 + label.textColor = .grey10 + label.text = "닉네임" + return label + }() + + let nicknameTextField: BooltiTextField = { + let textField = BooltiTextField() + textField.setPlaceHolderText(placeholder: "닉네임을 입력해주세요 (20자 이내)", foregroundColor: .grey70) + textField.layer.borderColor = UIColor.error.cgColor + return textField + }() + + private let errorInfoLabel: BooltiUILabel = { + let label = BooltiUILabel() + label.font = .pretendardR(14) + label.textColor = .error + label.numberOfLines = 0 + return label + }() + + // MARK: Initailizer + + override init(frame: CGRect) { + super.init(frame: .zero) + + self.configureUI() + self.bindTextField() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - Methods + +extension EditNicknameView { + + func setData(with name: String) { + self.nicknameTextField.text = name + } + + private func bindTextField() { + self.nicknameTextField.rx.text + .asDriver() + .skip(1) + .drive(with: self) { owner, changedText in + guard let changedText = changedText else { return } + owner.nicknameTextField.text = changedText + + if changedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + owner.nicknameTextField.text = nil + owner.nicknameTextField.layer.borderWidth = 1 + owner.errorInfoLabel.text = "1자 이상 입력해주세요" + } else { + if changedText.count > 20 { + owner.nicknameTextField.deleteBackward() + } + owner.nicknameTextField.layer.borderWidth = 0 + owner.errorInfoLabel.text = "" + } + } + .disposed(by: self.disposeBag) + + self.nicknameTextField.rx.controlEvent(.editingDidEnd) + .asDriver() + .drive(with: self) { owner, _ in + guard let text = owner.nicknameTextField.text else { return } + owner.nicknameTextField.text = text.trimmingCharacters(in: .whitespacesAndNewlines) + } + .disposed(by: self.disposeBag) + } + +} + +// MARK: - UI + +extension EditNicknameView { + + private func configureUI() { + self.backgroundColor = .grey90 + self.addSubviews([self.nicknameLabel, + self.nicknameTextField, + self.errorInfoLabel]) + + self.configureConstraints() + } + + private func configureConstraints() { + self.nicknameLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().inset(20) + } + + self.nicknameTextField.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(20) + make.top.equalTo(self.nicknameLabel.snp.bottom).offset(16) + } + + self.errorInfoLabel.snp.makeConstraints { make in + make.top.equalTo(self.nicknameTextField.snp.bottom).offset(12) + make.horizontalEdges.bottom.equalToSuperview().inset(20) + } + + } +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/EditProfileImageView.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/EditProfileImageView.swift new file mode 100644 index 00000000..ab625b05 --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/EditProfile/Views/EditProfileImageView.swift @@ -0,0 +1,81 @@ +// +// EditProfileImageView.swift +// Boolti +// +// Created by Juhyeon Byun on 9/4/24. +// + +import UIKit + +final class EditProfileImageView: UIView { + + // MARK: UI Components + + let profileImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.cornerRadius = 50 + imageView.backgroundColor = .grey80 + imageView.layer.borderColor = UIColor.grey80.cgColor + imageView.layer.borderWidth = 1 + imageView.image = .defaultProfile + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + + return imageView + }() + + private let cameraImageView = UIImageView(image: .camera) + + // MARK: Initailizer + + override init(frame: CGRect) { + super.init(frame: .zero) + + self.configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - Methods + +extension EditProfileImageView { + + func setImage(imageURL: String) { + self.profileImageView.setImage(with: imageURL) + } + +} + +// MARK: - UI + +extension EditProfileImageView { + + private func configureUI() { + self.backgroundColor = .grey95 + self.addSubviews([self.profileImageView, + self.cameraImageView]) + + self.configureConstraints() + } + + private func configureConstraints() { + self.snp.makeConstraints { make in + make.height.equalTo(180) + } + + self.profileImageView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(100) + } + + self.cameraImageView.snp.makeConstraints { make in + make.bottom.equalTo(self.profileImageView) + make.trailing.equalTo(self.profileImageView).offset(8) + make.size.equalTo(40) + } + } +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/Cells/ProfileLinkCollectionViewCell.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/Cells/ProfileLinkCollectionViewCell.swift new file mode 100644 index 00000000..d44de3d3 --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/Cells/ProfileLinkCollectionViewCell.swift @@ -0,0 +1,88 @@ +// +// ProfileLinkCollectionViewCell.swift +// Boolti +// +// Created by Juhyeon Byun on 8/24/24. +// + +import UIKit + +final class ProfileLinkCollectionViewCell: UICollectionViewCell { + + // MARK: UI Component + + private let linkImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = .link + + return imageView + }() + + private let linkNameLabel: BooltiUILabel = { + let label = BooltiUILabel() + label.font = .body3 + label.textColor = .grey15 + + return label + }() + + // MARK: Init + + override init(frame: CGRect) { + super.init(frame: frame) + + self.configureUI() + self.configureConstraints() + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: - Override + + override func prepareForReuse() { + self.resetData() + } +} + +// MARK: - Methods + +extension ProfileLinkCollectionViewCell { + + func setData(linkName: String) { + self.linkNameLabel.text = linkName + } + + private func resetData() { + self.linkNameLabel.text = nil + } +} + +// MARK: - UI + +extension ProfileLinkCollectionViewCell { + + private func configureUI() { + self.contentView.addSubviews([self.linkImageView, + self.linkNameLabel]) + + self.contentView.layer.cornerRadius = 4 + self.contentView.backgroundColor = .grey90 + self.backgroundColor = .grey95 + } + + private func configureConstraints() { + self.linkImageView.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalToSuperview().inset(20) + make.size.equalTo(24) + } + + self.linkNameLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalTo(self.linkImageView.snp.trailing).offset(12) + make.trailing.equalToSuperview().inset(20) + } + } +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/ProfileDIContainer.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/ProfileDIContainer.swift new file mode 100644 index 00000000..07f81318 --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/ProfileDIContainer.swift @@ -0,0 +1,33 @@ +// +// ProfileDIContainer.swift +// Boolti +// +// Created by Juhyeon Byun on 8/24/24. +// + +import UIKit + +final class ProfileDIContainer { + + private let authRepository: AuthRepositoryType + + init(authRepository: AuthRepositoryType) { + self.authRepository = authRepository + } + + func createProfileViewController() -> ProfileViewController { + let editProfileViewControllerFactory = { + let DIContainer = EditProfileDIContainer(authRepository: self.authRepository) + let viewController = DIContainer.createEditProfileViewController() + + return viewController + } + + let viewModel = ProfileViewModel(authRepository: self.authRepository) + let viewController = ProfileViewController(viewModel: viewModel, + editProfileViewControllerFactory: editProfileViewControllerFactory) + + return viewController + } + +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/ProfileViewController.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/ProfileViewController.swift new file mode 100644 index 00000000..f97256a0 --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/ProfileViewController.swift @@ -0,0 +1,255 @@ +// +// ProfileViewController.swift +// Boolti +// +// Created by Juhyeon Byun on 8/24/24. +// + +import UIKit + +import RxSwift +import RxCocoa + +final class ProfileViewController: BooltiViewController { + + // MARK: Properties + + private let disposeBag = DisposeBag() + private let viewModel: ProfileViewModel + + private let editProfileViewControllerFactory: () -> EditProfileViewController + + // MARK: UI Components + + private let navigationBar = BooltiNavigationBar(type: .backButtonWithTitle(title: "프로필")) + + private lazy var mainScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.keyboardDismissMode = .onDrag + scrollView.addSubview(self.stackView) + scrollView.bounces = false + scrollView.delegate = self + scrollView.contentInset = .init(top: 0, left: 0, bottom: 32, right: 0) + + return scrollView + }() + + private lazy var stackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.addArrangedSubviews([self.profileMainView, + self.dataCollectionView]) + + return stackView + }() + + private let profileMainView = ProfileMainView() + + let dataCollectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .vertical + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.showsVerticalScrollIndicator = false + collectionView.backgroundColor = .clear + collectionView.isScrollEnabled = false + return collectionView + }() + + // MARK: Initailizer + + init(viewModel: ProfileViewModel, + editProfileViewControllerFactory: @escaping () -> EditProfileViewController) { + self.viewModel = viewModel + self.editProfileViewControllerFactory = editProfileViewControllerFactory + + super.init() + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: Life Cycle + + override func viewWillAppear(_ animated: Bool) { + self.tabBarController?.tabBar.isHidden = true + self.viewModel.fetchLinkList() + } + + override func viewDidLoad() { + super.viewDidLoad() + + self.configureUI() + self.configureCollectionView() + self.configureToastView(isButtonExisted: false) + self.bindUIComponents() + self.bindViewModel() + } + +} + +// MARK: - Methods + +extension ProfileViewController { + + private func bindViewModel() { + self.viewModel.output.didProfileFetch + .subscribe(with: self) { owner, introduction in + owner.profileMainView.setData(introduction: introduction) + owner.dataCollectionView.reloadData() + owner.updateCollectionViewHeight() + } + .disposed(by: self.disposeBag) + } + + private func bindUIComponents() { + self.navigationBar.didBackButtonTap() + .emit(with: self) { owner, _ in + owner.navigationController?.popViewController(animated: true) + } + .disposed(by: self.disposeBag) + + self.dataCollectionView.rx.itemSelected + .map { $0.row } + .subscribe(with: self) { owner, index in + guard let url = URL(string: owner.viewModel.output.links[index].link) else { return } + if UIApplication.shared.canOpenURL(url) { + owner.openSafari(with: url) + } else { + owner.showToast(message: "링크를 열 수 없습니다") + } + } + .disposed(by: self.disposeBag) + + self.profileMainView.didEditButtonTap() + .emit(with: self) { owner, _ in + owner.navigationController?.pushViewController(owner.editProfileViewControllerFactory(), animated: true) + } + .disposed(by: self.disposeBag) + } + + private func configureCollectionView() { + self.dataCollectionView.delegate = self + self.dataCollectionView.dataSource = self + + self.dataCollectionView.register(ProfileLinkHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: ProfileLinkHeaderView.className) + self.dataCollectionView.register(ProfileLinkCollectionViewCell.self, forCellWithReuseIdentifier: ProfileLinkCollectionViewCell.className) + } + + private func updateCollectionViewHeight() { + self.dataCollectionView.layoutIfNeeded() + let collectionViewHeight = self.dataCollectionView.contentSize.height + self.dataCollectionView.snp.updateConstraints { make in + make.height.equalTo(collectionViewHeight) + } + + self.profileMainView.layoutIfNeeded() + let profileViewHeight = self.profileMainView.getHeight() + self.profileMainView.snp.updateConstraints { make in + make.height.equalTo(profileViewHeight) + } + } + +} + +// MARK: - UICollectionViewDataSource + +extension ProfileViewController: UICollectionViewDataSource { + + func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return self.viewModel.output.links.count + } + + /// 헤더를 결정하는 메서드 + func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + guard kind == UICollectionView.elementKindSectionHeader, + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: ProfileLinkHeaderView.className, + for: indexPath + ) as? ProfileLinkHeaderView else { return UICollectionReusableView() } + + return header + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ProfileLinkCollectionViewCell.className, + for: indexPath) as? ProfileLinkCollectionViewCell else { return UICollectionViewCell() } + cell.setData(linkName: self.viewModel.output.links[indexPath.row].title) + return cell + } + +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension ProfileViewController: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + return CGSize(width: self.dataCollectionView.frame.width - 40, height: 56) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { + return 16 + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { + guard !self.viewModel.output.links.isEmpty else { return .zero } + return CGSize(width: self.view.frame.width, height: 74) + } +} + +// MARK: - UI + +extension ProfileViewController { + + private func configureUI() { + self.view.backgroundColor = .grey95 + self.view.addSubviews([self.navigationBar, + self.mainScrollView]) + self.navigationBar.setBackgroundColor(with: .grey90) + self.configureConstraints() + } + + private func configureConstraints() { + self.navigationBar.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + } + + self.mainScrollView.snp.makeConstraints { make in + make.top.equalTo(self.navigationBar.snp.bottom) + make.width.equalToSuperview() + make.bottom.equalToSuperview() + } + + self.stackView.snp.makeConstraints { make in + make.verticalEdges.equalTo(self.mainScrollView) + make.width.equalTo(self.mainScrollView) + } + + self.profileMainView.snp.makeConstraints { make in + make.width.equalTo(UIScreen.main.bounds.width) + } + + self.dataCollectionView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview() + } + + self.profileMainView.snp.makeConstraints { make in + make.height.equalTo(400) + } + + self.dataCollectionView.snp.makeConstraints { make in + make.height.equalTo(0) + } + } + +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/ProfileViewModel.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/ProfileViewModel.swift new file mode 100644 index 00000000..aa428a1a --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/ProfileViewModel.swift @@ -0,0 +1,47 @@ +// +// ProfileViewModel.swift +// Boolti +// +// Created by Juhyeon Byun on 8/24/24. +// + +import Foundation + +import RxSwift + +final class ProfileViewModel { + + // MARK: Properties + + private let disposeBag = DisposeBag() + private let authRepository: AuthRepositoryType + + struct Output { + var links: [LinkEntity] = [] + var didProfileFetch = PublishSubject() + } + + var output: Output + + // MARK: Initailizer + + init(authRepository: AuthRepositoryType) { + self.output = Output() + self.authRepository = authRepository + } + +} + +// MARK: - Network + +extension ProfileViewModel { + + func fetchLinkList() { + self.authRepository.userProfile() + .subscribe(with: self) { owner, profile in + owner.output.links = profile.link ?? [] + owner.output.didProfileFetch.onNext(profile.introduction) + } + .disposed(by: self.disposeBag) + } +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/Views/ProfileLinkHeaderView.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/Views/ProfileLinkHeaderView.swift new file mode 100644 index 00000000..a326cb7c --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/Views/ProfileLinkHeaderView.swift @@ -0,0 +1,52 @@ +// +// ProfileLinkHeaderView.swift +// Boolti +// +// Created by Juhyeon Byun on 8/24/24. +// + +import UIKit + +final class ProfileLinkHeaderView: UICollectionReusableView { + + // MARK: UI Components + + private let titleLabel: BooltiUILabel = { + let label = BooltiUILabel() + label.font = .subhead2 + label.textColor = .grey10 + label.text = "SNS 링크" + return label + }() + + // MARK: Initailizer + + override init(frame: CGRect) { + super.init(frame: .zero) + + self.configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - UI + +extension ProfileLinkHeaderView { + + private func configureUI() { + self.addSubview(self.titleLabel) + self.configureConstraints() + } + + private func configureConstraints() { + self.titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().inset(32) + make.horizontalEdges.equalToSuperview().inset(20) + } + } + +} diff --git a/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/Views/ProfileMainView.swift b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/Views/ProfileMainView.swift new file mode 100644 index 00000000..1fdd1918 --- /dev/null +++ b/Boolti/Boolti/Sources/UILayer/MyPage/Profile/Main/Views/ProfileMainView.swift @@ -0,0 +1,146 @@ +// +// ProfileMainViewCell.swift +// Boolti +// +// Created by Juhyeon Byun on 8/24/24. +// + +import UIKit + +import RxSwift +import RxCocoa + +final class ProfileMainView: UIView { + + // MARK: Properties + + var disposeBag = DisposeBag() + + // MARK: UI Components + + private let profileImageView: UIImageView = { + let imageView = UIImageView() + imageView.backgroundColor = .grey80 + imageView.layer.cornerRadius = 35 + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.borderColor = UIColor.grey80.cgColor + imageView.image = .defaultProfile + + return imageView + }() + + private let nameLabel: BooltiUILabel = { + let label = BooltiUILabel() + label.font = .aggroM(24) + label.textColor = .grey10 + label.numberOfLines = 0 + + return label + }() + + private let introductionLabel: BooltiUILabel = { + let label = BooltiUILabel() + label.font = .body3 + label.textColor = .grey30 + label.numberOfLines = 0 + + return label + }() + + private lazy var labelStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 2 + stackView.addArrangedSubviews([self.nameLabel, + self.introductionLabel]) + + return stackView + }() + + private let editButton: UIButton = { + var config = UIButton.Configuration.plain() + config.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12) + config.title = "프로필 편집" + config.attributedTitle?.font = .pretendardR(12) + config.background.backgroundColor = .grey80 + config.baseForegroundColor = .grey05 + config.background.cornerRadius = 4 + config.imagePadding = 6 + + let button = UIButton(configuration: config) + button.setImage(.pencil, for: .normal) + + return button + }() + + // MARK: Initailizer + + override init(frame: CGRect) { + super.init(frame: .zero) + + self.configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - Methods + +extension ProfileMainView { + + func setData(introduction: String?) { + self.profileImageView.setImage(with: UserDefaults.userImageURLPath) + self.nameLabel.text = UserDefaults.userName + self.introductionLabel.text = introduction ?? "" + } + + func getHeight() -> CGFloat { + return 222 + self.nameLabel.getLabelHeight() + self.introductionLabel.getLabelHeight() + } + + func didEditButtonTap() -> Signal { + return self.editButton.rx.tap.asSignal() + } + +} + +// MARK: - UI + +extension ProfileMainView { + + private func configureUI() { + self.backgroundColor = .grey90 + self.layer.cornerRadius = 20 + self.layer.maskedCorners = CACornerMask( + arrayLiteral: .layerMinXMaxYCorner, .layerMaxXMaxYCorner + ) + self.addSubviews([self.profileImageView, + self.labelStackView, + self.editButton]) + + self.configureConstraints() + } + + private func configureConstraints() { + self.profileImageView.snp.makeConstraints { make in + make.size.equalTo(70) + make.top.equalToSuperview().inset(40) + make.leading.equalToSuperview().inset(20) + } + + self.labelStackView.snp.makeConstraints { make in + make.top.equalTo(self.profileImageView.snp.bottom).offset(20) + make.horizontalEdges.equalToSuperview().inset(20) + } + + self.editButton.snp.makeConstraints { make in + make.top.equalTo(self.labelStackView.snp.bottom).offset(28) + make.leading.equalTo(self.labelStackView) + make.bottom.equalToSuperview().inset(32) + } + } +}