From 62172eb2e073310667160c862d5a97f65464f1b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=80=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Wed, 8 Jan 2025 00:09:17 +0900 Subject: [PATCH 01/12] =?UTF-8?q?[FEAT]=20:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20UI=20=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20API=20=EC=97=B0=EB=8F=99=20=EC=85=8B=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- "Poppool/AdminRepository\\.swift" | 8 + Poppool/Poppool.xcodeproj/project.pbxproj | 126 +++++ .../xcshareddata/swiftpm/Package.resolved | 186 ------- .../Poppool/Application/SceneDelegate.swift | 41 +- .../PreSignedService/PreSignedService.swift | 1 - .../Admin/AdminBottomSheetView.swift | 234 ++++++++ .../AdminBottomSheetViewController.swift | 270 +++++++++ .../Presentation/Admin/AdminReactor.swift | 86 +++ .../Presentation/Admin/AdminStoreCell.swift | 82 +++ .../Presentation/Admin/AdminView.swift | 193 +++++++ .../Admin/AdminViewController.swift | 96 ++++ .../Admin/Common/DateTimePickerManager.swift | 131 +++++ .../Admin/Data/DTO/AdminResponseDTO.swift | 44 ++ .../GetAdminPopUpStoreListResponseDTO.swift | 76 +++ .../Admin/Data/Remote/AdminAPIEndpoint.swift | 105 ++++ .../Data/Repository/AdminRepository.swift | 96 ++++ .../Admin/Domain/AdminUseCase.swift | 57 ++ .../Admin/PopUpStoreRegisterReactor.swift | 286 ++++++++++ .../PopUpStoreRegisterViewController.swift | 521 ++++++++++++++++++ .../StoreListViewController.swift | 1 - .../ico/adminlist.imageset/Contents.json | 21 + .../ico/adminlist.imageset/adminlist.png | Bin 0 -> 218 bytes .../Image/ico/date.imageset/Contents.json | 21 + .../Image/ico/date.imageset/date.png | Bin 0 -> 674 bytes .../Image/ico/due.imageset/Contents.json | 21 + .../Image/ico/due.imageset/due.svg | 6 + .../date.imageset/Contents.json | 21 + .../Assets.xcassets/date.imageset/date.png | Bin 0 -> 674 bytes .../due.imageset/Contents.json | 20 + 29 files changed, 2541 insertions(+), 209 deletions(-) create mode 100644 "Poppool/AdminRepository\\.swift" delete mode 100644 Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Poppool/Poppool/Presentation/Admin/AdminBottomSheetView.swift create mode 100644 Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift create mode 100644 Poppool/Poppool/Presentation/Admin/AdminReactor.swift create mode 100644 Poppool/Poppool/Presentation/Admin/AdminStoreCell.swift create mode 100644 Poppool/Poppool/Presentation/Admin/AdminView.swift create mode 100644 Poppool/Poppool/Presentation/Admin/AdminViewController.swift create mode 100644 Poppool/Poppool/Presentation/Admin/Common/DateTimePickerManager.swift create mode 100644 Poppool/Poppool/Presentation/Admin/Data/DTO/AdminResponseDTO.swift create mode 100644 Poppool/Poppool/Presentation/Admin/Data/DTO/GetAdminPopUpStoreListResponseDTO.swift create mode 100644 Poppool/Poppool/Presentation/Admin/Data/Remote/AdminAPIEndpoint.swift create mode 100644 Poppool/Poppool/Presentation/Admin/Data/Repository/AdminRepository.swift create mode 100644 Poppool/Poppool/Presentation/Admin/Domain/AdminUseCase.swift create mode 100644 Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterReactor.swift create mode 100644 Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift create mode 100644 Poppool/Poppool/Resource/Assets.xcassets/Image/ico/adminlist.imageset/Contents.json create mode 100644 Poppool/Poppool/Resource/Assets.xcassets/Image/ico/adminlist.imageset/adminlist.png create mode 100644 Poppool/Poppool/Resource/Assets.xcassets/Image/ico/date.imageset/Contents.json create mode 100644 Poppool/Poppool/Resource/Assets.xcassets/Image/ico/date.imageset/date.png create mode 100644 Poppool/Poppool/Resource/Assets.xcassets/Image/ico/due.imageset/Contents.json create mode 100644 Poppool/Poppool/Resource/Assets.xcassets/Image/ico/due.imageset/due.svg create mode 100644 Poppool/Poppool/Resource/Assets.xcassets/date.imageset/Contents.json create mode 100644 Poppool/Poppool/Resource/Assets.xcassets/date.imageset/date.png create mode 100644 Poppool/Poppool/Resource/Assets.xcassets/due.imageset/Contents.json diff --git "a/Poppool/AdminRepository\\.swift" "b/Poppool/AdminRepository\\.swift" new file mode 100644 index 00000000..b5f7eebc --- /dev/null +++ "b/Poppool/AdminRepository\\.swift" @@ -0,0 +1,8 @@ +// +// AdminRepository\.swift +// Poppool +// +// Created by 김기현 on 1/6/25. +// + +import Foundation diff --git a/Poppool/Poppool.xcodeproj/project.pbxproj b/Poppool/Poppool.xcodeproj/project.pbxproj index 12153523..a76ab325 100644 --- a/Poppool/Poppool.xcodeproj/project.pbxproj +++ b/Poppool/Poppool.xcodeproj/project.pbxproj @@ -385,9 +385,24 @@ 4E685EE72D12CEB6001EF91C /* MapSearchInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E685EC92D12CEB6001EF91C /* MapSearchInput.swift */; }; 4E685EE92D12CEB6001EF91C /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E685ECB2D12CEB6001EF91C /* MapView.swift */; }; 4E685EEA2D12CEB6001EF91C /* MapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E685ECC2D12CEB6001EF91C /* MapViewController.swift */; }; + 4E755B1D2D2B9AD300ADFB21 /* GetAdminPopUpStoreListResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B1C2D2B9AD300ADFB21 /* GetAdminPopUpStoreListResponseDTO.swift */; }; + 4E755B1F2D2B9AE500ADFB21 /* AdminAPIEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B1E2D2B9AE500ADFB21 /* AdminAPIEndpoint.swift */; }; + 4E755B212D2B9BAB00ADFB21 /* AdminResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B202D2B9BAB00ADFB21 /* AdminResponseDTO.swift */; }; + 4E755B232D2B9C5D00ADFB21 /* AdminViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B222D2B9C5D00ADFB21 /* AdminViewController.swift */; }; + 4E755B252D2B9C6C00ADFB21 /* AdminView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B242D2B9C6C00ADFB21 /* AdminView.swift */; }; + 4E755B272D2B9C7C00ADFB21 /* AdminStoreCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B262D2B9C7C00ADFB21 /* AdminStoreCell.swift */; }; + 4E755B292D2BA65A00ADFB21 /* AdminReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B282D2BA65A00ADFB21 /* AdminReactor.swift */; }; + 4E755B2B2D2BA76E00ADFB21 /* AdminUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B2A2D2BA76E00ADFB21 /* AdminUseCase.swift */; }; + 4E755B2F2D2BA7FB00ADFB21 /* AdminRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B2E2D2BA7FB00ADFB21 /* AdminRepository.swift */; }; + 4E755B322D2BA81800ADFB21 /* Then in Frameworks */ = {isa = PBXBuildFile; productRef = 4E755B312D2BA81800ADFB21 /* Then */; }; 4E92F0D52D1BE72F00D00495 /* StoreListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E92F0D42D1BE72E00D00495 /* StoreListHeaderView.swift */; }; + 4E9C12782D2BC7A0006744D6 /* AdminBottomSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9C12772D2BC7A0006744D6 /* AdminBottomSheetView.swift */; }; + 4E9C127A2D2BC811006744D6 /* AdminBottomSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9C12792D2BC811006744D6 /* AdminBottomSheetViewController.swift */; }; + 4E9C12812D2BE0A6006744D6 /* PopUpStoreRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9C12802D2BE0A6006744D6 /* PopUpStoreRegisterViewController.swift */; }; 4EA9989A2D21C2FC009DC30B /* StoreListSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA998992D21C2FC009DC30B /* StoreListSection.swift */; }; 4EA9989D2D21C404009DC30B /* RxDataSources in Frameworks */ = {isa = PBXBuildFile; productRef = 4EA9989C2D21C404009DC30B /* RxDataSources */; }; + 4EDDEFB42D2D285900CFAFA5 /* DateTimePickerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDDEFB32D2D285900CFAFA5 /* DateTimePickerManager.swift */; }; + 4EDDEFB62D2D2A9100CFAFA5 /* PopUpStoreRegisterReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDDEFB52D2D2A9100CFAFA5 /* PopUpStoreRegisterReactor.swift */; }; 4EED9BAC2D22730400B288E7 /* FilterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED9BAB2D22730400B288E7 /* FilterType.swift */; }; BD226D512CF6DB290038C984 /* PPReturnHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD226D502CF6DB290038C984 /* PPReturnHeaderView.swift */; }; BD9103612CF6149D00BBCCAE /* AuthAPIEndPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD91034E2CF6149D00BBCCAE /* AuthAPIEndPoint.swift */; }; @@ -823,8 +838,22 @@ 4E685EC92D12CEB6001EF91C /* MapSearchInput.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapSearchInput.swift; sourceTree = ""; }; 4E685ECB2D12CEB6001EF91C /* MapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; 4E685ECC2D12CEB6001EF91C /* MapViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapViewController.swift; sourceTree = ""; }; + 4E755B1C2D2B9AD300ADFB21 /* GetAdminPopUpStoreListResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetAdminPopUpStoreListResponseDTO.swift; sourceTree = ""; }; + 4E755B1E2D2B9AE500ADFB21 /* AdminAPIEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminAPIEndpoint.swift; sourceTree = ""; }; + 4E755B202D2B9BAB00ADFB21 /* AdminResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminResponseDTO.swift; sourceTree = ""; }; + 4E755B222D2B9C5D00ADFB21 /* AdminViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminViewController.swift; sourceTree = ""; }; + 4E755B242D2B9C6C00ADFB21 /* AdminView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminView.swift; sourceTree = ""; }; + 4E755B262D2B9C7C00ADFB21 /* AdminStoreCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminStoreCell.swift; sourceTree = ""; }; + 4E755B282D2BA65A00ADFB21 /* AdminReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminReactor.swift; sourceTree = ""; }; + 4E755B2A2D2BA76E00ADFB21 /* AdminUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminUseCase.swift; sourceTree = ""; }; + 4E755B2E2D2BA7FB00ADFB21 /* AdminRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminRepository.swift; sourceTree = ""; }; 4E92F0D42D1BE72E00D00495 /* StoreListHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreListHeaderView.swift; sourceTree = ""; }; + 4E9C12772D2BC7A0006744D6 /* AdminBottomSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminBottomSheetView.swift; sourceTree = ""; }; + 4E9C12792D2BC811006744D6 /* AdminBottomSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminBottomSheetViewController.swift; sourceTree = ""; }; + 4E9C12802D2BE0A6006744D6 /* PopUpStoreRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopUpStoreRegisterViewController.swift; sourceTree = ""; }; 4EA998992D21C2FC009DC30B /* StoreListSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreListSection.swift; sourceTree = ""; }; + 4EDDEFB32D2D285900CFAFA5 /* DateTimePickerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimePickerManager.swift; sourceTree = ""; }; + 4EDDEFB52D2D2A9100CFAFA5 /* PopUpStoreRegisterReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopUpStoreRegisterReactor.swift; sourceTree = ""; }; 4EED9BAB2D22730400B288E7 /* FilterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterType.swift; sourceTree = ""; }; BD226D502CF6DB290038C984 /* PPReturnHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PPReturnHeaderView.swift; sourceTree = ""; }; BD91034E2CF6149D00BBCCAE /* AuthAPIEndPoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthAPIEndPoint.swift; sourceTree = ""; }; @@ -876,6 +905,7 @@ BDCA41F52CF35D33005EECF6 /* Kingfisher in Frameworks */, BDCA42072CF35FA6005EECF6 /* Tabman in Frameworks */, BDCA42042CF35F76005EECF6 /* PanModal in Frameworks */, + 4E755B322D2BA81800ADFB21 /* Then in Frameworks */, 08B191BA2CF609AE0057BC04 /* RxKakaoSDK in Frameworks */, 08B191BC2CF609AE0057BC04 /* RxKakaoSDKAuth in Frameworks */, 083A25D02CF364B70099B58E /* Alamofire in Frameworks */, @@ -1278,6 +1308,7 @@ 083A256D2CF3613C0099B58E /* Presentation */ = { isa = PBXGroup; children = ( + 4E755B1B2D2B9ABF00ADFB21 /* Admin */, 4E685ECD2D12CEB6001EF91C /* Map */, 083A258B2CF361F90099B58E /* Convention */, 08B1915F2CF430D40057BC04 /* Components */, @@ -2592,6 +2623,75 @@ path = Map; sourceTree = ""; }; + 4E755B1B2D2B9ABF00ADFB21 /* Admin */ = { + isa = PBXGroup; + children = ( + 4E9C127F2D2BD01F006744D6 /* Domain */, + 4E9C127B2D2BCFE4006744D6 /* Data */, + 4E755B222D2B9C5D00ADFB21 /* AdminViewController.swift */, + 4E755B242D2B9C6C00ADFB21 /* AdminView.swift */, + 4E755B262D2B9C7C00ADFB21 /* AdminStoreCell.swift */, + 4E755B282D2BA65A00ADFB21 /* AdminReactor.swift */, + 4E9C12772D2BC7A0006744D6 /* AdminBottomSheetView.swift */, + 4E9C12792D2BC811006744D6 /* AdminBottomSheetViewController.swift */, + 4E9C12802D2BE0A6006744D6 /* PopUpStoreRegisterViewController.swift */, + 4EDDEFB52D2D2A9100CFAFA5 /* PopUpStoreRegisterReactor.swift */, + 4EDDEFB22D2D284B00CFAFA5 /* Common */, + ); + path = Admin; + sourceTree = ""; + }; + 4E9C127B2D2BCFE4006744D6 /* Data */ = { + isa = PBXGroup; + children = ( + 4E9C127E2D2BD012006744D6 /* Remote */, + 4E9C127D2D2BD007006744D6 /* Repository */, + 4E9C127C2D2BCFF1006744D6 /* DTO */, + ); + path = Data; + sourceTree = ""; + }; + 4E9C127C2D2BCFF1006744D6 /* DTO */ = { + isa = PBXGroup; + children = ( + 4E755B1C2D2B9AD300ADFB21 /* GetAdminPopUpStoreListResponseDTO.swift */, + 4E755B202D2B9BAB00ADFB21 /* AdminResponseDTO.swift */, + ); + path = DTO; + sourceTree = ""; + }; + 4E9C127D2D2BD007006744D6 /* Repository */ = { + isa = PBXGroup; + children = ( + 4E755B2E2D2BA7FB00ADFB21 /* AdminRepository.swift */, + ); + path = Repository; + sourceTree = ""; + }; + 4E9C127E2D2BD012006744D6 /* Remote */ = { + isa = PBXGroup; + children = ( + 4E755B1E2D2B9AE500ADFB21 /* AdminAPIEndpoint.swift */, + ); + path = Remote; + sourceTree = ""; + }; + 4E9C127F2D2BD01F006744D6 /* Domain */ = { + isa = PBXGroup; + children = ( + 4E755B2A2D2BA76E00ADFB21 /* AdminUseCase.swift */, + ); + path = Domain; + sourceTree = ""; + }; + 4EDDEFB22D2D284B00CFAFA5 /* Common */ = { + isa = PBXGroup; + children = ( + 4EDDEFB32D2D285900CFAFA5 /* DateTimePickerManager.swift */, + ); + path = Common; + sourceTree = ""; + }; 4EED9BAA2D2272F500B288E7 /* Common */ = { isa = PBXGroup; children = ( @@ -2794,6 +2894,7 @@ 088DE2462D12DB5C0030FA9E /* GoogleMaps */, 4E5825662D1951DF00EE83EF /* FloatingPanel */, 4EA9989C2D21C404009DC30B /* RxDataSources */, + 4E755B312D2BA81800ADFB21 /* Then */, ); productName = Poppool; productReference = BDCA41BD2CF35AC0005EECF6 /* Poppool.app */; @@ -2884,6 +2985,7 @@ 088DE2452D12DB5C0030FA9E /* XCRemoteSwiftPackageReference "ios-maps-sdk" */, 4E5825652D1951DF00EE83EF /* XCRemoteSwiftPackageReference "FloatingPanel" */, 4EA9989B2D21C404009DC30B /* XCRemoteSwiftPackageReference "RxDataSources" */, + 4E755B302D2BA81700ADFB21 /* XCRemoteSwiftPackageReference "Then" */, ); productRefGroup = BDCA41BE2CF35AC0005EECF6 /* Products */; projectDirPath = ""; @@ -2967,6 +3069,7 @@ BD9103692CF6149D00BBCCAE /* HomeAPIRepository.swift in Sources */, 083A25832CF361EF0099B58E /* BaseViewController.swift in Sources */, 089952752D0475F20022AEF9 /* SearchResultCountSectionCell.swift in Sources */, + 4E9C12782D2BC7A0006744D6 /* AdminBottomSheetView.swift in Sources */, 086DD9362D00963900B97D3B /* SearchTitleSection.swift in Sources */, 083A25922CF361F90099B58E /* ViewConvention.swift in Sources */, 086F89D92D1E79E200CA4FC9 /* GetOtherUserCommentListRequestDTO.swift in Sources */, @@ -3014,16 +3117,19 @@ 0899526E2D0474340022AEF9 /* GetSearchPopUpListResponse.swift in Sources */, 08B191392CF366680057BC04 /* UITableViewCell+.swift in Sources */, 08A2E48F2D1BF6E500102313 /* CommentListView.swift in Sources */, + 4E755B1D2D2B9AD300ADFB21 /* GetAdminPopUpStoreListResponseDTO.swift in Sources */, 083A25992CF362090099B58E /* Sectionable.swift in Sources */, 086DD8E02CFF2C3700B97D3B /* UIImageView+.swift in Sources */, 081898DA2D32559B0067BF01 /* PutUserProfileRequestDTO.swift in Sources */, 08B191AC2CF5BF9D0057BC04 /* AgeSelectedController.swift in Sources */, + 4E755B1F2D2B9AE500ADFB21 /* AdminAPIEndpoint.swift in Sources */, 083C86622D0EC49E003F441C /* InstaCommentAddController.swift in Sources */, 08B1919C2CF4A77C0057BC04 /* TagSection.swift in Sources */, BD91038B2CF614A900BBCCAE /* AuthRepository.swift in Sources */, 083A25902CF361F90099B58E /* TestDynamicCell.swift in Sources */, 08DC61F32CF75037002A2F44 /* KeyChainService.swift in Sources */, 086DD93B2D009A1C00B97D3B /* CancelableTagSection.swift in Sources */, + 4E755B232D2B9C5D00ADFB21 /* AdminViewController.swift in Sources */, 08A2E49F2D1C417000102313 /* CommentListTitleSectionCell.swift in Sources */, 086F89D52D1E6DB100CA4FC9 /* OtherUserCommentController.swift in Sources */, 083C866E2D0ECB87003F441C /* InstaGuideChildSection.swift in Sources */, @@ -3039,6 +3145,7 @@ 083C864C2D0DCF9B003F441C /* AddCommentDescriptionSectionCell.swift in Sources */, 086DD8CE2CFDFEB000B97D3B /* HomeListView.swift in Sources */, 08B191C22CF615CA0057BC04 /* Secrets.swift in Sources */, + 4EDDEFB62D2D2A9100CFAFA5 /* PopUpStoreRegisterReactor.swift in Sources */, 083A258C2CF361F90099B58E /* ControllerConvention.swift in Sources */, 083C86242D087A44003F441C /* DetailTitleSection.swift in Sources */, 08A2E4822D1BCDEA00102313 /* CommentDetailController.swift in Sources */, @@ -3095,6 +3202,7 @@ 086F89DB2D1E7A6C00CA4FC9 /* GetOtherUserCommentListResponseDTO.swift in Sources */, 086F89D32D1E6DA600CA4FC9 /* OtherUserCommentView.swift in Sources */, 4E685ECE2D12CEB6001EF91C /* BalloonBackgroundView.swift in Sources */, + 4E755B292D2BA65A00ADFB21 /* AdminReactor.swift in Sources */, 086DD8D62CFF182100B97D3B /* UserAPIEndPoint.swift in Sources */, 081899372D35F1140067BF01 /* MyPageBookmarkReactor.swift in Sources */, 4E685EE22D12CEB6001EF91C /* StoreListView.swift in Sources */, @@ -3113,6 +3221,7 @@ 083C86362D0C7EF4003F441C /* CommentSelectedController.swift in Sources */, 08A2E4792D1B06A300102313 /* ImageDetailView.swift in Sources */, 088DE2542D144A7E0030FA9E /* DetailCommentSection.swift in Sources */, + 4E755B2B2D2BA76E00ADFB21 /* AdminUseCase.swift in Sources */, 083C86292D088080003F441C /* DetailContentSection.swift in Sources */, 08DC61FA2CF7684F002A2F44 /* SignUpCompleteController.swift in Sources */, 083C864A2D0DCF96003F441C /* AddCommentDescriptionSection.swift in Sources */, @@ -3184,6 +3293,7 @@ 08B1916C2CF434C30057BC04 /* SignUpStep1View.swift in Sources */, 083A25BC2CF362670099B58E /* ProviderImpl.swift in Sources */, 086DD93D2D009A2600B97D3B /* CancelableTagSectionCell.swift in Sources */, + 4E9C127A2D2BC811006744D6 /* AdminBottomSheetViewController.swift in Sources */, 086DD8DA2CFF194700B97D3B /* UserAPIRepositoryImpl.swift in Sources */, 4E685ECF2D12CEB6001EF91C /* BalloonChipCell.swift in Sources */, 083A25CA2CF363C60099B58E /* LoginReactor.swift in Sources */, @@ -3235,8 +3345,10 @@ 086DD8D32CFDFF1500B97D3B /* HomePopUpType.swift in Sources */, 081898CC2D30D5BF0067BF01 /* InfoEditModalController.swift in Sources */, 4E685EE92D12CEB6001EF91C /* MapView.swift in Sources */, + 4E755B212D2B9BAB00ADFB21 /* AdminResponseDTO.swift in Sources */, 08B191382CF366680057BC04 /* UICollectionReusableView+.swift in Sources */, 4E685EE02D12CEB6001EF91C /* StoreListCell.swift in Sources */, + 4EDDEFB42D2D285900CFAFA5 /* DateTimePickerManager.swift in Sources */, 08B1915D2CF41E6F0057BC04 /* SignUpMainView.swift in Sources */, 0841BAA52CFA31A900049E31 /* SpacingSectionCell.swift in Sources */, 4E685EDF2D12CEB6001EF91C /* PopupCardCell.swift in Sources */, @@ -3323,6 +3435,7 @@ 086DD8D82CFF185200B97D3B /* PostBookmarkPopUpRequestDTO.swift in Sources */, 089952422D031E650022AEF9 /* SearchSortedController.swift in Sources */, 089952532D033C940022AEF9 /* PopUpAPIRepositoryImpl.swift in Sources */, + 4E755B252D2B9C6C00ADFB21 /* AdminView.swift in Sources */, 4E685ED42D12CEB6001EF91C /* FilterCell.swift in Sources */, 088DE24F2D13019A0030FA9E /* DetailCommentTitleSection.swift in Sources */, 081898B72D2D23A90067BF01 /* UINavigationController+.swift in Sources */, @@ -3715,6 +3828,14 @@ minimumVersion = 2.8.6; }; }; + 4E755B302D2BA81700ADFB21 /* XCRemoteSwiftPackageReference "Then" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/devxoul/Then.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.0; + }; + }; 4EA9989B2D21C404009DC30B /* XCRemoteSwiftPackageReference "RxDataSources" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/RxSwiftCommunity/RxDataSources.git"; @@ -3849,6 +3970,11 @@ package = 4E5825652D1951DF00EE83EF /* XCRemoteSwiftPackageReference "FloatingPanel" */; productName = FloatingPanel; }; + 4E755B312D2BA81800ADFB21 /* Then */ = { + isa = XCSwiftPackageProductDependency; + package = 4E755B302D2BA81700ADFB21 /* XCRemoteSwiftPackageReference "Then" */; + productName = Then; + }; 4EA9989C2D21C404009DC30B /* RxDataSources */ = { isa = XCSwiftPackageProductDependency; package = 4EA9989B2D21C404009DC30B /* XCRemoteSwiftPackageReference "RxDataSources" */; diff --git a/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 260f3056..00000000 --- a/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,186 +0,0 @@ -{ - "originHash" : "301b69a66ba06d1836f5cd9f502b100abe9846c1431f13502b490b59446673f5", - "pins" : [ - { - "identity" : "alamofire", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Alamofire/Alamofire.git", - "state" : { - "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", - "version" : "5.10.2" - } - }, - { - "identity" : "floatingpanel", - "kind" : "remoteSourceControl", - "location" : "https://github.com/scenee/FloatingPanel.git", - "state" : { - "revision" : "b6e8928b1a3ad909e6db6a0278d286c33cfd0dc3", - "version" : "2.8.6" - } - }, - { - "identity" : "ios-maps-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googlemaps/ios-maps-sdk", - "state" : { - "revision" : "df7ecd2f894fd83f0287f2cfb6842a0dfe6f290b", - "version" : "9.2.0" - } - }, - { - "identity" : "kakao-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kakao/kakao-ios-sdk.git", - "state" : { - "revision" : "ab4309c1950550add307046ad1e08024c7514603", - "version" : "2.23.0" - } - }, - { - "identity" : "kakao-ios-sdk-rx", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kakao/kakao-ios-sdk-rx", - "state" : { - "revision" : "fa5ce05d610c4b026df8d42e891a32f31a239d58", - "version" : "2.23.0" - } - }, - { - "identity" : "kingfisher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/onevcat/Kingfisher.git", - "state" : { - "revision" : "e6749919f9761d573d37a7d7e78b1b854c191d7f", - "version" : "8.1.3" - } - }, - { - "identity" : "lottie-spm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/airbnb/lottie-spm.git", - "state" : { - "revision" : "8c6edf4f0fa84fe9c058600a4295eb0c01661c69", - "version" : "4.5.1" - } - }, - { - "identity" : "ohhttpstubs", - "kind" : "remoteSourceControl", - "location" : "https://github.com/AliSoftware/OHHTTPStubs.git", - "state" : { - "revision" : "12f19662426d0434d6c330c6974d53e2eb10ecd9", - "version" : "9.1.0" - } - }, - { - "identity" : "pageboy", - "kind" : "remoteSourceControl", - "location" : "https://github.com/uias/Pageboy.git", - "state" : { - "revision" : "be0c1f6f1964cfb07f9d819b0863f2c3f255f612", - "version" : "4.2.0" - } - }, - { - "identity" : "panmodal", - "kind" : "remoteSourceControl", - "location" : "https://github.com/slackhq/PanModal.git", - "state" : { - "revision" : "b012aecb6b67a8e46369227f893c12544846613f", - "version" : "1.2.7" - } - }, - { - "identity" : "reactorkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ReactorKit/ReactorKit.git", - "state" : { - "revision" : "8fa33f09c6f6621a2aa536d739956d53b84dd139", - "version" : "3.2.0" - } - }, - { - "identity" : "rxalamofire", - "kind" : "remoteSourceControl", - "location" : "https://github.com/RxSwiftCommunity/RxAlamofire.git", - "state" : { - "revision" : "9535b58695b91fb67f56d58d6fd0c76462d7743a", - "version" : "6.1.2" - } - }, - { - "identity" : "rxdatasources", - "kind" : "remoteSourceControl", - "location" : "https://github.com/RxSwiftCommunity/RxDataSources.git", - "state" : { - "revision" : "90c29b48b628479097fe775ed1966d75ac374518", - "version" : "5.0.2" - } - }, - { - "identity" : "rxgesture", - "kind" : "remoteSourceControl", - "location" : "https://github.com/RxSwiftCommunity/RxGesture.git", - "state" : { - "revision" : "1b137c576b4aaaab949235752278956697c9e4a0", - "version" : "4.0.4" - } - }, - { - "identity" : "rxkeyboard", - "kind" : "remoteSourceControl", - "location" : "https://github.com/RxSwiftCommunity/RxKeyboard.git", - "state" : { - "revision" : "63f6377975c962a1d89f012a6f1e5bebb2c502b7", - "version" : "2.0.1" - } - }, - { - "identity" : "rxswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ReactiveX/RxSwift.git", - "state" : { - "revision" : "c7c7d2cf50a3211fe2843f76869c698e4e417930", - "version" : "6.8.0" - } - }, - { - "identity" : "snapkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SnapKit/SnapKit.git", - "state" : { - "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", - "version" : "5.7.1" - } - }, - { - "identity" : "swiftsoup", - "kind" : "remoteSourceControl", - "location" : "https://github.com/scinfu/SwiftSoup", - "state" : { - "revision" : "0837db354faf9c9deb710dc597046edaadf5360f", - "version" : "2.7.6" - } - }, - { - "identity" : "tabman", - "kind" : "remoteSourceControl", - "location" : "https://github.com/uias/Tabman.git", - "state" : { - "revision" : "3b2213290eb93e55bb50b49d1a179033005c11ab", - "version" : "3.2.0" - } - }, - { - "identity" : "weakmaptable", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ReactorKit/WeakMapTable.git", - "state" : { - "revision" : "cb05d64cef2bbf51e85c53adee937df46540a74e", - "version" : "1.2.1" - } - } - ], - "version" : 3 -} diff --git a/Poppool/Poppool/Application/SceneDelegate.swift b/Poppool/Poppool/Application/SceneDelegate.swift index fdd369d8..21ff3ff1 100644 --- a/Poppool/Poppool/Application/SceneDelegate.swift +++ b/Poppool/Poppool/Application/SceneDelegate.swift @@ -6,14 +6,14 @@ // import UIKit - import RxKakaoSDKAuth import KakaoSDKAuth import RxSwift class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? + var window: UIWindow? + static let appDidBecomeActive = PublishSubject() static let appDidBecomeActive = PublishSubject() private let disposeBag = DisposeBag() @@ -25,28 +25,27 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window?.makeKeyAndVisible() } - func sceneDidDisconnect(_ scene: UIScene) { - } + func sceneDidDisconnect(_ scene: UIScene) { + } - func sceneDidBecomeActive(_ scene: UIScene) { - SceneDelegate.appDidBecomeActive.onNext(()) - } + func sceneDidBecomeActive(_ scene: UIScene) { + SceneDelegate.appDidBecomeActive.onNext(()) + } - func sceneWillResignActive(_ scene: UIScene) { - } + func sceneWillResignActive(_ scene: UIScene) { + } - func sceneWillEnterForeground(_ scene: UIScene) { - } + func sceneWillEnterForeground(_ scene: UIScene) { + } - func sceneDidEnterBackground(_ scene: UIScene) { - } + func sceneDidEnterBackground(_ scene: UIScene) { + } - func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - if let url = URLContexts.first?.url { - if AuthApi.isKakaoTalkLoginUrl(url) { - _ = AuthController.rx.handleOpenUrl(url: url) - } - } - } + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + if let url = URLContexts.first?.url { + if AuthApi.isKakaoTalkLoginUrl(url) { + _ = AuthController.rx.handleOpenUrl(url: url) + } + } + } } - diff --git a/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift b/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift index 5fb476de..9167bff3 100644 --- a/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift +++ b/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift @@ -75,7 +75,6 @@ class PreSignedService { return Disposables.create() } - // 순서를 유지하기 위한 매핑 구조 var imageMap: [String: UIImage] = [:] var uncachedFilePaths: [String] = [] diff --git a/Poppool/Poppool/Presentation/Admin/AdminBottomSheetView.swift b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetView.swift new file mode 100644 index 00000000..7fc6307b --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetView.swift @@ -0,0 +1,234 @@ +import UIKit +import SnapKit + +final class AdminBottomSheetView: UIView { + // MARK: - Properties + private var contentHeightConstraint: Constraint? + + // MARK: - Components + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = 20 + view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + view.layer.masksToBounds = true + return view + }() + + let headerView: UIView = { + let view = UIView() + view.backgroundColor = .white + return view + }() + + let titleLabel: PPLabel = { + let label = PPLabel(style: .bold, fontSize: 18, text: "보기 옵션을 선택해주세요") + label.textColor = .black + return label + }() + + let closeButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(UIImage(named: "icon_xmark"), for: .normal) + button.tintColor = .black + return button + }() + + let segmentedControl: PPSegmentedControl = { + let control = PPSegmentedControl(type: .tab, segments: ["상태값", "카테고리"], selectedSegmentIndex: 0) + return control + }() + + let contentCollectionView: UICollectionView = { + let layout = UICollectionViewCompositionalLayout { section, env in + let itemSize = NSCollectionLayoutSize( + widthDimension: .estimated(26), + heightDimension: .absolute(36) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(36) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: [item] + ) + group.interItemSpacing = .fixed(12) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = .init( + top: 20, + leading: 20, + bottom: 20, + trailing: 20 + ) + section.interGroupSpacing = 16 + + return section + } + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .clear + collectionView.isScrollEnabled = false + collectionView.register(TagSectionCell.self, forCellWithReuseIdentifier: TagSectionCell.identifiers) + return collectionView + }() + + let filterChipsView: FilterChipsView = { + let view = FilterChipsView() + return view + }() + + let resetButton: PPButton = { + let button = PPButton( + style: .secondary, + text: "초기화", + font: .KorFont(style: .medium, size: 16), + cornerRadius: 4 + ) + button.isEnabled = false + button.contentEdgeInsets = UIEdgeInsets(top: 9, left: 16, bottom: 9, right: 12) + return button + }() + + let saveButton: PPButton = { + let button = PPButton( + style: .primary, + text: "옵션저장", + disabledText: "옵션저장", + font: .KorFont(style: .medium, size: 16), + cornerRadius: 4 + ) + button.isEnabled = false + button.contentEdgeInsets = UIEdgeInsets(top: 9, left: 16, bottom: 9, right: 12) + return button + }() + + private let buttonStack: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 12 + stack.distribution = .fillEqually + return stack + }() + + // MARK: - Initialization + override init(frame: CGRect) { + super.init(frame: frame) + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + private func setupLayout() { + backgroundColor = .clear + addSubview(containerView) + + containerView.addSubview(headerView) + headerView.addSubview(titleLabel) + headerView.addSubview(closeButton) + + [segmentedControl, contentCollectionView, filterChipsView, buttonStack].forEach { + containerView.addSubview($0) + } + + buttonStack.addArrangedSubview(resetButton) + buttonStack.addArrangedSubview(saveButton) + + setupConstraints() + } + + private func setupConstraints() { + containerView.snp.makeConstraints { make in + make.left.right.bottom.equalToSuperview() + make.top.equalTo(headerView.snp.top) + } + + headerView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(60) + } + + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.centerY.equalToSuperview() + } + + closeButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(16) + make.centerY.equalToSuperview() + make.size.equalTo(24) + } + + segmentedControl.snp.makeConstraints { make in + make.top.equalTo(headerView.snp.bottom).offset(16) + make.leading.trailing.equalToSuperview() + } + + contentCollectionView.snp.makeConstraints { make in + make.top.equalTo(segmentedControl.snp.bottom).offset(16) + make.leading.trailing.equalToSuperview() + contentHeightConstraint = make.height.equalTo(160).constraint + } + + filterChipsView.snp.makeConstraints { make in + make.top.equalTo(contentCollectionView.snp.bottom).offset(24) + make.leading.trailing.equalToSuperview().inset(16) + } + + buttonStack.snp.makeConstraints { make in + make.top.equalTo(filterChipsView.snp.bottom).offset(24) + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalToSuperview().inset(40) + make.height.equalTo(52) + } + } + + // MARK: - Public Methods + func updateContentVisibility(isCategorySelected: Bool) { + let newHeight: CGFloat = isCategorySelected ? 200 : 160 + + UIView.animate(withDuration: 0.3) { + self.contentHeightConstraint?.update(offset: newHeight) + self.contentCollectionView.reloadData() + self.layoutIfNeeded() + } + } + + func update(statusText: String?, categoryText: String?) { + var filters: [String] = [] + + if let statusText = statusText, !statusText.isEmpty { + filters.append(statusText) + } + if let categoryText = categoryText, !categoryText.isEmpty { + filters.append(categoryText) + } + + filterChipsView.updateChips(with: filters) + + + // 버튼 활성화 상태 업데이트 + let hasFilters = !filters.isEmpty + resetButton.isEnabled = hasFilters + saveButton.isEnabled = hasFilters + } + + func updateCategoryButtonSelection(_ category: String) { + contentCollectionView.visibleCells.forEach { cell in + guard let tagCell = cell as? TagSectionCell else { return } + // input으로 상태 업데이트 + let input = TagSectionCell.Input( + title: category, + isSelected: !tagCell.contentView.backgroundColor!.isEqual(UIColor.blu500), + id: nil + ) + tagCell.injection(with: input) + } + } + } diff --git a/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift new file mode 100644 index 00000000..88cb3edb --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift @@ -0,0 +1,270 @@ +import UIKit +import SnapKit +import RxSwift +import RxCocoa + +final class AdminBottomSheetViewController: BaseViewController { + + // MARK: - Properties + private let mainView = AdminBottomSheetView() + private let dimmedView = UIView() + var disposeBag = DisposeBag() + + private var selectedStatusOptions: Set = [] + private var selectedCategoryOptions: Set = [] + private var tagSection: TagSection? + + var onSave: (([String]) -> Void)? + var onDismiss: (() -> Void)? + + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + setupViews() + setupCollectionView() + bind() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + showBottomSheet() + } + + // MARK: - Setup + private func setupViews() { + view.addSubview(dimmedView) + dimmedView.backgroundColor = .black.withAlphaComponent(0.4) + dimmedView.alpha = 0 + + dimmedView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dimmedViewTapped)) + dimmedView.addGestureRecognizer(tapGesture) + dimmedView.isUserInteractionEnabled = true + + view.addSubview(mainView) + mainView.snp.makeConstraints { make in + make.left.right.equalToSuperview() + make.height.equalTo(view.bounds.height * 0.45) // 높이 조정 + make.bottom.equalTo(view.snp.bottom) + } + +// let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture)) +// panGesture.delegate = self +// mainView.addGestureRecognizer(panGesture) + } + + private func setupCollectionView() { + mainView.contentCollectionView.dataSource = self + mainView.contentCollectionView.delegate = self + mainView.contentCollectionView.register(TagSectionCell.self, forCellWithReuseIdentifier: TagSectionCell.identifiers) + } + + private func bind() { + // Close Button + mainView.closeButton.rx.tap + .bind { [weak self] in + self?.hideBottomSheet() + } + .disposed(by: disposeBag) + + // Save Button + mainView.saveButton.rx.tap + .bind { [weak self] in + guard let self = self else { return } + let selectedOptions = self.mainView.segmentedControl.selectedSegmentIndex == 0 ? + Array(self.selectedStatusOptions) : Array(self.selectedCategoryOptions) + self.onSave?(selectedOptions) + self.hideBottomSheet() + } + .disposed(by: disposeBag) + + // Reset Button + mainView.resetButton.rx.tap + .bind { [weak self] in + self?.selectedStatusOptions.removeAll() + self?.selectedCategoryOptions.removeAll() + self?.updateButtonStates() + self?.updateCollectionView() + } + .disposed(by: disposeBag) + + // Segment Control + mainView.segmentedControl.rx.selectedSegmentIndex + .bind { [weak self] index in + self?.mainView.updateContentVisibility(isCategorySelected: index == 1) + self?.updateCollectionView() + } + .disposed(by: disposeBag) + } + + private func updateCollectionView() { + let isStatusTab = mainView.segmentedControl.selectedSegmentIndex == 0 + let items = isStatusTab ? + ["전체", "운영", "종료"] : + ["게임", "라이프스타일", "반려동물", "뷰티", "스포츠", "애니메이션", + "엔터테이먼트", "여행", "예술", "음식/요리", "키즈", "패션"] + + let selectedItems = isStatusTab ? selectedStatusOptions : selectedCategoryOptions + + tagSection = TagSection(inputDataList: items.map { + TagSectionCell.Input( + title: $0, + isSelected: selectedItems.contains($0), + id: nil + ) + }) + + mainView.contentCollectionView.reloadData() + mainView.filterChipsView.updateChips(with: Array(selectedItems)) + + } + private func toggleStatusOption(_ option: String) { + if selectedStatusOptions.contains(option) { + selectedStatusOptions.remove(option) + } else { + if option == "전체" { + selectedStatusOptions = ["전체"] + } else { + selectedStatusOptions.remove("전체") + selectedStatusOptions.insert(option) + } + } + } + + private func toggleCategoryOption(_ option: String) { + if selectedCategoryOptions.contains(option) { + selectedCategoryOptions.remove(option) + } else { + selectedCategoryOptions.insert(option) + } + } + private func updateButtonStates() { + let hasSelectedOptions = !selectedStatusOptions.isEmpty || !selectedCategoryOptions.isEmpty + mainView.saveButton.isEnabled = hasSelectedOptions + mainView.resetButton.isEnabled = hasSelectedOptions + + if hasSelectedOptions { + mainView.saveButton.backgroundColor = .blu500 + mainView.saveButton.setTitleColor(.white, for: .normal) + } else { + mainView.saveButton.backgroundColor = .g100 + mainView.saveButton.setTitleColor(.g400, for: .disabled) + } + let selectedOptions = mainView.segmentedControl.selectedSegmentIndex == 0 ? + Array(selectedStatusOptions) : Array(selectedCategoryOptions) + mainView.filterChipsView.updateChips(with: selectedOptions) + + } + + +// private func updateButtonStates() { +// let hasSelectedOptions = !selectedStatusOptions.isEmpty || !selectedCategoryOptions.isEmpty +// mainView.updateButtonStates(isEnabled: hasSelectedOptions) +// } + + // MARK: - Gestures + @objc private func dimmedViewTapped() { + hideBottomSheet() + } + + @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: view) + + switch gesture.state { + case .changed: + guard translation.y >= 0 else { return } + mainView.transform = CGAffineTransform(translationX: 0, y: translation.y) + dimmedView.alpha = 1 - (translation.y / 500) + + case .ended: + let velocity = gesture.velocity(in: view) + if translation.y > 150 || velocity.y > 1000 { + hideBottomSheet() + } else { + UIView.animate(withDuration: 0.25) { + self.mainView.transform = .identity + self.dimmedView.alpha = 1 + } + } + + default: + break + } + } + + // MARK: - Animation Methods + func showBottomSheet() { + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) { + self.dimmedView.alpha = 1 + self.view.layoutIfNeeded() + } + } + + func hideBottomSheet() { + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn) { + self.dimmedView.alpha = 0 + self.mainView.transform = CGAffineTransform(translationX: 0, y: self.view.bounds.height) + } completion: { _ in + self.dismiss(animated: false) + self.onDismiss?() + } + } +} +extension AdminBottomSheetViewController: UICollectionViewDataSource { + func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return tagSection?.inputDataList.count ?? 0 + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: TagSectionCell.identifiers, + for: indexPath + ) as? TagSectionCell else { + return UICollectionViewCell() + } + + if let input = tagSection?.inputDataList[indexPath.item] { + cell.injection(with: input) + } + + return cell + } +} + +// MARK: - UICollectionViewDelegate +extension AdminBottomSheetViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let title = tagSection?.inputDataList[indexPath.item].title else { return } + + if mainView.segmentedControl.selectedSegmentIndex == 0 { + toggleStatusOption(title) + } else { + toggleCategoryOption(title) + } + + updateCollectionView() + updateButtonStates() + } +} +extension AdminBottomSheetViewController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + // 세그먼트 컨트롤이나 컬렉션뷰를 터치했을 때는 pan gesture 무시 + if let touchView = touch.view { + if touchView == mainView.segmentedControl || + touchView.isDescendant(of: mainView.segmentedControl) || + touchView == mainView.contentCollectionView || + touchView.isDescendant(of: mainView.contentCollectionView) { + return false + } + } + return true + } +} diff --git a/Poppool/Poppool/Presentation/Admin/AdminReactor.swift b/Poppool/Poppool/Presentation/Admin/AdminReactor.swift new file mode 100644 index 00000000..31093f54 --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/AdminReactor.swift @@ -0,0 +1,86 @@ +import ReactorKit +import RxSwift +import RxCocoa + +final class AdminReactor: Reactor { + + enum Action { + case viewDidLoad + case updateSearchQuery(String) + case tapRegisterButton + case tapEditButton(Int64) + + // 화면 이동 후 상태를 초기화하기 위한 액션 + case resetNavigation + } + + enum Mutation { + case setStores([GetAdminPopUpStoreListResponseDTO.PopUpStore]) + case setIsLoading(Bool) + case navigateToRegister(Bool) + } + + struct State { + var storeList: [GetAdminPopUpStoreListResponseDTO.PopUpStore] = [] + var isLoading: Bool = false + + // true가 되면 등록 화면 이동 + var shouldNavigateToRegister: Bool = false + } + + var initialState: State + var disposeBag = DisposeBag() + private let useCase: AdminUseCase + + init(useCase: AdminUseCase) { + self.useCase = useCase + self.initialState = State() + } + + func mutate(action: Action) -> Observable { + switch action { + case .viewDidLoad: + return .concat([ + .just(.setIsLoading(true)), + useCase.fetchStoreList(query: nil, page: 0, size: 20) + .map { .setStores($0.popUpStoreList) }, + .just(.setIsLoading(false)) + ]) + + case let .updateSearchQuery(query): + return .concat([ + .just(.setIsLoading(true)), + useCase.fetchStoreList(query: query, page: 0, size: 20) + .map { .setStores($0.popUpStoreList) }, + .just(.setIsLoading(false)) + ]) + + case .tapRegisterButton: + // 여기서 State.shouldNavigateToRegister = true 로 변경 + return .just(.navigateToRegister(true)) + + case .tapEditButton(_): + // 편집 화면 이동 등 다른 로직이 있을 수 있으나, 여기서는 생략 + return .empty() + + case .resetNavigation: + // 화면 이동 후 다시 false로 + return .just(.navigateToRegister(false)) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + case let .setStores(stores): + newState.storeList = stores + + case let .setIsLoading(isLoading): + newState.isLoading = isLoading + + case let .navigateToRegister(shouldGo): + newState.shouldNavigateToRegister = shouldGo + } + return newState + } +} diff --git a/Poppool/Poppool/Presentation/Admin/AdminStoreCell.swift b/Poppool/Poppool/Presentation/Admin/AdminStoreCell.swift new file mode 100644 index 00000000..874a45b1 --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/AdminStoreCell.swift @@ -0,0 +1,82 @@ +import UIKit +import SnapKit +import RxSwift +final class AdminStoreCell: UITableViewCell { + + // MARK: - Identifier + static let identifier = "AdminStoreCell" + + // MARK: - Components + private let storeImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.layer.cornerRadius = 4 + $0.clipsToBounds = true + } + + private let titleLabel = UILabel().then { + $0.font = UIFont.boldSystemFont(ofSize: 16) + $0.textColor = .black + } + + private let categoryLabel = UILabel().then { + $0.font = UIFont.systemFont(ofSize: 12) + $0.textColor = .gray + } + + private let statusChip = UILabel().then { + $0.font = UIFont.systemFont(ofSize: 12) + $0.textColor = .white + $0.backgroundColor = .systemBlue + $0.textAlignment = .center + $0.layer.cornerRadius = 12 + $0.clipsToBounds = true + } + + // MARK: - Init + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Layout + private func setupLayout() { + contentView.addSubview(storeImageView) + contentView.addSubview(titleLabel) + contentView.addSubview(categoryLabel) + contentView.addSubview(statusChip) + + storeImageView.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview().inset(8) + make.width.height.equalTo(80) + } + + titleLabel.snp.makeConstraints { make in + make.top.equalTo(storeImageView) + make.leading.equalTo(storeImageView.snp.trailing).offset(8) + make.trailing.equalToSuperview().inset(8) + } + + categoryLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(4) + make.leading.equalTo(titleLabel) + } + + statusChip.snp.makeConstraints { make in + make.top.equalTo(categoryLabel.snp.bottom).offset(8) + make.leading.equalTo(titleLabel) + make.width.equalTo(60) + make.height.equalTo(24) + } + } + + // MARK: - Configure + func configure(with store: GetAdminPopUpStoreListResponseDTO.PopUpStore) { + titleLabel.text = store.name + categoryLabel.text = store.categoryName + statusChip.text = "운영" // 상태에 따라 동적 변경 + } +} diff --git a/Poppool/Poppool/Presentation/Admin/AdminView.swift b/Poppool/Poppool/Presentation/Admin/AdminView.swift new file mode 100644 index 00000000..ff2fb01a --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/AdminView.swift @@ -0,0 +1,193 @@ +import UIKit +import SnapKit +import Then + +final class AdminView: UIView { + + // MARK: - Components + let navigationView = UIView() + let logoImageView = UIImageView().then { + $0.image = UIImage(named: "image_login_logo") + $0.contentMode = .scaleAspectFit + } + + let separatorView = UIView().then { + $0.backgroundColor = UIColor.g50 + } + + let usernameLabel = PPLabel( + style: .bold, + fontSize: 14, + text: "김채연님" + ) + + let menuButton = UIButton(type: .system).then { + $0.setImage(UIImage(named: "adminlist"), for: .normal) + $0.tintColor = .black + } + + let titleLabel = PPLabel( + style: .bold, + fontSize: 20, + text: "팝업스토어 관리" + ) + + let searchInput = UITextField().then { + $0.placeholder = "팝업스토어명을 입력해보세요" + $0.borderStyle = .none + $0.font = UIFont.systemFont(ofSize: 14, weight: .regular) + $0.textColor = .g400 + $0.backgroundColor = UIColor.g50 + $0.layer.cornerRadius = 8 + $0.leftView = { + let imageView = UIImageView(image: UIImage(named: "icon_search_gray")) + imageView.contentMode = .scaleAspectFit + imageView.frame = CGRect(x: 0, y: 0, width: 20, height: 20) + let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 36, height: 20)) + paddingView.addSubview(imageView) + imageView.center = paddingView.center + return paddingView + }() + $0.leftViewMode = .always + $0.clearButtonMode = .whileEditing + } + + let registerButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("등록", for: .normal) + button.setTitleColor(UIColor.systemBlue, for: .normal) + button.titleLabel?.font = UIFont.systemFont(ofSize: 13, weight: .regular) + button.layer.borderWidth = 1 + button.layer.borderColor = UIColor.systemBlue.cgColor + button.backgroundColor = .white + button.layer.cornerRadius = 6 + button.clipsToBounds = true + return button + }() + + let filterContainer = UIView() + let dropdownButton = UIButton(type: .system).then { + $0.setTitle("전체", for: .normal) + $0.setTitleColor(.g900, for: .normal) + $0.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .regular) + let dropdownIcon = UIImageView(image: UIImage(named: "icon_dropdown")) + dropdownIcon.contentMode = .scaleAspectFit + $0.addSubview(dropdownIcon) + dropdownIcon.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(8) + make.width.height.equalTo(22) + } + } + + let popupCountLabel = UILabel().then { + $0.text = "총 52건" + $0.textColor = .lightGray + $0.font = UIFont.systemFont(ofSize: 14) + } + + let tableView = UITableView().then { + $0.separatorStyle = .none + $0.backgroundColor = .clear + } + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayout() { + [navigationView, separatorView, titleLabel, searchInput, registerButton, filterContainer, tableView].forEach { + addSubview($0) + } + + [logoImageView, usernameLabel, menuButton].forEach { + navigationView.addSubview($0) + } + + [dropdownButton, popupCountLabel].forEach { + filterContainer.addSubview($0) + } + + navigationView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.leading.trailing.equalToSuperview() + make.height.equalTo(44) + } + + separatorView.snp.makeConstraints { make in + make.top.equalTo(navigationView.snp.bottom).offset(22) + make.leading.trailing.equalToSuperview() + make.height.equalTo(3) + } + + logoImageView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(28) + make.centerY.equalToSuperview() + make.width.equalTo(22) + make.height.equalTo(28) + } + + usernameLabel.snp.makeConstraints { make in + make.leading.equalTo(logoImageView.snp.trailing).offset(8) + make.centerY.equalToSuperview() + } + + menuButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(16) + make.centerY.equalToSuperview() + } + + titleLabel.snp.makeConstraints { make in + make.top.equalTo(separatorView.snp.bottom).offset(28) + make.leading.equalToSuperview().offset(16) + } + + registerButton.snp.makeConstraints { make in + make.centerY.equalTo(titleLabel) + make.trailing.equalToSuperview().inset(16) + make.width.equalTo(65) + make.height.equalTo(37) + } + + searchInput.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(28) + make.leading.equalToSuperview().offset(16) + make.trailing.equalToSuperview().inset(16) + make.height.equalTo(37) + } + + filterContainer.snp.makeConstraints { make in + make.top.equalTo(searchInput.snp.bottom).offset(16) + make.leading.trailing.equalToSuperview() + make.height.equalTo(32) + } + + dropdownButton.snp.makeConstraints { make in +// make.leading.equalToSuperview().offset(16) + make.trailing.equalTo(searchInput) + make.width.equalTo(80) + make.height.equalTo(32) + } + + popupCountLabel.snp.makeConstraints { make in + make.centerY.equalTo(dropdownButton) + make.leading.equalTo(searchInput) + } + + tableView.snp.makeConstraints { make in + make.top.equalTo(filterContainer.snp.bottom).offset(16) + make.leading.trailing.bottom.equalToSuperview() + } + } +} +extension AdminView { + func updateFilterOption(_ option: String) { + dropdownButton.setTitle(option, for: .normal) + } +} diff --git a/Poppool/Poppool/Presentation/Admin/AdminViewController.swift b/Poppool/Poppool/Presentation/Admin/AdminViewController.swift new file mode 100644 index 00000000..f05f211b --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/AdminViewController.swift @@ -0,0 +1,96 @@ +import UIKit +import ReactorKit +import RxSwift +import RxCocoa + +final class AdminViewController: BaseViewController, View { + + typealias Reactor = AdminReactor + + // MARK: - Properties + var disposeBag = DisposeBag() + private var mainView = AdminView() + private var adminBottomSheetVC: AdminBottomSheetViewController? + private var selectedFilterOption: String = "전체" + +} + +// MARK: - Life Cycle +extension AdminViewController { + override func viewDidLoad() { + super.viewDidLoad() + setUp() + } +} + +// MARK: - SetUp +private extension AdminViewController { + func setUp() { + view.addSubview(mainView) + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + navigationItem.title = "팝업스토어 관리" + + mainView.dropdownButton.addTarget(self, action: #selector(didTapDropdownButton), for: .touchUpInside) + } + + @objc private func didTapDropdownButton() { + let bottomSheetVC = AdminBottomSheetViewController() + + bottomSheetVC.onSave = { [weak self] selectedOptions in + guard let self = self else { return } + self.selectedFilterOption = selectedOptions.joined(separator: ", ") + self.mainView.dropdownButton.setTitle(self.selectedFilterOption, for: .normal) + } + + // BottomSheet 스타일 적용 + bottomSheetVC.modalPresentationStyle = .custom + + present(bottomSheetVC, animated: true) + self.adminBottomSheetVC = bottomSheetVC + } +} +// MARK: - ReactorKit Bindings +extension AdminViewController { + func bind(reactor: Reactor) { + + // 1) 검색어 입력 -> updateSearchQuery + mainView.searchInput.rx.text.orEmpty + .distinctUntilChanged() + .debounce(.milliseconds(300), scheduler: MainScheduler.instance) + .map { Reactor.Action.updateSearchQuery($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + // 2) 등록 버튼 탭 -> tapRegisterButton + mainView.registerButton.rx.tap + .map { Reactor.Action.tapRegisterButton } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + // 3) 테이블 바인딩 + reactor.state.map { $0.storeList } + .bind(to: mainView.tableView.rx.items( + cellIdentifier: AdminStoreCell.identifier, + cellType: AdminStoreCell.self + )) { _, item, cell in + cell.configure(with: item) + } + .disposed(by: disposeBag) + + // 4) shouldNavigateToRegister == true -> 등록화면 이동 + reactor.state.map { $0.shouldNavigateToRegister } + .distinctUntilChanged() + .filter { $0 == true } + .subscribe(onNext: { [weak self] _ in + guard let self = self else { return } + let registerVC = PopUpStoreRegisterViewController() + self.navigationController?.pushViewController(registerVC, animated: true) + + // 이동 직후, 다시 false로 + reactor.action.onNext(.resetNavigation) + }) + .disposed(by: disposeBag) + } +} diff --git a/Poppool/Poppool/Presentation/Admin/Common/DateTimePickerManager.swift b/Poppool/Poppool/Presentation/Admin/Common/DateTimePickerManager.swift new file mode 100644 index 00000000..047bea3a --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/Common/DateTimePickerManager.swift @@ -0,0 +1,131 @@ +import UIKit +import SnapKit + +/// 날짜/시간 피커 로직을 분리한 Manager +final class DateTimePickerManager { + + static let shared = DateTimePickerManager() + + private init() { } + + // MARK: - Date Range Picker (시작일/종료일) + func showDateRange( + on viewController: UIViewController, + completion: @escaping (Date, Date) -> Void + ) { + // 1) 시작일 + let alert1 = UIAlertController(title: "시작일 선택", message: nil, preferredStyle: .actionSheet) + let dp1 = UIDatePicker() + dp1.datePickerMode = .date + dp1.preferredDatePickerStyle = .wheels + + alert1.view.addSubview(dp1) + dp1.snp.makeConstraints { make in + make.top.left.right.equalToSuperview().inset(8) + make.height.equalTo(150) + } + + alert1.addAction(UIAlertAction(title: "확인", style: .default, handler: { _ in + let startDate = dp1.date + // 2) 종료일 + self.showEndDatePicker(on: viewController, startDate: startDate, completion: completion) + })) + alert1.addAction(UIAlertAction(title: "취소", style: .cancel)) + + alert1.view.snp.makeConstraints { make in + make.height.equalTo(300) + } + + viewController.present(alert1, animated: true) + } + + private func showEndDatePicker( + on viewController: UIViewController, + startDate: Date, + completion: @escaping (Date, Date) -> Void + ) { + let alert2 = UIAlertController(title: "종료일 선택", message: nil, preferredStyle: .actionSheet) + let dp2 = UIDatePicker() + dp2.datePickerMode = .date + dp2.preferredDatePickerStyle = .wheels + dp2.minimumDate = startDate + + alert2.view.addSubview(dp2) + dp2.snp.makeConstraints { make in + make.top.left.right.equalToSuperview().inset(8) + make.height.equalTo(150) + } + + alert2.addAction(UIAlertAction(title: "확인", style: .default, handler: { _ in + let endDate = dp2.date + completion(startDate, endDate) + })) + alert2.addAction(UIAlertAction(title: "취소", style: .cancel)) + + alert2.view.snp.makeConstraints { make in + make.height.equalTo(300) + } + + viewController.present(alert2, animated: true) + } + + // MARK: - Time Range Picker (시작시간/종료시간) + func showTimeRange( + on viewController: UIViewController, + completion: @escaping (Date, Date) -> Void + ) { + // 시작시간 + let alert1 = UIAlertController(title: "시작시간 선택", message: nil, preferredStyle: .actionSheet) + let dp1 = UIDatePicker() + dp1.datePickerMode = .time + dp1.preferredDatePickerStyle = .wheels + + alert1.view.addSubview(dp1) + dp1.snp.makeConstraints { make in + make.top.left.right.equalToSuperview().inset(8) + make.height.equalTo(150) + } + + alert1.addAction(UIAlertAction(title: "확인", style: .default, handler: { _ in + let startTime = dp1.date + // 종료시간 + self.showEndTimePicker(on: viewController, startTime: startTime, completion: completion) + })) + alert1.addAction(UIAlertAction(title: "취소", style: .cancel)) + + alert1.view.snp.makeConstraints { make in + make.height.equalTo(300) + } + + viewController.present(alert1, animated: true) + } + + private func showEndTimePicker( + on viewController: UIViewController, + startTime: Date, + completion: @escaping (Date, Date) -> Void + ) { + let alert2 = UIAlertController(title: "종료시간 선택", message: nil, preferredStyle: .actionSheet) + let dp2 = UIDatePicker() + dp2.datePickerMode = .time + dp2.preferredDatePickerStyle = .wheels + + alert2.view.addSubview(dp2) + dp2.snp.makeConstraints { make in + make.top.left.right.equalToSuperview().inset(8) + make.height.equalTo(150) + } + + alert2.addAction(UIAlertAction(title: "확인", style: .default, handler: { _ in + let endTime = dp2.date + completion(startTime, endTime) + })) + alert2.addAction(UIAlertAction(title: "취소", style: .cancel)) + + alert2.view.snp.makeConstraints { make in + make.height.equalTo(300) + } + + viewController.present(alert2, animated: true) + } +} diff --git a/Poppool/Poppool/Presentation/Admin/Data/DTO/AdminResponseDTO.swift b/Poppool/Poppool/Presentation/Admin/Data/DTO/AdminResponseDTO.swift new file mode 100644 index 00000000..c830954b --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/Data/DTO/AdminResponseDTO.swift @@ -0,0 +1,44 @@ +import Foundation + +// MARK: - Store List Response +struct GetAdminPopUpStoreListResponseDTO: Decodable { + let popUpStoreList: [PopUpStore] + let totalPages: Int + let totalElements: Int + + struct PopUpStore: Decodable { + let id: Int64 + let name: String + let categoryName: String + let mainImageUrl: String + } +} + +// MARK: - Store Detail Response +struct GetAdminPopUpStoreDetailResponseDTO: Decodable { + let id: Int64 + let name: String + let categoryId: Int64 + let categoryName: String + let desc: String + let address: String + let startDate: String + let endDate: String + let createUserId: String + let createDateTime: String + let mainImageUrl: String + let bannerYn: Bool + let imageList: [Image] + let latitude: Double + let longitude: Double + let markerTitle: String + let markerSnippet: String + + struct Image: Decodable { + let id: Int64 + let imageUrl: String + } +} + +// MARK: - Empty Response +struct EmptyResponse: Decodable {} diff --git a/Poppool/Poppool/Presentation/Admin/Data/DTO/GetAdminPopUpStoreListResponseDTO.swift b/Poppool/Poppool/Presentation/Admin/Data/DTO/GetAdminPopUpStoreListResponseDTO.swift new file mode 100644 index 00000000..3dbbe25e --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/Data/DTO/GetAdminPopUpStoreListResponseDTO.swift @@ -0,0 +1,76 @@ + +import Foundation + +// MARK: - Store List Request +struct StoreListRequestDTO: Encodable { + let query: String? + let page: Int + let size: Int + + enum CodingKeys: String, CodingKey { + case query + case page = "pageable.page" + case size = "pageable.size" + } +} + +// MARK: - Create Store Request +struct CreatePopUpStoreRequestDTO: Encodable { + let name: String + let categoryId: Int64 + let desc: String + let address: String + let startDate: String + let endDate: String + let mainImageUrl: String + let bannerYn: Bool + let imageUrlList: [String] + let latitude: Double + let longitude: Double + let markerTitle: String + let markerSnippet: String + let startDateBeforeEndDate: Bool +} + +// MARK: - Update Store Request +struct UpdatePopUpStoreRequestDTO: Encodable { + let popUpStore: PopUpStore + let location: Location + let imagesToAdd: [String] + let imagesToDelete: [Int64] + + struct PopUpStore: Encodable { + let id: Int64 + let name: String + let categoryId: Int64 + let desc: String + let address: String + let startDate: String + let endDate: String + let mainImageUrl: String + let bannerYn: Bool + let imageUrl: [String] + let startDateBeforeEndDate: Bool + } + + struct Location: Encodable { + let latitude: Double + let longitude: Double + let markerTitle: String + let markerSnippet: String + } +} + +// MARK: - Notice Request +struct CreateNoticeRequestDTO: Encodable { + let title: String + let content: String + let imageUrlList: [String] +} + +struct UpdateNoticeRequestDTO: Encodable { + let title: String + let content: String + let imageUrlList: [String] + let imagesToDelete: [Int64] +} diff --git a/Poppool/Poppool/Presentation/Admin/Data/Remote/AdminAPIEndpoint.swift b/Poppool/Poppool/Presentation/Admin/Data/Remote/AdminAPIEndpoint.swift new file mode 100644 index 00000000..cfb9baf3 --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/Data/Remote/AdminAPIEndpoint.swift @@ -0,0 +1,105 @@ +import Foundation + +struct AdminAPIEndpoint { + + // MARK: - Store List + static func fetchStoreList( + query: String?, + page: Int, + size: Int + ) -> Endpoint { + let params = StoreListRequestDTO( + query: query, + page: page, + size: size + ) + return Endpoint( + baseURL: Secrets.popPoolBaseUrl.rawValue, + path: "/admin/popup-stores/list", + method: .get, + queryParameters: params + ) + } + + // MARK: - Store Detail + static func fetchStoreDetail( + id: Int64 + ) -> Endpoint { + return Endpoint( + baseURL: Secrets.popPoolBaseUrl.rawValue, + path: "/admin/popup-stores", + method: .get, + queryParameters: ["popUpStoreId": id] + ) + } + + // MARK: - Create Store + static func createStore( + request: CreatePopUpStoreRequestDTO + ) -> Endpoint { + return Endpoint( + baseURL: Secrets.popPoolBaseUrl.rawValue, + path: "/admin/popup-stores", + method: .post, + bodyParameters: request + ) + } + + // MARK: - Update Store + static func updateStore( + request: UpdatePopUpStoreRequestDTO + ) -> Endpoint { + return Endpoint( + baseURL: Secrets.popPoolBaseUrl.rawValue, + path: "/admin/popup-stores", + method: .put, + bodyParameters: request + ) + } + + // MARK: - Delete Store + static func deleteStore( + id: Int64 + ) -> Endpoint { + return Endpoint( + baseURL: Secrets.popPoolBaseUrl.rawValue, + path: "/admin/popup-stores", + method: .delete, + queryParameters: ["popUpStoreId": id] + ) + } + + // MARK: - Notice + static func createNotice( + request: CreateNoticeRequestDTO + ) -> Endpoint { + return Endpoint( + baseURL: Secrets.popPoolBaseUrl.rawValue, + path: "/admin/notice", + method: .post, + bodyParameters: request + ) + } + + static func updateNotice( + id: Int64, + request: UpdateNoticeRequestDTO + ) -> Endpoint { + return Endpoint( + baseURL: Secrets.popPoolBaseUrl.rawValue, + path: "/admin/notice/\(id)", + method: .put, + bodyParameters: request + ) + } + + static func deleteNotice( + id: Int64 + ) -> Endpoint { + return Endpoint( + baseURL: Secrets.popPoolBaseUrl.rawValue, + path: "/admin/notice/\(id)", + method: .delete + ) + } +} diff --git a/Poppool/Poppool/Presentation/Admin/Data/Repository/AdminRepository.swift b/Poppool/Poppool/Presentation/Admin/Data/Repository/AdminRepository.swift new file mode 100644 index 00000000..3852b068 --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/Data/Repository/AdminRepository.swift @@ -0,0 +1,96 @@ +import Foundation +import RxSwift + +protocol AdminRepository { + func fetchStoreList(query: String?, page: Int, size: Int) -> Observable + func fetchStoreDetail(id: Int64) -> Observable + func createStore(request: CreatePopUpStoreRequestDTO) -> Observable + func updateStore(request: UpdatePopUpStoreRequestDTO) -> Observable + func deleteStore(id: Int64) -> Observable + + func createNotice(request: CreateNoticeRequestDTO) -> Observable + func updateNotice(id: Int64, request: UpdateNoticeRequestDTO) -> Observable + func deleteNotice(id: Int64) -> Observable +} + +final class DefaultAdminRepository: AdminRepository { + + // MARK: - Properties + private let provider: Provider + private let tokenInterceptor = TokenInterceptor() + + // MARK: - Init + init(provider: Provider) { + self.provider = provider + } + + // MARK: - Store Methods + func fetchStoreList(query: String?, page: Int, size: Int) -> Observable { + let endpoint = AdminAPIEndpoint.fetchStoreList( + query: query, + page: page, + size: size + ) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } + + func fetchStoreDetail(id: Int64) -> Observable { + let endpoint = AdminAPIEndpoint.fetchStoreDetail(id: id) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } + + func createStore(request: CreatePopUpStoreRequestDTO) -> Observable { + let endpoint = AdminAPIEndpoint.createStore(request: request) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } + + func updateStore(request: UpdatePopUpStoreRequestDTO) -> Observable { + let endpoint = AdminAPIEndpoint.updateStore(request: request) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } + + func deleteStore(id: Int64) -> Observable { + let endpoint = AdminAPIEndpoint.deleteStore(id: id) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } + + // MARK: - Notice Methods + func createNotice(request: CreateNoticeRequestDTO) -> Observable { + let endpoint = AdminAPIEndpoint.createNotice(request: request) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } + + func updateNotice(id: Int64, request: UpdateNoticeRequestDTO) -> Observable { + let endpoint = AdminAPIEndpoint.updateNotice(id: id, request: request) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } + + func deleteNotice(id: Int64) -> Observable { + let endpoint = AdminAPIEndpoint.deleteNotice(id: id) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } +} diff --git a/Poppool/Poppool/Presentation/Admin/Domain/AdminUseCase.swift b/Poppool/Poppool/Presentation/Admin/Domain/AdminUseCase.swift new file mode 100644 index 00000000..3605faa8 --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/Domain/AdminUseCase.swift @@ -0,0 +1,57 @@ +import Foundation +import RxSwift + +protocol AdminUseCase { + func fetchStoreList(query: String?, page: Int, size: Int) -> Observable + func fetchStoreDetail(id: Int64) -> Observable + func createStore(request: CreatePopUpStoreRequestDTO) -> Observable + func updateStore(request: UpdatePopUpStoreRequestDTO) -> Observable + func deleteStore(id: Int64) -> Observable + + // Notice + func createNotice(request: CreateNoticeRequestDTO) -> Observable + func updateNotice(id: Int64, request: UpdateNoticeRequestDTO) -> Observable + func deleteNotice(id: Int64) -> Observable +} + +final class DefaultAdminUseCase: AdminUseCase { + + private let repository: AdminRepository + + init(repository: AdminRepository) { + self.repository = repository + } + + func fetchStoreList(query: String?, page: Int, size: Int) -> Observable { + return repository.fetchStoreList(query: query, page: page, size: size) + } + + func fetchStoreDetail(id: Int64) -> Observable { + return repository.fetchStoreDetail(id: id) + } + + func createStore(request: CreatePopUpStoreRequestDTO) -> Observable { + return repository.createStore(request: request) + } + + func updateStore(request: UpdatePopUpStoreRequestDTO) -> Observable { + return repository.updateStore(request: request) + } + + func deleteStore(id: Int64) -> Observable { + return repository.deleteStore(id: id) + } + + // Notice + func createNotice(request: CreateNoticeRequestDTO) -> Observable { + return repository.createNotice(request: request) + } + + func updateNotice(id: Int64, request: UpdateNoticeRequestDTO) -> Observable { + return repository.updateNotice(id: id, request: request) + } + + func deleteNotice(id: Int64) -> Observable { + return repository.deleteNotice(id: id) + } +} diff --git a/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterReactor.swift b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterReactor.swift new file mode 100644 index 00000000..08dc3e4d --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterReactor.swift @@ -0,0 +1,286 @@ + + +import ReactorKit +import RxSwift +import Foundation +import UIKit + +/// 팝업스토어 등록/수정/삭제 화면의 Reactor +final class PopUpStoreRegisterReactor: Reactor { + + // MARK: - Action + enum Action { + // 기본 UI 이벤트 + case tapBack + case tapMore // "더보기" 버튼 → BottomSheet + case tapDelete // BottomSheet "삭제하기" 터치 + case tapSave // 저장 버튼 + + // 필드 입력 + case updateName(String) + case updateCategory(String) + case updateAddress(String) + case updateLatitude(String) + case updateLongitude(String) + case updateMarkerName(String) + case updateMarkerSnippet(String) + case updateDescription(String) + + // 이미지 관련 (간단 예시) + case addImage // 새 이미지 추가 + case removeImage(Int) // 인덱스로 삭제 + case removeAllImages + case checkRepresentativeImage(Int) // 대표이미지 체크 + + // 날짜/시간 Picker 완료 + case pickedPeriod(Date, Date) + case pickedTime(Date, Date) + } + + // MARK: - Mutation + enum Mutation { + case setBack // 뒤로가기 + case setShowMore // 더보기 시트 표시 + case setDelete // 삭제 실행 + case setSaved // 저장 완료 + + // 필드 업데이트 + case setName(String) + case setCategory(String) + case setAddress(String) + case setLatitude(String) + case setLongitude(String) + case setMarkerName(String) + case setMarkerSnippet(String) + case setDescription(String) + + // 이미지 업데이트 + case addImage(UIImage) + case removeImage(Int) + case removeAllImages + case checkRepresentativeImage(Int) + + // 날짜/시간 + case setPeriod(Date, Date) + case setTime(Date, Date) + } + + // MARK: - State + struct State { + // (0) 계정ID → View단에서 그냥 표시 + // (3) 팝업스토어 이미지 (대표이미지) + var selectedImages: [UIImage] = [] // 간단히 UIImage 배열로 예시 + var repImageIndex: Int? = nil // 대표이미지 인덱스 (3-1) + + // (4) 이름 + var name: String = "" + // (5) 이미지 >= 1 필수 + // (6) 카테고리 + var category: String = "게임" // 디폴트 + // (7) 위치 + var address: String = "" + // (7-1) 위도/경도 + var latitude: String = "" + var longitude: String = "" + // (8) 마커명 + var markerName: String = "" + // (9) 스니펫 + var markerSnippet: String = "" + // (10) 기간 + var startDate: Date? + var endDate: Date? + // (11) 시간 + var startTime: Date? + var endTime: Date? + // (12) 작성자 + var writerId: String = "김채연님" // 수정 시 변경될 수도 + // (13) 작성시간 + var writtenTime: String = "" // "2025-01-08 12:30" + // (14) 상태값 + var status: String = "진행" // chip + // (15) 설명 + var desc: String = "" + // (16) 저장버튼 활성/비활성 + var canSave: Bool = false + + // 플래그/이벤트 + var showMoreSheet: Bool = false // 2-1. 더보기 시트 + var needToPop: Bool = false // setBack + var didDelete: Bool = false // 삭제 완료 → 토스트 + var didSave: Bool = false // 저장 완료 → 토스트 + } + + // MARK: - Properties + let initialState: State + + private let useCase: AdminUseCase // or something + private let popUpStoreId: Int64? // nil이면 "등록", 값 있으면 "수정" + + // MARK: - Init + init(useCase: AdminUseCase, popUpStoreId: Int64? = nil) { + self.useCase = useCase + self.popUpStoreId = popUpStoreId + // 수정 모드면 useCase.fetchStoreDetail(...) 해서 initialState 만들어도 됨 + self.initialState = State(writerId: "김채연님", writtenTime: "2025-01-08 14:30") + } + + // MARK: - mutate + func mutate(action: Action) -> Observable { + switch action { + + case .tapBack: + return .just(.setBack) + + case .tapMore: + return .just(.setShowMore) + + case .tapDelete: + // 실제 useCase.deleteStore(...) 후 성공 시 Mutation.setDelete + // 간단 예시 + return .just(.setDelete) + + case .tapSave: + // 필수값 체크 → [이미지≥1, 이름, 카테고리, 주소, desc 등] + let st = currentState + guard st.selectedImages.count >= 1, + !st.name.isEmpty, + !st.address.isEmpty, + !st.desc.isEmpty + else { + // 유효성 실패 → 저장불가 + return .empty() + } + // 실제 UseCase.createStore or updateStore + // ... + return .just(.setSaved) + + case let .updateName(name): + return .just(.setName(name)) + + case let .updateCategory(cat): + return .just(.setCategory(cat)) + + case let .updateAddress(addr): + return .just(.setAddress(addr)) + + case let .updateLatitude(lat): + return .just(.setLatitude(lat)) + + case let .updateLongitude(lon): + return .just(.setLongitude(lon)) + + case let .updateMarkerName(mn): + return .just(.setMarkerName(mn)) + + case let .updateMarkerSnippet(ms): + return .just(.setMarkerSnippet(ms)) + + case let .updateDescription(d): + return .just(.setDescription(d)) + + // 이미지 + case .addImage: + // 임시로 UIImage(named: "dummy") 추가 + if let dummy = UIImage(named: "dummyImage") { + return .just(.addImage(dummy)) + } else { + return .empty() + } + + case let .removeImage(idx): + return .just(.removeImage(idx)) + + case .removeAllImages: + return .just(.removeAllImages) + + case let .checkRepresentativeImage(idx): + return .just(.checkRepresentativeImage(idx)) + + // 기간/시간 + case let .pickedPeriod(s, e): + return .just(.setPeriod(s, e)) + + case let .pickedTime(st, et): + return .just(.setTime(st, et)) + } + } + + // MARK: - reduce + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + switch mutation { + + case .setBack: + newState.needToPop = true + + case .setShowMore: + newState.showMoreSheet = true + + case .setDelete: + // 삭제 완료 + newState.didDelete = true + newState.needToPop = true + + case .setSaved: + newState.didSave = true + newState.needToPop = true + + case let .setName(name): + newState.name = name + + case let .setCategory(cat): + newState.category = cat + + case let .setAddress(addr): + newState.address = addr + + case let .setLatitude(lat): + newState.latitude = lat + + case let .setLongitude(lon): + newState.longitude = lon + + case let .setMarkerName(mn): + newState.markerName = mn + + case let .setMarkerSnippet(ms): + newState.markerSnippet = ms + + case let .setDescription(desc): + newState.desc = desc + + // 이미지 + case let .addImage(img): + newState.selectedImages.append(img) + case let .removeImage(idx): + guard idx < newState.selectedImages.count else { break } + newState.selectedImages.remove(at: idx) + if let rep = newState.repImageIndex, rep == idx { + newState.repImageIndex = nil + } else if let rep = newState.repImageIndex, rep > idx { + newState.repImageIndex = rep - 1 + } + case .removeAllImages: + newState.selectedImages.removeAll() + newState.repImageIndex = nil + case let .checkRepresentativeImage(idx): + guard idx < newState.selectedImages.count else { break } + newState.repImageIndex = idx + + case let .setPeriod(start, end): + newState.startDate = start + newState.endDate = end + + case let .setTime(st, et): + newState.startTime = st + newState.endTime = et + } + + // 필수값 체크(이미지≥1, name, address, desc, ...) + newState.canSave = ( !newState.name.isEmpty && + !newState.address.isEmpty && + !newState.desc.isEmpty && + newState.selectedImages.count >= 1 ) + return newState + } +} diff --git a/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift new file mode 100644 index 00000000..9f6cf620 --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift @@ -0,0 +1,521 @@ +import UIKit +import SnapKit + +final class PopUpStoreRegisterViewController: UIViewController { + + // MARK: - Navigation/Header + private let navContainer = UIView() + + private let logoImageView: UIImageView = { + let iv = UIImageView() + iv.image = UIImage(named: "image_login_logo") + iv.contentMode = .scaleAspectFit + return iv + }() + + private let accountIdLabel: UILabel = { + let lbl = UILabel() + lbl.text = "김채연님" + lbl.font = UIFont.systemFont(ofSize: 14, weight: .semibold) + lbl.textColor = .black + return lbl + }() + + private let menuButton: UIButton = { + let btn = UIButton(type: .system) + btn.setImage(UIImage(systemName: "adminlist"), for: .normal) + btn.tintColor = .black + return btn + }() + + // MARK: - Title (Back button + label) + private let titleContainer = UIView() + + private let backButton: UIButton = { + let btn = UIButton(type: .system) + btn.setImage(UIImage(systemName: "chevron.left"), for: .normal) + btn.tintColor = .black + return btn + }() + + private let pageTitleLabel: UILabel = { + let lbl = UILabel() + lbl.text = "팝업스토어 등록" + lbl.font = UIFont.boldSystemFont(ofSize: 18) + lbl.textColor = .black + return lbl + }() + + // (3) 메인 이미지 + private let mainImageView: UIImageView = { + let iv = UIImageView() + iv.contentMode = .scaleAspectFit + iv.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2) + return iv + }() + + // MARK: - Scroll + private let scrollView = UIScrollView() + private let contentView = UIView() + + // MARK: - Form Background + private let formBackgroundView: UIView = { + let v = UIView() + v.backgroundColor = .white + // 시안상 사각형 테두리 + v.layer.borderWidth = 1 + v.layer.borderColor = UIColor.lightGray.cgColor + v.layer.cornerRadius = 8 + return v + }() + private let verticalStack = UIStackView() + + // MARK: - Bottom Save Button + private let saveButton: UIButton = { + let btn = UIButton(type: .system) + btn.setTitle("저장", for: .normal) + btn.setTitleColor(.white, for: .normal) + btn.backgroundColor = .lightGray + btn.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .bold) + btn.layer.cornerRadius = 8 + btn.isEnabled = false + return btn + }() + // MARK: - DateTimePicker + private var selectedStartDate: Date? + private var selectedEndDate: Date? + private var selectedStartTime: Date? + private var selectedEndTime: Date? + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(white:0.95, alpha:1) + + setupNavigation() + setupLayout() + setupRows() + } + + // MARK: - Navigation + private func setupNavigation() { + backButton.addTarget(self, action: #selector(onBack), for: .touchUpInside) + } + + @objc private func onBack() { + navigationController?.popViewController(animated: true) + } + + // MARK: - Layout + private func setupLayout() { + // (1) 상단 컨테이너 + view.addSubview(navContainer) + navContainer.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.left.right.equalToSuperview() + make.height.equalTo(44) + } + + navContainer.addSubview(logoImageView) + logoImageView.snp.makeConstraints { make in + make.left.equalToSuperview().offset(8) + make.centerY.equalToSuperview() + make.width.equalTo(22) + make.height.equalTo(35) + } + + navContainer.addSubview(accountIdLabel) + accountIdLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.left.equalTo(logoImageView.snp.right).offset(8) + } + + navContainer.addSubview(menuButton) + menuButton.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.right.equalToSuperview().inset(16) + make.width.height.equalTo(32) + } + + view.addSubview(titleContainer) + titleContainer.snp.makeConstraints { make in + make.top.equalTo(navContainer.snp.bottom) + make.left.right.equalToSuperview() + make.height.equalTo(44) + } + + titleContainer.addSubview(backButton) + backButton.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.left.equalToSuperview().offset(8) + make.width.height.equalTo(32) + } + + titleContainer.addSubview(pageTitleLabel) + pageTitleLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.left.equalTo(backButton.snp.right).offset(4) + } + + // (3) Scroll + view.addSubview(scrollView) + scrollView.snp.makeConstraints { make in + make.top.equalTo(titleContainer.snp.bottom) + make.left.right.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-74) + } + scrollView.addSubview(contentView) + contentView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalTo(scrollView.snp.width) + } + + // 메인 이미지 + contentView.addSubview(mainImageView) + mainImageView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(12) + make.centerX.equalToSuperview() + make.width.height.equalTo(80) + } + + // (4) form BG + contentView.addSubview(formBackgroundView) + formBackgroundView.snp.makeConstraints { make in + make.top.equalTo(mainImageView.snp.bottom).offset(16) + make.left.right.equalToSuperview().inset(16) + make.bottom.equalToSuperview() + + } + formBackgroundView.addSubview(verticalStack) + verticalStack.axis = .vertical + verticalStack.spacing = 0 + verticalStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + // (5) 저장 버튼 + view.addSubview(saveButton) + saveButton.snp.makeConstraints { make in + make.left.right.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) + make.top.equalTo(scrollView.snp.bottom).offset(15) + make.height.equalTo(44) + } + } + + // MARK: - Setup Rows + private func setupRows() { + // 예시: 이름, 이미지, 카테고리... + addRowTextField(leftTitle: "이름", placeholder: "팝업스토어 이름을 입력해 주세요.") + addRowTextField(leftTitle: "이미지", placeholder: "팝업스토어 대표 이미지를 업로드 해주세요.") + let catBtn = makeRoundedButton("카테고리 선택 ▾") + addRowCustom(leftTitle: "카테고리", rightView: catBtn) + + // (위치) => 2줄 + // 1) 주소 (TextField) + let addressField = makeRoundedTextField("팝업스토어 주소를 입력해 주세요.") + addressField.snp.makeConstraints { make in + } + + // 2) (위도 Label + TF) + (경도 Label + TF) + let latLabel = makePlainLabel("위도") + let latField = makeRoundedTextField("") + latField.textAlignment = .center + let lonLabel = makePlainLabel("경도") + let lonField = makeRoundedTextField("") + lonField.textAlignment = .center + + let latStack = UIStackView(arrangedSubviews: [latLabel, latField]) + latStack.axis = .horizontal + latStack.spacing = 8 + latStack.distribution = .fillProportionally + + let lonStack = UIStackView(arrangedSubviews: [lonLabel, lonField]) + lonStack.axis = .horizontal + lonStack.spacing = 8 + lonStack.distribution = .fillProportionally + + let latLonRow = UIStackView(arrangedSubviews: [latStack, lonStack]) + latLonRow.axis = .horizontal + latLonRow.spacing = 16 + latLonRow.distribution = .fillEqually + + // 수직 스택(주소, latLonRow) + let locationVStack = UIStackView(arrangedSubviews: [addressField, latLonRow]) + locationVStack.axis = .vertical + locationVStack.spacing = 8 + locationVStack.distribution = .fillEqually + + + // 한 행에 왼쪽 "위치", 오른쪽 2줄(주소 / 위도경도) + addRowCustom(leftTitle: "위치", rightView: locationVStack, rowHeight: nil, totalHeight: 80) + + // (마커) => 2줄 + // 1) (마커명 Label + TF) + let markerLabel = makePlainLabel("마커명") + let markerField = makeRoundedTextField("") + let markerStackH = UIStackView(arrangedSubviews: [markerLabel, markerField]) + markerStackH.axis = .horizontal + markerStackH.spacing = 8 + markerStackH.distribution = .fillProportionally + + // 2) (스니펫 Label + TF) + let snippetLabel = makePlainLabel("스니펫") + let snippetField = makeRoundedTextField("") + let snippetStackH = UIStackView(arrangedSubviews: [snippetLabel, snippetField]) + snippetStackH.axis = .horizontal + snippetStackH.spacing = 8 + snippetStackH.distribution = .fillProportionally + + // 수직 + let markerVStack = UIStackView(arrangedSubviews: [markerStackH, snippetStackH]) + markerVStack.axis = .vertical + markerVStack.spacing = 8 + markerVStack.distribution = .fillEqually + + + // 한 행 => "마커" 라벨, 오른쪽 2줄 (마커명, 스니펫) + addRowCustom(leftTitle: "마커", rightView: markerVStack, rowHeight: nil, totalHeight: 80) + + // (10) 기간 + let periodBtn = makeIconButton("", iconName: "date") + periodBtn.addTarget(self, action: #selector(didTapPeriodButton), for: .touchUpInside) + + addRowCustom(leftTitle: "기간", rightView: periodBtn) + + // (11) 시간 + let timeBtn = makeIconButton("", iconName: "due") + timeBtn.addTarget(self, action: #selector(didTapTimeButton), for: .touchUpInside) + addRowCustom(leftTitle: "시간", rightView: timeBtn) + + + // (12) 작성자 + let writerLbl = makeSimpleLabel("김채연") + addRowCustom(leftTitle: "작성자", rightView: writerLbl) + + // (13) 작성시간 + let timeLbl = makeSimpleLabel("2025.01.06 10:30") + addRowCustom(leftTitle: "작성시간", rightView: timeLbl) + + // (14) 상태값 + let statusLbl = makeSimpleLabel("진행") + addRowCustom(leftTitle: "상태값", rightView: statusLbl) + + // (15) 설명 + let descTV = makeRoundedTextView() + addRowCustom(leftTitle: "설명", rightView: descTV, rowHeight: nil, totalHeight: 120) + } + + + // MARK: - Row + private func addRowTextField(leftTitle: String, placeholder: String) { + let tf = makeRoundedTextField(placeholder) + addRowCustom(leftTitle: leftTitle, rightView: tf) + } + + /** + rowHeight: 기본(41) + totalHeight: 2줄 필요한 경우(90~100), 3줄 등 필요 시 더 크게 + */ + private func addRowCustom(leftTitle: String, + rightView: UIView, + rowHeight: CGFloat? = 36, + totalHeight: CGFloat? = nil) { + let row = UIView() + row.backgroundColor = .white + + let leftBG = UIView() + leftBG.backgroundColor = UIColor(white: 0.94, alpha: 1) + row.addSubview(leftBG) + leftBG.snp.makeConstraints { make in + make.top.bottom.left.equalToSuperview() + make.width.equalTo(80) + } + + let leftLabel = UILabel() + leftLabel.text = leftTitle + leftLabel.font = UIFont.systemFont(ofSize: 15, weight: .bold) + leftLabel.textColor = .black + leftLabel.textAlignment = .center + leftBG.addSubview(leftLabel) + leftLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.left.right.equalToSuperview().inset(8) + } + + let rightBG = UIView() + rightBG.backgroundColor = .white + row.addSubview(rightBG) + rightBG.snp.makeConstraints { make in + make.top.bottom.right.equalToSuperview() + make.left.equalTo(leftBG.snp.right) + } + + rightBG.addSubview(rightView) + rightView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(7) + make.bottom.equalToSuperview().offset(-7) + make.left.equalToSuperview().offset(8) + make.right.equalToSuperview().offset(-8) + if let fixH = rowHeight { + make.height.equalTo(fixH).priority(.medium) + } + } + + if let totalH = totalHeight { + row.snp.makeConstraints { make in + make.height.equalTo(totalH).priority(.high) + } + } else { + row.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(41) + } + } + + let separator = UIView() + separator.backgroundColor = UIColor.lightGray.withAlphaComponent(0.3) + row.addSubview(separator) + separator.snp.makeConstraints { make in + make.left.right.bottom.equalToSuperview() + make.height.equalTo(1) + } + + verticalStack.addArrangedSubview(row) + } + @objc private func didTapPeriodButton() { + DateTimePickerManager.shared.showDateRange(on: self) { start, end in + // 여기서 ViewController는 날짜 2개만 받고, UI 업데이트 + self.selectedStartDate = start + self.selectedEndDate = end + self.updatePeriodButtonTitle() + } + } + + @objc private func didTapTimeButton() { + DateTimePickerManager.shared.showTimeRange(on: self) { st, et in + self.selectedStartTime = st + self.selectedEndTime = et + self.updateTimeButtonTitle() + } + } + + private func updatePeriodButtonTitle() { + guard let s = selectedStartDate, let e = selectedEndDate else { return } + let df = DateFormatter() + df.dateFormat = "yyyy-MM-dd" + let sStr = df.string(from: s) + let eStr = df.string(from: e) + + // verticalStack 안에서 "기간" 라벨 있는 row 찾아서, or 그냥 recall the same button if you stored it: + // For simplicity, let's re-scan or keep a reference + if let periodBtn = findButtonByIconName("date") { + periodBtn.setTitle("\(sStr) ~ \(eStr)", for: .normal) + } + } + + private func updateTimeButtonTitle() { + guard let st = selectedStartTime, let et = selectedEndTime else { return } + let df = DateFormatter() + df.dateFormat = "HH:mm" + let stStr = df.string(from: st) + let etStr = df.string(from: et) + if let timeBtn = findButtonByIconName("due") { + timeBtn.setTitle("\(stStr) ~ \(etStr)", for: .normal) + } + } + + // MARK: - helper to find icon button + private func findButtonByIconName(_ iconName: String) -> UIButton? { + for rowView in verticalStack.arrangedSubviews { + for sub in rowView.subviews { + for sub2 in sub.subviews { + if let btn = sub2 as? UIButton, + let image = btn.image(for: .normal), + image == UIImage(named: iconName) { + return btn + } + } + } + } + return nil + } + + + + // MARK: - UI Helpers + private func makeRoundedTextField(_ placeholder: String) -> UITextField { + let tf = UITextField() + tf.placeholder = placeholder + tf.font = UIFont.systemFont(ofSize:14) + tf.textColor = .darkGray + tf.borderStyle = .none + tf.layer.cornerRadius = 8 + tf.layer.borderWidth = 1 + tf.layer.borderColor = UIColor.lightGray.cgColor + tf.setLeftPaddingPoints(8) + return tf + } + + private func makeRoundedButton(_ title: String) -> UIButton { + let btn = UIButton(type: .system) + btn.setTitle(title, for: .normal) + btn.setTitleColor(.darkGray, for: .normal) + btn.titleLabel?.font = UIFont.systemFont(ofSize:14) + btn.layer.cornerRadius = 8 + btn.layer.borderWidth = 1 + btn.layer.borderColor = UIColor.lightGray.cgColor + btn.contentHorizontalAlignment = .left + btn.contentEdgeInsets = UIEdgeInsets(top:7, left:8, bottom:7, right:8) + return btn + } + + private func makeIconButton(_ title: String, iconName: String) -> UIButton { + let btn = makeRoundedButton(title) + if let icon = UIImage(named: iconName) { + btn.setImage(icon, for: .normal) + btn.imageView?.contentMode = .scaleAspectFit + btn.titleEdgeInsets = UIEdgeInsets(top:0, left:6, bottom:0, right:0) + } + return btn + } + + private func makeSimpleLabel(_ text: String) -> UILabel { + let lbl = UILabel() + lbl.text = text + lbl.font = UIFont.systemFont(ofSize:14) + lbl.textColor = .darkGray + return lbl + } + + private func makePlainLabel(_ text: String) -> UILabel { + // 작은 라벨(위도/경도/마커명/스니펫 등) + let lbl = UILabel() + lbl.text = text + lbl.font = UIFont.systemFont(ofSize:14) + lbl.textColor = .darkGray + lbl.textAlignment = .right + lbl.setContentHuggingPriority(.required, for: .horizontal) + return lbl + } + + private func makeRoundedTextView() -> UITextView { + let tv = UITextView() + tv.font = UIFont.systemFont(ofSize:14) + tv.textColor = .darkGray + tv.layer.cornerRadius = 8 + tv.layer.borderWidth = 1 + tv.layer.borderColor = UIColor.lightGray.cgColor + tv.textContainerInset = UIEdgeInsets(top:7, left:7, bottom:7, right:7) + tv.isScrollEnabled = false + return tv + } +} + +// MARK: - Padding +private extension UITextField { + func setLeftPaddingPoints(_ amount: CGFloat){ + let paddingView = UIView(frame: CGRect(x:0, y:0, width:amount, height: frame.size.height)) + leftView = paddingView + leftViewMode = .always + } +} diff --git a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift index 6584edd5..133fa2cc 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift @@ -146,7 +146,6 @@ final class StoreListViewController: UIViewController, View { } private func presentFilterBottomSheet(for filterType: FilterType) { - // FilterBottomSheetReactor (ex) let sheetReactor = FilterBottomSheetReactor() let viewController = FilterBottomSheetViewController(reactor: sheetReactor) diff --git a/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/adminlist.imageset/Contents.json b/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/adminlist.imageset/Contents.json new file mode 100644 index 00000000..c48dbafb --- /dev/null +++ b/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/adminlist.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "adminlist.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/adminlist.imageset/adminlist.png b/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/adminlist.imageset/adminlist.png new file mode 100644 index 0000000000000000000000000000000000000000..f50420e5444cd6ff3c4d4b15def1e25768fc0398 GIT binary patch literal 218 zcmeAS@N?(olHy`uVBq!ia0vp^(m*W3!3HEJTs-m?NO2Z;L>4nJa0`PlBg3pY55jgR3=A9lx&I`x0{Qu#E{-7;jBhVrZU8mOk0-fx1G~Vxz3R%Ub7W ze2TrCUY_QAO)+hgt8w!S&1`9R8R?f(O%Lx3koMJ1a+$+FHHCfp9KEpIgw|-F)eN4l KelF{r5}E)xibW{^ literal 0 HcmV?d00001 diff --git a/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/date.imageset/Contents.json b/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/date.imageset/Contents.json new file mode 100644 index 00000000..39ff8a4b --- /dev/null +++ b/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/date.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "date.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/date.imageset/date.png b/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/date.imageset/date.png new file mode 100644 index 0000000000000000000000000000000000000000..5be472cf2905b1933c1d5a0297ccd725e6d88277 GIT binary patch literal 674 zcmV;T0$u%yP)P000>X1^@s6#OZ}&00009a7bBm000XU z000XU0RWnu7ytkO0drDELIAGL9O(c600d`2O+f$vv5yPBB3$Nczol4`fmKJ0DZyHlOEk>m|!^}++ zOvh<4#{UPi!?X~J6I0O*hsO+*urX-7oy`=7?u%e5IstPH8$c2)wJ(=zp^{bcPl;8M%pp4vjc-Qb z=?5L16EQU+s3A8Rx})> zoLi2_|Io-McH#}9IWNX-=iNdr&PSi}u6*1%m(JEpVmiq@2|qK)2I;np@t{(lbYzD1 z6!Y-3pKnzUe(kZ&bO;In!}+YD<_EpBUjRoUw&2?Ev4XNO=uk*(@yGH39l_* zZH0je)Qt6EN@fHhh2ur@J(LOV07*qo IM6N<$f-z1g=>Px# literal 0 HcmV?d00001 diff --git a/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/due.imageset/Contents.json b/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/due.imageset/Contents.json new file mode 100644 index 00000000..37ca3459 --- /dev/null +++ b/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/due.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "due.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/due.imageset/due.svg b/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/due.imageset/due.svg new file mode 100644 index 00000000..ad5bfc74 --- /dev/null +++ b/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/due.imageset/due.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Poppool/Poppool/Resource/Assets.xcassets/date.imageset/Contents.json b/Poppool/Poppool/Resource/Assets.xcassets/date.imageset/Contents.json new file mode 100644 index 00000000..39ff8a4b --- /dev/null +++ b/Poppool/Poppool/Resource/Assets.xcassets/date.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "date.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Poppool/Poppool/Resource/Assets.xcassets/date.imageset/date.png b/Poppool/Poppool/Resource/Assets.xcassets/date.imageset/date.png new file mode 100644 index 0000000000000000000000000000000000000000..5be472cf2905b1933c1d5a0297ccd725e6d88277 GIT binary patch literal 674 zcmV;T0$u%yP)P000>X1^@s6#OZ}&00009a7bBm000XU z000XU0RWnu7ytkO0drDELIAGL9O(c600d`2O+f$vv5yPBB3$Nczol4`fmKJ0DZyHlOEk>m|!^}++ zOvh<4#{UPi!?X~J6I0O*hsO+*urX-7oy`=7?u%e5IstPH8$c2)wJ(=zp^{bcPl;8M%pp4vjc-Qb z=?5L16EQU+s3A8Rx})> zoLi2_|Io-McH#}9IWNX-=iNdr&PSi}u6*1%m(JEpVmiq@2|qK)2I;np@t{(lbYzD1 z6!Y-3pKnzUe(kZ&bO;In!}+YD<_EpBUjRoUw&2?Ev4XNO=uk*(@yGH39l_* zZH0je)Qt6EN@fHhh2ur@J(LOV07*qo IM6N<$f-z1g=>Px# literal 0 HcmV?d00001 diff --git a/Poppool/Poppool/Resource/Assets.xcassets/due.imageset/Contents.json b/Poppool/Poppool/Resource/Assets.xcassets/due.imageset/Contents.json new file mode 100644 index 00000000..a19a5492 --- /dev/null +++ b/Poppool/Poppool/Resource/Assets.xcassets/due.imageset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} From 1b414fccca78c470152af41d2253055c5f8c32bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=80=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Wed, 8 Jan 2025 18:48:42 +0900 Subject: [PATCH 02/12] =?UTF-8?q?[FIX]=20:=20=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=B7=B0=20=ED=8E=98=EC=9D=B4=EC=A7=80=ED=99=94=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Poppool/Application/SceneDelegate.swift | 114 +++++- .../FilterBottomSheetView.swift | 8 +- .../Poppool/Presentation/Map/MapView.swift | 71 ++-- .../Presentation/Map/MapViewController.swift | 368 +++++++++++------- .../Map/StoreListPanelLayout.swift | 2 +- .../StoreListView/StoreListHeaderView.swift | 108 ++--- .../Map/StoreListView/StoreListView.swift | 45 ++- .../StoreListViewController.swift | 64 +-- .../Presentation/Map/TestViewController.swift | 154 ++++++++ 9 files changed, 622 insertions(+), 312 deletions(-) create mode 100644 Poppool/Poppool/Presentation/Map/TestViewController.swift diff --git a/Poppool/Poppool/Application/SceneDelegate.swift b/Poppool/Poppool/Application/SceneDelegate.swift index 21ff3ff1..95eb1198 100644 --- a/Poppool/Poppool/Application/SceneDelegate.swift +++ b/Poppool/Poppool/Application/SceneDelegate.swift @@ -1,3 +1,76 @@ +//// +//// SceneDelegate.swift +//// Poppool +//// +//// Created by Porori on 11/24/24. +//// +// +//import UIKit +//import RxKakaoSDKAuth +//import KakaoSDKAuth +//import RxSwift +// +//class SceneDelegate: UIResponder, UIWindowSceneDelegate { +// +// var window: UIWindow? +// static let appDidBecomeActive = PublishSubject() +// +// func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { +// guard let windowScene = (scene as? UIWindowScene) else { return } +// window = UIWindow(windowScene: windowScene) +// +// // Debug: Admin Page Test +// let provider = ProviderImpl() +// let repository = DefaultAdminRepository(provider: provider) +// let useCase = DefaultAdminUseCase(repository: repository) +// let reactor = AdminReactor(useCase: useCase) +// let adminVC = AdminViewController() +// adminVC.reactor = reactor +// +// let navigationController = UINavigationController(rootViewController: adminVC) +// +// let rootViewController = LoginController() +// rootViewController.reactor = LoginReactor() +// +// let rootVC = WaveTabBarController() +// +// let rootViewController = DetailController() +// rootViewController.reactor = DetailReactor(popUpID: 8) +// +// let rootViewController = SearchMainController() +// rootViewController.reactor = SearchMainReactor() +// +// let navigationController = UINavigationController(rootViewController: rootVC) +// let navigationController = WaveTabBarController() +// +// window?.rootViewController = navigationController +// window?.makeKeyAndVisible() +// } +// +// func sceneDidDisconnect(_ scene: UIScene) { +// } +// +// func sceneDidBecomeActive(_ scene: UIScene) { +// SceneDelegate.appDidBecomeActive.onNext(()) +// } +// +// func sceneWillResignActive(_ scene: UIScene) { +// } +// +// func sceneWillEnterForeground(_ scene: UIScene) { +// } +// +// func sceneDidEnterBackground(_ scene: UIScene) { +// } +// +// func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { +// if let url = URLContexts.first?.url { +// if AuthApi.isKakaoTalkLoginUrl(url) { +// _ = AuthController.rx.handleOpenUrl(url: url) +// } +// } +// } +//} // // SceneDelegate.swift // Poppool @@ -6,14 +79,14 @@ // import UIKit + import RxKakaoSDKAuth import KakaoSDKAuth import RxSwift class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - static let appDidBecomeActive = PublishSubject() + var window: UIWindow? static let appDidBecomeActive = PublishSubject() private let disposeBag = DisposeBag() @@ -25,27 +98,28 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window?.makeKeyAndVisible() } - func sceneDidDisconnect(_ scene: UIScene) { - } + func sceneDidDisconnect(_ scene: UIScene) { + } - func sceneDidBecomeActive(_ scene: UIScene) { - SceneDelegate.appDidBecomeActive.onNext(()) - } + func sceneDidBecomeActive(_ scene: UIScene) { + SceneDelegate.appDidBecomeActive.onNext(()) + } - func sceneWillResignActive(_ scene: UIScene) { - } + func sceneWillResignActive(_ scene: UIScene) { + } - func sceneWillEnterForeground(_ scene: UIScene) { - } + func sceneWillEnterForeground(_ scene: UIScene) { + } - func sceneDidEnterBackground(_ scene: UIScene) { - } + func sceneDidEnterBackground(_ scene: UIScene) { + } - func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - if let url = URLContexts.first?.url { - if AuthApi.isKakaoTalkLoginUrl(url) { - _ = AuthController.rx.handleOpenUrl(url: url) - } - } - } + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + if let url = URLContexts.first?.url { + if AuthApi.isKakaoTalkLoginUrl(url) { + _ = AuthController.rx.handleOpenUrl(url: url) + } + } + } } + diff --git a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift index a3398c6b..a0acde41 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift @@ -164,17 +164,17 @@ final class FilterBottomSheetView: UIView { headerView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() - make.height.equalTo(60) + make.height.equalTo(70) } titleLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) - make.centerY.equalToSuperview() + make.top.equalToSuperview().offset(30) } closeButton.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(16) - make.centerY.equalToSuperview() + make.centerY.equalTo(titleLabel) make.size.equalTo(24) } @@ -305,7 +305,7 @@ final class FilterBottomSheetView: UIView { cornerRadius: 22 ) button.setBackgroundColor(.w100, for: .normal) - button.setTitleColor(.g700, for: .normal) + button.setTitleColor(.g400, for: .normal) button.layer.borderColor = UIColor.g400.cgColor button.layer.borderWidth = 1 diff --git a/Poppool/Poppool/Presentation/Map/MapView.swift b/Poppool/Poppool/Presentation/Map/MapView.swift index 5c81fc0e..4d02266e 100644 --- a/Poppool/Poppool/Presentation/Map/MapView.swift +++ b/Poppool/Poppool/Presentation/Map/MapView.swift @@ -4,22 +4,32 @@ import GoogleMaps final class MapView: UIView { // MARK: - Components - let mapView: GMSMapView = { let camera = GMSCameraPosition(latitude: 37.5666, longitude: 126.9784, zoom: 15) let view = GMSMapView(frame: .zero, camera: camera) view.settings.myLocationButton = false - return view }() - let topStackView: UIStackView = { - let stack = UIStackView() - stack.axis = .vertical - stack.spacing = 12 -// stack.backgroundColor = .white - return stack + // 기존 topStackView + // let topStackView: UIStackView = { + // let stack = UIStackView() + // stack.axis = .vertical + // stack.spacing = 12 + // return stack + // }() + + let backgroundView: UIView = { + let view = UIView() + view.backgroundColor = .white + return view }() + let searchFilterContainer: UIView = { + let view = UIView() + view.backgroundColor = .clear + return view + }() + let searchInput = MapSearchInput() let filterChips = MapFilterChips() @@ -62,39 +72,41 @@ private extension MapView { mapView.snp.makeConstraints { make in make.edges.equalToSuperview() } - - addSubview(topStackView) - topStackView.snp.makeConstraints { make in - make.top.equalTo(safeAreaLayoutGuide).offset(10) + + addSubview(searchFilterContainer) + searchFilterContainer.snp.makeConstraints { make in + make.top.equalToSuperview().offset(80) make.leading.trailing.equalToSuperview() + } + // searchInput, filterChips 제약조건 수정 + searchFilterContainer.addSubview(searchInput) searchInput.snp.makeConstraints { make in + make.top.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(16) make.height.equalTo(37) } - filterChips.snp.makeConstraints { make in - make.height.equalTo(36) - } - - let searchContainer = UIView() - let filterContainer = UIView() - - searchContainer.addSubview(searchInput) - filterContainer.addSubview(filterChips) - - searchInput.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview().inset(16) - make.top.bottom.equalToSuperview() - } + searchFilterContainer.addSubview(filterChips) filterChips.snp.makeConstraints { make in + make.top.equalTo(searchInput.snp.bottom).offset(12) make.leading.trailing.equalToSuperview().inset(16) - make.top.bottom.equalToSuperview() + make.height.equalTo(36) + make.bottom.equalToSuperview() } - topStackView.addArrangedSubview(searchContainer) - topStackView.addArrangedSubview(filterContainer) + + // 기존 코드 주석처리 + // let searchContainer = UIView() + // let filterContainer = UIView() + // searchContainer.addSubview(searchInput) + // filterContainer.addSubview(filterChips) + // searchInput.snp.makeConstraints { ... } + // filterChips.snp.makeConstraints { ... } + // topStackView.addArrangedSubview(searchContainer) + // topStackView.addArrangedSubview(filterContainer) addSubview(locationButton) addSubview(listButton) @@ -124,6 +136,7 @@ private extension MapView { storeCard.isHidden = true } } + private extension CALayer { func applyMapButtonShadow() { shadowColor = UIColor.black.cgColor diff --git a/Poppool/Poppool/Presentation/Map/MapViewController.swift b/Poppool/Poppool/Presentation/Map/MapViewController.swift index ff0d8026..97ea80e4 100644 --- a/Poppool/Poppool/Presentation/Map/MapViewController.swift +++ b/Poppool/Poppool/Presentation/Map/MapViewController.swift @@ -16,11 +16,18 @@ final class MapViewController: BaseViewController, View { let carouselView = MapPopupCarouselView() private let locationManager = CLLocationManager() private var currentMarker: GMSMarker? - + private let storeListViewController = StoreListViewController(reactor: StoreListReactor()) + private var listViewTopConstraint: Constraint? private var currentFilterBottomSheet: FilterBottomSheetViewController? private var filterChipsTopY: CGFloat = 0 - var fpc: FloatingPanelController? + enum ModalState { + case top + case middle + case bottom + } + + private var modalState: ModalState = .bottom // MARK: - Lifecycle override func viewDidAppear(_ animated: Bool) { @@ -29,8 +36,6 @@ final class MapViewController: BaseViewController, View { self.view.layoutIfNeeded() let frameInView = self.mainView.filterChips.convert(self.mainView.filterChips.bounds, to: self.view) self.filterChipsTopY = frameInView.minY - - print("[DEBUG] filterChipsTopY after layout: \(self.filterChipsTopY)") } } @@ -38,7 +43,6 @@ final class MapViewController: BaseViewController, View { super.viewDidLoad() setUp() checkLocationAuthorization() - locationManager.delegate = self locationManager.requestWhenInUseAuthorization() locationManager.desiredAccuracy = kCLLocationAccuracyBest @@ -49,18 +53,38 @@ final class MapViewController: BaseViewController, View { view.addSubview(mainView) mainView.snp.makeConstraints { make in make.edges.equalToSuperview() + } - view.addSubview(carouselView) - carouselView.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview() - make.height.equalTo(140) - make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) - } - - carouselView.isHidden = true - mainView.mapView.delegate = self + view.addSubview(carouselView) + carouselView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.height.equalTo(140) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) } + carouselView.isHidden = true + mainView.mapView.delegate = self + + // 리스트뷰 설정 + addChild(storeListViewController) + view.addSubview(storeListViewController.view) + storeListViewController.didMove(toParent: self) + + storeListViewController.view.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + listViewTopConstraint = make.top.equalTo(view.snp.bottom).constraint // 초기 숨김 상태 + make.height.equalTo(view.frame.height) + } + + // 제스처 설정 + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) +// storeListViewController.mainView.grabberHandle.addGestureRecognizer(panGesture) + storeListViewController.mainView.addGestureRecognizer(panGesture) + + + setupMarker() + } + private func setupMarker() { let marker = GMSMarker() marker.position = CLLocationCoordinate2D(latitude: 37.5666, longitude: 126.9784) let markerView = MapMarker() @@ -70,7 +94,34 @@ final class MapViewController: BaseViewController, View { markerView.frame = CGRect(x: 0, y: 0, width: 80, height: 28) } + private func setupPanAndSwipeGestures() { + storeListViewController.mainView.grabberHandle.rx.swipeGesture(.up) + .withUnretained(self) + .subscribe { owner, _ in + print("[DEBUG] Swipe Up Gesture Detected") + owner.animateToState(.top) + } + .disposed(by: disposeBag) + + storeListViewController.mainView.grabberHandle.rx.swipeGesture(.down) + .withUnretained(self) + .subscribe { owner, _ in + print("[DEBUG] Swipe Down Gesture Detected") + switch owner.modalState { + case .top: + owner.animateToState(.middle) + case .middle: + owner.animateToState(.bottom) + case .bottom: + break + } + } + .disposed(by: disposeBag) + } + + // MARK: - Bind func bind(reactor: Reactor) { + // 필터 관련 바인딩 mainView.filterChips.locationChip.rx.tap .map { Reactor.Action.filterTapped(.location) } .bind(to: reactor.action) @@ -81,34 +132,19 @@ final class MapViewController: BaseViewController, View { .bind(to: reactor.action) .disposed(by: disposeBag) + // 리스트 버튼 탭 mainView.listButton.rx.tap - .bind { [weak self] _ in - guard let self = self else { return } - - let reactor = StoreListReactor() - let listVC = StoreListViewController(reactor: reactor) - - let fpc = FloatingPanelController() - self.fpc = fpc - fpc.delegate = self - fpc.set(contentViewController: listVC) - fpc.layout = StoreListPanelLayout() -// fpc.surfaceView.grabberHandle.isHidden = true - fpc.surfaceView.grabberHandle.snp.makeConstraints { make in - make.top.equalToSuperview().offset(14) - make.centerX.equalToSuperview() - make.width.equalTo(36) - make.height.equalTo(5) - } - - fpc.surfaceView.layer.shadowColor = UIColor.clear.cgColor - fpc.surfaceView.layer.shadowRadius = 0 - fpc.surfaceView.layer.shadowOffset = .zero - fpc.surfaceView.layer.shadowOpacity = 0 - fpc.addPanel(toParent: self) + .withUnretained(self) + .subscribe { owner, _ in + print("[DEBUG] List Button Tapped") + owner.animateToState(.middle) // 버튼 눌렀을 때 상태를 middle로 변경 } .disposed(by: disposeBag) + + + + // 위치 버튼 mainView.locationButton.rx.tap .bind { [weak self] _ in guard let self = self else { return } @@ -116,6 +152,7 @@ final class MapViewController: BaseViewController, View { } .disposed(by: disposeBag) + // 필터 상태 업데이트 reactor.state.map { $0.selectedLocationFilters } .distinctUntilChanged() .observe(on: MainScheduler.instance) @@ -137,6 +174,7 @@ final class MapViewController: BaseViewController, View { ) } .disposed(by: disposeBag) + mainView.filterChips.onRemoveLocation = { reactor.action.onNext(.clearFilters(.location)) } @@ -173,18 +211,155 @@ final class MapViewController: BaseViewController, View { }) .disposed(by: disposeBag) } + + + // MARK: - List View Control + private func toggleListView() { + print("[DEBUG] Current Modal State: \(modalState)") + print("[DEBUG] Current listViewTopConstraint offset: \(listViewTopConstraint?.layoutConstraints.first?.constant ?? 0)") + + UIView.animate(withDuration: 0.3) { + let middleOffset = -self.view.frame.height * 0.7 + self.listViewTopConstraint?.update(offset: middleOffset) + self.modalState = .middle + self.mainView.searchFilterContainer.backgroundColor = .clear + print("[DEBUG] Changing state to Middle") + print("[DEBUG] Updated offset: \(middleOffset)") + self.view.layoutIfNeeded() + } + + // 상태 변경 후 로그 + print("[DEBUG] New Modal State: \(modalState)") + print("[DEBUG] New listViewTopConstraint offset: \(listViewTopConstraint?.layoutConstraints.first?.constant ?? 0)") + } func addMarker(for store: MapPopUpStore) { - let marker = GMSMarker() - marker.position = store.coordinate - marker.userData = store + let marker = GMSMarker() + marker.position = store.coordinate + marker.userData = store + + let markerView = MapMarker() + markerView.injection(with: store.toMarkerInput()) + marker.iconView = markerView + marker.map = mainView.mapView + } + + @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: view) + let velocity = gesture.velocity(in: view) + + switch gesture.state { + case .changed: + if let constraint = listViewTopConstraint { + let searchFilterFrame = self.mainView.searchFilterContainer.convert( + self.mainView.searchFilterContainer.bounds, + to: self.view + ) + let filterChipsFrame = self.mainView.filterChips.convert( + self.mainView.filterChips.bounds, + to: self.view + ) + let minOffset = searchFilterFrame.minY + filterChipsFrame.maxY + 12 + let maxOffset = view.frame.height - let markerView = MapMarker() - markerView.injection(with: store.toMarkerInput()) - marker.iconView = markerView - marker.map = mainView.mapView + let newOffset = constraint.layoutConstraints.first?.constant ?? 0 + translation.y + let clampedOffset = min(max(newOffset, minOffset), maxOffset) + + constraint.update(offset: clampedOffset) + gesture.setTranslation(.zero, in: view) + + let progress = (maxOffset - clampedOffset) / (maxOffset - minOffset) + mainView.searchFilterContainer.alpha = progress + } + + case .ended: + let currentOffset = listViewTopConstraint?.layoutConstraints.first?.constant ?? 0 + let targetState: ModalState + + if velocity.y > 500 { + targetState = .bottom + } else if velocity.y < -500 { + targetState = .top + } else { + let middleY = view.frame.height * 0.4 + if currentOffset < middleY * 0.7 { + targetState = .top + } else if currentOffset < view.frame.height * 0.7 { + targetState = .middle + } else { + targetState = .bottom + } + } + + print("[DEBUG] Pan Ended - Current Offset: \(currentOffset), Velocity Y: \(velocity.y)") + modalState = targetState + animateToState(targetState) + + default: + break + } } + + private func animateToState(_ state: ModalState) { + self.view.layoutIfNeeded() + + UIView.animate(withDuration: 0.3, animations: { + switch state { + case .top: + + let filterChipsFrame = self.mainView.filterChips.convert( + self.mainView.filterChips.bounds, + to: self.view + ) + self.mainView.mapView.isHidden = true + self.storeListViewController.setGrabberHandleVisible(false) + self.storeListViewController.mainView.layer.cornerRadius = 0 + self.storeListViewController.view.snp.remakeConstraints { make in + make.leading.trailing.equalToSuperview() + make.top.equalToSuperview().offset(filterChipsFrame.maxY) + make.bottom.equalToSuperview() + } + + self.mainView.searchFilterContainer.backgroundColor = .white + self.mainView.searchFilterContainer.alpha = 1 + + case .middle: + self.storeListViewController.setGrabberHandleVisible(true) + self.storeListViewController.view.snp.remakeConstraints { make in + make.leading.trailing.equalToSuperview() + make.top.equalToSuperview().offset(self.view.frame.height * 0.3) // 70% 가려짐 + make.height.equalTo(self.view.frame.height) + self.storeListViewController.mainView.layer.cornerRadius = 20 + self.storeListViewController.mainView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + self.mainView.mapView.isHidden = false + + } + self.mainView.searchFilterContainer.backgroundColor = .clear + + case .bottom: + self.storeListViewController.setGrabberHandleVisible(true) + self.storeListViewController.view.snp.remakeConstraints { make in + make.leading.trailing.equalToSuperview() + make.top.equalTo(self.view.snp.bottom) + make.height.equalTo(self.view.frame.height) + self.mainView.mapView.isHidden = false + + } + self.mainView.searchFilterContainer.backgroundColor = .clear + self.mainView.searchFilterContainer.alpha = 1 + } + + self.view.layoutIfNeeded() + }) { _ in + self.modalState = state + } + } + + + + + // MARK: - Filter Bottom Sheet func presentFilterBottomSheet(for filterType: FilterType) { let sheetReactor = FilterBottomSheetReactor() let viewController = FilterBottomSheetViewController(reactor: sheetReactor) @@ -195,8 +370,6 @@ final class MapViewController: BaseViewController, View { viewController.onSave = { [weak self] (selectedOptions: [String]) in guard let self = self else { return } - print("MapVC onSave - filterType: \(filterType), options: \(selectedOptions)") - self.reactor?.action.onNext(.filterUpdated(filterType, selectedOptions)) self.reactor?.action.onNext(.filterTapped(nil)) } @@ -212,6 +385,7 @@ final class MapViewController: BaseViewController, View { currentFilterBottomSheet = viewController } + private func dismissFilterBottomSheet() { if let bottomSheet = currentFilterBottomSheet { @@ -219,76 +393,21 @@ final class MapViewController: BaseViewController, View { } currentFilterBottomSheet = nil } -} - -// MARK: - FloatingPanelControllerDelegate -extension MapViewController: FloatingPanelControllerDelegate { - func floatingPanelDidMove(_ fpc: FloatingPanelController) { - let panelY = fpc.surfaceView.frame.minY -// print("[DEBUG] panelY: \(panelY), filterChipsTopY: \(filterChipsTopY)") - - let threshold: CGFloat = 40.0 - - if abs(panelY - filterChipsTopY) <= threshold { - transitionToFullScreen(fpc: fpc) - } else if panelY > filterChipsTopY + threshold { - restoreMapView(fpc: fpc) - } - } - func floatingPanelDidChangeState(_ fpc: FloatingPanelController) { - switch fpc.state { - case .full: - transitionToFullScreen(fpc: fpc) - case .half, .tip: - restoreMapView(fpc: fpc) - default: + // MARK: - Location + private func checkLocationAuthorization() { + let status = CLLocationManager.authorizationStatus() + switch status { + case .notDetermined: + locationManager.requestWhenInUseAuthorization() + case .authorizedWhenInUse, .authorizedAlways: + locationManager.startUpdatingLocation() + case .denied, .restricted: + print("위치 서비스가 비활성화되었습니다. 설정에서 권한을 확인해주세요.") + @unknown default: break } } - - private func transitionToFullScreen(fpc: FloatingPanelController) { - if let listVC = fpc.contentViewController as? StoreListViewController { - // 상태 변경 전에 레이아웃 준비 - listVC.view.layoutIfNeeded() - listVC.mainView.collectionView.layoutIfNeeded() - - UIView.animate(withDuration: 0.3) { - self.mainView.alpha = 0 - self.mainView.isHidden = true - listVC.view.backgroundColor = .white - - // 상태 변경 - listVC.updateHeaderVisibility(true) - listVC.view.layoutIfNeeded() - } - } - } - - - private func restoreMapView(fpc: FloatingPanelController) { - UIView.animate(withDuration: 0.3) { - self.mainView.alpha = 1 - self.mainView.isHidden = false - - if let listVC = fpc.contentViewController as? StoreListViewController { - listVC.view.backgroundColor = .clear - listVC.updateHeaderVisibility(false) - } - } - } -} - -// MARK: - UIScrollViewDelegate -extension MapViewController: UIScrollViewDelegate { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - guard let fpc = self.fpc else { return } - - if scrollView.contentOffset.y < 0 { - scrollView.contentOffset = .zero - fpc.move(to: .half, animated: true) - } - } } // MARK: - CLLocationManagerDelegate @@ -297,8 +416,8 @@ extension MapViewController: CLLocationManagerDelegate { guard let location = locations.last else { return } let camera = GMSCameraPosition.camera(withLatitude: location.coordinate.latitude, - longitude: location.coordinate.longitude, - zoom: 15) + longitude: location.coordinate.longitude, + zoom: 15) mainView.mapView.animate(to: camera) let currentLocationStore = MapPopUpStore( @@ -323,8 +442,6 @@ extension MapViewController: CLLocationManagerDelegate { // MARK: - GMSMapViewDelegate extension MapViewController: GMSMapViewDelegate { func mapView(_ mapView: GMSMapView, didTap marker: GMSMarker) -> Bool { - print("[DEBUG] Marker tapped") - let dummyStore1 = MapPopUpStore( id: 1, category: "카페", @@ -358,20 +475,3 @@ extension MapViewController: GMSMapViewDelegate { return true } } - -extension MapViewController { - private func checkLocationAuthorization() { - let status = CLLocationManager.authorizationStatus() - - switch status { - case .notDetermined: - locationManager.requestWhenInUseAuthorization() - case .authorizedWhenInUse, .authorizedAlways: - locationManager.startUpdatingLocation() - case .denied, .restricted: - print("위치 서비스가 비활성화되었습니다. 설정에서 권한을 확인해주세요.") - @unknown default: - break - } - } -} diff --git a/Poppool/Poppool/Presentation/Map/StoreListPanelLayout.swift b/Poppool/Poppool/Presentation/Map/StoreListPanelLayout.swift index eb03c008..4aebf156 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListPanelLayout.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListPanelLayout.swift @@ -8,7 +8,7 @@ class StoreListPanelLayout: FloatingPanelLayout { var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] { return [ - .full: FloatingPanelLayoutAnchor(absoluteInset: 0, edge: .top, referenceGuide: .superview), + .full: FloatingPanelLayoutAnchor(absoluteInset: 120, edge: .top, referenceGuide: .superview), .half: FloatingPanelLayoutAnchor(fractionalInset: 0.6, edge: .bottom, referenceGuide: .safeArea), .tip: FloatingPanelLayoutAnchor(absoluteInset: -100, edge: .bottom, referenceGuide: .safeArea) // 완전히 내림 ] diff --git a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListHeaderView.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListHeaderView.swift index 21b5ab01..7911caca 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListHeaderView.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListHeaderView.swift @@ -1,54 +1,54 @@ - -import UIKit -import SnapKit -import RxSwift - - -class StoreListHeaderView: UICollectionReusableView { - static let identifier = "StoreListHeaderView" - - let searchInput = MapSearchInput() - let filterChips = MapFilterChips() - - var disposeBag = DisposeBag() - override init(frame: CGRect) { - super.init(frame: frame) -// print("[DEBUG] StoreListHeaderView 초기화 - frame: \(frame)") - setupLayout() - searchInput.setBackgroundColorForList() - - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupLayout() { - - backgroundColor = .white - addSubview(searchInput) - addSubview(filterChips) - - - searchInput.snp.makeConstraints { make in - make.top.equalToSuperview().offset(16) - make.left.equalToSuperview().offset(20) - make.right.equalToSuperview().inset(16) - make.height.equalTo(37) - - - } - - filterChips.snp.makeConstraints { make in - make.top.equalTo(searchInput.snp.bottom).offset(11) - make.left.right.equalToSuperview().inset(20) - make.height.equalTo(36) - make.bottom.equalToSuperview().offset(-20) - } - - - layoutIfNeeded() - - - } -} +// +//import UIKit +//import SnapKit +//import RxSwift +// +// +//class StoreListHeaderView: UICollectionReusableView { +// static let identifier = "StoreListHeaderView" +// +// let searchInput = MapSearchInput() +// let filterChips = MapFilterChips() +// +// var disposeBag = DisposeBag() +// override init(frame: CGRect) { +// super.init(frame: frame) +//// print("[DEBUG] StoreListHeaderView 초기화 - frame: \(frame)") +// setupLayout() +// searchInput.setBackgroundColorForList() +// +// } +// +// required init?(coder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// private func setupLayout() { +// +// backgroundColor = .white +// addSubview(searchInput) +// addSubview(filterChips) +// +// +// searchInput.snp.makeConstraints { make in +// make.top.equalToSuperview().offset(16) +// make.left.equalToSuperview().offset(20) +// make.right.equalToSuperview().inset(16) +// make.height.equalTo(37) +// +// +// } +// +// filterChips.snp.makeConstraints { make in +// make.top.equalTo(searchInput.snp.bottom).offset(11) +// make.left.right.equalToSuperview().inset(20) +// make.height.equalTo(36) +// make.bottom.equalToSuperview().offset(-20) +// } +// +// +// layoutIfNeeded() +// +// +// } +//} diff --git a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift index 6722225e..9eb89785 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift @@ -4,21 +4,24 @@ import SnapKit final class StoreListView: UIView { // MARK: - Components lazy var collectionView: UICollectionView = { - let layout = createLayout() - let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) - cv.backgroundColor = .white - cv.register(StoreListCell.self, forCellWithReuseIdentifier: StoreListCell.identifier) - cv.register( - StoreListHeaderView.self, - forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, - withReuseIdentifier: StoreListHeaderView.identifier - ) - return cv - }() + let layout = createLayout() + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.backgroundColor = .white + cv.register(StoreListCell.self, forCellWithReuseIdentifier: StoreListCell.identifier) + return cv + }() + + let grabberHandle: UIView = { + let view = UIView() + view.backgroundColor = .g200 + view.layer.cornerRadius = 2.5 + return view + }() // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) + configureLayer() // 최상단 레이어 설정 setUpConstraints() } @@ -45,11 +48,27 @@ private extension StoreListView { } func setUpConstraints() { - backgroundColor = .clear + backgroundColor = .white addSubview(collectionView) + addSubview(grabberHandle) + + grabberHandle.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.top.equalToSuperview().inset(14) + make.width.equalTo(36) + make.height.equalTo(5) + } collectionView.snp.makeConstraints { make in - make.edges.equalToSuperview() + make.top.equalTo(grabberHandle.snp.bottom).offset(20) + make.leading.trailing.bottom.equalToSuperview() } } + + func configureLayer() { + // 최상단 레이어에 cornerRadius 설정 + layer.cornerRadius = 16 + layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] // 상단 좌우 코너만 적용 + layer.masksToBounds = true + } } diff --git a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift index 133fa2cc..dec0cf7e 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift @@ -75,36 +75,9 @@ final class StoreListViewController: UIViewController, View { .disposed(by: cell.disposeBag) return cell - }, + } // 헤더 설정 - configureSupplementaryView: { ds, cv, kind, indexPath in - guard kind == UICollectionView.elementKindSectionHeader else { - return UICollectionReusableView() - } - let headerView = cv.dequeueReusableSupplementaryView( - ofKind: kind, - withReuseIdentifier: StoreListHeaderView.identifier, - for: indexPath - ) as! StoreListHeaderView - - headerView.isHidden = false - headerView.searchInput.isHidden = !self.isHeaderVisible - headerView.filterChips.isHidden = !self.isHeaderVisible - headerView.filterChips.locationChip.rx.tap - .map { Reactor.Action.filterTapped(.location) } - .bind(to: reactor.action) - .disposed(by: headerView.disposeBag) - - headerView.filterChips.categoryChip.rx.tap - .map { Reactor.Action.filterTapped(.category) } - .bind(to: reactor.action) - .disposed(by: headerView.disposeBag) - - - let sectionModel = ds.sectionModels[indexPath.section] - return headerView - } ) reactor.state @@ -180,35 +153,6 @@ final class StoreListViewController: UIViewController, View { sheet.hideBottomSheet() } } - - // MARK: - updateHeaderVisibility - func updateHeaderVisibility(_ visible: Bool) { - guard isHeaderVisible != visible else { return } - isHeaderVisible = visible - - if let layout = mainView.collectionView.collectionViewLayout as? UICollectionViewFlowLayout { - UIView.animate(withDuration: 0.3) { - layout.headerReferenceSize = visible - ? CGSize(width: self.view.bounds.width, height: 120) - : .zero - - // 이미 표시 중인 HeaderView 있으면 동기화 - if let headerView = self.mainView.collectionView.supplementaryView( - forElementKind: UICollectionView.elementKindSectionHeader, - at: IndexPath(item: 0, section: 0) - ) as? StoreListHeaderView { - headerView.isHidden = false - headerView.searchInput.isHidden = !visible - headerView.filterChips.isHidden = !visible - headerView.layoutIfNeeded() - } - - layout.invalidateLayout() - } completion: { _ in - self.mainView.collectionView.reloadData() - } - } - } } // MARK: - UICollectionViewDelegateFlowLayout @@ -224,3 +168,9 @@ final class StoreListViewController: UIViewController, View { : .zero } } +extension StoreListViewController { + func setGrabberHandleVisible(_ visible: Bool) { + mainView.grabberHandle.isHidden = !visible + } +} + diff --git a/Poppool/Poppool/Presentation/Map/TestViewController.swift b/Poppool/Poppool/Presentation/Map/TestViewController.swift new file mode 100644 index 00000000..8a35c50d --- /dev/null +++ b/Poppool/Poppool/Presentation/Map/TestViewController.swift @@ -0,0 +1,154 @@ +// +// ViewController.swift +// Poppool +// +// Created by Porori on 11/24/24. +// + +import UIKit + +import SnapKit +import RxSwift +import RxGesture +import RxCocoa + +class TestViewController: UIViewController { + + private let topView: UIView = { + let view = UIView() + view.backgroundColor = .w100 + view.alpha = 0 + return view + }() + + private let topViewLabel: UILabel = { + let label = UILabel() + label.text = "Top View Label" + return label + }() + + private let bottomView: UIView = { + let view = UIView() + view.backgroundColor = .w100 + return view + }() + + private let gestureBar: UIView = { + let view = UIView() + view.backgroundColor = .g200 + return view + }() + + private let listButton: PPButton = { + let button = PPButton(style: .secondary, text: "리스트 버튼") + return button + }() + + private let disposeBag = DisposeBag() + + private var bottomViewTopConstraints: Constraint? + + enum ModalState { + case top + case middle + case bottom + } + + var modalState: ModalState = .bottom + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .blue + setUpConstratins() + bind() + } + + func setUpConstratins() { + view.addSubview(listButton) + listButton.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview().inset(20) + make.height.equalTo(50) + } + + view.addSubview(topView) + topView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(104) + } + + topView.addSubview(topViewLabel) + topViewLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + view.addSubview(bottomView) + bottomView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + bottomViewTopConstraints = make.top.equalTo(topView.snp.bottom).offset(700).constraint + make.height.equalTo(700) + } + + bottomView.addSubview(gestureBar) + gestureBar.snp.makeConstraints { make in + make.width.equalTo(50) + make.height.equalTo(20) + make.top.equalToSuperview().inset(20) + make.centerX.equalToSuperview() + } + } + + func bind() { + listButton.rx.tap + .withUnretained(self) + .subscribe { (owner, _) in + print("listButtonTapped") + UIView.animate(withDuration: 0.3) { + owner.bottomViewTopConstraints?.update(offset: 124) + owner.topView.alpha = 0 + owner.view.layoutIfNeeded() + } + } + .disposed(by: disposeBag) + + gestureBar.rx.swipeGesture(.up) + .skip(1) + .withUnretained(self) + .subscribe { (owner, gesture) in + print("swipe up") + UIView.animate(withDuration: 0.3) { + owner.bottomViewTopConstraints?.update(offset: 0) + owner.topView.alpha = 1 + owner.view.layoutIfNeeded() + owner.modalState = .top + } + } + .disposed(by: disposeBag) + + gestureBar.rx.swipeGesture(.down) + .skip(1) + .withUnretained(self) + .subscribe { (owner, gesture) in + print("swipe down") + switch owner.modalState { + case .top: + UIView.animate(withDuration: 0.3) { + owner.bottomViewTopConstraints?.update(offset: 124) + owner.topView.alpha = 0 + owner.view.layoutIfNeeded() + owner.modalState = .middle + } + case .middle: + UIView.animate(withDuration: 0.3) { + owner.bottomViewTopConstraints?.update(offset: 700) + owner.topView.alpha = 0 + owner.view.layoutIfNeeded() + owner.modalState = .bottom + } + case .bottom: + break + } + + } + .disposed(by: disposeBag) + } +} From ca24834c48dc2a81b5f202b6bb256bb561fcc509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=80=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Wed, 15 Jan 2025 18:53:29 +0900 Subject: [PATCH 03/12] =?UTF-8?q?[FEAT]=20:=20=EB=A7=B5=EB=B7=B0=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EB=B0=98=EC=98=81=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PreSignedAPIEndPoint.swift | 14 +- .../PreSignedService/PreSignedService.swift | 71 +- .../Admin/AdminBottomSheetReactor.swift | 99 +++ .../Admin/AdminBottomSheetView.swift | 150 ++-- .../AdminBottomSheetViewController.swift | 445 ++++++------ .../Presentation/Admin/AdminReactor.swift | 6 + .../Presentation/Admin/AdminStoreCell.swift | 7 +- .../Presentation/Admin/AdminView.swift | 9 +- .../Admin/AdminViewController.swift | 101 ++- .../Admin/Common/ExtendedImage.swift | 26 + .../GetAdminPopUpStoreListResponseDTO.swift | 2 +- .../Data/Repository/AdminRepository.swift | 169 +++-- .../Admin/Domain/AdminUseCase.swift | 102 +-- .../Presentation/Admin/ImageCell.swift | 84 +++ .../Admin/PopUpStoreRegisterReactor.swift | 393 +++++----- .../Admin/PopUpStoreRegisterView.swift | 424 +++++++++++ .../PopUpStoreRegisterViewController.swift | 677 ++++++++++++++++-- .../BalloonBackgroundView.swift | 112 +-- .../FillterSheetView/BalloonChipCell.swift | 2 +- .../FilterBottomSheetReactor.swift | 157 ++-- .../FilterBottomSheetView.swift | 34 +- .../FilterBottomSheetViewController.swift | 49 +- .../FillterSheetView/FilterChipsView.swift | 37 +- .../Presentation/Map/MapFilterChips.swift | 10 +- .../MapPopupCarouselView.swift | 2 +- .../Poppool/Presentation/Map/MapReactor.swift | 239 ++++--- .../Presentation/Map/MapSearchInput.swift | 21 +- .../Presentation/Map/MapViewController.swift | 260 ++++--- .../Map/StoreListPanelLayout.swift | 60 +- .../Map/StoreListView/StoreListCell.swift | 45 +- .../Map/StoreListView/StoreListView.swift | 24 +- .../StoreListViewController.swift | 97 ++- .../Scene/MyPage/Main/MyPageReactor.swift | 36 +- 33 files changed, 2741 insertions(+), 1223 deletions(-) create mode 100644 Poppool/Poppool/Presentation/Admin/AdminBottomSheetReactor.swift create mode 100644 Poppool/Poppool/Presentation/Admin/Common/ExtendedImage.swift create mode 100644 Poppool/Poppool/Presentation/Admin/ImageCell.swift create mode 100644 Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterView.swift diff --git a/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedAPIEndPoint.swift b/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedAPIEndPoint.swift index 8d71a836..a1bba549 100644 --- a/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedAPIEndPoint.swift +++ b/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedAPIEndPoint.swift @@ -8,8 +8,8 @@ import Foundation struct PreSignedAPIEndPoint { - - static func presigned_upload(request: PresignedURLRequestDTO) -> Endpoint{ + static func presigned_upload(request: PresignedURLRequestDTO) -> Endpoint { + Logger.log(message: "Presigned URL 생성 - Request: \(request)", category: .debug) return Endpoint( baseURL: Secrets.popPoolBaseUrl.rawValue, path: "/files/upload-preSignedUrl", @@ -17,8 +17,9 @@ struct PreSignedAPIEndPoint { bodyParameters: request ) } - - static func presigned_download(request: PresignedURLRequestDTO) -> Endpoint{ + + static func presigned_download(request: PresignedURLRequestDTO) -> Endpoint { + Logger.log(message: "Presigned Download URL 생성 - Request: \(request)", category: .debug) return Endpoint( baseURL: Secrets.popPoolBaseUrl.rawValue, path: "/files/download-preSignedUrl", @@ -26,8 +27,9 @@ struct PreSignedAPIEndPoint { bodyParameters: request ) } - - static func presigned_delete(request: PresignedURLRequestDTO) -> RequestEndpoint{ + + static func presigned_delete(request: PresignedURLRequestDTO) -> RequestEndpoint { + Logger.log(message: "Presigned Delete 생성 - Request: \(request)", category: .debug) return RequestEndpoint( baseURL: Secrets.popPoolBaseUrl.rawValue, path: "/files/delete", diff --git a/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift b/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift index 9167bff3..a0fd9366 100644 --- a/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift +++ b/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift @@ -17,64 +17,80 @@ class ImageCache { } class PreSignedService { - + struct PresignedURLRequest { var filePath: String var image: UIImage } - + let tokenInterceptor = TokenInterceptor() let provider = ProviderImpl() let disposeBag = DisposeBag() - + func tryDelete(targetPaths: PresignedURLRequestDTO) -> Completable { let endPoint = PreSignedAPIEndPoint.presigned_delete(request: targetPaths) return provider.request(with: endPoint, interceptor: tokenInterceptor) } - + func tryUpload(datas: [PresignedURLRequest]) -> Single { - + Logger.log(message: "tryUpload 호출됨 - 요청 데이터 수: \(datas.count)", category: .debug) + return Single.create { [weak self] observer in + Logger.log(message: "tryUpload 내부 흐름 시작", category: .debug) + guard let self = self else { + Logger.log(message: "self가 nil입니다. 작업을 중단합니다.", category: .error) return Disposables.create() } - - getUploadLinks(request: .init(objectKeyList: datas.map { $0.filePath } )) + + // 1. 업로드 링크 요청 + self.getUploadLinks(request: .init(objectKeyList: datas.map { $0.filePath })) .subscribe { response in + Logger.log(message: "getUploadLinks 성공: \(response.preSignedUrlList)", category: .debug) + let responseList = response.preSignedUrlList let inputList = datas + + // 2. 업로드 준비 let requestList = zip(responseList, inputList).compactMap { zipResponse in let urlResponse = zipResponse.0 let inputResponse = zipResponse.1 + Logger.log(message: "업로드 준비 - URL: \(urlResponse.preSignedUrl)", category: .debug) return self.uploadFromS3(url: urlResponse.preSignedUrl, image: inputResponse.image) } + + // 3. 병렬 업로드 실행 Single.zip(requestList) .subscribe(onSuccess: { _ in - print("All images uploaded successfully") + Logger.log(message: "모든 이미지 업로드 성공", category: .info) observer(.success(())) }, onFailure: { error in - print("Image upload failed: \(error.localizedDescription)") + Logger.log(message: "이미지 업로드 실패: \(error.localizedDescription)", category: .error) observer(.failure(error)) }) .disposed(by: self.disposeBag) + } onError: { error in - print("getUploadLinks Fail: \(error.localizedDescription)") + Logger.log(message: "getUploadLinks 실패: \(error.localizedDescription)", category: .error) observer(.failure(error)) } - .disposed(by: disposeBag) + .disposed(by: self.disposeBag) + return Disposables.create() } } - + + func tryDownload(filePaths: [String]) -> Single<[UIImage]> { - + return Single.create { [weak self] observer in guard let self = self else { return Disposables.create() } + // 순서를 유지하기 위한 매핑 구조 var imageMap: [String: UIImage] = [:] var uncachedFilePaths: [String] = [] @@ -135,36 +151,38 @@ class PreSignedService { private extension PreSignedService { - + func uploadFromS3(url: String, image: UIImage) -> Single { return Single.create { single in if let imageData = image.jpegData(compressionQuality: 0), - let url = URL(string: url) - { - // Content-Type 헤더 설정 + let url = URL(string: url) { + Logger.log(message: "S3 업로드 요청 URL: \(url.absoluteString)", category: .debug) + let headers: HTTPHeaders = [ "Content-Type": "image/jpeg" ] AF.upload(imageData, to: url, method: .put, headers: headers) .response { response in + Logger.log(message: "S3 업로드 응답 상태: \(response.response?.statusCode ?? -1)", category: .debug) switch response.result { case .success: - print("success") + Logger.log(message: "S3 업로드 성공 - URL: \(url.absoluteString)", category: .info) single(.success(())) case .failure(let error): - print("failure") + Logger.log(message: "S3 업로드 실패: \(error.localizedDescription)", category: .error) single(.failure(error)) } } return Disposables.create() } else { + Logger.log(message: "S3 업로드 실패 - 잘못된 URL 또는 데이터", category: .error) single(.failure(NSError(domain: "InvalidDataOrURL", code: -1, userInfo: nil))) return Disposables.create() } } } - + func downloadFromS3(url: String) -> Single { return Single.create { single in if let url = URL(string: url) { @@ -176,7 +194,7 @@ private extension PreSignedService { single(.failure(error)) } } - + return Disposables.create { request.cancel() } @@ -186,13 +204,20 @@ private extension PreSignedService { } } } - + + func getUploadLinks(request: PresignedURLRequestDTO) -> Observable { + Logger.log(message: "Presigned URL 생성 요청 데이터: \(request)", category: .debug) let provider = ProviderImpl() let endPoint = PreSignedAPIEndPoint.presigned_upload(request: request) return provider.requestData(with: endPoint, interceptor: tokenInterceptor) + .do(onNext: { response in + Logger.log(message: "Presigned URL 응답 데이터: \(response.preSignedUrlList)", category: .debug) + }, onError: { error in + Logger.log(message: "Presigned URL 요청 실패: \(error.localizedDescription)", category: .error) + }) } - + func getDownloadLinks(request: PresignedURLRequestDTO) -> Observable { let provider = ProviderImpl() let endPoint = PreSignedAPIEndPoint.presigned_download(request: request) diff --git a/Poppool/Poppool/Presentation/Admin/AdminBottomSheetReactor.swift b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetReactor.swift new file mode 100644 index 00000000..6fd8b3b4 --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetReactor.swift @@ -0,0 +1,99 @@ +// +// AdminBottomSheetReactor.swift +// Poppool +// +// Created by 김기현 on 1/13/25. +// + +import Foundation +import ReactorKit + +final class AdminBottomSheetReactor: Reactor { + enum Action { + case segmentChanged(Int) + case resetFilters + case toggleStatusOption(String) + case toggleCategoryOption(String) + } + + enum Mutation { + case setActiveSegment(Int) + case resetFilters + case updateStatusOptions(Set) + case updateCategoryOptions(Set) + } + + struct State { + var activeSegment: Int = 0 + var selectedStatusOptions: Set = [] + var selectedCategoryOptions: Set = [] + + let statusOptions = ["전체", "운영", "종료"] + let categoryOptions = ["게임", "라이프스타일", "반려동물", "뷰티", "스포츠", "애니메이션", + "엔터테이먼트", "여행", "예술", "음식/요리", "키즈", "패션"] + + var isSaveEnabled: Bool { + return !selectedStatusOptions.isEmpty || !selectedCategoryOptions.isEmpty + } + } + + let initialState: State + + init() { + self.initialState = State() + } + + func mutate(action: Action) -> Observable { + switch action { + case let .segmentChanged(index): + return .just(.setActiveSegment(index)) + + case .resetFilters: + return .just(.resetFilters) + + case let .toggleStatusOption(option): + var newOptions = currentState.selectedStatusOptions + if option == "전체" { + newOptions = newOptions.contains(option) ? [] : ["전체"] + } else { + if newOptions.contains(option) { + newOptions.remove(option) + } else { + newOptions.remove("전체") + newOptions.insert(option) + } + } + return .just(.updateStatusOptions(newOptions)) + + case let .toggleCategoryOption(option): + var newOptions = currentState.selectedCategoryOptions + if newOptions.contains(option) { + newOptions.remove(option) + } else { + newOptions.insert(option) + } + return .just(.updateCategoryOptions(newOptions)) + } + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case let .setActiveSegment(index): + newState.activeSegment = index + + case .resetFilters: + newState.selectedStatusOptions.removeAll() + newState.selectedCategoryOptions.removeAll() + + case let .updateStatusOptions(options): + newState.selectedStatusOptions = options + + case let .updateCategoryOptions(options): + newState.selectedCategoryOptions = options + } + + return newState + } +} diff --git a/Poppool/Poppool/Presentation/Admin/AdminBottomSheetView.swift b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetView.swift index 7fc6307b..5876db70 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminBottomSheetView.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetView.swift @@ -1,12 +1,17 @@ import UIKit import SnapKit +import RxSwift +import RxCocoa +import ReactorKit final class AdminBottomSheetView: UIView { + // MARK: - Properties private var contentHeightConstraint: Constraint? - + typealias Reactor = AdminBottomSheetReactor + // MARK: - Components - private let containerView: UIView = { + let containerView: UIView = { let view = UIView() view.backgroundColor = .white view.layer.cornerRadius = 20 @@ -14,31 +19,36 @@ final class AdminBottomSheetView: UIView { view.layer.masksToBounds = true return view }() - + let headerView: UIView = { let view = UIView() view.backgroundColor = .white return view }() - + let titleLabel: PPLabel = { let label = PPLabel(style: .bold, fontSize: 18, text: "보기 옵션을 선택해주세요") label.textColor = .black return label }() - + let closeButton: UIButton = { let button = UIButton(type: .system) button.setImage(UIImage(named: "icon_xmark"), for: .normal) + button.tintColor = .black return button }() - + let segmentedControl: PPSegmentedControl = { - let control = PPSegmentedControl(type: .tab, segments: ["상태값", "카테고리"], selectedSegmentIndex: 0) + let control = PPSegmentedControl( + type: .tab, + segments: ["상태값", "카테고리"], + selectedSegmentIndex: 0 + ) return control }() - + let contentCollectionView: UICollectionView = { let layout = UICollectionViewCompositionalLayout { section, env in let itemSize = NSCollectionLayoutSize( @@ -46,7 +56,7 @@ final class AdminBottomSheetView: UIView { heightDimension: .absolute(36) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) - + let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(36) @@ -56,7 +66,7 @@ final class AdminBottomSheetView: UIView { subitems: [item] ) group.interItemSpacing = .fixed(12) - + let section = NSCollectionLayoutSection(group: group) section.contentInsets = .init( top: 20, @@ -65,22 +75,21 @@ final class AdminBottomSheetView: UIView { trailing: 20 ) section.interGroupSpacing = 16 - + return section } - - let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + + let collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: layout + ) collectionView.backgroundColor = .clear collectionView.isScrollEnabled = false - collectionView.register(TagSectionCell.self, forCellWithReuseIdentifier: TagSectionCell.identifiers) return collectionView }() - - let filterChipsView: FilterChipsView = { - let view = FilterChipsView() - return view - }() - + + let filterChipsView = FilterChipsView() + let resetButton: PPButton = { let button = PPButton( style: .secondary, @@ -89,10 +98,15 @@ final class AdminBottomSheetView: UIView { cornerRadius: 4 ) button.isEnabled = false - button.contentEdgeInsets = UIEdgeInsets(top: 9, left: 16, bottom: 9, right: 12) + button.contentEdgeInsets = UIEdgeInsets( + top: 9, + left: 16, + bottom: 9, + right: 12 + ) return button }() - + let saveButton: PPButton = { let button = PPButton( style: .primary, @@ -102,10 +116,15 @@ final class AdminBottomSheetView: UIView { cornerRadius: 4 ) button.isEnabled = false - button.contentEdgeInsets = UIEdgeInsets(top: 9, left: 16, bottom: 9, right: 12) + button.contentEdgeInsets = UIEdgeInsets( + top: 9, + left: 16, + bottom: 9, + right: 12 + ) return button }() - + private let buttonStack: UIStackView = { let stack = UIStackView() stack.axis = .horizontal @@ -113,74 +132,74 @@ final class AdminBottomSheetView: UIView { stack.distribution = .fillEqually return stack }() - + // MARK: - Initialization override init(frame: CGRect) { super.init(frame: frame) setupLayout() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + // MARK: - Setup private func setupLayout() { backgroundColor = .clear addSubview(containerView) - + containerView.addSubview(headerView) headerView.addSubview(titleLabel) headerView.addSubview(closeButton) - + [segmentedControl, contentCollectionView, filterChipsView, buttonStack].forEach { containerView.addSubview($0) } - + buttonStack.addArrangedSubview(resetButton) buttonStack.addArrangedSubview(saveButton) - + setupConstraints() } - + private func setupConstraints() { containerView.snp.makeConstraints { make in make.left.right.bottom.equalToSuperview() make.top.equalTo(headerView.snp.top) } - + headerView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(60) } - + titleLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.centerY.equalToSuperview() } - + closeButton.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(16) make.centerY.equalToSuperview() make.size.equalTo(24) } - + segmentedControl.snp.makeConstraints { make in make.top.equalTo(headerView.snp.bottom).offset(16) make.leading.trailing.equalToSuperview() } - + contentCollectionView.snp.makeConstraints { make in make.top.equalTo(segmentedControl.snp.bottom).offset(16) make.leading.trailing.equalToSuperview() contentHeightConstraint = make.height.equalTo(160).constraint } - + filterChipsView.snp.makeConstraints { make in make.top.equalTo(contentCollectionView.snp.bottom).offset(24) make.leading.trailing.equalToSuperview().inset(16) } - + buttonStack.snp.makeConstraints { make in make.top.equalTo(filterChipsView.snp.bottom).offset(24) make.leading.trailing.equalToSuperview().inset(16) @@ -188,47 +207,20 @@ final class AdminBottomSheetView: UIView { make.height.equalTo(52) } } - + // MARK: - Public Methods func updateContentVisibility(isCategorySelected: Bool) { + Logger.log(message: "높이 변경 시작: \(isCategorySelected ? "카테고리" : "상태값")", category: .debug) + let newHeight: CGFloat = isCategorySelected ? 200 : 160 - - UIView.animate(withDuration: 0.3) { - self.contentHeightConstraint?.update(offset: newHeight) - self.contentCollectionView.reloadData() - self.layoutIfNeeded() - } + + // 애니메이션 없이 바로 적용 + contentHeightConstraint?.update(offset: newHeight) + contentCollectionView.invalidateIntrinsicContentSize() + + setNeedsLayout() + layoutIfNeeded() + + Logger.log(message: "높이 변경 완료", category: .debug) } - - func update(statusText: String?, categoryText: String?) { - var filters: [String] = [] - - if let statusText = statusText, !statusText.isEmpty { - filters.append(statusText) - } - if let categoryText = categoryText, !categoryText.isEmpty { - filters.append(categoryText) - } - - filterChipsView.updateChips(with: filters) - - - // 버튼 활성화 상태 업데이트 - let hasFilters = !filters.isEmpty - resetButton.isEnabled = hasFilters - saveButton.isEnabled = hasFilters - } - - func updateCategoryButtonSelection(_ category: String) { - contentCollectionView.visibleCells.forEach { cell in - guard let tagCell = cell as? TagSectionCell else { return } - // input으로 상태 업데이트 - let input = TagSectionCell.Input( - title: category, - isSelected: !tagCell.contentView.backgroundColor!.isEqual(UIColor.blu500), - id: nil - ) - tagCell.injection(with: input) - } - } - } +} diff --git a/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift index 88cb3edb..ac34531c 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift @@ -2,269 +2,228 @@ import UIKit import SnapKit import RxSwift import RxCocoa +import ReactorKit -final class AdminBottomSheetViewController: BaseViewController { - // MARK: - Properties - private let mainView = AdminBottomSheetView() - private let dimmedView = UIView() - var disposeBag = DisposeBag() +final class AdminBottomSheetViewController: BaseViewController, View { - private var selectedStatusOptions: Set = [] - private var selectedCategoryOptions: Set = [] - private var tagSection: TagSection? + typealias Reactor = AdminBottomSheetReactor - var onSave: (([String]) -> Void)? - var onDismiss: (() -> Void)? + // MARK: - Properties + private let mainView = AdminBottomSheetView() + private let dimmedView = UIView() + var disposeBag = DisposeBag() + private var containerViewBottomConstraint: Constraint? + private var tagSection: TagSection? - // MARK: - Life Cycle - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .clear - setupViews() - setupCollectionView() - bind() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - showBottomSheet() - } - - // MARK: - Setup - private func setupViews() { - view.addSubview(dimmedView) - dimmedView.backgroundColor = .black.withAlphaComponent(0.4) - dimmedView.alpha = 0 - - dimmedView.snp.makeConstraints { make in - make.edges.equalToSuperview() - } - - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dimmedViewTapped)) - dimmedView.addGestureRecognizer(tapGesture) - dimmedView.isUserInteractionEnabled = true - - view.addSubview(mainView) - mainView.snp.makeConstraints { make in - make.left.right.equalToSuperview() - make.height.equalTo(view.bounds.height * 0.45) // 높이 조정 - make.bottom.equalTo(view.snp.bottom) - } + var onSave: (([String]) -> Void)? + var onDismiss: (() -> Void)? -// let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture)) -// panGesture.delegate = self -// mainView.addGestureRecognizer(panGesture) + // MARK: - Initialization + init(reactor: AdminBottomSheetReactor) { + super.init() // BaseViewController의 init() 호출 + self.reactor = reactor } - - private func setupCollectionView() { - mainView.contentCollectionView.dataSource = self - mainView.contentCollectionView.delegate = self - mainView.contentCollectionView.register(TagSectionCell.self, forCellWithReuseIdentifier: TagSectionCell.identifiers) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - private func bind() { - // Close Button - mainView.closeButton.rx.tap - .bind { [weak self] in - self?.hideBottomSheet() - } - .disposed(by: disposeBag) - - // Save Button - mainView.saveButton.rx.tap - .bind { [weak self] in - guard let self = self else { return } - let selectedOptions = self.mainView.segmentedControl.selectedSegmentIndex == 0 ? - Array(self.selectedStatusOptions) : Array(self.selectedCategoryOptions) - self.onSave?(selectedOptions) - self.hideBottomSheet() - } - .disposed(by: disposeBag) - - // Reset Button - mainView.resetButton.rx.tap - .bind { [weak self] in - self?.selectedStatusOptions.removeAll() - self?.selectedCategoryOptions.removeAll() - self?.updateButtonStates() - self?.updateCollectionView() - } - .disposed(by: disposeBag) - - // Segment Control - mainView.segmentedControl.rx.selectedSegmentIndex - .bind { [weak self] index in - self?.mainView.updateContentVisibility(isCategorySelected: index == 1) - self?.updateCollectionView() - } - .disposed(by: disposeBag) - } - - private func updateCollectionView() { - let isStatusTab = mainView.segmentedControl.selectedSegmentIndex == 0 - let items = isStatusTab ? - ["전체", "운영", "종료"] : - ["게임", "라이프스타일", "반려동물", "뷰티", "스포츠", "애니메이션", - "엔터테이먼트", "여행", "예술", "음식/요리", "키즈", "패션"] - - let selectedItems = isStatusTab ? selectedStatusOptions : selectedCategoryOptions - - tagSection = TagSection(inputDataList: items.map { - TagSectionCell.Input( - title: $0, - isSelected: selectedItems.contains($0), - id: nil - ) - }) - mainView.contentCollectionView.reloadData() - mainView.filterChipsView.updateChips(with: Array(selectedItems)) - } - private func toggleStatusOption(_ option: String) { - if selectedStatusOptions.contains(option) { - selectedStatusOptions.remove(option) - } else { - if option == "전체" { - selectedStatusOptions = ["전체"] - } else { - selectedStatusOptions.remove("전체") - selectedStatusOptions.insert(option) - } - } - } - - private func toggleCategoryOption(_ option: String) { - if selectedCategoryOptions.contains(option) { - selectedCategoryOptions.remove(option) - } else { - selectedCategoryOptions.insert(option) - } - } - private func updateButtonStates() { - let hasSelectedOptions = !selectedStatusOptions.isEmpty || !selectedCategoryOptions.isEmpty - mainView.saveButton.isEnabled = hasSelectedOptions - mainView.resetButton.isEnabled = hasSelectedOptions - - if hasSelectedOptions { - mainView.saveButton.backgroundColor = .blu500 - mainView.saveButton.setTitleColor(.white, for: .normal) - } else { - mainView.saveButton.backgroundColor = .g100 - mainView.saveButton.setTitleColor(.g400, for: .disabled) - } - let selectedOptions = mainView.segmentedControl.selectedSegmentIndex == 0 ? - Array(selectedStatusOptions) : Array(selectedCategoryOptions) - mainView.filterChipsView.updateChips(with: selectedOptions) - - } + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + setupViews() + setupCollectionView() + } + // MARK: - Setup + private func setupViews() { + view.backgroundColor = .clear -// private func updateButtonStates() { -// let hasSelectedOptions = !selectedStatusOptions.isEmpty || !selectedCategoryOptions.isEmpty -// mainView.updateButtonStates(isEnabled: hasSelectedOptions) -// } - - // MARK: - Gestures - @objc private func dimmedViewTapped() { - hideBottomSheet() - } - - @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { - let translation = gesture.translation(in: view) - - switch gesture.state { - case .changed: - guard translation.y >= 0 else { return } - mainView.transform = CGAffineTransform(translationX: 0, y: translation.y) - dimmedView.alpha = 1 - (translation.y / 500) - - case .ended: - let velocity = gesture.velocity(in: view) - if translation.y > 150 || velocity.y > 1000 { - hideBottomSheet() - } else { - UIView.animate(withDuration: 0.25) { - self.mainView.transform = .identity - self.dimmedView.alpha = 1 - } - } - - default: - break - } - } - - // MARK: - Animation Methods - func showBottomSheet() { - UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) { - self.dimmedView.alpha = 1 - self.view.layoutIfNeeded() - } - } - - func hideBottomSheet() { - UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn) { - self.dimmedView.alpha = 0 - self.mainView.transform = CGAffineTransform(translationX: 0, y: self.view.bounds.height) - } completion: { _ in - self.dismiss(animated: false) - self.onDismiss?() - } - } -} -extension AdminBottomSheetViewController: UICollectionViewDataSource { - func numberOfSections(in collectionView: UICollectionView) -> Int { - return 1 - } + Logger.log(message: "초기 뷰 계층:", category: .debug) + print(view.value(forKey: "recursiveDescription") ?? "") - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return tagSection?.inputDataList.count ?? 0 - } + // mainView 설정 및 추가 + view.addSubview(mainView) + mainView.isUserInteractionEnabled = true + mainView.containerView.isUserInteractionEnabled = true + mainView.closeButton.isUserInteractionEnabled = true + mainView.segmentedControl.isUserInteractionEnabled = true + mainView.headerView.isUserInteractionEnabled = true - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - guard let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: TagSectionCell.identifiers, - for: indexPath - ) as? TagSectionCell else { - return UICollectionViewCell() + mainView.snp.makeConstraints { make in + make.left.right.equalToSuperview() + make.height.equalTo(view.bounds.height * 0.45) + containerViewBottomConstraint = make.bottom.equalTo(view.snp.bottom).constraint } - if let input = tagSection?.inputDataList[indexPath.item] { - cell.injection(with: input) - } + Logger.log(message: "mainView 추가 후 계층:", category: .debug) + print(view.value(forKey: "recursiveDescription") ?? "") - return cell - } -} + // dimmedView 설정 및 추가 + dimmedView.backgroundColor = .black.withAlphaComponent(0.4) + dimmedView.alpha = 0 + dimmedView.isUserInteractionEnabled = true -// MARK: - UICollectionViewDelegate -extension AdminBottomSheetViewController: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard let title = tagSection?.inputDataList[indexPath.item].title else { return } + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dimmedViewTapped)) + dimmedView.addGestureRecognizer(tapGesture) + tapGesture.cancelsTouchesInView = false // 터치 이벤트가 다른 뷰로 전달되도록 설정 + view.insertSubview(dimmedView, belowSubview: mainView) - if mainView.segmentedControl.selectedSegmentIndex == 0 { - toggleStatusOption(title) - } else { - toggleCategoryOption(title) + dimmedView.snp.makeConstraints { make in + make.edges.equalToSuperview() } - updateCollectionView() - updateButtonStates() - } -} -extension AdminBottomSheetViewController: UIGestureRecognizerDelegate { - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - // 세그먼트 컨트롤이나 컬렉션뷰를 터치했을 때는 pan gesture 무시 - if let touchView = touch.view { - if touchView == mainView.segmentedControl || - touchView.isDescendant(of: mainView.segmentedControl) || - touchView == mainView.contentCollectionView || - touchView.isDescendant(of: mainView.contentCollectionView) { - return false - } - } - return true - } + Logger.log(message: "최종 뷰 계층:", category: .debug) + print(view.value(forKey: "recursiveDescription") ?? "") + } + + private func setupCollectionView() { + mainView.contentCollectionView.register( + TagSectionCell.self, + forCellWithReuseIdentifier: TagSectionCell.identifiers + ) + } + + // MARK: - Binding + func bind(reactor: Reactor) { + // Action + mainView.segmentedControl.rx.selectedSegmentIndex + .do(onNext: { index in + Logger.log(message: "세그먼트 변경 시도: \(index)", category: .event) + Logger.log(message: "View 상태: \(self.view.window != nil)", category: .debug) + Logger.log(message: "MainView 상태: \(self.mainView.window != nil)", category: .debug) + }) + .map { Reactor.Action.segmentChanged($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.resetButton.rx.tap + .map { Reactor.Action.resetFilters } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + mainView.contentCollectionView.rx.itemSelected + .withLatestFrom(reactor.state) { indexPath, state -> Reactor.Action in + let title = state.activeSegment == 0 ? + state.statusOptions[indexPath.item] : + state.categoryOptions[indexPath.item] + + return state.activeSegment == 0 ? + .toggleStatusOption(title) : + .toggleCategoryOption(title) + } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + // State + reactor.state.map { state in + let items = state.activeSegment == 0 ? + state.statusOptions : + state.categoryOptions + let selectedItems = state.activeSegment == 0 ? + state.selectedStatusOptions : + state.selectedCategoryOptions + + return items.map { + TagSectionCell.Input( + title: $0, + isSelected: selectedItems.contains($0), + id: nil + ) + } + } + .bind(to: mainView.contentCollectionView.rx.items( + cellIdentifier: TagSectionCell.identifiers, + cellType: TagSectionCell.self + )) { _, item, cell in + cell.injection(with: item) + } + .disposed(by: disposeBag) + + reactor.state.map { $0.activeSegment } + .distinctUntilChanged() + .bind { [weak self] index in + self?.mainView.updateContentVisibility(isCategorySelected: index == 1) + } + .disposed(by: disposeBag) + + reactor.state.map { state -> [String] in + state.activeSegment == 0 ? + Array(state.selectedStatusOptions) : + Array(state.selectedCategoryOptions) + } + .distinctUntilChanged() + .bind { [weak self] selectedOptions in + self?.mainView.filterChipsView.updateChips(with: selectedOptions) + } + .disposed(by: disposeBag) + + reactor.state.map { $0.isSaveEnabled } + .distinctUntilChanged() + .bind { [weak self] isEnabled in + guard let self = self else { return } + + self.mainView.saveButton.isEnabled = isEnabled + self.mainView.saveButton.backgroundColor = isEnabled ? .blu500 : .g100 + self.mainView.saveButton.setTitleColor( + isEnabled ? .white : .g400, + for: isEnabled ? .normal : .disabled + ) + self.mainView.resetButton.isEnabled = isEnabled + } + .disposed(by: disposeBag) + + // View Events + mainView.closeButton.rx.tap + .bind { [weak self] in + self?.hideBottomSheet() + } + .disposed(by: disposeBag) + + mainView.saveButton.rx.tap + .withLatestFrom(reactor.state) + .bind { [weak self] state in + guard let self = self else { return } + + let selectedOptions = state.activeSegment == 0 ? + Array(state.selectedStatusOptions) : + Array(state.selectedCategoryOptions) + + self.onSave?(selectedOptions) + self.hideBottomSheet() + } + .disposed(by: disposeBag) + } + + // MARK: - Actions + @objc private func dimmedViewTapped() { + hideBottomSheet() + } + + // MARK: - Show/Hide + func showBottomSheet() { + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) { + self.dimmedView.alpha = 1 + self.containerViewBottomConstraint?.update(offset: 0) + self.view.layoutIfNeeded() + } + } + + func hideBottomSheet() { + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseIn) { + self.dimmedView.alpha = 0 + self.containerViewBottomConstraint?.update(offset: self.view.bounds.height) + self.view.layoutIfNeeded() + } completion: { _ in + self.dismiss(animated: false) + self.onDismiss?() + } + } + + deinit { + Logger.log(message: "BottomSheet deinit", category: .debug) + } } diff --git a/Poppool/Poppool/Presentation/Admin/AdminReactor.swift b/Poppool/Poppool/Presentation/Admin/AdminReactor.swift index 31093f54..e547c00f 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminReactor.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminReactor.swift @@ -51,10 +51,16 @@ final class AdminReactor: Reactor { return .concat([ .just(.setIsLoading(true)), useCase.fetchStoreList(query: query, page: 0, size: 20) + .do(onNext: { response in + Logger.log(message: "조회 성공 - 응답 데이터: \(response)", category: .info) + }, onError: { error in + Logger.log(message: "조회 실패 - 에러: \(error.localizedDescription)", category: .error) + }) .map { .setStores($0.popUpStoreList) }, .just(.setIsLoading(false)) ]) + case .tapRegisterButton: // 여기서 State.shouldNavigateToRegister = true 로 변경 return .just(.navigateToRegister(true)) diff --git a/Poppool/Poppool/Presentation/Admin/AdminStoreCell.swift b/Poppool/Poppool/Presentation/Admin/AdminStoreCell.swift index 874a45b1..997428a9 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminStoreCell.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminStoreCell.swift @@ -2,6 +2,7 @@ import UIKit import SnapKit import RxSwift final class AdminStoreCell: UITableViewCell { + private let disposeBag = DisposeBag() // MARK: - Identifier static let identifier = "AdminStoreCell" @@ -75,8 +76,12 @@ final class AdminStoreCell: UITableViewCell { // MARK: - Configure func configure(with store: GetAdminPopUpStoreListResponseDTO.PopUpStore) { + Logger.log(message: "셀 데이터 바인딩: \(store)", category: .debug) + titleLabel.text = store.name categoryLabel.text = store.categoryName - statusChip.text = "운영" // 상태에 따라 동적 변경 + statusChip.text = "운영" + Logger.log(message: "이미지 경로: \(store.mainImageUrl ?? "nil")", category: .debug) + storeImageView.setPPImage(path: store.mainImageUrl) } } diff --git a/Poppool/Poppool/Presentation/Admin/AdminView.swift b/Poppool/Poppool/Presentation/Admin/AdminView.swift index ff2fb01a..3ec4149e 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminView.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminView.swift @@ -16,10 +16,10 @@ final class AdminView: UIView { } let usernameLabel = PPLabel( - style: .bold, - fontSize: 14, - text: "김채연님" - ) + style: .bold, + fontSize: 14, + text: "" + ) let menuButton = UIButton(type: .system).then { $0.setImage(UIImage(named: "adminlist"), for: .normal) @@ -81,7 +81,6 @@ final class AdminView: UIView { } let popupCountLabel = UILabel().then { - $0.text = "총 52건" $0.textColor = .lightGray $0.font = UIFont.systemFont(ofSize: 14) } diff --git a/Poppool/Poppool/Presentation/Admin/AdminViewController.swift b/Poppool/Poppool/Presentation/Admin/AdminViewController.swift index f05f211b..25737db2 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminViewController.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminViewController.swift @@ -4,53 +4,96 @@ import RxSwift import RxCocoa final class AdminViewController: BaseViewController, View { - + typealias Reactor = AdminReactor - + // MARK: - Properties var disposeBag = DisposeBag() - private var mainView = AdminView() - private var adminBottomSheetVC: AdminBottomSheetViewController? - private var selectedFilterOption: String = "전체" + private let mainView: AdminView + private var adminBottomSheetVC: AdminBottomSheetViewController? + private var selectedFilterOption: String = "전체" + private let nickname: String + private let adminUseCase: AdminUseCase -} -// MARK: - Life Cycle -extension AdminViewController { + init(nickname: String, adminUseCase: AdminUseCase = DefaultAdminUseCase(repository: DefaultAdminRepository(provider: ProviderImpl()))) { + self.nickname = nickname + self.adminUseCase = adminUseCase + self.mainView = AdminView(frame: .zero) + super.init() + mainView.usernameLabel.text = nickname + "님" + } + + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() setUp() - } -} - -// MARK: - SetUp -private extension AdminViewController { - func setUp() { - view.addSubview(mainView) - mainView.snp.makeConstraints { make in - make.edges.equalTo(view.safeAreaLayoutGuide) - } - navigationItem.title = "팝업스토어 관리" - mainView.dropdownButton.addTarget(self, action: #selector(didTapDropdownButton), for: .touchUpInside) + // 로고 이미지에 탭 제스처 추가 + let logoTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapLogo)) + mainView.logoImageView.isUserInteractionEnabled = true + mainView.logoImageView.addGestureRecognizer(logoTapGesture) + mainView.tableView.register(AdminStoreCell.self, forCellReuseIdentifier: AdminStoreCell.identifier) + + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + tabBarController?.tabBar.isHidden = true + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + tabBarController?.tabBar.isHidden = false + } + + // MARK: - Actions + @objc private func didTapLogo() { + navigationController?.popViewController(animated: true) } @objc private func didTapDropdownButton() { - let bottomSheetVC = AdminBottomSheetViewController() + let reactor = AdminBottomSheetReactor() + let bottomSheetVC = AdminBottomSheetViewController(reactor: reactor) - bottomSheetVC.onSave = { [weak self] selectedOptions in + bottomSheetVC.onSave = { [weak self] (selectedOptions: [String]) in guard let self = self else { return } self.selectedFilterOption = selectedOptions.joined(separator: ", ") self.mainView.dropdownButton.setTitle(self.selectedFilterOption, for: .normal) } - // BottomSheet 스타일 적용 - bottomSheetVC.modalPresentationStyle = .custom + bottomSheetVC.onDismiss = { [weak self] in + guard let self = self else { return } + self.adminBottomSheetVC = nil + } + + bottomSheetVC.modalPresentationStyle = UIModalPresentationStyle.overFullScreen - present(bottomSheetVC, animated: true) + present(bottomSheetVC, animated: false) { + bottomSheetVC.showBottomSheet() + } self.adminBottomSheetVC = bottomSheetVC } + } +// MARK: - SetUp +private extension AdminViewController { + func setUp() { + view.addSubview(mainView) + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + navigationItem.title = "팝업스토어 관리" + + mainView.dropdownButton.addTarget(self, action: #selector(didTapDropdownButton), for: .touchUpInside) + } +} + // MARK: - ReactorKit Bindings extension AdminViewController { func bind(reactor: Reactor) { @@ -70,6 +113,11 @@ extension AdminViewController { .disposed(by: disposeBag) // 3) 테이블 바인딩 + reactor.state.map { $0.storeList } + .map { "총 \($0.count)건" } + .bind(to: mainView.popupCountLabel.rx.text) + .disposed(by: disposeBag) + reactor.state.map { $0.storeList } .bind(to: mainView.tableView.rx.items( cellIdentifier: AdminStoreCell.identifier, @@ -85,7 +133,8 @@ extension AdminViewController { .filter { $0 == true } .subscribe(onNext: { [weak self] _ in guard let self = self else { return } - let registerVC = PopUpStoreRegisterViewController() + + let registerVC = PopUpStoreRegisterViewController(nickname: self.nickname, adminUseCase: self.adminUseCase) self.navigationController?.pushViewController(registerVC, animated: true) // 이동 직후, 다시 false로 diff --git a/Poppool/Poppool/Presentation/Admin/Common/ExtendedImage.swift b/Poppool/Poppool/Presentation/Admin/Common/ExtendedImage.swift new file mode 100644 index 00000000..acfcfbab --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/Common/ExtendedImage.swift @@ -0,0 +1,26 @@ +// +// SelectableImage.swift +// Poppool +// +// Created by 김기현 on 1/13/25. +// + +import Foundation +import UIKit + +public struct ExtendedImage: Equatable { + public var filePath: String + public var image: UIImage + public var isMain: Bool + + public init(filePath: String, image: UIImage, isMain: Bool) { + self.filePath = filePath + self.image = image + self.isMain = isMain + } + + // MARK: - Equatable + public static func == (lhs: ExtendedImage, rhs: ExtendedImage) -> Bool { + return lhs.filePath == rhs.filePath && lhs.isMain == rhs.isMain + } +} diff --git a/Poppool/Poppool/Presentation/Admin/Data/DTO/GetAdminPopUpStoreListResponseDTO.swift b/Poppool/Poppool/Presentation/Admin/Data/DTO/GetAdminPopUpStoreListResponseDTO.swift index 3dbbe25e..58222704 100644 --- a/Poppool/Poppool/Presentation/Admin/Data/DTO/GetAdminPopUpStoreListResponseDTO.swift +++ b/Poppool/Poppool/Presentation/Admin/Data/DTO/GetAdminPopUpStoreListResponseDTO.swift @@ -24,7 +24,7 @@ struct CreatePopUpStoreRequestDTO: Encodable { let endDate: String let mainImageUrl: String let bannerYn: Bool - let imageUrlList: [String] + let imageUrlList: [String?] let latitude: Double let longitude: Double let markerTitle: String diff --git a/Poppool/Poppool/Presentation/Admin/Data/Repository/AdminRepository.swift b/Poppool/Poppool/Presentation/Admin/Data/Repository/AdminRepository.swift index 3852b068..d214077f 100644 --- a/Poppool/Poppool/Presentation/Admin/Data/Repository/AdminRepository.swift +++ b/Poppool/Poppool/Presentation/Admin/Data/Repository/AdminRepository.swift @@ -1,96 +1,111 @@ import Foundation import RxSwift +import UIKit protocol AdminRepository { - func fetchStoreList(query: String?, page: Int, size: Int) -> Observable - func fetchStoreDetail(id: Int64) -> Observable - func createStore(request: CreatePopUpStoreRequestDTO) -> Observable - func updateStore(request: UpdatePopUpStoreRequestDTO) -> Observable - func deleteStore(id: Int64) -> Observable + // 기존 메서드들 + func fetchStoreList(query: String?, page: Int, size: Int) -> Observable + func fetchStoreDetail(id: Int64) -> Observable + func createStore(request: CreatePopUpStoreRequestDTO) -> Observable + func updateStore(request: UpdatePopUpStoreRequestDTO) -> Observable + func deleteStore(id: Int64) -> Observable - func createNotice(request: CreateNoticeRequestDTO) -> Observable - func updateNotice(id: Int64, request: UpdateNoticeRequestDTO) -> Observable - func deleteNotice(id: Int64) -> Observable + // Notice + func createNotice(request: CreateNoticeRequestDTO) -> Observable + func updateNotice(id: Int64, request: UpdateNoticeRequestDTO) -> Observable + func deleteNotice(id: Int64) -> Observable } final class DefaultAdminRepository: AdminRepository { - // MARK: - Properties - private let provider: Provider - private let tokenInterceptor = TokenInterceptor() + // MARK: - Properties + private let provider: Provider + private let tokenInterceptor = TokenInterceptor() - // MARK: - Init - init(provider: Provider) { - self.provider = provider - } + // MARK: - Init + init(provider: Provider) { + self.provider = provider + } - // MARK: - Store Methods - func fetchStoreList(query: String?, page: Int, size: Int) -> Observable { - let endpoint = AdminAPIEndpoint.fetchStoreList( - query: query, - page: page, - size: size - ) - return provider.requestData( - with: endpoint, - interceptor: tokenInterceptor - ) - } + // MARK: - Store Methods + func fetchStoreList(query: String?, page: Int, size: Int) -> Observable { + let endpoint = AdminAPIEndpoint.fetchStoreList( + query: query, + page: page, + size: size + ) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } - func fetchStoreDetail(id: Int64) -> Observable { - let endpoint = AdminAPIEndpoint.fetchStoreDetail(id: id) - return provider.requestData( - with: endpoint, - interceptor: tokenInterceptor - ) - } + func fetchStoreDetail(id: Int64) -> Observable { + let endpoint = AdminAPIEndpoint.fetchStoreDetail(id: id) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } - func createStore(request: CreatePopUpStoreRequestDTO) -> Observable { - let endpoint = AdminAPIEndpoint.createStore(request: request) - return provider.requestData( - with: endpoint, - interceptor: tokenInterceptor - ) - } + func createStore(request: CreatePopUpStoreRequestDTO) -> Observable { + + Logger.log(message: "createStore API 호출 시작", category: .info) + let endpoint = AdminAPIEndpoint.createStore(request: request) + Logger.log(message: "Request URL: \(endpoint.baseURL + endpoint.path)", category: .info) + Logger.log(message: "Request Body: \(request)", category: .info) - func updateStore(request: UpdatePopUpStoreRequestDTO) -> Observable { - let endpoint = AdminAPIEndpoint.updateStore(request: request) - return provider.requestData( - with: endpoint, - interceptor: tokenInterceptor - ) - } + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ).do( + onNext: { _ in + Logger.log(message: "createStore API 호출 성공", category: .info) + }, + onError: { error in + Logger.log(message: "createStore API 호출 실패: \(error)", category: .error) + } + ) + } - func deleteStore(id: Int64) -> Observable { - let endpoint = AdminAPIEndpoint.deleteStore(id: id) - return provider.requestData( - with: endpoint, - interceptor: tokenInterceptor - ) - } + func updateStore(request: UpdatePopUpStoreRequestDTO) -> Observable { + let endpoint = AdminAPIEndpoint.updateStore(request: request) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } - // MARK: - Notice Methods - func createNotice(request: CreateNoticeRequestDTO) -> Observable { - let endpoint = AdminAPIEndpoint.createNotice(request: request) - return provider.requestData( - with: endpoint, - interceptor: tokenInterceptor - ) - } + func deleteStore(id: Int64) -> Observable { + let endpoint = AdminAPIEndpoint.deleteStore(id: id) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } - func updateNotice(id: Int64, request: UpdateNoticeRequestDTO) -> Observable { - let endpoint = AdminAPIEndpoint.updateNotice(id: id, request: request) - return provider.requestData( - with: endpoint, - interceptor: tokenInterceptor - ) - } + // MARK: - Notice Methods + func createNotice(request: CreateNoticeRequestDTO) -> Observable { + let endpoint = AdminAPIEndpoint.createNotice(request: request) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } - func deleteNotice(id: Int64) -> Observable { - let endpoint = AdminAPIEndpoint.deleteNotice(id: id) - return provider.requestData( - with: endpoint, - interceptor: tokenInterceptor - ) - } + func updateNotice(id: Int64, request: UpdateNoticeRequestDTO) -> Observable { + let endpoint = AdminAPIEndpoint.updateNotice(id: id, request: request) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } + + func deleteNotice(id: Int64) -> Observable { + let endpoint = AdminAPIEndpoint.deleteNotice(id: id) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } } diff --git a/Poppool/Poppool/Presentation/Admin/Domain/AdminUseCase.swift b/Poppool/Poppool/Presentation/Admin/Domain/AdminUseCase.swift index 3605faa8..021a6c1e 100644 --- a/Poppool/Poppool/Presentation/Admin/Domain/AdminUseCase.swift +++ b/Poppool/Poppool/Presentation/Admin/Domain/AdminUseCase.swift @@ -2,56 +2,62 @@ import Foundation import RxSwift protocol AdminUseCase { - func fetchStoreList(query: String?, page: Int, size: Int) -> Observable - func fetchStoreDetail(id: Int64) -> Observable - func createStore(request: CreatePopUpStoreRequestDTO) -> Observable - func updateStore(request: UpdatePopUpStoreRequestDTO) -> Observable - func deleteStore(id: Int64) -> Observable - - // Notice - func createNotice(request: CreateNoticeRequestDTO) -> Observable - func updateNotice(id: Int64, request: UpdateNoticeRequestDTO) -> Observable - func deleteNotice(id: Int64) -> Observable + func fetchStoreList(query: String?, page: Int, size: Int) -> Observable + func fetchStoreDetail(id: Int64) -> Observable + func createStore(request: CreatePopUpStoreRequestDTO) -> Observable + func updateStore(request: UpdatePopUpStoreRequestDTO) -> Observable + func deleteStore(id: Int64) -> Observable + + // Notice + func createNotice(request: CreateNoticeRequestDTO) -> Observable + func updateNotice(id: Int64, request: UpdateNoticeRequestDTO) -> Observable + func deleteNotice(id: Int64) -> Observable } final class DefaultAdminUseCase: AdminUseCase { - private let repository: AdminRepository - - init(repository: AdminRepository) { - self.repository = repository - } - - func fetchStoreList(query: String?, page: Int, size: Int) -> Observable { - return repository.fetchStoreList(query: query, page: page, size: size) - } - - func fetchStoreDetail(id: Int64) -> Observable { - return repository.fetchStoreDetail(id: id) - } - - func createStore(request: CreatePopUpStoreRequestDTO) -> Observable { - return repository.createStore(request: request) - } - - func updateStore(request: UpdatePopUpStoreRequestDTO) -> Observable { - return repository.updateStore(request: request) - } - - func deleteStore(id: Int64) -> Observable { - return repository.deleteStore(id: id) - } - - // Notice - func createNotice(request: CreateNoticeRequestDTO) -> Observable { - return repository.createNotice(request: request) - } - - func updateNotice(id: Int64, request: UpdateNoticeRequestDTO) -> Observable { - return repository.updateNotice(id: id, request: request) - } - - func deleteNotice(id: Int64) -> Observable { - return repository.deleteNotice(id: id) - } + private let repository: AdminRepository + + init(repository: AdminRepository) { + self.repository = repository + } + + func fetchStoreList(query: String?, page: Int, size: Int) -> Observable { + return repository.fetchStoreList(query: query, page: page, size: size) + } + + func fetchStoreDetail(id: Int64) -> Observable { + return repository.fetchStoreDetail(id: id) + } + + func createStore(request: CreatePopUpStoreRequestDTO) -> Observable { + Logger.log(message: "createStore 호출 - 요청 데이터: \(request)", category: .debug) + return repository.createStore(request: request) + .do(onNext: { _ in + Logger.log(message: "createStore 성공", category: .info) + }, onError: { error in + Logger.log(message: "createStore 실패 - Error: \(error)", category: .error) + }) + } + + func updateStore(request: UpdatePopUpStoreRequestDTO) -> Observable { + return repository.updateStore(request: request) + } + + func deleteStore(id: Int64) -> Observable { + return repository.deleteStore(id: id) + } + + // Notice + func createNotice(request: CreateNoticeRequestDTO) -> Observable { + return repository.createNotice(request: request) + } + + func updateNotice(id: Int64, request: UpdateNoticeRequestDTO) -> Observable { + return repository.updateNotice(id: id, request: request) + } + + func deleteNotice(id: Int64) -> Observable { + return repository.deleteNotice(id: id) + } } diff --git a/Poppool/Poppool/Presentation/Admin/ImageCell.swift b/Poppool/Poppool/Presentation/Admin/ImageCell.swift new file mode 100644 index 00000000..98f6fbf1 --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/ImageCell.swift @@ -0,0 +1,84 @@ +import UIKit +import SnapKit + +final class ImageCell: UICollectionViewCell { + static let identifier = "ImageCell" + + // UI + private let thumbnailImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.layer.cornerRadius = 6 + $0.clipsToBounds = true + } + + private let mainCheckButton = UIButton(type: .system).then { + $0.setTitle("대표", for: .normal) + $0.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) + $0.setTitleColor(.white, for: .normal) + $0.backgroundColor = .gray + $0.layer.cornerRadius = 4 + } + + private let deleteButton = UIButton(type: .system).then { + $0.setTitle("삭제", for: .normal) + $0.setTitleColor(.red, for: .normal) + $0.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium) + } + + // 외부에서 주입받을 콜백 + var onMainCheckToggled: (() -> Void)? + var onDeleteTapped: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupLayout() + setupActions() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayout() { + contentView.addSubview(thumbnailImageView) + contentView.addSubview(mainCheckButton) + contentView.addSubview(deleteButton) + + thumbnailImageView.snp.makeConstraints { make in + make.top.left.right.equalToSuperview() + make.height.equalTo(thumbnailImageView.snp.width) // 정사각형 + } + + mainCheckButton.snp.makeConstraints { make in + make.top.equalTo(thumbnailImageView.snp.bottom).offset(4) + make.left.equalToSuperview() + make.width.equalTo(40) + make.height.equalTo(24) + } + + deleteButton.snp.makeConstraints { make in + make.top.equalTo(thumbnailImageView.snp.bottom).offset(4) + make.right.equalToSuperview() + make.width.equalTo(40) + make.height.equalTo(24) + } + } + + private func setupActions() { + mainCheckButton.addTarget(self, action: #selector(didTapMainCheck), for: .touchUpInside) + deleteButton.addTarget(self, action: #selector(didTapDelete), for: .touchUpInside) + } + + @objc private func didTapMainCheck() { + onMainCheckToggled?() + } + + @objc private func didTapDelete() { + onDeleteTapped?() + } + + func configure(with item: ExtendedImage) { + thumbnailImageView.image = item.image + mainCheckButton.backgroundColor = item.isMain ? .systemRed : .gray + } +} diff --git a/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterReactor.swift b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterReactor.swift index 08dc3e4d..dc7a7c4f 100644 --- a/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterReactor.swift +++ b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterReactor.swift @@ -1,233 +1,183 @@ +// +// PopUpStoreRegisterReactor.swift +// Poppool +// +// Created by 김기현 on 1/14/25. +// - +import Foundation import ReactorKit import RxSwift -import Foundation import UIKit -/// 팝업스토어 등록/수정/삭제 화면의 Reactor final class PopUpStoreRegisterReactor: Reactor { // MARK: - Action enum Action { - // 기본 UI 이벤트 - case tapBack - case tapMore // "더보기" 버튼 → BottomSheet - case tapDelete // BottomSheet "삭제하기" 터치 - case tapSave // 저장 버튼 + /// 화면 최초 로드 + case viewDidLoad - // 필드 입력 + /// 사용자 입력값 갱신 case updateName(String) + case updateDesc(String) case updateCategory(String) case updateAddress(String) - case updateLatitude(String) + case updateLatitude(String) // 문자열 -> Double 변환 case updateLongitude(String) - case updateMarkerName(String) + case updateMarkerTitle(String) case updateMarkerSnippet(String) - case updateDescription(String) - - // 이미지 관련 (간단 예시) - case addImage // 새 이미지 추가 - case removeImage(Int) // 인덱스로 삭제 - case removeAllImages - case checkRepresentativeImage(Int) // 대표이미지 체크 - - // 날짜/시간 Picker 완료 - case pickedPeriod(Date, Date) - case pickedTime(Date, Date) + case updateStartDate(Date) + case updateEndDate(Date) + case updateStartTime(Date) + case updateEndTime(Date) + + /// 이미지 관련 + case addImage(ExtendedImage) // 개별 이미지 추가 + case removeImage(Int) // 특정 인덱스 이미지 삭제 + case toggleMainImage(Int) // 대표이미지 토글 + + /// "저장/등록" 버튼 탭 + case tapRegister } // MARK: - Mutation enum Mutation { - case setBack // 뒤로가기 - case setShowMore // 더보기 시트 표시 - case setDelete // 삭제 실행 - case setSaved // 저장 완료 - - // 필드 업데이트 + /// 폼 데이터 갱신 case setName(String) + case setDesc(String) case setCategory(String) case setAddress(String) - case setLatitude(String) - case setLongitude(String) - case setMarkerName(String) + case setLatitude(Double) + case setLongitude(Double) + case setMarkerTitle(String) case setMarkerSnippet(String) - case setDescription(String) - - // 이미지 업데이트 - case addImage(UIImage) - case removeImage(Int) - case removeAllImages - case checkRepresentativeImage(Int) - - // 날짜/시간 - case setPeriod(Date, Date) - case setTime(Date, Date) + case setStartDate(Date?) + case setEndDate(Date?) + case setStartTime(Date?) + case setEndTime(Date?) + + /// 이미지 변경 + case addImage(ExtendedImage) + case removeImageAt(Int) + case toggleMain(Int) + + /// 등록 성공 여부 + case setRegistered(Bool) } // MARK: - State struct State { - // (0) 계정ID → View단에서 그냥 표시 - // (3) 팝업스토어 이미지 (대표이미지) - var selectedImages: [UIImage] = [] // 간단히 UIImage 배열로 예시 - var repImageIndex: Int? = nil // 대표이미지 인덱스 (3-1) - - // (4) 이름 + // 폼 입력값 var name: String = "" - // (5) 이미지 >= 1 필수 - // (6) 카테고리 - var category: String = "게임" // 디폴트 - // (7) 위치 + var desc: String = "" + var category: String = "게임" var address: String = "" - // (7-1) 위도/경도 - var latitude: String = "" - var longitude: String = "" - // (8) 마커명 - var markerName: String = "" - // (9) 스니펫 + var latitude: Double = 0 + var longitude: Double = 0 + var markerTitle: String = "" var markerSnippet: String = "" - // (10) 기간 var startDate: Date? var endDate: Date? - // (11) 시간 var startTime: Date? var endTime: Date? - // (12) 작성자 - var writerId: String = "김채연님" // 수정 시 변경될 수도 - // (13) 작성시간 - var writtenTime: String = "" // "2025-01-08 12:30" - // (14) 상태값 - var status: String = "진행" // chip - // (15) 설명 - var desc: String = "" - // (16) 저장버튼 활성/비활성 - var canSave: Bool = false - - // 플래그/이벤트 - var showMoreSheet: Bool = false // 2-1. 더보기 시트 - var needToPop: Bool = false // setBack - var didDelete: Bool = false // 삭제 완료 → 토스트 - var didSave: Bool = false // 저장 완료 → 토스트 + + + // 이미지 목록 + var images: [ExtendedImage] = [] + + // 최종 등록 여부 + var isRegistered: Bool = false } - // MARK: - Properties - let initialState: State + // ReactorKit 필수 + let initialState: State = State() - private let useCase: AdminUseCase // or something - private let popUpStoreId: Int64? // nil이면 "등록", 값 있으면 "수정" + // 주입받는 의존성 + private let adminUseCase: AdminUseCase + + // disposeBag (mutate 안에서는 ReactorKit이 관리) + private let disposeBagInternal = DisposeBag() // MARK: - Init - init(useCase: AdminUseCase, popUpStoreId: Int64? = nil) { - self.useCase = useCase - self.popUpStoreId = popUpStoreId - // 수정 모드면 useCase.fetchStoreDetail(...) 해서 initialState 만들어도 됨 - self.initialState = State(writerId: "김채연님", writtenTime: "2025-01-08 14:30") + init(adminUseCase: AdminUseCase) { + self.adminUseCase = adminUseCase } // MARK: - mutate func mutate(action: Action) -> Observable { switch action { - case .tapBack: - return .just(.setBack) - - case .tapMore: - return .just(.setShowMore) - - case .tapDelete: - // 실제 useCase.deleteStore(...) 후 성공 시 Mutation.setDelete - // 간단 예시 - return .just(.setDelete) - - case .tapSave: - // 필수값 체크 → [이미지≥1, 이름, 카테고리, 주소, desc 등] - let st = currentState - guard st.selectedImages.count >= 1, - !st.name.isEmpty, - !st.address.isEmpty, - !st.desc.isEmpty - else { - // 유효성 실패 → 저장불가 - return .empty() - } - // 실제 UseCase.createStore or updateStore - // ... - return .just(.setSaved) + case .viewDidLoad: + // 화면 초기화 시점에 별도 로직이 필요없다면 .empty() + return .empty() + // 텍스트 입력 업데이트 case let .updateName(name): return .just(.setName(name)) + case let .updateDesc(desc): + return .just(.setDesc(desc)) + case let .updateCategory(cat): return .just(.setCategory(cat)) case let .updateAddress(addr): return .just(.setAddress(addr)) - case let .updateLatitude(lat): + case let .updateLatitude(latString): + // 문자 -> Double 변환 + let lat = Double(latString) ?? 0 return .just(.setLatitude(lat)) - case let .updateLongitude(lon): + case let .updateLongitude(lonString): + let lon = Double(lonString) ?? 0 return .just(.setLongitude(lon)) - case let .updateMarkerName(mn): - return .just(.setMarkerName(mn)) + case let .updateMarkerTitle(title): + return .just(.setMarkerTitle(title)) - case let .updateMarkerSnippet(ms): - return .just(.setMarkerSnippet(ms)) + case let .updateMarkerSnippet(snippet): + return .just(.setMarkerSnippet(snippet)) - case let .updateDescription(d): - return .just(.setDescription(d)) + case let .updateStartDate(date): + return .just(.setStartDate(date)) - // 이미지 - case .addImage: - // 임시로 UIImage(named: "dummy") 추가 - if let dummy = UIImage(named: "dummyImage") { - return .just(.addImage(dummy)) - } else { - return .empty() - } + case let .updateEndDate(date): + return .just(.setEndDate(date)) + + case let .updateStartTime(time): + return .just(.setStartTime(time)) - case let .removeImage(idx): - return .just(.removeImage(idx)) + case let .updateEndTime(time): + return .just(.setEndTime(time)) - case .removeAllImages: - return .just(.removeAllImages) + // 이미지 관련 + case let .addImage(img): + return .just(.addImage(img)) - case let .checkRepresentativeImage(idx): - return .just(.checkRepresentativeImage(idx)) + case let .removeImage(index): + return .just(.removeImageAt(index)) - // 기간/시간 - case let .pickedPeriod(s, e): - return .just(.setPeriod(s, e)) + case let .toggleMainImage(index): + return .just(.toggleMain(index)) - case let .pickedTime(st, et): - return .just(.setTime(st, et)) + // "저장" 액션 + case .tapRegister: + return doRegister() } } // MARK: - reduce func reduce(state: State, mutation: Mutation) -> State { var newState = state - switch mutation { - - case .setBack: - newState.needToPop = true - - case .setShowMore: - newState.showMoreSheet = true - - case .setDelete: - // 삭제 완료 - newState.didDelete = true - newState.needToPop = true - - case .setSaved: - newState.didSave = true - newState.needToPop = true + switch mutation { case let .setName(name): newState.name = name + case let .setDesc(desc): + newState.desc = desc + case let .setCategory(cat): newState.category = cat @@ -240,47 +190,118 @@ final class PopUpStoreRegisterReactor: Reactor { case let .setLongitude(lon): newState.longitude = lon - case let .setMarkerName(mn): - newState.markerName = mn + case let .setMarkerTitle(title): + newState.markerTitle = title - case let .setMarkerSnippet(ms): - newState.markerSnippet = ms + case let .setMarkerSnippet(snippet): + newState.markerSnippet = snippet - case let .setDescription(desc): - newState.desc = desc + case let .setStartDate(date): + newState.startDate = date + + case let .setEndDate(date): + newState.endDate = date + + case let .setStartTime(time): + newState.startTime = time + + case let .setEndTime(time): + newState.endTime = time // 이미지 case let .addImage(img): - newState.selectedImages.append(img) - case let .removeImage(idx): - guard idx < newState.selectedImages.count else { break } - newState.selectedImages.remove(at: idx) - if let rep = newState.repImageIndex, rep == idx { - newState.repImageIndex = nil - } else if let rep = newState.repImageIndex, rep > idx { - newState.repImageIndex = rep - 1 + newState.images.append(img) + + case let .removeImageAt(index): + if index >= 0 && index < newState.images.count { + newState.images.remove(at: index) + } + + case let .toggleMain(idx): + // 모든 이미지 isMain=false 후 idx만 true + for i in 0..= 1 ) return newState } + + // MARK: - Custom Method: doRegister + /// 실제 등록 로직 + private func doRegister() -> Observable { + // 1) 폼 유효성 검사 + guard validateForm() else { + // 유효성 실패시엔 Mutation 없이 .empty() (혹은 에러 Mutation) + return .empty() + } + + // 2) 대표 vs 서브 이미지 + let mainImg = currentState.images.first(where: { $0.isMain }) + ?? currentState.images.first! + let mainUrl = mainImg.filePath + let subImages = currentState.images + .filter { $0.filePath != mainUrl } + .map { $0.filePath } + + // 3) 날짜/시간 -> 문자열 변환 + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let startDateStr = currentState.startDate.map { dateFormatter.string(from: $0) } ?? "2025-01-01" + let endDateStr = currentState.endDate.map { dateFormatter.string(from: $0) } ?? "2025-12-31" + + // 4) DTO + let request = CreatePopUpStoreRequestDTO( + name: currentState.name, + categoryId: convertCategoryToId(currentState.category), + desc: currentState.desc, + address: currentState.address, + startDate: startDateStr, + endDate: endDateStr, + mainImageUrl: mainUrl, + bannerYn: false, + imageUrlList: subImages, + latitude: currentState.latitude, + longitude: currentState.longitude, + markerTitle: currentState.markerTitle, + markerSnippet: currentState.markerSnippet, + startDateBeforeEndDate: true + ) + + // 5) 서버 호출 -> 결과에 따라 Mutation + return adminUseCase.createStore(request: request) + .map { _ in Mutation.setRegistered(true) } + .catch { error in + // 에러 시 로깅/별도 처리 + return .empty() + } + .asObservable() + } + + // MARK: - validateForm() + private func validateForm() -> Bool { + // 간단 예시 + if currentState.name.isEmpty { return false } + if currentState.desc.isEmpty { return false } + if currentState.address.isEmpty { return false } + if currentState.latitude == 0 && currentState.longitude == 0 { return false } + if currentState.markerTitle.isEmpty || currentState.markerSnippet.isEmpty { return false } + // 이미지 >=1, 대표 1장 + if currentState.images.isEmpty { return false } + if !currentState.images.contains(where: { $0.isMain }) { return false } + return true + } + + /// 예시: 카테고리 문자열 -> ID 변환 (임의 로직) + private func convertCategoryToId(_ cat: String) -> Int64 { + switch cat { + case "게임": return 101 + case "라이프스타일": return 102 + default: return 100 + } + } } diff --git a/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterView.swift b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterView.swift new file mode 100644 index 00000000..55c20d1c --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterView.swift @@ -0,0 +1,424 @@ +//// +//// PopUpStoreRegisterView.swift +//// Poppool +//// +//// Created by 김기현 on 1/14/25. +//// +// +//import UIKit +//import SnapKit +//import Then +// +//final class PopUpStoreRegisterView: UIView { +// +// // MARK: - Callbacks (Closure) +// /// "이미지 추가" 버튼 탭 +// var onAddImageTapped: (() -> Void)? +// /// "전체삭제" 버튼 탭 +// var onRemoveAllTapped: (() -> Void)? +// /// 대표이미지 체크 토글 (콜렉션셀에서 index 전달) +// var onToggleMainImage: ((Int) -> Void)? +// /// 개별 이미지 삭제(index) +// var onDeleteImage: ((Int) -> Void)? +// +// /// "카테고리 선택" 버튼 탭 +// var onCategoryButtonTapped: (() -> Void)? +// /// "기간 선택" 버튼 탭 +// var onPeriodButtonTapped: (() -> Void)? +// /// "시간 선택" 버튼 탭 +// var onTimeButtonTapped: (() -> Void)? +// /// "저장" 버튼 탭 +// var onSaveTapped: (() -> Void)? +// +// // MARK: - Subviews +// // (1) 상단 "이름" 입력 필드 +// private let nameTextField = UITextField().then { +// $0.placeholder = "팝업스토어 이름을 입력해 주세요." +// $0.font = .systemFont(ofSize: 14) +// $0.textColor = .darkGray +// $0.borderStyle = .roundedRect +// } +// +// // (2) 이미지 버튼들 +// private let addImageButton = UIButton(type: .system).then { +// $0.setTitle("이미지 추가", for: .normal) +// $0.setTitleColor(.systemBlue, for: .normal) +// } +// private let removeAllButton = UIButton(type: .system).then { +// $0.setTitle("전체 삭제", for: .normal) +// $0.setTitleColor(.red, for: .normal) +// } +// +// // (3) 이미지 콜렉션뷰 +// private let imagesCollectionView: UICollectionView = { +// let layout = UICollectionViewFlowLayout() +// layout.scrollDirection = .horizontal +// layout.itemSize = CGSize(width: 80, height: 100) +// layout.minimumLineSpacing = 8 +// +// let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) +// cv.backgroundColor = .clear +// cv.register(PopUpImageCell.self, forCellWithReuseIdentifier: PopUpImageCell.identifier) +// return cv +// }() +// +// // (4) 카테고리/기간/시간 버튼 +// private let categoryButton = UIButton(type: .system).then { +// $0.setTitle("카테고리 선택 ▾", for: .normal) +// $0.setTitleColor(.darkGray, for: .normal) +// $0.titleLabel?.font = .systemFont(ofSize:14) +// $0.layer.cornerRadius = 8 +// $0.layer.borderWidth = 1 +// $0.layer.borderColor = UIColor.lightGray.cgColor +// $0.contentHorizontalAlignment = .left +// $0.contentEdgeInsets = UIEdgeInsets(top:7, left:8, bottom:7, right:8) +// } +// private let periodButton = UIButton(type: .system).then { +// $0.setTitle("기간 선택 ▾", for: .normal) +// $0.setTitleColor(.darkGray, for: .normal) +// $0.titleLabel?.font = UIFont.systemFont(ofSize:14) +// $0.layer.cornerRadius = 8 +// $0.layer.borderWidth = 1 +// $0.layer.borderColor = UIColor.lightGray.cgColor +// $0.contentHorizontalAlignment = .left +// $0.contentEdgeInsets = UIEdgeInsets(top:7, left:8, bottom:7, right:8) +// } +// private let timeButton = UIButton(type: .system).then { +// $0.setTitle("시간 선택 ▾", for: .normal) +// $0.setTitleColor(.darkGray, for: .normal) +// $0.titleLabel?.font = UIFont.systemFont(ofSize:14) +// $0.layer.cornerRadius = 8 +// $0.layer.borderWidth = 1 +// $0.layer.borderColor = UIColor.lightGray.cgColor +// $0.contentHorizontalAlignment = .left +// $0.contentEdgeInsets = UIEdgeInsets(top:7, left:8, bottom:7, right:8) +// } +// +// // (5) "저장" 버튼 +// private let saveButton = UIButton(type: .system).then { +// $0.setTitle("저장", for: .normal) +// $0.setTitleColor(.white, for: .normal) +// $0.backgroundColor = .lightGray +// $0.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .bold) +// $0.layer.cornerRadius = 8 +// $0.isEnabled = false +// } +// +// // (6) 스크롤/스택 +// private let scrollView = UIScrollView() +// private let contentView = UIView() +// private let verticalStack = UIStackView().then { +// $0.axis = .vertical +// $0.spacing = 8 +// $0.distribution = .fill +// } +// +// // MARK: - Internal Data +// /// 외부(뷰컨)에서 주입할 "이미지 목록" +// private var images: [ExtendedImage] = [] +// +// // MARK: - Public computed properties +// /// 입력한 이름 get/set +// var storeName: String { +// get { nameTextField.text ?? "" } +// set { nameTextField.text = newValue } +// } +// +// /// 현재 카테고리 버튼 타이틀 +// var categoryText: String { +// get { categoryButton.title(for: .normal) ?? "" } +// set { categoryButton.setTitle(newValue, for: .normal) } +// } +// +// // MARK: - Init +// override init(frame: CGRect) { +// super.init(frame: frame) +// setupLayout() +// setupActions() +// setupCollectionView() +// } +// +// required init?(coder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// // MARK: - Setup +// private func setupLayout() { +// backgroundColor = UIColor(white:0.95, alpha:1) +// +// // 1) 스크롤+컨텐츠 +// addSubview(scrollView) +// scrollView.snp.makeConstraints { make in +// make.top.left.right.equalToSuperview() +// make.bottom.equalToSuperview().offset(-64) // 아래 "저장"버튼을 띄우기 위해 예시 +// } +// scrollView.addSubview(contentView) +// contentView.snp.makeConstraints { make in +// make.edges.equalToSuperview() +// make.width.equalTo(scrollView.snp.width) +// } +// +// // 2) 수직스택 +// contentView.addSubview(verticalStack) +// verticalStack.snp.makeConstraints { make in +// make.top.equalToSuperview().offset(16) +// make.left.right.equalToSuperview().inset(16) +// make.bottom.equalToSuperview() +// } +// +// // (A) 이름 필드 +// let nameRow = makeRow(title: "이름", rightView: nameTextField) +// verticalStack.addArrangedSubview(nameRow) +// +// // (B) 이미지 버튼 (add/remove) +// let buttonStack = UIStackView(arrangedSubviews: [addImageButton, removeAllButton]) +// buttonStack.axis = .horizontal +// buttonStack.distribution = .fillEqually +// buttonStack.spacing = 8 +// verticalStack.addArrangedSubview(buttonStack) +// buttonStack.snp.makeConstraints { make in +// make.height.equalTo(40) +// } +// +// // (C) 콜렉션뷰 +// verticalStack.addArrangedSubview(imagesCollectionView) +// imagesCollectionView.snp.makeConstraints { make in +// make.height.equalTo(100) +// } +// +// // (D) 카테고리 버튼 +// let catRow = makeRow(title: "카테고리", rightView: categoryButton) +// verticalStack.addArrangedSubview(catRow) +// +// // (E) 기간 버튼 +// let periodRow = makeRow(title: "기간", rightView: periodButton) +// verticalStack.addArrangedSubview(periodRow) +// +// // (F) 시간 버튼 +// let timeRow = makeRow(title: "시간", rightView: timeButton) +// verticalStack.addArrangedSubview(timeRow) +// +// // (여기에 위치/마커/설명 등 다른 항목도 같은 방식으로) +// +// // 3) 저장 버튼 (화면 하단 고정) +// addSubview(saveButton) +// saveButton.snp.makeConstraints { make in +// make.left.right.equalToSuperview().inset(16) +// make.bottom.equalTo(safeAreaLayoutGuide).offset(-8) +// make.height.equalTo(44) +// } +// } +// +// private func setupActions() { +// // (1) 이미지추가 -> onAddImageTapped +// addImageButton.addTarget(self, action: #selector(tapAddImage), for: .touchUpInside) +// // (2) 전체삭제 -> onRemoveAllTapped +// removeAllButton.addTarget(self, action: #selector(tapRemoveAll), for: .touchUpInside) +// // (3) 카테고리 -> onCategoryButtonTapped +// categoryButton.addTarget(self, action: #selector(tapCategory), for: .touchUpInside) +// // (4) 기간 -> onPeriodButtonTapped +// periodButton.addTarget(self, action: #selector(tapPeriod), for: .touchUpInside) +// // (5) 시간 -> onTimeButtonTapped +// timeButton.addTarget(self, action: #selector(tapTime), for: .touchUpInside) +// // (6) 저장 -> onSaveTapped +// saveButton.addTarget(self, action: #selector(tapSave), for: .touchUpInside) +// } +// +// private func setupCollectionView() { +// imagesCollectionView.dataSource = self +// imagesCollectionView.delegate = self +// } +// +// // MARK: - Public Methods +// /// 외부에서 "이미지 목록"을 세팅할 때 사용 +// func updateImages(_ newImages: [ExtendedImage]) { +// self.images = newImages +// imagesCollectionView.reloadData() +// updateSaveButtonState() +// } +// +// /// 저장버튼 활성화 업데이트 +// private func updateSaveButtonState() { +// // 예: 이미지가 1장 이상 있을 때만 활성화 +// let hasImages = !images.isEmpty +// saveButton.isEnabled = hasImages +// saveButton.backgroundColor = hasImages ? .systemBlue : .lightGray +// } +// +// // MARK: - Actions +// @objc private func tapAddImage() { +// onAddImageTapped?() +// } +// @objc private func tapRemoveAll() { +// onRemoveAllTapped?() +// } +// @objc private func tapCategory() { +// onCategoryButtonTapped?() +// } +// @objc private func tapPeriod() { +// onPeriodButtonTapped?() +// } +// @objc private func tapTime() { +// onTimeButtonTapped?() +// } +// @objc private func tapSave() { +// onSaveTapped?() +// } +// +// // MARK: - Helpers +// private func makeRow(title: String, rightView: UIView) -> UIView { +// let row = UIView() +// +// // 왼쪽 BG +// let leftBG = UIView() +// leftBG.backgroundColor = UIColor(white:0.94, alpha:1) +// row.addSubview(leftBG) +// leftBG.snp.makeConstraints { make in +// make.top.left.bottom.equalToSuperview() +// make.width.equalTo(80) +// } +// +// // 왼쪽 라벨 +// let label = UILabel() +// label.text = title +// label.font = .systemFont(ofSize:15, weight:.bold) +// label.textColor = .black +// label.textAlignment = .center +// leftBG.addSubview(label) +// label.snp.makeConstraints { make in +// make.centerY.equalToSuperview() +// make.left.right.equalToSuperview().inset(8) +// } +// +// // 오른쪽 BG +// let rightBG = UIView() +// rightBG.backgroundColor = .white +// row.addSubview(rightBG) +// rightBG.snp.makeConstraints { make in +// make.top.bottom.right.equalToSuperview() +// make.left.equalTo(leftBG.snp.right) +// } +// +// // 오른쪽 컨텐츠 (파라미터) +// rightBG.addSubview(rightView) +// rightView.snp.makeConstraints { make in +// make.top.equalToSuperview().offset(8) +// make.bottom.equalToSuperview().offset(-8) +// make.left.equalToSuperview().offset(8) +// make.right.equalToSuperview().offset(-8) +// make.height.equalTo(36).priority(.medium) +// } +// +// // 구분선 +// let sep = UIView() +// sep.backgroundColor = UIColor.lightGray.withAlphaComponent(0.3) +// row.addSubview(sep) +// sep.snp.makeConstraints { make in +// make.left.right.bottom.equalToSuperview() +// make.height.equalTo(1) +// } +// +// return row +// } +//} +// +//// MARK: - UICollectionViewDataSource +//extension PopUpStoreRegisterView: UICollectionViewDataSource { +// func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { +// return images.count +// } +// +// func collectionView(_ collectionView: UICollectionView, +// cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { +// guard let cell = collectionView.dequeueReusableCell( +// withReuseIdentifier: PopUpImageCell.identifier, +// for: indexPath +// ) as? PopUpImageCell else { +// return UICollectionViewCell() +// } +// let item = images[indexPath.item] +// cell.configure(with: item) +// +// cell.onMainCheckToggled = { [weak self] in +// self?.onToggleMainImage?(indexPath.item) +// } +// cell.onDeleteTapped = { [weak self] in +// self?.onDeleteImage?(indexPath.item) +// } +// return cell +// } +//} +// +//// MARK: - UICollectionViewDelegateFlowLayout +//extension PopUpStoreRegisterView: UICollectionViewDelegateFlowLayout { +// // 혹시 셀 사이즈/간격을 동적으로 조정하고 싶다면 여기서 +//} +// +//// MARK: - PopUpImageCell (같은 파일) +//final class PopUpImageCell: UICollectionViewCell { +// static let identifier = "PopUpImageCell" +// +// // 콜백 +// var onMainCheckToggled: (() -> Void)? +// var onDeleteTapped: (() -> Void)? +// +// private let thumbImageView = UIImageView().then { +// $0.contentMode = .scaleAspectFill +// $0.layer.cornerRadius = 6 +// $0.clipsToBounds = true +// } +// private let mainCheckButton = UIButton(type: .system).then { +// $0.setTitle("대표", for: .normal) +// $0.setTitleColor(.white, for: .normal) +// $0.backgroundColor = .gray +// $0.titleLabel?.font = .systemFont(ofSize:12, weight:.medium) +// $0.layer.cornerRadius = 4 +// } +// private let deleteButton = UIButton(type: .system).then { +// $0.setTitle("삭제", for: .normal) +// $0.setTitleColor(.red, for: .normal) +// $0.titleLabel?.font = .systemFont(ofSize:12, weight:.medium) +// } +// +// override init(frame: CGRect) { +// super.init(frame: frame) +// contentView.addSubview(thumbImageView) +// contentView.addSubview(mainCheckButton) +// contentView.addSubview(deleteButton) +// +// thumbImageView.snp.makeConstraints { make in +// make.top.left.right.equalToSuperview() +// make.height.equalTo(thumbImageView.snp.width) +// } +// mainCheckButton.snp.makeConstraints { make in +// make.top.equalTo(thumbImageView.snp.bottom).offset(4) +// make.left.equalToSuperview() +// make.width.equalTo(40) +// make.height.equalTo(24) +// } +// deleteButton.snp.makeConstraints { make in +// make.top.equalTo(thumbImageView.snp.bottom).offset(4) +// make.right.equalToSuperview() +// make.width.equalTo(40) +// make.height.equalTo(24) +// } +// +// mainCheckButton.addTarget(self, action: #selector(didTapMainCheck), for: .touchUpInside) +// deleteButton.addTarget(self, action: #selector(didTapDelete), for: .touchUpInside) +// } +// required init?(coder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// @objc private func didTapMainCheck() { +// onMainCheckToggled?() +// } +// @objc private func didTapDelete() { +// onDeleteTapped?() +// } +// +// func configure(with item: ExtendedImage) { +// thumbImageView.image = item.image +// mainCheckButton.backgroundColor = item.isMain ? .systemRed : .gray +// } +//} diff --git a/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift index 9f6cf620..b0bdcd71 100644 --- a/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift +++ b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift @@ -1,10 +1,62 @@ import UIKit import SnapKit +import ReactorKit +import RxSwift +import RxCocoa +import PhotosUI +import Alamofire + +final class PopUpStoreRegisterViewController: BaseViewController { +// typealias Reactor = PopUpStoreRegisterReactor -final class PopUpStoreRegisterViewController: UIViewController { // MARK: - Navigation/Header - private let navContainer = UIView() + + private var selectedImages: [UIImage] = [] + private var selectedMainImageIndex: Int? + private var imageFileNames: [String] = [] // S3 업로드 후의 파일명 저장 + private var images: [ExtendedImage] = [] + private var pickerViewController: PHPickerViewController? + private let adminUseCase: AdminUseCase + private var nameField: UITextField? + private var addressField: UITextField? + private var latField: UITextField? + private var lonField: UITextField? + private var descTV: UITextView? + + + private let popupName: String = "" + + var disposeBag = DisposeBag() + private let nickname: String + private let navContainer = UIView() + + init(nickname: String, adminUseCase: AdminUseCase) { + self.nickname = nickname + self.adminUseCase = adminUseCase + super.init() + self.accountIdLabel.text = nickname + "님" + } + + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + private lazy var imagesCollectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.itemSize = CGSize(width: 80, height: 120) + layout.minimumLineSpacing = 8 + + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.backgroundColor = .clear + cv.register(ImageCell.self, forCellWithReuseIdentifier: ImageCell.identifier) + cv.dataSource = self + cv.delegate = self + return cv + }() private let logoImageView: UIImageView = { let iv = UIImageView() @@ -14,12 +66,12 @@ final class PopUpStoreRegisterViewController: UIViewController { }() private let accountIdLabel: UILabel = { - let lbl = UILabel() - lbl.text = "김채연님" - lbl.font = UIFont.systemFont(ofSize: 14, weight: .semibold) - lbl.textColor = .black - return lbl - }() + let lbl = UILabel() + lbl.font = UIFont.systemFont(ofSize: 14, weight: .semibold) + lbl.textColor = .black + return lbl + }() + private let menuButton: UIButton = { let btn = UIButton(type: .system) @@ -30,7 +82,6 @@ final class PopUpStoreRegisterViewController: UIViewController { // MARK: - Title (Back button + label) private let titleContainer = UIView() - private let backButton: UIButton = { let btn = UIButton(type: .system) btn.setImage(UIImage(systemName: "chevron.left"), for: .normal) @@ -53,6 +104,16 @@ final class PopUpStoreRegisterViewController: UIViewController { iv.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2) return iv }() + private let addImageButton = UIButton(type: .system).then { + $0.setTitle("이미지 추가", for: .normal) + $0.setTitleColor(.systemBlue, for: .normal) + } + + // 전체 삭제 버튼 (필요 없다면 제거) + private let removeAllButton = UIButton(type: .system).then { + $0.setTitle("전체 삭제", for: .normal) + $0.setTitleColor(.red, for: .normal) + } // MARK: - Scroll private let scrollView = UIScrollView() @@ -81,30 +142,100 @@ final class PopUpStoreRegisterViewController: UIViewController { btn.isEnabled = false return btn }() + // MARK: - DateTimePicker private var selectedStartDate: Date? - private var selectedEndDate: Date? - private var selectedStartTime: Date? - private var selectedEndTime: Date? + private var selectedEndDate: Date? + private var selectedStartTime: Date? + private var selectedEndTime: Date? + + // MARK: - Categories + private var categories: [String] = ["게임", "라이프스타일", "반려동물", "뷰티", "스포츠", "애니메이션", "엔터테인먼트", "여행", "예술", "음식/요리", "키즈", "패션"] + + // MARK: - UI Elements + private let categoryButton: UIButton = { + let btn = UIButton(type: .system) + btn.setTitle("카테고리 선택 ▾", for: .normal) + btn.setTitleColor(.darkGray, for: .normal) + btn.titleLabel?.font = UIFont.systemFont(ofSize:14) + btn.layer.cornerRadius = 8 + btn.layer.borderWidth = 1 + btn.layer.borderColor = UIColor.lightGray.cgColor + btn.contentHorizontalAlignment = .left + btn.contentEdgeInsets = UIEdgeInsets(top:7, left:8, bottom:7, right:8) + return btn + }() + + private let periodButton: UIButton = { + let btn = UIButton(type: .system) + btn.setTitle("기간 선택 ▾", for: .normal) + btn.setTitleColor(.darkGray, for: .normal) + btn.titleLabel?.font = UIFont.systemFont(ofSize:14) + btn.layer.cornerRadius = 8 + btn.layer.borderWidth = 1 + btn.layer.borderColor = UIColor.lightGray.cgColor + btn.contentHorizontalAlignment = .left + btn.contentEdgeInsets = UIEdgeInsets(top:7, left:8, bottom:7, right:8) + return btn + }() + + private let timeButton: UIButton = { + let btn = UIButton(type: .system) + btn.setTitle("시간 선택 ▾", for: .normal) + btn.setTitleColor(.darkGray, for: .normal) + btn.titleLabel?.font = UIFont.systemFont(ofSize:14) + btn.layer.cornerRadius = 8 + btn.layer.borderWidth = 1 + btn.layer.borderColor = UIColor.lightGray.cgColor + btn.contentHorizontalAlignment = .left + btn.contentEdgeInsets = UIEdgeInsets(top:7, left:8, bottom:7, right:8) + return btn + }() // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(white:0.95, alpha:1) + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + tapGesture.cancelsTouchesInView = false + view.addGestureRecognizer(tapGesture) + + setupNavigation() setupLayout() setupRows() + setupImageCollectionUI() + setupImageCollectionActions() + updateSaveButtonState() + + } + // MARK: - Navigation private func setupNavigation() { backButton.addTarget(self, action: #selector(onBack), for: .touchUpInside) } + @objc private func handleTap() { + view.endEditing(true) + } @objc private func onBack() { navigationController?.popViewController(animated: true) } + @objc private func fieldDidChange(_ textField: UITextField) { + if textField == addressField { + Logger.log(message: "주소 값 변경: \(textField.text ?? "nil")", category: .debug) + } else if textField == latField { + Logger.log(message: "위도 값 변경: \(textField.text ?? "nil")", category: .debug) + } else if textField == lonField { + Logger.log(message: "경도 값 변경: \(textField.text ?? "nil")", category: .debug) + updateSaveButtonState() + + } + } + // MARK: - Layout private func setupLayout() { @@ -205,25 +336,35 @@ final class PopUpStoreRegisterViewController: UIViewController { // MARK: - Setup Rows private func setupRows() { - // 예시: 이름, 이미지, 카테고리... addRowTextField(leftTitle: "이름", placeholder: "팝업스토어 이름을 입력해 주세요.") addRowTextField(leftTitle: "이미지", placeholder: "팝업스토어 대표 이미지를 업로드 해주세요.") - let catBtn = makeRoundedButton("카테고리 선택 ▾") - addRowCustom(leftTitle: "카테고리", rightView: catBtn) + + categoryButton.addTarget(self, action: #selector(didTapCategoryButton), for: .touchUpInside) + addRowCustom(leftTitle: "카테고리", rightView: categoryButton) // (위치) => 2줄 // 1) 주소 (TextField) let addressField = makeRoundedTextField("팝업스토어 주소를 입력해 주세요.") + self.addressField = addressField addressField.snp.makeConstraints { make in + addressField.addTarget(self, action: #selector(fieldDidChange(_:)), for: .editingChanged) + } // 2) (위도 Label + TF) + (경도 Label + TF) let latLabel = makePlainLabel("위도") let latField = makeRoundedTextField("") latField.textAlignment = .center + self.latField = latField // latField와 연결 + latField.addTarget(self, action: #selector(fieldDidChange(_:)), for: .editingChanged) + + let lonLabel = makePlainLabel("경도") let lonField = makeRoundedTextField("") + self.lonField = lonField // lonField와 연결 lonField.textAlignment = .center + lonField.addTarget(self, action: #selector(fieldDidChange(_:)), for: .editingChanged) + let latStack = UIStackView(arrangedSubviews: [latLabel, latField]) latStack.axis = .horizontal @@ -278,19 +419,15 @@ final class PopUpStoreRegisterViewController: UIViewController { addRowCustom(leftTitle: "마커", rightView: markerVStack, rowHeight: nil, totalHeight: 80) // (10) 기간 - let periodBtn = makeIconButton("", iconName: "date") - periodBtn.addTarget(self, action: #selector(didTapPeriodButton), for: .touchUpInside) - - addRowCustom(leftTitle: "기간", rightView: periodBtn) + periodButton.addTarget(self, action: #selector(didTapPeriodButton), for: .touchUpInside) + addRowCustom(leftTitle: "기간", rightView: periodButton) // (11) 시간 - let timeBtn = makeIconButton("", iconName: "due") - timeBtn.addTarget(self, action: #selector(didTapTimeButton), for: .touchUpInside) - addRowCustom(leftTitle: "시간", rightView: timeBtn) - + timeButton.addTarget(self, action: #selector(didTapTimeButton), for: .touchUpInside) + addRowCustom(leftTitle: "시간", rightView: timeButton) // (12) 작성자 - let writerLbl = makeSimpleLabel("김채연") + let writerLbl = makeSimpleLabel(nickname) addRowCustom(leftTitle: "작성자", rightView: writerLbl) // (13) 작성시간 @@ -303,14 +440,95 @@ final class PopUpStoreRegisterViewController: UIViewController { // (15) 설명 let descTV = makeRoundedTextView() + self.descTV = descTV // 설명 필드 연결 addRowCustom(leftTitle: "설명", rightView: descTV, rowHeight: nil, totalHeight: 120) + } // MARK: - Row + private func addRowTextField(leftTitle: String, placeholder: String) { let tf = makeRoundedTextField(placeholder) - addRowCustom(leftTitle: leftTitle, rightView: tf) + if leftTitle == "이름" { + nameField = tf // 이름 필드 연결 + } else if leftTitle == "주소" { + addressField = tf // 주소 필드 연결 + } + addRowCustom(leftTitle: leftTitle, rightView: tf) + } + + private func setupImageCollectionUI() { + // 1) 상단 버튼들 (Add / RemoveAll) + let buttonStack = UIStackView(arrangedSubviews: [addImageButton, removeAllButton]) + buttonStack.axis = .horizontal + buttonStack.distribution = .fillEqually + buttonStack.spacing = 16 + + contentView.addSubview(buttonStack) + buttonStack.snp.makeConstraints { make in + make.top.equalTo(mainImageView.snp.bottom).offset(16) + make.left.right.equalToSuperview().inset(16) + make.height.equalTo(40) + } + + // 2) CollectionView + contentView.addSubview(imagesCollectionView) + imagesCollectionView.snp.makeConstraints { make in + make.top.equalTo(buttonStack.snp.bottom).offset(8) + make.left.right.equalToSuperview().inset(16) + make.height.equalTo(130) // 셀 높이(120) + 패딩 + } + + // formBackgroundView를 아래로 조금 내려야 한다면? + formBackgroundView.snp.remakeConstraints { make in + make.top.equalTo(imagesCollectionView.snp.bottom).offset(16) + make.left.right.equalToSuperview().inset(16) + make.bottom.equalToSuperview() + } + } + private func setupImageCollectionActions() { + // (1) 이미지 추가 버튼 -> 앨범 열기 + addImageButton.rx.tap + .bind { [weak self] in + self?.showImagePicker() + } + .disposed(by: disposeBag) + + // (2) 전체 삭제 버튼 + removeAllButton.rx.tap + .bind { [weak self] in + self?.images.removeAll() + self?.imagesCollectionView.reloadData() + self?.updateSaveButtonState() + } + .disposed(by: disposeBag) + + saveButton.rx.tap + .bind { [weak self] in + guard let self = self else { return } + // 1) 유효성 검사 + if self.validateForm() { + // 2) OK -> 등록 로직 + self.doRegister() + } else { + // 3) 실패 -> Alert/toast + let alert = UIAlertController( + title: "필수값 미입력", + message: "필수 항목을 모두 입력해 주세요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + self.present(alert, animated: true, completion: nil) + } + } + .disposed(by: disposeBag) + } + // 저장 버튼 활성화 여부 갱신 + private func updateSaveButtonState() { + let isFormValid = validateForm() // 폼 유효성 검사 결과 + saveButton.isEnabled = isFormValid + saveButton.backgroundColor = isFormValid ? .systemBlue : .lightGray } /** @@ -382,6 +600,7 @@ final class PopUpStoreRegisterViewController: UIViewController { verticalStack.addArrangedSubview(row) } + @objc private func didTapPeriodButton() { DateTimePickerManager.shared.showDateRange(on: self) { start, end in // 여기서 ViewController는 날짜 2개만 받고, UI 업데이트 @@ -406,11 +625,7 @@ final class PopUpStoreRegisterViewController: UIViewController { let sStr = df.string(from: s) let eStr = df.string(from: e) - // verticalStack 안에서 "기간" 라벨 있는 row 찾아서, or 그냥 recall the same button if you stored it: - // For simplicity, let's re-scan or keep a reference - if let periodBtn = findButtonByIconName("date") { - periodBtn.setTitle("\(sStr) ~ \(eStr)", for: .normal) - } + periodButton.setTitle("\(sStr) ~ \(eStr)", for: .normal) } private func updateTimeButtonTitle() { @@ -419,28 +634,91 @@ final class PopUpStoreRegisterViewController: UIViewController { df.dateFormat = "HH:mm" let stStr = df.string(from: st) let etStr = df.string(from: et) - if let timeBtn = findButtonByIconName("due") { - timeBtn.setTitle("\(stStr) ~ \(etStr)", for: .normal) + timeButton.setTitle("\(stStr) ~ \(etStr)", for: .normal) + } + + // MARK: - Category Selection + + @objc private func didTapCategoryButton() { + let alertController = UIAlertController(title: "카테고리 선택", message: nil, preferredStyle: .actionSheet) + + // 기존 카테고리 목록 추가 + for category in categories { + let action = UIAlertAction(title: category, style: .default) { [weak self] _ in + self?.updateCategoryButtonTitle(with: category) + } + alertController.addAction(action) } + + // '카테고리 추가' 옵션 추가 + let addAction = UIAlertAction(title: "카테고리 추가", style: .default) { [weak self] _ in + self?.presentAddCategoryAlert() + } + alertController.addAction(addAction) + + // 취소 버튼 추가 + let cancelAction = UIAlertAction(title: "취소", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + + // iPad에서 액션 시트가 크래시되지 않도록 설정 + if let popoverController = alertController.popoverPresentationController { + popoverController.sourceView = categoryButton + popoverController.sourceRect = categoryButton.bounds + } + + present(alertController, animated: true, completion: nil) } - // MARK: - helper to find icon button - private func findButtonByIconName(_ iconName: String) -> UIButton? { - for rowView in verticalStack.arrangedSubviews { - for sub in rowView.subviews { - for sub2 in sub.subviews { - if let btn = sub2 as? UIButton, - let image = btn.image(for: .normal), - image == UIImage(named: iconName) { - return btn - } + private func presentAddCategoryAlert() { + let alert = UIAlertController(title: "새 카테고리 추가", message: "추가할 카테고리 이름을 입력하세요.", preferredStyle: .alert) + + alert.addTextField { textField in + textField.placeholder = "카테고리 이름" + } + + let addAction = UIAlertAction(title: "추가", style: .default) { [weak self] _ in + guard let self = self else { return } + if let newCategory = alert.textFields?.first?.text?.trimmingCharacters(in: .whitespacesAndNewlines), !newCategory.isEmpty { + // 중복 체크 + if self.categories.contains(newCategory) { + self.presentDuplicateCategoryAlert() + } else { + self.categories.append(newCategory) + self.updateCategoryButtonTitle(with: newCategory) } } } - return nil + + let cancelAction = UIAlertAction(title: "취소", style: .cancel, handler: nil) + + alert.addAction(addAction) + alert.addAction(cancelAction) + + present(alert, animated: true, completion: nil) + } + private func showImagePicker() { + // 1) PHPicker 설정 + var configuration = PHPickerConfiguration() + configuration.filter = .images // 이미지만 + configuration.selectionLimit = 0 // 0이면 무제한, 혹은 10, 5 등 제한 가능 + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + self.pickerViewController = picker + + // 2) 모달 표시 + present(picker, animated: true, completion: nil) } + private func presentDuplicateCategoryAlert() { + let alert = UIAlertController(title: "중복", message: "이미 존재하는 카테고리입니다.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "확인", style: .default, handler: nil)) + present(alert, animated: true, completion: nil) + } + private func updateCategoryButtonTitle(with category: String) { + categoryButton.setTitle("\(category) ▾", for: .normal) + } // MARK: - UI Helpers private func makeRoundedTextField(_ placeholder: String) -> UITextField { @@ -519,3 +797,316 @@ private extension UITextField { leftViewMode = .always } } +extension PopUpStoreRegisterViewController: UICollectionViewDataSource, UICollectionViewDelegate { + // 몇 개의 셀? + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return images.count + } + + // 셀 구성 + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: ImageCell.identifier, + for: indexPath + ) as? ImageCell else { + return UICollectionViewCell() + } + + let item = images[indexPath.item] + cell.configure(with: item) + + // 대표이미지 변경 + cell.onMainCheckToggled = { [weak self] in + self?.toggleMainImage(index: indexPath.item) + } + // 개별 삭제 + cell.onDeleteTapped = { [weak self] in + self?.deleteImage(index: indexPath.item) + } + + return cell + } +} + +// 헬퍼 메서드들 +private extension PopUpStoreRegisterViewController { + /// 대표이미지를 단 하나만 허용 -> 누른 index만 isMain = true + func toggleMainImage(index: Int) { + for i in 0.. Bool { + // (1) 팝업스토어 이름 + Logger.log(message: "nameField.text = \(nameField?.text ?? "nil")", category: .debug) + guard let nameField = nameField, !(nameField.text ?? "").isEmpty else { + Logger.log( + message: "이름 필드가 비어 있습니다.", + category: .debug, + fileName: #file, + line: #line + ) + return false + } + + // (2) 카테고리 선택 + if categoryButton.title(for: .normal) == "카테고리 선택 ▾" { + Logger.log( + message: "카테고리가 선택되지 않았습니다.", + category: .debug, + fileName: #file, + line: #line + ) + return false + } + + // (3) 주소 + Logger.log(message: "addressField = \(addressField != nil ? "초기화됨" : "nil")", category: .debug) + Logger.log(message: "addressField.text = \(addressField?.text ?? "nil")", category: .debug) + guard let addressField = addressField, !(addressField.text ?? "").isEmpty else { + Logger.log(message: "주소 필드가 비어 있습니다.", category: .debug) + return false + } + + + // (4) 위도/경도 + Logger.log(message: "latField.text = \(latField?.text ?? "nil")", category: .debug) + Logger.log(message: "lonField.text = \(lonField?.text ?? "nil")", category: .debug) + guard let latField = latField, + let lonField = lonField, + let latText = latField.text, !latText.isEmpty, + let lonText = lonField.text, !lonText.isEmpty, + let latVal = Double(latText), let lonVal = Double(lonText), + latVal != 0 || lonVal != 0 else { + Logger.log( + message: "위도/경도 값이 잘못되었습니다.", + category: .debug, + fileName: #file, + line: #line + ) + return false + } + + // (5) 설명 + guard let descTV = descTV, !(descTV.text ?? "").isEmpty else { + Logger.log( + message: "설명 필드가 비어 있습니다.", + category: .debug, + fileName: #file, + line: #line + ) + return false + } + + // (6) 이미지 ≥ 1장 + if images.isEmpty { + Logger.log( + message: "이미지가 추가되지 않았습니다.", + category: .debug, + fileName: #file, + line: #line + ) + return false + } + + // (7) 대표 이미지 설정 여부 + if !images.contains(where: { $0.isMain }) { + Logger.log( + message: "대표 이미지가 설정되지 않았습니다.", + category: .debug, + fileName: #file, + line: #line + ) + return false + } + + Logger.log( + message: "모든 조건이 충족되었습니다.", + category: .info, + fileName: #file, + line: #line + ) + return true + } + private func doRegister() { + Logger.log(message: "doRegister() 호출됨", category: .debug) + + // 1. 폼 데이터 검증 + guard validateFormData() else { return } + + // 2. 이미지 업로드 실행 + uploadImages() + } + + // 폼 데이터 검증 + private func validateFormData() -> Bool { + guard let name = nameField?.text, + let address = addressField?.text, + let latitude = latField?.text, Double(latitude) != nil, + let longitude = lonField?.text, Double(longitude) != nil, + let description = descTV?.text, + !images.isEmpty else { + Logger.log(message: "폼 데이터 검증 실패", category: .error) + return false + } + Logger.log(message: "폼 데이터 검증 성공", category: .debug) + return true + } + + // 이미지 업로드 + private func uploadImages() { + let uuid = UUID().uuidString + let baseS3URL = Secrets.popPoolS3BaseURL.rawValue + let updatedImages = images.enumerated().map { index, image in + let filePath = "PopUpImage/\(nameField?.text ?? "")/\(uuid)/\(index).jpg" + return ExtendedImage(filePath: filePath, image: image.image, isMain: image.isMain) + } + + let presignedService = PreSignedService() + presignedService.tryUpload(datas: updatedImages.map { + PreSignedService.PresignedURLRequest(filePath: $0.filePath, image: $0.image) + }) + .observe(on: MainScheduler.instance) + .subscribe( + onSuccess: { [weak self] _ in + guard let self = self else { return } + Logger.log(message: "이미지 업로드 성공", category: .info) + + let imagePaths = updatedImages.map { baseS3URL + $0.filePath } + let mainImage = updatedImages.first { $0.isMain }?.filePath ?? "" + self.callCreateStoreAPI(mainImage: baseS3URL + mainImage, imagePaths: imagePaths) + }, + onError: { error in + Logger.log(message: "이미지 업로드 실패: \(error.localizedDescription)", category: .error) + self.showErrorAlert(message: "이미지 업로드 실패: \(error.localizedDescription)") + } + ) + .disposed(by: disposeBag) + } + + // createStore API 호출 + private func callCreateStoreAPI(mainImage: String, imagePaths: [String]) { + guard let name = nameField?.text, + let address = addressField?.text, + let latitude = Double(latField?.text ?? ""), + let longitude = Double(lonField?.text ?? ""), + let description = descTV?.text, + let categoryTitle = categoryButton.title(for: .normal)?.replacingOccurrences(of: " ▾", with: "") else { + Logger.log(message: "필수 입력값이 비어 있음", category: .error) + return + } + + let request = CreatePopUpStoreRequestDTO( + name: name, + categoryId: Int64(getCategoryId(from: categoryTitle)), + desc: description, + address: address, + startDate: getFormattedDate(from: selectedStartDate), + endDate: getFormattedDate(from: selectedEndDate), + mainImageUrl: mainImage, + bannerYn: false, + imageUrlList: imagePaths, + latitude: latitude, + longitude: longitude, + markerTitle: "마커 제목", + markerSnippet: "마커 설명", + startDateBeforeEndDate: true + ) + + adminUseCase.createStore(request: request) + .subscribe( + onNext: { [weak self] _ in + Logger.log(message: "createStore API 호출 성공", category: .info) + self?.showSuccessAlert() + }, + onError: { [weak self] error in + Logger.log(message: "createStore API 호출 실패: \(error.localizedDescription)", category: .error) + self?.showErrorAlert(message: error.localizedDescription) + } + ) + .disposed(by: disposeBag) + } + + private func getCategoryId(from title: String) -> Int { + return categories.firstIndex(of: title) ?? 1 + } + + private func getFormattedDate(from date: Date?) -> String { + guard let date = date else { return "2025-01-14T09:00:00.000Z" } + let formatter = ISO8601DateFormatter() + return formatter.string(from: date) + } + + private func showSuccessAlert() { + let alert = UIAlertController( + title: "등록 성공", + message: "팝업스토어가 성공적으로 등록되었습니다.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + present(alert, animated: true) + } + + private func showErrorAlert(message: String) { + let alert = UIAlertController( + title: "등록 실패", + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + present(alert, animated: true) + } +} diff --git a/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonBackgroundView.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonBackgroundView.swift index aeeb9e01..5f25b783 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonBackgroundView.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonBackgroundView.swift @@ -2,10 +2,12 @@ import UIKit import SnapKit final class BalloonBackgroundView: UIView { + // MARK: - UI Components + private let containerView: UIView = { let view = UIView() - view.backgroundColor = .g50 +// view.backgroundColor = .g200 view.layer.cornerRadius = 8 return view }() @@ -40,6 +42,7 @@ final class BalloonBackgroundView: UIView { }() // MARK: - Properties + var arrowPosition: CGFloat = 0.6 { didSet { setNeedsLayout() @@ -55,6 +58,7 @@ final class BalloonBackgroundView: UIView { private var tagSection: TagSection? // MARK: - Init + override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear @@ -65,6 +69,7 @@ final class BalloonBackgroundView: UIView { required init?(coder: NSCoder) { fatalError() } // MARK: - Setup + private func setupLayout() { addSubview(containerView) containerView.addSubview(collectionView) @@ -85,24 +90,45 @@ final class BalloonBackgroundView: UIView { } // MARK: - Draw arrow - override func draw(_ rect: CGRect) { - super.draw(rect) - - let arrowWidth: CGFloat = 16 - let arrowHeight: CGFloat = 10 - let arrowX = bounds.width * arrowPosition - (arrowWidth / 2) - - let path = UIBezierPath() - path.move(to: CGPoint(x: arrowX, y: 8)) - path.addLine(to: CGPoint(x: arrowX + arrowWidth, y: 8)) - path.addLine(to: CGPoint(x: arrowX + (arrowWidth / 2), y: 0)) - path.close() - UIColor.g50.set() - path.fill() - } + override func draw(_ rect: CGRect) { + super.draw(rect) + + let arrowWidth: CGFloat = 16 + let arrowHeight: CGFloat = 10 + let arrowX = bounds.width * arrowPosition - (arrowWidth / 2) + + // 통합된 하나의 패스로 그리기 + let path = UIBezierPath() + + // 화살표 시작점부터 그리기 시작 + path.move(to: CGPoint(x: arrowX, y: arrowHeight)) + path.addLine(to: CGPoint(x: arrowX + (arrowWidth / 2), y: 0)) // 화살표 꼭지점 + path.addLine(to: CGPoint(x: arrowX + arrowWidth, y: arrowHeight)) + + // 말풍선 본체 그리기 + let containerRect = CGRect(x: 0, y: arrowHeight, + width: bounds.width, + height: bounds.height - arrowHeight) + path.addLine(to: CGPoint(x: containerRect.maxX, y: containerRect.minY)) + path.addLine(to: CGPoint(x: containerRect.maxX, y: containerRect.maxY)) + path.addLine(to: CGPoint(x: containerRect.minX, y: containerRect.maxY)) + path.addLine(to: CGPoint(x: containerRect.minX, y: containerRect.minY)) + path.close() + + // 전체를 하나의 색으로 채우기 + UIColor.g50.setFill() + path.fill() + + // 필요한 경우 그림자 추가 + self.layer.shadowColor = UIColor.black.cgColor + self.layer.shadowOpacity = 0.1 + self.layer.shadowOffset = CGSize(width: 0, height: 2) + self.layer.shadowRadius = 4 + } // MARK: - Public + func configure( with subRegions: [String], selectedRegions: [String] = [], @@ -116,21 +142,28 @@ final class BalloonBackgroundView: UIView { self.selectionHandler = selectionHandler self.allSelectionHandler = allSelectionHandler - var inputDataList = ["\(mainRegionTitle)전체"] - inputDataList.append(contentsOf: subRegions) + let allKey = "\(mainRegionTitle)전체" + var inputDataList = [allKey] + + // 선택된 항목들을 앞쪽에 배치 + let selectedSubRegions = selectedRegions.filter { $0 != allKey } + let unselectedSubRegions = subRegions.filter { !selectedRegions.contains($0) } + + inputDataList.append(contentsOf: selectedSubRegions) + inputDataList.append(contentsOf: unselectedSubRegions) self.tagSection = TagSection( - inputDataList: inputDataList.map { subRegion in - TagSectionCell.Input( - title: subRegion, - isSelected: subRegion == "\(mainRegionTitle)전체" - ? selectedRegions.count == subRegions.count + inputDataList: inputDataList + .map { subRegion in + TagSectionCell.Input( + title: subRegion, + isSelected: subRegion == allKey + ? selectedRegions.count == subRegions.count || selectedRegions.contains(allKey) : selectedRegions.contains(subRegion) - ) - } + ) + } ) - // UICollectionView 데이터 갱신 collectionView.reloadData() collectionView.layoutIfNeeded() @@ -142,6 +175,7 @@ final class BalloonBackgroundView: UIView { self.layoutIfNeeded() } + func calculateHeight() -> CGFloat { guard let inputDataList = tagSection?.inputDataList else { return 0 } @@ -157,28 +191,12 @@ final class BalloonBackgroundView: UIView { for input in inputDataList { // 버튼 너비 계산 let buttonWidth = calculateButtonWidth(for: input.title ?? "", font: .systemFont(ofSize: 12), isSelected: input.isSelected ?? false) -// print("DEBUG - Calculated Button Width: \(buttonWidth)") if currentRowWidth + buttonWidth + horizontalSpacing > availableWidth { -// print(""" -// DEBUG - 줄바꿈 발생: -// 버튼 길이: \(buttonWidth), -// 버튼 간 패딩: \(horizontalSpacing), -// 현재 줄 누적 너비: \(currentRowWidth), -// 가용 너비: \(availableWidth), -// 새로운 줄 시작. -// """) numberOfRows += 1 currentRowWidth = buttonWidth } else { currentRowWidth += buttonWidth + horizontalSpacing -// print(""" -// DEBUG - 현재 줄에 추가: -// 버튼 길이: \(buttonWidth), -// 버튼 간 패딩: \(horizontalSpacing), -// 현재 줄 누적 너비: \(currentRowWidth), -// 가용 너비: \(availableWidth). -// """) } } @@ -193,9 +211,9 @@ final class BalloonBackgroundView: UIView { 36 ) -// print("DEBUG - Total Calculated Height: \(totalHeight)") return totalHeight } + private func calculateButtonWidth(for text: String, font: UIFont, isSelected: Bool) -> CGFloat { let textWidth = (text as NSString).size(withAttributes: [.font: font]).width let iconWidth: CGFloat = isSelected ? 16 : 0 @@ -205,22 +223,18 @@ final class BalloonBackgroundView: UIView { let calculatedWidth = textWidth + iconWidth + iconGap + horizontalPadding - // 디버깅 출력 -// print("DEBUG - 텍스트: \(text), 선택 상태: \(isSelected), 최종 버튼 너비: \(calculatedWidth)") - return calculatedWidth } } // MARK: - UICollectionViewDataSource extension BalloonBackgroundView: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return tagSection?.inputDataList.count ?? 0 } - func collectionView(_ collectionView: UICollectionView, - cellForItemAt indexPath: IndexPath - ) -> UICollectionViewCell { + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell( withReuseIdentifier: BalloonChipCell.identifier, diff --git a/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonChipCell.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonChipCell.swift index f7548df1..f2d1fbd5 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonChipCell.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonChipCell.swift @@ -51,7 +51,7 @@ final class BalloonChipCell: UICollectionViewCell { button.setImage(nil, for: .normal) button.semanticContentAttribute = .unspecified button.imageEdgeInsets = .zero - button.contentEdgeInsets = UIEdgeInsets(top: 7, left: 12, bottom: 7, right: 10) + button.contentEdgeInsets = UIEdgeInsets(top: 7, left: 12, bottom: 7, right: 12) button.setBackgroundColor(.white, for: .normal) button.setTitleColor(.g400, for: .normal) button.layer.borderWidth = 1 diff --git a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetReactor.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetReactor.swift index 123e1599..a84fa801 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetReactor.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetReactor.swift @@ -1,4 +1,5 @@ import ReactorKit +import Foundation import RxSwift struct Location: Equatable { @@ -15,24 +16,20 @@ final class FilterBottomSheetReactor: Reactor { case toggleSubRegion(String) case toggleCategory(String) case toggleAllSubRegions - - - } + enum Mutation { case setActiveSegment(Int) case resetFilters case applyFilters([String]) case updateSelectedLocation(Int) - case updateSubRegions([String]) case toggleSubRegionSelection(String) case toggleCategorySelection(String) case toggleAllSubRegions case updateSavedSubRegions([String]) case updateSavedCategories([String]) - - } + struct State { var activeSegment: Int var selectedLocationIndex: Int? @@ -45,38 +42,27 @@ final class FilterBottomSheetReactor: Reactor { var isSaveEnabled: Bool { return !selectedSubRegions.isEmpty || !selectedCategories.isEmpty } - } let initialState: State init() { + let initialLocations: [Location] = [ + Location(main: "서울", sub: ["강남/역삼/선릉", "건대/군자/구의", "강북/목동/신촌", "명동/을지로/종로", "방이", "복촌/삼정", "상수/대치", "상수/현정/광원"]), + Location(main: "경기", sub: ["수원시", "성남시", "용인시", "용인시용인시", "용인시", "용인시"]), + Location(main: "인천", sub: ["부평", "송도"]), + Location(main: "부산", sub: ["해운대", "광안리", "사상구", "사하구", "북구", "남구"]), + Location(main: "제주", sub: ["제주시", "서귀포시"]), + Location(main: "광주", sub: ["동구", "서구", "남구", "북구", "광산구"]) + ] + self.initialState = State( activeSegment: 0, selectedLocationIndex: nil, selectedSubRegions: [], selectedCategories: [], - locations: [ - Location(main: "서울", sub: [ - "강남/역삼/선릉", "건대/군자/구의", "강북/목동/신촌", - "명동/을지로/종로", "방이", "복촌/삼정", - "상수/대치", "상수/현정/광원" - ]), - Location(main: "경기", sub: ["수원시", "성남시", "용인시","용인시용인시","용인시","용인시"]), - Location(main: "인천", sub: ["부평", "송도"]), - Location(main: "부산", sub: [ - "해운대", "광안리", "사상구", - "사하구", "북구", "남구" - ]), - Location(main: "제주", sub: ["제주시", "서귀포시"]), - Location(main: "광주", sub: ["동구", "서구", "남구", "북구", "광산구"]) - ], - categories: [ - "게임", "라이프스타일", "반려동물", "뷰티", - "스포츠", "애니메이션", "엔터테이먼트", - "여행","예술","음식/요리","키즈", - "패션" - ] + locations: initialLocations, // 초기 locations 설정 + categories: ["게임", "라이프스타일", "반려동물", "뷰티", "스포츠", "애니메이션", "엔터테이먼트", "여행", "예술", "음식/요리", "키즈", "패션"] ) } @@ -84,29 +70,21 @@ final class FilterBottomSheetReactor: Reactor { switch action { case .segmentChanged(let index): return Observable.just(.setActiveSegment(index)) - case .resetFilters: return Observable.just(.resetFilters) - case .applyFilters: let activeSegment = currentState.activeSegment if activeSegment == 0 { return Observable.just(.updateSavedSubRegions(currentState.selectedSubRegions)) - } else { // 카테고리 탭에서 저장 + } else { return Observable.just(.updateSavedCategories(currentState.selectedCategories)) } - - case .selectLocation(let index): - print("Select Location Index: \(index)") return Observable.just(.updateSelectedLocation(index)) - case .toggleCategory(let category): return Observable.just(.toggleCategorySelection(category)) - case .toggleSubRegion(let subRegion): return Observable.just(.toggleSubRegionSelection(subRegion)) - case .toggleAllSubRegions: return Observable.just(.toggleAllSubRegions) } @@ -117,110 +95,83 @@ final class FilterBottomSheetReactor: Reactor { switch mutation { case .setActiveSegment(let index): newState.activeSegment = index - case .resetFilters: - newState.selectedLocationIndex = nil + let currentIndex = newState.selectedLocationIndex + + // 선택 상태만 초기화 newState.selectedSubRegions = [] newState.selectedCategories = [] - - print("Reset filters - selectedLocationIndex: \(newState.selectedLocationIndex)") - case .applyFilters(let selectedOptions): - print("필터 적용: \(newState.selectedSubRegions + newState.selectedCategories)") + // 이전 선택된 index 복원하여 이벤트 핸들러 유지 + newState.selectedLocationIndex = currentIndex + return newState + + case .applyFilters: + print("필터 적용: \(newState.selectedSubRegions + newState.selectedCategories)") case .updateSelectedLocation(let index): newState.selectedLocationIndex = index - let location = newState.locations[index] - - // 새로운 지역의 "전체" 키 생성 - let allKey = "\(location.main)전체" - - // 기존 선택한 서브 지역 유지 (다른 지역 포함) - let previousSelections = newState.selectedSubRegions - - // 새로운 지역 선택 시 기존 선택된 옵션 그대로 유지 - newState.selectedSubRegions = previousSelections - - // 기존 선택된 옵션에 "전체"가 포함되지 않은 상태 유지 - newState.selectedSubRegions.removeAll { $0 == allKey } - - - - case .updateSubRegions(let subRegions): - print("서브지역 업: \(subRegions)") + let selectedLocation = newState.locations[index] + // 현재 선택된 지역의 전체만 남기고 모두 초기화 + newState.selectedSubRegions = ["\(selectedLocation.main)전체"] + break case .updateSavedSubRegions(let subRegions): newState.savedSubRegions = subRegions newState.selectedSubRegions = [] - case .updateSavedCategories(let categories): newState.savedCategories = categories newState.selectedCategories = [] - case .toggleSubRegionSelection(let subRegion): if let selectedIndex = newState.selectedLocationIndex { let location = newState.locations[selectedIndex] let allKey = "\(location.main)전체" if subRegion == allKey { - // "전체"를 선택한 경우 - if newState.selectedSubRegions.contains(allKey) { - // "전체"가 이미 선택된 경우: 선택 해제 - newState.selectedSubRegions.removeAll { $0 == allKey } - } else { - // "전체"를 활성화하고 다른 모든 옵션 제거 - newState.selectedSubRegions = [allKey] - } + // 전체 버튼 토글 + newState.selectedSubRegions = newState.selectedSubRegions.contains(allKey) ? [] : [allKey] } else { - // 개별 구/동 옵션 토글 + // 서브 지역 선택 시 무조건 전체 버튼은 해제 + newState.selectedSubRegions.removeAll { $0 == allKey } + + // 서브 지역 토글 if newState.selectedSubRegions.contains(subRegion) { - // 이미 선택된 구/동이면 비활성화 newState.selectedSubRegions.removeAll { $0 == subRegion } } else { - // 새로운 구/동 추가 newState.selectedSubRegions.append(subRegion) } - // "전체" 비활성화 - newState.selectedSubRegions.removeAll { $0 == allKey } - - // 모든 서브 지역이 선택되었으면 "전체" 활성화 - if Set(newState.selectedSubRegions).count == location.sub.count { + // 모든 서브 지역이 선택된 경우에만 전체로 변경 + if Set(newState.selectedSubRegions).isSuperset(of: location.sub) { newState.selectedSubRegions = [allKey] } } - - print("현재 선택된 옵션: \(newState.selectedSubRegions)") } - - - - - - - case .toggleCategorySelection(let category): - if newState.selectedCategories.contains(category) { - newState.selectedCategories.removeAll { $0 == category } - } else { - newState.selectedCategories.append(category) - } - + newState.selectedCategories.toggleElement(category) case .toggleAllSubRegions: - if let index = newState.selectedLocationIndex { - let location = newState.locations[index] + if let selectedIndex = newState.selectedLocationIndex { + let location = newState.locations[selectedIndex] let allKey = "\(location.main)전체" if newState.selectedSubRegions.contains(allKey) { - // 전체 선택 해제 - newState.selectedSubRegions.removeAll() + newState.selectedSubRegions = [] } else { - // 전체 선택 - newState.selectedSubRegions = location.sub + [allKey] + newState.selectedSubRegions = [allKey] } } + } + return newState + } +} - } - return newState - } + +extension Array where Element: Equatable { + mutating func toggleElement(_ element: Element) { + if let index = firstIndex(of: element) { + remove(at: index) + } else { + append(element) + } + } } diff --git a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift index a0acde41..41f2fb96 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift @@ -210,6 +210,7 @@ final class FilterBottomSheetView: UIView { func setupLocationScrollView(locations: [Location], buttonAction: @escaping (Int, UIButton) -> Void) { locationContentView.subviews.forEach { $0.removeFromSuperview() } + locationScrollView.delegate = self // 여기에 추가 var lastButton: UIButton? @@ -315,7 +316,7 @@ final class FilterBottomSheetView: UIView { button.layer.borderWidth = 0 } - button.contentEdgeInsets = UIEdgeInsets(top: 9, left: 16, bottom: 9, right: 16) + button.contentEdgeInsets = UIEdgeInsets(top: 7, left: 16, bottom: 7, right: 16) return button } @@ -329,7 +330,7 @@ final class FilterBottomSheetView: UIView { button.layer.borderWidth = 0 } else { button.setBackgroundColor(.w100, for: .normal) - button.setTitleColor(.g700, for: .normal) + button.setTitleColor(.g400, for: .normal) button.layer.borderColor = UIColor.g200.cgColor button.layer.borderWidth = 1 } @@ -353,6 +354,12 @@ final class FilterBottomSheetView: UIView { balloonBackgroundView.arrowPosition = buttonCenterX / totalWidth self.layoutIfNeeded() } + private func updateBalloonPositionAccurately(for button: PPButton) { + let buttonFrameInBalloon = button.convert(button.bounds, to: balloonBackgroundView) + let arrowPosition = buttonFrameInBalloon.midX / balloonBackgroundView.bounds.width + balloonBackgroundView.arrowPosition = arrowPosition + balloonBackgroundView.setNeedsDisplay() + } } extension FilterBottomSheetView { @@ -369,3 +376,26 @@ extension FilterBottomSheetView { filterChipsView.updateChips(with: filters) } } +extension FilterBottomSheetView: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + print("Scrolling - contentOffset: \(scrollView.contentOffset.x)") + + guard let selectedButton = locationContentView.subviews.first(where: { view in + guard let button = view as? PPButton else { return false } + return button.backgroundColor == .blu500 + }) as? PPButton else { + print("No selected button found") + return + } + + // 선택된 버튼의 현재 위치 확인 + let buttonFrame = selectedButton.convert(selectedButton.bounds, to: balloonBackgroundView) + print("Button center: \(buttonFrame.midX)") + + let arrowPosition = buttonFrame.midX / balloonBackgroundView.bounds.width + print("Arrow position: \(arrowPosition)") + + balloonBackgroundView.arrowPosition = arrowPosition + balloonBackgroundView.setNeedsDisplay() + } +} diff --git a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetViewController.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetViewController.swift index f03664a1..0c4e7bbb 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetViewController.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetViewController.swift @@ -6,10 +6,11 @@ import ReactorKit final class FilterBottomSheetViewController: UIViewController, View { typealias Reactor = FilterBottomSheetReactor + typealias FilterData = (locations: [String], categories: [String]) // MARK: - Properties var disposeBag = DisposeBag() - var onSave: (([String]) -> Void)? + var onSave: ((FilterData) -> Void)? var onDismiss: (() -> Void)? private var bottomConstraint: Constraint? let containerView = FilterBottomSheetView() @@ -79,21 +80,24 @@ final class FilterBottomSheetViewController: UIViewController, View { // 2. 리셋 버튼 바인딩 containerView.resetButton.rx.tap .do(onNext: { [weak self] _ in - // 선택된 location이 있으면 해당 location의 버튼들 초기화 - if let selectedIndex = self?.reactor?.currentState.selectedLocationIndex, - let location = self?.reactor?.currentState.locations[selectedIndex] { - self?.containerView.balloonBackgroundView.configure( - with: location.sub, - selectedRegions: [], // 빈 배열로 모든 버튼 선택 해제 - mainRegionTitle: location.main, - selectionHandler: { [weak self] subRegion in - self?.reactor?.action.onNext(.toggleSubRegion(subRegion)) - }, - allSelectionHandler: { [weak self] in - self?.reactor?.action.onNext(.toggleAllSubRegions) - } - ) - } + guard let self = self, + let reactor = self.reactor, + let selectedIndex = reactor.currentState.selectedLocationIndex else { return } + + let location = reactor.currentState.locations[selectedIndex] // Optional 체크 필요 없음 + + // 현재 location에 대한 configure 재설정 + self.containerView.balloonBackgroundView.configure( + with: location.sub, + selectedRegions: [], // 빈 배열로 초기화 + mainRegionTitle: location.main, + selectionHandler: { [weak self] subRegion in + self?.reactor?.action.onNext(.toggleSubRegion(subRegion)) + }, + allSelectionHandler: { [weak self] in + self?.reactor?.action.onNext(.toggleAllSubRegions) + } + ) }) .map { Reactor.Action.resetFilters } .bind(to: reactor.action) @@ -104,11 +108,12 @@ final class FilterBottomSheetViewController: UIViewController, View { .bind { [weak self] _ in guard let self = self, let reactor = self.reactor else { return } - let filters = reactor.currentState.activeSegment == 0 - ? reactor.currentState.selectedSubRegions - : reactor.currentState.selectedCategories + let filterData: FilterData = ( + locations: reactor.currentState.selectedSubRegions, + categories: reactor.currentState.selectedCategories + ) - self.onSave?(filters) + self.onSave?(filterData) self.hideBottomSheet() } .disposed(by: disposeBag) @@ -360,6 +365,10 @@ extension FilterBottomSheetViewController: UICollectionViewDataSource { extension FilterBottomSheetViewController: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let category = tagSection?.inputDataList[indexPath.item].title else { return } + print("[DEBUG] 👆 Category Option Selected: \(category)") + print("[DEBUG] 💾 Current Saved Filters:") + print("[DEBUG] 📍 Location: \(reactor?.currentState.selectedSubRegions ?? [])") + print("[DEBUG] 🏷️ Category: \(reactor?.currentState.selectedCategories ?? [])") reactor?.action.onNext(.toggleCategory(category)) } } diff --git a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterChipsView.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterChipsView.swift index caf0f86f..756f9c8f 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterChipsView.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterChipsView.swift @@ -1,4 +1,5 @@ import UIKit +import SnapKit final class FilterChipsView: UIView { // MARK: - Components @@ -23,6 +24,16 @@ final class FilterChipsView: UIView { return collectionView }() + private let emptyStateLabel: UILabel = { + let label = UILabel() + label.text = "선택한 옵션이 없어요 :)" + label.textColor = .g200 + label.font = UIFont.systemFont(ofSize: 14, weight: .regular) + label.textAlignment = .center + label.isHidden = true // 초기에는 숨김 상태 + return label + }() + private var filters: [String] = [] // MARK: - Initializer @@ -40,6 +51,7 @@ final class FilterChipsView: UIView { private func setupLayout() { addSubview(titleLabel) addSubview(collectionView) + addSubview(emptyStateLabel) titleLabel.snp.makeConstraints { make in make.top.equalToSuperview() @@ -52,30 +64,41 @@ final class FilterChipsView: UIView { make.bottom.equalToSuperview().offset(-8) make.height.equalTo(44) } + + emptyStateLabel.snp.makeConstraints { make in + make.center.equalTo(collectionView) + } } private func setupCollectionView() { collectionView.dataSource = self } - // MARK: - Configuration func configure(with filters: [String]) { self.filters = filters - collectionView.reloadData() + updateUI() } + func updateChips(with filters: [String]) { - self.filters = filters - collectionView.reloadData() - } - + self.filters = filters + updateUI() + } + + private func updateUI() { + let isEmpty = filters.isEmpty + collectionView.isHidden = isEmpty + emptyStateLabel.isHidden = !isEmpty + collectionView.reloadData() + } private func removeFilter(at index: Int) { filters.remove(at: index) - collectionView.reloadData() + updateUI() } } +// MARK: - UICollectionViewDataSource extension FilterChipsView: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return filters.count diff --git a/Poppool/Poppool/Presentation/Map/MapFilterChips.swift b/Poppool/Poppool/Presentation/Map/MapFilterChips.swift index 4f66f871..f7062a5f 100644 --- a/Poppool/Poppool/Presentation/Map/MapFilterChips.swift +++ b/Poppool/Poppool/Presentation/Map/MapFilterChips.swift @@ -46,10 +46,10 @@ class MapFilterChips: UIView { button.titleLabel?.font = UIFont.systemFont(ofSize: 13, weight: .medium) button.setTitleColor(isSelected ? .white : .g400, for: .normal) button.backgroundColor = isSelected ? .blu500 : .white - button.layer.cornerRadius = 18 + button.layer.cornerRadius = 24 button.layer.borderWidth = isSelected ? 0 : 1 button.layer.borderColor = isSelected ? UIColor.blu500.cgColor : UIColor.g200.cgColor - button.contentEdgeInsets = UIEdgeInsets(top: 9, left: 16, bottom: 9, right: 16) + button.contentEdgeInsets = UIEdgeInsets(top: 7, left: 16, bottom: 7, right: 16) return button } @@ -67,6 +67,7 @@ class MapFilterChips: UIView { if let text = text, !text.isEmpty, text != placeholder { button.setTitle(text, for: .normal) + button.titleLabel?.font = UIFont.systemFont(ofSize: 13, weight: .bold) button.setTitleColor(.white, for: .normal) button.backgroundColor = .blu500 button.layer.borderWidth = 0 @@ -86,15 +87,16 @@ class MapFilterChips: UIView { xButton.addTarget(self, action: #selector(handleClearButtonTapped(_:)), for: .touchUpInside) xButton.accessibilityLabel = button === locationChip ? "location" : "category" - button.contentEdgeInsets = UIEdgeInsets(top: 9, left: 16, bottom: 9, right: 36) + button.contentEdgeInsets = UIEdgeInsets(top: 7, left: 16, bottom: 7, right: 36) } else { + button.titleLabel?.font = UIFont.systemFont(ofSize: 13, weight: .medium) button.setTitle(placeholder, for: .normal) button.setTitleColor(.g400, for: .normal) button.backgroundColor = .white button.layer.borderWidth = 1 button.layer.borderColor = UIColor.g200.cgColor button.layer.cornerRadius = 16 - button.contentEdgeInsets = UIEdgeInsets(top: 9, left: 16, bottom: 9, right: 16) + button.contentEdgeInsets = UIEdgeInsets(top: 7, left: 16, bottom: 7, right: 16) } } diff --git a/Poppool/Poppool/Presentation/Map/MapPopupCardView/MapPopupCarouselView.swift b/Poppool/Poppool/Presentation/Map/MapPopupCardView/MapPopupCarouselView.swift index 8ae136a9..ad75bcae 100644 --- a/Poppool/Poppool/Presentation/Map/MapPopupCardView/MapPopupCarouselView.swift +++ b/Poppool/Poppool/Presentation/Map/MapPopupCardView/MapPopupCarouselView.swift @@ -40,7 +40,7 @@ final class MapPopupCarouselView: UIView { private func setupLayout() { addSubview(collectionView) collectionView.snp.makeConstraints { make in - make.edges.equalToSuperview() + make.top.leading.trailing.equalToSuperview() make.bottom.equalToSuperview().inset(16) } } diff --git a/Poppool/Poppool/Presentation/Map/MapReactor.swift b/Poppool/Poppool/Presentation/Map/MapReactor.swift index c4eb2879..514beafd 100644 --- a/Poppool/Poppool/Presentation/Map/MapReactor.swift +++ b/Poppool/Poppool/Presentation/Map/MapReactor.swift @@ -3,84 +3,163 @@ import RxSwift import CoreLocation final class MapReactor: Reactor { - // MARK: - Reactor - enum Action { - case viewDidLoad - case searchTapped - case locationButtonTapped - case listButtonTapped - case filterTapped(FilterType?) // 여기서 FilterType은 공통 파일에서 가져옴 - case filterUpdated(FilterType, [String]) - case clearFilters(FilterType) - } - - enum Mutation { - case setActiveFilter(FilterType?) - case setLocationFilters([String]) - case setCategoryFilters([String]) - case clearLocationFilters - case clearCategoryFilters - } - - struct State { - var activeFilterType: FilterType? - var selectedLocationFilters: [String] = [] - var selectedCategoryFilters: [String] = [] - } - - let initialState: State - private let useCase: MapUseCase - - init(useCase: MapUseCase) { - self.useCase = useCase - self.initialState = State() - } - - func mutate(action: Action) -> Observable { - switch action { - case let .filterTapped(filterType): - return .just(.setActiveFilter(filterType)) - - case let .filterUpdated(type, values): - print("MapReactor filterUpdated - type: \(type), values: \(values)") - switch type { - case .location: - return .just(.setLocationFilters(values)) - case .category: - return .just(.setCategoryFilters(values)) - } - - case let .clearFilters(type): - switch type { - case .location: - return .just(.clearLocationFilters) - case .category: - return .just(.clearCategoryFilters) - } - - default: - return .empty() - } - } - - - func reduce(state: State, mutation: Mutation) -> State { - var newState = state - - switch mutation { - case let .setActiveFilter(filterType): - newState.activeFilterType = filterType - case let .setLocationFilters(filters): - newState.selectedLocationFilters = filters - print("Updating selectedLocationFilters to: \(filters)") - case let .setCategoryFilters(filters): - newState.selectedCategoryFilters = filters - case .clearLocationFilters: - newState.selectedLocationFilters = [] - case .clearCategoryFilters: // 카테고리 필터 초기화 - newState.selectedCategoryFilters = [] - } - return newState - } -} + // MARK: - Reactor + enum Action { + case viewDidLoad + case searchTapped(String) + case locationButtonTapped + case listButtonTapped + case filterTapped(FilterType?) + case filterUpdated(FilterType, [String]) + case clearFilters(FilterType) + case updateBothFilters(locations: [String], categories: [String]) // 새로 추가 + + } + + enum Mutation { + case setSearchResult(MapPopUpStore?) + case setActiveFilter(FilterType?) + case setLocationFilters([String]) + case setCategoryFilters([String]) + case updateLocationDisplay(String) + case updateCategoryDisplay(String) + case clearLocationFilters + case clearCategoryFilters + case updateBothFilters(locations: [String], categories: [String]) // 새로 추가 + case setToastMessage(String) // 토스트 메시지 + + + } + + struct State { + var searchResult: MapPopUpStore? = nil + var toastMessage: String? = nil + var activeFilterType: FilterType? + var selectedLocationFilters: [String] = [] + var selectedCategoryFilters: [String] = [] + var locationDisplayText: String = "지역선택" + var categoryDisplayText: String = "카테고리" + } + + let initialState: State + private let useCase: MapUseCase + + init(useCase: MapUseCase) { + self.useCase = useCase + self.initialState = State() + } + + func mutate(action: Action) -> Observable { + switch action { + case let .searchTapped(query): + return useCase.searchStores(query: query, categories: []) + .flatMap { results -> Observable in + if let firstResult = results.first { + return .just(.setSearchResult(firstResult)) + } else { + return .just(.setToastMessage("검색 결과가 없습니다.")) + } + } + case let .filterTapped(filterType): + return .just(.setActiveFilter(filterType)) + + case let .filterUpdated(type, values): + switch type { + case .location: + let displayText = formatDisplayText(values, defaultText: "지역선택") + return .concat([ + .just(.setLocationFilters(values)), + .just(.updateLocationDisplay(displayText)) + ]) + case .category: + let displayText = formatDisplayText(values, defaultText: "카테고리") + return .concat([ + .just(.setCategoryFilters(values)), + .just(.updateCategoryDisplay(displayText)) + ]) + } + case let .updateBothFilters(locations, categories): + print("[DEBUG] 📝 Updating both filters - Locations: \(locations), Categories: \(categories)") + return .concat([ + .just(.updateBothFilters(locations: locations, categories: categories)), + .just(.updateLocationDisplay(formatDisplayText(locations, defaultText: "지역선택"))), + .just(.updateCategoryDisplay(formatDisplayText(categories, defaultText: "카테고리"))) + ]) + + + case let .clearFilters(type): + switch type { + case .location: + return .concat([ + .just(.clearLocationFilters), + .just(.updateLocationDisplay("지역선택")) + ]) + case .category: + return .concat([ + .just(.clearCategoryFilters), + .just(.updateCategoryDisplay("카테고리")) + ]) + } + + default: + return .empty() + } + } + + private func formatDisplayText(_ values: [String], defaultText: String) -> String { + guard !values.isEmpty else { return defaultText } + return values.count > 1 ? "\(values[0]) 외 \(values.count - 1)개" : values[0] + } + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + + switch mutation { + case let .setSearchResult(result): + newState.searchResult = result + case let .setToastMessage(message): + newState.toastMessage = message + + case let .setActiveFilter(filterType): + newState.activeFilterType = filterType + print("[DEBUG] 🎯 Active Filter Changed: \(String(describing: filterType))") + + case let .setLocationFilters(filters): + newState.selectedLocationFilters = filters + print("Updating selectedLocationFilters to: \(filters)") + + case let .setCategoryFilters(filters): + newState.selectedCategoryFilters = filters + print("[DEBUG] 🔄 Category Filters Updated: \(filters)") + + case let .updateLocationDisplay(text): + newState.locationDisplayText = text + + case let .updateCategoryDisplay(text): + newState.categoryDisplayText = text + + case .clearLocationFilters: + newState.selectedLocationFilters = [] + + case .clearCategoryFilters: + newState.selectedCategoryFilters = [] + case let .updateBothFilters(locations, categories): + print("[DEBUG] 💾 Reducing both filters update") + print("[DEBUG] 📍 Previous state - Locations: \(newState.selectedLocationFilters)") + print("[DEBUG] 🏷️ Previous state - Categories: \(newState.selectedCategoryFilters)") + + newState.selectedLocationFilters = locations + newState.selectedCategoryFilters = categories + + print("[DEBUG] ✅ Updated state - Locations: \(newState.selectedLocationFilters)") + print("[DEBUG] ✅ Updated state - Categories: \(newState.selectedCategoryFilters)") + + return newState + } + + + + return newState + } +} diff --git a/Poppool/Poppool/Presentation/Map/MapSearchInput.swift b/Poppool/Poppool/Presentation/Map/MapSearchInput.swift index 58f3dd02..851f0bd2 100644 --- a/Poppool/Poppool/Presentation/Map/MapSearchInput.swift +++ b/Poppool/Poppool/Presentation/Map/MapSearchInput.swift @@ -31,6 +31,12 @@ final class MapSearchInput: UIView, View { textField.textColor = .g400 textField.isUserInteractionEnabled = true return textField + textField.attributedPlaceholder = NSAttributedString( + string: "팝업스토어명, 지역을 입력해보세요", + attributes: [NSAttributedString.Key.foregroundColor: UIColor.g400] + ) + return textField + }() private let tapButton = UIButton() @@ -69,12 +75,13 @@ final class MapSearchInput: UIView, View { // 검색 버튼을 눌렀을 때 tapButton.rx.tap - .bind { [weak self] in + .withLatestFrom(searchTextField.rx.text.orEmpty) + .bind { [weak self] query in guard let self = self else { return } - // searchTapped 액션 호출 - reactor.action.onNext(.searchTapped) + self.reactor?.action.onNext(.searchTapped(query)) } .disposed(by: disposeBag) + } } @@ -84,7 +91,7 @@ private extension MapSearchInput { addSubview(containerView) containerView.addSubview(searchIcon) containerView.addSubview(searchTextField) - containerView.addSubview(tapButton) +// containerView.addSubview(tapButton) containerView.snp.makeConstraints { make in make.edges.equalToSuperview() @@ -103,8 +110,8 @@ private extension MapSearchInput { make.trailing.equalToSuperview().offset(-16) // 우측 여백을 추가해 텍스트가 오른쪽에 붙지 않도록 } - tapButton.snp.makeConstraints { make in - make.edges.equalToSuperview() - } +// tapButton.snp.makeConstraints { make in +// make.edges.equalToSuperview() +// } } } diff --git a/Poppool/Poppool/Presentation/Map/MapViewController.swift b/Poppool/Poppool/Presentation/Map/MapViewController.swift index 97ea80e4..ba5feb24 100644 --- a/Poppool/Poppool/Presentation/Map/MapViewController.swift +++ b/Poppool/Poppool/Presentation/Map/MapViewController.swift @@ -6,6 +6,8 @@ import RxCocoa import ReactorKit import GoogleMaps import CoreLocation +import RxGesture + final class MapViewController: BaseViewController, View { typealias Reactor = MapReactor @@ -20,6 +22,10 @@ final class MapViewController: BaseViewController, View { private var listViewTopConstraint: Constraint? private var currentFilterBottomSheet: FilterBottomSheetViewController? private var filterChipsTopY: CGFloat = 0 + private var filterContainerBottomY: CGFloat { + let frameInView = mainView.filterChips.convert(mainView.filterChips.bounds, to: view) + return frameInView.maxY // 필터 컨테이너의 바닥 높이 + } enum ModalState { case top @@ -48,6 +54,7 @@ final class MapViewController: BaseViewController, View { locationManager.desiredAccuracy = kCLLocationAccuracyBest } + // MARK: - Setup private func setUp() { view.addSubview(mainView) @@ -71,14 +78,15 @@ final class MapViewController: BaseViewController, View { storeListViewController.view.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() - listViewTopConstraint = make.top.equalTo(view.snp.bottom).constraint // 초기 숨김 상태 - make.height.equalTo(view.frame.height) + make.bottom.equalToSuperview() + listViewTopConstraint = make.top.equalToSuperview().offset(view.frame.height).constraint // 초기 숨김 상태 } // 제스처 설정 let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) -// storeListViewController.mainView.grabberHandle.addGestureRecognizer(panGesture) + storeListViewController.mainView.grabberHandle.addGestureRecognizer(panGesture) storeListViewController.mainView.addGestureRecognizer(panGesture) + setupPanAndSwipeGestures() setupMarker() @@ -95,18 +103,28 @@ final class MapViewController: BaseViewController, View { } private func setupPanAndSwipeGestures() { + // grabberHandle에 스와이프 제스처 추가 storeListViewController.mainView.grabberHandle.rx.swipeGesture(.up) + .skip(1) .withUnretained(self) .subscribe { owner, _ in - print("[DEBUG] Swipe Up Gesture Detected") - owner.animateToState(.top) + print("[DEBUG] ⬆️ Swipe Up Detected") + switch owner.modalState { + case .bottom: + owner.animateToState(.middle) + case .middle: + owner.animateToState(.top) + case .top: + break + } } .disposed(by: disposeBag) storeListViewController.mainView.grabberHandle.rx.swipeGesture(.down) + .skip(1) .withUnretained(self) .subscribe { owner, _ in - print("[DEBUG] Swipe Down Gesture Detected") + print("[DEBUG] ⬇️ Swipe Down Detected") switch owner.modalState { case .top: owner.animateToState(.middle) @@ -141,9 +159,6 @@ final class MapViewController: BaseViewController, View { } .disposed(by: disposeBag) - - - // 위치 버튼 mainView.locationButton.rx.tap .bind { [weak self] _ in @@ -152,28 +167,7 @@ final class MapViewController: BaseViewController, View { } .disposed(by: disposeBag) - // 필터 상태 업데이트 - reactor.state.map { $0.selectedLocationFilters } - .distinctUntilChanged() - .observe(on: MainScheduler.instance) - .bind { [weak self] filters in - self?.mainView.filterChips.update( - locationText: filters.first ?? "지역선택", - categoryText: nil - ) - } - .disposed(by: disposeBag) - reactor.state.map { $0.selectedCategoryFilters } - .distinctUntilChanged() - .observe(on: MainScheduler.instance) - .bind { [weak self] filters in - self?.mainView.filterChips.update( - locationText: nil, - categoryText: filters.first ?? "카테고리" - ) - } - .disposed(by: disposeBag) mainView.filterChips.onRemoveLocation = { reactor.action.onNext(.clearFilters(.location)) @@ -183,18 +177,39 @@ final class MapViewController: BaseViewController, View { } Observable.combineLatest( - reactor.state.map { $0.selectedLocationFilters.isEmpty }, - reactor.state.map { $0.selectedCategoryFilters.isEmpty } - ) - .observe(on: MainScheduler.instance) - .bind { [weak self] isLocationEmpty, isCategoryEmpty in - guard let self = self else { return } - if isLocationEmpty { - self.mainView.filterChips.update(locationText: "지역선택", categoryText: nil) + reactor.state.map { $0.selectedLocationFilters }.distinctUntilChanged(), + reactor.state.map { $0.selectedCategoryFilters }.distinctUntilChanged() + ) { locationFilters, categoryFilters -> (String, String) in + // 지역 필터 텍스트 포맷팅 + let locationText: String + if locationFilters.isEmpty { + locationText = "지역선택" + } else if locationFilters.count > 1 { + locationText = "\(locationFilters[0]) 외 \(locationFilters.count - 1)개" + } else { + locationText = locationFilters[0] } - if isCategoryEmpty { - self.mainView.filterChips.update(locationText: nil, categoryText: "카테고리") + + // 카테고리 필터 텍스트 포맷팅 + let categoryText: String + if categoryFilters.isEmpty { + categoryText = "카테고리" + } else if categoryFilters.count > 1 { + categoryText = "\(categoryFilters[0]) 외 \(categoryFilters.count - 1)개" + } else { + categoryText = categoryFilters[0] } + return (locationText, categoryText) + } + .observe(on: MainScheduler.instance) + .bind { [weak self] locationText, categoryText in + print("[DEBUG] 📍 Updating filters - Location: \(locationText)") + print("[DEBUG] 🏷️ Updating filters - Category: \(categoryText)") + + self?.mainView.filterChips.update( + locationText: locationText, + categoryText: categoryText + ) } .disposed(by: disposeBag) @@ -210,6 +225,31 @@ final class MapViewController: BaseViewController, View { } }) .disposed(by: disposeBag) + reactor.state.map { $0.searchResult } + .distinctUntilChanged() + .compactMap { $0 } + .observe(on: MainScheduler.instance) + .bind { [weak self] store in + guard let self = self else { return } + let camera = GMSCameraPosition.camera( + withLatitude: store.latitude, + longitude: store.longitude, + zoom: 15 + ) + self.mainView.mapView.animate(to: camera) + self.addMarker(for: store) + } + .disposed(by: disposeBag) + +// reactor.state.map { $0.toastMessage } +// .compactMap { $0 } +// .observe(on: MainScheduler.instance) +// .bind { message in +//// let toast = Toast(message: message) +// toast.show() +// } +// .disposed(by: disposeBag) + } @@ -251,126 +291,146 @@ final class MapViewController: BaseViewController, View { switch gesture.state { case .changed: if let constraint = listViewTopConstraint { - let searchFilterFrame = self.mainView.searchFilterContainer.convert( - self.mainView.searchFilterContainer.bounds, - to: self.view - ) - let filterChipsFrame = self.mainView.filterChips.convert( - self.mainView.filterChips.bounds, - to: self.view - ) - let minOffset = searchFilterFrame.minY + filterChipsFrame.maxY + 12 - let maxOffset = view.frame.height + let currentOffset = constraint.layoutConstraints.first?.constant ?? 0 + let newOffset = currentOffset + translation.y - let newOffset = constraint.layoutConstraints.first?.constant ?? 0 + translation.y + // 오프셋 제한 범위 설정 + let minOffset: CGFloat = filterContainerBottomY // 필터 컨테이너 바닥 제한 + let maxOffset: CGFloat = view.frame.height // 최하단 제한 let clampedOffset = min(max(newOffset, minOffset), maxOffset) constraint.update(offset: clampedOffset) gesture.setTranslation(.zero, in: view) - let progress = (maxOffset - clampedOffset) / (maxOffset - minOffset) - mainView.searchFilterContainer.alpha = progress + // 알파값 조절: 탑 상태에서만 적용 + if modalState == .top { + adjustMapViewAlpha(for: clampedOffset, minOffset: minOffset, maxOffset: maxOffset) + } } case .ended: - let currentOffset = listViewTopConstraint?.layoutConstraints.first?.constant ?? 0 - let targetState: ModalState + if let constraint = listViewTopConstraint { + let currentOffset = constraint.layoutConstraints.first?.constant ?? 0 + let middleY = view.frame.height * 0.3 // 중간 지점 기준 높이 + let targetState: ModalState - if velocity.y > 500 { - targetState = .bottom - } else if velocity.y < -500 { - targetState = .top - } else { - let middleY = view.frame.height * 0.4 - if currentOffset < middleY * 0.7 { + // 속도와 위치를 기반으로 상태 결정 + if velocity.y > 500 { // 아래로 빠르게 드래그 + targetState = .bottom + } else if velocity.y < -500 { // 위로 빠르게 드래그 + targetState = .top + } else if currentOffset < middleY * 0.7 { targetState = .top } else if currentOffset < view.frame.height * 0.7 { targetState = .middle } else { targetState = .bottom } - } - print("[DEBUG] Pan Ended - Current Offset: \(currentOffset), Velocity Y: \(velocity.y)") - modalState = targetState - animateToState(targetState) + // 최종 상태에 따라 애니메이션 적용 + animateToState(targetState) + } default: break } } + private func adjustMapViewAlpha(for offset: CGFloat, minOffset: CGFloat, maxOffset: CGFloat) { + let middleOffset = view.frame.height * 0.3 // 미들 상태 기준 높이 + + if offset <= minOffset { + mainView.mapView.alpha = 0 // 탑에서는 완전히 숨김 + } else if offset >= maxOffset { + mainView.mapView.alpha = 1 // 바텀에서는 완전히 보임 + } else if offset <= middleOffset { + // 탑 ~ 미들 사이에서는 알파값 점진적 증가 + let progress = (offset - minOffset) / (middleOffset - minOffset) + mainView.mapView.alpha = progress + } else { + // 미들 ~ 바텀 사이에서는 항상 보임 + mainView.mapView.alpha = 1 + } + } + private func updateMapViewAlpha(for offset: CGFloat, minOffset: CGFloat, maxOffset: CGFloat) { + let progress = (maxOffset - offset) / (maxOffset - minOffset) // 0(탑) ~ 1(바텀) + mainView.mapView.alpha = max(0, min(progress, 1)) // 0(완전히 가림) ~ 1(완전히 보임) + } private func animateToState(_ state: ModalState) { + guard modalState != state else { return } self.view.layoutIfNeeded() UIView.animate(withDuration: 0.3, animations: { switch state { case .top: - let filterChipsFrame = self.mainView.filterChips.convert( self.mainView.filterChips.bounds, to: self.view ) - self.mainView.mapView.isHidden = true + self.mainView.mapView.alpha = 0 // 탑 상태에서는 숨김 self.storeListViewController.setGrabberHandleVisible(false) - self.storeListViewController.mainView.layer.cornerRadius = 0 - self.storeListViewController.view.snp.remakeConstraints { make in - make.leading.trailing.equalToSuperview() - make.top.equalToSuperview().offset(filterChipsFrame.maxY) - make.bottom.equalToSuperview() - } + self.listViewTopConstraint?.update(offset: filterChipsFrame.maxY) + self.mainView.searchInput.backgroundColor = .g50 - self.mainView.searchFilterContainer.backgroundColor = .white - self.mainView.searchFilterContainer.alpha = 1 case .middle: self.storeListViewController.setGrabberHandleVisible(true) - self.storeListViewController.view.snp.remakeConstraints { make in - make.leading.trailing.equalToSuperview() - make.top.equalToSuperview().offset(self.view.frame.height * 0.3) // 70% 가려짐 - make.height.equalTo(self.view.frame.height) - self.storeListViewController.mainView.layer.cornerRadius = 20 - self.storeListViewController.mainView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - self.mainView.mapView.isHidden = false + // 필터 컨테이너 바닥 높이를 최소값으로 사용 + let offset = max(self.view.frame.height * 0.3, self.filterContainerBottomY) + self.listViewTopConstraint?.update(offset: offset) + self.storeListViewController.mainView.layer.cornerRadius = 20 + self.storeListViewController.mainView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + self.mainView.mapView.alpha = 1 // 미들 상태에서는 항상 보임 + self.mainView.mapView.isHidden = false + self.mainView.searchInput.backgroundColor = .white - } - self.mainView.searchFilterContainer.backgroundColor = .clear case .bottom: self.storeListViewController.setGrabberHandleVisible(true) - self.storeListViewController.view.snp.remakeConstraints { make in - make.leading.trailing.equalToSuperview() - make.top.equalTo(self.view.snp.bottom) - make.height.equalTo(self.view.frame.height) - self.mainView.mapView.isHidden = false + self.listViewTopConstraint?.update(offset: self.view.frame.height) // 화면 아래로 숨김 + self.mainView.mapView.alpha = 1 // 바텀 상태에서는 항상 보임 + self.mainView.mapView.isHidden = false + self.mainView.searchInput.backgroundColor = .white - } - self.mainView.searchFilterContainer.backgroundColor = .clear - self.mainView.searchFilterContainer.alpha = 1 } self.view.layoutIfNeeded() }) { _ in self.modalState = state + print("Completed animation to state: \(state)") } } - - - // MARK: - Filter Bottom Sheet func presentFilterBottomSheet(for filterType: FilterType) { let sheetReactor = FilterBottomSheetReactor() let viewController = FilterBottomSheetViewController(reactor: sheetReactor) let initialIndex = (filterType == .location) ? 0 : 1 viewController.containerView.segmentedControl.selectedSegmentIndex = initialIndex - sheetReactor.action.onNext(FilterBottomSheetReactor.Action.segmentChanged(initialIndex)) + sheetReactor.action.onNext(.segmentChanged(initialIndex)) + + viewController.onSave = { [weak self] filterData in + guard let self = self else { return } - viewController.onSave = { [weak self] (selectedOptions: [String]) in + print("[DEBUG] 💾 Save triggered with:") + print("[DEBUG] 📍 Locations: \(filterData.locations)") + print("[DEBUG] 🏷️ Categories: \(filterData.categories)") + + self.reactor?.action.onNext(.updateBothFilters( + locations: filterData.locations, + categories: filterData.categories + )) + self.reactor?.action.onNext(.filterTapped(nil)) + } + + viewController.onSave = { [weak self] filterData in guard let self = self else { return } - self.reactor?.action.onNext(.filterUpdated(filterType, selectedOptions)) + self.reactor?.action.onNext(.updateBothFilters( + locations: filterData.locations, + categories: filterData.categories + )) self.reactor?.action.onNext(.filterTapped(nil)) } @@ -385,8 +445,6 @@ final class MapViewController: BaseViewController, View { currentFilterBottomSheet = viewController } - - private func dismissFilterBottomSheet() { if let bottomSheet = currentFilterBottomSheet { bottomSheet.hideBottomSheet() diff --git a/Poppool/Poppool/Presentation/Map/StoreListPanelLayout.swift b/Poppool/Poppool/Presentation/Map/StoreListPanelLayout.swift index 4aebf156..842bc1de 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListPanelLayout.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListPanelLayout.swift @@ -1,30 +1,30 @@ - -import FloatingPanel -import UIKit - -class StoreListPanelLayout: FloatingPanelLayout { - let position: FloatingPanelPosition = .bottom - let initialState: FloatingPanelState = .half - - var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] { - return [ - .full: FloatingPanelLayoutAnchor(absoluteInset: 120, edge: .top, referenceGuide: .superview), - .half: FloatingPanelLayoutAnchor(fractionalInset: 0.6, edge: .bottom, referenceGuide: .safeArea), - .tip: FloatingPanelLayoutAnchor(absoluteInset: -100, edge: .bottom, referenceGuide: .safeArea) // 완전히 내림 - ] - } - - func backdropAlpha(for state: FloatingPanelState) -> CGFloat { - return 0.0 - } - - func shouldMove(for proposedTargetState: FloatingPanelState) -> Bool { - return true - } - - var cornerRadius: CGFloat { return 0 } - - func surfaceLayout(for size: CGSize) -> NSCollectionLayoutDimension { - return .fractionalWidth(1.0) - } -} +// +//import FloatingPanel +//import UIKit +// +//class StoreListPanelLayout: FloatingPanelLayout { +// let position: FloatingPanelPosition = .bottom +// let initialState: FloatingPanelState = .half +// +// var anchors: [FloatingPanelState: FloatingPanelLayoutAnchoring] { +// return [ +// .full: FloatingPanelLayoutAnchor(absoluteInset: 120, edge: .top, referenceGuide: .superview), +// .half: FloatingPanelLayoutAnchor(fractionalInset: 0.6, edge: .bottom, referenceGuide: .safeArea), +// .tip: FloatingPanelLayoutAnchor(absoluteInset: -100, edge: .bottom, referenceGuide: .safeArea) // 완전히 내림 +// ] +// } +// +// func backdropAlpha(for state: FloatingPanelState) -> CGFloat { +// return 0.0 +// } +// +// func shouldMove(for proposedTargetState: FloatingPanelState) -> Bool { +// return true +// } +// +// var cornerRadius: CGFloat { return 0 } +// +// func surfaceLayout(for size: CGSize) -> NSCollectionLayoutDimension { +// return .fractionalWidth(1.0) +// } +//} diff --git a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListCell.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListCell.swift index eef56a65..164e0786 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListCell.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListCell.swift @@ -89,23 +89,34 @@ private extension StoreListCell { make.size.equalTo(24) } - let labelStack = UIStackView(arrangedSubviews: [ - categoryTagLabel, - titleLabel, - locationLabel, - dateLabel - ]) - labelStack.axis = .vertical - labelStack.spacing = 6 - labelStack.alignment = .leading - - contentView.addSubview(labelStack) - labelStack.snp.makeConstraints { make in - make.top.equalTo(thumbnailImageView.snp.bottom).offset(8) - make.leading.trailing.equalToSuperview() - make.bottom.lessThanOrEqualToSuperview() - } - } + contentView.addSubview(categoryTagLabel) + contentView.addSubview(titleLabel) + contentView.addSubview(locationLabel) + contentView.addSubview(dateLabel) + + // 각 라벨의 위치 설정 + categoryTagLabel.snp.makeConstraints { make in + make.top.equalTo(thumbnailImageView.snp.bottom).offset(10) + make.leading.trailing.equalToSuperview() + make.height.equalTo(16) + } + + titleLabel.snp.makeConstraints { make in + make.top.equalTo(categoryTagLabel.snp.bottom).offset(6) + make.leading.trailing.equalToSuperview() + } + + locationLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(12) + make.leading.trailing.equalToSuperview() + } + + dateLabel.snp.makeConstraints { make in + make.top.equalTo(locationLabel.snp.bottom).offset(6) + make.leading.trailing.equalToSuperview() + make.bottom.lessThanOrEqualToSuperview() + } + } } // MARK: - Inputable diff --git a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift index 9eb89785..0ff7070f 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift @@ -15,6 +15,13 @@ final class StoreListView: UIView { let view = UIView() view.backgroundColor = .g200 view.layer.cornerRadius = 2.5 + view.isUserInteractionEnabled = true + return view + }() + + private let paddingView: UIView = { + let view = UIView() + view.backgroundColor = .clear // 간격만 추가하므로 투명 return view }() @@ -51,22 +58,27 @@ private extension StoreListView { backgroundColor = .white addSubview(collectionView) addSubview(grabberHandle) + addSubview(paddingView) grabberHandle.snp.makeConstraints { make in - make.centerX.equalToSuperview() - make.top.equalToSuperview().inset(14) - make.width.equalTo(36) - make.height.equalTo(5) + make.top.equalToSuperview().offset(14) + make.centerX.equalToSuperview() + make.width.equalTo(36) + make.height.equalTo(5) // priority 조정 + make.height.equalTo(5).priority(.high) // 우선순위 지정 + } + paddingView.snp.makeConstraints { make in + make.top.equalTo(grabberHandle.snp.bottom) + make.leading.trailing.equalToSuperview() } collectionView.snp.makeConstraints { make in - make.top.equalTo(grabberHandle.snp.bottom).offset(20) + make.top.equalTo(grabberHandle.snp.bottom).offset(14) make.leading.trailing.bottom.equalToSuperview() } } func configureLayer() { - // 최상단 레이어에 cornerRadius 설정 layer.cornerRadius = 16 layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] // 상단 좌우 코너만 적용 layer.masksToBounds = true diff --git a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift index dec0cf7e..158c2ddd 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift @@ -6,7 +6,6 @@ import ReactorKit import FloatingPanel import RxDataSources - final class StoreListViewController: UIViewController, View { typealias Reactor = StoreListReactor @@ -33,7 +32,6 @@ final class StoreListViewController: UIViewController, View { super.viewDidLoad() setupLayout() setupCollectionView() - } private func setupLayout() { @@ -48,37 +46,34 @@ final class StoreListViewController: UIViewController, View { mainView.collectionView.rx.setDelegate(self) .disposed(by: disposeBag) mainView.collectionView.contentInset = UIEdgeInsets(top: 10, left: 0, bottom: 0, right: 0) - - } + func bind(reactor: Reactor) { - let dataSource = RxCollectionViewSectionedReloadDataSource( - configureCell: { [weak self] ds, cv, indexPath, item in - guard let self = self else { return UICollectionViewCell() } - let cell = cv.dequeueReusableCell( - withReuseIdentifier: StoreListCell.identifier, - for: indexPath - ) as! StoreListCell - - cell.injection(with: .init( - thumbnailImage: nil, - category: item.category, - title: item.title, - location: item.location, - date: item.dateRange, - isBookmarked: item.isBookmarked - )) - // 북마크 버튼 - cell.bookmarkButton.rx.tap - .map { Reactor.Action.toggleBookmark(indexPath.item) } - .bind(to: reactor.action) - .disposed(by: cell.disposeBag) - - return cell - } - // 헤더 설정 - - ) + let dataSource = RxCollectionViewSectionedReloadDataSource( + configureCell: { [weak self] ds, cv, indexPath, item in + guard let self = self else { return UICollectionViewCell() } + let cell = cv.dequeueReusableCell( + withReuseIdentifier: StoreListCell.identifier, + for: indexPath + ) as! StoreListCell + + cell.injection(with: .init( + thumbnailImage: nil, + category: item.category, + title: item.title, + location: item.location, + date: item.dateRange, + isBookmarked: item.isBookmarked + )) + // 북마크 버튼 + cell.bookmarkButton.rx.tap + .map { Reactor.Action.toggleBookmark(indexPath.item) } + .bind(to: reactor.action) + .disposed(by: cell.disposeBag) + + return cell + } + ) reactor.state .map { state -> [StoreListSection] in @@ -130,7 +125,6 @@ final class StoreListViewController: UIViewController, View { // onSave -> Reactor.Action.filterUpdated(filterType, ...) viewController.onSave = { [weak self] selectedOptions in guard let self = self else { return } - self.reactor?.action.onNext(.filterUpdated(filterType, selectedOptions)) // 닫기 self.reactor?.action.onNext(.filterTapped(nil)) } @@ -147,30 +141,35 @@ final class StoreListViewController: UIViewController, View { } private func dismissFilterBottomSheet() { - // self.presentedViewController?.dismiss(animated: true) - // or 어떤 식으로 관리하는지에 따라 다름 if let sheet = presentedViewController as? FilterBottomSheetViewController { sheet.hideBottomSheet() } } - } - - // MARK: - UICollectionViewDelegateFlowLayout - extension StoreListViewController: UICollectionViewDelegateFlowLayout { - // 헤더 사이즈 - func collectionView( - _ collectionView: UICollectionView, - layout layout: UICollectionViewLayout, - referenceSizeForHeaderInSection section: Int - ) -> CGSize { - return isHeaderVisible - ? CGSize(width: collectionView.bounds.width, height: 120) - : .zero - } - } +} + +// MARK: - UICollectionViewDelegateFlowLayout +extension StoreListViewController: UICollectionViewDelegateFlowLayout { + // 헤더 사이즈 + func collectionView( + _ collectionView: UICollectionView, + layout layout: UICollectionViewLayout, + referenceSizeForHeaderInSection section: Int + ) -> CGSize { + return isHeaderVisible + ? CGSize(width: collectionView.bounds.width, height: 120) + : .zero + } +} + extension StoreListViewController { func setGrabberHandleVisible(_ visible: Bool) { mainView.grabberHandle.isHidden = !visible } } +// MARK: - UIGestureRecognizerDelegate (추가 권장) +extension StoreListViewController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return false + } +} diff --git a/Poppool/Poppool/Presentation/Scene/MyPage/Main/MyPageReactor.swift b/Poppool/Poppool/Presentation/Scene/MyPage/Main/MyPageReactor.swift index b9c790a8..5c44c64e 100644 --- a/Poppool/Poppool/Presentation/Scene/MyPage/Main/MyPageReactor.swift +++ b/Poppool/Poppool/Presentation/Scene/MyPage/Main/MyPageReactor.swift @@ -22,6 +22,8 @@ final class MyPageReactor: Reactor { case commentButtonTapped(controller: BaseViewController) case listCellTapped(controller: BaseViewController, title: String?) case logoutButtonTapped + case adminMenuTapped(controller: BaseViewController) + } enum Mutation { @@ -32,6 +34,8 @@ final class MyPageReactor: Reactor { case moveToPopUpDetailScene(controller: BaseViewController, row: Int) case moveToLoginScene(controller: BaseViewController) case moveToMyCommentScene(controller: BaseViewController) + case moveToAdminScene(controller: BaseViewController) // 추가 + } struct State { @@ -112,7 +116,7 @@ final class MyPageReactor: Reactor { .map { (owner, response) in owner.isLogin = response.loginYn owner.isAdmin = response.adminYn - + owner.profileSection.inputDataList = [ .init( isLogin: response.loginYn, @@ -121,7 +125,7 @@ final class MyPageReactor: Reactor { description: response.intro ) ] - owner.commentSection.inputDataList = response.myCommentedPopUpList.map { + owner.commentSection.inputDataList = response.myCommentedPopUpList.map { .init(popUpImagePath: $0.mainImageUrl, title: $0.popUpStoreName, popUpID: $0.popUpStoreId) } return .loadView @@ -135,12 +139,19 @@ final class MyPageReactor: Reactor { case .loginButtonTapped(let controller): return Observable.just(.moveToLoginScene(controller: controller)) case .listCellTapped(let controller, let title): - return Observable.just(.moveToDetailScene(controller: controller, title: title)) + if title == "관리자 메뉴 바로가기" { + return Observable.just(.moveToAdminScene(controller: controller)) + } else { + return Observable.just(.moveToDetailScene(controller: controller, title: title)) + } case .logoutButtonTapped: return userAPIUseCase.postLogout() .andThen(Observable.just(.logout)) + default: + return .empty() } } + func reduce(state: State, mutation: Mutation) -> State { var newState = state @@ -154,16 +165,15 @@ final class MyPageReactor: Reactor { controller.navigationController?.pushViewController(nextController, animated: true) case .logout: let service = KeyChainService() - let _ = service.deleteToken(type: .accessToken) - let _ = service.deleteToken(type: .refreshToken) + _ = service.deleteToken(type: .accessToken) + _ = service.deleteToken(type: .refreshToken) ToastMaker.createToast(message: "로그아웃 되었어요") DispatchQueue.main.async { [weak self] in self?.action.onNext(.viewWillAppear) } case .moveToDetailScene(let controller, let title): guard let title = title else { break } - switch title { - case "회원탈퇴": + if title == "회원탈퇴" { let nickName = profileSection.inputDataList.first?.nickName let nextController = WithdrawlCheckModalController(nickName: nickName) nextController.reactor = WithdrawlCheckModalReactor() @@ -208,7 +218,7 @@ final class MyPageReactor: Reactor { default: break } - case.moveToLoginScene(let controller): + case .moveToLoginScene(let controller): let nextController = SubLoginController() nextController.reactor = SubLoginReactor() let navigationController = UINavigationController(rootViewController: nextController) @@ -218,6 +228,16 @@ final class MyPageReactor: Reactor { let nextController = MyCommentController() nextController.reactor = MyCommentReactor() controller.navigationController?.pushViewController(nextController, animated: true) + case .moveToAdminScene(let controller): + let nickname = profileSection.inputDataList.first?.nickName ?? "" + let adminVC = AdminViewController(nickname: nickname) + adminVC.reactor = AdminReactor(useCase: DefaultAdminUseCase(repository: DefaultAdminRepository(provider: ProviderImpl()))) + controller.navigationController?.pushViewController(adminVC, animated: true) + default: + break + } + return newState + } case .moveToPopUpDetailScene(let controller, let row): let nextController = DetailController() nextController.reactor = DetailReactor(popUpID: commentSection.inputDataList[row].popUpID) From ac091b694e619e426d8dc96879a98c75934031b9 Mon Sep 17 00:00:00 2001 From: JunYoung Date: Thu, 9 Jan 2025 00:21:17 +0900 Subject: [PATCH 04/12] =?UTF-8?q?[FEAT]:=20=EC=9D=BC=EB=B6=80=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=8A=A4=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=EB=B0=94=20=EC=83=89=EC=83=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Scene/Detail/DetailController.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift b/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift index 6c8c2ce9..7ac9eaed 100644 --- a/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift +++ b/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift @@ -123,6 +123,12 @@ extension DetailController { .subscribe { (owner, state) in owner.sections = state.sections owner.mainView.contentCollectionView.reloadData() + state.barkGroundImagePath.isBrightImagePath { [weak owner] isBright in + if let isBright = isBright { + owner?.statusBarIsDarkMode = isBright + owner?.isBrightImage = isBright + } + } } .disposed(by: disposeBag) From 14d61af8cfb477a47bd16e07d7a4b02bbb346eea Mon Sep 17 00:00:00 2001 From: JunYoung Date: Fri, 10 Jan 2025 17:48:28 +0900 Subject: [PATCH 05/12] =?UTF-8?q?[FEAT]:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC,=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Poppool/Poppool.xcodeproj/project.pbxproj | 39 +++++++++++++++++++ .../Scene/Detail/DetailController.swift | 6 +-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/Poppool/Poppool.xcodeproj/project.pbxproj b/Poppool/Poppool.xcodeproj/project.pbxproj index a76ab325..efe79c62 100644 --- a/Poppool/Poppool.xcodeproj/project.pbxproj +++ b/Poppool/Poppool.xcodeproj/project.pbxproj @@ -1281,6 +1281,45 @@ path = ViewTypeModal; sourceTree = ""; }; + 081898C12D2FBD170067BF01 /* View */ = { + isa = PBXGroup; + children = ( + 081898952D2965C90067BF01 /* ProfileEditView.swift */, + 081898BF2D2FBD130067BF01 /* ProfileEditListButton.swift */, + ); + path = View; + sourceTree = ""; + }; + 081898C62D30D5940067BF01 /* Main */ = { + isa = PBXGroup; + children = ( + 081898C12D2FBD170067BF01 /* View */, + 081898932D2965C20067BF01 /* ProfileEditController.swift */, + 081898972D2965D20067BF01 /* ProfileEditReactor.swift */, + ); + path = Main; + sourceTree = ""; + }; + 081898C72D30D5A10067BF01 /* InfoEditModal */ = { + isa = PBXGroup; + children = ( + 081898C92D30D5BA0067BF01 /* InfoEditModalView.swift */, + 081898CB2D30D5BF0067BF01 /* InfoEditModalController.swift */, + 081898CD2D30D5C60067BF01 /* InfoEditModalReactor.swift */, + ); + path = InfoEditModal; + sourceTree = ""; + }; + 081898C82D30D5AC0067BF01 /* CategoryEditModal */ = { + isa = PBXGroup; + children = ( + 081898D12D30F57D0067BF01 /* CategoryEditModalView.swift */, + 081898D32D30F5840067BF01 /* CategoryEditModalController.swift */, + 081898D52D30F58A0067BF01 /* CategoryEditModalReactor.swift */, + ); + path = CategoryEditModal; + sourceTree = ""; + }; 083A256B2CF361190099B58E /* Application */ = { isa = PBXGroup; children = ( diff --git a/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift b/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift index 7ac9eaed..cd00e904 100644 --- a/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift +++ b/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift @@ -124,10 +124,8 @@ extension DetailController { owner.sections = state.sections owner.mainView.contentCollectionView.reloadData() state.barkGroundImagePath.isBrightImagePath { [weak owner] isBright in - if let isBright = isBright { - owner?.statusBarIsDarkMode = isBright - owner?.isBrightImage = isBright - } + owner?.statusBarIsDarkMode = isBright + owner?.isBrightImage = isBright } } .disposed(by: disposeBag) From 0380d720af7bcc2918d7bec473a975d871bb772e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=80=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Wed, 8 Jan 2025 00:09:17 +0900 Subject: [PATCH 06/12] =?UTF-8?q?[FEAT]=20:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20UI=20=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20API=20=EC=97=B0=EB=8F=99=20=EC=85=8B=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xcshareddata/swiftpm/Package.resolved | 195 ++++++++++++++++++ .../PreSignedService/PreSignedService.swift | 1 - 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..82b671fb --- /dev/null +++ b/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,195 @@ +{ + "originHash" : "467ca350c14df33e2c801b4d50726a849056e5bbcca318a1d231cc34c69c24ef", + "pins" : [ + { + "identity" : "alamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Alamofire/Alamofire.git", + "state" : { + "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", + "version" : "5.10.2" + } + }, + { + "identity" : "floatingpanel", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scenee/FloatingPanel.git", + "state" : { + "revision" : "b6e8928b1a3ad909e6db6a0278d286c33cfd0dc3", + "version" : "2.8.6" + } + }, + { + "identity" : "ios-maps-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googlemaps/ios-maps-sdk", + "state" : { + "revision" : "df7ecd2f894fd83f0287f2cfb6842a0dfe6f290b", + "version" : "9.2.0" + } + }, + { + "identity" : "kakao-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kakao/kakao-ios-sdk.git", + "state" : { + "revision" : "ab4309c1950550add307046ad1e08024c7514603", + "version" : "2.23.0" + } + }, + { + "identity" : "kakao-ios-sdk-rx", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kakao/kakao-ios-sdk-rx", + "state" : { + "revision" : "fa5ce05d610c4b026df8d42e891a32f31a239d58", + "version" : "2.23.0" + } + }, + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher.git", + "state" : { + "revision" : "e6749919f9761d573d37a7d7e78b1b854c191d7f", + "version" : "8.1.3" + } + }, + { + "identity" : "lottie-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/airbnb/lottie-spm.git", + "state" : { + "revision" : "8c6edf4f0fa84fe9c058600a4295eb0c01661c69", + "version" : "4.5.1" + } + }, + { + "identity" : "ohhttpstubs", + "kind" : "remoteSourceControl", + "location" : "https://github.com/AliSoftware/OHHTTPStubs.git", + "state" : { + "revision" : "12f19662426d0434d6c330c6974d53e2eb10ecd9", + "version" : "9.1.0" + } + }, + { + "identity" : "pageboy", + "kind" : "remoteSourceControl", + "location" : "https://github.com/uias/Pageboy.git", + "state" : { + "revision" : "be0c1f6f1964cfb07f9d819b0863f2c3f255f612", + "version" : "4.2.0" + } + }, + { + "identity" : "panmodal", + "kind" : "remoteSourceControl", + "location" : "https://github.com/slackhq/PanModal.git", + "state" : { + "revision" : "b012aecb6b67a8e46369227f893c12544846613f", + "version" : "1.2.7" + } + }, + { + "identity" : "reactorkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactorKit/ReactorKit.git", + "state" : { + "revision" : "8fa33f09c6f6621a2aa536d739956d53b84dd139", + "version" : "3.2.0" + } + }, + { + "identity" : "rxalamofire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/RxSwiftCommunity/RxAlamofire.git", + "state" : { + "revision" : "9535b58695b91fb67f56d58d6fd0c76462d7743a", + "version" : "6.1.2" + } + }, + { + "identity" : "rxdatasources", + "kind" : "remoteSourceControl", + "location" : "https://github.com/RxSwiftCommunity/RxDataSources.git", + "state" : { + "revision" : "90c29b48b628479097fe775ed1966d75ac374518", + "version" : "5.0.2" + } + }, + { + "identity" : "rxgesture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/RxSwiftCommunity/RxGesture.git", + "state" : { + "revision" : "1b137c576b4aaaab949235752278956697c9e4a0", + "version" : "4.0.4" + } + }, + { + "identity" : "rxkeyboard", + "kind" : "remoteSourceControl", + "location" : "https://github.com/RxSwiftCommunity/RxKeyboard.git", + "state" : { + "revision" : "63f6377975c962a1d89f012a6f1e5bebb2c502b7", + "version" : "2.0.1" + } + }, + { + "identity" : "rxswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveX/RxSwift.git", + "state" : { + "revision" : "c7c7d2cf50a3211fe2843f76869c698e4e417930", + "version" : "6.8.0" + } + }, + { + "identity" : "snapkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SnapKit/SnapKit.git", + "state" : { + "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", + "version" : "5.7.1" + } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup", + "state" : { + "revision" : "0837db354faf9c9deb710dc597046edaadf5360f", + "version" : "2.7.6" + } + }, + { + "identity" : "tabman", + "kind" : "remoteSourceControl", + "location" : "https://github.com/uias/Tabman.git", + "state" : { + "revision" : "3b2213290eb93e55bb50b49d1a179033005c11ab", + "version" : "3.2.0" + } + }, + { + "identity" : "then", + "kind" : "remoteSourceControl", + "location" : "https://github.com/devxoul/Then.git", + "state" : { + "revision" : "d41ef523faef0f911369f79c0b96815d9dbb6d7a", + "version" : "3.0.0" + } + }, + { + "identity" : "weakmaptable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactorKit/WeakMapTable.git", + "state" : { + "revision" : "cb05d64cef2bbf51e85c53adee937df46540a74e", + "version" : "1.2.1" + } + } + ], + "version" : 3 +} diff --git a/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift b/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift index a0fd9366..b6d03f4b 100644 --- a/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift +++ b/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift @@ -90,7 +90,6 @@ class PreSignedService { return Disposables.create() } - // 순서를 유지하기 위한 매핑 구조 var imageMap: [String: UIImage] = [:] var uncachedFilePaths: [String] = [] From e44234850f01534a236368f8b47ac9095a95e72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=80=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Wed, 8 Jan 2025 18:48:42 +0900 Subject: [PATCH 07/12] =?UTF-8?q?[FIX]=20:=20=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=B7=B0=20=ED=8E=98=EC=9D=B4=EC=A7=80=ED=99=94=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Poppool/Poppool/Presentation/Map/MapViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Poppool/Poppool/Presentation/Map/MapViewController.swift b/Poppool/Poppool/Presentation/Map/MapViewController.swift index ba5feb24..8f965fb4 100644 --- a/Poppool/Poppool/Presentation/Map/MapViewController.swift +++ b/Poppool/Poppool/Presentation/Map/MapViewController.swift @@ -272,6 +272,7 @@ final class MapViewController: BaseViewController, View { print("[DEBUG] New Modal State: \(modalState)") print("[DEBUG] New listViewTopConstraint offset: \(listViewTopConstraint?.layoutConstraints.first?.constant ?? 0)") } + func addMarker(for store: MapPopUpStore) { let marker = GMSMarker() From cad7e2cd776b0b3584353be0811666c7e1bb333e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=80=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Wed, 15 Jan 2025 18:53:29 +0900 Subject: [PATCH 08/12] =?UTF-8?q?[FEAT]=20:=20=EB=A7=B5=EB=B7=B0=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EB=B0=98=EC=98=81=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Poppool/Poppool.xcodeproj/project.pbxproj | 30 ++++++++++++++----- .../PreSignedService/PreSignedService.swift | 1 + Poppool/Poppool/Resource/Info.plist | 2 +- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Poppool/Poppool.xcodeproj/project.pbxproj b/Poppool/Poppool.xcodeproj/project.pbxproj index efe79c62..56e2f867 100644 --- a/Poppool/Poppool.xcodeproj/project.pbxproj +++ b/Poppool/Poppool.xcodeproj/project.pbxproj @@ -385,24 +385,27 @@ 4E685EE72D12CEB6001EF91C /* MapSearchInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E685EC92D12CEB6001EF91C /* MapSearchInput.swift */; }; 4E685EE92D12CEB6001EF91C /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E685ECB2D12CEB6001EF91C /* MapView.swift */; }; 4E685EEA2D12CEB6001EF91C /* MapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E685ECC2D12CEB6001EF91C /* MapViewController.swift */; }; + 4E6CA4852D34D6ED0034D09A /* AdminBottomSheetReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6CA4842D34D6ED0034D09A /* AdminBottomSheetReactor.swift */; }; 4E755B1D2D2B9AD300ADFB21 /* GetAdminPopUpStoreListResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B1C2D2B9AD300ADFB21 /* GetAdminPopUpStoreListResponseDTO.swift */; }; 4E755B1F2D2B9AE500ADFB21 /* AdminAPIEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B1E2D2B9AE500ADFB21 /* AdminAPIEndpoint.swift */; }; 4E755B212D2B9BAB00ADFB21 /* AdminResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B202D2B9BAB00ADFB21 /* AdminResponseDTO.swift */; }; 4E755B232D2B9C5D00ADFB21 /* AdminViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B222D2B9C5D00ADFB21 /* AdminViewController.swift */; }; 4E755B252D2B9C6C00ADFB21 /* AdminView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B242D2B9C6C00ADFB21 /* AdminView.swift */; }; - 4E755B272D2B9C7C00ADFB21 /* AdminStoreCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B262D2B9C7C00ADFB21 /* AdminStoreCell.swift */; }; 4E755B292D2BA65A00ADFB21 /* AdminReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B282D2BA65A00ADFB21 /* AdminReactor.swift */; }; 4E755B2B2D2BA76E00ADFB21 /* AdminUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B2A2D2BA76E00ADFB21 /* AdminUseCase.swift */; }; - 4E755B2F2D2BA7FB00ADFB21 /* AdminRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B2E2D2BA7FB00ADFB21 /* AdminRepository.swift */; }; 4E755B322D2BA81800ADFB21 /* Then in Frameworks */ = {isa = PBXBuildFile; productRef = 4E755B312D2BA81800ADFB21 /* Then */; }; + 4E7823A82D2E84E800AC5110 /* AdminRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B2E2D2BA7FB00ADFB21 /* AdminRepository.swift */; }; + 4E7823A92D2E84FB00AC5110 /* AdminStoreCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B262D2B9C7C00ADFB21 /* AdminStoreCell.swift */; }; 4E92F0D52D1BE72F00D00495 /* StoreListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E92F0D42D1BE72E00D00495 /* StoreListHeaderView.swift */; }; 4E9C12782D2BC7A0006744D6 /* AdminBottomSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9C12772D2BC7A0006744D6 /* AdminBottomSheetView.swift */; }; 4E9C127A2D2BC811006744D6 /* AdminBottomSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9C12792D2BC811006744D6 /* AdminBottomSheetViewController.swift */; }; - 4E9C12812D2BE0A6006744D6 /* PopUpStoreRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9C12802D2BE0A6006744D6 /* PopUpStoreRegisterViewController.swift */; }; 4EA9989A2D21C2FC009DC30B /* StoreListSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA998992D21C2FC009DC30B /* StoreListSection.swift */; }; 4EA9989D2D21C404009DC30B /* RxDataSources in Frameworks */ = {isa = PBXBuildFile; productRef = 4EA9989C2D21C404009DC30B /* RxDataSources */; }; 4EDDEFB42D2D285900CFAFA5 /* DateTimePickerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDDEFB32D2D285900CFAFA5 /* DateTimePickerManager.swift */; }; - 4EDDEFB62D2D2A9100CFAFA5 /* PopUpStoreRegisterReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDDEFB52D2D2A9100CFAFA5 /* PopUpStoreRegisterReactor.swift */; }; + 4EEA1D8F2D352012003E7DE9 /* ImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEA1D8E2D352012003E7DE9 /* ImageCell.swift */; }; + 4EEA1D912D352027003E7DE9 /* ExtendedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEA1D902D352027003E7DE9 /* ExtendedImage.swift */; }; + 4EEA1D932D358839003E7DE9 /* PopUpStoreRegisterReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEA1D922D358839003E7DE9 /* PopUpStoreRegisterReactor.swift */; }; + 4EEA1D952D358B23003E7DE9 /* PopUpStoreRegisterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEA1D942D358B23003E7DE9 /* PopUpStoreRegisterView.swift */; }; 4EED9BAC2D22730400B288E7 /* FilterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED9BAB2D22730400B288E7 /* FilterType.swift */; }; BD226D512CF6DB290038C984 /* PPReturnHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD226D502CF6DB290038C984 /* PPReturnHeaderView.swift */; }; BD9103612CF6149D00BBCCAE /* AuthAPIEndPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD91034E2CF6149D00BBCCAE /* AuthAPIEndPoint.swift */; }; @@ -838,6 +841,7 @@ 4E685EC92D12CEB6001EF91C /* MapSearchInput.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapSearchInput.swift; sourceTree = ""; }; 4E685ECB2D12CEB6001EF91C /* MapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; 4E685ECC2D12CEB6001EF91C /* MapViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapViewController.swift; sourceTree = ""; }; + 4E6CA4842D34D6ED0034D09A /* AdminBottomSheetReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminBottomSheetReactor.swift; sourceTree = ""; }; 4E755B1C2D2B9AD300ADFB21 /* GetAdminPopUpStoreListResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetAdminPopUpStoreListResponseDTO.swift; sourceTree = ""; }; 4E755B1E2D2B9AE500ADFB21 /* AdminAPIEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminAPIEndpoint.swift; sourceTree = ""; }; 4E755B202D2B9BAB00ADFB21 /* AdminResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminResponseDTO.swift; sourceTree = ""; }; @@ -853,7 +857,10 @@ 4E9C12802D2BE0A6006744D6 /* PopUpStoreRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopUpStoreRegisterViewController.swift; sourceTree = ""; }; 4EA998992D21C2FC009DC30B /* StoreListSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreListSection.swift; sourceTree = ""; }; 4EDDEFB32D2D285900CFAFA5 /* DateTimePickerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimePickerManager.swift; sourceTree = ""; }; - 4EDDEFB52D2D2A9100CFAFA5 /* PopUpStoreRegisterReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopUpStoreRegisterReactor.swift; sourceTree = ""; }; + 4EEA1D8E2D352012003E7DE9 /* ImageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCell.swift; sourceTree = ""; }; + 4EEA1D902D352027003E7DE9 /* ExtendedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedImage.swift; sourceTree = ""; }; + 4EEA1D922D358839003E7DE9 /* PopUpStoreRegisterReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopUpStoreRegisterReactor.swift; sourceTree = ""; }; + 4EEA1D942D358B23003E7DE9 /* PopUpStoreRegisterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopUpStoreRegisterView.swift; sourceTree = ""; }; 4EED9BAB2D22730400B288E7 /* FilterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterType.swift; sourceTree = ""; }; BD226D502CF6DB290038C984 /* PPReturnHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PPReturnHeaderView.swift; sourceTree = ""; }; BD91034E2CF6149D00BBCCAE /* AuthAPIEndPoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthAPIEndPoint.swift; sourceTree = ""; }; @@ -2673,8 +2680,11 @@ 4E755B282D2BA65A00ADFB21 /* AdminReactor.swift */, 4E9C12772D2BC7A0006744D6 /* AdminBottomSheetView.swift */, 4E9C12792D2BC811006744D6 /* AdminBottomSheetViewController.swift */, + 4E6CA4842D34D6ED0034D09A /* AdminBottomSheetReactor.swift */, 4E9C12802D2BE0A6006744D6 /* PopUpStoreRegisterViewController.swift */, - 4EDDEFB52D2D2A9100CFAFA5 /* PopUpStoreRegisterReactor.swift */, + 4EEA1D942D358B23003E7DE9 /* PopUpStoreRegisterView.swift */, + 4EEA1D922D358839003E7DE9 /* PopUpStoreRegisterReactor.swift */, + 4EEA1D8E2D352012003E7DE9 /* ImageCell.swift */, 4EDDEFB22D2D284B00CFAFA5 /* Common */, ); path = Admin; @@ -2727,6 +2737,7 @@ isa = PBXGroup; children = ( 4EDDEFB32D2D285900CFAFA5 /* DateTimePickerManager.swift */, + 4EEA1D902D352027003E7DE9 /* ExtendedImage.swift */, ); path = Common; sourceTree = ""; @@ -3178,13 +3189,13 @@ 08B191B82CF6092F0057BC04 /* AuthServiceable.swift in Sources */, 0841BAB82CFAC41300049E31 /* SectionBackGroundDecorationView.swift in Sources */, 086F89F52D2269E300CA4FC9 /* MyPageReactor.swift in Sources */, + 4EEA1D952D358B23003E7DE9 /* PopUpStoreRegisterView.swift in Sources */, 089952682D046CD80022AEF9 /* SearchResultReactor.swift in Sources */, BDCA41C52CF35AC0005EECF6 /* TestViewController.swift in Sources */, 086DD9402D01EEEB00B97D3B /* SearchCountTitleSection.swift in Sources */, 083C864C2D0DCF9B003F441C /* AddCommentDescriptionSectionCell.swift in Sources */, 086DD8CE2CFDFEB000B97D3B /* HomeListView.swift in Sources */, 08B191C22CF615CA0057BC04 /* Secrets.swift in Sources */, - 4EDDEFB62D2D2A9100CFAFA5 /* PopUpStoreRegisterReactor.swift in Sources */, 083A258C2CF361F90099B58E /* ControllerConvention.swift in Sources */, 083C86242D087A44003F441C /* DetailTitleSection.swift in Sources */, 08A2E4822D1BCDEA00102313 /* CommentDetailController.swift in Sources */, @@ -3218,6 +3229,7 @@ 08DC61FC2CF76862002A2F44 /* SignUpCompleteReactor.swift in Sources */, 0818992F2D3506290067BF01 /* FAQDropdownSection.swift in Sources */, BD9103832CF614A900BBCCAE /* SignUpAPIUseCaseImpl.swift in Sources */, + 4E6CA4852D34D6ED0034D09A /* AdminBottomSheetReactor.swift in Sources */, 08DC62072CF8AC14002A2F44 /* HomeReactor.swift in Sources */, 081898F72D33D6B70067BF01 /* BlockUserManageReactor.swift in Sources */, 081899502D363E5C0067BF01 /* BookMarkPopUpViewTypeModalView.swift in Sources */, @@ -3238,6 +3250,7 @@ 0818991A2D34D6430067BF01 /* NoticeListSectionCell.swift in Sources */, 086F89D72D1E6DB700CA4FC9 /* OtherUserCommentReactor.swift in Sources */, 083C867A2D0EE3BB003F441C /* CommentAPIUseCaseImpl.swift in Sources */, + 4E7823A82D2E84E800AC5110 /* AdminRepository.swift in Sources */, 086F89DB2D1E7A6C00CA4FC9 /* GetOtherUserCommentListResponseDTO.swift in Sources */, 086F89D32D1E6DA600CA4FC9 /* OtherUserCommentView.swift in Sources */, 4E685ECE2D12CEB6001EF91C /* BalloonBackgroundView.swift in Sources */, @@ -3259,6 +3272,7 @@ 081899182D34D63E0067BF01 /* NoticeListSection.swift in Sources */, 083C86362D0C7EF4003F441C /* CommentSelectedController.swift in Sources */, 08A2E4792D1B06A300102313 /* ImageDetailView.swift in Sources */, + 4E7823A92D2E84FB00AC5110 /* AdminStoreCell.swift in Sources */, 088DE2542D144A7E0030FA9E /* DetailCommentSection.swift in Sources */, 4E755B2B2D2BA76E00ADFB21 /* AdminUseCase.swift in Sources */, 083C86292D088080003F441C /* DetailContentSection.swift in Sources */, @@ -3287,6 +3301,7 @@ 081898F02D33A3A30067BF01 /* MyCommentSortedModalReactor.swift in Sources */, 081898E42D3391550067BF01 /* GetMyCommentResponseDTO.swift in Sources */, 088DE25D2D145E3A0030FA9E /* DetailSimilarSection.swift in Sources */, + 4EEA1D912D352027003E7DE9 /* ExtendedImage.swift in Sources */, 083C863D2D0C8BC4003F441C /* NormalCommentAddController.swift in Sources */, 08B1916E2CF434CF0057BC04 /* SignUpStep1Reactor.swift in Sources */, 08B1918F2CF4A0020057BC04 /* SignUpStep3Controller.swift in Sources */, @@ -3331,6 +3346,7 @@ 086F8A0F2D26297900CA4FC9 /* MyPageCommentSection.swift in Sources */, 08B1916C2CF434C30057BC04 /* SignUpStep1View.swift in Sources */, 083A25BC2CF362670099B58E /* ProviderImpl.swift in Sources */, + 4EEA1D8F2D352012003E7DE9 /* ImageCell.swift in Sources */, 086DD93D2D009A2600B97D3B /* CancelableTagSectionCell.swift in Sources */, 4E9C127A2D2BC811006744D6 /* AdminBottomSheetViewController.swift in Sources */, 086DD8DA2CFF194700B97D3B /* UserAPIRepositoryImpl.swift in Sources */, diff --git a/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift b/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift index b6d03f4b..a0fd9366 100644 --- a/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift +++ b/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift @@ -90,6 +90,7 @@ class PreSignedService { return Disposables.create() } + // 순서를 유지하기 위한 매핑 구조 var imageMap: [String: UIImage] = [:] var uncachedFilePaths: [String] = [] diff --git a/Poppool/Poppool/Resource/Info.plist b/Poppool/Poppool/Resource/Info.plist index 6bac1689..a5342e38 100644 --- a/Poppool/Poppool/Resource/Info.plist +++ b/Poppool/Poppool/Resource/Info.plist @@ -61,6 +61,6 @@ NSLocationWhenInUseUsageDescription 앱이 사용자의 현재 위치를 확인하기 위해 위치 권한이 필요합니다 CFBundleIdentifier - + com.poppoolIOS.poppool From 527c16fda4f3a4b56cf7d53af0bb6d7aa8f6b139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=80=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Sat, 18 Jan 2025 13:47:55 +0900 Subject: [PATCH 09/12] =?UTF-8?q?[FEAT]=20:=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=93=B1=EB=A1=9D,=EC=88=98?= =?UTF-8?q?=EC=A0=95,=EC=82=AD=EC=A0=9C=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Poppool/Poppool.xcodeproj/project.pbxproj | 44 +--- .../NetworkLayer/Provider/ProviderImpl.swift | 21 +- .../Presentation/Admin/AdminReactor.swift | 16 +- .../Presentation/Admin/AdminStoreCell.swift | 7 +- .../Admin/AdminViewController.swift | 200 +++++++++++++----- .../Data/Repository/AdminRepository.swift | 31 ++- .../PopUpStoreRegisterViewController.swift | 154 ++++++++++++-- .../FillterSheetView/FilterChipsView.swift | 4 +- .../Map/MapPopupCardView/PopupCardCell.swift | 4 +- .../Presentation/Map/MapSearchInput.swift | 21 +- .../Map/StoreListView/StoreListView.swift | 2 +- .../StoreListViewController.swift | 2 +- .../Scene/MyPage/Main/MyPageReactor.swift | 72 ++++--- Poppool/Poppool/Resource/Info.plist | 2 - 14 files changed, 405 insertions(+), 175 deletions(-) diff --git a/Poppool/Poppool.xcodeproj/project.pbxproj b/Poppool/Poppool.xcodeproj/project.pbxproj index 56e2f867..8c813e77 100644 --- a/Poppool/Poppool.xcodeproj/project.pbxproj +++ b/Poppool/Poppool.xcodeproj/project.pbxproj @@ -28,7 +28,6 @@ 081898BA2D2E5F4C0067BF01 /* MyCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081898B92D2E5F4C0067BF01 /* MyCommentView.swift */; }; 081898BC2D2E5F510067BF01 /* MyCommentReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081898BB2D2E5F510067BF01 /* MyCommentReactor.swift */; }; 081898BE2D2E5F590067BF01 /* MyCommentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081898BD2D2E5F590067BF01 /* MyCommentController.swift */; }; - 081898C02D2FBD130067BF01 /* ProfileEditListButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081898BF2D2FBD130067BF01 /* ProfileEditListButton.swift */; }; 081898C32D30AE2C0067BF01 /* GetMyProfileResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081898C22D30AE2C0067BF01 /* GetMyProfileResponseDTO.swift */; }; 081898C52D30AEF40067BF01 /* GetMyProfileResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081898C42D30AEF40067BF01 /* GetMyProfileResponse.swift */; }; 081898CA2D30D5BA0067BF01 /* InfoEditModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081898C92D30D5BA0067BF01 /* InfoEditModalView.swift */; }; @@ -396,6 +395,8 @@ 4E755B322D2BA81800ADFB21 /* Then in Frameworks */ = {isa = PBXBuildFile; productRef = 4E755B312D2BA81800ADFB21 /* Then */; }; 4E7823A82D2E84E800AC5110 /* AdminRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B2E2D2BA7FB00ADFB21 /* AdminRepository.swift */; }; 4E7823A92D2E84FB00AC5110 /* AdminStoreCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B262D2B9C7C00ADFB21 /* AdminStoreCell.swift */; }; + 4E78706E2D37CB1900465FC9 /* ProfileEditListButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081898BF2D2FBD130067BF01 /* ProfileEditListButton.swift */; }; + 4E78706F2D37CB2200465FC9 /* PopUpStoreRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9C12802D2BE0A6006744D6 /* PopUpStoreRegisterViewController.swift */; }; 4E92F0D52D1BE72F00D00495 /* StoreListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E92F0D42D1BE72E00D00495 /* StoreListHeaderView.swift */; }; 4E9C12782D2BC7A0006744D6 /* AdminBottomSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9C12772D2BC7A0006744D6 /* AdminBottomSheetView.swift */; }; 4E9C127A2D2BC811006744D6 /* AdminBottomSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9C12792D2BC811006744D6 /* AdminBottomSheetViewController.swift */; }; @@ -1288,45 +1289,6 @@ path = ViewTypeModal; sourceTree = ""; }; - 081898C12D2FBD170067BF01 /* View */ = { - isa = PBXGroup; - children = ( - 081898952D2965C90067BF01 /* ProfileEditView.swift */, - 081898BF2D2FBD130067BF01 /* ProfileEditListButton.swift */, - ); - path = View; - sourceTree = ""; - }; - 081898C62D30D5940067BF01 /* Main */ = { - isa = PBXGroup; - children = ( - 081898C12D2FBD170067BF01 /* View */, - 081898932D2965C20067BF01 /* ProfileEditController.swift */, - 081898972D2965D20067BF01 /* ProfileEditReactor.swift */, - ); - path = Main; - sourceTree = ""; - }; - 081898C72D30D5A10067BF01 /* InfoEditModal */ = { - isa = PBXGroup; - children = ( - 081898C92D30D5BA0067BF01 /* InfoEditModalView.swift */, - 081898CB2D30D5BF0067BF01 /* InfoEditModalController.swift */, - 081898CD2D30D5C60067BF01 /* InfoEditModalReactor.swift */, - ); - path = InfoEditModal; - sourceTree = ""; - }; - 081898C82D30D5AC0067BF01 /* CategoryEditModal */ = { - isa = PBXGroup; - children = ( - 081898D12D30F57D0067BF01 /* CategoryEditModalView.swift */, - 081898D32D30F5840067BF01 /* CategoryEditModalController.swift */, - 081898D52D30F58A0067BF01 /* CategoryEditModalReactor.swift */, - ); - path = CategoryEditModal; - sourceTree = ""; - }; 083A256B2CF361190099B58E /* Application */ = { isa = PBXGroup; children = ( @@ -3443,7 +3405,9 @@ 08B191842CF48A820057BC04 /* SignUpStep2View.swift in Sources */, 0841BA882CF9F62400049E31 /* PresignedURLRequestDTO.swift in Sources */, 0841BA8C2CF9F67100049E31 /* PreSignedAPIEndPoint.swift in Sources */, + 4E78706F2D37CB2200465FC9 /* PopUpStoreRegisterViewController.swift in Sources */, 081899082D34B35A0067BF01 /* MyPageNoticeView.swift in Sources */, + 4E78706E2D37CB1900465FC9 /* ProfileEditListButton.swift in Sources */, 081898D82D310C160067BF01 /* PutUserCategoryRequestDTO.swift in Sources */, 08A2E4992D1C08D600102313 /* GetPopUpCommentResponse.swift in Sources */, 08CBEA3C2D3FABED00248007 /* BookMarkToastView.swift in Sources */, diff --git a/Poppool/Poppool/Infrastructure/NetworkLayer/Provider/ProviderImpl.swift b/Poppool/Poppool/Infrastructure/NetworkLayer/Provider/ProviderImpl.swift index c828ce4b..8aa78d4b 100644 --- a/Poppool/Poppool/Infrastructure/NetworkLayer/Provider/ProviderImpl.swift +++ b/Poppool/Poppool/Infrastructure/NetworkLayer/Provider/ProviderImpl.swift @@ -20,25 +20,25 @@ final class ProviderImpl: Provider { with endpoint: E, interceptor: RequestInterceptor? = nil ) -> Observable where R == E.Response { - + return Observable.create { [weak self] observer in do { let urlRequest = try endpoint.getUrlRequest() Logger.log(message: "\(urlRequest) 요청 시간 :\(Date.now)", category: .network) -// self?.timeoutTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in -// IndicatorMaker.showIndicator() -// } - + let request = AF.request(urlRequest, interceptor: interceptor) .validate() .responseData { [weak self] response in -// self?.timeoutTimer?.invalidate() -// IndicatorMaker.hideIndicator() Logger.log(message: "\(urlRequest) 응답 시간 :\(Date.now)", category: .network) switch response.result { case .success(let data): - -// Logger.log(message: "응답 데이터: \(String(data: data, encoding: .utf8) ?? "데이터가 없습니다.")", category: .network) + // EmptyResponse 타입이고 데이터가 비어있는 경우 성공으로 처리 + if R.self == EmptyResponse.self && (data.isEmpty || data.count == 0) { + observer.onNext(EmptyResponse() as! R) + observer.onCompleted() + return + } + do { let decodedData = try JSONDecoder().decode(R.self, from: data) observer.onNext(decodedData) @@ -52,7 +52,7 @@ final class ProviderImpl: Provider { observer.onError(error) } } - + return Disposables.create { request.cancel() } @@ -63,6 +63,7 @@ final class ProviderImpl: Provider { } } } + func request( with request: E, diff --git a/Poppool/Poppool/Presentation/Admin/AdminReactor.swift b/Poppool/Poppool/Presentation/Admin/AdminReactor.swift index e547c00f..5502ca94 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminReactor.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminReactor.swift @@ -12,6 +12,8 @@ final class AdminReactor: Reactor { // 화면 이동 후 상태를 초기화하기 위한 액션 case resetNavigation + case reloadData + } enum Mutation { @@ -39,13 +41,13 @@ final class AdminReactor: Reactor { func mutate(action: Action) -> Observable { switch action { - case .viewDidLoad: - return .concat([ - .just(.setIsLoading(true)), - useCase.fetchStoreList(query: nil, page: 0, size: 20) - .map { .setStores($0.popUpStoreList) }, - .just(.setIsLoading(false)) - ]) + case .viewDidLoad, .reloadData: + return .concat([ + .just(.setIsLoading(true)), + useCase.fetchStoreList(query: nil, page: 0, size: 20) + .map { .setStores($0.popUpStoreList) }, + .just(.setIsLoading(false)) + ]) case let .updateSearchQuery(query): return .concat([ diff --git a/Poppool/Poppool/Presentation/Admin/AdminStoreCell.swift b/Poppool/Poppool/Presentation/Admin/AdminStoreCell.swift index 997428a9..706addaf 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminStoreCell.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminStoreCell.swift @@ -81,7 +81,10 @@ final class AdminStoreCell: UITableViewCell { titleLabel.text = store.name categoryLabel.text = store.categoryName statusChip.text = "운영" - Logger.log(message: "이미지 경로: \(store.mainImageUrl ?? "nil")", category: .debug) - storeImageView.setPPImage(path: store.mainImageUrl) + + // mainImageUrl에서 baseURL 부분 제거 + let imagePath = store.mainImageUrl.replacingOccurrences(of: Secrets.popPoolS3BaseURL.rawValue, with: "") + Logger.log(message: "이미지 경로: \(imagePath)", category: .debug) + storeImageView.setPPImage(path: imagePath) } } diff --git a/Poppool/Poppool/Presentation/Admin/AdminViewController.swift b/Poppool/Poppool/Presentation/Admin/AdminViewController.swift index 25737db2..c39c0a77 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminViewController.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminViewController.swift @@ -4,18 +4,18 @@ import RxSwift import RxCocoa final class AdminViewController: BaseViewController, View { - + typealias Reactor = AdminReactor - + // MARK: - Properties var disposeBag = DisposeBag() - private let mainView: AdminView - private var adminBottomSheetVC: AdminBottomSheetViewController? - private var selectedFilterOption: String = "전체" - private let nickname: String + private let mainView: AdminView + private var adminBottomSheetVC: AdminBottomSheetViewController? + private var selectedFilterOption: String = "전체" + private let nickname: String private let adminUseCase: AdminUseCase - + // MARK: - Init init(nickname: String, adminUseCase: AdminUseCase = DefaultAdminUseCase(repository: DefaultAdminRepository(provider: ProviderImpl()))) { self.nickname = nickname self.adminUseCase = adminUseCase @@ -24,39 +24,70 @@ final class AdminViewController: BaseViewController, View { mainView.usernameLabel.text = nickname + "님" } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() setUp() - - // 로고 이미지에 탭 제스처 추가 + setupMenuButton() + let logoTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapLogo)) mainView.logoImageView.isUserInteractionEnabled = true mainView.logoImageView.addGestureRecognizer(logoTapGesture) mainView.tableView.register(AdminStoreCell.self, forCellReuseIdentifier: AdminStoreCell.identifier) - } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) tabBarController?.tabBar.isHidden = true } - + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) tabBarController?.tabBar.isHidden = false } - + + // MARK: - Setup + private func setUp() { + view.addSubview(mainView) + mainView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + navigationItem.title = "팝업스토어 관리" + mainView.dropdownButton.addTarget(self, action: #selector(didTapDropdownButton), for: .touchUpInside) + } + + private func setupMenuButton() { + let editAction = UIAction( + title: "수정", + image: UIImage(systemName: "pencil"), + handler: { [weak self] _ in + self?.showEditOptions() + } + ) + + let deleteAction = UIAction( + title: "삭제", + image: UIImage(systemName: "trash"), + attributes: .destructive, + handler: { [weak self] _ in + self?.showDeleteOptions() + } + ) + + let menu = UIMenu(title: "", children: [editAction, deleteAction]) + mainView.menuButton.menu = menu + mainView.menuButton.showsMenuAsPrimaryAction = true + } + // MARK: - Actions @objc private func didTapLogo() { navigationController?.popViewController(animated: true) } - + @objc private func didTapDropdownButton() { let reactor = AdminBottomSheetReactor() let bottomSheetVC = AdminBottomSheetViewController(reactor: reactor) @@ -80,25 +111,96 @@ final class AdminViewController: BaseViewController, View { self.adminBottomSheetVC = bottomSheetVC } -} -// MARK: - SetUp -private extension AdminViewController { - func setUp() { - view.addSubview(mainView) - mainView.snp.makeConstraints { make in - make.edges.equalTo(view.safeAreaLayoutGuide) + private func showEditOptions() { + let alert = UIAlertController(title: "수정할 팝업스토어 선택", message: nil, preferredStyle: .actionSheet) + + reactor?.currentState.storeList.forEach { store in + alert.addAction(UIAlertAction(title: store.name, style: .default) { [weak self] _ in + self?.editStore(store) + }) } - navigationItem.title = "팝업스토어 관리" - mainView.dropdownButton.addTarget(self, action: #selector(didTapDropdownButton), for: .touchUpInside) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + + // iPad support + if let popoverController = alert.popoverPresentationController { + popoverController.sourceView = mainView.menuButton + popoverController.sourceRect = mainView.menuButton.bounds + } + + present(alert, animated: true) } -} -// MARK: - ReactorKit Bindings -extension AdminViewController { - func bind(reactor: Reactor) { + private func showDeleteOptions() { + let alert = UIAlertController(title: "삭제할 팝업스토어 선택", message: nil, preferredStyle: .actionSheet) - // 1) 검색어 입력 -> updateSearchQuery + reactor?.currentState.storeList.forEach { store in + alert.addAction(UIAlertAction(title: store.name, style: .destructive) { [weak self] _ in + self?.showDeleteConfirmation(for: store) + }) + } + + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + + // iPad support + if let popoverController = alert.popoverPresentationController { + popoverController.sourceView = mainView.menuButton + popoverController.sourceRect = mainView.menuButton.bounds + } + + present(alert, animated: true) + } + + private func showDeleteConfirmation(for store: GetAdminPopUpStoreListResponseDTO.PopUpStore) { + let alert = UIAlertController( + title: "삭제 확인", + message: "\(store.name)을(를) 삭제하시겠습니까?", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction(UIAlertAction(title: "삭제", style: .destructive) { [weak self] _ in + self?.deleteStore(store) + }) + + present(alert, animated: true) + } + + private func editStore(_ store: GetAdminPopUpStoreListResponseDTO.PopUpStore) { + let registerVC = PopUpStoreRegisterViewController( + nickname: nickname, + adminUseCase: adminUseCase, + editingStore: store + ) + navigationController?.pushViewController(registerVC, animated: true) + } + + private func deleteStore(_ store: GetAdminPopUpStoreListResponseDTO.PopUpStore) { + adminUseCase.deleteStore(id: store.id) + .subscribe( + onNext: { [weak self] _ in + self?.reactor?.action.onNext(.reloadData) + }, + onError: { [weak self] error in + self?.showErrorAlert(message: "삭제 실패: \(error.localizedDescription)") + } + ) + .disposed(by: disposeBag) + } + + private func showErrorAlert(message: String) { + let alert = UIAlertController( + title: "오류", + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + present(alert, animated: true) + } + + // MARK: - Reactor Binding + func bind(reactor: Reactor) { + // Search input mainView.searchInput.rx.text.orEmpty .distinctUntilChanged() .debounce(.milliseconds(300), scheduler: MainScheduler.instance) @@ -106,13 +208,24 @@ extension AdminViewController { .bind(to: reactor.action) .disposed(by: disposeBag) - // 2) 등록 버튼 탭 -> tapRegisterButton + // Register button mainView.registerButton.rx.tap - .map { Reactor.Action.tapRegisterButton } - .bind(to: reactor.action) + .subscribe(onNext: { [weak self] _ in + guard let self = self else { return } + let registerVC = PopUpStoreRegisterViewController( + nickname: self.nickname, + adminUseCase: self.adminUseCase + ) + + registerVC.completionHandler = { [weak self] in + self?.reactor?.action.onNext(.reloadData) + } + + self.navigationController?.pushViewController(registerVC, animated: true) + }) .disposed(by: disposeBag) - // 3) 테이블 바인딩 + // Store list binding reactor.state.map { $0.storeList } .map { "총 \($0.count)건" } .bind(to: mainView.popupCountLabel.rx.text) @@ -126,20 +239,5 @@ extension AdminViewController { cell.configure(with: item) } .disposed(by: disposeBag) - - // 4) shouldNavigateToRegister == true -> 등록화면 이동 - reactor.state.map { $0.shouldNavigateToRegister } - .distinctUntilChanged() - .filter { $0 == true } - .subscribe(onNext: { [weak self] _ in - guard let self = self else { return } - - let registerVC = PopUpStoreRegisterViewController(nickname: self.nickname, adminUseCase: self.adminUseCase) - self.navigationController?.pushViewController(registerVC, animated: true) - - // 이동 직후, 다시 false로 - reactor.action.onNext(.resetNavigation) - }) - .disposed(by: disposeBag) } } diff --git a/Poppool/Poppool/Presentation/Admin/Data/Repository/AdminRepository.swift b/Poppool/Poppool/Presentation/Admin/Data/Repository/AdminRepository.swift index d214077f..01e25584 100644 --- a/Poppool/Poppool/Presentation/Admin/Data/Repository/AdminRepository.swift +++ b/Poppool/Poppool/Presentation/Admin/Data/Repository/AdminRepository.swift @@ -1,6 +1,7 @@ import Foundation import RxSwift import UIKit +import Alamofire protocol AdminRepository { // 기존 메서드들 @@ -49,7 +50,6 @@ final class DefaultAdminRepository: AdminRepository { } func createStore(request: CreatePopUpStoreRequestDTO) -> Observable { - Logger.log(message: "createStore API 호출 시작", category: .info) let endpoint = AdminAPIEndpoint.createStore(request: request) Logger.log(message: "Request URL: \(endpoint.baseURL + endpoint.path)", category: .info) @@ -58,7 +58,17 @@ final class DefaultAdminRepository: AdminRepository { return provider.requestData( with: endpoint, interceptor: tokenInterceptor - ).do( + ) + .catch { error -> Observable in + if case .responseSerializationFailed(let reason) = error as? AFError, + case .inputDataNilOrZeroLength = reason { + // 빈 응답 데이터일 경우 성공으로 간주 + Logger.log(message: "빈 응답 데이터 처리: 성공으로 간주", category: .info) + return Observable.just(EmptyResponse()) + } + throw error + } + .do( onNext: { _ in Logger.log(message: "createStore API 호출 성공", category: .info) }, @@ -68,6 +78,8 @@ final class DefaultAdminRepository: AdminRepository { ) } + + func updateStore(request: UpdatePopUpStoreRequestDTO) -> Observable { let endpoint = AdminAPIEndpoint.updateStore(request: request) return provider.requestData( @@ -77,11 +89,18 @@ final class DefaultAdminRepository: AdminRepository { } func deleteStore(id: Int64) -> Observable { + Logger.log(message: "deleteStore API 호출 시작", category: .info) let endpoint = AdminAPIEndpoint.deleteStore(id: id) - return provider.requestData( - with: endpoint, - interceptor: tokenInterceptor - ) + return provider.request(with: endpoint, interceptor: tokenInterceptor) + .andThen(Observable.just(EmptyResponse())) + .do( + onNext: { _ in + Logger.log(message: "deleteStore API 호출 성공", category: .info) + }, + onError: { error in + Logger.log(message: "deleteStore API 호출 실패: \(error)", category: .error) + } + ) } // MARK: - Notice Methods diff --git a/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift index b0bdcd71..3cf4ef49 100644 --- a/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift +++ b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift @@ -11,7 +11,7 @@ final class PopUpStoreRegisterViewController: BaseViewController { // MARK: - Navigation/Header - + var completionHandler: (() -> Void)? private var selectedImages: [UIImage] = [] private var selectedMainImageIndex: Int? private var imageFileNames: [String] = [] // S3 업로드 후의 파일명 저장 @@ -26,17 +26,24 @@ final class PopUpStoreRegisterViewController: BaseViewController { private let popupName: String = "" + private let editingStore: GetAdminPopUpStoreListResponseDTO.PopUpStore? + let presignedService = PreSignedService() var disposeBag = DisposeBag() private let nickname: String private let navContainer = UIView() - init(nickname: String, adminUseCase: AdminUseCase) { + init(nickname: String, adminUseCase: AdminUseCase, editingStore: GetAdminPopUpStoreListResponseDTO.PopUpStore? = nil) { self.nickname = nickname self.adminUseCase = adminUseCase + self.editingStore = editingStore super.init() self.accountIdLabel.text = nickname + "님" - } + if editingStore != nil { + pageTitleLabel.text = "팝업스토어 수정" + } + } + required init?(coder: NSCoder) { @@ -200,6 +207,9 @@ final class PopUpStoreRegisterViewController: BaseViewController { let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) tapGesture.cancelsTouchesInView = false view.addGestureRecognizer(tapGesture) + if let store = editingStore { + fillFormWithExistingData(store) + } setupNavigation() @@ -235,6 +245,11 @@ final class PopUpStoreRegisterViewController: BaseViewController { } } + private func fillFormWithExistingData(_ store: GetAdminPopUpStoreListResponseDTO.PopUpStore) { + nameField?.text = store.name + categoryButton.setTitle("\(store.categoryName) ▾", for: .normal) +// addressField?.text = store.address + } // MARK: - Layout @@ -798,12 +813,10 @@ private extension UITextField { } } extension PopUpStoreRegisterViewController: UICollectionViewDataSource, UICollectionViewDelegate { - // 몇 개의 셀? func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return images.count } - // 셀 구성 func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell( withReuseIdentifier: ImageCell.identifier, @@ -987,10 +1000,17 @@ private extension PopUpStoreRegisterViewController { // 1. 폼 데이터 검증 guard validateFormData() else { return } - // 2. 이미지 업로드 실행 - uploadImages() + if let editingStore = editingStore { + // 수정 모드 + updateStore(editingStore) + } else { + // 새로 등록 모드 + // 2. 이미지 업로드 실행 + uploadImages() + } } + // 폼 데이터 검증 private func validateFormData() -> Bool { guard let name = nameField?.text, @@ -1005,29 +1025,130 @@ private extension PopUpStoreRegisterViewController { Logger.log(message: "폼 데이터 검증 성공", category: .debug) return true } + private func updateStore(_ store: GetAdminPopUpStoreListResponseDTO.PopUpStore) { + // 이미지가 수정되었다면 먼저 이미지 업로드 + if !images.isEmpty { + uploadImagesForUpdate(store) + } else { + // 이미지 수정이 없다면 바로 스토어 정보 업데이트 + updateStoreInfo(store, updatedImagePaths: nil) + } + } + private func uploadImagesForUpdate(_ store: GetAdminPopUpStoreListResponseDTO.PopUpStore) { + let uuid = UUID().uuidString + let updatedImages = images.enumerated().map { index, image in + let filePath = "PopUpImage/\(nameField?.text ?? "")/\(uuid)/\(index).jpg" + return ExtendedImage( + filePath: filePath, + image: image.image, + isMain: image.isMain) + } + + presignedService.tryUpload(datas: updatedImages.map { + PreSignedService.PresignedURLRequest(filePath: $0.filePath, image: $0.image) + }) + .subscribe( + onSuccess: { [weak self] _ in + guard let self = self else { return } + Logger.log(message: "이미지 업로드 성공", category: .info) + let imagePaths = updatedImages.map { $0.filePath } + self.updateStoreInfo(store, updatedImagePaths: imagePaths) + }, + onError: { [weak self] error in + Logger.log(message: "이미지 업로드 실패: \(error.localizedDescription)", category: .error) + self?.showErrorAlert(message: "이미지 업로드 실패: \(error.localizedDescription)") + } + ) + .disposed(by: disposeBag) + } + + private func updateStoreInfo(_ store: GetAdminPopUpStoreListResponseDTO.PopUpStore, updatedImagePaths: [String]?) { + guard let name = nameField?.text, + let address = addressField?.text, + let latitude = Double(latField?.text ?? ""), + let longitude = Double(lonField?.text ?? ""), + let description = descTV?.text, + let categoryTitle = categoryButton.title(for: .normal)?.replacingOccurrences(of: " ▾", with: "") else { + return + } + + let request = UpdatePopUpStoreRequestDTO( + popUpStore: .init( + id: store.id, + name: name, + categoryId: Int64(getCategoryId(from: categoryTitle)), + desc: description, + address: address, + startDate: getFormattedDate(from: selectedStartDate), + endDate: getFormattedDate(from: selectedEndDate), + mainImageUrl: (updatedImagePaths?.first { _ in true }) ?? store.mainImageUrl, + bannerYn: false, + imageUrl: updatedImagePaths ?? [store.mainImageUrl], + startDateBeforeEndDate: true + ), + location: .init( + latitude: latitude, + longitude: longitude, + markerTitle: "마커 제목", + markerSnippet: "마커 설명" + ), + imagesToAdd: updatedImagePaths ?? [], + imagesToDelete: [] // 기존 이미지 삭제 로직이 필요하다면 추가 + ) + + adminUseCase.updateStore(request: request) + .subscribe( + onNext: { [weak self] _ in + Logger.log(message: "updateStore API 호출 성공", category: .info) + self?.showSuccessAlert(isUpdate: true) + }, + onError: { [weak self] error in + Logger.log(message: "updateStore API 호출 실패: \(error.localizedDescription)", category: .error) + self?.showErrorAlert(message: error.localizedDescription) + } + ) + .disposed(by: disposeBag) + } + + private func showSuccessAlert(isUpdate: Bool = false) { + let message = isUpdate ? "팝업스토어가 성공적으로 수정되었습니다." : "팝업스토어가 성공적으로 등록되었습니다." + let alert = UIAlertController( + title: isUpdate ? "수정 성공" : "등록 성공", + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default) { [weak self] _ in + self?.completionHandler?() // 목록 새로고침 + self?.navigationController?.popViewController(animated: true) + }) + present(alert, animated: true) + } // 이미지 업로드 private func uploadImages() { let uuid = UUID().uuidString - let baseS3URL = Secrets.popPoolS3BaseURL.rawValue +// let baseS3URL = Secrets.popPoolS3BaseURL.rawValue let updatedImages = images.enumerated().map { index, image in let filePath = "PopUpImage/\(nameField?.text ?? "")/\(uuid)/\(index).jpg" - return ExtendedImage(filePath: filePath, image: image.image, isMain: image.isMain) + return ExtendedImage( + filePath: filePath, + image: image.image, + isMain: image.isMain) } - let presignedService = PreSignedService() +// let presignedService = PreSignedService() presignedService.tryUpload(datas: updatedImages.map { PreSignedService.PresignedURLRequest(filePath: $0.filePath, image: $0.image) }) - .observe(on: MainScheduler.instance) +// .observe(on: MainScheduler.instance) .subscribe( onSuccess: { [weak self] _ in guard let self = self else { return } Logger.log(message: "이미지 업로드 성공", category: .info) - let imagePaths = updatedImages.map { baseS3URL + $0.filePath } + let imagePaths = updatedImages.map { $0.filePath } let mainImage = updatedImages.first { $0.isMain }?.filePath ?? "" - self.callCreateStoreAPI(mainImage: baseS3URL + mainImage, imagePaths: imagePaths) + self.callCreateStoreAPI(mainImage: mainImage, imagePaths: imagePaths) // baseURL 제거 }, onError: { error in Logger.log(message: "이미지 업로드 실패: \(error.localizedDescription)", category: .error) @@ -1096,10 +1217,13 @@ private extension PopUpStoreRegisterViewController { message: "팝업스토어가 성공적으로 등록되었습니다.", preferredStyle: .alert ) - alert.addAction(UIAlertAction(title: "확인", style: .default)) + alert.addAction(UIAlertAction(title: "확인", style: .default, handler: { [weak self] _ in + // 성공 후 닫기와 핸들러 호출 + self?.completionHandler?() + self?.navigationController?.popViewController(animated: true) + })) present(alert, animated: true) } - private func showErrorAlert(message: String) { let alert = UIAlertController( title: "등록 실패", diff --git a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterChipsView.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterChipsView.swift index 756f9c8f..1d387504 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterChipsView.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterChipsView.swift @@ -27,8 +27,8 @@ final class FilterChipsView: UIView { private let emptyStateLabel: UILabel = { let label = UILabel() label.text = "선택한 옵션이 없어요 :)" - label.textColor = .g200 - label.font = UIFont.systemFont(ofSize: 14, weight: .regular) + label.textColor = .g300 + label.font = UIFont.systemFont(ofSize: 14, weight: .medium) label.textAlignment = .center label.isHidden = true // 초기에는 숨김 상태 return label diff --git a/Poppool/Poppool/Presentation/Map/MapPopupCardView/PopupCardCell.swift b/Poppool/Poppool/Presentation/Map/MapPopupCardView/PopupCardCell.swift index 5de4102e..819ff2cc 100644 --- a/Poppool/Poppool/Presentation/Map/MapPopupCardView/PopupCardCell.swift +++ b/Poppool/Poppool/Presentation/Map/MapPopupCardView/PopupCardCell.swift @@ -52,10 +52,8 @@ final class PopupCardCell: UICollectionViewCell { make.trailing.equalToSuperview().offset(-12) } - // Title Label titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .bold) titleLabel.numberOfLines = 2 -// titleLabel.textColor = g1000 titleLabel.snp.makeConstraints { make in make.top.equalTo(categoryLabel.snp.bottom).offset(4) make.leading.equalTo(imageView.snp.trailing).offset(12) @@ -66,7 +64,7 @@ final class PopupCardCell: UICollectionViewCell { addressLabel.font = UIFont.systemFont(ofSize: 12, weight: .regular) addressLabel.textColor = .g400 addressLabel.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(4) + make.top.equalTo(titleLabel.snp.bottom).offset(8) make.leading.equalTo(imageView.snp.trailing).offset(12) make.trailing.equalToSuperview().offset(-12) } diff --git a/Poppool/Poppool/Presentation/Map/MapSearchInput.swift b/Poppool/Poppool/Presentation/Map/MapSearchInput.swift index 851f0bd2..eae913a9 100644 --- a/Poppool/Poppool/Presentation/Map/MapSearchInput.swift +++ b/Poppool/Poppool/Presentation/Map/MapSearchInput.swift @@ -30,6 +30,9 @@ final class MapSearchInput: UIView, View { textField.clearButtonMode = .whileEditing textField.textColor = .g400 textField.isUserInteractionEnabled = true + textField.returnKeyType = .search // 검색 버튼으로 설정 + textField.enablesReturnKeyAutomatically = true // 텍스트가 입력되면 활성화 + return textField textField.attributedPlaceholder = NSAttributedString( string: "팝업스토어명, 지역을 입력해보세요", @@ -54,17 +57,26 @@ final class MapSearchInput: UIView, View { } func setBackgroundColorForList() { - containerView.backgroundColor = .g50 + searchTextField.backgroundColor = .g50 } + func bind(reactor: MapReactor) { - searchTextField.rx.text.orEmpty - .distinctUntilChanged() + searchTextField.rx.controlEvent(.editingDidEndOnExit) + .withLatestFrom(searchTextField.rx.text.orEmpty) .bind { query in - reactor.action.onNext(.filterUpdated(.location, [query])) + + reactor.action.onNext(.searchTapped(query)) } .disposed(by: disposeBag) + + searchTextField.rx.text.orEmpty + .subscribe(onNext: { text in + print("[DEBUG] TextField Input: \(text)") + }) + .disposed(by: disposeBag) + reactor.state .map { $0.selectedLocationFilters } .distinctUntilChanged() @@ -83,6 +95,7 @@ final class MapSearchInput: UIView, View { .disposed(by: disposeBag) } + } // MARK: - Setup diff --git a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift index 0ff7070f..2f156598 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift @@ -73,7 +73,7 @@ private extension StoreListView { } collectionView.snp.makeConstraints { make in - make.top.equalTo(grabberHandle.snp.bottom).offset(14) + make.top.equalTo(grabberHandle.snp.bottom).offset(1) make.leading.trailing.bottom.equalToSuperview() } } diff --git a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift index 158c2ddd..1f8d6f30 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift @@ -45,7 +45,7 @@ final class StoreListViewController: UIViewController, View { private func setupCollectionView() { mainView.collectionView.rx.setDelegate(self) .disposed(by: disposeBag) - mainView.collectionView.contentInset = UIEdgeInsets(top: 10, left: 0, bottom: 0, right: 0) + mainView.collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) } func bind(reactor: Reactor) { diff --git a/Poppool/Poppool/Presentation/Scene/MyPage/Main/MyPageReactor.swift b/Poppool/Poppool/Presentation/Scene/MyPage/Main/MyPageReactor.swift index 5c44c64e..e948e7b6 100644 --- a/Poppool/Poppool/Presentation/Scene/MyPage/Main/MyPageReactor.swift +++ b/Poppool/Poppool/Presentation/Scene/MyPage/Main/MyPageReactor.swift @@ -12,7 +12,7 @@ import RxSwift import RxCocoa final class MyPageReactor: Reactor { - + // MARK: - Reactor enum Action { case viewWillAppear @@ -22,10 +22,10 @@ final class MyPageReactor: Reactor { case commentButtonTapped(controller: BaseViewController) case listCellTapped(controller: BaseViewController, title: String?) case logoutButtonTapped - case adminMenuTapped(controller: BaseViewController) + case adminMenuTapped(controller: BaseViewController) // 관리자 메뉴 추가 } - + enum Mutation { case loadView case moveToProfileEditScene(controller: BaseViewController) @@ -34,23 +34,23 @@ final class MyPageReactor: Reactor { case moveToPopUpDetailScene(controller: BaseViewController, row: Int) case moveToLoginScene(controller: BaseViewController) case moveToMyCommentScene(controller: BaseViewController) - case moveToAdminScene(controller: BaseViewController) // 추가 + case moveToAdminScene(controller: BaseViewController) // 관리자 메뉴 이동 추가 } - + struct State { var sections: [any Sectionable] = [] var isLogin: Bool = false var backgroundImageViewPath: String? } - + // MARK: - properties - + var initialState: State var disposeBag = DisposeBag() - + private let userAPIUseCase = UserAPIUseCaseImpl(repository: UserAPIRepositoryImpl(provider: ProviderImpl())) - + lazy var compositionalLayout: UICollectionViewCompositionalLayout = { UICollectionViewCompositionalLayout { [weak self] section, env in guard let self = self else { @@ -64,7 +64,7 @@ final class MyPageReactor: Reactor { return getSection()[section].getSection(section: section, env: env) } }() - + private var profileSection = MyPageProfileSection(inputDataList: []) private var commentTitleSection = MyPageMyCommentTitleSection(inputDataList: [.init(title: "내 코멘트", buttonTitle: "전체보기")]) private var commentSection = MyPageCommentSection(inputDataList: []) @@ -84,29 +84,29 @@ final class MyPageReactor: Reactor { private var etcSection = MyPageListSection(inputDataList: [ .init(title: "회원탈퇴") ]) - + private var adminEtcSection = MyPageListSection(inputDataList: [ .init(title: "회원탈퇴"), .init(title: "관리자 메뉴 바로가기") ]) - + private var logoutSection = MyPageLogoutSection(inputDataList: [.init()]) - + private let spacing8Section = SpacingSection(inputDataList: [.init(spacing: 8)]) private let spacing16Section = SpacingSection(inputDataList: [.init(spacing: 16)]) private let spacing24Section = SpacingSection(inputDataList: [.init(spacing: 24)]) private let spacing28Section = SpacingSection(inputDataList: [.init(spacing: 28)]) private let spacing16GraySection = SpacingSection(inputDataList: [.init(spacing: 16, backgroundColor: .g50)]) private let spacing156Section = SpacingSection(inputDataList: [.init(spacing: 156)]) - + var isLogin: Bool = false var isAdmin: Bool = false - + // MARK: - init init() { self.initialState = State() } - + // MARK: - Reactor Methods func mutate(action: Action) -> Observable { switch action { @@ -125,7 +125,7 @@ final class MyPageReactor: Reactor { description: response.intro ) ] - owner.commentSection.inputDataList = response.myCommentedPopUpList.map { + owner.commentSection.inputDataList = response.myCommentedPopUpList.map { .init(popUpImagePath: $0.mainImageUrl, title: $0.popUpStoreName, popUpID: $0.popUpStoreId) } return .loadView @@ -144,15 +144,18 @@ final class MyPageReactor: Reactor { } else { return Observable.just(.moveToDetailScene(controller: controller, title: title)) } + case .adminMenuTapped(let controller): // 추가된 관리자 메뉴 액션 처리 + return Observable.just(.moveToAdminScene(controller: controller)) + case .logoutButtonTapped: return userAPIUseCase.postLogout() .andThen(Observable.just(.logout)) - default: - return .empty() + case .adminMenuTapped(let controller): + return Observable.just(.moveToAdminScene(controller: controller)) + } } - func reduce(state: State, mutation: Mutation) -> State { var newState = state switch mutation { @@ -165,15 +168,16 @@ final class MyPageReactor: Reactor { controller.navigationController?.pushViewController(nextController, animated: true) case .logout: let service = KeyChainService() - _ = service.deleteToken(type: .accessToken) - _ = service.deleteToken(type: .refreshToken) + let _ = service.deleteToken(type: .accessToken) + let _ = service.deleteToken(type: .refreshToken) ToastMaker.createToast(message: "로그아웃 되었어요") DispatchQueue.main.async { [weak self] in self?.action.onNext(.viewWillAppear) } case .moveToDetailScene(let controller, let title): guard let title = title else { break } - if title == "회원탈퇴" { + switch title { + case "회원탈퇴": let nickName = profileSection.inputDataList.first?.nickName let nextController = WithdrawlCheckModalController(nickName: nickName) nextController.reactor = WithdrawlCheckModalReactor() @@ -218,12 +222,18 @@ final class MyPageReactor: Reactor { default: break } - case .moveToLoginScene(let controller): + case.moveToLoginScene(let controller): let nextController = SubLoginController() nextController.reactor = SubLoginReactor() let navigationController = UINavigationController(rootViewController: nextController) navigationController.modalPresentationStyle = .fullScreen controller.present(navigationController, animated: true) + case .moveToAdminScene(let controller): // 관리자 메뉴 이동 처리 + let nickname = profileSection.inputDataList.first?.nickName ?? "" + let adminVC = AdminViewController(nickname: nickname) + adminVC.reactor = AdminReactor(useCase: DefaultAdminUseCase(repository: DefaultAdminRepository(provider: ProviderImpl()))) + controller.navigationController?.pushViewController(adminVC, animated: true) + case .moveToMyCommentScene(let controller): let nextController = MyCommentController() nextController.reactor = MyCommentReactor() @@ -248,16 +258,16 @@ final class MyPageReactor: Reactor { } return newState } - + func getSection() -> [any Sectionable] { return getProfileSection() + getCommentSection() + getNormalSection() + getInfoSection() + getETCSection() } - - + + func getProfileSection() -> [any Sectionable] { return [profileSection] } - + func getCommentSection() -> [any Sectionable] { if !isLogin { return [] } if commentSection.isEmpty { @@ -273,7 +283,7 @@ final class MyPageReactor: Reactor { ] } } - + func getNormalSection() -> [any Sectionable] { if isLogin { return [ @@ -286,7 +296,7 @@ final class MyPageReactor: Reactor { return [] } } - + func getInfoSection() -> [any Sectionable] { if isLogin { return [ @@ -306,7 +316,7 @@ final class MyPageReactor: Reactor { ] } } - + func getETCSection() -> [any Sectionable] { if isLogin { if isAdmin { diff --git a/Poppool/Poppool/Resource/Info.plist b/Poppool/Poppool/Resource/Info.plist index a5342e38..045513c7 100644 --- a/Poppool/Poppool/Resource/Info.plist +++ b/Poppool/Poppool/Resource/Info.plist @@ -60,7 +60,5 @@ NSLocationWhenInUseUsageDescription 앱이 사용자의 현재 위치를 확인하기 위해 위치 권한이 필요합니다 - CFBundleIdentifier - com.poppoolIOS.poppool From 2548d501a25908b84e3e38fc54fc9fa2737f235f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=80=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Tue, 21 Jan 2025 17:24:37 +0900 Subject: [PATCH 10/12] =?UTF-8?q?[FIX]:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=A0=88=EC=A7=80=EC=8A=A4=ED=84=B0=EB=B7=B0=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20UI=20=EC=88=98=EC=A0=95=20=EB=A7=B5?= =?UTF-8?q?=EB=B7=B0=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20Unable=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Poppool/Poppool.xcodeproj/project.pbxproj | 16 +- .../PopUpStoreRegisterViewController.swift | 259 +++++++++--- .../Map/Common/GMSMapViewDelegateProxy.swift | 39 ++ .../Map/Common/ViewportBounds.swift | 6 + .../BalloonBackgroundView.swift | 13 +- .../FillterSheetView/BalloonChipCell.swift | 1 - .../FilterBottomSheetView.swift | 56 ++- .../Map/MapAPI/MapAPIEndpoint.swift | 6 +- .../Map/MapAPI/MapPopUpStore.swift | 23 +- .../Map/MapAPI/MapPopUpStoreDTO.swift | 21 +- .../Map/MapAPI/Repository/MapRepository.swift | 38 ++ .../Presentation/Map/MapFilterChips.swift | 8 +- .../Poppool/Presentation/Map/MapReactor.swift | 389 +++++++++++------- .../Presentation/Map/MapSearchInput.swift | 78 ++-- .../Poppool/Presentation/Map/MapView.swift | 11 - .../Presentation/Map/MapViewController.swift | 174 +++++++- .../Map/StoreListView/StoreListCell.swift | 4 +- .../Map/StoreListView/StoreListReactor.swift | 53 ++- .../Map/StoreListView/StoreListView.swift | 20 +- .../StoreListViewController.swift | 2 +- Poppool/Poppool/Resource/Info.plist | 2 + 21 files changed, 856 insertions(+), 363 deletions(-) create mode 100644 Poppool/Poppool/Presentation/Map/Common/GMSMapViewDelegateProxy.swift create mode 100644 Poppool/Poppool/Presentation/Map/Common/ViewportBounds.swift diff --git a/Poppool/Poppool.xcodeproj/project.pbxproj b/Poppool/Poppool.xcodeproj/project.pbxproj index 8c813e77..d5e8a7a5 100644 --- a/Poppool/Poppool.xcodeproj/project.pbxproj +++ b/Poppool/Poppool.xcodeproj/project.pbxproj @@ -357,7 +357,6 @@ 08DC620D2CF8AE16002A2F44 /* ImageBannerSectionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DC620C2CF8AE16002A2F44 /* ImageBannerSectionCell.swift */; }; 08DC62112CF8B446002A2F44 /* SortedRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DC62102CF8B446002A2F44 /* SortedRequestDTO.swift */; }; 08DC62132CF8B833002A2F44 /* Optional+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DC62122CF8B833002A2F44 /* Optional+.swift */; }; - 4E5825642D19517B00EE83EF /* StoreListPanelLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5825632D19517B00EE83EF /* StoreListPanelLayout.swift */; }; 4E5825672D1951DF00EE83EF /* FloatingPanel in Frameworks */ = {isa = PBXBuildFile; productRef = 4E5825662D1951DF00EE83EF /* FloatingPanel */; }; 4E685ECE2D12CEB6001EF91C /* BalloonBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E685EAA2D12CEB6001EF91C /* BalloonBackgroundView.swift */; }; 4E685ECF2D12CEB6001EF91C /* BalloonChipCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E685EAB2D12CEB6001EF91C /* BalloonChipCell.swift */; }; @@ -397,11 +396,12 @@ 4E7823A92D2E84FB00AC5110 /* AdminStoreCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B262D2B9C7C00ADFB21 /* AdminStoreCell.swift */; }; 4E78706E2D37CB1900465FC9 /* ProfileEditListButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081898BF2D2FBD130067BF01 /* ProfileEditListButton.swift */; }; 4E78706F2D37CB2200465FC9 /* PopUpStoreRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9C12802D2BE0A6006744D6 /* PopUpStoreRegisterViewController.swift */; }; - 4E92F0D52D1BE72F00D00495 /* StoreListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E92F0D42D1BE72E00D00495 /* StoreListHeaderView.swift */; }; 4E9C12782D2BC7A0006744D6 /* AdminBottomSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9C12772D2BC7A0006744D6 /* AdminBottomSheetView.swift */; }; 4E9C127A2D2BC811006744D6 /* AdminBottomSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9C12792D2BC811006744D6 /* AdminBottomSheetViewController.swift */; }; 4EA9989A2D21C2FC009DC30B /* StoreListSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA998992D21C2FC009DC30B /* StoreListSection.swift */; }; 4EA9989D2D21C404009DC30B /* RxDataSources in Frameworks */ = {isa = PBXBuildFile; productRef = 4EA9989C2D21C404009DC30B /* RxDataSources */; }; + 4EAB809D2D3F78AA0041AF30 /* GMSMapViewDelegateProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EAB809C2D3F78AA0041AF30 /* GMSMapViewDelegateProxy.swift */; }; + 4EAB809F2D3F8EF50041AF30 /* ViewportBounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EAB809E2D3F8EF50041AF30 /* ViewportBounds.swift */; }; 4EDDEFB42D2D285900CFAFA5 /* DateTimePickerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDDEFB32D2D285900CFAFA5 /* DateTimePickerManager.swift */; }; 4EEA1D8F2D352012003E7DE9 /* ImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEA1D8E2D352012003E7DE9 /* ImageCell.swift */; }; 4EEA1D912D352027003E7DE9 /* ExtendedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEA1D902D352027003E7DE9 /* ExtendedImage.swift */; }; @@ -816,7 +816,6 @@ 08DC620C2CF8AE16002A2F44 /* ImageBannerSectionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageBannerSectionCell.swift; sourceTree = ""; }; 08DC62102CF8B446002A2F44 /* SortedRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortedRequestDTO.swift; sourceTree = ""; }; 08DC62122CF8B833002A2F44 /* Optional+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+.swift"; sourceTree = ""; }; - 4E5825632D19517B00EE83EF /* StoreListPanelLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StoreListPanelLayout.swift; path = ../StoreListPanelLayout.swift; sourceTree = ""; }; 4E685EAA2D12CEB6001EF91C /* BalloonBackgroundView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BalloonBackgroundView.swift; sourceTree = ""; }; 4E685EAB2D12CEB6001EF91C /* BalloonChipCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BalloonChipCell.swift; sourceTree = ""; }; 4E685EAD2D12CEB6001EF91C /* FilterBottomSheetReactor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilterBottomSheetReactor.swift; sourceTree = ""; }; @@ -852,11 +851,12 @@ 4E755B282D2BA65A00ADFB21 /* AdminReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminReactor.swift; sourceTree = ""; }; 4E755B2A2D2BA76E00ADFB21 /* AdminUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminUseCase.swift; sourceTree = ""; }; 4E755B2E2D2BA7FB00ADFB21 /* AdminRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminRepository.swift; sourceTree = ""; }; - 4E92F0D42D1BE72E00D00495 /* StoreListHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreListHeaderView.swift; sourceTree = ""; }; 4E9C12772D2BC7A0006744D6 /* AdminBottomSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminBottomSheetView.swift; sourceTree = ""; }; 4E9C12792D2BC811006744D6 /* AdminBottomSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminBottomSheetViewController.swift; sourceTree = ""; }; 4E9C12802D2BE0A6006744D6 /* PopUpStoreRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopUpStoreRegisterViewController.swift; sourceTree = ""; }; 4EA998992D21C2FC009DC30B /* StoreListSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreListSection.swift; sourceTree = ""; }; + 4EAB809C2D3F78AA0041AF30 /* GMSMapViewDelegateProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GMSMapViewDelegateProxy.swift; sourceTree = ""; }; + 4EAB809E2D3F8EF50041AF30 /* ViewportBounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewportBounds.swift; sourceTree = ""; }; 4EDDEFB32D2D285900CFAFA5 /* DateTimePickerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimePickerManager.swift; sourceTree = ""; }; 4EEA1D8E2D352012003E7DE9 /* ImageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCell.swift; sourceTree = ""; }; 4EEA1D902D352027003E7DE9 /* ExtendedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedImage.swift; sourceTree = ""; }; @@ -2602,13 +2602,11 @@ 4E685EC52D12CEB6001EF91C /* StoreListView */ = { isa = PBXGroup; children = ( - 4E5825632D19517B00EE83EF /* StoreListPanelLayout.swift */, 4E685EC12D12CEB6001EF91C /* StoreListCell.swift */, 4E685EC22D12CEB6001EF91C /* StoreListReactor.swift */, 4E685EC32D12CEB6001EF91C /* StoreListView.swift */, 4EA998992D21C2FC009DC30B /* StoreListSection.swift */, 4E685EC42D12CEB6001EF91C /* StoreListViewController.swift */, - 4E92F0D42D1BE72E00D00495 /* StoreListHeaderView.swift */, ); path = StoreListView; sourceTree = ""; @@ -2707,6 +2705,8 @@ 4EED9BAA2D2272F500B288E7 /* Common */ = { isa = PBXGroup; children = ( + 4EAB809C2D3F78AA0041AF30 /* GMSMapViewDelegateProxy.swift */, + 4EAB809E2D3F8EF50041AF30 /* ViewportBounds.swift */, 4EED9BAB2D22730400B288E7 /* FilterType.swift */, ); path = Common; @@ -3164,7 +3164,6 @@ 0841BAA32CFA31A300049E31 /* SpacingSection.swift in Sources */, 08B191982CF4A1010057BC04 /* SignUpStep4Reactor.swift in Sources */, 086F8A0A2D2621EE00CA4FC9 /* MyPageMyCommentTitleSection.swift in Sources */, - 4E5825642D19517B00EE83EF /* StoreListPanelLayout.swift in Sources */, 089952602D0366C40022AEF9 /* SearchMainController.swift in Sources */, 081898D42D30F5840067BF01 /* CategoryEditModalController.swift in Sources */, 088DE2562D144A830030FA9E /* DetailCommentSectionCell.swift in Sources */, @@ -3347,6 +3346,7 @@ 08B191672CF432220057BC04 /* PPCancelHeaderView.swift in Sources */, BD9103812CF614A900BBCCAE /* HomeAPIUseCaseImpl.swift in Sources */, 08DC62112CF8B446002A2F44 /* SortedRequestDTO.swift in Sources */, + 4EAB809F2D3F8EF50041AF30 /* ViewportBounds.swift in Sources */, 08B191712CF4398D0057BC04 /* PPLabel.swift in Sources */, 4E685ED22D12CEB6001EF91C /* FilterBottomSheetView.swift in Sources */, 0899524F2D033B5A0022AEF9 /* GetSearchPopUpListRequestDTO.swift in Sources */, @@ -3355,7 +3355,6 @@ 4E685EE42D12CEB6001EF91C /* MapFilterChips.swift in Sources */, BD91036E2CF6149D00BBCCAE /* SignUpRequestDTO.swift in Sources */, 08B191632CF430F30057BC04 /* PPProgressIndicator.swift in Sources */, - 4E92F0D52D1BE72F00D00495 /* StoreListHeaderView.swift in Sources */, 081898942D2965C20067BF01 /* ProfileEditController.swift in Sources */, 0818992D2D3506240067BF01 /* FAQDropdownSectionCell.swift in Sources */, 08B191742CF43DF40057BC04 /* SignUpCheckBoxButton.swift in Sources */, @@ -3442,6 +3441,7 @@ 086F89CA2D1E42A700CA4FC9 /* CommentUserBlockView.swift in Sources */, 086DD8D02CFDFEB900B97D3B /* HomeListReactor.swift in Sources */, 081899202D34DF880067BF01 /* MyPageNoticeDetailController.swift in Sources */, + 4EAB809D2D3F78AA0041AF30 /* GMSMapViewDelegateProxy.swift in Sources */, 083C86472D0DCDFB003F441C /* AddCommentTitleSection.swift in Sources */, 0841BAB62CFABEDC00049E31 /* HomePopularCardSectionCell.swift in Sources */, 08B191822CF48A7B0057BC04 /* SignUpStep2Controller.swift in Sources */, diff --git a/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift index 3cf4ef49..2370b70d 100644 --- a/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift +++ b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift @@ -5,9 +5,11 @@ import RxSwift import RxCocoa import PhotosUI import Alamofire +import GoogleMaps +import CoreLocation final class PopUpStoreRegisterViewController: BaseViewController { -// typealias Reactor = PopUpStoreRegisterReactor + // typealias Reactor = PopUpStoreRegisterReactor // MARK: - Navigation/Header @@ -30,8 +32,8 @@ final class PopUpStoreRegisterViewController: BaseViewController { let presignedService = PreSignedService() var disposeBag = DisposeBag() - private let nickname: String - private let navContainer = UIView() + private let nickname: String + private let navContainer = UIView() init(nickname: String, adminUseCase: AdminUseCase, editingStore: GetAdminPopUpStoreListResponseDTO.PopUpStore? = nil) { self.nickname = nickname @@ -40,15 +42,15 @@ final class PopUpStoreRegisterViewController: BaseViewController { super.init() self.accountIdLabel.text = nickname + "님" if editingStore != nil { - pageTitleLabel.text = "팝업스토어 수정" - } - } + pageTitleLabel.text = "팝업스토어 수정" + } + } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } private lazy var imagesCollectionView: UICollectionView = { @@ -73,11 +75,11 @@ final class PopUpStoreRegisterViewController: BaseViewController { }() private let accountIdLabel: UILabel = { - let lbl = UILabel() - lbl.font = UIFont.systemFont(ofSize: 14, weight: .semibold) - lbl.textColor = .black - return lbl - }() + let lbl = UILabel() + lbl.font = UIFont.systemFont(ofSize: 14, weight: .semibold) + lbl.textColor = .black + return lbl + }() private let menuButton: UIButton = { @@ -105,12 +107,12 @@ final class PopUpStoreRegisterViewController: BaseViewController { }() // (3) 메인 이미지 - private let mainImageView: UIImageView = { - let iv = UIImageView() - iv.contentMode = .scaleAspectFit - iv.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2) - return iv - }() + // private let mainImageView: UIImageView = { + // let iv = UIImageView() + // iv.contentMode = .scaleAspectFit + // iv.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2) + // return iv + // }() private let addImageButton = UIButton(type: .system).then { $0.setTitle("이미지 추가", for: .normal) $0.setTitleColor(.systemBlue, for: .normal) @@ -211,13 +213,13 @@ final class PopUpStoreRegisterViewController: BaseViewController { fillFormWithExistingData(store) } - setupNavigation() setupLayout() setupRows() setupImageCollectionUI() setupImageCollectionActions() - updateSaveButtonState() + setupKeyboardHandling() + setupAddressField() } @@ -248,9 +250,67 @@ final class PopUpStoreRegisterViewController: BaseViewController { private func fillFormWithExistingData(_ store: GetAdminPopUpStoreListResponseDTO.PopUpStore) { nameField?.text = store.name categoryButton.setTitle("\(store.categoryName) ▾", for: .normal) -// addressField?.text = store.address + // addressField?.text = store.address + } + private func setupKeyboardHandling() { + // 키보드 Notification 등록 + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillShow), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + + // 스크롤뷰 키보드 처리 설정 + scrollView.keyboardDismissMode = .interactive } + @objc private func keyboardWillShow(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { + return + } + + let keyboardHeight = keyboardFrame.height + let contentInset = UIEdgeInsets( + top: 0, + left: 0, + bottom: keyboardHeight, + right: 0 + ) + + scrollView.contentInset = contentInset + scrollView.scrollIndicatorInsets = contentInset + + // 현재 활성화된 필드가 키보드에 가려지는지 확인 + if let activeField = view.findFirstResponder() { + let activeRect = activeField.convert(activeField.bounds, to: scrollView) + let bottomOffset = activeRect.maxY + 20 // 여유 공간 + + if bottomOffset > (scrollView.frame.height - keyboardHeight) { + let scrollPoint = CGPoint( + x: 0, + y: bottomOffset - (scrollView.frame.height - keyboardHeight) + ) + scrollView.setContentOffset(scrollPoint, animated: true) + } + } + } + + @objc private func keyboardWillHide(_ notification: Notification) { + UIView.animate(withDuration: 0.3) { + self.scrollView.contentInset = .zero + self.scrollView.scrollIndicatorInsets = .zero + } + } + + // MARK: - Layout private func setupLayout() { @@ -283,6 +343,7 @@ final class PopUpStoreRegisterViewController: BaseViewController { make.width.height.equalTo(32) } + // (2) 타이틀 컨테이너 view.addSubview(titleContainer) titleContainer.snp.makeConstraints { make in make.top.equalTo(navContainer.snp.bottom) @@ -303,35 +364,48 @@ final class PopUpStoreRegisterViewController: BaseViewController { make.left.equalTo(backButton.snp.right).offset(4) } - // (3) Scroll + // (3) 스크롤뷰 view.addSubview(scrollView) scrollView.snp.makeConstraints { make in make.top.equalTo(titleContainer.snp.bottom) make.left.right.equalToSuperview() make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-74) } + scrollView.addSubview(contentView) contentView.snp.makeConstraints { make in make.edges.equalToSuperview() make.width.equalTo(scrollView.snp.width) } - // 메인 이미지 - contentView.addSubview(mainImageView) - mainImageView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(12) - make.centerX.equalToSuperview() - make.width.height.equalTo(80) + // (4) 이미지 영역 추가 + let buttonStack = UIStackView(arrangedSubviews: [addImageButton, removeAllButton]) + buttonStack.axis = .horizontal + buttonStack.distribution = .fillEqually + buttonStack.spacing = 16 + + contentView.addSubview(buttonStack) + buttonStack.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.left.right.equalToSuperview().inset(16) + make.height.equalTo(40) + } + + contentView.addSubview(imagesCollectionView) + imagesCollectionView.snp.makeConstraints { make in + make.top.equalTo(buttonStack.snp.bottom).offset(8) + make.left.right.equalToSuperview().inset(16) + make.height.equalTo(130) } - // (4) form BG + // (5) 폼 배경 contentView.addSubview(formBackgroundView) formBackgroundView.snp.makeConstraints { make in - make.top.equalTo(mainImageView.snp.bottom).offset(16) + make.top.equalTo(imagesCollectionView.snp.bottom).offset(16) make.left.right.equalToSuperview().inset(16) - make.bottom.equalToSuperview() - + make.bottom.equalToSuperview().offset(-16) } + formBackgroundView.addSubview(verticalStack) verticalStack.axis = .vertical verticalStack.spacing = 0 @@ -339,16 +413,14 @@ final class PopUpStoreRegisterViewController: BaseViewController { make.edges.equalToSuperview() } - // (5) 저장 버튼 + // (6) 저장 버튼 view.addSubview(saveButton) saveButton.snp.makeConstraints { make in make.left.right.equalToSuperview().inset(16) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) - make.top.equalTo(scrollView.snp.bottom).offset(15) make.height.equalTo(44) } } - // MARK: - Setup Rows private func setupRows() { addRowTextField(leftTitle: "이름", placeholder: "팝업스토어 이름을 입력해 주세요.") @@ -457,21 +529,21 @@ final class PopUpStoreRegisterViewController: BaseViewController { let descTV = makeRoundedTextView() self.descTV = descTV // 설명 필드 연결 addRowCustom(leftTitle: "설명", rightView: descTV, rowHeight: nil, totalHeight: 120) - + } // MARK: - Row - + private func addRowTextField(leftTitle: String, placeholder: String) { let tf = makeRoundedTextField(placeholder) - if leftTitle == "이름" { - nameField = tf // 이름 필드 연결 - } else if leftTitle == "주소" { - addressField = tf // 주소 필드 연결 - } - addRowCustom(leftTitle: leftTitle, rightView: tf) - } + if leftTitle == "이름" { + nameField = tf // 이름 필드 연결 + } else if leftTitle == "주소" { + addressField = tf // 주소 필드 연결 + } + addRowCustom(leftTitle: leftTitle, rightView: tf) + } private func setupImageCollectionUI() { // 1) 상단 버튼들 (Add / RemoveAll) @@ -482,7 +554,7 @@ final class PopUpStoreRegisterViewController: BaseViewController { contentView.addSubview(buttonStack) buttonStack.snp.makeConstraints { make in - make.top.equalTo(mainImageView.snp.bottom).offset(16) + make.top.equalToSuperview().offset(16) make.left.right.equalToSuperview().inset(16) make.height.equalTo(40) } @@ -549,7 +621,7 @@ final class PopUpStoreRegisterViewController: BaseViewController { /** rowHeight: 기본(41) totalHeight: 2줄 필요한 경우(90~100), 3줄 등 필요 시 더 크게 - */ + */ private func addRowCustom(leftTitle: String, rightView: UIView, rowHeight: CGFloat? = 36, @@ -1127,7 +1199,7 @@ private extension PopUpStoreRegisterViewController { // 이미지 업로드 private func uploadImages() { let uuid = UUID().uuidString -// let baseS3URL = Secrets.popPoolS3BaseURL.rawValue + // let baseS3URL = Secrets.popPoolS3BaseURL.rawValue let updatedImages = images.enumerated().map { index, image in let filePath = "PopUpImage/\(nameField?.text ?? "")/\(uuid)/\(index).jpg" return ExtendedImage( @@ -1136,11 +1208,11 @@ private extension PopUpStoreRegisterViewController { isMain: image.isMain) } -// let presignedService = PreSignedService() + // let presignedService = PreSignedService() presignedService.tryUpload(datas: updatedImages.map { PreSignedService.PresignedURLRequest(filePath: $0.filePath, image: $0.image) }) -// .observe(on: MainScheduler.instance) + // .observe(on: MainScheduler.instance) .subscribe( onSuccess: { [weak self] _ in guard let self = self else { return } @@ -1234,3 +1306,88 @@ private extension PopUpStoreRegisterViewController { present(alert, animated: true) } } +extension UIView { + func findFirstResponder() -> UIView? { + if isFirstResponder { + return self + } + + for subview in subviews { + if let firstResponder = subview.findFirstResponder() { + return firstResponder + } + } + + return nil + } +} +extension PopUpStoreRegisterViewController: UITextFieldDelegate { + private func setupAddressField() { + // RxCocoa를 사용한 텍스트 필드 바인딩 + addressField?.rx.text.orEmpty + .distinctUntilChanged() + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) + .filter { !$0.isEmpty } + .flatMapLatest { [weak self] address -> Observable in + return Observable.create { observer in + let geocoder = CLGeocoder() + let fullAddress = "\(address), Korea" + + geocoder.geocodeAddressString( + fullAddress, + in: nil, + preferredLocale: Locale(identifier: "ko_KR") + ) { placemarks, error in + if let error = error { + print("Geocoding error: \(error.localizedDescription)") + observer.onNext(nil) + observer.onCompleted() + return + } + + if let location = placemarks?.first?.location { + observer.onNext(location) + } else { + observer.onNext(nil) + } + observer.onCompleted() + } + + return Disposables.create() + } + } + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] location in + guard let location = location else { return } + self?.latField?.text = String(format: "%.6f", location.coordinate.latitude) + self?.lonField?.text = String(format: "%.6f", location.coordinate.longitude) + self?.updateSaveButtonState() + }) + .disposed(by: disposeBag) + } + + + @objc private func addressFieldDidChange(_ textField: UITextField) { + guard let address = textField.text, !address.isEmpty else { return } + + // 한국 주소임을 명시 + let geocoder = CLGeocoder() + let addressWithCountry = address + ", South Korea" + + geocoder.geocodeAddressString(addressWithCountry) { [weak self] placemarks, error in + if let error = error { + print("Geocoding error: \(error.localizedDescription)") + return + } + + guard let location = placemarks?.first?.location else { return } + + DispatchQueue.main.async { + self?.latField?.text = String(format: "%.6f", location.coordinate.latitude) + self?.lonField?.text = String(format: "%.6f", location.coordinate.longitude) + self?.updateSaveButtonState() + } + } + } + +} diff --git a/Poppool/Poppool/Presentation/Map/Common/GMSMapViewDelegateProxy.swift b/Poppool/Poppool/Presentation/Map/Common/GMSMapViewDelegateProxy.swift new file mode 100644 index 00000000..a3e7064b --- /dev/null +++ b/Poppool/Poppool/Presentation/Map/Common/GMSMapViewDelegateProxy.swift @@ -0,0 +1,39 @@ +import RxSwift +import RxCocoa +import GoogleMaps + +class GMSMapViewDelegateProxy: DelegateProxy, DelegateProxyType, GMSMapViewDelegate { + + public private(set) weak var mapView: GMSMapView? + let didChangePositionSubject = PublishSubject() + let idleAtPositionSubject = PublishSubject() + + init(mapView: GMSMapView) { + self.mapView = mapView + super.init(parentObject: mapView, delegateProxy: GMSMapViewDelegateProxy.self) + } + + static func registerKnownImplementations() { + self.register { mapView in + GMSMapViewDelegateProxy(mapView: mapView) + } + } + + static func currentDelegate(for object: GMSMapView) -> GMSMapViewDelegate? { + return object.delegate + } + + static func setCurrentDelegate(_ delegate: GMSMapViewDelegate?, to object: GMSMapView) { + object.delegate = delegate + } + + func mapView(_ mapView: GMSMapView, didChange position: GMSCameraPosition) { + didChangePositionSubject.onNext(()) + self.forwardToDelegate()?.mapView?(mapView, didChange: position) + } + + func mapView(_ mapView: GMSMapView, idleAt position: GMSCameraPosition) { + idleAtPositionSubject.onNext(()) + self.forwardToDelegate()?.mapView?(mapView, idleAt: position) + } +} diff --git a/Poppool/Poppool/Presentation/Map/Common/ViewportBounds.swift b/Poppool/Poppool/Presentation/Map/Common/ViewportBounds.swift new file mode 100644 index 00000000..9c1fc360 --- /dev/null +++ b/Poppool/Poppool/Presentation/Map/Common/ViewportBounds.swift @@ -0,0 +1,6 @@ +import CoreLocation + +struct ViewportBounds { + let northEast: CLLocationCoordinate2D + let southWest: CLLocationCoordinate2D +} diff --git a/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonBackgroundView.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonBackgroundView.swift index 5f25b783..0960692d 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonBackgroundView.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonBackgroundView.swift @@ -75,9 +75,11 @@ final class BalloonBackgroundView: UIView { containerView.addSubview(collectionView) containerView.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview() - make.bottom.equalToSuperview() - make.top.equalToSuperview().offset(11) +// make.leading.trailing.equalToSuperview() +// make.bottom.equalToSuperview() +// make.top.equalToSuperview().offset(11) + make.edges.equalToSuperview() // 단순화 + } collectionView.snp.makeConstraints { make in @@ -98,7 +100,6 @@ final class BalloonBackgroundView: UIView { let arrowHeight: CGFloat = 10 let arrowX = bounds.width * arrowPosition - (arrowWidth / 2) - // 통합된 하나의 패스로 그리기 let path = UIBezierPath() // 화살표 시작점부터 그리기 시작 @@ -116,11 +117,9 @@ final class BalloonBackgroundView: UIView { path.addLine(to: CGPoint(x: containerRect.minX, y: containerRect.minY)) path.close() - // 전체를 하나의 색으로 채우기 UIColor.g50.setFill() path.fill() - // 필요한 경우 그림자 추가 self.layer.shadowColor = UIColor.black.cgColor self.layer.shadowOpacity = 0.1 self.layer.shadowOffset = CGSize(width: 0, height: 2) @@ -203,7 +202,7 @@ final class BalloonBackgroundView: UIView { // 높이 계산 let itemHeight: CGFloat = 36 let interGroupSpacing: CGFloat = 8 - let verticalInset: CGFloat = 20 + 19 + let verticalInset: CGFloat = 20 + 10 let totalHeight = max( (itemHeight * CGFloat(numberOfRows)) + (interGroupSpacing * CGFloat(numberOfRows - 1)) + diff --git a/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonChipCell.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonChipCell.swift index f2d1fbd5..c45a767c 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonChipCell.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/BalloonChipCell.swift @@ -78,7 +78,6 @@ final class BalloonChipCell: UICollectionViewCell { } } -// UIImage extension for resizing extension UIImage { func resize(to size: CGSize) -> UIImage? { UIGraphicsBeginImageContextWithOptions(size, false, 0.0) diff --git a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift index 41f2fb96..ef001207 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift @@ -157,11 +157,18 @@ final class FilterBottomSheetView: UIView { } private func setupConstraints() { + // 1. 먼저 self의 width 설정 + self.snp.makeConstraints { make in + make.width.equalTo(UIScreen.main.bounds.width) + } + + // 2. containerView 설정 containerView.snp.makeConstraints { make in make.left.right.bottom.equalToSuperview() make.top.equalTo(headerView.snp.top) } + // 3. headerView 및 내부 요소들 headerView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(70) @@ -178,11 +185,13 @@ final class FilterBottomSheetView: UIView { make.size.equalTo(24) } + // 4. segmentedControl segmentedControl.snp.makeConstraints { make in make.top.equalTo(headerView.snp.bottom).offset(16) make.leading.trailing.equalToSuperview() } + // 5. locationScrollView 및 contentView locationScrollView.snp.makeConstraints { make in make.top.equalTo(segmentedControl.snp.bottom).offset(16) make.leading.trailing.equalToSuperview() @@ -194,12 +203,28 @@ final class FilterBottomSheetView: UIView { make.height.equalToSuperview() } + // 6. categoryCollectionView + categoryCollectionView.snp.makeConstraints { make in + make.top.equalTo(segmentedControl.snp.bottom).offset(16) + make.leading.trailing.equalToSuperview() +// categoryHeightConstraint = make.height.equalTo(160).constraint + } + + // 7. balloonBackgroundView balloonBackgroundView.snp.makeConstraints { make in make.top.equalTo(locationScrollView.snp.bottom).offset(16) make.leading.trailing.equalToSuperview().inset(16) - balloonHeightConstraint = make.height.equalTo(150).constraint + balloonHeightConstraint = make.height.equalTo(0).constraint + } + + // 8. filterChipsView + filterChipsView.snp.makeConstraints { make in + make.top.equalTo(balloonBackgroundView.snp.bottom).offset(24) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(80) } + // 9. buttonStack buttonStack.snp.makeConstraints { make in make.top.equalTo(filterChipsView.snp.bottom).offset(24) make.leading.trailing.equalToSuperview().inset(16) @@ -288,16 +313,29 @@ final class FilterBottomSheetView: UIView { } func updateContentVisibility(isCategorySelected: Bool) { + // 애니메이션과 함께 자연스럽게 처리 + UIView.animate(withDuration: 0.3) { + // 먼저 투명도 조정 + self.locationScrollView.alpha = isCategorySelected ? 0 : 1 + self.balloonBackgroundView.alpha = isCategorySelected ? 0 : 1 + self.categoryCollectionView.alpha = isCategorySelected ? 1 : 0 + + // 그 다음 숨김 처리 self.locationScrollView.isHidden = isCategorySelected self.balloonBackgroundView.isHidden = isCategorySelected self.categoryCollectionView.isHidden = !isCategorySelected - self.balloonHeightConstraint?.update(offset: isCategorySelected ? 170 : self.balloonBackgroundView.calculateHeight()) + // 높이 조정 + let newHeight = isCategorySelected ? 170 : self.balloonBackgroundView.calculateHeight() + self.balloonHeightConstraint?.update(offset: newHeight) + self.layoutIfNeeded() -// } + } } + + private func createStyledButton(title: String, isSelected: Bool = false) -> PPButton { let button = PPButton( style: .secondary, @@ -338,21 +376,21 @@ final class FilterBottomSheetView: UIView { } func updateBalloonHeight(isHidden: Bool, dynamicHeight: CGFloat = 160) { - let targetHeight = isHidden ? 0 : dynamicHeight - self.balloonHeightConstraint?.update(offset: targetHeight) - self.balloonBackgroundView.isHidden = isHidden - self.layoutIfNeeded() + UIView.animate(withDuration: 0.3) { + self.balloonBackgroundView.alpha = isHidden ? 0 : 1 + self.balloonHeightConstraint?.update(offset: isHidden ? 0 : dynamicHeight) + self.layoutIfNeeded() + } } - func updateBalloonPosition(for button: UIButton) { let buttonFrame = button.convert(button.bounds, to: self) let buttonCenterX = buttonFrame.midX let totalWidth = bounds.width balloonBackgroundView.arrowPosition = buttonCenterX / totalWidth - self.layoutIfNeeded() + balloonBackgroundView.setNeedsDisplay() } private func updateBalloonPositionAccurately(for button: PPButton) { let buttonFrameInBalloon = button.convert(button.bounds, to: balloonBackgroundView) diff --git a/Poppool/Poppool/Presentation/Map/MapAPI/MapAPIEndpoint.swift b/Poppool/Poppool/Presentation/Map/MapAPI/MapAPIEndpoint.swift index 878c1d61..2c18a629 100644 --- a/Poppool/Poppool/Presentation/Map/MapAPI/MapAPIEndpoint.swift +++ b/Poppool/Poppool/Presentation/Map/MapAPI/MapAPIEndpoint.swift @@ -49,10 +49,10 @@ struct MapAPIEndpoint { static func locations_searchStores( query: String, categories: [String] - ) -> Endpoint { + ) -> Endpoint { let params = SearchQueryDTO( query: query, - categories: categories + categories: categories.isEmpty ? nil : categories ) return Endpoint( @@ -75,5 +75,5 @@ struct BoundQueryDTO: Encodable { struct SearchQueryDTO: Encodable { let query: String - let categories: [String] + let categories: [String]? } diff --git a/Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStore.swift b/Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStore.swift index 1c633f66..8edfa104 100644 --- a/Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStore.swift +++ b/Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStore.swift @@ -19,6 +19,7 @@ struct MapPopUpStore: Equatable { let markerId: Int64 let markerTitle: String let markerSnippet: String + let mainImageUrl: String? // 이미지 URL 추가 var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) @@ -32,14 +33,16 @@ extension MapPopUpStore { count: 1 // 클러스터링 구현 시 수정 ) } -// -// func toCardInput() -> MapPopupCarouselView.Input { -// return .init( -// image: nil, -// category: self.category, -// title: self.name, -// location: self.address, -// date: "\(self.startDate) - \(self.endDate)" -// ) -// } + + func toStoreItem() -> StoreItem { + return StoreItem( + id: Int(id), + thumbnailURL: mainImageUrl ?? "", // 이미지 URL 매핑 + category: category, + title: name, + location: address, + dateRange: "\(startDate) ~ \(endDate)", + isBookmarked: false // 기본값 + ) + } } diff --git a/Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStoreDTO.swift b/Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStoreDTO.swift index e2e99e86..72f55b73 100644 --- a/Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStoreDTO.swift +++ b/Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStoreDTO.swift @@ -1,15 +1,8 @@ -// -// MapPopUpStoreDTO.swift -// Poppool -// -// Created by 김기현 on 12/3/24. -// - import Foundation struct MapPopUpStoreDTO: Codable { let id: Int64 - let category: String + let categoryName: String let name: String let address: String let startDate: String @@ -19,11 +12,14 @@ struct MapPopUpStoreDTO: Codable { let markerId: Int64 let markerTitle: String let markerSnippet: String + let mainImageUrl: String? + let bookmarkYn: Bool? + // toDomain() 메서드 추가 func toDomain() -> MapPopUpStore { return MapPopUpStore( id: id, - category: category, + category: categoryName, name: name, address: address, startDate: startDate, @@ -32,7 +28,9 @@ struct MapPopUpStoreDTO: Codable { longitude: longitude, markerId: markerId, markerTitle: markerTitle, - markerSnippet: markerSnippet + markerSnippet: markerSnippet, + mainImageUrl: mainImageUrl + ) } } @@ -41,6 +39,7 @@ struct GetViewBoundPopUpStoreListResponse: Decodable { var popUpStoreList: [MapPopUpStoreDTO] } -struct MapSearchPopUpStore: Decodable { +struct MapSearchResponseDTO: Codable { let popUpStoreList: [MapPopUpStoreDTO] + let loginYn: Bool } diff --git a/Poppool/Poppool/Presentation/Map/MapAPI/Repository/MapRepository.swift b/Poppool/Poppool/Presentation/Map/MapAPI/Repository/MapRepository.swift index a7982a14..f48d6647 100644 --- a/Poppool/Poppool/Presentation/Map/MapAPI/Repository/MapRepository.swift +++ b/Poppool/Poppool/Presentation/Map/MapAPI/Repository/MapRepository.swift @@ -37,6 +37,11 @@ class DefaultMapRepository: MapRepository { southWestLon: Double, categories: [String] ) -> Observable<[MapPopUpStoreDTO]> { + Logger.log( + message: "지도의 범위 내 스토어 정보를 가져옵니다. 카테고리: \(categories)", + category: .network + ) + return provider.requestData( with: MapAPIEndpoint.locations_fetchStoresInBounds( northEastLat: northEastLat, @@ -47,6 +52,20 @@ class DefaultMapRepository: MapRepository { ), interceptor: nil ) + .do( + onNext: { response in + Logger.log( + message: "스토어 조회 성공! 응답: \(response)", + category: .network + ) + }, + onError: { error in + Logger.log( + message: "스토어 조회 중 오류 발생: \(error.localizedDescription)", + category: .error + ) + } + ) .map { $0.popUpStoreList } } @@ -54,6 +73,11 @@ class DefaultMapRepository: MapRepository { query: String, categories: [String] ) -> Observable<[MapPopUpStoreDTO]> { + Logger.log( + message: "스토어 검색을 시작합니다. 검색어: '\(query)', 카테고리: \(categories)", + category: .network + ) + return provider.requestData( with: MapAPIEndpoint.locations_searchStores( query: query, @@ -61,6 +85,20 @@ class DefaultMapRepository: MapRepository { ), interceptor: nil ) + .do( + onNext: { response in + Logger.log( + message: "스토어 검색 성공! 응답: \(response)", + category: .network + ) + }, + onError: { error in + Logger.log( + message: "스토어 검색 중 오류 발생: \(error.localizedDescription)", + category: .error + ) + } + ) .map { $0.popUpStoreList } } } diff --git a/Poppool/Poppool/Presentation/Map/MapFilterChips.swift b/Poppool/Poppool/Presentation/Map/MapFilterChips.swift index f7062a5f..7745525c 100644 --- a/Poppool/Poppool/Presentation/Map/MapFilterChips.swift +++ b/Poppool/Poppool/Presentation/Map/MapFilterChips.swift @@ -74,14 +74,14 @@ class MapFilterChips: UIView { button.layer.cornerRadius = 18 let xButton = UIButton(type: .custom) - xButton.setImage(UIImage(named: "icon_xmark")?.withRenderingMode(.alwaysTemplate), for: .normal) + xButton.setImage(UIImage(named: "icon_xmark_white")?.withRenderingMode(.alwaysTemplate), for: .normal) xButton.tintColor = .white button.addSubview(xButton) xButton.snp.makeConstraints { make in make.centerY.equalToSuperview() - make.trailing.equalToSuperview().inset(12) - make.size.equalTo(16) + make.trailing.equalToSuperview().inset(14) + make.size.equalTo(18) } xButton.addTarget(self, action: #selector(handleClearButtonTapped(_:)), for: .touchUpInside) @@ -95,7 +95,7 @@ class MapFilterChips: UIView { button.backgroundColor = .white button.layer.borderWidth = 1 button.layer.borderColor = UIColor.g200.cgColor - button.layer.cornerRadius = 16 + button.layer.cornerRadius = 18 button.contentEdgeInsets = UIEdgeInsets(top: 7, left: 16, bottom: 7, right: 16) } } diff --git a/Poppool/Poppool/Presentation/Map/MapReactor.swift b/Poppool/Poppool/Presentation/Map/MapReactor.swift index 514beafd..87abeccd 100644 --- a/Poppool/Poppool/Presentation/Map/MapReactor.swift +++ b/Poppool/Poppool/Presentation/Map/MapReactor.swift @@ -3,163 +3,234 @@ import RxSwift import CoreLocation final class MapReactor: Reactor { - // MARK: - Reactor - enum Action { - case viewDidLoad - case searchTapped(String) - case locationButtonTapped - case listButtonTapped - case filterTapped(FilterType?) - case filterUpdated(FilterType, [String]) - case clearFilters(FilterType) - case updateBothFilters(locations: [String], categories: [String]) // 새로 추가 - - } - - enum Mutation { - case setSearchResult(MapPopUpStore?) - case setActiveFilter(FilterType?) - case setLocationFilters([String]) - case setCategoryFilters([String]) - case updateLocationDisplay(String) - case updateCategoryDisplay(String) - case clearLocationFilters - case clearCategoryFilters - case updateBothFilters(locations: [String], categories: [String]) // 새로 추가 - case setToastMessage(String) // 토스트 메시지 - - - } - - struct State { - var searchResult: MapPopUpStore? = nil - var toastMessage: String? = nil - var activeFilterType: FilterType? - var selectedLocationFilters: [String] = [] - var selectedCategoryFilters: [String] = [] - var locationDisplayText: String = "지역선택" - var categoryDisplayText: String = "카테고리" - } - - let initialState: State - private let useCase: MapUseCase - - init(useCase: MapUseCase) { - self.useCase = useCase - self.initialState = State() - } - - func mutate(action: Action) -> Observable { - switch action { - case let .searchTapped(query): - return useCase.searchStores(query: query, categories: []) - .flatMap { results -> Observable in - if let firstResult = results.first { - return .just(.setSearchResult(firstResult)) - } else { - return .just(.setToastMessage("검색 결과가 없습니다.")) - } - } - case let .filterTapped(filterType): - return .just(.setActiveFilter(filterType)) - - case let .filterUpdated(type, values): - switch type { - case .location: - let displayText = formatDisplayText(values, defaultText: "지역선택") - return .concat([ - .just(.setLocationFilters(values)), - .just(.updateLocationDisplay(displayText)) - ]) - case .category: - let displayText = formatDisplayText(values, defaultText: "카테고리") - return .concat([ - .just(.setCategoryFilters(values)), - .just(.updateCategoryDisplay(displayText)) - ]) - } - case let .updateBothFilters(locations, categories): - print("[DEBUG] 📝 Updating both filters - Locations: \(locations), Categories: \(categories)") - return .concat([ - .just(.updateBothFilters(locations: locations, categories: categories)), - .just(.updateLocationDisplay(formatDisplayText(locations, defaultText: "지역선택"))), - .just(.updateCategoryDisplay(formatDisplayText(categories, defaultText: "카테고리"))) - ]) - - - case let .clearFilters(type): - switch type { - case .location: - return .concat([ - .just(.clearLocationFilters), - .just(.updateLocationDisplay("지역선택")) - ]) - case .category: - return .concat([ - .just(.clearCategoryFilters), - .just(.updateCategoryDisplay("카테고리")) - ]) - } - - default: - return .empty() - } - } - - private func formatDisplayText(_ values: [String], defaultText: String) -> String { - guard !values.isEmpty else { return defaultText } - return values.count > 1 ? "\(values[0]) 외 \(values.count - 1)개" : values[0] - } - - func reduce(state: State, mutation: Mutation) -> State { - var newState = state - - - switch mutation { - case let .setSearchResult(result): - newState.searchResult = result - case let .setToastMessage(message): - newState.toastMessage = message - - case let .setActiveFilter(filterType): - newState.activeFilterType = filterType - print("[DEBUG] 🎯 Active Filter Changed: \(String(describing: filterType))") - - case let .setLocationFilters(filters): - newState.selectedLocationFilters = filters - print("Updating selectedLocationFilters to: \(filters)") - - case let .setCategoryFilters(filters): - newState.selectedCategoryFilters = filters - print("[DEBUG] 🔄 Category Filters Updated: \(filters)") - - case let .updateLocationDisplay(text): - newState.locationDisplayText = text - - case let .updateCategoryDisplay(text): - newState.categoryDisplayText = text - - case .clearLocationFilters: - newState.selectedLocationFilters = [] - - case .clearCategoryFilters: - newState.selectedCategoryFilters = [] - case let .updateBothFilters(locations, categories): - print("[DEBUG] 💾 Reducing both filters update") - print("[DEBUG] 📍 Previous state - Locations: \(newState.selectedLocationFilters)") - print("[DEBUG] 🏷️ Previous state - Categories: \(newState.selectedCategoryFilters)") - - newState.selectedLocationFilters = locations - newState.selectedCategoryFilters = categories - - print("[DEBUG] ✅ Updated state - Locations: \(newState.selectedLocationFilters)") - print("[DEBUG] ✅ Updated state - Categories: \(newState.selectedCategoryFilters)") - - return newState - } - - - - return newState - } + // MARK: - Reactor + enum Action { + case viewDidLoad + case searchTapped(String) + case locationButtonTapped + case listButtonTapped + case filterTapped(FilterType?) + case filterUpdated(FilterType, [String]) + case clearFilters(FilterType) + case updateBothFilters(locations: [String], categories: [String]) // 새로 추가 + case didSelectItem(MapPopUpStore) + case viewportChanged( + northEastLat: Double, + northEastLon: Double, + southWestLat: Double, + southWestLon: Double + ) + + } + + enum Mutation { + case setActiveFilter(FilterType?) + case setLocationFilters([String]) + case setCategoryFilters([String]) + case updateLocationDisplay(String) + case updateCategoryDisplay(String) + case clearLocationFilters + case clearCategoryFilters + case updateBothFilters(locations: [String], categories: [String]) // 새로 추가 + case setToastMessage(String) + case setLoading(Bool) // 검색시 로딩 + case setSearchResults([MapPopUpStore]) + case setSelectedStore(MapPopUpStore) // 선택된 스토어 상태 + case setViewportStores([MapPopUpStore]) + case setError(Error?) + + + } + + struct State { + var isLoading: Bool = false + var searchResults: [MapPopUpStore] = [] + var searchResult: MapPopUpStore? = nil + var toastMessage: String? = nil + var activeFilterType: FilterType? + var selectedLocationFilters: [String] = [] + var selectedCategoryFilters: [String] = [] + var locationDisplayText: String = "지역선택" + var categoryDisplayText: String = "카테고리" + var selectedStore: MapPopUpStore? // 선택된 스토어 + var viewportStores: [MapPopUpStore] = [] + var error: Error? = nil + + + + } + + let initialState: State + private let useCase: MapUseCase + + init(useCase: MapUseCase) { + self.useCase = useCase + self.initialState = State() + } + + func mutate(action: Action) -> Observable { + switch action { + case let .searchTapped(query): + let categories = currentState.selectedCategoryFilters + return .concat([ + .just(.setLoading(true)), // 로딩 시작 + useCase.searchStores(query: query, categories: categories) + .flatMap { results -> Observable in + if results.isEmpty { + return .just(.setToastMessage("검색 결과가 없습니다.")) + } else { + return .just(.setSearchResults(results)) + } + }, + .just(.setLoading(false)) // 로딩 종료 + ]) + case let .viewportChanged(northEastLat, northEastLon, southWestLat, southWestLon): + return .concat([ + .just(.setLoading(true)), + useCase.fetchStoresInBounds( + northEastLat: northEastLat, + northEastLon: northEastLon, + southWestLat: southWestLat, + southWestLon: southWestLon, + categories: currentState.selectedCategoryFilters + ) + .map(Mutation.setViewportStores) + .catch { .just(.setError($0)) }, + .just(.setLoading(false)) + ]) + + case let .updateBothFilters(locations, categories): + return .concat([ + .just(.setLocationFilters(locations)), + .just(.setCategoryFilters(categories)) + ]) + case let .filterTapped(filterType): + return .just(.setActiveFilter(filterType)) + + case let .filterUpdated(type, values): + switch type { + case .location: + let displayText = formatDisplayText(values, defaultText: "지역선택") + return .concat([ + .just(.setLocationFilters(values)), + .just(.updateLocationDisplay(displayText)) + ]) + case .category: + let displayText = formatDisplayText(values, defaultText: "카테고리") + return .concat([ + .just(.setCategoryFilters(values)), + .just(.updateCategoryDisplay(displayText)) + ]) + } + case let .updateBothFilters(locations, categories): + Logger.log( + message: """ + Updating both filters: + - Locations: \(locations) + - Categories: \(categories) + """, + category: .debug + ) + return .concat([ + .just(.setLocationFilters(locations)), + .just(.setCategoryFilters(categories)) + ]) + + case let .clearFilters(type): + switch type { + case .location: + return .concat([ + .just(.clearLocationFilters), + .just(.updateLocationDisplay("지역선택")) + ]) + case .category: + return .concat([ + .just(.clearCategoryFilters), + .just(.updateCategoryDisplay("카테고리")) + ]) + } + case let .didSelectItem(store): + return .just(.setSelectedStore(store)) + default: + return .empty() + } + } + + private func formatDisplayText(_ values: [String], defaultText: String) -> String { + guard !values.isEmpty else { return defaultText } + return values.count > 1 ? "\(values[0]) 외 \(values.count - 1)개" : values[0] + } + + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case let .setLoading(isLoading): + newState.isLoading = isLoading + + case let .setSearchResults(results): + newState.searchResults = results + + case let .setToastMessage(message): + newState.toastMessage = message + + case let .setActiveFilter(filterType): + newState.activeFilterType = filterType + print("[DEBUG] 🎯 Active Filter Changed: \(String(describing: filterType))") + + case let .setLocationFilters(filters): + newState.selectedLocationFilters = filters + print("Updating selectedLocationFilters to: \(filters)") + + case let .setCategoryFilters(filters): + newState.selectedCategoryFilters = filters + print("[DEBUG] 🔄 Category Filters Updated: \(filters)") + + case let .updateLocationDisplay(text): + newState.locationDisplayText = text + + case let .updateCategoryDisplay(text): + newState.categoryDisplayText = text + + case .clearLocationFilters: + newState.selectedLocationFilters = [] + + case .clearCategoryFilters: + newState.selectedCategoryFilters = [] + + case let .updateBothFilters(locations, categories): + print("[DEBUG] 💾 Reducing both filters update") + print("[DEBUG] 📍 Previous state - Locations: \(newState.selectedLocationFilters)") + print("[DEBUG] 🏷️ Previous state - Categories: \(newState.selectedCategoryFilters)") + + newState.selectedLocationFilters = locations + newState.selectedCategoryFilters = categories + + print("[DEBUG] ✅ Updated state - Locations: \(newState.selectedLocationFilters)") + print("[DEBUG] ✅ Updated state - Categories: \(newState.selectedCategoryFilters)") + + case let .setViewportStores(stores): + newState.viewportStores = stores + + case let .setSelectedStore(store): + newState.selectedStore = store + print("[DEBUG] 📍 Selected Store: \(store.name)") + case let .setError(error): + newState.error = error + if let error = error { + Logger.log( + message: """ + Error occurred in MapReactor: + - Description: \(error.localizedDescription) + - Domain: \(String(describing: (error as NSError).domain)) + - Code: \((error as NSError).code) + """, + category: .error + ) + } + + } + return newState + + } } diff --git a/Poppool/Poppool/Presentation/Map/MapSearchInput.swift b/Poppool/Poppool/Presentation/Map/MapSearchInput.swift index eae913a9..fe3fbac9 100644 --- a/Poppool/Poppool/Presentation/Map/MapSearchInput.swift +++ b/Poppool/Poppool/Presentation/Map/MapSearchInput.swift @@ -5,6 +5,17 @@ import RxSwift final class MapSearchInput: UIView, View { // MARK: - Components + var onSearch: ((String) -> Void)? + var disposeBag = DisposeBag() + + private let activityIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.hidesWhenStopped = true + return indicator + }() + + + private let containerView: UIView = { let view = UIView() view.backgroundColor = .white @@ -23,79 +34,70 @@ final class MapSearchInput: UIView, View { return iv }() - private let searchTextField: UITextField = { + let searchTextField: UITextField = { let textField = UITextField() textField.placeholder = "팝업스토어명, 지역을 입력해보세요" textField.font = UIFont.systemFont(ofSize: 14, weight: .regular) textField.clearButtonMode = .whileEditing - textField.textColor = .g400 - textField.isUserInteractionEnabled = true - textField.returnKeyType = .search // 검색 버튼으로 설정 - textField.enablesReturnKeyAutomatically = true // 텍스트가 입력되면 활성화 - - return textField + textField.textColor = .g400 + textField.returnKeyType = .search + textField.enablesReturnKeyAutomatically = true textField.attributedPlaceholder = NSAttributedString( string: "팝업스토어명, 지역을 입력해보세요", attributes: [NSAttributedString.Key.foregroundColor: UIColor.g400] ) return textField - }() - private let tapButton = UIButton() - - var disposeBag = DisposeBag() - // MARK: - Init init() { super.init(frame: .zero) setupLayout() + setupActions() + } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + // MARK: - Public Methods + func setLoading(_ isLoading: Bool) { + searchTextField.isEnabled = !isLoading + if isLoading { + activityIndicator.startAnimating() + } else { + activityIndicator.stopAnimating() + } + } + func setBackgroundColorForList() { searchTextField.backgroundColor = .g50 } - func bind(reactor: MapReactor) { + // 엔터 키로 검색 실행 searchTextField.rx.controlEvent(.editingDidEndOnExit) .withLatestFrom(searchTextField.rx.text.orEmpty) .bind { query in - reactor.action.onNext(.searchTapped(query)) } .disposed(by: disposeBag) - + // 텍스트 입력 로그 searchTextField.rx.text.orEmpty .subscribe(onNext: { text in print("[DEBUG] TextField Input: \(text)") }) .disposed(by: disposeBag) + // 선택된 필터를 검색창에 반영 reactor.state - .map { $0.selectedLocationFilters } + .map { $0.selectedLocationFilters.first } .distinctUntilChanged() - .bind { [weak self] filters in - self?.searchTextField.text = filters.first // 필터에 맞는 텍스트를 UI에 표시 - } - .disposed(by: disposeBag) - - // 검색 버튼을 눌렀을 때 - tapButton.rx.tap - .withLatestFrom(searchTextField.rx.text.orEmpty) - .bind { [weak self] query in - guard let self = self else { return } - self.reactor?.action.onNext(.searchTapped(query)) - } + .bind(to: searchTextField.rx.text) .disposed(by: disposeBag) - } - } // MARK: - Setup @@ -104,11 +106,9 @@ private extension MapSearchInput { addSubview(containerView) containerView.addSubview(searchIcon) containerView.addSubview(searchTextField) -// containerView.addSubview(tapButton) containerView.snp.makeConstraints { make in make.edges.equalToSuperview() - // 높이 제약 조건 제거하여 부모 뷰의 높이에 맞춤 } searchIcon.snp.makeConstraints { make in @@ -120,11 +120,17 @@ private extension MapSearchInput { searchTextField.snp.makeConstraints { make in make.leading.equalTo(searchIcon.snp.trailing).offset(8) make.centerY.equalToSuperview() - make.trailing.equalToSuperview().offset(-16) // 우측 여백을 추가해 텍스트가 오른쪽에 붙지 않도록 + make.trailing.equalToSuperview().offset(-16) } + } -// tapButton.snp.makeConstraints { make in -// make.edges.equalToSuperview() -// } + func setupActions() { + // 텍스트 필드 액션 설정 + searchTextField.rx.controlEvent(.editingDidEndOnExit) + .withLatestFrom(searchTextField.rx.text.orEmpty) + .subscribe(onNext: { [weak self] query in + self?.onSearch?(query) + }) + .disposed(by: disposeBag) } } diff --git a/Poppool/Poppool/Presentation/Map/MapView.swift b/Poppool/Poppool/Presentation/Map/MapView.swift index 4d02266e..e3bb88cd 100644 --- a/Poppool/Poppool/Presentation/Map/MapView.swift +++ b/Poppool/Poppool/Presentation/Map/MapView.swift @@ -97,17 +97,6 @@ private extension MapView { make.bottom.equalToSuperview() } - - // 기존 코드 주석처리 - // let searchContainer = UIView() - // let filterContainer = UIView() - // searchContainer.addSubview(searchInput) - // filterContainer.addSubview(filterChips) - // searchInput.snp.makeConstraints { ... } - // filterChips.snp.makeConstraints { ... } - // topStackView.addArrangedSubview(searchContainer) - // topStackView.addArrangedSubview(filterContainer) - addSubview(locationButton) addSubview(listButton) addSubview(storeCard) diff --git a/Poppool/Poppool/Presentation/Map/MapViewController.swift b/Poppool/Poppool/Presentation/Map/MapViewController.swift index 8f965fb4..7fa5ee91 100644 --- a/Poppool/Poppool/Presentation/Map/MapViewController.swift +++ b/Poppool/Poppool/Presentation/Map/MapViewController.swift @@ -18,6 +18,7 @@ final class MapViewController: BaseViewController, View { let carouselView = MapPopupCarouselView() private let locationManager = CLLocationManager() private var currentMarker: GMSMarker? + private let storeListReactor = StoreListReactor() private let storeListViewController = StoreListViewController(reactor: StoreListReactor()) private var listViewTopConstraint: Constraint? private var currentFilterBottomSheet: FilterBottomSheetViewController? @@ -82,6 +83,10 @@ final class MapViewController: BaseViewController, View { listViewTopConstraint = make.top.equalToSuperview().offset(view.frame.height).constraint // 초기 숨김 상태 } + if let reactor = self.reactor { + bind(reactor: reactor) + } + // 제스처 설정 let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) storeListViewController.mainView.grabberHandle.addGestureRecognizer(panGesture) @@ -92,6 +97,8 @@ final class MapViewController: BaseViewController, View { setupMarker() } + private let defaultZoomLevel: Float = 15.0 // 기본 줌 레벨 + private func setupMarker() { let marker = GMSMarker() marker.position = CLLocationCoordinate2D(latitude: 37.5666, longitude: 126.9784) @@ -240,17 +247,62 @@ final class MapViewController: BaseViewController, View { self.addMarker(for: store) } .disposed(by: disposeBag) + mainView.searchInput.onSearch = { [weak self] query in + self?.reactor?.action.onNext(.searchTapped(query)) + } + + reactor.state.map { $0.isLoading } + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .bind { [weak self] isLoading in + self?.mainView.searchInput.searchTextField.isEnabled = !isLoading +// self?.mainView.searchInput.setLoading(isLoading) + } + .disposed(by: disposeBag) + + reactor.state.map { $0.searchResults } + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .bind { [weak self] results in + guard let self = self else { return } + + // 검색 결과를 StoreItem으로 변환 + let storeItems = results.map { $0.toStoreItem() } + + // 1. StoreListReactor로 변환된 데이터를 전달 + self.storeListViewController.reactor?.action.onNext(.setStores(storeItems)) + + // 2. 지도에 마커 추가 + self.addMarkers(for: results) + + // 3. 캐러셀 뷰 업데이트 + self.carouselView.updateCards(results) + + // 4. 캐러셀 뷰 표시 여부 설정 + self.carouselView.isHidden = results.isEmpty + } + .disposed(by: disposeBag) + + + + reactor.state.map { $0.searchResults.isEmpty } + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .bind { [weak self] isEmpty in + guard let self = self else { return } -// reactor.state.map { $0.toastMessage } -// .compactMap { $0 } -// .observe(on: MainScheduler.instance) -// .bind { message in -//// let toast = Toast(message: message) -// toast.show() -// } -// .disposed(by: disposeBag) + if isEmpty { + self.showAlert( + title: "검색 결과 없음", + message: "검색 결과가 없습니다. 다른 키워드로 검색해보세요." + ) + } + } + .disposed(by: disposeBag) } + + // MARK: - List View Control @@ -452,11 +504,32 @@ final class MapViewController: BaseViewController, View { } currentFilterBottomSheet = nil } + private func addMarkers(for stores: [MapPopUpStore]) { + for store in stores { + let marker = GMSMarker() + marker.position = CLLocationCoordinate2D(latitude: store.latitude, longitude: store.longitude) + marker.title = store.name + marker.snippet = store.address + marker.map = mainView.mapView // mainView의 mapView에 추가 + } + } + private func updateListView(with results: [MapPopUpStore]) { + // MapPopUpStore 배열을 StoreItem 배열로 변환 + let storeItems = results.map { $0.toStoreItem() } + storeListViewController.reactor?.action.onNext(.setStores(storeItems)) + } + + private func showAlert(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "확인", style: .default, handler: nil)) + present(alert, animated: true, completion: nil) + } + + // MARK: - Location private func checkLocationAuthorization() { - let status = CLLocationManager.authorizationStatus() - switch status { + switch locationManager.authorizationStatus { case .notDetermined: locationManager.requestWhenInUseAuthorization() case .authorizedWhenInUse, .authorizedAlways: @@ -490,7 +563,9 @@ extension MapViewController: CLLocationManagerDelegate { longitude: location.coordinate.longitude, markerId: 0, markerTitle: "현재 위치", - markerSnippet: "현재 위치의 팝업스토어" + markerSnippet: "현재 위치의 팝업스토어", + mainImageUrl: "https://example.com/image1.jpg" // 이미지 URL 추가 + ) addMarker(for: currentLocationStore) @@ -512,7 +587,9 @@ extension MapViewController: GMSMapViewDelegate { longitude: 126.9780, markerId: 1, markerTitle: "서울", - markerSnippet: "팝업스토어" + markerSnippet: "팝업스토어", + mainImageUrl: "https://example.com/image1.jpg" // 이미지 URL 추가 + ) let dummyStore2 = MapPopUpStore( id: 2, @@ -525,7 +602,9 @@ extension MapViewController: GMSMapViewDelegate { longitude: 127.0276, markerId: 2, markerTitle: "강남", - markerSnippet: "전시 팝업스토어" + markerSnippet: "전시 팝업스토어", + mainImageUrl: "https://example.com/image1.jpg" // 이미지 URL 추가 + ) carouselView.updateCards([dummyStore1, dummyStore2]) @@ -534,3 +613,72 @@ extension MapViewController: GMSMapViewDelegate { return true } } +extension MapViewController { + func bindViewport(reactor: MapReactor) { + // 뷰포트 변경 감지 + Observable.merge([ + mainView.mapView.rx.didChangePosition, + mainView.mapView.rx.idleAtPosition + ]) + .debounce(.milliseconds(300), scheduler: MainScheduler.instance) + .map { [weak self] _ -> MapReactor.Action? in + guard let self = self else { return nil } + let bounds = self.mainView.mapView.projection.visibleRegion() + return .viewportChanged( + northEastLat: bounds.farRight.latitude, + northEastLon: bounds.farRight.longitude, + southWestLat: bounds.nearLeft.latitude, + southWestLon: bounds.nearLeft.longitude + ) + } + .compactMap { $0 } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + // 스토어 업데이트 + reactor.state + .map { $0.viewportStores } + .distinctUntilChanged() + .subscribe(onNext: { [weak self] stores in + self?.updateMarkers(with: stores) + }) + .disposed(by: disposeBag) + } + + private func getCurrentViewportBounds() -> (northEast: CLLocationCoordinate2D, southWest: CLLocationCoordinate2D) { + let region = mainView.mapView.projection.visibleRegion() + return (northEast: region.farRight, southWest: region.nearLeft) + } + private func updateMarkers(with stores: [MapPopUpStore]) { + mainView.mapView.clear() + stores.forEach { store in + let marker = GMSMarker() + marker.position = store.coordinate + marker.userData = store + + let markerView = MapMarker() + markerView.injection(with: store.toMarkerInput()) + marker.iconView = markerView + marker.map = mainView.mapView + } + } +} + +// MARK: - Reactive Extensions +extension Reactive where Base: GMSMapView { + var delegate: DelegateProxy { + return GMSMapViewDelegateProxy.proxy(for: base) + } + + var didChangePosition: Observable { + let proxy = GMSMapViewDelegateProxy.proxy(for: base) + return proxy.didChangePositionSubject.asObservable() + } + + var idleAtPosition: Observable { + let proxy = GMSMapViewDelegateProxy.proxy(for: base) + return proxy.idleAtPositionSubject.asObservable() + } +} + + diff --git a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListCell.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListCell.swift index 164e0786..8bef63ea 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListCell.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListCell.swift @@ -122,7 +122,7 @@ private extension StoreListCell { // MARK: - Inputable extension StoreListCell: Inputable { struct Input { - let thumbnailImage: UIImage? + let thumbnailURL: String let category: String let title: String let location: String @@ -131,7 +131,7 @@ extension StoreListCell: Inputable { } func injection(with input: Input) { - thumbnailImageView.image = input.thumbnailImage ?? UIImage(named: "default_thumbnail") + thumbnailImageView.setPPImage(path: input.thumbnailURL) categoryTagLabel.text = "#\(input.category)" titleLabel.text = input.title locationLabel.text = input.location diff --git a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListReactor.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListReactor.swift index 727ac63c..831026c2 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListReactor.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListReactor.swift @@ -7,21 +7,15 @@ final class StoreListReactor: Reactor { case viewDidLoad case didSelectItem(Int) case toggleBookmark(Int) - - // 필터칩 탭 시 (location / category) + case setStores([StoreItem]) case filterTapped(FilterType?) - // 바텀시트에서 필터 선택 후 save 시 case filterUpdated(FilterType, [String]) - // 필터 제거(초기화) case clearFilters(FilterType) } enum Mutation { - // 기존 - case setStores([StoreItem]) + case setStores([StoreItem]) // 변환된 StoreItem 리스트 case updateBookmark(Int) - - // 필터 관련 case setActiveFilter(FilterType?) case setLocationFilters([String]) case setCategoryFilters([String]) @@ -30,10 +24,7 @@ final class StoreListReactor: Reactor { } struct State { - // 기존 - var stores: [StoreItem] = [] - - // 필터 관련 상태 + var stores: [StoreItem] = [] // 변환된 리스트 var activeFilterType: FilterType? var selectedLocationFilters: [String] = [] var selectedCategoryFilters: [String] = [] @@ -41,9 +32,7 @@ final class StoreListReactor: Reactor { // MARK: - Properties var initialState: State - var disposeBag = DisposeBag() - // MARK: - Init init() { self.initialState = State() } @@ -51,22 +40,23 @@ final class StoreListReactor: Reactor { // MARK: - Reactor Methods func mutate(action: Action) -> Observable { switch action { - // 1) 리스트 데이터 fetch case .viewDidLoad: return fetchStores() case let .didSelectItem(index): - // TODO: item 선택 시 로직 + print("[DEBUG] Item Selected at Index: \(index)") return .empty() case let .toggleBookmark(index): return .just(.updateBookmark(index)) - // 2) 필터칩 탭: filterTapped(.location/.category or nil) + case let .setStores(storeItems): + return .just(.setStores(storeItems)) + + case let .filterTapped(filterType): return .just(.setActiveFilter(filterType)) - // 3) 바텀시트에서 선택된 필터 값 적용 case let .filterUpdated(type, values): switch type { case .location: @@ -75,7 +65,6 @@ final class StoreListReactor: Reactor { return .just(.setCategoryFilters(values)) } - // 4) 필터 제거(초기화) case let .clearFilters(type): switch type { case .location: @@ -88,10 +77,7 @@ final class StoreListReactor: Reactor { func reduce(state: State, mutation: Mutation) -> State { var newState = state - switch mutation { - - // 기존 case let .setStores(stores): newState.stores = stores @@ -102,7 +88,6 @@ final class StoreListReactor: Reactor { newState.stores[index] = item } - // 필터관련 case let .setActiveFilter(filterType): newState.activeFilterType = filterType @@ -118,23 +103,37 @@ final class StoreListReactor: Reactor { case .clearCategoryFilters: newState.selectedCategoryFilters = [] } - return newState } // MARK: - Private private func fetchStores() -> Observable { let mockStores = [ - StoreItem(id: 1, thumbnailURL: "", category: "카페", title: "팝업스토어1", - location: "서울 강남구", dateRange: "2024.06.30 ~ 08.23", + StoreItem(id: 1, thumbnailURL: "", category: "카페", title: "팝업스토어1", + location: "서울 강남구", dateRange: "2024.06.30 ~ 08.23", isBookmarked: false), - StoreItem(id: 2, thumbnailURL: "", category: "전시", title: "팝업스토어2", + StoreItem(id: 2, thumbnailURL: "", category: "전시", title: "팝업스토어2", location: "서울 성동구", dateRange: "2024.07.01 ~ 07.30", isBookmarked: true) ] return .just(.setStores(mockStores)) } } +// +//extension MapPopUpStore { +// func toStoreItem() -> StoreItem { +// return StoreItem( +// id: Int(id), +// thumbnailURL: mainImageUrl ?? "", // 이미지 URL 매핑 +// category: category, +// title: name, +// location: address, +// dateRange: "\(startDate) ~ \(endDate)", +// isBookmarked: false // 기본값 +// ) +// } +//} + // MARK: - Model struct StoreItem { diff --git a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift index 2f156598..a904223f 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift @@ -58,23 +58,23 @@ private extension StoreListView { backgroundColor = .white addSubview(collectionView) addSubview(grabberHandle) - addSubview(paddingView) grabberHandle.snp.makeConstraints { make in - make.top.equalToSuperview().offset(14) + make.top.equalToSuperview().offset(14).priority(.high) make.centerX.equalToSuperview() make.width.equalTo(36) - make.height.equalTo(5) // priority 조정 - make.height.equalTo(5).priority(.high) // 우선순위 지정 + make.height.equalTo(5) } - paddingView.snp.makeConstraints { make in - make.top.equalTo(grabberHandle.snp.bottom) - make.leading.trailing.equalToSuperview() - } +// paddingView.snp.makeConstraints { make in +// make.top.equalTo(grabberHandle.snp.bottom) +// make.leading.trailing.equalToSuperview() +// +// } collectionView.snp.makeConstraints { make in - make.top.equalTo(grabberHandle.snp.bottom).offset(1) - make.leading.trailing.bottom.equalToSuperview() + make.top.equalTo(grabberHandle.snp.bottom).offset(8).priority(.medium) + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview() // bottom 제약 다시 추가 } } diff --git a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift index 1f8d6f30..2c214d86 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListViewController.swift @@ -58,7 +58,7 @@ final class StoreListViewController: UIViewController, View { ) as! StoreListCell cell.injection(with: .init( - thumbnailImage: nil, + thumbnailURL: item.thumbnailURL, category: item.category, title: item.title, location: item.location, diff --git a/Poppool/Poppool/Resource/Info.plist b/Poppool/Poppool/Resource/Info.plist index 045513c7..8b3a2d0d 100644 --- a/Poppool/Poppool/Resource/Info.plist +++ b/Poppool/Poppool/Resource/Info.plist @@ -60,5 +60,7 @@ NSLocationWhenInUseUsageDescription 앱이 사용자의 현재 위치를 확인하기 위해 위치 권한이 필요합니다 + + From 474ee86001c62cf955dcdb23f652ba79493712d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=80=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Wed, 22 Jan 2025 03:10:54 +0900 Subject: [PATCH 11/12] =?UTF-8?q?[FEAT]=20:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=81=EC=9A=A9=ED=9B=84=20=EB=B7=B0=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=EC=9B=80=EC=A7=81=EC=9D=BC=EC=8B=9C=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xcshareddata/swiftpm/Package.resolved | 11 +-- .../AdminBottomSheetViewController.swift | 6 +- .../FilterBottomSheetReactor.swift | 2 +- .../FilterBottomSheetView.swift | 5 -- .../FilterBottomSheetViewController.swift | 8 +- .../Map/MapAPI/MapAPIEndpoint.swift | 44 +++++---- .../Map/MapAPI/MapPopUpStoreDTO.swift | 2 +- .../Map/MapAPI/Repository/MapRepository.swift | 88 +++++++++--------- .../Map/MapAPI/UseCase/MapUseCase.swift | 27 +++--- .../Poppool/Presentation/Map/MapReactor.swift | 90 ++++++++++++++++--- .../Presentation/Map/MapViewController.swift | 67 +++++++++----- .../Map/StoreListView/StoreListReactor.swift | 2 +- 12 files changed, 218 insertions(+), 134 deletions(-) diff --git a/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 82b671fb..260f3056 100644 --- a/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "467ca350c14df33e2c801b4d50726a849056e5bbcca318a1d231cc34c69c24ef", + "originHash" : "301b69a66ba06d1836f5cd9f502b100abe9846c1431f13502b490b59446673f5", "pins" : [ { "identity" : "alamofire", @@ -172,15 +172,6 @@ "version" : "3.2.0" } }, - { - "identity" : "then", - "kind" : "remoteSourceControl", - "location" : "https://github.com/devxoul/Then.git", - "state" : { - "revision" : "d41ef523faef0f911369f79c0b96815d9dbb6d7a", - "version" : "3.0.0" - } - }, { "identity" : "weakmaptable", "kind" : "remoteSourceControl", diff --git a/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift index ac34531c..933f5c26 100644 --- a/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift +++ b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift @@ -43,7 +43,7 @@ final class AdminBottomSheetViewController: BaseViewController, View { view.backgroundColor = .clear Logger.log(message: "초기 뷰 계층:", category: .debug) - print(view.value(forKey: "recursiveDescription") ?? "") +// print(view.value(forKey: "recursiveDescription") ?? "") // mainView 설정 및 추가 view.addSubview(mainView) @@ -60,7 +60,7 @@ final class AdminBottomSheetViewController: BaseViewController, View { } Logger.log(message: "mainView 추가 후 계층:", category: .debug) - print(view.value(forKey: "recursiveDescription") ?? "") +// print(view.value(forKey: "recursiveDescription") ?? "") // dimmedView 설정 및 추가 dimmedView.backgroundColor = .black.withAlphaComponent(0.4) @@ -77,7 +77,7 @@ final class AdminBottomSheetViewController: BaseViewController, View { } Logger.log(message: "최종 뷰 계층:", category: .debug) - print(view.value(forKey: "recursiveDescription") ?? "") +// print(view.value(forKey: "recursiveDescription") ?? "") } private func setupCollectionView() { diff --git a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetReactor.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetReactor.swift index a84fa801..309ee81b 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetReactor.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetReactor.swift @@ -62,7 +62,7 @@ final class FilterBottomSheetReactor: Reactor { selectedSubRegions: [], selectedCategories: [], locations: initialLocations, // 초기 locations 설정 - categories: ["게임", "라이프스타일", "반려동물", "뷰티", "스포츠", "애니메이션", "엔터테이먼트", "여행", "예술", "음식/요리", "키즈", "패션"] + categories: ["게임", "라이프스타일", "반려동물", "뷰티", "스포츠", "애니메이션", "엔터테인먼트", "여행", "예술", "음식/요리", "키즈", "패션"] ) } diff --git a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift index ef001207..3473bc40 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift @@ -416,22 +416,17 @@ extension FilterBottomSheetView { } extension FilterBottomSheetView: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { - print("Scrolling - contentOffset: \(scrollView.contentOffset.x)") guard let selectedButton = locationContentView.subviews.first(where: { view in guard let button = view as? PPButton else { return false } return button.backgroundColor == .blu500 }) as? PPButton else { - print("No selected button found") return } - // 선택된 버튼의 현재 위치 확인 let buttonFrame = selectedButton.convert(selectedButton.bounds, to: balloonBackgroundView) - print("Button center: \(buttonFrame.midX)") let arrowPosition = buttonFrame.midX / balloonBackgroundView.bounds.width - print("Arrow position: \(arrowPosition)") balloonBackgroundView.arrowPosition = arrowPosition balloonBackgroundView.setNeedsDisplay() diff --git a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetViewController.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetViewController.swift index 0c4e7bbb..e5a99497 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetViewController.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetViewController.swift @@ -365,10 +365,10 @@ extension FilterBottomSheetViewController: UICollectionViewDataSource { extension FilterBottomSheetViewController: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let category = tagSection?.inputDataList[indexPath.item].title else { return } - print("[DEBUG] 👆 Category Option Selected: \(category)") - print("[DEBUG] 💾 Current Saved Filters:") - print("[DEBUG] 📍 Location: \(reactor?.currentState.selectedSubRegions ?? [])") - print("[DEBUG] 🏷️ Category: \(reactor?.currentState.selectedCategories ?? [])") +// print("[DEBUG] 👆 Category Option Selected: \(category)") +// print("[DEBUG] 💾 Current Saved Filters:") +// print("[DEBUG] 📍 Location: \(reactor?.currentState.selectedSubRegions ?? [])") +// print("[DEBUG] 🏷️ Category: \(reactor?.currentState.selectedCategories ?? [])") reactor?.action.onNext(.toggleCategory(category)) } } diff --git a/Poppool/Poppool/Presentation/Map/MapAPI/MapAPIEndpoint.swift b/Poppool/Poppool/Presentation/Map/MapAPI/MapAPIEndpoint.swift index 2c18a629..cd08ba01 100644 --- a/Poppool/Poppool/Presentation/Map/MapAPI/MapAPIEndpoint.swift +++ b/Poppool/Poppool/Presentation/Map/MapAPI/MapAPIEndpoint.swift @@ -8,21 +8,13 @@ import Foundation struct MapAPIEndpoint { - /// 뷰 바운즈 내에 있는 팝업 스토어 정보를 조회 - /// - Parameters: - /// - northEastLat: 북동쪽 위도 - /// - northEastLon: 북동쪽 경도 - /// - southWestLat: 남서쪽 위도 - /// - southWestLon: 남서쪽 경도 - /// - categories: 카테고리 필터 배열 - /// - Returns: Endpoint static func locations_fetchStoresInBounds( northEastLat: Double, northEastLon: Double, southWestLat: Double, southWestLon: Double, - categories: [String] + categories: [Int64] ) -> Endpoint { let params = BoundQueryDTO( northEastLat: northEastLat, @@ -31,7 +23,6 @@ struct MapAPIEndpoint { southWestLon: southWestLon, categories: categories ) - // return Endpoint( baseURL: Secrets.popPoolBaseUrl.rawValue, @@ -42,13 +33,9 @@ struct MapAPIEndpoint { } /// 지도에서 검색합니다. - /// - Parameters: - /// - query: 검색어 - /// - categories: 카테고리 필터 배열 - /// - Returns: Endpoint static func locations_searchStores( query: String, - categories: [String] + categories: [Int64] ) -> Endpoint { let params = SearchQueryDTO( query: query, @@ -70,10 +57,33 @@ struct BoundQueryDTO: Encodable { let northEastLon: Double let southWestLat: Double let southWestLon: Double - let categories: [String] + let categories: [Int64] + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(northEastLat, forKey: .northEastLat) + try container.encode(northEastLon, forKey: .northEastLon) + try container.encode(southWestLat, forKey: .southWestLat) + try container.encode(southWestLon, forKey: .southWestLon) + + // 카테고리를 개별 쿼리 파라미터로 인코딩 + for categoryId in categories { + try container.encode(categoryId, forKey: .categories) + } + } + + private enum CodingKeys: String, CodingKey { + case northEastLat + case northEastLon + case southWestLat + case southWestLon + case categories + } } + struct SearchQueryDTO: Encodable { let query: String - let categories: [String]? + let categories: [Int64]? } + diff --git a/Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStoreDTO.swift b/Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStoreDTO.swift index 72f55b73..1db431dd 100644 --- a/Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStoreDTO.swift +++ b/Poppool/Poppool/Presentation/Map/MapAPI/MapPopUpStoreDTO.swift @@ -36,7 +36,7 @@ struct MapPopUpStoreDTO: Codable { } struct GetViewBoundPopUpStoreListResponse: Decodable { - var popUpStoreList: [MapPopUpStoreDTO] + let popUpStoreList: [MapPopUpStoreDTO] } struct MapSearchResponseDTO: Codable { diff --git a/Poppool/Poppool/Presentation/Map/MapAPI/Repository/MapRepository.swift b/Poppool/Poppool/Presentation/Map/MapAPI/Repository/MapRepository.swift index f48d6647..9094c54c 100644 --- a/Poppool/Poppool/Presentation/Map/MapAPI/Repository/MapRepository.swift +++ b/Poppool/Poppool/Presentation/Map/MapAPI/Repository/MapRepository.swift @@ -14,15 +14,18 @@ protocol MapRepository { northEastLon: Double, southWestLat: Double, southWestLon: Double, - categories: [String] + categories: [Int64] ) -> Observable<[MapPopUpStoreDTO]> func searchStores( query: String, - categories: [String] + categories: [Int64] ) -> Observable<[MapPopUpStoreDTO]> + + func fetchCategories() -> Observable<[Category]> } +// MARK: - Implementation class DefaultMapRepository: MapRepository { private let provider: Provider @@ -30,18 +33,15 @@ class DefaultMapRepository: MapRepository { self.provider = provider } + // DefaultMapRepository.swift (발췌) + func fetchStoresInBounds( northEastLat: Double, northEastLon: Double, southWestLat: Double, southWestLon: Double, - categories: [String] + categories: [Int64] ) -> Observable<[MapPopUpStoreDTO]> { - Logger.log( - message: "지도의 범위 내 스토어 정보를 가져옵니다. 카테고리: \(categories)", - category: .network - ) - return provider.requestData( with: MapAPIEndpoint.locations_fetchStoresInBounds( northEastLat: northEastLat, @@ -50,55 +50,55 @@ class DefaultMapRepository: MapRepository { southWestLon: southWestLon, categories: categories ), - interceptor: nil - ) - .do( - onNext: { response in - Logger.log( - message: "스토어 조회 성공! 응답: \(response)", - category: .network - ) - }, - onError: { error in - Logger.log( - message: "스토어 조회 중 오류 발생: \(error.localizedDescription)", - category: .error - ) - } + interceptor: TokenInterceptor() // ← 토큰 누락 해결 ) .map { $0.popUpStoreList } } func searchStores( query: String, - categories: [String] + categories: [Int64] ) -> Observable<[MapPopUpStoreDTO]> { - Logger.log( - message: "스토어 검색을 시작합니다. 검색어: '\(query)', 카테고리: \(categories)", - category: .network - ) - return provider.requestData( with: MapAPIEndpoint.locations_searchStores( query: query, categories: categories ), - interceptor: nil - ) - .do( - onNext: { response in - Logger.log( - message: "스토어 검색 성공! 응답: \(response)", - category: .network - ) - }, - onError: { error in - Logger.log( - message: "스토어 검색 중 오류 발생: \(error.localizedDescription)", - category: .error - ) - } + interceptor: TokenInterceptor() // ← 토큰 누락 해결 ) .map { $0.popUpStoreList } } + + + func fetchCategories() -> Observable<[Category]> { + Logger.log(message: "카테고리 매핑 요청을 시작합니다.", category: .network) + + return provider.requestData( + with: SignUpAPIEndpoint.signUp_getCategoryList(), + interceptor: TokenInterceptor() + ) + .do(onNext: { responseDTO in + Logger.log( + message: """ + 카테고리 매핑 응답: + - Response: \(responseDTO) + - categoryResponseList: \(responseDTO.categoryResponseList) + """, + category: .debug + ) + }) + .map { responseDTO in + let categories = responseDTO.categoryResponseList.map { $0.toDomain() } + Logger.log(message: "매핑된 카테고리 데이터: \(categories)", category: .debug) + return categories + } + .catch { error in + Logger.log( + message: "카테고리 매핑 요청 실패: \(error.localizedDescription)", + category: .error + ) + throw error + } + } + } diff --git a/Poppool/Poppool/Presentation/Map/MapAPI/UseCase/MapUseCase.swift b/Poppool/Poppool/Presentation/Map/MapAPI/UseCase/MapUseCase.swift index bbbd9f32..dfc2577e 100644 --- a/Poppool/Poppool/Presentation/Map/MapAPI/UseCase/MapUseCase.swift +++ b/Poppool/Poppool/Presentation/Map/MapAPI/UseCase/MapUseCase.swift @@ -1,28 +1,21 @@ -// -// MapUseCase.swift -// Poppool -// -// Created by 김기현 on 12/3/24. -// - import Foundation import RxSwift protocol MapUseCase { + func fetchCategories() -> Observable<[Category]> func fetchStoresInBounds( northEastLat: Double, northEastLon: Double, southWestLat: Double, southWestLon: Double, - categories: [String] + categories: [Int64] ) -> Observable<[MapPopUpStore]> func searchStores( query: String, - categories: [String] + categories: [Int64] ) -> Observable<[MapPopUpStore]> } - class DefaultMapUseCase: MapUseCase { private let repository: MapRepository @@ -30,30 +23,36 @@ class DefaultMapUseCase: MapUseCase { self.repository = repository } + func fetchCategories() -> Observable<[Category]> { + return repository.fetchCategories() + } + func fetchStoresInBounds( northEastLat: Double, northEastLon: Double, southWestLat: Double, southWestLon: Double, - categories: [String] + categories: [Int64] ) -> Observable<[MapPopUpStore]> { + return repository.fetchStoresInBounds( northEastLat: northEastLat, northEastLon: northEastLon, southWestLat: southWestLat, southWestLon: southWestLon, - categories: categories + categories: categories // ← 그대로 넘긴다 ) .map { $0.map { $0.toDomain() } } } + func searchStores( query: String, - categories: [String] + categories: [Int64] ) -> Observable<[MapPopUpStore]> { return repository.searchStores( query: query, - categories: categories + categories: categories.map { Int64($0) ?? 0 } ) .map { $0.map { $0.toDomain() } } } diff --git a/Poppool/Poppool/Presentation/Map/MapReactor.swift b/Poppool/Poppool/Presentation/Map/MapReactor.swift index 87abeccd..bc426cbd 100644 --- a/Poppool/Poppool/Presentation/Map/MapReactor.swift +++ b/Poppool/Poppool/Presentation/Map/MapReactor.swift @@ -12,6 +12,7 @@ final class MapReactor: Reactor { case filterTapped(FilterType?) case filterUpdated(FilterType, [String]) case clearFilters(FilterType) + case fetchCategories case updateBothFilters(locations: [String], categories: [String]) // 새로 추가 case didSelectItem(MapPopUpStore) case viewportChanged( @@ -38,6 +39,8 @@ final class MapReactor: Reactor { case setSelectedStore(MapPopUpStore) // 선택된 스토어 상태 case setViewportStores([MapPopUpStore]) case setError(Error?) + case setCategoryMapping([String: Int64]) + } @@ -55,6 +58,7 @@ final class MapReactor: Reactor { var selectedStore: MapPopUpStore? // 선택된 스토어 var viewportStores: [MapPopUpStore] = [] var error: Error? = nil + var categoryMapping: [String: Int64] = [:] @@ -70,11 +74,32 @@ final class MapReactor: Reactor { func mutate(action: Action) -> Observable { switch action { + case .fetchCategories: + Logger.log(message: "카테고리 매핑", category: .debug) + + return useCase.fetchCategories() + .map { categories in + let mapping = categories.reduce(into: [String: Int64]()) { dict, category in + dict[category.category] = category.categoryId + } + Logger.log(message: "생성된 카테고리 매핑: \(mapping)", category: .debug) + return .setCategoryMapping(mapping) + } + .catch { error in + Logger.log(message: "카테고리 매핑 생성 중 오류: \(error.localizedDescription)", category: .error) + return .just(.setError(error)) + } + + case let .searchTapped(query): - let categories = currentState.selectedCategoryFilters + // 1) categoryName -> categoryId 변환 + let categoryIDs = currentState.selectedCategoryFilters + .compactMap { currentState.categoryMapping[$0] } // [Int64] + return .concat([ - .just(.setLoading(true)), // 로딩 시작 - useCase.searchStores(query: query, categories: categories) + .just(.setLoading(true)), + // 2) 수정: [Int64]를 UseCase에 넘김 + useCase.searchStores(query: query, categories: categoryIDs) .flatMap { results -> Observable in if results.isEmpty { return .just(.setToastMessage("검색 결과가 없습니다.")) @@ -82,9 +107,23 @@ final class MapReactor: Reactor { return .just(.setSearchResults(results)) } }, - .just(.setLoading(false)) // 로딩 종료 + .just(.setLoading(false)) ]) + case let .viewportChanged(northEastLat, northEastLon, southWestLat, southWestLon): + // 🔒 1) 여기서 미리 categoryName(문자열) → categoryId(숫자)로 변환 + let categoryIDs = currentState.selectedCategoryFilters + .compactMap { currentState.categoryMapping[$0] } + + Logger.log( + message: """ + Viewport Changed: + - Category Names: \(currentState.selectedCategoryFilters) + - Category IDs: \(categoryIDs) + """, + category: .debug + ) + return .concat([ .just(.setLoading(true)), useCase.fetchStoresInBounds( @@ -92,10 +131,10 @@ final class MapReactor: Reactor { northEastLon: northEastLon, southWestLat: southWestLat, southWestLon: southWestLon, - categories: currentState.selectedCategoryFilters + categories: categoryIDs // ← 숫자 배열로 수정 ) .map(Mutation.setViewportStores) - .catch { .just(.setError($0)) }, + .catch { error in .just(.setError(error)) }, .just(.setLoading(false)) ]) @@ -176,15 +215,15 @@ final class MapReactor: Reactor { case let .setActiveFilter(filterType): newState.activeFilterType = filterType - print("[DEBUG] 🎯 Active Filter Changed: \(String(describing: filterType))") + Logger.log(message: "🎯 Active Filter Changed: \(String(describing: filterType))", category: .debug) case let .setLocationFilters(filters): newState.selectedLocationFilters = filters - print("Updating selectedLocationFilters to: \(filters)") + Logger.log(message: "선택된 위치 필터가 업데이트: \(filters)", category: .debug) case let .setCategoryFilters(filters): newState.selectedCategoryFilters = filters - print("[DEBUG] 🔄 Category Filters Updated: \(filters)") +// print("[DEBUG] 🔄 Category Filters Updated: \(filters)") case let .updateLocationDisplay(text): newState.locationDisplayText = text @@ -199,9 +238,15 @@ final class MapReactor: Reactor { newState.selectedCategoryFilters = [] case let .updateBothFilters(locations, categories): - print("[DEBUG] 💾 Reducing both filters update") - print("[DEBUG] 📍 Previous state - Locations: \(newState.selectedLocationFilters)") - print("[DEBUG] 🏷️ Previous state - Categories: \(newState.selectedCategoryFilters)") + Logger.log( + message: """ + 💾 필터 상태 업데이트 + 📍 이전 위치 필터: \(newState.selectedLocationFilters) + 🏷️ 이전 카테고리 필터: \(newState.selectedCategoryFilters) + """, + category: .debug + ) + newState.selectedLocationFilters = locations newState.selectedCategoryFilters = categories @@ -210,8 +255,16 @@ final class MapReactor: Reactor { print("[DEBUG] ✅ Updated state - Categories: \(newState.selectedCategoryFilters)") case let .setViewportStores(stores): + Logger.log( + message: """ + Updated viewport stores: + - Total: \(stores.count) + - Categories in view: \(stores.map { $0.category }.unique()) + - Current filter: \(newState.selectedCategoryFilters) + """, + category: .debug + ) newState.viewportStores = stores - case let .setSelectedStore(store): newState.selectedStore = store print("[DEBUG] 📍 Selected Store: \(store.name)") @@ -229,8 +282,19 @@ final class MapReactor: Reactor { ) } + case let .setCategoryMapping(mapping): + Logger.log( + message: "카테고리 매핑 업데이트 완료: \(mapping)", + category: .debug + ) + newState.categoryMapping = mapping } return newState } } +extension Array where Element: Hashable { + func unique() -> [Element] { + return Array(Set(self)) + } +} diff --git a/Poppool/Poppool/Presentation/Map/MapViewController.swift b/Poppool/Poppool/Presentation/Map/MapViewController.swift index 7fa5ee91..5f843181 100644 --- a/Poppool/Poppool/Presentation/Map/MapViewController.swift +++ b/Poppool/Poppool/Presentation/Map/MapViewController.swift @@ -53,6 +53,15 @@ final class MapViewController: BaseViewController, View { locationManager.delegate = self locationManager.requestWhenInUseAuthorization() locationManager.desiredAccuracy = kCLLocationAccuracyBest + + if let reactor = self.reactor { + bind(reactor: reactor) + bindViewport(reactor: reactor) + + reactor.action.onNext(.fetchCategories) + + } + } @@ -115,7 +124,7 @@ final class MapViewController: BaseViewController, View { .skip(1) .withUnretained(self) .subscribe { owner, _ in - print("[DEBUG] ⬆️ Swipe Up Detected") + Logger.log(message: "⬆️ 위로 스와이프 감지", category: .debug) switch owner.modalState { case .bottom: owner.animateToState(.middle) @@ -131,7 +140,7 @@ final class MapViewController: BaseViewController, View { .skip(1) .withUnretained(self) .subscribe { owner, _ in - print("[DEBUG] ⬇️ Swipe Down Detected") + Logger.log(message: "⬇️ 아래로 스와이프 감지됨", category: .debug) switch owner.modalState { case .top: owner.animateToState(.middle) @@ -161,7 +170,7 @@ final class MapViewController: BaseViewController, View { mainView.listButton.rx.tap .withUnretained(self) .subscribe { owner, _ in - print("[DEBUG] List Button Tapped") +// print("[DEBUG] List Button Tapped") owner.animateToState(.middle) // 버튼 눌렀을 때 상태를 middle로 변경 } .disposed(by: disposeBag) @@ -210,9 +219,14 @@ final class MapViewController: BaseViewController, View { } .observe(on: MainScheduler.instance) .bind { [weak self] locationText, categoryText in - print("[DEBUG] 📍 Updating filters - Location: \(locationText)") - print("[DEBUG] 🏷️ Updating filters - Category: \(categoryText)") - + Logger.log( + message: """ + 필터 업데이트: + 📍 위치: \(locationText) + 🏷️ 카테고리: \(categoryText) + """, + category: .debug + ) self?.mainView.filterChips.update( locationText: locationText, categoryText: categoryText @@ -220,6 +234,7 @@ final class MapViewController: BaseViewController, View { } .disposed(by: disposeBag) + reactor.state.map { $0.activeFilterType } .distinctUntilChanged() .observe(on: MainScheduler.instance) @@ -287,10 +302,10 @@ final class MapViewController: BaseViewController, View { reactor.state.map { $0.searchResults.isEmpty } .distinctUntilChanged() + .skip(1) // 초기값 스킵 .observe(on: MainScheduler.instance) .bind { [weak self] isEmpty in guard let self = self else { return } - if isEmpty { self.showAlert( title: "검색 결과 없음", @@ -299,7 +314,6 @@ final class MapViewController: BaseViewController, View { } } .disposed(by: disposeBag) - } @@ -307,24 +321,27 @@ final class MapViewController: BaseViewController, View { // MARK: - List View Control private func toggleListView() { - print("[DEBUG] Current Modal State: \(modalState)") - print("[DEBUG] Current listViewTopConstraint offset: \(listViewTopConstraint?.layoutConstraints.first?.constant ?? 0)") +// print("[DEBUG] Current Modal State: \(modalState)") +// print("[DEBUG] Current listViewTopConstraint offset: \(listViewTopConstraint?.layoutConstraints.first?.constant ?? 0)") UIView.animate(withDuration: 0.3) { let middleOffset = -self.view.frame.height * 0.7 self.listViewTopConstraint?.update(offset: middleOffset) self.modalState = .middle self.mainView.searchFilterContainer.backgroundColor = .clear - print("[DEBUG] Changing state to Middle") - print("[DEBUG] Updated offset: \(middleOffset)") self.view.layoutIfNeeded() } // 상태 변경 후 로그 - print("[DEBUG] New Modal State: \(modalState)") - print("[DEBUG] New listViewTopConstraint offset: \(listViewTopConstraint?.layoutConstraints.first?.constant ?? 0)") - } - + Logger.log( + message: """ + 리스트뷰 상태 변경: + 현재 상태: \(modalState) + 현재 오프셋: \(listViewTopConstraint?.layoutConstraints.first?.constant ?? 0) + """, + category: .debug + ) } + func addMarker(for store: MapPopUpStore) { let marker = GMSMarker() @@ -451,7 +468,7 @@ final class MapViewController: BaseViewController, View { self.view.layoutIfNeeded() }) { _ in self.modalState = state - print("Completed animation to state: \(state)") + Logger.log(message: ". 현재 상태: \(state)", category: .debug) } } @@ -467,9 +484,14 @@ final class MapViewController: BaseViewController, View { viewController.onSave = { [weak self] filterData in guard let self = self else { return } - print("[DEBUG] 💾 Save triggered with:") - print("[DEBUG] 📍 Locations: \(filterData.locations)") - print("[DEBUG] 🏷️ Categories: \(filterData.categories)") + Logger.log( + message: """ + 필터 저장: + 📍 위치: \(filterData.locations) + 🏷️ 카테고리: \(filterData.categories) + """, + category: .debug + ) self.reactor?.action.onNext(.updateBothFilters( locations: filterData.locations, @@ -535,7 +557,10 @@ final class MapViewController: BaseViewController, View { case .authorizedWhenInUse, .authorizedAlways: locationManager.startUpdatingLocation() case .denied, .restricted: - print("위치 서비스가 비활성화되었습니다. 설정에서 권한을 확인해주세요.") + Logger.log( + message: "위치 서비스가 비활성화되었습니다. 설정에서 권한을 확인해주세요.", + category: .error + ) @unknown default: break } diff --git a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListReactor.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListReactor.swift index 831026c2..af99b1af 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListReactor.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListReactor.swift @@ -44,7 +44,7 @@ final class StoreListReactor: Reactor { return fetchStores() case let .didSelectItem(index): - print("[DEBUG] Item Selected at Index: \(index)") +// print("[DEBUG] Item Selected at Index: \(index)") return .empty() case let .toggleBookmark(index): From 0cda69bd7b47a1e0b2d7bf4b3c68bdea99712505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=80=E1=85=B5=E1=84=92?= =?UTF-8?q?=E1=85=A7=E1=86=AB?= Date: Wed, 22 Jan 2025 03:29:39 +0900 Subject: [PATCH 12/12] =?UTF-8?q?[FIX]=20:=20=EB=88=84=EB=9D=BD=EB=90=9C?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=EC=9E=90=EB=A9=94=EB=89=B4=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=EA=B0=80=EA=B8=B0=20=EC=95=A1=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xcshareddata/swiftpm/Package.resolved | 11 +- .../Scene/Detail/DetailController.swift | 62 +++++----- .../Scene/MyPage/Main/MyPageReactor.swift | 112 +++++++++++------- 3 files changed, 108 insertions(+), 77 deletions(-) diff --git a/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 260f3056..82b671fb 100644 --- a/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "301b69a66ba06d1836f5cd9f502b100abe9846c1431f13502b490b59446673f5", + "originHash" : "467ca350c14df33e2c801b4d50726a849056e5bbcca318a1d231cc34c69c24ef", "pins" : [ { "identity" : "alamofire", @@ -172,6 +172,15 @@ "version" : "3.2.0" } }, + { + "identity" : "then", + "kind" : "remoteSourceControl", + "location" : "https://github.com/devxoul/Then.git", + "state" : { + "revision" : "d41ef523faef0f911369f79c0b96815d9dbb6d7a", + "version" : "3.0.0" + } + }, { "identity" : "weakmaptable", "kind" : "remoteSourceControl", diff --git a/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift b/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift index cd00e904..ab173125 100644 --- a/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift +++ b/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift @@ -13,24 +13,24 @@ import RxSwift import ReactorKit final class DetailController: BaseViewController, View { - + typealias Reactor = DetailReactor - + // MARK: - Properties var disposeBag = DisposeBag() - + private var mainView = DetailView() - + private let headerView: PPReturnHeaderView = { let view = PPReturnHeaderView() view.backButton.tintColor = .w100 return view }() - + private var sections: [any Sectionable] = [] - + private var isBrightImage: Bool = false - + private let headerBackgroundView: UIView = UIView() let backGroundblurEffect = UIBlurEffect(style: .regular) lazy var backGroundblurView = UIVisualEffectView(effect: backGroundblurEffect) @@ -42,7 +42,7 @@ extension DetailController { super.viewDidLoad() setUp() } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) tabBarController?.tabBar.isHidden = true @@ -71,25 +71,25 @@ private extension DetailController { make.top.equalToSuperview() make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide) } - + view.addSubview(headerView) headerView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) } - + headerBackgroundView.addSubview(backGroundblurView) backGroundblurView.snp.makeConstraints { make in make.edges.equalToSuperview() } backGroundblurView.isUserInteractionEnabled = false - + view.addSubview(headerBackgroundView) headerBackgroundView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.bottom.equalTo(headerView.snp.bottom).offset(7) } headerBackgroundView.isHidden = true - + view.bringSubviewToFront(headerView) } } @@ -101,7 +101,7 @@ extension DetailController { .map { Reactor.Action.viewWillAppear } .bind(to: reactor.action) .disposed(by: disposeBag) - + mainView.commentPostButton.rx.tap .withUnretained(self) .map { (owner, _) in @@ -109,7 +109,7 @@ extension DetailController { } .bind(to: reactor.action) .disposed(by: disposeBag) - + headerView.backButton.rx.tap .withUnretained(self) .map { (owner, _) in @@ -117,19 +117,15 @@ extension DetailController { } .bind(to: reactor.action) .disposed(by: disposeBag) - + reactor.state .withUnretained(self) .subscribe { (owner, state) in owner.sections = state.sections owner.mainView.contentCollectionView.reloadData() - state.barkGroundImagePath.isBrightImagePath { [weak owner] isBright in - owner?.statusBarIsDarkMode = isBright - owner?.isBrightImage = isBright - } } .disposed(by: disposeBag) - + reactor.state .withUnretained(self) .take(2) @@ -154,24 +150,24 @@ extension DetailController: UICollectionViewDelegate, UICollectionViewDataSource func numberOfSections(in collectionView: UICollectionView) -> Int { return sections.count } - + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return sections[section].dataCount } - + func collectionView( _ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath ) -> UICollectionViewCell { let cell = sections[indexPath.section].getCell(collectionView: collectionView, indexPath: indexPath) guard let reactor = reactor else { return cell } - + if let cell = cell as? DetailTitleSectionCell { cell.bookMarkButton.rx.tap .map { Reactor.Action.bookMarkButtonTapped } .bind(to: reactor.action) .disposed(by: cell.disposeBag) - + cell.sharedButton.rx.tap .withUnretained(self) .map { (owner, _) in @@ -180,13 +176,13 @@ extension DetailController: UICollectionViewDelegate, UICollectionViewDataSource .bind(to: reactor.action) .disposed(by: cell.disposeBag) } - + if let cell = cell as? DetailInfoSectionCell { cell.copyButton.rx.tap .map { Reactor.Action.copyButtonTapped } .bind(to: reactor.action) .disposed(by: cell.disposeBag) - + cell.mapButton.rx.tap .withUnretained(self) .map { (owner, _) in @@ -213,7 +209,7 @@ extension DetailController: UICollectionViewDelegate, UICollectionViewDataSource } .disposed(by: cell.disposeBag) } - + if let cell = cell as? DetailCommentTitleSectionCell { cell.totalViewButton.rx.tap .withUnretained(self) @@ -223,7 +219,7 @@ extension DetailController: UICollectionViewDelegate, UICollectionViewDataSource .bind(to: reactor.action) .disposed(by: cell.disposeBag) } - + if let cell = cell as? DetailCommentSectionCell { cell.imageCollectionView.rx.itemSelected .withUnretained(self) @@ -232,7 +228,7 @@ extension DetailController: UICollectionViewDelegate, UICollectionViewDataSource } .bind(to: reactor.action) .disposed(by: cell.disposeBag) - + cell.profileView.button.rx.tap .withUnretained(self) .map { (owner, _) in @@ -240,7 +236,7 @@ extension DetailController: UICollectionViewDelegate, UICollectionViewDataSource } .bind(to: reactor.action) .disposed(by: cell.disposeBag) - + cell.totalViewButton.rx.tap .withUnretained(self) .map { (owner, _) in @@ -248,12 +244,12 @@ extension DetailController: UICollectionViewDelegate, UICollectionViewDataSource } .bind(to: reactor.action) .disposed(by: cell.disposeBag) - + cell.likeButton.rx.tap .map { Reactor.Action.commentLikeButtonTapped(indexPath: indexPath) } .bind(to: reactor.action) .disposed(by: cell.disposeBag) - + cell.loginButton.rx.tap .withUnretained(self) .map { (owner, _) in @@ -270,7 +266,7 @@ extension DetailController: UICollectionViewDelegate, UICollectionViewDataSource reactor?.action.onNext(.similarSectionTapped(controller: self, indexPath: indexPath)) } } - + func scrollViewDidScroll(_ scrollView: UIScrollView) { if scrollView.contentOffset.y < 241 { headerBackgroundView.isHidden = true diff --git a/Poppool/Poppool/Presentation/Scene/MyPage/Main/MyPageReactor.swift b/Poppool/Poppool/Presentation/Scene/MyPage/Main/MyPageReactor.swift index e948e7b6..0451460a 100644 --- a/Poppool/Poppool/Presentation/Scene/MyPage/Main/MyPageReactor.swift +++ b/Poppool/Poppool/Presentation/Scene/MyPage/Main/MyPageReactor.swift @@ -6,7 +6,6 @@ // import UIKit - import ReactorKit import RxSwift import RxCocoa @@ -22,8 +21,7 @@ final class MyPageReactor: Reactor { case commentButtonTapped(controller: BaseViewController) case listCellTapped(controller: BaseViewController, title: String?) case logoutButtonTapped - case adminMenuTapped(controller: BaseViewController) // 관리자 메뉴 추가 - + case adminMenuTapped(controller: BaseViewController) // ← 관리자 메뉴 액션 추가 } enum Mutation { @@ -34,8 +32,7 @@ final class MyPageReactor: Reactor { case moveToPopUpDetailScene(controller: BaseViewController, row: Int) case moveToLoginScene(controller: BaseViewController) case moveToMyCommentScene(controller: BaseViewController) - case moveToAdminScene(controller: BaseViewController) // 관리자 메뉴 이동 추가 - + case moveToAdminScene(controller: BaseViewController) // ← 관리자 메뉴 이동 추가 } struct State { @@ -45,7 +42,6 @@ final class MyPageReactor: Reactor { } // MARK: - properties - var initialState: State var disposeBag = DisposeBag() @@ -54,17 +50,20 @@ final class MyPageReactor: Reactor { lazy var compositionalLayout: UICollectionViewCompositionalLayout = { UICollectionViewCompositionalLayout { [weak self] section, env in guard let self = self else { - return NSCollectionLayoutSection(group: NSCollectionLayoutGroup( - layoutSize: .init( - widthDimension: .fractionalWidth(1), - heightDimension: .fractionalHeight(1) - )) + return NSCollectionLayoutSection( + group: NSCollectionLayoutGroup( + layoutSize: .init( + widthDimension: .fractionalWidth(1), + heightDimension: .fractionalHeight(1) + ) + ) ) } return getSection()[section].getSection(section: section, env: env) } }() + // 섹션들 private var profileSection = MyPageProfileSection(inputDataList: []) private var commentTitleSection = MyPageMyCommentTitleSection(inputDataList: [.init(title: "내 코멘트", buttonTitle: "전체보기")]) private var commentSection = MyPageCommentSection(inputDataList: []) @@ -85,6 +84,7 @@ final class MyPageReactor: Reactor { .init(title: "회원탈퇴") ]) + /// 관리자 모드용 etcSection private var adminEtcSection = MyPageListSection(inputDataList: [ .init(title: "회원탈퇴"), .init(title: "관리자 메뉴 바로가기") @@ -110,13 +110,16 @@ final class MyPageReactor: Reactor { // MARK: - Reactor Methods func mutate(action: Action) -> Observable { switch action { + case .viewWillAppear: + // 로그인 여부 & 관리자 여부 등 MyPage 정보 가져오기 return userAPIUseCase.getMyPage() .withUnretained(self) .map { (owner, response) in owner.isLogin = response.loginYn owner.isAdmin = response.adminYn + // 프로필 owner.profileSection.inputDataList = [ .init( isLogin: response.loginYn, @@ -125,47 +128,58 @@ final class MyPageReactor: Reactor { description: response.intro ) ] + // 내가 댓글 단 팝업 리스트 owner.commentSection.inputDataList = response.myCommentedPopUpList.map { .init(popUpImagePath: $0.mainImageUrl, title: $0.popUpStoreName, popUpID: $0.popUpStoreId) } return .loadView } + case .settingButtonTapped(let controller): - return Observable.just(.moveToProfileEditScene(controller: controller)) + return .just(.moveToProfileEditScene(controller: controller)) + case .commentButtonTapped(let controller): - return Observable.just(.moveToMyCommentScene(controller: controller)) + return .just(.moveToMyCommentScene(controller: controller)) + case .commentCellTapped(let controller, let row): - return Observable.just(.moveToPopUpDetailScene(controller: controller, row: row)) + return .just(.moveToPopUpDetailScene(controller: controller, row: row)) + case .loginButtonTapped(let controller): - return Observable.just(.moveToLoginScene(controller: controller)) + return .just(.moveToLoginScene(controller: controller)) + case .listCellTapped(let controller, let title): + // 일반 리스트 셀 탭 + // 만약 "관리자 메뉴 바로가기"라면 adminScene으로 이동 if title == "관리자 메뉴 바로가기" { - return Observable.just(.moveToAdminScene(controller: controller)) + return .just(.moveToAdminScene(controller: controller)) } else { - return Observable.just(.moveToDetailScene(controller: controller, title: title)) + return .just(.moveToDetailScene(controller: controller, title: title)) } - case .adminMenuTapped(let controller): // 추가된 관리자 메뉴 액션 처리 - return Observable.just(.moveToAdminScene(controller: controller)) case .logoutButtonTapped: + // 로그아웃 API return userAPIUseCase.postLogout() .andThen(Observable.just(.logout)) - case .adminMenuTapped(let controller): - return Observable.just(.moveToAdminScene(controller: controller)) + case .adminMenuTapped(let controller): + // 별도의 액션으로도 관리자 메뉴로 이동 가능 + return .just(.moveToAdminScene(controller: controller)) } } func reduce(state: State, mutation: Mutation) -> State { var newState = state + switch mutation { case .loadView: newState.sections = getSection() newState.isLogin = isLogin + case .moveToProfileEditScene(let controller): let nextController = ProfileEditController() nextController.reactor = ProfileEditReactor() controller.navigationController?.pushViewController(nextController, animated: true) + case .logout: let service = KeyChainService() let _ = service.deleteToken(type: .accessToken) @@ -174,6 +188,7 @@ final class MyPageReactor: Reactor { DispatchQueue.main.async { [weak self] in self?.action.onNext(.viewWillAppear) } + case .moveToDetailScene(let controller, let title): guard let title = title else { break } switch title { @@ -199,71 +214,79 @@ final class MyPageReactor: Reactor { } }) .disposed(by: nextController.disposeBag) + case "차단한 사용자 관리": let nextController = BlockUserManageController() nextController.reactor = BlockUserManageReactor() controller.navigationController?.pushViewController(nextController, animated: true) + case "공지사항": let nextController = MyPageNoticeController() nextController.reactor = MyPageNoticeReactor() controller.navigationController?.pushViewController(nextController, animated: true) + case "고객문의": let nextController = FAQController() nextController.reactor = FAQReactor() controller.navigationController?.pushViewController(nextController, animated: true) + case "찜한 팝업": let nextController = MyPageBookmarkController() nextController.reactor = MyPageBookmarkReactor() controller.navigationController?.pushViewController(nextController, animated: true) + case "최근 본 팝업": let nextController = MyPageRecentController() nextController.reactor = MyPageRecentReactor() controller.navigationController?.pushViewController(nextController, animated: true) + default: break } - case.moveToLoginScene(let controller): + + case .moveToPopUpDetailScene(let controller, let row): + let nextController = DetailController() + let popUpID = commentSection.inputDataList[row].popUpID + nextController.reactor = DetailReactor(popUpID: popUpID) + controller.navigationController?.pushViewController(nextController, animated: true) + + case .moveToLoginScene(let controller): let nextController = SubLoginController() nextController.reactor = SubLoginReactor() let navigationController = UINavigationController(rootViewController: nextController) navigationController.modalPresentationStyle = .fullScreen controller.present(navigationController, animated: true) - case .moveToAdminScene(let controller): // 관리자 메뉴 이동 처리 - let nickname = profileSection.inputDataList.first?.nickName ?? "" - let adminVC = AdminViewController(nickname: nickname) - adminVC.reactor = AdminReactor(useCase: DefaultAdminUseCase(repository: DefaultAdminRepository(provider: ProviderImpl()))) - controller.navigationController?.pushViewController(adminVC, animated: true) - + case .moveToMyCommentScene(let controller): let nextController = MyCommentController() nextController.reactor = MyCommentReactor() controller.navigationController?.pushViewController(nextController, animated: true) + case .moveToAdminScene(let controller): - let nickname = profileSection.inputDataList.first?.nickName ?? "" + // 관리자 VC + let nickname = profileSection.inputDataList.first?.nickName ?? "" let adminVC = AdminViewController(nickname: nickname) - adminVC.reactor = AdminReactor(useCase: DefaultAdminUseCase(repository: DefaultAdminRepository(provider: ProviderImpl()))) - controller.navigationController?.pushViewController(adminVC, animated: true) - default: - break - } - return newState - } - case .moveToPopUpDetailScene(let controller, let row): - let nextController = DetailController() - nextController.reactor = DetailReactor(popUpID: commentSection.inputDataList[row].popUpID) - controller.navigationController?.pushViewController(nextController, animated: true) + adminVC.reactor = AdminReactor( + useCase: DefaultAdminUseCase( + repository: DefaultAdminRepository(provider: ProviderImpl()) + ) + ) + controller.navigationController?.pushViewController(adminVC, animated: true) } + + // 배경 프로필 이미지 if !profileSection.isEmpty { newState.backgroundImageViewPath = profileSection.inputDataList.first?.profileImagePath } + return newState } + // MARK: - Composing Sections func getSection() -> [any Sectionable] { return getProfileSection() + getCommentSection() + getNormalSection() + getInfoSection() + getETCSection() } - func getProfileSection() -> [any Sectionable] { return [profileSection] } @@ -320,25 +343,28 @@ final class MyPageReactor: Reactor { func getETCSection() -> [any Sectionable] { if isLogin { if isAdmin { + // 관리자 모드 return [ spacing16GraySection, spacing28Section, - adminEtcSection, + adminEtcSection, // "회원탈퇴" + "관리자 메뉴 바로가기" spacing28Section, logoutSection, spacing156Section ] } else { + // 일반 모드 return [ spacing16GraySection, spacing28Section, - etcSection, + etcSection, // "회원탈퇴" spacing28Section, logoutSection, spacing156Section ] } } else { + // 미로그인 return [spacing156Section] } }