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..d5e8a7a5 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 */; }; @@ -358,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 */; }; @@ -385,9 +383,30 @@ 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 */; }; - 4E92F0D52D1BE72F00D00495 /* StoreListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E92F0D42D1BE72E00D00495 /* StoreListHeaderView.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 */; }; + 4E755B292D2BA65A00ADFB21 /* AdminReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B282D2BA65A00ADFB21 /* AdminReactor.swift */; }; + 4E755B2B2D2BA76E00ADFB21 /* AdminUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E755B2A2D2BA76E00ADFB21 /* AdminUseCase.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 */; }; + 4E78706E2D37CB1900465FC9 /* ProfileEditListButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081898BF2D2FBD130067BF01 /* ProfileEditListButton.swift */; }; + 4E78706F2D37CB2200465FC9 /* PopUpStoreRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9C12802D2BE0A6006744D6 /* PopUpStoreRegisterViewController.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 */; }; + 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 */; }; @@ -797,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 = ""; }; @@ -823,8 +841,27 @@ 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 = ""; }; - 4E92F0D42D1BE72E00D00495 /* StoreListHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreListHeaderView.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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; @@ -876,6 +913,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 +1316,7 @@ 083A256D2CF3613C0099B58E /* Presentation */ = { isa = PBXGroup; children = ( + 4E755B1B2D2B9ABF00ADFB21 /* Admin */, 4E685ECD2D12CEB6001EF91C /* Map */, 083A258B2CF361F90099B58E /* Convention */, 08B1915F2CF430D40057BC04 /* Components */, @@ -2563,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 = ""; @@ -2592,9 +2629,84 @@ 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 */, + 4E6CA4842D34D6ED0034D09A /* AdminBottomSheetReactor.swift */, + 4E9C12802D2BE0A6006744D6 /* PopUpStoreRegisterViewController.swift */, + 4EEA1D942D358B23003E7DE9 /* PopUpStoreRegisterView.swift */, + 4EEA1D922D358839003E7DE9 /* PopUpStoreRegisterReactor.swift */, + 4EEA1D8E2D352012003E7DE9 /* ImageCell.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 */, + 4EEA1D902D352027003E7DE9 /* ExtendedImage.swift */, + ); + path = Common; + sourceTree = ""; + }; 4EED9BAA2D2272F500B288E7 /* Common */ = { isa = PBXGroup; children = ( + 4EAB809C2D3F78AA0041AF30 /* GMSMapViewDelegateProxy.swift */, + 4EAB809E2D3F8EF50041AF30 /* ViewportBounds.swift */, 4EED9BAB2D22730400B288E7 /* FilterType.swift */, ); path = Common; @@ -2794,6 +2906,7 @@ 088DE2462D12DB5C0030FA9E /* GoogleMaps */, 4E5825662D1951DF00EE83EF /* FloatingPanel */, 4EA9989C2D21C404009DC30B /* RxDataSources */, + 4E755B312D2BA81800ADFB21 /* Then */, ); productName = Poppool; productReference = BDCA41BD2CF35AC0005EECF6 /* Poppool.app */; @@ -2884,6 +2997,7 @@ 088DE2452D12DB5C0030FA9E /* XCRemoteSwiftPackageReference "ios-maps-sdk" */, 4E5825652D1951DF00EE83EF /* XCRemoteSwiftPackageReference "FloatingPanel" */, 4EA9989B2D21C404009DC30B /* XCRemoteSwiftPackageReference "RxDataSources" */, + 4E755B302D2BA81700ADFB21 /* XCRemoteSwiftPackageReference "Then" */, ); productRefGroup = BDCA41BE2CF35AC0005EECF6 /* Products */; projectDirPath = ""; @@ -2967,6 +3081,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 +3129,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 */, @@ -3033,6 +3151,7 @@ 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 */, @@ -3045,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 */, @@ -3072,6 +3190,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 */, @@ -3092,9 +3211,11 @@ 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 */, + 4E755B292D2BA65A00ADFB21 /* AdminReactor.swift in Sources */, 086DD8D62CFF182100B97D3B /* UserAPIEndPoint.swift in Sources */, 081899372D35F1140067BF01 /* MyPageBookmarkReactor.swift in Sources */, 4E685EE22D12CEB6001EF91C /* StoreListView.swift in Sources */, @@ -3112,7 +3233,9 @@ 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 */, 08DC61FA2CF7684F002A2F44 /* SignUpCompleteController.swift in Sources */, 083C864A2D0DCF96003F441C /* AddCommentDescriptionSection.swift in Sources */, @@ -3139,6 +3262,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 */, @@ -3183,7 +3307,9 @@ 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 */, 4E685ECF2D12CEB6001EF91C /* BalloonChipCell.swift in Sources */, 083A25CA2CF363C60099B58E /* LoginReactor.swift in Sources */, @@ -3220,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 */, @@ -3228,15 +3355,16 @@ 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 */, 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 */, @@ -3276,7 +3404,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 */, @@ -3311,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 */, @@ -3323,6 +3454,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 +3847,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 +3989,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 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/Application/SceneDelegate.swift b/Poppool/Poppool/Application/SceneDelegate.swift index fdd369d8..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 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/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 5fb476de..a0fd9366 100644 --- a/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift +++ b/Poppool/Poppool/Infrastructure/PreSignedService/PreSignedService.swift @@ -17,59 +17,74 @@ 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() @@ -136,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) { @@ -177,7 +194,7 @@ private extension PreSignedService { single(.failure(error)) } } - + return Disposables.create { request.cancel() } @@ -187,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 new file mode 100644 index 00000000..5876db70 --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetView.swift @@ -0,0 +1,226 @@ +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 + 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 + return collectionView + }() + + let filterChipsView = FilterChipsView() + + 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) { + Logger.log(message: "높이 변경 시작: \(isCategorySelected ? "카테고리" : "상태값")", category: .debug) + + let newHeight: CGFloat = isCategorySelected ? 200 : 160 + + // 애니메이션 없이 바로 적용 + contentHeightConstraint?.update(offset: newHeight) + contentCollectionView.invalidateIntrinsicContentSize() + + setNeedsLayout() + layoutIfNeeded() + + Logger.log(message: "높이 변경 완료", category: .debug) + } +} diff --git a/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift new file mode 100644 index 00000000..933f5c26 --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/AdminBottomSheetViewController.swift @@ -0,0 +1,229 @@ +import UIKit +import SnapKit +import RxSwift +import RxCocoa +import ReactorKit + + +final class AdminBottomSheetViewController: BaseViewController, View { + + typealias Reactor = AdminBottomSheetReactor + + // MARK: - Properties + private let mainView = AdminBottomSheetView() + private let dimmedView = UIView() + var disposeBag = DisposeBag() + private var containerViewBottomConstraint: Constraint? + private var tagSection: TagSection? + + var onSave: (([String]) -> Void)? + var onDismiss: (() -> Void)? + + // MARK: - Initialization + init(reactor: AdminBottomSheetReactor) { + super.init() // BaseViewController의 init() 호출 + self.reactor = reactor + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + setupViews() + setupCollectionView() + } + + // MARK: - Setup + private func setupViews() { + view.backgroundColor = .clear + + Logger.log(message: "초기 뷰 계층:", category: .debug) +// print(view.value(forKey: "recursiveDescription") ?? "") + + // mainView 설정 및 추가 + view.addSubview(mainView) + mainView.isUserInteractionEnabled = true + mainView.containerView.isUserInteractionEnabled = true + mainView.closeButton.isUserInteractionEnabled = true + mainView.segmentedControl.isUserInteractionEnabled = true + mainView.headerView.isUserInteractionEnabled = true + + 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 + } + + Logger.log(message: "mainView 추가 후 계층:", category: .debug) +// print(view.value(forKey: "recursiveDescription") ?? "") + + // dimmedView 설정 및 추가 + dimmedView.backgroundColor = .black.withAlphaComponent(0.4) + dimmedView.alpha = 0 + dimmedView.isUserInteractionEnabled = true + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dimmedViewTapped)) + dimmedView.addGestureRecognizer(tapGesture) + tapGesture.cancelsTouchesInView = false // 터치 이벤트가 다른 뷰로 전달되도록 설정 + view.insertSubview(dimmedView, belowSubview: mainView) + + dimmedView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + 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 new file mode 100644 index 00000000..5502ca94 --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/AdminReactor.swift @@ -0,0 +1,94 @@ +import ReactorKit +import RxSwift +import RxCocoa + +final class AdminReactor: Reactor { + + enum Action { + case viewDidLoad + case updateSearchQuery(String) + case tapRegisterButton + case tapEditButton(Int64) + + // 화면 이동 후 상태를 초기화하기 위한 액션 + case resetNavigation + case reloadData + + } + + 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, .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([ + .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)) + + 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..706addaf --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/AdminStoreCell.swift @@ -0,0 +1,90 @@ +import UIKit +import SnapKit +import RxSwift +final class AdminStoreCell: UITableViewCell { + private let disposeBag = DisposeBag() + + // 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) { + Logger.log(message: "셀 데이터 바인딩: \(store)", category: .debug) + + titleLabel.text = store.name + categoryLabel.text = store.categoryName + statusChip.text = "운영" + + // 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/AdminView.swift b/Poppool/Poppool/Presentation/Admin/AdminView.swift new file mode 100644 index 00000000..3ec4149e --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/AdminView.swift @@ -0,0 +1,192 @@ +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.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..c39c0a77 --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/AdminViewController.swift @@ -0,0 +1,243 @@ +import UIKit +import ReactorKit +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 adminUseCase: AdminUseCase + + // MARK: - Init + 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() + 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) + + 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) + } + + bottomSheetVC.onDismiss = { [weak self] in + guard let self = self else { return } + self.adminBottomSheetVC = nil + } + + bottomSheetVC.modalPresentationStyle = UIModalPresentationStyle.overFullScreen + + present(bottomSheetVC, animated: false) { + bottomSheetVC.showBottomSheet() + } + self.adminBottomSheetVC = bottomSheetVC + } + + 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) + }) + } + + 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 showDeleteOptions() { + let alert = UIAlertController(title: "삭제할 팝업스토어 선택", message: nil, preferredStyle: .actionSheet) + + 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) + .map { Reactor.Action.updateSearchQuery($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + // Register button + mainView.registerButton.rx.tap + .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) + + // Store list binding + 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, + cellType: AdminStoreCell.self + )) { _, item, cell in + cell.configure(with: item) + } + .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/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/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..58222704 --- /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..01e25584 --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/Data/Repository/AdminRepository.swift @@ -0,0 +1,130 @@ +import Foundation +import RxSwift +import UIKit +import Alamofire + +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 + + // 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: - 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 { + 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) + + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + .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) + }, + onError: { error in + Logger.log(message: "createStore API 호출 실패: \(error)", category: .error) + } + ) + } + + + + func updateStore(request: UpdatePopUpStoreRequestDTO) -> Observable { + let endpoint = AdminAPIEndpoint.updateStore(request: request) + return provider.requestData( + with: endpoint, + interceptor: tokenInterceptor + ) + } + + func deleteStore(id: Int64) -> Observable { + Logger.log(message: "deleteStore API 호출 시작", category: .info) + let endpoint = AdminAPIEndpoint.deleteStore(id: id) + 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 + 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..021a6c1e --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/Domain/AdminUseCase.swift @@ -0,0 +1,63 @@ +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 { + 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 new file mode 100644 index 00000000..dc7a7c4f --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterReactor.swift @@ -0,0 +1,307 @@ +// +// PopUpStoreRegisterReactor.swift +// Poppool +// +// Created by 김기현 on 1/14/25. +// + +import Foundation +import ReactorKit +import RxSwift +import UIKit + +final class PopUpStoreRegisterReactor: Reactor { + + // MARK: - Action + enum Action { + /// 화면 최초 로드 + case viewDidLoad + + /// 사용자 입력값 갱신 + case updateName(String) + case updateDesc(String) + case updateCategory(String) + case updateAddress(String) + case updateLatitude(String) // 문자열 -> Double 변환 + case updateLongitude(String) + case updateMarkerTitle(String) + case updateMarkerSnippet(String) + 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 setName(String) + case setDesc(String) + case setCategory(String) + case setAddress(String) + case setLatitude(Double) + case setLongitude(Double) + case setMarkerTitle(String) + case setMarkerSnippet(String) + 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 { + // 폼 입력값 + var name: String = "" + var desc: String = "" + var category: String = "게임" + var address: String = "" + var latitude: Double = 0 + var longitude: Double = 0 + var markerTitle: String = "" + var markerSnippet: String = "" + var startDate: Date? + var endDate: Date? + var startTime: Date? + var endTime: Date? + + + // 이미지 목록 + var images: [ExtendedImage] = [] + + // 최종 등록 여부 + var isRegistered: Bool = false + } + + // ReactorKit 필수 + let initialState: State = State() + + // 주입받는 의존성 + private let adminUseCase: AdminUseCase + + // disposeBag (mutate 안에서는 ReactorKit이 관리) + private let disposeBagInternal = DisposeBag() + + // MARK: - Init + init(adminUseCase: AdminUseCase) { + self.adminUseCase = adminUseCase + } + + // MARK: - mutate + func mutate(action: Action) -> Observable { + switch action { + + 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(latString): + // 문자 -> Double 변환 + let lat = Double(latString) ?? 0 + return .just(.setLatitude(lat)) + + case let .updateLongitude(lonString): + let lon = Double(lonString) ?? 0 + return .just(.setLongitude(lon)) + + case let .updateMarkerTitle(title): + return .just(.setMarkerTitle(title)) + + case let .updateMarkerSnippet(snippet): + return .just(.setMarkerSnippet(snippet)) + + case let .updateStartDate(date): + return .just(.setStartDate(date)) + + case let .updateEndDate(date): + return .just(.setEndDate(date)) + + case let .updateStartTime(time): + return .just(.setStartTime(time)) + + case let .updateEndTime(time): + return .just(.setEndTime(time)) + + // 이미지 관련 + case let .addImage(img): + return .just(.addImage(img)) + + case let .removeImage(index): + return .just(.removeImageAt(index)) + + case let .toggleMainImage(index): + return .just(.toggleMain(index)) + + // "저장" 액션 + case .tapRegister: + return doRegister() + } + } + + // MARK: - reduce + func reduce(state: State, mutation: Mutation) -> State { + var newState = state + + switch mutation { + case let .setName(name): + newState.name = name + + case let .setDesc(desc): + newState.desc = desc + + 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 .setMarkerTitle(title): + newState.markerTitle = title + + case let .setMarkerSnippet(snippet): + newState.markerSnippet = snippet + + 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.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.. 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 new file mode 100644 index 00000000..2370b70d --- /dev/null +++ b/Poppool/Poppool/Presentation/Admin/PopUpStoreRegisterViewController.swift @@ -0,0 +1,1393 @@ +import UIKit +import SnapKit +import ReactorKit +import RxSwift +import RxCocoa +import PhotosUI +import Alamofire +import GoogleMaps +import CoreLocation + +final class PopUpStoreRegisterViewController: BaseViewController { + // typealias Reactor = PopUpStoreRegisterReactor + + + // MARK: - Navigation/Header + var completionHandler: (() -> Void)? + 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 = "" + private let editingStore: GetAdminPopUpStoreListResponseDTO.PopUpStore? + let presignedService = PreSignedService() + + var disposeBag = DisposeBag() + private let nickname: String + private let navContainer = UIView() + + 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) { + 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() + iv.image = UIImage(named: "image_login_logo") + iv.contentMode = .scaleAspectFit + return iv + }() + + private let accountIdLabel: UILabel = { + 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) + 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 + // }() + 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() + 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: - 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) + if let store = editingStore { + fillFormWithExistingData(store) + } + + setupNavigation() + setupLayout() + setupRows() + setupImageCollectionUI() + setupImageCollectionActions() + setupKeyboardHandling() + setupAddressField() + + + } + + + // 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() + + } + } + private func fillFormWithExistingData(_ store: GetAdminPopUpStoreListResponseDTO.PopUpStore) { + nameField?.text = store.name + categoryButton.setTitle("\(store.categoryName) ▾", for: .normal) + // 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() { + // (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) + } + + // (2) 타이틀 컨테이너 + 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) 스크롤뷰 + 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) + } + + // (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) + } + + // (5) 폼 배경 + contentView.addSubview(formBackgroundView) + formBackgroundView.snp.makeConstraints { make in + make.top.equalTo(imagesCollectionView.snp.bottom).offset(16) + make.left.right.equalToSuperview().inset(16) + make.bottom.equalToSuperview().offset(-16) + } + + formBackgroundView.addSubview(verticalStack) + verticalStack.axis = .vertical + verticalStack.spacing = 0 + verticalStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + // (6) 저장 버튼 + view.addSubview(saveButton) + saveButton.snp.makeConstraints { make in + make.left.right.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) + make.height.equalTo(44) + } + } + // MARK: - Setup Rows + private func setupRows() { + addRowTextField(leftTitle: "이름", placeholder: "팝업스토어 이름을 입력해 주세요.") + addRowTextField(leftTitle: "이미지", placeholder: "팝업스토어 대표 이미지를 업로드 해주세요.") + + 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 + 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) 기간 + periodButton.addTarget(self, action: #selector(didTapPeriodButton), for: .touchUpInside) + addRowCustom(leftTitle: "기간", rightView: periodButton) + + // (11) 시간 + timeButton.addTarget(self, action: #selector(didTapTimeButton), for: .touchUpInside) + addRowCustom(leftTitle: "시간", rightView: timeButton) + + // (12) 작성자 + let writerLbl = makeSimpleLabel(nickname) + 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() + 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) + } + + 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.equalToSuperview().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 + } + + /** + 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) + + periodButton.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) + 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) + } + + 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) + } + } + } + + 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 { + 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 + } +} +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 } + + if let editingStore = editingStore { + // 수정 모드 + updateStore(editingStore) + } else { + // 새로 등록 모드 + // 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 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 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 { $0.filePath } + let mainImage = updatedImages.first { $0.isMain }?.filePath ?? "" + self.callCreateStoreAPI(mainImage: mainImage, imagePaths: imagePaths) // baseURL 제거 + }, + 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, handler: { [weak self] _ in + // 성공 후 닫기와 핸들러 호출 + self?.completionHandler?() + self?.navigationController?.popViewController(animated: true) + })) + 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) + } +} +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 aeeb9e01..0960692d 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,14 +69,17 @@ final class BalloonBackgroundView: UIView { required init?(coder: NSCoder) { fatalError() } // MARK: - Setup + private func setupLayout() { addSubview(containerView) 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 @@ -85,24 +92,42 @@ 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 +141,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 +174,7 @@ final class BalloonBackgroundView: UIView { self.layoutIfNeeded() } + func calculateHeight() -> CGFloat { guard let inputDataList = tagSection?.inputDataList else { return 0 } @@ -157,35 +190,19 @@ 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). -// """) } } // 높이 계산 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)) + @@ -193,9 +210,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 +222,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..c45a767c 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 @@ -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/FilterBottomSheetReactor.swift b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetReactor.swift index 123e1599..309ee81b 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 a3398c6b..3473bc40 100644 --- a/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift +++ b/Poppool/Poppool/Presentation/Map/FillterSheetView/FilterBottomSheetView.swift @@ -157,32 +157,41 @@ 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(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) } + // 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) @@ -210,6 +235,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? @@ -287,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, @@ -305,7 +344,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 @@ -315,7 +354,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 +368,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 } @@ -337,21 +376,27 @@ 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) + let arrowPosition = buttonFrameInBalloon.midX / balloonBackgroundView.bounds.width + balloonBackgroundView.arrowPosition = arrowPosition + balloonBackgroundView.setNeedsDisplay() } } @@ -369,3 +414,21 @@ extension FilterBottomSheetView { filterChipsView.updateChips(with: filters) } } +extension FilterBottomSheetView: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + + 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 { + return + } + + let buttonFrame = selectedButton.convert(selectedButton.bounds, to: balloonBackgroundView) + + let arrowPosition = buttonFrame.midX / balloonBackgroundView.bounds.width + + 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..e5a99497 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..1d387504 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 = .g300 + label.font = UIFont.systemFont(ofSize: 14, weight: .medium) + 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/MapAPI/MapAPIEndpoint.swift b/Poppool/Poppool/Presentation/Map/MapAPI/MapAPIEndpoint.swift index 878c1d61..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,17 +33,13 @@ struct MapAPIEndpoint { } /// 지도에서 검색합니다. - /// - Parameters: - /// - query: 검색어 - /// - categories: 카테고리 필터 배열 - /// - Returns: Endpoint static func locations_searchStores( query: String, - categories: [String] - ) -> Endpoint { + categories: [Int64] + ) -> Endpoint { let params = SearchQueryDTO( query: query, - categories: categories + categories: categories.isEmpty ? nil : categories ) return Endpoint( @@ -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/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..1db431dd 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,15 +28,18 @@ struct MapPopUpStoreDTO: Codable { longitude: longitude, markerId: markerId, markerTitle: markerTitle, - markerSnippet: markerSnippet + markerSnippet: markerSnippet, + mainImageUrl: mainImageUrl + ) } } struct GetViewBoundPopUpStoreListResponse: Decodable { - var popUpStoreList: [MapPopUpStoreDTO] + let 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..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,12 +33,14 @@ class DefaultMapRepository: MapRepository { self.provider = provider } + // DefaultMapRepository.swift (발췌) + func fetchStoresInBounds( northEastLat: Double, northEastLon: Double, southWestLat: Double, southWestLon: Double, - categories: [String] + categories: [Int64] ) -> Observable<[MapPopUpStoreDTO]> { return provider.requestData( with: MapAPIEndpoint.locations_fetchStoresInBounds( @@ -45,22 +50,55 @@ class DefaultMapRepository: MapRepository { southWestLon: southWestLon, categories: categories ), - interceptor: nil + interceptor: TokenInterceptor() // ← 토큰 누락 해결 ) .map { $0.popUpStoreList } } func searchStores( query: String, - categories: [String] + categories: [Int64] ) -> Observable<[MapPopUpStoreDTO]> { return provider.requestData( with: MapAPIEndpoint.locations_searchStores( query: query, categories: categories ), - interceptor: nil + 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/MapFilterChips.swift b/Poppool/Poppool/Presentation/Map/MapFilterChips.swift index 4f66f871..7745525c 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,34 +67,36 @@ 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 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) 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.layer.cornerRadius = 18 + 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/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/MapReactor.swift b/Poppool/Poppool/Presentation/Map/MapReactor.swift index c4eb2879..bc426cbd 100644 --- a/Poppool/Poppool/Presentation/Map/MapReactor.swift +++ b/Poppool/Poppool/Presentation/Map/MapReactor.swift @@ -6,26 +6,62 @@ final class MapReactor: Reactor { // MARK: - Reactor enum Action { case viewDidLoad - case searchTapped + case searchTapped(String) case locationButtonTapped case listButtonTapped - case filterTapped(FilterType?) // 여기서 FilterType은 공통 파일에서 가져옴 + case filterTapped(FilterType?) case filterUpdated(FilterType, [String]) case clearFilters(FilterType) + case fetchCategories + 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?) + case setCategoryMapping([String: Int64]) + + + } 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 + var categoryMapping: [String: Int64] = [:] + + + } let initialState: State @@ -38,49 +74,227 @@ 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): + // 1) categoryName -> categoryId 변환 + let categoryIDs = currentState.selectedCategoryFilters + .compactMap { currentState.categoryMapping[$0] } // [Int64] + + return .concat([ + .just(.setLoading(true)), + // 2) 수정: [Int64]를 UseCase에 넘김 + useCase.searchStores(query: query, categories: categoryIDs) + .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): + // 🔒 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( + northEastLat: northEastLat, + northEastLon: northEastLon, + southWestLat: southWestLat, + southWestLon: southWestLon, + categories: categoryIDs // ← 숫자 배열로 수정 + ) + .map(Mutation.setViewportStores) + .catch { error in .just(.setError(error)) }, + .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): - print("MapReactor filterUpdated - type: \(type), values: \(values)") switch type { case .location: - return .just(.setLocationFilters(values)) + let displayText = formatDisplayText(values, defaultText: "지역선택") + return .concat([ + .just(.setLocationFilters(values)), + .just(.updateLocationDisplay(displayText)) + ]) case .category: - return .just(.setCategoryFilters(values)) + 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 .just(.clearLocationFilters) + return .concat([ + .just(.clearLocationFilters), + .just(.updateLocationDisplay("지역선택")) + ]) case .category: - return .just(.clearCategoryFilters) + 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 + 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)") + + case let .updateLocationDisplay(text): + newState.locationDisplayText = text + + case let .updateCategoryDisplay(text): + newState.categoryDisplayText = text + case .clearLocationFilters: newState.selectedLocationFilters = [] - case .clearCategoryFilters: // 카테고리 필터 초기화 + + case .clearCategoryFilters: newState.selectedCategoryFilters = [] + + case let .updateBothFilters(locations, categories): + Logger.log( + message: """ + 💾 필터 상태 업데이트 + 📍 이전 위치 필터: \(newState.selectedLocationFilters) + 🏷️ 이전 카테고리 필터: \(newState.selectedCategoryFilters) + """, + category: .debug + ) + + + newState.selectedLocationFilters = locations + newState.selectedCategoryFilters = categories + + print("[DEBUG] ✅ Updated state - Locations: \(newState.selectedLocationFilters)") + 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)") + 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 + ) + } + + 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/MapSearchInput.swift b/Poppool/Poppool/Presentation/Map/MapSearchInput.swift index 58f3dd02..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,57 +34,68 @@ 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.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() { - 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) - reactor.state - .map { $0.selectedLocationFilters } - .distinctUntilChanged() - .bind { [weak self] filters in - self?.searchTextField.text = filters.first // 필터에 맞는 텍스트를 UI에 표시 - } + // 텍스트 입력 로그 + searchTextField.rx.text.orEmpty + .subscribe(onNext: { text in + print("[DEBUG] TextField Input: \(text)") + }) .disposed(by: disposeBag) - // 검색 버튼을 눌렀을 때 - tapButton.rx.tap - .bind { [weak self] in - guard let self = self else { return } - // searchTapped 액션 호출 - reactor.action.onNext(.searchTapped) - } + // 선택된 필터를 검색창에 반영 + reactor.state + .map { $0.selectedLocationFilters.first } + .distinctUntilChanged() + .bind(to: searchTextField.rx.text) .disposed(by: disposeBag) } } @@ -84,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 @@ -100,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 5c81fc0e..e3bb88cd 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,40 +72,31 @@ 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) - addSubview(locationButton) addSubview(listButton) addSubview(storeCard) @@ -124,6 +125,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..5f843181 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 @@ -16,11 +18,23 @@ 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? private var filterChipsTopY: CGFloat = 0 + private var filterContainerBottomY: CGFloat { + let frameInView = mainView.filterChips.convert(mainView.filterChips.bounds, to: view) + return frameInView.maxY // 필터 컨테이너의 바닥 높이 + } - 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 +43,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,29 +50,65 @@ final class MapViewController: BaseViewController, View { super.viewDidLoad() setUp() checkLocationAuthorization() - locationManager.delegate = self locationManager.requestWhenInUseAuthorization() locationManager.desiredAccuracy = kCLLocationAccuracyBest + + if let reactor = self.reactor { + bind(reactor: reactor) + bindViewport(reactor: reactor) + + reactor.action.onNext(.fetchCategories) + + } + } + // MARK: - Setup private func setUp() { 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 + + // 리스트뷰 설정 + addChild(storeListViewController) + view.addSubview(storeListViewController.view) + storeListViewController.didMove(toParent: self) + + storeListViewController.view.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview() + listViewTopConstraint = make.top.equalToSuperview().offset(view.frame.height).constraint // 초기 숨김 상태 + } - view.addSubview(carouselView) - carouselView.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview() - make.height.equalTo(140) - make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) + if let reactor = self.reactor { + bind(reactor: reactor) } - carouselView.isHidden = true - mainView.mapView.delegate = self - } + // 제스처 설정 + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) + storeListViewController.mainView.grabberHandle.addGestureRecognizer(panGesture) + storeListViewController.mainView.addGestureRecognizer(panGesture) + setupPanAndSwipeGestures() + + setupMarker() + } + + private let defaultZoomLevel: Float = 15.0 // 기본 줌 레벨 + + private func setupMarker() { let marker = GMSMarker() marker.position = CLLocationCoordinate2D(latitude: 37.5666, longitude: 126.9784) let markerView = MapMarker() @@ -70,7 +118,44 @@ final class MapViewController: BaseViewController, View { markerView.frame = CGRect(x: 0, y: 0, width: 80, height: 28) } + private func setupPanAndSwipeGestures() { + // grabberHandle에 스와이프 제스처 추가 + storeListViewController.mainView.grabberHandle.rx.swipeGesture(.up) + .skip(1) + .withUnretained(self) + .subscribe { owner, _ in + Logger.log(message: "⬆️ 위로 스와이프 감지", category: .debug) + 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 + Logger.log(message: "⬇️ 아래로 스와이프 감지됨", category: .debug) + 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 +166,16 @@ 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,27 +183,8 @@ 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)) } @@ -145,21 +193,48 @@ 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 + Logger.log( + message: """ + 필터 업데이트: + 📍 위치: \(locationText) + 🏷️ 카테고리: \(categoryText) + """, + category: .debug + ) + self?.mainView.filterChips.update( + locationText: locationText, + categoryText: categoryText + ) } .disposed(by: disposeBag) + reactor.state.map { $0.activeFilterType } .distinctUntilChanged() .observe(on: MainScheduler.instance) @@ -172,32 +247,265 @@ 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) + 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() + .skip(1) // 초기값 스킵 + .observe(on: MainScheduler.instance) + .bind { [weak self] isEmpty in + guard let self = self else { return } + if isEmpty { + self.showAlert( + title: "검색 결과 없음", + message: "검색 결과가 없습니다. 다른 키워드로 검색해보세요." + ) + } + } + .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 + self.view.layoutIfNeeded() + } + + // 상태 변경 후 로그 + Logger.log( + message: """ + 리스트뷰 상태 변경: + 현재 상태: \(modalState) + 현재 오프셋: \(listViewTopConstraint?.layoutConstraints.first?.constant ?? 0) + """, + category: .debug + ) } + + 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 currentOffset = constraint.layoutConstraints.first?.constant ?? 0 + let newOffset = currentOffset + 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) + + // 알파값 조절: 탑 상태에서만 적용 + if modalState == .top { + adjustMapViewAlpha(for: clampedOffset, minOffset: minOffset, maxOffset: maxOffset) + } + } - let markerView = MapMarker() - markerView.injection(with: store.toMarkerInput()) - marker.iconView = markerView - marker.map = mainView.mapView + case .ended: + 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 if currentOffset < middleY * 0.7 { + targetState = .top + } else if currentOffset < view.frame.height * 0.7 { + targetState = .middle + } else { + targetState = .bottom + } + + // 최종 상태에 따라 애니메이션 적용 + 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.alpha = 0 // 탑 상태에서는 숨김 + self.storeListViewController.setGrabberHandleVisible(false) + self.listViewTopConstraint?.update(offset: filterChipsFrame.maxY) + self.mainView.searchInput.backgroundColor = .g50 + + + case .middle: + self.storeListViewController.setGrabberHandleVisible(true) + // 필터 컨테이너 바닥 높이를 최소값으로 사용 + 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 + + + case .bottom: + self.storeListViewController.setGrabberHandleVisible(true) + self.listViewTopConstraint?.update(offset: self.view.frame.height) // 화면 아래로 숨김 + self.mainView.mapView.alpha = 1 // 바텀 상태에서는 항상 보임 + self.mainView.mapView.isHidden = false + self.mainView.searchInput.backgroundColor = .white + + } + + self.view.layoutIfNeeded() + }) { _ in + self.modalState = state + Logger.log(message: ". 현재 상태: \(state)", category: .debug) + } + } + + 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)) - - viewController.onSave = { [weak self] (selectedOptions: [String]) in + sheetReactor.action.onNext(.segmentChanged(initialIndex)) + + viewController.onSave = { [weak self] filterData in + guard let self = self else { return } + + Logger.log( + message: """ + 필터 저장: + 📍 위치: \(filterData.locations) + 🏷️ 카테고리: \(filterData.categories) + """, + category: .debug + ) + + 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 } - print("MapVC onSave - filterType: \(filterType), options: \(selectedOptions)") - - self.reactor?.action.onNext(.filterUpdated(filterType, selectedOptions)) + self.reactor?.action.onNext(.updateBothFilters( + locations: filterData.locations, + categories: filterData.categories + )) self.reactor?.action.onNext(.filterTapped(nil)) } @@ -212,81 +520,49 @@ final class MapViewController: BaseViewController, View { currentFilterBottomSheet = viewController } - private func dismissFilterBottomSheet() { if let bottomSheet = currentFilterBottomSheet { bottomSheet.hideBottomSheet() } 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) + 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에 추가 } } - - func floatingPanelDidChangeState(_ fpc: FloatingPanelController) { - switch fpc.state { - case .full: - transitionToFullScreen(fpc: fpc) - case .half, .tip: - restoreMapView(fpc: fpc) - default: - break - } + private func updateListView(with results: [MapPopUpStore]) { + // MapPopUpStore 배열을 StoreItem 배열로 변환 + let storeItems = results.map { $0.toStoreItem() } + storeListViewController.reactor?.action.onNext(.setStores(storeItems)) } - 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 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) } - 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: - Location + private func checkLocationAuthorization() { + switch locationManager.authorizationStatus { + case .notDetermined: + locationManager.requestWhenInUseAuthorization() + case .authorizedWhenInUse, .authorizedAlways: + locationManager.startUpdatingLocation() + case .denied, .restricted: + Logger.log( + message: "위치 서비스가 비활성화되었습니다. 설정에서 권한을 확인해주세요.", + category: .error + ) + @unknown default: + break } } } @@ -297,8 +573,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( @@ -312,7 +588,9 @@ extension MapViewController: CLLocationManagerDelegate { longitude: location.coordinate.longitude, markerId: 0, markerTitle: "현재 위치", - markerSnippet: "현재 위치의 팝업스토어" + markerSnippet: "현재 위치의 팝업스토어", + mainImageUrl: "https://example.com/image1.jpg" // 이미지 URL 추가 + ) addMarker(for: currentLocationStore) @@ -323,8 +601,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: "카페", @@ -336,7 +612,9 @@ extension MapViewController: GMSMapViewDelegate { longitude: 126.9780, markerId: 1, markerTitle: "서울", - markerSnippet: "팝업스토어" + markerSnippet: "팝업스토어", + mainImageUrl: "https://example.com/image1.jpg" // 이미지 URL 추가 + ) let dummyStore2 = MapPopUpStore( id: 2, @@ -349,7 +627,9 @@ extension MapViewController: GMSMapViewDelegate { longitude: 127.0276, markerId: 2, markerTitle: "강남", - markerSnippet: "전시 팝업스토어" + markerSnippet: "전시 팝업스토어", + mainImageUrl: "https://example.com/image1.jpg" // 이미지 URL 추가 + ) carouselView.updateCards([dummyStore1, dummyStore2]) @@ -358,20 +638,72 @@ extension MapViewController: GMSMapViewDelegate { return true } } - extension MapViewController { - private func checkLocationAuthorization() { - let status = CLLocationManager.authorizationStatus() + 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) - switch status { - case .notDetermined: - locationManager.requestWhenInUseAuthorization() - case .authorizedWhenInUse, .authorizedAlways: - locationManager.startUpdatingLocation() - case .denied, .restricted: - print("위치 서비스가 비활성화되었습니다. 설정에서 권한을 확인해주세요.") - @unknown default: - break + // 스토어 업데이트 + 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/StoreListPanelLayout.swift b/Poppool/Poppool/Presentation/Map/StoreListPanelLayout.swift index eb03c008..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: 0, 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..8bef63ea 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListCell.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListCell.swift @@ -89,29 +89,40 @@ 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 extension StoreListCell: Inputable { struct Input { - let thumbnailImage: UIImage? + let thumbnailURL: String let category: String let title: String let location: String @@ -120,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/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/StoreListReactor.swift b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListReactor.swift index 727ac63c..af99b1af 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 6722225e..a904223f 100644 --- a/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift +++ b/Poppool/Poppool/Presentation/Map/StoreListView/StoreListView.swift @@ -4,21 +4,31 @@ 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 + view.isUserInteractionEnabled = true + return view + }() + + private let paddingView: UIView = { + let view = UIView() + view.backgroundColor = .clear // 간격만 추가하므로 투명 + return view + }() // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) + configureLayer() // 최상단 레이어 설정 setUpConstraints() } @@ -45,11 +55,32 @@ private extension StoreListView { } func setUpConstraints() { - backgroundColor = .clear + backgroundColor = .white addSubview(collectionView) + addSubview(grabberHandle) + + grabberHandle.snp.makeConstraints { make in + make.top.equalToSuperview().offset(14).priority(.high) + make.centerX.equalToSuperview() + make.width.equalTo(36) + make.height.equalTo(5) + } +// paddingView.snp.makeConstraints { make in +// make.top.equalTo(grabberHandle.snp.bottom) +// make.leading.trailing.equalToSuperview() +// +// } collectionView.snp.makeConstraints { make in - make.edges.equalToSuperview() + make.top.equalTo(grabberHandle.snp.bottom).offset(8).priority(.medium) + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview() // bottom 제약 다시 추가 } } + + func configureLayer() { + 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 6584edd5..2c214d86 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() { @@ -47,65 +45,35 @@ 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) { - 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 - }, - // 헤더 설정 - 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 - } - ) + 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( + thumbnailURL: item.thumbnailURL, + 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 @@ -146,7 +114,6 @@ final class StoreListViewController: UIViewController, View { } private func presentFilterBottomSheet(for filterType: FilterType) { - // FilterBottomSheetReactor (ex) let sheetReactor = FilterBottomSheetReactor() let viewController = FilterBottomSheetViewController(reactor: sheetReactor) @@ -158,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)) } @@ -175,53 +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: - 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 - 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/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) + } +} diff --git a/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift b/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift index 6c8c2ce9..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,7 +117,7 @@ extension DetailController { } .bind(to: reactor.action) .disposed(by: disposeBag) - + reactor.state .withUnretained(self) .subscribe { (owner, state) in @@ -125,7 +125,7 @@ extension DetailController { owner.mainView.contentCollectionView.reloadData() } .disposed(by: disposeBag) - + reactor.state .withUnretained(self) .take(2) @@ -150,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 @@ -176,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 @@ -209,7 +209,7 @@ extension DetailController: UICollectionViewDelegate, UICollectionViewDataSource } .disposed(by: cell.disposeBag) } - + if let cell = cell as? DetailCommentTitleSectionCell { cell.totalViewButton.rx.tap .withUnretained(self) @@ -219,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) @@ -228,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 @@ -236,7 +236,7 @@ extension DetailController: UICollectionViewDelegate, UICollectionViewDataSource } .bind(to: reactor.action) .disposed(by: cell.disposeBag) - + cell.totalViewButton.rx.tap .withUnretained(self) .map { (owner, _) in @@ -244,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 @@ -266,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 b9c790a8..0451460a 100644 --- a/Poppool/Poppool/Presentation/Scene/MyPage/Main/MyPageReactor.swift +++ b/Poppool/Poppool/Presentation/Scene/MyPage/Main/MyPageReactor.swift @@ -6,13 +6,12 @@ // import UIKit - import ReactorKit import RxSwift import RxCocoa final class MyPageReactor: Reactor { - + // MARK: - Reactor enum Action { case viewWillAppear @@ -22,8 +21,9 @@ final class MyPageReactor: Reactor { case commentButtonTapped(controller: BaseViewController) case listCellTapped(controller: BaseViewController, title: String?) case logoutButtonTapped + case adminMenuTapped(controller: BaseViewController) // ← 관리자 메뉴 액션 추가 } - + enum Mutation { case loadView case moveToProfileEditScene(controller: BaseViewController) @@ -32,35 +32,38 @@ final class MyPageReactor: Reactor { case moveToPopUpDetailScene(controller: BaseViewController, row: Int) case moveToLoginScene(controller: BaseViewController) case moveToMyCommentScene(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 { - 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: []) @@ -80,39 +83,43 @@ final class MyPageReactor: Reactor { private var etcSection = MyPageListSection(inputDataList: [ .init(title: "회원탈퇴") ]) - + + /// 관리자 모드용 etcSection 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 { + 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, @@ -121,37 +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): - return Observable.just(.moveToDetailScene(controller: controller, title: title)) + // 일반 리스트 셀 탭 + // 만약 "관리자 메뉴 바로가기"라면 adminScene으로 이동 + if title == "관리자 메뉴 바로가기" { + return .just(.moveToAdminScene(controller: controller)) + } else { + return .just(.moveToDetailScene(controller: controller, title: title)) + } + case .logoutButtonTapped: + // 로그아웃 API return userAPIUseCase.postLogout() .andThen(Observable.just(.logout)) + + 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) @@ -160,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 { @@ -185,59 +214,83 @@ 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 .moveToMyCommentScene(let controller): let nextController = MyCommentController() nextController.reactor = MyCommentReactor() controller.navigationController?.pushViewController(nextController, animated: true) - case .moveToPopUpDetailScene(let controller, let row): - let nextController = DetailController() - nextController.reactor = DetailReactor(popUpID: commentSection.inputDataList[row].popUpID) - controller.navigationController?.pushViewController(nextController, animated: true) + + case .moveToAdminScene(let controller): + // 관리자 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) } + + // 배경 프로필 이미지 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] } - + func getCommentSection() -> [any Sectionable] { if !isLogin { return [] } if commentSection.isEmpty { @@ -253,7 +306,7 @@ final class MyPageReactor: Reactor { ] } } - + func getNormalSection() -> [any Sectionable] { if isLogin { return [ @@ -266,7 +319,7 @@ final class MyPageReactor: Reactor { return [] } } - + func getInfoSection() -> [any Sectionable] { if isLogin { return [ @@ -286,29 +339,32 @@ 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] } } 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 00000000..f50420e5 Binary files /dev/null and b/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/adminlist.imageset/adminlist.png differ 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 00000000..5be472cf Binary files /dev/null and b/Poppool/Poppool/Resource/Assets.xcassets/Image/ico/date.imageset/date.png differ 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 00000000..5be472cf Binary files /dev/null and b/Poppool/Poppool/Resource/Assets.xcassets/date.imageset/date.png differ 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 + } +} diff --git a/Poppool/Poppool/Resource/Info.plist b/Poppool/Poppool/Resource/Info.plist index 6bac1689..8b3a2d0d 100644 --- a/Poppool/Poppool/Resource/Info.plist +++ b/Poppool/Poppool/Resource/Info.plist @@ -60,7 +60,7 @@ NSLocationWhenInUseUsageDescription 앱이 사용자의 현재 위치를 확인하기 위해 위치 권한이 필요합니다 - CFBundleIdentifier +