From b368639e96b65ae3f1de509436f6b40f652a3257 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 29 Aug 2026 04:37:10 +0900 Subject: [PATCH 001/123] =?UTF-8?q?chore:=20./Core/View=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=20=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=20UIKit,=20Swi?= =?UTF-8?q?ftUI=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...llableInfiniteCarouselCollectionView.swift | 0 ...finiteCarouselCollectionViewProtocol.swift | 0 .../BottomSheetViewController.swift | 0 .../BottomSheetViewControllerB.swift | 0 .../{ => UIKit}/CancelableImageView.swift | 0 .../CustomNavigationController.swift | 0 .../View/{ => UIKit}/DebouncedButton.swift | 0 .../View/{ => UIKit}/DefaultTextField.swift | 0 .../{ => UIKit}/ExtendedTouchAreaView.swift | 0 .../Core/View/{ => UIKit}/IndicatorView.swift | 0 Koin/Core/View/{ => UIKit}/InsetLabel.swift | 0 .../Helper/KoinDropdownAnimator.swift | 0 .../KoinDropdown/KoinDropdown.swift | 0 .../KoinDropdownConfiguration.swift | 0 .../KoinDropdown/KoinDropdownHost.swift | 0 .../KoinModalConfiguration.swift | 0 .../Configuration/KoinModalStyle.swift | 0 .../KoinModalViewController.swift | 0 .../Subviews/ModalButtonView.swift | 0 .../Subviews/ModalContentView.swift | 0 .../Transition/KoinModalAnimator.swift | 0 .../KoinModalPresentationController.swift | 0 .../KoinPickerDropDownViewDateDelegate.swift | 0 .../KoinPickerDropDownViewTimeDelegate.swift | 0 .../KoinPickerDropDownView.swift | 0 .../Subviews/KoinPickerViewB.swift | 0 .../View/{ => UIKit}/KoinPickerView.swift | 0 .../EmptyStateView.swift | 0 .../FilteringButton.swift | 0 .../OrderFloatingButton.swift | 0 .../OrderHistoryCustomSearchBar.swift | 0 Koin/Core/View/{ => UIKit}/PaddingLabel.swift | 0 .../View/{ => UIKit}/StatefulButton.swift | 0 .../View/{ => UIKit}/TrackPaddedSlider.swift | 0 .../Subviews/ZoomingCollectionView.swift | 0 .../Subviews/ZoomingCollectionViewCell.swift | 0 .../ZoomedImageViewController.swift | 0 .../Subviews/ZoomedImageCollectionView.swift | 0 .../ZoomedImageCollectionViewCell.swift | 0 .../ZoomedImageRootViewController.swift | 0 .../ZoomedImageViewControllerB.swift | 0 koin.xcodeproj/project.pbxproj | 56 ++++++++++++------- 42 files changed, 36 insertions(+), 20 deletions(-) rename Koin/Core/View/{ => UIKit}/AutoScrollableInfiniteCarouselCollectionView/AutoScrollableInfiniteCarouselCollectionView.swift (100%) rename Koin/Core/View/{ => UIKit}/AutoScrollableInfiniteCarouselCollectionView/AutoScrollableInfiniteCarouselCollectionViewProtocol.swift (100%) rename Koin/Core/View/{ => UIKit}/BottomSheetViewController.swift (100%) rename Koin/Core/View/{ => UIKit}/BottomSheetViewControllerB.swift (100%) rename Koin/Core/View/{ => UIKit}/CancelableImageView.swift (100%) rename Koin/Core/View/{ => UIKit}/CustomNavigationController.swift (100%) rename Koin/Core/View/{ => UIKit}/DebouncedButton.swift (100%) rename Koin/Core/View/{ => UIKit}/DefaultTextField.swift (100%) rename Koin/Core/View/{ => UIKit}/ExtendedTouchAreaView.swift (100%) rename Koin/Core/View/{ => UIKit}/IndicatorView.swift (100%) rename Koin/Core/View/{ => UIKit}/InsetLabel.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinDropdown/Helper/KoinDropdownAnimator.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinDropdown/KoinDropdown.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinDropdown/KoinDropdownConfiguration.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinDropdown/KoinDropdownHost.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinModalViewController/Configuration/KoinModalConfiguration.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinModalViewController/Configuration/KoinModalStyle.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinModalViewController/KoinModalViewController.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinModalViewController/Subviews/ModalButtonView.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinModalViewController/Subviews/ModalContentView.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinModalViewController/Transition/KoinModalAnimator.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinModalViewController/Transition/KoinModalPresentationController.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewTimeDelegate.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinPickerDropDownView/KoinPickerDropDownView.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinPickerDropDownView/Subviews/KoinPickerViewB.swift (100%) rename Koin/Core/View/{ => UIKit}/KoinPickerView.swift (100%) rename Koin/Core/View/{ => UIKit}/OrderHistoryUIComponents/EmptyStateView.swift (100%) rename Koin/Core/View/{ => UIKit}/OrderHistoryUIComponents/FilteringButton.swift (100%) rename Koin/Core/View/{ => UIKit}/OrderHistoryUIComponents/OrderFloatingButton.swift (100%) rename Koin/Core/View/{ => UIKit}/OrderHistoryUIComponents/OrderHistoryCustomSearchBar.swift (100%) rename Koin/Core/View/{ => UIKit}/PaddingLabel.swift (100%) rename Koin/Core/View/{ => UIKit}/StatefulButton.swift (100%) rename Koin/Core/View/{ => UIKit}/TrackPaddedSlider.swift (100%) rename Koin/Core/View/{ => UIKit}/ZoomedImageViewController/Subviews/ZoomingCollectionView.swift (100%) rename Koin/Core/View/{ => UIKit}/ZoomedImageViewController/Subviews/ZoomingCollectionViewCell.swift (100%) rename Koin/Core/View/{ => UIKit}/ZoomedImageViewController/ZoomedImageViewController.swift (100%) rename Koin/Core/View/{ => UIKit}/ZoomedImageViewControllerB/Subviews/ZoomedImageCollectionView.swift (100%) rename Koin/Core/View/{ => UIKit}/ZoomedImageViewControllerB/Subviews/ZoomedImageCollectionViewCell.swift (100%) rename Koin/Core/View/{ => UIKit}/ZoomedImageViewControllerB/Subviews/ZoomedImageRootViewController.swift (100%) rename Koin/Core/View/{ => UIKit}/ZoomedImageViewControllerB/ZoomedImageViewControllerB.swift (100%) diff --git a/Koin/Core/View/AutoScrollableInfiniteCarouselCollectionView/AutoScrollableInfiniteCarouselCollectionView.swift b/Koin/Core/View/UIKit/AutoScrollableInfiniteCarouselCollectionView/AutoScrollableInfiniteCarouselCollectionView.swift similarity index 100% rename from Koin/Core/View/AutoScrollableInfiniteCarouselCollectionView/AutoScrollableInfiniteCarouselCollectionView.swift rename to Koin/Core/View/UIKit/AutoScrollableInfiniteCarouselCollectionView/AutoScrollableInfiniteCarouselCollectionView.swift diff --git a/Koin/Core/View/AutoScrollableInfiniteCarouselCollectionView/AutoScrollableInfiniteCarouselCollectionViewProtocol.swift b/Koin/Core/View/UIKit/AutoScrollableInfiniteCarouselCollectionView/AutoScrollableInfiniteCarouselCollectionViewProtocol.swift similarity index 100% rename from Koin/Core/View/AutoScrollableInfiniteCarouselCollectionView/AutoScrollableInfiniteCarouselCollectionViewProtocol.swift rename to Koin/Core/View/UIKit/AutoScrollableInfiniteCarouselCollectionView/AutoScrollableInfiniteCarouselCollectionViewProtocol.swift diff --git a/Koin/Core/View/BottomSheetViewController.swift b/Koin/Core/View/UIKit/BottomSheetViewController.swift similarity index 100% rename from Koin/Core/View/BottomSheetViewController.swift rename to Koin/Core/View/UIKit/BottomSheetViewController.swift diff --git a/Koin/Core/View/BottomSheetViewControllerB.swift b/Koin/Core/View/UIKit/BottomSheetViewControllerB.swift similarity index 100% rename from Koin/Core/View/BottomSheetViewControllerB.swift rename to Koin/Core/View/UIKit/BottomSheetViewControllerB.swift diff --git a/Koin/Core/View/CancelableImageView.swift b/Koin/Core/View/UIKit/CancelableImageView.swift similarity index 100% rename from Koin/Core/View/CancelableImageView.swift rename to Koin/Core/View/UIKit/CancelableImageView.swift diff --git a/Koin/Core/View/CustomNavigationController.swift b/Koin/Core/View/UIKit/CustomNavigationController.swift similarity index 100% rename from Koin/Core/View/CustomNavigationController.swift rename to Koin/Core/View/UIKit/CustomNavigationController.swift diff --git a/Koin/Core/View/DebouncedButton.swift b/Koin/Core/View/UIKit/DebouncedButton.swift similarity index 100% rename from Koin/Core/View/DebouncedButton.swift rename to Koin/Core/View/UIKit/DebouncedButton.swift diff --git a/Koin/Core/View/DefaultTextField.swift b/Koin/Core/View/UIKit/DefaultTextField.swift similarity index 100% rename from Koin/Core/View/DefaultTextField.swift rename to Koin/Core/View/UIKit/DefaultTextField.swift diff --git a/Koin/Core/View/ExtendedTouchAreaView.swift b/Koin/Core/View/UIKit/ExtendedTouchAreaView.swift similarity index 100% rename from Koin/Core/View/ExtendedTouchAreaView.swift rename to Koin/Core/View/UIKit/ExtendedTouchAreaView.swift diff --git a/Koin/Core/View/IndicatorView.swift b/Koin/Core/View/UIKit/IndicatorView.swift similarity index 100% rename from Koin/Core/View/IndicatorView.swift rename to Koin/Core/View/UIKit/IndicatorView.swift diff --git a/Koin/Core/View/InsetLabel.swift b/Koin/Core/View/UIKit/InsetLabel.swift similarity index 100% rename from Koin/Core/View/InsetLabel.swift rename to Koin/Core/View/UIKit/InsetLabel.swift diff --git a/Koin/Core/View/KoinDropdown/Helper/KoinDropdownAnimator.swift b/Koin/Core/View/UIKit/KoinDropdown/Helper/KoinDropdownAnimator.swift similarity index 100% rename from Koin/Core/View/KoinDropdown/Helper/KoinDropdownAnimator.swift rename to Koin/Core/View/UIKit/KoinDropdown/Helper/KoinDropdownAnimator.swift diff --git a/Koin/Core/View/KoinDropdown/KoinDropdown.swift b/Koin/Core/View/UIKit/KoinDropdown/KoinDropdown.swift similarity index 100% rename from Koin/Core/View/KoinDropdown/KoinDropdown.swift rename to Koin/Core/View/UIKit/KoinDropdown/KoinDropdown.swift diff --git a/Koin/Core/View/KoinDropdown/KoinDropdownConfiguration.swift b/Koin/Core/View/UIKit/KoinDropdown/KoinDropdownConfiguration.swift similarity index 100% rename from Koin/Core/View/KoinDropdown/KoinDropdownConfiguration.swift rename to Koin/Core/View/UIKit/KoinDropdown/KoinDropdownConfiguration.swift diff --git a/Koin/Core/View/KoinDropdown/KoinDropdownHost.swift b/Koin/Core/View/UIKit/KoinDropdown/KoinDropdownHost.swift similarity index 100% rename from Koin/Core/View/KoinDropdown/KoinDropdownHost.swift rename to Koin/Core/View/UIKit/KoinDropdown/KoinDropdownHost.swift diff --git a/Koin/Core/View/KoinModalViewController/Configuration/KoinModalConfiguration.swift b/Koin/Core/View/UIKit/KoinModalViewController/Configuration/KoinModalConfiguration.swift similarity index 100% rename from Koin/Core/View/KoinModalViewController/Configuration/KoinModalConfiguration.swift rename to Koin/Core/View/UIKit/KoinModalViewController/Configuration/KoinModalConfiguration.swift diff --git a/Koin/Core/View/KoinModalViewController/Configuration/KoinModalStyle.swift b/Koin/Core/View/UIKit/KoinModalViewController/Configuration/KoinModalStyle.swift similarity index 100% rename from Koin/Core/View/KoinModalViewController/Configuration/KoinModalStyle.swift rename to Koin/Core/View/UIKit/KoinModalViewController/Configuration/KoinModalStyle.swift diff --git a/Koin/Core/View/KoinModalViewController/KoinModalViewController.swift b/Koin/Core/View/UIKit/KoinModalViewController/KoinModalViewController.swift similarity index 100% rename from Koin/Core/View/KoinModalViewController/KoinModalViewController.swift rename to Koin/Core/View/UIKit/KoinModalViewController/KoinModalViewController.swift diff --git a/Koin/Core/View/KoinModalViewController/Subviews/ModalButtonView.swift b/Koin/Core/View/UIKit/KoinModalViewController/Subviews/ModalButtonView.swift similarity index 100% rename from Koin/Core/View/KoinModalViewController/Subviews/ModalButtonView.swift rename to Koin/Core/View/UIKit/KoinModalViewController/Subviews/ModalButtonView.swift diff --git a/Koin/Core/View/KoinModalViewController/Subviews/ModalContentView.swift b/Koin/Core/View/UIKit/KoinModalViewController/Subviews/ModalContentView.swift similarity index 100% rename from Koin/Core/View/KoinModalViewController/Subviews/ModalContentView.swift rename to Koin/Core/View/UIKit/KoinModalViewController/Subviews/ModalContentView.swift diff --git a/Koin/Core/View/KoinModalViewController/Transition/KoinModalAnimator.swift b/Koin/Core/View/UIKit/KoinModalViewController/Transition/KoinModalAnimator.swift similarity index 100% rename from Koin/Core/View/KoinModalViewController/Transition/KoinModalAnimator.swift rename to Koin/Core/View/UIKit/KoinModalViewController/Transition/KoinModalAnimator.swift diff --git a/Koin/Core/View/KoinModalViewController/Transition/KoinModalPresentationController.swift b/Koin/Core/View/UIKit/KoinModalViewController/Transition/KoinModalPresentationController.swift similarity index 100% rename from Koin/Core/View/KoinModalViewController/Transition/KoinModalPresentationController.swift rename to Koin/Core/View/UIKit/KoinModalViewController/Transition/KoinModalPresentationController.swift diff --git a/Koin/Core/View/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift b/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift similarity index 100% rename from Koin/Core/View/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift rename to Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift diff --git a/Koin/Core/View/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewTimeDelegate.swift b/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewTimeDelegate.swift similarity index 100% rename from Koin/Core/View/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewTimeDelegate.swift rename to Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewTimeDelegate.swift diff --git a/Koin/Core/View/KoinPickerDropDownView/KoinPickerDropDownView.swift b/Koin/Core/View/UIKit/KoinPickerDropDownView/KoinPickerDropDownView.swift similarity index 100% rename from Koin/Core/View/KoinPickerDropDownView/KoinPickerDropDownView.swift rename to Koin/Core/View/UIKit/KoinPickerDropDownView/KoinPickerDropDownView.swift diff --git a/Koin/Core/View/KoinPickerDropDownView/Subviews/KoinPickerViewB.swift b/Koin/Core/View/UIKit/KoinPickerDropDownView/Subviews/KoinPickerViewB.swift similarity index 100% rename from Koin/Core/View/KoinPickerDropDownView/Subviews/KoinPickerViewB.swift rename to Koin/Core/View/UIKit/KoinPickerDropDownView/Subviews/KoinPickerViewB.swift diff --git a/Koin/Core/View/KoinPickerView.swift b/Koin/Core/View/UIKit/KoinPickerView.swift similarity index 100% rename from Koin/Core/View/KoinPickerView.swift rename to Koin/Core/View/UIKit/KoinPickerView.swift diff --git a/Koin/Core/View/OrderHistoryUIComponents/EmptyStateView.swift b/Koin/Core/View/UIKit/OrderHistoryUIComponents/EmptyStateView.swift similarity index 100% rename from Koin/Core/View/OrderHistoryUIComponents/EmptyStateView.swift rename to Koin/Core/View/UIKit/OrderHistoryUIComponents/EmptyStateView.swift diff --git a/Koin/Core/View/OrderHistoryUIComponents/FilteringButton.swift b/Koin/Core/View/UIKit/OrderHistoryUIComponents/FilteringButton.swift similarity index 100% rename from Koin/Core/View/OrderHistoryUIComponents/FilteringButton.swift rename to Koin/Core/View/UIKit/OrderHistoryUIComponents/FilteringButton.swift diff --git a/Koin/Core/View/OrderHistoryUIComponents/OrderFloatingButton.swift b/Koin/Core/View/UIKit/OrderHistoryUIComponents/OrderFloatingButton.swift similarity index 100% rename from Koin/Core/View/OrderHistoryUIComponents/OrderFloatingButton.swift rename to Koin/Core/View/UIKit/OrderHistoryUIComponents/OrderFloatingButton.swift diff --git a/Koin/Core/View/OrderHistoryUIComponents/OrderHistoryCustomSearchBar.swift b/Koin/Core/View/UIKit/OrderHistoryUIComponents/OrderHistoryCustomSearchBar.swift similarity index 100% rename from Koin/Core/View/OrderHistoryUIComponents/OrderHistoryCustomSearchBar.swift rename to Koin/Core/View/UIKit/OrderHistoryUIComponents/OrderHistoryCustomSearchBar.swift diff --git a/Koin/Core/View/PaddingLabel.swift b/Koin/Core/View/UIKit/PaddingLabel.swift similarity index 100% rename from Koin/Core/View/PaddingLabel.swift rename to Koin/Core/View/UIKit/PaddingLabel.swift diff --git a/Koin/Core/View/StatefulButton.swift b/Koin/Core/View/UIKit/StatefulButton.swift similarity index 100% rename from Koin/Core/View/StatefulButton.swift rename to Koin/Core/View/UIKit/StatefulButton.swift diff --git a/Koin/Core/View/TrackPaddedSlider.swift b/Koin/Core/View/UIKit/TrackPaddedSlider.swift similarity index 100% rename from Koin/Core/View/TrackPaddedSlider.swift rename to Koin/Core/View/UIKit/TrackPaddedSlider.swift diff --git a/Koin/Core/View/ZoomedImageViewController/Subviews/ZoomingCollectionView.swift b/Koin/Core/View/UIKit/ZoomedImageViewController/Subviews/ZoomingCollectionView.swift similarity index 100% rename from Koin/Core/View/ZoomedImageViewController/Subviews/ZoomingCollectionView.swift rename to Koin/Core/View/UIKit/ZoomedImageViewController/Subviews/ZoomingCollectionView.swift diff --git a/Koin/Core/View/ZoomedImageViewController/Subviews/ZoomingCollectionViewCell.swift b/Koin/Core/View/UIKit/ZoomedImageViewController/Subviews/ZoomingCollectionViewCell.swift similarity index 100% rename from Koin/Core/View/ZoomedImageViewController/Subviews/ZoomingCollectionViewCell.swift rename to Koin/Core/View/UIKit/ZoomedImageViewController/Subviews/ZoomingCollectionViewCell.swift diff --git a/Koin/Core/View/ZoomedImageViewController/ZoomedImageViewController.swift b/Koin/Core/View/UIKit/ZoomedImageViewController/ZoomedImageViewController.swift similarity index 100% rename from Koin/Core/View/ZoomedImageViewController/ZoomedImageViewController.swift rename to Koin/Core/View/UIKit/ZoomedImageViewController/ZoomedImageViewController.swift diff --git a/Koin/Core/View/ZoomedImageViewControllerB/Subviews/ZoomedImageCollectionView.swift b/Koin/Core/View/UIKit/ZoomedImageViewControllerB/Subviews/ZoomedImageCollectionView.swift similarity index 100% rename from Koin/Core/View/ZoomedImageViewControllerB/Subviews/ZoomedImageCollectionView.swift rename to Koin/Core/View/UIKit/ZoomedImageViewControllerB/Subviews/ZoomedImageCollectionView.swift diff --git a/Koin/Core/View/ZoomedImageViewControllerB/Subviews/ZoomedImageCollectionViewCell.swift b/Koin/Core/View/UIKit/ZoomedImageViewControllerB/Subviews/ZoomedImageCollectionViewCell.swift similarity index 100% rename from Koin/Core/View/ZoomedImageViewControllerB/Subviews/ZoomedImageCollectionViewCell.swift rename to Koin/Core/View/UIKit/ZoomedImageViewControllerB/Subviews/ZoomedImageCollectionViewCell.swift diff --git a/Koin/Core/View/ZoomedImageViewControllerB/Subviews/ZoomedImageRootViewController.swift b/Koin/Core/View/UIKit/ZoomedImageViewControllerB/Subviews/ZoomedImageRootViewController.swift similarity index 100% rename from Koin/Core/View/ZoomedImageViewControllerB/Subviews/ZoomedImageRootViewController.swift rename to Koin/Core/View/UIKit/ZoomedImageViewControllerB/Subviews/ZoomedImageRootViewController.swift diff --git a/Koin/Core/View/ZoomedImageViewControllerB/ZoomedImageViewControllerB.swift b/Koin/Core/View/UIKit/ZoomedImageViewControllerB/ZoomedImageViewControllerB.swift similarity index 100% rename from Koin/Core/View/ZoomedImageViewControllerB/ZoomedImageViewControllerB.swift rename to Koin/Core/View/UIKit/ZoomedImageViewControllerB/ZoomedImageViewControllerB.swift diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index c01a4cdd3..500f92f8d 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -2591,6 +2591,40 @@ path = Bus; sourceTree = ""; }; + 7C50A1DA3042013B00897A6F /* UIKit */ = { + isa = PBXGroup; + children = ( + 7CED8ED32EF2D1F900457128 /* CancelableImageView.swift */, + 7CC42B062F56A7E900940CE1 /* ZoomedImageViewController */, + 7CA0D6172EC744E700E9A282 /* ZoomedImageViewControllerB */, + 7C333E7A302E280C009D2B89 /* KoinModalViewController */, + D2FB23442BFB6D090098BA2B /* CustomNavigationController.swift */, + D20AB96E2C574291006BC684 /* BottomSheetViewController.swift */, + 7CC42B822F5D307C00940CE1 /* BottomSheetViewControllerB.swift */, + D27A5D992C6B97D200C4275F /* InsetLabel.swift */, + 7CC42B042F56A78600940CE1 /* PaddingLabel.swift */, + 83B7457C2C9B2EBB005868BD /* IndicatorView.swift */, + 83EBB8792D0FDFD700346018 /* KoinPickerView.swift */, + 7CC42B732F5AD39C00940CE1 /* KoinPickerDropDownView */, + FE01000000000000000031 /* KoinDropdown */, + D289873D2D21B30D00AA7285 /* DebouncedButton.swift */, + D89F8FD82DF0A8A4000265DC /* DefaultTextField.swift */, + D89F8FDA2DF12847000265DC /* StatefulButton.swift */, + D891F25E2E45DC9D006D1F41 /* TrackPaddedSlider.swift */, + 7C61D3502F22B344005FAC3A /* ExtendedTouchAreaView.swift */, + B608F0E52EB5DC2C0006F355 /* OrderHistoryUIComponents */, + 7C65FDCA2FE69A00007831CE /* AutoScrollableInfiniteCarouselCollectionView */, + ); + path = UIKit; + sourceTree = ""; + }; + 7C50A1DB3042014000897A6F /* SwiftUI */ = { + isa = PBXGroup; + children = ( + ); + path = SwiftUI; + sourceTree = ""; + }; 7C58FE842F5F9CC700A2737B /* CallVanData */ = { isa = PBXGroup; children = ( @@ -5307,26 +5341,8 @@ D2D380E42BA6EAE7009AD5A9 /* View */ = { isa = PBXGroup; children = ( - 7CED8ED32EF2D1F900457128 /* CancelableImageView.swift */, - 7CC42B062F56A7E900940CE1 /* ZoomedImageViewController */, - 7CA0D6172EC744E700E9A282 /* ZoomedImageViewControllerB */, - 7C333E7A302E280C009D2B89 /* KoinModalViewController */, - D2FB23442BFB6D090098BA2B /* CustomNavigationController.swift */, - D20AB96E2C574291006BC684 /* BottomSheetViewController.swift */, - 7CC42B822F5D307C00940CE1 /* BottomSheetViewControllerB.swift */, - D27A5D992C6B97D200C4275F /* InsetLabel.swift */, - 7CC42B042F56A78600940CE1 /* PaddingLabel.swift */, - 83B7457C2C9B2EBB005868BD /* IndicatorView.swift */, - 83EBB8792D0FDFD700346018 /* KoinPickerView.swift */, - 7CC42B732F5AD39C00940CE1 /* KoinPickerDropDownView */, - FE01000000000000000031 /* KoinDropdown */, - D289873D2D21B30D00AA7285 /* DebouncedButton.swift */, - D89F8FD82DF0A8A4000265DC /* DefaultTextField.swift */, - D89F8FDA2DF12847000265DC /* StatefulButton.swift */, - D891F25E2E45DC9D006D1F41 /* TrackPaddedSlider.swift */, - 7C61D3502F22B344005FAC3A /* ExtendedTouchAreaView.swift */, - B608F0E52EB5DC2C0006F355 /* OrderHistoryUIComponents */, - 7C65FDCA2FE69A00007831CE /* AutoScrollableInfiniteCarouselCollectionView */, + 7C50A1DA3042013B00897A6F /* UIKit */, + 7C50A1DB3042014000897A6F /* SwiftUI */, ); path = View; sourceTree = ""; From c3f6f2a32166afc2146b1041229709f9fd68c457 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 29 Aug 2026 04:37:52 +0900 Subject: [PATCH 002/123] =?UTF-8?q?add:=20SwiftUI=20LeftAlignedLayout=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/View/SwiftUI/LeftAlignedLayout.swift | 98 +++++++++++++++++++ koin.xcodeproj/project.pbxproj | 4 + 2 files changed, 102 insertions(+) create mode 100644 Koin/Core/View/SwiftUI/LeftAlignedLayout.swift diff --git a/Koin/Core/View/SwiftUI/LeftAlignedLayout.swift b/Koin/Core/View/SwiftUI/LeftAlignedLayout.swift new file mode 100644 index 000000000..987c5845f --- /dev/null +++ b/Koin/Core/View/SwiftUI/LeftAlignedLayout.swift @@ -0,0 +1,98 @@ +// +// LeftAlignedLayout.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import SwiftUI + +struct LeftAlignedLayout: Layout { + var interitemSpacing: CGFloat + var interlineSpacing: CGFloat + + init( + interitemSpacing: CGFloat, + interlineSpacing: CGFloat + ) { + self.interitemSpacing = interitemSpacing + self.interlineSpacing = interlineSpacing + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + layout(proposal: proposal, subviews: subviews).size + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let result = layout(proposal: proposal, subviews: subviews) + + for (index, position) in result.positions.enumerated() { + subviews[index].place( + at: CGPoint( + x: bounds.minX + position.x, + y: bounds.minY + position.y + ), + proposal: .unspecified + ) + } + } +} + +extension LeftAlignedLayout { + private func layout( + proposal: ProposedViewSize, + subviews: Subviews + ) -> ( + size: CGSize, + positions: [CGPoint] + ) { + let maxWidth = proposal.width ?? .infinity + + var positions: [CGPoint] = [] + + var x: CGFloat = 0 + var y: CGFloat = 0 + + var rowHeight: CGFloat = 0 + var contentWidth: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + + // 줄바꿈 판단 + if x > 0 && x + size.width > maxWidth { + x = 0 + y += rowHeight + interlineSpacing + rowHeight = 0 + } + + positions.append( + CGPoint( + x: x, + y: y + ) + ) + + x += size.width + interitemSpacing + rowHeight = max(rowHeight, size.height) + contentWidth = max(contentWidth, x) + } + + return ( + CGSize( + width: contentWidth, + height: y + rowHeight + ), + positions + ) + } +} diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 500f92f8d..bda6baec6 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -134,6 +134,7 @@ 7C4D5A5A2F01638800B40128 /* BusAreaSelectdViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C4D5A0D2F01638800B40128 /* BusAreaSelectdViewController.swift */; }; 7C4D5A5B2F01638800B40128 /* ManyBusTimetableCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C4D5A2E2F01638800B40128 /* ManyBusTimetableCollectionViewCell.swift */; }; 7C4D5A5D2F0163BA00B40128 /* DIContainer+Bus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C4D5A5C2F0163BA00B40128 /* DIContainer+Bus.swift */; }; + 7C50A1DD3042016300897A6F /* LeftAlignedLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1DC3042016300897A6F /* LeftAlignedLayout.swift */; }; 7C50F8DC300FC03B00A87BAE /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 7C50F8DB300FC03B00A87BAE /* AppIcon.icon */; }; 7C5583602F11778B00D3A980 /* ShopInfoHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C55835F2F11778B00D3A980 /* ShopInfoHeaderView.swift */; }; 7C5583622F11779400D3A980 /* ShopInfoFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C5583612F11779400D3A980 /* ShopInfoFooterView.swift */; }; @@ -1176,6 +1177,7 @@ 7C4D5A382F01638800B40128 /* BusTimetableDataViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusTimetableDataViewController.swift; sourceTree = ""; }; 7C4D5A392F01638800B40128 /* BusTimetableDataViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusTimetableDataViewModel.swift; sourceTree = ""; }; 7C4D5A5C2F0163BA00B40128 /* DIContainer+Bus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DIContainer+Bus.swift"; sourceTree = ""; }; + 7C50A1DC3042016300897A6F /* LeftAlignedLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftAlignedLayout.swift; sourceTree = ""; }; 7C50F8DB300FC03B00A87BAE /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; 7C55835F2F11778B00D3A980 /* ShopInfoHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopInfoHeaderView.swift; sourceTree = ""; }; 7C5583612F11779400D3A980 /* ShopInfoFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopInfoFooterView.swift; sourceTree = ""; }; @@ -2621,6 +2623,7 @@ 7C50A1DB3042014000897A6F /* SwiftUI */ = { isa = PBXGroup; children = ( + 7C50A1DC3042016300897A6F /* LeftAlignedLayout.swift */, ); path = SwiftUI; sourceTree = ""; @@ -6592,6 +6595,7 @@ 7C05D07B2FBC2A0100F4D825 /* ShopBenefitTableView.swift in Sources */, B68D2CE92EA505B800F3B479 /* ToastVariant.swift in Sources */, 7C4C714E2FBECB3D009D9875 /* SendDeviceTokenIfNeededUseCase.swift in Sources */, + 7C50A1DD3042016300897A6F /* LeftAlignedLayout.swift in Sources */, 7CA60D952F667AE4007C08C1 /* ReopenCallVanUseCase.swift in Sources */, D29F21D92C4E1AB800994554 /* CheckDuplicatedNicknameUseCase.swift in Sources */, D249CD232C12DB73000813F9 /* DiningRepository.swift in Sources */, From 1cc069e8425f12ea2644622ddd06a6108f116c8a Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 29 Aug 2026 04:40:55 +0900 Subject: [PATCH 003/123] =?UTF-8?q?chore:=20./Core/Extension/Asset?= =?UTF-8?q?=EC=9D=84=20./Core/Asset=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/{Extensions => }/Asset/ColorAsset.swift | 0 Koin/Core/{Extensions => }/Asset/FontAsset.swift | 0 Koin/Core/{Extensions => }/Asset/ImageAsset.swift | 0 Koin/Core/{Extensions => }/Asset/SFSymbols.swift | 0 Koin/Core/{Extensions => }/Asset/TimetableColorAsset.swift | 0 koin.xcodeproj/project.pbxproj | 2 +- 6 files changed, 1 insertion(+), 1 deletion(-) rename Koin/Core/{Extensions => }/Asset/ColorAsset.swift (100%) rename Koin/Core/{Extensions => }/Asset/FontAsset.swift (100%) rename Koin/Core/{Extensions => }/Asset/ImageAsset.swift (100%) rename Koin/Core/{Extensions => }/Asset/SFSymbols.swift (100%) rename Koin/Core/{Extensions => }/Asset/TimetableColorAsset.swift (100%) diff --git a/Koin/Core/Extensions/Asset/ColorAsset.swift b/Koin/Core/Asset/ColorAsset.swift similarity index 100% rename from Koin/Core/Extensions/Asset/ColorAsset.swift rename to Koin/Core/Asset/ColorAsset.swift diff --git a/Koin/Core/Extensions/Asset/FontAsset.swift b/Koin/Core/Asset/FontAsset.swift similarity index 100% rename from Koin/Core/Extensions/Asset/FontAsset.swift rename to Koin/Core/Asset/FontAsset.swift diff --git a/Koin/Core/Extensions/Asset/ImageAsset.swift b/Koin/Core/Asset/ImageAsset.swift similarity index 100% rename from Koin/Core/Extensions/Asset/ImageAsset.swift rename to Koin/Core/Asset/ImageAsset.swift diff --git a/Koin/Core/Extensions/Asset/SFSymbols.swift b/Koin/Core/Asset/SFSymbols.swift similarity index 100% rename from Koin/Core/Extensions/Asset/SFSymbols.swift rename to Koin/Core/Asset/SFSymbols.swift diff --git a/Koin/Core/Extensions/Asset/TimetableColorAsset.swift b/Koin/Core/Asset/TimetableColorAsset.swift similarity index 100% rename from Koin/Core/Extensions/Asset/TimetableColorAsset.swift rename to Koin/Core/Asset/TimetableColorAsset.swift diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index bda6baec6..b1d056c0c 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -5043,7 +5043,6 @@ D27DD0712BA608310081FD36 /* Extensions */ = { isa = PBXGroup; children = ( - 7C6790AB2FCC56FF0045E163 /* Asset */, 7C67906C2FCC48A60045E163 /* Common */, 7C67906D2FCC48A60045E163 /* SwiftUI */, 7C6790832FCC48A60045E163 /* UIKit */, @@ -5089,6 +5088,7 @@ B65422F52EA12BE700018F15 /* DesignSystem */, 8354BE482C7A0394009D4D7A /* CoreData */, D27DD0832BA608310081FD36 /* Workers */, + 7C6790AB2FCC56FF0045E163 /* Asset */, D27DD0712BA608310081FD36 /* Extensions */, D2D380E42BA6EAE7009AD5A9 /* View */, D27DD07E2BA608310081FD36 /* Logger */, From 80edce4aa7da0adb998b5e18cbd6c8932d79778e Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 29 Aug 2026 04:41:18 +0900 Subject: [PATCH 004/123] =?UTF-8?q?add:=20Image=20Asset=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Asset/ImageAsset.swift | 15 +++++++++++++++ .../Home/categoryRecruit.imageset/Contents.json | 12 ++++++++++++ .../categoryRecruit.imageset/categoryRecruit.svg | 8 ++++++++ .../Assets.xcassets/Recruit/Contents.json | 6 ++++++ .../Recruit/recruitBell.imageset/Contents.json | 12 ++++++++++++ .../Recruit/recruitBell.imageset/recruitBell.svg | 5 +++++ .../Recruit/recruitBellDot.imageset/Contents.json | 12 ++++++++++++ .../recruitBellDot.imageset/recruitBellDot.svg | 6 ++++++ .../Recruit/recruitDate.imageset/Contents.json | 12 ++++++++++++ .../Recruit/recruitDate.imageset/recruitDate.svg | 5 +++++ .../Recruit/recruitFilter.imageset/Contents.json | 12 ++++++++++++ .../recruitFilter.imageset/recruitFilter.svg | 8 ++++++++ .../Recruit/recruitFilterX.imageset/Contents.json | 12 ++++++++++++ .../recruitFilterX.imageset/recruitFilterX.svg | 3 +++ .../recruitLocation.imageset/Contents.json | 12 ++++++++++++ .../recruitLocation.imageset/recruitLocation.svg | 4 ++++ .../Recruit/recruitMember.imageset/Contents.json | 12 ++++++++++++ .../recruitMember.imageset/recruitMember.svg | 8 ++++++++ .../Contents.json | 12 ++++++++++++ .../recruitNotificationChat.svg | 6 ++++++ .../Contents.json | 12 ++++++++++++ .../recruitNotificationMember.svg | 8 ++++++++ .../Recruit/recruitPencil.imageset/Contents.json | 12 ++++++++++++ .../recruitPencil.imageset/recruitPencil.svg | 4 ++++ .../Recruit/recruitProfile.imageset/Contents.json | 12 ++++++++++++ .../recruitProfile.imageset/recruitProfile.svg | 8 ++++++++ .../Recruit/recruitSearch.imageset/Contents.json | 12 ++++++++++++ .../recruitSearch.imageset/recruitSearch.svg | 4 ++++ 28 files changed, 254 insertions(+) create mode 100644 Koin/Resources/Assets.xcassets/Home/categoryRecruit.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Home/categoryRecruit.imageset/categoryRecruit.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitBell.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitBell.imageset/recruitBell.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitBellDot.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitBellDot.imageset/recruitBellDot.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDate.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDate.imageset/recruitDate.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitFilter.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitFilter.imageset/recruitFilter.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitFilterX.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitFilterX.imageset/recruitFilterX.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitLocation.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitLocation.imageset/recruitLocation.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitMember.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitMember.imageset/recruitMember.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitNotificationChat.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitNotificationChat.imageset/recruitNotificationChat.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitNotificationMember.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitNotificationMember.imageset/recruitNotificationMember.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitPencil.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitPencil.imageset/recruitPencil.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitProfile.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitProfile.imageset/recruitProfile.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitSearch.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitSearch.imageset/recruitSearch.svg diff --git a/Koin/Core/Asset/ImageAsset.swift b/Koin/Core/Asset/ImageAsset.swift index faaeda9c7..15370fd51 100644 --- a/Koin/Core/Asset/ImageAsset.swift +++ b/Koin/Core/Asset/ImageAsset.swift @@ -214,6 +214,20 @@ public enum ImageAsset: String { case noticeNotLoginToolTip case noticeSearch + // MARK: - Recruit + case recruitBell + case recruitBellDot + case recruitDate + case recruitFilter + case recruitFilterX + case recruitLocation + case recruitMember + case recruitNotificationChat + case recruitNotificationMember + case recruitPencil + case recruitProfile + case recruitSearch + // MARK: - Shop case call case callBenefit @@ -265,6 +279,7 @@ public enum ImageAsset: String { case categoryFacility case categoryLand case categoryLostitem + case categoryRecruit case categoryShop case categoryTimetable case chevronRightRounded diff --git a/Koin/Resources/Assets.xcassets/Home/categoryRecruit.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Home/categoryRecruit.imageset/Contents.json new file mode 100644 index 000000000..b26f0aaca --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Home/categoryRecruit.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "categoryRecruit.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Home/categoryRecruit.imageset/categoryRecruit.svg b/Koin/Resources/Assets.xcassets/Home/categoryRecruit.imageset/categoryRecruit.svg new file mode 100644 index 000000000..2e945dbba --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Home/categoryRecruit.imageset/categoryRecruit.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitBell.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitBell.imageset/Contents.json new file mode 100644 index 000000000..18ade6fd4 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitBell.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitBell.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitBell.imageset/recruitBell.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitBell.imageset/recruitBell.svg new file mode 100644 index 000000000..a7773243f --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitBell.imageset/recruitBell.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitBellDot.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitBellDot.imageset/Contents.json new file mode 100644 index 000000000..ef7a36479 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitBellDot.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitBellDot.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitBellDot.imageset/recruitBellDot.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitBellDot.imageset/recruitBellDot.svg new file mode 100644 index 000000000..47c19cf66 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitBellDot.imageset/recruitBellDot.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDate.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitDate.imageset/Contents.json new file mode 100644 index 000000000..808fc9dac --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDate.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitDate.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDate.imageset/recruitDate.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitDate.imageset/recruitDate.svg new file mode 100644 index 000000000..b32cf7ca3 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDate.imageset/recruitDate.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitFilter.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitFilter.imageset/Contents.json new file mode 100644 index 000000000..b15870968 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitFilter.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitFilter.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitFilter.imageset/recruitFilter.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitFilter.imageset/recruitFilter.svg new file mode 100644 index 000000000..d034b3e5e --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitFilter.imageset/recruitFilter.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitFilterX.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitFilterX.imageset/Contents.json new file mode 100644 index 000000000..d3d1e9355 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitFilterX.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitFilterX.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitFilterX.imageset/recruitFilterX.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitFilterX.imageset/recruitFilterX.svg new file mode 100644 index 000000000..de38c8525 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitFilterX.imageset/recruitFilterX.svg @@ -0,0 +1,3 @@ + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitLocation.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitLocation.imageset/Contents.json new file mode 100644 index 000000000..e78529fe7 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitLocation.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitLocation.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitLocation.imageset/recruitLocation.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitLocation.imageset/recruitLocation.svg new file mode 100644 index 000000000..8b7127889 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitLocation.imageset/recruitLocation.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitMember.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitMember.imageset/Contents.json new file mode 100644 index 000000000..17daec9bf --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitMember.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitMember.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitMember.imageset/recruitMember.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitMember.imageset/recruitMember.svg new file mode 100644 index 000000000..1766c2bc8 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitMember.imageset/recruitMember.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationChat.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationChat.imageset/Contents.json new file mode 100644 index 000000000..5a7c10890 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationChat.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitNotificationChat.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationChat.imageset/recruitNotificationChat.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationChat.imageset/recruitNotificationChat.svg new file mode 100644 index 000000000..a34445bea --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationChat.imageset/recruitNotificationChat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationMember.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationMember.imageset/Contents.json new file mode 100644 index 000000000..9ed52f6be --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationMember.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitNotificationMember.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationMember.imageset/recruitNotificationMember.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationMember.imageset/recruitNotificationMember.svg new file mode 100644 index 000000000..82565de43 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationMember.imageset/recruitNotificationMember.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitPencil.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitPencil.imageset/Contents.json new file mode 100644 index 000000000..cbc2af0b7 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitPencil.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitPencil.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitPencil.imageset/recruitPencil.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitPencil.imageset/recruitPencil.svg new file mode 100644 index 000000000..0e9d857ca --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitPencil.imageset/recruitPencil.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitProfile.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitProfile.imageset/Contents.json new file mode 100644 index 000000000..6d4ca91b0 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitProfile.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitProfile.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitProfile.imageset/recruitProfile.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitProfile.imageset/recruitProfile.svg new file mode 100644 index 000000000..5c0a968fd --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitProfile.imageset/recruitProfile.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitSearch.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitSearch.imageset/Contents.json new file mode 100644 index 000000000..33f11fc10 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitSearch.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitSearch.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitSearch.imageset/recruitSearch.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitSearch.imageset/recruitSearch.svg new file mode 100644 index 000000000..637c9dadb --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitSearch.imageset/recruitSearch.svg @@ -0,0 +1,4 @@ + + + + From a09d5c6f6be3bcd919726e98c3b6325b22448fb0 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 30 Aug 2026 00:43:05 +0900 Subject: [PATCH 005/123] =?UTF-8?q?add:=20Array=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20Extension=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Extensions/Common/Array+.swift | 18 ++++++++++++++++++ Koin/Domain/Model/Core/ErrorResponse.swift | 2 ++ koin.xcodeproj/project.pbxproj | 4 ++++ 3 files changed, 24 insertions(+) create mode 100644 Koin/Core/Extensions/Common/Array+.swift diff --git a/Koin/Core/Extensions/Common/Array+.swift b/Koin/Core/Extensions/Common/Array+.swift new file mode 100644 index 000000000..8b3199611 --- /dev/null +++ b/Koin/Core/Extensions/Common/Array+.swift @@ -0,0 +1,18 @@ +// +// Array+.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +extension Array where Element: Equatable { + mutating func removeDuplicates() { + self = self.reduce(into: [Element]()) { array, item in + if !array.contains(item) { + array.append(item) + } + } + } +} diff --git a/Koin/Domain/Model/Core/ErrorResponse.swift b/Koin/Domain/Model/Core/ErrorResponse.swift index 02ec10f8f..a4e48ed53 100644 --- a/Koin/Domain/Model/Core/ErrorResponse.swift +++ b/Koin/Domain/Model/Core/ErrorResponse.swift @@ -41,4 +41,6 @@ extension ErrorResponse { static let fileManagerFailedDirectory = ErrorResponse(statusCode: nil, code: "FILEMANAGER_FAILED_DIRECTORY", message: "파일 저장 위치 찾기 실패") static let deleteKeywordError = ErrorResponse(statusCode: nil, code: "DELETE_KEYWORD_ERROR", message: "로그인에 실패하여 코어데이터에서 키워드 삭제") static let createKeywordError = ErrorResponse(statusCode: nil, code: "CREATE_KEYWORD_ERROR", message: "로그인에 실패하여 코어데이터에서 키워드 저장") + + static let dateFormatterFailedConvert = ErrorResponse(code: "DATEFORMATTER_FAILED_CONVERT", message: "날짜 변환 실패") } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index b1d056c0c..b55936fc2 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -135,6 +135,7 @@ 7C4D5A5B2F01638800B40128 /* ManyBusTimetableCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C4D5A2E2F01638800B40128 /* ManyBusTimetableCollectionViewCell.swift */; }; 7C4D5A5D2F0163BA00B40128 /* DIContainer+Bus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C4D5A5C2F0163BA00B40128 /* DIContainer+Bus.swift */; }; 7C50A1DD3042016300897A6F /* LeftAlignedLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1DC3042016300897A6F /* LeftAlignedLayout.swift */; }; + 7C50A20930431F9900897A6F /* Array+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20830431F9900897A6F /* Array+.swift */; }; 7C50F8DC300FC03B00A87BAE /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 7C50F8DB300FC03B00A87BAE /* AppIcon.icon */; }; 7C5583602F11778B00D3A980 /* ShopInfoHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C55835F2F11778B00D3A980 /* ShopInfoHeaderView.swift */; }; 7C5583622F11779400D3A980 /* ShopInfoFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C5583612F11779400D3A980 /* ShopInfoFooterView.swift */; }; @@ -1178,6 +1179,7 @@ 7C4D5A392F01638800B40128 /* BusTimetableDataViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusTimetableDataViewModel.swift; sourceTree = ""; }; 7C4D5A5C2F0163BA00B40128 /* DIContainer+Bus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DIContainer+Bus.swift"; sourceTree = ""; }; 7C50A1DC3042016300897A6F /* LeftAlignedLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftAlignedLayout.swift; sourceTree = ""; }; + 7C50A20830431F9900897A6F /* Array+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+.swift"; sourceTree = ""; }; 7C50F8DB300FC03B00A87BAE /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; 7C55835F2F11778B00D3A980 /* ShopInfoHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopInfoHeaderView.swift; sourceTree = ""; }; 7C5583612F11779400D3A980 /* ShopInfoFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopInfoFooterView.swift; sourceTree = ""; }; @@ -2755,6 +2757,7 @@ 7C8A94452FD50E8400DEA6F5 /* String+byCharWrapping.swift */, 7C67906B2FCC48A60045E163 /* TimeInterval+.swift */, 7C67909D2FCC48BF0045E163 /* Bundle+APIKey.swift */, + 7C50A20830431F9900897A6F /* Array+.swift */, ); path = Common; sourceTree = ""; @@ -6174,6 +6177,7 @@ 7CED90DD2EF2D1F900457128 /* AddDirectCollectionViewCell.swift in Sources */, 7CED90DF2EF2D1F900457128 /* LandDetailViewModel.swift in Sources */, 7CED90E02EF2D1F900457128 /* ShopSummaryTableViewCell.swift in Sources */, + 7C50A20930431F9900897A6F /* Array+.swift in Sources */, 7CED90E22EF2D1F900457128 /* PointLabel.swift in Sources */, 7CED90E32EF2D1F900457128 /* FindPhoneIdViewController.swift in Sources */, 7CED90E42EF2D1F900457128 /* SubstituteTimetableModalViewController.swift in Sources */, From 5cefd8309165af278d5547354896b9de51221700 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 30 Aug 2026 00:44:53 +0900 Subject: [PATCH 006/123] =?UTF-8?q?chore:=20NavigationBarStyle=20case=20or?= =?UTF-8?q?der=20=EB=A5=BC=20newBackground=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UIKit/UIViewController+NavigationBarStyle.swift | 8 ++++---- .../Department/DepartmentHostingController.swift | 2 +- .../DepartmentCategoryHostingController.swift | 2 +- .../Home/HomeTabBar/HomeTabbarController.swift | 2 +- Koin/Presentation/Shop/Shop/ShopViewController.swift | 2 +- .../Shop/ShopSearch/ShopSearchViewController.swift | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Koin/Core/Extensions/UIKit/UIViewController+NavigationBarStyle.swift b/Koin/Core/Extensions/UIKit/UIViewController+NavigationBarStyle.swift index 4a2c9c3f4..f2b01715f 100644 --- a/Koin/Core/Extensions/UIKit/UIViewController+NavigationBarStyle.swift +++ b/Koin/Core/Extensions/UIKit/UIViewController+NavigationBarStyle.swift @@ -12,7 +12,7 @@ extension UIViewController { enum NavigationBarStyle { case fill case empty - case order + case newBackground case transparentWhite case shuttleTicket @@ -22,7 +22,7 @@ extension UIViewController { return UIColor.appColor(.primary500) case .empty: return UIColor.appColor(.neutral0) - case .order: + case .newBackground: return UIColor.appColor(.newBackground) case .transparentWhite: return UIColor.clear @@ -35,7 +35,7 @@ extension UIViewController { switch self { case .fill, .transparentWhite: return UIColor.appColor(.neutral0) - case .empty, .order, .shuttleTicket: + case .empty, .newBackground, .shuttleTicket: return UIColor.appColor(.neutral800) } } @@ -48,7 +48,7 @@ extension UIViewController { switch self { case .fill, .transparentWhite: return UIColor.appColor(.neutral0) - case .empty, .order, .shuttleTicket: + case .empty, .newBackground, .shuttleTicket: return UIColor.appColor(.neutral800) } } diff --git a/Koin/Presentation/Department/Department/DepartmentHostingController.swift b/Koin/Presentation/Department/Department/DepartmentHostingController.swift index fcb4271df..361f1ddde 100644 --- a/Koin/Presentation/Department/Department/DepartmentHostingController.swift +++ b/Koin/Presentation/Department/Department/DepartmentHostingController.swift @@ -21,7 +21,7 @@ final class DepartmentHostingController: UIHostingController, Ho // MARK: - Life Cycle override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - configureNavigationBar(style: .order) + configureNavigationBar(style: .newBackground) } // MARK: - Execute diff --git a/Koin/Presentation/Department/DepartmentCategory/DepartmentCategoryHostingController.swift b/Koin/Presentation/Department/DepartmentCategory/DepartmentCategoryHostingController.swift index 7756ce48b..5220eeba7 100644 --- a/Koin/Presentation/Department/DepartmentCategory/DepartmentCategoryHostingController.swift +++ b/Koin/Presentation/Department/DepartmentCategory/DepartmentCategoryHostingController.swift @@ -22,7 +22,7 @@ final class DepartmentCategoryHostingController: UIHostingController Date: Sun, 30 Aug 2026 00:47:22 +0900 Subject: [PATCH 007/123] =?UTF-8?q?chore:=20FilterBottomSheetView=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=84=A0=ED=83=9D=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인 상태에 따른 선택 가능 여부 분기처리를 위해 주입하는 onFilterItemTapped를 optional로 수정했습니다. --- .../FilterBottomSheet/FilterBottomSheetView.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Koin/Presentation/Shared/FilterBottomSheet/FilterBottomSheetView.swift b/Koin/Presentation/Shared/FilterBottomSheet/FilterBottomSheetView.swift index 3f45112f7..5af12b93e 100644 --- a/Koin/Presentation/Shared/FilterBottomSheet/FilterBottomSheetView.swift +++ b/Koin/Presentation/Shared/FilterBottomSheet/FilterBottomSheetView.swift @@ -15,7 +15,7 @@ class FilterBottomSheetView: UIView { // MARK: - Properties weak var delegate: BottomSheetViewControllerBDelegate? private var groupModels: [FilterGroupModel] - private let onFilterItemTapped: (FilterItemModel)->Bool + private let onFilterItemTapped: ((FilterItemModel)->Bool)? private let onApplyTapped: ([FilterGroupModel])->Void private var subscriptions: Set = [] @@ -36,7 +36,7 @@ class FilterBottomSheetView: UIView { // MARK: - Initializer init( groupModels: [FilterGroupModel], - onFilterItemTapped: @escaping (FilterItemModel)->Bool, + onFilterItemTapped: ((FilterItemModel)->Bool)? = nil, onApplyTapped: @escaping ([FilterGroupModel])->Void ) { self.groupModels = groupModels @@ -69,10 +69,11 @@ class FilterBottomSheetView: UIView { private func didTapItem(groupIndex: Int, itemIndex: Int) { let tappedItem = groupModels[groupIndex].items[itemIndex] - guard onFilterItemTapped(tappedItem) else { - return - } - + if let onFilterItemTapped { + guard onFilterItemTapped(tappedItem) else { + return + } + } let before = groupModels[groupIndex].items.map(\.isSelected) groupModels[groupIndex].didTap(itemAt: itemIndex) let after = groupModels[groupIndex].items.map(\.isSelected) From b296f6ab22166f387cfb46d2af688716cd631faa Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 30 Aug 2026 03:32:00 +0900 Subject: [PATCH 008/123] =?UTF-8?q?chore:=20UserDataManager=EC=97=90=20isL?= =?UTF-8?q?oggedIn=20=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Workers/UserDataManager.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Koin/Core/Workers/UserDataManager.swift b/Koin/Core/Workers/UserDataManager.swift index c2cfd7e53..b43c829b1 100644 --- a/Koin/Core/Workers/UserDataManager.swift +++ b/Koin/Core/Workers/UserDataManager.swift @@ -9,6 +9,7 @@ import FirebaseAnalytics final class UserDataManager { static let shared = UserDataManager() + private(set) var isLoggedIn: Bool = false private(set) var userId: String = "" private(set) var id: Int = 0 private(set) var gender: Any = "" @@ -18,6 +19,8 @@ final class UserDataManager { private init() {} func setUserData(userData: UserDto) { + isLoggedIn = true + guard let id = userData.id else { self.id = 0 self.userId = "anonymous" @@ -46,6 +49,7 @@ final class UserDataManager { } func resetUserData() { + isLoggedIn = false userId = "" id = 0 gender = "" From 6452fc9fe6cca4d35323d4c26c2d7d1d47b8d433 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 30 Aug 2026 03:33:25 +0900 Subject: [PATCH 009/123] =?UTF-8?q?design:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EB=AA=A9=EB=A1=9D=ED=99=94=EB=A9=B4=20UI=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/MockRecruitRepository.swift | 139 ++++++++++++++ .../Model/Recruit/RecruitCategory.swift | 61 +++++++ Koin/Domain/Model/Recruit/RecruitDetail.swift | 28 +++ Koin/Domain/Model/Recruit/RecruitList.swift | 16 ++ .../Model/Recruit/RecruitListFilter.swift | 57 ++++++ .../Model/Recruit/RecruitListSort.swift | 22 +++ .../Model/Recruit/RecruitMeetingType.swift | 25 +++ .../Model/Recruit/RecruitNotification.swift | 26 +++ .../Recruit/RecruitNotificationList.swift | 18 ++ Koin/Domain/Model/Recruit/RecruitRole.swift | 16 ++ .../Model/Recruit/RecruitRoleType.swift | 13 ++ Koin/Domain/Model/Recruit/RecruitState.swift | 25 +++ .../Domain/Repository/RecruitRepository.swift | 13 ++ .../Recruit/FetchRecruitListUseCase.swift | 25 +++ .../FetchRecruitNotificationListUseCase.swift | 25 +++ .../Mapper/RecruitListFilter+.swift | 79 ++++++++ .../RecruitListHostingController.swift | 170 ++++++++++++++++++ .../Recruit/RecruitList/RecruitListView.swift | 131 ++++++++++++++ .../RecruitList/RecruitListViewModel.swift | 150 ++++++++++++++++ .../Subviews/RecruitListEmptyView.swift | 25 +++ .../Subviews/RecruitListHeaderView.swift | 121 +++++++++++++ .../Subviews/RecruitListRowView.swift | 109 +++++++++++ koin.xcodeproj/project.pbxproj | 136 ++++++++++++++ 23 files changed, 1430 insertions(+) create mode 100644 Koin/Data/Repository/MockRecruitRepository.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitCategory.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitDetail.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitList.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitListFilter.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitListSort.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitMeetingType.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitNotification.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitNotificationList.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitRole.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitRoleType.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitState.swift create mode 100644 Koin/Domain/Repository/RecruitRepository.swift create mode 100644 Koin/Domain/UseCase/Recruit/FetchRecruitListUseCase.swift create mode 100644 Koin/Domain/UseCase/Recruit/FetchRecruitNotificationListUseCase.swift create mode 100644 Koin/Presentation/Recruit/RecruitList/Mapper/RecruitListFilter+.swift create mode 100644 Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift create mode 100644 Koin/Presentation/Recruit/RecruitList/RecruitListView.swift create mode 100644 Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift create mode 100644 Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListEmptyView.swift create mode 100644 Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListHeaderView.swift create mode 100644 Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift new file mode 100644 index 000000000..ed6c52b88 --- /dev/null +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -0,0 +1,139 @@ +// +// MockRecruitRepository.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +final class MockRecruitRepository: RecruitRepository { + func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList { + let contest = RecruitDetail( + id: 1, + category: .contest, + title: "AI 아이디어 공모전 팀원 모집", + meetingType: .online, + startDate: "2026.07.26", + endDate: "2026.08.07", + deadline: "2026.08.07", + dDay: 5, + state: .recruiting, + currentParticipants: 0, + maximumParticipants: 3, + type: .roleBased, + roles: [ + RecruitRole(id: 1, name: "프론트엔드", currentParticipants: 0, maximumParticipants: 1, isClosed: false), + RecruitRole(id: 2, name: "백엔드", currentParticipants: 0, maximumParticipants: 1, isClosed: false), + RecruitRole(id: 3, name: "디자인", currentParticipants: 0, maximumParticipants: 1, isClosed: false) + ] + ) + + let externalActivity = RecruitDetail( + id: 2, + category: .externalActivity, + title: "2026 대외활동 팀원 모집", + meetingType: .mixed, + startDate: "2026.07.26", + endDate: "2026.08.07", + deadline: "2026.08.07", + dDay: 13, + state: .recruiting, + currentParticipants: 2, + maximumParticipants: 3, + type: .general, + roles: [] + ) + + let closedExternalActivity = RecruitDetail( + id: 3, + category: .externalActivity, + title: "2026 대외활동 팀원 모집", + meetingType: .mixed, + startDate: "2026.07.26", + endDate: "2026.08.07", + deadline: "2026.08.07", + dDay: 0, + state: .closed, + currentParticipants: 5, + maximumParticipants: 5, + type: .general, + roles: [] + ) + + let studies = (4...7).map { id in + RecruitDetail( + id: id, + category: .study, + title: "2026 스터디 팀원 모집", + meetingType: .mixed, + startDate: "2026.07.26", + endDate: "2026.08.07", + deadline: "2026.08.07", + dDay: 13, + state: .recruiting, + currentParticipants: 2, + maximumParticipants: 3, + type: .general, + roles: [] + ) + } + + return RecruitList( + recruits: [contest, externalActivity, closedExternalActivity] + studies, + totalCount: 7, + totalPage: 1, + currentPage: 1 + ) + } + + func fetchNotificationList() async throws -> RecruitNotificationList { + return RecruitNotificationList(notifications: [ + RecruitNotification( + id: 1, + chatRoomId: 1, + type: .chat, + title: "팀원모집 @@님의 메시지", + description: "메세지메세지", + time: "2시간 전", + isRead: false + ), + RecruitNotification( + id: 2, + chatRoomId: 0, + type: .default, + title: "팀원 모집 지원 승인", + description: "지원했던 AI 공모전 팀원 모집에 승인되었어요.", + time: "2시간 전", + isRead: false + ), + RecruitNotification( + id: 3, + chatRoomId: 0, + type: .default, + title: "팀원 모집 지원 거절", + description: "지원했던 AI 공모전 팀원 모집에 승인 거절되었어요.\n다른 모집글에 지원해보세요.", + time: "2시간 전", + isRead: false + ), + RecruitNotification( + id: 4, + chatRoomId: 0, + type: .default, + title: "팀원 모집글 삭제", + description: "지원했던 AI 공모전 팀원 모집글이 삭제되었어요.\n다른 모집글에 지원해보세요.", + time: "2시간 전", + isRead: false + ), + RecruitNotification( + id: 5, + chatRoomId: 0, + type: .default, + title: "팀원 모집기간 종료", + description: "작성했던 AI 공모전 팀원 모집 기간이 종료되었어요.", + time: "2시간 전", + isRead: false + ) + ]) + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitCategory.swift b/Koin/Domain/Model/Recruit/RecruitCategory.swift new file mode 100644 index 000000000..252d2e784 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitCategory.swift @@ -0,0 +1,61 @@ +// +// RecruitCategory.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +enum RecruitCategory: String, CaseIterable { + case contest = "공모전" + case externalActivity = "대외활동" + case study = "스터디" + case project = "프로젝트" + case other = "기타" + + var backgroundColor: ColorAsset { + switch self { + case .contest: + return .info200 + case .externalActivity: + return .success200 + case .study: + return .new100 + case .project: + return .warning100 + case .other: + return .warning200 + } + } + + var foregroundColor: ColorAsset { + switch self { + case .contest: + return .info700 + case .externalActivity: + return .success700 + case .study: + return .new600 + case .project: + return .warning600 + case .other: + return .danger600 + } + } + + var index: Int { + switch self { + case .contest: + 1 + case .externalActivity: + 2 + case .study: + 3 + case .project: + 4 + case .other: + 5 + } + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitDetail.swift b/Koin/Domain/Model/Recruit/RecruitDetail.swift new file mode 100644 index 000000000..624c690dd --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitDetail.swift @@ -0,0 +1,28 @@ +// +// RecruitDetail.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +struct RecruitDetail: Identifiable, Equatable { + let id: Int + let category: RecruitCategory + let title: String + let meetingType: RecruitMeetingType + + let startDate: String + let endDate: String + let deadline: String + let dDay: Int + + let state: RecruitState + + let currentParticipants: Int + let maximumParticipants: Int + + let type: RecruitRoleType + let roles: [RecruitRole] +} diff --git a/Koin/Domain/Model/Recruit/RecruitList.swift b/Koin/Domain/Model/Recruit/RecruitList.swift new file mode 100644 index 000000000..21d4be571 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitList.swift @@ -0,0 +1,16 @@ +// +// RecruitList.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +struct RecruitList { + var recruits: [RecruitDetail] + + let totalCount: Int + let totalPage: Int + let currentPage: Int +} diff --git a/Koin/Domain/Model/Recruit/RecruitListFilter.swift b/Koin/Domain/Model/Recruit/RecruitListFilter.swift new file mode 100644 index 000000000..656f9c26c --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitListFilter.swift @@ -0,0 +1,57 @@ +// +// RecruitListFilter.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +struct RecruitListFilter { + var keyword: String? + var state: RecruitState = .all + var sort: RecruitListSort = .latestDescending + var category: Set = .init() + var meetingType: RecruitMeetingType? = nil + + var page: Int = 1 + var limit: Int? = 10 +} + +extension RecruitListFilter { + var nonDefaultItems: [String] { + var items: [String] = [] + if state != .all { + items.append(state.rawValue) + } + if sort != .latestDescending { + items.append(sort.rawValue) + } + category.forEach { + items.append($0.rawValue) + } + if let meetingType { + items.append(meetingType.rawValue) + } + return items + } + + mutating func remove(item rawValue: String) { + if let _ = RecruitState(rawValue: rawValue) { + self.state = .all + return + } + if let _ = RecruitListSort(rawValue: rawValue) { + self.sort = .latestDescending + return + } + if let category = RecruitCategory(rawValue: rawValue) { + self.category.remove(category) + return + } + if let _ = RecruitMeetingType(rawValue: rawValue) { + self.meetingType = nil + return + } + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitListSort.swift b/Koin/Domain/Model/Recruit/RecruitListSort.swift new file mode 100644 index 000000000..45310535a --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitListSort.swift @@ -0,0 +1,22 @@ +// +// RecruitListSort.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +enum RecruitListSort: String { + case latestDescending = "최신순" + case deadlineAscending = "마감임박순" + + var index: Int { + switch self { + case .latestDescending: + 0 + case .deadlineAscending: + 1 + } + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitMeetingType.swift b/Koin/Domain/Model/Recruit/RecruitMeetingType.swift new file mode 100644 index 000000000..56d634c2c --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitMeetingType.swift @@ -0,0 +1,25 @@ +// +// RecruitMeetingType.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +enum RecruitMeetingType: String, CaseIterable { + case online = "온라인" + case offline = "오프라인" + case mixed = "온 · 오프라인" + + var index: Int { + switch self { + case .online: + 1 + case .offline: + 2 + case .mixed: + 3 + } + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitNotification.swift b/Koin/Domain/Model/Recruit/RecruitNotification.swift new file mode 100644 index 000000000..91a225828 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitNotification.swift @@ -0,0 +1,26 @@ +// +// RecruitNotification.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +struct RecruitNotification { + let id: Int + let chatRoomId: Int + + let type: RecruitNotificationType + + let title: String + let description: String + let time: String + let isRead: Bool +} + +enum RecruitNotificationType { + case chat + case newApplicant + case `default` +} diff --git a/Koin/Domain/Model/Recruit/RecruitNotificationList.swift b/Koin/Domain/Model/Recruit/RecruitNotificationList.swift new file mode 100644 index 000000000..16593d0cb --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitNotificationList.swift @@ -0,0 +1,18 @@ +// +// RecruitNotificationList.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +struct RecruitNotificationList { + let notifications: [RecruitNotification] +} + +extension RecruitNotificationList { + var hasUnread: Bool { + !notifications.filter({ !$0.isRead }).isEmpty + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitRole.swift b/Koin/Domain/Model/Recruit/RecruitRole.swift new file mode 100644 index 000000000..a26cd50b4 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitRole.swift @@ -0,0 +1,16 @@ +// +// RecruitRole.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +struct RecruitRole: Identifiable, Hashable { + let id: Int + let name: String + let currentParticipants: Int + let maximumParticipants: Int + let isClosed: Bool +} diff --git a/Koin/Domain/Model/Recruit/RecruitRoleType.swift b/Koin/Domain/Model/Recruit/RecruitRoleType.swift new file mode 100644 index 000000000..9a4dca0b9 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitRoleType.swift @@ -0,0 +1,13 @@ +// +// RecruitRoleType.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +enum RecruitRoleType: String { + case roleBased + case general +} diff --git a/Koin/Domain/Model/Recruit/RecruitState.swift b/Koin/Domain/Model/Recruit/RecruitState.swift new file mode 100644 index 000000000..55c6cdf69 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitState.swift @@ -0,0 +1,25 @@ +// +// RecruitState.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +enum RecruitState: String { + case all = "전체" + case recruiting = "모집중" + case closed = "모집완료" + + var index: Int { + switch self { + case .all: + 0 + case .recruiting: + 1 + case .closed: + 2 + } + } +} diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift new file mode 100644 index 000000000..cac6ab271 --- /dev/null +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -0,0 +1,13 @@ +// +// RecruitRepository.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +protocol RecruitRepository { + func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList + func fetchNotificationList() async throws -> RecruitNotificationList +} diff --git a/Koin/Domain/UseCase/Recruit/FetchRecruitListUseCase.swift b/Koin/Domain/UseCase/Recruit/FetchRecruitListUseCase.swift new file mode 100644 index 000000000..5a2dcdc94 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/FetchRecruitListUseCase.swift @@ -0,0 +1,25 @@ +// +// FetchRecruitListUseCase.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +protocol FetchRecruitListUseCase { + func execute(filter: RecruitListFilter) async throws -> RecruitList +} + +final class DefaultFetchRecruitListUseCase: FetchRecruitListUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(filter: RecruitListFilter) async throws -> RecruitList { + try await repository.fetchList(filter) + } +} diff --git a/Koin/Domain/UseCase/Recruit/FetchRecruitNotificationListUseCase.swift b/Koin/Domain/UseCase/Recruit/FetchRecruitNotificationListUseCase.swift new file mode 100644 index 000000000..5bce96cde --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/FetchRecruitNotificationListUseCase.swift @@ -0,0 +1,25 @@ +// +// FetchRecruitNotificationListUseCase.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +protocol FetchRecruitNotificationListUseCase { + func execute() async throws -> RecruitNotificationList +} + +final class DefaultFetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute() async throws -> RecruitNotificationList { + try await repository.fetchNotificationList() + } +} diff --git a/Koin/Presentation/Recruit/RecruitList/Mapper/RecruitListFilter+.swift b/Koin/Presentation/Recruit/RecruitList/Mapper/RecruitListFilter+.swift new file mode 100644 index 000000000..571e76bb8 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitList/Mapper/RecruitListFilter+.swift @@ -0,0 +1,79 @@ +// +// RecruitListFilter+.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +extension RecruitListFilter { + func toGroupModels() -> [FilterGroupModel] { + var groupModels = [ + FilterGroupModel( + title: "모집 상태", + hasAllButton: true, + items: [ + RecruitState.recruiting.rawValue, + RecruitState.closed.rawValue + ], + behavior: .single + ), + FilterGroupModel( + title: "정렬", + hasAllButton: false, + items: [ + RecruitListSort.latestDescending.rawValue, + RecruitListSort.deadlineAscending.rawValue + ], + behavior: .single + ), + FilterGroupModel( + title: "카테고리", + hasAllButton: true, + items: RecruitCategory.allCases.map(\.rawValue), + behavior: .multiple + ), + FilterGroupModel( + title: "진행방식", + hasAllButton: true, + items: RecruitMeetingType.allCases.map(\.rawValue), + behavior: .single + ) + ] + + groupModels[0].didTap(itemAt: state.index) + groupModels[1].didTap(itemAt: sort.index) + + category.forEach { + groupModels[2].didTap(itemAt: $0.index) + } + + if let meetingType { + groupModels[3].didTap(itemAt: meetingType.index) + } + + return groupModels + } +} + +extension RecruitListFilter { + init?(from groupModels: [FilterGroupModel]) { + guard let state = RecruitState(rawValue: groupModels[0].selectedItems.first?.title ?? ""), + let sort = RecruitListSort(rawValue: groupModels[1].selectedItems.first?.title ?? "") else { + return nil + } + self.state = state + self.sort = sort + + self.category = groupModels[2].selectedItems + .compactMap { + RecruitCategory(rawValue: $0.title) + } + .reduce(into: Set(), { (set, category) in + set.insert(category) + }) + + self.meetingType = RecruitMeetingType(rawValue: groupModels[3].selectedItems.first?.title ?? "") + } +} diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift new file mode 100644 index 000000000..d5a4a4ef6 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -0,0 +1,170 @@ +// +// RecruitListHostingController.swift +// koin +// +// Created by 홍기정 on 8/28/26. +// + +import SwiftUI + +final class RecruitListHostingController: UIHostingController, HostingControllerProtocol { + + // MARK: - UI Components + private let notificationBarButton = UIButton(type: .system) + private let profileBarButton = UIButton(type: .system) + private let rightBarButtonsView = UIView() + private lazy var rightBarButton = UIBarButtonItem(customView: rightBarButtonsView) + + // MARK: - Initializer + override init(rootView: RecruitListView) { + super.init(rootView: rootView) + bindAction(to: rootView) + title = "팀원모집" + } + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + configureView() + setAddTargets() + navigationItem.rightBarButtonItem = rightBarButton + } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar(style: .newBackground) + } + + // MARK: Public + func execute(action: RootView.Action) { + switch action { + case .configureRightButtons(let hasNotification): + updateNotificationBarButton(hasNotification) + case .showFilterBottomSheet(let filterState, let onApplyTapped): + showFilterBottomSheet(filterState, onApplyTapped) + case .showToast(let message): + showToastMessage(message: message) + case .showLoginToast: + showLoginToast() + case .showRecruitPost: + showRecruitPost() + } + } +} + +extension RecruitListHostingController { + private func updateNotificationBarButton(_ hasUnreadNotification: Bool) { + UIView.transition( + with: notificationBarButton, + duration: 0.2, + options: [ + .transitionCrossDissolve, + .beginFromCurrentState + ] + ) { [weak self] in + self?.notificationBarButton.setImage( + .appImage(asset: hasUnreadNotification ? .recruitBellDot : .recruitBell)?.withRenderingMode(.alwaysOriginal), + for: .normal + ) + } + } + + private func showLoginToast() { + showToastMessageWithButton( + message: "로그인이 필요한 기능입니다.", + buttonTitle: "로그인" + ) { [weak self] in + self?.navigateToLogin() + } + return + } + private func showRecruitNotification() { + // TODO + } + private func showRecruitProfile() { + // TODO + } + private func showRecruitPost() { + // TOOD + } +} + +extension RecruitListHostingController { + private func showFilterBottomSheet( + _ filterState: RecruitListFilter, + _ onApplyTapped: @escaping ([FilterGroupModel])->Void + ) { + let filterBottomSheetView = FilterBottomSheetView( + groupModels: filterState.toGroupModels(), + onApplyTapped: onApplyTapped + ) + let bottomSheetViewController = BottomSheetViewControllerB(contentView: filterBottomSheetView) + filterBottomSheetView.delegate = bottomSheetViewController + present(bottomSheetViewController, animated: true) + } +} + +extension RecruitListHostingController { + private func setAddTargets() { + notificationBarButton.addTarget(self, action: #selector(notificationButtonTapped), for: .touchUpInside) + profileBarButton.addTarget(self, action: #selector(profileButtonTapped), for: .touchUpInside) + } + + @objc private func notificationButtonTapped() { + guard UserDataManager.shared.isLoggedIn else { + showLoginToast() + return + } + showRecruitNotification() + } + + @objc private func profileButtonTapped() { + guard UserDataManager.shared.isLoggedIn else { + showLoginToast() + return + } + showRecruitProfile() + } +} + +extension RecruitListHostingController { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + notificationBarButton.do { + $0.setImage(.appImage(asset: .recruitBell), for: .normal) + } + + profileBarButton.do { + $0.setImage(.appImage(asset: .recruitProfile), for: .normal) + } + } + + private func setUpLayouts() { + [notificationBarButton, profileBarButton].forEach { + rightBarButtonsView.addSubview($0) + } + } + + private func setUpConstraints() { + notificationBarButton.snp.makeConstraints { + $0.size.equalTo(44) + $0.leading.top.bottom.equalToSuperview() + } + profileBarButton.snp.makeConstraints { + $0.size.equalTo(44) + $0.leading.equalTo(notificationBarButton.snp.trailing) + $0.top.bottom.trailing.equalToSuperview() + } + rightBarButtonsView.snp.makeConstraints { + $0.width.equalTo(88) + $0.height.equalTo(44) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift new file mode 100644 index 000000000..bc1fcec8e --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -0,0 +1,131 @@ +// +// RecruitListView.swift +// koin +// +// Created by 홍기정 on 8/28/26. +// + +import SwiftUI + +struct RecruitListView: ActionBindableView { + enum Action { + case configureRightButtons(hasUnreadNotification: Bool) + case showFilterBottomSheet(filterState: RecruitListFilter, onApplyTapped: ([FilterGroupModel])->Void) + case showToast(message: String) + case showLoginToast + case showRecruitPost + } + + // MARK: - Properties + var sendAction: ((Action) -> Void) = { _ in } + @State private var viewModel: RecruitListViewModel + + // MARK: - Initializer + init(viewModel: RecruitListViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + var body: some View { + VStack { + RecruitListHeaderView( + filterState: viewModel.filterState, + onSearchTapped: { keyword in + viewModel.execute(.updateFilter(keyword: keyword)) + }, + onFilterButtonTapped: { + sendAction(.showFilterBottomSheet( + filterState: viewModel.filterState, + onApplyTapped: { filterGroups in + viewModel.execute(.updateFilter(filterState: RecruitListFilter(from: filterGroups))) + } + )) + }, + onFilterDeleted: { rawValue in + viewModel.execute(.deleteFilter(rawvalue: rawValue)) + } + ) + .padding(EdgeInsets(top: 20, leading: 0, bottom: 12, trailing: 0)) + + ScrollView { + Text("전체(\(viewModel.recruitList?.totalCount ?? 0))") + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral500)) + .frame(height: 19) + .padding(EdgeInsets(top: 0, leading: 4, bottom: 12, trailing: 0)) + .frame(maxWidth: .infinity, alignment: .leading) + + LazyVStack(spacing: 8) { + ForEach(viewModel.recruitList?.recruits ?? []) { row in + Button { + + } label: { + RecruitListRowView(model: row) + } + .frame(maxWidth: .infinity) + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity) + + if viewModel.hasNextPage { + ProgressView() + .frame(maxWidth: .infinity, idealHeight: 77, alignment: .center) + .task { + viewModel.execute(.loadNextPage) + } + .isHidden(!viewModel.isLoading) + + } else { + Spacer(minLength: 77) + } + } + .padding(.horizontal, 22) + .background { + RecruitListEmptyView() + .isHidden(!viewModel.isEmpty) + } + .refreshable { + viewModel.execute(.refresh) + } + .scrollIndicators(.hidden) + } + .background(Color.appColor(.newBackground)) + .overlay(alignment: .bottomTrailing) { + postButton + .padding(EdgeInsets(top: 0, leading: 0, bottom: 23, trailing: 30)) + } + .onFirstAppear { + viewModel.execute(.viewDidAppear) + } + .onChange(of: viewModel.errorMessage) { + if let message = viewModel.errorMessage { + sendAction(.showToast(message: message)) + viewModel.execute(.didShowToast) + } + } + .onChange(of: viewModel.hasUnreadNotification) { + sendAction(.configureRightButtons(hasUnreadNotification: viewModel.hasUnreadNotification)) + } + .hideKeyboardWhenTapAround() + } + + @ViewBuilder + private var postButton: some View { + Button { + UserDataManager.shared.isLoggedIn ? sendAction(.showRecruitPost) : sendAction(.showLoginToast) + } label: { + HStack(alignment: .center, spacing: 4) { + Text("모집하기") + .font(.appFont(.pretendardSemiBold, size: 16)) + .foregroundStyle(Color.appColor(.neutral0)) + Image.appImage(asset: .recruitPencil) + } + .padding(.horizontal, 22) + .frame(height: 43) + .background(Color.appColor(.new400)) + .clipShape(RoundedRectangle(cornerRadius: 43/2)) + } + .buttonStyle(.plain) + } +} diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift new file mode 100644 index 000000000..21a6d228e --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift @@ -0,0 +1,150 @@ +// +// RecruitListViewModel.swift +// koin +// +// Created by 홍기정 on 8/28/26. +// + +import Foundation +import Observation + +@Observable +final class RecruitListViewModel: SwiftUIViewModelProtocol { + + enum Input { + case viewDidAppear + case refresh + case updateFilter(keyword: String? = nil, filterState: RecruitListFilter? = nil) + case deleteFilter(rawvalue: String) + case resetFilter + case loadNextPage + + case didShowToast + } + + // MARK: - State + private(set) var recruitList: RecruitList? + private(set) var filterState = RecruitListFilter() + private(set) var isLoading: Bool = false + private(set) var hasUnreadNotification: Bool = false + private(set) var errorMessage: String? = nil + + var isEmpty: Bool { + if let recruitList { + recruitList.totalCount == 0 + } else { + true + } + } + var currentPage: Int { + recruitList?.currentPage ?? 0 + } + var totalPage: Int { + recruitList?.totalPage ?? 0 + } + var hasNextPage: Bool { + currentPage < totalPage + } + + // MARK: - UseCase + private let fetchListUseCase: FetchRecruitListUseCase + private let fetchNotificationListUseCase: FetchRecruitNotificationListUseCase + + // MARK: - Initializer + init( + fetchRecruitListUseCase: FetchRecruitListUseCase, + fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase + ) { + self.fetchListUseCase = fetchRecruitListUseCase + self.fetchNotificationListUseCase = fetchRecruitNotificationListUseCase + } + + // MARK: - Public + func execute(_ input: Input) { + switch input { + case .viewDidAppear, .refresh: + fetchList() + fetchHasUnreadNotification() + case .updateFilter(let keyword, let filterState): + updateFilter(keyword, filterState) + case .deleteFilter(let rawvalue): + deleteFilter(rawvalue) + case .resetFilter: + resetFilter() + case .loadNextPage: + loadNextPage() + case .didShowToast: + errorMessage = nil + } + } +} + +extension RecruitListViewModel { + private func updateFilter( + _ keyword: String?, + _ filterState: RecruitListFilter? + ) { + if let keyword { + self.filterState.keyword = keyword + fetchList() + } else if let filterState { + var filterState = filterState + filterState.keyword = self.filterState.keyword + self.filterState = filterState + fetchList() + } + } + + private func deleteFilter(_ rawValue: String) { + filterState.remove(item: rawValue) + fetchList() + } + + private func loadNextPage() { + fetchList(page: currentPage + 1) + } + + private func resetFilter() { + filterState = .init() + fetchList() + } +} + +extension RecruitListViewModel { + private func fetchList(page: Int = 1) { + guard !isLoading else { + return + } + Task { + do { + isLoading = true + defer { + isLoading = false + } + + filterState.page = page + var response = try await fetchListUseCase.execute(filter: filterState) + + response.recruits = (recruitList?.recruits ?? []) + response.recruits + response.recruits.removeDuplicates() + + self.recruitList = response + } catch { + errorMessage = (error as? ErrorResponse)?.message + } + } + } + + private func fetchHasUnreadNotification() { + Task { + do { + self.hasUnreadNotification = try await fetchNotificationListUseCase.execute().hasUnread + } catch { + if let error = (error as? ErrorResponse), + error.statusCode != 401 { + self.errorMessage = error.message + } + } + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListEmptyView.swift b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListEmptyView.swift new file mode 100644 index 000000000..34520492c --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListEmptyView.swift @@ -0,0 +1,25 @@ +// +// RecruitListEmptyView.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import SwiftUI + +struct RecruitListEmptyView: View { + var body: some View { + VStack(alignment: .center, spacing: 12) { + Spacer() + + Image.appImage(asset: .sleepBcsdSymbol) + .frame(height: 75) + + Text("조건에 맞는 모집글이 없어요.") + .font(.appFont(.pretendardRegular, size: 14)) + .foregroundStyle(Color.appColor(.neutral500)) + + Spacer() + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListHeaderView.swift b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListHeaderView.swift new file mode 100644 index 000000000..4fd9edecd --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListHeaderView.swift @@ -0,0 +1,121 @@ +// +// RecruitListHeaderView.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import SwiftUI + +struct RecruitListHeaderView: View { + + // MARK: - State + @State private var keyword: String = "" + + // MARK: - Properties + let filterState: RecruitListFilter + let onSearchTapped: (String)->Void + let onFilterButtonTapped: ()->Void + let onFilterDeleted: (String)->Void + + // MARK: - Initializer + init( + filterState: RecruitListFilter, + onSearchTapped: @escaping (String) -> Void, + onFilterButtonTapped: @escaping () -> Void, + onFilterDeleted: @escaping (String) -> Void + ) { + self.filterState = filterState + self.onSearchTapped = onSearchTapped + self.onFilterButtonTapped = onFilterButtonTapped + self.onFilterDeleted = onFilterDeleted + } + + // MARK: - Body + var body: some View { + VStack(spacing: 12) { + HStack(spacing: 12) { + textField + filterButton + } + .padding(.horizontal, 22) + + if !filterState.nonDefaultItems.isEmpty { + ScrollView(.horizontal) { + LazyHStack(spacing: 8) { + ForEach(filterState.nonDefaultItems, id: \.self) { item in + Button { + onFilterDeleted(item) + } label: { + HStack(spacing: 0) { + Text(item) + .font(.appFont(.pretendardMedium, size: 12)) + .foregroundStyle(Color.appColor(.neutral0)) + Image.appImage(asset: .recruitFilterX) + } + .padding(.horizontal, 10) + .frame(height: 30) + .background(Color.appColor(.new400)) + .clipShape(.capsule) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 22) + } + .frame(height: 30) + .transition(.asymmetric( + insertion: .push(from: .top).combined(with: .opacity), + removal: .push(from: .bottom).combined(with: .opacity))) + } + } + .animation(.spring(duration: 0.2), value: filterState.nonDefaultItems) + } + + @ViewBuilder + private var textField: some View { + HStack(alignment: .center, spacing: 10) { + TextField(text: $keyword) { + Text("검색어를 입력해주세요.") + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral600)) + } + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral800)) + .onSubmit { + onSearchTapped(keyword) + } + + Button { + onSearchTapped(keyword) + } label: { + Image.appImage(asset: .recruitSearch) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .frame(height: 36) + .background(Color.appColor(.neutral0)) + .clipShape(.capsule) + } + + @ViewBuilder + private var filterButton: some View { + Button { + onFilterButtonTapped() + } label: { + HStack(alignment: .center, spacing: 4) { + Text("필터") + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral600)) + + Image.appImage(asset: .recruitFilter) + } + .padding(.horizontal, 15) + .frame(height: 36) + .background(Color.appColor(.neutral0)) + .clipShape(.capsule) + } + .buttonStyle(.plain) + } +} diff --git a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift new file mode 100644 index 000000000..5f93b96e6 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift @@ -0,0 +1,109 @@ +// +// RecruitListRowView.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import SwiftUI + +struct RecruitListRowView: View { + + let model: RecruitDetail + + init(model: RecruitDetail) { + self.model = model + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + headerView + .padding(.bottom, 8) + titleLabel + .padding(.bottom, 4) + rolesView + .padding(.bottom, 8) + .isHidden(model.type == .general) + detailView + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + Color.appColor(.neutral0) + } + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} + +extension RecruitListRowView { + @ViewBuilder + private var headerView: some View { + HStack(alignment: .center, spacing: 4) { + Text(model.category.rawValue) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(model.category.foregroundColor)) + .padding(.horizontal, 8) + .frame(height: 18) + .background(Color.appColor(model.category.backgroundColor)) + .clipShape(.capsule) + + Text("D-\(model.dDay)") + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(.danger700)) + } + } + + @ViewBuilder + private var titleLabel: some View { + Text(model.title) + .font(.appFont(.pretendardSemiBold, size: 16)) + .foregroundStyle(Color.appColor(.neutral700)) + .frame(height: 26) + .lineLimit(1) + } + + @ViewBuilder + private var rolesView: some View { + LeftAlignedLayout(interitemSpacing: 4, interlineSpacing: 4) { + ForEach(model.roles) { role in + Text("\(role.name) \(role.maximumParticipants)") + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + .padding(.horizontal, 8) + .frame(height: 18) + .background(Color.appColor(.neutral200)) + .clipShape(.capsule) + } + } + } + + @ViewBuilder + private var detailView: some View { + LeftAlignedLayout(interitemSpacing: 8, interlineSpacing: 4) { + Group { + HStack(alignment: .center, spacing: 2) { + Image.appImage(asset: .recruitLocation) + Text(model.meetingType.rawValue) + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + } + + HStack(alignment: .center, spacing: 2) { + Image.appImage(asset: .recruitDate) + Text("\(model.startDate) ~ \(model.endDate)") + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + } + + HStack(alignment: .center, spacing: 2) { + Image.appImage(asset: .recruitMember) + Text("\(model.currentParticipants)/\(model.maximumParticipants)명") + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + } + } + .frame(height: 16) + } + } +} diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index b55936fc2..78bb992f2 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -134,8 +134,30 @@ 7C4D5A5A2F01638800B40128 /* BusAreaSelectdViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C4D5A0D2F01638800B40128 /* BusAreaSelectdViewController.swift */; }; 7C4D5A5B2F01638800B40128 /* ManyBusTimetableCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C4D5A2E2F01638800B40128 /* ManyBusTimetableCollectionViewCell.swift */; }; 7C4D5A5D2F0163BA00B40128 /* DIContainer+Bus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C4D5A5C2F0163BA00B40128 /* DIContainer+Bus.swift */; }; + 7C50A1BF3041A44E00897A6F /* RecruitListHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1BE3041A44E00897A6F /* RecruitListHostingController.swift */; }; + 7C50A1C13041A47100897A6F /* RecruitListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1C03041A47100897A6F /* RecruitListView.swift */; }; + 7C50A1C33041A47800897A6F /* RecruitListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1C23041A47800897A6F /* RecruitListViewModel.swift */; }; + 7C50A1C93041E14500897A6F /* RecruitListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1C83041E14500897A6F /* RecruitListHeaderView.swift */; }; + 7C50A1CC3041E29000897A6F /* RecruitListFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1CB3041E29000897A6F /* RecruitListFilter.swift */; }; + 7C50A1CF3041F66700897A6F /* RecruitListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1CE3041F66700897A6F /* RecruitListEmptyView.swift */; }; + 7C50A1D13041F8BF00897A6F /* RecruitState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D03041F8BF00897A6F /* RecruitState.swift */; }; + 7C50A1D33041F8CA00897A6F /* RecruitCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D23041F8CA00897A6F /* RecruitCategory.swift */; }; + 7C50A1D53041F8D500897A6F /* RecruitMeetingType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D43041F8D500897A6F /* RecruitMeetingType.swift */; }; + 7C50A1D73041F91400897A6F /* RecruitDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D63041F91400897A6F /* RecruitDetail.swift */; }; + 7C50A1D93041FC6800897A6F /* RecruitListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D83041FC6800897A6F /* RecruitListRowView.swift */; }; 7C50A1DD3042016300897A6F /* LeftAlignedLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1DC3042016300897A6F /* LeftAlignedLayout.swift */; }; + 7C50A1E23042C82F00897A6F /* RecruitRoleType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1E13042C82F00897A6F /* RecruitRoleType.swift */; }; + 7C50A1E43042C97F00897A6F /* RecruitRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1E33042C97F00897A6F /* RecruitRole.swift */; }; + 7C50A1F43042CB4100897A6F /* RecruitListSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1F33042CB4100897A6F /* RecruitListSort.swift */; }; + 7C50A1FA3042DB3200897A6F /* RecruitListFilter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1F93042DB3200897A6F /* RecruitListFilter+.swift */; }; + 7C50A1FD3042E9AB00897A6F /* FetchRecruitListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1FC3042E9AB00897A6F /* FetchRecruitListUseCase.swift */; }; + 7C50A1FF3042E9DC00897A6F /* RecruitList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1FE3042E9DC00897A6F /* RecruitList.swift */; }; + 7C50A2033042EF2C00897A6F /* RecruitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A2023042EF2C00897A6F /* RecruitRepository.swift */; }; + 7C50A2053042EF4F00897A6F /* MockRecruitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A2043042EF4F00897A6F /* MockRecruitRepository.swift */; }; 7C50A20930431F9900897A6F /* Array+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20830431F9900897A6F /* Array+.swift */; }; + 7C50A20B304324F500897A6F /* RecruitNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20A304324F500897A6F /* RecruitNotification.swift */; }; + 7C50A20D3043273A00897A6F /* RecruitNotificationList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */; }; + 7C50A20F3043278300897A6F /* FetchRecruitNotificationListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20E3043278300897A6F /* FetchRecruitNotificationListUseCase.swift */; }; 7C50F8DC300FC03B00A87BAE /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 7C50F8DB300FC03B00A87BAE /* AppIcon.icon */; }; 7C5583602F11778B00D3A980 /* ShopInfoHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C55835F2F11778B00D3A980 /* ShopInfoHeaderView.swift */; }; 7C5583622F11779400D3A980 /* ShopInfoFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C5583612F11779400D3A980 /* ShopInfoFooterView.swift */; }; @@ -1178,8 +1200,30 @@ 7C4D5A382F01638800B40128 /* BusTimetableDataViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusTimetableDataViewController.swift; sourceTree = ""; }; 7C4D5A392F01638800B40128 /* BusTimetableDataViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusTimetableDataViewModel.swift; sourceTree = ""; }; 7C4D5A5C2F0163BA00B40128 /* DIContainer+Bus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DIContainer+Bus.swift"; sourceTree = ""; }; + 7C50A1BE3041A44E00897A6F /* RecruitListHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListHostingController.swift; sourceTree = ""; }; + 7C50A1C03041A47100897A6F /* RecruitListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListView.swift; sourceTree = ""; }; + 7C50A1C23041A47800897A6F /* RecruitListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListViewModel.swift; sourceTree = ""; }; + 7C50A1C83041E14500897A6F /* RecruitListHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListHeaderView.swift; sourceTree = ""; }; + 7C50A1CB3041E29000897A6F /* RecruitListFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListFilter.swift; sourceTree = ""; }; + 7C50A1CE3041F66700897A6F /* RecruitListEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListEmptyView.swift; sourceTree = ""; }; + 7C50A1D03041F8BF00897A6F /* RecruitState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitState.swift; sourceTree = ""; }; + 7C50A1D23041F8CA00897A6F /* RecruitCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitCategory.swift; sourceTree = ""; }; + 7C50A1D43041F8D500897A6F /* RecruitMeetingType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMeetingType.swift; sourceTree = ""; }; + 7C50A1D63041F91400897A6F /* RecruitDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDetail.swift; sourceTree = ""; }; + 7C50A1D83041FC6800897A6F /* RecruitListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListRowView.swift; sourceTree = ""; }; 7C50A1DC3042016300897A6F /* LeftAlignedLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftAlignedLayout.swift; sourceTree = ""; }; + 7C50A1E13042C82F00897A6F /* RecruitRoleType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitRoleType.swift; sourceTree = ""; }; + 7C50A1E33042C97F00897A6F /* RecruitRole.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitRole.swift; sourceTree = ""; }; + 7C50A1F33042CB4100897A6F /* RecruitListSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListSort.swift; sourceTree = ""; }; + 7C50A1F93042DB3200897A6F /* RecruitListFilter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecruitListFilter+.swift"; sourceTree = ""; }; + 7C50A1FC3042E9AB00897A6F /* FetchRecruitListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitListUseCase.swift; sourceTree = ""; }; + 7C50A1FE3042E9DC00897A6F /* RecruitList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitList.swift; sourceTree = ""; }; + 7C50A2023042EF2C00897A6F /* RecruitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitRepository.swift; sourceTree = ""; }; + 7C50A2043042EF4F00897A6F /* MockRecruitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRecruitRepository.swift; sourceTree = ""; }; 7C50A20830431F9900897A6F /* Array+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+.swift"; sourceTree = ""; }; + 7C50A20A304324F500897A6F /* RecruitNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotification.swift; sourceTree = ""; }; + 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotificationList.swift; sourceTree = ""; }; + 7C50A20E3043278300897A6F /* FetchRecruitNotificationListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitNotificationListUseCase.swift; sourceTree = ""; }; 7C50F8DB300FC03B00A87BAE /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; 7C55835F2F11778B00D3A980 /* ShopInfoHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopInfoHeaderView.swift; sourceTree = ""; }; 7C5583612F11779400D3A980 /* ShopInfoFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopInfoFooterView.swift; sourceTree = ""; }; @@ -2595,6 +2639,54 @@ path = Bus; sourceTree = ""; }; + 7C50A1B43041A25D00897A6F /* RecruitList */ = { + isa = PBXGroup; + children = ( + 7C50A1F83042DB2000897A6F /* Mapper */, + 7C50A1C73041E13C00897A6F /* Subviews */, + 7C50A1BE3041A44E00897A6F /* RecruitListHostingController.swift */, + 7C50A1C03041A47100897A6F /* RecruitListView.swift */, + 7C50A1C23041A47800897A6F /* RecruitListViewModel.swift */, + ); + path = RecruitList; + sourceTree = ""; + }; + 7C50A1B53041A25D00897A6F /* Recruit */ = { + isa = PBXGroup; + children = ( + 7C50A1B43041A25D00897A6F /* RecruitList */, + ); + path = Recruit; + sourceTree = ""; + }; + 7C50A1C73041E13C00897A6F /* Subviews */ = { + isa = PBXGroup; + children = ( + 7C50A1C83041E14500897A6F /* RecruitListHeaderView.swift */, + 7C50A1CE3041F66700897A6F /* RecruitListEmptyView.swift */, + 7C50A1D83041FC6800897A6F /* RecruitListRowView.swift */, + ); + path = Subviews; + sourceTree = ""; + }; + 7C50A1CA3041E27E00897A6F /* Recruit */ = { + isa = PBXGroup; + children = ( + 7C50A1D03041F8BF00897A6F /* RecruitState.swift */, + 7C50A1D23041F8CA00897A6F /* RecruitCategory.swift */, + 7C50A1D43041F8D500897A6F /* RecruitMeetingType.swift */, + 7C50A1E13042C82F00897A6F /* RecruitRoleType.swift */, + 7C50A1E33042C97F00897A6F /* RecruitRole.swift */, + 7C50A1F33042CB4100897A6F /* RecruitListSort.swift */, + 7C50A1CB3041E29000897A6F /* RecruitListFilter.swift */, + 7C50A1D63041F91400897A6F /* RecruitDetail.swift */, + 7C50A1FE3042E9DC00897A6F /* RecruitList.swift */, + 7C50A20A304324F500897A6F /* RecruitNotification.swift */, + 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */, + ); + path = Recruit; + sourceTree = ""; + }; 7C50A1DA3042013B00897A6F /* UIKit */ = { isa = PBXGroup; children = ( @@ -2630,6 +2722,23 @@ path = SwiftUI; sourceTree = ""; }; + 7C50A1F83042DB2000897A6F /* Mapper */ = { + isa = PBXGroup; + children = ( + 7C50A1F93042DB3200897A6F /* RecruitListFilter+.swift */, + ); + path = Mapper; + sourceTree = ""; + }; + 7C50A1FB3042E99E00897A6F /* Recruit */ = { + isa = PBXGroup; + children = ( + 7C50A1FC3042E9AB00897A6F /* FetchRecruitListUseCase.swift */, + 7C50A20E3043278300897A6F /* FetchRecruitNotificationListUseCase.swift */, + ); + path = Recruit; + sourceTree = ""; + }; 7C58FE842F5F9CC700A2737B /* CallVanData */ = { isa = PBXGroup; children = ( @@ -4456,6 +4565,7 @@ 7CED90A62EF2D1F900457128 /* Facility */, 7CED90BA2EF2D1F900457128 /* Land */, 7CC42B082F56AAAD00940CE1 /* CallVan */, + 7C50A1B53041A25D00897A6F /* Recruit */, ); path = Presentation; sourceTree = ""; @@ -5438,6 +5548,7 @@ D2FB234D2BFD45930098BA2B /* Shop */, D249CD242C12DC16000813F9 /* Dining */, 7C016572300BD89E0013DD7B /* Department */, + 7C50A1FB3042E99E00897A6F /* Recruit */, D208A7A12CA11102007040E7 /* AbTest */, ); path = UseCase; @@ -5459,6 +5570,7 @@ D249CD392C158EEC000813F9 /* Dining */, 7CC42B1A2F56F21A00940CE1 /* CallVan */, 7C016552300AA2BE0013DD7B /* Department */, + 7C50A1CA3041E27E00897A6F /* Recruit */, ); path = Model; sourceTree = ""; @@ -5482,6 +5594,7 @@ 7C8A943F2FD426B400DEA6F5 /* HomeRepository.swift */, 7CA686682FFC3242007937E9 /* NotificationHistoryRepository.swift */, 7C6403FF300F395300A9589F /* DepartmentRepository.swift */, + 7C50A2023042EF2C00897A6F /* RecruitRepository.swift */, ); path = Repository; sourceTree = ""; @@ -5504,6 +5617,7 @@ 7CA60D642F6619B7007C08C1 /* DefaultCallVanRepository.swift */, 7CA686792FFCB758007937E9 /* DefaultNotificationHistoryRepository.swift */, 7C640401300F39BB00A9589F /* DefaultDepartmentRepository.swift */, + 7C50A2043042EF4F00897A6F /* MockRecruitRepository.swift */, ); path = Repository; sourceTree = ""; @@ -5953,6 +6067,7 @@ 7CC42B392F58284100940CE1 /* CallVanNotificationTableView.swift in Sources */, 7CC42B332F58247D00940CE1 /* CallVanNotification.swift in Sources */, B46B8CC22E76CB7300A8E797 /* ShopSummaryDto.swift in Sources */, + 7C50A2053042EF4F00897A6F /* MockRecruitRepository.swift in Sources */, D2D462662D63B98F00C60864 /* LostItemFetchChatRoomUseCase.swift in Sources */, 7CA0D6232EC74ADE00E9A282 /* ZoomedImageCollectionViewCell.swift in Sources */, D2BD36962BAA027A00FAE609 /* TokenRefreshRequest.swift in Sources */, @@ -6036,6 +6151,7 @@ 7C05D0782FBC29ED00F4D825 /* ShopBenefitViewModel.swift in Sources */, D29F21F32C4FD23F00994554 /* LandItem.swift in Sources */, 7C7F56B72FFB9FE000847151 /* NotificationHistoryRecord.swift in Sources */, + 7C50A1FD3042E9AB00897A6F /* FetchRecruitListUseCase.swift in Sources */, D20AB95A2C55369F006BC684 /* NotiAPI.swift in Sources */, 7C457AE72FCDE7240011D338 /* SwiftUIViewModelProtocol.swift in Sources */, 833D9C392C7058C300982145 /* NoticeListRepository.swift in Sources */, @@ -6068,6 +6184,7 @@ D2D7C0DB2C282B5600C85A85 /* LandAPI.swift in Sources */, D208A7B72CA16577007040E7 /* ShopBenefitsDto.swift in Sources */, D20AB9472C552E68006BC684 /* NetworkService.swift in Sources */, + 7C50A1FA3042DB3200897A6F /* RecruitListFilter+.swift in Sources */, D2FFA9702D5F4DC500EF8E56 /* CheckLoginUseCase.swift in Sources */, 833D9C452C70672C00982145 /* NoticeListService.swift in Sources */, 7CA60D652F6619B7007C08C1 /* DefaultCallVanRepository.swift in Sources */, @@ -6162,6 +6279,7 @@ 7C016558300AA3790013DD7B /* DepartmentTask.swift in Sources */, 7CED90D12EF2D1F900457128 /* NoticeSearchViewController.swift in Sources */, 7CED90D22EF2D1F900457128 /* TagCollectionViewCell.swift in Sources */, + 7C50A1CC3041E29000897A6F /* RecruitListFilter.swift in Sources */, 7CED90D32EF2D1F900457128 /* LandCollectionView.swift in Sources */, 7C6403D6300BF12500A9589F /* SearchDepartmentByCategoryUseCase.swift in Sources */, 7CED90D52EF2D1F900457128 /* SelectDeptModalViewController.swift in Sources */, @@ -6169,6 +6287,7 @@ 7CED90D92EF2D1F900457128 /* DiningViewController.swift in Sources */, 7C7BD725302B3E25003C5A15 /* NoticeAISummaryView.swift in Sources */, 7CCB1E4B2F29D87C00472669 /* LostItemPostChatDetailUseCase.swift in Sources */, + 7C50A20F3043278300897A6F /* FetchRecruitNotificationListUseCase.swift in Sources */, 7CED90DA2EF2D1F900457128 /* RecentSearchTableView.swift in Sources */, 7C7BD727302D8002003C5A15 /* NoticeAISummaryDto.swift in Sources */, 7C61D35F2F24000C005FAC3A /* LostItemDataButtonsView.swift in Sources */, @@ -6334,6 +6453,7 @@ 7C457B3D2FCE1BA80011D338 /* CategoryFeaturedButton.swift in Sources */, 7C457B3E2FCE1BA80011D338 /* CategoryRow.swift in Sources */, 7C457B3F2FCE1BA80011D338 /* CategorySection.swift in Sources */, + 7C50A1CF3041F66700897A6F /* RecruitListEmptyView.swift in Sources */, 7C457B402FCE1BA80011D338 /* HomeTabBarButton.swift in Sources */, 7C457B422FCE1BA80011D338 /* MobilityView.swift in Sources */, 7C457B442FCE1BA80011D338 /* CallVanRecruitmentButton.swift in Sources */, @@ -6460,6 +6580,7 @@ 7CED91B52EF2D1F900457128 /* NoticeAttachmentsTableView.swift in Sources */, 7C6790842FCC48A60045E163 /* UIViewController+.swift in Sources */, 7C6790852FCC48A60045E163 /* UIViewController+ToastMessage.swift in Sources */, + 7C50A1C13041A47100897A6F /* RecruitListView.swift in Sources */, 7C6790862FCC48A60045E163 /* UITableView+.swift in Sources */, 7C6790872FCC48A60045E163 /* UICollectionViewCell+.swift in Sources */, 7C6790882FCC48A60045E163 /* UIApplication+.swift in Sources */, @@ -6473,10 +6594,14 @@ 7C6790902FCC48A60045E163 /* UIImage+.swift in Sources */, 7C6790912FCC48A60045E163 /* Date+.swift in Sources */, 7C01655D300AA4680013DD7B /* DepartmentCategoryRow.swift in Sources */, + 7C50A1D93041FC6800897A6F /* RecruitListRowView.swift in Sources */, 7C6790922FCC48A60045E163 /* UICollectionVIewFlowLayout.swift in Sources */, + 7C50A1E23042C82F00897A6F /* RecruitRoleType.swift in Sources */, 7C6790932FCC48A60045E163 /* UICollectionView+.swift in Sources */, 7C6790942FCC48A60045E163 /* UIViewcontroller+Alert.swift in Sources */, 7C6790952FCC48A60045E163 /* UIButton+.swift in Sources */, + 7C50A1D13041F8BF00897A6F /* RecruitState.swift in Sources */, + 7C50A1F43042CB4100897A6F /* RecruitListSort.swift in Sources */, 7C8A94272FD0A97400DEA6F5 /* NotificationFooterView.swift in Sources */, 7C8A94282FD0A97400DEA6F5 /* NotificationTableViewCell.swift in Sources */, 7C8A94292FD0A97400DEA6F5 /* NotificationTableView.swift in Sources */, @@ -6509,12 +6634,14 @@ 7CED91D22EF2D1F900457128 /* LandImageCollectionViewCell.swift in Sources */, 7CED91D72EF2D1F900457128 /* ShopDetailViewModel.swift in Sources */, 7C01656F300B4A2D0013DD7B /* DepartmentView.swift in Sources */, + 7C50A1C93041E14500897A6F /* RecruitListHeaderView.swift in Sources */, 7C7F429B2F629B8800CC5860 /* UploadDomain.swift in Sources */, 7CED91D82EF2D1F900457128 /* ClassComponentView.swift in Sources */, 7C372F222F1CD91200149729 /* LostItemDataRecentTableViewCell.swift in Sources */, 7C6403FE300F36F200A9589F /* DepartmentAPI.swift in Sources */, 7CA60D632F66197C007C08C1 /* CallVanRepository.swift in Sources */, 7C0C188F2F67F8BA001781B3 /* CallVanDataDto.swift in Sources */, + 7C50A2033042EF2C00897A6F /* RecruitRepository.swift in Sources */, 7CC42B962F5DCEB300940CE1 /* ChatDateHeaderView.swift in Sources */, 7CED91DD2EF2D1F900457128 /* ShopDetailTableViewNameCell.swift in Sources */, 7C8A943B2FD424A600DEA6F5 /* WeatherDto.swift in Sources */, @@ -6552,6 +6679,7 @@ 7C04A4802FB3380700214992 /* NotiSubscribeRequest.swift in Sources */, 7C04A4812FB3380700214992 /* SendDeviceTokenRequest.swift in Sources */, 7CED92062EF2D1F900457128 /* ShopSearchTableViewShopNameCell.swift in Sources */, + 7C50A1D73041F91400897A6F /* RecruitDetail.swift in Sources */, 7CED92072EF2D1F900457128 /* SortTypeBottomSheetViewController.swift in Sources */, 7CED92082EF2D1F900457128 /* DeleteLectureModalViewController.swift in Sources */, 7CED92092EF2D1F900457128 /* AgreementFormViewController.swift in Sources */, @@ -6615,6 +6743,7 @@ D21572542CEDC17A0061E725 /* DeleteFrameUseCase.swift in Sources */, D89BAA392DCA5B3D006D3BB7 /* GeneralRegisterFormRequest.swift in Sources */, D20AB9562C55352A006BC684 /* ChangeNotiDetailUseCase.swift in Sources */, + 7C50A20B304324F500897A6F /* RecruitNotification.swift in Sources */, D29F21C12C4DDF5300994554 /* DefaultUserRepository.swift in Sources */, 7C016546300AA14C0013DD7B /* DepartmentCategoryView.swift in Sources */, 7C7BD71D302B2B0A003C5A15 /* AppImage.swift in Sources */, @@ -6626,6 +6755,7 @@ 7CB11C0C2FB198A6004E0C80 /* LostItemKeywordCollectionViewKeywordCell.swift in Sources */, 7C281CFD2EB3ED4B00BD6B4E /* FetchOrderShopDetailFromShopUseCase.swift in Sources */, 833D9C4B2C70D54E00982145 /* SearchArticleRequest.swift in Sources */, + 7C50A1D33041F8CA00897A6F /* RecruitCategory.swift in Sources */, D208A7B92CA1661F007040E7 /* FetchShopBenefitUseCase.swift in Sources */, D20AD6FB2E02A1280067760A /* FindIdEmailResponse.swift in Sources */, B68D2CE52EA505AA00F3B479 /* ToastIntent.swift in Sources */, @@ -6720,6 +6850,7 @@ D2908D282BB8D1580008F908 /* LectureDto.swift in Sources */, D2908D2C2BB9D4150008F908 /* TimetablesDto.swift in Sources */, 7CA63A2A2F1AF80B00226F86 /* LostItemListTableView.swift in Sources */, + 7C50A1FF3042E9DC00897A6F /* RecruitList.swift in Sources */, 833D9C5E2C7375B400982145 /* FetchNoticeDataUseCase.swift in Sources */, D2D4225F2BAC2AC600A8AF04 /* UserPutRequest.swift in Sources */, D22421802C8ADE6200E8B480 /* PasswordValidator.swift in Sources */, @@ -6759,10 +6890,13 @@ 7C6790A92FCC4C600045E163 /* Font+.swift in Sources */, 7C6790AA2FCC4C600045E163 /* Image+.swift in Sources */, 7C016554300AA2CD0013DD7B /* Department.swift in Sources */, + 7C50A1BF3041A44E00897A6F /* RecruitListHostingController.swift in Sources */, 7C0F4A822FEDE6410045E163 /* View+isHidden.swift in Sources */, + 7C50A1E43042C97F00897A6F /* RecruitRole.swift in Sources */, 7C0F4A842FEDE6410045E163 /* View+loadingOverlay.swift in Sources */, 7C0F4A902FEDE6410045E163 /* View+emptyOverlay.swift in Sources */, 7C0F4A862FEDE6410045E163 /* View+onFirstAppear.swift in Sources */, + 7C50A20D3043273A00897A6F /* RecruitNotificationList.swift in Sources */, 7C0F4A882FEE0AE80045E163 /* View+applySketchShadow.swift in Sources */, 7C0F4A8A2FEE0AE80045E163 /* View+hideKeyboardWhenTapAround.swift in Sources */, 7C7CCE742F86866B00E3A54B /* CallVanRestriction.swift in Sources */, @@ -6805,6 +6939,7 @@ 7C333E9530301CB7009D2B89 /* KoinModalConfiguration.swift in Sources */, D8F5C54C2E6F012500FB6708 /* LottieAnimationManageable.swift in Sources */, 83E770FA2D05B5EC00EB3F84 /* BusNoticeDto.swift in Sources */, + 7C50A1C33041A47800897A6F /* RecruitListViewModel.swift in Sources */, D2FB23742C0432CD0098BA2B /* GA4AnalyticsRepository.swift in Sources */, 83DD0DBD2C551FDB00278ABD /* FetchBusTimetableRequest.swift in Sources */, D20AD6F12E029B560067760A /* SendVerificationEmailRequest.swift in Sources */, @@ -6819,6 +6954,7 @@ D874DB512DBF200C0098EED0 /* SendVerificationCodeDto.swift in Sources */, 7CC42B1E2F56F3B200940CE1 /* CallVanButton.swift in Sources */, 7C61D33E2F21FADF005FAC3A /* EditLostItemViewController.swift in Sources */, + 7C50A1D53041F8D500897A6F /* RecruitMeetingType.swift in Sources */, D20AD7052E0367E00067760A /* ResetPasswordSmsRequest.swift in Sources */, 831ABDC42CA29A120099B70C /* NoticeKeywordsFetchResult.swift in Sources */, 7C6790322FCC00150045E163 /* PopLoggable.swift in Sources */, From 2263b509a4b186df7f12ec2c7f3606fb7d99eb57 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 30 Aug 2026 03:58:58 +0900 Subject: [PATCH 010/123] =?UTF-8?q?design:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=A7=84=EC=9E=85=EC=A0=90=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Domain/Model/Home/CategoryModels.swift | 9 +++++++-- .../Category/CategoryHostingController.swift | 14 ++++++++++++++ .../Home/Category/CategoryView.swift | 18 ++++++++++++------ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Koin/Domain/Model/Home/CategoryModels.swift b/Koin/Domain/Model/Home/CategoryModels.swift index 1d2810447..41031cc00 100644 --- a/Koin/Domain/Model/Home/CategoryModels.swift +++ b/Koin/Domain/Model/Home/CategoryModels.swift @@ -20,6 +20,7 @@ enum HomeCategoryItem: Identifiable { case land case business case department + case recruit var id: String { title } @@ -49,13 +50,15 @@ enum HomeCategoryItem: Identifiable { return "복덕방" case .business: return "코인 for Business" + case .recruit: + return "팀원모집" } } var subtitle: String? { switch self { - case .timetable: - return "내 강의 정보 확인하기" + case .recruit: + return "교내 활동 팀원 구하기" case .lostItem: return "분실물 신고 / 조회하기" default: @@ -89,6 +92,8 @@ enum HomeCategoryItem: Identifiable { return .categoryLand case .business: return .categoryBusiness + case .recruit: + return .categoryRecruit } } } diff --git a/Koin/Presentation/Home/Category/CategoryHostingController.swift b/Koin/Presentation/Home/Category/CategoryHostingController.swift index 1a39c77a3..596374562 100644 --- a/Koin/Presentation/Home/Category/CategoryHostingController.swift +++ b/Koin/Presentation/Home/Category/CategoryHostingController.swift @@ -50,6 +50,8 @@ final class CategoryHostingController: UIHostingController, Hostin navigationController?.pushViewController(makeLandViewController(), animated: true) case .showBusiness: presentBusiness() + case .showRecruit: + navigationController?.pushViewController(makeRecruitListViewController(), animated: true) case .showLoginToast: showToastMessageWithButton( @@ -197,6 +199,18 @@ extension CategoryHostingController { let viewModel = LandViewModel(fetchLandListUseCase: fetchLandListUseCase, logAnalyticsEventUseCase: logAnalyticsEventUseCase) return LandViewController(viewModel: viewModel) } + + private func makeRecruitListViewController() -> UIViewController { + let recruitRepository = MockRecruitRepository() + let fetchRecruitListUseCase = DefaultFetchRecruitListUseCase(repository: recruitRepository) + let fetchRecruitNotificationListUseCase = DefaultFetchRecruitNotificationListUseCase(repository: recruitRepository) + let viewModel = RecruitListViewModel( + fetchRecruitListUseCase: fetchRecruitListUseCase, + fetchRecruitNotificationListUseCase: fetchRecruitNotificationListUseCase + ) + let rootView = RecruitListView(viewModel: viewModel) + return RecruitListHostingController(rootView: rootView) + } private func presentBusiness() { if let url = URL(string: "https://owner.koreatech.in/") { diff --git a/Koin/Presentation/Home/Category/CategoryView.swift b/Koin/Presentation/Home/Category/CategoryView.swift index e2bfe625b..eb4bcc4d3 100644 --- a/Koin/Presentation/Home/Category/CategoryView.swift +++ b/Koin/Presentation/Home/Category/CategoryView.swift @@ -24,6 +24,7 @@ struct CategoryView: ActionBindableView { case showChatList case showLand case showBusiness + case showRecruit case showLoginToast } @@ -51,8 +52,8 @@ struct CategoryView: ActionBindableView { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 16) { HStack(spacing: 12) { - CategoryFeaturedButton(item: .timetable) { - didTapItem(.timetable) + CategoryFeaturedButton(item: .recruit) { + didTapItem(.recruit) } CategoryFeaturedButton(item: .lostItem) { didTapItem(.lostItem) @@ -64,7 +65,8 @@ struct CategoryView: ActionBindableView { .facility, .department, .dining, - .shop + .shop, + .timetable ], action: { item in didTapItem(item) @@ -86,7 +88,7 @@ struct CategoryView: ActionBindableView { items: [ .chat, .land, - .business, + .business ], action: { item in didTapItem(item) @@ -150,13 +152,15 @@ private extension CategoryView { return .showLand case .business: return .showBusiness + case .recruit: + return .showRecruit } } private func loggingInfo(for action: Action) -> (label: EventParameter.EventLabel.Campus, value: String)? { switch action { - case .showTimetable: - return (.categoryTimetable, "시간표") + case .showRecruit: + return nil // TODO: 로깅 추가 case .showLostItem: return (.categoryLostProperty, "분실물") case .showFacility: @@ -167,6 +171,8 @@ private extension CategoryView { return (.categoryCampus, "식단") case .showShop: return (.categoryCampus, "주변상점") + case .showTimetable: + return (.categoryTimetable, "시간표") // TODO: 로깅 수정 case .showBusTimetable: return (.categoryTransportation, "버스 시간표") case .showBusRoute: From af20a5b8f11be6194d12c1afe4577467966a37ed Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 30 Aug 2026 04:45:17 +0900 Subject: [PATCH 011/123] =?UTF-8?q?chore:=20=EC=BD=94=EB=93=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Mapper/NotificationHistoryItem+.swift | 19 +++++++++++++++++++ .../NotificationViewController.swift | 2 +- ...tionRowModel+NotificationHistoryItem.swift | 19 ------------------- koin.xcodeproj/project.pbxproj | 14 +++++++------- 4 files changed, 27 insertions(+), 27 deletions(-) create mode 100644 Koin/Presentation/Home/Notification/Mapper/NotificationHistoryItem+.swift delete mode 100644 Koin/Presentation/Home/Notification/Support/NotificationRowModel+NotificationHistoryItem.swift diff --git a/Koin/Presentation/Home/Notification/Mapper/NotificationHistoryItem+.swift b/Koin/Presentation/Home/Notification/Mapper/NotificationHistoryItem+.swift new file mode 100644 index 000000000..570020ed4 --- /dev/null +++ b/Koin/Presentation/Home/Notification/Mapper/NotificationHistoryItem+.swift @@ -0,0 +1,19 @@ +// +// NotificationHistoryItem+.swift +// koin +// +// Created by 홍기정 on 8/19/26. +// + +extension NotificationHistoryItem { + func toNotificationRowModel() -> NotificationRowModel { + NotificationRowModel( + id: id, + isRead: isRead, + icon: icon, + title: title, + content: content, + dateText: dateText + ) + } +} diff --git a/Koin/Presentation/Home/Notification/NotificationViewController.swift b/Koin/Presentation/Home/Notification/NotificationViewController.swift index f745f104b..51a9d8ebd 100644 --- a/Koin/Presentation/Home/Notification/NotificationViewController.swift +++ b/Koin/Presentation/Home/Notification/NotificationViewController.swift @@ -56,7 +56,7 @@ private extension NotificationViewController { switch event { case .updateNotifications(let notifications): - notificationListView.update(items: notifications.map { NotificationRowModel(from: $0) }) + notificationListView.update(items: notifications.map { $0.toNotificationRowModel() }) case .selectedNotification(let notification): handleNavigation(notification) case .showToast(let message): diff --git a/Koin/Presentation/Home/Notification/Support/NotificationRowModel+NotificationHistoryItem.swift b/Koin/Presentation/Home/Notification/Support/NotificationRowModel+NotificationHistoryItem.swift deleted file mode 100644 index 26b2018b4..000000000 --- a/Koin/Presentation/Home/Notification/Support/NotificationRowModel+NotificationHistoryItem.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// NotificationRowModel+NotificationHistoryItem.swift -// koin -// -// Created by 홍기정 on 8/19/26. -// - -extension NotificationRowModel { - init(from item: NotificationHistoryItem) { - self.init( - id: item.id, - isRead: item.isRead, - icon: item.icon, - title: item.title, - content: item.content, - dateText: item.dateText - ) - } -} diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 78bb992f2..19a34cae2 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -799,7 +799,7 @@ D0A71C4F2F1B8340009E2D71 /* KoinDropdownHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */; }; D19A00013000000000000002 /* NotificationRowModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A00013000000000000001 /* NotificationRowModel.swift */; }; D19A00013000000000000004 /* NotificationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A00013000000000000003 /* NotificationListView.swift */; }; - D19A00013000000000000006 /* NotificationRowModel+NotificationHistoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A00013000000000000005 /* NotificationRowModel+NotificationHistoryItem.swift */; }; + D19A00013000000000000006 /* NotificationHistoryItem+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A00013000000000000005 /* NotificationHistoryItem+.swift */; }; D19A10013000000000000002 /* ChatListModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A10013000000000000001 /* ChatListModel.swift */; }; D19A10013000000000000004 /* ChatMessageRowModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A10013000000000000003 /* ChatMessageRowModel.swift */; }; D19A10013000000000000006 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A10013000000000000005 /* ChatListView.swift */; }; @@ -1849,7 +1849,7 @@ D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdownHost.swift; sourceTree = ""; }; D19A00013000000000000001 /* NotificationRowModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRowModel.swift; sourceTree = ""; }; D19A00013000000000000003 /* NotificationListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationListView.swift; sourceTree = ""; }; - D19A00013000000000000005 /* NotificationRowModel+NotificationHistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationRowModel+NotificationHistoryItem.swift"; sourceTree = ""; }; + D19A00013000000000000005 /* NotificationHistoryItem+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationHistoryItem+.swift"; sourceTree = ""; }; D19A10013000000000000001 /* ChatListModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListModel.swift; sourceTree = ""; }; D19A10013000000000000003 /* ChatMessageRowModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageRowModel.swift; sourceTree = ""; }; D19A10013000000000000005 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = ""; }; @@ -2469,7 +2469,7 @@ 7C457B282FCE1BA80011D338 /* Notification */ = { isa = PBXGroup; children = ( - D19A0001300000000000000A /* Support */, + D19A0001300000000000000A /* Mapper */, 7C8A941C2FD0A94F00DEA6F5 /* NotificationViewController.swift */, 7C8A941D2FD0A94F00DEA6F5 /* NotificationViewModel.swift */, ); @@ -4931,12 +4931,12 @@ path = Models; sourceTree = ""; }; - D19A0001300000000000000A /* Support */ = { + D19A0001300000000000000A /* Mapper */ = { isa = PBXGroup; children = ( - D19A00013000000000000005 /* NotificationRowModel+NotificationHistoryItem.swift */, + D19A00013000000000000005 /* NotificationHistoryItem+.swift */, ); - path = Support; + path = Mapper; sourceTree = ""; }; D19A1001300000000000000C /* Chat */ = { @@ -6780,7 +6780,7 @@ 7C8BFCE42FD04B8000963679 /* NotificationHistoryItem.swift in Sources */, D19A00013000000000000002 /* NotificationRowModel.swift in Sources */, D19A00013000000000000004 /* NotificationListView.swift in Sources */, - D19A00013000000000000006 /* NotificationRowModel+NotificationHistoryItem.swift in Sources */, + D19A00013000000000000006 /* NotificationHistoryItem+.swift in Sources */, D19A10013000000000000002 /* ChatListModel.swift in Sources */, D19A10013000000000000004 /* ChatMessageRowModel.swift in Sources */, D19A10013000000000000006 /* ChatListView.swift in Sources */, From ac62deff281392c6ad9c1db84122390d70a86025 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 30 Aug 2026 19:46:46 +0900 Subject: [PATCH 012/123] =?UTF-8?q?design:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=95=8C=EB=A6=BC=ED=99=94=EB=A9=B4=20UI=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/MockRecruitRepository.swift | 36 +++- .../Model/Recruit/RecruitNotification.swift | 6 +- .../Recruit/RecruitNotificationList.swift | 24 ++- .../Domain/Repository/RecruitRepository.swift | 4 + .../FetchRecruitListUseCase.swift | 0 .../DeleteRecruitNotificationUseCase.swift | 29 +++ .../FetchRecruitNotificationListUseCase.swift | 0 ...MarkAsReadRecruitNotificationUseCase.swift | 29 +++ .../RecruitListHostingController.swift | 16 +- .../RecruitList/RecruitListViewModel.swift | 12 +- .../Mapper/RecruitNotification+.swift | 28 +++ ...ecruitNotificationListViewController.swift | 175 ++++++++++++++++++ .../RecruitNotificationListViewModel.swift | 131 +++++++++++++ koin.xcodeproj/project.pbxproj | 56 +++++- 14 files changed, 521 insertions(+), 25 deletions(-) rename Koin/Domain/UseCase/Recruit/{ => RecruitList}/FetchRecruitListUseCase.swift (100%) create mode 100644 Koin/Domain/UseCase/Recruit/RecruitNotificationList/DeleteRecruitNotificationUseCase.swift rename Koin/Domain/UseCase/Recruit/{ => RecruitNotificationList}/FetchRecruitNotificationListUseCase.swift (100%) create mode 100644 Koin/Domain/UseCase/Recruit/RecruitNotificationList/MarkAsReadRecruitNotificationUseCase.swift create mode 100644 Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotification+.swift create mode 100644 Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift create mode 100644 Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index ed6c52b88..1cde7cf82 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -94,8 +94,8 @@ final class MockRecruitRepository: RecruitRepository { chatRoomId: 1, type: .chat, title: "팀원모집 @@님의 메시지", - description: "메세지메세지", - time: "2시간 전", + content: "메세지메세지", + dateText: "2시간 전", isRead: false ), RecruitNotification( @@ -103,8 +103,8 @@ final class MockRecruitRepository: RecruitRepository { chatRoomId: 0, type: .default, title: "팀원 모집 지원 승인", - description: "지원했던 AI 공모전 팀원 모집에 승인되었어요.", - time: "2시간 전", + content: "지원했던 AI 공모전 팀원 모집에 승인되었어요.", + dateText: "2시간 전", isRead: false ), RecruitNotification( @@ -112,8 +112,8 @@ final class MockRecruitRepository: RecruitRepository { chatRoomId: 0, type: .default, title: "팀원 모집 지원 거절", - description: "지원했던 AI 공모전 팀원 모집에 승인 거절되었어요.\n다른 모집글에 지원해보세요.", - time: "2시간 전", + content: "지원했던 AI 공모전 팀원 모집에 승인 거절되었어요.\n다른 모집글에 지원해보세요.", + dateText: "2시간 전", isRead: false ), RecruitNotification( @@ -121,8 +121,8 @@ final class MockRecruitRepository: RecruitRepository { chatRoomId: 0, type: .default, title: "팀원 모집글 삭제", - description: "지원했던 AI 공모전 팀원 모집글이 삭제되었어요.\n다른 모집글에 지원해보세요.", - time: "2시간 전", + content: "지원했던 AI 공모전 팀원 모집글이 삭제되었어요.\n다른 모집글에 지원해보세요.", + dateText: "2시간 전", isRead: false ), RecruitNotification( @@ -130,10 +130,26 @@ final class MockRecruitRepository: RecruitRepository { chatRoomId: 0, type: .default, title: "팀원 모집기간 종료", - description: "작성했던 AI 공모전 팀원 모집 기간이 종료되었어요.", - time: "2시간 전", + content: "작성했던 AI 공모전 팀원 모집 기간이 종료되었어요.", + dateText: "2시간 전", isRead: false ) ]) } + + func deleteNotification(_ id: Int) async throws -> Void { + + } + + func deleteAllNotification() async throws -> Void { + + } + + func markAsReadNotification(_ id: Int) async throws -> Void { + + } + + func markAllAsReadNotification() async throws -> Void { + + } } diff --git a/Koin/Domain/Model/Recruit/RecruitNotification.swift b/Koin/Domain/Model/Recruit/RecruitNotification.swift index 91a225828..c5c5b5cdf 100644 --- a/Koin/Domain/Model/Recruit/RecruitNotification.swift +++ b/Koin/Domain/Model/Recruit/RecruitNotification.swift @@ -14,9 +14,9 @@ struct RecruitNotification { let type: RecruitNotificationType let title: String - let description: String - let time: String - let isRead: Bool + let content: String + let dateText: String + var isRead: Bool } enum RecruitNotificationType { diff --git a/Koin/Domain/Model/Recruit/RecruitNotificationList.swift b/Koin/Domain/Model/Recruit/RecruitNotificationList.swift index 16593d0cb..7e2495fb7 100644 --- a/Koin/Domain/Model/Recruit/RecruitNotificationList.swift +++ b/Koin/Domain/Model/Recruit/RecruitNotificationList.swift @@ -8,11 +8,33 @@ import Foundation struct RecruitNotificationList { - let notifications: [RecruitNotification] + var notifications: [RecruitNotification] } extension RecruitNotificationList { var hasUnread: Bool { !notifications.filter({ !$0.isRead }).isEmpty } + + mutating func delete(id: Int) { + notifications = notifications.filter { $0.id != id } + } + + mutating func deleteAll() { + notifications.removeAll() + } + + mutating func markAsRead(id: Int) { + if let index = notifications.firstIndex(where: { $0.id == id }) { + var notification = notifications[index] + notification.isRead = true + notifications[index] = notification + } + } + + mutating func markAllAsRead() { + for index in notifications.indices { + notifications[index].isRead = true + } + } } diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index cac6ab271..d194afc40 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -10,4 +10,8 @@ import Foundation protocol RecruitRepository { func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList func fetchNotificationList() async throws -> RecruitNotificationList + func deleteNotification(_ id: Int) async throws -> Void + func deleteAllNotification() async throws -> Void + func markAsReadNotification(_ id: Int) async throws -> Void + func markAllAsReadNotification() async throws -> Void } diff --git a/Koin/Domain/UseCase/Recruit/FetchRecruitListUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitList/FetchRecruitListUseCase.swift similarity index 100% rename from Koin/Domain/UseCase/Recruit/FetchRecruitListUseCase.swift rename to Koin/Domain/UseCase/Recruit/RecruitList/FetchRecruitListUseCase.swift diff --git a/Koin/Domain/UseCase/Recruit/RecruitNotificationList/DeleteRecruitNotificationUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitNotificationList/DeleteRecruitNotificationUseCase.swift new file mode 100644 index 000000000..48c3bf954 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitNotificationList/DeleteRecruitNotificationUseCase.swift @@ -0,0 +1,29 @@ +// +// DeleteRecruitNotificationUseCase.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +protocol DeleteRecruitNotificationUseCase { + func execute(id: Int?) async throws -> Void +} + +final class DefaultDeleteRecruitNotificationUseCase: DeleteRecruitNotificationUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(id: Int? = nil) async throws -> Void { + if let id { + try await repository.deleteNotification(id) + } else { + try await repository.deleteAllNotification() + } + } +} diff --git a/Koin/Domain/UseCase/Recruit/FetchRecruitNotificationListUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitNotificationList/FetchRecruitNotificationListUseCase.swift similarity index 100% rename from Koin/Domain/UseCase/Recruit/FetchRecruitNotificationListUseCase.swift rename to Koin/Domain/UseCase/Recruit/RecruitNotificationList/FetchRecruitNotificationListUseCase.swift diff --git a/Koin/Domain/UseCase/Recruit/RecruitNotificationList/MarkAsReadRecruitNotificationUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitNotificationList/MarkAsReadRecruitNotificationUseCase.swift new file mode 100644 index 000000000..38ca3c7ab --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitNotificationList/MarkAsReadRecruitNotificationUseCase.swift @@ -0,0 +1,29 @@ +// +// MarkAsReadRecruitNotificationUseCase.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +protocol MarkAsReadRecruitNotificationUseCase { + func execute(id: Int?) async throws -> Void +} + +final class DefaultMarkAsReadRecruitNotificationUseCase: MarkAsReadRecruitNotificationUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(id: Int?) async throws -> Void { + if let id { + try await repository.markAsReadNotification(id) + } else { + try await repository.markAllAsReadNotification() + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index d5a4a4ef6..b12a73a78 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -80,8 +80,18 @@ extension RecruitListHostingController { } return } - private func showRecruitNotification() { - // TODO + private func showRecruitNotificationList() { + let recruitRepository = MockRecruitRepository() + let fetchRecruitNotificationListUseCase = DefaultFetchRecruitNotificationListUseCase(repository: recruitRepository) + let markAsReadRecruitNotificationUseCase = DefaultMarkAsReadRecruitNotificationUseCase(repository: recruitRepository) + let deleteRecruitNotificationUseCase = DefaultDeleteRecruitNotificationUseCase(repository: recruitRepository) + let viewModel = RecruitNotificationListViewModel( + fetchRecruitNotificationListUseCase: fetchRecruitNotificationListUseCase, + markAsReadRecruitNotificationUseCase: markAsReadRecruitNotificationUseCase, + deleteRecruitNotificationUseCase: deleteRecruitNotificationUseCase + ) + let viewController = RecruitNotificationListViewController(viewModel: viewModel) + navigationController?.pushViewController(viewController, animated: true) } private func showRecruitProfile() { // TODO @@ -117,7 +127,7 @@ extension RecruitListHostingController { showLoginToast() return } - showRecruitNotification() + showRecruitNotificationList() } @objc private func profileButtonTapped() { diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift index 21a6d228e..ef1f55295 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift @@ -47,16 +47,16 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { } // MARK: - UseCase - private let fetchListUseCase: FetchRecruitListUseCase - private let fetchNotificationListUseCase: FetchRecruitNotificationListUseCase + private let fetchRecruitListUseCase: FetchRecruitListUseCase + private let fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase // MARK: - Initializer init( fetchRecruitListUseCase: FetchRecruitListUseCase, fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase ) { - self.fetchListUseCase = fetchRecruitListUseCase - self.fetchNotificationListUseCase = fetchRecruitNotificationListUseCase + self.fetchRecruitListUseCase = fetchRecruitListUseCase + self.fetchRecruitNotificationListUseCase = fetchRecruitNotificationListUseCase } // MARK: - Public @@ -123,7 +123,7 @@ extension RecruitListViewModel { } filterState.page = page - var response = try await fetchListUseCase.execute(filter: filterState) + var response = try await fetchRecruitListUseCase.execute(filter: filterState) response.recruits = (recruitList?.recruits ?? []) + response.recruits response.recruits.removeDuplicates() @@ -138,7 +138,7 @@ extension RecruitListViewModel { private func fetchHasUnreadNotification() { Task { do { - self.hasUnreadNotification = try await fetchNotificationListUseCase.execute().hasUnread + self.hasUnreadNotification = try await fetchRecruitNotificationListUseCase.execute().hasUnread } catch { if let error = (error as? ErrorResponse), error.statusCode != 401 { diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotification+.swift b/Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotification+.swift new file mode 100644 index 000000000..e38c82b78 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotification+.swift @@ -0,0 +1,28 @@ +// +// RecruitNotification+.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import Foundation + +extension RecruitNotification { + var icon: ImageAsset { + switch type { + case .chat: ImageAsset.recruitNotificationChat + default: ImageAsset.recruitNotificationMember + } + } + + func toNotificationRowModel() -> NotificationRowModel { + NotificationRowModel( + id: "\(id)", + isRead: isRead, + icon: icon, + title: title, + content: content, + dateText: dateText + ) + } +} diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift new file mode 100644 index 000000000..40ebce0ba --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift @@ -0,0 +1,175 @@ +// +// RecruitNotificationListViewController.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import UIKit +import Combine +import SnapKit + +final class RecruitNotificationListViewController: UIViewController { + + // MARK: - Properties + private let viewModel: RecruitNotificationListViewModel + private let inputSubject = PassthroughSubject() + private var subscriptions = Set() + + private var notificationList: RecruitNotificationList? + + // MARK: - UI Components + private let notificationListView = NotificationListView() + + // MARK: - Initialization + init(viewModel: RecruitNotificationListViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + configureView() + configureNavigationBar() + bind() + notificationListView.startLoading() + inputSubject.send(.viewDidLoad) + title = "알림" + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar(style: .empty) + } +} + +// MARK: - Bind + +private extension RecruitNotificationListViewController { + func bind() { + viewModel.transform(with: inputSubject.eraseToAnyPublisher()) + .receive(on: DispatchQueue.main) + .sink { [weak self] output in + guard let self else { return } + switch output { + case .updateNotifications(let notificationList): + updateNotificationList(notificationList) + case .showToast(let message): + showToastMessage(message: message) + } + } + .store(in: &subscriptions) + + notificationListView.deletePublisher + .sink { [weak self] id in + guard let self, + let id = Int(id) else { + return + } + self.notificationList?.delete(id: id) + self.inputSubject.send(.deleteNotification(id: id)) + self.showToastMessage(message: "알림이 삭제되었습니다.") + } + .store(in: &subscriptions) + + notificationListView.itemTappedPublisher + .sink { [weak self] id in + guard let self, + let id = Int(id) else { + return + } + self.notificationList?.markAsRead(id: id) + handleNavigation(id: id) + inputSubject.send(.didTapNotification(id: id)) + } + .store(in: &subscriptions) + + notificationListView.refreshPublisher + .sink { [weak self] in + self?.inputSubject.send(.reload) + } + .store(in: &subscriptions) + } +} + +extension RecruitNotificationListViewController { + private func updateNotificationList(_ notificationList: RecruitNotificationList) { + self.notificationList = notificationList + + notificationListView.update( + items: notificationList.notifications.map { + $0.toNotificationRowModel() + } + ) + } + private func handleNavigation(id: Int) { + guard let notification = notificationList?.notifications.first(where: { $0.id == id }) else { + return + } + // TODO + } +} + +extension RecruitNotificationListViewController { + @objc func rightBarButtonItemTapped() { + showPopUpView() + } + + private func showPopUpView() { + let popUpViewController = NotificationPopUpViewController( + markAllAsRead: { [weak self] in + self?.inputSubject.send(.markAllAsRead) + self?.notificationList?.markAllAsRead() + self?.notificationListView.markAllAsRead() + }, + deleteAll: { [weak self] in + self?.inputSubject.send(.deleteAllNotifications) + self?.notificationList?.deleteAll() + self?.notificationListView.deleteAll() + self?.showToastMessage(message: "알림이 삭제되었습니다.") + } + ) + popUpViewController.modalPresentationStyle = .overFullScreen + navigationController?.present( + popUpViewController, + animated: false + ) + } +} + +private extension RecruitNotificationListViewController { + private func configureNavigationBar() { + let rightBarButtonItem = UIBarButtonItem( + image: .appImage(asset: .threeCircle), + style: .plain, + target: self, + action: #selector(rightBarButtonItemTapped) + ) + navigationItem.rightBarButtonItem = rightBarButtonItem + } + + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + view.backgroundColor = UIColor.ColorSystem.Neutral.gray0 + } + + private func setUpLayouts() { + view.addSubview(notificationListView) + } + + private func setUpConstraints() { + notificationListView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) + $0.leading.trailing.bottom.equalToSuperview() + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift new file mode 100644 index 000000000..cbd85f71b --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift @@ -0,0 +1,131 @@ +// +// RecruitNotificationListViewModel.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import Combine +import Foundation + +final class RecruitNotificationListViewModel: ViewModelProtocol { + + enum Input { + case viewDidLoad + case reload + case didTapNotification(id: Int) + case deleteNotification(id: Int) + case deleteAllNotifications + case markAllAsRead + } + + enum Output { + case updateNotifications(RecruitNotificationList) + case showToast(String) + } + + // MARK: - Properties + private let fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase + private let markAsReadRecruitNotificationUseCase: MarkAsReadRecruitNotificationUseCase + private let deleteRecruitNotificationUseCase: DeleteRecruitNotificationUseCase + private let outputSubject = PassthroughSubject() + private var subscriptions = Set() + + // MARK: - Initializer + init( + fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase, + markAsReadRecruitNotificationUseCase: MarkAsReadRecruitNotificationUseCase, + deleteRecruitNotificationUseCase: DeleteRecruitNotificationUseCase + ) { + self.fetchRecruitNotificationListUseCase = fetchRecruitNotificationListUseCase + self.markAsReadRecruitNotificationUseCase = markAsReadRecruitNotificationUseCase + self.deleteRecruitNotificationUseCase = deleteRecruitNotificationUseCase + } + + // MARK: - Transform + func transform(with input: AnyPublisher) -> AnyPublisher { + input + .receive(on: DispatchQueue.main) + .sink { [weak self] input in + switch input { + case .viewDidLoad, .reload: + self?.fetchNotificationList() + case .didTapNotification(let id): + self?.markAsRead(id: id) + case .deleteNotification(let id): + self?.deleteNotification(id: id) + case .deleteAllNotifications: + self?.deleteAllNotifications() + case .markAllAsRead: + self?.markAllAsRead() + } + } + .store(in: &subscriptions) + + return outputSubject.eraseToAnyPublisher() + } +} + +private extension RecruitNotificationListViewModel { + + private func fetchNotificationList() { + Task { + do { + let notificationList = try await fetchRecruitNotificationListUseCase.execute() + outputSubject.send(.updateNotifications(notificationList)) + } catch { + if let message = (error as? ErrorResponse)?.message { + outputSubject.send(.showToast(message)) + } + } + } + } + + private func deleteNotification(id: Int) { + Task { + do { + try await deleteRecruitNotificationUseCase.execute(id: id) + } catch { + if let message = (error as? ErrorResponse)?.message { + outputSubject.send(.showToast(message)) + } + } + } + } + + private func deleteAllNotifications() { + Task { + do { + try await deleteRecruitNotificationUseCase.execute(id: nil) + } catch { + if let message = (error as? ErrorResponse)?.message { + outputSubject.send(.showToast(message)) + } + } + } + } + + private func markAsRead(id: Int) { + Task { + do { + try await markAsReadRecruitNotificationUseCase.execute(id: id) + } catch { + if let message = (error as? ErrorResponse)?.message { + outputSubject.send(.showToast(message)) + } + } + } + } + + private func markAllAsRead() { + Task { + do { + try await markAsReadRecruitNotificationUseCase.execute(id: nil) + } catch { + if let message = (error as? ErrorResponse)?.message { + outputSubject.send(.showToast(message)) + } + } + } + } +} diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 19a34cae2..39a4eb4cf 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -357,6 +357,11 @@ 7CA6868D2FFCE867007937E9 /* UpdateNotificationHistoryUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA6868C2FFCE867007937E9 /* UpdateNotificationHistoryUseCase.swift */; }; 7CA6868F2FFCF42E007937E9 /* NotificationHistoryError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA6868E2FFCF42E007937E9 /* NotificationHistoryError.swift */; }; 7CA686902FFCF42E007937E9 /* NotificationHistoryError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA6868E2FFCF42E007937E9 /* NotificationHistoryError.swift */; }; + 7CACF86530436CFF00AC2828 /* RecruitNotificationListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CACF86430436CFF00AC2828 /* RecruitNotificationListViewController.swift */; }; + 7CACF86730436D2600AC2828 /* RecruitNotificationListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CACF86630436D2600AC2828 /* RecruitNotificationListViewModel.swift */; }; + 7CACF86930436E7700AC2828 /* RecruitNotification+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CACF86830436E7700AC2828 /* RecruitNotification+.swift */; }; + 7CACF86B3044093F00AC2828 /* DeleteRecruitNotificationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CACF86A3044093F00AC2828 /* DeleteRecruitNotificationUseCase.swift */; }; + 7CACF87030440CEE00AC2828 /* MarkAsReadRecruitNotificationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CACF86F30440CEE00AC2828 /* MarkAsReadRecruitNotificationUseCase.swift */; }; 7CB0618F2F320F3A00E83CA9 /* NMapsMap in Frameworks */ = {isa = PBXBuildFile; productRef = 7CB0618E2F320F3A00E83CA9 /* NMapsMap */; }; 7CB061932F32100900E83CA9 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 7CB061922F32100900E83CA9 /* Alamofire */; }; 7CB061992F32186800E83CA9 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7CB061982F32186800E83CA9 /* SnapKit */; }; @@ -1420,6 +1425,11 @@ 7CA6868A2FFCE750007937E9 /* DeleteNotificationHistoryUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteNotificationHistoryUseCase.swift; sourceTree = ""; }; 7CA6868C2FFCE867007937E9 /* UpdateNotificationHistoryUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateNotificationHistoryUseCase.swift; sourceTree = ""; }; 7CA6868E2FFCF42E007937E9 /* NotificationHistoryError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationHistoryError.swift; sourceTree = ""; }; + 7CACF86430436CFF00AC2828 /* RecruitNotificationListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotificationListViewController.swift; sourceTree = ""; }; + 7CACF86630436D2600AC2828 /* RecruitNotificationListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotificationListViewModel.swift; sourceTree = ""; }; + 7CACF86830436E7700AC2828 /* RecruitNotification+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecruitNotification+.swift"; sourceTree = ""; }; + 7CACF86A3044093F00AC2828 /* DeleteRecruitNotificationUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteRecruitNotificationUseCase.swift; sourceTree = ""; }; + 7CACF86F30440CEE00AC2828 /* MarkAsReadRecruitNotificationUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkAsReadRecruitNotificationUseCase.swift; sourceTree = ""; }; 7CB061BE2F32312E00E83CA9 /* DPDConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPDConstants.swift; sourceTree = ""; }; 7CB061BF2F32312E00E83CA9 /* DPDKeyboardListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPDKeyboardListener.swift; sourceTree = ""; }; 7CB061C02F32312E00E83CA9 /* DPDUIView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DPDUIView+Extension.swift"; sourceTree = ""; }; @@ -2655,6 +2665,7 @@ isa = PBXGroup; children = ( 7C50A1B43041A25D00897A6F /* RecruitList */, + 7CACF86130436C1900AC2828 /* RecruitNotificationList */, ); path = Recruit; sourceTree = ""; @@ -2733,8 +2744,8 @@ 7C50A1FB3042E99E00897A6F /* Recruit */ = { isa = PBXGroup; children = ( - 7C50A1FC3042E9AB00897A6F /* FetchRecruitListUseCase.swift */, - 7C50A20E3043278300897A6F /* FetchRecruitNotificationListUseCase.swift */, + 7CACF86C3044096200AC2828 /* RecruitList */, + 7CACF86E3044097800AC2828 /* RecruitNotificationList */, ); path = Recruit; sourceTree = ""; @@ -3211,6 +3222,42 @@ path = Core; sourceTree = ""; }; + 7CACF86130436C1900AC2828 /* RecruitNotificationList */ = { + isa = PBXGroup; + children = ( + 7CACF86230436C4A00AC2828 /* Mapper */, + 7CACF86430436CFF00AC2828 /* RecruitNotificationListViewController.swift */, + 7CACF86630436D2600AC2828 /* RecruitNotificationListViewModel.swift */, + ); + path = RecruitNotificationList; + sourceTree = ""; + }; + 7CACF86230436C4A00AC2828 /* Mapper */ = { + isa = PBXGroup; + children = ( + 7CACF86830436E7700AC2828 /* RecruitNotification+.swift */, + ); + path = Mapper; + sourceTree = ""; + }; + 7CACF86C3044096200AC2828 /* RecruitList */ = { + isa = PBXGroup; + children = ( + 7C50A1FC3042E9AB00897A6F /* FetchRecruitListUseCase.swift */, + ); + path = RecruitList; + sourceTree = ""; + }; + 7CACF86E3044097800AC2828 /* RecruitNotificationList */ = { + isa = PBXGroup; + children = ( + 7C50A20E3043278300897A6F /* FetchRecruitNotificationListUseCase.swift */, + 7CACF86A3044093F00AC2828 /* DeleteRecruitNotificationUseCase.swift */, + 7CACF86F30440CEE00AC2828 /* MarkAsReadRecruitNotificationUseCase.swift */, + ); + path = RecruitNotificationList; + sourceTree = ""; + }; 7CB061C12F32312E00E83CA9 /* helpers */ = { isa = PBXGroup; children = ( @@ -6207,6 +6254,7 @@ D29F21C72C4DF15200994554 /* FindPasswordUseCase.swift in Sources */, D2DA83932C181A2100A2F156 /* FetchShopMenuListUseCase.swift in Sources */, D2C163D82E252DB500DC68A8 /* ChangePasswordUseCase.swift in Sources */, + 7CACF86730436D2600AC2828 /* RecruitNotificationListViewModel.swift in Sources */, D29F21D32C4E17C300994554 /* TimetableAPI.swift in Sources */, 83E770FE2D05D76200EB3F84 /* ShuttleBusTimetableDto.swift in Sources */, D29F21E22C4F303400994554 /* FetchDiningListRequest.swift in Sources */, @@ -6607,6 +6655,7 @@ 7C8A94292FD0A97400DEA6F5 /* NotificationTableView.swift in Sources */, 7C6790962FCC48A60045E163 /* UIImageView+.swift in Sources */, 7C8A942D2FD2C01A00DEA6F5 /* FetchEventCountUseCase.swift in Sources */, + 7CACF86930436E7700AC2828 /* RecruitNotification+.swift in Sources */, 7C6790972FCC48A60045E163 /* CALayer+.swift in Sources */, 7C6790982FCC48A60045E163 /* UITextFiled+Publisher.swift in Sources */, 7C6790992FCC48A60045E163 /* UIFont+.swift in Sources */, @@ -6630,6 +6679,7 @@ 7CED91CD2EF2D1F900457128 /* LostItemChatViewModel.swift in Sources */, 7C640400300F395300A9589F /* DepartmentRepository.swift in Sources */, 7CED91CE2EF2D1F900457128 /* NotiViewModel.swift in Sources */, + 7CACF86530436CFF00AC2828 /* RecruitNotificationListViewController.swift in Sources */, 7CED91CF2EF2D1F900457128 /* ManageNoticeKeywordViewModel.swift in Sources */, 7CED91D22EF2D1F900457128 /* LandImageCollectionViewCell.swift in Sources */, 7CED91D72EF2D1F900457128 /* ShopDetailViewModel.swift in Sources */, @@ -6798,6 +6848,7 @@ D85939772DC12D0900CE4CB2 /* CheckDuplicatedIdRequest.swift in Sources */, D208A7CF2CABB85B007040E7 /* DefaultCoreRepository.swift in Sources */, 83DD0DB92C5378E600278ABD /* SearchBusInfoResult.swift in Sources */, + 7CACF86B3044093F00AC2828 /* DeleteRecruitNotificationUseCase.swift in Sources */, 7C58FE922F5FAC0100A2737B /* CallVanDataView.swift in Sources */, D27DD0CA2BA608310081FD36 /* DiningDto.swift in Sources */, D20AB94E2C553427006BC684 /* NotiRepository.swift in Sources */, @@ -6910,6 +6961,7 @@ D208A7A62CA111EB007040E7 /* AssignAbTestRequest.swift in Sources */, D223D61B2D9C0FEC00C29BE6 /* FetchBannerUseCase.swift in Sources */, D21572662CEDDDC60061E725 /* FetchMySemesterUseCase.swift in Sources */, + 7CACF87030440CEE00AC2828 /* MarkAsReadRecruitNotificationUseCase.swift in Sources */, B68D2CEB2EA50C1E00F3B479 /* UIViewController+Toast.swift in Sources */, D249CD2A2C12DD34000813F9 /* DiningAPI.swift in Sources */, 83E770FC2D05B6DD00EB3F84 /* FetchEmergencyNoticeUseCase.swift in Sources */, From a2d745f81ba7ed2cbdb6aff1904a0458286cabfa Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 8 Sep 2026 02:06:26 +0900 Subject: [PATCH 013/123] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitListHostingController.swift | 11 ++++- .../Recruit/RecruitList/RecruitListView.swift | 35 +++++++++------ .../RecruitList/RecruitListViewModel.swift | 43 ++++++++++++++----- ...ecruitNotificationListViewController.swift | 5 +-- .../RecruitNotificationListViewModel.swift | 4 ++ .../Views/NotificationListView.swift | 4 ++ 6 files changed, 73 insertions(+), 29 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index b12a73a78..8f10eae57 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -9,6 +9,11 @@ import SwiftUI final class RecruitListHostingController: UIHostingController, HostingControllerProtocol { + // MARK: - Layout + private var toastMessageBottomInset: CGFloat { + 23 + 43 + 12 + } + // MARK: - UI Components private let notificationBarButton = UIButton(type: .system) private let profileBarButton = UIButton(type: .system) @@ -45,7 +50,7 @@ final class RecruitListHostingController: UIHostingController, case .showFilterBottomSheet(let filterState, let onApplyTapped): showFilterBottomSheet(filterState, onApplyTapped) case .showToast(let message): - showToastMessage(message: message) + showToastMessage(message: message, bottomInset: toastMessageBottomInset) case .showLoginToast: showLoginToast() case .showRecruitPost: @@ -72,9 +77,11 @@ extension RecruitListHostingController { } private func showLoginToast() { + showToastMessageWithButton( message: "로그인이 필요한 기능입니다.", - buttonTitle: "로그인" + buttonTitle: "로그인", + bottomInset: toastMessageBottomInset ) { [weak self] in self?.navigateToLogin() } diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift index bc1fcec8e..651f1139c 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -20,6 +20,10 @@ struct RecruitListView: ActionBindableView { var sendAction: ((Action) -> Void) = { _ in } @State private var viewModel: RecruitListViewModel + var recruits: [RecruitDetail] { + viewModel.recruitList?.recruits ?? [] + } + // MARK: - Initializer init(viewModel: RecruitListViewModel) { self.viewModel = viewModel @@ -56,29 +60,29 @@ struct RecruitListView: ActionBindableView { .frame(maxWidth: .infinity, alignment: .leading) LazyVStack(spacing: 8) { - ForEach(viewModel.recruitList?.recruits ?? []) { row in + ForEach(recruits) { recruit in Button { } label: { - RecruitListRowView(model: row) + RecruitListRowView(model: recruit) } .frame(maxWidth: .infinity) .buttonStyle(.plain) + .onAppear { + if recruits.last?.id == recruit.id, + viewModel.hasNextPage, + !viewModel.isLoading { + viewModel.execute(.loadNextPage) + } + } } } .frame(maxWidth: .infinity) - if viewModel.hasNextPage { - ProgressView() - .frame(maxWidth: .infinity, idealHeight: 77, alignment: .center) - .task { - viewModel.execute(.loadNextPage) - } - .isHidden(!viewModel.isLoading) - - } else { - Spacer(minLength: 77) - } + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 77) + .isHidden(!(viewModel.hasNextPage && viewModel.isLoading), shouldOccupySpace: true) } .padding(.horizontal, 22) .background { @@ -96,7 +100,10 @@ struct RecruitListView: ActionBindableView { .padding(EdgeInsets(top: 0, leading: 0, bottom: 23, trailing: 30)) } .onFirstAppear { - viewModel.execute(.viewDidAppear) + viewModel.execute(.onFirstAppear) + } + .onAppear { + viewModel.execute(.onAppear) } .onChange(of: viewModel.errorMessage) { if let message = viewModel.errorMessage { diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift index ef1f55295..517868de6 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift @@ -12,7 +12,8 @@ import Observation final class RecruitListViewModel: SwiftUIViewModelProtocol { enum Input { - case viewDidAppear + case onFirstAppear + case onAppear case refresh case updateFilter(keyword: String? = nil, filterState: RecruitListFilter? = nil) case deleteFilter(rawvalue: String) @@ -28,6 +29,7 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { private(set) var isLoading: Bool = false private(set) var hasUnreadNotification: Bool = false private(set) var errorMessage: String? = nil + private var fetchListTask: Task? var isEmpty: Bool { if let recruitList { @@ -62,7 +64,11 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { // MARK: - Public func execute(_ input: Input) { switch input { - case .viewDidAppear, .refresh: + case .onFirstAppear: + fetchList() + case .onAppear: + fetchHasUnreadNotification() + case .refresh: fetchList() fetchHasUnreadNotification() case .updateFilter(let keyword, let filterState): @@ -84,20 +90,25 @@ extension RecruitListViewModel { _ keyword: String?, _ filterState: RecruitListFilter? ) { + defer { + isLoading = false + fetchList() + } if let keyword { self.filterState.keyword = keyword - fetchList() } else if let filterState { var filterState = filterState filterState.keyword = self.filterState.keyword self.filterState = filterState - fetchList() } } private func deleteFilter(_ rawValue: String) { + defer { + isLoading = false + fetchList() + } filterState.remove(item: rawValue) - fetchList() } private func loadNextPage() { @@ -105,8 +116,12 @@ extension RecruitListViewModel { } private func resetFilter() { - filterState = .init() - fetchList() + defer { + isLoading = false + fetchList() + } + let keyword = filterState.keyword + filterState = .init(keyword: keyword) } } @@ -115,19 +130,27 @@ extension RecruitListViewModel { guard !isLoading else { return } - Task { + fetchListTask?.cancel() + fetchListTask = Task { do { isLoading = true defer { isLoading = false } + let cachedPage = filterState.page filterState.page = page var response = try await fetchRecruitListUseCase.execute(filter: filterState) - response.recruits = (recruitList?.recruits ?? []) + response.recruits - response.recruits.removeDuplicates() + guard response.currentPage == page else { + filterState.page = cachedPage + return + } + if 1 < page { + response.recruits = (recruitList?.recruits ?? []) + response.recruits + response.recruits.removeDuplicates() + } self.recruitList = response } catch { errorMessage = (error as? ErrorResponse)?.message diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift index 40ebce0ba..f463e69e2 100644 --- a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift @@ -15,7 +15,6 @@ final class RecruitNotificationListViewController: UIViewController { private let viewModel: RecruitNotificationListViewModel private let inputSubject = PassthroughSubject() private var subscriptions = Set() - private var notificationList: RecruitNotificationList? // MARK: - UI Components @@ -60,6 +59,8 @@ private extension RecruitNotificationListViewController { updateNotificationList(notificationList) case .showToast(let message): showToastMessage(message: message) + case .didFinishLoading: + notificationListView.stopLoading() } } .store(in: &subscriptions) @@ -72,7 +73,6 @@ private extension RecruitNotificationListViewController { } self.notificationList?.delete(id: id) self.inputSubject.send(.deleteNotification(id: id)) - self.showToastMessage(message: "알림이 삭제되었습니다.") } .store(in: &subscriptions) @@ -130,7 +130,6 @@ extension RecruitNotificationListViewController { self?.inputSubject.send(.deleteAllNotifications) self?.notificationList?.deleteAll() self?.notificationListView.deleteAll() - self?.showToastMessage(message: "알림이 삭제되었습니다.") } ) popUpViewController.modalPresentationStyle = .overFullScreen diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift index cbd85f71b..ac6f81408 100644 --- a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift @@ -22,6 +22,7 @@ final class RecruitNotificationListViewModel: ViewModelProtocol { enum Output { case updateNotifications(RecruitNotificationList) case showToast(String) + case didFinishLoading } // MARK: - Properties @@ -77,6 +78,7 @@ private extension RecruitNotificationListViewModel { if let message = (error as? ErrorResponse)?.message { outputSubject.send(.showToast(message)) } + outputSubject.send(.didFinishLoading) } } } @@ -85,6 +87,7 @@ private extension RecruitNotificationListViewModel { Task { do { try await deleteRecruitNotificationUseCase.execute(id: id) + outputSubject.send(.showToast("알림이 삭제되었습니다.")) } catch { if let message = (error as? ErrorResponse)?.message { outputSubject.send(.showToast(message)) @@ -97,6 +100,7 @@ private extension RecruitNotificationListViewModel { Task { do { try await deleteRecruitNotificationUseCase.execute(id: nil) + outputSubject.send(.showToast("알림이 삭제되었습니다.")) } catch { if let message = (error as? ErrorResponse)?.message { outputSubject.send(.showToast(message)) diff --git a/Koin/Presentation/Shared/Notification/Views/NotificationListView.swift b/Koin/Presentation/Shared/Notification/Views/NotificationListView.swift index c29ababb1..a01ec2a17 100644 --- a/Koin/Presentation/Shared/Notification/Views/NotificationListView.swift +++ b/Koin/Presentation/Shared/Notification/Views/NotificationListView.swift @@ -39,6 +39,10 @@ final class NotificationListView: UIView { loadingIndicator.startAnimating() } + func stopLoading() { + loadingIndicator.stopAnimating() + } + func update(items: [NotificationRowModel]) { tableView.update(notifications: items) updateStateViews(isEmpty: items.isEmpty) From a050b8f877e6ff697eb3d779a15222016780f7b7 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 5 Sep 2026 21:03:24 +0900 Subject: [PATCH 014/123] =?UTF-8?q?chore:=20KoinDropdown=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=EB=AA=85=20trigger=EB=A5=BC=20anchor=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UIKit/KoinDropdown/KoinDropdown.swift | 20 +++++++++---------- .../UIKit/KoinDropdown/KoinDropdownHost.swift | 10 +++++----- .../CallVanPostViewController.swift | 4 ++-- .../SubViews/EditLostItemFoundDateView.swift | 2 +- .../AddLostItemCollectionViewCell.swift | 2 +- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Koin/Core/View/UIKit/KoinDropdown/KoinDropdown.swift b/Koin/Core/View/UIKit/KoinDropdown/KoinDropdown.swift index 400beca96..4283b335c 100644 --- a/Koin/Core/View/UIKit/KoinDropdown/KoinDropdown.swift +++ b/Koin/Core/View/UIKit/KoinDropdown/KoinDropdown.swift @@ -19,7 +19,7 @@ final class KoinDropdown: UIView { // MARK: - Properties private weak var host: KoinDropdownHost? - private(set) weak var trigger: UIView? + private(set) weak var anchor: UIView? private let configuration: KoinDropdownConfiguration private var subscriptions: Set = [] @@ -33,12 +33,12 @@ final class KoinDropdown: UIView { // MARK: - Initializer init( host: KoinDropdownHost, - trigger: UIView, + anchor: UIView, contentView: UIView & KoinDropdownContentView, configuration: KoinDropdownConfiguration ) { self.host = host - self.trigger = trigger + self.anchor = anchor self.contentView = contentView self.configuration = configuration super.init(frame: .zero) @@ -78,27 +78,27 @@ final class KoinDropdown: UIView { extension KoinDropdown { func layout(in space: UIView) -> Bool { - guard let trigger else { return false } + guard let anchor else { return false } contentView.transform = .identity - let triggerFrame = trigger.convert(trigger.bounds, to: space) + let anchorFrame = anchor.convert(anchor.bounds, to: space) let panelHeight = contentView.height let padding = configuration.shadowPadding - guard 0 < triggerFrame.width, + guard 0 < anchorFrame.width, 0 < panelHeight else { return false } self.frame = CGRect( - x: triggerFrame.minX - padding.left, - y: triggerFrame.maxY, - width: triggerFrame.width + padding.left + padding.right, + x: anchorFrame.minX - padding.left, + y: anchorFrame.maxY, + width: anchorFrame.width + padding.left + padding.right, height: configuration.topPadding + panelHeight + padding.bottom ) contentView.frame = CGRect( x: padding.left, y: configuration.topPadding, - width: triggerFrame.width, + width: anchorFrame.width, height: panelHeight ) applyShadow() diff --git a/Koin/Core/View/UIKit/KoinDropdown/KoinDropdownHost.swift b/Koin/Core/View/UIKit/KoinDropdown/KoinDropdownHost.swift index 44179ffa4..24471c16e 100644 --- a/Koin/Core/View/UIKit/KoinDropdown/KoinDropdownHost.swift +++ b/Koin/Core/View/UIKit/KoinDropdown/KoinDropdownHost.swift @@ -32,13 +32,13 @@ final class KoinDropdownHost { // MARK: - Public func makeDropdown( - trigger: UIView, + anchor: UIView, contentView: UIView & KoinDropdownContentView, configuration: KoinDropdownConfiguration ) -> KoinDropdown { return KoinDropdown( host: self, - trigger: trigger, + anchor: anchor, contentView: contentView, configuration: configuration ) @@ -159,12 +159,12 @@ extension KoinDropdownHost { of dropdown: KoinDropdown, in scrollView: UIScrollView ) -> CGFloat { - guard let trigger = dropdown.trigger else { + guard let anchor = dropdown.anchor else { return 0 } - let triggerMaxY = trigger.convert(trigger.bounds, to: scrollView).maxY - let dropdownMaxY = triggerMaxY + travel + let anchorMaxY = anchor.convert(anchor.bounds, to: scrollView).maxY + let dropdownMaxY = anchorMaxY + travel let viewport = scrollView.bounds.height let maxOffsetY = max( -scrollView.adjustedContentInset.top, diff --git a/Koin/Presentation/CallVan/CallVanPost/CallVanPostViewController.swift b/Koin/Presentation/CallVan/CallVanPost/CallVanPostViewController.swift index ac648c14e..d339bd0c6 100644 --- a/Koin/Presentation/CallVan/CallVanPost/CallVanPostViewController.swift +++ b/Koin/Presentation/CallVan/CallVanPost/CallVanPostViewController.swift @@ -39,12 +39,12 @@ final class CallVanPostViewController: UIViewController { // MARK: - Dropdown private lazy var dropdownHost = KoinDropdownHost(scrollView: scrollView) private lazy var dateDropdown = dropdownHost.makeDropdown( - trigger: dateView.dropdownTrigger, + anchor: dateView.dropdownTrigger, contentView: dateView.dropdownContentView, configuration: .init(topPadding: 12, shadow: .shadow2) ) private lazy var timeDropdown = dropdownHost.makeDropdown( - trigger: timeView.dropdownTrigger, + anchor: timeView.dropdownTrigger, contentView: timeView.dropdownContentView, configuration: .init(topPadding: 12, shadow: .shadow2) ) diff --git a/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemFoundDateView.swift b/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemFoundDateView.swift index 1cde73e78..393fad213 100644 --- a/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemFoundDateView.swift +++ b/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemFoundDateView.swift @@ -121,7 +121,7 @@ final class EditLostItemFoundDateView: UIView { func prepareDropdown(host: KoinDropdownHost) { guard dropdown == nil else { return } dropdown = host.makeDropdown( - trigger: dateButton, + anchor: dateButton, contentView: dropdownView, configuration: .init(topPadding: 4, shadow: .shadow2) ) diff --git a/Koin/Presentation/LostItem/PostLostItem/SubViews/AddLostItemCollectionView/AddLostItemCollectionViewCell.swift b/Koin/Presentation/LostItem/PostLostItem/SubViews/AddLostItemCollectionView/AddLostItemCollectionViewCell.swift index ea57d727f..0a3530314 100644 --- a/Koin/Presentation/LostItem/PostLostItem/SubViews/AddLostItemCollectionView/AddLostItemCollectionViewCell.swift +++ b/Koin/Presentation/LostItem/PostLostItem/SubViews/AddLostItemCollectionView/AddLostItemCollectionViewCell.swift @@ -225,7 +225,7 @@ final class AddLostItemCollectionViewCell: UICollectionViewCell { func prepareDropdown(host: KoinDropdownHost) { guard dropdown == nil else { return } dropdown = host.makeDropdown( - trigger: dateButton, + anchor: dateButton, contentView: dropdownView, configuration: .init(topPadding: 4, shadow: .shadow2) ) From 0ec5e7b6a5a3ffeb67c5c5847be7af543413b2a1 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 5 Sep 2026 21:10:44 +0900 Subject: [PATCH 015/123] =?UTF-8?q?add:=20ImageAsset=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Asset/ImageAsset.swift | 4 ++++ .../Contents.json | 12 ++++++++++++ .../recruitPostMeetingTypeMixed.svg | 6 ++++++ .../Contents.json | 12 ++++++++++++ .../recruitPostMeetingTypeOffline.svg | 10 ++++++++++ .../Contents.json | 12 ++++++++++++ .../recruitPostMeetingTypeOnline.svg | 4 ++++ .../recruitPostRolePlus.imageset/Contents.json | 12 ++++++++++++ .../recruitPostRolePlus.svg | 4 ++++ 9 files changed, 76 insertions(+) create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeMixed.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeMixed.imageset/recruitPostMeetingTypeMixed.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOffline.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOffline.imageset/recruitPostMeetingTypeOffline.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOnline.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOnline.imageset/recruitPostMeetingTypeOnline.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitPostRolePlus.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitPostRolePlus.imageset/recruitPostRolePlus.svg diff --git a/Koin/Core/Asset/ImageAsset.swift b/Koin/Core/Asset/ImageAsset.swift index 15370fd51..1e2362b00 100644 --- a/Koin/Core/Asset/ImageAsset.swift +++ b/Koin/Core/Asset/ImageAsset.swift @@ -225,6 +225,10 @@ public enum ImageAsset: String { case recruitNotificationChat case recruitNotificationMember case recruitPencil + case recruitPostMeetingTypeMixed + case recruitPostMeetingTypeOffline + case recruitPostMeetingTypeOnline + case recruitPostRolePlus case recruitProfile case recruitSearch diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeMixed.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeMixed.imageset/Contents.json new file mode 100644 index 000000000..47a06942f --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeMixed.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitPostMeetingTypeMixed.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeMixed.imageset/recruitPostMeetingTypeMixed.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeMixed.imageset/recruitPostMeetingTypeMixed.svg new file mode 100644 index 000000000..8657a3d79 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeMixed.imageset/recruitPostMeetingTypeMixed.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOffline.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOffline.imageset/Contents.json new file mode 100644 index 000000000..6e7ae0777 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOffline.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitPostMeetingTypeOffline.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOffline.imageset/recruitPostMeetingTypeOffline.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOffline.imageset/recruitPostMeetingTypeOffline.svg new file mode 100644 index 000000000..d3c6fa7a1 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOffline.imageset/recruitPostMeetingTypeOffline.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOnline.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOnline.imageset/Contents.json new file mode 100644 index 000000000..d00d32a06 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOnline.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitPostMeetingTypeOnline.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOnline.imageset/recruitPostMeetingTypeOnline.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOnline.imageset/recruitPostMeetingTypeOnline.svg new file mode 100644 index 000000000..352331e6d --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitPostMeetingTypeOnline.imageset/recruitPostMeetingTypeOnline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitPostRolePlus.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitPostRolePlus.imageset/Contents.json new file mode 100644 index 000000000..37ed07313 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitPostRolePlus.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitPostRolePlus.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitPostRolePlus.imageset/recruitPostRolePlus.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitPostRolePlus.imageset/recruitPostRolePlus.svg new file mode 100644 index 000000000..7ef0416cf --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitPostRolePlus.imageset/recruitPostRolePlus.svg @@ -0,0 +1,4 @@ + + + + From d10a0089c5e061441ed038fe4e669573dc0276e2 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 12 Sep 2026 20:17:29 +0900 Subject: [PATCH 016/123] =?UTF-8?q?design:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=9E=91=EC=A0=95=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/MockRecruitRepository.swift | 54 ++- Koin/Domain/Model/Recruit/RecruitDetail.swift | 4 + .../Model/Recruit/RecruitMeetingType.swift | 26 + .../Model/Recruit/RecruitPostRequest.swift | 86 ++++ .../Domain/Repository/RecruitRepository.swift | 3 + .../FetchRecruitDetailUseCase.swift | 25 + .../RecruitPost/ModifyRecruitUseCase.swift | 25 + .../RecruitPost/PostRecruitUseCase.swift | 25 + .../RecruitListHostingController.swift | 15 +- .../RecruitPostViewController.swift | 449 ++++++++++++++++++ .../RecruitPost/RecruitPostViewModel.swift | 121 +++++ .../RecruitPostCategoryDropdownView.swift | 112 +++++ .../Subviews/RecruitPostCategoryView.swift | 105 ++++ .../Subviews/RecruitPostMeetingTypeView.swift | 137 ++++++ .../Subviews/RecruitPostRoleTableView.swift | 292 ++++++++++++ .../RecruitPostRoleTableViewCell.swift | 259 ++++++++++ .../Subviews/RecruitPostRoleView.swift | 327 +++++++++++++ .../Subviews/RecruitPostScheduleView.swift | 230 +++++++++ .../RecruitPostSectionHeaderView.swift | 110 +++++ .../Subviews/RecruitPostTextFieldView.swift | 133 ++++++ .../Subviews/RecruitPostTextViewView.swift | 127 +++++ koin.xcodeproj/project.pbxproj | 98 ++++ 22 files changed, 2757 insertions(+), 6 deletions(-) create mode 100644 Koin/Domain/Model/Recruit/RecruitPostRequest.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitDetail/FetchRecruitDetailUseCase.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitPost/ModifyRecruitUseCase.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitPost/PostRecruitUseCase.swift create mode 100644 Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift create mode 100644 Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift create mode 100644 Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostCategoryDropdownView.swift create mode 100644 Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostCategoryView.swift create mode 100644 Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostMeetingTypeView.swift create mode 100644 Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableView.swift create mode 100644 Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableViewCell.swift create mode 100644 Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift create mode 100644 Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostScheduleView.swift create mode 100644 Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostSectionHeaderView.swift create mode 100644 Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextFieldView.swift create mode 100644 Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextViewView.swift diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 1cde7cf82..f28d70983 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -26,7 +26,10 @@ final class MockRecruitRepository: RecruitRepository { RecruitRole(id: 1, name: "프론트엔드", currentParticipants: 0, maximumParticipants: 1, isClosed: false), RecruitRole(id: 2, name: "백엔드", currentParticipants: 0, maximumParticipants: 1, isClosed: false), RecruitRole(id: 3, name: "디자인", currentParticipants: 0, maximumParticipants: 1, isClosed: false) - ] + ], + description: "", + relatedUrl: nil, + qualification: nil ) let externalActivity = RecruitDetail( @@ -42,7 +45,10 @@ final class MockRecruitRepository: RecruitRepository { currentParticipants: 2, maximumParticipants: 3, type: .general, - roles: [] + roles: [], + description: "", + relatedUrl: nil, + qualification: nil ) let closedExternalActivity = RecruitDetail( @@ -58,7 +64,10 @@ final class MockRecruitRepository: RecruitRepository { currentParticipants: 5, maximumParticipants: 5, type: .general, - roles: [] + roles: [], + description: "", + relatedUrl: nil, + qualification: nil ) let studies = (4...7).map { id in @@ -75,7 +84,10 @@ final class MockRecruitRepository: RecruitRepository { currentParticipants: 2, maximumParticipants: 3, type: .general, - roles: [] + roles: [], + description: "", + relatedUrl: nil, + qualification: nil ) } @@ -87,6 +99,40 @@ final class MockRecruitRepository: RecruitRepository { ) } + func fetchDetail(_ id: Int) async throws -> RecruitDetail { + try await Task.sleep(nanoseconds: 300_000_000) + return RecruitDetail( + id: id, + category: .contest, + title: "AI 아이디어 공모전 팀원 모집", + meetingType: .online, + startDate: "2026.07.26", + endDate: "2026.08.07", + deadline: "2026.08.07", + dDay: 5, + state: .recruiting, + currentParticipants: 0, + maximumParticipants: 3, + type: .roleBased, + roles: [ + RecruitRole(id: 1, name: "프론트엔드", currentParticipants: 0, maximumParticipants: 1, isClosed: false), + RecruitRole(id: 2, name: "백엔드", currentParticipants: 0, maximumParticipants: 1, isClosed: false), + RecruitRole(id: 3, name: "디자인", currentParticipants: 0, maximumParticipants: 1, isClosed: false) + ], + description: "소개소개소개소개소개소개소개소개소개소개", + relatedUrl: nil, + qualification: "2학년이상\n참여율 높은 사람\n@@@" + ) + } + + func post(_ request: RecruitPostRequest) async throws -> Int { + return 1 + } + + func modify(_ id: Int, _ request: RecruitPostRequest) async throws -> Void { + return + } + func fetchNotificationList() async throws -> RecruitNotificationList { return RecruitNotificationList(notifications: [ RecruitNotification( diff --git a/Koin/Domain/Model/Recruit/RecruitDetail.swift b/Koin/Domain/Model/Recruit/RecruitDetail.swift index 624c690dd..6407ff3a4 100644 --- a/Koin/Domain/Model/Recruit/RecruitDetail.swift +++ b/Koin/Domain/Model/Recruit/RecruitDetail.swift @@ -25,4 +25,8 @@ struct RecruitDetail: Identifiable, Equatable { let type: RecruitRoleType let roles: [RecruitRole] + + let description: String + let relatedUrl: String? + let qualification: String? } diff --git a/Koin/Domain/Model/Recruit/RecruitMeetingType.swift b/Koin/Domain/Model/Recruit/RecruitMeetingType.swift index 56d634c2c..9776b133e 100644 --- a/Koin/Domain/Model/Recruit/RecruitMeetingType.swift +++ b/Koin/Domain/Model/Recruit/RecruitMeetingType.swift @@ -22,4 +22,30 @@ enum RecruitMeetingType: String, CaseIterable { 3 } } + + var imageAsset: ImageAsset { + switch self { + case .online: + return .recruitPostMeetingTypeOnline + case .offline: + return .recruitPostMeetingTypeOffline + case .mixed: + return .recruitPostMeetingTypeMixed + } + } +} + +extension RecruitMeetingType { + init?(index: Int) { + switch index { + case 1: + self = .online + case 2: + self = .offline + case 3: + self = .mixed + default: + return nil + } + } } diff --git a/Koin/Domain/Model/Recruit/RecruitPostRequest.swift b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift new file mode 100644 index 000000000..894e455d3 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift @@ -0,0 +1,86 @@ +// +// RecruitPostRequest.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation +import Then + +struct RecruitPostRequest { + var category: RecruitCategory? + var title: String? + var meetingType: RecruitMeetingType? + var startDate: Date? + var endDate: Date? + var deadline: Date? + var type: RecruitRoleType = .roleBased + var numberOfGeneralMembers: Int? + var roles: [RecruitRoleRequest] = [.init()] + var description: String? + var relatedUrl: String? + var qualification: String? + + var isValid: Bool { + switch type { + case .roleBased: + guard !roles.isEmpty else { + return false + } + for role in roles { + guard role.isValid else { + return false + } + } + case .general: + guard roles.isEmpty, numberOfGeneralMembers != nil else { + return false + } + } + return category != nil + && !(title?.isEmpty ?? true) + && meetingType != nil + && startDate != nil + && endDate != nil + && deadline != nil + && description != nil + } +} + +struct RecruitRoleRequest { + var name: String = "" + var maximumParticipants: Int = 1 + + var isValid: Bool { + !name.isEmpty && 1 <= maximumParticipants && maximumParticipants <= 10 + } +} + +extension RecruitPostRequest { + init(from form: RecruitDetail) { + let dateFormatter = DateFormatter().then { + $0.dateFormat = "yyyy-MM-dd" + } + + self.category = form.category + self.title = form.title + self.meetingType = form.meetingType + self.startDate = dateFormatter.date(from: form.startDate) + self.endDate = dateFormatter.date(from: form.endDate) + self.deadline = dateFormatter.date(from: form.deadline) + self.type = form.type + self.numberOfGeneralMembers = form.maximumParticipants + self.roles = form.roles.map { .init(from: $0) } + self.description = form.description + self.relatedUrl = form.relatedUrl + self.qualification = form.qualification + } +} + +extension RecruitRoleRequest { + init(from form: RecruitRole) { + self.name = form.name + self.maximumParticipants = form.maximumParticipants + } +} diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index d194afc40..bede8da11 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -9,9 +9,12 @@ import Foundation protocol RecruitRepository { func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList + func fetchDetail(_ id: Int) async throws -> RecruitDetail func fetchNotificationList() async throws -> RecruitNotificationList func deleteNotification(_ id: Int) async throws -> Void func deleteAllNotification() async throws -> Void func markAsReadNotification(_ id: Int) async throws -> Void func markAllAsReadNotification() async throws -> Void + func post(_ request: RecruitPostRequest) async throws -> Int + func modify(_ id: Int, _ request: RecruitPostRequest) async throws -> Void } diff --git a/Koin/Domain/UseCase/Recruit/RecruitDetail/FetchRecruitDetailUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitDetail/FetchRecruitDetailUseCase.swift new file mode 100644 index 000000000..60cf3334e --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitDetail/FetchRecruitDetailUseCase.swift @@ -0,0 +1,25 @@ +// +// FetchRecruitDetailUseCase.swift +// koin +// +// Created by 홍기정 on 9/10/26. +// + +import Foundation + +protocol FetchRecruitDetailUseCase { + func execute(id: Int) async throws -> RecruitDetail +} + +final class DefaultFetchRecruitDetailUseCase: FetchRecruitDetailUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(id: Int) async throws -> RecruitDetail { + try await repository.fetchDetail(id) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitPost/ModifyRecruitUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitPost/ModifyRecruitUseCase.swift new file mode 100644 index 000000000..1bd5492a4 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitPost/ModifyRecruitUseCase.swift @@ -0,0 +1,25 @@ +// +// ModifyRecruitUseCase.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import Foundation + +protocol ModifyRecruitUseCase { + func execute(id: Int, request: RecruitPostRequest) async throws -> Void +} + +final class DefaultModifyRecruitUseCase: ModifyRecruitUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(id: Int, request: RecruitPostRequest) async throws -> Void { + try await repository.modify(id, request) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitPost/PostRecruitUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitPost/PostRecruitUseCase.swift new file mode 100644 index 000000000..71d372c42 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitPost/PostRecruitUseCase.swift @@ -0,0 +1,25 @@ +// +// PostRecruitUseCase.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import Foundation + +protocol PostRecruitUseCase { + func execute(request: RecruitPostRequest) async throws -> Int +} + +final class DefaultPostRecruitUseCase: PostRecruitUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(request: RecruitPostRequest) async throws -> Int { + return try await repository.post(request) + } +} diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index 8f10eae57..961171a89 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -104,8 +104,19 @@ extension RecruitListHostingController { // TODO } private func showRecruitPost() { - // TOOD - } + let recruitRepository = MockRecruitRepository() + let fetchRecruitDetailUseCase = DefaultFetchRecruitDetailUseCase(repository: recruitRepository) + let postRecruitUseCase = DefaultPostRecruitUseCase(repository: recruitRepository) + let modifyRecruitUseCase = DefaultModifyRecruitUseCase(repository: recruitRepository) + let viewModel = RecruitPostViewModel( + postType: .post, + postRecruitUseCase: postRecruitUseCase, + modifyRecruitUseCase: modifyRecruitUseCase, + fetchRecruitDetailUseCase: fetchRecruitDetailUseCase + ) + let viewController = RecruitPostViewController(viewModel: viewModel) + navigationController?.pushViewController(viewController, animated: true) + } } extension RecruitListHostingController { diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift new file mode 100644 index 000000000..0445c9833 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift @@ -0,0 +1,449 @@ +// +// RecruitPostViewController.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class RecruitPostViewController: UIViewController { + + private var navigationTitle: String { + switch viewModel.postType { + case .post: + return "모집글 작성" + case .edit: + return "모집글 수정" + } + } + private var submitButtonTitle: String { + switch viewModel.postType { + case .post: + return "등록하기" + case .edit: + return "수정하기" + } + } + private var completionMessage: String { + switch viewModel.postType { + case .post: + return "모집글이 등록되었습니다." + case .edit: + return "모집글이 수정되었습니다." + } + } + + // MARK: - Properties + private let viewModel: RecruitPostViewModel + private let inputSubject = PassthroughSubject() + private var subscriptions: Set = [] + private weak var focusedInputView: UIView? + private var request: RecruitPostRequest = .init() { + didSet { + validate() + } + } + + // MARK: - UI Components + private let scrollView = UIScrollView() + private let contentStackView = UIStackView() + + private let categoryView = RecruitPostCategoryView() + private let titleView = RecruitPostTextFieldView( + title: "제목", + isRequired: true, + limit: 50, + placeholder: "제목을 입력해주세요." + ) + private let meetingTypeView = RecruitPostMeetingTypeView() + private let scheduleView = RecruitPostScheduleView() + private let roleView = RecruitPostRoleView() + private let descriptionView = RecruitPostTextViewView( + title: "모집 소개", + isRequired: true, + limit: 1000, + placeholder: "소개를 작성해주세요." + ) + private let relatedUrlView = RecruitPostTextFieldView( + title: "관련 Url", + isRequired: false, + limit: nil, + placeholder: "공모전/대외활동 등 모집글 관련 Url을 작성해주세요.", + ) + private let qualificationView = RecruitPostTextViewView( + title: "지원 자격", + isRequired: false, + limit: 500, + placeholder: "지원 자격 또는 우대사항을 작성해주세요." + ) + + private let submitButton = UIButton() + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + + // MARK: - Dropdown + private lazy var dropdownHost = KoinDropdownHost(scrollView: scrollView) + + private lazy var categoryDropdown = dropdownHost.makeDropdown( + anchor: categoryView.dropdownAnchor, + contentView: RecruitPostCategoryDropdownView { [weak self] category in + self?.request.category = category + self?.categoryView.configure(category: category) + }, + configuration: .init(topPadding: 4, shadow: .shadow2) + ) + private lazy var startDateDropdown = dropdownHost.makeDropdown( + anchor: scheduleView.activityPeriodDropdownAnchor, + contentView: scheduleView.startDateDropdownContentView, + configuration: .init(topPadding: 4, shadow: .shadow2) + ) + private lazy var endDateDropdown = dropdownHost.makeDropdown( + anchor: scheduleView.activityPeriodDropdownAnchor, + contentView: scheduleView.endDateDropdownContentView, + configuration: .init(topPadding: 4, shadow: .shadow2) + ) + private lazy var deadlineDateDropdown = dropdownHost.makeDropdown( + anchor: scheduleView.deadlineDateDropdownAnchor, + contentView: scheduleView.deadlineDateDropdownContentView, + configuration: .init(topPadding: 4, shadow: .shadow2) + ) + + // MARK: - Initializer + init( + viewModel: RecruitPostViewModel, + ) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + title = navigationTitle + configureView() + setAddTargets() + bind() + bindSections() + roleView.configure( + roleType: request.type, + roles: request.roles, + numberOfGeneralMembers: request.numberOfGeneralMembers + ) + observeEditing() + observeKeyboard() + inputSubject.send(.viewDidLoad) + hideKeyboardWhenTappedAround() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar(style: .newBackground) + } +} + +extension RecruitPostViewController { + private func bind() { + viewModel.transform(with: inputSubject.eraseToAnyPublisher()) + .receive(on: DispatchQueue.main) + .sink { [weak self] output in + guard let self else { return } + switch output { + case let .updateForm(request): + var formRequest = request + if formRequest.type == .roleBased && formRequest.roles.isEmpty { + formRequest.roles = [.init()] + } + self.request = formRequest + updateViews() + case let .updateLoading(isLoading): + isLoading ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating() + case .submitFailed: + submitButton.isUserInteractionEnabled = true + case .submitCompleted: + handleSubmitCompleted() + case let .showToast(message): + showToastMessage(message: message) + } + }.store(in: &subscriptions) + } + + private func bindSections() { + categoryView.categoryButtonTappedPublisher.sink { [weak self] in + guard let self else { return } + view.endEditing(true) + categoryDropdown.toggle() + }.store(in: &subscriptions) + + titleView.textChangedPublisher.sink { [weak self] text in + self?.request.title = text + }.store(in: &subscriptions) + + meetingTypeView.meetingTypeChangedPublisher.sink { [weak self] meetingType in + self?.request.meetingType = meetingType + }.store(in: &subscriptions) + + scheduleView.startDateButtonTappedPublisher.sink { [weak self] in + guard let self else { return } + view.endEditing(true) + scheduleView.startDateDropdownContentView.reset(initialDate: request.startDate ?? Date()) + startDateDropdown.toggle() + }.store(in: &subscriptions) + + scheduleView.endDateButtonTappedPublisher.sink { [weak self] in + guard let self else { return } + view.endEditing(true) + scheduleView.endDateDropdownContentView.reset(initialDate: request.endDate ?? Date()) + endDateDropdown.toggle() + }.store(in: &subscriptions) + + scheduleView.deadlineDateButtonTappedPublisher.sink { [weak self] in + guard let self else { return } + view.endEditing(true) + scheduleView.deadlineDateDropdownContentView.reset(initialDate: request.deadline ?? Date()) + deadlineDateDropdown.toggle() + }.store(in: &subscriptions) + + scheduleView.startDateDropdownContentView.selectedItemPublisher.sink { [weak self] selectedItems in + let startDate = self?.date(from: selectedItems) + if self?.request.startDate != startDate { + self?.request.startDate = startDate + self?.scheduleView.configure(startDate: startDate) + } + }.store(in: &subscriptions) + + scheduleView.endDateDropdownContentView.selectedItemPublisher.sink { [weak self] selectedItems in + let endDate = self?.date(from: selectedItems) + if self?.request.endDate != endDate { + self?.request.endDate = endDate + self?.scheduleView.configure(endDate: endDate) + } + }.store(in: &subscriptions) + + scheduleView.deadlineDateDropdownContentView.selectedItemPublisher.sink { [weak self] selectedItems in + let deadline = self?.date(from: selectedItems) + if self?.request.deadline != deadline { + self?.request.deadline = deadline + self?.scheduleView.configure(deadlineDate: deadline) + } + }.store(in: &subscriptions) + + roleView.roleTypeChangedPublisher.sink { [weak self] roleType in + self?.request.type = roleType + }.store(in: &subscriptions) + + roleView.rolesChangedPublisher.sink { [weak self] roles in + self?.request.roles = roles + }.store(in: &subscriptions) + + roleView.numberOfGeneralMembersChangedPublisher.sink { [weak self] numberOfGeneralMembers in + self?.request.numberOfGeneralMembers = numberOfGeneralMembers + }.store(in: &subscriptions) + + roleView.didChangeHeightPublisher.sink { [weak self] in + guard let self else { return } + view.layoutIfNeeded() + UIView.animate( + springDuration: 0.25, + options: [.beginFromCurrentState, .allowUserInteraction] + ) { + self.roleView.applyPendingSizeChange() + self.view.layoutIfNeeded() + } + }.store(in: &subscriptions) + + descriptionView.textChangedPublisher.sink { [weak self] description in + self?.request.description = description + }.store(in: &subscriptions) + + relatedUrlView.textChangedPublisher.sink { [weak self] relatedUrl in + self?.request.relatedUrl = relatedUrl + }.store(in: &subscriptions) + + qualificationView.textChangedPublisher.sink { [weak self] qualification in + self?.request.qualification = qualification + }.store(in: &subscriptions) + } + + private func date(from selectedItems: [String]) -> Date? { + guard selectedItems.count == 3, + let year = Int(selectedItems[0].filter { $0.isNumber }), + let month = Int(selectedItems[1].filter { $0.isNumber }), + let day = Int(selectedItems[2].filter { $0.isNumber }) else { + return nil + } + var components = DateComponents() + components.calendar = Calendar(identifier: .gregorian) + components.timeZone = TimeZone.current + components.year = year + components.month = month + components.day = day + return components.date + } +} + +extension RecruitPostViewController { + + private func handleSubmitCompleted() { // TODO: 화면전환 +// showToastMessage(message: ) +// navigationController?.replaceTopViewController(<#T##viewController: UIViewController##UIViewController#>, animated: <#T##Bool#>) + } + + private func validate() { + submitButton.isEnabled = request.isValid + submitButton.backgroundColor = request.isValid ? UIColor.appColor(.new500) : UIColor.appColor(.neutral400) + } + + private func updateViews() { + categoryView.configure(category: request.category) + titleView.configure(text: request.title) + meetingTypeView.configure(meetingType: request.meetingType) + scheduleView.configure( + startDate: request.startDate, + endDate: request.endDate, + deadlineDate: request.deadline + ) + roleView.configure( + roleType: request.type, + roles: request.roles, + numberOfGeneralMembers: request.numberOfGeneralMembers, + ) + descriptionView.configure(text: request.description ?? "") + relatedUrlView.configure(text: request.relatedUrl ?? "") + qualificationView.configure(text: request.qualification ?? "") + } +} + +extension RecruitPostViewController { + + private func setAddTargets() { + submitButton.addTarget(self, action: #selector(submitButtonTapped), for: .touchUpInside) + } + + @objc private func submitButtonTapped() { + guard !dropdownHost.isPresenting else { return } + submitButton.isUserInteractionEnabled = false + view.endEditing(true) + inputSubject.send(.submit(request)) + } +} + +extension RecruitPostViewController { + private func observeEditing() { + NotificationCenter.default.publisher(for: UITextField.textDidBeginEditingNotification) + .merge(with: NotificationCenter.default.publisher(for: UITextView.textDidBeginEditingNotification)) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let self, + let inputView = notification.object as? UIView, + inputView.isDescendant(of: scrollView) else { return } + focusedInputView = inputView + scrollFocusedInputIntoView() + } + .store(in: &subscriptions) + } + + private func scrollFocusedInputIntoView() { + guard let focusedInputView, + focusedInputView.isFirstResponder, + focusedInputView.isDescendant(of: scrollView) else { return } + let rect = focusedInputView.convert(focusedInputView.bounds, to: scrollView) + scrollView.scrollRectToVisible(rect.insetBy(dx: 0, dy: -12), animated: true) + } + + private func observeKeyboard() { + NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let self else { return } + let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + let keyboardMinY = keyboardFrame.map { self.view.convert($0, from: nil).minY } ?? scrollView.frame.maxY + let overlap = max(0, scrollView.frame.maxY - keyboardMinY) + scrollView.contentInset.bottom = overlap + 16 + scrollFocusedInputIntoView() + } + .store(in: &subscriptions) + + NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + self?.scrollView.contentInset.bottom = 16 + } + .store(in: &subscriptions) + } +} + +extension RecruitPostViewController { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + view.backgroundColor = UIColor.appColor(.newBackground) + + scrollView.do { + $0.backgroundColor = .clear + $0.keyboardDismissMode = .interactive + $0.alwaysBounceVertical = true + $0.contentInset = UIEdgeInsets(top: 32, left: 0, bottom: 16, right: 0) + $0.showsVerticalScrollIndicator = false + } + contentStackView.do { + $0.spacing = 24 + $0.axis = .vertical + } + submitButton.do { + $0.setAttributedTitle(NSAttributedString( + string: submitButtonTitle, + attributes: [ + .font: UIFont.appFont(.pretendardSemiBold, size: 15), + .foregroundColor: UIColor.appColor(.neutral0) + ]), for: .normal) + $0.isEnabled = false + $0.backgroundColor = .appColor(.neutral400) + $0.layer.cornerRadius = 16 + } + } + + private func setUpLayouts() { + [categoryView, titleView, meetingTypeView, scheduleView, roleView, descriptionView, relatedUrlView, qualificationView].forEach { + contentStackView.addArrangedSubview($0) + } + + [contentStackView].forEach { + scrollView.addSubview($0) + } + + [scrollView, submitButton].forEach { + view.addSubview($0) + } + } + + private func setUpConstraints() { + scrollView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.leading.trailing.equalToSuperview().inset(24) + $0.bottom.equalTo(submitButton.snp.top).offset(-16) + } + contentStackView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.width.equalToSuperview() + } + submitButton.snp.makeConstraints { + $0.height.equalTo(48) + $0.leading.trailing.equalToSuperview().inset(32) + $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift new file mode 100644 index 000000000..36fc3742d --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift @@ -0,0 +1,121 @@ +// +// RecruitPostViewModel.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import Foundation +import Combine + +final class RecruitPostViewModel: ViewModelProtocol { + + enum PostType { + case post + case edit(id: Int) + } + + enum Input { + case viewDidLoad + case submit(RecruitPostRequest) + } + enum Output { + case updateLoading(Bool) + case updateForm(RecruitPostRequest) + + case submitFailed + case submitCompleted(id: Int?) + case showToast(String) + } + + // MARK: - Properties + private let postRecruitUseCase: PostRecruitUseCase + private let modifyRecruitUseCase: ModifyRecruitUseCase + private let fetchRecruitDetailUseCase: FetchRecruitDetailUseCase + + private let outputSubject = PassthroughSubject() + private var subscriptions: Set = [] + + let postType: PostType + + // MARK: - Initializer + init( + postType: PostType, + postRecruitUseCase: PostRecruitUseCase, + modifyRecruitUseCase: ModifyRecruitUseCase, + fetchRecruitDetailUseCase: FetchRecruitDetailUseCase + ) { + self.postType = postType + self.postRecruitUseCase = postRecruitUseCase + self.modifyRecruitUseCase = modifyRecruitUseCase + self.fetchRecruitDetailUseCase = fetchRecruitDetailUseCase + } + + // MARK: - Public + func transform(with input: AnyPublisher) -> AnyPublisher { + input.sink { [weak self] input in + guard let self else { return } + switch input { + case .viewDidLoad: + fetchFormIfNeeded() + case let .submit(form): + submit(form) + } + }.store(in: &subscriptions) + + return outputSubject.eraseToAnyPublisher() + } +} + +extension RecruitPostViewModel { + + private func fetchFormIfNeeded() { + guard case let .edit(id) = postType else { + return + } + + Task { + outputSubject.send(.updateLoading(true)) + defer { + outputSubject.send(.updateLoading(false)) + } + + do { + let detail = try await fetchRecruitDetailUseCase.execute(id: id) + outputSubject.send(.updateForm(.init(from: detail))) + } catch { + if let error = error as? ErrorResponse { + outputSubject.send(.showToast(error.message)) + } + } + } + } +} + +extension RecruitPostViewModel { + + private func submit(_ request: RecruitPostRequest) { + outputSubject.send(.updateLoading(true)) + + Task { + do { + defer { + outputSubject.send(.updateLoading(false)) + } + switch postType { + case .post: + let id = try await postRecruitUseCase.execute(request: request) + outputSubject.send(.submitCompleted(id: id)) + case let .edit(id): + try await modifyRecruitUseCase.execute(id: id, request: request) + outputSubject.send(.submitCompleted(id: id)) + } + } catch { + outputSubject.send(.submitFailed) + if let error = error as? ErrorResponse { + outputSubject.send(.showToast(error.message)) + } + } + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostCategoryDropdownView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostCategoryDropdownView.swift new file mode 100644 index 000000000..57474038b --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostCategoryDropdownView.swift @@ -0,0 +1,112 @@ +// +// RecruitPostCategoryDropdownView.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class RecruitPostCategoryDropdownView: UIView, KoinDropdownContentView { + + // MARK: - Properties + private let dismissTappedSubject = PassthroughSubject() + var dismissTappedPublisher: AnyPublisher { + dismissTappedSubject.eraseToAnyPublisher() + } + let height: CGFloat = 34 * 5 + 12 + private let onSelect: (RecruitCategory) -> Void + + private let stackView = UIStackView() + private lazy var buttons: [UIButton] = RecruitCategory.allCases.map { makeButton(for: $0) } + + // MARK: - Initializer + init(onSelect: @escaping (RecruitCategory) -> Void) { + self.onSelect = onSelect + super.init(frame: .zero) + configureView() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension RecruitPostCategoryDropdownView { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpLayouts() { + buttons.forEach { + stackView.addArrangedSubview($0) + } + addSubview(stackView) + } + + private func setUpConstraints() { + stackView.snp.makeConstraints { + $0.top.bottom.equalToSuperview().inset(6) + $0.leading.trailing.equalToSuperview() + } + buttons.forEach { + $0.snp.makeConstraints { + $0.height.equalTo(34) + } + } + } + + private func setUpStyles() { + self.do { + $0.backgroundColor = UIColor.appColor(.neutral0) + $0.layer.cornerRadius = 16 + } + + stackView.do { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .fill + $0.spacing = 0 + } + } +} + +extension RecruitPostCategoryDropdownView { + private func makeButton(for category: RecruitCategory) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.attributedTitle = AttributedString( + category.rawValue, + attributes: AttributeContainer([ + .font: UIFont.appFont(.pretendardRegular, size: 14), + .foregroundColor: UIColor.appColor(.neutral800) + ]) + ) + configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 0) + + let button = UIButton(configuration: configuration) + + button.contentHorizontalAlignment = .leading + button.configurationUpdateHandler = { button in + var configuration = button.configuration + let backgroundColor = UIColor.appColor(button.isHighlighted ? .neutral100 : .neutral0) + configuration?.background.backgroundColor = backgroundColor + button.configuration = configuration + } + button.tag = category.index + button.addTarget(self, action: #selector(categoryButtonTapped(_:)), for: .touchUpInside) + + return button + } + + @objc private func categoryButtonTapped(_ sender: UIButton) { + guard let category = RecruitCategory.allCases.first(where: { $0.index == sender.tag }) else { + return + } + onSelect(category) + dismissTappedSubject.send() + } +} diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostCategoryView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostCategoryView.swift new file mode 100644 index 000000000..f7e29b4d3 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostCategoryView.swift @@ -0,0 +1,105 @@ +// +// RecruitPostCategoryView.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class RecruitPostCategoryView: UIView { + + // MARK: - Properties + let categoryButtonTappedPublisher = PassthroughSubject() + private let placeholder = "카테고리를 선택해주세요." + + var dropdownAnchor: UIView { categoryButton } + + // MARK: - UI Components + private let headerView = RecruitPostSectionHeaderView(title: "카테고리", isRequired: true) + private let categoryButton = UIButton() + private let categoryLabel = UILabel() + private let chevronDownImageView = UIImageView(image: .appImage(asset: .chevronDown)) + + // MARK: - Initializer + init(category: RecruitCategory = .contest) { + super.init(frame: .zero) + configureView() + setAddTargets() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(category: RecruitCategory?) { + categoryLabel.text = category?.rawValue ?? placeholder + categoryLabel.textColor = category == nil ? UIColor.appColor(.neutral500) : UIColor.appColor(.neutral800) + } +} + +extension RecruitPostCategoryView { + private func setAddTargets() { + categoryButton.addTarget(self, action: #selector(categoryButtonTapped), for: .touchUpInside) + } + + @objc private func categoryButtonTapped() { + categoryButtonTappedPublisher.send() + } +} + +extension RecruitPostCategoryView { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + categoryButton.do { + $0.layer.cornerRadius = 16 + $0.backgroundColor = .appColor(.neutral0) + } + categoryLabel.do { + $0.font = UIFont.appFont(.pretendardRegular, size: 14) + $0.text = placeholder + } + chevronDownImageView.do { + $0.image = UIImage.appImage(asset: .chevronDown) + $0.contentMode = .scaleAspectFit + } + } + + private func setUpLayouts() { + [headerView, categoryButton].forEach { + addSubview($0) + } + [categoryLabel, chevronDownImageView].forEach { + categoryButton.addSubview($0) + } + } + + private func setUpConstraints() { + headerView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + } + categoryButton.snp.makeConstraints { + $0.top.equalTo(headerView.snp.bottom).offset(8) + $0.leading.trailing.bottom.equalToSuperview() + $0.height.equalTo(40) + } + categoryLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(12) + $0.trailing.lessThanOrEqualTo(chevronDownImageView.snp.leading).offset(-8) + } + chevronDownImageView.snp.makeConstraints { + $0.size.equalTo(24) + $0.centerY.equalToSuperview() + $0.trailing.equalToSuperview().offset(-12) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostMeetingTypeView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostMeetingTypeView.swift new file mode 100644 index 000000000..35d9daebc --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostMeetingTypeView.swift @@ -0,0 +1,137 @@ +// +// RecruitPostMeetingTypeView.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class RecruitPostMeetingTypeView: UIView { + + // MARK: - Properties + let meetingTypeChangedPublisher = PassthroughSubject() + + // MARK: - UI Components + private let headerView = RecruitPostSectionHeaderView(title: "진행방식", isRequired: true) + private let optionStackView = UIStackView() + private lazy var optionButtons: [UIButton] = RecruitMeetingType.allCases.map { meetingType in + makeButton(for: meetingType) + } + + // MARK: - Initializer + init() { + super.init(frame: .zero) + configureView() + setAddTargets() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(meetingType: RecruitMeetingType?) { + optionButtons.forEach { button in + button.isSelected = button.tag == meetingType?.index + button.setNeedsUpdateConfiguration() + } + } +} + +extension RecruitPostMeetingTypeView { + private func setAddTargets() { + optionButtons.forEach { + $0.addTarget(self, action: #selector(optionButtonTapped), for: .touchUpInside) + } + } + + @objc private func optionButtonTapped(_ sender: UIButton) { + guard let meetingType = RecruitMeetingType(index: sender.tag) else { + return + } + configure(meetingType: meetingType) + meetingTypeChangedPublisher.send(meetingType) + } +} + +extension RecruitPostMeetingTypeView { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + optionStackView.do { + $0.axis = .horizontal + $0.distribution = .fillEqually + $0.spacing = 8 + } + } + + private func setUpLayouts() { + [headerView, optionStackView].forEach { + addSubview($0) + } + optionButtons.forEach { + optionStackView.addArrangedSubview($0) + } + } + + private func setUpConstraints() { + headerView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + } + optionStackView.snp.makeConstraints { + $0.top.equalTo(headerView.snp.bottom).offset(8) + $0.leading.trailing.bottom.equalToSuperview() + $0.height.equalTo(63) + } + } +} + +extension RecruitPostMeetingTypeView { + private func makeButton(for meetingType: RecruitMeetingType) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.image = .appImage(asset: meetingType.imageAsset) + configuration.attributedTitle = AttributedString( + meetingType.rawValue, + attributes: AttributeContainer([ + .font: UIFont.appFont(.pretendardRegular, size: 12) + ]) + ) + configuration.imagePadding = 4 + configuration.imagePlacement = .top + configuration.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0) + let button = UIButton(configuration: configuration) + button.tag = meetingType.index + button.configurationUpdateHandler = { button in + let foregroundColor: UIColor + let backgroundColor: UIColor + + if button.isSelected { + foregroundColor = .appColor(.neutral0) + backgroundColor = .appColor(.new500) + } else if button.isHighlighted { + foregroundColor = .appColor(.neutral600) + backgroundColor = .appColor(.neutral100) + } else { + foregroundColor = .appColor(.neutral600) + backgroundColor = .appColor(.neutral0) + } + + var configuration = button.configuration + let image = configuration?.image + configuration?.image = image?.withTintColor(foregroundColor, renderingMode: .alwaysOriginal) + configuration?.attributedTitle?.foregroundColor = foregroundColor + configuration?.background.backgroundColor = backgroundColor + configuration?.background.cornerRadius = 16 + button.configuration = configuration + } + button.setNeedsUpdateConfiguration() + return button + } +} diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableView.swift new file mode 100644 index 000000000..12298825e --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableView.swift @@ -0,0 +1,292 @@ +// +// RecruitPostRoleTableView.swift +// koin +// +// Created by 홍기정 on 9/11/26. +// + +import UIKit +import Combine + +final class RecruitPostRoleTableView: UITableView { + // MARK: - Publishers + let nameChangedPublisher = PassthroughSubject<(index: Int, text: String, isComposing: Bool), Never>() + let nameEditingEndedPublisher = PassthroughSubject<(index: Int, text: String), Never>() + let addMembersTappedPublisher = PassthroughSubject() + let subtractMembersTappedPublisher = PassthroughSubject() + let deleteRoleTappedPublisher = PassthroughSubject() + + // MARK: - Properties + private var type: RecruitRoleType = .roleBased + private var numberOfGeneralMembers: Int? + private var rows: [RecruitRoleRequest] = [.init()] + private var showsNameTextField = true + private var canSubtractMembers = [false] + private var canAddMember = true + private var showsDeleteButton = false + + private var displayedRowCount: Int { + type == .general ? 1 : rows.count + } + + override var intrinsicContentSize: CGSize { + let rows = displayedRowCount * 44 + let spacing = max(0, displayedRowCount - 1) * 8 + return CGSize(width: UIView.noIntrinsicMetric, height: CGFloat(rows + spacing)) + } + + // MARK: - Initializer + init() { + super.init(frame: .zero, style: .plain) + commonInit() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure( + type: RecruitRoleType, + numberOfGeneralMembers: Int?, + rows: [RecruitRoleRequest], + showsNameTextField: Bool, + canSubtractMembers: [Bool], + canAddMember: Bool, + showsDeleteButton: Bool, + rowChangeIndex: Int? = nil + ) { + let previousType = self.type + let previousRowCount = displayedRowCount + + self.type = type + self.numberOfGeneralMembers = numberOfGeneralMembers + self.rows = rows + self.showsNameTextField = showsNameTextField + self.canSubtractMembers = canSubtractMembers + self.canAddMember = canAddMember + self.showsDeleteButton = showsDeleteButton + + reload( + previousType: previousType, + previousRowCount: previousRowCount, + rowChangeIndex: rowChangeIndex + ) + } + + private func reload( + previousType: RecruitRoleType, + previousRowCount: Int, + rowChangeIndex: Int? = nil, + ) { + var didChangeRoleType: Bool { + previousType != type + } + var didAddRole: Bool { + if type == .roleBased, + let rowChangeIndex, + displayedRowCount == previousRowCount + 1, + rows.indices.contains(rowChangeIndex) { + return true + } else { + return false + } + } + var didSubtractRole: Bool { + if type == .roleBased, + let rowChangeIndex, + displayedRowCount == previousRowCount - 1, + (0.. Int { + displayedRowCount + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: RecruitPostRoleTableViewCell.identifier, + for: indexPath + ) as? RecruitPostRoleTableViewCell else { + return UITableViewCell() + } + switch type { + case .general: + cell.configure( + numberOfGeneralMembers: numberOfGeneralMembers, + canSubtractMember: canSubtractMember(at: indexPath.row), + canAddMember: canAddMember + ) + case .roleBased: + cell.configure( + model: rows[indexPath.row], + showsNameTextField: showsNameTextField, + canSubtractMember: canSubtractMember(at: indexPath.row), + canAddMember: canAddMember, + showsDeleteButton: showsDeleteButton + ) + } + bind(cell) + return cell + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + indexPath.row == displayedRowCount - 1 ? 44 : 52 + } +} + +extension RecruitPostRoleTableView { + private func bind(_ cell: RecruitPostRoleTableViewCell) { + cell.cellSubscriptions.removeAll() + cell.nameChangedPublisher.sink { [weak self, weak cell] value in + guard let self, let cell, + let index = self.index(of: cell) else { + return + } + self.nameChangedPublisher.send((index, value.text, value.isComposing)) + }.store(in: &cell.cellSubscriptions) + + cell.nameEditingEndedPublisher.sink { [weak self, weak cell] text in + guard let self, let cell, + let index = self.index(of: cell) else { + return + } + self.nameEditingEndedPublisher.send((index, text)) + }.store(in: &cell.cellSubscriptions) + + cell.addMembersTappedPublisher.sink { [weak self, weak cell] in + guard let self, let cell, + let index = self.index(of: cell) else { + return + } + self.addMembersTappedPublisher.send(index) + }.store(in: &cell.cellSubscriptions) + + cell.subtractMembersTappedPublisher.sink { [weak self, weak cell] in + guard let self, let cell, + let index = self.index(of: cell) else { + return + } + self.subtractMembersTappedPublisher.send(index) + }.store(in: &cell.cellSubscriptions) + + cell.deleteTappedPublisher.sink { [weak self, weak cell] in + guard let self, let cell, + let index = self.index(of: cell) else { + return + } + self.deleteRoleTappedPublisher.send(index) + }.store(in: &cell.cellSubscriptions) + } + + private func index(of cell: RecruitPostRoleTableViewCell) -> Int? { + indexPath(for: cell)?.row + } + + private func canSubtractMember(at index: Int) -> Bool { + canSubtractMembers.indices.contains(index) && canSubtractMembers[index] + } +} + +extension RecruitPostRoleTableView { + + private func commonInit() { + backgroundColor = .clear + separatorStyle = .none + isScrollEnabled = false + showsVerticalScrollIndicator = false + rowHeight = 44 + sectionHeaderHeight = 0 + sectionFooterHeight = 0 + dataSource = self + delegate = self + register(RecruitPostRoleTableViewCell.self, forCellReuseIdentifier: RecruitPostRoleTableViewCell.identifier) + } +} diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableViewCell.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableViewCell.swift new file mode 100644 index 000000000..df12fde22 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableViewCell.swift @@ -0,0 +1,259 @@ +// +// RecruitPostRoleTableViewCell.swift +// koin +// +// Created by Codex on 9/11/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class RecruitPostRoleTableViewCell: UITableViewCell { + // MARK: - Publishers + let nameChangedPublisher = PassthroughSubject<(text: String, isComposing: Bool), Never>() + let nameEditingEndedPublisher = PassthroughSubject() + let addMembersTappedPublisher = PassthroughSubject() + let subtractMembersTappedPublisher = PassthroughSubject() + let deleteTappedPublisher = PassthroughSubject() + var cellSubscriptions: Set = [] + + // MARK: - UI Components + private let stackView = UIStackView() + private let nameContainerView = UIView() + private let nameTextField = UITextField() + private let nameCounterLabel = UILabel() + private let membersContainerView = UIView() + private let subtractMembersButton = UIButton() + private let membersLabel = UILabel() + private let addMembersButton = UIButton() + private let deleteButton = UIButton() + + // MARK: - Initializer + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configureView() + setAddTargets() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + cellSubscriptions.removeAll() + nameTextField.text = nil + nameCounterLabel.text = nil + } + + // MARK: - Public + func configure( + numberOfGeneralMembers: Int?, + canSubtractMember: Bool, + canAddMember: Bool + ) { + guard let numberOfGeneralMembers else { + assert(false) + return + } + UIView.performWithoutAnimation { [weak self] in + guard let self else { return } + nameContainerView.isHidden = true + membersLabel.text = "\(numberOfGeneralMembers)" + subtractMembersButton.isEnabled = canSubtractMember + addMembersButton.isEnabled = canAddMember + deleteButton.isHidden = true + + membersContainerView.snp.removeConstraints() + } + } + + func configure( + model: RecruitRoleRequest, + showsNameTextField: Bool, + canSubtractMember: Bool, + canAddMember: Bool, + showsDeleteButton: Bool + ) { + UIView.performWithoutAnimation { [weak self] in + guard let self else { return } + configure(name: model.name) + nameContainerView.isHidden = !showsNameTextField + membersLabel.text = "\(model.maximumParticipants)" + subtractMembersButton.isEnabled = canSubtractMember + addMembersButton.isEnabled = canAddMember + deleteButton.isHidden = !showsDeleteButton + + membersContainerView.snp.remakeConstraints { + $0.width.equalTo(101).priority(.high) + } + } + } + + func configure( + numberOfMembers: Int, + canSubtractMember: Bool, + canAddMember: Bool, + showsDeleteButton: Bool + ) { + membersLabel.text = "\(numberOfMembers)" + subtractMembersButton.isEnabled = canSubtractMember + addMembersButton.isEnabled = canAddMember + deleteButton.isHidden = !showsDeleteButton + } + + func configure(name: String) { + if nameTextField.text != name { + nameTextField.text = name + } + nameCounterLabel.text = "\(name.count)/10" + } +} + +extension RecruitPostRoleTableViewCell { + private func setAddTargets() { + nameTextField.addTarget(self, action: #selector(nameTextFieldEditingChanged), for: .editingChanged) + nameTextField.addTarget(self, action: #selector(nameTextFieldEditingEnded), for: .editingDidEnd) + subtractMembersButton.addTarget(self, action: #selector(subtractMembersButtonTapped), for: .touchUpInside) + addMembersButton.addTarget(self, action: #selector(addMembersButtonTapped), for: .touchUpInside) + deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside) + } + + @objc private func nameTextFieldEditingChanged() { + let text = nameTextField.text ?? "" + nameCounterLabel.text = "\(text.count)/10" + nameChangedPublisher.send((text, nameTextField.markedTextRange != nil)) + } + + @objc private func nameTextFieldEditingEnded() { + nameEditingEndedPublisher.send(nameTextField.text ?? "") + } + + @objc private func subtractMembersButtonTapped() { + subtractMembersTappedPublisher.send(()) + } + + @objc private func addMembersButtonTapped() { + addMembersTappedPublisher.send(()) + } + + @objc private func deleteButtonTapped() { + deleteTappedPublisher.send(()) + } +} + +extension RecruitPostRoleTableViewCell { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + + stackView.do { + $0.axis = .horizontal + $0.alignment = .fill + $0.spacing = 8 + } + [nameContainerView, membersContainerView].forEach { + $0.backgroundColor = .appColor(.neutral0) + $0.layer.cornerRadius = 8 + } + + nameTextField.do { + $0.font = .appFont(.pretendardRegular, size: 12) + $0.textColor = .appColor(.neutral600) + $0.autocorrectionType = .no + $0.spellCheckingType = .no + $0.autocapitalizationType = .none + $0.attributedPlaceholder = NSAttributedString( + string: "역할을 입력해주세요.", + attributes: [ + .font: UIFont.appFont(.pretendardRegular, size: 12), + .foregroundColor: UIColor.appColor(.neutral500) + ] + ) + } + nameCounterLabel.do { + $0.font = .appFont(.pretendardRegular, size: 12) + $0.textColor = .appColor(.neutral600) + $0.textAlignment = .right + $0.setContentCompressionResistancePriority(.required, for: .horizontal) + } + membersLabel.do { + $0.font = .appFont(.pretendardRegular, size: 12) + $0.textColor = .appColor(.neutral600) + $0.textAlignment = .center + } + configureIconButton(subtractMembersButton, imageAsset: .minus) + configureIconButton(addMembersButton, imageAsset: .addThin) + configureIconButton(deleteButton, imageAsset: .delete) + } + + private func configureIconButton(_ button: UIButton, imageAsset: ImageAsset) { + var configuration = UIButton.Configuration.plain() + configuration.image = UIImage.appImage(asset: imageAsset)? + .withTintColor(.appColor(.neutral600), renderingMode: .alwaysOriginal) + configuration.contentInsets = .zero + button.configuration = configuration + button.configurationUpdateHandler = { button in + if !button.isEnabled { + var configuration = button.configuration + let image = configuration?.image + configuration?.image?.withTintColor(.appColor(.neutral400)) + button.configuration = configuration + } + } + } + + private func setUpLayouts() { + contentView.addSubview(stackView) + [nameContainerView, membersContainerView, deleteButton].forEach { + stackView.addArrangedSubview($0) + } + [nameTextField, nameCounterLabel].forEach { + nameContainerView.addSubview($0) + } + [subtractMembersButton, membersLabel, addMembersButton].forEach { + membersContainerView.addSubview($0) + } + } + + private func setUpConstraints() { + stackView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.height.equalTo(44) + } + nameTextField.snp.makeConstraints { + $0.top.bottom.equalToSuperview() + $0.leading.equalToSuperview().offset(12) + } + nameCounterLabel.snp.makeConstraints { + $0.leading.equalTo(nameTextField.snp.trailing).offset(4) + $0.trailing.equalToSuperview().offset(-12) + $0.centerY.equalToSuperview() + } + subtractMembersButton.snp.makeConstraints { + $0.top.leading.bottom.equalToSuperview() + $0.width.equalTo(34) + } + membersLabel.snp.makeConstraints { + $0.leading.equalTo(subtractMembersButton.snp.trailing) + $0.top.bottom.equalToSuperview() + } + addMembersButton.snp.makeConstraints { + $0.leading.equalTo(membersLabel.snp.trailing) + $0.top.trailing.bottom.equalToSuperview() + $0.width.equalTo(34) + } + deleteButton.snp.makeConstraints { + $0.width.equalTo(16) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift new file mode 100644 index 000000000..a1101389e --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift @@ -0,0 +1,327 @@ +// +// RecruitPostRoleView.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class RecruitPostRoleView: UIView { + // MARK: - Publishers + let roleTypeChangedPublisher = PassthroughSubject() + let rolesChangedPublisher = PassthroughSubject<[RecruitRoleRequest], Never>() + let numberOfGeneralMembersChangedPublisher = PassthroughSubject() + let didChangeHeightPublisher = PassthroughSubject() + + // MARK: - Properties + private var subscriptions: Set = [] + private var roleType: RecruitRoleType + private var roles: [RecruitRoleRequest] + private var numberOfGeneralMembers: Int + private var pendingRowChangeIndex: Int? = nil + + private let minimumNumberOfMembers = 1 + private let maximumNumberOfRoles = 5 + private let maximumRoleNameLength = 10 + private let maximumNumberOfMembers = 10 + + // MARK: - UI Components + private let headerView = RecruitPostSectionHeaderView(title: "모집 인원 및 역할", isRequired: true) + private let descriptionLabel = UILabel() + private let addRoleButton = UIButton() + private let changeRoleTypeButton = UIButton() + private let tableView = RecruitPostRoleTableView() + + // MARK: - Initializer + init( + roleType: RecruitRoleType = .roleBased, + roles: [RecruitRoleRequest] = [.init()], + numberOfGeneralMembers: Int = 1 + ) { + self.roleType = roleType + self.roles = roles.isEmpty && roleType == .roleBased ? [.init()] : roles + self.numberOfGeneralMembers = numberOfGeneralMembers + super.init(frame: .zero) + configureView() + setAddTargets() + bindTableView() + updateViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure( + roleType: RecruitRoleType, + roles: [RecruitRoleRequest], + numberOfGeneralMembers: Int? + ) { + self.roleType = roleType + self.roles = roles.isEmpty && roleType == .roleBased ? [.init()] : roles + self.numberOfGeneralMembers = numberOfGeneralMembers ?? minimumNumberOfMembers + pendingRowChangeIndex = nil + updateViews() + } + + func applyPendingSizeChange() { + let rowChangeIndex = pendingRowChangeIndex + pendingRowChangeIndex = nil + updateViews(rowChangeIndex: rowChangeIndex) + } +} + +extension RecruitPostRoleView { + private func setAddTargets() { + addRoleButton.addTarget(self, action: #selector(addRoleButtonTapped), for: .touchUpInside) + changeRoleTypeButton.addTarget(self, action: #selector(changeRoleTypeButtonTapped), for: .touchUpInside) + } + + private func bindTableView() { + tableView.nameChangedPublisher.sink { [weak self] value in + self?.updateRoleName(at: value.index, text: value.text, trimWhitespace: false, isComposing: value.isComposing) + }.store(in: &subscriptions) + + tableView.nameEditingEndedPublisher.sink { [weak self] value in + self?.updateRoleName(at: value.index, text: value.text, trimWhitespace: true, isComposing: false) + }.store(in: &subscriptions) + + tableView.addMembersTappedPublisher.sink { [weak self] index in + self?.changeNumberOfMembers(at: index, by: 1) + }.store(in: &subscriptions) + + tableView.subtractMembersTappedPublisher.sink { [weak self] index in + self?.changeNumberOfMembers(at: index, by: -1) + }.store(in: &subscriptions) + + tableView.deleteRoleTappedPublisher.sink { [weak self] index in + self?.deleteRole(at: index) + }.store(in: &subscriptions) + } + + @objc private func addRoleButtonTapped() { + guard roleType == .roleBased, + roles.count < maximumNumberOfRoles, + totalRoleMembers < maximumNumberOfMembers else { return } + tableView.endEditing(true) + roles.append(.init()) + pendingRowChangeIndex = roles.count - 1 + didChangeHeightPublisher.send() + rolesChangedPublisher.send(roles) + } + + @objc private func changeRoleTypeButtonTapped() { + tableView.endEditing(true) + pendingRowChangeIndex = nil + let previousRowCount = displayedRowCount + switch roleType { + case .roleBased: + roleType = .general + roles = [] + numberOfGeneralMembers = minimumNumberOfMembers + if previousRowCount != displayedRowCount { + didChangeHeightPublisher.send() + } else { + updateViews() + } + roleTypeChangedPublisher.send(roleType) + rolesChangedPublisher.send(roles) + numberOfGeneralMembersChangedPublisher.send(numberOfGeneralMembers) + case .general: + roleType = .roleBased + roles = [.init()] + numberOfGeneralMembers = minimumNumberOfMembers + if previousRowCount != displayedRowCount { + didChangeHeightPublisher.send() + } else { + updateViews() + } + roleTypeChangedPublisher.send(roleType) + rolesChangedPublisher.send(roles) + numberOfGeneralMembersChangedPublisher.send(nil) + } + } + + private func updateRoleName(at index: Int, text: String, trimWhitespace: Bool, isComposing: Bool) { + guard roleType == .roleBased, roles.indices.contains(index), !isComposing else { return } + let input = trimWhitespace ? text.trimmingCharacters(in: .whitespacesAndNewlines) : text + let name = String(input.prefix(maximumRoleNameLength)) + tableView.configure(name: name, at: index) + guard roles[index].name != name else { return } + roles[index].name = name + rolesChangedPublisher.send(roles) + } + + private func changeNumberOfMembers(at index: Int, by amount: Int) { + switch roleType { + case .general: + guard index == 0 else { return } + let next = numberOfGeneralMembers + amount + guard (minimumNumberOfMembers...maximumNumberOfMembers).contains(next) else { return } + numberOfGeneralMembers = next + updateViews(rowChangeIndex: index) + numberOfGeneralMembersChangedPublisher.send(next) + case .roleBased: + guard roles.indices.contains(index) else { return } + let next = roles[index].maximumParticipants + amount + guard next >= minimumNumberOfMembers, + totalRoleMembers + amount <= maximumNumberOfMembers else { return } + roles[index].maximumParticipants = next + updateViews(rowChangeIndex: index) + rolesChangedPublisher.send(roles) + } + } + + private func deleteRole(at index: Int) { + guard roleType == .roleBased, roles.count > 1, roles.indices.contains(index) else { return } + tableView.endEditing(true) + roles.remove(at: index) + pendingRowChangeIndex = index + didChangeHeightPublisher.send() + rolesChangedPublisher.send(roles) + } +} + +extension RecruitPostRoleView { + private var displayedRowCount: Int { + roleType == .general ? 1 : roles.count + } + + private var totalRoleMembers: Int { + roles.reduce(0) { $0 + $1.maximumParticipants } + } + + private func updateViews(rowChangeIndex: Int? = nil) { + addRoleButton.isHidden = roleType == .general + addRoleButton.isEnabled = roleType == .roleBased + && roles.count < maximumNumberOfRoles + && totalRoleMembers < maximumNumberOfMembers + changeRoleTypeButton.isSelected = roleType == .general + + switch roleType { + case .general: + tableView.configure( + type: roleType, + numberOfGeneralMembers: numberOfGeneralMembers, + rows: [], + showsNameTextField: false, + canSubtractMembers: [numberOfGeneralMembers > minimumNumberOfMembers], + canAddMember: numberOfGeneralMembers < maximumNumberOfMembers, + showsDeleteButton: false, + rowChangeIndex: rowChangeIndex + ) + case .roleBased: + tableView.configure( + type: roleType, + numberOfGeneralMembers: nil, + rows: roles, + showsNameTextField: true, + canSubtractMembers: roles.map { $0.maximumParticipants > minimumNumberOfMembers }, + canAddMember: totalRoleMembers < maximumNumberOfMembers, + showsDeleteButton: roles.count > 1, + rowChangeIndex: rowChangeIndex + ) + } + } +} + +extension RecruitPostRoleView { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + descriptionLabel.do { + $0.text = "역할을 추가하고 필요한 인원을 선택해주세요" + $0.font = .appFont(.pretendardRegular, size: 12) + $0.textColor = .appColor(.neutral500) + } + + addRoleButton.do { + var configuration = UIButton.Configuration.plain() + configuration.attributedTitle = AttributedString( + "역할 추가", + attributes: AttributeContainer([ + .font: UIFont.appFont(.pretendardRegular, size: 12), + .foregroundColor: UIColor.appColor(.neutral0) + ]) + ) + configuration.image = UIImage.appImage(asset: .addThin)? + .withTintColor(.appColor(.neutral0), renderingMode: .alwaysOriginal) + configuration.imagePadding = 4 + configuration.background.cornerRadius = 15.5 + configuration.contentInsets = .init(top: 0, leading: 12, bottom: 0, trailing: 12) + $0.configuration = configuration + $0.configurationUpdateHandler = { button in + var configuration = button.configuration + configuration?.background.backgroundColor = button.isEnabled + ? .appColor(.new500) : .appColor(.neutral300) + button.configuration = configuration + } + } + + changeRoleTypeButton.do { + var configuration = UIButton.Configuration.plain() + configuration.attributedTitle = AttributedString( + "역할 구분 없이 모집하기", + attributes: AttributeContainer([ + .font: UIFont.appFont(.pretendardRegular, size: 12), + .foregroundColor: UIColor.appColor(.neutral500) + ]) + ) + configuration.imagePlacement = .leading + configuration.imagePadding = 4 + configuration.contentInsets = .zero + configuration.automaticallyUpdateForSelection = false + configuration.background.backgroundColor = .clear + configuration.baseBackgroundColor = nil + $0.configuration = configuration + $0.contentHorizontalAlignment = .leading + $0.configurationUpdateHandler = { button in + let asset: ImageAsset = button.isSelected ? .circleCheckedPrimary500 : .circlePrimary500 + var configuration = button.configuration + configuration?.image = UIImage.appImage(asset: asset)? + .withTintColor(.appColor(.new500), renderingMode: .alwaysOriginal) + button.configuration = configuration + } + } + } + + private func setUpLayouts() { + [headerView, descriptionLabel, addRoleButton, changeRoleTypeButton, tableView].forEach { + addSubview($0) + } + } + + private func setUpConstraints() { + headerView.snp.makeConstraints { + $0.top.leading.equalToSuperview() + $0.trailing.lessThanOrEqualTo(addRoleButton.snp.leading).offset(-8) + } + addRoleButton.snp.makeConstraints { + $0.top.trailing.equalToSuperview() + $0.height.equalTo(31) + } + descriptionLabel.snp.makeConstraints { + $0.top.equalTo(headerView.snp.bottom) + $0.leading.equalToSuperview() + $0.height.equalTo(19) + } + changeRoleTypeButton.snp.makeConstraints { + $0.top.equalTo(descriptionLabel.snp.bottom).offset(4) + $0.leading.trailing.equalToSuperview() + $0.height.equalTo(24) + } + tableView.snp.makeConstraints { + $0.top.equalTo(changeRoleTypeButton.snp.bottom).offset(8) + $0.leading.trailing.bottom.equalToSuperview() + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostScheduleView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostScheduleView.swift new file mode 100644 index 000000000..3d9e61043 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostScheduleView.swift @@ -0,0 +1,230 @@ +// +// RecruitPostScheduleView.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class RecruitPostScheduleView: UIView { + + // MARK: - Properties + let startDateButtonTappedPublisher = PassthroughSubject() + let endDateButtonTappedPublisher = PassthroughSubject() + let deadlineDateButtonTappedPublisher = PassthroughSubject() + + private let formatter = DateFormatter().then { + $0.locale = Locale(identifier: "ko_KR") + $0.calendar = Calendar(identifier: .gregorian) + $0.dateFormat = "yyyy.MM.dd" + } + + // MARK: - UI Components + private let headerView = RecruitPostSectionHeaderView(title: "일정", isRequired: true) + + private let activityPeriodLabel = UILabel() + private let startDateButton = UIButton() + private let startDateLabel = UILabel() + private let dashLabel = UILabel() + private let endDateButton = UIButton() + private let endDateLabel = UILabel() + private let deadlineLabel = UILabel() + private let deadlineDateButton = UIButton() + private let deadlineDateLabel = UILabel() + + // MARK: - Dropdown + let activityPeriodDropdownAnchor = UIView() + let deadlineDateDropdownAnchor = UIView() + + let startDateDropdownContentView = KoinPickerDropDownView( + delegate: KoinPickerDropDownViewDateDelegate(range: 0..<365) // TODO: range 대신 Date 로 리팩토링 + ).then { + $0.reset(initialDate: Date()) + $0.backgroundColor = .appColor(.neutral0) + } + let endDateDropdownContentView = KoinPickerDropDownView( + delegate: KoinPickerDropDownViewDateDelegate(range: 0..<365) + ).then { + $0.reset(initialDate: Date()) + $0.backgroundColor = .appColor(.neutral0) + } + let deadlineDateDropdownContentView = KoinPickerDropDownView( + delegate: KoinPickerDropDownViewDateDelegate(range: 0..<365) + ).then { + $0.reset(initialDate: Date()) + $0.backgroundColor = .appColor(.neutral0) + } + + // MARK: - Initializer + init() { + super.init(frame: .zero) + configureView() + setAddTargets() + startDateDropdownContentView.reset(initialDate: Date()) + endDateDropdownContentView.reset(initialDate: Date()) + deadlineDateDropdownContentView.reset(initialDate: Date()) + + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure( + startDate: Date? = nil, + endDate: Date? = nil, + deadlineDate: Date? = nil + ) { + if let startDate { + startDateLabel.text = formatter.string(from: startDate) + startDateDropdownContentView.reset(initialDate: startDate) + } + if let endDate { + endDateLabel.text = formatter.string(from: endDate) + endDateDropdownContentView.reset(initialDate: endDate) + } + if let deadlineDate { + deadlineDateLabel.text = formatter.string(from: deadlineDate) + deadlineDateDropdownContentView.reset(initialDate: deadlineDate) + } + } +} + +extension RecruitPostScheduleView { + private func setAddTargets() { + startDateButton.addTarget(self, action: #selector(startDateButtonTapped), for: .touchUpInside) + endDateButton.addTarget(self, action: #selector(endDateButtonTapped), for: .touchUpInside) + deadlineDateButton.addTarget(self, action: #selector(deadlineDateButtonTapped), for: .touchUpInside) + } + @objc private func startDateButtonTapped() { + startDateButtonTappedPublisher.send() + } + @objc private func endDateButtonTapped() { + endDateButtonTappedPublisher.send() + } + @objc private func deadlineDateButtonTapped() { + deadlineDateButtonTappedPublisher.send() + } +} + +extension RecruitPostScheduleView { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + activityPeriodLabel.do { + $0.text = "활동기간" + $0.font = UIFont.appFont(.pretendardMedium, size: 14) + $0.textColor = UIColor.appColor(.neutral800) + } + deadlineLabel.do { + $0.text = "마감일" + $0.font = UIFont.appFont(.pretendardMedium, size: 14) + $0.textColor = UIColor.appColor(.neutral800) + } + [startDateButton, endDateButton, deadlineDateButton].forEach { + $0.layer.cornerRadius = 16 + $0.backgroundColor = .appColor(.neutral0) + } + [startDateLabel, endDateLabel, deadlineDateLabel].forEach { label in + label.do { + $0.font = UIFont.appFont(.pretendardRegular, size: 12) + $0.textAlignment = .center + $0.textColor = .appColor(.neutral600) + } + } + startDateLabel.text = "시작일" + endDateLabel.text = "종료일" + deadlineDateLabel.text = "마감일" + + dashLabel.do { + $0.text = "-" + $0.font = UIFont.appFont(.pretendardSemiBold, size: 16) + $0.textColor = UIColor.appColor(.neutral500) + $0.textAlignment = .center + } + [activityPeriodDropdownAnchor, deadlineDateDropdownAnchor].forEach { + $0.backgroundColor = .clear + $0.isUserInteractionEnabled = false + } + [startDateDropdownContentView, endDateDropdownContentView, deadlineDateDropdownContentView].forEach { + $0.layer.cornerRadius = 16 + } + } + + private func setUpLayouts() { + [headerView, + activityPeriodLabel, + activityPeriodDropdownAnchor, startDateButton, startDateLabel, dashLabel, endDateButton, endDateLabel, + deadlineLabel, + deadlineDateDropdownAnchor, deadlineDateButton, deadlineDateLabel + ].forEach { + addSubview($0) + } + } + + private func setUpConstraints() { + headerView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + } + + activityPeriodLabel.snp.makeConstraints { + $0.top.equalTo(headerView.snp.bottom).offset(8) + $0.leading.equalToSuperview() + $0.height.equalTo(22) + } + activityPeriodDropdownAnchor.snp.makeConstraints { + $0.top.bottom.equalTo(startDateButton) + $0.leading.trailing.equalToSuperview() + } + startDateButton.snp.makeConstraints { + $0.top.equalTo(activityPeriodLabel.snp.bottom).offset(4) + $0.leading.equalToSuperview() + $0.height.equalTo(35) + } + startDateLabel.snp.makeConstraints { + $0.edges.equalTo(startDateButton) + } + dashLabel.snp.makeConstraints { + $0.centerY.equalTo(startDateButton) + $0.centerX.equalToSuperview() + $0.leading.equalTo(startDateButton.snp.trailing) + $0.trailing.equalTo(endDateButton.snp.leading) + $0.width.equalTo(48) + } + endDateButton.snp.makeConstraints { + $0.trailing.equalToSuperview() + $0.top.height.equalTo(startDateButton) + $0.height.equalTo(startDateButton) + } + endDateLabel.snp.makeConstraints { + $0.edges.equalTo(endDateButton) + } + + deadlineLabel.snp.makeConstraints { + $0.top.equalTo(startDateButton.snp.bottom).offset(12) + $0.leading.equalToSuperview() + $0.height.equalTo(22) + } + deadlineDateDropdownAnchor.snp.makeConstraints { + $0.top.bottom.equalTo(deadlineDateButton) + $0.leading.trailing.equalToSuperview() + } + deadlineDateButton.snp.makeConstraints { + $0.top.equalTo(deadlineLabel.snp.bottom).offset(4) + $0.leading.equalToSuperview() + $0.width.height.equalTo(startDateButton) + $0.bottom.equalToSuperview() + } + deadlineDateLabel.snp.makeConstraints { + $0.edges.equalTo(deadlineDateButton) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostSectionHeaderView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostSectionHeaderView.swift new file mode 100644 index 000000000..ffb6e8a47 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostSectionHeaderView.swift @@ -0,0 +1,110 @@ +// +// RecruitPostSectionHeaderView.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import UIKit +import SnapKit +import Then + +final class RecruitPostSectionHeaderView: UIView { + + // MARK: - UI Components + private let titleLabel = UILabel() + private let isRequiredLabel = UILabel() + private let counterLabel = UILabel() + + // MARK: - Initializer + init( + title: String, + isRequired: Bool, + limit: Int? = nil + ) { + super.init(frame: .zero) + configureView( + title, + isRequired: isRequired, + limit: limit + ) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func updateCounter(current: Int, limit: Int) { + counterLabel.text = "\(current)/\(limit)" + } +} + +extension RecruitPostSectionHeaderView { + private func configureView( + _ title: String, + isRequired: Bool, + limit: Int? + ) { + setUpStyles( + title, + isRequired: isRequired, + limit: limit + ) + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles( + _ title: String, + isRequired: Bool, + limit: Int? + ) { + titleLabel.do { + $0.text = title + $0.font = .appFont(.pretendardSemiBold, size: 16) + $0.textColor = .appColor(.neutral800) + } + isRequiredLabel.do { + $0.text = "*" + $0.font = .appFont(.pretendardSemiBold, size: 16) + $0.textColor = .appColor(.new500) + $0.isHidden = !isRequired + } + counterLabel.do { + $0.font = UIFont.appFont(.pretendardRegular, size: 12) + $0.textColor = UIColor.appColor(.neutral500) + $0.textAlignment = .right + $0.isHidden = true + } + counterLabel.do { + if let limit { + $0.isHidden = false + $0.text = "0/\(limit)" + } else { + $0.isHidden = true + } + } + } + + private func setUpLayouts() { + [titleLabel, isRequiredLabel, counterLabel].forEach { + addSubview($0) + } + } + + private func setUpConstraints() { + self.snp.makeConstraints { + $0.height.equalTo(26) + } + titleLabel.snp.makeConstraints { + $0.leading.centerY.equalToSuperview() + } + isRequiredLabel.snp.makeConstraints { + $0.leading.equalTo(titleLabel.snp.trailing).offset(3) + $0.centerY.equalToSuperview() + } + counterLabel.snp.makeConstraints { + $0.trailing.centerY.equalToSuperview() + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextFieldView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextFieldView.swift new file mode 100644 index 000000000..cadf01219 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextFieldView.swift @@ -0,0 +1,133 @@ +// +// RecruitPostTextFieldView.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class RecruitPostTextFieldView: UIView { + + // MARK: - Properties + let textChangedPublisher = PassthroughSubject() + private let limit: Int? + + // MARK: - UI Components + private let headerView: RecruitPostSectionHeaderView + private let textField = UITextField() + + // MARK: - Initializer + init( + title: String, + isRequired: Bool, + limit: Int?, + placeholder: String + ) { + self.headerView = RecruitPostSectionHeaderView( + title: title, + isRequired: isRequired, + limit: limit + ) + self.limit = limit + super.init(frame: .zero) + configureView() + setAddTargets() + setUpPlaceholder(placeholder) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(text: String?) { + textField.text = text + updateCounter(text: text) + } +} + +extension RecruitPostTextFieldView { + private func setAddTargets() { + textField.addTarget(self, action: #selector(textFieldEditingChanged), for: .editingChanged) + textField.addTarget(self, action: #selector(textFieldEditingEnd), for: .editingDidEnd) + } + + @objc private func textFieldEditingChanged() { + var text = textField.text ?? "" + if let limit { + text = String(text.prefix(limit)) + } + textField.text = text + textChangedPublisher.send(text) + updateCounter(text: text) + } + + @objc private func textFieldEditingEnd() { + var text = (textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if let limit { + text = String(text.prefix(limit)) + } + textField.text = text + textChangedPublisher.send(text) + updateCounter(text: text) + } + + private func updateCounter(text: String?) { + if let limit { + headerView.updateCounter(current: text?.count ?? 0, limit: limit) + } + } +} + +extension RecruitPostTextFieldView { + + private func setUpPlaceholder(_ placeholder: String?) { + guard let placeholder else { return } + textField.attributedPlaceholder = NSAttributedString( + string: placeholder, + attributes: [ + .font: UIFont.appFont(.pretendardRegular, size: 14), + .foregroundColor: UIColor.appColor(.neutral500) + ] + ) + } + + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + textField.do { + $0.font = UIFont.appFont(.pretendardRegular, size: 14) + $0.textColor = UIColor.appColor(.neutral800) + $0.backgroundColor = .appColor(.neutral0) + $0.layer.cornerRadius = 16 + $0.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + $0.rightViewMode = .always + $0.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + $0.leftViewMode = .always + } + } + + private func setUpLayouts() { + [headerView, textField].forEach { + addSubview($0) + } + } + + private func setUpConstraints() { + headerView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + } + textField.snp.makeConstraints { + $0.top.equalTo(headerView.snp.bottom).offset(8) + $0.leading.trailing.bottom.equalToSuperview() + $0.height.equalTo(40) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextViewView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextViewView.swift new file mode 100644 index 000000000..9fb2a6fc9 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextViewView.swift @@ -0,0 +1,127 @@ +// +// RecruitPostTextViewView.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class RecruitPostTextViewView: UIView { + + // MARK: - Properties + let textChangedPublisher = PassthroughSubject() + private let limit: Int + + // MARK: - UI Components + private let headerView: RecruitPostSectionHeaderView + private let textView = UITextView() + private let placeholderLabel = UILabel() + + // MARK: - Initializer + init( + title: String, + isRequired: Bool, + limit: Int, + placeholder: String + ) { + self.headerView = RecruitPostSectionHeaderView( + title: title, + isRequired: isRequired, + limit: limit + ) + self.limit = limit + super.init(frame: .zero) + configureView() + setUpDelegate() + setUpPlaceholder(placeholder) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(text: String) { + textView.text = text + placeholderLabel.isHidden = !text.isEmpty + headerView.updateCounter(current: text.count, limit: limit) + } +} + +extension RecruitPostTextViewView: UITextViewDelegate { + private func setUpDelegate() { + textView.delegate = self + } + + func textViewDidChange(_ textView: UITextView) { + let text = String(textView.text.prefix(limit)) + + textView.text = text + placeholderLabel.isHidden = !text.isEmpty + textChangedPublisher.send(text) + + headerView.updateCounter(current: text.count, limit: limit) + } + + func textViewDidEndEditing(_ textView: UITextView) { + let text = String(textView.text.trimmingCharacters(in: .whitespacesAndNewlines).prefix(limit)) + + textView.text = text + placeholderLabel.isHidden = !text.isEmpty + textChangedPublisher.send(text) + + headerView.updateCounter(current: text.count, limit: limit) + } +} + +extension RecruitPostTextViewView { + private func setUpPlaceholder(_ placeholder: String) { + placeholderLabel.text = placeholder + } + + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + textView.do { + $0.font = UIFont.appFont(.pretendardRegular, size: 14) + $0.textColor = UIColor.appColor(.neutral800) + $0.backgroundColor = .appColor(.neutral0) + $0.layer.cornerRadius = 16 + $0.textContainerInset = UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12) + $0.textContainer.lineFragmentPadding = 0 + } + placeholderLabel.do { + $0.font = UIFont.appFont(.pretendardRegular, size: 14) + $0.textColor = UIColor.appColor(.neutral500) + $0.numberOfLines = 0 + } + } + + private func setUpLayouts() { + [headerView, textView, placeholderLabel].forEach { + addSubview($0) + } + } + + private func setUpConstraints() { + headerView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + } + textView.snp.makeConstraints { + $0.top.equalTo(headerView.snp.bottom).offset(8) + $0.leading.trailing.bottom.equalToSuperview() + $0.height.equalTo(151) + } + placeholderLabel.snp.makeConstraints { + $0.top.equalTo(textView).offset(8) + $0.leading.trailing.equalTo(textView).inset(12) + } + } +} diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 39a4eb4cf..949cb4dc2 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -7,7 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + 004A06CB3468BBA8DCB4CB44 /* RecruitPostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F7EBAB20247086024B51CB /* RecruitPostViewController.swift */; }; + 0E2253358E42231BE436FA27 /* RecruitPostScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE127F2AE4B18899F6E5E0D /* RecruitPostScheduleView.swift */; }; 0FCD163D2BA80EAA00C23A2F /* KeychainWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCD163C2BA80EAA00C23A2F /* KeychainWorker.swift */; }; + 1537B6CFD3D7AF52A2E9E944 /* RecruitPostMeetingTypeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73F70BC5F3AA1A34663C32CA /* RecruitPostMeetingTypeView.swift */; }; + 5006987DEF4B5F454D7EA2D5 /* RecruitPostTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */; }; + 515B96092C6730C797C1F837 /* PostRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D447F2D75951DD4C3E08D4E /* PostRecruitUseCase.swift */; }; + 75EBF253F4468B2060B79212 /* RecruitPostSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3694E3ACBE05B8C7F15357 /* RecruitPostSectionHeaderView.swift */; }; 7C016540300A88940013DD7B /* ShuttleTicketViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C01653F300A88940013DD7B /* ShuttleTicketViewController.swift */; }; 7C016544300AA1450013DD7B /* DepartmentCategoryHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C016543300AA1450013DD7B /* DepartmentCategoryHostingController.swift */; }; 7C016546300AA14C0013DD7B /* DepartmentCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C016545300AA14C0013DD7B /* DepartmentCategoryView.swift */; }; @@ -357,6 +363,7 @@ 7CA6868D2FFCE867007937E9 /* UpdateNotificationHistoryUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA6868C2FFCE867007937E9 /* UpdateNotificationHistoryUseCase.swift */; }; 7CA6868F2FFCF42E007937E9 /* NotificationHistoryError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA6868E2FFCF42E007937E9 /* NotificationHistoryError.swift */; }; 7CA686902FFCF42E007937E9 /* NotificationHistoryError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA6868E2FFCF42E007937E9 /* NotificationHistoryError.swift */; }; + 7CAC2DDC304C34E900AC2828 /* RecruitPostRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAC2DDB304C34E900AC2828 /* RecruitPostRequest.swift */; }; 7CACF86530436CFF00AC2828 /* RecruitNotificationListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CACF86430436CFF00AC2828 /* RecruitNotificationListViewController.swift */; }; 7CACF86730436D2600AC2828 /* RecruitNotificationListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CACF86630436D2600AC2828 /* RecruitNotificationListViewModel.swift */; }; 7CACF86930436E7700AC2828 /* RecruitNotification+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CACF86830436E7700AC2828 /* RecruitNotification+.swift */; }; @@ -447,6 +454,7 @@ 7CCB1E4B2F29D87C00472669 /* LostItemPostChatDetailUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CCB1E4A2F29D87C00472669 /* LostItemPostChatDetailUseCase.swift */; }; 7CCB1E4E2F29DB3200472669 /* LostItemPostChatDetailRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CCB1E4D2F29DB3200472669 /* LostItemPostChatDetailRequest.swift */; }; 7CD411EA2FEB92280079467B /* HomeTabBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CD411E92FEB92280079467B /* HomeTabBarItem.swift */; }; + 7CDCD0A93053F96E004204DA /* RecruitPostRoleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0A83053F96E004204DA /* RecruitPostRoleTableViewCell.swift */; }; 7CEB3B4B2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */; }; 7CED90BC2EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FD02EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift */; }; 7CED90BD2EF2D1F900457128 /* ManageNoticeKeywordViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F612EF2D1F900457128 /* ManageNoticeKeywordViewController.swift */; }; @@ -764,6 +772,9 @@ 83E96DB82BBC6FBF00164914 /* BusSearchDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83E96DB72BBC6FBF00164914 /* BusSearchDto.swift */; }; 83EBB87A2D0FDFD700346018 /* KoinPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83EBB8792D0FDFD700346018 /* KoinPickerView.swift */; }; 83EBB87E2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83EBB87D2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift */; }; + A0B343754988C9398C4FE726 /* ModifyRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */; }; + A1D000010000000000000001 /* FetchRecruitDetailUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D000010000000000000002 /* FetchRecruitDetailUseCase.swift */; }; + A1E000010000000000000001 /* RecruitPostRoleTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E000010000000000000002 /* RecruitPostRoleTableView.swift */; }; AA20000000000000000001 /* PublisherTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000001 /* PublisherTestSupport.swift */; }; AA20000000000000000002 /* DiningFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000002 /* DiningFixtures.swift */; }; AA20000000000000000003 /* SpyDiningRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000003 /* SpyDiningRepository.swift */; }; @@ -774,6 +785,7 @@ AA20000000000000000008 /* DiningViewModelStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000008 /* DiningViewModelStubs.swift */; }; AA20000000000000000009 /* DiningLoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000009 /* DiningLoggingTests.swift */; }; AA20000000000000000010 /* DiningTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000010 /* DiningTypeTests.swift */; }; + B0495459FC164809B16A96B6 /* RecruitPostTextViewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54351A8A0C1A4046C3155481 /* RecruitPostTextViewView.swift */; }; B438AEE42E756AB600E889C4 /* OrderImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B438AEE32E756AB600E889C4 /* OrderImage.swift */; }; B46B8CC22E76CB7300A8E797 /* ShopSummaryDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = B46B8CC12E76CB7300A8E797 /* ShopSummaryDto.swift */; }; B46B8CC42E76CC6300A8E797 /* FetchOrderShopSummaryFromShopUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B46B8CC32E76CC6300A8E797 /* FetchOrderShopSummaryFromShopUseCase.swift */; }; @@ -801,6 +813,8 @@ B68D2CEB2EA50C1E00F3B479 /* UIViewController+Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68D2CEA2EA50C1E00F3B479 /* UIViewController+Toast.swift */; }; B6A0D62A2EEB01E600A19521 /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = B6A0D6292EEB01E600A19521 /* Debug.xcconfig */; }; B6A0D62C2EEB01F100A19521 /* Release.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = B6A0D62B2EEB01F100A19521 /* Release.xcconfig */; }; + C1838F7A4424DEA14DB2D107 /* RecruitPostCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */; }; + CD3000000000000000000001 /* RecruitPostCategoryDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3000000000000000000002 /* RecruitPostCategoryDropdownView.swift */; }; D0A71C4F2F1B8340009E2D71 /* KoinDropdownHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */; }; D19A00013000000000000002 /* NotificationRowModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A00013000000000000001 /* NotificationRowModel.swift */; }; D19A00013000000000000004 /* NotificationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A00013000000000000003 /* NotificationListView.swift */; }; @@ -1039,6 +1053,8 @@ D8F5C54A2E6EF76800FB6708 /* OrderFloatingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F5C5492E6EF76800FB6708 /* OrderFloatingButton.swift */; }; D8F5C54C2E6F012500FB6708 /* LottieAnimationManageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F5C54B2E6F012500FB6708 /* LottieAnimationManageable.swift */; }; D8F5C5562E6F144300FB6708 /* floatingLogo.json in Resources */ = {isa = PBXBuildFile; fileRef = D8F5C5532E6F144300FB6708 /* floatingLogo.json */; }; + E5DD3F6BE6C501B7B3BD659C /* RecruitPostRoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F670021A95E0AA103E8F7BED /* RecruitPostRoleView.swift */; }; + F0F216EDAA69B52CBEC0E606 /* RecruitPostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 288BC1E04044DC3FB48524FC /* RecruitPostViewModel.swift */; }; F1A000010000000000000001 /* ZoomingCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A000010000000000000002 /* ZoomingCollectionView.swift */; }; F1A000010000000000000003 /* ZoomingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A000010000000000000004 /* ZoomingCollectionViewCell.swift */; }; FE01000000000000000021 /* KoinDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01000000000000000011 /* KoinDropdown.swift */; }; @@ -1079,6 +1095,13 @@ /* Begin PBXFileReference section */ 0FCD163C2BA80EAA00C23A2F /* KeychainWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainWorker.swift; sourceTree = ""; }; + 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostTextFieldView.swift; sourceTree = ""; }; + 288BC1E04044DC3FB48524FC /* RecruitPostViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostViewModel.swift; sourceTree = ""; }; + 3C3694E3ACBE05B8C7F15357 /* RecruitPostSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostSectionHeaderView.swift; sourceTree = ""; }; + 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostCategoryView.swift; sourceTree = ""; }; + 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyRecruitUseCase.swift; sourceTree = ""; }; + 54351A8A0C1A4046C3155481 /* RecruitPostTextViewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostTextViewView.swift; sourceTree = ""; }; + 73F70BC5F3AA1A34663C32CA /* RecruitPostMeetingTypeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostMeetingTypeView.swift; sourceTree = ""; }; 7C01653F300A88940013DD7B /* ShuttleTicketViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShuttleTicketViewController.swift; sourceTree = ""; }; 7C016543300AA1450013DD7B /* DepartmentCategoryHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DepartmentCategoryHostingController.swift; sourceTree = ""; }; 7C016545300AA14C0013DD7B /* DepartmentCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DepartmentCategoryView.swift; sourceTree = ""; }; @@ -1425,6 +1448,7 @@ 7CA6868A2FFCE750007937E9 /* DeleteNotificationHistoryUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteNotificationHistoryUseCase.swift; sourceTree = ""; }; 7CA6868C2FFCE867007937E9 /* UpdateNotificationHistoryUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateNotificationHistoryUseCase.swift; sourceTree = ""; }; 7CA6868E2FFCF42E007937E9 /* NotificationHistoryError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationHistoryError.swift; sourceTree = ""; }; + 7CAC2DDB304C34E900AC2828 /* RecruitPostRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostRequest.swift; sourceTree = ""; }; 7CACF86430436CFF00AC2828 /* RecruitNotificationListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotificationListViewController.swift; sourceTree = ""; }; 7CACF86630436D2600AC2828 /* RecruitNotificationListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotificationListViewModel.swift; sourceTree = ""; }; 7CACF86830436E7700AC2828 /* RecruitNotification+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecruitNotification+.swift"; sourceTree = ""; }; @@ -1499,6 +1523,7 @@ 7CCB1E4A2F29D87C00472669 /* LostItemPostChatDetailUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemPostChatDetailUseCase.swift; sourceTree = ""; }; 7CCB1E4D2F29DB3200472669 /* LostItemPostChatDetailRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemPostChatDetailRequest.swift; sourceTree = ""; }; 7CD411E92FEB92280079467B /* HomeTabBarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTabBarItem.swift; sourceTree = ""; }; + 7CDCD0A83053F96E004204DA /* RecruitPostRoleTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostRoleTableViewCell.swift; sourceTree = ""; }; 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteDeviceTokenUseCase.swift; sourceTree = ""; }; 7CED8EC32EF2D1F900457128 /* ShopCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCollectionView.swift; sourceTree = ""; }; 7CED8EC42EF2D1F900457128 /* ShopCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCollectionViewCell.swift; sourceTree = ""; }; @@ -1741,6 +1766,8 @@ 7CF77B7330058AF3003642C1 /* NoticeKeywordScrollViewHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeKeywordScrollViewHostingController.swift; sourceTree = ""; }; 7CF77B843005F105003642C1 /* NoticeSearchEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeSearchEmptyView.swift; sourceTree = ""; }; 7CF77B863005F642003642C1 /* FetchMainFrameUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMainFrameUseCase.swift; sourceTree = ""; }; + 7D447F2D75951DD4C3E08D4E /* PostRecruitUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRecruitUseCase.swift; sourceTree = ""; }; + 82F7EBAB20247086024B51CB /* RecruitPostViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostViewController.swift; sourceTree = ""; }; 831ABDC32CA29A120099B70C /* NoticeKeywordsFetchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeKeywordsFetchResult.swift; sourceTree = ""; }; 831ABDC52CA2A7C20099B70C /* FetchRecentSearchedWordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecentSearchedWordUseCase.swift; sourceTree = ""; }; 831C469F2C63C94000CF84C0 /* GetUserScreenTimeUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = GetUserScreenTimeUseCase.swift; path = ScreenTime/GetUserScreenTimeUseCase.swift; sourceTree = ""; }; @@ -1817,7 +1844,10 @@ 83E96DB72BBC6FBF00164914 /* BusSearchDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusSearchDto.swift; sourceTree = ""; }; 83EBB8792D0FDFD700346018 /* KoinPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinPickerView.swift; sourceTree = ""; }; 83EBB87D2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchKoinPickerDateUseCase.swift; sourceTree = ""; }; + 8CE127F2AE4B18899F6E5E0D /* RecruitPostScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostScheduleView.swift; sourceTree = ""; }; A001E2B42845091D00D6C310 /* koin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = koin.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A1D000010000000000000002 /* FetchRecruitDetailUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitDetailUseCase.swift; sourceTree = ""; }; + A1E000010000000000000002 /* RecruitPostRoleTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostRoleTableView.swift; sourceTree = ""; }; AA10000000000000000001 /* PublisherTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublisherTestSupport.swift; sourceTree = ""; }; AA10000000000000000002 /* DiningFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningFixtures.swift; sourceTree = ""; }; AA10000000000000000003 /* SpyDiningRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpyDiningRepository.swift; sourceTree = ""; }; @@ -1856,6 +1886,7 @@ B68D2CEA2EA50C1E00F3B479 /* UIViewController+Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Toast.swift"; sourceTree = ""; }; B6A0D6292EEB01E600A19521 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; B6A0D62B2EEB01F100A19521 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + CD3000000000000000000002 /* RecruitPostCategoryDropdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostCategoryDropdownView.swift; sourceTree = ""; }; D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdownHost.swift; sourceTree = ""; }; D19A00013000000000000001 /* NotificationRowModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRowModel.swift; sourceTree = ""; }; D19A00013000000000000003 /* NotificationListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationListView.swift; sourceTree = ""; }; @@ -2100,6 +2131,7 @@ EC692285302700A400EE26ED /* koinUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = koinUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; F1A000010000000000000002 /* ZoomingCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomingCollectionView.swift; sourceTree = ""; }; F1A000010000000000000004 /* ZoomingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomingCollectionViewCell.swift; sourceTree = ""; }; + F670021A95E0AA103E8F7BED /* RecruitPostRoleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostRoleView.swift; sourceTree = ""; }; FE01000000000000000011 /* KoinDropdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdown.swift; sourceTree = ""; }; FE01000000000000000013 /* KoinDropdownAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdownAnimator.swift; sourceTree = ""; }; FE01000000000000000016 /* KoinDropdownConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdownConfiguration.swift; sourceTree = ""; }; @@ -2146,6 +2178,23 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 35728BBC0BEFE046C56D7CDF /* Subviews */ = { + isa = PBXGroup; + children = ( + 3C3694E3ACBE05B8C7F15357 /* RecruitPostSectionHeaderView.swift */, + 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */, + 54351A8A0C1A4046C3155481 /* RecruitPostTextViewView.swift */, + 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */, + CD3000000000000000000002 /* RecruitPostCategoryDropdownView.swift */, + 73F70BC5F3AA1A34663C32CA /* RecruitPostMeetingTypeView.swift */, + 8CE127F2AE4B18899F6E5E0D /* RecruitPostScheduleView.swift */, + F670021A95E0AA103E8F7BED /* RecruitPostRoleView.swift */, + A1E000010000000000000002 /* RecruitPostRoleTableView.swift */, + 7CDCD0A83053F96E004204DA /* RecruitPostRoleTableViewCell.swift */, + ); + path = Subviews; + sourceTree = ""; + }; 7C016541300A9FF80013DD7B /* Department */ = { isa = PBXGroup; children = ( @@ -2666,6 +2715,7 @@ children = ( 7C50A1B43041A25D00897A6F /* RecruitList */, 7CACF86130436C1900AC2828 /* RecruitNotificationList */, + 7CACF873304441AA00AC2828 /* RecruitPost */, ); path = Recruit; sourceTree = ""; @@ -2694,6 +2744,7 @@ 7C50A1FE3042E9DC00897A6F /* RecruitList.swift */, 7C50A20A304324F500897A6F /* RecruitNotification.swift */, 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */, + 7CAC2DDB304C34E900AC2828 /* RecruitPostRequest.swift */, ); path = Recruit; sourceTree = ""; @@ -2744,6 +2795,8 @@ 7C50A1FB3042E99E00897A6F /* Recruit */ = { isa = PBXGroup; children = ( + A1D000010000000000000003 /* RecruitDetail */, + C1309DAEB0F857F481B1A4B0 /* RecruitPost */, 7CACF86C3044096200AC2828 /* RecruitList */, 7CACF86E3044097800AC2828 /* RecruitNotificationList */, ); @@ -3258,6 +3311,16 @@ path = RecruitNotificationList; sourceTree = ""; }; + 7CACF873304441AA00AC2828 /* RecruitPost */ = { + isa = PBXGroup; + children = ( + 35728BBC0BEFE046C56D7CDF /* Subviews */, + 82F7EBAB20247086024B51CB /* RecruitPostViewController.swift */, + 288BC1E04044DC3FB48524FC /* RecruitPostViewModel.swift */, + ); + path = RecruitPost; + sourceTree = ""; + }; 7CB061C12F32312E00E83CA9 /* helpers */ = { isa = PBXGroup; children = ( @@ -4846,6 +4909,14 @@ name = Products; sourceTree = ""; }; + A1D000010000000000000003 /* RecruitDetail */ = { + isa = PBXGroup; + children = ( + A1D000010000000000000002 /* FetchRecruitDetailUseCase.swift */, + ); + path = RecruitDetail; + sourceTree = ""; + }; AA30000000000000000001 /* Support */ = { isa = PBXGroup; children = ( @@ -4951,6 +5022,15 @@ path = Configuration; sourceTree = ""; }; + C1309DAEB0F857F481B1A4B0 /* RecruitPost */ = { + isa = PBXGroup; + children = ( + 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */, + 7D447F2D75951DD4C3E08D4E /* PostRecruitUseCase.swift */, + ); + path = RecruitPost; + sourceTree = ""; + }; D19A00013000000000000007 /* Shared */ = { isa = PBXGroup; children = ( @@ -6020,6 +6100,19 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F0F216EDAA69B52CBEC0E606 /* RecruitPostViewModel.swift in Sources */, + 004A06CB3468BBA8DCB4CB44 /* RecruitPostViewController.swift in Sources */, + E5DD3F6BE6C501B7B3BD659C /* RecruitPostRoleView.swift in Sources */, + A1E000010000000000000001 /* RecruitPostRoleTableView.swift in Sources */, + 0E2253358E42231BE436FA27 /* RecruitPostScheduleView.swift in Sources */, + 1537B6CFD3D7AF52A2E9E944 /* RecruitPostMeetingTypeView.swift in Sources */, + C1838F7A4424DEA14DB2D107 /* RecruitPostCategoryView.swift in Sources */, + CD3000000000000000000001 /* RecruitPostCategoryDropdownView.swift in Sources */, + B0495459FC164809B16A96B6 /* RecruitPostTextViewView.swift in Sources */, + 5006987DEF4B5F454D7EA2D5 /* RecruitPostTextFieldView.swift in Sources */, + 75EBF253F4468B2060B79212 /* RecruitPostSectionHeaderView.swift in Sources */, + A0B343754988C9398C4FE726 /* ModifyRecruitUseCase.swift in Sources */, + 515B96092C6730C797C1F837 /* PostRecruitUseCase.swift in Sources */, FE01000000000000000021 /* KoinDropdown.swift in Sources */, D0A71C4F2F1B8340009E2D71 /* KoinDropdownHost.swift in Sources */, FE01000000000000000023 /* KoinDropdownAnimator.swift in Sources */, @@ -6142,6 +6235,7 @@ D2FCBEA42D08060C0097A388 /* RollbackFrameUseCase.swift in Sources */, 7CA63A212F1AF73F00226F86 /* LostItemList.swift in Sources */, D208A7C62CABB426007040E7 /* CoreRepository.swift in Sources */, + 7CDCD0A93053F96E004204DA /* RecruitPostRoleTableViewCell.swift in Sources */, 7C61D3172F213D57005FAC3A /* ChangeLostItemStateUseCase.swift in Sources */, 7C16AA372F3E0DBE0071ABE6 /* Interceptor.swift in Sources */, D2A8F8BC2C4A71420090C7A4 /* CoopShopDto.swift in Sources */, @@ -6199,6 +6293,7 @@ D29F21F32C4FD23F00994554 /* LandItem.swift in Sources */, 7C7F56B72FFB9FE000847151 /* NotificationHistoryRecord.swift in Sources */, 7C50A1FD3042E9AB00897A6F /* FetchRecruitListUseCase.swift in Sources */, + A1D000010000000000000001 /* FetchRecruitDetailUseCase.swift in Sources */, D20AB95A2C55369F006BC684 /* NotiAPI.swift in Sources */, 7C457AE72FCDE7240011D338 /* SwiftUIViewModelProtocol.swift in Sources */, 833D9C392C7058C300982145 /* NoticeListRepository.swift in Sources */, @@ -6791,6 +6886,7 @@ 7C372F292F1D6A7F00149729 /* LostItemDataViewModel.swift in Sources */, 7C17EEAE2EBF7031008BCA89 /* FetchShopSearchRequest.swift in Sources */, D21572542CEDC17A0061E725 /* DeleteFrameUseCase.swift in Sources */, + 7CAC2DDC304C34E900AC2828 /* RecruitPostRequest.swift in Sources */, D89BAA392DCA5B3D006D3BB7 /* GeneralRegisterFormRequest.swift in Sources */, D20AB9562C55352A006BC684 /* ChangeNotiDetailUseCase.swift in Sources */, 7C50A20B304324F500897A6F /* RecruitNotification.swift in Sources */, @@ -7244,6 +7340,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = koin/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "코인(stage)"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "이 앱은 복덕방 기능을 사용하기 위해 위치 정보가 필요합니다."; @@ -7279,6 +7376,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = koin/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "코인(stage)"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "이 앱은 복덕방 기능을 사용하기 위해 위치 정보가 필요합니다."; From 52fae6568803d935d0e485a22b12b589998e491d Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 12 Sep 2026 20:42:15 +0900 Subject: [PATCH 017/123] =?UTF-8?q?add:=20ImageAsset=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Asset/ImageAsset.swift | 7 +++++++ .../recruitDataAuthor.imageset/Contents.json | 15 +++++++++++++++ .../recruitDataAuthor.svg | 6 ++++++ .../recruitDataCalendar.imageset/Contents.json | 15 +++++++++++++++ .../recruitDataCalendar.svg | 7 +++++++ .../recruitDataClock.imageset/Contents.json | 15 +++++++++++++++ .../recruitDataClock.svg | 6 ++++++ .../recruitDataLocation.imageset/Contents.json | 15 +++++++++++++++ .../recruitDataLocation.svg | 6 ++++++ .../recruitDataMembers.imageset/Contents.json | 15 +++++++++++++++ .../recruitDataMembers.svg | 10 ++++++++++ .../recruitDataRoleClosed.imageset/Contents.json | 15 +++++++++++++++ .../recruitDataRoleClosed.svg | 5 +++++ .../recruitDataRoleOpen.imageset/Contents.json | 15 +++++++++++++++ .../recruitDataRoleOpen.svg | 5 +++++ 15 files changed, 157 insertions(+) create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDataAuthor.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDataAuthor.imageset/recruitDataAuthor.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDataCalendar.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDataCalendar.imageset/recruitDataCalendar.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDataClock.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDataClock.imageset/recruitDataClock.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDataLocation.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDataLocation.imageset/recruitDataLocation.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDataMembers.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDataMembers.imageset/recruitDataMembers.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleClosed.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleClosed.imageset/recruitDataRoleClosed.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleOpen.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleOpen.imageset/recruitDataRoleOpen.svg diff --git a/Koin/Core/Asset/ImageAsset.swift b/Koin/Core/Asset/ImageAsset.swift index 1e2362b00..a0c090efd 100644 --- a/Koin/Core/Asset/ImageAsset.swift +++ b/Koin/Core/Asset/ImageAsset.swift @@ -215,6 +215,13 @@ public enum ImageAsset: String { case noticeSearch // MARK: - Recruit + case recruitDataLocation + case recruitDataCalendar + case recruitDataMembers + case recruitDataClock + case recruitDataAuthor + case recruitDataRoleClosed + case recruitDataRoleOpen case recruitBell case recruitBellDot case recruitDate diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDataAuthor.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitDataAuthor.imageset/Contents.json new file mode 100644 index 000000000..716876d35 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDataAuthor.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "recruitDataAuthor.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDataAuthor.imageset/recruitDataAuthor.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitDataAuthor.imageset/recruitDataAuthor.svg new file mode 100644 index 000000000..6d9f110ee --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDataAuthor.imageset/recruitDataAuthor.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDataCalendar.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitDataCalendar.imageset/Contents.json new file mode 100644 index 000000000..be92858b0 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDataCalendar.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "recruitDataCalendar.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDataCalendar.imageset/recruitDataCalendar.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitDataCalendar.imageset/recruitDataCalendar.svg new file mode 100644 index 000000000..889601ad1 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDataCalendar.imageset/recruitDataCalendar.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDataClock.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitDataClock.imageset/Contents.json new file mode 100644 index 000000000..3878782ad --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDataClock.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "recruitDataClock.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDataClock.imageset/recruitDataClock.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitDataClock.imageset/recruitDataClock.svg new file mode 100644 index 000000000..5c7d62d54 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDataClock.imageset/recruitDataClock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDataLocation.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitDataLocation.imageset/Contents.json new file mode 100644 index 000000000..26c01816b --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDataLocation.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "recruitDataLocation.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDataLocation.imageset/recruitDataLocation.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitDataLocation.imageset/recruitDataLocation.svg new file mode 100644 index 000000000..2c15c911b --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDataLocation.imageset/recruitDataLocation.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDataMembers.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitDataMembers.imageset/Contents.json new file mode 100644 index 000000000..184d6b407 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDataMembers.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "recruitDataMembers.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDataMembers.imageset/recruitDataMembers.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitDataMembers.imageset/recruitDataMembers.svg new file mode 100644 index 000000000..3fff1a642 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDataMembers.imageset/recruitDataMembers.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleClosed.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleClosed.imageset/Contents.json new file mode 100644 index 000000000..60c8397bc --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleClosed.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "recruitDataRoleClosed.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleClosed.imageset/recruitDataRoleClosed.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleClosed.imageset/recruitDataRoleClosed.svg new file mode 100644 index 000000000..bc246ac06 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleClosed.imageset/recruitDataRoleClosed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleOpen.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleOpen.imageset/Contents.json new file mode 100644 index 000000000..4a87f3717 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleOpen.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "recruitDataRoleOpen.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleOpen.imageset/recruitDataRoleOpen.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleOpen.imageset/recruitDataRoleOpen.svg new file mode 100644 index 000000000..082c2983e --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitDataRoleOpen.imageset/recruitDataRoleOpen.svg @@ -0,0 +1,5 @@ + + + + + From 0c81e955525b3cc66fa44fba1daed498783dddc9 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 12 Sep 2026 22:53:15 +0900 Subject: [PATCH 018/123] =?UTF-8?q?fix:=20applySketchShadow=20Modifier=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SwiftUI/View+applySketchShadow.swift | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/Koin/Core/Extensions/SwiftUI/View+applySketchShadow.swift b/Koin/Core/Extensions/SwiftUI/View+applySketchShadow.swift index c34aace19..f523cdfb1 100644 --- a/Koin/Core/Extensions/SwiftUI/View+applySketchShadow.swift +++ b/Koin/Core/Extensions/SwiftUI/View+applySketchShadow.swift @@ -7,8 +7,37 @@ import SwiftUI +private struct ApplySketchShadowModifier: ViewModifier { + let color: Color + let alpha: Double + let x: CGFloat + let y: CGFloat + let blur: CGFloat + + func body(content: Content) -> some View { + content.shadow( + color: color.opacity(alpha), + radius: blur, + x: x, + y: y + ) + } +} + extension View { - func applySketchShadow(color: Color, alpha: Double, x: CGFloat, y: CGFloat, blur: CGFloat) -> some View { - shadow(color: color.opacity(alpha), radius: blur, x: x, y: y) + func applySketchShadow( + color: Color, + alpha: Double, + x: CGFloat, + y: CGFloat, + blur: CGFloat + ) -> some View { + modifier(ApplySketchShadowModifier( + color: color, + alpha: alpha, + x: x, + y: y, + blur: blur + )) } } From 19f32f90dd4b575c9686a1f0ea84ef1da6aea99a Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 13 Sep 2026 01:03:47 +0900 Subject: [PATCH 019/123] =?UTF-8?q?design:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=83=81=EC=84=B8=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/MockRecruitRepository.swift | 98 ++++---- Koin/Domain/Model/Recruit/RecruitData.swift | 39 ++++ Koin/Domain/Model/Recruit/RecruitList.swift | 8 +- .../Model/Recruit/RecruitPostRequest.swift | 6 +- ...cruitDetail.swift => RecruitSummary.swift} | 14 +- .../Domain/Repository/RecruitRepository.swift | 3 +- .../DeleteRecruitDataUseCase.swift | 25 ++ .../RecruitData/FetchRecruitDataUseCase.swift | 25 ++ .../FetchRecruitDetailUseCase.swift | 25 -- .../RecruitDataHostingController.swift | 164 +++++++++++++ .../Recruit/RecruitData/RecruitDataView.swift | 95 ++++++++ .../RecruitData/RecruitDataViewModel.swift | 100 ++++++++ .../Subviews/RecruitDataButton.swift | 48 ++++ .../Subviews/RecruitDataContentView.swift | 96 ++++++++ .../Subviews/RecruitDataHeaderView.swift | 32 +++ .../Subviews/RecruitDataMetadataView.swift | 62 +++++ .../RecruitDataPopUpViewController.swift | 216 ++++++++++++++++++ .../Subviews/RecruitDataRelatedUrlView.swift | 29 +++ .../Subviews/RecruitDataRolesView.swift | 54 +++++ .../Subviews/RecruitDataTextSectionView.swift | 27 +++ .../RecruitListHostingController.swift | 25 +- .../Recruit/RecruitList/RecruitListView.swift | 10 +- .../RecruitList/RecruitListViewModel.swift | 4 +- .../Subviews/RecruitListRowView.swift | 10 +- .../RecruitPostViewController.swift | 71 ++++-- .../RecruitPost/RecruitPostViewModel.swift | 27 +-- koin.xcodeproj/project.pbxproj | 90 +++++++- 27 files changed, 1257 insertions(+), 146 deletions(-) create mode 100644 Koin/Domain/Model/Recruit/RecruitData.swift rename Koin/Domain/Model/Recruit/{RecruitDetail.swift => RecruitSummary.swift} (59%) create mode 100644 Koin/Domain/UseCase/Recruit/RecruitData/DeleteRecruitDataUseCase.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitData/FetchRecruitDataUseCase.swift delete mode 100644 Koin/Domain/UseCase/Recruit/RecruitDetail/FetchRecruitDetailUseCase.swift create mode 100644 Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift create mode 100644 Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift create mode 100644 Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift create mode 100644 Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataButton.swift create mode 100644 Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataContentView.swift create mode 100644 Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift create mode 100644 Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataMetadataView.swift create mode 100644 Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataPopUpViewController.swift create mode 100644 Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataRelatedUrlView.swift create mode 100644 Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataRolesView.swift create mode 100644 Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataTextSectionView.swift diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index f28d70983..356a42d25 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -9,7 +9,7 @@ import Foundation final class MockRecruitRepository: RecruitRepository { func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList { - let contest = RecruitDetail( + let contest = RecruitSummary( id: 1, category: .contest, title: "AI 아이디어 공모전 팀원 모집", @@ -17,22 +17,18 @@ final class MockRecruitRepository: RecruitRepository { startDate: "2026.07.26", endDate: "2026.08.07", deadline: "2026.08.07", - dDay: 5, - state: .recruiting, + dDay: "D-5", currentParticipants: 0, maximumParticipants: 3, type: .roleBased, roles: [ - RecruitRole(id: 1, name: "프론트엔드", currentParticipants: 0, maximumParticipants: 1, isClosed: false), + RecruitRole(id: 1, name: "프론트엔드", currentParticipants: 0, maximumParticipants: 1, isClosed: true), RecruitRole(id: 2, name: "백엔드", currentParticipants: 0, maximumParticipants: 1, isClosed: false), RecruitRole(id: 3, name: "디자인", currentParticipants: 0, maximumParticipants: 1, isClosed: false) - ], - description: "", - relatedUrl: nil, - qualification: nil + ] ) - let externalActivity = RecruitDetail( + let externalActivity = RecruitSummary( id: 2, category: .externalActivity, title: "2026 대외활동 팀원 모집", @@ -40,18 +36,14 @@ final class MockRecruitRepository: RecruitRepository { startDate: "2026.07.26", endDate: "2026.08.07", deadline: "2026.08.07", - dDay: 13, - state: .recruiting, + dDay: "D-1", currentParticipants: 2, maximumParticipants: 3, type: .general, - roles: [], - description: "", - relatedUrl: nil, - qualification: nil + roles: [] ) - let closedExternalActivity = RecruitDetail( + let closedExternalActivity = RecruitSummary( id: 3, category: .externalActivity, title: "2026 대외활동 팀원 모집", @@ -59,19 +51,15 @@ final class MockRecruitRepository: RecruitRepository { startDate: "2026.07.26", endDate: "2026.08.07", deadline: "2026.08.07", - dDay: 0, - state: .closed, + dDay: "D-day", currentParticipants: 5, maximumParticipants: 5, type: .general, - roles: [], - description: "", - relatedUrl: nil, - qualification: nil + roles: [] ) let studies = (4...7).map { id in - RecruitDetail( + RecruitSummary( id: id, category: .study, title: "2026 스터디 팀원 모집", @@ -79,15 +67,11 @@ final class MockRecruitRepository: RecruitRepository { startDate: "2026.07.26", endDate: "2026.08.07", deadline: "2026.08.07", - dDay: 13, - state: .recruiting, + dDay: "D-1", currentParticipants: 2, maximumParticipants: 3, type: .general, - roles: [], - description: "", - relatedUrl: nil, - qualification: nil + roles: [] ) } @@ -99,29 +83,36 @@ final class MockRecruitRepository: RecruitRepository { ) } - func fetchDetail(_ id: Int) async throws -> RecruitDetail { + func fetchData(_ id: Int) async throws -> RecruitData { try await Task.sleep(nanoseconds: 300_000_000) - return RecruitDetail( - id: id, - category: .contest, - title: "AI 아이디어 공모전 팀원 모집", - meetingType: .online, - startDate: "2026.07.26", - endDate: "2026.08.07", - deadline: "2026.08.07", - dDay: 5, - state: .recruiting, - currentParticipants: 0, - maximumParticipants: 3, - type: .roleBased, - roles: [ - RecruitRole(id: 1, name: "프론트엔드", currentParticipants: 0, maximumParticipants: 1, isClosed: false), - RecruitRole(id: 2, name: "백엔드", currentParticipants: 0, maximumParticipants: 1, isClosed: false), - RecruitRole(id: 3, name: "디자인", currentParticipants: 0, maximumParticipants: 1, isClosed: false) - ], - description: "소개소개소개소개소개소개소개소개소개소개", - relatedUrl: nil, - qualification: "2학년이상\n참여율 높은 사람\n@@@" + let list = try await fetchList(RecruitListFilter()) + guard let item = list.recruits.first(where: { $0.id == id }) else { + throw NSError(domain: "MockRecruitRepository", code: 404) + } + return RecruitData( + id: item.id, + category: item.category, + dDay: item.dDay, + title: item.title, + meetingType: item.meetingType, + startDate: item.startDate, + endDate: item.endDate, + deadlineDate: item.deadline, + currentParticipants: item.currentParticipants, + maximumParticipants: item.maximumParticipants, + createdAt: "2026.02.02", + author: "@@@", + type: item.type, + roles: item.roles, + description: "소개소개소개소개소개소개소개소개소개소개소개소개소개소개", + relatedUrl: URL(string: "https://bcsdlab.com"), + qualification: "2학년이상\n참여율 높은 사람\n@@@", + isAuthor: UserDataManager.shared.isLoggedIn && item.id % 2 == 1, + canApply: true, + applyBlockReason: nil, + canManageApplicants: false, + teamChatAvailable: false, + teamChatRoomId: nil ) } @@ -183,6 +174,10 @@ final class MockRecruitRepository: RecruitRepository { ]) } + func deleteData(id: Int) async throws -> Bool { + true + } + func deleteNotification(_ id: Int) async throws -> Void { } @@ -199,3 +194,4 @@ final class MockRecruitRepository: RecruitRepository { } } + diff --git a/Koin/Domain/Model/Recruit/RecruitData.swift b/Koin/Domain/Model/Recruit/RecruitData.swift new file mode 100644 index 000000000..58b0bfaf9 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitData.swift @@ -0,0 +1,39 @@ +// +// RecruitData.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +struct RecruitData: Identifiable, Equatable { + let id: Int + + let category: RecruitCategory + let dDay: String + let title: String + + let meetingType: RecruitMeetingType + let startDate: String + let endDate: String + let deadlineDate: String + let currentParticipants: Int + let maximumParticipants: Int + let createdAt: String? + let author: String? + + let type: RecruitRoleType + let roles: [RecruitRole] + + let description: String + let relatedUrl: URL? + let qualification: String? + + let isAuthor: Bool + let canApply: Bool + let applyBlockReason: String? + let canManageApplicants: Bool + let teamChatAvailable: Bool + let teamChatRoomId: Int? +} diff --git a/Koin/Domain/Model/Recruit/RecruitList.swift b/Koin/Domain/Model/Recruit/RecruitList.swift index 21d4be571..15a831deb 100644 --- a/Koin/Domain/Model/Recruit/RecruitList.swift +++ b/Koin/Domain/Model/Recruit/RecruitList.swift @@ -8,9 +8,15 @@ import Foundation struct RecruitList { - var recruits: [RecruitDetail] + var recruits: [RecruitSummary] let totalCount: Int let totalPage: Int let currentPage: Int } + +extension RecruitList { + mutating func delete(id: Int) { + recruits.removeAll(where: { $0.id == id }) + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitPostRequest.swift b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift index 894e455d3..16a3895d8 100644 --- a/Koin/Domain/Model/Recruit/RecruitPostRequest.swift +++ b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift @@ -58,7 +58,7 @@ struct RecruitRoleRequest { } extension RecruitPostRequest { - init(from form: RecruitDetail) { + init(from form: RecruitData) { let dateFormatter = DateFormatter().then { $0.dateFormat = "yyyy-MM-dd" } @@ -68,12 +68,12 @@ extension RecruitPostRequest { self.meetingType = form.meetingType self.startDate = dateFormatter.date(from: form.startDate) self.endDate = dateFormatter.date(from: form.endDate) - self.deadline = dateFormatter.date(from: form.deadline) + self.deadline = dateFormatter.date(from: form.deadlineDate) self.type = form.type self.numberOfGeneralMembers = form.maximumParticipants self.roles = form.roles.map { .init(from: $0) } self.description = form.description - self.relatedUrl = form.relatedUrl + self.relatedUrl = form.relatedUrl?.absoluteString self.qualification = form.qualification } } diff --git a/Koin/Domain/Model/Recruit/RecruitDetail.swift b/Koin/Domain/Model/Recruit/RecruitSummary.swift similarity index 59% rename from Koin/Domain/Model/Recruit/RecruitDetail.swift rename to Koin/Domain/Model/Recruit/RecruitSummary.swift index 6407ff3a4..aa404c073 100644 --- a/Koin/Domain/Model/Recruit/RecruitDetail.swift +++ b/Koin/Domain/Model/Recruit/RecruitSummary.swift @@ -1,13 +1,13 @@ // -// RecruitDetail.swift +// RecruitSummary.swift // koin // -// Created by 홍기정 on 8/29/26. +// Created by 홍기정 on 9/12/26. // import Foundation -struct RecruitDetail: Identifiable, Equatable { +struct RecruitSummary: Identifiable, Equatable { let id: Int let category: RecruitCategory let title: String @@ -16,17 +16,11 @@ struct RecruitDetail: Identifiable, Equatable { let startDate: String let endDate: String let deadline: String - let dDay: Int + let dDay: String - let state: RecruitState - let currentParticipants: Int let maximumParticipants: Int let type: RecruitRoleType let roles: [RecruitRole] - - let description: String - let relatedUrl: String? - let qualification: String? } diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index bede8da11..c43ee00da 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -9,7 +9,6 @@ import Foundation protocol RecruitRepository { func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList - func fetchDetail(_ id: Int) async throws -> RecruitDetail func fetchNotificationList() async throws -> RecruitNotificationList func deleteNotification(_ id: Int) async throws -> Void func deleteAllNotification() async throws -> Void @@ -17,4 +16,6 @@ protocol RecruitRepository { func markAllAsReadNotification() async throws -> Void func post(_ request: RecruitPostRequest) async throws -> Int func modify(_ id: Int, _ request: RecruitPostRequest) async throws -> Void + func fetchData(_ id: Int) async throws -> RecruitData + func deleteData(id: Int) async throws -> Bool } diff --git a/Koin/Domain/UseCase/Recruit/RecruitData/DeleteRecruitDataUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitData/DeleteRecruitDataUseCase.swift new file mode 100644 index 000000000..25dd1ae33 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitData/DeleteRecruitDataUseCase.swift @@ -0,0 +1,25 @@ +// +// DeleteRecruitDataUseCase.swift +// koin +// +// Created by 홍기정 on 9/12/26. +// + +import Foundation + +protocol DeleteRecruitDataUseCase { + func execute(id: Int) async throws -> Bool +} + +final class DefaultDeleteRecruitDataUseCase: DeleteRecruitDataUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(id: Int) async throws -> Bool { + try await repository.deleteData(id: id) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitData/FetchRecruitDataUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitData/FetchRecruitDataUseCase.swift new file mode 100644 index 000000000..a6c79acf1 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitData/FetchRecruitDataUseCase.swift @@ -0,0 +1,25 @@ +// +// FetchRecruitDataUseCase.swift +// koin +// +// Created by 홍기정 on 9/10/26. +// + +import Foundation + +protocol FetchRecruitDataUseCase { + func execute(id: Int) async throws -> RecruitData +} + +final class DefaultFetchRecruitDataUseCase: FetchRecruitDataUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(id: Int) async throws -> RecruitData { + try await repository.fetchData(id) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitDetail/FetchRecruitDetailUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitDetail/FetchRecruitDetailUseCase.swift deleted file mode 100644 index 60cf3334e..000000000 --- a/Koin/Domain/UseCase/Recruit/RecruitDetail/FetchRecruitDetailUseCase.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// FetchRecruitDetailUseCase.swift -// koin -// -// Created by 홍기정 on 9/10/26. -// - -import Foundation - -protocol FetchRecruitDetailUseCase { - func execute(id: Int) async throws -> RecruitDetail -} - -final class DefaultFetchRecruitDetailUseCase: FetchRecruitDetailUseCase { - - private let repository: RecruitRepository - - init(repository: RecruitRepository) { - self.repository = repository - } - - func execute(id: Int) async throws -> RecruitDetail { - try await repository.fetchDetail(id) - } -} diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift new file mode 100644 index 000000000..29db3f683 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -0,0 +1,164 @@ +// +// RecruitDataHostingController.swift +// koin +// +// Created by 홍기정 on 9/12/26. +// + +import SwiftUI + +protocol RecruitDataHostingControllerDelegate: AnyObject { + func delete(id: Int) +} + +final class RecruitDataHostingController: UIHostingController, HostingControllerProtocol { + + // MARK: - Propertoes + private weak var delegate: RecruitDataHostingControllerDelegate? + + // MARK: - Initializer + init( + rootView: RecruitDataView, + delegate: RecruitDataHostingControllerDelegate? + ) { + super.init(rootView: rootView) + bindAction(to: rootView) + self.delegate = delegate + } + + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + title = "팀원 모집" + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar(style: .newBackground) + } + + // MARK: - Execute + func execute(action: RecruitDataView.Action) { + switch action { + case .showApplicant: + navigateToApplicant() + case .showApply: + navigateToApply() + case .didDelete: + handleDelete() + case let .showToast(message): + showToastMessage(message: message) + case .showLoginToast: + showLoginToast() + case .isAuthor(let isAuthor): + configureRightBarButton(isAuthor) + } + } +} + +extension RecruitDataHostingController { + private func configureRightBarButton(_ isAuthor: Bool) { + if !isAuthor { + navigationItem.rightBarButtonItem = nil + } else { + let rightBarButtonItem = UIBarButtonItem( + image: .appImage(asset: .threeCircle), + style: .plain, + target: self, + action: #selector(rightBarButtonItemTapped) + ) + navigationItem.rightBarButtonItem = rightBarButtonItem + } + } +} + +extension RecruitDataHostingController { + private func showLoginToast() { + showToastMessageWithButton( + message: "로그인이 필요한 기능입니다.", + buttonTitle: "로그인하기" + ) { [weak self] in + self?.navigateToLogin() + } + } +} + +extension RecruitDataHostingController { + @objc private func rightBarButtonItemTapped() { + let popUpViewController = RecruitDataPopUpViewController( + onEditButtonTapped: { [weak self] in + self?.navigateToEdit() + }, + onDeleteButtonTapped: { [weak self] in + self?.showDeleteModal() + } + ) + popUpViewController.modalPresentationStyle = .overFullScreen + navigationController?.present( + popUpViewController, + animated: false + ) + } + + private func showDeleteModal() { + let onDeleteButtonTapped: ()->Void = { [weak self] in + self?.rootView.didTapDelete() + } + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .new, + content: .singleTitle(text: "해당 모집글을 삭제하시겠습니까?"), + button: .buttons( + leftButtonTitle: "취소하기", + leftButtonAction: nil, + rightButtonTitle: "삭제하기", + rightButtonAction: onDeleteButtonTapped + ) + )) + present(modalViewController, animated: true) + } +} + +extension RecruitDataHostingController { + private func navigateToApplicant() { + guard let id = rootView.id else { + return + } + // TODO: show applicant + } + private func navigateToApply() { + guard let id = rootView.id else { + return + } + // TODO: show apply + } + + private func handleDelete() { + guard let id = rootView.id else { + return + } + delegate?.delete(id: id) + navigationController?.popViewController(animated: true) + } + + private func navigateToEdit() { + guard let id = rootView.id else { + return + } + let recruitRepository = MockRecruitRepository() + let fetchRecruitDataUseCase = DefaultFetchRecruitDataUseCase(repository: recruitRepository) + let postRecruitUseCase = DefaultPostRecruitUseCase(repository: recruitRepository) + let modifyRecruitUseCase = DefaultModifyRecruitUseCase(repository: recruitRepository) + let viewModel = RecruitPostViewModel( + postType: .modify(id: id), + postRecruitUseCase: postRecruitUseCase, + modifyRecruitUseCase: modifyRecruitUseCase, + fetchRecruitDataUseCase: fetchRecruitDataUseCase + ) + let viewController = RecruitPostViewController(viewModel: viewModel) + navigationController?.pushViewController(viewController, animated: true) + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift new file mode 100644 index 000000000..67c9fe2e2 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift @@ -0,0 +1,95 @@ +// +// RecruitDataView.swift +// koin +// +// Created by 홍기정 on 9/12/26. +// + +import SwiftUI + +struct RecruitDataView: ActionBindableView { + enum Action { + case showApply + case showApplicant + case didDelete + case showLoginToast + case showToast(message: String) + case isAuthor(Bool) + } + + // MARK: - Properties + var sendAction: ((Action) -> Void) = { _ in } + @State private var viewModel: RecruitDataViewModel + + var id: Int? { + viewModel.data?.id + } + + // MARK: - Initializer + init( + viewModel: RecruitDataViewModel + ) { + self.viewModel = viewModel + } + + // MARK: - Body + var body: some View { + RecruitDataContentView( + data: viewModel.data, + onButtonTapped: { + didTapButton() + } + ) + .loadingOverlay(viewModel.isLoading) + .background(Color.appColor(.newBackground).ignoresSafeArea()) + .onAppear { + viewModel.execute(.load) + } + .onChange(of: viewModel.didDelete) { + handleDelete() + } + .onChange(of: viewModel.data?.isAuthor) { + sendAction(.isAuthor(viewModel.data?.isAuthor == true)) + } + .onChange(of: viewModel.errorMessage) { + if let message = viewModel.errorMessage { + sendAction(.showToast(message: message)) + viewModel.execute(.didShowToast) + } + } + } + + // MARK: - Public + func didTapDelete() { + viewModel.execute(.delete) + } +} + +extension RecruitDataView { + private func didTapButton() { + guard let data = viewModel.data else { + return + } + guard UserDataManager.shared.isLoggedIn else { + sendAction(.showLoginToast) + return + } + let id = data.id + + if data.isAuthor { + sendAction(.showApplicant) + } else if data.canApply { + sendAction(.showApply) + } + } + + private func handleDelete() { + guard let data = viewModel.data, + viewModel.didDelete else { + return + } + let id = data.id + sendAction(.showToast(message: "삭제되었습니다")) + sendAction(.didDelete) + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift new file mode 100644 index 000000000..f72684648 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift @@ -0,0 +1,100 @@ +// +// RecruitDataViewModel.swift +// koin +// +// Created by 홍기정 on 9/12/26. +// + +import Foundation +import Observation + +@MainActor +@Observable +final class RecruitDataViewModel: SwiftUIViewModelProtocol { + + enum Input { + case load + case delete + case didShowToast + } + + // MARK: - State + private(set) var data: RecruitData? + private(set) var isLoading = false + private(set) var errorMessage: String? + private(set) var didDelete = false + + // MARK: - Properties + private let recruitId: Int + private let fetchRecruitDataUseCase: FetchRecruitDataUseCase + private let deleteRecruitDataUseCase: DeleteRecruitDataUseCase + + // MARK: - Initializer + init( + fetchRecruitDataUseCase: FetchRecruitDataUseCase, + deleteRecruitDataUseCase: DeleteRecruitDataUseCase, + recruitId: Int + ) { + self.fetchRecruitDataUseCase = fetchRecruitDataUseCase + self.deleteRecruitDataUseCase = deleteRecruitDataUseCase + self.recruitId = recruitId + } + + func execute(_ input: Input) { + switch input { + case .load: + load() + case .delete: + delete() + case .didShowToast: + errorMessage = nil + } + } +} + +extension RecruitDataViewModel { + private func load() { + guard !isLoading else { return } + + Task { + do { + isLoading = true + defer { + isLoading = false + } + let data = try await fetchRecruitDataUseCase.execute(id: recruitId) + self.data = data + } catch { + if let error = error as? ErrorResponse { + errorMessage = error.message + } + } + } + } + + private func delete() { + guard let data else { + return + } + Task { + do { + isLoading = true + defer { + isLoading = false + } + + let result = try await deleteRecruitDataUseCase.execute(id: data.id) + guard result == true else { + errorMessage = "오류가 발생했습니다." + return + } + self.didDelete = true + } catch { + if let error = error as? ErrorResponse { + errorMessage = error.message + } + self.didDelete = false + } + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataButton.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataButton.swift new file mode 100644 index 000000000..71d3b36d9 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataButton.swift @@ -0,0 +1,48 @@ +// +// RecruitDataButton.swift +// koin +// + +import SwiftUI + +struct RecruitDataButton: View { + let text: String + let isEnabled: Bool + let action: () -> Void + + init( + text: String, + isEnabled: Bool, + action: @escaping () -> Void + ) { + self.text = text + self.isEnabled = isEnabled + self.action = action + } + + private var backgroundColor: Color { + return isEnabled ? .appColor(.new500) : .appColor(.neutral400) + } + + var body: some View { + Button { + action() + } label: { + Text(text) + .font(.appFont(.pretendardSemiBold, size: 15)) + .foregroundStyle(Color.appColor(.neutral0)) + .frame(maxWidth: .infinity, minHeight: 48) + .background(backgroundColor, in: RoundedRectangle(cornerRadius: 16)) + .applySketchShadow( + color: .appColor(.neutral800), + alpha: 0.04, + x: 0, + y: 2, + blur: 4 + ) + .shadow(color: .black.opacity(0.04), radius: 2, y: 2) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataContentView.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataContentView.swift new file mode 100644 index 000000000..a71d53f32 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataContentView.swift @@ -0,0 +1,96 @@ +// +// RecruitDataContentView.swift +// koin +// + +import SwiftUI + +struct RecruitDataContentView: View { + let data: RecruitData? + let onButtonTapped: () -> Void + + private var buttonText: String { + guard let data else { + return "" + } + if data.isAuthor { + return "지원자 확인하기" + } else if data.canApply { + return "지원하기" + } else { + return "모집 마감" + } + } + + private var isButtonEnabled: Bool { + guard let data else { + return false + } + if data.isAuthor || data.canApply { + return true + } else { + return false + } + } + + init( + data: RecruitData?, + onButtonTapped: @escaping () -> Void + ) { + self.data = data + self.onButtonTapped = onButtonTapped + } + + var body: some View { + if let data { + GeometryReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + RecruitDataHeaderView(data: data) + .padding(.horizontal, 40) + .padding(.top, 16) + + Divider() + .backgroundStyle(Color.appColor(.neutral300)) + .frame(height: 1) + .padding(.vertical, 16) + .padding(.horizontal, 20) + + VStack(alignment: .leading, spacing: 20) { + RecruitDataMetadataView(data: data) + + if data.type == .roleBased, !data.roles.isEmpty { + RecruitDataRolesView(roles: data.roles) + } + + RecruitDataTextSectionView(title: "모집 소개", text: data.description) + + if let qualification = data.qualification, !qualification.isEmpty { + RecruitDataTextSectionView(title: "지원 자격", text: qualification) + } + + if let url = data.relatedUrl { + RecruitDataRelatedUrlView(url: url) + } + } + .padding(.horizontal, 36) + + Spacer(minLength: 32) + + RecruitDataButton( + text: buttonText, + isEnabled: isButtonEnabled, + action: onButtonTapped + ) + .padding(.horizontal, 32) + } + .frame(minHeight: proxy.size.height) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize, axes: .vertical) + } + } else { + Color.appColor(.newBackground) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift new file mode 100644 index 000000000..de2a3f6b1 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift @@ -0,0 +1,32 @@ +// +// RecruitDataHeaderView.swift +// koin +// + +import SwiftUI + +struct RecruitDataHeaderView: View { + let data: RecruitData + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 4) { + Text(data.category.rawValue) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(data.category.foregroundColor)) + .padding(.horizontal, 8) + .frame(height: 18) + .background(Color.appColor(data.category.backgroundColor), in: Capsule()) + Text(data.dDay) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(.danger700)) + } + .frame(minHeight: 18) + + Text(data.title) + .font(.appFont(.pretendardSemiBold, size: 18)) + .foregroundStyle(Color.appColor(.neutral700)) + .frame(height: 29) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataMetadataView.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataMetadataView.swift new file mode 100644 index 000000000..19422c5d6 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataMetadataView.swift @@ -0,0 +1,62 @@ +// +// RecruitDataMetadataView.swift +// koin +// + +import SwiftUI + +struct RecruitDataMetadataView: View { + let data: RecruitData + + var body: some View { + Grid(horizontalSpacing: 12, verticalSpacing: 12) { + metadataRow( + icon: .recruitDataLocation, + title: "진행 방식", + value: data.meetingType.rawValue + ) + metadataRow( + icon: .recruitDataCalendar, + title: "활동 기간", + value: "\(data.startDate) ~ \(data.endDate)" + ) + metadataRow( + icon: .recruitDataMembers, + title: "모집 인원", + value: "\(data.currentParticipants)/\(data.maximumParticipants)명" + ) + metadataRow( + icon: .recruitDataClock, + title: "작성일", + value: data.createdAt ?? "-" + ) + metadataRow( + icon: .recruitDataAuthor, + title: "작성자", + value: data.author ?? "-" + ) + } + } +} + +extension RecruitDataMetadataView { + private func metadataRow( + icon asset: ImageAsset, + title: String, + value: String + ) -> some View { + GridRow { + HStack(spacing: 4) { + Image.appImage(asset: asset) + Text(title) + .font(.appFont(.pretendardMedium, size: 12)) + .foregroundStyle(Color.appColor(.neutral800)) + } + .frame(maxWidth: .infinity, minHeight: 19, alignment: .leading) + + Text(value) + .font(.appFont(.pretendardRegular, size: 12)) + .frame(maxWidth: .infinity, idealHeight: 19, alignment: .center) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataPopUpViewController.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataPopUpViewController.swift new file mode 100644 index 000000000..5c54ff506 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataPopUpViewController.swift @@ -0,0 +1,216 @@ +// +// RecruitDataPopUpViewController.swift +// koin +// +// Created by 홍기정 on 7/6/26. +// + +import UIKit +import SnapKit +import Then + +final class RecruitDataPopUpViewController: UIViewController { + + // MARK: - UI Components + private let backgroundView = UIView() + private let editButton = UIButton() + private let separatorView = UIView() + private let deleteButton = UIButton() + + // MARK: - Properties + private let onEditButtonTapped: ()->Void + private let onDeleteButtonTapped: ()->Void + private var minimizedTransform: CGAffineTransform { + let scale = 0.4 + + return CGAffineTransform( + a: scale, + b: 0, + c: 0, + d: scale, + tx: backgroundView.bounds.width * (1 - scale) / 2, + ty: -backgroundView.bounds.height * (1 - scale) / 2 + ) + } + + // MARK: - Initializer + init( + onEditButtonTapped: @escaping () -> Void, + onDeleteButtonTapped: @escaping () -> Void + ) { + self.onEditButtonTapped = onEditButtonTapped + self.onDeleteButtonTapped = onDeleteButtonTapped + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + configureView() + setAddTargets() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + view.layoutIfNeeded() + backgroundView.transform = minimizedTransform + backgroundView.alpha = 0 + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + UIView.animate( + springDuration: 0.2, + bounce: 0.2, + options: [.curveEaseInOut, .beginFromCurrentState] + ) { [weak self] in + self?.backgroundView.transform = .identity + self?.backgroundView.alpha = 1 + } + } +} + +extension RecruitDataPopUpViewController { + private func dismiss() { + UIView.animate( + springDuration: 0.2, + bounce: 0.2, + options: [.curveEaseInOut, .beginFromCurrentState] + ) { [weak self] in + guard let self else { return } + backgroundView.transform = minimizedTransform + backgroundView.alpha = 0 + } completion: { [weak self] _ in + self?.dismiss(animated: false) + } + } +} + +extension RecruitDataPopUpViewController { + private func setAddTargets() { + editButton.addTarget(self, action: #selector(didTapEditButton), for: .touchUpInside) + deleteButton.addTarget(self, action: #selector(didTapDeleteButton), for: .touchUpInside) + view.addGestureRecognizer( + UITapGestureRecognizer( + target: self, + action: #selector(didTapAround) + ).then { + $0.cancelsTouchesInView = false + } + ) + } + + @objc private func didTapEditButton() { + dismiss(animated: false) { [weak self] in + self?.onEditButtonTapped() + } + } + + @objc private func didTapDeleteButton() { + dismiss(animated: false) { [weak self] in + self?.onDeleteButtonTapped() + } + } + + @objc private func didTapAround() { + dismiss() + } +} + +extension RecruitDataPopUpViewController { + + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + view.backgroundColor = .clear + + backgroundView.do { + $0.backgroundColor = .appColor(.neutral50) + $0.layer.applySketchShadow( + color: .black, + alpha: 0.04, + x: 0, + y: 2, + blur: 4, + spread: 0 + ) + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + $0.layer.masksToBounds = false + } + + editButton.do { + var configuration = UIButton.Configuration.plain() + configuration.attributedTitle = AttributedString( + "편집하기", + attributes: AttributeContainer([ + .foregroundColor : UIColor.appColor(.neutral800), + .font : UIFont.appFont(.pretendardRegular, size: 12), + .paragraphStyle : NSMutableParagraphStyle().then { $0.alignment = .left } + ] + )) + configuration.contentInsets = .init(top: 0, leading: 12, bottom: 0, trailing: 40) + $0.configuration = configuration + } + + separatorView.do { + $0.backgroundColor = .appColor(.neutral200) + } + + deleteButton.do { + var configuration = UIButton.Configuration.plain() + configuration.attributedTitle = AttributedString( + "삭제하기", + attributes: AttributeContainer([ + .font : UIFont.appFont(.pretendardRegular, size: 12), + .foregroundColor : UIColor.appColor(.danger700), + .paragraphStyle : NSMutableParagraphStyle().then { $0.alignment = .left } + ]) + ) + configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 40) + $0.configuration = configuration + } + } + + private func setUpLayouts() { + [editButton, deleteButton, separatorView].forEach { + backgroundView.addSubview($0) + } + [backgroundView].forEach { + view.addSubview($0) + } + } + + private func setUpConstraints() { + backgroundView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide).offset(44) + $0.trailing.equalToSuperview().offset(-40) + } + + editButton.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.trailing.equalToSuperview().inset(4) + $0.height.equalTo(35) + } + + deleteButton.snp.makeConstraints { + $0.top.equalTo(editButton.snp.bottom) + $0.leading.equalToSuperview().inset(4) + $0.bottom.equalToSuperview() + $0.height.equalTo(35) + } + + separatorView.snp.makeConstraints { + $0.height.equalTo(1) + $0.leading.trailing.equalToSuperview().inset(12) + $0.centerY.equalToSuperview() + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataRelatedUrlView.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataRelatedUrlView.swift new file mode 100644 index 000000000..4a586acc2 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataRelatedUrlView.swift @@ -0,0 +1,29 @@ +// +// RecruitDataRelatedUrlView.swift +// koin +// +// Created by 홍기정 on 9/12/26. +// + +import SwiftUI + +struct RecruitDataRelatedUrlView: View { + let url: URL + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("관련 링크") + .font(.appFont(.pretendardSemiBold, size: 14)) + .foregroundStyle(Color.appColor(.neutral700)) + .frame(minHeight: 22) + + Link( + url.absoluteString, + destination: url + ) + .font(.appFont(.pretendardMedium, size: 12)) + .foregroundStyle(Color.appColor(.neutral800)) + .multilineTextAlignment(.leading) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataRolesView.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataRolesView.swift new file mode 100644 index 000000000..7fe092de7 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataRolesView.swift @@ -0,0 +1,54 @@ +// +// RecruitDataRolesView.swift +// koin +// + +import SwiftUI + +struct RecruitDataRolesView: View { + let roles: [RecruitRole] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("모집 역할 및 인원") + .font(.appFont(.pretendardSemiBold, size: 14)) + .foregroundStyle(Color.appColor(.neutral700)) + .frame(minHeight: 22) + + VStack(spacing: 0) { + ForEach(roles) { role in + roleRow(role) + .border(role.id != roles.last?.id ? .appColor(.neutral200) : .clear, + width: 1, + edges: [.bottom] + ) + } + } + .border( + .appColor(.neutral200), + width: 1, + radius: 12 + ) + } + } +} + +extension RecruitDataRolesView { + private func roleRow(_ role: RecruitRole) -> some View { + return HStack(spacing: 0) { + HStack(spacing: 4) { + Image.appImage(asset: role.isClosed ? .recruitDataRoleClosed : .recruitDataRoleOpen) + + Text(role.name) + .font(.appFont(.pretendardMedium, size: 12)) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Text(role.isClosed ? "마감" : "\(role.maximumParticipants)명") + .font(.appFont(.pretendardRegular, size: 12)) + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 10) + .frame(minHeight: 32) + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataTextSectionView.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataTextSectionView.swift new file mode 100644 index 000000000..9526209d5 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataTextSectionView.swift @@ -0,0 +1,27 @@ +// +// RecruitDataTextSectionView.swift +// koin +// + +import SwiftUI + +struct RecruitDataTextSectionView: View { + let title: String + let text: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.appFont(.pretendardSemiBold, size: 14)) + .foregroundStyle(Color.appColor(.neutral700)) + .frame(minHeight: 22) + + Text(text) + .font(.appFont(.pretendardMedium, size: 12)) + .foregroundStyle(Color.appColor(.neutral800)) + .linespacing(fontSize: 12, percent: 160) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index 961171a89..fe7cdfdd4 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -55,10 +55,30 @@ final class RecruitListHostingController: UIHostingController, showLoginToast() case .showRecruitPost: showRecruitPost() + case .showRecruitData(let id): + showRecruitData(id: id) } } } +extension RecruitListHostingController: RecruitDataHostingControllerDelegate { + func delete(id: Int) { + rootView.delete(id: id) + } + + private func showRecruitData(id: Int) { + let repository = MockRecruitRepository() + let fetchUseCase = DefaultFetchRecruitDataUseCase(repository: repository) + let deleteUseCase = DefaultDeleteRecruitDataUseCase(repository: repository) + let viewModel = RecruitDataViewModel(fetchRecruitDataUseCase: fetchUseCase, deleteRecruitDataUseCase: deleteUseCase, recruitId: id) + let controller = RecruitDataHostingController( + rootView: RecruitDataView(viewModel: viewModel), + delegate: self + ) + navigationController?.pushViewController(controller, animated: true) + } +} + extension RecruitListHostingController { private func updateNotificationBarButton(_ hasUnreadNotification: Bool) { UIView.transition( @@ -105,14 +125,14 @@ extension RecruitListHostingController { } private func showRecruitPost() { let recruitRepository = MockRecruitRepository() - let fetchRecruitDetailUseCase = DefaultFetchRecruitDetailUseCase(repository: recruitRepository) + let fetchRecruitDataUseCase = DefaultFetchRecruitDataUseCase(repository: recruitRepository) let postRecruitUseCase = DefaultPostRecruitUseCase(repository: recruitRepository) let modifyRecruitUseCase = DefaultModifyRecruitUseCase(repository: recruitRepository) let viewModel = RecruitPostViewModel( postType: .post, postRecruitUseCase: postRecruitUseCase, modifyRecruitUseCase: modifyRecruitUseCase, - fetchRecruitDetailUseCase: fetchRecruitDetailUseCase + fetchRecruitDataUseCase: fetchRecruitDataUseCase ) let viewController = RecruitPostViewController(viewModel: viewModel) navigationController?.pushViewController(viewController, animated: true) @@ -196,3 +216,4 @@ extension RecruitListHostingController { } } } + diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift index 651f1139c..e40cd245a 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -14,13 +14,14 @@ struct RecruitListView: ActionBindableView { case showToast(message: String) case showLoginToast case showRecruitPost + case showRecruitData(id: Int) } // MARK: - Properties var sendAction: ((Action) -> Void) = { _ in } @State private var viewModel: RecruitListViewModel - var recruits: [RecruitDetail] { + var recruits: [RecruitSummary] { viewModel.recruitList?.recruits ?? [] } @@ -62,7 +63,7 @@ struct RecruitListView: ActionBindableView { LazyVStack(spacing: 8) { ForEach(recruits) { recruit in Button { - + sendAction(.showRecruitData(id: recruit.id)) } label: { RecruitListRowView(model: recruit) } @@ -135,4 +136,9 @@ struct RecruitListView: ActionBindableView { } .buttonStyle(.plain) } + + // MARK: - Public + func delete(id: Int) { + viewModel.execute(.delete(id: id)) + } } diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift index 517868de6..bb4d1f4bd 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift @@ -19,7 +19,7 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { case deleteFilter(rawvalue: String) case resetFilter case loadNextPage - + case delete(id: Int) case didShowToast } @@ -79,6 +79,8 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { resetFilter() case .loadNextPage: loadNextPage() + case .delete(let id): + recruitList?.delete(id: id) case .didShowToast: errorMessage = nil } diff --git a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift index 5f93b96e6..d989e9889 100644 --- a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift @@ -9,9 +9,9 @@ import SwiftUI struct RecruitListRowView: View { - let model: RecruitDetail + let model: RecruitSummary - init(model: RecruitDetail) { + init(model: RecruitSummary) { self.model = model } @@ -24,7 +24,7 @@ struct RecruitListRowView: View { rolesView .padding(.bottom, 8) .isHidden(model.type == .general) - detailView + dataView } .padding(.horizontal, 20) .padding(.vertical, 16) @@ -48,7 +48,7 @@ extension RecruitListRowView { .background(Color.appColor(model.category.backgroundColor)) .clipShape(.capsule) - Text("D-\(model.dDay)") + Text(model.dDay) .font(.appFont(.pretendardMedium, size: 10)) .foregroundStyle(Color.appColor(.danger700)) } @@ -79,7 +79,7 @@ extension RecruitListRowView { } @ViewBuilder - private var detailView: some View { + private var dataView: some View { LeftAlignedLayout(interitemSpacing: 8, interlineSpacing: 4) { Group { HStack(alignment: .center, spacing: 2) { diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift index 0445c9833..45950f739 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift @@ -16,7 +16,7 @@ final class RecruitPostViewController: UIViewController { switch viewModel.postType { case .post: return "모집글 작성" - case .edit: + case .modify: return "모집글 수정" } } @@ -24,7 +24,7 @@ final class RecruitPostViewController: UIViewController { switch viewModel.postType { case .post: return "등록하기" - case .edit: + case .modify: return "수정하기" } } @@ -32,7 +32,7 @@ final class RecruitPostViewController: UIViewController { switch viewModel.postType { case .post: return "모집글이 등록되었습니다." - case .edit: + case .modify: return "모집글이 수정되었습니다." } } @@ -81,7 +81,7 @@ final class RecruitPostViewController: UIViewController { placeholder: "지원 자격 또는 우대사항을 작성해주세요." ) - private let submitButton = UIButton() + private let postButton = UIButton() private let loadingIndicator = UIActivityIndicatorView(style: .medium) // MARK: - Dropdown @@ -167,12 +167,14 @@ extension RecruitPostViewController { updateViews() case let .updateLoading(isLoading): isLoading ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating() - case .submitFailed: - submitButton.isUserInteractionEnabled = true - case .submitCompleted: - handleSubmitCompleted() + case .postFailed: + postButton.isUserInteractionEnabled = true + case .postCompleted(let id): + handlePostCompleted(id: id) + case .modifyCompleted(let id): + handleModifyCompleted(id: id) case let .showToast(message): - showToastMessage(message: message) + showToastMessage(message: message, bottomInset: 72) } }.store(in: &subscriptions) } @@ -293,14 +295,41 @@ extension RecruitPostViewController { extension RecruitPostViewController { - private func handleSubmitCompleted() { // TODO: 화면전환 -// showToastMessage(message: ) -// navigationController?.replaceTopViewController(<#T##viewController: UIViewController##UIViewController#>, animated: <#T##Bool#>) + private func handlePostCompleted(id: Int) { // TODO: 화면전환 + let repository = MockRecruitRepository() + let fetchUseCase = DefaultFetchRecruitDataUseCase(repository: repository) + let deleteUseCase = DefaultDeleteRecruitDataUseCase(repository: repository) + let viewModel = RecruitDataViewModel(fetchRecruitDataUseCase: fetchUseCase, deleteRecruitDataUseCase: deleteUseCase, recruitId: id) + let viewController = RecruitDataHostingController( + rootView: RecruitDataView(viewModel: viewModel), + delegate: nil + ) + navigationController?.replaceTopViewController(viewController, animated: true) + showToastMessage(message: completionMessage, bottomInset: 60) + } + + private func handleModifyCompleted(id: Int) { + guard var viewControllers = navigationController?.viewControllers else { + return + } + + let repository = MockRecruitRepository() + let fetchUseCase = DefaultFetchRecruitDataUseCase(repository: repository) + let deleteUseCase = DefaultDeleteRecruitDataUseCase(repository: repository) + let viewModel = RecruitDataViewModel(fetchRecruitDataUseCase: fetchUseCase, deleteRecruitDataUseCase: deleteUseCase, recruitId: id) + let viewController = RecruitDataHostingController( + rootView: RecruitDataView(viewModel: viewModel), + delegate: nil + ) + viewControllers[viewControllers.count - 2] = viewController + navigationController?.viewControllers = viewControllers + navigationController?.popToViewController(viewController, animated: true) + showToastMessage(message: completionMessage, bottomInset: 60) } private func validate() { - submitButton.isEnabled = request.isValid - submitButton.backgroundColor = request.isValid ? UIColor.appColor(.new500) : UIColor.appColor(.neutral400) + postButton.isEnabled = request.isValid + postButton.backgroundColor = request.isValid ? UIColor.appColor(.new500) : UIColor.appColor(.neutral400) } private func updateViews() { @@ -326,12 +355,12 @@ extension RecruitPostViewController { extension RecruitPostViewController { private func setAddTargets() { - submitButton.addTarget(self, action: #selector(submitButtonTapped), for: .touchUpInside) + postButton.addTarget(self, action: #selector(postButtonTapped), for: .touchUpInside) } - @objc private func submitButtonTapped() { + @objc private func postButtonTapped() { guard !dropdownHost.isPresenting else { return } - submitButton.isUserInteractionEnabled = false + postButton.isUserInteractionEnabled = false view.endEditing(true) inputSubject.send(.submit(request)) } @@ -403,7 +432,7 @@ extension RecruitPostViewController { $0.spacing = 24 $0.axis = .vertical } - submitButton.do { + postButton.do { $0.setAttributedTitle(NSAttributedString( string: submitButtonTitle, attributes: [ @@ -425,7 +454,7 @@ extension RecruitPostViewController { scrollView.addSubview($0) } - [scrollView, submitButton].forEach { + [scrollView, postButton].forEach { view.addSubview($0) } } @@ -434,13 +463,13 @@ extension RecruitPostViewController { scrollView.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide) $0.leading.trailing.equalToSuperview().inset(24) - $0.bottom.equalTo(submitButton.snp.top).offset(-16) + $0.bottom.equalTo(postButton.snp.top).offset(-16) } contentStackView.snp.makeConstraints { $0.edges.equalToSuperview() $0.width.equalToSuperview() } - submitButton.snp.makeConstraints { + postButton.snp.makeConstraints { $0.height.equalTo(48) $0.leading.trailing.equalToSuperview().inset(32) $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift index 36fc3742d..0d46d00b4 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift @@ -12,7 +12,7 @@ final class RecruitPostViewModel: ViewModelProtocol { enum PostType { case post - case edit(id: Int) + case modify(id: Int) } enum Input { @@ -23,15 +23,16 @@ final class RecruitPostViewModel: ViewModelProtocol { case updateLoading(Bool) case updateForm(RecruitPostRequest) - case submitFailed - case submitCompleted(id: Int?) + case postFailed + case postCompleted(id: Int) + case modifyCompleted(id: Int) case showToast(String) } // MARK: - Properties private let postRecruitUseCase: PostRecruitUseCase private let modifyRecruitUseCase: ModifyRecruitUseCase - private let fetchRecruitDetailUseCase: FetchRecruitDetailUseCase + private let fetchRecruitDataUseCase: FetchRecruitDataUseCase private let outputSubject = PassthroughSubject() private var subscriptions: Set = [] @@ -43,12 +44,12 @@ final class RecruitPostViewModel: ViewModelProtocol { postType: PostType, postRecruitUseCase: PostRecruitUseCase, modifyRecruitUseCase: ModifyRecruitUseCase, - fetchRecruitDetailUseCase: FetchRecruitDetailUseCase + fetchRecruitDataUseCase: FetchRecruitDataUseCase ) { self.postType = postType self.postRecruitUseCase = postRecruitUseCase self.modifyRecruitUseCase = modifyRecruitUseCase - self.fetchRecruitDetailUseCase = fetchRecruitDetailUseCase + self.fetchRecruitDataUseCase = fetchRecruitDataUseCase } // MARK: - Public @@ -70,7 +71,7 @@ final class RecruitPostViewModel: ViewModelProtocol { extension RecruitPostViewModel { private func fetchFormIfNeeded() { - guard case let .edit(id) = postType else { + guard case let .modify(id) = postType else { return } @@ -81,8 +82,8 @@ extension RecruitPostViewModel { } do { - let detail = try await fetchRecruitDetailUseCase.execute(id: id) - outputSubject.send(.updateForm(.init(from: detail))) + let data = try await fetchRecruitDataUseCase.execute(id: id) + outputSubject.send(.updateForm(.init(from: data))) } catch { if let error = error as? ErrorResponse { outputSubject.send(.showToast(error.message)) @@ -105,13 +106,13 @@ extension RecruitPostViewModel { switch postType { case .post: let id = try await postRecruitUseCase.execute(request: request) - outputSubject.send(.submitCompleted(id: id)) - case let .edit(id): + outputSubject.send(.postCompleted(id: id)) + case let .modify(id): try await modifyRecruitUseCase.execute(id: id, request: request) - outputSubject.send(.submitCompleted(id: id)) + outputSubject.send(.modifyCompleted(id: id)) } } catch { - outputSubject.send(.submitFailed) + outputSubject.send(.postFailed) if let error = error as? ErrorResponse { outputSubject.send(.showToast(error.message)) } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 949cb4dc2..dd63d057f 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -11,8 +11,10 @@ 0E2253358E42231BE436FA27 /* RecruitPostScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE127F2AE4B18899F6E5E0D /* RecruitPostScheduleView.swift */; }; 0FCD163D2BA80EAA00C23A2F /* KeychainWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCD163C2BA80EAA00C23A2F /* KeychainWorker.swift */; }; 1537B6CFD3D7AF52A2E9E944 /* RecruitPostMeetingTypeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73F70BC5F3AA1A34663C32CA /* RecruitPostMeetingTypeView.swift */; }; + 2E6336C1CDB4445BA858DA5A /* RecruitDataContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */; }; 5006987DEF4B5F454D7EA2D5 /* RecruitPostTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */; }; 515B96092C6730C797C1F837 /* PostRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D447F2D75951DD4C3E08D4E /* PostRecruitUseCase.swift */; }; + 678D20761DEF461CA6D4728A /* RecruitDataTextSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2FBEEB767094CE8B219095B /* RecruitDataTextSectionView.swift */; }; 75EBF253F4468B2060B79212 /* RecruitPostSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3694E3ACBE05B8C7F15357 /* RecruitPostSectionHeaderView.swift */; }; 7C016540300A88940013DD7B /* ShuttleTicketViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C01653F300A88940013DD7B /* ShuttleTicketViewController.swift */; }; 7C016544300AA1450013DD7B /* DepartmentCategoryHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C016543300AA1450013DD7B /* DepartmentCategoryHostingController.swift */; }; @@ -149,9 +151,10 @@ 7C50A1D13041F8BF00897A6F /* RecruitState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D03041F8BF00897A6F /* RecruitState.swift */; }; 7C50A1D33041F8CA00897A6F /* RecruitCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D23041F8CA00897A6F /* RecruitCategory.swift */; }; 7C50A1D53041F8D500897A6F /* RecruitMeetingType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D43041F8D500897A6F /* RecruitMeetingType.swift */; }; - 7C50A1D73041F91400897A6F /* RecruitDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D63041F91400897A6F /* RecruitDetail.swift */; }; + 7C50A1D73041F91400897A6F /* RecruitData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D63041F91400897A6F /* RecruitData.swift */; }; 7C50A1D93041FC6800897A6F /* RecruitListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D83041FC6800897A6F /* RecruitListRowView.swift */; }; 7C50A1DD3042016300897A6F /* LeftAlignedLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1DC3042016300897A6F /* LeftAlignedLayout.swift */; }; + 7C50A1E13041F91400897A6F /* RecruitSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1E23041F91400897A6F /* RecruitSummary.swift */; }; 7C50A1E23042C82F00897A6F /* RecruitRoleType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1E13042C82F00897A6F /* RecruitRoleType.swift */; }; 7C50A1E43042C97F00897A6F /* RecruitRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1E33042C97F00897A6F /* RecruitRole.swift */; }; 7C50A1F43042CB4100897A6F /* RecruitListSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1F33042CB4100897A6F /* RecruitListSort.swift */; }; @@ -455,6 +458,12 @@ 7CCB1E4E2F29DB3200472669 /* LostItemPostChatDetailRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CCB1E4D2F29DB3200472669 /* LostItemPostChatDetailRequest.swift */; }; 7CD411EA2FEB92280079467B /* HomeTabBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CD411E92FEB92280079467B /* HomeTabBarItem.swift */; }; 7CDCD0A93053F96E004204DA /* RecruitPostRoleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0A83053F96E004204DA /* RecruitPostRoleTableViewCell.swift */; }; + 7CDCD0B030556CB1004204DA /* RecruitDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0AF30556CB1004204DA /* RecruitDataView.swift */; }; + 7CDCD0B230556CBA004204DA /* RecruitDataViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0B130556CBA004204DA /* RecruitDataViewModel.swift */; }; + 7CDCD0B430556CC2004204DA /* RecruitDataHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0B330556CC2004204DA /* RecruitDataHostingController.swift */; }; + 7CDCD0B73055863E004204DA /* RecruitDataRelatedUrlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0B63055863E004204DA /* RecruitDataRelatedUrlView.swift */; }; + 7CDCD0BB30559234004204DA /* RecruitDataPopUpViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0BA30559234004204DA /* RecruitDataPopUpViewController.swift */; }; + 7CDCD0BD30559E70004204DA /* DeleteRecruitDataUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0BC30559E70004204DA /* DeleteRecruitDataUseCase.swift */; }; 7CEB3B4B2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */; }; 7CED90BC2EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FD02EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift */; }; 7CED90BD2EF2D1F900457128 /* ManageNoticeKeywordViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F612EF2D1F900457128 /* ManageNoticeKeywordViewController.swift */; }; @@ -772,8 +781,10 @@ 83E96DB82BBC6FBF00164914 /* BusSearchDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83E96DB72BBC6FBF00164914 /* BusSearchDto.swift */; }; 83EBB87A2D0FDFD700346018 /* KoinPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83EBB8792D0FDFD700346018 /* KoinPickerView.swift */; }; 83EBB87E2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83EBB87D2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift */; }; + 8A7B1679CC9945559E674584 /* RecruitDataRolesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */; }; + 9C95E190C98F440DADB6F91B /* RecruitDataMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DADCC36CEEA42EC86AB75B6 /* RecruitDataMetadataView.swift */; }; A0B343754988C9398C4FE726 /* ModifyRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */; }; - A1D000010000000000000001 /* FetchRecruitDetailUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D000010000000000000002 /* FetchRecruitDetailUseCase.swift */; }; + A1D000010000000000000001 /* FetchRecruitDataUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D000010000000000000002 /* FetchRecruitDataUseCase.swift */; }; A1E000010000000000000001 /* RecruitPostRoleTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E000010000000000000002 /* RecruitPostRoleTableView.swift */; }; AA20000000000000000001 /* PublisherTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000001 /* PublisherTestSupport.swift */; }; AA20000000000000000002 /* DiningFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000002 /* DiningFixtures.swift */; }; @@ -813,6 +824,7 @@ B68D2CEB2EA50C1E00F3B479 /* UIViewController+Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68D2CEA2EA50C1E00F3B479 /* UIViewController+Toast.swift */; }; B6A0D62A2EEB01E600A19521 /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = B6A0D6292EEB01E600A19521 /* Debug.xcconfig */; }; B6A0D62C2EEB01F100A19521 /* Release.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = B6A0D62B2EEB01F100A19521 /* Release.xcconfig */; }; + BAD01845AA334A648E2FA2BD /* RecruitDataHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBC0EF8E0FE44F48744BEC7 /* RecruitDataHeaderView.swift */; }; C1838F7A4424DEA14DB2D107 /* RecruitPostCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */; }; CD3000000000000000000001 /* RecruitPostCategoryDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3000000000000000000002 /* RecruitPostCategoryDropdownView.swift */; }; D0A71C4F2F1B8340009E2D71 /* KoinDropdownHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */; }; @@ -1060,6 +1072,7 @@ FE01000000000000000021 /* KoinDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01000000000000000011 /* KoinDropdown.swift */; }; FE01000000000000000023 /* KoinDropdownAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01000000000000000013 /* KoinDropdownAnimator.swift */; }; FE01000000000000000026 /* KoinDropdownConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01000000000000000016 /* KoinDropdownConfiguration.swift */; }; + FE0B6B2358364622BC242C74 /* RecruitDataButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AB6864692D44B9849E0447 /* RecruitDataButton.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1101,6 +1114,8 @@ 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostCategoryView.swift; sourceTree = ""; }; 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyRecruitUseCase.swift; sourceTree = ""; }; 54351A8A0C1A4046C3155481 /* RecruitPostTextViewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostTextViewView.swift; sourceTree = ""; }; + 5CBC0EF8E0FE44F48744BEC7 /* RecruitDataHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataHeaderView.swift; sourceTree = ""; }; + 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataContentView.swift; sourceTree = ""; }; 73F70BC5F3AA1A34663C32CA /* RecruitPostMeetingTypeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostMeetingTypeView.swift; sourceTree = ""; }; 7C01653F300A88940013DD7B /* ShuttleTicketViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShuttleTicketViewController.swift; sourceTree = ""; }; 7C016543300AA1450013DD7B /* DepartmentCategoryHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DepartmentCategoryHostingController.swift; sourceTree = ""; }; @@ -1237,10 +1252,11 @@ 7C50A1D03041F8BF00897A6F /* RecruitState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitState.swift; sourceTree = ""; }; 7C50A1D23041F8CA00897A6F /* RecruitCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitCategory.swift; sourceTree = ""; }; 7C50A1D43041F8D500897A6F /* RecruitMeetingType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMeetingType.swift; sourceTree = ""; }; - 7C50A1D63041F91400897A6F /* RecruitDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDetail.swift; sourceTree = ""; }; + 7C50A1D63041F91400897A6F /* RecruitData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitData.swift; sourceTree = ""; }; 7C50A1D83041FC6800897A6F /* RecruitListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListRowView.swift; sourceTree = ""; }; 7C50A1DC3042016300897A6F /* LeftAlignedLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftAlignedLayout.swift; sourceTree = ""; }; 7C50A1E13042C82F00897A6F /* RecruitRoleType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitRoleType.swift; sourceTree = ""; }; + 7C50A1E23041F91400897A6F /* RecruitSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitSummary.swift; sourceTree = ""; }; 7C50A1E33042C97F00897A6F /* RecruitRole.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitRole.swift; sourceTree = ""; }; 7C50A1F33042CB4100897A6F /* RecruitListSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListSort.swift; sourceTree = ""; }; 7C50A1F93042DB3200897A6F /* RecruitListFilter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecruitListFilter+.swift"; sourceTree = ""; }; @@ -1524,6 +1540,12 @@ 7CCB1E4D2F29DB3200472669 /* LostItemPostChatDetailRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemPostChatDetailRequest.swift; sourceTree = ""; }; 7CD411E92FEB92280079467B /* HomeTabBarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTabBarItem.swift; sourceTree = ""; }; 7CDCD0A83053F96E004204DA /* RecruitPostRoleTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostRoleTableViewCell.swift; sourceTree = ""; }; + 7CDCD0AF30556CB1004204DA /* RecruitDataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataView.swift; sourceTree = ""; }; + 7CDCD0B130556CBA004204DA /* RecruitDataViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataViewModel.swift; sourceTree = ""; }; + 7CDCD0B330556CC2004204DA /* RecruitDataHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataHostingController.swift; sourceTree = ""; }; + 7CDCD0B63055863E004204DA /* RecruitDataRelatedUrlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataRelatedUrlView.swift; sourceTree = ""; }; + 7CDCD0BA30559234004204DA /* RecruitDataPopUpViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataPopUpViewController.swift; sourceTree = ""; }; + 7CDCD0BC30559E70004204DA /* DeleteRecruitDataUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteRecruitDataUseCase.swift; sourceTree = ""; }; 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteDeviceTokenUseCase.swift; sourceTree = ""; }; 7CED8EC32EF2D1F900457128 /* ShopCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCollectionView.swift; sourceTree = ""; }; 7CED8EC42EF2D1F900457128 /* ShopCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCollectionViewCell.swift; sourceTree = ""; }; @@ -1767,6 +1789,7 @@ 7CF77B843005F105003642C1 /* NoticeSearchEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeSearchEmptyView.swift; sourceTree = ""; }; 7CF77B863005F642003642C1 /* FetchMainFrameUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMainFrameUseCase.swift; sourceTree = ""; }; 7D447F2D75951DD4C3E08D4E /* PostRecruitUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRecruitUseCase.swift; sourceTree = ""; }; + 7DADCC36CEEA42EC86AB75B6 /* RecruitDataMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataMetadataView.swift; sourceTree = ""; }; 82F7EBAB20247086024B51CB /* RecruitPostViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostViewController.swift; sourceTree = ""; }; 831ABDC32CA29A120099B70C /* NoticeKeywordsFetchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeKeywordsFetchResult.swift; sourceTree = ""; }; 831ABDC52CA2A7C20099B70C /* FetchRecentSearchedWordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecentSearchedWordUseCase.swift; sourceTree = ""; }; @@ -1845,8 +1868,9 @@ 83EBB8792D0FDFD700346018 /* KoinPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinPickerView.swift; sourceTree = ""; }; 83EBB87D2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchKoinPickerDateUseCase.swift; sourceTree = ""; }; 8CE127F2AE4B18899F6E5E0D /* RecruitPostScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostScheduleView.swift; sourceTree = ""; }; + 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataRolesView.swift; sourceTree = ""; }; A001E2B42845091D00D6C310 /* koin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = koin.app; sourceTree = BUILT_PRODUCTS_DIR; }; - A1D000010000000000000002 /* FetchRecruitDetailUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitDetailUseCase.swift; sourceTree = ""; }; + A1D000010000000000000002 /* FetchRecruitDataUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitDataUseCase.swift; sourceTree = ""; }; A1E000010000000000000002 /* RecruitPostRoleTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostRoleTableView.swift; sourceTree = ""; }; AA10000000000000000001 /* PublisherTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublisherTestSupport.swift; sourceTree = ""; }; AA10000000000000000002 /* DiningFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningFixtures.swift; sourceTree = ""; }; @@ -1858,6 +1882,7 @@ AA10000000000000000008 /* DiningViewModelStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningViewModelStubs.swift; sourceTree = ""; }; AA10000000000000000009 /* DiningLoggingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningLoggingTests.swift; sourceTree = ""; }; AA10000000000000000010 /* DiningTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningTypeTests.swift; sourceTree = ""; }; + B2FBEEB767094CE8B219095B /* RecruitDataTextSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataTextSectionView.swift; sourceTree = ""; }; B438AEE32E756AB600E889C4 /* OrderImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderImage.swift; sourceTree = ""; }; B46B8CC12E76CB7300A8E797 /* ShopSummaryDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopSummaryDto.swift; sourceTree = ""; }; B46B8CC32E76CC6300A8E797 /* FetchOrderShopSummaryFromShopUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchOrderShopSummaryFromShopUseCase.swift; sourceTree = ""; }; @@ -2106,6 +2131,7 @@ D2FCBEA52D0877CC0097A388 /* FetchFramesUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchFramesUseCase.swift; sourceTree = ""; }; D2FFA96F2D5F4DB600EF8E56 /* CheckLoginUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckLoginUseCase.swift; sourceTree = ""; }; D2FFA9842D6333BB00EF8E56 /* ReportLostItemRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportLostItemRequest.swift; sourceTree = ""; }; + D6AB6864692D44B9849E0447 /* RecruitDataButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataButton.swift; sourceTree = ""; }; D80AD4E32E6C6BC30061334B /* waveLogo.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = waveLogo.json; sourceTree = ""; }; D837FB112E254BD8002BFB9F /* CustomSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomSessionManager.swift; sourceTree = ""; }; D85939742DC12C9D00CE4CB2 /* CheckDuplicatedIdUsecase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckDuplicatedIdUsecase.swift; sourceTree = ""; }; @@ -2716,6 +2742,7 @@ 7C50A1B43041A25D00897A6F /* RecruitList */, 7CACF86130436C1900AC2828 /* RecruitNotificationList */, 7CACF873304441AA00AC2828 /* RecruitPost */, + 7CDCD0AE30556C89004204DA /* RecruitData */, ); path = Recruit; sourceTree = ""; @@ -2740,11 +2767,12 @@ 7C50A1E33042C97F00897A6F /* RecruitRole.swift */, 7C50A1F33042CB4100897A6F /* RecruitListSort.swift */, 7C50A1CB3041E29000897A6F /* RecruitListFilter.swift */, - 7C50A1D63041F91400897A6F /* RecruitDetail.swift */, + 7C50A1E23041F91400897A6F /* RecruitSummary.swift */, 7C50A1FE3042E9DC00897A6F /* RecruitList.swift */, 7C50A20A304324F500897A6F /* RecruitNotification.swift */, 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */, 7CAC2DDB304C34E900AC2828 /* RecruitPostRequest.swift */, + 7C50A1D63041F91400897A6F /* RecruitData.swift */, ); path = Recruit; sourceTree = ""; @@ -2795,7 +2823,7 @@ 7C50A1FB3042E99E00897A6F /* Recruit */ = { isa = PBXGroup; children = ( - A1D000010000000000000003 /* RecruitDetail */, + A1D000010000000000000003 /* RecruitData */, C1309DAEB0F857F481B1A4B0 /* RecruitPost */, 7CACF86C3044096200AC2828 /* RecruitList */, 7CACF86E3044097800AC2828 /* RecruitNotificationList */, @@ -3635,6 +3663,32 @@ path = LostItemChat; sourceTree = ""; }; + 7CDCD0AE30556C89004204DA /* RecruitData */ = { + isa = PBXGroup; + children = ( + 7CDCD0B530556CC6004204DA /* Subviews */, + 7CDCD0B330556CC2004204DA /* RecruitDataHostingController.swift */, + 7CDCD0AF30556CB1004204DA /* RecruitDataView.swift */, + 7CDCD0B130556CBA004204DA /* RecruitDataViewModel.swift */, + ); + path = RecruitData; + sourceTree = ""; + }; + 7CDCD0B530556CC6004204DA /* Subviews */ = { + isa = PBXGroup; + children = ( + 7CDCD0BA30559234004204DA /* RecruitDataPopUpViewController.swift */, + 5CBC0EF8E0FE44F48744BEC7 /* RecruitDataHeaderView.swift */, + 7DADCC36CEEA42EC86AB75B6 /* RecruitDataMetadataView.swift */, + 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */, + B2FBEEB767094CE8B219095B /* RecruitDataTextSectionView.swift */, + D6AB6864692D44B9849E0447 /* RecruitDataButton.swift */, + 7CDCD0B63055863E004204DA /* RecruitDataRelatedUrlView.swift */, + 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */, + ); + path = Subviews; + sourceTree = ""; + }; 7CED8EC52EF2D1F900457128 /* ShopCollectionView */ = { isa = PBXGroup; children = ( @@ -4909,12 +4963,13 @@ name = Products; sourceTree = ""; }; - A1D000010000000000000003 /* RecruitDetail */ = { + A1D000010000000000000003 /* RecruitData */ = { isa = PBXGroup; children = ( - A1D000010000000000000002 /* FetchRecruitDetailUseCase.swift */, + A1D000010000000000000002 /* FetchRecruitDataUseCase.swift */, + 7CDCD0BC30559E70004204DA /* DeleteRecruitDataUseCase.swift */, ); - path = RecruitDetail; + path = RecruitData; sourceTree = ""; }; AA30000000000000000001 /* Support */ = { @@ -6293,7 +6348,7 @@ D29F21F32C4FD23F00994554 /* LandItem.swift in Sources */, 7C7F56B72FFB9FE000847151 /* NotificationHistoryRecord.swift in Sources */, 7C50A1FD3042E9AB00897A6F /* FetchRecruitListUseCase.swift in Sources */, - A1D000010000000000000001 /* FetchRecruitDetailUseCase.swift in Sources */, + A1D000010000000000000001 /* FetchRecruitDataUseCase.swift in Sources */, D20AB95A2C55369F006BC684 /* NotiAPI.swift in Sources */, 7C457AE72FCDE7240011D338 /* SwiftUIViewModelProtocol.swift in Sources */, 833D9C392C7058C300982145 /* NoticeListRepository.swift in Sources */, @@ -6373,6 +6428,7 @@ 7CB11C782FB32656004E0C80 /* SubscribeKeywordRequest.swift in Sources */, 836242362BD9522400845499 /* FirebaseLog.swift in Sources */, 83DD0DEA2C57486400278ABD /* CityBusTimetableDto.swift in Sources */, + 7CDCD0BD30559E70004204DA /* DeleteRecruitDataUseCase.swift in Sources */, 7C7BD71F302B2B37003C5A15 /* PostLostItemUseCase.swift in Sources */, D29F21DB2C4E228D00994554 /* CheckDuplicatedNicknameRequest.swift in Sources */, D249CD3B2C158F49000813F9 /* DiningItem.swift in Sources */, @@ -6441,6 +6497,7 @@ 7CED90E02EF2D1F900457128 /* ShopSummaryTableViewCell.swift in Sources */, 7C50A20930431F9900897A6F /* Array+.swift in Sources */, 7CED90E22EF2D1F900457128 /* PointLabel.swift in Sources */, + 7CDCD0B230556CBA004204DA /* RecruitDataViewModel.swift in Sources */, 7CED90E32EF2D1F900457128 /* FindPhoneIdViewController.swift in Sources */, 7CED90E42EF2D1F900457128 /* SubstituteTimetableModalViewController.swift in Sources */, 7CED90E72EF2D1F900457128 /* ForceUpdateViewController.swift in Sources */, @@ -6742,6 +6799,7 @@ 7C50A1E23042C82F00897A6F /* RecruitRoleType.swift in Sources */, 7C6790932FCC48A60045E163 /* UICollectionView+.swift in Sources */, 7C6790942FCC48A60045E163 /* UIViewcontroller+Alert.swift in Sources */, + 7CDCD0B430556CC2004204DA /* RecruitDataHostingController.swift in Sources */, 7C6790952FCC48A60045E163 /* UIButton+.swift in Sources */, 7C50A1D13041F8BF00897A6F /* RecruitState.swift in Sources */, 7C50A1F43042CB4100897A6F /* RecruitListSort.swift in Sources */, @@ -6824,7 +6882,8 @@ 7C04A4802FB3380700214992 /* NotiSubscribeRequest.swift in Sources */, 7C04A4812FB3380700214992 /* SendDeviceTokenRequest.swift in Sources */, 7CED92062EF2D1F900457128 /* ShopSearchTableViewShopNameCell.swift in Sources */, - 7C50A1D73041F91400897A6F /* RecruitDetail.swift in Sources */, + 7C50A1D73041F91400897A6F /* RecruitData.swift in Sources */, + 7C50A1E13041F91400897A6F /* RecruitSummary.swift in Sources */, 7CED92072EF2D1F900457128 /* SortTypeBottomSheetViewController.swift in Sources */, 7CED92082EF2D1F900457128 /* DeleteLectureModalViewController.swift in Sources */, 7CED92092EF2D1F900457128 /* AgreementFormViewController.swift in Sources */, @@ -6862,6 +6921,7 @@ D89F8FDB2DF12847000265DC /* StatefulButton.swift in Sources */, D208A7BB2CA169DF007040E7 /* FetchBeneficialShopUseCase.swift in Sources */, D2D462712D63C5ED00C60864 /* LostItemChatRoomItem.swift in Sources */, + 7CDCD0BB30559234004204DA /* RecruitDataPopUpViewController.swift in Sources */, 7C8ADD2D2F20B43F00F85BDE /* UpdateLostItemRequest.swift in Sources */, 7C7BD723302B3D8F003C5A15 /* NoticeAISummary.swift in Sources */, D2B7EBCA2D37C81B00EE46B0 /* PostLostItemRequest.swift in Sources */, @@ -7002,6 +7062,7 @@ D2D4225F2BAC2AC600A8AF04 /* UserPutRequest.swift in Sources */, D22421802C8ADE6200E8B480 /* PasswordValidator.swift in Sources */, 7CC42B3C2F58284D00940CE1 /* CallVanNotificationTableViewCell.swift in Sources */, + 7CDCD0B73055863E004204DA /* RecruitDataRelatedUrlView.swift in Sources */, D20AD6F52E029F8D0067760A /* CheckVerificationEmailUseCase.swift in Sources */, 7C64040F300F428200A9589F /* DepartmentService.swift in Sources */, 7C333EA53030429B009D2B89 /* ModalButtonView.swift in Sources */, @@ -7015,6 +7076,13 @@ 7C7CCE702F866E9400E3A54B /* AppPath.swift in Sources */, 7C61D3472F220EA2005FAC3A /* EditLostItemCategoryView.swift in Sources */, 7CED92462EF3DC2800457128 /* OrderCategoryCollectionViewCell.swift in Sources */, + 7CDCD0B030556CB1004204DA /* RecruitDataView.swift in Sources */, + BAD01845AA334A648E2FA2BD /* RecruitDataHeaderView.swift in Sources */, + 9C95E190C98F440DADB6F91B /* RecruitDataMetadataView.swift in Sources */, + 8A7B1679CC9945559E674584 /* RecruitDataRolesView.swift in Sources */, + 678D20761DEF461CA6D4728A /* RecruitDataTextSectionView.swift in Sources */, + FE0B6B2358364622BC242C74 /* RecruitDataButton.swift in Sources */, + 2E6336C1CDB4445BA858DA5A /* RecruitDataContentView.swift in Sources */, 7C8A94402FD426B400DEA6F5 /* HomeRepository.swift in Sources */, 7CED92472EF3DC2800457128 /* OrderCategoryCollectionView.swift in Sources */, 7C8ADD332F20BEC200F85BDE /* LostItemService.swift in Sources */, From 9b944ca59b4e00b938b960f2341f5da0a4e923bc Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 13 Sep 2026 01:18:35 +0900 Subject: [PATCH 020/123] =?UTF-8?q?chore:=20=EC=88=98=EC=A0=95=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit id 대신 data를 전달. placeholder가 일시적으로 보이는 현상 방지. --- .../RecruitDataHostingController.swift | 8 ++--- .../Recruit/RecruitData/RecruitDataView.swift | 3 ++ .../RecruitListHostingController.swift | 4 +-- .../RecruitPostViewController.swift | 2 +- .../RecruitPost/RecruitPostViewModel.swift | 34 +++++-------------- 5 files changed, 16 insertions(+), 35 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index 29db3f683..553953664 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -145,18 +145,16 @@ extension RecruitDataHostingController { } private func navigateToEdit() { - guard let id = rootView.id else { + guard let data = rootView.data else { return } let recruitRepository = MockRecruitRepository() - let fetchRecruitDataUseCase = DefaultFetchRecruitDataUseCase(repository: recruitRepository) let postRecruitUseCase = DefaultPostRecruitUseCase(repository: recruitRepository) let modifyRecruitUseCase = DefaultModifyRecruitUseCase(repository: recruitRepository) let viewModel = RecruitPostViewModel( - postType: .modify(id: id), + postType: .modify(data: data), postRecruitUseCase: postRecruitUseCase, - modifyRecruitUseCase: modifyRecruitUseCase, - fetchRecruitDataUseCase: fetchRecruitDataUseCase + modifyRecruitUseCase: modifyRecruitUseCase ) let viewController = RecruitPostViewController(viewModel: viewModel) navigationController?.pushViewController(viewController, animated: true) diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift index 67c9fe2e2..526e11ed6 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift @@ -24,6 +24,9 @@ struct RecruitDataView: ActionBindableView { var id: Int? { viewModel.data?.id } + var data: RecruitData? { + viewModel.data + } // MARK: - Initializer init( diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index fe7cdfdd4..9d7d4f191 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -125,14 +125,12 @@ extension RecruitListHostingController { } private func showRecruitPost() { let recruitRepository = MockRecruitRepository() - let fetchRecruitDataUseCase = DefaultFetchRecruitDataUseCase(repository: recruitRepository) let postRecruitUseCase = DefaultPostRecruitUseCase(repository: recruitRepository) let modifyRecruitUseCase = DefaultModifyRecruitUseCase(repository: recruitRepository) let viewModel = RecruitPostViewModel( postType: .post, postRecruitUseCase: postRecruitUseCase, - modifyRecruitUseCase: modifyRecruitUseCase, - fetchRecruitDataUseCase: fetchRecruitDataUseCase + modifyRecruitUseCase: modifyRecruitUseCase ) let viewController = RecruitPostViewController(viewModel: viewModel) navigationController?.pushViewController(viewController, animated: true) diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift index 45950f739..897221ef3 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift @@ -295,7 +295,7 @@ extension RecruitPostViewController { extension RecruitPostViewController { - private func handlePostCompleted(id: Int) { // TODO: 화면전환 + private func handlePostCompleted(id: Int) { let repository = MockRecruitRepository() let fetchUseCase = DefaultFetchRecruitDataUseCase(repository: repository) let deleteUseCase = DefaultDeleteRecruitDataUseCase(repository: repository) diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift index 0d46d00b4..4c39cb4ca 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift @@ -12,7 +12,7 @@ final class RecruitPostViewModel: ViewModelProtocol { enum PostType { case post - case modify(id: Int) + case modify(data: RecruitData) } enum Input { @@ -32,7 +32,6 @@ final class RecruitPostViewModel: ViewModelProtocol { // MARK: - Properties private let postRecruitUseCase: PostRecruitUseCase private let modifyRecruitUseCase: ModifyRecruitUseCase - private let fetchRecruitDataUseCase: FetchRecruitDataUseCase private let outputSubject = PassthroughSubject() private var subscriptions: Set = [] @@ -44,12 +43,10 @@ final class RecruitPostViewModel: ViewModelProtocol { postType: PostType, postRecruitUseCase: PostRecruitUseCase, modifyRecruitUseCase: ModifyRecruitUseCase, - fetchRecruitDataUseCase: FetchRecruitDataUseCase ) { self.postType = postType self.postRecruitUseCase = postRecruitUseCase self.modifyRecruitUseCase = modifyRecruitUseCase - self.fetchRecruitDataUseCase = fetchRecruitDataUseCase } // MARK: - Public @@ -58,7 +55,7 @@ final class RecruitPostViewModel: ViewModelProtocol { guard let self else { return } switch input { case .viewDidLoad: - fetchFormIfNeeded() + applyDataIfModifying() case let .submit(form): submit(form) } @@ -70,26 +67,11 @@ final class RecruitPostViewModel: ViewModelProtocol { extension RecruitPostViewModel { - private func fetchFormIfNeeded() { - guard case let .modify(id) = postType else { + private func applyDataIfModifying() { + guard case let .modify(data) = postType else { return } - - Task { - outputSubject.send(.updateLoading(true)) - defer { - outputSubject.send(.updateLoading(false)) - } - - do { - let data = try await fetchRecruitDataUseCase.execute(id: id) - outputSubject.send(.updateForm(.init(from: data))) - } catch { - if let error = error as? ErrorResponse { - outputSubject.send(.showToast(error.message)) - } - } - } + outputSubject.send(.updateForm(.init(from: data))) } } @@ -107,9 +89,9 @@ extension RecruitPostViewModel { case .post: let id = try await postRecruitUseCase.execute(request: request) outputSubject.send(.postCompleted(id: id)) - case let .modify(id): - try await modifyRecruitUseCase.execute(id: id, request: request) - outputSubject.send(.modifyCompleted(id: id)) + case let .modify(data): + try await modifyRecruitUseCase.execute(id: data.id, request: request) + outputSubject.send(.modifyCompleted(id: data.id)) } } catch { outputSubject.send(.postFailed) From b47ba30039378b77993e1b5311a66ec2e833b7ce Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 13 Sep 2026 03:18:47 +0900 Subject: [PATCH 021/123] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KoinPickerDropDownViewDateDelegate.swift | 12 +++- .../KoinPickerDropDownViewTimeDelegate.swift | 1 - .../KoinPickerDropDownView.swift | 9 +++ .../Repository/MockRecruitRepository.swift | 31 +++++----- Koin/Domain/Model/Recruit/RecruitData.swift | 8 +-- Koin/Domain/Model/Recruit/RecruitList.swift | 3 +- .../Model/Recruit/RecruitPostRequest.swift | 32 +++++------ .../Domain/Model/Recruit/RecruitSummary.swift | 6 +- .../Subviews/RecruitDataMetadataView.swift | 4 +- .../Subviews/RecruitListRowView.swift | 2 +- .../RecruitPostViewController.swift | 56 ++++++++++--------- .../RecruitPost/RecruitPostViewModel.swift | 9 ++- .../RecruitPostRoleTableViewCell.swift | 8 +-- .../Subviews/RecruitPostScheduleView.swift | 41 +++++++++++++- .../Subviews/RecruitPostTextViewView.swift | 6 +- koin.xcodeproj/project.pbxproj | 2 - 16 files changed, 143 insertions(+), 87 deletions(-) diff --git a/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift b/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift index e35b2ef10..98052e036 100644 --- a/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift +++ b/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift @@ -20,6 +20,7 @@ final class KoinPickerDropDownViewDateDelegate { } } +// FIXME: 리팩토링 필요 extension KoinPickerDropDownViewDateDelegate: KoinPickerDropDownViewDelegate { func reset(koinPicker: KoinPickerDropDownView, initialDate: Date) { @@ -65,9 +66,16 @@ extension KoinPickerDropDownViewDateDelegate: KoinPickerDropDownViewDelegate { extension KoinPickerDropDownViewDateDelegate { - private func resetDates(range: Range) { + func resetDates(from startDate: Date, to endDate: Date) { + let interval = endDate.timeIntervalSince(startDate) + let intervalDay = Int(interval / 86400) + let range: Range = .init(1...intervalDay) + resetDates(range: range, startDate: startDate) + } + + private func resetDates(range: Range, startDate: Date? = nil) { let calendar = Calendar.current - let startDay = calendar.startOfDay(for: Date()) + let startDay = calendar.startOfDay(for: startDate ?? Date()) let availableDates = range.compactMap { calendar.date(byAdding: .day, value: $0, to: startDay) } diff --git a/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewTimeDelegate.swift b/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewTimeDelegate.swift index c923547fa..a6c28e292 100644 --- a/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewTimeDelegate.swift +++ b/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewTimeDelegate.swift @@ -22,7 +22,6 @@ final class KoinPickerDropDownViewTimeDelegate { } extension KoinPickerDropDownViewTimeDelegate: KoinPickerDropDownViewDelegate { - func reset(koinPicker: KoinPickerDropDownView, initialDate: Date) { let dateString = outputFormatter.string(from: initialDate) let selectedItem = dateString.components(separatedBy: "*") diff --git a/Koin/Core/View/UIKit/KoinPickerDropDownView/KoinPickerDropDownView.swift b/Koin/Core/View/UIKit/KoinPickerDropDownView/KoinPickerDropDownView.swift index 8efa05348..cc9ed5584 100644 --- a/Koin/Core/View/UIKit/KoinPickerDropDownView/KoinPickerDropDownView.swift +++ b/Koin/Core/View/UIKit/KoinPickerDropDownView/KoinPickerDropDownView.swift @@ -61,6 +61,15 @@ final class KoinPickerDropDownView: UIView { self.selectedItem = selectedItem pickerView.update(selectedItem: selectedItem) } + + func configure( + from startDate: Date, + to endDate: Date, + selectedDate: Date + ) { + (delegate as? KoinPickerDropDownViewDateDelegate)?.resetDates(from: startDate, to: endDate) + delegate.reset(koinPicker: self, initialDate: selectedDate) + } } // MARK: - KoinDropdownContentView diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 356a42d25..3630d5544 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -9,14 +9,17 @@ import Foundation final class MockRecruitRepository: RecruitRepository { func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy.MM.dd" + let contest = RecruitSummary( id: 1, category: .contest, title: "AI 아이디어 공모전 팀원 모집", meetingType: .online, - startDate: "2026.07.26", - endDate: "2026.08.07", - deadline: "2026.08.07", + startDate: dateFormatter.date(from: "2026.07.26") ?? Date(), + endDate: dateFormatter.date(from: "2026.08.26") ?? Date(), + deadline: dateFormatter.date(from: "2026.08.26") ?? Date(), dDay: "D-5", currentParticipants: 0, maximumParticipants: 3, @@ -33,9 +36,9 @@ final class MockRecruitRepository: RecruitRepository { category: .externalActivity, title: "2026 대외활동 팀원 모집", meetingType: .mixed, - startDate: "2026.07.26", - endDate: "2026.08.07", - deadline: "2026.08.07", + startDate: dateFormatter.date(from: "2026.07.26") ?? Date(), + endDate: dateFormatter.date(from: "2026.08.26") ?? Date(), + deadline: dateFormatter.date(from: "2026.08.26") ?? Date(), dDay: "D-1", currentParticipants: 2, maximumParticipants: 3, @@ -48,9 +51,9 @@ final class MockRecruitRepository: RecruitRepository { category: .externalActivity, title: "2026 대외활동 팀원 모집", meetingType: .mixed, - startDate: "2026.07.26", - endDate: "2026.08.07", - deadline: "2026.08.07", + startDate: dateFormatter.date(from: "2026.07.26") ?? Date(), + endDate: dateFormatter.date(from: "2026.08.26") ?? Date(), + deadline: dateFormatter.date(from: "2026.08.26") ?? Date(), dDay: "D-day", currentParticipants: 5, maximumParticipants: 5, @@ -64,9 +67,9 @@ final class MockRecruitRepository: RecruitRepository { category: .study, title: "2026 스터디 팀원 모집", meetingType: .mixed, - startDate: "2026.07.26", - endDate: "2026.08.07", - deadline: "2026.08.07", + startDate: dateFormatter.date(from: "2026.07.26") ?? Date(), + endDate: dateFormatter.date(from: "2026.08.26") ?? Date(), + deadline: dateFormatter.date(from: "2026.08.26") ?? Date(), dDay: "D-1", currentParticipants: 2, maximumParticipants: 3, @@ -89,6 +92,8 @@ final class MockRecruitRepository: RecruitRepository { guard let item = list.recruits.first(where: { $0.id == id }) else { throw NSError(domain: "MockRecruitRepository", code: 404) } + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy.MM.dd" return RecruitData( id: item.id, category: item.category, @@ -100,7 +105,7 @@ final class MockRecruitRepository: RecruitRepository { deadlineDate: item.deadline, currentParticipants: item.currentParticipants, maximumParticipants: item.maximumParticipants, - createdAt: "2026.02.02", + createdAt: dateFormatter.date(from: "2026.07.26") ?? Date(), author: "@@@", type: item.type, roles: item.roles, diff --git a/Koin/Domain/Model/Recruit/RecruitData.swift b/Koin/Domain/Model/Recruit/RecruitData.swift index 58b0bfaf9..a73884387 100644 --- a/Koin/Domain/Model/Recruit/RecruitData.swift +++ b/Koin/Domain/Model/Recruit/RecruitData.swift @@ -15,12 +15,12 @@ struct RecruitData: Identifiable, Equatable { let title: String let meetingType: RecruitMeetingType - let startDate: String - let endDate: String - let deadlineDate: String + let startDate: Date + let endDate: Date + let deadlineDate: Date let currentParticipants: Int let maximumParticipants: Int - let createdAt: String? + let createdAt: Date? let author: String? let type: RecruitRoleType diff --git a/Koin/Domain/Model/Recruit/RecruitList.swift b/Koin/Domain/Model/Recruit/RecruitList.swift index 15a831deb..5345da214 100644 --- a/Koin/Domain/Model/Recruit/RecruitList.swift +++ b/Koin/Domain/Model/Recruit/RecruitList.swift @@ -10,7 +10,7 @@ import Foundation struct RecruitList { var recruits: [RecruitSummary] - let totalCount: Int + var totalCount: Int let totalPage: Int let currentPage: Int } @@ -18,5 +18,6 @@ struct RecruitList { extension RecruitList { mutating func delete(id: Int) { recruits.removeAll(where: { $0.id == id }) + totalCount -= 1 } } diff --git a/Koin/Domain/Model/Recruit/RecruitPostRequest.swift b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift index 16a3895d8..ea660d4d0 100644 --- a/Koin/Domain/Model/Recruit/RecruitPostRequest.swift +++ b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift @@ -44,7 +44,7 @@ struct RecruitPostRequest { && startDate != nil && endDate != nil && deadline != nil - && description != nil + && !(description?.isEmpty ?? true) } } @@ -58,23 +58,19 @@ struct RecruitRoleRequest { } extension RecruitPostRequest { - init(from form: RecruitData) { - let dateFormatter = DateFormatter().then { - $0.dateFormat = "yyyy-MM-dd" - } - - self.category = form.category - self.title = form.title - self.meetingType = form.meetingType - self.startDate = dateFormatter.date(from: form.startDate) - self.endDate = dateFormatter.date(from: form.endDate) - self.deadline = dateFormatter.date(from: form.deadlineDate) - self.type = form.type - self.numberOfGeneralMembers = form.maximumParticipants - self.roles = form.roles.map { .init(from: $0) } - self.description = form.description - self.relatedUrl = form.relatedUrl?.absoluteString - self.qualification = form.qualification + init(from data: RecruitData) { + self.category = data.category + self.title = data.title + self.meetingType = data.meetingType + self.startDate = data.startDate + self.endDate = data.endDate + self.deadline = data.deadlineDate + self.type = data.type + self.numberOfGeneralMembers = data.maximumParticipants + self.roles = data.roles.map { .init(from: $0) } + self.description = data.description + self.relatedUrl = data.relatedUrl?.absoluteString + self.qualification = data.qualification } } diff --git a/Koin/Domain/Model/Recruit/RecruitSummary.swift b/Koin/Domain/Model/Recruit/RecruitSummary.swift index aa404c073..dda7b1c05 100644 --- a/Koin/Domain/Model/Recruit/RecruitSummary.swift +++ b/Koin/Domain/Model/Recruit/RecruitSummary.swift @@ -13,9 +13,9 @@ struct RecruitSummary: Identifiable, Equatable { let title: String let meetingType: RecruitMeetingType - let startDate: String - let endDate: String - let deadline: String + let startDate: Date + let endDate: Date + let deadline: Date let dDay: String let currentParticipants: Int diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataMetadataView.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataMetadataView.swift index 19422c5d6..b5d920dbf 100644 --- a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataMetadataView.swift +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataMetadataView.swift @@ -18,7 +18,7 @@ struct RecruitDataMetadataView: View { metadataRow( icon: .recruitDataCalendar, title: "활동 기간", - value: "\(data.startDate) ~ \(data.endDate)" + value: "\(data.startDate.formatDateToYYYYMMDD(separator: ".")) ~ \(data.endDate.formatDateToYYYYMMDD(separator: "."))" ) metadataRow( icon: .recruitDataMembers, @@ -28,7 +28,7 @@ struct RecruitDataMetadataView: View { metadataRow( icon: .recruitDataClock, title: "작성일", - value: data.createdAt ?? "-" + value: data.createdAt?.formatDateToYYYYMMDD(separator: ".") ?? "" ) metadataRow( icon: .recruitDataAuthor, diff --git a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift index d989e9889..d630eadc5 100644 --- a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift @@ -91,7 +91,7 @@ extension RecruitListRowView { HStack(alignment: .center, spacing: 2) { Image.appImage(asset: .recruitDate) - Text("\(model.startDate) ~ \(model.endDate)") + Text("\(model.startDate.formatDateToYYYYMMDD(separator: ".")) ~ \(model.endDate.formatDateToYYYYMMDD(separator: "."))") .font(.appFont(.pretendardRegular, size: 10)) .foregroundStyle(Color.appColor(.neutral500)) } diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift index 897221ef3..cec4ca58f 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift @@ -82,7 +82,6 @@ final class RecruitPostViewController: UIViewController { ) private let postButton = UIButton() - private let loadingIndicator = UIActivityIndicatorView(style: .medium) // MARK: - Dropdown private lazy var dropdownHost = KoinDropdownHost(scrollView: scrollView) @@ -158,17 +157,11 @@ extension RecruitPostViewController { .sink { [weak self] output in guard let self else { return } switch output { - case let .updateForm(request): - var formRequest = request - if formRequest.type == .roleBased && formRequest.roles.isEmpty { - formRequest.roles = [.init()] - } - self.request = formRequest - updateViews() + case let .updateForm(data): + updateForm(data) + updateViews(data) case let .updateLoading(isLoading): - isLoading ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating() - case .postFailed: - postButton.isUserInteractionEnabled = true + updateLoading(isLoading) case .postCompleted(let id): handlePostCompleted(id: id) case .modifyCompleted(let id): @@ -295,6 +288,15 @@ extension RecruitPostViewController { extension RecruitPostViewController { + private func updateLoading(_ isLoading: Bool) { + guard var configuration = postButton.configuration else { + return + } + configuration.showsActivityIndicator = isLoading + postButton.configuration = configuration + postButton.isUserInteractionEnabled = !isLoading + } + private func handlePostCompleted(id: Int) { let repository = MockRecruitRepository() let fetchUseCase = DefaultFetchRecruitDataUseCase(repository: repository) @@ -304,7 +306,7 @@ extension RecruitPostViewController { rootView: RecruitDataView(viewModel: viewModel), delegate: nil ) - navigationController?.replaceTopViewController(viewController, animated: true) + replaceTopViewController(viewController, animated: true) showToastMessage(message: completionMessage, bottomInset: 60) } @@ -332,23 +334,27 @@ extension RecruitPostViewController { postButton.backgroundColor = request.isValid ? UIColor.appColor(.new500) : UIColor.appColor(.neutral400) } - private func updateViews() { - categoryView.configure(category: request.category) - titleView.configure(text: request.title) - meetingTypeView.configure(meetingType: request.meetingType) + private func updateForm(_ data: RecruitData) { + request = .init(from: data) + } + + private func updateViews(_ data: RecruitData) { + categoryView.configure(category: data.category) + titleView.configure(text: data.title) + meetingTypeView.configure(meetingType: data.meetingType) scheduleView.configure( - startDate: request.startDate, - endDate: request.endDate, - deadlineDate: request.deadline + startDate: data.startDate, + endDate: data.endDate, + deadlineDate: data.deadlineDate ) roleView.configure( - roleType: request.type, - roles: request.roles, - numberOfGeneralMembers: request.numberOfGeneralMembers, + roleType: data.type, + roles: data.roles.map { RecruitRoleRequest(from: $0) }, + numberOfGeneralMembers: data.maximumParticipants, ) - descriptionView.configure(text: request.description ?? "") - relatedUrlView.configure(text: request.relatedUrl ?? "") - qualificationView.configure(text: request.qualification ?? "") + descriptionView.configure(text: data.description) + relatedUrlView.configure(text: data.relatedUrl?.absoluteString) + qualificationView.configure(text: data.qualification) } } diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift index 4c39cb4ca..9021d1eb7 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift @@ -21,9 +21,8 @@ final class RecruitPostViewModel: ViewModelProtocol { } enum Output { case updateLoading(Bool) - case updateForm(RecruitPostRequest) + case updateForm(RecruitData) - case postFailed case postCompleted(id: Int) case modifyCompleted(id: Int) case showToast(String) @@ -71,20 +70,21 @@ extension RecruitPostViewModel { guard case let .modify(data) = postType else { return } - outputSubject.send(.updateForm(.init(from: data))) + outputSubject.send(.updateForm(data)) } } extension RecruitPostViewModel { private func submit(_ request: RecruitPostRequest) { - outputSubject.send(.updateLoading(true)) Task { do { + outputSubject.send(.updateLoading(true)) defer { outputSubject.send(.updateLoading(false)) } + switch postType { case .post: let id = try await postRecruitUseCase.execute(request: request) @@ -94,7 +94,6 @@ extension RecruitPostViewModel { outputSubject.send(.modifyCompleted(id: data.id)) } } catch { - outputSubject.send(.postFailed) if let error = error as? ErrorResponse { outputSubject.send(.showToast(error.message)) } diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableViewCell.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableViewCell.swift index df12fde22..c15e9107c 100644 --- a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableViewCell.swift +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableViewCell.swift @@ -203,12 +203,8 @@ extension RecruitPostRoleTableViewCell { configuration.contentInsets = .zero button.configuration = configuration button.configurationUpdateHandler = { button in - if !button.isEnabled { - var configuration = button.configuration - let image = configuration?.image - configuration?.image?.withTintColor(.appColor(.neutral400)) - button.configuration = configuration - } + let image = button.configuration?.image + button.configuration?.image = image?.withTintColor(.appColor(button.isEnabled ? .neutral600 : .neutral400)) } } diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostScheduleView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostScheduleView.swift index 3d9e61043..98fb2e629 100644 --- a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostScheduleView.swift +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostScheduleView.swift @@ -67,7 +67,9 @@ final class RecruitPostScheduleView: UIView { startDateDropdownContentView.reset(initialDate: Date()) endDateDropdownContentView.reset(initialDate: Date()) deadlineDateDropdownContentView.reset(initialDate: Date()) - + + let today = Date() + configure(startDate: today, endDate: today, deadlineDate: today, updateLabelText: false) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") @@ -92,6 +94,43 @@ final class RecruitPostScheduleView: UIView { deadlineDateDropdownContentView.reset(initialDate: deadlineDate) } } + + func configure( + startDate: Date, + endDate: Date, + deadlineDate: Date, + updateLabelText: Bool = true + ) { + let date1970 = Date.init(timeIntervalSince1970: 0) + func dateAfterOneYaer(_ date: Date) -> Date { + let oneYear: Double = 365 * 24 * 60 * 60 + return date.addingTimeInterval(oneYear) + } + + if updateLabelText { + startDateLabel.text = startDate.formatDateToYYYYMMDD(separator: ".") + endDateLabel.text = endDate.formatDateToYYYYMMDD(separator: ".") + deadlineDateLabel.text = deadlineDate.formatDateToYYYYMMDD(separator: ".") + } + + startDateDropdownContentView.configure( + from: date1970, + to: dateAfterOneYaer(startDate), + selectedDate: startDate + ) + + endDateDropdownContentView.configure( + from: date1970, + to: dateAfterOneYaer(endDate), + selectedDate: endDate + ) + + deadlineDateDropdownContentView.configure( + from: date1970, + to: dateAfterOneYaer(deadlineDate), + selectedDate: deadlineDate + ) + } } extension RecruitPostScheduleView { diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextViewView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextViewView.swift index 9fb2a6fc9..c03b3cb09 100644 --- a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextViewView.swift +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextViewView.swift @@ -44,10 +44,10 @@ final class RecruitPostTextViewView: UIView { } // MARK: - Public - func configure(text: String) { + func configure(text: String?) { textView.text = text - placeholderLabel.isHidden = !text.isEmpty - headerView.updateCounter(current: text.count, limit: limit) + placeholderLabel.isHidden = !(text?.isEmpty ?? true) + headerView.updateCounter(current: text?.count ?? 0, limit: limit) } } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index dd63d057f..f819cd5eb 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -7408,7 +7408,6 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = koin/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "코인(stage)"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "이 앱은 복덕방 기능을 사용하기 위해 위치 정보가 필요합니다."; @@ -7444,7 +7443,6 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = koin/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "코인(stage)"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "이 앱은 복덕방 기능을 사용하기 위해 위치 정보가 필요합니다."; From 106e0ac6d38117aa3df0ce948357bc8d2021cec7 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 13 Sep 2026 03:42:11 +0900 Subject: [PATCH 022/123] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KoinPickerDropDownViewDateDelegate.swift | 11 +++++--- .../Model/Recruit/RecruitPostRequest.swift | 26 ++++++++++++------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift b/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift index 98052e036..bf3b9a630 100644 --- a/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift +++ b/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift @@ -67,9 +67,14 @@ extension KoinPickerDropDownViewDateDelegate: KoinPickerDropDownViewDelegate { extension KoinPickerDropDownViewDateDelegate { func resetDates(from startDate: Date, to endDate: Date) { - let interval = endDate.timeIntervalSince(startDate) - let intervalDay = Int(interval / 86400) - let range: Range = .init(1...intervalDay) + let calendar = Calendar.current + let startDay = calendar.startOfDay(for: startDate) + let endDay = calendar.startOfDay(for: endDate) + guard let dayDiff = calendar.dateComponents([.day], from: startDay, to: endDay).day, + 0 < dayDiff else { + return + } + let range: Range = .init(1...(dayDiff + 1)) resetDates(range: range, startDate: startDate) } diff --git a/Koin/Domain/Model/Recruit/RecruitPostRequest.swift b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift index ea660d4d0..e28623daa 100644 --- a/Koin/Domain/Model/Recruit/RecruitPostRequest.swift +++ b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift @@ -23,6 +23,20 @@ struct RecruitPostRequest { var qualification: String? var isValid: Bool { + guard let category, + let title, + !title.isEmpty, + let meetingType, + let startDate, + let endDate, + let deadline, + let description, + !description.isEmpty, + !(startDate < endDate), + !(endDate < deadline) else { + return false + } + switch type { case .roleBased: guard !roles.isEmpty else { @@ -33,18 +47,10 @@ struct RecruitPostRequest { return false } } + return true case .general: - guard roles.isEmpty, numberOfGeneralMembers != nil else { - return false - } + return roles.isEmpty } - return category != nil - && !(title?.isEmpty ?? true) - && meetingType != nil - && startDate != nil - && endDate != nil - && deadline != nil - && !(description?.isEmpty ?? true) } } From 633cbfe54ddbb361d42d1ecfc613fe6a123c8db2 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 13 Sep 2026 05:00:14 +0900 Subject: [PATCH 023/123] =?UTF-8?q?add:=20ImageAsset=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Asset/ImageAsset.swift | 2 ++ .../Contents.json | 12 ++++++++++++ .../recruitProfileMyApplications.svg | 8 ++++++++ .../recruitProfileMyPosts.imageset/Contents.json | 12 ++++++++++++ .../recruitProfileMyPosts.svg | 6 ++++++ 5 files changed, 40 insertions(+) create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplications.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplications.imageset/recruitProfileMyApplications.svg create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPosts.imageset/Contents.json create mode 100644 Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPosts.imageset/recruitProfileMyPosts.svg diff --git a/Koin/Core/Asset/ImageAsset.swift b/Koin/Core/Asset/ImageAsset.swift index a0c090efd..dd5d1a5bc 100644 --- a/Koin/Core/Asset/ImageAsset.swift +++ b/Koin/Core/Asset/ImageAsset.swift @@ -237,6 +237,8 @@ public enum ImageAsset: String { case recruitPostMeetingTypeOnline case recruitPostRolePlus case recruitProfile + case recruitProfileMyApplications + case recruitProfileMyPosts case recruitSearch // MARK: - Shop diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplications.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplications.imageset/Contents.json new file mode 100644 index 000000000..857086f12 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplications.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitProfileMyApplications.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplications.imageset/recruitProfileMyApplications.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplications.imageset/recruitProfileMyApplications.svg new file mode 100644 index 000000000..c175505bc --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplications.imageset/recruitProfileMyApplications.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPosts.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPosts.imageset/Contents.json new file mode 100644 index 000000000..7e9beb723 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPosts.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitProfileMyPosts.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPosts.imageset/recruitProfileMyPosts.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPosts.imageset/recruitProfileMyPosts.svg new file mode 100644 index 000000000..347d468b5 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPosts.imageset/recruitProfileMyPosts.svg @@ -0,0 +1,6 @@ + + + + + + From db2eac0d08803d0b0d94ff90cc9877c9deb50952 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 13 Sep 2026 05:37:19 +0900 Subject: [PATCH 024/123] =?UTF-8?q?design:=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/MockRecruitRepository.swift | 40 +++++++++- .../Domain/Model/Recruit/RecruitProfile.swift | 18 +++++ .../Recruit/RecruitProfileActivity.swift | 17 ++++ .../Domain/Repository/RecruitRepository.swift | 1 + .../FetchMyProfileUseCase.swift | 24 ++++++ .../Recruit/RecruitData/RecruitDataView.swift | 6 +- .../RecruitListHostingController.swift | 9 ++- .../RecruitProfileHostingController.swift | 66 ++++++++++++++++ .../RecruitProfile/RecruitProfileView.swift | 77 +++++++++++++++++++ .../RecruitProfileViewModel.swift | 70 +++++++++++++++++ .../Subviews/RecruitProfileActionButton.swift | 37 +++++++++ .../RecruitProfileActionCardView.swift | 63 +++++++++++++++ .../Subviews/RecruitProfileCardView.swift | 59 ++++++++++++++ .../RecruitProfileEmptyCardView.swift | 49 ++++++++++++ koin.xcodeproj/project.pbxproj | 64 +++++++++++++++ 15 files changed, 592 insertions(+), 8 deletions(-) create mode 100644 Koin/Domain/Model/Recruit/RecruitProfile.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitProfileActivity.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileActionButton.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileActionCardView.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileCardView.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileEmptyCardView.swift diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 3630d5544..2f70dc1aa 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -8,6 +8,45 @@ import Foundation final class MockRecruitRepository: RecruitRepository { + func fetchMyProfile() async throws -> RecruitProfile { + guard UserDataManager.shared.isLoggedIn else { + throw ErrorResponse( + statusCode: 401, + code: "UNAUTHORIZED", + message: "로그인이 필요한 기능입니다." + ) + } + + try await Task.sleep(nanoseconds: 300_000_000) + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy.MM.dd" + + guard let startedAt = dateFormatter.date(from: "2025.03.03"), + let endedAt = dateFormatter.date(from: "2025.05.05") else { + throw ErrorResponse.dateFormatterFailedConvert + } + + return RecruitProfile( + nickname: "홍길동", + department: "컴퓨터공학부", + studentNumber: "2023100000", + preferredRole: "기획", + skills: ["정보처리기사"], + activities: [ + RecruitProfileActivity( + id: 1, + title: "AI 공모전", + startedAt: startedAt, + endedAt: endedAt, + isOngoing: false, + description: "기획 담당" + ) + ], + selfIntroduction: "안녕하세요." + ) + } + func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy.MM.dd" @@ -199,4 +238,3 @@ final class MockRecruitRepository: RecruitRepository { } } - diff --git a/Koin/Domain/Model/Recruit/RecruitProfile.swift b/Koin/Domain/Model/Recruit/RecruitProfile.swift new file mode 100644 index 000000000..80ea9d67c --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitProfile.swift @@ -0,0 +1,18 @@ +// +// RecruitProfile.swift +// koin +// +// Created by Codex on 9/13/26. +// + +import Foundation + +struct RecruitProfile { + let nickname: String + let department: String + let studentNumber: String + let preferredRole: String + let skills: [String] + let activities: [RecruitProfileActivity] + let selfIntroduction: String +} diff --git a/Koin/Domain/Model/Recruit/RecruitProfileActivity.swift b/Koin/Domain/Model/Recruit/RecruitProfileActivity.swift new file mode 100644 index 000000000..b2ffc279e --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitProfileActivity.swift @@ -0,0 +1,17 @@ +// +// RecruitProfileActivity.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import Foundation + +struct RecruitProfileActivity: Identifiable, Equatable { + let id: Int + let title: String + let startedAt: Date + let endedAt: Date? + let isOngoing: Bool + let description: String +} diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index c43ee00da..fd4f6bd89 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -18,4 +18,5 @@ protocol RecruitRepository { func modify(_ id: Int, _ request: RecruitPostRequest) async throws -> Void func fetchData(_ id: Int) async throws -> RecruitData func deleteData(id: Int) async throws -> Bool + func fetchMyProfile() async throws -> RecruitProfile } diff --git a/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift new file mode 100644 index 000000000..16ea191a0 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift @@ -0,0 +1,24 @@ +// +// FetchMyProfileUseCase.swift +// koin +// +// Created by Codex on 9/13/26. +// + +import Foundation + +protocol FetchMyProfileUseCase { + func execute() async throws -> RecruitProfile +} + +final class DefaultFetchMyProfileUseCase: FetchMyProfileUseCase { + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute() async throws -> RecruitProfile { + try await repository.fetchMyProfile() + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift index 526e11ed6..75c223f17 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift @@ -77,8 +77,6 @@ extension RecruitDataView { sendAction(.showLoginToast) return } - let id = data.id - if data.isAuthor { sendAction(.showApplicant) } else if data.canApply { @@ -87,11 +85,9 @@ extension RecruitDataView { } private func handleDelete() { - guard let data = viewModel.data, - viewModel.didDelete else { + guard viewModel.didDelete else { return } - let id = data.id sendAction(.showToast(message: "삭제되었습니다")) sendAction(.didDelete) } diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index 9d7d4f191..f30d8969e 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -121,7 +121,13 @@ extension RecruitListHostingController { navigationController?.pushViewController(viewController, animated: true) } private func showRecruitProfile() { - // TODO + let repository = MockRecruitRepository() + let fetchMyProfileUseCase = DefaultFetchMyProfileUseCase(repository: repository) + let viewModel = RecruitProfileViewModel(fetchMyProfileUseCase: fetchMyProfileUseCase) + let viewController = RecruitProfileHostingController( + rootView: RecruitProfileView(viewModel: viewModel) + ) + navigationController?.pushViewController(viewController, animated: true) } private func showRecruitPost() { let recruitRepository = MockRecruitRepository() @@ -214,4 +220,3 @@ extension RecruitListHostingController { } } } - diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift new file mode 100644 index 000000000..ae4a385c9 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift @@ -0,0 +1,66 @@ +// +// RecruitProfileHostingController.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import SwiftUI + +final class RecruitProfileHostingController: UIHostingController, HostingControllerProtocol { + + // MARK: - Initializer + override init(rootView: RecruitProfileView) { + super.init(rootView: rootView) + bindAction(to: rootView) + } + + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + title = "팀원 모집 프로필" + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar(style: .newBackground) + } + + // MARK: - Execute + func execute(action: RecruitProfileView.Action) { + switch action { + case .showProfilePost: + navigateToProfilePost() + case .showProfileModify(let profile): + navigateToProfileModify(profile: profile) + case .showMyPosts: + navigateToMyPosts() + case .showMyApplications: + navigateToMyApplications() + case let .showToast(message): + showToastMessage(message: message) + } + } +} + +extension RecruitProfileHostingController { + private func navigateToProfilePost() { + // TODO: navigate + } + + private func navigateToProfileModify(profile: RecruitProfile) { + // TODO: navigate + } + + private func navigateToMyPosts() { + // TODO: navigate + } + + private func navigateToMyApplications() { + // TODO: navigate + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift new file mode 100644 index 000000000..97ed11eb4 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift @@ -0,0 +1,77 @@ +// +// RecruitProfileView.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import SwiftUI + +struct RecruitProfileView: ActionBindableView { + enum Action { + case showProfilePost + case showProfileModify(profile: RecruitProfile) + case showMyPosts + case showMyApplications + case showToast(message: String) + } + + // MARK: - Properties + var sendAction: (Action) -> Void = { _ in } + @State private var viewModel: RecruitProfileViewModel + + // MARK: - Initializer + init(viewModel: RecruitProfileViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + var body: some View { + ScrollView { + if viewModel.didLoad { + VStack(spacing: 24) { + if let profile = viewModel.profile { + RecruitProfileCardView(profile: profile) { + sendAction(.showProfileModify(profile: profile)) + } + } else { + RecruitProfileEmptyCardView { + sendAction(.showProfilePost) + } + } + + RecruitProfileActionCardView( + icon: .recruitProfileMyPosts, + title: viewModel.profile == nil ? "내가 작성한 모집글 모아보기" : "내가 작성한 모집글", + description: "작성자 모집글과 지원자를 한눈에 확인할 수 있어요." + ) { + sendAction(.showMyPosts) + } + + RecruitProfileActionCardView( + icon: .recruitProfileMyApplications, + title: viewModel.profile == nil ? "내가 지원한 모집글 모아보기" : "내가 지원한 모집글", + description: "지원한 모집글과 지원 상태를 확인 할 수 있어요." + ) { + sendAction(.showMyPosts) + } + } + .padding(.top, 16) + .padding(.horizontal, 24) + .frame(maxWidth: .infinity) + } else { + Color.appColor(.newBackground) + } + } + .background(Color.appColor(.newBackground).ignoresSafeArea()) + .loadingOverlay(viewModel.isLoading) + .onAppear { + viewModel.execute(.didAppear) + } + .onChange(of: viewModel.errorMessage) { + guard let message = viewModel.errorMessage else { return } + sendAction(.showToast(message: message)) + viewModel.execute(.didShowToast) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift new file mode 100644 index 000000000..9ceeb161e --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift @@ -0,0 +1,70 @@ +// +// RecruitProfileViewModel.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import Foundation +import Observation + +@MainActor +@Observable +final class RecruitProfileViewModel: SwiftUIViewModelProtocol { + enum Input { + case didAppear + case didShowToast + } + + // MARK: - State + private(set) var profile: RecruitProfile? + private(set) var isLoading = false + private(set) var didLoad = false + private(set) var errorMessage: String? + + // MARK: - UseCase + private let fetchMyProfileUseCase: FetchMyProfileUseCase + + // MARK: - Initializer + init(fetchMyProfileUseCase: FetchMyProfileUseCase) { + self.fetchMyProfileUseCase = fetchMyProfileUseCase + } + + // MARK: - Public + func execute(_ input: Input) { + switch input { + case .didAppear: + fetchMyProfile() + case .didShowToast: + errorMessage = nil + } + } +} + +extension RecruitProfileViewModel { + private func fetchMyProfile() { + guard !isLoading else { return } + + Task { + isLoading = true + defer { + isLoading = false + } + + do { + profile = try await fetchMyProfileUseCase.execute() + didLoad = true + } catch { + guard let error = error as? ErrorResponse else { + return + } + if error.statusCode == 404 { + profile = nil + didLoad = true + } else { + errorMessage = error.message + } + } + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileActionButton.swift b/Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileActionButton.swift new file mode 100644 index 000000000..d02a783be --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileActionButton.swift @@ -0,0 +1,37 @@ +// +// RecruitProfileActionButton.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import SwiftUI + +struct RecruitProfileActionButton: View { + + let title: String + let action: ()->Void + + init( + title: String, + action: @escaping () -> Void + ) { + self.title = title + self.action = action + } + + var body: some View { + Button { + action() + } label: { + Text(title) + .font(.appFont(.pretendardRegular, size: 14)) + .foregroundStyle(Color.appColor(.new500)) + .frame(maxWidth: .infinity) + .frame(height: 40) + .border(.appColor(.new500), width: 1, radius: 16) + .contentShape(RoundedRectangle(cornerRadius: 16)) + } + .buttonStyle(.plain) + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileActionCardView.swift b/Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileActionCardView.swift new file mode 100644 index 000000000..b4cdbb635 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileActionCardView.swift @@ -0,0 +1,63 @@ +// +// RecruitProfileActionCardView.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import SwiftUI + +struct RecruitProfileActionCardView: View { + + let icon: ImageAsset + let title: String + let description: String + let action: ()->Void + + init( + icon: ImageAsset, + title: String, + description: String, + action: @escaping ()->Void + ) { + self.icon = icon + self.title = title + self.description = description + self.action = action + } + + var body: some View { + Button { + action() + } label: { + HStack(spacing: 12) { + Image.appImage(asset: icon) + .frame(width: 40, height: 40, alignment: .center) + .border(.appColor(.neutral400), width: 1, radius: 20) + + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.appFont(.pretendardSemiBold, size: 14)) + .foregroundStyle(Color.appColor(.neutral800)) + .frame(minHeight: 22) + + Text(description) + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral500)) + .multilineTextAlignment(.leading) + .linespacing(fontSize: 12, percent: 160) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image.appImage(asset: .newChevronRight) + .frame(width: 24, height: 24) + } + .padding(20) + .frame(maxWidth: .infinity, minHeight: 108) + .background(Color.appColor(.neutral0)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .contentShape(RoundedRectangle(cornerRadius: 16)) + } + .buttonStyle(.plain) + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileCardView.swift b/Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileCardView.swift new file mode 100644 index 000000000..aa0d2dba4 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileCardView.swift @@ -0,0 +1,59 @@ +// +// RecruitProfileCardView.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import SwiftUI + +struct RecruitProfileCardView: View { + + let profile: RecruitProfile + let action: ()->Void + + init( + profile: RecruitProfile, + action: @escaping ()->Void + ) { + self.profile = profile + self.action = action + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image.appImage(asset: .profileHuman) + .frame(width: 40, height: 40, alignment: .center) + .border(.appColor(.neutral400), width: 1, radius: 20) + + VStack(alignment: .leading, spacing: 8) { + Text(profile.nickname) + .font(.appFont(.pretendardSemiBold, size: 16)) + .foregroundStyle(Color.appColor(.neutral800)) + .frame(minHeight: 22) + + VStack(alignment: .leading, spacing: 0) { + Text(" • " + profile.department) + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral500)) + .frame(minHeight: 19) + + Text(" • " + profile.studentNumber) + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral500)) + .frame(minHeight: 19) + } + + RecruitProfileActionButton( + title: "프로필 수정하기", + action: action + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(Color.appColor(.neutral0)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileEmptyCardView.swift b/Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileEmptyCardView.swift new file mode 100644 index 000000000..6a290418a --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfile/Subviews/RecruitProfileEmptyCardView.swift @@ -0,0 +1,49 @@ +// +// RecruitProfileCardView.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import SwiftUI + +struct RecruitProfileEmptyCardView: View { + + let action: ()->Void + + init(action: @escaping () -> Void) { + self.action = action + } + + var body: some View { + VStack(spacing: 0) { + Image.appImage(asset: .sleepBcsdSymbol) + .resizable() + .scaledToFit() + .frame(width: 85, height: 66) + .padding(.bottom, 4) + + Text("아직 팀원 모집 프로필을 작성하지 않았어요.") + .font(.appFont(.pretendardSemiBold, size: 16)) + .foregroundStyle(Color.appColor(.neutral800)) + .multilineTextAlignment(.center) + .frame(minHeight: 26) + .padding(.bottom, 12) + + Text("프로필을 작성하면 지원 시 더 빠르고 편리하게 활동할 수 있어요.") + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral500)) + .multilineTextAlignment(.center) + .frame(minHeight: 19) + .padding(.bottom, 12) + + RecruitProfileActionButton( + title: "프로필 작성하기", + action: action + ) + } + .padding(20) + .background(Color.appColor(.neutral0)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index f819cd5eb..6c22e24be 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -464,6 +464,14 @@ 7CDCD0B73055863E004204DA /* RecruitDataRelatedUrlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0B63055863E004204DA /* RecruitDataRelatedUrlView.swift */; }; 7CDCD0BB30559234004204DA /* RecruitDataPopUpViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0BA30559234004204DA /* RecruitDataPopUpViewController.swift */; }; 7CDCD0BD30559E70004204DA /* DeleteRecruitDataUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0BC30559E70004204DA /* DeleteRecruitDataUseCase.swift */; }; + 7CDCD0C93055D639004204DA /* RecruitProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0C83055D639004204DA /* RecruitProfileView.swift */; }; + 7CDCD0CB3055D63F004204DA /* RecruitProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0CA3055D63F004204DA /* RecruitProfileViewModel.swift */; }; + 7CDCD0CD3055D64A004204DA /* RecruitProfileHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0CC3055D64A004204DA /* RecruitProfileHostingController.swift */; }; + 7CDCD0D03055E938004204DA /* RecruitProfileActionCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0CF3055E938004204DA /* RecruitProfileActionCardView.swift */; }; + 7CDCD0D23055E9E4004204DA /* RecruitProfileCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0D13055E9E4004204DA /* RecruitProfileCardView.swift */; }; + 7CDCD0D43055EA07004204DA /* RecruitProfileEmptyCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0D33055EA07004204DA /* RecruitProfileEmptyCardView.swift */; }; + 7CDCD0D63055EA5B004204DA /* RecruitProfileActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0D53055EA5B004204DA /* RecruitProfileActionButton.swift */; }; + 7CDCD0D83055EECF004204DA /* RecruitProfileActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0D73055EECF004204DA /* RecruitProfileActivity.swift */; }; 7CEB3B4B2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */; }; 7CED90BC2EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FD02EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift */; }; 7CED90BD2EF2D1F900457128 /* ManageNoticeKeywordViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F612EF2D1F900457128 /* ManageNoticeKeywordViewController.swift */; }; @@ -825,6 +833,8 @@ B6A0D62A2EEB01E600A19521 /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = B6A0D6292EEB01E600A19521 /* Debug.xcconfig */; }; B6A0D62C2EEB01F100A19521 /* Release.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = B6A0D62B2EEB01F100A19521 /* Release.xcconfig */; }; BAD01845AA334A648E2FA2BD /* RecruitDataHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBC0EF8E0FE44F48744BEC7 /* RecruitDataHeaderView.swift */; }; + C0DE00000000000000000001 /* RecruitProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000002 /* RecruitProfile.swift */; }; + C0DE00000000000000000003 /* FetchMyProfileUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000004 /* FetchMyProfileUseCase.swift */; }; C1838F7A4424DEA14DB2D107 /* RecruitPostCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */; }; CD3000000000000000000001 /* RecruitPostCategoryDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3000000000000000000002 /* RecruitPostCategoryDropdownView.swift */; }; D0A71C4F2F1B8340009E2D71 /* KoinDropdownHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */; }; @@ -1546,6 +1556,14 @@ 7CDCD0B63055863E004204DA /* RecruitDataRelatedUrlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataRelatedUrlView.swift; sourceTree = ""; }; 7CDCD0BA30559234004204DA /* RecruitDataPopUpViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataPopUpViewController.swift; sourceTree = ""; }; 7CDCD0BC30559E70004204DA /* DeleteRecruitDataUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteRecruitDataUseCase.swift; sourceTree = ""; }; + 7CDCD0C83055D639004204DA /* RecruitProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileView.swift; sourceTree = ""; }; + 7CDCD0CA3055D63F004204DA /* RecruitProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileViewModel.swift; sourceTree = ""; }; + 7CDCD0CC3055D64A004204DA /* RecruitProfileHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileHostingController.swift; sourceTree = ""; }; + 7CDCD0CF3055E938004204DA /* RecruitProfileActionCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileActionCardView.swift; sourceTree = ""; }; + 7CDCD0D13055E9E4004204DA /* RecruitProfileCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileCardView.swift; sourceTree = ""; }; + 7CDCD0D33055EA07004204DA /* RecruitProfileEmptyCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileEmptyCardView.swift; sourceTree = ""; }; + 7CDCD0D53055EA5B004204DA /* RecruitProfileActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileActionButton.swift; sourceTree = ""; }; + 7CDCD0D73055EECF004204DA /* RecruitProfileActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileActivity.swift; sourceTree = ""; }; 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteDeviceTokenUseCase.swift; sourceTree = ""; }; 7CED8EC32EF2D1F900457128 /* ShopCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCollectionView.swift; sourceTree = ""; }; 7CED8EC42EF2D1F900457128 /* ShopCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCollectionViewCell.swift; sourceTree = ""; }; @@ -1911,6 +1929,8 @@ B68D2CEA2EA50C1E00F3B479 /* UIViewController+Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Toast.swift"; sourceTree = ""; }; B6A0D6292EEB01E600A19521 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; B6A0D62B2EEB01F100A19521 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + C0DE00000000000000000002 /* RecruitProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfile.swift; sourceTree = ""; }; + C0DE00000000000000000004 /* FetchMyProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMyProfileUseCase.swift; sourceTree = ""; }; CD3000000000000000000002 /* RecruitPostCategoryDropdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostCategoryDropdownView.swift; sourceTree = ""; }; D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdownHost.swift; sourceTree = ""; }; D19A00013000000000000001 /* NotificationRowModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRowModel.swift; sourceTree = ""; }; @@ -2743,6 +2763,7 @@ 7CACF86130436C1900AC2828 /* RecruitNotificationList */, 7CACF873304441AA00AC2828 /* RecruitPost */, 7CDCD0AE30556C89004204DA /* RecruitData */, + 7CDCD0C43055D58B004204DA /* RecruitProfile */, ); path = Recruit; sourceTree = ""; @@ -2773,6 +2794,8 @@ 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */, 7CAC2DDB304C34E900AC2828 /* RecruitPostRequest.swift */, 7C50A1D63041F91400897A6F /* RecruitData.swift */, + C0DE00000000000000000002 /* RecruitProfile.swift */, + 7CDCD0D73055EECF004204DA /* RecruitProfileActivity.swift */, ); path = Recruit; sourceTree = ""; @@ -2824,6 +2847,7 @@ isa = PBXGroup; children = ( A1D000010000000000000003 /* RecruitData */, + C0DE00000000000000000005 /* RecruitProfile */, C1309DAEB0F857F481B1A4B0 /* RecruitPost */, 7CACF86C3044096200AC2828 /* RecruitList */, 7CACF86E3044097800AC2828 /* RecruitNotificationList */, @@ -3689,6 +3713,28 @@ path = Subviews; sourceTree = ""; }; + 7CDCD0C43055D58B004204DA /* RecruitProfile */ = { + isa = PBXGroup; + children = ( + 7CDCD0CE3055E929004204DA /* Subviews */, + 7CDCD0CC3055D64A004204DA /* RecruitProfileHostingController.swift */, + 7CDCD0C83055D639004204DA /* RecruitProfileView.swift */, + 7CDCD0CA3055D63F004204DA /* RecruitProfileViewModel.swift */, + ); + path = RecruitProfile; + sourceTree = ""; + }; + 7CDCD0CE3055E929004204DA /* Subviews */ = { + isa = PBXGroup; + children = ( + 7CDCD0D13055E9E4004204DA /* RecruitProfileCardView.swift */, + 7CDCD0D33055EA07004204DA /* RecruitProfileEmptyCardView.swift */, + 7CDCD0D53055EA5B004204DA /* RecruitProfileActionButton.swift */, + 7CDCD0CF3055E938004204DA /* RecruitProfileActionCardView.swift */, + ); + path = Subviews; + sourceTree = ""; + }; 7CED8EC52EF2D1F900457128 /* ShopCollectionView */ = { isa = PBXGroup; children = ( @@ -5077,6 +5123,14 @@ path = Configuration; sourceTree = ""; }; + C0DE00000000000000000005 /* RecruitProfile */ = { + isa = PBXGroup; + children = ( + C0DE00000000000000000004 /* FetchMyProfileUseCase.swift */, + ); + path = RecruitProfile; + sourceTree = ""; + }; C1309DAEB0F857F481B1A4B0 /* RecruitPost */ = { isa = PBXGroup; children = ( @@ -6208,6 +6262,9 @@ D215726A2CEDDE500061E725 /* FetchSemesterUseCase.swift in Sources */, D27DD0D32BA608310081FD36 /* SceneDelegate.swift in Sources */, 7C0C189F2F68AB33001781B3 /* CallVanChatRequest.swift in Sources */, + 7CDCD0CB3055D63F004204DA /* RecruitProfileViewModel.swift in Sources */, + C0DE00000000000000000001 /* RecruitProfile.swift in Sources */, + C0DE00000000000000000003 /* FetchMyProfileUseCase.swift in Sources */, D2D7C0DD2C282E2700C85A85 /* DefaultLandRepository.swift in Sources */, 7CEB3B4B2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift in Sources */, 7CC42B132F56AB9D00940CE1 /* CallVanListCollectionView.swift in Sources */, @@ -6351,6 +6408,7 @@ A1D000010000000000000001 /* FetchRecruitDataUseCase.swift in Sources */, D20AB95A2C55369F006BC684 /* NotiAPI.swift in Sources */, 7C457AE72FCDE7240011D338 /* SwiftUIViewModelProtocol.swift in Sources */, + 7CDCD0D43055EA07004204DA /* RecruitProfileEmptyCardView.swift in Sources */, 833D9C392C7058C300982145 /* NoticeListRepository.swift in Sources */, D27DD0E22BA609740081FD36 /* MenuDto.swift in Sources */, D27A5D9C2C6BE56500C4275F /* FetchShopListRequest.swift in Sources */, @@ -6630,6 +6688,7 @@ 7CED91752EF2D1F900457128 /* ShopReviewReportViewController.swift in Sources */, 7CED91782EF2D1F900457128 /* DiningOperatingTimeHeaderView.swift in Sources */, 7C457B2B2FCE1BA80011D338 /* DiningView.swift in Sources */, + 7CDCD0D03055E938004204DA /* RecruitProfileActionCardView.swift in Sources */, 7C8A00072FD200000011D338 /* DiningHeaderButton.swift in Sources */, 7C8A00092FD200000011D338 /* DiningCarousel.swift in Sources */, 7C8A000B2FD200000011D338 /* DiningMenuCard.swift in Sources */, @@ -6799,6 +6858,7 @@ 7C50A1E23042C82F00897A6F /* RecruitRoleType.swift in Sources */, 7C6790932FCC48A60045E163 /* UICollectionView+.swift in Sources */, 7C6790942FCC48A60045E163 /* UIViewcontroller+Alert.swift in Sources */, + 7CDCD0D83055EECF004204DA /* RecruitProfileActivity.swift in Sources */, 7CDCD0B430556CC2004204DA /* RecruitDataHostingController.swift in Sources */, 7C6790952FCC48A60045E163 /* UIButton+.swift in Sources */, 7C50A1D13041F8BF00897A6F /* RecruitState.swift in Sources */, @@ -6869,10 +6929,12 @@ 7C0C18992F68AA2B001781B3 /* CallVanChatDto.swift in Sources */, 7CED91F82EF2D1F900457128 /* TimetableViewModel.swift in Sources */, 7CC42B3E2F59600200940CE1 /* CallVanNotificationDropdownViewController.swift in Sources */, + 7CDCD0D63055EA5B004204DA /* RecruitProfileActionButton.swift in Sources */, 7CED91F92EF2D1F900457128 /* LandOptionCollectionView.swift in Sources */, 7CED91FA2EF2D1F900457128 /* DeliveryTipsCollectionViewCell.swift in Sources */, 7CED91FC2EF2D1F900457128 /* DiningCollectionViewCellA.swift in Sources */, 7CED91FE2EF2D1F900457128 /* LostArticleImageCollectionViewCell.swift in Sources */, + 7CDCD0CD3055D64A004204DA /* RecruitProfileHostingController.swift in Sources */, 7CED91FF2EF2D1F900457128 /* FoundIdViewController.swift in Sources */, 7CED92002EF2D1F900457128 /* MyKeywordCollectionView.swift in Sources */, 7CED92022EF2D1F900457128 /* ShopSummaryMenuGroupCollectionViewCell.swift in Sources */, @@ -6970,6 +7032,7 @@ 7C333E9330301B3D009D2B89 /* KoinModalViewController.swift in Sources */, D20AD6EF2E029B250067760A /* CheckVerificationEmailRequest.swift in Sources */, D261BB982D79418600A67F06 /* UserDataManager.swift in Sources */, + 7CDCD0D23055E9E4004204DA /* RecruitProfileCardView.swift in Sources */, D20AB96F2C574291006BC684 /* BottomSheetViewController.swift in Sources */, D22C54812C6A5957000826DA /* FileUploadResponse.swift in Sources */, D2D380FD2BA7415C009AD5A9 /* ErrorResponseDto.swift in Sources */, @@ -7009,6 +7072,7 @@ D27DD0CA2BA608310081FD36 /* DiningDto.swift in Sources */, D20AB94E2C553427006BC684 /* NotiRepository.swift in Sources */, 833D9C3E2C705E9300982145 /* FetchNoticeArticlesRequest.swift in Sources */, + 7CDCD0C93055D639004204DA /* RecruitProfileView.swift in Sources */, 7C016574300BD8AF0013DD7B /* FetchDepartmentCategoryUseCase.swift in Sources */, 7C3ECC7A2F41505000EE8F13 /* ErrorResponse.swift in Sources */, D2FB23552BFD49520098BA2B /* ShopRepository.swift in Sources */, From b61b807c70533044fe19a7d92a48a8aaa0524f4a Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 13 Sep 2026 07:01:45 +0900 Subject: [PATCH 025/123] =?UTF-8?q?chore:=20=EC=82=AC=EC=86=8C=ED=95=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Extensions/SwiftUI/Image+.swift | 2 +- Koin/Domain/Model/Recruit/RecruitProfile.swift | 2 +- .../UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift | 2 +- .../RecruitPost/Subviews/RecruitPostRoleTableViewCell.swift | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Koin/Core/Extensions/SwiftUI/Image+.swift b/Koin/Core/Extensions/SwiftUI/Image+.swift index 89f2231df..aabd913ad 100644 --- a/Koin/Core/Extensions/SwiftUI/Image+.swift +++ b/Koin/Core/Extensions/SwiftUI/Image+.swift @@ -2,7 +2,7 @@ // Image+.swift // Koin // -// Created by Claude on 5/31/26. +// Created by 홍기정 on 5/31/26. // import SwiftUI diff --git a/Koin/Domain/Model/Recruit/RecruitProfile.swift b/Koin/Domain/Model/Recruit/RecruitProfile.swift index 80ea9d67c..0ac3335ce 100644 --- a/Koin/Domain/Model/Recruit/RecruitProfile.swift +++ b/Koin/Domain/Model/Recruit/RecruitProfile.swift @@ -2,7 +2,7 @@ // RecruitProfile.swift // koin // -// Created by Codex on 9/13/26. +// Created by 홍기정 on 9/13/26. // import Foundation diff --git a/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift index 16ea191a0..21fd349af 100644 --- a/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift +++ b/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift @@ -2,7 +2,7 @@ // FetchMyProfileUseCase.swift // koin // -// Created by Codex on 9/13/26. +// Created by 홍기정 on 9/13/26. // import Foundation diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableViewCell.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableViewCell.swift index c15e9107c..7bdcd4bc3 100644 --- a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableViewCell.swift +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableViewCell.swift @@ -2,7 +2,7 @@ // RecruitPostRoleTableViewCell.swift // koin // -// Created by Codex on 9/11/26. +// Created by 홍기정 on 9/11/26. // import UIKit From d1e07045dd78a17ffaf96e02fe56047ecf7b53ec Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 21 Sep 2026 13:21:05 +0900 Subject: [PATCH 026/123] =?UTF-8?q?design:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=ED=94=84=EB=A1=9C=ED=95=84=201=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Domain/Model/Recruit/BasicInfo.swift | 28 ++ .../RecruitProfileActivityRequest.swift | 37 +++ .../Model/Recruit/RecruitProfileRequest.swift | 16 + .../Timetable/MockFetchDeptListUseCase.swift | 25 ++ .../RecruitDataHostingController.swift | 2 +- .../RecruitListHostingController.swift | 2 +- .../RecruitPostViewController.swift | 14 +- .../RecruitPost/RecruitPostViewModel.swift | 12 +- .../Subviews/RecruitPostTextFieldView.swift | 16 +- .../RecruitProfileHostingController.swift | 18 +- .../RecruitProfilePostViewController.swift | 308 ++++++++++++++++++ .../RecruitProfilePostViewModel.swift | 123 +++++++ .../RecruitDropdownTriggerButton.swift | 92 ++++++ ...uitProfilePostDepartmentDropdownView.swift | 78 +++++ ...ecruitProfilePostDepartmentTableView.swift | 79 +++++ ...itProfilePostDepartmentTableViewCell.swift | 50 +++ .../RecruitProfilePostFirstStepView.swift | 192 +++++++++++ .../RecruitProfilePostSecondStepView.swift | 25 ++ .../Subviews/RecruitSectionHeaderView.swift | 117 +++++++ .../Subviews/RecruitStepView.swift | 132 ++++++++ koin.xcodeproj/project.pbxproj | 74 ++++- 21 files changed, 1416 insertions(+), 24 deletions(-) create mode 100644 Koin/Domain/Model/Recruit/BasicInfo.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitProfileActivityRequest.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitProfileRequest.swift create mode 100644 Koin/Domain/UseCase/Timetable/MockFetchDeptListUseCase.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitDropdownTriggerButton.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentDropdownView.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentTableView.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentTableViewCell.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostFirstStepView.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostSecondStepView.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitSectionHeaderView.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitStepView.swift diff --git a/Koin/Domain/Model/Recruit/BasicInfo.swift b/Koin/Domain/Model/Recruit/BasicInfo.swift new file mode 100644 index 000000000..0007cd689 --- /dev/null +++ b/Koin/Domain/Model/Recruit/BasicInfo.swift @@ -0,0 +1,28 @@ +// +// BasicInfo.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import Foundation + +struct BasicInfo { + var nickname: String? = nil + var department: String? = nil + var studentNumber: String? = nil +} + +extension BasicInfo { + var isValid: Bool { + guard let nickname, + !nickname.isEmpty, + let department, + !department.isEmpty, + let studentNumber, + !studentNumber.isEmpty else { + return false + } + return true + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitProfileActivityRequest.swift b/Koin/Domain/Model/Recruit/RecruitProfileActivityRequest.swift new file mode 100644 index 000000000..9359273b6 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitProfileActivityRequest.swift @@ -0,0 +1,37 @@ +// +// RecruitProfileActivityRequest.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import Foundation + +struct RecruitProfileActivityRequest { + var title: String? + var startedAt: Date? + var endedAt: Date? + var isOngoing: Bool + var description: String? +} + +extension RecruitProfileActivityRequest { + var isValid: Bool { + guard let title, + !title.isEmpty, + let startedAt, + let description, + !description.isEmpty else { + return false + } + if isOngoing { + return true + } else { + guard let endedAt, + startedAt < endedAt else { + return false + } + return true + } + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitProfileRequest.swift b/Koin/Domain/Model/Recruit/RecruitProfileRequest.swift new file mode 100644 index 000000000..aa2feb2cc --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitProfileRequest.swift @@ -0,0 +1,16 @@ +// +// RecruitProfileRequest.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import Foundation + +struct RecruitProfileRequest { + var nickname: String? = nil + var preferredRole: String? = nil + var skills: [String] = [] + var activities: [RecruitProfileActivityRequest] = [] + var introduction: String? = nil +} diff --git a/Koin/Domain/UseCase/Timetable/MockFetchDeptListUseCase.swift b/Koin/Domain/UseCase/Timetable/MockFetchDeptListUseCase.swift new file mode 100644 index 000000000..82596ddb6 --- /dev/null +++ b/Koin/Domain/UseCase/Timetable/MockFetchDeptListUseCase.swift @@ -0,0 +1,25 @@ +// +// MockFetchDeptListUseCase.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import Combine + +final class MockFetchDeptListUseCase: FetchDeptListUseCase { + func execute() -> AnyPublisher<[String], ErrorResponse> { + Just([ + "건축공학부", + "고용서비스정책학과", + "기계공학부", + "메카트로닉스공학부", + "산업경영학부", + "에너지신소재화학공학부", + "전기전자통신공학부", + "컴퓨터공학부" + ]) + .setFailureType(to: ErrorResponse.self) + .eraseToAnyPublisher() + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index 553953664..d7956f421 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -152,7 +152,7 @@ extension RecruitDataHostingController { let postRecruitUseCase = DefaultPostRecruitUseCase(repository: recruitRepository) let modifyRecruitUseCase = DefaultModifyRecruitUseCase(repository: recruitRepository) let viewModel = RecruitPostViewModel( - postType: .modify(data: data), + mode: .modify(data: data), postRecruitUseCase: postRecruitUseCase, modifyRecruitUseCase: modifyRecruitUseCase ) diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index f30d8969e..58b2c7ab6 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -134,7 +134,7 @@ extension RecruitListHostingController { let postRecruitUseCase = DefaultPostRecruitUseCase(repository: recruitRepository) let modifyRecruitUseCase = DefaultModifyRecruitUseCase(repository: recruitRepository) let viewModel = RecruitPostViewModel( - postType: .post, + mode: .post, postRecruitUseCase: postRecruitUseCase, modifyRecruitUseCase: modifyRecruitUseCase ) diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift index cec4ca58f..a14eb6c02 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift @@ -13,7 +13,7 @@ import Then final class RecruitPostViewController: UIViewController { private var navigationTitle: String { - switch viewModel.postType { + switch viewModel.mode { case .post: return "모집글 작성" case .modify: @@ -21,7 +21,7 @@ final class RecruitPostViewController: UIViewController { } } private var submitButtonTitle: String { - switch viewModel.postType { + switch viewModel.mode { case .post: return "등록하기" case .modify: @@ -29,7 +29,7 @@ final class RecruitPostViewController: UIViewController { } } private var completionMessage: String { - switch viewModel.postType { + switch viewModel.mode { case .post: return "모집글이 등록되었습니다." case .modify: @@ -53,7 +53,7 @@ final class RecruitPostViewController: UIViewController { private let contentStackView = UIStackView() private let categoryView = RecruitPostCategoryView() - private let titleView = RecruitPostTextFieldView( + private let titleView = RecruitTextFieldView( title: "제목", isRequired: true, limit: 50, @@ -68,7 +68,7 @@ final class RecruitPostViewController: UIViewController { limit: 1000, placeholder: "소개를 작성해주세요." ) - private let relatedUrlView = RecruitPostTextFieldView( + private let relatedUrlView = RecruitTextFieldView( title: "관련 Url", isRequired: false, limit: nil, @@ -401,7 +401,9 @@ extension RecruitPostViewController { .sink { [weak self] notification in guard let self else { return } let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect - let keyboardMinY = keyboardFrame.map { self.view.convert($0, from: nil).minY } ?? scrollView.frame.maxY + let keyboardMinY = keyboardFrame.map { [weak self] in + self?.view.convert($0, from: nil).minY ?? 0 + } ?? scrollView.frame.maxY let overlap = max(0, scrollView.frame.maxY - keyboardMinY) scrollView.contentInset.bottom = overlap + 16 scrollFocusedInputIntoView() diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift index 9021d1eb7..0b4f43203 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift @@ -10,7 +10,7 @@ import Combine final class RecruitPostViewModel: ViewModelProtocol { - enum PostType { + enum Mode { case post case modify(data: RecruitData) } @@ -35,15 +35,15 @@ final class RecruitPostViewModel: ViewModelProtocol { private let outputSubject = PassthroughSubject() private var subscriptions: Set = [] - let postType: PostType + let mode: Mode // MARK: - Initializer init( - postType: PostType, + mode: Mode, postRecruitUseCase: PostRecruitUseCase, modifyRecruitUseCase: ModifyRecruitUseCase, ) { - self.postType = postType + self.mode = mode self.postRecruitUseCase = postRecruitUseCase self.modifyRecruitUseCase = modifyRecruitUseCase } @@ -67,7 +67,7 @@ final class RecruitPostViewModel: ViewModelProtocol { extension RecruitPostViewModel { private func applyDataIfModifying() { - guard case let .modify(data) = postType else { + guard case let .modify(data) = mode else { return } outputSubject.send(.updateForm(data)) @@ -85,7 +85,7 @@ extension RecruitPostViewModel { outputSubject.send(.updateLoading(false)) } - switch postType { + switch mode { case .post: let id = try await postRecruitUseCase.execute(request: request) outputSubject.send(.postCompleted(id: id)) diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextFieldView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextFieldView.swift index cadf01219..06d0ac75a 100644 --- a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextFieldView.swift +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextFieldView.swift @@ -1,5 +1,5 @@ // -// RecruitPostTextFieldView.swift +// RecruitTextFieldView.swift // koin // // Created by 홍기정 on 8/30/26. @@ -10,7 +10,7 @@ import Combine import SnapKit import Then -final class RecruitPostTextFieldView: UIView { +final class RecruitTextFieldView: UIView { // MARK: - Properties let textChangedPublisher = PassthroughSubject() @@ -25,7 +25,8 @@ final class RecruitPostTextFieldView: UIView { title: String, isRequired: Bool, limit: Int?, - placeholder: String + placeholder: String, + keyboardType: UIKeyboardType = .default ) { self.headerView = RecruitPostSectionHeaderView( title: title, @@ -37,6 +38,7 @@ final class RecruitPostTextFieldView: UIView { configureView() setAddTargets() setUpPlaceholder(placeholder) + textField.keyboardType = keyboardType } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") @@ -44,12 +46,14 @@ final class RecruitPostTextFieldView: UIView { // MARK: - Public func configure(text: String?) { - textField.text = text + if textField.text != text { + textField.text = text + } updateCounter(text: text) } } -extension RecruitPostTextFieldView { +extension RecruitTextFieldView { private func setAddTargets() { textField.addTarget(self, action: #selector(textFieldEditingChanged), for: .editingChanged) textField.addTarget(self, action: #selector(textFieldEditingEnd), for: .editingDidEnd) @@ -82,7 +86,7 @@ extension RecruitPostTextFieldView { } } -extension RecruitPostTextFieldView { +extension RecruitTextFieldView { private func setUpPlaceholder(_ placeholder: String?) { guard let placeholder else { return } diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift index ae4a385c9..c836f98c5 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift @@ -49,11 +49,13 @@ final class RecruitProfileHostingController: UIHostingController RecruitProfilePostViewController { + let userRepository = DefaultUserRepository(service: DefaultUserService()) + let viewModel = RecruitProfilePostViewModel( + fetchDeptListUseCase: MockFetchDeptListUseCase(), + fetchUserDataUseCase: DefaultFetchUserDataUseCase(userRepository: userRepository), + mode: mode + ) + return RecruitProfilePostViewController(viewModel: viewModel) + } } diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift new file mode 100644 index 000000000..5f9e1c44f --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift @@ -0,0 +1,308 @@ +// +// RecruitProfilePostViewController.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import Combine +import SnapKit +import Then +import UIKit + +final class RecruitProfilePostViewController: UIViewController { + + // MARK: - Properties + private let viewModel: RecruitProfilePostViewModel + private let inputSubject = PassthroughSubject() + private var subscriptions = Set() + + private var basicInfo = BasicInfo() +// private var request = RecruitProfileRequest() + private var isFirstStep = true + + private var titleText: String { + switch viewModel.mode { + case .post: + return "팀원 모집 프로필" + case .modify: + return "팀원 모집 프로필 수정" + } + } + private var completeButtonTitle: String { + switch viewModel.mode { + case .post: + return "저장" + case .modify: + return "수정하기" + } + } + + // MARK: - UI Components + private let scrollView = UIScrollView() + private let stackView = UIStackView() + private let firstStepView = RecruitProfilePostFirstStepView() + private let secondStepView = RecruitProfilePostSecondStepView() + + private let nextButton = StatefulButton( + title: "다음", + font: .appFont(.pretendardSemiBold, size: 15), + enabledColor: .appColor(.new500), + disabledColor: .appColor(.neutral400), + enabledTextColor: .appColor(.neutral0), + disabledTextColor: .appColor(.neutral0), + cornerRadius: 16 + ) + private let prevButton = StatefulButton( + title: "이전", + font: .appFont(.pretendardSemiBold, size: 15), + enabledColor: .appColor(.new500), + disabledColor: .appColor(.neutral400), + enabledTextColor: .appColor(.neutral0), + disabledTextColor: .appColor(.neutral0), + cornerRadius: 16 + ) + private lazy var completeButton = StatefulButton( + title: completeButtonTitle, + font: .appFont(.pretendardSemiBold, size: 15), + enabledColor: .appColor(.new500), + disabledColor: .appColor(.neutral400), + enabledTextColor: .appColor(.neutral0), + disabledTextColor: .appColor(.neutral0), + cornerRadius: 16 + ) + + // MARK: - Dropdown + private lazy var dropdownHost = KoinDropdownHost(scrollView: firstStepView) + private lazy var departmentDropdownContentView = RecruitProfilePostDepartmentDropdownView { [weak self] department in + guard let self else { return } + basicInfo.department = department + firstStepView.configure(department: department) + nextButton.updateState(isEnabled: basicInfo.isValid) + } + private lazy var departmentDropdown = dropdownHost.makeDropdown( + anchor: firstStepView.departmentDropdownAnchor, + contentView: departmentDropdownContentView, + configuration: .init(topPadding: 12, shadow: .shadow2) + ) + + // MARK: - Initializer + init(viewModel: RecruitProfilePostViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + title = titleText + configureView() + setAddTargets() + bind() + observeKeyboard() + hideKeyboardWhenTappedAround() + inputSubject.send(.viewDidLoad) + } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar(style: .newBackground) + } +} + +extension RecruitProfilePostViewController { + private func setAddTargets() { + nextButton.addTarget(self, action: #selector(nextButtonTapped), for: .touchUpInside) + prevButton.addTarget(self, action: #selector(prevButtonTapped), for: .touchUpInside) + completeButton.addTarget(self, action: #selector(completeButtonTapped), for: .touchUpInside) + } + + @objc private func nextButtonTapped() { + guard isFirstStep && basicInfo.isValid else { + return + } + showSecondStep() + } + + @objc private func prevButtonTapped() { + showFirstStep() + } + + @objc private func completeButtonTapped() { + // TODO: todo + } + + private func showFirstStep() { + showStep(isFirstStep: true) + } + + private func showSecondStep() { + showStep(isFirstStep: false) + } + + private func showStep(isFirstStep: Bool) { + view.endEditing(true) + dropdownHost.dismissPresented() + self.isFirstStep = isFirstStep + + view.layoutIfNeeded() + scrollView.setContentOffset( + CGPoint(x: isFirstStep ? 0 : scrollView.bounds.width, y: 0), + animated: true + ) + } +} + +extension RecruitProfilePostViewController { + private func bind() { + viewModel.transform(with: inputSubject.eraseToAnyPublisher()) + .receive(on: DispatchQueue.main) + .sink { [weak self] output in + guard let self else { return } + switch output { + case let .updateBasicInfo(info): + configureBasicInfo(info) + case let .updateDepartments(departments): + departmentDropdownContentView.configure(departments: departments) + case let .showToast(message): + showToastMessage(message: message, bottomInset: 72) + } + } + .store(in: &subscriptions) + + firstStepView.loadInfoButtonTappedPublisher + .sink { [weak self] in + self?.inputSubject.send(.loadUserData) + } + .store(in: &subscriptions) + + firstStepView.nicknameChangedPublisher + .sink { [weak self] nickname in + guard let self else { return } + basicInfo.nickname = nickname + nextButton.updateState(isEnabled: basicInfo.isValid) + } + .store(in: &subscriptions) + + firstStepView.departmentButtonTappedPublisher + .sink { [weak self] in + guard let self else { return } + view.endEditing(true) + restoreFirstStepContentInsetBottom { [weak self] in + self?.departmentDropdown.toggle() + } + } + .store(in: &subscriptions) + + firstStepView.studentNumberChangedPublisher + .sink { [weak self] studentNumber in + guard let self else { return } + basicInfo.studentNumber = studentNumber + nextButton.updateState(isEnabled: basicInfo.isValid) + } + .store(in: &subscriptions) + } +} + +extension RecruitProfilePostViewController { + private func configureBasicInfo(_ basicInfo: BasicInfo) { + self.basicInfo = basicInfo + firstStepView.configure(basicInfo) + nextButton.updateState(isEnabled: basicInfo.isValid) + } +} + +extension RecruitProfilePostViewController { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + view.backgroundColor = .appColor(.newBackground) + + scrollView.do { + $0.isPagingEnabled = true + $0.isScrollEnabled = false + $0.bounces = false + $0.showsHorizontalScrollIndicator = false + $0.showsVerticalScrollIndicator = false + $0.contentInsetAdjustmentBehavior = .never + } + stackView.do { + $0.axis = .horizontal + $0.alignment = .fill + $0.distribution = .fill + $0.spacing = 0 + } + } + + private func setUpLayouts() { + [firstStepView, secondStepView].forEach { + stackView.addArrangedSubview($0) + } + scrollView.addSubview(stackView) + view.addSubview(scrollView) + view.addSubview(nextButton) + } + + private func setUpConstraints() { + stackView.snp.makeConstraints { + $0.edges.equalTo(scrollView.contentLayoutGuide) + $0.height.equalTo(scrollView.frameLayoutGuide) + } + [firstStepView, secondStepView].forEach { + $0.snp.makeConstraints { + $0.width.equalTo(scrollView.frameLayoutGuide) + } + } + scrollView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(nextButton.snp.top).offset(-16) + } + nextButton.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(32) + $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) + $0.height.equalTo(48) + } + } +} + +extension RecruitProfilePostViewController { + private func observeKeyboard() { + NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let self else { return } + let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + let keyboardMinY = keyboardFrame.map { [weak self] in + self?.view.convert($0, from: nil).minY ?? 0 + } ?? firstStepView.convert(firstStepView.bounds, to: view).maxY + let firstStepMaxY = firstStepView.convert(firstStepView.bounds, to: view).maxY + firstStepView.contentInset.bottom = max(16, firstStepMaxY - keyboardMinY + 16) + } + .store(in: &subscriptions) + + NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.restoreFirstStepContentInsetBottom() + } + .store(in: &subscriptions) + } + + private func restoreFirstStepContentInsetBottom(_ completion: (() -> Void)? = nil) { + UIView.animate( + springDuration: 0.1, + options: [.beginFromCurrentState] + ) { [weak self] in + self?.firstStepView.contentInset.bottom = 16 + } completion: { _ in + completion?() + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift new file mode 100644 index 000000000..bb070d9df --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift @@ -0,0 +1,123 @@ +// +// RecruitProfilePostViewController.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import Combine +import Foundation + +final class RecruitProfilePostViewModel: ViewModelProtocol { + + enum Mode { + case post + case modify(RecruitProfile) + } + + enum Input { + case viewDidLoad + case loadUserData + } + + enum Output { + case updateBasicInfo(BasicInfo) + case updateDepartments([String]) + case showToast(String) + } + + // MARK: - State + let mode: Mode + + // MARK: - UseCase + private let fetchDeptListUseCase: FetchDeptListUseCase + private let fetchUserDataUseCase: FetchUserDataUseCase + + // MARK: - Publisher + private let outputSubject = PassthroughSubject() + private var subscriptions = Set() + + // MARK: - Initializer + init( + fetchDeptListUseCase: FetchDeptListUseCase, + fetchUserDataUseCase: FetchUserDataUseCase, + mode: Mode + ) { + self.fetchDeptListUseCase = fetchDeptListUseCase + self.fetchUserDataUseCase = fetchUserDataUseCase + self.mode = mode + } + + // MARK: - Public + func transform(with input: AnyPublisher) -> AnyPublisher { + input.sink { [weak self] input in + guard let self else { return } + switch input { + case .viewDidLoad: + sendBasicInfo() + fetchDepartments() + case .loadUserData: + fetchUserData() + } + } + .store(in: &subscriptions) + + return outputSubject.eraseToAnyPublisher() + } +} + +extension RecruitProfilePostViewModel { + private func sendBasicInfo() { + switch mode { + case .post: + outputSubject.send(.updateBasicInfo(.init( + nickname: nil, + department: nil, + studentNumber: nil + ))) + case let .modify(profile): + outputSubject.send(.updateBasicInfo(.init( + nickname: profile.nickname, + department: profile.department, + studentNumber: profile.studentNumber + ))) + } + } +} + +extension RecruitProfilePostViewModel { + private func fetchDepartments() { + fetchDeptListUseCase.execute() + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(error) = completion else { return } + self?.outputSubject.send(.showToast(error.message)) + }, + receiveValue: { [weak self] departments in + self?.outputSubject.send(.updateDepartments(departments)) + } + ) + .store(in: &subscriptions) + } + + private func fetchUserData() { + fetchUserDataUseCase.execute() + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(error) = completion else { return } + self?.outputSubject.send(.showToast(error.message)) + }, + receiveValue: { [weak self] user in + guard let self else { return } + self.outputSubject.send(.updateBasicInfo(.init( + nickname: user.nickname ?? user.anonymousNickname, + department: user.major, + studentNumber: user.studentNumber + ))) + } + ) + .store(in: &subscriptions) + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitDropdownTriggerButton.swift b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitDropdownTriggerButton.swift new file mode 100644 index 000000000..1a9d270d7 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitDropdownTriggerButton.swift @@ -0,0 +1,92 @@ +// +// RecruitDropdownTriggerButton.swift +// koin +// +// Created by 홍기정 on 9/15/26. +// + +import SnapKit +import Then +import UIKit + +final class RecruitDropdownTriggerButton: UIButton { + + // MARK: - Properties + private let placeholder: String + + // MARK: - UI Components + private let valueLabel = UILabel() + private let placeholderLabel = UILabel() + private let chevronDownImageView = UIImageView(image: .appImage(asset: .chevronDown)) + + // MARK: - Initializer + init(placeholder: String) { + self.placeholder = placeholder + super.init(frame: .zero) + configureView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(text: String?) { + valueLabel.text = text + valueLabel.isHidden = text == nil + placeholderLabel.isHidden = text != nil + } +} + +extension RecruitDropdownTriggerButton { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + layer.cornerRadius = 16 + backgroundColor = .appColor(.neutral0) + + valueLabel.do { + $0.font = UIFont.appFont(.pretendardRegular, size: 14) + $0.textColor = .appColor(.neutral800) + $0.isHidden = true + } + placeholderLabel.do { + $0.font = UIFont.appFont(.pretendardRegular, size: 14) + $0.text = placeholder + $0.textColor = .appColor(.neutral500) + } + chevronDownImageView.do { + $0.image = UIImage.appImage(asset: .chevronDown) + $0.contentMode = .scaleAspectFit + } + } + + private func setUpLayouts() { + [valueLabel, placeholderLabel, chevronDownImageView].forEach { + addSubview($0) + } + } + + private func setUpConstraints() { + valueLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(12) + $0.trailing.lessThanOrEqualTo(chevronDownImageView.snp.leading).offset(-8) + } + placeholderLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().offset(12) + $0.trailing.lessThanOrEqualTo(chevronDownImageView.snp.leading).offset(-8) + } + chevronDownImageView.snp.makeConstraints { + $0.size.equalTo(24) + $0.centerY.equalToSuperview() + $0.trailing.equalToSuperview().offset(-12) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentDropdownView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentDropdownView.swift new file mode 100644 index 000000000..7fb5c98ff --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentDropdownView.swift @@ -0,0 +1,78 @@ +// +// RecruitProfilePostDepartmentDropdownView.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import Combine +import SnapKit +import Then +import UIKit + +final class RecruitProfilePostDepartmentDropdownView: UIView, KoinDropdownContentView { + + // MARK: - Properties + private let dismissTappedSubject = PassthroughSubject() + private var subscriptions = Set() + + var dismissTappedPublisher: AnyPublisher { + dismissTappedSubject.eraseToAnyPublisher() + } + var height: CGFloat { + let visibleRows = min(departments.count, 5) + return CGFloat(visibleRows) * 34 + 6 * 2 + } + + private let onSelect: (String) -> Void + private var departments: [String] = [] + + // MARK: - UI Component + private let tableView = RecruitProfilePostDepartmentTableView() + + // MARK: - Initializer + init( + onSelect: @escaping (String) -> Void + ) { + self.onSelect = onSelect + super.init(frame: .zero) + configureView() + bind() + } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(departments: [String]) { + self.departments = departments + tableView.configure(departments: departments) + } +} + +extension RecruitProfilePostDepartmentDropdownView { + private func bind() { + tableView.departmentSelectedPublisher + .sink { [weak self] department in + guard let self else { return } + onSelect(department) + dismissTappedSubject.send() + } + .store(in: &subscriptions) + } +} + +extension RecruitProfilePostDepartmentDropdownView { + private func configureView() { + backgroundColor = .appColor(.neutral0) + layer.cornerRadius = 16 + clipsToBounds = true + + addSubview(tableView) + tableView.snp.makeConstraints { + $0.horizontalEdges.equalToSuperview() + $0.verticalEdges.equalToSuperview().inset(6) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentTableView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentTableView.swift new file mode 100644 index 000000000..d4e4b7668 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentTableView.swift @@ -0,0 +1,79 @@ +// +// RecruitProfilePostDepartmentTableView.swift +// koin +// +// Created by 홍기정 on 9/14/26. +// + +import Combine +import Then +import UIKit + +final class RecruitProfilePostDepartmentTableView: UITableView { + + // MARK: - Publisher + let departmentSelectedPublisher = PassthroughSubject() + + // MARK: - Properties + private var departments: [String] = [] + + // MARK: - Initializer + init() { + super.init(frame: .zero, style: .plain) + configureView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(departments: [String]) { + self.departments = departments + reloadData() + isScrollEnabled = departments.count > 5 + } +} + +extension RecruitProfilePostDepartmentTableView: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + departments.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: RecruitProfilePostDepartmentTableViewCell.identifier, + for: indexPath + ) as? RecruitProfilePostDepartmentTableViewCell else { + return UITableViewCell() + } + cell.configure(department: departments[indexPath.row]) + return cell + } +} + +extension RecruitProfilePostDepartmentTableView: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + departmentSelectedPublisher.send(departments[indexPath.row]) + } +} + +extension RecruitProfilePostDepartmentTableView { + private func configureView() { + backgroundColor = .clear + separatorStyle = .none + rowHeight = 34 + sectionHeaderHeight = 0 + sectionFooterHeight = 0 + sectionHeaderTopPadding = 0 + contentInset = .zero + showsVerticalScrollIndicator = false + dataSource = self + delegate = self + register( + RecruitProfilePostDepartmentTableViewCell.self, + forCellReuseIdentifier: RecruitProfilePostDepartmentTableViewCell.identifier + ) + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentTableViewCell.swift b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentTableViewCell.swift new file mode 100644 index 000000000..af4a48a4a --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentTableViewCell.swift @@ -0,0 +1,50 @@ +// +// RecruitProfilePostDepartmentTableViewCell.swift +// koin +// +// Created by 홍기정 on 9/14/26. +// + +import SnapKit +import Then +import UIKit + +final class RecruitProfilePostDepartmentTableViewCell: UITableViewCell { + + // MARK: - UI Component + private let departmentLabel = UILabel() + + // MARK: - Initializer + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configureView() + } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(department: String) { + departmentLabel.text = department + } +} + +extension RecruitProfilePostDepartmentTableViewCell { + private func configureView() { + backgroundColor = .clear + contentView.backgroundColor = .clear + + departmentLabel.do { + $0.font = .appFont(.pretendardRegular, size: 14) + $0.textColor = .appColor(.neutral800) + } + + contentView.addSubview(departmentLabel) + + departmentLabel.snp.makeConstraints { + $0.top.bottom.equalToSuperview() + $0.leading.trailing.equalToSuperview().inset(12) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostFirstStepView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostFirstStepView.swift new file mode 100644 index 000000000..e92d3a59a --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostFirstStepView.swift @@ -0,0 +1,192 @@ +// +// RecruitProfilePostFirstStepView.swift +// koin +// +// Created by 홍기정 on 9/15/26. +// + +import Combine +import SnapKit +import Then +import UIKit + +final class RecruitProfilePostFirstStepView: UIScrollView { + + // MARK: - Publishers + let loadInfoButtonTappedPublisher = PassthroughSubject() + let departmentButtonTappedPublisher = PassthroughSubject() + var nicknameChangedPublisher: AnyPublisher { + nicknameView.textChangedPublisher.eraseToAnyPublisher() + } + var studentNumberChangedPublisher: AnyPublisher { + studentNumberView.textChangedPublisher.eraseToAnyPublisher() + } + var departmentDropdownAnchor: UIView { + departmentButton + } + + // MARK: - UI Components + private let contentStackView = UIStackView() + private let stepView = RecruitStepView( + firstStepTitle: "기본 정보", + secondStepTitle: "지원서 작성", + isFirstStep: true + ) + private let loadInfoStackView = UIStackView() + private let loadInfoHeaderView: RecruitSectionHeaderView = { + let title = NSMutableAttributedString( + string: "코인", + attributes: [ + .font: UIFont.appFont(.pretendardSemiBold, size: 16), + .foregroundColor: UIColor.appColor(.new500) + ] + ) + title.append(NSAttributedString( + string: " 회원정보 불러오기", + attributes: [ + .font: UIFont.appFont(.pretendardSemiBold, size: 16), + .foregroundColor: UIColor.appColor(.neutral800) + ] + )) + return RecruitSectionHeaderView( + attributedTitle: title, + description: "닉네임, 학과(학부), 학번" + ) + }() + private let loadInfoButton = UIButton(type: .system) + private let nicknameView = RecruitTextFieldView( + title: "닉네임", + isRequired: true, + limit: 20, + placeholder: "닉네임을 입력해주세요." + ) + private let departmentContainerView = UIView() + private let departmentHeaderView = RecruitPostSectionHeaderView( + title: "학과 · 학부", + isRequired: true + ) + private let departmentButton = RecruitDropdownTriggerButton( + placeholder: "학과 · 학부를 선택해주세요." + ) + private let studentNumberView = RecruitTextFieldView( + title: "학번", + isRequired: true, + limit: 10, + placeholder: "학번을 작성해주세요.", + keyboardType: .numberPad + ) + + // MARK: - Initializer + init() { + super.init(frame: .zero) + configureView() + setAddTargets() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(_ info: BasicInfo) { + nicknameView.configure(text: info.nickname) + departmentButton.configure(text: info.department) + studentNumberView.configure(text: info.studentNumber) + } + + func configure(department: String?) { + departmentButton.configure(text: department) + } +} + +extension RecruitProfilePostFirstStepView { + private func setAddTargets() { + loadInfoButton.addTarget(self, action: #selector(loadInfoButtonTapped), for: .touchUpInside) + departmentButton.addTarget(self, action: #selector(departmentButtonTapped), for: .touchUpInside) + } + + @objc private func loadInfoButtonTapped() { + loadInfoButtonTappedPublisher.send() + } + + @objc private func departmentButtonTapped() { + departmentButtonTappedPublisher.send() + } +} + +extension RecruitProfilePostFirstStepView { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + backgroundColor = .appColor(.newBackground) + showsVerticalScrollIndicator = false + alwaysBounceVertical = true + keyboardDismissMode = .interactive + contentInsetAdjustmentBehavior = .never + contentInset = UIEdgeInsets(top: 32, left: 0, bottom: 16, right: 0) + + contentStackView.do { + $0.axis = .vertical + $0.spacing = 24 + } + loadInfoStackView.do { + $0.axis = .vertical + $0.spacing = 8 + } + loadInfoButton.do { + var configuration = UIButton.Configuration.plain() + configuration.attributedTitle = AttributedString( + "회원정보 불러오기", + attributes: AttributeContainer([ + .font: UIFont.appFont(.pretendardRegular, size: 14), + .foregroundColor: UIColor.appColor(.new500) + ]) + ) + $0.configuration = configuration + $0.layer.cornerRadius = 16 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.appColor(.new500).cgColor + } + configure(department: nil) + } + + private func setUpLayouts() { + [loadInfoHeaderView, loadInfoButton].forEach { + loadInfoStackView.addArrangedSubview($0) + } + [departmentHeaderView, departmentButton].forEach { + departmentContainerView.addSubview($0) + } + [stepView, loadInfoStackView, nicknameView, departmentContainerView, studentNumberView].forEach { + contentStackView.addArrangedSubview($0) + } + addSubview(contentStackView) + } + + private func setUpConstraints() { + contentStackView.snp.makeConstraints { + $0.top.bottom.equalTo(contentLayoutGuide) + $0.leading.trailing.equalTo(contentLayoutGuide).inset(24) + $0.width.equalTo(frameLayoutGuide).offset(-48) + } + stepView.snp.makeConstraints { + $0.height.equalTo(52) + } + loadInfoButton.snp.makeConstraints { + $0.height.equalTo(40) + } + departmentHeaderView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + } + departmentButton.snp.makeConstraints { + $0.top.equalTo(departmentHeaderView.snp.bottom).offset(8) + $0.leading.trailing.bottom.equalToSuperview() + $0.height.equalTo(40) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostSecondStepView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostSecondStepView.swift new file mode 100644 index 000000000..41a8617fa --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostSecondStepView.swift @@ -0,0 +1,25 @@ +// +// RecruitProfilePostSecondStepView.swift +// koin +// +// Created by 홍기정 on 9/15/26. +// + +import UIKit + +final class RecruitProfilePostSecondStepView: UIScrollView { + + init() { + super.init(frame: .zero) + backgroundColor = .appColor(.newBackground) + showsVerticalScrollIndicator = false + alwaysBounceVertical = true + keyboardDismissMode = .interactive + contentInsetAdjustmentBehavior = .never + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitSectionHeaderView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitSectionHeaderView.swift new file mode 100644 index 000000000..d1adf5bf4 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitSectionHeaderView.swift @@ -0,0 +1,117 @@ +// +// RecruitSectionHeaderView.swift +// koin +// +// Created by 홍기정 on 9/14/26. +// + +import SnapKit +import Then +import UIKit + +final class RecruitSectionHeaderView: UIView { + + // MARK: - Properties + private var currentAxis: NSLayoutConstraint.Axis = .horizontal + + // MARK: - UI Components + private let headerStackView = UIStackView() + private let titleLabel = UILabel() + private let descriptionLabel = UILabel() + + // MARK: - Initializer + init( + title: String, + description: String + ) { + super.init(frame: .zero) + configureView() + titleLabel.text = title + descriptionLabel.text = description + } + + init( + attributedTitle: NSAttributedString, + description: String + ) { + super.init(frame: .zero) + configureView() + titleLabel.attributedText = attributedTitle + descriptionLabel.text = description + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension RecruitSectionHeaderView { + override func layoutSubviews() { + super.layoutSubviews() + updateLayout() + } + + private func updateLayout() { + guard bounds.width > 0 else { return } + let requiredWidth = titleLabel.intrinsicContentSize.width + + 8 + + descriptionLabel.intrinsicContentSize.width + let nextAxis: NSLayoutConstraint.Axis = bounds.width >= requiredWidth ? .horizontal : .vertical + guard currentAxis != nextAxis else { return } + + currentAxis = nextAxis + headerStackView.axis = nextAxis + headerStackView.alignment = nextAxis == .horizontal ? .center : .leading + headerStackView.spacing = nextAxis == .horizontal ? 8 : 0 + invalidateIntrinsicContentSize() + } +} + +extension RecruitSectionHeaderView { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + titleLabel.do { + $0.textColor = .appColor(.neutral800) + $0.font = UIFont.appFont(.pretendardSemiBold, size: 16) + } + descriptionLabel.do { + $0.font = .appFont(.pretendardRegular, size: 12) + $0.textColor = .appColor(.neutral500) + } + headerStackView.do { + $0.axis = .horizontal + $0.alignment = .center + $0.spacing = 8 + } + } + + private func setUpLayouts() { + [titleLabel, descriptionLabel].forEach { + headerStackView.addArrangedSubview($0) + } + [headerStackView].forEach { + addSubview($0) + } + } + + private func setUpConstraints() { + headerStackView.snp.makeConstraints { + $0.top.leading.bottom.equalToSuperview() + $0.trailing.lessThanOrEqualToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.height.equalTo(26) + } + + descriptionLabel.snp.makeConstraints { + $0.height.equalTo(19) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitStepView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitStepView.swift new file mode 100644 index 000000000..82bf8123d --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitStepView.swift @@ -0,0 +1,132 @@ +// +// RecruitStepView.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import SnapKit +import Then +import UIKit + +final class RecruitStepView: UIView { + + // MARK: - Properties + private let firstStepTitle: String + private let secondStepTitle: String + private let isFirstStep: Bool + + // MARK: - UI Components + private let firstCircleView = UIView() + private let firstNumberLabel = UILabel() + private let firstTitleLabel = UILabel() + private let connectorView = UIView() + private let secondCircleView = UIView() + private let secondNumberLabel = UILabel() + private let secondTitleLabel = UILabel() + + // MARK: - Initializer + init( + firstStepTitle: String, + secondStepTitle: String, + isFirstStep: Bool + ) { + self.firstStepTitle = firstStepTitle + self.secondStepTitle = secondStepTitle + self.isFirstStep = isFirstStep + super.init(frame: .zero) + configureView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension RecruitStepView { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + firstCircleView.do { + $0.backgroundColor = .appColor(.new400) + $0.layer.cornerRadius = 16 + } + secondCircleView.do { + $0.backgroundColor = .appColor(isFirstStep ? .neutral300 : .new400) + $0.layer.cornerRadius = 16 + } + connectorView.backgroundColor = .appColor(isFirstStep ? .neutral300 : .new400) + + firstNumberLabel.do { + $0.text = "1" + $0.font = .appFont(.pretendardSemiBold, size: 15) + $0.textColor = .appColor(.neutral0) + $0.textAlignment = .center + } + secondNumberLabel.do { + $0.text = "2" + $0.font = .appFont(.pretendardSemiBold, size: 15) + $0.textColor = .appColor(isFirstStep ? .neutral800 : .neutral0) + $0.textAlignment = .center + } + firstTitleLabel.do { + $0.text = firstStepTitle + $0.font = .appFont(.pretendardRegular, size: 10) + $0.textColor = .appColor(isFirstStep ? .new400 : .neutral400) + $0.textAlignment = .center + } + secondTitleLabel.do { + $0.text = secondStepTitle + $0.font = .appFont(.pretendardRegular, size: 10) + $0.textColor = .appColor(isFirstStep ? .neutral400 : .new400) + $0.textAlignment = .center + } + } + + private func setUpLayouts() { + [firstCircleView, connectorView, secondCircleView, firstTitleLabel, secondTitleLabel].forEach { + addSubview($0) + } + firstCircleView.addSubview(firstNumberLabel) + secondCircleView.addSubview(secondNumberLabel) + } + + private func setUpConstraints() { + firstCircleView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.centerX.equalToSuperview().offset(-46) + $0.size.equalTo(32) + } + connectorView.snp.makeConstraints { + $0.leading.equalTo(firstCircleView.snp.trailing) + $0.trailing.equalTo(secondCircleView.snp.leading) + $0.centerY.equalTo(firstCircleView) + $0.height.equalTo(3) + $0.width.equalTo(60) + } + secondCircleView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.size.equalTo(32) + } + [firstNumberLabel, secondNumberLabel].forEach { + $0.snp.makeConstraints { $0.edges.equalToSuperview() } + } + firstTitleLabel.snp.makeConstraints { + $0.top.equalTo(firstCircleView.snp.bottom).offset(4) + $0.centerX.equalTo(firstCircleView) + $0.width.equalTo(56) + $0.bottom.equalToSuperview() + } + secondTitleLabel.snp.makeConstraints { + $0.top.equalTo(secondCircleView.snp.bottom).offset(4) + $0.centerX.equalTo(secondCircleView) + $0.width.equalTo(56) + $0.bottom.equalToSuperview() + } + } +} diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 6c22e24be..5b27c394b 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 0E2253358E42231BE436FA27 /* RecruitPostScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE127F2AE4B18899F6E5E0D /* RecruitPostScheduleView.swift */; }; 0FCD163D2BA80EAA00C23A2F /* KeychainWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCD163C2BA80EAA00C23A2F /* KeychainWorker.swift */; }; 1537B6CFD3D7AF52A2E9E944 /* RecruitPostMeetingTypeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73F70BC5F3AA1A34663C32CA /* RecruitPostMeetingTypeView.swift */; }; + 247DF973D072E88901E5C249 /* RecruitProfilePostDepartmentDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */; }; 2E6336C1CDB4445BA858DA5A /* RecruitDataContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */; }; 5006987DEF4B5F454D7EA2D5 /* RecruitPostTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */; }; 515B96092C6730C797C1F837 /* PostRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D447F2D75951DD4C3E08D4E /* PostRecruitUseCase.swift */; }; @@ -412,6 +413,9 @@ 7CB11C7E2FB32A26004E0C80 /* SubscribeLostItemKeywordUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB11C7D2FB32A26004E0C80 /* SubscribeLostItemKeywordUseCase.swift */; }; 7CB11C802FB32AF1004E0C80 /* UnsubscribeLostItemKeywordUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB11C7F2FB32AF1004E0C80 /* UnsubscribeLostItemKeywordUseCase.swift */; }; 7CB2D8B62FDDA7000A9C1234 /* HostingControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB2D8B52FDDA7000A9C1234 /* HostingControllerProtocol.swift */; }; + 7CB8EB4330584FC100300E99 /* RecruitProfilePostFirstStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB8EB4230584FC100300E99 /* RecruitProfilePostFirstStepView.swift */; }; + 7CB8EB4530584FC500300E99 /* RecruitProfilePostSecondStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB8EB4430584FC500300E99 /* RecruitProfilePostSecondStepView.swift */; }; + 7CB8EB48305857A800300E99 /* RecruitDropdownTriggerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CB8EB47305857A800300E99 /* RecruitDropdownTriggerButton.swift */; }; 7CBFAF112FBC44CA001D64E3 /* ShopBenefitPageControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBFAF102FBC44CA001D64E3 /* ShopBenefitPageControl.swift */; }; 7CBFAF152FBC48C6001D64E3 /* ShopBenefitEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBFAF142FBC48C6001D64E3 /* ShopBenefitEmptyView.swift */; }; 7CBFAF172FBC49C9001D64E3 /* ShopBenefitOpenCloseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBFAF162FBC49C9001D64E3 /* ShopBenefitOpenCloseView.swift */; }; @@ -472,6 +476,14 @@ 7CDCD0D43055EA07004204DA /* RecruitProfileEmptyCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0D33055EA07004204DA /* RecruitProfileEmptyCardView.swift */; }; 7CDCD0D63055EA5B004204DA /* RecruitProfileActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0D53055EA5B004204DA /* RecruitProfileActionButton.swift */; }; 7CDCD0D83055EECF004204DA /* RecruitProfileActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0D73055EECF004204DA /* RecruitProfileActivity.swift */; }; + 7CDCD0DB3055EFA7004204DA /* RecruitProfilePostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0DA3055EFA7004204DA /* RecruitProfilePostViewController.swift */; }; + 7CDCD0DD3055EFA9004204DA /* RecruitProfilePostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0DC3055EFA9004204DA /* RecruitProfilePostViewModel.swift */; }; + 7CDCD0E0305602A2004204DA /* RecruitProfileRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0DF305602A2004204DA /* RecruitProfileRequest.swift */; }; + 7CDCD0E2305602F6004204DA /* RecruitProfileActivityRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E1305602F6004204DA /* RecruitProfileActivityRequest.swift */; }; + 7CDCD0E430560758004204DA /* BasicInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E330560758004204DA /* BasicInfo.swift */; }; + 7CDCD0E6305695E8004204DA /* RecruitSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E5305695E8004204DA /* RecruitSectionHeaderView.swift */; }; + 7CDCD0E83056A000004204DA /* RecruitProfilePostDepartmentTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E73056A000004204DA /* RecruitProfilePostDepartmentTableView.swift */; }; + 7CDCD0EA3056A000004204DA /* RecruitProfilePostDepartmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E93056A000004204DA /* RecruitProfilePostDepartmentTableViewCell.swift */; }; 7CEB3B4B2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */; }; 7CED90BC2EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FD02EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift */; }; 7CED90BD2EF2D1F900457128 /* ManageNoticeKeywordViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F612EF2D1F900457128 /* ManageNoticeKeywordViewController.swift */; }; @@ -805,6 +817,7 @@ AA20000000000000000009 /* DiningLoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000009 /* DiningLoggingTests.swift */; }; AA20000000000000000010 /* DiningTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000010 /* DiningTypeTests.swift */; }; B0495459FC164809B16A96B6 /* RecruitPostTextViewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54351A8A0C1A4046C3155481 /* RecruitPostTextViewView.swift */; }; + B154A7D97E6AAEC2ED3D8119 /* MockFetchDeptListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E10C78030CB500BD595276 /* MockFetchDeptListUseCase.swift */; }; B438AEE42E756AB600E889C4 /* OrderImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B438AEE32E756AB600E889C4 /* OrderImage.swift */; }; B46B8CC22E76CB7300A8E797 /* ShopSummaryDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = B46B8CC12E76CB7300A8E797 /* ShopSummaryDto.swift */; }; B46B8CC42E76CC6300A8E797 /* FetchOrderShopSummaryFromShopUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B46B8CC32E76CC6300A8E797 /* FetchOrderShopSummaryFromShopUseCase.swift */; }; @@ -1079,6 +1092,7 @@ F0F216EDAA69B52CBEC0E606 /* RecruitPostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 288BC1E04044DC3FB48524FC /* RecruitPostViewModel.swift */; }; F1A000010000000000000001 /* ZoomingCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A000010000000000000002 /* ZoomingCollectionView.swift */; }; F1A000010000000000000003 /* ZoomingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A000010000000000000004 /* ZoomingCollectionViewCell.swift */; }; + F7F814BB370C922B6D8AA6EF /* RecruitStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFEC522ED41CF624AD3CB3A6 /* RecruitStepView.swift */; }; FE01000000000000000021 /* KoinDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01000000000000000011 /* KoinDropdown.swift */; }; FE01000000000000000023 /* KoinDropdownAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01000000000000000013 /* KoinDropdownAnimator.swift */; }; FE01000000000000000026 /* KoinDropdownConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01000000000000000016 /* KoinDropdownConfiguration.swift */; }; @@ -1120,6 +1134,7 @@ 0FCD163C2BA80EAA00C23A2F /* KeychainWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainWorker.swift; sourceTree = ""; }; 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostTextFieldView.swift; sourceTree = ""; }; 288BC1E04044DC3FB48524FC /* RecruitPostViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostViewModel.swift; sourceTree = ""; }; + 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostDepartmentDropdownView.swift; sourceTree = ""; }; 3C3694E3ACBE05B8C7F15357 /* RecruitPostSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostSectionHeaderView.swift; sourceTree = ""; }; 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostCategoryView.swift; sourceTree = ""; }; 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyRecruitUseCase.swift; sourceTree = ""; }; @@ -1504,6 +1519,9 @@ 7CB11C7D2FB32A26004E0C80 /* SubscribeLostItemKeywordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscribeLostItemKeywordUseCase.swift; sourceTree = ""; }; 7CB11C7F2FB32AF1004E0C80 /* UnsubscribeLostItemKeywordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsubscribeLostItemKeywordUseCase.swift; sourceTree = ""; }; 7CB2D8B52FDDA7000A9C1234 /* HostingControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingControllerProtocol.swift; sourceTree = ""; }; + 7CB8EB4230584FC100300E99 /* RecruitProfilePostFirstStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostFirstStepView.swift; sourceTree = ""; }; + 7CB8EB4430584FC500300E99 /* RecruitProfilePostSecondStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostSecondStepView.swift; sourceTree = ""; }; + 7CB8EB47305857A800300E99 /* RecruitDropdownTriggerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDropdownTriggerButton.swift; sourceTree = ""; }; 7CBFAF102FBC44CA001D64E3 /* ShopBenefitPageControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopBenefitPageControl.swift; sourceTree = ""; }; 7CBFAF142FBC48C6001D64E3 /* ShopBenefitEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopBenefitEmptyView.swift; sourceTree = ""; }; 7CBFAF162FBC49C9001D64E3 /* ShopBenefitOpenCloseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopBenefitOpenCloseView.swift; sourceTree = ""; }; @@ -1564,6 +1582,14 @@ 7CDCD0D33055EA07004204DA /* RecruitProfileEmptyCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileEmptyCardView.swift; sourceTree = ""; }; 7CDCD0D53055EA5B004204DA /* RecruitProfileActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileActionButton.swift; sourceTree = ""; }; 7CDCD0D73055EECF004204DA /* RecruitProfileActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileActivity.swift; sourceTree = ""; }; + 7CDCD0DA3055EFA7004204DA /* RecruitProfilePostViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostViewController.swift; sourceTree = ""; }; + 7CDCD0DC3055EFA9004204DA /* RecruitProfilePostViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostViewModel.swift; sourceTree = ""; }; + 7CDCD0DF305602A2004204DA /* RecruitProfileRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileRequest.swift; sourceTree = ""; }; + 7CDCD0E1305602F6004204DA /* RecruitProfileActivityRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileActivityRequest.swift; sourceTree = ""; }; + 7CDCD0E330560758004204DA /* BasicInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicInfo.swift; sourceTree = ""; }; + 7CDCD0E5305695E8004204DA /* RecruitSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitSectionHeaderView.swift; sourceTree = ""; }; + 7CDCD0E73056A000004204DA /* RecruitProfilePostDepartmentTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostDepartmentTableView.swift; sourceTree = ""; }; + 7CDCD0E93056A000004204DA /* RecruitProfilePostDepartmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostDepartmentTableViewCell.swift; sourceTree = ""; }; 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteDeviceTokenUseCase.swift; sourceTree = ""; }; 7CED8EC32EF2D1F900457128 /* ShopCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCollectionView.swift; sourceTree = ""; }; 7CED8EC42EF2D1F900457128 /* ShopCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCollectionViewCell.swift; sourceTree = ""; }; @@ -2175,6 +2201,8 @@ D8F5C54B2E6F012500FB6708 /* LottieAnimationManageable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieAnimationManageable.swift; sourceTree = ""; }; D8F5C5532E6F144300FB6708 /* floatingLogo.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = floatingLogo.json; sourceTree = ""; }; EC692285302700A400EE26ED /* koinUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = koinUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + EFEC522ED41CF624AD3CB3A6 /* RecruitStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitStepView.swift; sourceTree = ""; }; + F0E10C78030CB500BD595276 /* MockFetchDeptListUseCase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockFetchDeptListUseCase.swift; sourceTree = ""; }; F1A000010000000000000002 /* ZoomingCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomingCollectionView.swift; sourceTree = ""; }; F1A000010000000000000004 /* ZoomingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomingCollectionViewCell.swift; sourceTree = ""; }; F670021A95E0AA103E8F7BED /* RecruitPostRoleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostRoleView.swift; sourceTree = ""; }; @@ -2764,6 +2792,7 @@ 7CACF873304441AA00AC2828 /* RecruitPost */, 7CDCD0AE30556C89004204DA /* RecruitData */, 7CDCD0C43055D58B004204DA /* RecruitProfile */, + 7CDCD0D93055EF87004204DA /* RecruitProfilePost */, ); path = Recruit; sourceTree = ""; @@ -2796,6 +2825,9 @@ 7C50A1D63041F91400897A6F /* RecruitData.swift */, C0DE00000000000000000002 /* RecruitProfile.swift */, 7CDCD0D73055EECF004204DA /* RecruitProfileActivity.swift */, + 7CDCD0DF305602A2004204DA /* RecruitProfileRequest.swift */, + 7CDCD0E1305602F6004204DA /* RecruitProfileActivityRequest.swift */, + 7CDCD0E330560758004204DA /* BasicInfo.swift */, ); path = Recruit; sourceTree = ""; @@ -3735,6 +3767,31 @@ path = Subviews; sourceTree = ""; }; + 7CDCD0D93055EF87004204DA /* RecruitProfilePost */ = { + isa = PBXGroup; + children = ( + 7CDCD0DE3055EFB1004204DA /* Subviews */, + 7CDCD0DA3055EFA7004204DA /* RecruitProfilePostViewController.swift */, + 7CDCD0DC3055EFA9004204DA /* RecruitProfilePostViewModel.swift */, + ); + path = RecruitProfilePost; + sourceTree = ""; + }; + 7CDCD0DE3055EFB1004204DA /* Subviews */ = { + isa = PBXGroup; + children = ( + 7CB8EB4230584FC100300E99 /* RecruitProfilePostFirstStepView.swift */, + 7CB8EB4430584FC500300E99 /* RecruitProfilePostSecondStepView.swift */, + EFEC522ED41CF624AD3CB3A6 /* RecruitStepView.swift */, + 7CDCD0E5305695E8004204DA /* RecruitSectionHeaderView.swift */, + 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */, + 7CDCD0E73056A000004204DA /* RecruitProfilePostDepartmentTableView.swift */, + 7CDCD0E93056A000004204DA /* RecruitProfilePostDepartmentTableViewCell.swift */, + 7CB8EB47305857A800300E99 /* RecruitDropdownTriggerButton.swift */, + ); + path = Subviews; + sourceTree = ""; + }; 7CED8EC52EF2D1F900457128 /* ShopCollectionView */ = { isa = PBXGroup; children = ( @@ -5518,6 +5575,7 @@ D2FCBEA32D08060C0097A388 /* RollbackFrameUseCase.swift */, D2FCBEA52D0877CC0097A388 /* FetchFramesUseCase.swift */, 7CF77B863005F642003642C1 /* FetchMainFrameUseCase.swift */, + F0E10C78030CB500BD595276 /* MockFetchDeptListUseCase.swift */, ); path = Timetable; sourceTree = ""; @@ -6069,8 +6127,6 @@ EC69228A302700A400EE26ED /* PBXTargetDependency */, ); name = koinUnitTests; - packageProductDependencies = ( - ); productName = koinUnitTests; productReference = EC692285302700A400EE26ED /* koinUnitTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -6260,6 +6316,7 @@ D2FB23532BFD47E30098BA2B /* FetchShopCategoryListUseCase.swift in Sources */, D2FCBEA62D0877CC0097A388 /* FetchFramesUseCase.swift in Sources */, D215726A2CEDDE500061E725 /* FetchSemesterUseCase.swift in Sources */, + 7CDCD0DB3055EFA7004204DA /* RecruitProfilePostViewController.swift in Sources */, D27DD0D32BA608310081FD36 /* SceneDelegate.swift in Sources */, 7C0C189F2F68AB33001781B3 /* CallVanChatRequest.swift in Sources */, 7CDCD0CB3055D63F004204DA /* RecruitProfileViewModel.swift in Sources */, @@ -6321,6 +6378,7 @@ B46B8CC22E76CB7300A8E797 /* ShopSummaryDto.swift in Sources */, 7C50A2053042EF4F00897A6F /* MockRecruitRepository.swift in Sources */, D2D462662D63B98F00C60864 /* LostItemFetchChatRoomUseCase.swift in Sources */, + 7CDCD0DD3055EFA9004204DA /* RecruitProfilePostViewModel.swift in Sources */, 7CA0D6232EC74ADE00E9A282 /* ZoomedImageCollectionViewCell.swift in Sources */, D2BD36962BAA027A00FAE609 /* TokenRefreshRequest.swift in Sources */, D2D7C0D72C28299200C85A85 /* FetchLandDetailUseCase.swift in Sources */, @@ -6407,6 +6465,7 @@ 7C50A1FD3042E9AB00897A6F /* FetchRecruitListUseCase.swift in Sources */, A1D000010000000000000001 /* FetchRecruitDataUseCase.swift in Sources */, D20AB95A2C55369F006BC684 /* NotiAPI.swift in Sources */, + 7CB8EB48305857A800300E99 /* RecruitDropdownTriggerButton.swift in Sources */, 7C457AE72FCDE7240011D338 /* SwiftUIViewModelProtocol.swift in Sources */, 7CDCD0D43055EA07004204DA /* RecruitProfileEmptyCardView.swift in Sources */, 833D9C392C7058C300982145 /* NoticeListRepository.swift in Sources */, @@ -6531,10 +6590,12 @@ 7CED90CE2EF2D1F900457128 /* ForceModifyUserViewController.swift in Sources */, 7CED90CF2EF2D1F900457128 /* ChangeMyProfileViewModel.swift in Sources */, 7C0C18912F67FA58001781B3 /* ReportCallVanUserUseCase.swift in Sources */, + 7CB8EB4330584FC100300E99 /* RecruitProfilePostFirstStepView.swift in Sources */, 7CED90D02EF2D1F900457128 /* LostItemChatHistoryTableView.swift in Sources */, 7CF77B543003B276003642C1 /* HomeLogoView.swift in Sources */, 7C016558300AA3790013DD7B /* DepartmentTask.swift in Sources */, 7CED90D12EF2D1F900457128 /* NoticeSearchViewController.swift in Sources */, + 7CDCD0E430560758004204DA /* BasicInfo.swift in Sources */, 7CED90D22EF2D1F900457128 /* TagCollectionViewCell.swift in Sources */, 7C50A1CC3041E29000897A6F /* RecruitListFilter.swift in Sources */, 7CED90D32EF2D1F900457128 /* LandCollectionView.swift in Sources */, @@ -6603,6 +6664,7 @@ 7CED91152EF2D1F900457128 /* LoginViewModel.swift in Sources */, 7C7F56B42FFB6A6400847151 /* NotificationPopUpViewController.swift in Sources */, 7CED91162EF2D1F900457128 /* ShopSortOptionSheetViewController.swift in Sources */, + 7CB8EB4530584FC500300E99 /* RecruitProfilePostSecondStepView.swift in Sources */, 7CED91172EF2D1F900457128 /* TimetableText+.swift in Sources */, 7CED91182EF2D1F900457128 /* LandViewModel.swift in Sources */, 7CED911C2EF2D1F900457128 /* DiningOperatingTimeCollectionViewCell.swift in Sources */, @@ -6710,6 +6772,7 @@ 7C457B382FCE1BA80011D338 /* HomeHeaderView.swift in Sources */, 7C457B3C2FCE1BA80011D338 /* MobilitySmallButton.swift in Sources */, 7C457B3D2FCE1BA80011D338 /* CategoryFeaturedButton.swift in Sources */, + 7CDCD0E0305602A2004204DA /* RecruitProfileRequest.swift in Sources */, 7C457B3E2FCE1BA80011D338 /* CategoryRow.swift in Sources */, 7C457B3F2FCE1BA80011D338 /* CategorySection.swift in Sources */, 7C50A1CF3041F66700897A6F /* RecruitListEmptyView.swift in Sources */, @@ -6963,6 +7026,7 @@ 7CED92172EF2D1F900457128 /* DiningOperatingTimeFooterView.swift in Sources */, 7C372EE62F1C0CC400149729 /* LostItemListData.swift in Sources */, 7CED92192EF2D1F900457128 /* ReportLostItemViewModel.swift in Sources */, + 7CDCD0E2305602F6004204DA /* RecruitProfileActivityRequest.swift in Sources */, 7CED921A2EF2D1F900457128 /* ShopViewController.swift in Sources */, D86E2E152E7D860E006D63AF /* ShuttleBusTimetable.swift in Sources */, 7C01656D300B49F80013DD7B /* DepartmentHostingController.swift in Sources */, @@ -7239,6 +7303,12 @@ 831ABDC42CA29A120099B70C /* NoticeKeywordsFetchResult.swift in Sources */, 7C6790322FCC00150045E163 /* PopLoggable.swift in Sources */, D249CD2C2C12E32F000813F9 /* DefaultDiningRepository.swift in Sources */, + F7F814BB370C922B6D8AA6EF /* RecruitStepView.swift in Sources */, + 247DF973D072E88901E5C249 /* RecruitProfilePostDepartmentDropdownView.swift in Sources */, + 7CDCD0E6305695E8004204DA /* RecruitSectionHeaderView.swift in Sources */, + 7CDCD0E83056A000004204DA /* RecruitProfilePostDepartmentTableView.swift in Sources */, + 7CDCD0EA3056A000004204DA /* RecruitProfilePostDepartmentTableViewCell.swift in Sources */, + B154A7D97E6AAEC2ED3D8119 /* MockFetchDeptListUseCase.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 20309cd196b2cd7abf192c23ea53084153046d76 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 21 Sep 2026 20:03:38 +0900 Subject: [PATCH 027/123] =?UTF-8?q?fix:=20StatefulButton=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=EB=90=9C=20=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/View/UIKit/StatefulButton.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Koin/Core/View/UIKit/StatefulButton.swift b/Koin/Core/View/UIKit/StatefulButton.swift index d869c9eaf..aa9624192 100644 --- a/Koin/Core/View/UIKit/StatefulButton.swift +++ b/Koin/Core/View/UIKit/StatefulButton.swift @@ -23,7 +23,9 @@ final class StatefulButton: UIButton { cornerRadius: CGFloat = 4) { super.init(frame: .zero) self.enabledColor = enabledColor + self.enabledTextColor = enabledTextColor self.disabledColor = disabledColor + self.disabledTextColor = disabledTextColor self.setTitle(title, for: .normal) self.titleLabel?.font = font self.layer.cornerRadius = cornerRadius From a0679a7fa6590cc305b9bb8af08f64203cf43e6b Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 21 Sep 2026 22:59:50 +0900 Subject: [PATCH 028/123] =?UTF-8?q?design:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=ED=94=84=EB=A1=9C=ED=95=84=202=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Model/Recruit/RecruitProfile.swift | 19 + .../Recruit/RecruitProfileActivity.swift | 12 + .../RecruitProfileActivityRequest.swift | 30 +- .../Model/Recruit/RecruitProfileRequest.swift | 22 +- .../RecruitPostSectionHeaderView.swift | 9 +- .../RecruitProfilePostFirstStepView.swift | 1 + .../RecruitDropdownTriggerButton.swift | 0 ...uitProfilePostDepartmentDropdownView.swift | 0 ...ecruitProfilePostDepartmentTableView.swift | 0 ...itProfilePostDepartmentTableViewCell.swift | 0 .../Subviews/RecruitSectionHeaderView.swift | 0 .../Subviews/RecruitStepView.swift | 6 +- .../RecruitProfilePostViewController.swift | 182 ++++++- .../RecruitProfilePostViewModel.swift | 20 - .../RecruitProfilePostSecondStepView.swift | 138 +++++ ...filePostActivityDisplayTableViewCell.swift | 194 +++++++ ...ProfilePostActivityEditTableViewCell.swift | 474 ++++++++++++++++++ .../RecruitProfilePostActivityTableView.swift | 275 ++++++++++ ...RecruitProfilePostAddTableFooterView.swift | 63 +++ .../RecruitProfilePostSkillTableView.swift | 174 +++++++ ...RecruitProfilePostSkillTableViewCell.swift | 129 +++++ .../RecruitProfilePostSecondStepView.swift | 25 - koin.xcodeproj/project.pbxproj | 80 ++- 23 files changed, 1752 insertions(+), 101 deletions(-) rename Koin/Presentation/Recruit/RecruitProfilePost/{Subviews => FirstStepView}/RecruitProfilePostFirstStepView.swift (99%) rename Koin/Presentation/Recruit/RecruitProfilePost/{ => FirstStepView}/Subviews/RecruitDropdownTriggerButton.swift (100%) rename Koin/Presentation/Recruit/RecruitProfilePost/{ => FirstStepView}/Subviews/RecruitProfilePostDepartmentDropdownView.swift (100%) rename Koin/Presentation/Recruit/RecruitProfilePost/{ => FirstStepView}/Subviews/RecruitProfilePostDepartmentTableView.swift (100%) rename Koin/Presentation/Recruit/RecruitProfilePost/{ => FirstStepView}/Subviews/RecruitProfilePostDepartmentTableViewCell.swift (100%) rename Koin/Presentation/Recruit/RecruitProfilePost/{ => FirstStepView}/Subviews/RecruitSectionHeaderView.swift (100%) rename Koin/Presentation/Recruit/RecruitProfilePost/{ => FirstStepView}/Subviews/RecruitStepView.swift (94%) create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityDisplayTableViewCell.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityEditTableViewCell.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostAddTableFooterView.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift create mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableViewCell.swift delete mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostSecondStepView.swift diff --git a/Koin/Domain/Model/Recruit/RecruitProfile.swift b/Koin/Domain/Model/Recruit/RecruitProfile.swift index 0ac3335ce..efde87169 100644 --- a/Koin/Domain/Model/Recruit/RecruitProfile.swift +++ b/Koin/Domain/Model/Recruit/RecruitProfile.swift @@ -16,3 +16,22 @@ struct RecruitProfile { let activities: [RecruitProfileActivity] let selfIntroduction: String } + +extension RecruitProfile { + func toBasicInfo() -> BasicInfo { + BasicInfo( + nickname: nickname, + department: department, + studentNumber: studentNumber + ) + } + + func toRequest() -> RecruitProfileRequest { + RecruitProfileRequest( + preferredRole: preferredRole, + skills: skills, + activities: activities.map { $0.toRequest() }, + introduction: selfIntroduction + ) + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitProfileActivity.swift b/Koin/Domain/Model/Recruit/RecruitProfileActivity.swift index b2ffc279e..f8098e3ce 100644 --- a/Koin/Domain/Model/Recruit/RecruitProfileActivity.swift +++ b/Koin/Domain/Model/Recruit/RecruitProfileActivity.swift @@ -15,3 +15,15 @@ struct RecruitProfileActivity: Identifiable, Equatable { let isOngoing: Bool let description: String } + +extension RecruitProfileActivity { + func toRequest() -> RecruitProfileActivityRequest { + RecruitProfileActivityRequest( + title: title, + startedAt: startedAt, + endedAt: endedAt, + isOngoing: isOngoing, + description: description + ) + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitProfileActivityRequest.swift b/Koin/Domain/Model/Recruit/RecruitProfileActivityRequest.swift index 9359273b6..b60634223 100644 --- a/Koin/Domain/Model/Recruit/RecruitProfileActivityRequest.swift +++ b/Koin/Domain/Model/Recruit/RecruitProfileActivityRequest.swift @@ -8,30 +8,34 @@ import Foundation struct RecruitProfileActivityRequest { - var title: String? - var startedAt: Date? - var endedAt: Date? - var isOngoing: Bool - var description: String? + var title: String? = nil + var startedAt: Date? = nil + var endedAt: Date? = nil + var isOngoing: Bool = false + var description: String? = nil } extension RecruitProfileActivityRequest { var isValid: Bool { guard let title, - !title.isEmpty, + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let startedAt, let description, - !description.isEmpty else { + !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + + let calendar = Calendar.current + let startDay = calendar.startOfDay(for: startedAt) + let today = calendar.startOfDay(for: Date()) + guard startDay <= today else { return false } + if isOngoing { return true - } else { - guard let endedAt, - startedAt < endedAt else { - return false - } - return true } + + guard let endedAt else { return false } + let endDay = calendar.startOfDay(for: endedAt) + return startDay <= endDay } } diff --git a/Koin/Domain/Model/Recruit/RecruitProfileRequest.swift b/Koin/Domain/Model/Recruit/RecruitProfileRequest.swift index aa2feb2cc..e17ca96e4 100644 --- a/Koin/Domain/Model/Recruit/RecruitProfileRequest.swift +++ b/Koin/Domain/Model/Recruit/RecruitProfileRequest.swift @@ -8,9 +8,29 @@ import Foundation struct RecruitProfileRequest { - var nickname: String? = nil var preferredRole: String? = nil var skills: [String] = [] var activities: [RecruitProfileActivityRequest] = [] var introduction: String? = nil } + +extension RecruitProfileRequest { + var isValid: Bool { + guard let preferredRole, + !preferredRole.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let introduction, + !introduction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + + let normalizedSkills = skills.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard normalizedSkills.allSatisfy({ !$0.isEmpty }), + Set(normalizedSkills).count == normalizedSkills.count else { + return false + } + + return activities.allSatisfy(\.isValid) + } +} diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostSectionHeaderView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostSectionHeaderView.swift index ffb6e8a47..607c11b0e 100644 --- a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostSectionHeaderView.swift +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostSectionHeaderView.swift @@ -11,6 +11,9 @@ import Then final class RecruitPostSectionHeaderView: UIView { + // MARK: - Properties + private let titleFont: UIFont + // MARK: - UI Components private let titleLabel = UILabel() private let isRequiredLabel = UILabel() @@ -19,9 +22,11 @@ final class RecruitPostSectionHeaderView: UIView { // MARK: - Initializer init( title: String, + titleFont: UIFont = .appFont(.pretendardSemiBold, size: 16), isRequired: Bool, limit: Int? = nil ) { + self.titleFont = titleFont super.init(frame: .zero) configureView( title, @@ -61,12 +66,12 @@ extension RecruitPostSectionHeaderView { ) { titleLabel.do { $0.text = title - $0.font = .appFont(.pretendardSemiBold, size: 16) + $0.font = titleFont $0.textColor = .appColor(.neutral800) } isRequiredLabel.do { $0.text = "*" - $0.font = .appFont(.pretendardSemiBold, size: 16) + $0.font = titleFont $0.textColor = .appColor(.new500) $0.isHidden = !isRequired } diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostFirstStepView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/RecruitProfilePostFirstStepView.swift similarity index 99% rename from Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostFirstStepView.swift rename to Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/RecruitProfilePostFirstStepView.swift index e92d3a59a..a9cc37922 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostFirstStepView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/RecruitProfilePostFirstStepView.swift @@ -151,6 +151,7 @@ extension RecruitProfilePostFirstStepView { $0.layer.cornerRadius = 16 $0.layer.borderWidth = 1 $0.layer.borderColor = UIColor.appColor(.new500).cgColor + $0.backgroundColor = .appColor(.neutral0) } configure(department: nil) } diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitDropdownTriggerButton.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitDropdownTriggerButton.swift similarity index 100% rename from Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitDropdownTriggerButton.swift rename to Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitDropdownTriggerButton.swift diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentDropdownView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentDropdownView.swift similarity index 100% rename from Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentDropdownView.swift rename to Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentDropdownView.swift diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentTableView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentTableView.swift similarity index 100% rename from Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentTableView.swift rename to Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentTableView.swift diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentTableViewCell.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentTableViewCell.swift similarity index 100% rename from Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostDepartmentTableViewCell.swift rename to Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentTableViewCell.swift diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitSectionHeaderView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitSectionHeaderView.swift similarity index 100% rename from Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitSectionHeaderView.swift rename to Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitSectionHeaderView.swift diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitStepView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitStepView.swift similarity index 94% rename from Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitStepView.swift rename to Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitStepView.swift index 82bf8123d..1290d311f 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitStepView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitStepView.swift @@ -53,19 +53,19 @@ extension RecruitStepView { private func setUpStyles() { firstCircleView.do { - $0.backgroundColor = .appColor(.new400) + $0.backgroundColor = .appColor(isFirstStep ? .new400 : .neutral300) $0.layer.cornerRadius = 16 } secondCircleView.do { $0.backgroundColor = .appColor(isFirstStep ? .neutral300 : .new400) $0.layer.cornerRadius = 16 } - connectorView.backgroundColor = .appColor(isFirstStep ? .neutral300 : .new400) + connectorView.backgroundColor = .appColor(.neutral300) firstNumberLabel.do { $0.text = "1" $0.font = .appFont(.pretendardSemiBold, size: 15) - $0.textColor = .appColor(.neutral0) + $0.textColor = .appColor(isFirstStep ? .neutral0 : .neutral800) $0.textAlignment = .center } secondNumberLabel.do { diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift index 5f9e1c44f..597fda69f 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift @@ -18,7 +18,7 @@ final class RecruitProfilePostViewController: UIViewController { private var subscriptions = Set() private var basicInfo = BasicInfo() -// private var request = RecruitProfileRequest() + private var request = RecruitProfileRequest() private var isFirstStep = true private var titleText: String { @@ -37,12 +37,31 @@ final class RecruitProfilePostViewController: UIViewController { return "수정하기" } } + private var completeModalTitle: String { + switch viewModel.mode { + case .post: + return "프로필을 저장하시겠어요?" + case .modify: + return "프로필을 수정하시겠어요?" + } + } + private var completeModalButtonTitle: String { + switch viewModel.mode { + case .post: + return "저장하기" + case .modify: + return "수정하기" + } + } // MARK: - UI Components private let scrollView = UIScrollView() private let stackView = UIStackView() + private let firstPageView = UIView() + private let secondPageView = UIView() private let firstStepView = RecruitProfilePostFirstStepView() private let secondStepView = RecruitProfilePostSecondStepView() + private let secondStepButtonStackView = UIStackView() private let nextButton = StatefulButton( title: "다음", @@ -53,15 +72,7 @@ final class RecruitProfilePostViewController: UIViewController { disabledTextColor: .appColor(.neutral0), cornerRadius: 16 ) - private let prevButton = StatefulButton( - title: "이전", - font: .appFont(.pretendardSemiBold, size: 15), - enabledColor: .appColor(.new500), - disabledColor: .appColor(.neutral400), - enabledTextColor: .appColor(.neutral0), - disabledTextColor: .appColor(.neutral0), - cornerRadius: 16 - ) + private let prevButton = UIButton() private lazy var completeButton = StatefulButton( title: completeButtonTitle, font: .appFont(.pretendardSemiBold, size: 15), @@ -73,14 +84,15 @@ final class RecruitProfilePostViewController: UIViewController { ) // MARK: - Dropdown - private lazy var dropdownHost = KoinDropdownHost(scrollView: firstStepView) + private lazy var firstStepDropdownHost = KoinDropdownHost(scrollView: firstStepView) + private lazy var secondStepDropdownHost = KoinDropdownHost(scrollView: secondStepView) private lazy var departmentDropdownContentView = RecruitProfilePostDepartmentDropdownView { [weak self] department in guard let self else { return } basicInfo.department = department firstStepView.configure(department: department) nextButton.updateState(isEnabled: basicInfo.isValid) } - private lazy var departmentDropdown = dropdownHost.makeDropdown( + private lazy var departmentDropdown = firstStepDropdownHost.makeDropdown( anchor: firstStepView.departmentDropdownAnchor, contentView: departmentDropdownContentView, configuration: .init(topPadding: 12, shadow: .shadow2) @@ -89,6 +101,13 @@ final class RecruitProfilePostViewController: UIViewController { // MARK: - Initializer init(viewModel: RecruitProfilePostViewModel) { self.viewModel = viewModel + switch viewModel.mode { + case .post: + self.request = RecruitProfileRequest() + case let .modify(profile): + self.basicInfo = profile.toBasicInfo() + self.request = profile.toRequest() + } super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { @@ -100,6 +119,9 @@ final class RecruitProfilePostViewController: UIViewController { super.viewDidLoad() title = titleText configureView() + secondStepView.prepareDropdown(host: secondStepDropdownHost) + secondStepView.configure(request) + completeButton.updateState(isEnabled: request.isValid) setAddTargets() bind() observeKeyboard() @@ -131,7 +153,24 @@ extension RecruitProfilePostViewController { } @objc private func completeButtonTapped() { - // TODO: todo + guard basicInfo.isValid && request.isValid else { + return + } + let rightButtonAction = { [weak self] in + /* do something */ + } + let modalViewController = KoinModalViewController( + configuration: .init( + appearance: .new, + content: .singleTitle(text: completeModalTitle), + button: .buttons( + leftButtonTitle: "취소하기", + rightButtonTitle: completeModalButtonTitle, + rightButtonAction: rightButtonAction + ) + ) + ) + present(modalViewController, animated: true) } private func showFirstStep() { @@ -144,7 +183,8 @@ extension RecruitProfilePostViewController { private func showStep(isFirstStep: Bool) { view.endEditing(true) - dropdownHost.dismissPresented() + firstStepDropdownHost.dismissPresented() + secondStepDropdownHost.dismissPresented() self.isFirstStep = isFirstStep view.layoutIfNeeded() @@ -203,6 +243,52 @@ extension RecruitProfilePostViewController { nextButton.updateState(isEnabled: basicInfo.isValid) } .store(in: &subscriptions) + + secondStepView.preferredRoleChangedPublisher + .sink { [weak self] preferredRole in + guard let self else { return } + request.preferredRole = preferredRole + updateCompleteButtonState() + } + .store(in: &subscriptions) + + secondStepView.skillsChangedPublisher + .sink { [weak self] skills in + guard let self else { return } + request.skills = skills + updateCompleteButtonState() + } + .store(in: &subscriptions) + + secondStepView.activitiesChangedPublisher + .sink { [weak self] activities in + guard let self else { return } + request.activities = activities + updateCompleteButtonState() + } + .store(in: &subscriptions) + + secondStepView.introductionChangedPublisher + .sink { [weak self] introduction in + guard let self else { return } + request.introduction = introduction + updateCompleteButtonState() + } + .store(in: &subscriptions) + + secondStepView.didChangeHeightPublisher + .sink { [weak self] in + guard let self else { return } + view.layoutIfNeeded() + UIView.animate( + springDuration: 0.25, + options: [.beginFromCurrentState, .allowUserInteraction] + ) { + self.secondStepView.applyPendingSizeChange() + self.view.layoutIfNeeded() + } + } + .store(in: &subscriptions) } } @@ -212,6 +298,10 @@ extension RecruitProfilePostViewController { firstStepView.configure(basicInfo) nextButton.updateState(isEnabled: basicInfo.isValid) } + + private func updateCompleteButtonState() { + completeButton.updateState(isEnabled: request.isValid) + } } extension RecruitProfilePostViewController { @@ -238,15 +328,41 @@ extension RecruitProfilePostViewController { $0.distribution = .fill $0.spacing = 0 } + secondStepButtonStackView.do { + $0.axis = .horizontal + $0.alignment = .fill + $0.distribution = .fillEqually + $0.spacing = 12 + } + prevButton.do { + $0.setAttributedTitle(NSAttributedString( + string: "이전", + attributes: [ + .font: UIFont.appFont(.pretendardSemiBold, size: 15), + .foregroundColor: UIColor.appColor(.new500) + ]), for: .normal) + $0.backgroundColor = .clear + $0.layer.borderColor = UIColor.appColor(.new500).cgColor + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 16 + } } private func setUpLayouts() { - [firstStepView, secondStepView].forEach { + [firstStepView, nextButton].forEach { + firstPageView.addSubview($0) + } + [prevButton, completeButton].forEach { + secondStepButtonStackView.addArrangedSubview($0) + } + [secondStepView, secondStepButtonStackView].forEach { + secondPageView.addSubview($0) + } + [firstPageView, secondPageView].forEach { stackView.addArrangedSubview($0) } scrollView.addSubview(stackView) view.addSubview(scrollView) - view.addSubview(nextButton) } private func setUpConstraints() { @@ -254,7 +370,7 @@ extension RecruitProfilePostViewController { $0.edges.equalTo(scrollView.contentLayoutGuide) $0.height.equalTo(scrollView.frameLayoutGuide) } - [firstStepView, secondStepView].forEach { + [firstPageView, secondPageView].forEach { $0.snp.makeConstraints { $0.width.equalTo(scrollView.frameLayoutGuide) } @@ -262,11 +378,24 @@ extension RecruitProfilePostViewController { scrollView.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide) $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(view.safeAreaLayoutGuide) + } + firstStepView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() $0.bottom.equalTo(nextButton.snp.top).offset(-16) } nextButton.snp.makeConstraints { $0.leading.trailing.equalToSuperview().inset(32) - $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) + $0.bottom.equalToSuperview().offset(-16) + $0.height.equalTo(48) + } + secondStepView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.bottom.equalTo(secondStepButtonStackView.snp.top).offset(-16) + } + secondStepButtonStackView.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(32) + $0.bottom.equalToSuperview().offset(-16) $0.height.equalTo(48) } } @@ -282,15 +411,16 @@ extension RecruitProfilePostViewController { let keyboardMinY = keyboardFrame.map { [weak self] in self?.view.convert($0, from: nil).minY ?? 0 } ?? firstStepView.convert(firstStepView.bounds, to: view).maxY - let firstStepMaxY = firstStepView.convert(firstStepView.bounds, to: view).maxY - firstStepView.contentInset.bottom = max(16, firstStepMaxY - keyboardMinY + 16) + let activeStepView: UIScrollView = isFirstStep ? firstStepView : secondStepView + let activeStepMaxY = activeStepView.convert(activeStepView.bounds, to: view).maxY + activeStepView.contentInset.bottom = max(16, activeStepMaxY - keyboardMinY + 16) } .store(in: &subscriptions) NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) .receive(on: DispatchQueue.main) .sink { [weak self] _ in - self?.restoreFirstStepContentInsetBottom() + self?.restoreStepContentInsetBottom() } .store(in: &subscriptions) } @@ -305,4 +435,14 @@ extension RecruitProfilePostViewController { completion?() } } + + private func restoreStepContentInsetBottom() { + UIView.animate( + springDuration: 0.1, + options: [.beginFromCurrentState] + ) { [weak self] in + self?.firstStepView.contentInset.bottom = 16 + self?.secondStepView.contentInset.bottom = 16 + } + } } diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift index bb070d9df..785b2aa44 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift @@ -54,7 +54,6 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { guard let self else { return } switch input { case .viewDidLoad: - sendBasicInfo() fetchDepartments() case .loadUserData: fetchUserData() @@ -66,25 +65,6 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { } } -extension RecruitProfilePostViewModel { - private func sendBasicInfo() { - switch mode { - case .post: - outputSubject.send(.updateBasicInfo(.init( - nickname: nil, - department: nil, - studentNumber: nil - ))) - case let .modify(profile): - outputSubject.send(.updateBasicInfo(.init( - nickname: profile.nickname, - department: profile.department, - studentNumber: profile.studentNumber - ))) - } - } -} - extension RecruitProfilePostViewModel { private func fetchDepartments() { fetchDeptListUseCase.execute() diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift new file mode 100644 index 000000000..99efd6242 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift @@ -0,0 +1,138 @@ +// +// RecruitProfilePostSecondStepView.swift +// koin +// +// Created by 홍기정 on 9/15/26. +// + +import Combine +import SnapKit +import Then +import UIKit + +final class RecruitProfilePostSecondStepView: UIScrollView { + + // MARK: - Publishers + let preferredRoleChangedPublisher = PassthroughSubject() + let skillsChangedPublisher = PassthroughSubject<[String], Never>() + let activitiesChangedPublisher = PassthroughSubject<[RecruitProfileActivityRequest], Never>() + let introductionChangedPublisher = PassthroughSubject() + let didChangeHeightPublisher = PassthroughSubject() + + // MARK: - Properties + private var subscriptions = Set() + + private let contentStackView = UIStackView() + private let stepView = RecruitStepView( + firstStepTitle: "기본 정보", + secondStepTitle: "지원서 작성", + isFirstStep: false + ) + private let preferredRoleView = RecruitTextFieldView( + title: "선호 역할", + isRequired: true, + limit: 20, + placeholder: "선호 역할을 작서해주세요." + ) + private let skillSectionStackView = UIStackView() + private let skillHeaderView = RecruitSectionHeaderView( + title: "보유기술/자격증", + description: "기술 / 자격증은 항목별로 하나씩 작성해주세요." + ) + private let skillTableView = RecruitProfilePostSkillTableView() + private let activitySectionStackView = UIStackView() + private let activityHeaderView = RecruitSectionHeaderView( + title: "활동이력", + description: "공모전, 대외활동, 자치단체 등 활동 이력을 작성해주세요." + ) + private let activityTableView = RecruitProfilePostActivityTableView() + private let introductionView = RecruitPostTextViewView( + title: "자기소개", + isRequired: true, + limit: 1000, + placeholder: "자기소개를 입력해주세요." + ) + + init() { + super.init(frame: .zero) + configureView() + bind() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(_ request: RecruitProfileRequest) { + preferredRoleView.configure(text: request.preferredRole) + skillTableView.configure(skills: request.skills) + activityTableView.configure(activities: request.activities) + introductionView.configure(text: request.introduction) + } + + func prepareDropdown(host: KoinDropdownHost) { + activityTableView.prepareDropdown(host: host) + } + + func applyPendingSizeChange() { + skillTableView.applyPendingSizeChange() + activityTableView.applyPendingSizeChange() + } + + private func bind() { + preferredRoleView.textChangedPublisher + .subscribe(preferredRoleChangedPublisher) + .store(in: &subscriptions) + skillTableView.skillsChangedPublisher + .subscribe(skillsChangedPublisher) + .store(in: &subscriptions) + activityTableView.activitiesChangedPublisher + .subscribe(activitiesChangedPublisher) + .store(in: &subscriptions) + introductionView.textChangedPublisher + .subscribe(introductionChangedPublisher) + .store(in: &subscriptions) + + Publishers.Merge( + skillTableView.didChangeHeightPublisher, + activityTableView.didChangeHeightPublisher + ) + .subscribe(didChangeHeightPublisher) + .store(in: &subscriptions) + } + + private func configureView() { + backgroundColor = .appColor(.newBackground) + showsVerticalScrollIndicator = false + alwaysBounceVertical = true + keyboardDismissMode = .interactive + contentInsetAdjustmentBehavior = .never + contentInset = UIEdgeInsets(top: 32, left: 0, bottom: 16, right: 0) + + contentStackView.do { + $0.axis = .vertical + $0.spacing = 24 + } + [skillSectionStackView, activitySectionStackView].forEach { + $0.axis = .vertical + $0.spacing = 8 + } + + [skillHeaderView, skillTableView].forEach(skillSectionStackView.addArrangedSubview) + [activityHeaderView, activityTableView].forEach(activitySectionStackView.addArrangedSubview) + [stepView, preferredRoleView, skillSectionStackView, + activitySectionStackView, introductionView].forEach(contentStackView.addArrangedSubview) + addSubview(contentStackView) + + contentStackView.setCustomSpacing(32, after: stepView) + contentStackView.snp.makeConstraints { + $0.top.bottom.equalTo(contentLayoutGuide) + $0.leading.trailing.equalTo(contentLayoutGuide).inset(24) + $0.width.equalTo(frameLayoutGuide).offset(-48) + } + stepView.snp.makeConstraints { + $0.height.equalTo(52) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityDisplayTableViewCell.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityDisplayTableViewCell.swift new file mode 100644 index 000000000..b3b7022e0 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityDisplayTableViewCell.swift @@ -0,0 +1,194 @@ +// +// RecruitProfilePostActivityDisplayTableViewCell.swift +// koin +// +// Created by 홍기정 on 9/21/26. +// + +import Combine +import SnapKit +import Then +import UIKit + +final class RecruitProfilePostActivityDisplayTableViewCell: UITableViewCell { + + // MARK: - Properties + let editButtonTappedPublisher = PassthroughSubject() + let deleteButtonTappedPublisher = PassthroughSubject() + var cellSubscriptions = Set() + + // MARK: - UI Components + private let cardView = UIView() + private let titleLabel = UILabel() + private let editButton = UIButton(type: .system) + private let deleteButton = UIButton(type: .system) + private let periodTitleLabel = UILabel() + private let periodLabel = UILabel() + private let descriptionTitleLabel = UILabel() + private let descriptionLabel = UILabel() + + // MARK: - Initializer + override init( + style: UITableViewCell.CellStyle, + reuseIdentifier: String? + ) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configureView() + setAddTargets() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - PrepareForReuse + override func prepareForReuse() { + super.prepareForReuse() + cellSubscriptions.removeAll() + } + + // MARK: - Public + func configure(activity: RecruitProfileActivityRequest) { + titleLabel.text = activity.title + descriptionLabel.text = activity.description + descriptionLabel.do { + $0.setLineHeight(lineHeight: 1.60, text: activity.description ?? "") + } + + guard let startedAt = activity.startedAt else { + periodLabel.text = nil + return + } + let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy.MM.dd" + return formatter + }() + let start = dateFormatter.string(from: startedAt) + if activity.isOngoing { + periodLabel.text = "\(start) - 진행 중" + } else if let endedAt = activity.endedAt { + periodLabel.text = "\(start) - \(dateFormatter.string(from: endedAt))" + } else { + periodLabel.text = start + } + } +} + +extension RecruitProfilePostActivityDisplayTableViewCell { + private func setAddTargets() { + editButton.addTarget(self, action: #selector(editButtonTapped), for: .touchUpInside) + deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside) + } + + @objc private func editButtonTapped() { + editButtonTappedPublisher.send() + } + + @objc private func deleteButtonTapped() { + deleteButtonTappedPublisher.send() + } +} + +extension RecruitProfilePostActivityDisplayTableViewCell { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + private func setUpStyles() { + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + + cardView.do { + $0.backgroundColor = .appColor(.neutral0) + $0.layer.cornerRadius = 16 + } + titleLabel.do { + $0.font = .appFont(.pretendardSemiBold, size: 14) + $0.textColor = .appColor(.neutral800) + $0.numberOfLines = 0 + } + editButton.do { + $0.setAttributedTitle(NSAttributedString( + string: "수정", + attributes: [ + .font: UIFont.appFont(.pretendardRegular, size: 10), + .foregroundColor: UIColor.appColor(.new500) + ] + ), for: .normal) + $0.layer.cornerRadius = 10 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.appColor(.new500).cgColor + } + deleteButton.do { + $0.setImage(UIImage.appImage(asset: .newCancel)?.resize(to: CGSize(width: 20, height: 20)), for: .normal) + $0.tintColor = .appColor(.neutral800) + } + [periodTitleLabel, descriptionTitleLabel].forEach { + $0.font = .appFont(.pretendardRegular, size: 12) + $0.textColor = .appColor(.neutral500) + } + periodTitleLabel.text = "활동 기간" + descriptionTitleLabel.text = "활동 내용" + + [periodLabel, descriptionLabel].forEach { + $0.font = .appFont(.pretendardRegular, size: 12) + $0.textColor = .appColor(.neutral800) + $0.numberOfLines = 0 + } + } + + private func setUpLayouts() { + [titleLabel, editButton, deleteButton, periodTitleLabel, periodLabel, + descriptionTitleLabel, descriptionLabel].forEach(cardView.addSubview) + contentView.addSubview(cardView) + } + + private func setUpConstraints() { + cardView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview().offset(-8) + } + titleLabel.snp.makeConstraints { + $0.height.equalTo(22) + $0.top.leading.equalToSuperview().offset(12) + $0.trailing.lessThanOrEqualTo(editButton.snp.leading).offset(-8) + } + deleteButton.snp.makeConstraints { + $0.centerY.equalTo(titleLabel) + $0.trailing.equalToSuperview() + $0.size.equalTo(44) + } + editButton.snp.makeConstraints { + $0.centerY.equalTo(titleLabel) + $0.trailing.equalTo(deleteButton.snp.leading) + $0.width.equalTo(34) + $0.height.equalTo(20) + } + periodTitleLabel.snp.makeConstraints { + $0.height.equalTo(19) + $0.top.equalTo(titleLabel.snp.bottom).offset(12) + $0.leading.equalToSuperview().offset(12) + } + periodLabel.snp.makeConstraints { + $0.height.equalTo(19) + $0.top.equalTo(periodTitleLabel) + $0.leading.equalTo(periodTitleLabel.snp.trailing).offset(8) + $0.trailing.lessThanOrEqualToSuperview().offset(-12) + } + descriptionTitleLabel.snp.makeConstraints { + $0.height.equalTo(19) + $0.top.equalTo(periodLabel.snp.bottom).offset(12) + $0.leading.equalToSuperview().offset(12) + } + descriptionLabel.snp.makeConstraints { + $0.height.greaterThanOrEqualTo(19) + $0.top.equalTo(descriptionTitleLabel).offset(-12 * 0.6) + $0.leading.equalTo(descriptionTitleLabel.snp.trailing).offset(8) + $0.trailing.equalToSuperview().offset(-12) + $0.bottom.equalToSuperview().offset(-16) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityEditTableViewCell.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityEditTableViewCell.swift new file mode 100644 index 000000000..61539b552 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityEditTableViewCell.swift @@ -0,0 +1,474 @@ +// +// RecruitProfilePostActivityEditTableViewCell.swift +// koin +// +// Created by 홍기정 on 9/21/26. +// + +import Combine +import SnapKit +import Then +import UIKit + +final class RecruitProfilePostActivityEditTableViewCell: UITableViewCell { + + // MARK: - Properties + let activityChangedPublisher = PassthroughSubject() + let completeButtonTappedPublisher = PassthroughSubject() + let deleteButtonTappedPublisher = PassthroughSubject() + var cellSubscriptions = Set() + + private var activity = RecruitProfileActivityRequest() + private var subscriptions = Set() + + // MARK: - Dropdown + private var startDropdown: KoinDropdown? + private var endDropdown: KoinDropdown? + + // MARK: - UI Components + private let cardView = UIView() + private let titleHeaderView = RecruitPostSectionHeaderView( + title: "활동명", + titleFont: .appFont(.pretendardSemiBold, size: 14), + isRequired: true) + private let deleteButton = UIButton(type: .system) + private let titleTextField = UITextField() + private let periodHeaderView = RecruitPostSectionHeaderView( + title: "활동 기간", + titleFont: .appFont(.pretendardSemiBold, size: 14), + isRequired: true + ) + private let activityPeriodDropdownAnchor = UIView() + private let dateStackView = UIStackView() + private let startDateButton = UIButton(type: .system) + private let periodDashLabel = UILabel() + private let endDateButton = UIButton(type: .system) + private let ongoingButton = UIButton(type: .system) + private let descriptionHeaderView = RecruitPostSectionHeaderView( + title: "활동 내용", + titleFont: .appFont(.pretendardSemiBold, size: 14), + isRequired: true, + limit: 1000 + ) + private let descriptionTextView = UITextView() + private let descriptionPlaceholderLabel = UILabel() + private let completeButton = StatefulButton( + title: "완료", + font: .appFont(.pretendardSemiBold, size: 14), + enabledColor: .appColor(.new500), + disabledColor: .appColor(.neutral400), + enabledTextColor: .appColor(.neutral0), + disabledTextColor: .appColor(.neutral0), + cornerRadius: 16 + ) + private let startPickerView = KoinPickerDropDownView( + delegate: KoinPickerDropDownViewDateDelegate(range: -18_250..<1) + ) + private let endPickerView = KoinPickerDropDownView( + delegate: KoinPickerDropDownViewDateDelegate(range: -18_250..<1) + ) + + // MARK: - Initializer + override init( + style: UITableViewCell.CellStyle, + reuseIdentifier: String? + ) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configureView() + setAddTargets() + bind() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - PrepareForReuse + override func prepareForReuse() { + super.prepareForReuse() + cellSubscriptions.removeAll() + startDropdown?.dismiss() + endDropdown?.dismiss() + } + + // MARK: - Public + func prepareDropdown(host: KoinDropdownHost) { + guard startDropdown == nil, endDropdown == nil else { return } + startDropdown = host.makeDropdown( + anchor: activityPeriodDropdownAnchor, + contentView: startPickerView, + configuration: .init(topPadding: 8, shadow: .shadow2) + ) + endDropdown = host.makeDropdown( + anchor: activityPeriodDropdownAnchor, + contentView: endPickerView, + configuration: .init(topPadding: 8, shadow: .shadow2) + ) + } + + func configure(activity: RecruitProfileActivityRequest) { + self.activity = activity + titleTextField.text = activity.title + descriptionTextView.text = activity.description + descriptionPlaceholderLabel.isHidden = !(activity.description?.isEmpty ?? true) + descriptionHeaderView.updateCounter(current: activity.description?.count ?? 0, limit: 1000) + refreshDates() + updateOngoingButton() + updateCompleteButton() + } +} + +extension RecruitProfilePostActivityEditTableViewCell { + private func bind() { + startPickerView.selectedItemPublisher + .sink { [weak self] item in + guard let self, let date = date(from: item) else { return } + activity.startedAt = date + refreshDates() + publishActivity() + } + .store(in: &subscriptions) + endPickerView.selectedItemPublisher + .sink { [weak self] item in + guard let self, let date = date(from: item) else { return } + activity.endedAt = date + refreshDates() + publishActivity() + } + .store(in: &subscriptions) + } +} + +extension RecruitProfilePostActivityEditTableViewCell { + private func setAddTargets() { + titleTextField.addTarget(self, action: #selector(titleEditingChanged), for: .editingChanged) + titleTextField.addTarget(self, action: #selector(titleEditingDidEnd), for: .editingDidEnd) + deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside) + startDateButton.addTarget(self, action: #selector(startDateButtonTapped), for: .touchUpInside) + endDateButton.addTarget(self, action: #selector(endDateButtonTapped), for: .touchUpInside) + ongoingButton.addTarget(self, action: #selector(ongoingButtonTapped), for: .touchUpInside) + completeButton.addTarget(self, action: #selector(completeButtonTapped), for: .touchUpInside) + } + + @objc private func titleEditingChanged() { + activity.title = titleTextField.text + publishActivity() + } + + @objc private func titleEditingDidEnd() { + let text = (titleTextField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + titleTextField.text = text + activity.title = text + publishActivity() + } + + @objc private func deleteButtonTapped() { + deleteButtonTappedPublisher.send() + } + + @objc private func startDateButtonTapped() { + endDropdown?.dismiss() + endEditing(true) + startPickerView.reset(initialDate: activity.startedAt ?? Date()) + startDropdown?.toggle() + } + + @objc private func endDateButtonTapped() { + guard !activity.isOngoing else { return } + startDropdown?.dismiss() + endEditing(true) + endPickerView.reset(initialDate: activity.endedAt ?? activity.startedAt ?? Date()) + endDropdown?.toggle() + } + + @objc private func ongoingButtonTapped() { + activity.isOngoing.toggle() + if activity.isOngoing { + activity.endedAt = nil + endDropdown?.dismiss() + } + updateOngoingButton() + refreshDates() + publishActivity() + } + + @objc private func completeButtonTapped() { + guard activity.isValid else { return } + endEditing(true) + completeButtonTappedPublisher.send() + } + + private func publishActivity() { + updateCompleteButton() + activityChangedPublisher.send(activity) + } + + private func refreshDates() { + configureDateButton( + startDateButton, + date: activity.startedAt, + placeholder: "시작일" + ) + configureDateButton( + endDateButton, + date: activity.endedAt, + placeholder: "종료일" + ) + endDateButton.isEnabled = !activity.isOngoing + endDateButton.alpha = activity.isOngoing ? 0.45 : 1 + } +} + +extension RecruitProfilePostActivityEditTableViewCell { + private func configureDateButton( + _ button: UIButton, + date: Date?, + placeholder: String + ) { + let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy.MM.dd" + return formatter + }() + + let text = date.map(dateFormatter.string(from:)) ?? placeholder + button.setAttributedTitle(NSAttributedString( + string: text, + attributes: [ + .font: UIFont.appFont(.pretendardRegular, size: 12), + .foregroundColor: UIColor.appColor(date == nil ? .neutral500 : .neutral800) + ] + ), for: .normal) + } + + private func updateOngoingButton() { + ongoingButton.isSelected = activity.isOngoing + ongoingButton.setNeedsUpdateConfiguration() + } + + private func updateCompleteButton() { + completeButton.updateState(isEnabled: activity.isValid) + } + + private func date(from item: [String]) -> Date? { + guard item.count == 3, + let year = Int(item[0].filter(\.isNumber)), + let month = Int(item[1].filter(\.isNumber)), + let day = Int(item[2].filter(\.isNumber)) else { return nil } + return Calendar.current.date(from: DateComponents(year: year, month: month, day: day)) + } +} + +extension RecruitProfilePostActivityEditTableViewCell { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + + updateOngoingButton() + updateCompleteButton() + } + private func setUpStyles() { + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + descriptionTextView.delegate = self + + cardView.do { + $0.backgroundColor = .appColor(.neutral0) + $0.layer.cornerRadius = 16 + } + deleteButton.do { + $0.setImage(UIImage.appImage(asset: .newCancel)?.resize(to: CGSize(width: 20, height: 20)), for: .normal) + $0.tintColor = .appColor(.neutral800) + } + titleTextField.do { + $0.font = .appFont(.pretendardRegular, size: 14) + $0.textColor = .appColor(.neutral800) + $0.backgroundColor = .appColor(.neutral0) + $0.layer.cornerRadius = 16 + $0.layer.borderWidth = 0.5 + $0.layer.borderColor = UIColor.appColor(.neutral400).cgColor + $0.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + $0.leftViewMode = .always + $0.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + $0.rightViewMode = .always + $0.attributedPlaceholder = NSAttributedString( + string: "활동명을 입력해주세요.", + attributes: [.foregroundColor: UIColor.appColor(.neutral500)] + ) + } + dateStackView.do { + $0.axis = .horizontal + $0.alignment = .center + $0.spacing = 8 + } + activityPeriodDropdownAnchor.do { + $0.backgroundColor = .clear + $0.isUserInteractionEnabled = false + } + [startPickerView, endPickerView].forEach { + $0.backgroundColor = .appColor(.neutral0) + $0.layer.cornerRadius = 16 + } + [startDateButton, endDateButton].forEach { + $0.backgroundColor = .appColor(.neutral0) + $0.layer.cornerRadius = 16 + $0.layer.borderWidth = 0.5 + $0.layer.borderColor = UIColor.appColor(.neutral400).cgColor + $0.contentHorizontalAlignment = .center + } + periodDashLabel.do { + $0.text = "-" + $0.font = .appFont(.pretendardRegular, size: 14) + $0.textColor = .appColor(.neutral700) + $0.textAlignment = .center + } + ongoingButton.do { + var configuration = UIButton.Configuration.plain() + configuration.attributedTitle = AttributedString( + "진행 중", + attributes: AttributeContainer([ + .font: UIFont.appFont(.pretendardRegular, size: 14), + .foregroundColor: UIColor.appColor(.neutral800) + ]) + ) + configuration.imagePlacement = .leading + configuration.imagePadding = 4 + configuration.contentInsets = .zero + configuration.automaticallyUpdateForSelection = false + configuration.background.backgroundColor = .clear + configuration.baseBackgroundColor = nil + $0.configuration = configuration + $0.contentHorizontalAlignment = .leading + $0.configurationUpdateHandler = { button in + let asset: ImageAsset = button.isSelected + ? .circleCheckedPrimary500 + : .circlePrimary500 + var configuration = button.configuration + configuration?.image = UIImage.appImage(asset: asset)? + .withTintColor(.appColor(.new500), renderingMode: .alwaysOriginal) + button.configuration = configuration + } + } + descriptionTextView.do { + $0.font = .appFont(.pretendardRegular, size: 14) + $0.textColor = .appColor(.neutral800) + $0.backgroundColor = .appColor(.neutral0) + $0.layer.cornerRadius = 16 + $0.layer.borderWidth = 0.5 + $0.layer.borderColor = UIColor.appColor(.neutral400).cgColor + $0.textContainerInset = UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12) + $0.textContainer.lineFragmentPadding = 0 + } + descriptionPlaceholderLabel.do { + $0.text = "활동 내용을 입력해주세요." + $0.font = .appFont(.pretendardRegular, size: 14) + $0.textColor = .appColor(.neutral500) + } + } + + private func setUpLayouts() { + [startDateButton, periodDashLabel, endDateButton, ongoingButton].forEach { + dateStackView.addArrangedSubview($0) + } + [titleHeaderView, deleteButton, titleTextField, periodHeaderView, + activityPeriodDropdownAnchor, dateStackView, + descriptionHeaderView, descriptionTextView, descriptionPlaceholderLabel, + completeButton].forEach { + cardView.addSubview($0) + } + [cardView].forEach { + contentView.addSubview($0) + } + } + + private func setUpConstraints() { + startDateButton.setContentHuggingPriority(.defaultLow, for: .horizontal) + endDateButton.setContentHuggingPriority(.defaultLow, for: .horizontal) + + cardView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview().offset(-8) + } + deleteButton.snp.makeConstraints { + $0.top.trailing.equalToSuperview() + $0.size.equalTo(44) + } + titleHeaderView.snp.makeConstraints { + $0.top.equalToSuperview().offset(12) + $0.leading.equalToSuperview().offset(12) + $0.trailing.equalTo(deleteButton.snp.leading) + } + titleTextField.snp.makeConstraints { + $0.top.equalTo(titleHeaderView.snp.bottom).offset(8) + $0.leading.trailing.equalToSuperview().inset(12) + $0.height.equalTo(40) + } + periodHeaderView.snp.makeConstraints { + $0.top.equalTo(titleTextField.snp.bottom).offset(16) + $0.leading.trailing.equalToSuperview().inset(12) + } + dateStackView.snp.makeConstraints { + $0.top.equalTo(periodHeaderView.snp.bottom).offset(8) + $0.leading.trailing.equalToSuperview().inset(12) + $0.height.equalTo(40) + } + activityPeriodDropdownAnchor.snp.makeConstraints { + $0.edges.equalTo(dateStackView) + } + startDateButton.snp.makeConstraints { + $0.height.equalTo(40) + } + endDateButton.snp.makeConstraints { + $0.height.equalTo(40) + } + startDateButton.snp.makeConstraints { + $0.width.equalTo(endDateButton) + } + periodDashLabel.snp.makeConstraints { + $0.width.equalTo(8) + } + ongoingButton.snp.makeConstraints { + $0.width.equalTo(70) + } + descriptionHeaderView.snp.makeConstraints { + $0.top.equalTo(dateStackView.snp.bottom).offset(16) + $0.leading.trailing.equalToSuperview().inset(12) + } + descriptionTextView.snp.makeConstraints { + $0.top.equalTo(descriptionHeaderView.snp.bottom).offset(8) + $0.leading.trailing.equalToSuperview().inset(12) + $0.height.equalTo(106) + } + descriptionPlaceholderLabel.snp.makeConstraints { + $0.top.equalTo(descriptionTextView).offset(8) + $0.leading.trailing.equalTo(descriptionTextView).inset(12) + } + completeButton.snp.makeConstraints { + $0.top.equalTo(descriptionTextView.snp.bottom).offset(12) + $0.leading.trailing.bottom.equalToSuperview().inset(12) + $0.height.equalTo(40) + } + } +} + +extension RecruitProfilePostActivityEditTableViewCell: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + let text = String(textView.text.prefix(1000)) + textView.text = text + descriptionPlaceholderLabel.isHidden = !text.isEmpty + descriptionHeaderView.updateCounter(current: text.count, limit: 1000) + activity.description = text + publishActivity() + } + + func textViewDidEndEditing(_ textView: UITextView) { + let text = String(textView.text.trimmingCharacters(in: .whitespacesAndNewlines).prefix(1000)) + textView.text = text + descriptionPlaceholderLabel.isHidden = !text.isEmpty + descriptionHeaderView.updateCounter(current: text.count, limit: 1000) + activity.description = text + publishActivity() + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift new file mode 100644 index 000000000..257c85f2c --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift @@ -0,0 +1,275 @@ +// +// RecruitProfilePostActivityTableView.swift +// koin +// +// Created by 홍기정 on 9/21/26. +// + +import Combine +import UIKit + +final class RecruitProfilePostActivityTableView: UITableView { + + private enum RowMode { + case display + case editing + } + + private struct Row { + var draft: RecruitProfileActivityRequest + var committed: RecruitProfileActivityRequest? + var mode: RowMode + } + + private enum PendingSizeChange { + case insert(IndexPath) + case delete(IndexPath) + case reload(IndexPath) + } + + // MARK: - Properties + let activitiesChangedPublisher = PassthroughSubject<[RecruitProfileActivityRequest], Never>() + let didChangeHeightPublisher = PassthroughSubject() + + private var rows: [Row] = [] + private var footerSubscriptions = Set() + + private weak var dropdownHost: KoinDropdownHost? + private var lastContentHeight: CGFloat = 0 + private var pendingSizeChange: PendingSizeChange? + + // MARK: - Initializer + init() { + super.init(frame: .zero, style: .plain) + configureView() + commonInit() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Override + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: max(40, contentSize.height)) + } + + override var contentSize: CGSize { + didSet { + guard contentSize.height != lastContentHeight else { return } + lastContentHeight = contentSize.height + invalidateIntrinsicContentSize() + } + } + + // MARK: - Public + func configure(activities: [RecruitProfileActivityRequest]) { + rows = activities.map { Row(draft: $0, committed: $0, mode: .display) } + pendingSizeChange = nil + reloadData() + } + + func prepareDropdown(host: KoinDropdownHost) { + dropdownHost = host + } + + func applyPendingSizeChange() { + guard let pendingSizeChange else { return } + self.pendingSizeChange = nil + invalidateIntrinsicContentSize() + performBatchUpdates { + switch pendingSizeChange { + case let .insert(indexPath): + insertRows(at: [indexPath], with: .automatic) + case let .delete(indexPath): + deleteRows(at: [indexPath], with: .automatic) + case let .reload(indexPath): + reloadRows(at: [indexPath], with: .automatic) + } + } + } +} + +extension RecruitProfilePostActivityTableView { + private func addActivity() { + dropdownHost?.dismissPresented() + let indexPath = IndexPath(row: rows.count, section: 0) + rows.append(Row( + draft: RecruitProfileActivityRequest(), + committed: nil, + mode: .editing + )) + pendingSizeChange = .insert(indexPath) + didChangeHeightPublisher.send() + } + + private func editActivity(at indexPath: IndexPath) { + guard rows.indices.contains(indexPath.row), + let committed = rows[indexPath.row].committed else { return } + dropdownHost?.dismissPresented() + rows[indexPath.row].draft = committed + rows[indexPath.row].mode = .editing + pendingSizeChange = .reload(indexPath) + didChangeHeightPublisher.send() + } + + private func completeActivity(at indexPath: IndexPath) { + guard rows.indices.contains(indexPath.row), + rows[indexPath.row].draft.isValid else { return } + dropdownHost?.dismissPresented() + rows[indexPath.row].committed = rows[indexPath.row].draft + rows[indexPath.row].mode = .display + pendingSizeChange = .reload(indexPath) + didChangeHeightPublisher.send() + + activitiesChangedPublisher.send(rows.compactMap(\.committed)) + } + + private func deleteActivity(at indexPath: IndexPath) { + guard rows.indices.contains(indexPath.row) else { return } + dropdownHost?.dismissPresented() + rows.remove(at: indexPath.row) + pendingSizeChange = .delete(indexPath) + didChangeHeightPublisher.send() + + activitiesChangedPublisher.send(rows.compactMap(\.committed)) + } +} + +extension RecruitProfilePostActivityTableView: UITableViewDataSource { + func tableView( + _ tableView: UITableView, + numberOfRowsInSection section: Int + ) -> Int { + rows.count + } + + func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + switch rows[indexPath.row].mode { + case .display: + return displayCell(for: indexPath) + case .editing: + return editCell(for: indexPath) + } + } + + func tableView( + _ tableView: UITableView, + viewForFooterInSection section: Int + ) -> UIView? { + guard let footer = dequeueReusableHeaderFooterView( + withIdentifier: RecruitProfilePostAddTableFooterView.identifier + ) as? RecruitProfilePostAddTableFooterView else { return nil } + + footer.configure(title: "활동이력 추가") + footerSubscriptions.removeAll() + footer.addButtonTappedPublisher + .sink { [weak self] in self?.addActivity() } + .store(in: &footerSubscriptions) + return footer + } + + private func displayCell(for indexPath: IndexPath) -> UITableViewCell { + guard let cell = dequeueReusableCell( + withIdentifier: RecruitProfilePostActivityDisplayTableViewCell.identifier, + for: indexPath + ) as? RecruitProfilePostActivityDisplayTableViewCell, + let activity = rows[indexPath.row].committed else { + return UITableViewCell() + } + cell.configure(activity: activity) + cell.cellSubscriptions.removeAll() + cell.editButtonTappedPublisher + .sink { [weak self, weak cell] in + guard let self, let cell, let currentIndexPath = self.indexPath(for: cell) else { return } + editActivity(at: currentIndexPath) + } + .store(in: &cell.cellSubscriptions) + cell.deleteButtonTappedPublisher + .sink { [weak self, weak cell] in + guard let self, let cell, let currentIndexPath = self.indexPath(for: cell) else { return } + deleteActivity(at: currentIndexPath) + } + .store(in: &cell.cellSubscriptions) + return cell + } + + private func editCell(for indexPath: IndexPath) -> UITableViewCell { + guard let cell = dequeueReusableCell( + withIdentifier: RecruitProfilePostActivityEditTableViewCell.identifier, + for: indexPath + ) as? RecruitProfilePostActivityEditTableViewCell else { + return UITableViewCell() + } + + if let dropdownHost { + cell.prepareDropdown(host: dropdownHost) + } + cell.configure(activity: rows[indexPath.row].draft) + cell.cellSubscriptions.removeAll() + cell.activityChangedPublisher + .sink { [weak self, weak cell] activity in + guard let self, + let cell, + let currentIndexPath = self.indexPath(for: cell), + rows.indices.contains(currentIndexPath.row) else { return } + rows[currentIndexPath.row].draft = activity + } + .store(in: &cell.cellSubscriptions) + cell.completeButtonTappedPublisher + .sink { [weak self, weak cell] in + guard let self, let cell, let currentIndexPath = self.indexPath(for: cell) else { return } + completeActivity(at: currentIndexPath) + } + .store(in: &cell.cellSubscriptions) + cell.deleteButtonTappedPublisher + .sink { [weak self, weak cell] in + guard let self, let cell, let currentIndexPath = self.indexPath(for: cell) else { return } + deleteActivity(at: currentIndexPath) + } + .store(in: &cell.cellSubscriptions) + return cell + } +} + +extension RecruitProfilePostActivityTableView: UITableViewDelegate { + func tableView( + _ tableView: UITableView, + heightForFooterInSection section: Int + ) -> CGFloat { + 40 + } +} + +extension RecruitProfilePostActivityTableView { + private func configureView() { + backgroundColor = .clear + separatorStyle = .none + rowHeight = UITableView.automaticDimension + estimatedRowHeight = 180 + sectionHeaderHeight = 0 + sectionFooterHeight = 40 + sectionHeaderTopPadding = 0 + showsVerticalScrollIndicator = false + isScrollEnabled = false + } + + private func commonInit() { + dataSource = self + delegate = self + register( + RecruitProfilePostActivityDisplayTableViewCell.self, + forCellReuseIdentifier: RecruitProfilePostActivityDisplayTableViewCell.identifier + ) + register( + RecruitProfilePostActivityEditTableViewCell.self, + forCellReuseIdentifier: RecruitProfilePostActivityEditTableViewCell.identifier + ) + register( + RecruitProfilePostAddTableFooterView.self, + forHeaderFooterViewReuseIdentifier: RecruitProfilePostAddTableFooterView.identifier + ) + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostAddTableFooterView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostAddTableFooterView.swift new file mode 100644 index 000000000..73e5e1395 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostAddTableFooterView.swift @@ -0,0 +1,63 @@ +// +// RecruitProfilePostAddTableFooterView.swift +// koin +// +// Created by 홍기정 on 9/21/26. +// + +import Combine +import SnapKit +import Then +import UIKit + +final class RecruitProfilePostAddTableFooterView: UITableViewHeaderFooterView { + + // MARK: - Properties + let addButtonTappedPublisher = PassthroughSubject() + + // MARK: - UI Components + private let addButton = UIButton(type: .system) + + // MARK: - Initializer + override init(reuseIdentifier: String?) { + super.init(reuseIdentifier: reuseIdentifier) + configureView() + addButton.addTarget(self, action: #selector(addButtonTapped), for: .touchUpInside) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(title: String) { + addButton.setAttributedTitle(NSAttributedString( + string: title, + attributes: [ + .font: UIFont.appFont(.pretendardRegular, size: 14), + .foregroundColor: UIColor.appColor(.new500) + ] + ), for: .normal) + } + + // MARK: - Objc + @objc private func addButtonTapped() { + addButtonTappedPublisher.send() + } +} + +extension RecruitProfilePostAddTableFooterView { + private func configureView() { + contentView.backgroundColor = .appColor(.newBackground) + addButton.do { + $0.backgroundColor = .appColor(.neutral0) + $0.layer.cornerRadius = 16 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.appColor(.new500).cgColor + } + contentView.addSubview(addButton) + addButton.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.height.equalTo(40) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift new file mode 100644 index 000000000..51fdcb696 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift @@ -0,0 +1,174 @@ +// +// RecruitProfilePostSkillTableView.swift +// koin +// +// Created by 홍기정 on 9/21/26. +// + +import Combine +import UIKit + +final class RecruitProfilePostSkillTableView: UITableView { + + private enum PendingSizeChange { + case insert(IndexPath) + case delete(IndexPath) + } + + // MARK: - Properties + let skillsChangedPublisher = PassthroughSubject<[String], Never>() + let didChangeHeightPublisher = PassthroughSubject() + + private var skills: [String] = [] + private var footerSubscriptions = Set() + private var pendingSizeChange: PendingSizeChange? + + // MARK: - Initializer + init() { + super.init(frame: .zero, style: .plain) + configureView() + commonInit() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Override + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: CGFloat(skills.count * 48 + 40)) + } + + // MARK: - Public + func configure(skills: [String]) { + self.skills = skills + pendingSizeChange = nil + invalidateIntrinsicContentSize() + reloadData() + } + + func applyPendingSizeChange() { + guard let pendingSizeChange else { return } + self.pendingSizeChange = nil + invalidateIntrinsicContentSize() + performBatchUpdates { + switch pendingSizeChange { + case let .insert(indexPath): + insertRows(at: [indexPath], with: .automatic) + case let .delete(indexPath): + deleteRows(at: [indexPath], with: .automatic) + } + } + } +} + +extension RecruitProfilePostSkillTableView { + private func addSkill() { + let indexPath = IndexPath(row: skills.count, section: 0) + skills.append("") + pendingSizeChange = .insert(indexPath) + didChangeHeightPublisher.send() + skillsChangedPublisher.send(skills) + } + + private func deleteSkill(at indexPath: IndexPath) { + guard skills.indices.contains(indexPath.row) else { return } + skills.remove(at: indexPath.row) + pendingSizeChange = .delete(indexPath) + didChangeHeightPublisher.send() + skillsChangedPublisher.send(skills) + } +} + +extension RecruitProfilePostSkillTableView: UITableViewDataSource { + func tableView( + _ tableView: UITableView, + numberOfRowsInSection section: Int + ) -> Int { + skills.count + } + + func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + guard let cell = dequeueReusableCell( + withIdentifier: RecruitProfilePostSkillTableViewCell.identifier, + for: indexPath + ) as? RecruitProfilePostSkillTableViewCell else { + return UITableViewCell() + } + + cell.configure(text: skills[indexPath.row]) + cell.cellSubscriptions.removeAll() + cell.textChangedPublisher + .sink { [weak self, weak cell] text in + guard let self, + let cell, + let currentIndexPath = self.indexPath(for: cell), + skills.indices.contains(currentIndexPath.row) else { return } + skills[currentIndexPath.row] = text + skillsChangedPublisher.send(skills) + } + .store(in: &cell.cellSubscriptions) + cell.deleteButtonTappedPublisher + .sink { [weak self, weak cell] in + guard let self, + let cell, + let currentIndexPath = self.indexPath(for: cell) else { return } + deleteSkill(at: currentIndexPath) + } + .store(in: &cell.cellSubscriptions) + return cell + } + + func tableView( + _ tableView: UITableView, + viewForFooterInSection section: Int + ) -> UIView? { + guard let footer = dequeueReusableHeaderFooterView( + withIdentifier: RecruitProfilePostAddTableFooterView.identifier + ) as? RecruitProfilePostAddTableFooterView else { return nil } + + footer.configure(title: "보유기술/자격증 추가") + footerSubscriptions.removeAll() + footer.addButtonTappedPublisher + .sink { [weak self] in self?.addSkill() } + .store(in: &footerSubscriptions) + return footer + } +} + +extension RecruitProfilePostSkillTableView: UITableViewDelegate { + func tableView( + _ tableView: UITableView, + heightForFooterInSection section: Int + ) -> CGFloat { + 40 + } +} + +extension RecruitProfilePostSkillTableView { + private func configureView() { + backgroundColor = .clear + separatorStyle = .none + rowHeight = 48 + sectionHeaderHeight = 0 + sectionFooterHeight = 40 + sectionHeaderTopPadding = 0 + showsVerticalScrollIndicator = false + isScrollEnabled = false + } + + private func commonInit() { + dataSource = self + delegate = self + register( + RecruitProfilePostSkillTableViewCell.self, + forCellReuseIdentifier: RecruitProfilePostSkillTableViewCell.identifier + ) + register( + RecruitProfilePostAddTableFooterView.self, + forHeaderFooterViewReuseIdentifier: RecruitProfilePostAddTableFooterView.identifier + ) + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableViewCell.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableViewCell.swift new file mode 100644 index 000000000..192851729 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableViewCell.swift @@ -0,0 +1,129 @@ +// +// RecruitProfilePostSkillTableViewCell.swift +// koin +// +// Created by 홍기정 on 9/21/26. +// + +import Combine +import SnapKit +import Then +import UIKit + +final class RecruitProfilePostSkillTableViewCell: UITableViewCell { + + // MARK: - Properties + let textChangedPublisher = PassthroughSubject() + let deleteButtonTappedPublisher = PassthroughSubject() + var cellSubscriptions = Set() + + // MARK: - UI Components + private let containerView = UIView() + private let textField = UITextField() + private let deleteButton = UIButton(type: .system) + + // MARK: - Initializer + override init( + style: UITableViewCell.CellStyle, + reuseIdentifier: String? + ) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configureView() + setAddTargets() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - PrepareForReuse + override func prepareForReuse() { + super.prepareForReuse() + cellSubscriptions.removeAll() + textField.text = nil + } + + // MARK: - Public + func configure(text: String) { + textField.text = text + } +} + +extension RecruitProfilePostSkillTableViewCell { + private func setAddTargets() { + textField.addTarget(self, action: #selector(textFieldEditingChanged), for: .editingChanged) + textField.addTarget(self, action: #selector(textFieldEditingDidEnd), for: .editingDidEnd) + deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside) + } + + @objc private func textFieldEditingChanged() { + textChangedPublisher.send(textField.text ?? "") + } + + @objc private func textFieldEditingDidEnd() { + let text = (textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + textField.text = text + textChangedPublisher.send(text) + } + + @objc private func deleteButtonTapped() { + deleteButtonTappedPublisher.send() + } +} + +extension RecruitProfilePostSkillTableViewCell { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + + containerView.do { + $0.backgroundColor = .appColor(.neutral0) + $0.layer.cornerRadius = 16 + } + textField.do { + $0.font = .appFont(.pretendardRegular, size: 14) + $0.textColor = .appColor(.neutral800) + $0.attributedPlaceholder = NSAttributedString( + string: "보유기술 또는 자격증을 입력해주세요.", + attributes: [ + .font: UIFont.appFont(.pretendardRegular, size: 14), + .foregroundColor: UIColor.appColor(.neutral500) + ] + ) + } + deleteButton.do { + $0.setImage(UIImage.appImage(asset: .newCancel)?.resize(to: CGSize(width: 20, height: 20)), for: .normal) + $0.tintColor = .appColor(.neutral800) + } + } + private func setUpLayouts() { + [textField, deleteButton].forEach { + containerView.addSubview($0) + } + [containerView].forEach { + contentView.addSubview($0) + } + } + private func setUpConstraints() { + containerView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview().offset(-8) + $0.height.equalTo(40) + } + deleteButton.snp.makeConstraints { + $0.top.bottom.trailing.equalToSuperview() + $0.width.equalTo(44) + } + textField.snp.makeConstraints { + $0.leading.equalToSuperview().offset(12) + $0.top.bottom.equalToSuperview() + $0.trailing.equalTo(deleteButton.snp.leading) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostSecondStepView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostSecondStepView.swift deleted file mode 100644 index 41a8617fa..000000000 --- a/Koin/Presentation/Recruit/RecruitProfilePost/Subviews/RecruitProfilePostSecondStepView.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// RecruitProfilePostSecondStepView.swift -// koin -// -// Created by 홍기정 on 9/15/26. -// - -import UIKit - -final class RecruitProfilePostSecondStepView: UIScrollView { - - init() { - super.init(frame: .zero) - backgroundColor = .appColor(.newBackground) - showsVerticalScrollIndicator = false - alwaysBounceVertical = true - keyboardDismissMode = .interactive - contentInsetAdjustmentBehavior = .never - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 5b27c394b..ff3205c71 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -806,6 +806,12 @@ A0B343754988C9398C4FE726 /* ModifyRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */; }; A1D000010000000000000001 /* FetchRecruitDataUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D000010000000000000002 /* FetchRecruitDataUseCase.swift */; }; A1E000010000000000000001 /* RecruitPostRoleTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E000010000000000000002 /* RecruitPostRoleTableView.swift */; }; + A51C00000000000000000001 /* RecruitProfilePostAddTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B00000000000000000001 /* RecruitProfilePostAddTableFooterView.swift */; }; + A51C00000000000000000002 /* RecruitProfilePostSkillTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B00000000000000000002 /* RecruitProfilePostSkillTableView.swift */; }; + A51C00000000000000000003 /* RecruitProfilePostSkillTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B00000000000000000003 /* RecruitProfilePostSkillTableViewCell.swift */; }; + A51C00000000000000000004 /* RecruitProfilePostActivityTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B00000000000000000004 /* RecruitProfilePostActivityTableView.swift */; }; + A51C00000000000000000005 /* RecruitProfilePostActivityDisplayTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B00000000000000000005 /* RecruitProfilePostActivityDisplayTableViewCell.swift */; }; + A51C00000000000000000006 /* RecruitProfilePostActivityEditTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B00000000000000000006 /* RecruitProfilePostActivityEditTableViewCell.swift */; }; AA20000000000000000001 /* PublisherTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000001 /* PublisherTestSupport.swift */; }; AA20000000000000000002 /* DiningFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000002 /* DiningFixtures.swift */; }; AA20000000000000000003 /* SpyDiningRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000003 /* SpyDiningRepository.swift */; }; @@ -1916,6 +1922,12 @@ A001E2B42845091D00D6C310 /* koin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = koin.app; sourceTree = BUILT_PRODUCTS_DIR; }; A1D000010000000000000002 /* FetchRecruitDataUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitDataUseCase.swift; sourceTree = ""; }; A1E000010000000000000002 /* RecruitPostRoleTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostRoleTableView.swift; sourceTree = ""; }; + A51B00000000000000000001 /* RecruitProfilePostAddTableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostAddTableFooterView.swift; sourceTree = ""; }; + A51B00000000000000000002 /* RecruitProfilePostSkillTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostSkillTableView.swift; sourceTree = ""; }; + A51B00000000000000000003 /* RecruitProfilePostSkillTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostSkillTableViewCell.swift; sourceTree = ""; }; + A51B00000000000000000004 /* RecruitProfilePostActivityTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostActivityTableView.swift; sourceTree = ""; }; + A51B00000000000000000005 /* RecruitProfilePostActivityDisplayTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostActivityDisplayTableViewCell.swift; sourceTree = ""; }; + A51B00000000000000000006 /* RecruitProfilePostActivityEditTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostActivityEditTableViewCell.swift; sourceTree = ""; }; AA10000000000000000001 /* PublisherTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublisherTestSupport.swift; sourceTree = ""; }; AA10000000000000000002 /* DiningFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningFixtures.swift; sourceTree = ""; }; AA10000000000000000003 /* SpyDiningRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpyDiningRepository.swift; sourceTree = ""; }; @@ -3497,6 +3509,50 @@ path = LostItemKeywordLeftAlignedCollectionView; sourceTree = ""; }; + 7CB8EB4A30615FC500300E99 /* FirstStepView */ = { + isa = PBXGroup; + children = ( + 7CB8EB4C30615FCE00300E99 /* Subviews */, + 7CB8EB4230584FC100300E99 /* RecruitProfilePostFirstStepView.swift */, + ); + path = FirstStepView; + sourceTree = ""; + }; + 7CB8EB4B30615FCB00300E99 /* SecondStepView */ = { + isa = PBXGroup; + children = ( + 7CB8EB4D30615FDB00300E99 /* Subviews */, + 7CB8EB4430584FC500300E99 /* RecruitProfilePostSecondStepView.swift */, + ); + path = SecondStepView; + sourceTree = ""; + }; + 7CB8EB4C30615FCE00300E99 /* Subviews */ = { + isa = PBXGroup; + children = ( + EFEC522ED41CF624AD3CB3A6 /* RecruitStepView.swift */, + 7CDCD0E5305695E8004204DA /* RecruitSectionHeaderView.swift */, + 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */, + 7CDCD0E73056A000004204DA /* RecruitProfilePostDepartmentTableView.swift */, + 7CDCD0E93056A000004204DA /* RecruitProfilePostDepartmentTableViewCell.swift */, + 7CB8EB47305857A800300E99 /* RecruitDropdownTriggerButton.swift */, + ); + path = Subviews; + sourceTree = ""; + }; + 7CB8EB4D30615FDB00300E99 /* Subviews */ = { + isa = PBXGroup; + children = ( + A51B00000000000000000002 /* RecruitProfilePostSkillTableView.swift */, + A51B00000000000000000003 /* RecruitProfilePostSkillTableViewCell.swift */, + A51B00000000000000000004 /* RecruitProfilePostActivityTableView.swift */, + A51B00000000000000000005 /* RecruitProfilePostActivityDisplayTableViewCell.swift */, + A51B00000000000000000006 /* RecruitProfilePostActivityEditTableViewCell.swift */, + A51B00000000000000000001 /* RecruitProfilePostAddTableFooterView.swift */, + ); + path = Subviews; + sourceTree = ""; + }; 7CC42B062F56A7E900940CE1 /* ZoomedImageViewController */ = { isa = PBXGroup; children = ( @@ -3770,28 +3826,14 @@ 7CDCD0D93055EF87004204DA /* RecruitProfilePost */ = { isa = PBXGroup; children = ( - 7CDCD0DE3055EFB1004204DA /* Subviews */, + 7CB8EB4A30615FC500300E99 /* FirstStepView */, + 7CB8EB4B30615FCB00300E99 /* SecondStepView */, 7CDCD0DA3055EFA7004204DA /* RecruitProfilePostViewController.swift */, 7CDCD0DC3055EFA9004204DA /* RecruitProfilePostViewModel.swift */, ); path = RecruitProfilePost; sourceTree = ""; }; - 7CDCD0DE3055EFB1004204DA /* Subviews */ = { - isa = PBXGroup; - children = ( - 7CB8EB4230584FC100300E99 /* RecruitProfilePostFirstStepView.swift */, - 7CB8EB4430584FC500300E99 /* RecruitProfilePostSecondStepView.swift */, - EFEC522ED41CF624AD3CB3A6 /* RecruitStepView.swift */, - 7CDCD0E5305695E8004204DA /* RecruitSectionHeaderView.swift */, - 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */, - 7CDCD0E73056A000004204DA /* RecruitProfilePostDepartmentTableView.swift */, - 7CDCD0E93056A000004204DA /* RecruitProfilePostDepartmentTableViewCell.swift */, - 7CB8EB47305857A800300E99 /* RecruitDropdownTriggerButton.swift */, - ); - path = Subviews; - sourceTree = ""; - }; 7CED8EC52EF2D1F900457128 /* ShopCollectionView */ = { isa = PBXGroup; children = ( @@ -7308,6 +7350,12 @@ 7CDCD0E6305695E8004204DA /* RecruitSectionHeaderView.swift in Sources */, 7CDCD0E83056A000004204DA /* RecruitProfilePostDepartmentTableView.swift in Sources */, 7CDCD0EA3056A000004204DA /* RecruitProfilePostDepartmentTableViewCell.swift in Sources */, + A51C00000000000000000001 /* RecruitProfilePostAddTableFooterView.swift in Sources */, + A51C00000000000000000002 /* RecruitProfilePostSkillTableView.swift in Sources */, + A51C00000000000000000003 /* RecruitProfilePostSkillTableViewCell.swift in Sources */, + A51C00000000000000000004 /* RecruitProfilePostActivityTableView.swift in Sources */, + A51C00000000000000000005 /* RecruitProfilePostActivityDisplayTableViewCell.swift in Sources */, + A51C00000000000000000006 /* RecruitProfilePostActivityEditTableViewCell.swift in Sources */, B154A7D97E6AAEC2ED3D8119 /* MockFetchDeptListUseCase.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; From 9258d5829e4973c8342e8af9936c38c5d9b93596 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 21 Sep 2026 23:15:33 +0900 Subject: [PATCH 029/123] =?UTF-8?q?chore:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=ED=94=84=EB=A1=9C=ED=95=84=201=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...uitProfilePostDepartmentDropdownView.swift | 106 ++++++++++++++---- ...ecruitProfilePostDepartmentTableView.swift | 79 ------------- ...itProfilePostDepartmentTableViewCell.swift | 50 --------- .../RecruitProfilePostViewModel.swift | 8 ++ koin.xcodeproj/project.pbxproj | 8 -- 5 files changed, 93 insertions(+), 158 deletions(-) delete mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentTableView.swift delete mode 100644 Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentTableViewCell.swift diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentDropdownView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentDropdownView.swift index 7fb5c98ff..44e429ad9 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentDropdownView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentDropdownView.swift @@ -14,7 +14,6 @@ final class RecruitProfilePostDepartmentDropdownView: UIView, KoinDropdownConten // MARK: - Properties private let dismissTappedSubject = PassthroughSubject() - private var subscriptions = Set() var dismissTappedPublisher: AnyPublisher { dismissTappedSubject.eraseToAnyPublisher() @@ -28,7 +27,9 @@ final class RecruitProfilePostDepartmentDropdownView: UIView, KoinDropdownConten private var departments: [String] = [] // MARK: - UI Component - private let tableView = RecruitProfilePostDepartmentTableView() + private let scrollView = UIScrollView() + private let stackView = UIStackView() + private var buttons: [UIButton] = [] // MARK: - Initializer init( @@ -37,7 +38,6 @@ final class RecruitProfilePostDepartmentDropdownView: UIView, KoinDropdownConten self.onSelect = onSelect super.init(frame: .zero) configureView() - bind() } @available(*, unavailable) required init?(coder: NSCoder) { @@ -47,32 +47,96 @@ final class RecruitProfilePostDepartmentDropdownView: UIView, KoinDropdownConten // MARK: - Public func configure(departments: [String]) { self.departments = departments - tableView.configure(departments: departments) + buttons.forEach { + stackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + buttons = departments.enumerated().map { makeButton(for: $0.element, at: $0.offset) } + buttons.forEach { + stackView.addArrangedSubview($0) + $0.snp.makeConstraints { + $0.height.equalTo(34) + } + } + scrollView.isScrollEnabled = departments.count > 5 } } extension RecruitProfilePostDepartmentDropdownView { - private func bind() { - tableView.departmentSelectedPublisher - .sink { [weak self] department in - guard let self else { return } - onSelect(department) - dismissTappedSubject.send() - } - .store(in: &subscriptions) + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpLayouts() { + addSubview(scrollView) + scrollView.addSubview(stackView) + } + + private func setUpConstraints() { + scrollView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + stackView.snp.makeConstraints { + $0.top.bottom.equalTo(scrollView.contentLayoutGuide).inset(6) + $0.leading.trailing.equalTo(scrollView.contentLayoutGuide) + $0.width.equalTo(scrollView.frameLayoutGuide) + } + } + + private func setUpStyles() { + self.do { + $0.backgroundColor = .appColor(.neutral0) + $0.layer.cornerRadius = 16 + $0.clipsToBounds = true + } + + scrollView.do { + $0.showsVerticalScrollIndicator = false + $0.layer.cornerRadius = 16 + $0.clipsToBounds = true + } + + stackView.do { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .fill + $0.spacing = 0 + } } } extension RecruitProfilePostDepartmentDropdownView { - private func configureView() { - backgroundColor = .appColor(.neutral0) - layer.cornerRadius = 16 - clipsToBounds = true - - addSubview(tableView) - tableView.snp.makeConstraints { - $0.horizontalEdges.equalToSuperview() - $0.verticalEdges.equalToSuperview().inset(6) + private func makeButton(for department: String, at index: Int) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.attributedTitle = AttributedString( + department, + attributes: AttributeContainer([ + .font: UIFont.appFont(.pretendardRegular, size: 14), + .foregroundColor: UIColor.appColor(.neutral800) + ]) + ) + configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 0) + + let button = UIButton(configuration: configuration) + + button.contentHorizontalAlignment = .leading + button.configurationUpdateHandler = { button in + var configuration = button.configuration + let backgroundColor = UIColor.appColor(button.isHighlighted ? .neutral100 : .neutral0) + configuration?.background.backgroundColor = backgroundColor + button.configuration = configuration } + button.tag = index + button.addTarget(self, action: #selector(departmentButtonTapped(_:)), for: .touchUpInside) + + return button + } + + @objc private func departmentButtonTapped(_ sender: UIButton) { + guard departments.indices.contains(sender.tag) else { return } + onSelect(departments[sender.tag]) + dismissTappedSubject.send() } } diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentTableView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentTableView.swift deleted file mode 100644 index d4e4b7668..000000000 --- a/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentTableView.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// RecruitProfilePostDepartmentTableView.swift -// koin -// -// Created by 홍기정 on 9/14/26. -// - -import Combine -import Then -import UIKit - -final class RecruitProfilePostDepartmentTableView: UITableView { - - // MARK: - Publisher - let departmentSelectedPublisher = PassthroughSubject() - - // MARK: - Properties - private var departments: [String] = [] - - // MARK: - Initializer - init() { - super.init(frame: .zero, style: .plain) - configureView() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Public - func configure(departments: [String]) { - self.departments = departments - reloadData() - isScrollEnabled = departments.count > 5 - } -} - -extension RecruitProfilePostDepartmentTableView: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - departments.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell( - withIdentifier: RecruitProfilePostDepartmentTableViewCell.identifier, - for: indexPath - ) as? RecruitProfilePostDepartmentTableViewCell else { - return UITableViewCell() - } - cell.configure(department: departments[indexPath.row]) - return cell - } -} - -extension RecruitProfilePostDepartmentTableView: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - departmentSelectedPublisher.send(departments[indexPath.row]) - } -} - -extension RecruitProfilePostDepartmentTableView { - private func configureView() { - backgroundColor = .clear - separatorStyle = .none - rowHeight = 34 - sectionHeaderHeight = 0 - sectionFooterHeight = 0 - sectionHeaderTopPadding = 0 - contentInset = .zero - showsVerticalScrollIndicator = false - dataSource = self - delegate = self - register( - RecruitProfilePostDepartmentTableViewCell.self, - forCellReuseIdentifier: RecruitProfilePostDepartmentTableViewCell.identifier - ) - } -} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentTableViewCell.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentTableViewCell.swift deleted file mode 100644 index af4a48a4a..000000000 --- a/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentTableViewCell.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// RecruitProfilePostDepartmentTableViewCell.swift -// koin -// -// Created by 홍기정 on 9/14/26. -// - -import SnapKit -import Then -import UIKit - -final class RecruitProfilePostDepartmentTableViewCell: UITableViewCell { - - // MARK: - UI Component - private let departmentLabel = UILabel() - - // MARK: - Initializer - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - configureView() - } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Public - func configure(department: String) { - departmentLabel.text = department - } -} - -extension RecruitProfilePostDepartmentTableViewCell { - private func configureView() { - backgroundColor = .clear - contentView.backgroundColor = .clear - - departmentLabel.do { - $0.font = .appFont(.pretendardRegular, size: 14) - $0.textColor = .appColor(.neutral800) - } - - contentView.addSubview(departmentLabel) - - departmentLabel.snp.makeConstraints { - $0.top.bottom.equalToSuperview() - $0.leading.trailing.equalToSuperview().inset(12) - } - } -} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift index 785b2aa44..60e1ce181 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift @@ -55,6 +55,14 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { switch input { case .viewDidLoad: fetchDepartments() + + switch mode { + case .post: + return + case .modify(let recruitProfile): + let basicInfo = recruitProfile.toBasicInfo() + self.outputSubject.send(.updateBasicInfo(basicInfo)) + } case .loadUserData: fetchUserData() } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index ff3205c71..6c8678a00 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -482,8 +482,6 @@ 7CDCD0E2305602F6004204DA /* RecruitProfileActivityRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E1305602F6004204DA /* RecruitProfileActivityRequest.swift */; }; 7CDCD0E430560758004204DA /* BasicInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E330560758004204DA /* BasicInfo.swift */; }; 7CDCD0E6305695E8004204DA /* RecruitSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E5305695E8004204DA /* RecruitSectionHeaderView.swift */; }; - 7CDCD0E83056A000004204DA /* RecruitProfilePostDepartmentTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E73056A000004204DA /* RecruitProfilePostDepartmentTableView.swift */; }; - 7CDCD0EA3056A000004204DA /* RecruitProfilePostDepartmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E93056A000004204DA /* RecruitProfilePostDepartmentTableViewCell.swift */; }; 7CEB3B4B2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */; }; 7CED90BC2EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FD02EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift */; }; 7CED90BD2EF2D1F900457128 /* ManageNoticeKeywordViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F612EF2D1F900457128 /* ManageNoticeKeywordViewController.swift */; }; @@ -1594,8 +1592,6 @@ 7CDCD0E1305602F6004204DA /* RecruitProfileActivityRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileActivityRequest.swift; sourceTree = ""; }; 7CDCD0E330560758004204DA /* BasicInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicInfo.swift; sourceTree = ""; }; 7CDCD0E5305695E8004204DA /* RecruitSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitSectionHeaderView.swift; sourceTree = ""; }; - 7CDCD0E73056A000004204DA /* RecruitProfilePostDepartmentTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostDepartmentTableView.swift; sourceTree = ""; }; - 7CDCD0E93056A000004204DA /* RecruitProfilePostDepartmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostDepartmentTableViewCell.swift; sourceTree = ""; }; 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteDeviceTokenUseCase.swift; sourceTree = ""; }; 7CED8EC32EF2D1F900457128 /* ShopCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCollectionView.swift; sourceTree = ""; }; 7CED8EC42EF2D1F900457128 /* ShopCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCollectionViewCell.swift; sourceTree = ""; }; @@ -3533,8 +3529,6 @@ EFEC522ED41CF624AD3CB3A6 /* RecruitStepView.swift */, 7CDCD0E5305695E8004204DA /* RecruitSectionHeaderView.swift */, 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */, - 7CDCD0E73056A000004204DA /* RecruitProfilePostDepartmentTableView.swift */, - 7CDCD0E93056A000004204DA /* RecruitProfilePostDepartmentTableViewCell.swift */, 7CB8EB47305857A800300E99 /* RecruitDropdownTriggerButton.swift */, ); path = Subviews; @@ -7348,8 +7342,6 @@ F7F814BB370C922B6D8AA6EF /* RecruitStepView.swift in Sources */, 247DF973D072E88901E5C249 /* RecruitProfilePostDepartmentDropdownView.swift in Sources */, 7CDCD0E6305695E8004204DA /* RecruitSectionHeaderView.swift in Sources */, - 7CDCD0E83056A000004204DA /* RecruitProfilePostDepartmentTableView.swift in Sources */, - 7CDCD0EA3056A000004204DA /* RecruitProfilePostDepartmentTableViewCell.swift in Sources */, A51C00000000000000000001 /* RecruitProfilePostAddTableFooterView.swift in Sources */, A51C00000000000000000002 /* RecruitProfilePostSkillTableView.swift in Sources */, A51C00000000000000000003 /* RecruitProfilePostSkillTableViewCell.swift in Sources */, From f79e4116ac6019768b74e5d524bd1a63ab2eebf0 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 21 Sep 2026 23:55:55 +0900 Subject: [PATCH 030/123] =?UTF-8?q?design:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/MockRecruitRepository.swift | 28 +++++++++++++ .../Domain/Repository/RecruitRepository.swift | 2 + .../RecruitProfile/PostBasicInfoUseCase.swift | 24 ++++++++++++ .../PostRecruitProfileUseCase.swift | 24 ++++++++++++ .../RecruitProfileHostingController.swift | 15 +++++-- .../RecruitProfile/RecruitProfileView.swift | 7 +++- .../RecruitProfileViewModel.swift | 3 ++ .../RecruitProfilePostViewController.swift | 39 ++++++++++++++++++- .../RecruitProfilePostViewModel.swift | 37 ++++++++++++++++++ koin.xcodeproj/project.pbxproj | 8 ++++ 10 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 Koin/Domain/UseCase/Recruit/RecruitProfile/PostBasicInfoUseCase.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitProfile/PostRecruitProfileUseCase.swift diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 2f70dc1aa..748f2d18d 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -8,6 +8,34 @@ import Foundation final class MockRecruitRepository: RecruitRepository { + func postBasicInfo(_ basicInfo: BasicInfo) async throws -> BasicInfo { + try await Task.sleep(nanoseconds: 300_000_000) + return basicInfo + } + + func postRecruitProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile { + try await Task.sleep(nanoseconds: 300_000_000) + + return RecruitProfile( + nickname: "홍길동", + department: "컴퓨터공학부", + studentNumber: "2023100000", + preferredRole: request.preferredRole ?? "", + skills: request.skills, + activities: request.activities.enumerated().map { index, activity in + RecruitProfileActivity( + id: index + 1, + title: activity.title ?? "", + startedAt: activity.startedAt ?? Date(), + endedAt: activity.endedAt, + isOngoing: activity.isOngoing, + description: activity.description ?? "" + ) + }, + selfIntroduction: request.introduction ?? "" + ) + } + func fetchMyProfile() async throws -> RecruitProfile { guard UserDataManager.shared.isLoggedIn else { throw ErrorResponse( diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index fd4f6bd89..bd0c9b565 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -19,4 +19,6 @@ protocol RecruitRepository { func fetchData(_ id: Int) async throws -> RecruitData func deleteData(id: Int) async throws -> Bool func fetchMyProfile() async throws -> RecruitProfile + func postBasicInfo(_ basicInfo: BasicInfo) async throws -> BasicInfo + func postRecruitProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile } diff --git a/Koin/Domain/UseCase/Recruit/RecruitProfile/PostBasicInfoUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitProfile/PostBasicInfoUseCase.swift new file mode 100644 index 000000000..372615d11 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitProfile/PostBasicInfoUseCase.swift @@ -0,0 +1,24 @@ +// +// PostBasicInfoUseCase.swift +// koin +// +// Created by 홍기정 on 9/21/26. +// + +import Foundation + +protocol PostBasicInfoUseCase { + func execute(basicInfo: BasicInfo) async throws -> BasicInfo +} + +final class DefaultPostBasicInfoUseCase: PostBasicInfoUseCase { + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(basicInfo: BasicInfo) async throws -> BasicInfo { + try await repository.postBasicInfo(basicInfo) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitProfile/PostRecruitProfileUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitProfile/PostRecruitProfileUseCase.swift new file mode 100644 index 000000000..589ae2054 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitProfile/PostRecruitProfileUseCase.swift @@ -0,0 +1,24 @@ +// +// PostRecruitProfileUseCase.swift +// koin +// +// Created by 홍기정 on 9/21/26. +// + +import Foundation + +protocol PostRecruitProfileUseCase { + func execute(request: RecruitProfileRequest) async throws -> RecruitProfile +} + +final class DefaultPostRecruitProfileUseCase: PostRecruitProfileUseCase { + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(request: RecruitProfileRequest) async throws -> RecruitProfile { + try await repository.postRecruitProfile(request) + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift index c836f98c5..b5575db68 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift @@ -70,11 +70,20 @@ extension RecruitProfileHostingController { mode: RecruitProfilePostViewModel.Mode ) -> RecruitProfilePostViewController { let userRepository = DefaultUserRepository(service: DefaultUserService()) + let recruitRepository = MockRecruitRepository() + let fetchDeptListUseCase = MockFetchDeptListUseCase() + let fetchUserDataUseCase = DefaultFetchUserDataUseCase(userRepository: userRepository) + let postBasicInfoUseCase = DefaultPostBasicInfoUseCase(repository: recruitRepository) + let postRecruitProfileUseCase = DefaultPostRecruitProfileUseCase(repository: recruitRepository) let viewModel = RecruitProfilePostViewModel( - fetchDeptListUseCase: MockFetchDeptListUseCase(), - fetchUserDataUseCase: DefaultFetchUserDataUseCase(userRepository: userRepository), + fetchDeptListUseCase: fetchDeptListUseCase, + fetchUserDataUseCase: fetchUserDataUseCase, + postBasicInfoUseCase: postBasicInfoUseCase, + postRecruitProfileUseCase: postRecruitProfileUseCase, mode: mode ) - return RecruitProfilePostViewController(viewModel: viewModel) + return RecruitProfilePostViewController(viewModel: viewModel) { [weak self] profile in + self?.rootView.updateProfile(profile) + } } } diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift index 97ed11eb4..420e14a2b 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift @@ -24,6 +24,11 @@ struct RecruitProfileView: ActionBindableView { init(viewModel: RecruitProfileViewModel) { self.viewModel = viewModel } + + // MARK: - Public + func updateProfile(_ profile: RecruitProfile) { + viewModel.execute(.profileUpdated(profile)) + } // MARK: - Body var body: some View { @@ -65,7 +70,7 @@ struct RecruitProfileView: ActionBindableView { } .background(Color.appColor(.newBackground).ignoresSafeArea()) .loadingOverlay(viewModel.isLoading) - .onAppear { + .onFirstAppear { viewModel.execute(.didAppear) } .onChange(of: viewModel.errorMessage) { diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift index 9ceeb161e..2f371d53a 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift @@ -14,6 +14,7 @@ final class RecruitProfileViewModel: SwiftUIViewModelProtocol { enum Input { case didAppear case didShowToast + case profileUpdated(RecruitProfile) } // MARK: - State @@ -37,6 +38,8 @@ final class RecruitProfileViewModel: SwiftUIViewModelProtocol { fetchMyProfile() case .didShowToast: errorMessage = nil + case let .profileUpdated(profile): + self.profile = profile } } } diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift index 597fda69f..5c3f38991 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift @@ -14,6 +14,7 @@ final class RecruitProfilePostViewController: UIViewController { // MARK: - Properties private let viewModel: RecruitProfilePostViewModel + private let onCompleted: (RecruitProfile) -> Void private let inputSubject = PassthroughSubject() private var subscriptions = Set() @@ -53,6 +54,14 @@ final class RecruitProfilePostViewController: UIViewController { return "수정하기" } } + private var completionMessage: String { + switch viewModel.mode { + case .post: + return "프로필이 저장되었습니다." + case .modify: + return "프로필이 수정되었습니다." + } + } // MARK: - UI Components private let scrollView = UIScrollView() @@ -99,8 +108,12 @@ final class RecruitProfilePostViewController: UIViewController { ) // MARK: - Initializer - init(viewModel: RecruitProfilePostViewModel) { + init( + viewModel: RecruitProfilePostViewModel, + onCompleted: @escaping (RecruitProfile) -> Void + ) { self.viewModel = viewModel + self.onCompleted = onCompleted switch viewModel.mode { case .post: self.request = RecruitProfileRequest() @@ -157,7 +170,8 @@ extension RecruitProfilePostViewController { return } let rightButtonAction = { [weak self] in - /* do something */ + guard let self else { return } + inputSubject.send(.submit(basicInfo: basicInfo, request: request)) } let modalViewController = KoinModalViewController( configuration: .init( @@ -206,6 +220,10 @@ extension RecruitProfilePostViewController { configureBasicInfo(info) case let .updateDepartments(departments): departmentDropdownContentView.configure(departments: departments) + case let .updateLoading(isLoading): + updateLoading(isLoading) + case let .postCompleted(profile): + handleCompletion(profile) case let .showToast(message): showToastMessage(message: message, bottomInset: 72) } @@ -302,6 +320,23 @@ extension RecruitProfilePostViewController { private func updateCompleteButtonState() { completeButton.updateState(isEnabled: request.isValid) } + + private func updateLoading(_ isLoading: Bool) { + view.isUserInteractionEnabled = !isLoading + navigationController?.view.isUserInteractionEnabled = !isLoading + + if isLoading { + IndicatorView.show() + } else { + IndicatorView.dismiss() + } + } + + private func handleCompletion(_ profile: RecruitProfile) { + onCompleted(profile) + navigationController?.popViewController(animated: true) + showToastMessage(message: completionMessage) + } } extension RecruitProfilePostViewController { diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift index 60e1ce181..8e23c559e 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift @@ -18,11 +18,14 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { enum Input { case viewDidLoad case loadUserData + case submit(basicInfo: BasicInfo, request: RecruitProfileRequest) } enum Output { case updateBasicInfo(BasicInfo) case updateDepartments([String]) + case updateLoading(Bool) + case postCompleted(RecruitProfile) case showToast(String) } @@ -32,19 +35,26 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { // MARK: - UseCase private let fetchDeptListUseCase: FetchDeptListUseCase private let fetchUserDataUseCase: FetchUserDataUseCase + private let postBasicInfoUseCase: PostBasicInfoUseCase + private let postRecruitProfileUseCase: PostRecruitProfileUseCase // MARK: - Publisher private let outputSubject = PassthroughSubject() private var subscriptions = Set() + private var isSubmitting = false // MARK: - Initializer init( fetchDeptListUseCase: FetchDeptListUseCase, fetchUserDataUseCase: FetchUserDataUseCase, + postBasicInfoUseCase: PostBasicInfoUseCase, + postRecruitProfileUseCase: PostRecruitProfileUseCase, mode: Mode ) { self.fetchDeptListUseCase = fetchDeptListUseCase self.fetchUserDataUseCase = fetchUserDataUseCase + self.postBasicInfoUseCase = postBasicInfoUseCase + self.postRecruitProfileUseCase = postRecruitProfileUseCase self.mode = mode } @@ -65,6 +75,8 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { } case .loadUserData: fetchUserData() + case let .submit(basicInfo, request): + submit(basicInfo: basicInfo, request: request) } } .store(in: &subscriptions) @@ -108,4 +120,29 @@ extension RecruitProfilePostViewModel { ) .store(in: &subscriptions) } + + private func submit( + basicInfo: BasicInfo, + request: RecruitProfileRequest + ) { + guard !isSubmitting else { return } + + isSubmitting = true + outputSubject.send(.updateLoading(true)) + + Task { + do { + _ = try await postBasicInfoUseCase.execute(basicInfo: basicInfo) + let profile = try await postRecruitProfileUseCase.execute(request: request) + isSubmitting = false + outputSubject.send(.updateLoading(false)) + outputSubject.send(.postCompleted(profile)) + } catch { + isSubmitting = false + outputSubject.send(.updateLoading(false)) + let message = (error as? ErrorResponse)?.message ?? "프로필 저장에 실패했습니다." + outputSubject.send(.showToast(message)) + } + } + } } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 6c8678a00..a0a64e3ab 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -852,6 +852,8 @@ BAD01845AA334A648E2FA2BD /* RecruitDataHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBC0EF8E0FE44F48744BEC7 /* RecruitDataHeaderView.swift */; }; C0DE00000000000000000001 /* RecruitProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000002 /* RecruitProfile.swift */; }; C0DE00000000000000000003 /* FetchMyProfileUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000004 /* FetchMyProfileUseCase.swift */; }; + A11B00000000000000000001 /* PostBasicInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11B00000000000000000002 /* PostBasicInfoUseCase.swift */; }; + A11B00000000000000000003 /* PostRecruitProfileUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11B00000000000000000004 /* PostRecruitProfileUseCase.swift */; }; C1838F7A4424DEA14DB2D107 /* RecruitPostCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */; }; CD3000000000000000000001 /* RecruitPostCategoryDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3000000000000000000002 /* RecruitPostCategoryDropdownView.swift */; }; D0A71C4F2F1B8340009E2D71 /* KoinDropdownHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */; }; @@ -1965,6 +1967,8 @@ B6A0D62B2EEB01F100A19521 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; C0DE00000000000000000002 /* RecruitProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfile.swift; sourceTree = ""; }; C0DE00000000000000000004 /* FetchMyProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMyProfileUseCase.swift; sourceTree = ""; }; + A11B00000000000000000002 /* PostBasicInfoUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostBasicInfoUseCase.swift; sourceTree = ""; }; + A11B00000000000000000004 /* PostRecruitProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRecruitProfileUseCase.swift; sourceTree = ""; }; CD3000000000000000000002 /* RecruitPostCategoryDropdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostCategoryDropdownView.swift; sourceTree = ""; }; D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdownHost.swift; sourceTree = ""; }; D19A00013000000000000001 /* NotificationRowModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRowModel.swift; sourceTree = ""; }; @@ -5220,6 +5224,8 @@ isa = PBXGroup; children = ( C0DE00000000000000000004 /* FetchMyProfileUseCase.swift */, + A11B00000000000000000002 /* PostBasicInfoUseCase.swift */, + A11B00000000000000000004 /* PostRecruitProfileUseCase.swift */, ); path = RecruitProfile; sourceTree = ""; @@ -6358,6 +6364,8 @@ 7CDCD0CB3055D63F004204DA /* RecruitProfileViewModel.swift in Sources */, C0DE00000000000000000001 /* RecruitProfile.swift in Sources */, C0DE00000000000000000003 /* FetchMyProfileUseCase.swift in Sources */, + A11B00000000000000000001 /* PostBasicInfoUseCase.swift in Sources */, + A11B00000000000000000003 /* PostRecruitProfileUseCase.swift in Sources */, D2D7C0DD2C282E2700C85A85 /* DefaultLandRepository.swift in Sources */, 7CEB3B4B2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift in Sources */, 7CC42B132F56AB9D00940CE1 /* CallVanListCollectionView.swift in Sources */, From ab8c15b77386acc9b2fe8572e2c2b04ed9b6de3a Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 22 Sep 2026 00:30:16 +0900 Subject: [PATCH 031/123] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Domain/Model/Recruit/BasicInfo.swift | 4 ++-- .../Recruit/RecruitProfile/RecruitProfileView.swift | 2 +- .../RecruitProfilePostViewController.swift | 3 ++- .../RecruitProfilePostSecondStepView.swift | 9 +++++++-- .../RecruitProfilePostActivityEditTableViewCell.swift | 2 +- .../Subviews/RecruitProfilePostActivityTableView.swift | 7 +++++++ .../Subviews/RecruitProfilePostSkillTableViewCell.swift | 2 +- 7 files changed, 21 insertions(+), 8 deletions(-) diff --git a/Koin/Domain/Model/Recruit/BasicInfo.swift b/Koin/Domain/Model/Recruit/BasicInfo.swift index 0007cd689..d93732e4a 100644 --- a/Koin/Domain/Model/Recruit/BasicInfo.swift +++ b/Koin/Domain/Model/Recruit/BasicInfo.swift @@ -16,9 +16,9 @@ struct BasicInfo { extension BasicInfo { var isValid: Bool { guard let nickname, - !nickname.isEmpty, + !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let department, - !department.isEmpty, + !department.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let studentNumber, !studentNumber.isEmpty else { return false diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift index 420e14a2b..76888fdf1 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift @@ -58,7 +58,7 @@ struct RecruitProfileView: ActionBindableView { title: viewModel.profile == nil ? "내가 지원한 모집글 모아보기" : "내가 지원한 모집글", description: "지원한 모집글과 지원 상태를 확인 할 수 있어요." ) { - sendAction(.showMyPosts) + sendAction(.showMyApplications) } } .padding(.top, 16) diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift index 5c3f38991..a57f531ff 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift @@ -303,6 +303,7 @@ extension RecruitProfilePostViewController { options: [.beginFromCurrentState, .allowUserInteraction] ) { self.secondStepView.applyPendingSizeChange() + self.updateCompleteButtonState() self.view.layoutIfNeeded() } } @@ -318,7 +319,7 @@ extension RecruitProfilePostViewController { } private func updateCompleteButtonState() { - completeButton.updateState(isEnabled: request.isValid) + completeButton.updateState(isEnabled: request.isValid && !secondStepView.isEditing) } private func updateLoading(_ isLoading: Bool) { diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift index 99efd6242..30acae5e5 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift @@ -21,7 +21,12 @@ final class RecruitProfilePostSecondStepView: UIScrollView { // MARK: - Properties private var subscriptions = Set() + + var isEditing: Bool { + activityTableView.hasEditingRow + } + // MARK: - UI Components private let contentStackView = UIStackView() private let stepView = RecruitStepView( firstStepTitle: "기본 정보", @@ -32,7 +37,7 @@ final class RecruitProfilePostSecondStepView: UIScrollView { title: "선호 역할", isRequired: true, limit: 20, - placeholder: "선호 역할을 작서해주세요." + placeholder: "선호 역할을 작성해주세요." ) private let skillSectionStackView = UIStackView() private let skillHeaderView = RecruitSectionHeaderView( @@ -50,7 +55,7 @@ final class RecruitProfilePostSecondStepView: UIScrollView { title: "자기소개", isRequired: true, limit: 1000, - placeholder: "자기소개를 입력해주세요." + placeholder: "자기소개를 작성해주세요." ) init() { diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityEditTableViewCell.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityEditTableViewCell.swift index 61539b552..5531cdf40 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityEditTableViewCell.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityEditTableViewCell.swift @@ -362,7 +362,7 @@ extension RecruitProfilePostActivityEditTableViewCell { $0.textContainer.lineFragmentPadding = 0 } descriptionPlaceholderLabel.do { - $0.text = "활동 내용을 입력해주세요." + $0.text = "활동 내용을 간단히 작성해주세요." $0.font = .appFont(.pretendardRegular, size: 14) $0.textColor = .appColor(.neutral500) } diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift index 257c85f2c..b552b3fef 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift @@ -38,6 +38,13 @@ final class RecruitProfilePostActivityTableView: UITableView { private var lastContentHeight: CGFloat = 0 private var pendingSizeChange: PendingSizeChange? + var hasEditingRow: Bool { + if let _ = rows.first(where: { $0.mode == .editing }) { + return true + } + return false + } + // MARK: - Initializer init() { super.init(frame: .zero, style: .plain) diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableViewCell.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableViewCell.swift index 192851729..b579e35e0 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableViewCell.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableViewCell.swift @@ -90,7 +90,7 @@ extension RecruitProfilePostSkillTableViewCell { $0.font = .appFont(.pretendardRegular, size: 14) $0.textColor = .appColor(.neutral800) $0.attributedPlaceholder = NSAttributedString( - string: "보유기술 또는 자격증을 입력해주세요.", + string: "기술 또는 자격증을 입력해주세요.", attributes: [ .font: UIFont.appFont(.pretendardRegular, size: 14), .foregroundColor: UIColor.appColor(.neutral500) From ccab9ae683daca4a72c5b5315a722ccdd84689cf Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Thu, 24 Sep 2026 06:12:31 +0900 Subject: [PATCH 032/123] =?UTF-8?q?chore:=20ImageAsset=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Asset/ImageAsset.swift | 4 ++-- .../Recruit/RecruitProfile/RecruitProfileView.swift | 4 ++-- .../Contents.json | 2 +- .../recruitProfileMyApplication.svg} | 0 .../Contents.json | 2 +- .../recruitProfileMyPost.svg} | 0 6 files changed, 6 insertions(+), 6 deletions(-) rename Koin/Resources/Assets.xcassets/Recruit/{recruitProfileMyApplications.imageset => recruitProfileMyApplication.imageset}/Contents.json (69%) rename Koin/Resources/Assets.xcassets/Recruit/{recruitProfileMyApplications.imageset/recruitProfileMyApplications.svg => recruitProfileMyApplication.imageset/recruitProfileMyApplication.svg} (100%) rename Koin/Resources/Assets.xcassets/Recruit/{recruitProfileMyPosts.imageset => recruitProfileMyPost.imageset}/Contents.json (71%) rename Koin/Resources/Assets.xcassets/Recruit/{recruitProfileMyPosts.imageset/recruitProfileMyPosts.svg => recruitProfileMyPost.imageset/recruitProfileMyPost.svg} (100%) diff --git a/Koin/Core/Asset/ImageAsset.swift b/Koin/Core/Asset/ImageAsset.swift index dd5d1a5bc..25fbde18a 100644 --- a/Koin/Core/Asset/ImageAsset.swift +++ b/Koin/Core/Asset/ImageAsset.swift @@ -237,8 +237,8 @@ public enum ImageAsset: String { case recruitPostMeetingTypeOnline case recruitPostRolePlus case recruitProfile - case recruitProfileMyApplications - case recruitProfileMyPosts + case recruitProfileMyApplication + case recruitProfileMyPost case recruitSearch // MARK: - Shop diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift index 76888fdf1..259043ab3 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift @@ -46,7 +46,7 @@ struct RecruitProfileView: ActionBindableView { } RecruitProfileActionCardView( - icon: .recruitProfileMyPosts, + icon: .recruitProfileMyPost, title: viewModel.profile == nil ? "내가 작성한 모집글 모아보기" : "내가 작성한 모집글", description: "작성자 모집글과 지원자를 한눈에 확인할 수 있어요." ) { @@ -54,7 +54,7 @@ struct RecruitProfileView: ActionBindableView { } RecruitProfileActionCardView( - icon: .recruitProfileMyApplications, + icon: .recruitProfileMyApplication, title: viewModel.profile == nil ? "내가 지원한 모집글 모아보기" : "내가 지원한 모집글", description: "지원한 모집글과 지원 상태를 확인 할 수 있어요." ) { diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplications.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplication.imageset/Contents.json similarity index 69% rename from Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplications.imageset/Contents.json rename to Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplication.imageset/Contents.json index 857086f12..43b4743dd 100644 --- a/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplications.imageset/Contents.json +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplication.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "recruitProfileMyApplications.svg", + "filename" : "recruitProfileMyApplication.svg", "idiom" : "universal" } ], diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplications.imageset/recruitProfileMyApplications.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplication.imageset/recruitProfileMyApplication.svg similarity index 100% rename from Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplications.imageset/recruitProfileMyApplications.svg rename to Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplication.imageset/recruitProfileMyApplication.svg diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPosts.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPost.imageset/Contents.json similarity index 71% rename from Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPosts.imageset/Contents.json rename to Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPost.imageset/Contents.json index 7e9beb723..a7c064bb7 100644 --- a/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPosts.imageset/Contents.json +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPost.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "recruitProfileMyPosts.svg", + "filename" : "recruitProfileMyPost.svg", "idiom" : "universal" } ], diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPosts.imageset/recruitProfileMyPosts.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPost.imageset/recruitProfileMyPost.svg similarity index 100% rename from Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPosts.imageset/recruitProfileMyPosts.svg rename to Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPost.imageset/recruitProfileMyPost.svg From e73764718a1129ac7dbc2c0551118f04a189b4ba Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Thu, 24 Sep 2026 06:31:16 +0900 Subject: [PATCH 033/123] =?UTF-8?q?chore:=20RecruitSummary=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=9D=B4=EB=A6=84=EC=9D=84=20RecruitListSummary=20?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Data/Repository/MockRecruitRepository.swift | 8 ++++---- Koin/Domain/Model/Recruit/RecruitList.swift | 2 +- .../{RecruitSummary.swift => RecruitListSummary.swift} | 4 ++-- .../Recruit/RecruitList/RecruitListView.swift | 2 +- .../Recruit/RecruitList/Subviews/RecruitListRowView.swift | 4 ++-- koin.xcodeproj/project.pbxproj | 8 ++++---- 6 files changed, 14 insertions(+), 14 deletions(-) rename Koin/Domain/Model/Recruit/{RecruitSummary.swift => RecruitListSummary.swift} (83%) diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 748f2d18d..d99393a22 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -79,7 +79,7 @@ final class MockRecruitRepository: RecruitRepository { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy.MM.dd" - let contest = RecruitSummary( + let contest = RecruitListSummary( id: 1, category: .contest, title: "AI 아이디어 공모전 팀원 모집", @@ -98,7 +98,7 @@ final class MockRecruitRepository: RecruitRepository { ] ) - let externalActivity = RecruitSummary( + let externalActivity = RecruitListSummary( id: 2, category: .externalActivity, title: "2026 대외활동 팀원 모집", @@ -113,7 +113,7 @@ final class MockRecruitRepository: RecruitRepository { roles: [] ) - let closedExternalActivity = RecruitSummary( + let closedExternalActivity = RecruitListSummary( id: 3, category: .externalActivity, title: "2026 대외활동 팀원 모집", @@ -129,7 +129,7 @@ final class MockRecruitRepository: RecruitRepository { ) let studies = (4...7).map { id in - RecruitSummary( + RecruitListSummary( id: id, category: .study, title: "2026 스터디 팀원 모집", diff --git a/Koin/Domain/Model/Recruit/RecruitList.swift b/Koin/Domain/Model/Recruit/RecruitList.swift index 5345da214..7f600123f 100644 --- a/Koin/Domain/Model/Recruit/RecruitList.swift +++ b/Koin/Domain/Model/Recruit/RecruitList.swift @@ -8,7 +8,7 @@ import Foundation struct RecruitList { - var recruits: [RecruitSummary] + var recruits: [RecruitListSummary] var totalCount: Int let totalPage: Int diff --git a/Koin/Domain/Model/Recruit/RecruitSummary.swift b/Koin/Domain/Model/Recruit/RecruitListSummary.swift similarity index 83% rename from Koin/Domain/Model/Recruit/RecruitSummary.swift rename to Koin/Domain/Model/Recruit/RecruitListSummary.swift index dda7b1c05..c87e10ab5 100644 --- a/Koin/Domain/Model/Recruit/RecruitSummary.swift +++ b/Koin/Domain/Model/Recruit/RecruitListSummary.swift @@ -1,5 +1,5 @@ // -// RecruitSummary.swift +// RecruitListSummary.swift // koin // // Created by 홍기정 on 9/12/26. @@ -7,7 +7,7 @@ import Foundation -struct RecruitSummary: Identifiable, Equatable { +struct RecruitListSummary: Identifiable, Equatable { let id: Int let category: RecruitCategory let title: String diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift index e40cd245a..7fb3b7e82 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -21,7 +21,7 @@ struct RecruitListView: ActionBindableView { var sendAction: ((Action) -> Void) = { _ in } @State private var viewModel: RecruitListViewModel - var recruits: [RecruitSummary] { + var recruits: [RecruitListSummary] { viewModel.recruitList?.recruits ?? [] } diff --git a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift index d630eadc5..298e4f8df 100644 --- a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift @@ -9,9 +9,9 @@ import SwiftUI struct RecruitListRowView: View { - let model: RecruitSummary + let model: RecruitListSummary - init(model: RecruitSummary) { + init(model: RecruitListSummary) { self.model = model } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 65f8a4d5f..72785eb6c 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -155,7 +155,7 @@ 7C50A1D73041F91400897A6F /* RecruitData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D63041F91400897A6F /* RecruitData.swift */; }; 7C50A1D93041FC6800897A6F /* RecruitListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D83041FC6800897A6F /* RecruitListRowView.swift */; }; 7C50A1DD3042016300897A6F /* LeftAlignedLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1DC3042016300897A6F /* LeftAlignedLayout.swift */; }; - 7C50A1E13041F91400897A6F /* RecruitSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1E23041F91400897A6F /* RecruitSummary.swift */; }; + 7C50A1E13041F91400897A6F /* RecruitListSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1E23041F91400897A6F /* RecruitListSummary.swift */; }; 7C50A1E23042C82F00897A6F /* RecruitRoleType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1E13042C82F00897A6F /* RecruitRoleType.swift */; }; 7C50A1E43042C97F00897A6F /* RecruitRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1E33042C97F00897A6F /* RecruitRole.swift */; }; 7C50A1F43042CB4100897A6F /* RecruitListSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1F33042CB4100897A6F /* RecruitListSort.swift */; }; @@ -1287,7 +1287,7 @@ 7C50A1D83041FC6800897A6F /* RecruitListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListRowView.swift; sourceTree = ""; }; 7C50A1DC3042016300897A6F /* LeftAlignedLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftAlignedLayout.swift; sourceTree = ""; }; 7C50A1E13042C82F00897A6F /* RecruitRoleType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitRoleType.swift; sourceTree = ""; }; - 7C50A1E23041F91400897A6F /* RecruitSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitSummary.swift; sourceTree = ""; }; + 7C50A1E23041F91400897A6F /* RecruitListSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListSummary.swift; sourceTree = ""; }; 7C50A1E33042C97F00897A6F /* RecruitRole.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitRole.swift; sourceTree = ""; }; 7C50A1F33042CB4100897A6F /* RecruitListSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListSort.swift; sourceTree = ""; }; 7C50A1F93042DB3200897A6F /* RecruitListFilter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecruitListFilter+.swift"; sourceTree = ""; }; @@ -2829,7 +2829,7 @@ 7C50A1E33042C97F00897A6F /* RecruitRole.swift */, 7C50A1F33042CB4100897A6F /* RecruitListSort.swift */, 7C50A1CB3041E29000897A6F /* RecruitListFilter.swift */, - 7C50A1E23041F91400897A6F /* RecruitSummary.swift */, + 7C50A1E23041F91400897A6F /* RecruitListSummary.swift */, 7C50A1FE3042E9DC00897A6F /* RecruitList.swift */, 7C50A20A304324F500897A6F /* RecruitNotification.swift */, 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */, @@ -7052,7 +7052,7 @@ 7C04A4812FB3380700214992 /* SendDeviceTokenRequest.swift in Sources */, 7CED92062EF2D1F900457128 /* ShopSearchTableViewShopNameCell.swift in Sources */, 7C50A1D73041F91400897A6F /* RecruitData.swift in Sources */, - 7C50A1E13041F91400897A6F /* RecruitSummary.swift in Sources */, + 7C50A1E13041F91400897A6F /* RecruitListSummary.swift in Sources */, 7CED92072EF2D1F900457128 /* SortTypeBottomSheetViewController.swift in Sources */, 7CED92082EF2D1F900457128 /* DeleteLectureModalViewController.swift in Sources */, 7CED92092EF2D1F900457128 /* AgreementFormViewController.swift in Sources */, From 80e1d02dc68af172d5a0e33a5a9705da1b6cfde8 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Thu, 24 Sep 2026 07:58:50 +0900 Subject: [PATCH 034/123] =?UTF-8?q?design:=20=EB=82=B4=EA=B0=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=ED=95=9C=20=EB=AA=A8=EC=A7=91=EA=B8=80=20UI?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/MockRecruitRepository.swift | 48 ++++++ .../Model/Recruit/RecruitMyPostFilter.swift | 15 ++ .../Model/Recruit/RecruitMyPostList.swift | 34 ++++ .../Model/Recruit/RecruitMyPostSummary.swift | 30 ++++ .../Domain/Repository/RecruitRepository.swift | 2 + .../CloseRecruitMyPostUseCase.swift | 25 +++ .../FetchRecruitMyPostListUseCase.swift | 25 +++ .../RecruitListHostingController.swift | 2 +- .../Recruit/RecruitList/RecruitListView.swift | 5 +- .../Mapper/RecruitMyPostFilter+.swift | 48 ++++++ .../RecruitMyPostListHostingController.swift | 109 +++++++++++++ .../RecruitMyPostListView.swift | 139 ++++++++++++++++ .../RecruitMyPostListViewModel.swift | 137 ++++++++++++++++ .../Subviews/RecruitMyPostListEmptyView.swift | 32 ++++ .../RecruitMyPostListHeaderView.swift | 40 +++++ .../Subviews/RecruitMyPostListRowView.swift | 152 ++++++++++++++++++ .../RecruitProfileHostingController.swift | 18 ++- .../RecruitProfile/RecruitProfileView.swift | 4 +- koin.xcodeproj/project.pbxproj | 80 +++++++++ 19 files changed, 936 insertions(+), 9 deletions(-) create mode 100644 Koin/Domain/Model/Recruit/RecruitMyPostFilter.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitMyPostList.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitMyPostList/CloseRecruitMyPostUseCase.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostListUseCase.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostList/Mapper/RecruitMyPostFilter+.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListViewModel.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListEmptyView.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListHeaderView.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index d99393a22..f7fcd1b82 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -152,6 +152,54 @@ final class MockRecruitRepository: RecruitRepository { currentPage: 1 ) } + + func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList { + let list = try await fetchList(RecruitListFilter()) + var recruits = list.recruits.map { summary in + RecruitMyPostSummary( + id: summary.id, + category: summary.category, + title: summary.title, + meetingType: summary.meetingType, + startDate: summary.startDate, + endDate: summary.endDate, + deadline: summary.deadline, + dDay: summary.dDay, + currentParticipants: summary.currentParticipants, + maximumParticipants: summary.maximumParticipants, + type: summary.type, + roles: summary.roles, + state: summary.id == 3 ? .closed : .recruiting, + canClose: summary.id != 3, + chatRoomId: summary.id.isMultiple(of: 2) ? summary.id : nil + ) + } + + if filter.state != .all { + recruits = recruits.filter { $0.state == filter.state } + } + if filter.sort == .deadlineAscending { + recruits.sort { $0.deadline < $1.deadline } + } + + let totalCount = recruits.count + let limit = max(filter.limit ?? totalCount, 1) + let totalPage = max(Int(ceil(Double(totalCount) / Double(limit))), 1) + let currentPage = min(max(filter.page, 1), totalPage) + let startIndex = min((currentPage - 1) * limit, totalCount) + let endIndex = min(startIndex + limit, totalCount) + + return RecruitMyPostList( + recruits: Array(recruits[startIndex.. Bool { + true + } func fetchData(_ id: Int) async throws -> RecruitData { try await Task.sleep(nanoseconds: 300_000_000) diff --git a/Koin/Domain/Model/Recruit/RecruitMyPostFilter.swift b/Koin/Domain/Model/Recruit/RecruitMyPostFilter.swift new file mode 100644 index 000000000..6412a5fbb --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitMyPostFilter.swift @@ -0,0 +1,15 @@ +// +// RecruitMyPostFilter.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +struct RecruitMyPostFilter: Equatable { + var state: RecruitState = .all + var sort: RecruitListSort = .latestDescending + var page: Int = 1 + var limit: Int? = 10 +} diff --git a/Koin/Domain/Model/Recruit/RecruitMyPostList.swift b/Koin/Domain/Model/Recruit/RecruitMyPostList.swift new file mode 100644 index 000000000..8d4f4135f --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitMyPostList.swift @@ -0,0 +1,34 @@ +// +// RecruitMyPostList.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +struct RecruitMyPostList { + var recruits: [RecruitMyPostSummary] + + var totalCount: Int + let totalPage: Int + let currentPage: Int +} + +extension RecruitMyPostList { + mutating func delete(id: Int) { + let previousCount = recruits.count + recruits.removeAll(where: { $0.id == id }) + if recruits.count < previousCount { + totalCount -= 1 + } + } + + var hasNextPage: Bool { + return currentPage < totalPage + } + + var isEmpty: Bool { + return totalCount == 0 + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift b/Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift new file mode 100644 index 000000000..f4db6eee4 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift @@ -0,0 +1,30 @@ +// +// RecruitMyPostSummary.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +struct RecruitMyPostSummary: Identifiable, Equatable { + let id: Int + let category: RecruitCategory + let title: String + let meetingType: RecruitMeetingType + + let startDate: Date + let endDate: Date + let deadline: Date + let dDay: String + + let currentParticipants: Int + let maximumParticipants: Int + + let type: RecruitRoleType + let roles: [RecruitRole] + + let state: RecruitState + let canClose: Bool + let chatRoomId: Int? +} diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index bd0c9b565..0910a5f05 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -9,6 +9,8 @@ import Foundation protocol RecruitRepository { func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList + func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList + func closeMyPost(id: Int) async throws -> Bool func fetchNotificationList() async throws -> RecruitNotificationList func deleteNotification(_ id: Int) async throws -> Void func deleteAllNotification() async throws -> Void diff --git a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/CloseRecruitMyPostUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/CloseRecruitMyPostUseCase.swift new file mode 100644 index 000000000..bc3571bc2 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/CloseRecruitMyPostUseCase.swift @@ -0,0 +1,25 @@ +// +// CloseRecruitMyPostUseCase.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +protocol CloseRecruitMyPostUseCase { + func execute(id: Int) async throws -> Bool +} + +final class DefaultCloseRecruitMyPostUseCase: CloseRecruitMyPostUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(id: Int) async throws -> Bool { + try await repository.closeMyPost(id: id) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostListUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostListUseCase.swift new file mode 100644 index 000000000..f4a274169 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostListUseCase.swift @@ -0,0 +1,25 @@ +// +// FetchRecruitMyPostListUseCase.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +protocol FetchRecruitMyPostListUseCase { + func execute(filter: RecruitMyPostFilter) async throws -> RecruitMyPostList +} + +final class DefaultFetchRecruitMyPostListUseCase: FetchRecruitMyPostListUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(filter: RecruitMyPostFilter) async throws -> RecruitMyPostList { + try await repository.fetchMyPostList(filter) + } +} diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index 58b2c7ab6..74cfe8964 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -11,7 +11,7 @@ final class RecruitListHostingController: UIHostingController, // MARK: - Layout private var toastMessageBottomInset: CGFloat { - 23 + 43 + 12 + 24 + 43 + 12 } // MARK: - UI Components diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift index 7fb3b7e82..55274a4e0 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -82,7 +82,8 @@ struct RecruitListView: ActionBindableView { ProgressView() .frame(maxWidth: .infinity, alignment: .center) - .frame(height: 77) + .frame(height: 24 + 43) + .padding(.top, 12) .isHidden(!(viewModel.hasNextPage && viewModel.isLoading), shouldOccupySpace: true) } .padding(.horizontal, 22) @@ -98,7 +99,7 @@ struct RecruitListView: ActionBindableView { .background(Color.appColor(.newBackground)) .overlay(alignment: .bottomTrailing) { postButton - .padding(EdgeInsets(top: 0, leading: 0, bottom: 23, trailing: 30)) + .padding(EdgeInsets(top: 0, leading: 0, bottom: 24, trailing: 30)) } .onFirstAppear { viewModel.execute(.onFirstAppear) diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/Mapper/RecruitMyPostFilter+.swift b/Koin/Presentation/Recruit/RecruitMyPostList/Mapper/RecruitMyPostFilter+.swift new file mode 100644 index 000000000..db63983a2 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostList/Mapper/RecruitMyPostFilter+.swift @@ -0,0 +1,48 @@ +// +// RecruitMyPostFilter+.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +extension RecruitMyPostFilter { + func toGroupModels() -> [FilterGroupModel] { + var groupModels = [ + FilterGroupModel( + title: "모집 상태", + hasAllButton: true, + items: [ + RecruitState.recruiting.rawValue, + RecruitState.closed.rawValue + ], + behavior: .single + ), + FilterGroupModel( + title: "정렬", + hasAllButton: false, + items: [ + RecruitListSort.latestDescending.rawValue, + RecruitListSort.deadlineAscending.rawValue + ], + behavior: .single + ) + ] + + groupModels[0].didTap(itemAt: state.index) + groupModels[1].didTap(itemAt: sort.index) + return groupModels + } + + init?(from groupModels: [FilterGroupModel]) { + guard groupModels.count == 2, + let state = RecruitState(rawValue: groupModels[0].selectedItems.first?.title ?? ""), + let sort = RecruitListSort(rawValue: groupModels[1].selectedItems.first?.title ?? "") else { + return nil + } + + self.state = state + self.sort = sort + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift new file mode 100644 index 000000000..06ae1bd7f --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift @@ -0,0 +1,109 @@ +// +// RecruitMyPostListHostingController.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import SwiftUI + +final class RecruitMyPostListHostingController: UIHostingController, HostingControllerProtocol { + + // MARK: - Initializer + override init(rootView: RecruitMyPostListView) { + super.init(rootView: rootView) + bindAction(to: rootView) + title = "내가 작성한 모집글" + } + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar(style: .newBackground) + } + + // MARK: - Public + func execute(action: RootView.Action) { + switch action { + case .showFilterBottomSheet(let filterState, let onApplyTapped): + showFilterBottomSheet(filterState, onApplyTapped) + case .showToast(let message): + showToastMessage(message: message, bottomInset: 24) + case .showRecruitData(let id): + showRecruitData(id: id) + case .showChat(let chatRoomId): + showChat(chatRoomId: chatRoomId) + case .showApplicants(let recruitId): + showApplicants(recruitId: recruitId) + case .showCloseModal(let recruitId): + showCloseModal(recruitId: recruitId) + } + } +} + +extension RecruitMyPostListHostingController: RecruitDataHostingControllerDelegate { + func delete(id: Int) { + rootView.delete(id: id) + } + + private func showRecruitData(id: Int) { + let repository = MockRecruitRepository() + let fetchUseCase = DefaultFetchRecruitDataUseCase(repository: repository) + let deleteUseCase = DefaultDeleteRecruitDataUseCase(repository: repository) + let viewModel = RecruitDataViewModel( + fetchRecruitDataUseCase: fetchUseCase, + deleteRecruitDataUseCase: deleteUseCase, + recruitId: id + ) + let controller = RecruitDataHostingController( + rootView: RecruitDataView(viewModel: viewModel), + delegate: self + ) + navigationController?.pushViewController(controller, animated: true) + } + + private func showChat(chatRoomId: Int) { + // TODO: 채팅 화면 연결 + } + + private func showApplicants(recruitId: Int) { + // TODO: 지원자 관리 화면 연결 + } + + private func showCloseModal(recruitId: Int) { + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .new, + content: .titles( + mainTitleText: "해당 모집글을 마감하시겠어요?", + subTitleText: "마감 후에는 더이상 지원자를 받을 수 없습니다." + ), + button: .buttons( + leftButtonTitle: "취소하기", + rightButtonTitle: "마감하기", + rightButtonAction: { [weak self] in + self?.rootView.close(id: recruitId) + } + ), + layout: .init(width: 320) + )) + present(modalViewController, animated: true) + } +} + +extension RecruitMyPostListHostingController { + private func showFilterBottomSheet( + _ filterState: RecruitMyPostFilter, + _ onApplyTapped: @escaping ([FilterGroupModel]) -> Void + ) { + let filterBottomSheetView = FilterBottomSheetView( + groupModels: filterState.toGroupModels(), + onApplyTapped: onApplyTapped + ) + let bottomSheetViewController = BottomSheetViewControllerB(contentView: filterBottomSheetView) + filterBottomSheetView.delegate = bottomSheetViewController + present(bottomSheetViewController, animated: true) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift new file mode 100644 index 000000000..a8b9e1e73 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift @@ -0,0 +1,139 @@ +// +// RecruitMyPostListView.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import SwiftUI + +struct RecruitMyPostListView: ActionBindableView { + + enum Action { + case showFilterBottomSheet( + filterState: RecruitMyPostFilter, + onApplyTapped: ([FilterGroupModel]) -> Void + ) + case showToast(message: String) + case showRecruitData(id: Int) + case showChat(chatRoomId: Int) + case showApplicants(recruitId: Int) + case showCloseModal(recruitId: Int) + } + + // MARK: - Properties + var sendAction: (Action) -> Void = { _ in } + @State private var viewModel: RecruitMyPostListViewModel + + // MARK: - Initializer + init(viewModel: RecruitMyPostListViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + var body: some View { + VStack(spacing: 0) { + RecruitMyPostListHeaderView( + totalCount: viewModel.recruitList?.totalCount ?? 0, + onFilterButtonTapped: { + sendAction(.showFilterBottomSheet( + filterState: viewModel.filterState, + onApplyTapped: { groupModels in + guard let filter = RecruitMyPostFilter(from: groupModels) else { + return + } + viewModel.execute(.updateFilter(filter)) + } + )) + } + ) + .padding(.top, 20) + .padding(.bottom, 12) + + ScrollView { + LazyVStack(spacing: 8) { + ForEach(viewModel.recruits) { recruit in + RecruitMyPostListRowView( + model: recruit, + onShowDetail: { + sendAction(.showRecruitData(id: recruit.id)) + }, + onShowChat: { chatRoomId in + sendAction(.showChat(chatRoomId: chatRoomId)) + }, + onShowApplicants: { + sendAction(.showApplicants(recruitId: recruit.id)) + }, + onCloseRecruit: { + sendAction(.showCloseModal(recruitId: recruit.id)) + } + ) + .frame(maxWidth: .infinity) + .onAppear { + loadNextPageIfNeeded(recruitId: recruit.id) + } + } + } + .animation( + .spring(duration: 0.3), + value: viewModel.recruits.map(\.id) + ) + .frame(maxWidth: .infinity) + + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 24) + .padding(.top, 12) + .isHidden(!(viewModel.recruitList?.hasNextPage ?? false && viewModel.isLoading)) + + Spacer(minLength: 24) + } + .padding(.horizontal, 22) + .background { + RecruitMyPostListEmptyView() + .isHidden(!(viewModel.recruitList?.isEmpty ?? true)) + } + .refreshable { + viewModel.execute(.refresh) + } + .scrollIndicators(.hidden) + } + .background(Color.appColor(.newBackground)) + .onFirstAppear { + viewModel.execute(.onFirstAppear) + } + .onChange(of: viewModel.errorMessage) { + guard let message = viewModel.errorMessage else { + return + } + sendAction(.showToast(message: message)) + viewModel.execute(.didShowToast) + } + .onChange(of: viewModel.successMessage) { + guard let message = viewModel.successMessage else { + return + } + sendAction(.showToast(message: message)) + viewModel.execute(.didShowToast) + } + } +} + +extension RecruitMyPostListView { + private func loadNextPageIfNeeded(recruitId: Int) { + guard viewModel.recruits.last?.id == recruitId, + viewModel.recruitList?.hasNextPage ?? false, + !viewModel.isLoading else { + return + } + viewModel.execute(.loadNextPage) + } + + func delete(id: Int) { + viewModel.execute(.delete(id: id)) + } + + func close(id: Int) { + viewModel.execute(.close(id: id)) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListViewModel.swift b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListViewModel.swift new file mode 100644 index 000000000..ec7cbc16b --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListViewModel.swift @@ -0,0 +1,137 @@ +// +// RecruitMyPostListViewModel.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation +import Observation + +@Observable +final class RecruitMyPostListViewModel: SwiftUIViewModelProtocol { + + enum Input { + case onFirstAppear + case refresh + case updateFilter(RecruitMyPostFilter) + case loadNextPage + case close(id: Int) + case delete(id: Int) + case didShowToast + } + + // MARK: - State + private(set) var recruitList: RecruitMyPostList? + private(set) var filterState = RecruitMyPostFilter() + private(set) var isLoading = false + private(set) var errorMessage: String? + private(set) var successMessage: String? + + // MARK: - Properties + private let fetchRecruitMyPostListUseCase: FetchRecruitMyPostListUseCase + private let closeRecruitMyPostUseCase: CloseRecruitMyPostUseCase + + var recruits: [RecruitMyPostSummary] { + recruitList?.recruits ?? [] + } + + // MARK: - Initializer + init( + fetchRecruitMyPostListUseCase: FetchRecruitMyPostListUseCase, + closeRecruitMyPostUseCase: CloseRecruitMyPostUseCase + ) { + self.fetchRecruitMyPostListUseCase = fetchRecruitMyPostListUseCase + self.closeRecruitMyPostUseCase = closeRecruitMyPostUseCase + } + + // MARK: - Public + func execute(_ input: Input) { + switch input { + case .onFirstAppear: + fetchList() + case .refresh: + fetchList() + case .updateFilter(let filter): + updateFilter(filter) + case .loadNextPage: + loadNextPage() + case .close(let id): + close(id: id) + case .delete(let id): + recruitList?.delete(id: id) + case .didShowToast: + errorMessage = nil + successMessage = nil + } + } +} + +extension RecruitMyPostListViewModel { + private func updateFilter(_ filter: RecruitMyPostFilter) { + var filter = filter + filter.page = 1 + filterState = filter + fetchList() + } + + private func loadNextPage() { + guard let recruitList else { + return + } + fetchList(page: recruitList.currentPage + 1) + } + + private func close(id: Int) { + Task { + do { + guard try await closeRecruitMyPostUseCase.execute(id: id) else { + return + } + recruitList?.delete(id: id) + successMessage = "모집글을 마감했어요." + } catch { + errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + } + } + } +} + +extension RecruitMyPostListViewModel { + private func fetchList(page: Int = 1) { + guard !isLoading else { + return + } + + Task { + isLoading = true + defer { + isLoading = false + } + + var filter = filterState + filter.page = page + + do { + var response = try await fetchRecruitMyPostListUseCase.execute(filter: filter) + try Task.checkCancellation() + + guard response.currentPage == page else { + return + } + + if page > 1 { + response.recruits = (recruitList?.recruits ?? []) + response.recruits + response.recruits.removeDuplicates() + } + + filterState = filter + recruitList = response + } catch is CancellationError { + return + } catch { + errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + } + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListEmptyView.swift b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListEmptyView.swift new file mode 100644 index 000000000..220072b2c --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListEmptyView.swift @@ -0,0 +1,32 @@ +// +// RecruitMyPostListEmptyView.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import SwiftUI + +struct RecruitMyPostListEmptyView: View { + var body: some View { + VStack(alignment: .center, spacing: 12) { + Spacer() + + Image.appImage(asset: .sleepBcsdSymbol) + .frame(height: 75) + + Text("작성한 모집글이 없어요.") + .font(.appFont(.pretendardMedium, size: 16)) + .foregroundStyle(Color.appColor(.neutral800)) + .frame(height: 26) + .padding(.bottom, 2) + + Text("직접 모집글을 작성하여 팀원을 모집해보세요.") + .font(.appFont(.pretendardRegular, size: 14)) + .foregroundStyle(Color.appColor(.neutral600)) + .frame(height: 22) + + Spacer() + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListHeaderView.swift b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListHeaderView.swift new file mode 100644 index 000000000..65124d91f --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListHeaderView.swift @@ -0,0 +1,40 @@ +// +// RecruitMyPostListHeaderView.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import SwiftUI + +struct RecruitMyPostListHeaderView: View { + + let totalCount: Int + let onFilterButtonTapped: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Text("전체(\(totalCount))") + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral500)) + + Spacer() + + Button(action: onFilterButtonTapped) { + HStack(alignment: .center, spacing: 4) { + Text("필터") + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral600)) + + Image.appImage(asset: .recruitFilter) + } + .padding(.horizontal, 15) + .frame(height: 36) + .background(Color.appColor(.neutral0)) + .clipShape(.capsule) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 22) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift new file mode 100644 index 000000000..382b21d23 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift @@ -0,0 +1,152 @@ +// +// RecruitMyPostListRowView.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import SwiftUI + +struct RecruitMyPostListRowView: View { + + let model: RecruitMyPostSummary + let onShowDetail: () -> Void + let onShowChat: (Int) -> Void + let onShowApplicants: () -> Void + let onCloseRecruit: () -> Void + + var body: some View { + Button(action: onShowDetail) { + VStack(alignment: .leading, spacing: 0) { + headerView + .padding(.bottom, 8) + titleView + .padding(.bottom, 4) + rolesView + .padding(.bottom, 8) + .isHidden(model.type == .general) + dataView + manageButtons + .padding(.top, 8) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.appColor(.neutral0)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .contentShape(RoundedRectangle(cornerRadius: 16)) + } + .buttonStyle(.plain) + } +} + +extension RecruitMyPostListRowView { + private var headerView: some View { + HStack(alignment: .center, spacing: 8) { + Text(model.category.rawValue) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(model.category.foregroundColor)) + .padding(.horizontal, 8) + .frame(height: 18) + .background(Color.appColor(model.category.backgroundColor)) + .clipShape(.capsule) + + Text(model.state == .closed ? "모집완료" : model.dDay) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(model.state == .closed ? .new600 : .danger700)) + + Spacer() + + if let chatRoomId = model.chatRoomId { + Button { + onShowChat(chatRoomId) + } label: { + Image.appImage(asset: .recruitNotificationChat) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + + private var titleView: some View { + Text(model.title) + .font(.appFont(.pretendardSemiBold, size: 16)) + .foregroundStyle(Color.appColor(.neutral700)) + .frame(height: 26) + .lineLimit(1) + } + + private var rolesView: some View { + LeftAlignedLayout(interitemSpacing: 4, interlineSpacing: 4) { + ForEach(model.roles) { role in + Text("\(role.name) \(role.maximumParticipants)") + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + .padding(.horizontal, 8) + .frame(height: 18) + .background(Color.appColor(.neutral200)) + .clipShape(.capsule) + } + } + } +} + +extension RecruitMyPostListRowView { + private var dataView: some View { + LeftAlignedLayout(interitemSpacing: 8, interlineSpacing: 4) { + Group { + HStack(alignment: .center, spacing: 2) { + Image.appImage(asset: .recruitLocation) + Text(model.meetingType.rawValue) + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + } + + HStack(alignment: .center, spacing: 2) { + Image.appImage(asset: .recruitDate) + Text("\(model.startDate.formatDateToYYYYMMDD(separator: ".")) ~ \(model.endDate.formatDateToYYYYMMDD(separator: "."))") + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + } + + HStack(alignment: .center, spacing: 2) { + Image.appImage(asset: .recruitMember) + Text("\(model.currentParticipants)/\(model.maximumParticipants)명") + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + } + } + .frame(height: 16) + } + } + + private var manageButtons: some View { + HStack(spacing: 26) { + Button(action: onShowApplicants) { + manageButtonLabel("지원자 관리") + } + .buttonStyle(.plain) + + if model.canClose { + Button(action: onCloseRecruit) { + manageButtonLabel("모집 마감") + } + .buttonStyle(.plain) + } + } + } + + private func manageButtonLabel(_ title: String) -> some View { + Text(title) + .font(.appFont(.pretendardRegular, size: 14)) + .foregroundStyle(Color.appColor(.new500)) + .frame(maxWidth: .infinity, minHeight: 40) + .border(.appColor(.new500), width: 1, radius: 16) + .contentShape(RoundedRectangle(cornerRadius: 16)) + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift index b5575db68..e53422f51 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift @@ -37,8 +37,8 @@ final class RecruitProfileHostingController: UIHostingController Date: Thu, 24 Sep 2026 08:33:23 +0900 Subject: [PATCH 035/123] =?UTF-8?q?design:=20=EB=82=B4=EA=B0=80=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=ED=95=9C=20=EB=AA=A8=EC=A7=91=EA=B8=80=20UI?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/MockRecruitRepository.swift | 46 ++++++ .../Recruit/RecruitMyApplicationFilter.swift | 15 ++ .../Recruit/RecruitMyApplicationList.swift | 26 ++++ .../Recruit/RecruitMyApplicationStatus.swift | 25 ++++ .../Recruit/RecruitMyApplicationSummary.swift | 29 ++++ .../Domain/Repository/RecruitRepository.swift | 1 + ...FetchRecruitMyApplicationListUseCase.swift | 25 ++++ .../Mapper/RecruitMyApplicationFilter+.swift | 49 +++++++ ...itMyApplicationListHostingController.swift | 90 ++++++++++++ .../RecruitMyApplicationListView.swift | 119 ++++++++++++++++ .../RecruitMyApplicationListViewModel.swift | 110 +++++++++++++++ .../RecruitMyApplicationListEmptyView.swift | 54 +++++++ .../RecruitMyApplicationListHeaderView.swift | 40 ++++++ .../RecruitMyApplicationListRowView.swift | 133 ++++++++++++++++++ .../RecruitProfileHostingController.swift | 16 ++- .../RecruitProfile/RecruitProfileView.swift | 4 +- koin.xcodeproj/project.pbxproj | 80 +++++++++++ 17 files changed, 856 insertions(+), 6 deletions(-) create mode 100644 Koin/Domain/Model/Recruit/RecruitMyApplicationFilter.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitMyApplicationList.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitMyApplicationStatus.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitMyApplicationSummary.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitMyApplicationList/FetchRecruitMyApplicationListUseCase.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListViewModel.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListEmptyView.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListHeaderView.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index f7fcd1b82..b05a8e4fd 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -200,6 +200,52 @@ final class MockRecruitRepository: RecruitRepository { func closeMyPost(id: Int) async throws -> Bool { true } + + func fetchMyApplicationList( + _ filter: RecruitMyApplicationFilter + ) async throws -> RecruitMyApplicationList { + let list = try await fetchList(RecruitListFilter()) + let statuses = RecruitMyApplicationStatus.allCases + var recruits = list.recruits.enumerated().map { index, summary in + RecruitMyApplicationSummary( + id: summary.id, + category: summary.category, + title: summary.title, + meetingType: summary.meetingType, + startDate: summary.startDate, + endDate: summary.endDate, + deadline: summary.deadline, + dDay: summary.dDay, + currentParticipants: summary.currentParticipants, + maximumParticipants: summary.maximumParticipants, + type: summary.type, + roles: summary.roles, + status: statuses[index % statuses.count], + chatRoomId: summary.id.isMultiple(of: 2) ? summary.id : nil + ) + } + + if let status = filter.status { + recruits = recruits.filter { $0.status == status } + } + if filter.sort == .deadlineAscending { + recruits.sort { $0.deadline < $1.deadline } + } + + let totalCount = recruits.count + let limit = max(filter.limit ?? totalCount, 1) + let totalPage = max(Int(ceil(Double(totalCount) / Double(limit))), 1) + let currentPage = min(max(filter.page, 1), totalPage) + let startIndex = min((currentPage - 1) * limit, totalCount) + let endIndex = min(startIndex + limit, totalCount) + + return RecruitMyApplicationList( + recruits: Array(recruits[startIndex.. RecruitData { try await Task.sleep(nanoseconds: 300_000_000) diff --git a/Koin/Domain/Model/Recruit/RecruitMyApplicationFilter.swift b/Koin/Domain/Model/Recruit/RecruitMyApplicationFilter.swift new file mode 100644 index 000000000..f4f41f916 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitMyApplicationFilter.swift @@ -0,0 +1,15 @@ +// +// RecruitMyApplicationFilter.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +struct RecruitMyApplicationFilter: Equatable { + var status: RecruitMyApplicationStatus? = nil + var sort: RecruitListSort = .latestDescending + var page: Int = 1 + var limit: Int? = 10 +} diff --git a/Koin/Domain/Model/Recruit/RecruitMyApplicationList.swift b/Koin/Domain/Model/Recruit/RecruitMyApplicationList.swift new file mode 100644 index 000000000..720d70ce5 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitMyApplicationList.swift @@ -0,0 +1,26 @@ +// +// RecruitMyApplicationList.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +struct RecruitMyApplicationList { + var recruits: [RecruitMyApplicationSummary] + + let totalCount: Int + let totalPage: Int + let currentPage: Int +} + +extension RecruitMyApplicationList { + var hasNextPage: Bool { + currentPage < totalPage + } + + var isEmpty: Bool { + totalCount == 0 + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitMyApplicationStatus.swift b/Koin/Domain/Model/Recruit/RecruitMyApplicationStatus.swift new file mode 100644 index 000000000..cd3a8a5af --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitMyApplicationStatus.swift @@ -0,0 +1,25 @@ +// +// RecruitMyApplicationStatus.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +enum RecruitMyApplicationStatus: String, CaseIterable, Equatable { + case accepted = "승인" + case denied = "거절" + case pending = "대기" + + var textColor: ColorAsset { + switch self { + case .accepted: + return .new600 + case .denied: + return .danger700 + case .pending: + return .neutral500 + } + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitMyApplicationSummary.swift b/Koin/Domain/Model/Recruit/RecruitMyApplicationSummary.swift new file mode 100644 index 000000000..45870dc52 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitMyApplicationSummary.swift @@ -0,0 +1,29 @@ +// +// RecruitMyApplicationSummary.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +struct RecruitMyApplicationSummary: Identifiable, Equatable { + let id: Int + let category: RecruitCategory + let title: String + let meetingType: RecruitMeetingType + + let startDate: Date + let endDate: Date + let deadline: Date + let dDay: String + + let currentParticipants: Int + let maximumParticipants: Int + + let type: RecruitRoleType + let roles: [RecruitRole] + + let status: RecruitMyApplicationStatus + let chatRoomId: Int? +} diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index 0910a5f05..12ae084ff 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -10,6 +10,7 @@ import Foundation protocol RecruitRepository { func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList + func fetchMyApplicationList(_ filter: RecruitMyApplicationFilter) async throws -> RecruitMyApplicationList func closeMyPost(id: Int) async throws -> Bool func fetchNotificationList() async throws -> RecruitNotificationList func deleteNotification(_ id: Int) async throws -> Void diff --git a/Koin/Domain/UseCase/Recruit/RecruitMyApplicationList/FetchRecruitMyApplicationListUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyApplicationList/FetchRecruitMyApplicationListUseCase.swift new file mode 100644 index 000000000..c5656ea66 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitMyApplicationList/FetchRecruitMyApplicationListUseCase.swift @@ -0,0 +1,25 @@ +// +// FetchRecruitMyApplicationListUseCase.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +protocol FetchRecruitMyApplicationListUseCase { + func execute(filter: RecruitMyApplicationFilter) async throws -> RecruitMyApplicationList +} + +final class DefaultFetchRecruitMyApplicationListUseCase: FetchRecruitMyApplicationListUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(filter: RecruitMyApplicationFilter) async throws -> RecruitMyApplicationList { + try await repository.fetchMyApplicationList(filter) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift new file mode 100644 index 000000000..b31b749e6 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift @@ -0,0 +1,49 @@ +// +// RecruitMyApplicationFilter+.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +extension RecruitMyApplicationFilter { + func toGroupModels() -> [FilterGroupModel] { + var groupModels = [ + FilterGroupModel( + title: "지원 상태", + hasAllButton: true, + items: RecruitMyApplicationStatus.allCases.map(\.rawValue), + behavior: .single + ), + FilterGroupModel( + title: "정렬", + hasAllButton: false, + items: [ + RecruitListSort.latestDescending.rawValue, + RecruitListSort.deadlineAscending.rawValue + ], + behavior: .single + ) + ] + + if let status, + let statusIndex = RecruitMyApplicationStatus.allCases.firstIndex(of: status) { + groupModels[0].didTap(itemAt: statusIndex + 1) + } + groupModels[1].didTap(itemAt: sort.index) + return groupModels + } + + init?(from groupModels: [FilterGroupModel]) { + guard groupModels.count == 2, + let sort = RecruitListSort(rawValue: groupModels[1].selectedItems.first?.title ?? "") else { + return nil + } + + status = RecruitMyApplicationStatus( + rawValue: groupModels[0].selectedItems.first?.title ?? "" + ) + self.sort = sort + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift new file mode 100644 index 000000000..828e8bf6a --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift @@ -0,0 +1,90 @@ +// +// RecruitMyApplicationListHostingController.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import SwiftUI + +final class RecruitMyApplicationListHostingController: UIHostingController, HostingControllerProtocol { + + // MARK: - Initializer + override init(rootView: RecruitMyApplicationListView) { + super.init(rootView: rootView) + bindAction(to: rootView) + title = "내가 지원한 모집글" + } + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar(style: .newBackground) + } + + // MARK: - Public + func execute(action: RootView.Action) { + switch action { + case .showFilterBottomSheet(let filterState, let onApplyTapped): + showFilterBottomSheet(filterState, onApplyTapped) + case .showToast(let message): + showToastMessage(message: message, bottomInset: 24) + case .showRecruitData(let id): + showRecruitData(id: id) + case .showChat(let chatRoomId): + showChat(chatRoomId: chatRoomId) + case .showAllRecruitList: + showAllRecruitList() + } + } +} + +extension RecruitMyApplicationListHostingController { + private func showRecruitData(id: Int) { + let repository = MockRecruitRepository() + let fetchUseCase = DefaultFetchRecruitDataUseCase(repository: repository) + let deleteUseCase = DefaultDeleteRecruitDataUseCase(repository: repository) + let viewModel = RecruitDataViewModel( + fetchRecruitDataUseCase: fetchUseCase, + deleteRecruitDataUseCase: deleteUseCase, + recruitId: id + ) + let controller = RecruitDataHostingController( + rootView: RecruitDataView(viewModel: viewModel), + delegate: nil + ) + navigationController?.pushViewController(controller, animated: true) + } + + private func showChat(chatRoomId: Int) { + // TODO: 채팅 화면 연결 + } + + private func showAllRecruitList() { + guard let navigationController, + let recruitListController = navigationController.viewControllers + .compactMap({ $0 as? RecruitListHostingController }) + .first else { + return + } + navigationController.popToViewController(recruitListController, animated: true) + } +} + +extension RecruitMyApplicationListHostingController { + private func showFilterBottomSheet( + _ filterState: RecruitMyApplicationFilter, + _ onApplyTapped: @escaping ([FilterGroupModel]) -> Void + ) { + let filterBottomSheetView = FilterBottomSheetView( + groupModels: filterState.toGroupModels(), + onApplyTapped: onApplyTapped + ) + let bottomSheetViewController = BottomSheetViewControllerB(contentView: filterBottomSheetView) + filterBottomSheetView.delegate = bottomSheetViewController + present(bottomSheetViewController, animated: true) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift new file mode 100644 index 000000000..ee62355b1 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift @@ -0,0 +1,119 @@ +// +// RecruitMyApplicationListView.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import SwiftUI + +struct RecruitMyApplicationListView: ActionBindableView { + + enum Action { + case showFilterBottomSheet( + filterState: RecruitMyApplicationFilter, + onApplyTapped: ([FilterGroupModel]) -> Void + ) + case showToast(message: String) + case showRecruitData(id: Int) + case showChat(chatRoomId: Int) + case showAllRecruitList + } + + // MARK: - Properties + var sendAction: (Action) -> Void = { _ in } + @State private var viewModel: RecruitMyApplicationListViewModel + + // MARK: - Initializer + init(viewModel: RecruitMyApplicationListViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + var body: some View { + VStack(spacing: 0) { + RecruitMyApplicationListHeaderView( + totalCount: viewModel.recruitList?.totalCount ?? 0, + onFilterButtonTapped: { + sendAction(.showFilterBottomSheet( + filterState: viewModel.filterState, + onApplyTapped: { groupModels in + guard let filter = RecruitMyApplicationFilter(from: groupModels) else { + return + } + viewModel.execute(.updateFilter(filter)) + } + )) + } + ) + .padding(.top, 20) + .padding(.bottom, 12) + + ScrollView { + LazyVStack(spacing: 8) { + ForEach(viewModel.recruits) { recruit in + RecruitMyApplicationListRowView( + model: recruit, + onShowDetail: { + sendAction(.showRecruitData(id: recruit.id)) + }, + onShowChat: { chatRoomId in + sendAction(.showChat(chatRoomId: chatRoomId)) + } + ) + .frame(maxWidth: .infinity) + .onAppear { + loadNextPageIfNeeded(recruitId: recruit.id) + } + } + } + .animation( + .spring(duration: 0.3), + value: viewModel.recruits.map(\.id) + ) + .frame(maxWidth: .infinity) + + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 24) + .padding(.top, 12) + .isHidden(!(viewModel.recruitList?.hasNextPage ?? false && viewModel.isLoading)) + + Spacer(minLength: 24) + } + .padding(.horizontal, 22) + .background { + RecruitMyApplicationListEmptyView { + sendAction(.showAllRecruitList) + } + .isHidden(!(viewModel.recruitList?.isEmpty ?? true)) + } + .refreshable { + viewModel.execute(.refresh) + } + .scrollIndicators(.hidden) + } + .background(Color.appColor(.newBackground)) + .onFirstAppear { + viewModel.execute(.onFirstAppear) + } + .onChange(of: viewModel.errorMessage) { + guard let message = viewModel.errorMessage else { + return + } + sendAction(.showToast(message: message)) + viewModel.execute(.didShowToast) + } + } +} + +extension RecruitMyApplicationListView { + private func loadNextPageIfNeeded(recruitId: Int) { + guard viewModel.recruits.last?.id == recruitId, + viewModel.recruitList?.hasNextPage ?? false, + !viewModel.isLoading else { + return + } + viewModel.execute(.loadNextPage) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListViewModel.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListViewModel.swift new file mode 100644 index 000000000..23c1f0c21 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListViewModel.swift @@ -0,0 +1,110 @@ +// +// RecruitMyApplicationListViewModel.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation +import Observation + +@Observable +final class RecruitMyApplicationListViewModel: SwiftUIViewModelProtocol { + + enum Input { + case onFirstAppear + case refresh + case updateFilter(RecruitMyApplicationFilter) + case loadNextPage + case didShowToast + } + + // MARK: - State + private(set) var recruitList: RecruitMyApplicationList? + private(set) var filterState = RecruitMyApplicationFilter() + private(set) var isLoading = false + private(set) var errorMessage: String? + + // MARK: - Properties + private let fetchRecruitMyApplicationListUseCase: FetchRecruitMyApplicationListUseCase + + var recruits: [RecruitMyApplicationSummary] { + recruitList?.recruits ?? [] + } + + // MARK: - Initializer + init(fetchRecruitMyApplicationListUseCase: FetchRecruitMyApplicationListUseCase) { + self.fetchRecruitMyApplicationListUseCase = fetchRecruitMyApplicationListUseCase + } + + // MARK: - Public + func execute(_ input: Input) { + switch input { + case .onFirstAppear: + fetchList() + case .refresh: + fetchList() + case .updateFilter(let filter): + updateFilter(filter) + case .loadNextPage: + loadNextPage() + case .didShowToast: + errorMessage = nil + } + } +} + +extension RecruitMyApplicationListViewModel { + private func updateFilter(_ filter: RecruitMyApplicationFilter) { + var filter = filter + filter.page = 1 + filterState = filter + fetchList() + } + + private func loadNextPage() { + guard let recruitList else { + return + } + fetchList(page: recruitList.currentPage + 1) + } +} + +extension RecruitMyApplicationListViewModel { + private func fetchList(page: Int = 1) { + guard !isLoading else { + return + } + + Task { + isLoading = true + defer { + isLoading = false + } + + var filter = filterState + filter.page = page + + do { + var response = try await fetchRecruitMyApplicationListUseCase.execute(filter: filter) + try Task.checkCancellation() + + guard response.currentPage == page else { + return + } + + if page > 1 { + response.recruits = (recruitList?.recruits ?? []) + response.recruits + response.recruits.removeDuplicates() + } + + filterState = filter + recruitList = response + } catch is CancellationError { + return + } catch { + errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + } + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListEmptyView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListEmptyView.swift new file mode 100644 index 000000000..437fd5639 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListEmptyView.swift @@ -0,0 +1,54 @@ +// +// RecruitMyApplicationListEmptyView.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import SwiftUI + +struct RecruitMyApplicationListEmptyView: View { + + let onListButtonTapped: () -> Void + + var body: some View { + VStack(alignment: .center, spacing: 12) { + Spacer() + + Image.appImage(asset: .sleepBcsdSymbol) + .frame(height: 75) + + Text("지원한 모집글이 없어요.") + .font(.appFont(.pretendardMedium, size: 16)) + .foregroundStyle(Color.appColor(.neutral800)) + .frame(height: 26) + .padding(.bottom, 2) + + Text("직접 모집글을 작성하여 팀원을 모집해보세요.") + .font(.appFont(.pretendardRegular, size: 14)) + .foregroundStyle(Color.appColor(.neutral600)) + .frame(height: 22) + + Button(action: onListButtonTapped) { + Text("모집글 둘러보기") + .font(.appFont(.pretendardMedium, size: 16)) + .foregroundStyle(Color.appColor(.new600)) + .padding(.horizontal, 12) + .frame(height: 42) + .clipShape(.capsule) + .border(.appColor(.new400), width: 1, radius: 21) + .applySketchShadow( + color: .appColor(.neutral800), + alpha: 0.09, + x: 0, + y: 4, + blur: 10 + ) + } + .buttonStyle(.plain) + .padding(.top, 12) + + Spacer() + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListHeaderView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListHeaderView.swift new file mode 100644 index 000000000..04f7234ea --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListHeaderView.swift @@ -0,0 +1,40 @@ +// +// RecruitMyApplicationListHeaderView.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import SwiftUI + +struct RecruitMyApplicationListHeaderView: View { + + let totalCount: Int + let onFilterButtonTapped: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Text("전체(\(totalCount))") + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral500)) + + Spacer() + + Button(action: onFilterButtonTapped) { + HStack(alignment: .center, spacing: 4) { + Text("필터") + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral600)) + + Image.appImage(asset: .recruitFilter) + } + .padding(.horizontal, 15) + .frame(height: 36) + .background(Color.appColor(.neutral0)) + .clipShape(.capsule) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 22) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift new file mode 100644 index 000000000..84213b540 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift @@ -0,0 +1,133 @@ +// +// RecruitMyApplicationListRowView.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import SwiftUI + +struct RecruitMyApplicationListRowView: View { + + let model: RecruitMyApplicationSummary + let onShowDetail: () -> Void + let onShowChat: (Int) -> Void + + var body: some View { + Button(action: onShowDetail) { + VStack(alignment: .leading, spacing: 0) { + headerView + .padding(.bottom, 8) + titleView + .padding(.bottom, 4) + rolesView + .padding(.bottom, 8) + .isHidden(model.type == .general) + dataView + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.appColor(.neutral0)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .contentShape(RoundedRectangle(cornerRadius: 16)) + } + .buttonStyle(.plain) + } +} + +extension RecruitMyApplicationListRowView { + private var headerView: some View { + HStack(alignment: .center, spacing: 8) { + Text(model.category.rawValue) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(model.category.foregroundColor)) + .padding(.horizontal, 8) + .frame(height: 18) + .background(Color.appColor(model.category.backgroundColor)) + .clipShape(.capsule) + + Text(model.dDay) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(.danger700)) + + Spacer() + + Text(model.status.rawValue) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(model.status.textColor)) + .padding(.trailing, 8) + } + } + + private var titleView: some View { + Text(model.title) + .font(.appFont(.pretendardSemiBold, size: 16)) + .foregroundStyle(Color.appColor(.neutral700)) + .frame(height: 26) + .lineLimit(1) + } + + private var rolesView: some View { + LeftAlignedLayout(interitemSpacing: 4, interlineSpacing: 4) { + ForEach(model.roles) { role in + Text("\(role.name) \(role.maximumParticipants)") + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + .padding(.horizontal, 8) + .frame(height: 18) + .background(Color.appColor(.neutral200)) + .clipShape(.capsule) + } + } + } +} + +extension RecruitMyApplicationListRowView { + private var dataView: some View { + HStack(spacing: 24) { + LeftAlignedLayout(interitemSpacing: 8, interlineSpacing: 4) { + Group { + HStack(alignment: .center, spacing: 2) { + Image.appImage(asset: .recruitLocation) + Text(model.meetingType.rawValue) + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + } + + HStack(alignment: .center, spacing: 2) { + Image.appImage(asset: .recruitDate) + Text("\(model.startDate.formatDateToYYYYMMDD(separator: ".")) ~ \(model.endDate.formatDateToYYYYMMDD(separator: "."))") + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + } + + HStack(alignment: .center, spacing: 2) { + Image.appImage(asset: .recruitMember) + Text("\(model.currentParticipants)/\(model.maximumParticipants)명") + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + } + } + .frame(height: 16) + } + + Spacer(minLength: 0) + + if let chatRoomId = model.chatRoomId { + Button { + onShowChat(chatRoomId) + } label: { + Image.appImage(asset: .recruitNotificationChat) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity) + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift index e53422f51..5796d2d84 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift @@ -39,8 +39,8 @@ final class RecruitProfileHostingController: UIHostingController Date: Sun, 27 Sep 2026 20:55:16 +0900 Subject: [PATCH 036/123] =?UTF-8?q?design:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=A7=80=EC=9B=90=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/MockRecruitRepository.swift | 6 +- .../Model/Recruit/RecruitApplyRequest.swift | 47 ++ Koin/Domain/Model/Recruit/RecruitData.swift | 30 ++ .../Domain/Repository/RecruitRepository.swift | 1 + .../RecruitApply/ApplyRecruitUseCase.swift | 24 + .../RecruitApplyViewController.swift | 417 ++++++++++++++++++ .../RecruitApply/RecruitApplyViewModel.swift | 115 +++++ .../Subviews/RecruitApplyFirstStepView.swift | 266 +++++++++++ .../RecruitApplyRoleSelectionView.swift | 174 ++++++++ .../Subviews/RecruitApplySecondStepView.swift | 112 +++++ .../RecruitDataHostingController.swift | 27 +- .../Recruit/RecruitData/RecruitDataView.swift | 4 + .../RecruitData/RecruitDataViewModel.swift | 12 +- .../Subviews/RecruitPostTextFieldView.swift | 8 + koin.xcodeproj/project.pbxproj | 310 +++++++------ 15 files changed, 1418 insertions(+), 135 deletions(-) create mode 100644 Koin/Domain/Model/Recruit/RecruitApplyRequest.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitApply/ApplyRecruitUseCase.swift create mode 100644 Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift create mode 100644 Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift create mode 100644 Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift create mode 100644 Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyRoleSelectionView.swift create mode 100644 Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplySecondStepView.swift diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index b05a8e4fd..868f7cf21 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -8,6 +8,10 @@ import Foundation final class MockRecruitRepository: RecruitRepository { + func apply(_ request: RecruitApplyRequest) async throws -> Void { + try await Task.sleep(nanoseconds: 300_000_000) + } + func postBasicInfo(_ basicInfo: BasicInfo) async throws -> BasicInfo { try await Task.sleep(nanoseconds: 300_000_000) return basicInfo @@ -273,7 +277,7 @@ final class MockRecruitRepository: RecruitRepository { description: "소개소개소개소개소개소개소개소개소개소개소개소개소개소개", relatedUrl: URL(string: "https://bcsdlab.com"), qualification: "2학년이상\n참여율 높은 사람\n@@@", - isAuthor: UserDataManager.shared.isLoggedIn && item.id % 2 == 1, + isAuthor: UserDataManager.shared.isLoggedIn && item.id % 2 == 0, canApply: true, applyBlockReason: nil, canManageApplicants: false, diff --git a/Koin/Domain/Model/Recruit/RecruitApplyRequest.swift b/Koin/Domain/Model/Recruit/RecruitApplyRequest.swift new file mode 100644 index 000000000..38a9a4e10 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitApplyRequest.swift @@ -0,0 +1,47 @@ +// +// RecruitApplyRequest.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import Foundation + +struct RecruitApplyRequest { + let recruit: RecruitData + var basicInfo = BasicInfo() + var skills: [String] = [] + var activities: [RecruitProfileActivityRequest] = [] + var introduction: String? + var selectedRole: RecruitRole? + var motivation: String? + var availableTime: String? +} + +extension RecruitApplyRequest { + var isFirstStepValid: Bool { + guard basicInfo.isValid, + let introduction, + !introduction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + + let normalizedSkills = skills.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + return normalizedSkills.allSatisfy { !$0.isEmpty } + && Set(normalizedSkills).count == normalizedSkills.count + && activities.allSatisfy(\.isValid) + } + + var isSecondStepValid: Bool { + guard (recruit.type != .roleBased || selectedRole != nil), + let motivation, + !motivation.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let availableTime, + !availableTime.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + return true + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitData.swift b/Koin/Domain/Model/Recruit/RecruitData.swift index a73884387..0caa72558 100644 --- a/Koin/Domain/Model/Recruit/RecruitData.swift +++ b/Koin/Domain/Model/Recruit/RecruitData.swift @@ -37,3 +37,33 @@ struct RecruitData: Identifiable, Equatable { let teamChatAvailable: Bool let teamChatRoomId: Int? } + +extension RecruitData { + func disablingApplication() -> RecruitData { + RecruitData( + id: id, + category: category, + dDay: dDay, + title: title, + meetingType: meetingType, + startDate: startDate, + endDate: endDate, + deadlineDate: deadlineDate, + currentParticipants: currentParticipants, + maximumParticipants: maximumParticipants, + createdAt: createdAt, + author: author, + type: type, + roles: roles, + description: description, + relatedUrl: relatedUrl, + qualification: qualification, + isAuthor: isAuthor, + canApply: false, + applyBlockReason: "이미 지원한 모집글입니다.", + canManageApplicants: canManageApplicants, + teamChatAvailable: teamChatAvailable, + teamChatRoomId: teamChatRoomId + ) + } +} diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index 12ae084ff..3755402b8 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -24,4 +24,5 @@ protocol RecruitRepository { func fetchMyProfile() async throws -> RecruitProfile func postBasicInfo(_ basicInfo: BasicInfo) async throws -> BasicInfo func postRecruitProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile + func apply(_ request: RecruitApplyRequest) async throws -> Void } diff --git a/Koin/Domain/UseCase/Recruit/RecruitApply/ApplyRecruitUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitApply/ApplyRecruitUseCase.swift new file mode 100644 index 000000000..9753d8b09 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitApply/ApplyRecruitUseCase.swift @@ -0,0 +1,24 @@ +// +// ApplyRecruitUseCase.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import Foundation + +protocol ApplyRecruitUseCase { + func execute(request: RecruitApplyRequest) async throws -> Void +} + +final class DefaultApplyRecruitUseCase: ApplyRecruitUseCase { + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(request: RecruitApplyRequest) async throws -> Void { + try await repository.apply(request) + } +} diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift new file mode 100644 index 000000000..cbe980be6 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift @@ -0,0 +1,417 @@ +// +// RecruitApplyViewController.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import Combine +import SnapKit +import Then +import UIKit + +protocol RecruitApplyViewControllerDelegate: AnyObject { + func didApply() +} + +final class RecruitApplyViewController: UIViewController { + + // MARK: - Properties + private weak var delegate: RecruitApplyViewControllerDelegate? + private let viewModel: RecruitApplyViewModel + private let inputSubject = PassthroughSubject() + private var subscriptions = Set() + + private var request: RecruitApplyRequest + private var isFirstStep = true + + // MARK: - UI Components + private let scrollView = UIScrollView() + private let stackView = UIStackView() + + private let firstPageView = UIView() + private let secondPageView = UIView() + private let firstStepView = RecruitApplyFirstStepView() + private let secondStepView: RecruitApplySecondStepView + private let secondStepButtonStackView = UIStackView() + + private let nextButton = StatefulButton( + title: "다음", + font: .appFont(.pretendardSemiBold, size: 15), + enabledColor: .appColor(.new500), + disabledColor: .appColor(.neutral400), + enabledTextColor: .appColor(.neutral0), + disabledTextColor: .appColor(.neutral0), + cornerRadius: 16 + ) + private let applyButton = StatefulButton( + title: "지원하기", + font: .appFont(.pretendardSemiBold, size: 15), + enabledColor: .appColor(.new500), + disabledColor: .appColor(.neutral400), + enabledTextColor: .appColor(.neutral0), + disabledTextColor: .appColor(.neutral0), + cornerRadius: 16 + ) + private let previousButton = UIButton() + + // MARK: - Dropdown + private lazy var dropdownHost = KoinDropdownHost(scrollView: firstStepView) + private lazy var departmentDropdownContentView = RecruitProfilePostDepartmentDropdownView { [weak self] department in + guard let self else { return } + request.basicInfo.department = department + firstStepView.configure(department: department) + updateNextButtonState() + } + private lazy var departmentDropdown = dropdownHost.makeDropdown( + anchor: firstStepView.departmentDropdownAnchor, + contentView: departmentDropdownContentView, + configuration: .init(topPadding: 12, shadow: .shadow2) + ) + + // MARK: - Initializer + init( + viewModel: RecruitApplyViewModel, + recruit: RecruitData, + delegate: RecruitApplyViewControllerDelegate? + ) { + self.viewModel = viewModel + self.delegate = delegate + self.request = RecruitApplyRequest(recruit: recruit) + self.secondStepView = RecruitApplySecondStepView(recruit: recruit) + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + title = "팀원 모집 지원" + configureView() + firstStepView.prepareDropdown(host: dropdownHost) + setAddTargets() + bind() + observeKeyboard() + hideKeyboardWhenTappedAround() + inputSubject.send(.viewDidLoad) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar(style: .newBackground) + } +} + +extension RecruitApplyViewController { + private func bind() { + viewModel.transform(with: inputSubject.eraseToAnyPublisher()) + .receive(on: DispatchQueue.main) + .sink { [weak self] output in + switch output { + case let .updateRecruitProfile(profile): + self?.request.basicInfo = profile.toBasicInfo() + self?.request.skills = profile.skills + self?.request.activities = profile.activities.map { $0.toRequest() } + self?.request.introduction = profile.selfIntroduction + self?.firstStepView.configure(profile) + self?.updateNextButtonState() + case let .updateDepartments(departments): + self?.departmentDropdownContentView.configure(departments: departments) + case .applyCompleted: + self?.handleApplyCompleted() + case let .showToast(message): + self?.showToastMessage(message: message, bottomInset: 72) + } + } + .store(in: &subscriptions) + + firstStepView.loadInfoButtonTappedPublisher + .sink { [weak self] in self?.inputSubject.send(.loadRecruitProfile) } + .store(in: &subscriptions) + firstStepView.nicknameChangedPublisher + .sink { [weak self] value in self?.request.basicInfo.nickname = value; self?.updateNextButtonState() } + .store(in: &subscriptions) + firstStepView.departmentButtonTappedPublisher + .sink { [weak self] in + guard let self else { return } + view.endEditing(true) + restoreFirstStepContentInsetBottom { [weak self] in self?.departmentDropdown.toggle() } + } + .store(in: &subscriptions) + firstStepView.studentNumberChangedPublisher + .sink { [weak self] value in self?.request.basicInfo.studentNumber = value; self?.updateNextButtonState() } + .store(in: &subscriptions) + firstStepView.skillsChangedPublisher + .sink { [weak self] value in self?.request.skills = value; self?.updateNextButtonState() } + .store(in: &subscriptions) + firstStepView.activitiesChangedPublisher + .sink { [weak self] value in self?.request.activities = value; self?.updateNextButtonState() } + .store(in: &subscriptions) + firstStepView.introductionChangedPublisher + .sink { [weak self] value in self?.request.introduction = value; self?.updateNextButtonState() } + .store(in: &subscriptions) + firstStepView.didChangeHeightPublisher + .sink { [weak self] in + guard let self else { return } + view.layoutIfNeeded() + UIView.animate( + springDuration: 0.25, + options: [.beginFromCurrentState, .allowUserInteraction] + ) { + self.firstStepView.applyPendingSizeChange() + self.updateNextButtonState() + self.view.layoutIfNeeded() + } + } + .store(in: &subscriptions) + + secondStepView.selectedRoleChangedPublisher + .sink { [weak self] value in self?.request.selectedRole = value; self?.updateApplyButtonState() } + .store(in: &subscriptions) + secondStepView.motivationChangedPublisher + .sink { [weak self] value in self?.request.motivation = value; self?.updateApplyButtonState() } + .store(in: &subscriptions) + secondStepView.availableTimeChangedPublisher + .sink { [weak self] value in self?.request.availableTime = value; self?.updateApplyButtonState() } + .store(in: &subscriptions) + } +} + +extension RecruitApplyViewController { + private func setAddTargets() { + nextButton.addTarget(self, action: #selector(nextButtonTapped), for: .touchUpInside) + previousButton.addTarget(self, action: #selector(previousButtonTapped), for: .touchUpInside) + applyButton.addTarget(self, action: #selector(applyButtonTapped), for: .touchUpInside) + } + + @objc private func nextButtonTapped() { + guard request.isFirstStepValid, !firstStepView.isEditingActivity else { return } + isFirstStep = false + view.endEditing(true) + dropdownHost.dismissPresented() + view.layoutIfNeeded() + scrollView.setContentOffset(CGPoint(x: scrollView.bounds.width, y: 0), animated: true) + } + + @objc private func previousButtonTapped() { + isFirstStep = true + view.endEditing(true) + dropdownHost.dismissPresented() + view.layoutIfNeeded() + scrollView.setContentOffset(.zero, animated: true) + } + + @objc private func applyButtonTapped() { + guard request.isSecondStepValid else { return } + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .new, + content: .singleTitle(text: "해당 팀원 모집에 지원하시겠어요?"), + button: .buttons( + leftButtonTitle: "취소하기", + rightButtonTitle: "지원하기", + rightButtonAction: { [weak self] in + guard let self else { return } + inputSubject.send(.apply(request)) + } + ) + )) + present(modalViewController, animated: true) + } + + private func handleApplyCompleted() { + delegate?.didApply() + navigationController?.popViewController(animated: true) + } +} + +extension RecruitApplyViewController { + private func observeKeyboard() { + NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let self else { return } + let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + let keyboardMinY = keyboardFrame.map { self.view.convert($0, from: nil).minY } + ?? firstStepView.convert(firstStepView.bounds, to: view).maxY + let activeView: UIScrollView = isFirstStep ? firstStepView : secondStepView + let activeMaxY = activeView.convert(activeView.bounds, to: view).maxY + let bottomInset = max(16, activeMaxY - keyboardMinY + 16) + let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25 + let curveValue = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt + ?? UInt(UIView.AnimationCurve.easeInOut.rawValue) + let options = UIView.AnimationOptions(rawValue: curveValue << 16) + + UIView.animate( + withDuration: duration, + delay: 0, + options: [.beginFromCurrentState, options] + ) { + activeView.contentInset.bottom = bottomInset + activeView.verticalScrollIndicatorInsets.bottom = bottomInset + } completion: { [weak self, weak activeView] _ in + guard let self, let activeView else { return } + scrollFirstResponderToVisible(in: activeView) + } + } + .store(in: &subscriptions) + + NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.restoreStepContentInsetBottom() + } + .store(in: &subscriptions) + } + + private func restoreFirstStepContentInsetBottom(_ completion: (() -> Void)? = nil) { + UIView.animate( + springDuration: 0.1, + options: [.beginFromCurrentState] + ) { [weak self] in + self?.firstStepView.contentInset.bottom = 16 + } completion: { _ in + completion?() + } + } + + private func restoreStepContentInsetBottom() { + UIView.animate( + springDuration: 0.1, + options: [.beginFromCurrentState] + ) { [weak self] in + self?.firstStepView.contentInset.bottom = 16 + self?.secondStepView.contentInset.bottom = 16 + self?.firstStepView.verticalScrollIndicatorInsets.bottom = 16 + self?.secondStepView.verticalScrollIndicatorInsets.bottom = 16 + } + } + + private func scrollFirstResponderToVisible(in scrollView: UIScrollView) { + guard let firstResponder = findFirstResponder(in: scrollView) else { return } + var rect = firstResponder.convert(firstResponder.bounds, to: scrollView) + rect.size.height += 16 + scrollView.scrollRectToVisible(rect, animated: true) + } + + private func findFirstResponder(in view: UIView) -> UIView? { + if view.isFirstResponder { + return view + } + for subview in view.subviews { + if let firstResponder = findFirstResponder(in: subview) { + return firstResponder + } + } + return nil + } +} + +extension RecruitApplyViewController { + private func updateNextButtonState() { + nextButton.updateState( + isEnabled: request.isFirstStepValid && !firstStepView.isEditingActivity + ) + } + + private func updateApplyButtonState() { + applyButton.updateState(isEnabled: request.isSecondStepValid) + } +} + +extension RecruitApplyViewController { + private func configureView() { + setUpStyle() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyle() { + view.backgroundColor = .appColor(.newBackground) + + scrollView.do { + $0.isPagingEnabled = true + $0.isScrollEnabled = false + $0.bounces = false + $0.showsHorizontalScrollIndicator = false + $0.showsVerticalScrollIndicator = false + $0.contentInsetAdjustmentBehavior = .never + } + stackView.do { + $0.axis = .horizontal + $0.alignment = .fill + $0.distribution = .fill + $0.spacing = 0 + } + secondStepButtonStackView.do { + $0.axis = .horizontal + $0.alignment = .fill + $0.distribution = .fillEqually + $0.spacing = 12 + } + previousButton.do { + $0.setAttributedTitle(NSAttributedString( + string: "이전", + attributes: [ + .font: UIFont.appFont(.pretendardSemiBold, size: 15), + .foregroundColor: UIColor.appColor(.new500) + ] + ), for: .normal) + $0.backgroundColor = .clear + $0.layer.borderColor = UIColor.appColor(.new500).cgColor + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 16 + } + } + + private func setUpLayouts() { + [firstStepView, nextButton].forEach { + firstPageView.addSubview($0) + } + [previousButton, applyButton].forEach { + secondStepButtonStackView.addArrangedSubview($0) + } + [secondStepView, secondStepButtonStackView].forEach { + secondPageView.addSubview($0) + } + [firstPageView, secondPageView].forEach { + stackView.addArrangedSubview($0) + } + scrollView.addSubview(stackView) + view.addSubview(scrollView) + } + + private func setUpConstraints() { + stackView.snp.makeConstraints { + $0.edges.equalTo(scrollView.contentLayoutGuide) + $0.height.equalTo(scrollView.frameLayoutGuide) + } + [firstPageView, secondPageView].forEach { + $0.snp.makeConstraints { $0.width.equalTo(scrollView.frameLayoutGuide) } + } + scrollView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(view.safeAreaLayoutGuide) + } + firstStepView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.bottom.equalTo(nextButton.snp.top).offset(-16) + } + nextButton.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(32) + $0.bottom.equalToSuperview().offset(-16) + $0.height.equalTo(48) + } + secondStepView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.bottom.equalTo(secondStepButtonStackView.snp.top).offset(-16) + } + secondStepButtonStackView.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(32) + $0.bottom.equalToSuperview().offset(-16) + $0.height.equalTo(48) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift new file mode 100644 index 000000000..776de034f --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift @@ -0,0 +1,115 @@ +// +// RecruitApplyViewModel.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import Combine +import Foundation + +final class RecruitApplyViewModel: ViewModelProtocol { + + enum Input { + case viewDidLoad + case loadRecruitProfile + case apply(RecruitApplyRequest) + } + + enum Output { + case updateRecruitProfile(RecruitProfile) + case updateDepartments([String]) + case applyCompleted + case showToast(String) + } + + // MARK: - Properties + private let fetchDeptListUseCase: FetchDeptListUseCase + private let fetchMyProfileUseCase: FetchMyProfileUseCase + private let applyRecruitUseCase: ApplyRecruitUseCase + private let outputSubject = PassthroughSubject() + private var subscriptions = Set() + private var isLoadingProfile = false + private var isApplying = false + + // MARK: - Initializer + init( + fetchDeptListUseCase: FetchDeptListUseCase, + fetchMyProfileUseCase: FetchMyProfileUseCase, + applyRecruitUseCase: ApplyRecruitUseCase + ) { + self.fetchDeptListUseCase = fetchDeptListUseCase + self.fetchMyProfileUseCase = fetchMyProfileUseCase + self.applyRecruitUseCase = applyRecruitUseCase + } + + func transform(with input: AnyPublisher) -> AnyPublisher { + input + .sink { [weak self] input in + switch input { + case .viewDidLoad: + self?.fetchDepartments() + case .loadRecruitProfile: + self?.fetchRecruitProfile() + case let .apply(request): + self?.apply(request) + } + } + .store(in: &subscriptions) + + return outputSubject.eraseToAnyPublisher() + } +} + +extension RecruitApplyViewModel { + private func fetchDepartments() { + fetchDeptListUseCase.execute() + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + guard case let .failure(error) = completion else { return } + self?.outputSubject.send(.showToast(error.message)) + }, + receiveValue: { [weak self] departments in + self?.outputSubject.send(.updateDepartments(departments)) + } + ) + .store(in: &subscriptions) + } + + private func fetchRecruitProfile() { + guard !isLoadingProfile else { return } + + isLoadingProfile = true + Task { [weak self] in + guard let self else { return } + defer { isLoadingProfile = false } + + do { + let profile = try await fetchMyProfileUseCase.execute() + outputSubject.send(.updateRecruitProfile(profile)) + } catch { + let message = (error as? ErrorResponse)?.message ?? "프로필을 불러오지 못했습니다." + outputSubject.send(.showToast(message)) + } + } + } + + private func apply(_ request: RecruitApplyRequest) { + guard !isApplying else { return } + + isApplying = true + Task { [weak self] in + guard let self else { return } + defer { isApplying = false } + + do { + try await applyRecruitUseCase.execute(request: request) + outputSubject.send(.applyCompleted) + } catch { + guard let error = error as? ErrorResponse else { return } + outputSubject.send(.showToast(error.message)) + } + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift new file mode 100644 index 000000000..e7f8685c6 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift @@ -0,0 +1,266 @@ +// +// RecruitApplyFirstStepView.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import Combine +import SnapKit +import Then +import UIKit + +final class RecruitApplyFirstStepView: UIScrollView { + + // MARK: - Publisher + let loadInfoButtonTappedPublisher = PassthroughSubject() + let departmentButtonTappedPublisher = PassthroughSubject() + let didChangeHeightPublisher = PassthroughSubject() + + var nicknameChangedPublisher: AnyPublisher { + nicknameView.textChangedPublisher.eraseToAnyPublisher() + } + var studentNumberChangedPublisher: AnyPublisher { + studentNumberView.textChangedPublisher.eraseToAnyPublisher() + } + var skillsChangedPublisher: AnyPublisher<[String], Never> { + skillTableView.skillsChangedPublisher.eraseToAnyPublisher() + } + var activitiesChangedPublisher: AnyPublisher<[RecruitProfileActivityRequest], Never> { + activityTableView.activitiesChangedPublisher.eraseToAnyPublisher() + } + var introductionChangedPublisher: AnyPublisher { + introductionView.textChangedPublisher.eraseToAnyPublisher() + } + + // MARK: - Properties + private var subscriptions = Set() + + var isEditingActivity: Bool { + activityTableView.hasEditingRow + } + + // MARK: - Dropdown + var departmentDropdownAnchor: UIView { + departmentButton + } + + // MARK: - UI Components + private let contentStackView = UIStackView() + private let stepView = RecruitStepView( + firstStepTitle: "기본 정보", + secondStepTitle: "지원서 작성", + isFirstStep: true + ) + private let loadInfoStackView = UIStackView() + private let loadInfoHeaderView: RecruitSectionHeaderView = { + let title = NSMutableAttributedString( + string: "코인", + attributes: [ + .font: UIFont.appFont(.pretendardSemiBold, size: 16), + .foregroundColor: UIColor.appColor(.new500) + ] + ) + title.append(NSAttributedString( + string: " 회원정보 불러오기", + attributes: [ + .font: UIFont.appFont(.pretendardSemiBold, size: 16), + .foregroundColor: UIColor.appColor(.neutral800) + ] + )) + return RecruitSectionHeaderView( + attributedTitle: title, + description: "닉네임, 학과(학부), 학번" + ) + }() + private let loadInfoButton = UIButton(type: .system) + private let nicknameView = RecruitTextFieldView( + title: "닉네임", + isRequired: true, + limit: 20, + placeholder: "닉네임을 입력해주세요." + ) + private let departmentContainerView = UIView() + private let departmentHeaderView = RecruitPostSectionHeaderView( + title: "학과 · 학부", + isRequired: true + ) + private let departmentButton = RecruitDropdownTriggerButton( + placeholder: "학과 · 학부를 선택해주세요." + ) + private let studentNumberView = RecruitTextFieldView( + title: "학번", + isRequired: true, + limit: nil, + placeholder: "학번을 작성해주세요.", + keyboardType: .numberPad + ) + private let skillSectionStackView = UIStackView() + private let skillHeaderView = RecruitSectionHeaderView( + title: "보유기술 / 자격증", + description: "기술 / 자격증은 항목별로 하나씩 작성해주세요." + ) + private let skillTableView = RecruitProfilePostSkillTableView() + private let activitySectionStackView = UIStackView() + private let activityHeaderView = RecruitSectionHeaderView( + title: "활동 이력", + description: "공모전, 대외활동, 자치단체 등 활동 이력을 작성해주세요." + ) + private let activityTableView = RecruitProfilePostActivityTableView() + private let introductionView = RecruitPostTextViewView( + title: "자기소개", + isRequired: true, + limit: 1000, + placeholder: "자기소개를 작성해주세요." + ) + + // MARK: - Initializer + init() { + super.init(frame: .zero) + configureView() + setAddTargets() + bind() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(_ info: BasicInfo) { + nicknameView.configure(text: info.nickname) + departmentButton.configure(text: info.department) + studentNumberView.configure(text: info.studentNumber) + } + + func configure(_ profile: RecruitProfile) { + configure(profile.toBasicInfo()) + skillTableView.configure(skills: profile.skills) + activityTableView.configure(activities: profile.activities.map { $0.toRequest() }) + introductionView.configure(text: profile.selfIntroduction) + } + + func configure(department: String?) { + departmentButton.configure(text: department) + } + + func prepareDropdown(host: KoinDropdownHost) { + activityTableView.prepareDropdown(host: host) + } + + func applyPendingSizeChange() { + skillTableView.applyPendingSizeChange() + activityTableView.applyPendingSizeChange() + } +} + +extension RecruitApplyFirstStepView { + private func bind() { + Publishers.Merge( + skillTableView.didChangeHeightPublisher, + activityTableView.didChangeHeightPublisher + ) + .subscribe(didChangeHeightPublisher) + .store(in: &subscriptions) + } +} + +extension RecruitApplyFirstStepView { + private func setAddTargets() { + loadInfoButton.addTarget(self, action: #selector(loadInfoButtonTapped), for: .touchUpInside) + departmentButton.addTarget(self, action: #selector(departmentButtonTapped), for: .touchUpInside) + } + + @objc private func loadInfoButtonTapped() { + loadInfoButtonTappedPublisher.send() + } + + @objc private func departmentButtonTapped() { + departmentButtonTappedPublisher.send() + } +} + + +extension RecruitApplyFirstStepView { + private func configureView() { + setUpStyle() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyle() { + backgroundColor = .appColor(.newBackground) + showsVerticalScrollIndicator = false + alwaysBounceVertical = true + keyboardDismissMode = .interactive + contentInsetAdjustmentBehavior = .never + contentInset = UIEdgeInsets(top: 32, left: 0, bottom: 16, right: 0) + + contentStackView.do { + $0.axis = .vertical + $0.spacing = 24 + } + [loadInfoStackView, skillSectionStackView, activitySectionStackView].forEach { + $0.axis = .vertical + $0.spacing = 8 + } + loadInfoButton.do { + var configuration = UIButton.Configuration.plain() + configuration.attributedTitle = AttributedString( + "회원정보 불러오기", + attributes: AttributeContainer([ + .font: UIFont.appFont(.pretendardRegular, size: 14), + .foregroundColor: UIColor.appColor(.new500) + ]) + ) + $0.configuration = configuration + $0.layer.cornerRadius = 16 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.appColor(.new500).cgColor + $0.backgroundColor = .appColor(.neutral0) + } + } + + private func setUpLayouts() { + [loadInfoHeaderView, loadInfoButton].forEach { + loadInfoStackView.addArrangedSubview($0) + } + [departmentHeaderView, departmentButton].forEach { + departmentContainerView.addSubview($0) + } + [skillHeaderView, skillTableView].forEach { + skillSectionStackView.addArrangedSubview($0) + } + [activityHeaderView, activityTableView].forEach { + activitySectionStackView.addArrangedSubview($0) + } + [stepView, loadInfoStackView, nicknameView, departmentContainerView, studentNumberView, + skillSectionStackView, activitySectionStackView, introductionView].forEach { + contentStackView.addArrangedSubview($0) + } + addSubview(contentStackView) + + contentStackView.setCustomSpacing(32, after: stepView) + } + + private func setUpConstraints() { + contentStackView.snp.makeConstraints { + $0.top.bottom.equalTo(contentLayoutGuide) + $0.leading.trailing.equalTo(contentLayoutGuide).inset(24) + $0.width.equalTo(frameLayoutGuide).offset(-48) + } + stepView.snp.makeConstraints { + $0.height.equalTo(52) + } + loadInfoButton.snp.makeConstraints { + $0.height.equalTo(40) + } + departmentHeaderView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + } + departmentButton.snp.makeConstraints { + $0.top.equalTo(departmentHeaderView.snp.bottom).offset(8) + $0.leading.trailing.bottom.equalToSuperview() + $0.height.equalTo(40) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyRoleSelectionView.swift b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyRoleSelectionView.swift new file mode 100644 index 000000000..3ed6927f7 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyRoleSelectionView.swift @@ -0,0 +1,174 @@ +// +// RecruitApplyRoleSelectionView.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import Combine +import SnapKit +import Then +import UIKit + +final class RecruitApplyRoleSelectionView: UIView { + + // MARK: - Publisher + let selectedRoleChangedPublisher = PassthroughSubject() + + // MARK: - Properties + private let roles: [RecruitRole] + private var selectedRoleId: Int? + + // MARK: - UI Components + private let stackView = UIStackView() + private let headerView = RecruitPostSectionHeaderView( + title: "지원 역할 선택", + isRequired: true + ) + private var roleButtons: [RecruitApplyRoleButton] = [] + + init(roles: [RecruitRole]) { + self.roles = roles + super.init(frame: .zero) + configureView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension RecruitApplyRoleSelectionView { + private func configureView() { + setUpStyle() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyle() { + stackView.do { + $0.axis = .vertical + $0.spacing = 8 + } + } + + private func setUpLayouts() { + addSubview(stackView) + stackView.addArrangedSubview(headerView) + + roleButtons = roles.enumerated().map { index, role in + let button = RecruitApplyRoleButton(role: role) + button.tag = index + button.addTarget(self, action: #selector(roleButtonTapped(_:)), for: .touchUpInside) + stackView.addArrangedSubview(button) + return button + } + } + + private func setUpConstraints() { + roleButtons.forEach { button in + button.snp.makeConstraints { $0.height.equalTo(40) } + } + stackView.snp.makeConstraints { $0.edges.equalToSuperview() } + } + + @objc private func roleButtonTapped(_ sender: RecruitApplyRoleButton) { + guard roles.indices.contains(sender.tag), !roles[sender.tag].isClosed else { return } + selectedRoleId = roles[sender.tag].id + for (index, button) in roleButtons.enumerated() { + button.setSelected(roles[index].id == selectedRoleId) + } + selectedRoleChangedPublisher.send(roles[sender.tag]) + UISelectionFeedbackGenerator().selectionChanged() + } +} + +private final class RecruitApplyRoleButton: UIButton { + + private let recruitRole: RecruitRole + private let radioView = UIView() + private let radioDotView = UIView() + private let roleLabel = UILabel() + private let closedLabel = UILabel() + + init(role: RecruitRole) { + self.recruitRole = role + super.init(frame: .zero) + configureView() + setSelected(false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setSelected(_ isSelected: Bool) { + radioDotView.isHidden = !isSelected + } +} + +extension RecruitApplyRoleButton { + private func configureView() { + setUpStyle() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyle() { + backgroundColor = .appColor(recruitRole.isClosed ? .neutral200 : .neutral0) + layer.cornerRadius = 16 + isEnabled = !recruitRole.isClosed + + radioView.do { + $0.backgroundColor = .clear + $0.layer.cornerRadius = 10 + $0.layer.borderWidth = 1.5 + $0.layer.borderColor = UIColor.appColor(.neutral400).cgColor + $0.isUserInteractionEnabled = false + } + radioDotView.do { + $0.backgroundColor = .appColor(.new500) + $0.layer.cornerRadius = 5 + $0.isUserInteractionEnabled = false + } + roleLabel.do { + $0.text = recruitRole.name + $0.font = .appFont(.pretendardRegular, size: 14) + $0.textColor = .appColor(recruitRole.isClosed ? .neutral500 : .neutral800) + $0.isUserInteractionEnabled = false + } + closedLabel.do { + $0.text = recruitRole.isClosed ? "모집 마감" : nil + $0.font = .appFont(.pretendardRegular, size: 14) + $0.textColor = .appColor(.neutral600) + $0.isHidden = !recruitRole.isClosed + $0.isUserInteractionEnabled = false + } + } + + private func setUpLayouts() { + [radioView, roleLabel, closedLabel].forEach { + addSubview($0) + } + radioView.addSubview(radioDotView) + } + + private func setUpConstraints() { + radioView.snp.makeConstraints { + $0.leading.equalToSuperview().offset(16) + $0.centerY.equalToSuperview() + $0.size.equalTo(20) + } + radioDotView.snp.makeConstraints { $0.center.equalToSuperview(); $0.size.equalTo(10) } + roleLabel.snp.makeConstraints { + $0.leading.equalTo(radioView.snp.trailing).offset(12) + $0.centerY.equalToSuperview() + } + closedLabel.snp.makeConstraints { + $0.trailing.equalToSuperview().offset(-16) + $0.centerY.equalToSuperview() + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplySecondStepView.swift b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplySecondStepView.swift new file mode 100644 index 000000000..02ef5000c --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplySecondStepView.swift @@ -0,0 +1,112 @@ +// +// RecruitApplySecondStepView.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import Combine +import SnapKit +import Then +import UIKit + +final class RecruitApplySecondStepView: UIScrollView { + + // MARK: - Publisher + let selectedRoleChangedPublisher = PassthroughSubject() + var motivationChangedPublisher: AnyPublisher { + motivationView.textChangedPublisher.eraseToAnyPublisher() + } + var availableTimeChangedPublisher: AnyPublisher { + availableTimeView.textChangedPublisher.eraseToAnyPublisher() + } + + // MARK: - Properties + private var subscriptions = Set() + private let showsRoleSelection: Bool + + // MARK: - UI Components + private let contentStackView = UIStackView() + private let stepView = RecruitStepView( + firstStepTitle: "기본 정보", + secondStepTitle: "지원서 작성", + isFirstStep: false + ) + private let roleSelectionView: RecruitApplyRoleSelectionView + private let motivationView = RecruitPostTextViewView( + title: "지원동기", + isRequired: true, + limit: 1000, + placeholder: "지원동기를 작성해주세요." + ) + private let availableTimeView = RecruitPostTextViewView( + title: "참여 가능 시간", + isRequired: true, + limit: 100, + placeholder: "참여 가능한 시간을 작성해주세요." + ) + + // MARK: - Initializer + init(recruit: RecruitData) { + showsRoleSelection = recruit.type == .roleBased + roleSelectionView = RecruitApplyRoleSelectionView(roles: recruit.roles) + super.init(frame: .zero) + configureView() + bind() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension RecruitApplySecondStepView { + private func bind() { + roleSelectionView.selectedRoleChangedPublisher + .subscribe(selectedRoleChangedPublisher) + .store(in: &subscriptions) + } +} + +extension RecruitApplySecondStepView { + private func configureView() { + setUpStyle() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyle() { + backgroundColor = .appColor(.newBackground) + showsVerticalScrollIndicator = false + alwaysBounceVertical = true + keyboardDismissMode = .interactive + contentInsetAdjustmentBehavior = .never + contentInset = UIEdgeInsets(top: 32, left: 0, bottom: 16, right: 0) + + contentStackView.do { + $0.axis = .vertical + $0.spacing = 24 + $0.setCustomSpacing(32, after: stepView) + } + roleSelectionView.isHidden = !showsRoleSelection + } + + private func setUpLayouts() { + [stepView, roleSelectionView, motivationView, availableTimeView].forEach { + contentStackView.addArrangedSubview($0) + } + [contentStackView].forEach { + addSubview($0) + } + } + + private func setUpConstraints() { + contentStackView.snp.makeConstraints { + $0.top.bottom.equalTo(contentLayoutGuide) + $0.leading.trailing.equalTo(contentLayoutGuide).inset(24) + $0.width.equalTo(frameLayoutGuide).offset(-48) + } + stepView.snp.makeConstraints { + $0.height.equalTo(52) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index d7956f421..c171cc6a7 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -51,7 +51,7 @@ final class RecruitDataHostingController: UIHostingController, case .didDelete: handleDelete() case let .showToast(message): - showToastMessage(message: message) + showToastMessage(message: message, bottomInset: 72) case .showLoginToast: showLoginToast() case .isAuthor(let isAuthor): @@ -80,7 +80,8 @@ extension RecruitDataHostingController { private func showLoginToast() { showToastMessageWithButton( message: "로그인이 필요한 기능입니다.", - buttonTitle: "로그인하기" + buttonTitle: "로그인하기", + bottomInset: 72 ) { [weak self] in self?.navigateToLogin() } @@ -130,10 +131,21 @@ extension RecruitDataHostingController { // TODO: show applicant } private func navigateToApply() { - guard let id = rootView.id else { + guard let recruit = rootView.data else { return } - // TODO: show apply + let recruitRepository = MockRecruitRepository() + let viewModel = RecruitApplyViewModel( + fetchDeptListUseCase: MockFetchDeptListUseCase(), + fetchMyProfileUseCase: DefaultFetchMyProfileUseCase(repository: recruitRepository), + applyRecruitUseCase: DefaultApplyRecruitUseCase(repository: recruitRepository) + ) + let viewController = RecruitApplyViewController( + viewModel: viewModel, + recruit: recruit, + delegate: self + ) + navigationController?.pushViewController(viewController, animated: true) } private func handleDelete() { @@ -160,3 +172,10 @@ extension RecruitDataHostingController { navigationController?.pushViewController(viewController, animated: true) } } + +extension RecruitDataHostingController: RecruitApplyViewControllerDelegate { + func didApply() { + rootView.didSubmitApplication() + showToastMessage(message: "지원서가 제출되었습니다.", bottomInset: 72) + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift index 75c223f17..d883b849d 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift @@ -66,6 +66,10 @@ struct RecruitDataView: ActionBindableView { func didTapDelete() { viewModel.execute(.delete) } + + func didSubmitApplication() { + viewModel.execute(.applicationSubmitted) + } } extension RecruitDataView { diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift index f72684648..6b8b0cf83 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift @@ -15,6 +15,7 @@ final class RecruitDataViewModel: SwiftUIViewModelProtocol { enum Input { case load case delete + case applicationSubmitted case didShowToast } @@ -23,6 +24,7 @@ final class RecruitDataViewModel: SwiftUIViewModelProtocol { private(set) var isLoading = false private(set) var errorMessage: String? private(set) var didDelete = false + private var didSubmitApplication = false // MARK: - Properties private let recruitId: Int @@ -46,6 +48,8 @@ final class RecruitDataViewModel: SwiftUIViewModelProtocol { load() case .delete: delete() + case .applicationSubmitted: + applicationSubmitted() case .didShowToast: errorMessage = nil } @@ -63,7 +67,7 @@ extension RecruitDataViewModel { isLoading = false } let data = try await fetchRecruitDataUseCase.execute(id: recruitId) - self.data = data + self.data = didSubmitApplication ? data.disablingApplication() : data } catch { if let error = error as? ErrorResponse { errorMessage = error.message @@ -97,4 +101,10 @@ extension RecruitDataViewModel { } } } + + private func applicationSubmitted() { + didSubmitApplication = true + data = data?.disablingApplication() + load() + } } diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextFieldView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextFieldView.swift index 06d0ac75a..6bff899d8 100644 --- a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextFieldView.swift +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextFieldView.swift @@ -36,6 +36,7 @@ final class RecruitTextFieldView: UIView { self.limit = limit super.init(frame: .zero) configureView() + textField.delegate = self setAddTargets() setUpPlaceholder(placeholder) textField.keyboardType = keyboardType @@ -53,6 +54,13 @@ final class RecruitTextFieldView: UIView { } } +extension RecruitTextFieldView: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} + extension RecruitTextFieldView { private func setAddTargets() { textField.addTarget(self, action: #selector(textFieldEditingChanged), for: .editingChanged) diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 4bdeb5b73..de65606a6 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -168,31 +168,14 @@ 7C50A20B304324F500897A6F /* RecruitNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20A304324F500897A6F /* RecruitNotification.swift */; }; 7C50A20D3043273A00897A6F /* RecruitNotificationList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */; }; 7C50A20F3043278300897A6F /* FetchRecruitNotificationListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20E3043278300897A6F /* FetchRecruitNotificationListUseCase.swift */; }; - BEEF20000000000000000001 /* RecruitMyPostList.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000001 /* RecruitMyPostList.swift */; }; - BEEF20000000000000000002 /* RecruitMyPostSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000002 /* RecruitMyPostSummary.swift */; }; - BEEF20000000000000000003 /* RecruitMyPostFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000003 /* RecruitMyPostFilter.swift */; }; - BEEF20000000000000000004 /* FetchRecruitMyPostListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000004 /* FetchRecruitMyPostListUseCase.swift */; }; - BEEF20000000000000000005 /* RecruitMyPostFilter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000005 /* RecruitMyPostFilter+.swift */; }; - BEEF20000000000000000006 /* RecruitMyPostListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000006 /* RecruitMyPostListViewModel.swift */; }; - BEEF20000000000000000007 /* RecruitMyPostListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000007 /* RecruitMyPostListView.swift */; }; - BEEF20000000000000000008 /* RecruitMyPostListHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000008 /* RecruitMyPostListHostingController.swift */; }; - BEEF20000000000000000009 /* RecruitMyPostListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000009 /* RecruitMyPostListHeaderView.swift */; }; - BEEF2000000000000000000A /* RecruitMyPostListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF1000000000000000000A /* RecruitMyPostListRowView.swift */; }; - BEEF2000000000000000000B /* RecruitMyPostListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF1000000000000000000B /* RecruitMyPostListEmptyView.swift */; }; - BEEF2000000000000000000C /* CloseRecruitMyPostUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF1000000000000000000C /* CloseRecruitMyPostUseCase.swift */; }; - CAFE20000000000000000001 /* RecruitMyApplicationList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000001 /* RecruitMyApplicationList.swift */; }; - CAFE20000000000000000002 /* RecruitMyApplicationSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000002 /* RecruitMyApplicationSummary.swift */; }; - CAFE20000000000000000003 /* RecruitMyApplicationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */; }; - CAFE20000000000000000004 /* RecruitMyApplicationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000004 /* RecruitMyApplicationStatus.swift */; }; - CAFE20000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift */; }; - CAFE20000000000000000006 /* RecruitMyApplicationFilter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000006 /* RecruitMyApplicationFilter+.swift */; }; - CAFE20000000000000000007 /* RecruitMyApplicationListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000007 /* RecruitMyApplicationListViewModel.swift */; }; - CAFE20000000000000000008 /* RecruitMyApplicationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000008 /* RecruitMyApplicationListView.swift */; }; - CAFE20000000000000000009 /* RecruitMyApplicationListHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000009 /* RecruitMyApplicationListHostingController.swift */; }; - CAFE2000000000000000000A /* RecruitMyApplicationListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE1000000000000000000A /* RecruitMyApplicationListHeaderView.swift */; }; - CAFE2000000000000000000B /* RecruitMyApplicationListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE1000000000000000000B /* RecruitMyApplicationListRowView.swift */; }; - CAFE2000000000000000000C /* RecruitMyApplicationListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE1000000000000000000C /* RecruitMyApplicationListEmptyView.swift */; }; 7C50F8DC300FC03B00A87BAE /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 7C50F8DB300FC03B00A87BAE /* AppIcon.icon */; }; + 7C52086130692C150009BDD3 /* ApplyRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C52086030692C150009BDD3 /* ApplyRecruitUseCase.swift */; }; + 7C520863306902C10009BDD3 /* RecruitApplyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C52085E306902C10009BDD3 /* RecruitApplyViewModel.swift */; }; + 7C520864306902C10009BDD3 /* RecruitApplyFirstStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C52085F306902C10009BDD3 /* RecruitApplyFirstStepView.swift */; }; + 7C520865306902C10009BDD3 /* RecruitApplyRoleSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C520860306902C10009BDD3 /* RecruitApplyRoleSelectionView.swift */; }; + 7C520866306902C10009BDD3 /* RecruitApplySecondStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C520861306902C10009BDD3 /* RecruitApplySecondStepView.swift */; }; + 7C520867306902C10009BDD3 /* RecruitApplyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C520862306902C10009BDD3 /* RecruitApplyViewController.swift */; }; + 7C520869306902C10009BDD3 /* RecruitApplyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C520868306902C10009BDD3 /* RecruitApplyRequest.swift */; }; 7C5583602F11778B00D3A980 /* ShopInfoHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C55835F2F11778B00D3A980 /* ShopInfoHeaderView.swift */; }; 7C5583622F11779400D3A980 /* ShopInfoFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C5583612F11779400D3A980 /* ShopInfoFooterView.swift */; }; 7C58FE862F5F9CDD00A2737B /* CallVanDataViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C58FE852F5F9CDD00A2737B /* CallVanDataViewController.swift */; }; @@ -826,6 +809,8 @@ 8A7B1679CC9945559E674584 /* RecruitDataRolesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */; }; 9C95E190C98F440DADB6F91B /* RecruitDataMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DADCC36CEEA42EC86AB75B6 /* RecruitDataMetadataView.swift */; }; A0B343754988C9398C4FE726 /* ModifyRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */; }; + A11B00000000000000000001 /* PostBasicInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11B00000000000000000002 /* PostBasicInfoUseCase.swift */; }; + A11B00000000000000000003 /* PostRecruitProfileUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11B00000000000000000004 /* PostRecruitProfileUseCase.swift */; }; A1D000010000000000000001 /* FetchRecruitDataUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D000010000000000000002 /* FetchRecruitDataUseCase.swift */; }; A1E000010000000000000001 /* RecruitPostRoleTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E000010000000000000002 /* RecruitPostRoleTableView.swift */; }; A51C00000000000000000001 /* RecruitProfilePostAddTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B00000000000000000001 /* RecruitProfilePostAddTableFooterView.swift */; }; @@ -874,11 +859,33 @@ B6A0D62A2EEB01E600A19521 /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = B6A0D6292EEB01E600A19521 /* Debug.xcconfig */; }; B6A0D62C2EEB01F100A19521 /* Release.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = B6A0D62B2EEB01F100A19521 /* Release.xcconfig */; }; BAD01845AA334A648E2FA2BD /* RecruitDataHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBC0EF8E0FE44F48744BEC7 /* RecruitDataHeaderView.swift */; }; + BEEF20000000000000000001 /* RecruitMyPostList.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000001 /* RecruitMyPostList.swift */; }; + BEEF20000000000000000002 /* RecruitMyPostSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000002 /* RecruitMyPostSummary.swift */; }; + BEEF20000000000000000003 /* RecruitMyPostFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000003 /* RecruitMyPostFilter.swift */; }; + BEEF20000000000000000004 /* FetchRecruitMyPostListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000004 /* FetchRecruitMyPostListUseCase.swift */; }; + BEEF20000000000000000005 /* RecruitMyPostFilter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000005 /* RecruitMyPostFilter+.swift */; }; + BEEF20000000000000000006 /* RecruitMyPostListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000006 /* RecruitMyPostListViewModel.swift */; }; + BEEF20000000000000000007 /* RecruitMyPostListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000007 /* RecruitMyPostListView.swift */; }; + BEEF20000000000000000008 /* RecruitMyPostListHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000008 /* RecruitMyPostListHostingController.swift */; }; + BEEF20000000000000000009 /* RecruitMyPostListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000009 /* RecruitMyPostListHeaderView.swift */; }; + BEEF2000000000000000000A /* RecruitMyPostListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF1000000000000000000A /* RecruitMyPostListRowView.swift */; }; + BEEF2000000000000000000B /* RecruitMyPostListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF1000000000000000000B /* RecruitMyPostListEmptyView.swift */; }; + BEEF2000000000000000000C /* CloseRecruitMyPostUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF1000000000000000000C /* CloseRecruitMyPostUseCase.swift */; }; C0DE00000000000000000001 /* RecruitProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000002 /* RecruitProfile.swift */; }; C0DE00000000000000000003 /* FetchMyProfileUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000004 /* FetchMyProfileUseCase.swift */; }; - A11B00000000000000000001 /* PostBasicInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11B00000000000000000002 /* PostBasicInfoUseCase.swift */; }; - A11B00000000000000000003 /* PostRecruitProfileUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11B00000000000000000004 /* PostRecruitProfileUseCase.swift */; }; C1838F7A4424DEA14DB2D107 /* RecruitPostCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */; }; + CAFE20000000000000000001 /* RecruitMyApplicationList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000001 /* RecruitMyApplicationList.swift */; }; + CAFE20000000000000000002 /* RecruitMyApplicationSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000002 /* RecruitMyApplicationSummary.swift */; }; + CAFE20000000000000000003 /* RecruitMyApplicationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */; }; + CAFE20000000000000000004 /* RecruitMyApplicationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000004 /* RecruitMyApplicationStatus.swift */; }; + CAFE20000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift */; }; + CAFE20000000000000000006 /* RecruitMyApplicationFilter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000006 /* RecruitMyApplicationFilter+.swift */; }; + CAFE20000000000000000007 /* RecruitMyApplicationListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000007 /* RecruitMyApplicationListViewModel.swift */; }; + CAFE20000000000000000008 /* RecruitMyApplicationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000008 /* RecruitMyApplicationListView.swift */; }; + CAFE20000000000000000009 /* RecruitMyApplicationListHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000009 /* RecruitMyApplicationListHostingController.swift */; }; + CAFE2000000000000000000A /* RecruitMyApplicationListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE1000000000000000000A /* RecruitMyApplicationListHeaderView.swift */; }; + CAFE2000000000000000000B /* RecruitMyApplicationListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE1000000000000000000B /* RecruitMyApplicationListRowView.swift */; }; + CAFE2000000000000000000C /* RecruitMyApplicationListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE1000000000000000000C /* RecruitMyApplicationListEmptyView.swift */; }; CD3000000000000000000001 /* RecruitPostCategoryDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3000000000000000000002 /* RecruitPostCategoryDropdownView.swift */; }; D0A71C4F2F1B8340009E2D71 /* KoinDropdownHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */; }; D19A00013000000000000002 /* NotificationRowModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A00013000000000000001 /* NotificationRowModel.swift */; }; @@ -1323,31 +1330,14 @@ 7C50A20A304324F500897A6F /* RecruitNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotification.swift; sourceTree = ""; }; 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotificationList.swift; sourceTree = ""; }; 7C50A20E3043278300897A6F /* FetchRecruitNotificationListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitNotificationListUseCase.swift; sourceTree = ""; }; - BEEF10000000000000000001 /* RecruitMyPostList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostList.swift; sourceTree = ""; }; - BEEF10000000000000000002 /* RecruitMyPostSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostSummary.swift; sourceTree = ""; }; - BEEF10000000000000000003 /* RecruitMyPostFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostFilter.swift; sourceTree = ""; }; - BEEF10000000000000000004 /* FetchRecruitMyPostListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitMyPostListUseCase.swift; sourceTree = ""; }; - BEEF10000000000000000005 /* RecruitMyPostFilter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecruitMyPostFilter+.swift"; sourceTree = ""; }; - BEEF10000000000000000006 /* RecruitMyPostListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListViewModel.swift; sourceTree = ""; }; - BEEF10000000000000000007 /* RecruitMyPostListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListView.swift; sourceTree = ""; }; - BEEF10000000000000000008 /* RecruitMyPostListHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListHostingController.swift; sourceTree = ""; }; - BEEF10000000000000000009 /* RecruitMyPostListHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListHeaderView.swift; sourceTree = ""; }; - BEEF1000000000000000000A /* RecruitMyPostListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListRowView.swift; sourceTree = ""; }; - BEEF1000000000000000000B /* RecruitMyPostListEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListEmptyView.swift; sourceTree = ""; }; - BEEF1000000000000000000C /* CloseRecruitMyPostUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseRecruitMyPostUseCase.swift; sourceTree = ""; }; - CAFE10000000000000000001 /* RecruitMyApplicationList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationList.swift; sourceTree = ""; }; - CAFE10000000000000000002 /* RecruitMyApplicationSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationSummary.swift; sourceTree = ""; }; - CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationFilter.swift; sourceTree = ""; }; - CAFE10000000000000000004 /* RecruitMyApplicationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationStatus.swift; sourceTree = ""; }; - CAFE10000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitMyApplicationListUseCase.swift; sourceTree = ""; }; - CAFE10000000000000000006 /* RecruitMyApplicationFilter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecruitMyApplicationFilter+.swift"; sourceTree = ""; }; - CAFE10000000000000000007 /* RecruitMyApplicationListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListViewModel.swift; sourceTree = ""; }; - CAFE10000000000000000008 /* RecruitMyApplicationListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListView.swift; sourceTree = ""; }; - CAFE10000000000000000009 /* RecruitMyApplicationListHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListHostingController.swift; sourceTree = ""; }; - CAFE1000000000000000000A /* RecruitMyApplicationListHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListHeaderView.swift; sourceTree = ""; }; - CAFE1000000000000000000B /* RecruitMyApplicationListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListRowView.swift; sourceTree = ""; }; - CAFE1000000000000000000C /* RecruitMyApplicationListEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListEmptyView.swift; sourceTree = ""; }; 7C50F8DB300FC03B00A87BAE /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; + 7C52085E306902C10009BDD3 /* RecruitApplyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplyViewModel.swift; sourceTree = ""; }; + 7C52085F306902C10009BDD3 /* RecruitApplyFirstStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplyFirstStepView.swift; sourceTree = ""; }; + 7C520860306902C10009BDD3 /* RecruitApplyRoleSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplyRoleSelectionView.swift; sourceTree = ""; }; + 7C52086030692C150009BDD3 /* ApplyRecruitUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplyRecruitUseCase.swift; sourceTree = ""; }; + 7C520861306902C10009BDD3 /* RecruitApplySecondStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplySecondStepView.swift; sourceTree = ""; }; + 7C520862306902C10009BDD3 /* RecruitApplyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplyViewController.swift; sourceTree = ""; }; + 7C520868306902C10009BDD3 /* RecruitApplyRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplyRequest.swift; sourceTree = ""; }; 7C55835F2F11778B00D3A980 /* ShopInfoHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopInfoHeaderView.swift; sourceTree = ""; }; 7C5583612F11779400D3A980 /* ShopInfoFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopInfoFooterView.swift; sourceTree = ""; }; 7C58FE852F5F9CDD00A2737B /* CallVanDataViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanDataViewController.swift; sourceTree = ""; }; @@ -1966,6 +1956,8 @@ 8CE127F2AE4B18899F6E5E0D /* RecruitPostScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostScheduleView.swift; sourceTree = ""; }; 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataRolesView.swift; sourceTree = ""; }; A001E2B42845091D00D6C310 /* koin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = koin.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A11B00000000000000000002 /* PostBasicInfoUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostBasicInfoUseCase.swift; sourceTree = ""; }; + A11B00000000000000000004 /* PostRecruitProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRecruitProfileUseCase.swift; sourceTree = ""; }; A1D000010000000000000002 /* FetchRecruitDataUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitDataUseCase.swift; sourceTree = ""; }; A1E000010000000000000002 /* RecruitPostRoleTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostRoleTableView.swift; sourceTree = ""; }; A51B00000000000000000001 /* RecruitProfilePostAddTableFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostAddTableFooterView.swift; sourceTree = ""; }; @@ -2013,10 +2005,32 @@ B68D2CEA2EA50C1E00F3B479 /* UIViewController+Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Toast.swift"; sourceTree = ""; }; B6A0D6292EEB01E600A19521 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; B6A0D62B2EEB01F100A19521 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + BEEF10000000000000000001 /* RecruitMyPostList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostList.swift; sourceTree = ""; }; + BEEF10000000000000000002 /* RecruitMyPostSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostSummary.swift; sourceTree = ""; }; + BEEF10000000000000000003 /* RecruitMyPostFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostFilter.swift; sourceTree = ""; }; + BEEF10000000000000000004 /* FetchRecruitMyPostListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitMyPostListUseCase.swift; sourceTree = ""; }; + BEEF10000000000000000005 /* RecruitMyPostFilter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecruitMyPostFilter+.swift"; sourceTree = ""; }; + BEEF10000000000000000006 /* RecruitMyPostListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListViewModel.swift; sourceTree = ""; }; + BEEF10000000000000000007 /* RecruitMyPostListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListView.swift; sourceTree = ""; }; + BEEF10000000000000000008 /* RecruitMyPostListHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListHostingController.swift; sourceTree = ""; }; + BEEF10000000000000000009 /* RecruitMyPostListHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListHeaderView.swift; sourceTree = ""; }; + BEEF1000000000000000000A /* RecruitMyPostListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListRowView.swift; sourceTree = ""; }; + BEEF1000000000000000000B /* RecruitMyPostListEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListEmptyView.swift; sourceTree = ""; }; + BEEF1000000000000000000C /* CloseRecruitMyPostUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseRecruitMyPostUseCase.swift; sourceTree = ""; }; C0DE00000000000000000002 /* RecruitProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfile.swift; sourceTree = ""; }; C0DE00000000000000000004 /* FetchMyProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMyProfileUseCase.swift; sourceTree = ""; }; - A11B00000000000000000002 /* PostBasicInfoUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostBasicInfoUseCase.swift; sourceTree = ""; }; - A11B00000000000000000004 /* PostRecruitProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRecruitProfileUseCase.swift; sourceTree = ""; }; + CAFE10000000000000000001 /* RecruitMyApplicationList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationList.swift; sourceTree = ""; }; + CAFE10000000000000000002 /* RecruitMyApplicationSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationSummary.swift; sourceTree = ""; }; + CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationFilter.swift; sourceTree = ""; }; + CAFE10000000000000000004 /* RecruitMyApplicationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationStatus.swift; sourceTree = ""; }; + CAFE10000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitMyApplicationListUseCase.swift; sourceTree = ""; }; + CAFE10000000000000000006 /* RecruitMyApplicationFilter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecruitMyApplicationFilter+.swift"; sourceTree = ""; }; + CAFE10000000000000000007 /* RecruitMyApplicationListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListViewModel.swift; sourceTree = ""; }; + CAFE10000000000000000008 /* RecruitMyApplicationListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListView.swift; sourceTree = ""; }; + CAFE10000000000000000009 /* RecruitMyApplicationListHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListHostingController.swift; sourceTree = ""; }; + CAFE1000000000000000000A /* RecruitMyApplicationListHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListHeaderView.swift; sourceTree = ""; }; + CAFE1000000000000000000B /* RecruitMyApplicationListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListRowView.swift; sourceTree = ""; }; + CAFE1000000000000000000C /* RecruitMyApplicationListEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListEmptyView.swift; sourceTree = ""; }; CD3000000000000000000002 /* RecruitPostCategoryDropdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostCategoryDropdownView.swift; sourceTree = ""; }; D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdownHost.swift; sourceTree = ""; }; D19A00013000000000000001 /* NotificationRowModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRowModel.swift; sourceTree = ""; }; @@ -2855,6 +2869,7 @@ 7CDCD0AE30556C89004204DA /* RecruitData */, 7CDCD0C43055D58B004204DA /* RecruitProfile */, 7CDCD0D93055EF87004204DA /* RecruitProfilePost */, + 7C52085D306902C10009BDD3 /* RecruitApply */, ); path = Recruit; sourceTree = ""; @@ -2897,6 +2912,7 @@ 7CDCD0DF305602A2004204DA /* RecruitProfileRequest.swift */, 7CDCD0E1305602F6004204DA /* RecruitProfileActivityRequest.swift */, 7CDCD0E330560758004204DA /* BasicInfo.swift */, + 7C520868306902C10009BDD3 /* RecruitApplyRequest.swift */, ); path = Recruit; sourceTree = ""; @@ -2954,10 +2970,39 @@ BEEF30000000000000000004 /* RecruitMyPostList */, CAFE30000000000000000004 /* RecruitMyApplicationList */, 7CACF86E3044097800AC2828 /* RecruitNotificationList */, + 7C52085F30692C000009BDD3 /* RecruitApply */, ); path = Recruit; sourceTree = ""; }; + 7C52085D306902C10009BDD3 /* RecruitApply */ = { + isa = PBXGroup; + children = ( + 7C52085E30690E9F0009BDD3 /* Subviews */, + 7C520862306902C10009BDD3 /* RecruitApplyViewController.swift */, + 7C52085E306902C10009BDD3 /* RecruitApplyViewModel.swift */, + ); + path = RecruitApply; + sourceTree = ""; + }; + 7C52085E30690E9F0009BDD3 /* Subviews */ = { + isa = PBXGroup; + children = ( + 7C52085F306902C10009BDD3 /* RecruitApplyFirstStepView.swift */, + 7C520861306902C10009BDD3 /* RecruitApplySecondStepView.swift */, + 7C520860306902C10009BDD3 /* RecruitApplyRoleSelectionView.swift */, + ); + path = Subviews; + sourceTree = ""; + }; + 7C52085F30692C000009BDD3 /* RecruitApply */ = { + isa = PBXGroup; + children = ( + 7C52086030692C150009BDD3 /* ApplyRecruitUseCase.swift */, + ); + path = RecruitApply; + sourceTree = ""; + }; 7C58FE842F5F9CC700A2737B /* CallVanData */ = { isa = PBXGroup; children = ( @@ -3456,83 +3501,6 @@ path = RecruitList; sourceTree = ""; }; - BEEF30000000000000000001 /* RecruitMyPostList */ = { - isa = PBXGroup; - children = ( - BEEF30000000000000000002 /* Mapper */, - BEEF30000000000000000003 /* Subviews */, - BEEF10000000000000000008 /* RecruitMyPostListHostingController.swift */, - BEEF10000000000000000007 /* RecruitMyPostListView.swift */, - BEEF10000000000000000006 /* RecruitMyPostListViewModel.swift */, - ); - path = RecruitMyPostList; - sourceTree = ""; - }; - BEEF30000000000000000002 /* Mapper */ = { - isa = PBXGroup; - children = ( - BEEF10000000000000000005 /* RecruitMyPostFilter+.swift */, - ); - path = Mapper; - sourceTree = ""; - }; - BEEF30000000000000000003 /* Subviews */ = { - isa = PBXGroup; - children = ( - BEEF10000000000000000009 /* RecruitMyPostListHeaderView.swift */, - BEEF1000000000000000000A /* RecruitMyPostListRowView.swift */, - BEEF1000000000000000000B /* RecruitMyPostListEmptyView.swift */, - ); - path = Subviews; - sourceTree = ""; - }; - BEEF30000000000000000004 /* RecruitMyPostList */ = { - isa = PBXGroup; - children = ( - BEEF1000000000000000000C /* CloseRecruitMyPostUseCase.swift */, - BEEF10000000000000000004 /* FetchRecruitMyPostListUseCase.swift */, - ); - path = RecruitMyPostList; - sourceTree = ""; - }; - CAFE30000000000000000001 /* RecruitMyApplicationList */ = { - isa = PBXGroup; - children = ( - CAFE30000000000000000002 /* Mapper */, - CAFE30000000000000000003 /* Subviews */, - CAFE10000000000000000009 /* RecruitMyApplicationListHostingController.swift */, - CAFE10000000000000000008 /* RecruitMyApplicationListView.swift */, - CAFE10000000000000000007 /* RecruitMyApplicationListViewModel.swift */, - ); - path = RecruitMyApplicationList; - sourceTree = ""; - }; - CAFE30000000000000000002 /* Mapper */ = { - isa = PBXGroup; - children = ( - CAFE10000000000000000006 /* RecruitMyApplicationFilter+.swift */, - ); - path = Mapper; - sourceTree = ""; - }; - CAFE30000000000000000003 /* Subviews */ = { - isa = PBXGroup; - children = ( - CAFE1000000000000000000A /* RecruitMyApplicationListHeaderView.swift */, - CAFE1000000000000000000B /* RecruitMyApplicationListRowView.swift */, - CAFE1000000000000000000C /* RecruitMyApplicationListEmptyView.swift */, - ); - path = Subviews; - sourceTree = ""; - }; - CAFE30000000000000000004 /* RecruitMyApplicationList */ = { - isa = PBXGroup; - children = ( - CAFE10000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift */, - ); - path = RecruitMyApplicationList; - sourceTree = ""; - }; 7CACF86E3044097800AC2828 /* RecruitNotificationList */ = { isa = PBXGroup; children = ( @@ -5356,6 +5324,45 @@ path = Configuration; sourceTree = ""; }; + BEEF30000000000000000001 /* RecruitMyPostList */ = { + isa = PBXGroup; + children = ( + BEEF30000000000000000002 /* Mapper */, + BEEF30000000000000000003 /* Subviews */, + BEEF10000000000000000008 /* RecruitMyPostListHostingController.swift */, + BEEF10000000000000000007 /* RecruitMyPostListView.swift */, + BEEF10000000000000000006 /* RecruitMyPostListViewModel.swift */, + ); + path = RecruitMyPostList; + sourceTree = ""; + }; + BEEF30000000000000000002 /* Mapper */ = { + isa = PBXGroup; + children = ( + BEEF10000000000000000005 /* RecruitMyPostFilter+.swift */, + ); + path = Mapper; + sourceTree = ""; + }; + BEEF30000000000000000003 /* Subviews */ = { + isa = PBXGroup; + children = ( + BEEF10000000000000000009 /* RecruitMyPostListHeaderView.swift */, + BEEF1000000000000000000A /* RecruitMyPostListRowView.swift */, + BEEF1000000000000000000B /* RecruitMyPostListEmptyView.swift */, + ); + path = Subviews; + sourceTree = ""; + }; + BEEF30000000000000000004 /* RecruitMyPostList */ = { + isa = PBXGroup; + children = ( + BEEF1000000000000000000C /* CloseRecruitMyPostUseCase.swift */, + BEEF10000000000000000004 /* FetchRecruitMyPostListUseCase.swift */, + ); + path = RecruitMyPostList; + sourceTree = ""; + }; C0DE00000000000000000005 /* RecruitProfile */ = { isa = PBXGroup; children = ( @@ -5375,6 +5382,44 @@ path = RecruitPost; sourceTree = ""; }; + CAFE30000000000000000001 /* RecruitMyApplicationList */ = { + isa = PBXGroup; + children = ( + CAFE30000000000000000002 /* Mapper */, + CAFE30000000000000000003 /* Subviews */, + CAFE10000000000000000009 /* RecruitMyApplicationListHostingController.swift */, + CAFE10000000000000000008 /* RecruitMyApplicationListView.swift */, + CAFE10000000000000000007 /* RecruitMyApplicationListViewModel.swift */, + ); + path = RecruitMyApplicationList; + sourceTree = ""; + }; + CAFE30000000000000000002 /* Mapper */ = { + isa = PBXGroup; + children = ( + CAFE10000000000000000006 /* RecruitMyApplicationFilter+.swift */, + ); + path = Mapper; + sourceTree = ""; + }; + CAFE30000000000000000003 /* Subviews */ = { + isa = PBXGroup; + children = ( + CAFE1000000000000000000A /* RecruitMyApplicationListHeaderView.swift */, + CAFE1000000000000000000B /* RecruitMyApplicationListRowView.swift */, + CAFE1000000000000000000C /* RecruitMyApplicationListEmptyView.swift */, + ); + path = Subviews; + sourceTree = ""; + }; + CAFE30000000000000000004 /* RecruitMyApplicationList */ = { + isa = PBXGroup; + children = ( + CAFE10000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift */, + ); + path = RecruitMyApplicationList; + sourceTree = ""; + }; D19A00013000000000000007 /* Shared */ = { isa = PBXGroup; children = ( @@ -6495,6 +6540,12 @@ D2FCBEA62D0877CC0097A388 /* FetchFramesUseCase.swift in Sources */, D215726A2CEDDE500061E725 /* FetchSemesterUseCase.swift in Sources */, 7CDCD0DB3055EFA7004204DA /* RecruitProfilePostViewController.swift in Sources */, + 7C520863306902C10009BDD3 /* RecruitApplyViewModel.swift in Sources */, + 7C520864306902C10009BDD3 /* RecruitApplyFirstStepView.swift in Sources */, + 7C520865306902C10009BDD3 /* RecruitApplyRoleSelectionView.swift in Sources */, + 7C520866306902C10009BDD3 /* RecruitApplySecondStepView.swift in Sources */, + 7C520867306902C10009BDD3 /* RecruitApplyViewController.swift in Sources */, + 7C520869306902C10009BDD3 /* RecruitApplyRequest.swift in Sources */, D27DD0D32BA608310081FD36 /* SceneDelegate.swift in Sources */, 7C0C189F2F68AB33001781B3 /* CallVanChatRequest.swift in Sources */, 7CDCD0CB3055D63F004204DA /* RecruitProfileViewModel.swift in Sources */, @@ -7179,6 +7230,7 @@ 7CC42B602F59D06C00940CE1 /* CallVanPostParticipantsView.swift in Sources */, 7CED91E32EF2D1F900457128 /* AddClassCollectionViewCell.swift in Sources */, 7C61D3492F220EAC005FAC3A /* EditLostItemFoundDateView.swift in Sources */, + 7C52086130692C150009BDD3 /* ApplyRecruitUseCase.swift in Sources */, 7CC42B202F56F3C400940CE1 /* CallVanState.swift in Sources */, 7C8ADD382F20C0B200F85BDE /* LostItemListDto.swift in Sources */, 7C640409300F3F7700A9589F /* FetchDepartmentRequestDto.swift in Sources */, From 776b6cb50045cd748b5c89bca12060abfe2c3f30 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 27 Sep 2026 20:58:03 +0900 Subject: [PATCH 037/123] =?UTF-8?q?chore:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=83=81=EC=84=B8=ED=99=94=EB=A9=B4=20UI=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 하단 버튼을 scroll 범위 밖으로 이동했습니다. --- .../Subviews/RecruitDataContentView.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataContentView.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataContentView.swift index a71d53f32..e869dd722 100644 --- a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataContentView.swift +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataContentView.swift @@ -43,7 +43,7 @@ struct RecruitDataContentView: View { var body: some View { if let data { - GeometryReader { proxy in + VStack(spacing: 16) { ScrollView { VStack(alignment: .leading, spacing: 0) { RecruitDataHeaderView(data: data) @@ -76,18 +76,17 @@ struct RecruitDataContentView: View { .padding(.horizontal, 36) Spacer(minLength: 32) - - RecruitDataButton( - text: buttonText, - isEnabled: isButtonEnabled, - action: onButtonTapped - ) - .padding(.horizontal, 32) } - .frame(minHeight: proxy.size.height) } .scrollIndicators(.hidden) .scrollBounceBehavior(.basedOnSize, axes: .vertical) + + RecruitDataButton( + text: buttonText, + isEnabled: isButtonEnabled, + action: onButtonTapped + ) + .padding(.horizontal, 32) } } else { Color.appColor(.newBackground) From bb56c60edee72419cd14575d0a38455c05e5367e Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 27 Sep 2026 21:44:10 +0900 Subject: [PATCH 038/123] =?UTF-8?q?fix:=20=EB=88=84=EB=9D=BD=EB=90=9C=20?= =?UTF-8?q?=EB=AA=A8=EC=A7=91=EC=99=84=EB=A3=8C=20=EC=83=81=ED=83=9C=20UI?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Data/Repository/MockRecruitRepository.swift | 8 +++++++- Koin/Domain/Model/Recruit/RecruitData.swift | 2 ++ Koin/Domain/Model/Recruit/RecruitListSummary.swift | 1 + .../Model/Recruit/RecruitMyApplicationSummary.swift | 1 + .../RecruitData/Subviews/RecruitDataHeaderView.swift | 4 ++-- .../Recruit/RecruitList/Subviews/RecruitListRowView.swift | 4 ++-- .../Subviews/RecruitMyApplicationListRowView.swift | 4 ++-- 7 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 868f7cf21..31372abe2 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -92,6 +92,7 @@ final class MockRecruitRepository: RecruitRepository { endDate: dateFormatter.date(from: "2026.08.26") ?? Date(), deadline: dateFormatter.date(from: "2026.08.26") ?? Date(), dDay: "D-5", + state: .recruiting, currentParticipants: 0, maximumParticipants: 3, type: .roleBased, @@ -111,6 +112,7 @@ final class MockRecruitRepository: RecruitRepository { endDate: dateFormatter.date(from: "2026.08.26") ?? Date(), deadline: dateFormatter.date(from: "2026.08.26") ?? Date(), dDay: "D-1", + state: .recruiting, currentParticipants: 2, maximumParticipants: 3, type: .general, @@ -126,6 +128,7 @@ final class MockRecruitRepository: RecruitRepository { endDate: dateFormatter.date(from: "2026.08.26") ?? Date(), deadline: dateFormatter.date(from: "2026.08.26") ?? Date(), dDay: "D-day", + state: .closed, currentParticipants: 5, maximumParticipants: 5, type: .general, @@ -142,6 +145,7 @@ final class MockRecruitRepository: RecruitRepository { endDate: dateFormatter.date(from: "2026.08.26") ?? Date(), deadline: dateFormatter.date(from: "2026.08.26") ?? Date(), dDay: "D-1", + state: .recruiting, currentParticipants: 2, maximumParticipants: 3, type: .general, @@ -173,7 +177,7 @@ final class MockRecruitRepository: RecruitRepository { maximumParticipants: summary.maximumParticipants, type: summary.type, roles: summary.roles, - state: summary.id == 3 ? .closed : .recruiting, + state: summary.state, canClose: summary.id != 3, chatRoomId: summary.id.isMultiple(of: 2) ? summary.id : nil ) @@ -220,6 +224,7 @@ final class MockRecruitRepository: RecruitRepository { endDate: summary.endDate, deadline: summary.deadline, dDay: summary.dDay, + state: summary.state, currentParticipants: summary.currentParticipants, maximumParticipants: summary.maximumParticipants, type: summary.type, @@ -263,6 +268,7 @@ final class MockRecruitRepository: RecruitRepository { id: item.id, category: item.category, dDay: item.dDay, + state: item.state, title: item.title, meetingType: item.meetingType, startDate: item.startDate, diff --git a/Koin/Domain/Model/Recruit/RecruitData.swift b/Koin/Domain/Model/Recruit/RecruitData.swift index 0caa72558..c9c3fb080 100644 --- a/Koin/Domain/Model/Recruit/RecruitData.swift +++ b/Koin/Domain/Model/Recruit/RecruitData.swift @@ -12,6 +12,7 @@ struct RecruitData: Identifiable, Equatable { let category: RecruitCategory let dDay: String + let state: RecruitState let title: String let meetingType: RecruitMeetingType @@ -44,6 +45,7 @@ extension RecruitData { id: id, category: category, dDay: dDay, + state: state, title: title, meetingType: meetingType, startDate: startDate, diff --git a/Koin/Domain/Model/Recruit/RecruitListSummary.swift b/Koin/Domain/Model/Recruit/RecruitListSummary.swift index c87e10ab5..2571593fd 100644 --- a/Koin/Domain/Model/Recruit/RecruitListSummary.swift +++ b/Koin/Domain/Model/Recruit/RecruitListSummary.swift @@ -17,6 +17,7 @@ struct RecruitListSummary: Identifiable, Equatable { let endDate: Date let deadline: Date let dDay: String + let state: RecruitState let currentParticipants: Int let maximumParticipants: Int diff --git a/Koin/Domain/Model/Recruit/RecruitMyApplicationSummary.swift b/Koin/Domain/Model/Recruit/RecruitMyApplicationSummary.swift index 45870dc52..397ed4600 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyApplicationSummary.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyApplicationSummary.swift @@ -17,6 +17,7 @@ struct RecruitMyApplicationSummary: Identifiable, Equatable { let endDate: Date let deadline: Date let dDay: String + let state: RecruitState let currentParticipants: Int let maximumParticipants: Int diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift index de2a3f6b1..10128df9f 100644 --- a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift @@ -17,9 +17,9 @@ struct RecruitDataHeaderView: View { .padding(.horizontal, 8) .frame(height: 18) .background(Color.appColor(data.category.backgroundColor), in: Capsule()) - Text(data.dDay) + Text(data.state == .closed ? "모집완료" : data.dDay) .font(.appFont(.pretendardMedium, size: 10)) - .foregroundStyle(Color.appColor(.danger700)) + .foregroundStyle(Color.appColor(data.state == .closed ? .new600 : .danger700)) } .frame(minHeight: 18) diff --git a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift index 298e4f8df..a38700618 100644 --- a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift @@ -48,9 +48,9 @@ extension RecruitListRowView { .background(Color.appColor(model.category.backgroundColor)) .clipShape(.capsule) - Text(model.dDay) + Text(model.state == .closed ? "모집완료" : model.dDay) .font(.appFont(.pretendardMedium, size: 10)) - .foregroundStyle(Color.appColor(.danger700)) + .foregroundStyle(Color.appColor(model.state == .closed ? .new600 : .danger700)) } } diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift index 84213b540..a1871fd85 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift @@ -47,9 +47,9 @@ extension RecruitMyApplicationListRowView { .background(Color.appColor(model.category.backgroundColor)) .clipShape(.capsule) - Text(model.dDay) + Text(model.state == .closed ? "모집완료" : model.dDay) .font(.appFont(.pretendardMedium, size: 10)) - .foregroundStyle(Color.appColor(.danger700)) + .foregroundStyle(Color.appColor(model.state == .closed ? .new600 : .danger700)) Spacer() From b2b72d5db0b479928c8b9df838957c63b2bed086 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 27 Sep 2026 22:43:05 +0900 Subject: [PATCH 039/123] =?UTF-8?q?fix:=20=EB=82=B4=EA=B0=80=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=ED=95=9C=20=EB=AA=A8=EC=A7=91=EA=B8=80=20UI=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitMyApplicationListRowView.swift | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift index a1871fd85..2867354cf 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift @@ -85,35 +85,34 @@ extension RecruitMyApplicationListRowView { extension RecruitMyApplicationListRowView { private var dataView: some View { - HStack(spacing: 24) { - LeftAlignedLayout(interitemSpacing: 8, interlineSpacing: 4) { - Group { - HStack(alignment: .center, spacing: 2) { - Image.appImage(asset: .recruitLocation) - Text(model.meetingType.rawValue) - .font(.appFont(.pretendardRegular, size: 10)) - .foregroundStyle(Color.appColor(.neutral500)) - } + LeftAlignedLayout(interitemSpacing: 8, interlineSpacing: 4) { + Group { + HStack(alignment: .center, spacing: 2) { + Image.appImage(asset: .recruitLocation) + Text(model.meetingType.rawValue) + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + } - HStack(alignment: .center, spacing: 2) { - Image.appImage(asset: .recruitDate) - Text("\(model.startDate.formatDateToYYYYMMDD(separator: ".")) ~ \(model.endDate.formatDateToYYYYMMDD(separator: "."))") - .font(.appFont(.pretendardRegular, size: 10)) - .foregroundStyle(Color.appColor(.neutral500)) - } + HStack(alignment: .center, spacing: 2) { + Image.appImage(asset: .recruitDate) + Text("\(model.startDate.formatDateToYYYYMMDD(separator: ".")) ~ \(model.endDate.formatDateToYYYYMMDD(separator: "."))") + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + } - HStack(alignment: .center, spacing: 2) { - Image.appImage(asset: .recruitMember) - Text("\(model.currentParticipants)/\(model.maximumParticipants)명") - .font(.appFont(.pretendardRegular, size: 10)) - .foregroundStyle(Color.appColor(.neutral500)) - } + HStack(alignment: .center, spacing: 2) { + Image.appImage(asset: .recruitMember) + Text("\(model.currentParticipants)/\(model.maximumParticipants)명") + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) } - .frame(height: 16) } - - Spacer(minLength: 0) - + .frame(height: 16) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, model.chatRoomId == nil ? 0 : 60) + .overlay(alignment: .trailing) { if let chatRoomId = model.chatRoomId { Button { onShowChat(chatRoomId) @@ -128,6 +127,5 @@ extension RecruitMyApplicationListRowView { .buttonStyle(.plain) } } - .frame(maxWidth: .infinity) } } From cfe3397f25af18efbf8f07cce378e9b0eb7fc6db Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 27 Sep 2026 22:47:08 +0900 Subject: [PATCH 040/123] =?UTF-8?q?fix:=20=EB=82=B4=EA=B0=80=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=ED=95=9C=20=EB=AA=A8=EC=A7=91=EA=B8=80=20UI=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift index 382b21d23..917883b23 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift @@ -56,7 +56,9 @@ extension RecruitMyPostListRowView { .foregroundStyle(Color.appColor(model.state == .closed ? .new600 : .danger700)) Spacer() - + } + .padding(.trailing, model.chatRoomId == nil ? 0 : 44) + .overlay(alignment: .trailing) { if let chatRoomId = model.chatRoomId { Button { onShowChat(chatRoomId) From 067ebe378fad8684dd048588958a6cc2e135100d Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 28 Sep 2026 13:58:45 +0900 Subject: [PATCH 041/123] =?UTF-8?q?design:=20=EC=A7=80=EC=9B=90=EC=9E=90?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/MockRecruitRepository.swift | 70 ++++++++++- .../Model/Recruit/RecruitApplication.swift | 24 ++++ .../Model/Recruit/RecruitMyPostSummary.swift | 4 +- .../Domain/Repository/RecruitRepository.swift | 1 + .../FetchRecruitMyPostUseCase.swift | 25 ++++ .../RecruitDataHostingController.swift | 11 +- ...itMyApplicationListHostingController.swift | 2 +- .../RecruitMyPostHostingController.swift | 26 ++++ .../RecruitMyPost/RecruitMyPostView.swift | 103 ++++++++++++++++ .../RecruitMyPostViewModel.swift | 72 +++++++++++ .../RecruitMyPostApplicantRowView.swift | 99 +++++++++++++++ .../Subviews/RecruitMyPostEmptyView.swift | 29 +++++ .../Subviews/RecruitMyPostSummaryView.swift | 116 ++++++++++++++++++ .../RecruitMyPostListHostingController.swift | 13 +- .../Subviews/RecruitMyPostListRowView.swift | 2 +- koin.xcodeproj/project.pbxproj | 50 ++++++++ 16 files changed, 640 insertions(+), 7 deletions(-) create mode 100644 Koin/Domain/Model/Recruit/RecruitApplication.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostUseCase.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostApplicantRowView.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostEmptyView.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 31372abe2..4865dd3fb 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -179,7 +179,11 @@ final class MockRecruitRepository: RecruitRepository { roles: summary.roles, state: summary.state, canClose: summary.id != 3, - chatRoomId: summary.id.isMultiple(of: 2) ? summary.id : nil + chatRoomId: summary.id.isMultiple(of: 2) ? summary.id : nil, + applications: mockApplications( + recruitId: summary.id, + includesRole: summary.type == .roleBased + ) ) } @@ -205,6 +209,70 @@ final class MockRecruitRepository: RecruitRepository { ) } + func fetchMyPost(_ id: Int) async throws -> RecruitMyPostSummary { + let response = try await fetchMyPostList(RecruitMyPostFilter()) + guard let recruit = response.recruits.first(where: { $0.id == id }) else { + throw ErrorResponse.unexpectedInternalError + } + return RecruitMyPostSummary( + id: recruit.id, + category: recruit.category, + title: recruit.title, + meetingType: recruit.meetingType, + startDate: recruit.startDate, + endDate: recruit.endDate, + deadline: recruit.deadline, + dDay: recruit.dDay, + currentParticipants: recruit.currentParticipants, + maximumParticipants: recruit.maximumParticipants, + type: recruit.type, + roles: recruit.roles, + state: recruit.state, + canClose: recruit.canClose, + chatRoomId: recruit.chatRoomId ?? recruit.id, + applications: recruit.applications + ) + } + + private func mockApplications( + recruitId: Int, + includesRole: Bool + ) -> [RecruitApplication] { + guard recruitId == 1 || recruitId == 2 else { + return [] + } + + return [ + RecruitApplication( + applicationId: recruitId * 100 + 1, + nickname: "김철수", + department: "컴퓨터공학부", + studentYear: 23, + role: includesRole ? "백엔드" : "", + status: .denied, + canChat: false + ), + RecruitApplication( + applicationId: recruitId * 100 + 2, + nickname: "김철수", + department: "컴퓨터공학부", + studentYear: 23, + role: includesRole ? "디자인" : "", + status: .accepted, + canChat: true + ), + RecruitApplication( + applicationId: recruitId * 100 + 3, + nickname: "김철수", + department: "컴퓨터공학부", + studentYear: 23, + role: includesRole ? "프론트엔드" : "", + status: .pending, + canChat: false + ) + ] + } + func closeMyPost(id: Int) async throws -> Bool { true } diff --git a/Koin/Domain/Model/Recruit/RecruitApplication.swift b/Koin/Domain/Model/Recruit/RecruitApplication.swift new file mode 100644 index 000000000..a3f1f91b0 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitApplication.swift @@ -0,0 +1,24 @@ +// +// RecruitApplication.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +struct RecruitApplication: Identifiable, Equatable { + let applicationId: Int + let nickname: String + let department: String + let studentYear: Int + let role: String + let status: RecruitMyApplicationStatus + let canChat: Bool +} + +extension RecruitApplication { + var id: Int { + return applicationId + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift b/Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift index f4db6eee4..68bd9f52f 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift @@ -25,6 +25,8 @@ struct RecruitMyPostSummary: Identifiable, Equatable { let roles: [RecruitRole] let state: RecruitState - let canClose: Bool + let canClose: Bool? let chatRoomId: Int? + + let applications: [RecruitApplication] } diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index 3755402b8..56dbcecf6 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -10,6 +10,7 @@ import Foundation protocol RecruitRepository { func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList + func fetchMyPost(_ id: Int) async throws -> RecruitMyPostSummary func fetchMyApplicationList(_ filter: RecruitMyApplicationFilter) async throws -> RecruitMyApplicationList func closeMyPost(id: Int) async throws -> Bool func fetchNotificationList() async throws -> RecruitNotificationList diff --git a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostUseCase.swift new file mode 100644 index 000000000..1f8dade8d --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostUseCase.swift @@ -0,0 +1,25 @@ +// +// FetchRecruitMyPostUseCase.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +protocol FetchRecruitMyPostUseCase { + func execute(id: Int) async throws -> RecruitMyPostSummary +} + +final class DefaultFetchRecruitMyPostUseCase: FetchRecruitMyPostUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(id: Int) async throws -> RecruitMyPostSummary { + try await repository.fetchMyPost(id) + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index c171cc6a7..4e31f1464 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -128,7 +128,16 @@ extension RecruitDataHostingController { guard let id = rootView.id else { return } - // TODO: show applicant + let repository = MockRecruitRepository() + let fetchRecruitMyPostUseCase = DefaultFetchRecruitMyPostUseCase(repository: repository) + let viewModel = RecruitMyPostViewModel( + fetchRecruitMyPostUseCase: fetchRecruitMyPostUseCase, + recruitId: id + ) + let controller = RecruitMyPostHostingController( + rootView: RecruitMyPostView(viewModel: viewModel) + ) + navigationController?.pushViewController(controller, animated: true) } private func navigateToApply() { guard let recruit = rootView.data else { diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift index 828e8bf6a..fcd21160b 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift @@ -31,7 +31,7 @@ final class RecruitMyApplicationListHostingController: UIHostingController, HostingControllerProtocol { + + // MARK: - Life Cycle + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar(style: .newBackground) + title = "지원자 관리" + } + + // MARK: - Public + func execute(action: RootView.Action) { + switch action { + case .showToast(let message): + showToastMessage(message: message) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift new file mode 100644 index 000000000..540db511f --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift @@ -0,0 +1,103 @@ +// +// RecruitMyPostView.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import SwiftUI + +struct RecruitMyPostView: ActionBindableView { + + enum Action { + case showToast(message: String) + } + + // MARK: - Properties + var sendAction: (Action) -> Void = { _ in } + @State private var viewModel: RecruitMyPostViewModel + + // MARK: - Initializer + init(viewModel: RecruitMyPostViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + var body: some View { + Group { + if let data = viewModel.data { + contentView(data: data) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .loadingOverlay(viewModel.isLoading) + .background(Color.appColor(.newBackground).ignoresSafeArea()) + .onFirstAppear { + viewModel.execute(.onFirstAppear) + } + .onChange(of: viewModel.errorMessage) { + guard let message = viewModel.errorMessage else { + return + } + sendAction(.showToast(message: message)) + viewModel.execute(.didShowToast) + } + } +} + +extension RecruitMyPostView { + private func contentView(data: RecruitMyPostSummary) -> some View { + GeometryReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + RecruitMyPostSummaryView(model: data) { + // TODO + } + + applicantListHeader + .padding(.top, 24) + .padding(.leading, 4) + + if data.applications.isEmpty { + Spacer(minLength: 0) + + RecruitMyPostEmptyView() + .frame(maxWidth: .infinity) + + Spacer(minLength: 0) + } else { + LazyVStack(spacing: 12) { + ForEach(data.applications) { applicant in + RecruitMyPostApplicantRowView( + model: applicant, + onApplicationTapped: {}, // TODO + onDirectChatTapped: {} // TODO + ) + } + } + .padding(.top, 24) + } + } + .padding(.horizontal, 24) + .frame(minHeight: max(proxy.size.height - 44, 0), alignment: .top) + .padding(.top, 20) + .padding(.bottom, 24) + } + .scrollIndicators(.hidden) + } + } + + private var applicantListHeader: some View { + VStack(alignment: .leading, spacing: 0) { + Text("지원자 목록") + .font(.appFont(.pretendardSemiBold, size: 16)) + .foregroundStyle(Color.appColor(.neutral800)) + .frame(height: 26) + + Text("총 \(viewModel.data?.applications.count ?? 0)명") + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral500)) + .frame(height: 19) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift new file mode 100644 index 000000000..9726bf2d7 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift @@ -0,0 +1,72 @@ +// +// RecruitMyPostViewModel.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import Foundation +import Observation + +@MainActor +@Observable +final class RecruitMyPostViewModel: SwiftUIViewModelProtocol { + + enum Input { + case onFirstAppear + case didShowToast + } + + // MARK: - State + private(set) var data: RecruitMyPostSummary? + private(set) var isLoading = false + private(set) var errorMessage: String? + + // MARK: - Properties + private let recruitId: Int + private let fetchRecruitMyPostUseCase: FetchRecruitMyPostUseCase + + // MARK: - Initializer + init( + fetchRecruitMyPostUseCase: FetchRecruitMyPostUseCase, + recruitId: Int + ) { + self.fetchRecruitMyPostUseCase = fetchRecruitMyPostUseCase + self.recruitId = recruitId + } + + // MARK: - Public + func execute(_ input: Input) { + switch input { + case .onFirstAppear: + fetchRecruitMyPost() + case .didShowToast: + errorMessage = nil + } + } +} + +extension RecruitMyPostViewModel { + private func fetchRecruitMyPost() { + guard !isLoading else { + return + } + + Task { + isLoading = true + defer { + isLoading = false + } + + do { + let data = try await fetchRecruitMyPostUseCase.execute(id: recruitId) + try Task.checkCancellation() + self.data = data + } catch is CancellationError { + return + } catch { + errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + } + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostApplicantRowView.swift b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostApplicantRowView.swift new file mode 100644 index 000000000..b3d3a86d4 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostApplicantRowView.swift @@ -0,0 +1,99 @@ +// +// RecruitMyPostApplicantRowView.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import SwiftUI + +struct RecruitMyPostApplicantRowView: View { + + let onApplicationTapped: ()->Void + let onDirectChatTapped: ()->Void + let model: RecruitApplication + + init( + model: RecruitApplication, + onApplicationTapped: @escaping () -> Void, + onDirectChatTapped: @escaping () -> Void + ) { + self.onApplicationTapped = onApplicationTapped + self.onDirectChatTapped = onDirectChatTapped + self.model = model + } + + var body: some View { + Button(action: onApplicationTapped) { + HStack(spacing: 12) { + profileImage + applicantInformation + Spacer(minLength: 0) + Image.appImage(asset: .newChevronRight) + .frame(width: 24, height: 24) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .frame(maxWidth: .infinity) + .background(Color.appColor(.neutral0)) + .clipShape(.rect(cornerRadius: 16)) + .contentShape(.rect(cornerRadius: 16)) + } + .buttonStyle(.plain) + } +} + +extension RecruitMyPostApplicantRowView { + private var profileImage: some View { + Image.appImage(asset: .recruitNotificationMember) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30, height: 30) + .frame(width: 45, height: 45) + .border(Color.appColor(.neutral400), width: 1, radius: 45/2) + } + + private var applicantInformation: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(model.nickname) + .font(.appFont(.pretendardRegular, size: 14)) + .foregroundStyle(Color.appColor(.neutral800)) + + Text("· \(model.status.rawValue)") + .font(.appFont(.pretendardMedium, size: 11)) + .foregroundStyle(Color.appColor(model.status.textColor)) + + if model.status == .accepted, model.canChat { + Button(action: onDirectChatTapped) { + Image.appImage(asset: .recruitNotificationChat) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + } + .frame(height: 24) + + applicantMetadata + .font(.appFont(.pretendardRegular, size: 11)) + .lineLimit(1) + } + } + + @ViewBuilder + private var applicantMetadata: some View { + if !model.role.isEmpty { + HStack(spacing: 0) { + Text(model.role) + .foregroundStyle(Color.appColor(.new500)) + Text(" | \(model.department) · \(model.studentYear)학번") + .foregroundStyle(Color.appColor(.neutral500)) + } + } else { + Text("\(model.department) · \(model.studentYear)학번") + .foregroundStyle(Color.appColor(.neutral500)) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostEmptyView.swift b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostEmptyView.swift new file mode 100644 index 000000000..525068530 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostEmptyView.swift @@ -0,0 +1,29 @@ +// +// RecruitMyPostEmptyView.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import SwiftUI + +struct RecruitMyPostEmptyView: View { + + var body: some View { + VStack(spacing: 0) { + Image.appImage(asset: .sleepBcsdSymbol) + .frame(width: 98, height: 75) + + Text("아직 지원자가 없어요.") + .font(.appFont(.pretendardMedium, size: 16)) + .foregroundStyle(Color.appColor(.neutral800)) + .frame(height: 26) + + Text("새로운 지원자가 등록되면 이곳에서 확인할 수 있어요.") + .font(.appFont(.pretendardRegular, size: 14)) + .foregroundStyle(Color.appColor(.neutral600)) + .frame(height: 22) + .padding(.top, 4) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift new file mode 100644 index 000000000..f1574e69f --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift @@ -0,0 +1,116 @@ +// +// RecruitMyPostSummaryView.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import SwiftUI + +struct RecruitMyPostSummaryView: View { + + let model: RecruitMyPostSummary + let onGroupChatTapped: ()->Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + headerView + .padding(.bottom, 8) + titleView + .padding(.bottom, 4) + rolesView + .padding(.bottom, 8) + .isHidden(model.type == .general) + dataView + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.appColor(.neutral0)) + .clipShape(.rect(cornerRadius: 16)) + } +} + +extension RecruitMyPostSummaryView { + private var headerView: some View { + HStack(alignment: .center, spacing: 4) { + Text(model.category.rawValue) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(model.category.foregroundColor)) + .padding(.horizontal, 8) + .frame(height: 18) + .background(Color.appColor(model.category.backgroundColor)) + .clipShape(.capsule) + + Text(model.state == .closed ? "모집완료" : model.dDay) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(model.state == .closed ? .new600 : .danger700)) + } + } + + private var titleView: some View { + Text(model.title) + .font(.appFont(.pretendardSemiBold, size: 16)) + .foregroundStyle(Color.appColor(.neutral700)) + .frame(height: 26) + .lineLimit(1) + } + + private var rolesView: some View { + LeftAlignedLayout(interitemSpacing: 4, interlineSpacing: 4) { + ForEach(model.roles) { role in + Text("\(role.name) \(role.maximumParticipants)명") + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + .padding(.horizontal, 8) + .frame(height: 18) + .background(Color.appColor(.neutral200)) + .clipShape(.capsule) + } + } + } +} + +extension RecruitMyPostSummaryView { + private var dataView: some View { + LeftAlignedLayout(interitemSpacing: 8, interlineSpacing: 4) { + Group { + metadataView( + image: .recruitLocation, + text: model.meetingType.rawValue + ) + metadataView( + image: .recruitDate, + text: "\(model.startDate.formatDateToYYYYMMDD(separator: ".")) ~ \(model.endDate.formatDateToYYYYMMDD(separator: "."))" + ) + metadataView( + image: .recruitMember, + text: "\(model.currentParticipants)/\(model.maximumParticipants)명" + ) + } + .frame(height: 16) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, model.chatRoomId == nil ? 0 : 44) + .overlay(alignment: .trailing) { + Button(action: onGroupChatTapped) { + Image.appImage(asset: .recruitNotificationChat) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + + private func metadataView(image: ImageAsset, text: String) -> some View { + HStack(alignment: .center, spacing: 2) { + Image.appImage(asset: image) + Text(text) + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift index 06ae1bd7f..f570357e1 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift @@ -31,7 +31,7 @@ final class RecruitMyPostListHostingController: UIHostingController Date: Mon, 28 Sep 2026 17:49:45 +0900 Subject: [PATCH 042/123] =?UTF-8?q?design:=20=EC=A7=80=EC=9B=90=EC=9E=90?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/MockRecruitRepository.swift | 72 +++++++++- .../Model/Recruit/RecruitApplication.swift | 26 ++-- .../Recruit/RecruitApplicationDecision.swift | 13 ++ .../Recruit/RecruitApplicationSummary.swift | 24 ++++ .../Model/Recruit/RecruitMyPostSummary.swift | 2 +- .../Recruit/RecruitProfileActivity.swift | 16 +++ .../Domain/Repository/RecruitRepository.swift | 9 ++ ...ecideRecruitMyPostApplicationUseCase.swift | 37 +++++ .../FetchRecruitMyPostApplicantUseCase.swift | 34 +++++ .../RecruitMyPostHostingController.swift | 43 +++++- .../RecruitMyPost/RecruitMyPostView.swift | 13 +- .../RecruitMyPostApplicantRowView.swift | 4 +- ...ruitMyPostApplicantHostingController.swift | 105 ++++++++++++++ .../RecruitMyPostApplicantView.swift | 133 ++++++++++++++++++ .../RecruitMyPostApplicantViewModel.swift | 113 +++++++++++++++ .../RecruitMyPostApplicantActivityView.swift | 46 ++++++ ...cruitMyPostApplicantBottomActionView.swift | 94 +++++++++++++ .../RecruitMyPostApplicantProfileView.swift | 68 +++++++++ .../RecruitMyPostApplicantRoleChipView.swift | 23 +++ .../RecruitMyPostApplicantTextView.swift | 25 ++++ koin.xcodeproj/project.pbxproj | 72 +++++++++- 21 files changed, 947 insertions(+), 25 deletions(-) create mode 100644 Koin/Domain/Model/Recruit/RecruitApplicationDecision.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitApplicationSummary.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitMyPostList/DecideRecruitMyPostApplicationUseCase.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostApplicantUseCase.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantActivityView.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantBottomActionView.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantProfileView.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantRoleChipView.swift create mode 100644 Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantTextView.swift diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 4865dd3fb..c12c966e1 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -234,16 +234,80 @@ final class MockRecruitRepository: RecruitRepository { ) } + func fetchMyPostApplication( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitApplication { + try await Task.sleep(nanoseconds: 300_000_000) + + let recruitment = try await fetchMyPost(recruitmentId) + guard let application = recruitment.applications.first(where: { + $0.applicationId == applicationId + }) else { + throw ErrorResponse.unexpectedInternalError + } + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy.MM.dd" + guard let startedAt = dateFormatter.date(from: "2026.03.23"), + let endedAt = dateFormatter.date(from: "2026.04.06") else { + throw ErrorResponse.dateFormatterFailedConvert + } + + let role = application.role.isEmpty ? nil : application.role + return RecruitApplication( + applicationId: application.applicationId, + status: application.status, + profile: RecruitProfile( + nickname: application.nickname, + department: application.department, + studentNumber: String(format: "20%02d000000", application.studentYear), + preferredRole: role ?? "", + skills: ["정보처리기사"], + activities: [ + RecruitProfileActivity( + id: 1, + title: "AI 공모전", + startedAt: startedAt, + endedAt: endedAt, + isOngoing: false, + description: "AI 공모전에서 기획을 담당했고 @@@를 주제로 @@@를 만들었습니다" + ) + ], + selfIntroduction: "안녕하세요." + ), + motivation: "안녕하세요.", + availableTime: "월 수 금 20시 이후", + role: role, + canDecide: application.status == .pending, + canDirectChat: application.status == .accepted && application.canChat + ) + } + + func decideMyPostApplication( + recruitmentId: Int, + applicationId: Int, + decision: RecruitApplicationDecision + ) async throws -> Void { + let recruitment = try await fetchMyPost(recruitmentId) + guard recruitment.applications.contains(where: { + $0.applicationId == applicationId && $0.status == .pending + }) else { + throw ErrorResponse.unexpectedInternalError + } + try await Task.sleep(nanoseconds: 300_000_000) + } + private func mockApplications( recruitId: Int, includesRole: Bool - ) -> [RecruitApplication] { + ) -> [RecruitApplicationSummary] { guard recruitId == 1 || recruitId == 2 else { return [] } return [ - RecruitApplication( + RecruitApplicationSummary( applicationId: recruitId * 100 + 1, nickname: "김철수", department: "컴퓨터공학부", @@ -252,7 +316,7 @@ final class MockRecruitRepository: RecruitRepository { status: .denied, canChat: false ), - RecruitApplication( + RecruitApplicationSummary( applicationId: recruitId * 100 + 2, nickname: "김철수", department: "컴퓨터공학부", @@ -261,7 +325,7 @@ final class MockRecruitRepository: RecruitRepository { status: .accepted, canChat: true ), - RecruitApplication( + RecruitApplicationSummary( applicationId: recruitId * 100 + 3, nickname: "김철수", department: "컴퓨터공학부", diff --git a/Koin/Domain/Model/Recruit/RecruitApplication.swift b/Koin/Domain/Model/Recruit/RecruitApplication.swift index a3f1f91b0..238bc7ef1 100644 --- a/Koin/Domain/Model/Recruit/RecruitApplication.swift +++ b/Koin/Domain/Model/Recruit/RecruitApplication.swift @@ -2,23 +2,29 @@ // RecruitApplication.swift // koin // -// Created by 홍기정 on 9/28/26. +// Created by 홍기정 on 9/27/26. // import Foundation -struct RecruitApplication: Identifiable, Equatable { +struct RecruitApplication { let applicationId: Int - let nickname: String - let department: String - let studentYear: Int - let role: String - let status: RecruitMyApplicationStatus - let canChat: Bool + var status: RecruitMyApplicationStatus + let profile: RecruitProfile + let motivation: String + let availableTime: String + let role: String? + let canDecide: Bool + let canDirectChat: Bool } extension RecruitApplication { - var id: Int { - return applicationId + mutating func decided(as decision: RecruitApplicationDecision) { + switch decision { + case .accepted: + self.status = .accepted + case .denied: + self.status = .denied + } } } diff --git a/Koin/Domain/Model/Recruit/RecruitApplicationDecision.swift b/Koin/Domain/Model/Recruit/RecruitApplicationDecision.swift new file mode 100644 index 000000000..07efdce6b --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitApplicationDecision.swift @@ -0,0 +1,13 @@ +// +// RecruitApplicationDecision.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +enum RecruitApplicationDecision: Equatable { + case accepted + case denied +} diff --git a/Koin/Domain/Model/Recruit/RecruitApplicationSummary.swift b/Koin/Domain/Model/Recruit/RecruitApplicationSummary.swift new file mode 100644 index 000000000..d83db221c --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitApplicationSummary.swift @@ -0,0 +1,24 @@ +// +// RecruitApplicationSummary.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +struct RecruitApplicationSummary: Identifiable, Equatable { + let applicationId: Int + let nickname: String + let department: String + let studentYear: Int + let role: String + let status: RecruitMyApplicationStatus + let canChat: Bool +} + +extension RecruitApplicationSummary { + var id: Int { + return applicationId + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift b/Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift index 68bd9f52f..db3548c53 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift @@ -28,5 +28,5 @@ struct RecruitMyPostSummary: Identifiable, Equatable { let canClose: Bool? let chatRoomId: Int? - let applications: [RecruitApplication] + let applications: [RecruitApplicationSummary] } diff --git a/Koin/Domain/Model/Recruit/RecruitProfileActivity.swift b/Koin/Domain/Model/Recruit/RecruitProfileActivity.swift index f8098e3ce..121d084f4 100644 --- a/Koin/Domain/Model/Recruit/RecruitProfileActivity.swift +++ b/Koin/Domain/Model/Recruit/RecruitProfileActivity.swift @@ -14,6 +14,22 @@ struct RecruitProfileActivity: Identifiable, Equatable { let endedAt: Date? let isOngoing: Bool let description: String + + var periodText: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy.MM.dd" + + let start = formatter.string(from: startedAt) + if isOngoing { + return "\(start) - 진행 중" + } + guard let endedAt else { + return start + } + + return "\(start) - \(formatter.string(from: endedAt))" + } } extension RecruitProfileActivity { diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index 56dbcecf6..c02d9f1cf 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -11,6 +11,15 @@ protocol RecruitRepository { func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList func fetchMyPost(_ id: Int) async throws -> RecruitMyPostSummary + func fetchMyPostApplication( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitApplication + func decideMyPostApplication( + recruitmentId: Int, + applicationId: Int, + decision: RecruitApplicationDecision + ) async throws -> Void func fetchMyApplicationList(_ filter: RecruitMyApplicationFilter) async throws -> RecruitMyApplicationList func closeMyPost(id: Int) async throws -> Bool func fetchNotificationList() async throws -> RecruitNotificationList diff --git a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/DecideRecruitMyPostApplicationUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/DecideRecruitMyPostApplicationUseCase.swift new file mode 100644 index 000000000..5ace5c593 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/DecideRecruitMyPostApplicationUseCase.swift @@ -0,0 +1,37 @@ +// +// DecideRecruitMyPostApplicationUseCase.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +protocol DecideRecruitMyPostApplicationUseCase { + func execute( + recruitmentId: Int, + applicationId: Int, + decision: RecruitApplicationDecision + ) async throws -> Void +} + +final class DefaultDecideRecruitMyPostApplicationUseCase: DecideRecruitMyPostApplicationUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute( + recruitmentId: Int, + applicationId: Int, + decision: RecruitApplicationDecision + ) async throws -> Void { + try await repository.decideMyPostApplication( + recruitmentId: recruitmentId, + applicationId: applicationId, + decision: decision + ) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostApplicantUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostApplicantUseCase.swift new file mode 100644 index 000000000..b824ee340 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostApplicantUseCase.swift @@ -0,0 +1,34 @@ +// +// FetchRecruitMyPostApplicationUseCase.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +protocol FetchRecruitMyPostApplicationUseCase { + func execute( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitApplication +} + +final class DefaultFetchRecruitMyPostApplicationUseCase: FetchRecruitMyPostApplicationUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitApplication { + try await repository.fetchMyPostApplication( + recruitmentId: recruitmentId, + applicationId: applicationId + ) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift index 02a8378cf..ee54de4d8 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift @@ -8,12 +8,22 @@ import SwiftUI final class RecruitMyPostHostingController: UIHostingController, HostingControllerProtocol { - + + // MARK: - Initializer + override init(rootView: RecruitMyPostView) { + super.init(rootView: rootView) + bindAction(to: rootView) + title = "지원자 관리" + } + + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + // MARK: - Life Cycle override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) configureNavigationBar(style: .newBackground) - title = "지원자 관리" } // MARK: - Public @@ -21,6 +31,35 @@ final class RecruitMyPostHostingController: UIHostingControllerVoid let onDirectChatTapped: ()->Void - let model: RecruitApplication + let model: RecruitApplicationSummary init( - model: RecruitApplication, + model: RecruitApplicationSummary, onApplicationTapped: @escaping () -> Void, onDirectChatTapped: @escaping () -> Void ) { diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift new file mode 100644 index 000000000..1ff35f0b7 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift @@ -0,0 +1,105 @@ +// +// RecruitMyPostApplicantHostingController.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import SwiftUI + +final class RecruitMyPostApplicantHostingController: UIHostingController, HostingControllerProtocol { + + // MARK: - Initializer + override init(rootView: RecruitMyPostApplicantView) { + super.init(rootView: rootView) + bindAction(to: rootView) + title = "지원자 상세" + } + + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar(style: .newBackground) + } + + // MARK: - Public + func execute(action: RootView.Action) { + switch action { + case .showDecisionModal(let decision): + showDecisionModal(decision) + case .showDirectChat: + showDirectChat() + case .showToast(let message): + showToastMessage(message: message, bottomInset: 80) + } + } +} + +extension RecruitMyPostApplicantHostingController { + private func showDecisionModal(_ decision: RecruitApplicationDecision) { + let copy: (title: String, subtitle: String, action: String) + switch decision { + case .accepted: + copy = ( + "해당 지원자를 승인하시겠어요?", + "승인 후에는 ‘승인’ 상태로 변경되며\n지원자에게 승인 알림이 전송됩니다.", + "승인하기" + ) + case .denied: + copy = ( + "해당 지원자를 거절하시겠어요?", + "거절 후에는 ‘거절’ 상태로 변경되며\n지원자에게 거절 알림이 전송됩니다.", + "거절하기" + ) + } + + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .new, + content: .titles( + mainTitleText: copy.title, + mainTitleStyle: .init( + textColor: .neutral600, + font: .pretendardMedium, + fontSize: 15 + ), + subTitleText: copy.subtitle, + subTitleStyle: .init( + textColor: .neutral500, + font: .pretendardRegular, + fontSize: 13 + ) + ), + button: .buttons( + leftButtonTitle: "취소하기", + leftButtonStyle: .init( + textColor: .neutral600, + font: .pretendardMedium, + fontSize: 15, + borderColor: .neutral400, + borderWidth: 1, + cornerRadius: 8 + ), + rightButtonTitle: copy.action, + rightButtonAction: { [weak self] in + self?.rootView.decide(decision) + }, + rightButtonStyle: .init( + textColor: .neutral0, + font: .pretendardMedium, + fontSize: 15, + backgroundColor: .new500, + cornerRadius: 8 + ) + ) + )) + present(modalViewController, animated: true) + } + + private func showDirectChat() { + // TODO: 지원자와의 1:1 채팅 화면 연결 + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift new file mode 100644 index 000000000..acff87bcc --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift @@ -0,0 +1,133 @@ +// +// RecruitMyPostApplicantView.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import SwiftUI + +struct RecruitMyPostApplicantView: ActionBindableView { + + enum Action { + case showDecisionModal(RecruitApplicationDecision) + case showDirectChat + case showToast(message: String) + } + + // MARK: - Properties + var sendAction: (Action) -> Void = { _ in } + @State private var viewModel: RecruitMyPostApplicantViewModel + + // MARK: - Initializer + init(viewModel: RecruitMyPostApplicantViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + var body: some View { + Group { + if let application = viewModel.application { + contentView(application) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.appColor(.newBackground).ignoresSafeArea()) + .loadingOverlay(viewModel.isLoading) + .onFirstAppear { + viewModel.execute(.onFirstAppear) + } + .onChange(of: viewModel.errorMessage) { + guard let message = viewModel.errorMessage else { + return + } + sendAction(.showToast(message: message)) + viewModel.execute(.didShowToast) + } + } + + // MARK: - Public + func decide(_ decision: RecruitApplicationDecision) { + viewModel.execute(.decide(decision)) + } +} + + +extension RecruitMyPostApplicantView { + private func contentView(_ application: RecruitApplication) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + RecruitMyPostApplicantProfileView(application: application) + + VStack(alignment: .leading, spacing: 16) { + largeTitleView("기본 정보") + + VStack(alignment: .leading, spacing: 12) { + mediumTitleView("보유기술") + LeftAlignedLayout(interitemSpacing: 8, interlineSpacing: 8) { + ForEach(application.profile.skills, id: \.self) { role in + RecruitMyPostApplicantRoleChipView(role: role) + } + } + + mediumTitleView("활동 이력") + VStack(spacing: 12) { + ForEach(application.profile.activities) { activity in + RecruitMyPostApplicantActivityView(activity: activity) + } + } + + mediumTitleView("자기소개") + RecruitMyPostApplicantTextView(text: application.profile.selfIntroduction) + } + } + + VStack(alignment: .leading, spacing: 16) { + largeTitleView("지원 내용") + + VStack(alignment: .leading, spacing: 12) { + mediumTitleView("지원 동기") + RecruitMyPostApplicantTextView(text: application.motivation) + + mediumTitleView("활동 가능 시간") + RecruitMyPostApplicantTextView(text: application.availableTime) + } + } + } + .padding(.horizontal, 24) + .padding(.top, 20) + .padding(.bottom, 24) + } + .scrollIndicators(.hidden) + .safeAreaInset(edge: .bottom, spacing: 0) { + RecruitMyPostApplicantBottomActionView( + application: application, + isLoading: viewModel.isLoading, + onDecisionTapped: { decision in + sendAction(.showDecisionModal(decision)) + }, + onDirectChatTapped: { + sendAction(.showDirectChat) + } + ) + .padding(.horizontal, 32) + .padding(.vertical, 16) + .background(Color.appColor(.newBackground)) + } + } + + @ViewBuilder + private func largeTitleView(_ title: String) -> some View { + Text(title) + .font(.appFont(.pretendardSemiBold, size: 16)) + .foregroundStyle(Color.appColor(.neutral800)) + } + + @ViewBuilder + private func mediumTitleView(_ title: String) -> some View { + Text(title) + .font(.appFont(.pretendardMedium, size: 14)) + .foregroundStyle(Color.appColor(.neutral800)) + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift new file mode 100644 index 000000000..9acdd3ea1 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift @@ -0,0 +1,113 @@ +// +// RecruitMyPostApplicantViewModel.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation +import Observation + +@MainActor +@Observable +final class RecruitMyPostApplicantViewModel: SwiftUIViewModelProtocol { + + enum Input { + case onFirstAppear + case decide(RecruitApplicationDecision) + case didShowToast + } + + // MARK: - State + private(set) var application: RecruitApplication? + private(set) var isLoading = false + private(set) var errorMessage: String? + + // MARK: - Properties + private let recruitmentId: Int + private let applicationId: Int + private let fetchUseCase: FetchRecruitMyPostApplicationUseCase + private let decideUseCase: DecideRecruitMyPostApplicationUseCase + + // MARK: - Initializer + init( + recruitmentId: Int, + applicationId: Int, + fetchUseCase: FetchRecruitMyPostApplicationUseCase, + decideUseCase: DecideRecruitMyPostApplicationUseCase + ) { + self.recruitmentId = recruitmentId + self.applicationId = applicationId + self.fetchUseCase = fetchUseCase + self.decideUseCase = decideUseCase + } + + // MARK: - Public + func execute(_ input: Input) { + switch input { + case .onFirstAppear: + fetchApplication() + case .decide(let decision): + decideApplication(decision) + case .didShowToast: + errorMessage = nil + } + } +} + +extension RecruitMyPostApplicantViewModel { + private func fetchApplication() { + guard !isLoading else { + return + } + + Task { + isLoading = true + defer { + isLoading = false + } + + do { + let application = try await fetchUseCase.execute( + recruitmentId: recruitmentId, + applicationId: applicationId + ) + self.application = application + } catch { + if let error = error as? ErrorResponse { + errorMessage = error.message + } else { + errorMessage = error.localizedDescription + } + } + } + } + + private func decideApplication(_ decision: RecruitApplicationDecision) { + guard !isLoading, let application, application.canDecide else { + return + } + + Task { + isLoading = true + defer { + isLoading = false + } + + do { + try await decideUseCase.execute( + recruitmentId: recruitmentId, + applicationId: applicationId, + decision: decision + ) + self.application?.decided(as: decision) + } catch { + if let error = error as? ErrorResponse { + errorMessage = error.message + } else { + errorMessage = error.localizedDescription + } + } + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantActivityView.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantActivityView.swift new file mode 100644 index 000000000..049f6de81 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantActivityView.swift @@ -0,0 +1,46 @@ +// +// RecruitMyPostApplicantActivityView.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import SwiftUI + +struct RecruitMyPostApplicantActivityView: View { + + let activity: RecruitProfileActivity + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(activity.title) + .font(.appFont(.pretendardSemiBold, size: 14)) + .foregroundStyle(Color.appColor(.neutral800)) + .frame(minHeight: 22) + + activityRow(title: "활동 기간", content: activity.periodText) + activityRow(title: "활동 내용", content: activity.description) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.appColor(.neutral0)) + .clipShape(.rect(cornerRadius: 16)) + } +} + +extension RecruitMyPostApplicantActivityView { + private func activityRow(title: String, content: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(title) + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral500)) + .frame(minHeight: 19) + Text(content) + .font(.appFont(.pretendardRegular, size: 12)) + .linespacing(fontSize: 12, percent: 160) + .foregroundStyle(Color.appColor(.neutral800)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.appFont(.pretendardRegular, size: 12)) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantBottomActionView.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantBottomActionView.swift new file mode 100644 index 000000000..24c150b5e --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantBottomActionView.swift @@ -0,0 +1,94 @@ +// +// RecruitMyPostApplicantBottomActionView.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import SwiftUI + +struct RecruitMyPostApplicantBottomActionView: View { + + let application: RecruitApplication + let isLoading: Bool + let onDecisionTapped: (RecruitApplicationDecision) -> Void + let onDirectChatTapped: () -> Void + + @ViewBuilder + var body: some View { + if application.canDecide { + HStack(spacing: 12) { + decisionButton( + title: "거절하기", + foregroundColor: .new500, + backgroundColor: .neutral0, + borderColor: .new500 + ) { + onDecisionTapped(.denied) + } + + decisionButton( + title: "승인하기", + foregroundColor: .neutral0, + backgroundColor: .new500, + borderColor: .new500 + ) { + onDecisionTapped(.accepted) + } + } + } else if application.status == .accepted { + HStack(spacing: 12) { + statusButton(title: "승인된 지원자", color: .new500) + + if application.canDirectChat { + Button(action: onDirectChatTapped) { + Image.appImage(asset: .recruitNotificationChat) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30, height: 30) + .frame(width: 50, height: 50) + } + .buttonStyle(.plain) + .accessibilityLabel("지원자와 채팅하기") + } + } + } else if application.status == .denied { + statusButton(title: "거절된 지원자", color: .danger700) + } + } +} + +extension RecruitMyPostApplicantBottomActionView { + private func decisionButton( + title: String, + foregroundColor: ColorAsset, + backgroundColor: ColorAsset, + borderColor: ColorAsset, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(title) + .font(.appFont(.pretendardSemiBold, size: 15)) + .foregroundStyle(Color.appColor(foregroundColor)) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(Color.appColor(backgroundColor)) + .clipShape(.rect(cornerRadius: 16)) + .border(Color.appColor(borderColor), width: 0.5, radius: 16) + } + .buttonStyle(.plain) + .disabled(isLoading) + } + + private func statusButton(title: String, color: ColorAsset) -> some View { + Text(title) + .font(.appFont(.pretendardSemiBold, size: 15)) + .foregroundStyle(Color.appColor(color)) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(Color.appColor(.neutral0)) + .clipShape(.rect(cornerRadius: 16)) + .border(Color.appColor(color), width: 0.5, radius: 16) + .accessibilityAddTraits(.isStaticText) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantProfileView.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantProfileView.swift new file mode 100644 index 000000000..5de241840 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantProfileView.swift @@ -0,0 +1,68 @@ +// +// RecruitMyPostApplicantProfileView.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import SwiftUI + +struct RecruitMyPostApplicantProfileView: View { + + let application: RecruitApplication + + var body: some View { + HStack(spacing: 12) { + Image.appImage(asset: .recruitNotificationMember) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30, height: 30) + .frame(width: 45, height: 45) + .border(Color.appColor(.neutral300), width: 1, radius: 50/2) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(application.profile.nickname) + .font(.appFont(.pretendardRegular, size: 14)) + .foregroundStyle(Color.appColor(.neutral800)) + + Spacer(minLength: 8) + + Text(application.status.rawValue) + .font(.appFont(.pretendardMedium, size: 11)) + .foregroundStyle(Color.appColor(application.status.textColor)) + } + + applicantMetadata + .font(.appFont(.pretendardRegular, size: 11)) + .lineLimit(1) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .frame(maxWidth: .infinity) + .background(Color.appColor(.neutral0)) + .clipShape(.rect(cornerRadius: 16)) + .accessibilityElement(children: .combine) + } +} + +extension RecruitMyPostApplicantProfileView { + @ViewBuilder + private var applicantMetadata: some View { + let profile = application.profile + let studentYear = String(profile.studentNumber.prefix(4).suffix(2)) + + if let role = application.role, !role.isEmpty { + HStack(spacing: 0) { + Text(role) + .foregroundStyle(Color.appColor(.new500)) + Text(" | \(profile.department) · \(studentYear)학번") + .foregroundStyle(Color.appColor(.neutral500)) + } + } else { + Text("\(profile.department) · \(studentYear)학번") + .foregroundStyle(Color.appColor(.neutral500)) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantRoleChipView.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantRoleChipView.swift new file mode 100644 index 000000000..02fef4319 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantRoleChipView.swift @@ -0,0 +1,23 @@ +// +// RecruitMyPostApplicantRoleChipView.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import SwiftUI + +struct RecruitMyPostApplicantRoleChipView: View { + + let role: String + + var body: some View { + Text(role) + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral500)) + .padding(.horizontal, 12) + .frame(minHeight: 36) + .background(Color.appColor(.neutral0)) + .clipShape(.capsule) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantTextView.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantTextView.swift new file mode 100644 index 000000000..5b7971b4f --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantTextView.swift @@ -0,0 +1,25 @@ +// +// RecruitMyPostApplicantTextView.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import SwiftUI + +struct RecruitMyPostApplicantTextView: View { + + let text: String + + var body: some View { + Text(text) + .linespacing(fontSize: 14, percent: 160) + .font(.appFont(.pretendardRegular, size: 14)) + .foregroundStyle(Color.appColor(.neutral500)) + .frame(maxWidth: .infinity, minHeight: 22, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.appColor(.neutral0)) + .clipShape(.rect(cornerRadius: 16)) + } +} diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index f7b1e7564..03d92631d 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -489,6 +489,18 @@ 7CDCD0E2305602F6004204DA /* RecruitProfileActivityRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E1305602F6004204DA /* RecruitProfileActivityRequest.swift */; }; 7CDCD0E430560758004204DA /* BasicInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E330560758004204DA /* BasicInfo.swift */; }; 7CDCD0E6305695E8004204DA /* RecruitSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E5305695E8004204DA /* RecruitSectionHeaderView.swift */; }; + 7CE13418306A2CBF0011B052 /* FetchRecruitMyPostApplicantUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13417306A2CBF0011B052 /* FetchRecruitMyPostApplicantUseCase.swift */; }; + 7CE1341A306A2D100011B052 /* RecruitApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13419306A2D100011B052 /* RecruitApplication.swift */; }; + 7CE1341C306A2F930011B052 /* DecideRecruitMyPostApplicationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE1341B306A2F930011B052 /* DecideRecruitMyPostApplicationUseCase.swift */; }; + 7CE1341E306A2FA30011B052 /* RecruitApplicationDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE1341D306A2FA30011B052 /* RecruitApplicationDecision.swift */; }; + 7CE13420306A40000011B052 /* RecruitMyPostApplicantViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE1341F306A40000011B052 /* RecruitMyPostApplicantViewModel.swift */; }; + 7CE13422306A40000011B052 /* RecruitMyPostApplicantView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13421306A40000011B052 /* RecruitMyPostApplicantView.swift */; }; + 7CE13424306A40000011B052 /* RecruitMyPostApplicantHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13423306A40000011B052 /* RecruitMyPostApplicantHostingController.swift */; }; + 7CE13427306A40000011B052 /* RecruitMyPostApplicantProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13426306A40000011B052 /* RecruitMyPostApplicantProfileView.swift */; }; + 7CE1342B306A40000011B052 /* RecruitMyPostApplicantActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE1342A306A40000011B052 /* RecruitMyPostApplicantActivityView.swift */; }; + 7CE1342D306A40000011B052 /* RecruitMyPostApplicantBottomActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE1342C306A40000011B052 /* RecruitMyPostApplicantBottomActionView.swift */; }; + 7CE13431306A40000011B052 /* RecruitMyPostApplicantRoleChipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13430306A40000011B052 /* RecruitMyPostApplicantRoleChipView.swift */; }; + 7CE13433306A40000011B052 /* RecruitMyPostApplicantTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13432306A40000011B052 /* RecruitMyPostApplicantTextView.swift */; }; 7CEB3B4B2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */; }; 7CED90BC2EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FD02EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift */; }; 7CED90BD2EF2D1F900457128 /* ManageNoticeKeywordViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F612EF2D1F900457128 /* ManageNoticeKeywordViewController.swift */; }; @@ -713,7 +725,7 @@ 7CED92472EF3DC2800457128 /* OrderCategoryCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED92432EF3DC2800457128 /* OrderCategoryCollectionView.swift */; }; 7CF1D58B30695E7900295ED9 /* RecruitMyPostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D58A30695E7900295ED9 /* RecruitMyPostViewModel.swift */; }; 7CF1D58D30695E8A00295ED9 /* RecruitMyPostHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D58C30695E8A00295ED9 /* RecruitMyPostHostingController.swift */; }; - 7CF1D58F3069772700295ED9 /* RecruitApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D58E3069772700295ED9 /* RecruitApplication.swift */; }; + 7CF1D58F3069772700295ED9 /* RecruitApplicationSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D58E3069772700295ED9 /* RecruitApplicationSummary.swift */; }; 7CF1D5913069772700295ED9 /* FetchRecruitMyPostUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D5903069772700295ED9 /* FetchRecruitMyPostUseCase.swift */; }; 7CF77B3A300254BE003642C1 /* ProfileHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF77B39300254BE003642C1 /* ProfileHostingController.swift */; }; 7CF77B3C300254C8003642C1 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF77B3B300254C8003642C1 /* ProfileView.swift */; }; @@ -1640,6 +1652,18 @@ 7CDCD0E1305602F6004204DA /* RecruitProfileActivityRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileActivityRequest.swift; sourceTree = ""; }; 7CDCD0E330560758004204DA /* BasicInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicInfo.swift; sourceTree = ""; }; 7CDCD0E5305695E8004204DA /* RecruitSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitSectionHeaderView.swift; sourceTree = ""; }; + 7CE13417306A2CBF0011B052 /* FetchRecruitMyPostApplicantUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitMyPostApplicantUseCase.swift; sourceTree = ""; }; + 7CE13419306A2D100011B052 /* RecruitApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplication.swift; sourceTree = ""; }; + 7CE1341B306A2F930011B052 /* DecideRecruitMyPostApplicationUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecideRecruitMyPostApplicationUseCase.swift; sourceTree = ""; }; + 7CE1341D306A2FA30011B052 /* RecruitApplicationDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplicationDecision.swift; sourceTree = ""; }; + 7CE1341F306A40000011B052 /* RecruitMyPostApplicantViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostApplicantViewModel.swift; sourceTree = ""; }; + 7CE13421306A40000011B052 /* RecruitMyPostApplicantView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostApplicantView.swift; sourceTree = ""; }; + 7CE13423306A40000011B052 /* RecruitMyPostApplicantHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostApplicantHostingController.swift; sourceTree = ""; }; + 7CE13426306A40000011B052 /* RecruitMyPostApplicantProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostApplicantProfileView.swift; sourceTree = ""; }; + 7CE1342A306A40000011B052 /* RecruitMyPostApplicantActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostApplicantActivityView.swift; sourceTree = ""; }; + 7CE1342C306A40000011B052 /* RecruitMyPostApplicantBottomActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostApplicantBottomActionView.swift; sourceTree = ""; }; + 7CE13430306A40000011B052 /* RecruitMyPostApplicantRoleChipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostApplicantRoleChipView.swift; sourceTree = ""; }; + 7CE13432306A40000011B052 /* RecruitMyPostApplicantTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostApplicantTextView.swift; sourceTree = ""; }; 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteDeviceTokenUseCase.swift; sourceTree = ""; }; 7CED8EC32EF2D1F900457128 /* ShopCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCollectionView.swift; sourceTree = ""; }; 7CED8EC42EF2D1F900457128 /* ShopCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCollectionViewCell.swift; sourceTree = ""; }; @@ -1864,7 +1888,7 @@ 7CED92442EF3DC2800457128 /* OrderCategoryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderCategoryCollectionViewCell.swift; sourceTree = ""; }; 7CF1D58A30695E7900295ED9 /* RecruitMyPostViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostViewModel.swift; sourceTree = ""; }; 7CF1D58C30695E8A00295ED9 /* RecruitMyPostHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostHostingController.swift; sourceTree = ""; }; - 7CF1D58E3069772700295ED9 /* RecruitApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplication.swift; sourceTree = ""; }; + 7CF1D58E3069772700295ED9 /* RecruitApplicationSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplicationSummary.swift; sourceTree = ""; }; 7CF1D5903069772700295ED9 /* FetchRecruitMyPostUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitMyPostUseCase.swift; sourceTree = ""; }; 7CF77B39300254BE003642C1 /* ProfileHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHostingController.swift; sourceTree = ""; }; 7CF77B3B300254C8003642C1 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; @@ -2887,6 +2911,7 @@ 7CDCD0D93055EF87004204DA /* RecruitProfilePost */, 7C52085D306902C10009BDD3 /* RecruitApply */, 7C520862306947220009BDD3 /* RecruitMyPost */, + 7CE13413306A2BA20011B052 /* RecruitMyPostApplicant */, ); path = Recruit; sourceTree = ""; @@ -2930,7 +2955,9 @@ 7CDCD0E1305602F6004204DA /* RecruitProfileActivityRequest.swift */, 7CDCD0E330560758004204DA /* BasicInfo.swift */, 7C520868306902C10009BDD3 /* RecruitApplyRequest.swift */, - 7CF1D58E3069772700295ED9 /* RecruitApplication.swift */, + 7CF1D58E3069772700295ED9 /* RecruitApplicationSummary.swift */, + 7CE13419306A2D100011B052 /* RecruitApplication.swift */, + 7CE1341D306A2FA30011B052 /* RecruitApplicationDecision.swift */, ); path = Recruit; sourceTree = ""; @@ -3965,6 +3992,29 @@ path = RecruitProfilePost; sourceTree = ""; }; + 7CE13413306A2BA20011B052 /* RecruitMyPostApplicant */ = { + isa = PBXGroup; + children = ( + 7CE13425306A40000011B052 /* Subviews */, + 7CE13423306A40000011B052 /* RecruitMyPostApplicantHostingController.swift */, + 7CE13421306A40000011B052 /* RecruitMyPostApplicantView.swift */, + 7CE1341F306A40000011B052 /* RecruitMyPostApplicantViewModel.swift */, + ); + path = RecruitMyPostApplicant; + sourceTree = ""; + }; + 7CE13425306A40000011B052 /* Subviews */ = { + isa = PBXGroup; + children = ( + 7CE13426306A40000011B052 /* RecruitMyPostApplicantProfileView.swift */, + 7CE13430306A40000011B052 /* RecruitMyPostApplicantRoleChipView.swift */, + 7CE1342A306A40000011B052 /* RecruitMyPostApplicantActivityView.swift */, + 7CE13432306A40000011B052 /* RecruitMyPostApplicantTextView.swift */, + 7CE1342C306A40000011B052 /* RecruitMyPostApplicantBottomActionView.swift */, + ); + path = Subviews; + sourceTree = ""; + }; 7CED8EC52EF2D1F900457128 /* ShopCollectionView */ = { isa = PBXGroup; children = ( @@ -5399,6 +5449,8 @@ BEEF1000000000000000000C /* CloseRecruitMyPostUseCase.swift */, 7CF1D5903069772700295ED9 /* FetchRecruitMyPostUseCase.swift */, BEEF10000000000000000004 /* FetchRecruitMyPostListUseCase.swift */, + 7CE13417306A2CBF0011B052 /* FetchRecruitMyPostApplicantUseCase.swift */, + 7CE1341B306A2F930011B052 /* DecideRecruitMyPostApplicationUseCase.swift */, ); path = RecruitMyPostList; sourceTree = ""; @@ -6756,6 +6808,15 @@ CAFE20000000000000000007 /* RecruitMyApplicationListViewModel.swift in Sources */, CAFE20000000000000000008 /* RecruitMyApplicationListView.swift in Sources */, CAFE20000000000000000009 /* RecruitMyApplicationListHostingController.swift in Sources */, + 7CE1341E306A2FA30011B052 /* RecruitApplicationDecision.swift in Sources */, + 7CE13420306A40000011B052 /* RecruitMyPostApplicantViewModel.swift in Sources */, + 7CE13422306A40000011B052 /* RecruitMyPostApplicantView.swift in Sources */, + 7CE13424306A40000011B052 /* RecruitMyPostApplicantHostingController.swift in Sources */, + 7CE13427306A40000011B052 /* RecruitMyPostApplicantProfileView.swift in Sources */, + 7CE1342B306A40000011B052 /* RecruitMyPostApplicantActivityView.swift in Sources */, + 7CE1342D306A40000011B052 /* RecruitMyPostApplicantBottomActionView.swift in Sources */, + 7CE13431306A40000011B052 /* RecruitMyPostApplicantRoleChipView.swift in Sources */, + 7CE13433306A40000011B052 /* RecruitMyPostApplicantTextView.swift in Sources */, CAFE2000000000000000000A /* RecruitMyApplicationListHeaderView.swift in Sources */, CAFE2000000000000000000B /* RecruitMyApplicationListRowView.swift in Sources */, CAFE2000000000000000000C /* RecruitMyApplicationListEmptyView.swift in Sources */, @@ -6853,7 +6914,7 @@ 7CA63A332F1B1B0C00226F86 /* LostItemListFilterViewController.swift in Sources */, 7CA63A302F1B13F100226F86 /* LostItemStats.swift in Sources */, 7C8A942F2FD2C15400DEA6F5 /* ShopEventCountDto.swift in Sources */, - 7CF1D58F3069772700295ED9 /* RecruitApplication.swift in Sources */, + 7CF1D58F3069772700295ED9 /* RecruitApplicationSummary.swift in Sources */, 7CF1D5913069772700295ED9 /* FetchRecruitMyPostUseCase.swift in Sources */, D27DD0CF2BA608310081FD36 /* CollectionViewDelegate.swift in Sources */, D22C545A2C68B033000826DA /* FetchShopReviewRequest.swift in Sources */, @@ -7255,6 +7316,7 @@ 7C016576300BD9610013DD7B /* FetchDepartmentByCategoryUseCase.swift in Sources */, 7CED91C92EF2D1F900457128 /* CategoryCollectionView.swift in Sources */, 7CED91CB2EF2D1F900457128 /* ShopSummaryImagesCollectionViewCell.swift in Sources */, + 7CE1341A306A2D100011B052 /* RecruitApplication.swift in Sources */, 7CED91CD2EF2D1F900457128 /* LostItemChatViewModel.swift in Sources */, 7C640400300F395300A9589F /* DepartmentRepository.swift in Sources */, 7CED91CE2EF2D1F900457128 /* NotiViewModel.swift in Sources */, @@ -7530,6 +7592,7 @@ 7CA63A272F1AF80000226F86 /* LostItemListViewModel.swift in Sources */, D2FB23712C0412C70098BA2B /* EventParameter.swift in Sources */, D2CA2DD22C1CAE4E0036F02C /* ShopEvent.swift in Sources */, + 7CE1341C306A2F930011B052 /* DecideRecruitMyPostApplicationUseCase.swift in Sources */, D22C54832C6A59C4000826DA /* UploadFileUseCase.swift in Sources */, D208A7AE2CA117AA007040E7 /* AbTestAPI.swift in Sources */, D208A7CB2CABB755007040E7 /* CoreService.swift in Sources */, @@ -7556,6 +7619,7 @@ D29F21CC2C4E157E00994554 /* FetchDeptListUseCase.swift in Sources */, D208A7A62CA111EB007040E7 /* AssignAbTestRequest.swift in Sources */, D223D61B2D9C0FEC00C29BE6 /* FetchBannerUseCase.swift in Sources */, + 7CE13418306A2CBF0011B052 /* FetchRecruitMyPostApplicantUseCase.swift in Sources */, D21572662CEDDDC60061E725 /* FetchMySemesterUseCase.swift in Sources */, 7CACF87030440CEE00AC2828 /* MarkAsReadRecruitNotificationUseCase.swift in Sources */, B68D2CEB2EA50C1E00F3B479 /* UIViewController+Toast.swift in Sources */, From 5a7a39e36b3c3d6d302b7f7a0fae1e801ee895b6 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 28 Sep 2026 18:00:07 +0900 Subject: [PATCH 043/123] =?UTF-8?q?chore:=20Image=20Asset=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../recruitNotificationChat.svg | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationChat.imageset/recruitNotificationChat.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationChat.imageset/recruitNotificationChat.svg index a34445bea..7b0f267b2 100644 --- a/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationChat.imageset/recruitNotificationChat.svg +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationChat.imageset/recruitNotificationChat.svg @@ -1,6 +1,4 @@ - - - - + + From 345d487acab9e52033428cc55d04e0a58fe2c0c9 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 29 Sep 2026 00:53:21 +0900 Subject: [PATCH 044/123] =?UTF-8?q?chore:=20PublisherTestSupport=EB=A5=BC?= =?UTF-8?q?=20Common/Support/Publisher+=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테스트 전용 유틸을 공용 위치로 옮기고 프로덕션 타깃에도 등록 --- .../Support/Publisher+.swift | 2 +- koin.xcodeproj/project.pbxproj | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) rename koinUnitTests/Support/PublisherTestSupport.swift => Common/Support/Publisher+.swift (95%) diff --git a/koinUnitTests/Support/PublisherTestSupport.swift b/Common/Support/Publisher+.swift similarity index 95% rename from koinUnitTests/Support/PublisherTestSupport.swift rename to Common/Support/Publisher+.swift index 8dceeb16e..c2ba94866 100644 --- a/koinUnitTests/Support/PublisherTestSupport.swift +++ b/Common/Support/Publisher+.swift @@ -1,5 +1,5 @@ // -// PublisherTestError.swift +// Publisher+.swift // koinUnitTests // // Created by 이은지 on 8/8/26. diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 03d92631d..e6bae988d 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -499,6 +499,7 @@ 7CE13427306A40000011B052 /* RecruitMyPostApplicantProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13426306A40000011B052 /* RecruitMyPostApplicantProfileView.swift */; }; 7CE1342B306A40000011B052 /* RecruitMyPostApplicantActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE1342A306A40000011B052 /* RecruitMyPostApplicantActivityView.swift */; }; 7CE1342D306A40000011B052 /* RecruitMyPostApplicantBottomActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE1342C306A40000011B052 /* RecruitMyPostApplicantBottomActionView.swift */; }; + 7CE13430306ABEB90011B052 /* Publisher+.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000001 /* Publisher+.swift */; }; 7CE13431306A40000011B052 /* RecruitMyPostApplicantRoleChipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13430306A40000011B052 /* RecruitMyPostApplicantRoleChipView.swift */; }; 7CE13433306A40000011B052 /* RecruitMyPostApplicantTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13432306A40000011B052 /* RecruitMyPostApplicantTextView.swift */; }; 7CEB3B4B2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */; }; @@ -839,7 +840,7 @@ A51C00000000000000000004 /* RecruitProfilePostActivityTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B00000000000000000004 /* RecruitProfilePostActivityTableView.swift */; }; A51C00000000000000000005 /* RecruitProfilePostActivityDisplayTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B00000000000000000005 /* RecruitProfilePostActivityDisplayTableViewCell.swift */; }; A51C00000000000000000006 /* RecruitProfilePostActivityEditTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B00000000000000000006 /* RecruitProfilePostActivityEditTableViewCell.swift */; }; - AA20000000000000000001 /* PublisherTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000001 /* PublisherTestSupport.swift */; }; + AA20000000000000000001 /* Publisher+.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000001 /* Publisher+.swift */; }; AA20000000000000000002 /* DiningFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000002 /* DiningFixtures.swift */; }; AA20000000000000000003 /* SpyDiningRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000003 /* SpyDiningRepository.swift */; }; AA20000000000000000004 /* DateProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000004 /* DateProviderTests.swift */; }; @@ -2006,7 +2007,7 @@ A51B00000000000000000004 /* RecruitProfilePostActivityTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostActivityTableView.swift; sourceTree = ""; }; A51B00000000000000000005 /* RecruitProfilePostActivityDisplayTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostActivityDisplayTableViewCell.swift; sourceTree = ""; }; A51B00000000000000000006 /* RecruitProfilePostActivityEditTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostActivityEditTableViewCell.swift; sourceTree = ""; }; - AA10000000000000000001 /* PublisherTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublisherTestSupport.swift; sourceTree = ""; }; + AA10000000000000000001 /* Publisher+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+.swift"; sourceTree = ""; }; AA10000000000000000002 /* DiningFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningFixtures.swift; sourceTree = ""; }; AA10000000000000000003 /* SpyDiningRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpyDiningRepository.swift; sourceTree = ""; }; AA10000000000000000004 /* DateProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateProviderTests.swift; sourceTree = ""; }; @@ -3261,6 +3262,7 @@ 7C7F56B52FFB9F4A00847151 /* Common */ = { isa = PBXGroup; children = ( + 7CE1342F306ABEAC0011B052 /* Support */, 7CA686742FFCB6D8007937E9 /* Core */, 7CA686732FFCB6BE007937E9 /* NotificationHistory */, ); @@ -4015,6 +4017,14 @@ path = Subviews; sourceTree = ""; }; + 7CE1342F306ABEAC0011B052 /* Support */ = { + isa = PBXGroup; + children = ( + AA10000000000000000001 /* Publisher+.swift */, + ); + path = Support; + sourceTree = ""; + }; 7CED8EC52EF2D1F900457128 /* ShopCollectionView */ = { isa = PBXGroup; children = ( @@ -5311,7 +5321,6 @@ AA30000000000000000001 /* Support */ = { isa = PBXGroup; children = ( - AA10000000000000000001 /* PublisherTestSupport.swift */, AA10000000000000000002 /* DiningFixtures.swift */, AA10000000000000000007 /* DiningLoggingTestSupport.swift */, ); @@ -7228,6 +7237,7 @@ 7C4D5A4B2F01638800B40128 /* BusTimetableViewModel.swift in Sources */, 7C4D5A4C2F01638800B40128 /* BusAreaSelectedCollectionView.swift in Sources */, 7CA63A252F1AF7F900226F86 /* LostItemListViewController.swift in Sources */, + 7CE13430306ABEB90011B052 /* Publisher+.swift in Sources */, 7C58FE942F5FB69E00A2737B /* CallVanDataViewModel.swift in Sources */, 7C4D5A4D2F01638800B40128 /* ManyBusTimetableCollectionViewHeaderCell.swift in Sources */, 7C7F432A2F65F42300CC5860 /* CallVanRecruitmentState.swift in Sources */, @@ -7688,7 +7698,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - AA20000000000000000001 /* PublisherTestSupport.swift in Sources */, + AA20000000000000000001 /* Publisher+.swift in Sources */, AA20000000000000000002 /* DiningFixtures.swift in Sources */, AA20000000000000000003 /* SpyDiningRepository.swift in Sources */, AA20000000000000000004 /* DateProviderTests.swift in Sources */, From d2b26f6133a9754fc58f4105a31e936b840df811 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 29 Sep 2026 00:59:05 +0900 Subject: [PATCH 045/123] =?UTF-8?q?chore:=20Date=20String=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Extensions/Common/Date+.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Koin/Core/Extensions/Common/Date+.swift b/Koin/Core/Extensions/Common/Date+.swift index bb67c117f..022edf012 100644 --- a/Koin/Core/Extensions/Common/Date+.swift +++ b/Koin/Core/Extensions/Common/Date+.swift @@ -44,6 +44,12 @@ extension Date { return dateFormatter.string(from: self) } + func formatDateToYYYY년M월D일() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy년 M월 d일" + return dateFormatter.string(from: self) + } + func formatDateToHHMM(isHH: Bool) -> String { let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "ko_KR") From 0e3928065279a689d4ef346a36f5eb22eac9f54a Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 29 Sep 2026 22:55:32 +0900 Subject: [PATCH 046/123] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20UI=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cell Reuse시 transform 적용이 보일 수 있는 문제 수정 --- .../Chat/Views/ChatTableView/ChatDateHeaderView.swift | 4 +++- .../Shared/Chat/Views/ChatTableView/ChatLeftCell.swift | 6 ++++-- .../Shared/Chat/Views/ChatTableView/ChatRightCell.swift | 4 +++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatDateHeaderView.swift b/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatDateHeaderView.swift index d4e449d85..a5d401aa8 100644 --- a/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatDateHeaderView.swift +++ b/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatDateHeaderView.swift @@ -39,7 +39,9 @@ extension ChatDateHeaderView { } private func setUpStyles() { - transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi)) + UIView.performWithoutAnimation { [weak self] in + self?.transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi)) + } dateView.do { $0.backgroundColor = UIColor.appColor(.neutral100) diff --git a/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatLeftCell.swift b/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatLeftCell.swift index 5a42889c5..0f1981706 100644 --- a/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatLeftCell.swift +++ b/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatLeftCell.swift @@ -116,8 +116,10 @@ extension ChatLeftCell { } private func setUpStyles() { - transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi)) - + UIView.performWithoutAnimation { [weak self] in + self?.transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi)) + } + contentsStackView.do { $0.axis = .vertical $0.spacing = 8 diff --git a/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatRightCell.swift b/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatRightCell.swift index 71a0bb4e0..353f0191a 100644 --- a/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatRightCell.swift +++ b/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatRightCell.swift @@ -110,7 +110,9 @@ extension ChatRightCell { } private func setUpStyles() { - transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi)) + UIView.performWithoutAnimation { [weak self] in + self?.transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi)) + } contentsStackView.do { $0.axis = .vertical From 10f4241b14f3badb803830ac5b0b41ecd4f47d6a Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Wed, 30 Sep 2026 20:05:30 +0900 Subject: [PATCH 047/123] =?UTF-8?q?design:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=B1=84=ED=8C=85=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/MockRecruitRepository.swift | 121 +++++++++ Koin/Domain/Model/Core/ErrorResponse.swift | 2 + Koin/Domain/Model/Core/UploadDomain.swift | 1 + .../Model/Recruit/RecruitChatData.swift | 21 ++ .../Model/Recruit/RecruitChatMessage.swift | 25 ++ .../Model/Recruit/RecruitChatMessages.swift | 13 + .../Recruit/RecruitChatPostRequest.swift | 13 + .../Model/Recruit/RecruitNotification.swift | 7 +- .../Domain/Repository/RecruitRepository.swift | 17 ++ .../FetchRecruitChatMessagesUseCase.swift | 34 +++ .../FetchRecruitDirectChatDataUseCase.swift | 34 +++ .../FetchRecruitTeamChatDataUseCase.swift | 34 +++ .../PostRecruitChatMessageUseCase.swift | 37 +++ .../Mapper/ChatListModel+RecruitChat.swift | 33 +++ .../RecruitChatViewController.swift | 232 +++++++++++++++++ .../RecruitChat/RecruitChatViewModel.swift | 244 ++++++++++++++++++ ...itMyApplicationListHostingController.swift | 17 +- .../RecruitMyApplicationListView.swift | 4 +- .../RecruitMyPostHostingController.swift | 27 +- .../RecruitMyPost/RecruitMyPostView.swift | 11 +- ...ruitMyPostApplicantHostingController.swift | 17 +- .../RecruitMyPostApplicantView.swift | 7 +- .../RecruitMyPostApplicantViewModel.swift | 2 +- .../RecruitMyPostListHostingController.swift | 17 +- .../RecruitMyPostListView.swift | 4 +- koin.xcodeproj/project.pbxproj | 68 +++++ 26 files changed, 1014 insertions(+), 28 deletions(-) create mode 100644 Koin/Domain/Model/Recruit/RecruitChatData.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitChatMessage.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitChatMessages.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitChatPostRequest.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitChatMessagesUseCase.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitDirectChatDataUseCase.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitTeamChatDataUseCase.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitChat/PostRecruitChatMessageUseCase.swift create mode 100644 Koin/Presentation/Recruit/RecruitChat/Mapper/ChatListModel+RecruitChat.swift create mode 100644 Koin/Presentation/Recruit/RecruitChat/RecruitChatViewController.swift create mode 100644 Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index c12c966e1..973e9c12a 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -8,6 +8,112 @@ import Foundation final class MockRecruitRepository: RecruitRepository { + private var postedChatMessages: [RecruitChatMessage] = [] + + func fetchTeamChatData( + recruitmentId: Int, + chatRoomId: Int + ) async throws -> RecruitChatData { + try await Task.sleep(nanoseconds: 300_000_000) + + return RecruitChatData( + chatRoomId: chatRoomId, + chatRoomName: "AI 아이디어 공모전 팀원 모집", + chatRoomType: .team, + currentMemberCount: 3, + maximumMemberCount: 6 + ) + } + + func fetchDirectChatData( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitChatData { + try await Task.sleep(nanoseconds: 300_000_000) + + return RecruitChatData( + chatRoomId: applicationId + 20, + chatRoomName: "김철수", + chatRoomType: .direct, + currentMemberCount: nil, + maximumMemberCount: nil + ) + } + + func fetchChatMessages( + recruitmentId: Int, + chatRoomId: Int + ) async throws -> RecruitChatMessages { + try await Task.sleep(nanoseconds: 300_000_000) + + let messages = [ + RecruitChatMessage( + messageId: 901, + userId: 22, + userNickname: "김철수", + content: "안녕하세요!", + timestamp: Date().addingTimeInterval(-120), + isImage: false, + unreadCount: 2, + isMine: false, + showProfile: true, + profileImage: .callVanProfile0 + ), + RecruitChatMessage( + messageId: 902, + userId: 23, + userNickname: "이영희", + content: "반갑습니다.", + timestamp: Date().addingTimeInterval(-60), + isImage: false, + unreadCount: 1, + isMine: true, + showProfile: true, + profileImage: .callVanProfile1 + ), + RecruitChatMessage( + messageId: 903, + userId: 22, + userNickname: "김철수", + content: "https://placehold.co/600x400/000000/FFFFFF/png", + timestamp: Date().addingTimeInterval(-30), + isImage: true, + unreadCount: 0, + isMine: false, + showProfile: true, + profileImage: .callVanProfile0 + ) + ] + postedChatMessages + + return RecruitChatMessages( + dates: [Date().formatDateToYYYY년M월D일()], + messages: [Array(messages.reversed())] + ) + } + + func postChatMessage( + recruitmentId: Int, + chatRoomId: Int, + request: RecruitChatPostRequest + ) async throws { + try await Task.sleep(nanoseconds: 300_000_000) + + postedChatMessages.append( + RecruitChatMessage( + messageId: 904 + postedChatMessages.count, + userId: 1, + userNickname: "나", + content: request.content, + timestamp: Date(), + isImage: request.isImage, + unreadCount: 0, + isMine: true, + showProfile: true, + profileImage: .callVanProfile2 + ) + ) + } + func apply(_ request: RecruitApplyRequest) async throws -> Void { try await Task.sleep(nanoseconds: 300_000_000) } @@ -436,7 +542,10 @@ final class MockRecruitRepository: RecruitRepository { return RecruitNotificationList(notifications: [ RecruitNotification( id: 1, + recruitmentId: 101, chatRoomId: 1, + roomType: .team, + applicationId: nil, type: .chat, title: "팀원모집 @@님의 메시지", content: "메세지메세지", @@ -445,7 +554,10 @@ final class MockRecruitRepository: RecruitRepository { ), RecruitNotification( id: 2, + recruitmentId: 0, chatRoomId: 0, + roomType: .team, + applicationId: nil, type: .default, title: "팀원 모집 지원 승인", content: "지원했던 AI 공모전 팀원 모집에 승인되었어요.", @@ -454,7 +566,10 @@ final class MockRecruitRepository: RecruitRepository { ), RecruitNotification( id: 3, + recruitmentId: 0, chatRoomId: 0, + roomType: .team, + applicationId: nil, type: .default, title: "팀원 모집 지원 거절", content: "지원했던 AI 공모전 팀원 모집에 승인 거절되었어요.\n다른 모집글에 지원해보세요.", @@ -463,7 +578,10 @@ final class MockRecruitRepository: RecruitRepository { ), RecruitNotification( id: 4, + recruitmentId: 0, chatRoomId: 0, + roomType: .team, + applicationId: nil, type: .default, title: "팀원 모집글 삭제", content: "지원했던 AI 공모전 팀원 모집글이 삭제되었어요.\n다른 모집글에 지원해보세요.", @@ -472,7 +590,10 @@ final class MockRecruitRepository: RecruitRepository { ), RecruitNotification( id: 5, + recruitmentId: 0, chatRoomId: 0, + roomType: .team, + applicationId: nil, type: .default, title: "팀원 모집기간 종료", content: "작성했던 AI 공모전 팀원 모집 기간이 종료되었어요.", diff --git a/Koin/Domain/Model/Core/ErrorResponse.swift b/Koin/Domain/Model/Core/ErrorResponse.swift index a4e48ed53..d13163d55 100644 --- a/Koin/Domain/Model/Core/ErrorResponse.swift +++ b/Koin/Domain/Model/Core/ErrorResponse.swift @@ -43,4 +43,6 @@ extension ErrorResponse { static let createKeywordError = ErrorResponse(statusCode: nil, code: "CREATE_KEYWORD_ERROR", message: "로그인에 실패하여 코어데이터에서 키워드 저장") static let dateFormatterFailedConvert = ErrorResponse(code: "DATEFORMATTER_FAILED_CONVERT", message: "날짜 변환 실패") + + static let imageUploadError = ErrorResponse(statusCode: nil, code: "IMAGE_UPLOAD_ERROR", message: "이미지 전송 실패") } diff --git a/Koin/Domain/Model/Core/UploadDomain.swift b/Koin/Domain/Model/Core/UploadDomain.swift index ccfe26a24..9c6e6e464 100644 --- a/Koin/Domain/Model/Core/UploadDomain.swift +++ b/Koin/Domain/Model/Core/UploadDomain.swift @@ -22,4 +22,5 @@ enum UploadDomain: String { case club = "CLUB" case callVanReport = "CALLVAN_REPORT" case callVanChat = "CALLVAN_CHAT" + case recruit = "TEAM_RECRUITMENT" } diff --git a/Koin/Domain/Model/Recruit/RecruitChatData.swift b/Koin/Domain/Model/Recruit/RecruitChatData.swift new file mode 100644 index 000000000..d7a18ffb4 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitChatData.swift @@ -0,0 +1,21 @@ +// +// RecruitChatData.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +struct RecruitChatData { + let chatRoomId: Int + let chatRoomName: String + let chatRoomType: RecruitChatRoomType + let currentMemberCount: Int? + let maximumMemberCount: Int? +} + +enum RecruitChatRoomType: String { + case direct + case team +} diff --git a/Koin/Domain/Model/Recruit/RecruitChatMessage.swift b/Koin/Domain/Model/Recruit/RecruitChatMessage.swift new file mode 100644 index 000000000..9e08b3977 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitChatMessage.swift @@ -0,0 +1,25 @@ +// +// RecruitChatMessage.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +struct RecruitChatMessage { + let messageId: Int + let userId: Int + let userNickname: String + let content: String + let timestamp: Date + let isImage: Bool + let unreadCount: Int + let isMine: Bool + let showProfile: Bool + let profileImage: ImageAsset? + + var displayTime: String { + timestamp.formatDateToHHMM(isHH: true) + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitChatMessages.swift b/Koin/Domain/Model/Recruit/RecruitChatMessages.swift new file mode 100644 index 000000000..b07e804f9 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitChatMessages.swift @@ -0,0 +1,13 @@ +// +// RecruitChatMessages.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +struct RecruitChatMessages { + let dates: [String] + let messages: [[RecruitChatMessage]] +} diff --git a/Koin/Domain/Model/Recruit/RecruitChatPostRequest.swift b/Koin/Domain/Model/Recruit/RecruitChatPostRequest.swift new file mode 100644 index 000000000..5e2504b6d --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitChatPostRequest.swift @@ -0,0 +1,13 @@ +// +// RecruitChatPostRequest.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +struct RecruitChatPostRequest { + let content: String + let isImage: Bool +} diff --git a/Koin/Domain/Model/Recruit/RecruitNotification.swift b/Koin/Domain/Model/Recruit/RecruitNotification.swift index c5c5b5cdf..d42a232de 100644 --- a/Koin/Domain/Model/Recruit/RecruitNotification.swift +++ b/Koin/Domain/Model/Recruit/RecruitNotification.swift @@ -9,10 +9,13 @@ import Foundation struct RecruitNotification { let id: Int + let recruitmentId: Int let chatRoomId: Int - + let roomType: RecruitChatRoomType + let applicationId: Int? + let type: RecruitNotificationType - + let title: String let content: String let dateText: String diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index c02d9f1cf..cb0a8b44a 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -8,6 +8,23 @@ import Foundation protocol RecruitRepository { + func fetchTeamChatData( + recruitmentId: Int, + chatRoomId: Int + ) async throws -> RecruitChatData + func fetchDirectChatData( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitChatData + func fetchChatMessages( + recruitmentId: Int, + chatRoomId: Int + ) async throws -> RecruitChatMessages + func postChatMessage( + recruitmentId: Int, + chatRoomId: Int, + request: RecruitChatPostRequest + ) async throws func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList func fetchMyPost(_ id: Int) async throws -> RecruitMyPostSummary diff --git a/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitChatMessagesUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitChatMessagesUseCase.swift new file mode 100644 index 000000000..17c34ccc2 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitChatMessagesUseCase.swift @@ -0,0 +1,34 @@ +// +// FetchRecruitChatMessagesUseCase.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +protocol FetchRecruitChatMessagesUseCase { + func execute( + recruitmentId: Int, + chatRoomId: Int + ) async throws -> RecruitChatMessages +} + +final class DefaultFetchRecruitChatMessagesUseCase: FetchRecruitChatMessagesUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute( + recruitmentId: Int, + chatRoomId: Int + ) async throws -> RecruitChatMessages { + try await repository.fetchChatMessages( + recruitmentId: recruitmentId, + chatRoomId: chatRoomId + ) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitDirectChatDataUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitDirectChatDataUseCase.swift new file mode 100644 index 000000000..67de572f8 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitDirectChatDataUseCase.swift @@ -0,0 +1,34 @@ +// +// FetchRecruitDirectChatDataUseCase.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +protocol FetchRecruitDirectChatDataUseCase { + func execute( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitChatData +} + +final class DefaultFetchRecruitDirectChatDataUseCase: FetchRecruitDirectChatDataUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitChatData { + try await repository.fetchDirectChatData( + recruitmentId: recruitmentId, + applicationId: applicationId + ) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitTeamChatDataUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitTeamChatDataUseCase.swift new file mode 100644 index 000000000..4b6327804 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitTeamChatDataUseCase.swift @@ -0,0 +1,34 @@ +// +// FetchRecruitTeamChatDataUseCase.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +protocol FetchRecruitTeamChatDataUseCase { + func execute( + recruitmentId: Int, + chatRoomId: Int + ) async throws -> RecruitChatData +} + +final class DefaultFetchRecruitTeamChatDataUseCase: FetchRecruitTeamChatDataUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute( + recruitmentId: Int, + chatRoomId: Int + ) async throws -> RecruitChatData { + try await repository.fetchTeamChatData( + recruitmentId: recruitmentId, + chatRoomId: chatRoomId + ) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitChat/PostRecruitChatMessageUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitChat/PostRecruitChatMessageUseCase.swift new file mode 100644 index 000000000..a963ffbfe --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitChat/PostRecruitChatMessageUseCase.swift @@ -0,0 +1,37 @@ +// +// PostRecruitChatMessageUseCase.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +protocol PostRecruitChatMessageUseCase { + func execute( + recruitmentId: Int, + chatRoomId: Int, + request: RecruitChatPostRequest + ) async throws +} + +final class DefaultPostRecruitChatMessageUseCase: PostRecruitChatMessageUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute( + recruitmentId: Int, + chatRoomId: Int, + request: RecruitChatPostRequest + ) async throws { + try await repository.postChatMessage( + recruitmentId: recruitmentId, + chatRoomId: chatRoomId, + request: request + ) + } +} diff --git a/Koin/Presentation/Recruit/RecruitChat/Mapper/ChatListModel+RecruitChat.swift b/Koin/Presentation/Recruit/RecruitChat/Mapper/ChatListModel+RecruitChat.swift new file mode 100644 index 000000000..9e08923b5 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitChat/Mapper/ChatListModel+RecruitChat.swift @@ -0,0 +1,33 @@ +// +// ChatListModel+RecruitChat.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import UIKit + +extension ChatListModel { + init(from data: RecruitChatMessages) { + self.init( + dates: data.dates, + messages: data.messages.map { messages in + messages.map { ChatMessageRowModel(from: $0) } + } + ) + } +} + +extension ChatMessageRowModel { + init(from message: RecruitChatMessage) { + self.init( + alignment: message.isMine ? .right : .left, + content: message.isImage ? .image(message.content) : .text(message.content), + senderNickname: message.userNickname, + timeText: message.displayTime, + showsProfile: message.showProfile, + isLeftUser: false, + profileImage: message.profileImage.flatMap { UIImage.appImage(asset: $0) } + ) + } +} diff --git a/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewController.swift b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewController.swift new file mode 100644 index 000000000..711c97c75 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewController.swift @@ -0,0 +1,232 @@ +// +// RecruitChatViewController.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import UIKit +import PhotosUI +import Combine +import SnapKit +import Then + +final class RecruitChatViewController: UIViewController { + + // MARK: - Properties + private let viewModel: RecruitChatViewModel + private let inputSubject = PassthroughSubject() + private var subscriptions = Set() + + // MARK: - UI Components + private let chatListView = ChatListView() + + private let titleStackView = UIStackView() + private let titleLabel = UILabel() + private let peopleImageView = UIImageView() + private let participantsLabel = UILabel() + + // MARK: - Initializer + init(viewModel: RecruitChatViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + configureView() + bind() + setUpObservers() + configureNavigationBar(style: .empty) + inputSubject.send(.viewDidLoad) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + inputSubject.send(.viewWillAppear) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + inputSubject.send(.viewWillDisappear) + } +} + +extension RecruitChatViewController { + + private func bind() { + viewModel.transform(with: inputSubject.eraseToAnyPublisher()) + .receive(on: DispatchQueue.main) + .sink { [weak self] output in + guard let self else { return } + + switch output { + case .updateData(let data): + configureNavigationTitle(data) + case .updateMessages(let messages): + chatListView.update(model: ChatListModel(from: messages)) + case .showToast(let message): + showToastMessage(message: message, bottomInset: 60) + } + } + .store(in: &subscriptions) + + chatListView.messageSendPublisher + .sink { [weak self] message in + self?.inputSubject.send(.sendText(text: message)) + } + .store(in: &subscriptions) + + chatListView.imageSendTappedPublisher + .sink { [weak self] in + self?.presentImagePicker() + } + .store(in: &subscriptions) + + chatListView.imageTappedPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] imageUrl in + self?.presentZoomedImageViewControllerB(imageUrl) + } + .store(in: &subscriptions) + } +} + +extension RecruitChatViewController { + private func setUpObservers() { + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .sink { [weak self] _ in + self?.inputSubject.send(.viewWillAppear) + } + .store(in: &subscriptions) + + NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification) + .sink { [weak self] _ in + self?.inputSubject.send(.viewWillDisappear) + } + .store(in: &subscriptions) + } +} + +extension RecruitChatViewController { + private func configureNavigationTitle(_ data: RecruitChatData) { + titleLabel.text = data.chatRoomName + + if data.chatRoomType == .team, + let currentMemberCount = data.currentMemberCount, + let maximumMemberCount = data.maximumMemberCount { + participantsLabel.text = "\(currentMemberCount)/\(maximumMemberCount)" + } else { + peopleImageView.isHidden = true + participantsLabel.isHidden = true + } + + navigationItem.titleView = titleStackView + } +} + +extension RecruitChatViewController: PHPickerViewControllerDelegate { + private func presentImagePicker() { + var configuration = PHPickerConfiguration() + configuration.filter = .images + configuration.selectionLimit = 1 + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + present(picker, animated: true, completion: nil) + } + + func picker( + _ picker: PHPickerViewController, + didFinishPicking results: [PHPickerResult] + ) { + picker.dismiss(animated: true, completion: nil) + + guard let provider = results.first?.itemProvider else { return } + + if provider.canLoadObject(ofClass: UIImage.self) { + provider.loadObject(ofClass: UIImage.self) { [weak self] image, error in + DispatchQueue.main.async { + if let selectedImage = image as? UIImage { + self?.handleSelectedImage(image: selectedImage) + } + } + } + } + } + + private func handleSelectedImage(image: UIImage) { + guard let imageData = image.jpegData(compressionQuality: 0.5) else { + return + } + inputSubject.send(.sendImage(image: imageData)) + } +} + +extension RecruitChatViewController { + private func presentZoomedImageViewControllerB(_ imageUrl: String) { + let viewController = ZoomedImageViewControllerB(shouldShowTitle: false) + viewController.configure(url: imageUrl) + viewController.modalTransitionStyle = .crossDissolve + viewController.modalPresentationStyle = .overFullScreen + present(viewController, animated: true) + } +} + +extension RecruitChatViewController { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + view.backgroundColor = UIColor.appColor(.neutral100) + + titleStackView.do { + $0.axis = .horizontal + $0.alignment = .center + $0.spacing = 4 + } + + titleLabel.do { + $0.textColor = UIColor.appColor(.neutral800) + $0.font = UIFont.appFont(.pretendardMedium, size: 15) + } + + peopleImageView.do { + $0.image = UIImage.appImage(asset: .callVanListPeople)?.withRenderingMode(.alwaysTemplate) + $0.tintColor = UIColor.appColor(.neutral600) + $0.isHidden = true + } + + participantsLabel.do { + $0.textColor = UIColor.appColor(.neutral600) + $0.font = UIFont.appFont(.pretendardRegular, size: 12) + $0.isHidden = true + } + } + + private func setUpLayouts() { + [titleLabel, peopleImageView, participantsLabel].forEach { + titleStackView.addArrangedSubview($0) + } + view.addSubview(chatListView) + } + + private func setUpConstraints() { + peopleImageView.snp.makeConstraints { + $0.size.equalTo(16) + } + + chatListView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.leading.trailing.bottom.equalToSuperview() + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift new file mode 100644 index 000000000..3263f0d31 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift @@ -0,0 +1,244 @@ +// +// RecruitChatViewModel.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Combine +import Foundation + +final class RecruitChatViewModel: ViewModelProtocol { + + enum RoomSource { + case team(recruitmentId: Int, chatRoomId: Int) + case direct(recruitmentId: Int, applicationId: Int) + } + + enum Input { + case viewDidLoad + case viewWillAppear + case viewWillDisappear + case sendText(text: String) + case sendImage(image: Data) + } + + enum Output { + case updateData(RecruitChatData) + case updateMessages(RecruitChatMessages) + case showToast(String) + } + + // MARK: - Properties + private let fetchTeamChatDataUseCase: FetchRecruitTeamChatDataUseCase + private let fetchDirectChatDataUseCase: FetchRecruitDirectChatDataUseCase + private let fetchChatMessagesUseCase: FetchRecruitChatMessagesUseCase + private let postChatMessageUseCase: PostRecruitChatMessageUseCase + private let uploadFileUseCase: UploadFileUseCase + private let outputSubject = PassthroughSubject() + private var subscriptions = Set() + + private var pollingTask: Task? + + private let roomSource: RoomSource + private var chatRoomId: Int? + private var recruitmentId: Int { + switch roomSource { + case let .team(recruitmentId, _), let .direct(recruitmentId, _): + return recruitmentId + } + } + + // MARK: - Initializer + init( + roomSource: RoomSource, + fetchTeamChatDataUseCase: FetchRecruitTeamChatDataUseCase, + fetchDirectChatDataUseCase: FetchRecruitDirectChatDataUseCase, + fetchChatMessagesUseCase: FetchRecruitChatMessagesUseCase, + postChatMessageUseCase: PostRecruitChatMessageUseCase, + uploadFileUseCase: UploadFileUseCase + ) { + self.roomSource = roomSource + self.fetchTeamChatDataUseCase = fetchTeamChatDataUseCase + self.fetchDirectChatDataUseCase = fetchDirectChatDataUseCase + self.fetchChatMessagesUseCase = fetchChatMessagesUseCase + self.postChatMessageUseCase = postChatMessageUseCase + self.uploadFileUseCase = uploadFileUseCase + + if case let .team(_, chatRoomId) = roomSource { + self.chatRoomId = chatRoomId + } + } + + deinit { + stopPolling() + } + + // MARK: - Public + func transform(with input: AnyPublisher) -> AnyPublisher { + input + .sink { [weak self] input in + switch input { + case .viewDidLoad: + self?.fetchChatRoomData() + case .viewWillAppear: + self?.startPolling() + case .viewWillDisappear: + self?.stopPolling() + case .sendText(let text): + self?.postMessage(text: text) + case .sendImage(let image): + self?.postMessage(image: image) + } + } + .store(in: &subscriptions) + + return outputSubject.eraseToAnyPublisher() + } +} + +extension RecruitChatViewModel { + + private func fetchChatRoomData() { + switch roomSource { + case .direct(_, let applicationId): + fetchDirectChatData(applicationId) + case .team: + fetchTeamChatData() + } + } + + private func fetchDirectChatData(_ applicationId: Int) { + Task { [weak self] in + guard let self else { return } + do { + let chatData = try await fetchDirectChatDataUseCase.execute( + recruitmentId: recruitmentId, + applicationId: applicationId + ) + self.chatRoomId = chatData.chatRoomId + outputSubject.send(.updateData(chatData)) + } catch { + outputSubject.send(.showToast(errorMessage(from: error))) + } + } + } + + private func fetchTeamChatData() { + Task { [weak self] in + guard let self, let chatRoomId else { return } + do { + let chatData = try await fetchTeamChatDataUseCase.execute( + recruitmentId: recruitmentId, + chatRoomId: chatRoomId + ) + outputSubject.send(.updateData(chatData)) + } catch { + outputSubject.send(.showToast(errorMessage(from: error))) + } + } + } +} + +extension RecruitChatViewModel { + + private func startPolling() { + guard pollingTask == nil else { + return + } + + pollingTask = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + guard let chatRoomId else { continue } + + await fetchChatMessages(chatRoomId: chatRoomId) + + do { + try await Task.sleep(nanoseconds: 1_000_000_000) + } catch { + return + } + } + } + } + + private func stopPolling() { + pollingTask?.cancel() + pollingTask = nil + } + + private func fetchChatMessages(chatRoomId: Int) async { + do { + let messages = try await fetchChatMessagesUseCase.execute( + recruitmentId: recruitmentId, + chatRoomId: chatRoomId + ) + outputSubject.send(.updateMessages(messages)) + } catch { + outputSubject.send(.showToast(errorMessage(from: error))) + } + } +} + +extension RecruitChatViewModel { + + private func postMessage(text: String) { + Task { [weak self] in + guard let self else { return } + do { + let request = RecruitChatPostRequest(content: text, isImage: false) + try await postMessage(request: request) + } catch { + outputSubject.send(.showToast(errorMessage(from: error))) + } + } + } + + private func postMessage(image: Data) { + Task { [weak self] in + guard let self else { return } + do { + let imageUrl = try await uploadImage(image) + let request = RecruitChatPostRequest(content: imageUrl, isImage: true) + try await postMessage(request: request) + } catch { + outputSubject.send(.showToast(errorMessage(from: error))) + } + } + } + + private func uploadImage(_ data: Data) async throws -> String { + do { + let response = try await uploadFileUseCase.execute(files: [data], domain: .recruit).firstValue() + guard let imageUrl = response.fileUrls.first else { + throw ErrorResponse.imageUploadError + } + return imageUrl + } catch { + throw error + } + } + + private func postMessage(request: RecruitChatPostRequest) async throws { + guard let chatRoomId else { + return + } + + do { + try await postChatMessageUseCase.execute( + recruitmentId: recruitmentId, + chatRoomId: chatRoomId, + request: request + ) + } catch { + outputSubject.send(.showToast(errorMessage(from: error))) + } + } +} + +extension RecruitChatViewModel { + private func errorMessage(from error: Error) -> String { + (error as? ErrorResponse)?.message ?? error.localizedDescription + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift index fcd21160b..ac602e71c 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift @@ -34,8 +34,8 @@ final class RecruitMyApplicationListHostingController: UIHostingController Date: Wed, 30 Sep 2026 20:36:49 +0900 Subject: [PATCH 048/123] =?UTF-8?q?chore:=20=EC=82=AC=EC=86=8C=ED=95=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Recruit/RecruitApply/RecruitApplyViewModel.swift | 4 ++-- koin.xcodeproj/project.pbxproj | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift index 776de034f..d5c7629a4 100644 --- a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift @@ -107,8 +107,8 @@ extension RecruitApplyViewModel { try await applyRecruitUseCase.execute(request: request) outputSubject.send(.applyCompleted) } catch { - guard let error = error as? ErrorResponse else { return } - outputSubject.send(.showToast(error.message)) + let errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + outputSubject.send(.showToast(errorMessage)) } } } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 03d92631d..4e62ad444 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -7914,7 +7914,6 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = koin/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "코인(stage)"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "이 앱은 복덕방 기능을 사용하기 위해 위치 정보가 필요합니다."; @@ -7950,7 +7949,6 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = koin/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "코인(stage)"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "이 앱은 복덕방 기능을 사용하기 위해 위치 정보가 필요합니다."; From ce99b02d0c4922c9d57b867b3ebc9fcd36d4dcdc Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Wed, 30 Sep 2026 20:50:27 +0900 Subject: [PATCH 049/123] =?UTF-8?q?chore:=20=EC=B1=84=ED=8C=85=20=ED=8F=B4?= =?UTF-8?q?=EB=A7=81=20=EC=9D=BC=EC=8B=9C=EC=A4=91=EB=8B=A8=EC=9D=84=20vie?= =?UTF-8?q?wWillDisappear=EC=9D=B4=20=EC=95=84=EB=8B=8C=20viewDidDisappear?= =?UTF-8?q?=20=EC=8B=9C=EC=A0=90=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99,?= =?UTF-8?q?=20CancellationError=20=EA=B0=80=20=ED=86=A0=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B4=EC=9D=B4=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Recruit/RecruitChat/RecruitChatViewController.swift | 6 +++--- .../Recruit/RecruitChat/RecruitChatViewModel.swift | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewController.swift b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewController.swift index 711c97c75..20588207a 100644 --- a/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewController.swift +++ b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewController.swift @@ -51,9 +51,9 @@ final class RecruitChatViewController: UIViewController { inputSubject.send(.viewWillAppear) } - override func viewWillDisappear(_ animated: Bool) { + override func viewDidDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - inputSubject.send(.viewWillDisappear) + inputSubject.send(.viewDidDisappear) } } @@ -107,7 +107,7 @@ extension RecruitChatViewController { NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification) .sink { [weak self] _ in - self?.inputSubject.send(.viewWillDisappear) + self?.inputSubject.send(.viewDidDisappear) } .store(in: &subscriptions) } diff --git a/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift index 3263f0d31..4b706c147 100644 --- a/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift @@ -18,7 +18,7 @@ final class RecruitChatViewModel: ViewModelProtocol { enum Input { case viewDidLoad case viewWillAppear - case viewWillDisappear + case viewDidDisappear case sendText(text: String) case sendImage(image: Data) } @@ -83,7 +83,7 @@ final class RecruitChatViewModel: ViewModelProtocol { self?.fetchChatRoomData() case .viewWillAppear: self?.startPolling() - case .viewWillDisappear: + case .viewDidDisappear: self?.stopPolling() case .sendText(let text): self?.postMessage(text: text) @@ -175,6 +175,8 @@ extension RecruitChatViewModel { chatRoomId: chatRoomId ) outputSubject.send(.updateMessages(messages)) + } catch is CancellationError { + return } catch { outputSubject.send(.showToast(errorMessage(from: error))) } From dcbe62d260e9c19cd87eadcd76fff576dcc5e9f2 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Wed, 30 Sep 2026 22:00:23 +0900 Subject: [PATCH 050/123] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitChatViewController.swift | 3 +- .../RecruitChat/RecruitChatViewModel.swift | 94 +++++++++---------- 2 files changed, 47 insertions(+), 50 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewController.swift b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewController.swift index 20588207a..11b23223c 100644 --- a/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewController.swift +++ b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewController.swift @@ -43,7 +43,6 @@ final class RecruitChatViewController: UIViewController { bind() setUpObservers() configureNavigationBar(style: .empty) - inputSubject.send(.viewDidLoad) } override func viewWillAppear(_ animated: Bool) { @@ -52,7 +51,7 @@ final class RecruitChatViewController: UIViewController { } override func viewDidDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) + super.viewDidDisappear(animated) inputSubject.send(.viewDidDisappear) } } diff --git a/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift index 4b706c147..4ba0fd34c 100644 --- a/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift @@ -16,7 +16,6 @@ final class RecruitChatViewModel: ViewModelProtocol { } enum Input { - case viewDidLoad case viewWillAppear case viewDidDisappear case sendText(text: String) @@ -48,6 +47,7 @@ final class RecruitChatViewModel: ViewModelProtocol { return recruitmentId } } + private var didUpdateData = false // MARK: - Initializer init( @@ -79,8 +79,6 @@ final class RecruitChatViewModel: ViewModelProtocol { input .sink { [weak self] input in switch input { - case .viewDidLoad: - self?.fetchChatRoomData() case .viewWillAppear: self?.startPolling() case .viewDidDisappear: @@ -98,45 +96,43 @@ final class RecruitChatViewModel: ViewModelProtocol { } extension RecruitChatViewModel { - - private func fetchChatRoomData() { - switch roomSource { - case .direct(_, let applicationId): - fetchDirectChatData(applicationId) - case .team: - fetchTeamChatData() + private func fetchChatRoomDataIfNeeded() async { + guard !didUpdateData else { + return } - } - - private func fetchDirectChatData(_ applicationId: Int) { - Task { [weak self] in - guard let self else { return } - do { - let chatData = try await fetchDirectChatDataUseCase.execute( - recruitmentId: recruitmentId, - applicationId: applicationId - ) - self.chatRoomId = chatData.chatRoomId - outputSubject.send(.updateData(chatData)) - } catch { - outputSubject.send(.showToast(errorMessage(from: error))) + + do { + switch roomSource { + case .direct(_, let applicationId): + try await fetchDirectChatData(applicationId) + case .team: + try await fetchTeamChatData() } + didUpdateData = true + } catch { + outputSubject.send(.showToast(errorMessage(from: error))) } } - private func fetchTeamChatData() { - Task { [weak self] in - guard let self, let chatRoomId else { return } - do { - let chatData = try await fetchTeamChatDataUseCase.execute( - recruitmentId: recruitmentId, - chatRoomId: chatRoomId - ) - outputSubject.send(.updateData(chatData)) - } catch { - outputSubject.send(.showToast(errorMessage(from: error))) - } + private func fetchDirectChatData(_ applicationId: Int) async throws { + let chatData = try await fetchDirectChatDataUseCase.execute( + recruitmentId: recruitmentId, + applicationId: applicationId + ) + self.chatRoomId = chatData.chatRoomId + outputSubject.send(.updateData(chatData)) + } + + private func fetchTeamChatData() async throws { + guard let chatRoomId else { + throw ErrorResponse.unexpectedInternalError } + + let chatData = try await fetchTeamChatDataUseCase.execute( + recruitmentId: recruitmentId, + chatRoomId: chatRoomId + ) + outputSubject.send(.updateData(chatData)) } } @@ -148,9 +144,16 @@ extension RecruitChatViewModel { } pollingTask = Task { [weak self] in + guard let self else { + return + } + + await fetchChatRoomDataIfNeeded() + while !Task.isCancelled { - guard let self else { return } - guard let chatRoomId else { continue } + guard let chatRoomId else { + continue + } await fetchChatMessages(chatRoomId: chatRoomId) @@ -184,7 +187,6 @@ extension RecruitChatViewModel { } extension RecruitChatViewModel { - private func postMessage(text: String) { Task { [weak self] in guard let self else { return } @@ -227,15 +229,11 @@ extension RecruitChatViewModel { return } - do { - try await postChatMessageUseCase.execute( - recruitmentId: recruitmentId, - chatRoomId: chatRoomId, - request: request - ) - } catch { - outputSubject.send(.showToast(errorMessage(from: error))) - } + try await postChatMessageUseCase.execute( + recruitmentId: recruitmentId, + chatRoomId: chatRoomId, + request: request + ) } } From a9ff515af83be107a649a2c8b8618ef86e3c5995 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Fri, 2 Oct 2026 00:22:36 +0900 Subject: [PATCH 051/123] =?UTF-8?q?refactor:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20List-Row=20=EB=AA=A8=EB=8D=B8=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecruitListSummary → RecruitRow - RecruitMyPostSummary → RecruitMyPostRow - RecruitMyApplicationSummary → RecruitMyApplicationRow - RecruitNotification → RecruitNotificationRow - RecruitChatMessages → RecruitChatMessageList --- .../Repository/MockRecruitRepository.swift | 30 ++++++------ ...ges.swift => RecruitChatMessageList.swift} | 4 +- Koin/Domain/Model/Recruit/RecruitList.swift | 2 +- .../Recruit/RecruitMyApplicationList.swift | 2 +- ...ry.swift => RecruitMyApplicationRow.swift} | 4 +- .../Model/Recruit/RecruitMyPostList.swift | 2 +- ...stSummary.swift => RecruitMyPostRow.swift} | 4 +- .../Recruit/RecruitNotificationList.swift | 2 +- ...ion.swift => RecruitNotificationRow.swift} | 4 +- ...ruitListSummary.swift => RecruitRow.swift} | 4 +- .../Domain/Repository/RecruitRepository.swift | 4 +- .../FetchRecruitChatMessagesUseCase.swift | 4 +- .../FetchRecruitMyPostUseCase.swift | 4 +- .../Mapper/ChatListModel+RecruitChat.swift | 2 +- .../RecruitChat/RecruitChatViewModel.swift | 2 +- .../Recruit/RecruitList/RecruitListView.swift | 2 +- .../Subviews/RecruitListRowView.swift | 4 +- .../RecruitMyApplicationListViewModel.swift | 2 +- .../RecruitMyApplicationListRowView.swift | 2 +- .../RecruitMyPost/RecruitMyPostView.swift | 2 +- .../RecruitMyPostViewModel.swift | 2 +- .../Subviews/RecruitMyPostSummaryView.swift | 2 +- .../RecruitMyPostListViewModel.swift | 2 +- .../Subviews/RecruitMyPostListRowView.swift | 2 +- ...n+.swift => RecruitNotificationRow+.swift} | 4 +- koin.xcodeproj/project.pbxproj | 48 +++++++++---------- 26 files changed, 73 insertions(+), 73 deletions(-) rename Koin/Domain/Model/Recruit/{RecruitChatMessages.swift => RecruitChatMessageList.swift} (68%) rename Koin/Domain/Model/Recruit/{RecruitMyApplicationSummary.swift => RecruitMyApplicationRow.swift} (83%) rename Koin/Domain/Model/Recruit/{RecruitMyPostSummary.swift => RecruitMyPostRow.swift} (86%) rename Koin/Domain/Model/Recruit/{RecruitNotification.swift => RecruitNotificationRow.swift} (86%) rename Koin/Domain/Model/Recruit/{RecruitListSummary.swift => RecruitRow.swift} (84%) rename Koin/Presentation/Recruit/RecruitNotificationList/Mapper/{RecruitNotification+.swift => RecruitNotificationRow+.swift} (88%) diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 973e9c12a..8741f24c9 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -43,7 +43,7 @@ final class MockRecruitRepository: RecruitRepository { func fetchChatMessages( recruitmentId: Int, chatRoomId: Int - ) async throws -> RecruitChatMessages { + ) async throws -> RecruitChatMessageList { try await Task.sleep(nanoseconds: 300_000_000) let messages = [ @@ -85,7 +85,7 @@ final class MockRecruitRepository: RecruitRepository { ) ] + postedChatMessages - return RecruitChatMessages( + return RecruitChatMessageList( dates: [Date().formatDateToYYYY년M월D일()], messages: [Array(messages.reversed())] ) @@ -189,7 +189,7 @@ final class MockRecruitRepository: RecruitRepository { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy.MM.dd" - let contest = RecruitListSummary( + let contest = RecruitRow( id: 1, category: .contest, title: "AI 아이디어 공모전 팀원 모집", @@ -209,7 +209,7 @@ final class MockRecruitRepository: RecruitRepository { ] ) - let externalActivity = RecruitListSummary( + let externalActivity = RecruitRow( id: 2, category: .externalActivity, title: "2026 대외활동 팀원 모집", @@ -225,7 +225,7 @@ final class MockRecruitRepository: RecruitRepository { roles: [] ) - let closedExternalActivity = RecruitListSummary( + let closedExternalActivity = RecruitRow( id: 3, category: .externalActivity, title: "2026 대외활동 팀원 모집", @@ -242,7 +242,7 @@ final class MockRecruitRepository: RecruitRepository { ) let studies = (4...7).map { id in - RecruitListSummary( + RecruitRow( id: id, category: .study, title: "2026 스터디 팀원 모집", @@ -270,7 +270,7 @@ final class MockRecruitRepository: RecruitRepository { func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList { let list = try await fetchList(RecruitListFilter()) var recruits = list.recruits.map { summary in - RecruitMyPostSummary( + RecruitMyPostRow( id: summary.id, category: summary.category, title: summary.title, @@ -315,12 +315,12 @@ final class MockRecruitRepository: RecruitRepository { ) } - func fetchMyPost(_ id: Int) async throws -> RecruitMyPostSummary { + func fetchMyPost(_ id: Int) async throws -> RecruitMyPostRow { let response = try await fetchMyPostList(RecruitMyPostFilter()) guard let recruit = response.recruits.first(where: { $0.id == id }) else { throw ErrorResponse.unexpectedInternalError } - return RecruitMyPostSummary( + return RecruitMyPostRow( id: recruit.id, category: recruit.category, title: recruit.title, @@ -453,7 +453,7 @@ final class MockRecruitRepository: RecruitRepository { let list = try await fetchList(RecruitListFilter()) let statuses = RecruitMyApplicationStatus.allCases var recruits = list.recruits.enumerated().map { index, summary in - RecruitMyApplicationSummary( + RecruitMyApplicationRow( id: summary.id, category: summary.category, title: summary.title, @@ -540,7 +540,7 @@ final class MockRecruitRepository: RecruitRepository { func fetchNotificationList() async throws -> RecruitNotificationList { return RecruitNotificationList(notifications: [ - RecruitNotification( + RecruitNotificationRow( id: 1, recruitmentId: 101, chatRoomId: 1, @@ -552,7 +552,7 @@ final class MockRecruitRepository: RecruitRepository { dateText: "2시간 전", isRead: false ), - RecruitNotification( + RecruitNotificationRow( id: 2, recruitmentId: 0, chatRoomId: 0, @@ -564,7 +564,7 @@ final class MockRecruitRepository: RecruitRepository { dateText: "2시간 전", isRead: false ), - RecruitNotification( + RecruitNotificationRow( id: 3, recruitmentId: 0, chatRoomId: 0, @@ -576,7 +576,7 @@ final class MockRecruitRepository: RecruitRepository { dateText: "2시간 전", isRead: false ), - RecruitNotification( + RecruitNotificationRow( id: 4, recruitmentId: 0, chatRoomId: 0, @@ -588,7 +588,7 @@ final class MockRecruitRepository: RecruitRepository { dateText: "2시간 전", isRead: false ), - RecruitNotification( + RecruitNotificationRow( id: 5, recruitmentId: 0, chatRoomId: 0, diff --git a/Koin/Domain/Model/Recruit/RecruitChatMessages.swift b/Koin/Domain/Model/Recruit/RecruitChatMessageList.swift similarity index 68% rename from Koin/Domain/Model/Recruit/RecruitChatMessages.swift rename to Koin/Domain/Model/Recruit/RecruitChatMessageList.swift index b07e804f9..48f46d339 100644 --- a/Koin/Domain/Model/Recruit/RecruitChatMessages.swift +++ b/Koin/Domain/Model/Recruit/RecruitChatMessageList.swift @@ -1,5 +1,5 @@ // -// RecruitChatMessages.swift +// RecruitChatMessageList.swift // koin // // Created by 홍기정 on 9/28/26. @@ -7,7 +7,7 @@ import Foundation -struct RecruitChatMessages { +struct RecruitChatMessageList { let dates: [String] let messages: [[RecruitChatMessage]] } diff --git a/Koin/Domain/Model/Recruit/RecruitList.swift b/Koin/Domain/Model/Recruit/RecruitList.swift index 7f600123f..c5509879e 100644 --- a/Koin/Domain/Model/Recruit/RecruitList.swift +++ b/Koin/Domain/Model/Recruit/RecruitList.swift @@ -8,7 +8,7 @@ import Foundation struct RecruitList { - var recruits: [RecruitListSummary] + var recruits: [RecruitRow] var totalCount: Int let totalPage: Int diff --git a/Koin/Domain/Model/Recruit/RecruitMyApplicationList.swift b/Koin/Domain/Model/Recruit/RecruitMyApplicationList.swift index 720d70ce5..64cb98b7b 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyApplicationList.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyApplicationList.swift @@ -8,7 +8,7 @@ import Foundation struct RecruitMyApplicationList { - var recruits: [RecruitMyApplicationSummary] + var recruits: [RecruitMyApplicationRow] let totalCount: Int let totalPage: Int diff --git a/Koin/Domain/Model/Recruit/RecruitMyApplicationSummary.swift b/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift similarity index 83% rename from Koin/Domain/Model/Recruit/RecruitMyApplicationSummary.swift rename to Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift index 397ed4600..f10eb9592 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyApplicationSummary.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift @@ -1,5 +1,5 @@ // -// RecruitMyApplicationSummary.swift +// RecruitMyApplicationRow.swift // koin // // Created by 홍기정 on 9/24/26. @@ -7,7 +7,7 @@ import Foundation -struct RecruitMyApplicationSummary: Identifiable, Equatable { +struct RecruitMyApplicationRow: Identifiable, Equatable { let id: Int let category: RecruitCategory let title: String diff --git a/Koin/Domain/Model/Recruit/RecruitMyPostList.swift b/Koin/Domain/Model/Recruit/RecruitMyPostList.swift index 8d4f4135f..aa06b44d6 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyPostList.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyPostList.swift @@ -8,7 +8,7 @@ import Foundation struct RecruitMyPostList { - var recruits: [RecruitMyPostSummary] + var recruits: [RecruitMyPostRow] var totalCount: Int let totalPage: Int diff --git a/Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift b/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift similarity index 86% rename from Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift rename to Koin/Domain/Model/Recruit/RecruitMyPostRow.swift index db3548c53..9a4ffe861 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyPostSummary.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift @@ -1,5 +1,5 @@ // -// RecruitMyPostSummary.swift +// RecruitMyPostRow.swift // koin // // Created by 홍기정 on 9/24/26. @@ -7,7 +7,7 @@ import Foundation -struct RecruitMyPostSummary: Identifiable, Equatable { +struct RecruitMyPostRow: Identifiable, Equatable { let id: Int let category: RecruitCategory let title: String diff --git a/Koin/Domain/Model/Recruit/RecruitNotificationList.swift b/Koin/Domain/Model/Recruit/RecruitNotificationList.swift index 7e2495fb7..1654d6ae9 100644 --- a/Koin/Domain/Model/Recruit/RecruitNotificationList.swift +++ b/Koin/Domain/Model/Recruit/RecruitNotificationList.swift @@ -8,7 +8,7 @@ import Foundation struct RecruitNotificationList { - var notifications: [RecruitNotification] + var notifications: [RecruitNotificationRow] } extension RecruitNotificationList { diff --git a/Koin/Domain/Model/Recruit/RecruitNotification.swift b/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift similarity index 86% rename from Koin/Domain/Model/Recruit/RecruitNotification.swift rename to Koin/Domain/Model/Recruit/RecruitNotificationRow.swift index d42a232de..6feef1fcc 100644 --- a/Koin/Domain/Model/Recruit/RecruitNotification.swift +++ b/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift @@ -1,5 +1,5 @@ // -// RecruitNotification.swift +// RecruitNotificationRow.swift // koin // // Created by 홍기정 on 8/29/26. @@ -7,7 +7,7 @@ import Foundation -struct RecruitNotification { +struct RecruitNotificationRow { let id: Int let recruitmentId: Int let chatRoomId: Int diff --git a/Koin/Domain/Model/Recruit/RecruitListSummary.swift b/Koin/Domain/Model/Recruit/RecruitRow.swift similarity index 84% rename from Koin/Domain/Model/Recruit/RecruitListSummary.swift rename to Koin/Domain/Model/Recruit/RecruitRow.swift index 2571593fd..0f408b455 100644 --- a/Koin/Domain/Model/Recruit/RecruitListSummary.swift +++ b/Koin/Domain/Model/Recruit/RecruitRow.swift @@ -1,5 +1,5 @@ // -// RecruitListSummary.swift +// RecruitRow.swift // koin // // Created by 홍기정 on 9/12/26. @@ -7,7 +7,7 @@ import Foundation -struct RecruitListSummary: Identifiable, Equatable { +struct RecruitRow: Identifiable, Equatable { let id: Int let category: RecruitCategory let title: String diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index cb0a8b44a..34d9c9570 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -19,7 +19,7 @@ protocol RecruitRepository { func fetchChatMessages( recruitmentId: Int, chatRoomId: Int - ) async throws -> RecruitChatMessages + ) async throws -> RecruitChatMessageList func postChatMessage( recruitmentId: Int, chatRoomId: Int, @@ -27,7 +27,7 @@ protocol RecruitRepository { ) async throws func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList - func fetchMyPost(_ id: Int) async throws -> RecruitMyPostSummary + func fetchMyPost(_ id: Int) async throws -> RecruitMyPostRow func fetchMyPostApplication( recruitmentId: Int, applicationId: Int diff --git a/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitChatMessagesUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitChatMessagesUseCase.swift index 17c34ccc2..b674a3956 100644 --- a/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitChatMessagesUseCase.swift +++ b/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitChatMessagesUseCase.swift @@ -11,7 +11,7 @@ protocol FetchRecruitChatMessagesUseCase { func execute( recruitmentId: Int, chatRoomId: Int - ) async throws -> RecruitChatMessages + ) async throws -> RecruitChatMessageList } final class DefaultFetchRecruitChatMessagesUseCase: FetchRecruitChatMessagesUseCase { @@ -25,7 +25,7 @@ final class DefaultFetchRecruitChatMessagesUseCase: FetchRecruitChatMessagesUseC func execute( recruitmentId: Int, chatRoomId: Int - ) async throws -> RecruitChatMessages { + ) async throws -> RecruitChatMessageList { try await repository.fetchChatMessages( recruitmentId: recruitmentId, chatRoomId: chatRoomId diff --git a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostUseCase.swift index 1f8dade8d..ef9f72b94 100644 --- a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostUseCase.swift +++ b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostUseCase.swift @@ -8,7 +8,7 @@ import Foundation protocol FetchRecruitMyPostUseCase { - func execute(id: Int) async throws -> RecruitMyPostSummary + func execute(id: Int) async throws -> RecruitMyPostRow } final class DefaultFetchRecruitMyPostUseCase: FetchRecruitMyPostUseCase { @@ -19,7 +19,7 @@ final class DefaultFetchRecruitMyPostUseCase: FetchRecruitMyPostUseCase { self.repository = repository } - func execute(id: Int) async throws -> RecruitMyPostSummary { + func execute(id: Int) async throws -> RecruitMyPostRow { try await repository.fetchMyPost(id) } } diff --git a/Koin/Presentation/Recruit/RecruitChat/Mapper/ChatListModel+RecruitChat.swift b/Koin/Presentation/Recruit/RecruitChat/Mapper/ChatListModel+RecruitChat.swift index 9e08923b5..b8c16548a 100644 --- a/Koin/Presentation/Recruit/RecruitChat/Mapper/ChatListModel+RecruitChat.swift +++ b/Koin/Presentation/Recruit/RecruitChat/Mapper/ChatListModel+RecruitChat.swift @@ -8,7 +8,7 @@ import UIKit extension ChatListModel { - init(from data: RecruitChatMessages) { + init(from data: RecruitChatMessageList) { self.init( dates: data.dates, messages: data.messages.map { messages in diff --git a/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift index 4ba0fd34c..c75532d34 100644 --- a/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift @@ -24,7 +24,7 @@ final class RecruitChatViewModel: ViewModelProtocol { enum Output { case updateData(RecruitChatData) - case updateMessages(RecruitChatMessages) + case updateMessages(RecruitChatMessageList) case showToast(String) } diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift index 55274a4e0..eecf3b6d5 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -21,7 +21,7 @@ struct RecruitListView: ActionBindableView { var sendAction: ((Action) -> Void) = { _ in } @State private var viewModel: RecruitListViewModel - var recruits: [RecruitListSummary] { + var recruits: [RecruitRow] { viewModel.recruitList?.recruits ?? [] } diff --git a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift index a38700618..3105ca0f6 100644 --- a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift @@ -9,9 +9,9 @@ import SwiftUI struct RecruitListRowView: View { - let model: RecruitListSummary + let model: RecruitRow - init(model: RecruitListSummary) { + init(model: RecruitRow) { self.model = model } diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListViewModel.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListViewModel.swift index 23c1f0c21..56f025298 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListViewModel.swift @@ -28,7 +28,7 @@ final class RecruitMyApplicationListViewModel: SwiftUIViewModelProtocol { // MARK: - Properties private let fetchRecruitMyApplicationListUseCase: FetchRecruitMyApplicationListUseCase - var recruits: [RecruitMyApplicationSummary] { + var recruits: [RecruitMyApplicationRow] { recruitList?.recruits ?? [] } diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift index 2867354cf..ae28128d7 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift @@ -9,7 +9,7 @@ import SwiftUI struct RecruitMyApplicationListRowView: View { - let model: RecruitMyApplicationSummary + let model: RecruitMyApplicationRow let onShowDetail: () -> Void let onShowChat: (Int) -> Void diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift index 06a5954d9..ebc23b4ee 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift @@ -49,7 +49,7 @@ struct RecruitMyPostView: ActionBindableView { } extension RecruitMyPostView { - private func contentView(data: RecruitMyPostSummary) -> some View { + private func contentView(data: RecruitMyPostRow) -> some View { GeometryReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 0) { diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift index 9726bf2d7..e316ab569 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift @@ -18,7 +18,7 @@ final class RecruitMyPostViewModel: SwiftUIViewModelProtocol { } // MARK: - State - private(set) var data: RecruitMyPostSummary? + private(set) var data: RecruitMyPostRow? private(set) var isLoading = false private(set) var errorMessage: String? diff --git a/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift index f1574e69f..b920a593a 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift @@ -9,7 +9,7 @@ import SwiftUI struct RecruitMyPostSummaryView: View { - let model: RecruitMyPostSummary + let model: RecruitMyPostRow let onGroupChatTapped: ()->Void var body: some View { diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListViewModel.swift b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListViewModel.swift index ec7cbc16b..9913f3172 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListViewModel.swift @@ -32,7 +32,7 @@ final class RecruitMyPostListViewModel: SwiftUIViewModelProtocol { private let fetchRecruitMyPostListUseCase: FetchRecruitMyPostListUseCase private let closeRecruitMyPostUseCase: CloseRecruitMyPostUseCase - var recruits: [RecruitMyPostSummary] { + var recruits: [RecruitMyPostRow] { recruitList?.recruits ?? [] } diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift index 37b60a67b..a21ec9081 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift @@ -9,7 +9,7 @@ import SwiftUI struct RecruitMyPostListRowView: View { - let model: RecruitMyPostSummary + let model: RecruitMyPostRow let onShowDetail: () -> Void let onShowChat: (Int) -> Void let onShowApplicants: () -> Void diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotification+.swift b/Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotificationRow+.swift similarity index 88% rename from Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotification+.swift rename to Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotificationRow+.swift index e38c82b78..56fdc2983 100644 --- a/Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotification+.swift +++ b/Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotificationRow+.swift @@ -1,5 +1,5 @@ // -// RecruitNotification+.swift +// RecruitNotificationRow+.swift // koin // // Created by 홍기정 on 8/30/26. @@ -7,7 +7,7 @@ import Foundation -extension RecruitNotification { +extension RecruitNotificationRow { var icon: ImageAsset { switch type { case .chat: ImageAsset.recruitNotificationChat diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 9c302fe0a..6dd2839ab 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -155,7 +155,7 @@ 7C50A1D73041F91400897A6F /* RecruitData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D63041F91400897A6F /* RecruitData.swift */; }; 7C50A1D93041FC6800897A6F /* RecruitListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1D83041FC6800897A6F /* RecruitListRowView.swift */; }; 7C50A1DD3042016300897A6F /* LeftAlignedLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1DC3042016300897A6F /* LeftAlignedLayout.swift */; }; - 7C50A1E13041F91400897A6F /* RecruitListSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1E23041F91400897A6F /* RecruitListSummary.swift */; }; + 7C50A1E13041F91400897A6F /* RecruitRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1E23041F91400897A6F /* RecruitRow.swift */; }; 7C50A1E23042C82F00897A6F /* RecruitRoleType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1E13042C82F00897A6F /* RecruitRoleType.swift */; }; 7C50A1E43042C97F00897A6F /* RecruitRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1E33042C97F00897A6F /* RecruitRole.swift */; }; 7C50A1F43042CB4100897A6F /* RecruitListSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1F33042CB4100897A6F /* RecruitListSort.swift */; }; @@ -165,7 +165,7 @@ 7C50A2033042EF2C00897A6F /* RecruitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A2023042EF2C00897A6F /* RecruitRepository.swift */; }; 7C50A2053042EF4F00897A6F /* MockRecruitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A2043042EF4F00897A6F /* MockRecruitRepository.swift */; }; 7C50A20930431F9900897A6F /* Array+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20830431F9900897A6F /* Array+.swift */; }; - 7C50A20B304324F500897A6F /* RecruitNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20A304324F500897A6F /* RecruitNotification.swift */; }; + 7C50A20B304324F500897A6F /* RecruitNotificationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20A304324F500897A6F /* RecruitNotificationRow.swift */; }; 7C50A20D3043273A00897A6F /* RecruitNotificationList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */; }; 7C50A20F3043278300897A6F /* FetchRecruitNotificationListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20E3043278300897A6F /* FetchRecruitNotificationListUseCase.swift */; }; 7C50F8DC300FC03B00A87BAE /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 7C50F8DB300FC03B00A87BAE /* AppIcon.icon */; }; @@ -377,7 +377,7 @@ 7CAC2DDC304C34E900AC2828 /* RecruitPostRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAC2DDB304C34E900AC2828 /* RecruitPostRequest.swift */; }; 7CACF86530436CFF00AC2828 /* RecruitNotificationListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CACF86430436CFF00AC2828 /* RecruitNotificationListViewController.swift */; }; 7CACF86730436D2600AC2828 /* RecruitNotificationListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CACF86630436D2600AC2828 /* RecruitNotificationListViewModel.swift */; }; - 7CACF86930436E7700AC2828 /* RecruitNotification+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CACF86830436E7700AC2828 /* RecruitNotification+.swift */; }; + 7CACF86930436E7700AC2828 /* RecruitNotificationRow+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CACF86830436E7700AC2828 /* RecruitNotificationRow+.swift */; }; 7CACF86B3044093F00AC2828 /* DeleteRecruitNotificationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CACF86A3044093F00AC2828 /* DeleteRecruitNotificationUseCase.swift */; }; 7CACF87030440CEE00AC2828 /* MarkAsReadRecruitNotificationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CACF86F30440CEE00AC2828 /* MarkAsReadRecruitNotificationUseCase.swift */; }; 7CB0618F2F320F3A00E83CA9 /* NMapsMap in Frameworks */ = {isa = PBXBuildFile; productRef = 7CB0618E2F320F3A00E83CA9 /* NMapsMap */; }; @@ -837,7 +837,7 @@ A1D000010000000000000001 /* FetchRecruitDataUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D000010000000000000002 /* FetchRecruitDataUseCase.swift */; }; A1E000010000000000000001 /* RecruitPostRoleTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E000010000000000000002 /* RecruitPostRoleTableView.swift */; }; A2C110000000000000000001 /* RecruitChatData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2C110000000000000000002 /* RecruitChatData.swift */; }; - A2C110000000000000000003 /* RecruitChatMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2C110000000000000000004 /* RecruitChatMessages.swift */; }; + A2C110000000000000000003 /* RecruitChatMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2C110000000000000000004 /* RecruitChatMessageList.swift */; }; A2C110000000000000000005 /* RecruitChatPostRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2C110000000000000000006 /* RecruitChatPostRequest.swift */; }; A2C120000000000000000005 /* FetchRecruitChatMessagesUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2C120000000000000000006 /* FetchRecruitChatMessagesUseCase.swift */; }; A2C120000000000000000007 /* PostRecruitChatMessageUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2C120000000000000000008 /* PostRecruitChatMessageUseCase.swift */; }; @@ -905,7 +905,7 @@ B68D2CEB2EA50C1E00F3B479 /* UIViewController+Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68D2CEA2EA50C1E00F3B479 /* UIViewController+Toast.swift */; }; BAD01845AA334A648E2FA2BD /* RecruitDataHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBC0EF8E0FE44F48744BEC7 /* RecruitDataHeaderView.swift */; }; BEEF20000000000000000001 /* RecruitMyPostList.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000001 /* RecruitMyPostList.swift */; }; - BEEF20000000000000000002 /* RecruitMyPostSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000002 /* RecruitMyPostSummary.swift */; }; + BEEF20000000000000000002 /* RecruitMyPostRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000002 /* RecruitMyPostRow.swift */; }; BEEF20000000000000000003 /* RecruitMyPostFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000003 /* RecruitMyPostFilter.swift */; }; BEEF20000000000000000004 /* FetchRecruitMyPostListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000004 /* FetchRecruitMyPostListUseCase.swift */; }; BEEF20000000000000000005 /* RecruitMyPostFilter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000005 /* RecruitMyPostFilter+.swift */; }; @@ -920,7 +920,7 @@ C0DE00000000000000000003 /* FetchMyProfileUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000004 /* FetchMyProfileUseCase.swift */; }; C1838F7A4424DEA14DB2D107 /* RecruitPostCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */; }; CAFE20000000000000000001 /* RecruitMyApplicationList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000001 /* RecruitMyApplicationList.swift */; }; - CAFE20000000000000000002 /* RecruitMyApplicationSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000002 /* RecruitMyApplicationSummary.swift */; }; + CAFE20000000000000000002 /* RecruitMyApplicationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000002 /* RecruitMyApplicationRow.swift */; }; CAFE20000000000000000003 /* RecruitMyApplicationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */; }; CAFE20000000000000000004 /* RecruitMyApplicationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000004 /* RecruitMyApplicationStatus.swift */; }; CAFE20000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift */; }; @@ -1362,7 +1362,7 @@ 7C50A1D83041FC6800897A6F /* RecruitListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListRowView.swift; sourceTree = ""; }; 7C50A1DC3042016300897A6F /* LeftAlignedLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftAlignedLayout.swift; sourceTree = ""; }; 7C50A1E13042C82F00897A6F /* RecruitRoleType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitRoleType.swift; sourceTree = ""; }; - 7C50A1E23041F91400897A6F /* RecruitListSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListSummary.swift; sourceTree = ""; }; + 7C50A1E23041F91400897A6F /* RecruitRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitRow.swift; sourceTree = ""; }; 7C50A1E33042C97F00897A6F /* RecruitRole.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitRole.swift; sourceTree = ""; }; 7C50A1F33042CB4100897A6F /* RecruitListSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListSort.swift; sourceTree = ""; }; 7C50A1F93042DB3200897A6F /* RecruitListFilter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecruitListFilter+.swift"; sourceTree = ""; }; @@ -1371,7 +1371,7 @@ 7C50A2023042EF2C00897A6F /* RecruitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitRepository.swift; sourceTree = ""; }; 7C50A2043042EF4F00897A6F /* MockRecruitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRecruitRepository.swift; sourceTree = ""; }; 7C50A20830431F9900897A6F /* Array+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+.swift"; sourceTree = ""; }; - 7C50A20A304324F500897A6F /* RecruitNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotification.swift; sourceTree = ""; }; + 7C50A20A304324F500897A6F /* RecruitNotificationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotificationRow.swift; sourceTree = ""; }; 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotificationList.swift; sourceTree = ""; }; 7C50A20E3043278300897A6F /* FetchRecruitNotificationListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitNotificationListUseCase.swift; sourceTree = ""; }; 7C50F8DB300FC03B00A87BAE /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; @@ -1580,7 +1580,7 @@ 7CAC2DDB304C34E900AC2828 /* RecruitPostRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostRequest.swift; sourceTree = ""; }; 7CACF86430436CFF00AC2828 /* RecruitNotificationListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotificationListViewController.swift; sourceTree = ""; }; 7CACF86630436D2600AC2828 /* RecruitNotificationListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotificationListViewModel.swift; sourceTree = ""; }; - 7CACF86830436E7700AC2828 /* RecruitNotification+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecruitNotification+.swift"; sourceTree = ""; }; + 7CACF86830436E7700AC2828 /* RecruitNotificationRow+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecruitNotificationRow+.swift"; sourceTree = ""; }; 7CACF86A3044093F00AC2828 /* DeleteRecruitNotificationUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteRecruitNotificationUseCase.swift; sourceTree = ""; }; 7CACF86F30440CEE00AC2828 /* MarkAsReadRecruitNotificationUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkAsReadRecruitNotificationUseCase.swift; sourceTree = ""; }; 7CB061BE2F32312E00E83CA9 /* DPDConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPDConstants.swift; sourceTree = ""; }; @@ -2027,7 +2027,7 @@ A1D000010000000000000002 /* FetchRecruitDataUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitDataUseCase.swift; sourceTree = ""; }; A1E000010000000000000002 /* RecruitPostRoleTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostRoleTableView.swift; sourceTree = ""; }; A2C110000000000000000002 /* RecruitChatData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitChatData.swift; sourceTree = ""; }; - A2C110000000000000000004 /* RecruitChatMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitChatMessages.swift; sourceTree = ""; }; + A2C110000000000000000004 /* RecruitChatMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitChatMessageList.swift; sourceTree = ""; }; A2C110000000000000000006 /* RecruitChatPostRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitChatPostRequest.swift; sourceTree = ""; }; A2C120000000000000000006 /* FetchRecruitChatMessagesUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitChatMessagesUseCase.swift; sourceTree = ""; }; A2C120000000000000000008 /* PostRecruitChatMessageUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRecruitChatMessageUseCase.swift; sourceTree = ""; }; @@ -2096,7 +2096,7 @@ B6A0D6292EEB01E600A19521 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; B6A0D62B2EEB01F100A19521 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; BEEF10000000000000000001 /* RecruitMyPostList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostList.swift; sourceTree = ""; }; - BEEF10000000000000000002 /* RecruitMyPostSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostSummary.swift; sourceTree = ""; }; + BEEF10000000000000000002 /* RecruitMyPostRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostRow.swift; sourceTree = ""; }; BEEF10000000000000000003 /* RecruitMyPostFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostFilter.swift; sourceTree = ""; }; BEEF10000000000000000004 /* FetchRecruitMyPostListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitMyPostListUseCase.swift; sourceTree = ""; }; BEEF10000000000000000005 /* RecruitMyPostFilter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecruitMyPostFilter+.swift"; sourceTree = ""; }; @@ -2110,7 +2110,7 @@ C0DE00000000000000000002 /* RecruitProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfile.swift; sourceTree = ""; }; C0DE00000000000000000004 /* FetchMyProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMyProfileUseCase.swift; sourceTree = ""; }; CAFE10000000000000000001 /* RecruitMyApplicationList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationList.swift; sourceTree = ""; }; - CAFE10000000000000000002 /* RecruitMyApplicationSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationSummary.swift; sourceTree = ""; }; + CAFE10000000000000000002 /* RecruitMyApplicationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationRow.swift; sourceTree = ""; }; CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationFilter.swift; sourceTree = ""; }; CAFE10000000000000000004 /* RecruitMyApplicationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationStatus.swift; sourceTree = ""; }; CAFE10000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitMyApplicationListUseCase.swift; sourceTree = ""; }; @@ -2986,16 +2986,16 @@ 7C50A1E33042C97F00897A6F /* RecruitRole.swift */, 7C50A1F33042CB4100897A6F /* RecruitListSort.swift */, 7C50A1CB3041E29000897A6F /* RecruitListFilter.swift */, - 7C50A1E23041F91400897A6F /* RecruitListSummary.swift */, + 7C50A1E23041F91400897A6F /* RecruitRow.swift */, 7C50A1FE3042E9DC00897A6F /* RecruitList.swift */, BEEF10000000000000000001 /* RecruitMyPostList.swift */, - BEEF10000000000000000002 /* RecruitMyPostSummary.swift */, + BEEF10000000000000000002 /* RecruitMyPostRow.swift */, BEEF10000000000000000003 /* RecruitMyPostFilter.swift */, CAFE10000000000000000001 /* RecruitMyApplicationList.swift */, - CAFE10000000000000000002 /* RecruitMyApplicationSummary.swift */, + CAFE10000000000000000002 /* RecruitMyApplicationRow.swift */, CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */, CAFE10000000000000000004 /* RecruitMyApplicationStatus.swift */, - 7C50A20A304324F500897A6F /* RecruitNotification.swift */, + 7C50A20A304324F500897A6F /* RecruitNotificationRow.swift */, 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */, 7CAC2DDB304C34E900AC2828 /* RecruitPostRequest.swift */, 7C50A1D63041F91400897A6F /* RecruitData.swift */, @@ -3009,7 +3009,7 @@ 7CE13419306A2D100011B052 /* RecruitApplication.swift */, 7CE1341D306A2FA30011B052 /* RecruitApplicationDecision.swift */, A2C110000000000000000002 /* RecruitChatData.swift */, - A2C110000000000000000004 /* RecruitChatMessages.swift */, + A2C110000000000000000004 /* RecruitChatMessageList.swift */, 7CE13431306C01BC0011B052 /* RecruitChatMessage.swift */, A2C110000000000000000006 /* RecruitChatPostRequest.swift */, ); @@ -3601,7 +3601,7 @@ 7CACF86230436C4A00AC2828 /* Mapper */ = { isa = PBXGroup; children = ( - 7CACF86830436E7700AC2828 /* RecruitNotification+.swift */, + 7CACF86830436E7700AC2828 /* RecruitNotificationRow+.swift */, ); path = Mapper; sourceTree = ""; @@ -6688,7 +6688,7 @@ A2C140000000000000000003 /* RecruitChatViewModel.swift in Sources */, A2C140000000000000000005 /* ChatListModel+RecruitChat.swift in Sources */, A2C110000000000000000001 /* RecruitChatData.swift in Sources */, - A2C110000000000000000003 /* RecruitChatMessages.swift in Sources */, + A2C110000000000000000003 /* RecruitChatMessageList.swift in Sources */, A2C110000000000000000005 /* RecruitChatPostRequest.swift in Sources */, A2C120000000000000000005 /* FetchRecruitChatMessagesUseCase.swift in Sources */, A2C120000000000000000007 /* PostRecruitChatMessageUseCase.swift in Sources */, @@ -6899,7 +6899,7 @@ 7C7F56B72FFB9FE000847151 /* NotificationHistoryRecord.swift in Sources */, 7C50A1FD3042E9AB00897A6F /* FetchRecruitListUseCase.swift in Sources */, BEEF20000000000000000001 /* RecruitMyPostList.swift in Sources */, - BEEF20000000000000000002 /* RecruitMyPostSummary.swift in Sources */, + BEEF20000000000000000002 /* RecruitMyPostRow.swift in Sources */, BEEF20000000000000000003 /* RecruitMyPostFilter.swift in Sources */, BEEF20000000000000000004 /* FetchRecruitMyPostListUseCase.swift in Sources */, BEEF20000000000000000005 /* RecruitMyPostFilter+.swift in Sources */, @@ -6911,7 +6911,7 @@ BEEF2000000000000000000B /* RecruitMyPostListEmptyView.swift in Sources */, BEEF2000000000000000000C /* CloseRecruitMyPostUseCase.swift in Sources */, CAFE20000000000000000001 /* RecruitMyApplicationList.swift in Sources */, - CAFE20000000000000000002 /* RecruitMyApplicationSummary.swift in Sources */, + CAFE20000000000000000002 /* RecruitMyApplicationRow.swift in Sources */, CAFE20000000000000000003 /* RecruitMyApplicationFilter.swift in Sources */, CAFE20000000000000000004 /* RecruitMyApplicationStatus.swift in Sources */, CAFE20000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift in Sources */, @@ -7409,7 +7409,7 @@ 7C8A94292FD0A97400DEA6F5 /* NotificationTableView.swift in Sources */, 7C6790962FCC48A60045E163 /* UIImageView+.swift in Sources */, 7C8A942D2FD2C01A00DEA6F5 /* FetchEventCountUseCase.swift in Sources */, - 7CACF86930436E7700AC2828 /* RecruitNotification+.swift in Sources */, + 7CACF86930436E7700AC2828 /* RecruitNotificationRow+.swift in Sources */, 7C6790972FCC48A60045E163 /* CALayer+.swift in Sources */, 7C6790982FCC48A60045E163 /* UITextFiled+Publisher.swift in Sources */, 7C6790992FCC48A60045E163 /* UIFont+.swift in Sources */, @@ -7488,7 +7488,7 @@ 7C04A4812FB3380700214992 /* SendDeviceTokenRequest.swift in Sources */, 7CED92062EF2D1F900457128 /* ShopSearchTableViewShopNameCell.swift in Sources */, 7C50A1D73041F91400897A6F /* RecruitData.swift in Sources */, - 7C50A1E13041F91400897A6F /* RecruitListSummary.swift in Sources */, + 7C50A1E13041F91400897A6F /* RecruitRow.swift in Sources */, 7CED92072EF2D1F900457128 /* SortTypeBottomSheetViewController.swift in Sources */, 7CED92082EF2D1F900457128 /* DeleteLectureModalViewController.swift in Sources */, 7CED92092EF2D1F900457128 /* AgreementFormViewController.swift in Sources */, @@ -7556,7 +7556,7 @@ 7CAC2DDC304C34E900AC2828 /* RecruitPostRequest.swift in Sources */, D89BAA392DCA5B3D006D3BB7 /* GeneralRegisterFormRequest.swift in Sources */, D20AB9562C55352A006BC684 /* ChangeNotiDetailUseCase.swift in Sources */, - 7C50A20B304324F500897A6F /* RecruitNotification.swift in Sources */, + 7C50A20B304324F500897A6F /* RecruitNotificationRow.swift in Sources */, D29F21C12C4DDF5300994554 /* DefaultUserRepository.swift in Sources */, 7C016546300AA14C0013DD7B /* DepartmentCategoryView.swift in Sources */, 7C7BD71D302B2B0A003C5A15 /* AppImage.swift in Sources */, From 9ab60ce84b6945b4f6effa876564cb60dde4df0e Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Fri, 2 Oct 2026 00:23:36 +0900 Subject: [PATCH 052/123] =?UTF-8?q?refactor:=20=EB=82=B4=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90(Application)=20/=20=EB=82=B4=20=EA=B8=80=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=9E=90(Applicant)=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecruitMyApplicationStatus → RecruitApplicationStatus - RecruitApplicationSummary → RecruitApplicantRow - RecruitApplication → RecruitApplicantData - RecruitApplicationDecision → RecruitApplicantDecision - fetchMyPostApplication / decideMyPostApplication → fetchApplicant / decideApplicant - Fetch/DecideRecruitMyPostApplicationUseCase → Fetch/DecideRecruitApplicantUseCase --- .../Repository/MockRecruitRepository.swift | 20 ++++---- ...ation.swift => RecruitApplicantData.swift} | 10 ++-- ...n.swift => RecruitApplicantDecision.swift} | 4 +- ...ummary.swift => RecruitApplicantRow.swift} | 8 ++-- ...s.swift => RecruitApplicationStatus.swift} | 4 +- .../Recruit/RecruitMyApplicationFilter.swift | 2 +- .../Recruit/RecruitMyApplicationRow.swift | 2 +- .../Model/Recruit/RecruitMyPostRow.swift | 2 +- .../Domain/Repository/RecruitRepository.swift | 8 ++-- ...ft => DecideRecruitApplicantUseCase.swift} | 12 ++--- ...ift => FetchRecruitApplicantUseCase.swift} | 12 ++--- .../Mapper/RecruitMyApplicationFilter+.swift | 6 +-- .../RecruitMyPostHostingController.swift | 4 +- .../RecruitMyPostApplicantRowView.swift | 4 +- ...ruitMyPostApplicantHostingController.swift | 2 +- .../RecruitMyPostApplicantView.swift | 6 +-- .../RecruitMyPostApplicantViewModel.swift | 14 +++--- ...cruitMyPostApplicantBottomActionView.swift | 4 +- .../RecruitMyPostApplicantProfileView.swift | 2 +- koin.xcodeproj/project.pbxproj | 48 +++++++++---------- 20 files changed, 87 insertions(+), 87 deletions(-) rename Koin/Domain/Model/Recruit/{RecruitApplication.swift => RecruitApplicantData.swift} (67%) rename Koin/Domain/Model/Recruit/{RecruitApplicationDecision.swift => RecruitApplicantDecision.swift} (57%) rename Koin/Domain/Model/Recruit/{RecruitApplicationSummary.swift => RecruitApplicantRow.swift} (61%) rename Koin/Domain/Model/Recruit/{RecruitMyApplicationStatus.swift => RecruitApplicationStatus.swift} (78%) rename Koin/Domain/UseCase/Recruit/RecruitMyPostList/{DecideRecruitMyPostApplicationUseCase.swift => DecideRecruitApplicantUseCase.swift} (61%) rename Koin/Domain/UseCase/Recruit/RecruitMyPostList/{FetchRecruitMyPostApplicantUseCase.swift => FetchRecruitApplicantUseCase.swift} (58%) diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 8741f24c9..7a8f82972 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -340,10 +340,10 @@ final class MockRecruitRepository: RecruitRepository { ) } - func fetchMyPostApplication( + func fetchApplicant( recruitmentId: Int, applicationId: Int - ) async throws -> RecruitApplication { + ) async throws -> RecruitApplicantData { try await Task.sleep(nanoseconds: 300_000_000) let recruitment = try await fetchMyPost(recruitmentId) @@ -361,7 +361,7 @@ final class MockRecruitRepository: RecruitRepository { } let role = application.role.isEmpty ? nil : application.role - return RecruitApplication( + return RecruitApplicantData( applicationId: application.applicationId, status: application.status, profile: RecruitProfile( @@ -390,10 +390,10 @@ final class MockRecruitRepository: RecruitRepository { ) } - func decideMyPostApplication( + func decideApplicant( recruitmentId: Int, applicationId: Int, - decision: RecruitApplicationDecision + decision: RecruitApplicantDecision ) async throws -> Void { let recruitment = try await fetchMyPost(recruitmentId) guard recruitment.applications.contains(where: { @@ -407,13 +407,13 @@ final class MockRecruitRepository: RecruitRepository { private func mockApplications( recruitId: Int, includesRole: Bool - ) -> [RecruitApplicationSummary] { + ) -> [RecruitApplicantRow] { guard recruitId == 1 || recruitId == 2 else { return [] } return [ - RecruitApplicationSummary( + RecruitApplicantRow( applicationId: recruitId * 100 + 1, nickname: "김철수", department: "컴퓨터공학부", @@ -422,7 +422,7 @@ final class MockRecruitRepository: RecruitRepository { status: .denied, canChat: false ), - RecruitApplicationSummary( + RecruitApplicantRow( applicationId: recruitId * 100 + 2, nickname: "김철수", department: "컴퓨터공학부", @@ -431,7 +431,7 @@ final class MockRecruitRepository: RecruitRepository { status: .accepted, canChat: true ), - RecruitApplicationSummary( + RecruitApplicantRow( applicationId: recruitId * 100 + 3, nickname: "김철수", department: "컴퓨터공학부", @@ -451,7 +451,7 @@ final class MockRecruitRepository: RecruitRepository { _ filter: RecruitMyApplicationFilter ) async throws -> RecruitMyApplicationList { let list = try await fetchList(RecruitListFilter()) - let statuses = RecruitMyApplicationStatus.allCases + let statuses = RecruitApplicationStatus.allCases var recruits = list.recruits.enumerated().map { index, summary in RecruitMyApplicationRow( id: summary.id, diff --git a/Koin/Domain/Model/Recruit/RecruitApplication.swift b/Koin/Domain/Model/Recruit/RecruitApplicantData.swift similarity index 67% rename from Koin/Domain/Model/Recruit/RecruitApplication.swift rename to Koin/Domain/Model/Recruit/RecruitApplicantData.swift index 238bc7ef1..38c9ea111 100644 --- a/Koin/Domain/Model/Recruit/RecruitApplication.swift +++ b/Koin/Domain/Model/Recruit/RecruitApplicantData.swift @@ -1,5 +1,5 @@ // -// RecruitApplication.swift +// RecruitApplicantData.swift // koin // // Created by 홍기정 on 9/27/26. @@ -7,9 +7,9 @@ import Foundation -struct RecruitApplication { +struct RecruitApplicantData { let applicationId: Int - var status: RecruitMyApplicationStatus + var status: RecruitApplicationStatus let profile: RecruitProfile let motivation: String let availableTime: String @@ -18,8 +18,8 @@ struct RecruitApplication { let canDirectChat: Bool } -extension RecruitApplication { - mutating func decided(as decision: RecruitApplicationDecision) { +extension RecruitApplicantData { + mutating func decided(as decision: RecruitApplicantDecision) { switch decision { case .accepted: self.status = .accepted diff --git a/Koin/Domain/Model/Recruit/RecruitApplicationDecision.swift b/Koin/Domain/Model/Recruit/RecruitApplicantDecision.swift similarity index 57% rename from Koin/Domain/Model/Recruit/RecruitApplicationDecision.swift rename to Koin/Domain/Model/Recruit/RecruitApplicantDecision.swift index 07efdce6b..e3e973d56 100644 --- a/Koin/Domain/Model/Recruit/RecruitApplicationDecision.swift +++ b/Koin/Domain/Model/Recruit/RecruitApplicantDecision.swift @@ -1,5 +1,5 @@ // -// RecruitApplicationDecision.swift +// RecruitApplicantDecision.swift // koin // // Created by 홍기정 on 9/28/26. @@ -7,7 +7,7 @@ import Foundation -enum RecruitApplicationDecision: Equatable { +enum RecruitApplicantDecision: Equatable { case accepted case denied } diff --git a/Koin/Domain/Model/Recruit/RecruitApplicationSummary.swift b/Koin/Domain/Model/Recruit/RecruitApplicantRow.swift similarity index 61% rename from Koin/Domain/Model/Recruit/RecruitApplicationSummary.swift rename to Koin/Domain/Model/Recruit/RecruitApplicantRow.swift index d83db221c..c87846890 100644 --- a/Koin/Domain/Model/Recruit/RecruitApplicationSummary.swift +++ b/Koin/Domain/Model/Recruit/RecruitApplicantRow.swift @@ -1,5 +1,5 @@ // -// RecruitApplicationSummary.swift +// RecruitApplicantRow.swift // koin // // Created by 홍기정 on 9/28/26. @@ -7,17 +7,17 @@ import Foundation -struct RecruitApplicationSummary: Identifiable, Equatable { +struct RecruitApplicantRow: Identifiable, Equatable { let applicationId: Int let nickname: String let department: String let studentYear: Int let role: String - let status: RecruitMyApplicationStatus + let status: RecruitApplicationStatus let canChat: Bool } -extension RecruitApplicationSummary { +extension RecruitApplicantRow { var id: Int { return applicationId } diff --git a/Koin/Domain/Model/Recruit/RecruitMyApplicationStatus.swift b/Koin/Domain/Model/Recruit/RecruitApplicationStatus.swift similarity index 78% rename from Koin/Domain/Model/Recruit/RecruitMyApplicationStatus.swift rename to Koin/Domain/Model/Recruit/RecruitApplicationStatus.swift index cd3a8a5af..83c7c6b97 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyApplicationStatus.swift +++ b/Koin/Domain/Model/Recruit/RecruitApplicationStatus.swift @@ -1,5 +1,5 @@ // -// RecruitMyApplicationStatus.swift +// RecruitApplicationStatus.swift // koin // // Created by 홍기정 on 9/24/26. @@ -7,7 +7,7 @@ import Foundation -enum RecruitMyApplicationStatus: String, CaseIterable, Equatable { +enum RecruitApplicationStatus: String, CaseIterable, Equatable { case accepted = "승인" case denied = "거절" case pending = "대기" diff --git a/Koin/Domain/Model/Recruit/RecruitMyApplicationFilter.swift b/Koin/Domain/Model/Recruit/RecruitMyApplicationFilter.swift index f4f41f916..e4db2a078 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyApplicationFilter.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyApplicationFilter.swift @@ -8,7 +8,7 @@ import Foundation struct RecruitMyApplicationFilter: Equatable { - var status: RecruitMyApplicationStatus? = nil + var status: RecruitApplicationStatus? = nil var sort: RecruitListSort = .latestDescending var page: Int = 1 var limit: Int? = 10 diff --git a/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift b/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift index f10eb9592..16e5766cb 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift @@ -25,6 +25,6 @@ struct RecruitMyApplicationRow: Identifiable, Equatable { let type: RecruitRoleType let roles: [RecruitRole] - let status: RecruitMyApplicationStatus + let status: RecruitApplicationStatus let chatRoomId: Int? } diff --git a/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift b/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift index 9a4ffe861..d4c01f9cd 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift @@ -28,5 +28,5 @@ struct RecruitMyPostRow: Identifiable, Equatable { let canClose: Bool? let chatRoomId: Int? - let applications: [RecruitApplicationSummary] + let applications: [RecruitApplicantRow] } diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index 34d9c9570..4fb91f08c 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -28,14 +28,14 @@ protocol RecruitRepository { func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList func fetchMyPost(_ id: Int) async throws -> RecruitMyPostRow - func fetchMyPostApplication( + func fetchApplicant( recruitmentId: Int, applicationId: Int - ) async throws -> RecruitApplication - func decideMyPostApplication( + ) async throws -> RecruitApplicantData + func decideApplicant( recruitmentId: Int, applicationId: Int, - decision: RecruitApplicationDecision + decision: RecruitApplicantDecision ) async throws -> Void func fetchMyApplicationList(_ filter: RecruitMyApplicationFilter) async throws -> RecruitMyApplicationList func closeMyPost(id: Int) async throws -> Bool diff --git a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/DecideRecruitMyPostApplicationUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/DecideRecruitApplicantUseCase.swift similarity index 61% rename from Koin/Domain/UseCase/Recruit/RecruitMyPostList/DecideRecruitMyPostApplicationUseCase.swift rename to Koin/Domain/UseCase/Recruit/RecruitMyPostList/DecideRecruitApplicantUseCase.swift index 5ace5c593..4913d1e1f 100644 --- a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/DecideRecruitMyPostApplicationUseCase.swift +++ b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/DecideRecruitApplicantUseCase.swift @@ -1,5 +1,5 @@ // -// DecideRecruitMyPostApplicationUseCase.swift +// DecideRecruitApplicantUseCase.swift // koin // // Created by 홍기정 on 9/28/26. @@ -7,15 +7,15 @@ import Foundation -protocol DecideRecruitMyPostApplicationUseCase { +protocol DecideRecruitApplicantUseCase { func execute( recruitmentId: Int, applicationId: Int, - decision: RecruitApplicationDecision + decision: RecruitApplicantDecision ) async throws -> Void } -final class DefaultDecideRecruitMyPostApplicationUseCase: DecideRecruitMyPostApplicationUseCase { +final class DefaultDecideRecruitApplicantUseCase: DecideRecruitApplicantUseCase { private let repository: RecruitRepository @@ -26,9 +26,9 @@ final class DefaultDecideRecruitMyPostApplicationUseCase: DecideRecruitMyPostApp func execute( recruitmentId: Int, applicationId: Int, - decision: RecruitApplicationDecision + decision: RecruitApplicantDecision ) async throws -> Void { - try await repository.decideMyPostApplication( + try await repository.decideApplicant( recruitmentId: recruitmentId, applicationId: applicationId, decision: decision diff --git a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostApplicantUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitApplicantUseCase.swift similarity index 58% rename from Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostApplicantUseCase.swift rename to Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitApplicantUseCase.swift index b824ee340..cbc7f30c9 100644 --- a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostApplicantUseCase.swift +++ b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitApplicantUseCase.swift @@ -1,5 +1,5 @@ // -// FetchRecruitMyPostApplicationUseCase.swift +// FetchRecruitApplicantUseCase.swift // koin // // Created by 홍기정 on 9/28/26. @@ -7,14 +7,14 @@ import Foundation -protocol FetchRecruitMyPostApplicationUseCase { +protocol FetchRecruitApplicantUseCase { func execute( recruitmentId: Int, applicationId: Int - ) async throws -> RecruitApplication + ) async throws -> RecruitApplicantData } -final class DefaultFetchRecruitMyPostApplicationUseCase: FetchRecruitMyPostApplicationUseCase { +final class DefaultFetchRecruitApplicantUseCase: FetchRecruitApplicantUseCase { private let repository: RecruitRepository @@ -25,8 +25,8 @@ final class DefaultFetchRecruitMyPostApplicationUseCase: FetchRecruitMyPostAppli func execute( recruitmentId: Int, applicationId: Int - ) async throws -> RecruitApplication { - try await repository.fetchMyPostApplication( + ) async throws -> RecruitApplicantData { + try await repository.fetchApplicant( recruitmentId: recruitmentId, applicationId: applicationId ) diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift index b31b749e6..427fc730c 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift @@ -13,7 +13,7 @@ extension RecruitMyApplicationFilter { FilterGroupModel( title: "지원 상태", hasAllButton: true, - items: RecruitMyApplicationStatus.allCases.map(\.rawValue), + items: RecruitApplicationStatus.allCases.map(\.rawValue), behavior: .single ), FilterGroupModel( @@ -28,7 +28,7 @@ extension RecruitMyApplicationFilter { ] if let status, - let statusIndex = RecruitMyApplicationStatus.allCases.firstIndex(of: status) { + let statusIndex = RecruitApplicationStatus.allCases.firstIndex(of: status) { groupModels[0].didTap(itemAt: statusIndex + 1) } groupModels[1].didTap(itemAt: sort.index) @@ -41,7 +41,7 @@ extension RecruitMyApplicationFilter { return nil } - status = RecruitMyApplicationStatus( + status = RecruitApplicationStatus( rawValue: groupModels[0].selectedItems.first?.title ?? "" ) self.sort = sort diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift index 8effd64ed..754a0e6a3 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift @@ -47,8 +47,8 @@ final class RecruitMyPostHostingController: UIHostingControllerVoid let onDirectChatTapped: ()->Void - let model: RecruitApplicationSummary + let model: RecruitApplicantRow init( - model: RecruitApplicationSummary, + model: RecruitApplicantRow, onApplicationTapped: @escaping () -> Void, onDirectChatTapped: @escaping () -> Void ) { diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift index 2dedffa03..4be60b355 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift @@ -40,7 +40,7 @@ final class RecruitMyPostApplicantHostingController: UIHostingController some View { + private func contentView(_ application: RecruitApplicantData) -> some View { ScrollView { VStack(alignment: .leading, spacing: 24) { RecruitMyPostApplicantProfileView(application: application) diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift index 03781e222..5a3bab58f 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift @@ -14,27 +14,27 @@ final class RecruitMyPostApplicantViewModel: SwiftUIViewModelProtocol { enum Input { case onFirstAppear - case decide(RecruitApplicationDecision) + case decide(RecruitApplicantDecision) case didShowToast } // MARK: - State - private(set) var application: RecruitApplication? + private(set) var application: RecruitApplicantData? private(set) var isLoading = false private(set) var errorMessage: String? // MARK: - Properties let recruitmentId: Int private let applicationId: Int - private let fetchUseCase: FetchRecruitMyPostApplicationUseCase - private let decideUseCase: DecideRecruitMyPostApplicationUseCase + private let fetchUseCase: FetchRecruitApplicantUseCase + private let decideUseCase: DecideRecruitApplicantUseCase // MARK: - Initializer init( recruitmentId: Int, applicationId: Int, - fetchUseCase: FetchRecruitMyPostApplicationUseCase, - decideUseCase: DecideRecruitMyPostApplicationUseCase + fetchUseCase: FetchRecruitApplicantUseCase, + decideUseCase: DecideRecruitApplicantUseCase ) { self.recruitmentId = recruitmentId self.applicationId = applicationId @@ -83,7 +83,7 @@ extension RecruitMyPostApplicantViewModel { } } - private func decideApplication(_ decision: RecruitApplicationDecision) { + private func decideApplication(_ decision: RecruitApplicantDecision) { guard !isLoading, let application, application.canDecide else { return } diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantBottomActionView.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantBottomActionView.swift index 24c150b5e..e5d4639b8 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantBottomActionView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantBottomActionView.swift @@ -9,9 +9,9 @@ import SwiftUI struct RecruitMyPostApplicantBottomActionView: View { - let application: RecruitApplication + let application: RecruitApplicantData let isLoading: Bool - let onDecisionTapped: (RecruitApplicationDecision) -> Void + let onDecisionTapped: (RecruitApplicantDecision) -> Void let onDirectChatTapped: () -> Void @ViewBuilder diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantProfileView.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantProfileView.swift index 5de241840..467f014f0 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantProfileView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/Subviews/RecruitMyPostApplicantProfileView.swift @@ -9,7 +9,7 @@ import SwiftUI struct RecruitMyPostApplicantProfileView: View { - let application: RecruitApplication + let application: RecruitApplicantData var body: some View { HStack(spacing: 12) { diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 6dd2839ab..a8e21245f 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -489,10 +489,10 @@ 7CDCD0E2305602F6004204DA /* RecruitProfileActivityRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E1305602F6004204DA /* RecruitProfileActivityRequest.swift */; }; 7CDCD0E430560758004204DA /* BasicInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E330560758004204DA /* BasicInfo.swift */; }; 7CDCD0E6305695E8004204DA /* RecruitSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDCD0E5305695E8004204DA /* RecruitSectionHeaderView.swift */; }; - 7CE13418306A2CBF0011B052 /* FetchRecruitMyPostApplicantUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13417306A2CBF0011B052 /* FetchRecruitMyPostApplicantUseCase.swift */; }; - 7CE1341A306A2D100011B052 /* RecruitApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13419306A2D100011B052 /* RecruitApplication.swift */; }; - 7CE1341C306A2F930011B052 /* DecideRecruitMyPostApplicationUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE1341B306A2F930011B052 /* DecideRecruitMyPostApplicationUseCase.swift */; }; - 7CE1341E306A2FA30011B052 /* RecruitApplicationDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE1341D306A2FA30011B052 /* RecruitApplicationDecision.swift */; }; + 7CE13418306A2CBF0011B052 /* FetchRecruitApplicantUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13417306A2CBF0011B052 /* FetchRecruitApplicantUseCase.swift */; }; + 7CE1341A306A2D100011B052 /* RecruitApplicantData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13419306A2D100011B052 /* RecruitApplicantData.swift */; }; + 7CE1341C306A2F930011B052 /* DecideRecruitApplicantUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE1341B306A2F930011B052 /* DecideRecruitApplicantUseCase.swift */; }; + 7CE1341E306A2FA30011B052 /* RecruitApplicantDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE1341D306A2FA30011B052 /* RecruitApplicantDecision.swift */; }; 7CE13420306A40000011B052 /* RecruitMyPostApplicantViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE1341F306A40000011B052 /* RecruitMyPostApplicantViewModel.swift */; }; 7CE13422306A40000011B052 /* RecruitMyPostApplicantView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13421306A40000011B052 /* RecruitMyPostApplicantView.swift */; }; 7CE13424306A40000011B052 /* RecruitMyPostApplicantHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13423306A40000011B052 /* RecruitMyPostApplicantHostingController.swift */; }; @@ -728,7 +728,7 @@ 7CED92472EF3DC2800457128 /* OrderCategoryCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED92432EF3DC2800457128 /* OrderCategoryCollectionView.swift */; }; 7CF1D58B30695E7900295ED9 /* RecruitMyPostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D58A30695E7900295ED9 /* RecruitMyPostViewModel.swift */; }; 7CF1D58D30695E8A00295ED9 /* RecruitMyPostHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D58C30695E8A00295ED9 /* RecruitMyPostHostingController.swift */; }; - 7CF1D58F3069772700295ED9 /* RecruitApplicationSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D58E3069772700295ED9 /* RecruitApplicationSummary.swift */; }; + 7CF1D58F3069772700295ED9 /* RecruitApplicantRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D58E3069772700295ED9 /* RecruitApplicantRow.swift */; }; 7CF1D5913069772700295ED9 /* FetchRecruitMyPostUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D5903069772700295ED9 /* FetchRecruitMyPostUseCase.swift */; }; 7CF77B3A300254BE003642C1 /* ProfileHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF77B39300254BE003642C1 /* ProfileHostingController.swift */; }; 7CF77B3C300254C8003642C1 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF77B3B300254C8003642C1 /* ProfileView.swift */; }; @@ -922,7 +922,7 @@ CAFE20000000000000000001 /* RecruitMyApplicationList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000001 /* RecruitMyApplicationList.swift */; }; CAFE20000000000000000002 /* RecruitMyApplicationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000002 /* RecruitMyApplicationRow.swift */; }; CAFE20000000000000000003 /* RecruitMyApplicationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */; }; - CAFE20000000000000000004 /* RecruitMyApplicationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000004 /* RecruitMyApplicationStatus.swift */; }; + CAFE20000000000000000004 /* RecruitApplicationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000004 /* RecruitApplicationStatus.swift */; }; CAFE20000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift */; }; CAFE20000000000000000006 /* RecruitMyApplicationFilter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000006 /* RecruitMyApplicationFilter+.swift */; }; CAFE20000000000000000007 /* RecruitMyApplicationListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000007 /* RecruitMyApplicationListViewModel.swift */; }; @@ -1676,10 +1676,10 @@ 7CDCD0E1305602F6004204DA /* RecruitProfileActivityRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfileActivityRequest.swift; sourceTree = ""; }; 7CDCD0E330560758004204DA /* BasicInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicInfo.swift; sourceTree = ""; }; 7CDCD0E5305695E8004204DA /* RecruitSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitSectionHeaderView.swift; sourceTree = ""; }; - 7CE13417306A2CBF0011B052 /* FetchRecruitMyPostApplicantUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitMyPostApplicantUseCase.swift; sourceTree = ""; }; - 7CE13419306A2D100011B052 /* RecruitApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplication.swift; sourceTree = ""; }; - 7CE1341B306A2F930011B052 /* DecideRecruitMyPostApplicationUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecideRecruitMyPostApplicationUseCase.swift; sourceTree = ""; }; - 7CE1341D306A2FA30011B052 /* RecruitApplicationDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplicationDecision.swift; sourceTree = ""; }; + 7CE13417306A2CBF0011B052 /* FetchRecruitApplicantUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitApplicantUseCase.swift; sourceTree = ""; }; + 7CE13419306A2D100011B052 /* RecruitApplicantData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplicantData.swift; sourceTree = ""; }; + 7CE1341B306A2F930011B052 /* DecideRecruitApplicantUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecideRecruitApplicantUseCase.swift; sourceTree = ""; }; + 7CE1341D306A2FA30011B052 /* RecruitApplicantDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplicantDecision.swift; sourceTree = ""; }; 7CE1341F306A40000011B052 /* RecruitMyPostApplicantViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostApplicantViewModel.swift; sourceTree = ""; }; 7CE13421306A40000011B052 /* RecruitMyPostApplicantView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostApplicantView.swift; sourceTree = ""; }; 7CE13423306A40000011B052 /* RecruitMyPostApplicantHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostApplicantHostingController.swift; sourceTree = ""; }; @@ -1914,7 +1914,7 @@ 7CED92442EF3DC2800457128 /* OrderCategoryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderCategoryCollectionViewCell.swift; sourceTree = ""; }; 7CF1D58A30695E7900295ED9 /* RecruitMyPostViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostViewModel.swift; sourceTree = ""; }; 7CF1D58C30695E8A00295ED9 /* RecruitMyPostHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostHostingController.swift; sourceTree = ""; }; - 7CF1D58E3069772700295ED9 /* RecruitApplicationSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplicationSummary.swift; sourceTree = ""; }; + 7CF1D58E3069772700295ED9 /* RecruitApplicantRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplicantRow.swift; sourceTree = ""; }; 7CF1D5903069772700295ED9 /* FetchRecruitMyPostUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitMyPostUseCase.swift; sourceTree = ""; }; 7CF77B39300254BE003642C1 /* ProfileHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHostingController.swift; sourceTree = ""; }; 7CF77B3B300254C8003642C1 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; @@ -2112,7 +2112,7 @@ CAFE10000000000000000001 /* RecruitMyApplicationList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationList.swift; sourceTree = ""; }; CAFE10000000000000000002 /* RecruitMyApplicationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationRow.swift; sourceTree = ""; }; CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationFilter.swift; sourceTree = ""; }; - CAFE10000000000000000004 /* RecruitMyApplicationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationStatus.swift; sourceTree = ""; }; + CAFE10000000000000000004 /* RecruitApplicationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplicationStatus.swift; sourceTree = ""; }; CAFE10000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitMyApplicationListUseCase.swift; sourceTree = ""; }; CAFE10000000000000000006 /* RecruitMyApplicationFilter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecruitMyApplicationFilter+.swift"; sourceTree = ""; }; CAFE10000000000000000007 /* RecruitMyApplicationListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListViewModel.swift; sourceTree = ""; }; @@ -2994,7 +2994,7 @@ CAFE10000000000000000001 /* RecruitMyApplicationList.swift */, CAFE10000000000000000002 /* RecruitMyApplicationRow.swift */, CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */, - CAFE10000000000000000004 /* RecruitMyApplicationStatus.swift */, + CAFE10000000000000000004 /* RecruitApplicationStatus.swift */, 7C50A20A304324F500897A6F /* RecruitNotificationRow.swift */, 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */, 7CAC2DDB304C34E900AC2828 /* RecruitPostRequest.swift */, @@ -3005,9 +3005,9 @@ 7CDCD0E1305602F6004204DA /* RecruitProfileActivityRequest.swift */, 7CDCD0E330560758004204DA /* BasicInfo.swift */, 7C520868306902C10009BDD3 /* RecruitApplyRequest.swift */, - 7CF1D58E3069772700295ED9 /* RecruitApplicationSummary.swift */, - 7CE13419306A2D100011B052 /* RecruitApplication.swift */, - 7CE1341D306A2FA30011B052 /* RecruitApplicationDecision.swift */, + 7CF1D58E3069772700295ED9 /* RecruitApplicantRow.swift */, + 7CE13419306A2D100011B052 /* RecruitApplicantData.swift */, + 7CE1341D306A2FA30011B052 /* RecruitApplicantDecision.swift */, A2C110000000000000000002 /* RecruitChatData.swift */, A2C110000000000000000004 /* RecruitChatMessageList.swift */, 7CE13431306C01BC0011B052 /* RecruitChatMessage.swift */, @@ -5551,8 +5551,8 @@ BEEF1000000000000000000C /* CloseRecruitMyPostUseCase.swift */, 7CF1D5903069772700295ED9 /* FetchRecruitMyPostUseCase.swift */, BEEF10000000000000000004 /* FetchRecruitMyPostListUseCase.swift */, - 7CE13417306A2CBF0011B052 /* FetchRecruitMyPostApplicantUseCase.swift */, - 7CE1341B306A2F930011B052 /* DecideRecruitMyPostApplicationUseCase.swift */, + 7CE13417306A2CBF0011B052 /* FetchRecruitApplicantUseCase.swift */, + 7CE1341B306A2F930011B052 /* DecideRecruitApplicantUseCase.swift */, ); path = RecruitMyPostList; sourceTree = ""; @@ -6913,13 +6913,13 @@ CAFE20000000000000000001 /* RecruitMyApplicationList.swift in Sources */, CAFE20000000000000000002 /* RecruitMyApplicationRow.swift in Sources */, CAFE20000000000000000003 /* RecruitMyApplicationFilter.swift in Sources */, - CAFE20000000000000000004 /* RecruitMyApplicationStatus.swift in Sources */, + CAFE20000000000000000004 /* RecruitApplicationStatus.swift in Sources */, CAFE20000000000000000005 /* FetchRecruitMyApplicationListUseCase.swift in Sources */, CAFE20000000000000000006 /* RecruitMyApplicationFilter+.swift in Sources */, CAFE20000000000000000007 /* RecruitMyApplicationListViewModel.swift in Sources */, CAFE20000000000000000008 /* RecruitMyApplicationListView.swift in Sources */, CAFE20000000000000000009 /* RecruitMyApplicationListHostingController.swift in Sources */, - 7CE1341E306A2FA30011B052 /* RecruitApplicationDecision.swift in Sources */, + 7CE1341E306A2FA30011B052 /* RecruitApplicantDecision.swift in Sources */, 7CE13420306A40000011B052 /* RecruitMyPostApplicantViewModel.swift in Sources */, 7CE13422306A40000011B052 /* RecruitMyPostApplicantView.swift in Sources */, 7CE13424306A40000011B052 /* RecruitMyPostApplicantHostingController.swift in Sources */, @@ -7027,7 +7027,7 @@ 7CA63A332F1B1B0C00226F86 /* LostItemListFilterViewController.swift in Sources */, 7CA63A302F1B13F100226F86 /* LostItemStats.swift in Sources */, 7C8A942F2FD2C15400DEA6F5 /* ShopEventCountDto.swift in Sources */, - 7CF1D58F3069772700295ED9 /* RecruitApplicationSummary.swift in Sources */, + 7CF1D58F3069772700295ED9 /* RecruitApplicantRow.swift in Sources */, 7CF1D5913069772700295ED9 /* FetchRecruitMyPostUseCase.swift in Sources */, D27DD0CF2BA608310081FD36 /* CollectionViewDelegate.swift in Sources */, D22C545A2C68B033000826DA /* FetchShopReviewRequest.swift in Sources */, @@ -7430,7 +7430,7 @@ 7C016576300BD9610013DD7B /* FetchDepartmentByCategoryUseCase.swift in Sources */, 7CED91C92EF2D1F900457128 /* CategoryCollectionView.swift in Sources */, 7CED91CB2EF2D1F900457128 /* ShopSummaryImagesCollectionViewCell.swift in Sources */, - 7CE1341A306A2D100011B052 /* RecruitApplication.swift in Sources */, + 7CE1341A306A2D100011B052 /* RecruitApplicantData.swift in Sources */, 7CED91CD2EF2D1F900457128 /* LostItemChatViewModel.swift in Sources */, 7C640400300F395300A9589F /* DepartmentRepository.swift in Sources */, 7CED91CE2EF2D1F900457128 /* NotiViewModel.swift in Sources */, @@ -7708,7 +7708,7 @@ 7CA63A272F1AF80000226F86 /* LostItemListViewModel.swift in Sources */, D2FB23712C0412C70098BA2B /* EventParameter.swift in Sources */, D2CA2DD22C1CAE4E0036F02C /* ShopEvent.swift in Sources */, - 7CE1341C306A2F930011B052 /* DecideRecruitMyPostApplicationUseCase.swift in Sources */, + 7CE1341C306A2F930011B052 /* DecideRecruitApplicantUseCase.swift in Sources */, D22C54832C6A59C4000826DA /* UploadFileUseCase.swift in Sources */, D208A7AE2CA117AA007040E7 /* AbTestAPI.swift in Sources */, D208A7CB2CABB755007040E7 /* CoreService.swift in Sources */, @@ -7735,7 +7735,7 @@ D29F21CC2C4E157E00994554 /* FetchDeptListUseCase.swift in Sources */, D208A7A62CA111EB007040E7 /* AssignAbTestRequest.swift in Sources */, D223D61B2D9C0FEC00C29BE6 /* FetchBannerUseCase.swift in Sources */, - 7CE13418306A2CBF0011B052 /* FetchRecruitMyPostApplicantUseCase.swift in Sources */, + 7CE13418306A2CBF0011B052 /* FetchRecruitApplicantUseCase.swift in Sources */, D21572662CEDDDC60061E725 /* FetchMySemesterUseCase.swift in Sources */, 7CACF87030440CEE00AC2828 /* MarkAsReadRecruitNotificationUseCase.swift in Sources */, B68D2CEB2EA50C1E00F3B479 /* UIViewController+Toast.swift in Sources */, From 82ff95b8d278559e821e9d6e0be56610553f7447 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Fri, 2 Oct 2026 00:24:22 +0900 Subject: [PATCH 053/123] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EB=AA=A8=EB=8D=B8=20RecruitMyApplicati?= =?UTF-8?q?on=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecruitMyApplication { id, status } 신설 - RecruitData 에 application(조회자 본인 지원 정보) 추가 - RecruitMyApplicationRow 의 status 를 application 으로 교체 --- Koin/Data/Repository/MockRecruitRepository.swift | 8 ++++++-- Koin/Domain/Model/Recruit/RecruitData.swift | 3 +++ .../Model/Recruit/RecruitMyApplication.swift | 13 +++++++++++++ .../Model/Recruit/RecruitMyApplicationRow.swift | 2 +- .../Subviews/RecruitMyApplicationListRowView.swift | 4 ++-- koin.xcodeproj/project.pbxproj | 14 +++++++++----- 6 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 Koin/Domain/Model/Recruit/RecruitMyApplication.swift diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 7a8f82972..b94572248 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -467,13 +467,16 @@ final class MockRecruitRepository: RecruitRepository { maximumParticipants: summary.maximumParticipants, type: summary.type, roles: summary.roles, - status: statuses[index % statuses.count], + application: RecruitMyApplication( + id: summary.id * 100 + index, + status: statuses[index % statuses.count] + ), chatRoomId: summary.id.isMultiple(of: 2) ? summary.id : nil ) } if let status = filter.status { - recruits = recruits.filter { $0.status == status } + recruits = recruits.filter { $0.application.status == status } } if filter.sort == .deadlineAscending { recruits.sort { $0.deadline < $1.deadline } @@ -521,6 +524,7 @@ final class MockRecruitRepository: RecruitRepository { description: "소개소개소개소개소개소개소개소개소개소개소개소개소개소개", relatedUrl: URL(string: "https://bcsdlab.com"), qualification: "2학년이상\n참여율 높은 사람\n@@@", + application: nil, isAuthor: UserDataManager.shared.isLoggedIn && item.id % 2 == 0, canApply: true, applyBlockReason: nil, diff --git a/Koin/Domain/Model/Recruit/RecruitData.swift b/Koin/Domain/Model/Recruit/RecruitData.swift index c9c3fb080..13b3ef60a 100644 --- a/Koin/Domain/Model/Recruit/RecruitData.swift +++ b/Koin/Domain/Model/Recruit/RecruitData.swift @@ -31,6 +31,8 @@ struct RecruitData: Identifiable, Equatable { let relatedUrl: URL? let qualification: String? + let application: RecruitMyApplication? + let isAuthor: Bool let canApply: Bool let applyBlockReason: String? @@ -60,6 +62,7 @@ extension RecruitData { description: description, relatedUrl: relatedUrl, qualification: qualification, + application: application, isAuthor: isAuthor, canApply: false, applyBlockReason: "이미 지원한 모집글입니다.", diff --git a/Koin/Domain/Model/Recruit/RecruitMyApplication.swift b/Koin/Domain/Model/Recruit/RecruitMyApplication.swift new file mode 100644 index 000000000..0b42925f8 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitMyApplication.swift @@ -0,0 +1,13 @@ +// +// RecruitMyApplication.swift +// koin +// +// Created by 홍기정 on 10/1/26. +// + +import Foundation + +struct RecruitMyApplication: Equatable { + let id: Int + let status: RecruitApplicationStatus +} diff --git a/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift b/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift index 16e5766cb..83819a863 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift @@ -25,6 +25,6 @@ struct RecruitMyApplicationRow: Identifiable, Equatable { let type: RecruitRoleType let roles: [RecruitRole] - let status: RecruitApplicationStatus + let application: RecruitMyApplication let chatRoomId: Int? } diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift index ae28128d7..2f93da87b 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift @@ -53,9 +53,9 @@ extension RecruitMyApplicationListRowView { Spacer() - Text(model.status.rawValue) + Text(model.application.status.rawValue) .font(.appFont(.pretendardMedium, size: 10)) - .foregroundStyle(Color.appColor(model.status.textColor)) + .foregroundStyle(Color.appColor(model.application.status.textColor)) .padding(.trailing, 8) } } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index a8e21245f..5b71de214 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -851,6 +851,7 @@ A51C00000000000000000004 /* RecruitProfilePostActivityTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B00000000000000000004 /* RecruitProfilePostActivityTableView.swift */; }; A51C00000000000000000005 /* RecruitProfilePostActivityDisplayTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B00000000000000000005 /* RecruitProfilePostActivityDisplayTableViewCell.swift */; }; A51C00000000000000000006 /* RecruitProfilePostActivityEditTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B00000000000000000006 /* RecruitProfilePostActivityEditTableViewCell.swift */; }; + A5F8FD7A714E689BC4F3321B /* RecruitMyApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645CEE62E516B130B4E2D784 /* RecruitMyApplication.swift */; }; AA20000000000000000001 /* Publisher+.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000001 /* Publisher+.swift */; }; AA20000000000000000002 /* DiningFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000002 /* DiningFixtures.swift */; }; AA20000000000000000003 /* SpyDiningRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000003 /* SpyDiningRepository.swift */; }; @@ -1221,6 +1222,7 @@ 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyRecruitUseCase.swift; sourceTree = ""; }; 54351A8A0C1A4046C3155481 /* RecruitPostTextViewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostTextViewView.swift; sourceTree = ""; }; 5CBC0EF8E0FE44F48744BEC7 /* RecruitDataHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataHeaderView.swift; sourceTree = ""; }; + 645CEE62E516B130B4E2D784 /* RecruitMyApplication.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyApplication.swift; sourceTree = ""; }; 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataContentView.swift; sourceTree = ""; }; 73F70BC5F3AA1A34663C32CA /* RecruitPostMeetingTypeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostMeetingTypeView.swift; sourceTree = ""; }; 7C01653F300A88940013DD7B /* ShuttleTicketViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShuttleTicketViewController.swift; sourceTree = ""; }; @@ -2984,21 +2986,21 @@ 7C50A1D43041F8D500897A6F /* RecruitMeetingType.swift */, 7C50A1E13042C82F00897A6F /* RecruitRoleType.swift */, 7C50A1E33042C97F00897A6F /* RecruitRole.swift */, + 7C50A1FE3042E9DC00897A6F /* RecruitList.swift */, 7C50A1F33042CB4100897A6F /* RecruitListSort.swift */, 7C50A1CB3041E29000897A6F /* RecruitListFilter.swift */, 7C50A1E23041F91400897A6F /* RecruitRow.swift */, - 7C50A1FE3042E9DC00897A6F /* RecruitList.swift */, BEEF10000000000000000001 /* RecruitMyPostList.swift */, - BEEF10000000000000000002 /* RecruitMyPostRow.swift */, BEEF10000000000000000003 /* RecruitMyPostFilter.swift */, + BEEF10000000000000000002 /* RecruitMyPostRow.swift */, CAFE10000000000000000001 /* RecruitMyApplicationList.swift */, - CAFE10000000000000000002 /* RecruitMyApplicationRow.swift */, CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */, + CAFE10000000000000000002 /* RecruitMyApplicationRow.swift */, CAFE10000000000000000004 /* RecruitApplicationStatus.swift */, - 7C50A20A304324F500897A6F /* RecruitNotificationRow.swift */, 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */, - 7CAC2DDB304C34E900AC2828 /* RecruitPostRequest.swift */, + 7C50A20A304324F500897A6F /* RecruitNotificationRow.swift */, 7C50A1D63041F91400897A6F /* RecruitData.swift */, + 7CAC2DDB304C34E900AC2828 /* RecruitPostRequest.swift */, C0DE00000000000000000002 /* RecruitProfile.swift */, 7CDCD0D73055EECF004204DA /* RecruitProfileActivity.swift */, 7CDCD0DF305602A2004204DA /* RecruitProfileRequest.swift */, @@ -3012,6 +3014,7 @@ A2C110000000000000000004 /* RecruitChatMessageList.swift */, 7CE13431306C01BC0011B052 /* RecruitChatMessage.swift */, A2C110000000000000000006 /* RecruitChatPostRequest.swift */, + 645CEE62E516B130B4E2D784 /* RecruitMyApplication.swift */, ); path = Recruit; sourceTree = ""; @@ -7797,6 +7800,7 @@ A51C00000000000000000005 /* RecruitProfilePostActivityDisplayTableViewCell.swift in Sources */, A51C00000000000000000006 /* RecruitProfilePostActivityEditTableViewCell.swift in Sources */, B154A7D97E6AAEC2ED3D8119 /* MockFetchDeptListUseCase.swift in Sources */, + A5F8FD7A714E689BC4F3321B /* RecruitMyApplication.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 649be85f81fc2f3fda99bbaf35852358d052e16b Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Fri, 2 Oct 2026 00:25:10 +0900 Subject: [PATCH 054/123] =?UTF-8?q?refactor:=20=EB=82=B4=20=EA=B8=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=AA=A8=EB=8D=B8=20RecruitMyPostData=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지원자 목록 화면 전용 RecruitMyPostData 신설 (모집글 요약 + applicants + totalCount) - RecruitMyPostRow 에서 미사용 applications 제거 - fetchMyPost → fetchMyPostData, FetchRecruitMyPostUseCase → FetchRecruitMyPostDataUseCase - 지원자 수 표시를 totalCount 기준으로 변경 --- .../Repository/MockRecruitRepository.swift | 28 ++++++++-------- .../Model/Recruit/RecruitMyPostData.swift | 32 +++++++++++++++++++ .../Model/Recruit/RecruitMyPostRow.swift | 2 -- .../Domain/Repository/RecruitRepository.swift | 2 +- .../FetchRecruitMyPostDataUseCase.swift | 25 +++++++++++++++ .../FetchRecruitMyPostUseCase.swift | 25 --------------- .../RecruitDataHostingController.swift | 4 +-- .../RecruitMyPost/RecruitMyPostView.swift | 8 ++--- .../RecruitMyPostViewModel.swift | 10 +++--- .../Subviews/RecruitMyPostSummaryView.swift | 2 +- .../RecruitMyPostListHostingController.swift | 4 +-- koin.xcodeproj/project.pbxproj | 12 ++++--- 12 files changed, 94 insertions(+), 60 deletions(-) create mode 100644 Koin/Domain/Model/Recruit/RecruitMyPostData.swift create mode 100644 Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostDataUseCase.swift delete mode 100644 Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostUseCase.swift diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index b94572248..ab43b1165 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -285,11 +285,7 @@ final class MockRecruitRepository: RecruitRepository { roles: summary.roles, state: summary.state, canClose: summary.id != 3, - chatRoomId: summary.id.isMultiple(of: 2) ? summary.id : nil, - applications: mockApplications( - recruitId: summary.id, - includesRole: summary.type == .roleBased - ) + chatRoomId: summary.id.isMultiple(of: 2) ? summary.id : nil ) } @@ -315,12 +311,16 @@ final class MockRecruitRepository: RecruitRepository { ) } - func fetchMyPost(_ id: Int) async throws -> RecruitMyPostRow { + func fetchMyPostData(_ id: Int) async throws -> RecruitMyPostData { let response = try await fetchMyPostList(RecruitMyPostFilter()) guard let recruit = response.recruits.first(where: { $0.id == id }) else { throw ErrorResponse.unexpectedInternalError } - return RecruitMyPostRow( + let applicants = mockApplicants( + recruitId: recruit.id, + includesRole: recruit.type == .roleBased + ) + return RecruitMyPostData( id: recruit.id, category: recruit.category, title: recruit.title, @@ -334,9 +334,9 @@ final class MockRecruitRepository: RecruitRepository { type: recruit.type, roles: recruit.roles, state: recruit.state, - canClose: recruit.canClose, chatRoomId: recruit.chatRoomId ?? recruit.id, - applications: recruit.applications + applicants: applicants, + totalCount: applicants.count ) } @@ -346,8 +346,8 @@ final class MockRecruitRepository: RecruitRepository { ) async throws -> RecruitApplicantData { try await Task.sleep(nanoseconds: 300_000_000) - let recruitment = try await fetchMyPost(recruitmentId) - guard let application = recruitment.applications.first(where: { + let recruitment = try await fetchMyPostData(recruitmentId) + guard let application = recruitment.applicants.first(where: { $0.applicationId == applicationId }) else { throw ErrorResponse.unexpectedInternalError @@ -395,8 +395,8 @@ final class MockRecruitRepository: RecruitRepository { applicationId: Int, decision: RecruitApplicantDecision ) async throws -> Void { - let recruitment = try await fetchMyPost(recruitmentId) - guard recruitment.applications.contains(where: { + let recruitment = try await fetchMyPostData(recruitmentId) + guard recruitment.applicants.contains(where: { $0.applicationId == applicationId && $0.status == .pending }) else { throw ErrorResponse.unexpectedInternalError @@ -404,7 +404,7 @@ final class MockRecruitRepository: RecruitRepository { try await Task.sleep(nanoseconds: 300_000_000) } - private func mockApplications( + private func mockApplicants( recruitId: Int, includesRole: Bool ) -> [RecruitApplicantRow] { diff --git a/Koin/Domain/Model/Recruit/RecruitMyPostData.swift b/Koin/Domain/Model/Recruit/RecruitMyPostData.swift new file mode 100644 index 000000000..824644cd0 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitMyPostData.swift @@ -0,0 +1,32 @@ +// +// RecruitMyPostData.swift +// koin +// +// Created by 홍기정 on 10/1/26. +// + +import Foundation + +struct RecruitMyPostData: Identifiable, Equatable { + let id: Int + let category: RecruitCategory + let title: String + let meetingType: RecruitMeetingType + + let startDate: Date + let endDate: Date + let deadline: Date + let dDay: String + + let currentParticipants: Int + let maximumParticipants: Int + + let type: RecruitRoleType + let roles: [RecruitRole] + + let state: RecruitState + let chatRoomId: Int? + + let applicants: [RecruitApplicantRow] + let totalCount: Int +} diff --git a/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift b/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift index d4c01f9cd..a354f60d0 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift @@ -27,6 +27,4 @@ struct RecruitMyPostRow: Identifiable, Equatable { let state: RecruitState let canClose: Bool? let chatRoomId: Int? - - let applications: [RecruitApplicantRow] } diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index 4fb91f08c..28e38efea 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -27,7 +27,7 @@ protocol RecruitRepository { ) async throws func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList - func fetchMyPost(_ id: Int) async throws -> RecruitMyPostRow + func fetchMyPostData(_ id: Int) async throws -> RecruitMyPostData func fetchApplicant( recruitmentId: Int, applicationId: Int diff --git a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostDataUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostDataUseCase.swift new file mode 100644 index 000000000..f50426f68 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostDataUseCase.swift @@ -0,0 +1,25 @@ +// +// FetchRecruitMyPostDataUseCase.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +protocol FetchRecruitMyPostDataUseCase { + func execute(id: Int) async throws -> RecruitMyPostData +} + +final class DefaultFetchRecruitMyPostDataUseCase: FetchRecruitMyPostDataUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(id: Int) async throws -> RecruitMyPostData { + try await repository.fetchMyPostData(id) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostUseCase.swift deleted file mode 100644 index ef9f72b94..000000000 --- a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostUseCase.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// FetchRecruitMyPostUseCase.swift -// koin -// -// Created by 홍기정 on 9/28/26. -// - -import Foundation - -protocol FetchRecruitMyPostUseCase { - func execute(id: Int) async throws -> RecruitMyPostRow -} - -final class DefaultFetchRecruitMyPostUseCase: FetchRecruitMyPostUseCase { - - private let repository: RecruitRepository - - init(repository: RecruitRepository) { - self.repository = repository - } - - func execute(id: Int) async throws -> RecruitMyPostRow { - try await repository.fetchMyPost(id) - } -} diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index 4e31f1464..449a1ee80 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -129,9 +129,9 @@ extension RecruitDataHostingController { return } let repository = MockRecruitRepository() - let fetchRecruitMyPostUseCase = DefaultFetchRecruitMyPostUseCase(repository: repository) + let fetchRecruitMyPostDataUseCase = DefaultFetchRecruitMyPostDataUseCase(repository: repository) let viewModel = RecruitMyPostViewModel( - fetchRecruitMyPostUseCase: fetchRecruitMyPostUseCase, + fetchRecruitMyPostDataUseCase: fetchRecruitMyPostDataUseCase, recruitId: id ) let controller = RecruitMyPostHostingController( diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift index ebc23b4ee..4646c42fc 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift @@ -49,7 +49,7 @@ struct RecruitMyPostView: ActionBindableView { } extension RecruitMyPostView { - private func contentView(data: RecruitMyPostRow) -> some View { + private func contentView(data: RecruitMyPostData) -> some View { GeometryReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 0) { @@ -62,7 +62,7 @@ extension RecruitMyPostView { .padding(.top, 24) .padding(.leading, 4) - if data.applications.isEmpty { + if data.applicants.isEmpty { Spacer(minLength: 0) RecruitMyPostEmptyView() @@ -71,7 +71,7 @@ extension RecruitMyPostView { Spacer(minLength: 0) } else { LazyVStack(spacing: 12) { - ForEach(data.applications) { applicant in + ForEach(data.applicants) { applicant in RecruitMyPostApplicantRowView( model: applicant, onApplicationTapped: { @@ -108,7 +108,7 @@ extension RecruitMyPostView { .foregroundStyle(Color.appColor(.neutral800)) .frame(height: 26) - Text("총 \(viewModel.data?.applications.count ?? 0)명") + Text("총 \(viewModel.data?.totalCount ?? 0)명") .font(.appFont(.pretendardRegular, size: 12)) .foregroundStyle(Color.appColor(.neutral500)) .frame(height: 19) diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift index e316ab569..ce7f78011 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift @@ -18,20 +18,20 @@ final class RecruitMyPostViewModel: SwiftUIViewModelProtocol { } // MARK: - State - private(set) var data: RecruitMyPostRow? + private(set) var data: RecruitMyPostData? private(set) var isLoading = false private(set) var errorMessage: String? // MARK: - Properties private let recruitId: Int - private let fetchRecruitMyPostUseCase: FetchRecruitMyPostUseCase + private let fetchRecruitMyPostDataUseCase: FetchRecruitMyPostDataUseCase // MARK: - Initializer init( - fetchRecruitMyPostUseCase: FetchRecruitMyPostUseCase, + fetchRecruitMyPostDataUseCase: FetchRecruitMyPostDataUseCase, recruitId: Int ) { - self.fetchRecruitMyPostUseCase = fetchRecruitMyPostUseCase + self.fetchRecruitMyPostDataUseCase = fetchRecruitMyPostDataUseCase self.recruitId = recruitId } @@ -59,7 +59,7 @@ extension RecruitMyPostViewModel { } do { - let data = try await fetchRecruitMyPostUseCase.execute(id: recruitId) + let data = try await fetchRecruitMyPostDataUseCase.execute(id: recruitId) try Task.checkCancellation() self.data = data } catch is CancellationError { diff --git a/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift index b920a593a..4adc2d95f 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift @@ -9,7 +9,7 @@ import SwiftUI struct RecruitMyPostSummaryView: View { - let model: RecruitMyPostRow + let model: RecruitMyPostData let onGroupChatTapped: ()->Void var body: some View { diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift index c81a3ef09..8e98ff5d4 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift @@ -80,9 +80,9 @@ extension RecruitMyPostListHostingController: RecruitDataHostingControllerDelega private func showApplicants(recruitId: Int) { let repository = MockRecruitRepository() - let fetchRecruitMyPostUseCase = DefaultFetchRecruitMyPostUseCase(repository: repository) + let fetchRecruitMyPostDataUseCase = DefaultFetchRecruitMyPostDataUseCase(repository: repository) let viewModel = RecruitMyPostViewModel( - fetchRecruitMyPostUseCase: fetchRecruitMyPostUseCase, + fetchRecruitMyPostDataUseCase: fetchRecruitMyPostDataUseCase, recruitId: recruitId ) let controller = RecruitMyPostHostingController( diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 5b71de214..37ff7d1de 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -729,7 +729,7 @@ 7CF1D58B30695E7900295ED9 /* RecruitMyPostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D58A30695E7900295ED9 /* RecruitMyPostViewModel.swift */; }; 7CF1D58D30695E8A00295ED9 /* RecruitMyPostHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D58C30695E8A00295ED9 /* RecruitMyPostHostingController.swift */; }; 7CF1D58F3069772700295ED9 /* RecruitApplicantRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D58E3069772700295ED9 /* RecruitApplicantRow.swift */; }; - 7CF1D5913069772700295ED9 /* FetchRecruitMyPostUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D5903069772700295ED9 /* FetchRecruitMyPostUseCase.swift */; }; + 7CF1D5913069772700295ED9 /* FetchRecruitMyPostDataUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D5903069772700295ED9 /* FetchRecruitMyPostDataUseCase.swift */; }; 7CF77B3A300254BE003642C1 /* ProfileHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF77B39300254BE003642C1 /* ProfileHostingController.swift */; }; 7CF77B3C300254C8003642C1 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF77B3B300254C8003642C1 /* ProfileView.swift */; }; 7CF77B3E300254CD003642C1 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF77B3D300254CD003642C1 /* ProfileViewModel.swift */; }; @@ -826,6 +826,7 @@ 83EBB87A2D0FDFD700346018 /* KoinPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83EBB8792D0FDFD700346018 /* KoinPickerView.swift */; }; 83EBB87E2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83EBB87D2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift */; }; 8A7B1679CC9945559E674584 /* RecruitDataRolesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */; }; + 91E82A6B67BF5BF911535B1C /* RecruitMyPostData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87039C64B7CB2AEB632113C6 /* RecruitMyPostData.swift */; }; 9C95E190C98F440DADB6F91B /* RecruitDataMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DADCC36CEEA42EC86AB75B6 /* RecruitDataMetadataView.swift */; }; A0B343754988C9398C4FE726 /* ModifyRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */; }; A11B00000000000000000001 /* PostBasicInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11B00000000000000000002 /* PostBasicInfoUseCase.swift */; }; @@ -1917,7 +1918,7 @@ 7CF1D58A30695E7900295ED9 /* RecruitMyPostViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostViewModel.swift; sourceTree = ""; }; 7CF1D58C30695E8A00295ED9 /* RecruitMyPostHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostHostingController.swift; sourceTree = ""; }; 7CF1D58E3069772700295ED9 /* RecruitApplicantRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplicantRow.swift; sourceTree = ""; }; - 7CF1D5903069772700295ED9 /* FetchRecruitMyPostUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitMyPostUseCase.swift; sourceTree = ""; }; + 7CF1D5903069772700295ED9 /* FetchRecruitMyPostDataUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitMyPostDataUseCase.swift; sourceTree = ""; }; 7CF77B39300254BE003642C1 /* ProfileHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHostingController.swift; sourceTree = ""; }; 7CF77B3B300254C8003642C1 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; 7CF77B3D300254CD003642C1 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; @@ -2017,6 +2018,7 @@ 83E96DB72BBC6FBF00164914 /* BusSearchDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusSearchDto.swift; sourceTree = ""; }; 83EBB8792D0FDFD700346018 /* KoinPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinPickerView.swift; sourceTree = ""; }; 83EBB87D2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchKoinPickerDateUseCase.swift; sourceTree = ""; }; + 87039C64B7CB2AEB632113C6 /* RecruitMyPostData.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyPostData.swift; sourceTree = ""; }; 8CE127F2AE4B18899F6E5E0D /* RecruitPostScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostScheduleView.swift; sourceTree = ""; }; 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataRolesView.swift; sourceTree = ""; }; A001E2B42845091D00D6C310 /* koin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = koin.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -3015,6 +3017,7 @@ 7CE13431306C01BC0011B052 /* RecruitChatMessage.swift */, A2C110000000000000000006 /* RecruitChatPostRequest.swift */, 645CEE62E516B130B4E2D784 /* RecruitMyApplication.swift */, + 87039C64B7CB2AEB632113C6 /* RecruitMyPostData.swift */, ); path = Recruit; sourceTree = ""; @@ -5552,7 +5555,7 @@ isa = PBXGroup; children = ( BEEF1000000000000000000C /* CloseRecruitMyPostUseCase.swift */, - 7CF1D5903069772700295ED9 /* FetchRecruitMyPostUseCase.swift */, + 7CF1D5903069772700295ED9 /* FetchRecruitMyPostDataUseCase.swift */, BEEF10000000000000000004 /* FetchRecruitMyPostListUseCase.swift */, 7CE13417306A2CBF0011B052 /* FetchRecruitApplicantUseCase.swift */, 7CE1341B306A2F930011B052 /* DecideRecruitApplicantUseCase.swift */, @@ -7031,7 +7034,7 @@ 7CA63A302F1B13F100226F86 /* LostItemStats.swift in Sources */, 7C8A942F2FD2C15400DEA6F5 /* ShopEventCountDto.swift in Sources */, 7CF1D58F3069772700295ED9 /* RecruitApplicantRow.swift in Sources */, - 7CF1D5913069772700295ED9 /* FetchRecruitMyPostUseCase.swift in Sources */, + 7CF1D5913069772700295ED9 /* FetchRecruitMyPostDataUseCase.swift in Sources */, D27DD0CF2BA608310081FD36 /* CollectionViewDelegate.swift in Sources */, D22C545A2C68B033000826DA /* FetchShopReviewRequest.swift in Sources */, 83EBB87E2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift in Sources */, @@ -7801,6 +7804,7 @@ A51C00000000000000000006 /* RecruitProfilePostActivityEditTableViewCell.swift in Sources */, B154A7D97E6AAEC2ED3D8119 /* MockFetchDeptListUseCase.swift in Sources */, A5F8FD7A714E689BC4F3321B /* RecruitMyApplication.swift in Sources */, + 91E82A6B67BF5BF911535B1C /* RecruitMyPostData.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From f3c089f713bfea96c718065ca9640b4f6152dfc2 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Thu, 1 Oct 2026 21:20:17 +0900 Subject: [PATCH 055/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EB=AA=A9=EB=A1=9D=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Extensions/Common/String+.swift | 2 + .../Decodable/Recruit/RecruitEnumDto.swift | 110 ++++++++++++++ .../Decodable/Recruit/RecruitListDto.swift | 35 +++++ .../Recruit/RecruitListSummaryDto.swift | 66 +++++++++ .../Decodable/Recruit/RecruitRoleDto.swift | 35 +++++ .../Recruit/RecruitListRequestDto.swift | 75 ++++++++++ .../Repository/DefaultRecruitRepository.swift | 139 ++++++++++++++++++ .../Repository/MockRecruitRepository.swift | 22 +-- .../Data/Service/Network/API/RecruitAPI.swift | 55 +++++++ Koin/Data/Service/RecruitService.swift | 21 +++ Koin/Domain/Model/Recruit/RecruitData.swift | 2 +- Koin/Domain/Model/Recruit/RecruitRow.swift | 8 +- .../Category/CategoryHostingController.swift | 2 +- .../RecruitDataHostingController.swift | 6 +- .../Subviews/RecruitDataHeaderView.swift | 12 +- .../RecruitListHostingController.swift | 8 +- .../Subviews/RecruitListRowView.swift | 14 +- ...itMyApplicationListHostingController.swift | 4 +- .../RecruitMyPostHostingController.swift | 4 +- ...ruitMyPostApplicantHostingController.swift | 2 +- .../RecruitMyPostListHostingController.swift | 6 +- .../RecruitPostViewController.swift | 4 +- .../RecruitProfileHostingController.swift | 6 +- koin.xcodeproj/project.pbxproj | 48 ++++++ 24 files changed, 642 insertions(+), 44 deletions(-) create mode 100644 Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift create mode 100644 Koin/Data/DTOs/Decodable/Recruit/RecruitListDto.swift create mode 100644 Koin/Data/DTOs/Decodable/Recruit/RecruitListSummaryDto.swift create mode 100644 Koin/Data/DTOs/Decodable/Recruit/RecruitRoleDto.swift create mode 100644 Koin/Data/DTOs/Encodable/Recruit/RecruitListRequestDto.swift create mode 100644 Koin/Data/Repository/DefaultRecruitRepository.swift create mode 100644 Koin/Data/Service/Network/API/RecruitAPI.swift create mode 100644 Koin/Data/Service/RecruitService.swift diff --git a/Koin/Core/Extensions/Common/String+.swift b/Koin/Core/Extensions/Common/String+.swift index e226f405b..05b6b2764 100644 --- a/Koin/Core/Extensions/Common/String+.swift +++ b/Koin/Core/Extensions/Common/String+.swift @@ -267,6 +267,8 @@ extension String { func toDateFromYYYYMMDD() -> Date? { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" + dateFormatter.locale = Locale(identifier: "ko_KR") + dateFormatter.calendar = Calendar(identifier: .gregorian) return dateFormatter.date(from: self) } diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift new file mode 100644 index 000000000..6df49a1ec --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift @@ -0,0 +1,110 @@ +// +// RecruitEnumDto.swift +// koin +// +// Created by 홍기정 on 10/1/26. +// + +import Foundation + +enum RecruitCategoryDto: String, Codable { + case contest = "CONTEST" + case externalActivity = "EXTERNAL_ACTIVITY" + case study = "STUDY" + case project = "PROJECT" + case other = "OTHER" +} + +enum RecruitMeetingTypeDto: String, Codable { + case online = "ONLINE" + case offline = "OFFLINE" + case mixed = "MIXED" +} + +enum RecruitStatusDto: String, Decodable { + case recruiting = "RECRUITING" + case closed = "CLOSED" +} + +enum RecruitRoleTypeDto: String, Decodable { + case roleBased = "ROLE_BASED" + case general = "GENERAL" +} + +extension RecruitCategoryDto { + init(from model: RecruitCategory) { + switch model { + case .contest: + self = .contest + case .externalActivity: + self = .externalActivity + case .study: + self = .study + case .project: + self = .project + case .other: + self = .other + } + } + + func toDomain() -> RecruitCategory { + switch self { + case .contest: + return .contest + case .externalActivity: + return .externalActivity + case .study: + return .study + case .project: + return .project + case .other: + return .other + } + } +} + +extension RecruitMeetingTypeDto { + init(from model: RecruitMeetingType) { + switch model { + case .online: + self = .online + case .offline: + self = .offline + case .mixed: + self = .mixed + } + } + + func toDomain() -> RecruitMeetingType { + switch self { + case .online: + return .online + case .offline: + return .offline + case .mixed: + return .mixed + } + } +} + +extension RecruitStatusDto { + func toDomain() -> RecruitState { + switch self { + case .recruiting: + return .recruiting + case .closed: + return .closed + } + } +} + +extension RecruitRoleTypeDto { + func toDomain() -> RecruitRoleType { + switch self { + case .roleBased: + return .roleBased + case .general: + return .general + } + } +} diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitListDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitListDto.swift new file mode 100644 index 000000000..084834770 --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitListDto.swift @@ -0,0 +1,35 @@ +// +// RecruitListDto.swift +// koin +// +// Created by 홍기정 on 10/1/26. +// + +import Foundation + +struct RecruitListDto: Decodable { + let recruitments: [RecruitListSummaryDto] + let totalCount: Int + let currentCount: Int + let totalPage: Int + let currentPage: Int + + enum CodingKeys: String, CodingKey { + case recruitments + case totalCount = "total_count" + case currentCount = "current_count" + case totalPage = "total_page" + case currentPage = "current_page" + } +} + +extension RecruitListDto { + func toDomain() -> RecruitList { + return RecruitList( + recruits: recruitments.map { $0.toDomain() }, + totalCount: totalCount, + totalPage: totalPage, + currentPage: currentPage + ) + } +} diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitListSummaryDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitListSummaryDto.swift new file mode 100644 index 000000000..458d36f5e --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitListSummaryDto.swift @@ -0,0 +1,66 @@ +// +// RecruitListSummaryDto.swift +// koin +// +// Created by 홍기정 on 10/1/26. +// + +import Foundation + +struct RecruitListSummaryDto: Decodable { + let id: Int + let category: RecruitCategoryDto + let title: String + let meetingType: RecruitMeetingTypeDto + let activityStartDate: String + let activityEndDate: String + let deadlineDate: String + let dDay: Int? + let status: RecruitStatusDto + let recruitmentType: RecruitRoleTypeDto + let currentParticipants: Int + let maxParticipants: Int + let roles: [RecruitRoleDto] + + enum CodingKeys: String, CodingKey { + case id, category, title + case meetingType = "meeting_type" + case activityStartDate = "activity_start_date" + case activityEndDate = "activity_end_date" + case deadlineDate = "deadline_date" + case dDay = "d_day" + case status + case recruitmentType = "recruitment_type" + case currentParticipants = "current_participants" + case maxParticipants = "max_participants" + case roles + } +} + +extension RecruitListSummaryDto { + func toDomain() -> RecruitRow { + return RecruitRow( + id: id, + category: category.toDomain(), + title: title, + meetingType: meetingType.toDomain(), + startDate: activityStartDate.toDateFromYYYYMMDD(), + endDate: activityEndDate.toDateFromYYYYMMDD(), + deadline: deadlineDate.toDateFromYYYYMMDD(), + dDay: displayDDay(dDay), + state: status.toDomain(), + currentParticipants: currentParticipants, + maximumParticipants: maxParticipants, + type: recruitmentType.toDomain(), + roles: roles.map { $0.toDomain() } + ) + } + + private func displayDDay(_ dDay: Int?) -> String? { + guard let dDay else { + return nil + } + + return "D-" + (dDay == 0 ? "day" : "\(dDay)") + } +} diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitRoleDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitRoleDto.swift new file mode 100644 index 000000000..488ecd181 --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitRoleDto.swift @@ -0,0 +1,35 @@ +// +// RecruitRoleDto.swift +// koin +// +// Created by 홍기정 on 10/1/26. +// + +import Foundation + +struct RecruitRoleDto: Decodable { + let id: Int + let name: String + let currentParticipants: Int + let maxParticipants: Int + let isClosed: Bool + + enum CodingKeys: String, CodingKey { + case id, name + case currentParticipants = "current_participants" + case maxParticipants = "max_participants" + case isClosed = "is_closed" + } +} + +extension RecruitRoleDto { + func toDomain() -> RecruitRole { + return RecruitRole( + id: id, + name: name, + currentParticipants: currentParticipants, + maximumParticipants: maxParticipants, + isClosed: isClosed + ) + } +} diff --git a/Koin/Data/DTOs/Encodable/Recruit/RecruitListRequestDto.swift b/Koin/Data/DTOs/Encodable/Recruit/RecruitListRequestDto.swift new file mode 100644 index 000000000..b55ed669f --- /dev/null +++ b/Koin/Data/DTOs/Encodable/Recruit/RecruitListRequestDto.swift @@ -0,0 +1,75 @@ +// +// RecruitListRequestDto.swift +// koin +// +// Created by 홍기정 on 10/1/26. +// + +import Foundation + +struct RecruitListRequestDto: Encodable { + let keyword: String? + let status: RecruitListStatusDto + let categories: [RecruitCategoryDto]? + let meetingType: RecruitMeetingTypeDto? + let sort: RecruitListSortDto + let page: Int + let limit: Int? + + enum CodingKeys: String, CodingKey { + case keyword, status, categories + case meetingType = "meetingType" + case sort, page, limit + } +} + +enum RecruitListStatusDto: String, Encodable { + case all = "ALL" + case recruiting = "RECRUITING" + case closed = "CLOSED" +} + +enum RecruitListSortDto: String, Encodable { + case latestDesc = "LATEST_DESC" + case deadlineAsc = "DEADLINE_ASC" +} + +extension RecruitListRequestDto { + init(from filter: RecruitListFilter) { + let keyword = filter.keyword?.trimmingCharacters(in: .whitespacesAndNewlines) + + self.keyword = (keyword?.isEmpty ?? true) ? nil : keyword + self.status = RecruitListStatusDto(from: filter.state) + self.categories = filter.category.isEmpty ? nil : filter.category + .sorted { $0.index < $1.index } + .map { RecruitCategoryDto(from: $0) } + self.meetingType = filter.meetingType.map { RecruitMeetingTypeDto(from: $0) } + self.sort = RecruitListSortDto(from: filter.sort) + self.page = filter.page + self.limit = filter.limit + } +} + +extension RecruitListStatusDto { + init(from model: RecruitState) { + switch model { + case .all: + self = .all + case .recruiting: + self = .recruiting + case .closed: + self = .closed + } + } +} + +extension RecruitListSortDto { + init(from model: RecruitListSort) { + switch model { + case .latestDescending: + self = .latestDesc + case .deadlineAscending: + self = .deadlineAsc + } + } +} diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift new file mode 100644 index 000000000..c638c7159 --- /dev/null +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -0,0 +1,139 @@ +// +// DefaultRecruitRepository.swift +// koin +// +// Created by 홍기정 on 10/1/26. +// + +import Foundation + +final class DefaultRecruitRepository: RecruitRepository { + + private let service: RecruitService + + private let mockRepository = MockRecruitRepository() // TODO: API 연결 후 mock 위임 제거 + + init(service: RecruitService) { + self.service = service + } + + func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList { + let request = RecruitListRequestDto(from: filter) + return try await service.fetchList(request).toDomain() + } +} + +extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 + + func fetchTeamChatData( + recruitmentId: Int, + chatRoomId: Int + ) async throws -> RecruitChatData { + try await mockRepository.fetchTeamChatData(recruitmentId: recruitmentId, chatRoomId: chatRoomId) + } + + func fetchDirectChatData( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitChatData { + try await mockRepository.fetchDirectChatData(recruitmentId: recruitmentId, applicationId: applicationId) + } + + func fetchChatMessages( + recruitmentId: Int, + chatRoomId: Int + ) async throws -> RecruitChatMessageList { + try await mockRepository.fetchChatMessages(recruitmentId: recruitmentId, chatRoomId: chatRoomId) + } + + func postChatMessage( + recruitmentId: Int, + chatRoomId: Int, + request: RecruitChatPostRequest + ) async throws { + try await mockRepository.postChatMessage(recruitmentId: recruitmentId, chatRoomId: chatRoomId, request: request) + } + + func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList { + try await mockRepository.fetchMyPostList(filter) + } + + func fetchMyPostData(_ id: Int) async throws -> RecruitMyPostData { + try await mockRepository.fetchMyPostData(id) + } + + func fetchApplicant( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitApplicantData { + try await mockRepository.fetchApplicant(recruitmentId: recruitmentId, applicationId: applicationId) + } + + func decideApplicant( + recruitmentId: Int, + applicationId: Int, + decision: RecruitApplicantDecision + ) async throws -> Void { + try await mockRepository.decideApplicant(recruitmentId: recruitmentId, applicationId: applicationId, decision: decision) + } + + func fetchMyApplicationList(_ filter: RecruitMyApplicationFilter) async throws -> RecruitMyApplicationList { + try await mockRepository.fetchMyApplicationList(filter) + } + + func closeMyPost(id: Int) async throws -> Bool { + try await mockRepository.closeMyPost(id: id) + } + + func fetchNotificationList() async throws -> RecruitNotificationList { + try await mockRepository.fetchNotificationList() + } + + func deleteNotification(_ id: Int) async throws -> Void { + try await mockRepository.deleteNotification(id) + } + + func deleteAllNotification() async throws -> Void { + try await mockRepository.deleteAllNotification() + } + + func markAsReadNotification(_ id: Int) async throws -> Void { + try await mockRepository.markAsReadNotification(id) + } + + func markAllAsReadNotification() async throws -> Void { + try await mockRepository.markAllAsReadNotification() + } + + func post(_ request: RecruitPostRequest) async throws -> Int { + try await mockRepository.post(request) + } + + func modify(_ id: Int, _ request: RecruitPostRequest) async throws -> Void { + try await mockRepository.modify(id, request) + } + + func fetchData(_ id: Int) async throws -> RecruitData { + try await mockRepository.fetchData(id) + } + + func deleteData(id: Int) async throws -> Bool { + try await mockRepository.deleteData(id: id) + } + + func fetchMyProfile() async throws -> RecruitProfile { + try await mockRepository.fetchMyProfile() + } + + func postBasicInfo(_ basicInfo: BasicInfo) async throws -> BasicInfo { + try await mockRepository.postBasicInfo(basicInfo) + } + + func postRecruitProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile { + try await mockRepository.postRecruitProfile(request) + } + + func apply(_ request: RecruitApplyRequest) async throws -> Void { + try await mockRepository.apply(request) + } +} diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index ab43b1165..d436fea2e 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -275,10 +275,10 @@ final class MockRecruitRepository: RecruitRepository { category: summary.category, title: summary.title, meetingType: summary.meetingType, - startDate: summary.startDate, - endDate: summary.endDate, - deadline: summary.deadline, - dDay: summary.dDay, + startDate: summary.startDate ?? Date(), + endDate: summary.endDate ?? Date(), + deadline: summary.deadline ?? Date(), + dDay: summary.dDay ?? "", currentParticipants: summary.currentParticipants, maximumParticipants: summary.maximumParticipants, type: summary.type, @@ -458,10 +458,10 @@ final class MockRecruitRepository: RecruitRepository { category: summary.category, title: summary.title, meetingType: summary.meetingType, - startDate: summary.startDate, - endDate: summary.endDate, - deadline: summary.deadline, - dDay: summary.dDay, + startDate: summary.startDate ?? Date(), + endDate: summary.endDate ?? Date(), + deadline: summary.deadline ?? Date(), + dDay: summary.dDay ?? "", state: summary.state, currentParticipants: summary.currentParticipants, maximumParticipants: summary.maximumParticipants, @@ -512,9 +512,9 @@ final class MockRecruitRepository: RecruitRepository { state: item.state, title: item.title, meetingType: item.meetingType, - startDate: item.startDate, - endDate: item.endDate, - deadlineDate: item.deadline, + startDate: item.startDate ?? Date(), + endDate: item.endDate ?? Date(), + deadlineDate: item.deadline ?? Date(), currentParticipants: item.currentParticipants, maximumParticipants: item.maximumParticipants, createdAt: dateFormatter.date(from: "2026.07.26") ?? Date(), diff --git a/Koin/Data/Service/Network/API/RecruitAPI.swift b/Koin/Data/Service/Network/API/RecruitAPI.swift new file mode 100644 index 000000000..204396360 --- /dev/null +++ b/Koin/Data/Service/Network/API/RecruitAPI.swift @@ -0,0 +1,55 @@ +// +// RecruitAPI.swift +// koin +// +// Created by 홍기정 on 10/1/26. +// + +import Foundation +import Alamofire + +enum RecruitAPI { + case fetchList(RecruitListRequestDto) +} + +extension RecruitAPI: Router, URLRequestConvertible { + + public var baseURL: String { + return Bundle.main.baseUrl + } + + public var path: String { + switch self { + case .fetchList: return "/team-recruitments" + } + } + + public var method: Alamofire.HTTPMethod { + switch self { + case .fetchList: return .get + } + } + + public var headers: [String: String] { + let baseHeaders: [String: String] = [:] + switch self { + case .fetchList: + break + } + return baseHeaders + } + + public var parameters: Any? { + switch self { + case .fetchList(let request): + return try? request.toDictionary() + } + } + + public var encoding: ParameterEncoding? { + switch self { + case .fetchList: + return URLEncoding(arrayEncoding: .noBrackets) + } + } +} diff --git a/Koin/Data/Service/RecruitService.swift b/Koin/Data/Service/RecruitService.swift new file mode 100644 index 000000000..7d91fb03e --- /dev/null +++ b/Koin/Data/Service/RecruitService.swift @@ -0,0 +1,21 @@ +// +// RecruitService.swift +// koin +// +// Created by 홍기정 on 10/1/26. +// + +import Foundation + +protocol RecruitService { + func fetchList(_ request: RecruitListRequestDto) async throws -> RecruitListDto +} + +final class DefaultRecruitService: RecruitService { + + private let networkService = NetworkService.shared + + func fetchList(_ request: RecruitListRequestDto) async throws -> RecruitListDto { + try await networkService.requestWithResponse(api: RecruitAPI.fetchList(request)) + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitData.swift b/Koin/Domain/Model/Recruit/RecruitData.swift index 13b3ef60a..913a82567 100644 --- a/Koin/Domain/Model/Recruit/RecruitData.swift +++ b/Koin/Domain/Model/Recruit/RecruitData.swift @@ -11,7 +11,7 @@ struct RecruitData: Identifiable, Equatable { let id: Int let category: RecruitCategory - let dDay: String + let dDay: String? let state: RecruitState let title: String diff --git a/Koin/Domain/Model/Recruit/RecruitRow.swift b/Koin/Domain/Model/Recruit/RecruitRow.swift index 0f408b455..ffdcf560d 100644 --- a/Koin/Domain/Model/Recruit/RecruitRow.swift +++ b/Koin/Domain/Model/Recruit/RecruitRow.swift @@ -13,10 +13,10 @@ struct RecruitRow: Identifiable, Equatable { let title: String let meetingType: RecruitMeetingType - let startDate: Date - let endDate: Date - let deadline: Date - let dDay: String + let startDate: Date? + let endDate: Date? + let deadline: Date? + let dDay: String? let state: RecruitState let currentParticipants: Int diff --git a/Koin/Presentation/Home/Category/CategoryHostingController.swift b/Koin/Presentation/Home/Category/CategoryHostingController.swift index 596374562..234e43637 100644 --- a/Koin/Presentation/Home/Category/CategoryHostingController.swift +++ b/Koin/Presentation/Home/Category/CategoryHostingController.swift @@ -201,7 +201,7 @@ extension CategoryHostingController { } private func makeRecruitListViewController() -> UIViewController { - let recruitRepository = MockRecruitRepository() + let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchRecruitListUseCase = DefaultFetchRecruitListUseCase(repository: recruitRepository) let fetchRecruitNotificationListUseCase = DefaultFetchRecruitNotificationListUseCase(repository: recruitRepository) let viewModel = RecruitListViewModel( diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index 449a1ee80..2e38ba8e1 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -128,7 +128,7 @@ extension RecruitDataHostingController { guard let id = rootView.id else { return } - let repository = MockRecruitRepository() + let repository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchRecruitMyPostDataUseCase = DefaultFetchRecruitMyPostDataUseCase(repository: repository) let viewModel = RecruitMyPostViewModel( fetchRecruitMyPostDataUseCase: fetchRecruitMyPostDataUseCase, @@ -143,7 +143,7 @@ extension RecruitDataHostingController { guard let recruit = rootView.data else { return } - let recruitRepository = MockRecruitRepository() + let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let viewModel = RecruitApplyViewModel( fetchDeptListUseCase: MockFetchDeptListUseCase(), fetchMyProfileUseCase: DefaultFetchMyProfileUseCase(repository: recruitRepository), @@ -169,7 +169,7 @@ extension RecruitDataHostingController { guard let data = rootView.data else { return } - let recruitRepository = MockRecruitRepository() + let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let postRecruitUseCase = DefaultPostRecruitUseCase(repository: recruitRepository) let modifyRecruitUseCase = DefaultModifyRecruitUseCase(repository: recruitRepository) let viewModel = RecruitPostViewModel( diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift index 10128df9f..d53b3cc53 100644 --- a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift @@ -17,9 +17,15 @@ struct RecruitDataHeaderView: View { .padding(.horizontal, 8) .frame(height: 18) .background(Color.appColor(data.category.backgroundColor), in: Capsule()) - Text(data.state == .closed ? "모집완료" : data.dDay) - .font(.appFont(.pretendardMedium, size: 10)) - .foregroundStyle(Color.appColor(data.state == .closed ? .new600 : .danger700)) + if data.state == .closed { + Text("모집완료") + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(.new600)) + } else if let dDay = data.dDay { + Text(dDay) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(.danger700)) + } } .frame(minHeight: 18) diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index 74cfe8964..51c6a7f95 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -67,7 +67,7 @@ extension RecruitListHostingController: RecruitDataHostingControllerDelegate { } private func showRecruitData(id: Int) { - let repository = MockRecruitRepository() + let repository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchUseCase = DefaultFetchRecruitDataUseCase(repository: repository) let deleteUseCase = DefaultDeleteRecruitDataUseCase(repository: repository) let viewModel = RecruitDataViewModel(fetchRecruitDataUseCase: fetchUseCase, deleteRecruitDataUseCase: deleteUseCase, recruitId: id) @@ -108,7 +108,7 @@ extension RecruitListHostingController { return } private func showRecruitNotificationList() { - let recruitRepository = MockRecruitRepository() + let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchRecruitNotificationListUseCase = DefaultFetchRecruitNotificationListUseCase(repository: recruitRepository) let markAsReadRecruitNotificationUseCase = DefaultMarkAsReadRecruitNotificationUseCase(repository: recruitRepository) let deleteRecruitNotificationUseCase = DefaultDeleteRecruitNotificationUseCase(repository: recruitRepository) @@ -121,7 +121,7 @@ extension RecruitListHostingController { navigationController?.pushViewController(viewController, animated: true) } private func showRecruitProfile() { - let repository = MockRecruitRepository() + let repository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchMyProfileUseCase = DefaultFetchMyProfileUseCase(repository: repository) let viewModel = RecruitProfileViewModel(fetchMyProfileUseCase: fetchMyProfileUseCase) let viewController = RecruitProfileHostingController( @@ -130,7 +130,7 @@ extension RecruitListHostingController { navigationController?.pushViewController(viewController, animated: true) } private func showRecruitPost() { - let recruitRepository = MockRecruitRepository() + let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let postRecruitUseCase = DefaultPostRecruitUseCase(repository: recruitRepository) let modifyRecruitUseCase = DefaultModifyRecruitUseCase(repository: recruitRepository) let viewModel = RecruitPostViewModel( diff --git a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift index 3105ca0f6..e84030f0a 100644 --- a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift @@ -48,9 +48,15 @@ extension RecruitListRowView { .background(Color.appColor(model.category.backgroundColor)) .clipShape(.capsule) - Text(model.state == .closed ? "모집완료" : model.dDay) - .font(.appFont(.pretendardMedium, size: 10)) - .foregroundStyle(Color.appColor(model.state == .closed ? .new600 : .danger700)) + if model.state == .closed { + Text("모집완료") + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(.new600)) + } else if let dDay = model.dDay { + Text(dDay) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(.danger700)) + } } } @@ -91,7 +97,7 @@ extension RecruitListRowView { HStack(alignment: .center, spacing: 2) { Image.appImage(asset: .recruitDate) - Text("\(model.startDate.formatDateToYYYYMMDD(separator: ".")) ~ \(model.endDate.formatDateToYYYYMMDD(separator: "."))") + Text("\(model.startDate?.formatDateToYYYYMMDD(separator: ".") ?? "") ~ \(model.endDate?.formatDateToYYYYMMDD(separator: ".") ?? "")") .font(.appFont(.pretendardRegular, size: 10)) .foregroundStyle(Color.appColor(.neutral500)) } diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift index ac602e71c..63b6cc64c 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift @@ -44,7 +44,7 @@ final class RecruitMyApplicationListHostingController: UIHostingController RecruitProfilePostViewController { let userRepository = DefaultUserRepository(service: DefaultUserService()) - let recruitRepository = MockRecruitRepository() + let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchDeptListUseCase = MockFetchDeptListUseCase() let fetchUserDataUseCase = DefaultFetchUserDataUseCase(userRepository: userRepository) let postBasicInfoUseCase = DefaultPostBasicInfoUseCase(repository: recruitRepository) diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 37ff7d1de..06a514002 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -11,12 +11,15 @@ 0E2253358E42231BE436FA27 /* RecruitPostScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE127F2AE4B18899F6E5E0D /* RecruitPostScheduleView.swift */; }; 0FCD163D2BA80EAA00C23A2F /* KeychainWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCD163C2BA80EAA00C23A2F /* KeychainWorker.swift */; }; 1537B6CFD3D7AF52A2E9E944 /* RecruitPostMeetingTypeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73F70BC5F3AA1A34663C32CA /* RecruitPostMeetingTypeView.swift */; }; + 239837C1CD4A0D7A5D1D36C7 /* RecruitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC394F2327076B2F9AEE2A4 /* RecruitService.swift */; }; 247DF973D072E88901E5C249 /* RecruitProfilePostDepartmentDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */; }; 2E6336C1CDB4445BA858DA5A /* RecruitDataContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */; }; 5006987DEF4B5F454D7EA2D5 /* RecruitPostTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */; }; 515B96092C6730C797C1F837 /* PostRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D447F2D75951DD4C3E08D4E /* PostRecruitUseCase.swift */; }; 678D20761DEF461CA6D4728A /* RecruitDataTextSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2FBEEB767094CE8B219095B /* RecruitDataTextSectionView.swift */; }; + 682DA9853EC4F396EFEEF876 /* RecruitListDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21AA98431B25CB5914BC2598 /* RecruitListDto.swift */; }; 75EBF253F4468B2060B79212 /* RecruitPostSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3694E3ACBE05B8C7F15357 /* RecruitPostSectionHeaderView.swift */; }; + 79759F6CDB325A7914C75CBE /* RecruitListRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4067CAF18D134F99A04BBA0D /* RecruitListRequestDto.swift */; }; 7C016540300A88940013DD7B /* ShuttleTicketViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C01653F300A88940013DD7B /* ShuttleTicketViewController.swift */; }; 7C016544300AA1450013DD7B /* DepartmentCategoryHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C016543300AA1450013DD7B /* DepartmentCategoryHostingController.swift */; }; 7C016546300AA14C0013DD7B /* DepartmentCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C016545300AA14C0013DD7B /* DepartmentCategoryView.swift */; }; @@ -54,6 +57,8 @@ 7C0C189F2F68AB33001781B3 /* CallVanChatRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0C189E2F68AB33001781B3 /* CallVanChatRequest.swift */; }; 7C0C18A12F68FC89001781B3 /* FetchCallVanSummaryUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0C18A02F68FC89001781B3 /* FetchCallVanSummaryUseCase.swift */; }; 7C0C18A32F68FD3D001781B3 /* CallVanSummaryDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0C18A22F68FD3D001781B3 /* CallVanSummaryDto.swift */; }; + 7C0D8CC3306E837F00F7CE08 /* RecruitListSummaryDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0D8CC2306E837F00F7CE08 /* RecruitListSummaryDto.swift */; }; + 7C0D8CC5306E83A500F7CE08 /* RecruitRoleDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0D8CC4306E83A500F7CE08 /* RecruitRoleDto.swift */; }; 7C0F4A822FEDE6410045E163 /* View+isHidden.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0F4A832FEDE6410045E163 /* View+isHidden.swift */; }; 7C0F4A842FEDE6410045E163 /* View+loadingOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0F4A852FEDE6410045E163 /* View+loadingOverlay.swift */; }; 7C0F4A862FEDE6410045E163 /* View+onFirstAppear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0F4A872FEDE6410045E163 /* View+onFirstAppear.swift */; }; @@ -827,6 +832,7 @@ 83EBB87E2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83EBB87D2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift */; }; 8A7B1679CC9945559E674584 /* RecruitDataRolesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */; }; 91E82A6B67BF5BF911535B1C /* RecruitMyPostData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87039C64B7CB2AEB632113C6 /* RecruitMyPostData.swift */; }; + 9059A862F4E9CDA304B2F082 /* RecruitEnumDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E31EA58151824C938383893 /* RecruitEnumDto.swift */; }; 9C95E190C98F440DADB6F91B /* RecruitDataMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DADCC36CEEA42EC86AB75B6 /* RecruitDataMetadataView.swift */; }; A0B343754988C9398C4FE726 /* ModifyRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */; }; A11B00000000000000000001 /* PostBasicInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11B00000000000000000002 /* PostBasicInfoUseCase.swift */; }; @@ -921,6 +927,7 @@ C0DE00000000000000000001 /* RecruitProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000002 /* RecruitProfile.swift */; }; C0DE00000000000000000003 /* FetchMyProfileUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000004 /* FetchMyProfileUseCase.swift */; }; C1838F7A4424DEA14DB2D107 /* RecruitPostCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */; }; + C2B6232F34E3251E7E182808 /* RecruitAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54F105D18C46AE639FC3F3EE /* RecruitAPI.swift */; }; CAFE20000000000000000001 /* RecruitMyApplicationList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000001 /* RecruitMyApplicationList.swift */; }; CAFE20000000000000000002 /* RecruitMyApplicationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000002 /* RecruitMyApplicationRow.swift */; }; CAFE20000000000000000003 /* RecruitMyApplicationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */; }; @@ -934,6 +941,7 @@ CAFE2000000000000000000B /* RecruitMyApplicationListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE1000000000000000000B /* RecruitMyApplicationListRowView.swift */; }; CAFE2000000000000000000C /* RecruitMyApplicationListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE1000000000000000000C /* RecruitMyApplicationListEmptyView.swift */; }; CD3000000000000000000001 /* RecruitPostCategoryDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3000000000000000000002 /* RecruitPostCategoryDropdownView.swift */; }; + CE3D147031BC1F6BDD25184B /* DefaultRecruitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED038AB5420C8C565688CF6B /* DefaultRecruitRepository.swift */; }; D0A71C4F2F1B8340009E2D71 /* KoinDropdownHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */; }; D19A00013000000000000002 /* NotificationRowModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A00013000000000000001 /* NotificationRowModel.swift */; }; D19A00013000000000000004 /* NotificationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A00013000000000000003 /* NotificationListView.swift */; }; @@ -1216,12 +1224,16 @@ /* Begin PBXFileReference section */ 0FCD163C2BA80EAA00C23A2F /* KeychainWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainWorker.swift; sourceTree = ""; }; 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostTextFieldView.swift; sourceTree = ""; }; + 21AA98431B25CB5914BC2598 /* RecruitListDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitListDto.swift; sourceTree = ""; }; 288BC1E04044DC3FB48524FC /* RecruitPostViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostViewModel.swift; sourceTree = ""; }; 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostDepartmentDropdownView.swift; sourceTree = ""; }; + 2E31EA58151824C938383893 /* RecruitEnumDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitEnumDto.swift; sourceTree = ""; }; 3C3694E3ACBE05B8C7F15357 /* RecruitPostSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostSectionHeaderView.swift; sourceTree = ""; }; + 4067CAF18D134F99A04BBA0D /* RecruitListRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitListRequestDto.swift; sourceTree = ""; }; 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostCategoryView.swift; sourceTree = ""; }; 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyRecruitUseCase.swift; sourceTree = ""; }; 54351A8A0C1A4046C3155481 /* RecruitPostTextViewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostTextViewView.swift; sourceTree = ""; }; + 54F105D18C46AE639FC3F3EE /* RecruitAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitAPI.swift; sourceTree = ""; }; 5CBC0EF8E0FE44F48744BEC7 /* RecruitDataHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataHeaderView.swift; sourceTree = ""; }; 645CEE62E516B130B4E2D784 /* RecruitMyApplication.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyApplication.swift; sourceTree = ""; }; 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataContentView.swift; sourceTree = ""; }; @@ -1263,6 +1275,8 @@ 7C0C189E2F68AB33001781B3 /* CallVanChatRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanChatRequest.swift; sourceTree = ""; }; 7C0C18A02F68FC89001781B3 /* FetchCallVanSummaryUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchCallVanSummaryUseCase.swift; sourceTree = ""; }; 7C0C18A22F68FD3D001781B3 /* CallVanSummaryDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanSummaryDto.swift; sourceTree = ""; }; + 7C0D8CC2306E837F00F7CE08 /* RecruitListSummaryDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitListSummaryDto.swift; sourceTree = ""; }; + 7C0D8CC4306E83A500F7CE08 /* RecruitRoleDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitRoleDto.swift; sourceTree = ""; }; 7C0F4A832FEDE6410045E163 /* View+isHidden.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+isHidden.swift"; sourceTree = ""; }; 7C0F4A852FEDE6410045E163 /* View+loadingOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+loadingOverlay.swift"; sourceTree = ""; }; 7C0F4A872FEDE6410045E163 /* View+onFirstAppear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+onFirstAppear.swift"; sourceTree = ""; }; @@ -2019,6 +2033,7 @@ 83EBB8792D0FDFD700346018 /* KoinPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinPickerView.swift; sourceTree = ""; }; 83EBB87D2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchKoinPickerDateUseCase.swift; sourceTree = ""; }; 87039C64B7CB2AEB632113C6 /* RecruitMyPostData.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyPostData.swift; sourceTree = ""; }; + 8AC394F2327076B2F9AEE2A4 /* RecruitService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitService.swift; sourceTree = ""; }; 8CE127F2AE4B18899F6E5E0D /* RecruitPostScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostScheduleView.swift; sourceTree = ""; }; 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataRolesView.swift; sourceTree = ""; }; A001E2B42845091D00D6C310 /* koin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = koin.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -2368,6 +2383,7 @@ D8F5C5532E6F144300FB6708 /* floatingLogo.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = floatingLogo.json; sourceTree = ""; }; E1A70C002D9A4F1100AGSEL0 /* AgreementSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementSelection.swift; sourceTree = ""; }; EC692285302700A400EE26ED /* koinUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = koinUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + ED038AB5420C8C565688CF6B /* DefaultRecruitRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultRecruitRepository.swift; sourceTree = ""; }; EFEC522ED41CF624AD3CB3A6 /* RecruitStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitStepView.swift; sourceTree = ""; }; F0E10C78030CB500BD595276 /* MockFetchDeptListUseCase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockFetchDeptListUseCase.swift; sourceTree = ""; }; F1A000010000000000000002 /* ZoomingCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomingCollectionView.swift; sourceTree = ""; }; @@ -2436,6 +2452,17 @@ path = Subviews; sourceTree = ""; }; + 4C4575D89FC0D358F532B18E /* Recruit */ = { + isa = PBXGroup; + children = ( + 2E31EA58151824C938383893 /* RecruitEnumDto.swift */, + 21AA98431B25CB5914BC2598 /* RecruitListDto.swift */, + 7C0D8CC2306E837F00F7CE08 /* RecruitListSummaryDto.swift */, + 7C0D8CC4306E83A500F7CE08 /* RecruitRoleDto.swift */, + ); + path = Recruit; + sourceTree = ""; + }; 7C016541300A9FF80013DD7B /* Department */ = { isa = PBXGroup; children = ( @@ -5521,6 +5548,14 @@ path = Configuration; sourceTree = ""; }; + B99A68507F04177F19780924 /* Recruit */ = { + isa = PBXGroup; + children = ( + 4067CAF18D134F99A04BBA0D /* RecruitListRequestDto.swift */, + ); + path = Recruit; + sourceTree = ""; + }; BEEF30000000000000000001 /* RecruitMyPostList */ = { isa = PBXGroup; children = ( @@ -5895,6 +5930,7 @@ D23479DD2BDB715700FA91BD /* Timetable */, D23479D82BDB5EEF00FA91BD /* Land */, D208A7A32CA1118D007040E7 /* AbTest */, + 4C4575D89FC0D358F532B18E /* Recruit */, ); path = Decodable; sourceTree = ""; @@ -6184,6 +6220,7 @@ D29F21E62C4F5E1F00994554 /* Timetable */, D29F21DC2C4F2DFF00994554 /* User */, D208A7A42CA111D5007040E7 /* AbTest */, + B99A68507F04177F19780924 /* Recruit */, ); path = Encodable; sourceTree = ""; @@ -6325,6 +6362,7 @@ 7CA686792FFCB758007937E9 /* DefaultNotificationHistoryRepository.swift */, 7C640401300F39BB00A9589F /* DefaultDepartmentRepository.swift */, 7C50A2043042EF4F00897A6F /* MockRecruitRepository.swift */, + ED038AB5420C8C565688CF6B /* DefaultRecruitRepository.swift */, ); path = Repository; sourceTree = ""; @@ -6388,6 +6426,7 @@ 7CA60D6C2F661A6D007C08C1 /* CallVanAPI.swift */, 7C8A94432FD4279B00DEA6F5 /* HomeAPI.swift */, 7C6403FD300F36F200A9589F /* DepartmentAPI.swift */, + 54F105D18C46AE639FC3F3EE /* RecruitAPI.swift */, ); path = API; sourceTree = ""; @@ -6420,6 +6459,7 @@ 7CA60D662F6619F4007C08C1 /* CallVanService.swift */, 7C8A94412FD4278800DEA6F5 /* HomeService.swift */, 7C64040E300F428200A9589F /* DepartmentService.swift */, + 8AC394F2327076B2F9AEE2A4 /* RecruitService.swift */, D2FB23582BFD72C60098BA2B /* Network */, 7C16AA352F3DE8F50071ABE6 /* CoreData */, ); @@ -7172,6 +7212,7 @@ 7C372F332F1DB4C300149729 /* LostItemImagesCollectionView.swift in Sources */, 7CA6868F2FFCF42E007937E9 /* NotificationHistoryError.swift in Sources */, 7CED91302EF2D1F900457128 /* NoticeListSearchTableViewFooter.swift in Sources */, + 7C0D8CC5306E83A500F7CE08 /* RecruitRoleDto.swift in Sources */, 7CED91312EF2D1F900457128 /* ShopSummaryTableViewTableHeaderView.swift in Sources */, 7C8BFCD32FD0272900963679 /* View+border.swift in Sources */, 7CED91322EF2D1F900457128 /* FrameListViewController.swift in Sources */, @@ -7783,6 +7824,7 @@ 7CB11C212FB24AA8004E0C80 /* LostItemKeywordLeftAlignedCollectionView.swift in Sources */, 7CC42B152F56ABC000940CE1 /* CallVanListCollectionViewCell.swift in Sources */, D289873E2D21B30D00AA7285 /* DebouncedButton.swift in Sources */, + 7C0D8CC3306E837F00F7CE08 /* RecruitListSummaryDto.swift in Sources */, D249CD3F2C159426000813F9 /* CurrentDiningTime.swift in Sources */, 7CA686692FFC3242007937E9 /* NotificationHistoryRepository.swift in Sources */, D874DB512DBF200C0098EED0 /* SendVerificationCodeDto.swift in Sources */, @@ -7805,6 +7847,12 @@ B154A7D97E6AAEC2ED3D8119 /* MockFetchDeptListUseCase.swift in Sources */, A5F8FD7A714E689BC4F3321B /* RecruitMyApplication.swift in Sources */, 91E82A6B67BF5BF911535B1C /* RecruitMyPostData.swift in Sources */, + 9059A862F4E9CDA304B2F082 /* RecruitEnumDto.swift in Sources */, + 682DA9853EC4F396EFEEF876 /* RecruitListDto.swift in Sources */, + 79759F6CDB325A7914C75CBE /* RecruitListRequestDto.swift in Sources */, + C2B6232F34E3251E7E182808 /* RecruitAPI.swift in Sources */, + 239837C1CD4A0D7A5D1D36C7 /* RecruitService.swift in Sources */, + CE3D147031BC1F6BDD25184B /* DefaultRecruitRepository.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From ccf91661e4ea360e9a1d1dcb91e61ca47a4eba9f Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Fri, 2 Oct 2026 19:36:29 +0900 Subject: [PATCH 056/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /team-recruitments/{recruitmentId} 연결 (RecruitDataDto 신설) - RecruitApplyBlockReason 신설, RecruitData.applyBlockReason 을 enum 으로 변경 - RecruitData 날짜 Date? 로 변경, markAsAlreadyApplied() 추가 - dDay 변환을 Int.toRecruitDDay() 로 공용화 --- Koin/Core/Extensions/Common/Int+.swift | 6 + .../Decodable/Recruit/RecruitDataDto.swift | 119 ++++++++++++++++++ .../Decodable/Recruit/RecruitEnumDto.swift | 53 ++++++++ .../Recruit/RecruitListSummaryDto.swift | 10 +- .../Repository/DefaultRecruitRepository.swift | 8 +- .../Repository/MockRecruitRepository.swift | 6 +- .../Data/Service/Network/API/RecruitAPI.swift | 9 +- Koin/Data/Service/RecruitService.swift | 5 + .../Recruit/RecruitApplyBlockReason.swift | 19 +++ Koin/Domain/Model/Recruit/RecruitData.swift | 41 ++---- .../RecruitData/RecruitDataViewModel.swift | 9 +- .../Subviews/RecruitDataMetadataView.swift | 2 +- koin.xcodeproj/project.pbxproj | 8 ++ 13 files changed, 241 insertions(+), 54 deletions(-) create mode 100644 Koin/Data/DTOs/Decodable/Recruit/RecruitDataDto.swift create mode 100644 Koin/Domain/Model/Recruit/RecruitApplyBlockReason.swift diff --git a/Koin/Core/Extensions/Common/Int+.swift b/Koin/Core/Extensions/Common/Int+.swift index e981ed673..5f07483db 100644 --- a/Koin/Core/Extensions/Common/Int+.swift +++ b/Koin/Core/Extensions/Common/Int+.swift @@ -22,3 +22,9 @@ extension Int { self.init(string ?? "") } } + +extension Int { + func toDDay() -> String { + return "D-" + (self <= 0 ? "day" : "\(self)") + } +} diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitDataDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitDataDto.swift new file mode 100644 index 000000000..a6d9deec1 --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitDataDto.swift @@ -0,0 +1,119 @@ +// +// RecruitDataDto.swift +// koin +// +// Created by 홍기정 on 10/2/26. +// + +import Foundation + +struct RecruitDataDto: Decodable { + let id: Int + let category: RecruitCategoryDto + let title: String + let meetingType: RecruitMeetingTypeDto + let activityStartDate: String + let activityEndDate: String + let deadlineDate: String + let dDay: Int? + let status: RecruitStatusDto + let recruitmentType: RecruitRoleTypeDto + let currentParticipants: Int + let maxParticipants: Int + let roles: [RecruitRoleDto] + let authorNickname: String? + let description: String + let relatedUrl: String? + let qualification: String? + let createdAt: String + let isAuthor: Bool + let canApply: Bool + let applyBlockReason: RecruitApplyBlockReasonDto? + let application: RecruitMyApplicationDto? + let canManageApplicants: Bool + let teamChatAvailable: Bool + let teamChatRoomId: Int? + + enum CodingKeys: String, CodingKey { + case id, category, title + case meetingType = "meeting_type" + case activityStartDate = "activity_start_date" + case activityEndDate = "activity_end_date" + case deadlineDate = "deadline_date" + case dDay = "d_day" + case status + case recruitmentType = "recruitment_type" + case currentParticipants = "current_participants" + case maxParticipants = "max_participants" + case roles + case authorNickname = "author_nickname" + case description + case relatedUrl = "related_url" + case qualification + case createdAt = "created_at" + case isAuthor = "is_author" + case canApply = "can_apply" + case applyBlockReason = "apply_block_reason" + case application + case canManageApplicants = "can_manage_applicants" + case teamChatAvailable = "team_chat_available" + case teamChatRoomId = "team_chat_room_id" + } +} + +struct RecruitMyApplicationDto: Decodable { + let applicationId: Int + let status: RecruitApplicationStatusDto + + enum CodingKeys: String, CodingKey { + case applicationId = "application_id" + case status + } +} + +extension RecruitDataDto { + func toDomain() -> RecruitData { + let createdAtFormatter = DateFormatter().then { + $0.dateFormat = "yyyy-MM-dd HH:mm:ss" + $0.locale = Locale(identifier: "ko_KR") + $0.calendar = Calendar(identifier: .gregorian) + } + + return RecruitData( + id: id, + category: category.toDomain(), + dDay: dDay?.toDDay(), + state: status.toDomain(), + title: title, + meetingType: meetingType.toDomain(), + startDate: activityStartDate.toDateFromYYYYMMDD(), + endDate: activityEndDate.toDateFromYYYYMMDD(), + deadlineDate: deadlineDate.toDateFromYYYYMMDD(), + currentParticipants: currentParticipants, + maximumParticipants: maxParticipants, + createdAt: createdAtFormatter.date(from: createdAt), + author: authorNickname, + type: recruitmentType.toDomain(), + roles: roles.map { $0.toDomain() }, + description: description, + relatedUrl: relatedUrl.flatMap { URL(string: $0) }, + qualification: qualification, + application: application?.toDomain(), + isAuthor: isAuthor, + canApply: canApply, + applyBlockReason: applyBlockReason?.toDomain(), + canManageApplicants: canManageApplicants, + teamChatAvailable: teamChatAvailable, + teamChatRoomId: teamChatRoomId + ) + } +} + +extension RecruitMyApplicationDto { + func toDomain() -> RecruitMyApplication { + return RecruitMyApplication( + id: applicationId, + status: status.toDomain() + ) + } +} diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift index 6df49a1ec..57b61dc3e 100644 --- a/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift @@ -31,6 +31,23 @@ enum RecruitRoleTypeDto: String, Decodable { case general = "GENERAL" } +enum RecruitApplicationStatusDto: String, Decodable { + case pending = "PENDING" + case accepted = "ACCEPTED" + case rejected = "REJECTED" +} + +enum RecruitApplyBlockReasonDto: String, Decodable { + case recruitmentDeleted = "RECRUITMENT_DELETED" + case loginRequired = "LOGIN_REQUIRED" + case ownRecruitment = "OWN_RECRUITMENT" + case alreadyApplied = "ALREADY_APPLIED" + case recruitmentClosed = "RECRUITMENT_CLOSED" + case deadlinePassed = "DEADLINE_PASSED" + case roleClosed = "ROLE_CLOSED" + case profileRequired = "PROFILE_REQUIRED" +} + extension RecruitCategoryDto { init(from model: RecruitCategory) { switch model { @@ -108,3 +125,39 @@ extension RecruitRoleTypeDto { } } } + +extension RecruitApplicationStatusDto { + func toDomain() -> RecruitApplicationStatus { + switch self { + case .pending: + return .pending + case .accepted: + return .accepted + case .rejected: + return .denied + } + } +} + +extension RecruitApplyBlockReasonDto { + func toDomain() -> RecruitApplyBlockReason { + switch self { + case .recruitmentDeleted: + return .recruitmentDeleted + case .loginRequired: + return .loginRequired + case .ownRecruitment: + return .ownRecruitment + case .alreadyApplied: + return .alreadyApplied + case .recruitmentClosed: + return .recruitmentClosed + case .deadlinePassed: + return .deadlinePassed + case .roleClosed: + return .roleClosed + case .profileRequired: + return .profileRequired + } + } +} diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitListSummaryDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitListSummaryDto.swift index 458d36f5e..36a05b433 100644 --- a/Koin/Data/DTOs/Decodable/Recruit/RecruitListSummaryDto.swift +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitListSummaryDto.swift @@ -47,7 +47,7 @@ extension RecruitListSummaryDto { startDate: activityStartDate.toDateFromYYYYMMDD(), endDate: activityEndDate.toDateFromYYYYMMDD(), deadline: deadlineDate.toDateFromYYYYMMDD(), - dDay: displayDDay(dDay), + dDay: dDay?.toDDay(), state: status.toDomain(), currentParticipants: currentParticipants, maximumParticipants: maxParticipants, @@ -55,12 +55,4 @@ extension RecruitListSummaryDto { roles: roles.map { $0.toDomain() } ) } - - private func displayDDay(_ dDay: Int?) -> String? { - guard let dDay else { - return nil - } - - return "D-" + (dDay == 0 ? "day" : "\(dDay)") - } } diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift index c638c7159..e178c44f1 100644 --- a/Koin/Data/Repository/DefaultRecruitRepository.swift +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -21,6 +21,10 @@ final class DefaultRecruitRepository: RecruitRepository { let request = RecruitListRequestDto(from: filter) return try await service.fetchList(request).toDomain() } + + func fetchData(_ id: Int) async throws -> RecruitData { + try await service.fetchData(id).toDomain() + } } extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 @@ -113,10 +117,6 @@ extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 try await mockRepository.modify(id, request) } - func fetchData(_ id: Int) async throws -> RecruitData { - try await mockRepository.fetchData(id) - } - func deleteData(id: Int) async throws -> Bool { try await mockRepository.deleteData(id: id) } diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index d436fea2e..196c49624 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -512,9 +512,9 @@ final class MockRecruitRepository: RecruitRepository { state: item.state, title: item.title, meetingType: item.meetingType, - startDate: item.startDate ?? Date(), - endDate: item.endDate ?? Date(), - deadlineDate: item.deadline ?? Date(), + startDate: item.startDate, + endDate: item.endDate, + deadlineDate: item.deadline, currentParticipants: item.currentParticipants, maximumParticipants: item.maximumParticipants, createdAt: dateFormatter.date(from: "2026.07.26") ?? Date(), diff --git a/Koin/Data/Service/Network/API/RecruitAPI.swift b/Koin/Data/Service/Network/API/RecruitAPI.swift index 204396360..2ccad71cd 100644 --- a/Koin/Data/Service/Network/API/RecruitAPI.swift +++ b/Koin/Data/Service/Network/API/RecruitAPI.swift @@ -10,6 +10,7 @@ import Alamofire enum RecruitAPI { case fetchList(RecruitListRequestDto) + case fetchData(Int) } extension RecruitAPI: Router, URLRequestConvertible { @@ -21,19 +22,21 @@ extension RecruitAPI: Router, URLRequestConvertible { public var path: String { switch self { case .fetchList: return "/team-recruitments" + case .fetchData(let id): return "/team-recruitments/\(id)" } } public var method: Alamofire.HTTPMethod { switch self { case .fetchList: return .get + case .fetchData: return .get } } public var headers: [String: String] { let baseHeaders: [String: String] = [:] switch self { - case .fetchList: + case .fetchList, .fetchData: break } return baseHeaders @@ -43,6 +46,8 @@ extension RecruitAPI: Router, URLRequestConvertible { switch self { case .fetchList(let request): return try? request.toDictionary() + case .fetchData: + return nil } } @@ -50,6 +55,8 @@ extension RecruitAPI: Router, URLRequestConvertible { switch self { case .fetchList: return URLEncoding(arrayEncoding: .noBrackets) + case .fetchData: + return nil } } } diff --git a/Koin/Data/Service/RecruitService.swift b/Koin/Data/Service/RecruitService.swift index 7d91fb03e..7f2f8791d 100644 --- a/Koin/Data/Service/RecruitService.swift +++ b/Koin/Data/Service/RecruitService.swift @@ -9,6 +9,7 @@ import Foundation protocol RecruitService { func fetchList(_ request: RecruitListRequestDto) async throws -> RecruitListDto + func fetchData(_ id: Int) async throws -> RecruitDataDto } final class DefaultRecruitService: RecruitService { @@ -18,4 +19,8 @@ final class DefaultRecruitService: RecruitService { func fetchList(_ request: RecruitListRequestDto) async throws -> RecruitListDto { try await networkService.requestWithResponse(api: RecruitAPI.fetchList(request)) } + + func fetchData(_ id: Int) async throws -> RecruitDataDto { + try await networkService.requestWithResponse(api: RecruitAPI.fetchData(id)) + } } diff --git a/Koin/Domain/Model/Recruit/RecruitApplyBlockReason.swift b/Koin/Domain/Model/Recruit/RecruitApplyBlockReason.swift new file mode 100644 index 000000000..a58ce4903 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitApplyBlockReason.swift @@ -0,0 +1,19 @@ +// +// RecruitApplyBlockReason.swift +// koin +// +// Created by 홍기정 on 10/2/26. +// + +import Foundation + +enum RecruitApplyBlockReason { + case recruitmentDeleted + case loginRequired + case ownRecruitment + case alreadyApplied + case recruitmentClosed + case deadlinePassed + case roleClosed + case profileRequired +} diff --git a/Koin/Domain/Model/Recruit/RecruitData.swift b/Koin/Domain/Model/Recruit/RecruitData.swift index 913a82567..ed53ad515 100644 --- a/Koin/Domain/Model/Recruit/RecruitData.swift +++ b/Koin/Domain/Model/Recruit/RecruitData.swift @@ -16,9 +16,9 @@ struct RecruitData: Identifiable, Equatable { let title: String let meetingType: RecruitMeetingType - let startDate: Date - let endDate: Date - let deadlineDate: Date + let startDate: Date? + let endDate: Date? + let deadlineDate: Date? let currentParticipants: Int let maximumParticipants: Int let createdAt: Date? @@ -34,41 +34,16 @@ struct RecruitData: Identifiable, Equatable { let application: RecruitMyApplication? let isAuthor: Bool - let canApply: Bool - let applyBlockReason: String? + private(set) var canApply: Bool + private(set) var applyBlockReason: RecruitApplyBlockReason? let canManageApplicants: Bool let teamChatAvailable: Bool let teamChatRoomId: Int? } extension RecruitData { - func disablingApplication() -> RecruitData { - RecruitData( - id: id, - category: category, - dDay: dDay, - state: state, - title: title, - meetingType: meetingType, - startDate: startDate, - endDate: endDate, - deadlineDate: deadlineDate, - currentParticipants: currentParticipants, - maximumParticipants: maximumParticipants, - createdAt: createdAt, - author: author, - type: type, - roles: roles, - description: description, - relatedUrl: relatedUrl, - qualification: qualification, - application: application, - isAuthor: isAuthor, - canApply: false, - applyBlockReason: "이미 지원한 모집글입니다.", - canManageApplicants: canManageApplicants, - teamChatAvailable: teamChatAvailable, - teamChatRoomId: teamChatRoomId - ) + mutating func markAsAlreadyApplied() { + canApply = false + applyBlockReason = .alreadyApplied } } diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift index 6b8b0cf83..355e7e0f4 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift @@ -66,8 +66,11 @@ extension RecruitDataViewModel { defer { isLoading = false } - let data = try await fetchRecruitDataUseCase.execute(id: recruitId) - self.data = didSubmitApplication ? data.disablingApplication() : data + var data = try await fetchRecruitDataUseCase.execute(id: recruitId) + if didSubmitApplication { + data.markAsAlreadyApplied() + } + self.data = data } catch { if let error = error as? ErrorResponse { errorMessage = error.message @@ -104,7 +107,7 @@ extension RecruitDataViewModel { private func applicationSubmitted() { didSubmitApplication = true - data = data?.disablingApplication() + data?.markAsAlreadyApplied() load() } } diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataMetadataView.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataMetadataView.swift index b5d920dbf..0950de62a 100644 --- a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataMetadataView.swift +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataMetadataView.swift @@ -18,7 +18,7 @@ struct RecruitDataMetadataView: View { metadataRow( icon: .recruitDataCalendar, title: "활동 기간", - value: "\(data.startDate.formatDateToYYYYMMDD(separator: ".")) ~ \(data.endDate.formatDateToYYYYMMDD(separator: "."))" + value: "\(data.startDate?.formatDateToYYYYMMDD(separator: ".") ?? "") ~ \(data.endDate?.formatDateToYYYYMMDD(separator: ".") ?? "")" ) metadataRow( icon: .recruitDataMembers, diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 06a514002..50096a1eb 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 239837C1CD4A0D7A5D1D36C7 /* RecruitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC394F2327076B2F9AEE2A4 /* RecruitService.swift */; }; 247DF973D072E88901E5C249 /* RecruitProfilePostDepartmentDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */; }; 2E6336C1CDB4445BA858DA5A /* RecruitDataContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */; }; + 3DDE20379881F001713618FC /* RecruitDataDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA92583628DDFBC3545C197B /* RecruitDataDto.swift */; }; 5006987DEF4B5F454D7EA2D5 /* RecruitPostTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */; }; 515B96092C6730C797C1F837 /* PostRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D447F2D75951DD4C3E08D4E /* PostRecruitUseCase.swift */; }; 678D20761DEF461CA6D4728A /* RecruitDataTextSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2FBEEB767094CE8B219095B /* RecruitDataTextSectionView.swift */; }; @@ -833,6 +834,7 @@ 8A7B1679CC9945559E674584 /* RecruitDataRolesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */; }; 91E82A6B67BF5BF911535B1C /* RecruitMyPostData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87039C64B7CB2AEB632113C6 /* RecruitMyPostData.swift */; }; 9059A862F4E9CDA304B2F082 /* RecruitEnumDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E31EA58151824C938383893 /* RecruitEnumDto.swift */; }; + 999FC904C4F9ED4E2B829341 /* RecruitApplyBlockReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB6A15BFD7B896A787835F8E /* RecruitApplyBlockReason.swift */; }; 9C95E190C98F440DADB6F91B /* RecruitDataMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DADCC36CEEA42EC86AB75B6 /* RecruitDataMetadataView.swift */; }; A0B343754988C9398C4FE726 /* ModifyRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */; }; A11B00000000000000000001 /* PostBasicInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11B00000000000000000002 /* PostBasicInfoUseCase.swift */; }; @@ -2382,6 +2384,7 @@ D8F5C54B2E6F012500FB6708 /* LottieAnimationManageable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieAnimationManageable.swift; sourceTree = ""; }; D8F5C5532E6F144300FB6708 /* floatingLogo.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = floatingLogo.json; sourceTree = ""; }; E1A70C002D9A4F1100AGSEL0 /* AgreementSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementSelection.swift; sourceTree = ""; }; + EA92583628DDFBC3545C197B /* RecruitDataDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitDataDto.swift; sourceTree = ""; }; EC692285302700A400EE26ED /* koinUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = koinUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; ED038AB5420C8C565688CF6B /* DefaultRecruitRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultRecruitRepository.swift; sourceTree = ""; }; EFEC522ED41CF624AD3CB3A6 /* RecruitStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitStepView.swift; sourceTree = ""; }; @@ -2389,6 +2392,7 @@ F1A000010000000000000002 /* ZoomingCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomingCollectionView.swift; sourceTree = ""; }; F1A000010000000000000004 /* ZoomingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomingCollectionViewCell.swift; sourceTree = ""; }; F670021A95E0AA103E8F7BED /* RecruitPostRoleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostRoleView.swift; sourceTree = ""; }; + FB6A15BFD7B896A787835F8E /* RecruitApplyBlockReason.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitApplyBlockReason.swift; sourceTree = ""; }; FE01000000000000000011 /* KoinDropdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdown.swift; sourceTree = ""; }; FE01000000000000000013 /* KoinDropdownAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdownAnimator.swift; sourceTree = ""; }; FE01000000000000000016 /* KoinDropdownConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdownConfiguration.swift; sourceTree = ""; }; @@ -2459,6 +2463,7 @@ 21AA98431B25CB5914BC2598 /* RecruitListDto.swift */, 7C0D8CC2306E837F00F7CE08 /* RecruitListSummaryDto.swift */, 7C0D8CC4306E83A500F7CE08 /* RecruitRoleDto.swift */, + EA92583628DDFBC3545C197B /* RecruitDataDto.swift */, ); path = Recruit; sourceTree = ""; @@ -3029,6 +3034,7 @@ 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */, 7C50A20A304324F500897A6F /* RecruitNotificationRow.swift */, 7C50A1D63041F91400897A6F /* RecruitData.swift */, + FB6A15BFD7B896A787835F8E /* RecruitApplyBlockReason.swift */, 7CAC2DDB304C34E900AC2828 /* RecruitPostRequest.swift */, C0DE00000000000000000002 /* RecruitProfile.swift */, 7CDCD0D73055EECF004204DA /* RecruitProfileActivity.swift */, @@ -7853,6 +7859,8 @@ C2B6232F34E3251E7E182808 /* RecruitAPI.swift in Sources */, 239837C1CD4A0D7A5D1D36C7 /* RecruitService.swift in Sources */, CE3D147031BC1F6BDD25184B /* DefaultRecruitRepository.swift in Sources */, + 3DDE20379881F001713618FC /* RecruitDataDto.swift in Sources */, + 999FC904C4F9ED4E2B829341 /* RecruitApplyBlockReason.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From e94c1f69c8a1ce585cf16567b3214ae88a4e53b8 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Fri, 2 Oct 2026 19:37:12 +0900 Subject: [PATCH 057/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=83=81=EC=84=B8=20=EC=A7=80=EC=9B=90=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=83=81=ED=83=9C=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지원 불가 사유별 버튼 문구/활성 여부 추가 (지원완료 신규) - RecruitData.buttonText / isButtonEnabled, RecruitApplyBlockReason.buttonText - RecruitDataContentView 버튼 상태를 모델 기준으로 변경 --- .../Recruit/RecruitApplyBlockReason.swift | 13 +++++++++ Koin/Domain/Model/Recruit/RecruitData.swift | 24 ++++++++++++++++ .../Subviews/RecruitDataContentView.swift | 28 ++----------------- 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/Koin/Domain/Model/Recruit/RecruitApplyBlockReason.swift b/Koin/Domain/Model/Recruit/RecruitApplyBlockReason.swift index a58ce4903..eb9dbb1d8 100644 --- a/Koin/Domain/Model/Recruit/RecruitApplyBlockReason.swift +++ b/Koin/Domain/Model/Recruit/RecruitApplyBlockReason.swift @@ -17,3 +17,16 @@ enum RecruitApplyBlockReason { case roleClosed case profileRequired } + +extension RecruitApplyBlockReason { + var buttonText: String { + switch self { + case .loginRequired, .profileRequired: + return "지원하기" + case .alreadyApplied: + return "지원완료" + case .recruitmentDeleted, .ownRecruitment, .recruitmentClosed, .deadlinePassed, .roleClosed: + return "모집완료" + } + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitData.swift b/Koin/Domain/Model/Recruit/RecruitData.swift index ed53ad515..c870e1249 100644 --- a/Koin/Domain/Model/Recruit/RecruitData.swift +++ b/Koin/Domain/Model/Recruit/RecruitData.swift @@ -47,3 +47,27 @@ extension RecruitData { applyBlockReason = .alreadyApplied } } + +extension RecruitData { + var buttonText: String { + if isAuthor { + return "지원자 확인하기" + } + if canApply { + return "지원하기" + } + return applyBlockReason?.buttonText ?? "모집완료" + } + + var isButtonEnabled: Bool { + if isAuthor || canApply { + return true + } + switch applyBlockReason { + case .loginRequired, .profileRequired: + return true + default: + return false + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataContentView.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataContentView.swift index e869dd722..42ed158ac 100644 --- a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataContentView.swift +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataContentView.swift @@ -9,30 +9,6 @@ struct RecruitDataContentView: View { let data: RecruitData? let onButtonTapped: () -> Void - private var buttonText: String { - guard let data else { - return "" - } - if data.isAuthor { - return "지원자 확인하기" - } else if data.canApply { - return "지원하기" - } else { - return "모집 마감" - } - } - - private var isButtonEnabled: Bool { - guard let data else { - return false - } - if data.isAuthor || data.canApply { - return true - } else { - return false - } - } - init( data: RecruitData?, onButtonTapped: @escaping () -> Void @@ -82,8 +58,8 @@ struct RecruitDataContentView: View { .scrollBounceBehavior(.basedOnSize, axes: .vertical) RecruitDataButton( - text: buttonText, - isEnabled: isButtonEnabled, + text: data.buttonText, + isEnabled: data.isButtonEnabled, action: onButtonTapped ) .padding(.horizontal, 32) From 8153350f5d122c5df25855dd161fafdb15ddb869 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Fri, 2 Oct 2026 19:37:44 +0900 Subject: [PATCH 058/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=83=81=EC=84=B8=20=EC=A7=80=EC=9B=90=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=83=AD=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9E=91=EC=84=B1=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지원 불가 사유별 탭 처리 (로그인/프로필 작성 토스트, 그 외 사유 토스트) - RecruitApplyBlockReason.toastMessage 추가 - 프로필 미작성 시 프로필 작성 화면 이동, 작성 완료 후 상세 재조회 --- .../Recruit/RecruitApplyBlockReason.swift | 21 ++++++++++++++ .../RecruitDataHostingController.swift | 28 +++++++++++++++++++ .../Recruit/RecruitData/RecruitDataView.swift | 28 +++++++++++++++---- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/Koin/Domain/Model/Recruit/RecruitApplyBlockReason.swift b/Koin/Domain/Model/Recruit/RecruitApplyBlockReason.swift index eb9dbb1d8..90c7d52c9 100644 --- a/Koin/Domain/Model/Recruit/RecruitApplyBlockReason.swift +++ b/Koin/Domain/Model/Recruit/RecruitApplyBlockReason.swift @@ -29,4 +29,25 @@ extension RecruitApplyBlockReason { return "모집완료" } } + + var toastMessage: String { + switch self { + case .loginRequired: + return "로그인이 필요한 기능입니다." + case .profileRequired: + return "팀원모집 프로필이 필요합니다." + case .alreadyApplied: + return "이미 지원한 모집글입니다." + case .recruitmentDeleted: + return "삭제된 모집글입니다." + case .ownRecruitment: + return "내가 작성한 모집글입니다." + case .recruitmentClosed: + return "모집이 마감된 모집글입니다." + case .deadlinePassed: + return "마감일이 지난 모집글입니다." + case .roleClosed: + return "모집이 마감된 역할입니다." + } + } } diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index 2e38ba8e1..3bde0ae31 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -54,6 +54,8 @@ final class RecruitDataHostingController: UIHostingController, showToastMessage(message: message, bottomInset: 72) case .showLoginToast: showLoginToast() + case .showProfileRequiredToast: + showProfileRequiredToast() case .isAuthor(let isAuthor): configureRightBarButton(isAuthor) } @@ -86,6 +88,16 @@ extension RecruitDataHostingController { self?.navigateToLogin() } } + + private func showProfileRequiredToast() { + showToastMessageWithButton( + message: RecruitApplyBlockReason.profileRequired.toastMessage, + buttonTitle: "작성하기", + bottomInset: 72 + ) { [weak self] in + self?.navigateToProfilePost() + } + } } extension RecruitDataHostingController { @@ -157,6 +169,22 @@ extension RecruitDataHostingController { navigationController?.pushViewController(viewController, animated: true) } + private func navigateToProfilePost() { + let userRepository = DefaultUserRepository(service: DefaultUserService()) + let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) + let viewModel = RecruitProfilePostViewModel( + fetchDeptListUseCase: MockFetchDeptListUseCase(), + fetchUserDataUseCase: DefaultFetchUserDataUseCase(userRepository: userRepository), + postBasicInfoUseCase: DefaultPostBasicInfoUseCase(repository: recruitRepository), + postRecruitProfileUseCase: DefaultPostRecruitProfileUseCase(repository: recruitRepository), + mode: .post + ) + let viewController = RecruitProfilePostViewController(viewModel: viewModel) { [weak self] _ in + self?.rootView.reload() + } + navigationController?.pushViewController(viewController, animated: true) + } + private func handleDelete() { guard let id = rootView.id else { return diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift index d883b849d..2ab790001 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift @@ -13,6 +13,7 @@ struct RecruitDataView: ActionBindableView { case showApplicant case didDelete case showLoginToast + case showProfileRequiredToast case showToast(message: String) case isAuthor(Bool) } @@ -67,6 +68,10 @@ struct RecruitDataView: ActionBindableView { viewModel.execute(.delete) } + func reload() { + viewModel.execute(.load) + } + func didSubmitApplication() { viewModel.execute(.applicationSubmitted) } @@ -77,17 +82,30 @@ extension RecruitDataView { guard let data = viewModel.data else { return } - guard UserDataManager.shared.isLoggedIn else { - sendAction(.showLoginToast) - return - } + if data.isAuthor { sendAction(.showApplicant) + return } else if data.canApply { sendAction(.showApply) + return + } + + guard let applyBlockReason = data.applyBlockReason else { + return + } + switch applyBlockReason { + case .loginRequired: + sendAction(.showLoginToast) + case .profileRequired: + sendAction(.showProfileRequiredToast) + default: + sendAction(.showToast(message: applyBlockReason.toastMessage)) } } - +} + +extension RecruitDataView { private func handleDelete() { guard viewModel.didDelete else { return From 9a566fae7aaed7c52e633e26c1bcc63f87092488 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Fri, 2 Oct 2026 19:38:11 +0900 Subject: [PATCH 059/123] =?UTF-8?q?refactor:=20RecruitDataViewModel=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=83=81=ED=83=9C=EB=A5=BC=20Task=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isLoading 을 fetchTask / deleteTask 기준 계산 프로퍼티로 변경 - 지원 완료 플래그(didSubmitApplication) 제거, 재조회 시 서버 상태 사용 --- .../RecruitData/RecruitDataViewModel.swift | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift index 355e7e0f4..5d96abf23 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift @@ -21,15 +21,22 @@ final class RecruitDataViewModel: SwiftUIViewModelProtocol { // MARK: - State private(set) var data: RecruitData? - private(set) var isLoading = false private(set) var errorMessage: String? private(set) var didDelete = false - private var didSubmitApplication = false + + var isLoading: Bool { + if fetchTask == nil && deleteTask == nil { + return false + } + return true + } // MARK: - Properties private let recruitId: Int private let fetchRecruitDataUseCase: FetchRecruitDataUseCase private let deleteRecruitDataUseCase: DeleteRecruitDataUseCase + private var fetchTask: Task? + private var deleteTask: Task? // MARK: - Initializer init( @@ -42,6 +49,7 @@ final class RecruitDataViewModel: SwiftUIViewModelProtocol { self.recruitId = recruitId } + // MARK: - Public func execute(_ input: Input) { switch input { case .load: @@ -60,36 +68,28 @@ extension RecruitDataViewModel { private func load() { guard !isLoading else { return } - Task { + fetchTask = Task { + defer { + fetchTask = nil + } do { - isLoading = true - defer { - isLoading = false - } - var data = try await fetchRecruitDataUseCase.execute(id: recruitId) - if didSubmitApplication { - data.markAsAlreadyApplied() - } + let data = try await fetchRecruitDataUseCase.execute(id: recruitId) self.data = data } catch { - if let error = error as? ErrorResponse { - errorMessage = error.message - } + errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription } } } private func delete() { - guard let data else { + guard let data, !isLoading else { return } - Task { + deleteTask = Task { + defer { + deleteTask = nil + } do { - isLoading = true - defer { - isLoading = false - } - let result = try await deleteRecruitDataUseCase.execute(id: data.id) guard result == true else { errorMessage = "오류가 발생했습니다." @@ -97,16 +97,15 @@ extension RecruitDataViewModel { } self.didDelete = true } catch { - if let error = error as? ErrorResponse { - errorMessage = error.message - } + errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription self.didDelete = false } } } +} +extension RecruitDataViewModel { private func applicationSubmitted() { - didSubmitApplication = true data?.markAsAlreadyApplied() load() } From 400263508a19ee8afbe5defc48ca4ca43d4c95ca Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Fri, 2 Oct 2026 19:38:43 +0900 Subject: [PATCH 060/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EB=AA=A8=EC=A7=91=EA=B8=80=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DELETE /team-recruitments/{recruitmentId} 연결 --- Koin/Data/Repository/DefaultRecruitRepository.swift | 9 +++++---- Koin/Data/Service/Network/API/RecruitAPI.swift | 9 ++++++--- Koin/Data/Service/RecruitService.swift | 5 +++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift index e178c44f1..5132d9c70 100644 --- a/Koin/Data/Repository/DefaultRecruitRepository.swift +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -25,6 +25,11 @@ final class DefaultRecruitRepository: RecruitRepository { func fetchData(_ id: Int) async throws -> RecruitData { try await service.fetchData(id).toDomain() } + + func deleteData(id: Int) async throws -> Bool { + try await service.deleteData(id) + return true + } } extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 @@ -117,10 +122,6 @@ extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 try await mockRepository.modify(id, request) } - func deleteData(id: Int) async throws -> Bool { - try await mockRepository.deleteData(id: id) - } - func fetchMyProfile() async throws -> RecruitProfile { try await mockRepository.fetchMyProfile() } diff --git a/Koin/Data/Service/Network/API/RecruitAPI.swift b/Koin/Data/Service/Network/API/RecruitAPI.swift index 2ccad71cd..8c8dd74cc 100644 --- a/Koin/Data/Service/Network/API/RecruitAPI.swift +++ b/Koin/Data/Service/Network/API/RecruitAPI.swift @@ -11,6 +11,7 @@ import Alamofire enum RecruitAPI { case fetchList(RecruitListRequestDto) case fetchData(Int) + case deleteData(Int) } extension RecruitAPI: Router, URLRequestConvertible { @@ -23,6 +24,7 @@ extension RecruitAPI: Router, URLRequestConvertible { switch self { case .fetchList: return "/team-recruitments" case .fetchData(let id): return "/team-recruitments/\(id)" + case .deleteData(let id): return "/team-recruitments/\(id)" } } @@ -30,13 +32,14 @@ extension RecruitAPI: Router, URLRequestConvertible { switch self { case .fetchList: return .get case .fetchData: return .get + case .deleteData: return .delete } } public var headers: [String: String] { let baseHeaders: [String: String] = [:] switch self { - case .fetchList, .fetchData: + case .fetchList, .fetchData, .deleteData: break } return baseHeaders @@ -46,7 +49,7 @@ extension RecruitAPI: Router, URLRequestConvertible { switch self { case .fetchList(let request): return try? request.toDictionary() - case .fetchData: + case .fetchData, .deleteData: return nil } } @@ -55,7 +58,7 @@ extension RecruitAPI: Router, URLRequestConvertible { switch self { case .fetchList: return URLEncoding(arrayEncoding: .noBrackets) - case .fetchData: + case .fetchData, .deleteData: return nil } } diff --git a/Koin/Data/Service/RecruitService.swift b/Koin/Data/Service/RecruitService.swift index 7f2f8791d..7a521c3f9 100644 --- a/Koin/Data/Service/RecruitService.swift +++ b/Koin/Data/Service/RecruitService.swift @@ -10,6 +10,7 @@ import Foundation protocol RecruitService { func fetchList(_ request: RecruitListRequestDto) async throws -> RecruitListDto func fetchData(_ id: Int) async throws -> RecruitDataDto + func deleteData(_ id: Int) async throws } final class DefaultRecruitService: RecruitService { @@ -23,4 +24,8 @@ final class DefaultRecruitService: RecruitService { func fetchData(_ id: Int) async throws -> RecruitDataDto { try await networkService.requestWithResponse(api: RecruitAPI.fetchData(id)) } + + func deleteData(_ id: Int) async throws { + try await networkService.request(api: RecruitAPI.deleteData(id)) + } } From 627436d355ec004265dc718e80d420ab4b2a2ebc Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 3 Oct 2026 00:02:53 +0900 Subject: [PATCH 061/123] =?UTF-8?q?fix:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=9E=91=EC=84=B1=20=ED=99=94=EB=A9=B4=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20=EC=A0=9C=EC=B6=9C?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EB=A1=9C=EB=94=A9=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isValid 활동 기간 조건 수정 (시작일 ≤ 종료일) - 제출 버튼이 configuration 미사용이라 로딩 해제가 안 되던 문제 수정 - 로딩 중 IndicatorView 표시, 종료 시 validate() 로 버튼 상태 복구 - 관련 링크 입력 키보드 타입 .URL --- Koin/Domain/Model/Recruit/RecruitPostRequest.swift | 2 +- .../RecruitPost/RecruitPostViewController.swift | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Koin/Domain/Model/Recruit/RecruitPostRequest.swift b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift index e28623daa..ceb29add1 100644 --- a/Koin/Domain/Model/Recruit/RecruitPostRequest.swift +++ b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift @@ -32,7 +32,7 @@ struct RecruitPostRequest { let deadline, let description, !description.isEmpty, - !(startDate < endDate), + !(endDate < startDate), !(endDate < deadline) else { return false } diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift index e2bb09677..72ca792c7 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift @@ -73,6 +73,7 @@ final class RecruitPostViewController: UIViewController { isRequired: false, limit: nil, placeholder: "공모전/대외활동 등 모집글 관련 Url을 작성해주세요.", + keyboardType: .URL ) private let qualificationView = RecruitPostTextViewView( title: "지원 자격", @@ -289,12 +290,14 @@ extension RecruitPostViewController { extension RecruitPostViewController { private func updateLoading(_ isLoading: Bool) { - guard var configuration = postButton.configuration else { - return - } - configuration.showsActivityIndicator = isLoading - postButton.configuration = configuration postButton.isUserInteractionEnabled = !isLoading + if isLoading { + IndicatorView.show() + postButton.backgroundColor = .appColor(.neutral400) + } else { + IndicatorView.dismiss() + validate() + } } private func handlePostCompleted(id: Int) { From 7248c7724119f92101700f304787529d405231c4 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 3 Oct 2026 00:03:28 +0900 Subject: [PATCH 062/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EA=B4=80=EB=A0=A8=20=EB=A7=81=ED=81=AC=20https=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스킴 없으면 https:// 추가, https:// 는 소문자로 정규화 - https:// 외 스킴은 recruitRelatedUrlSchemeError 로 제출 차단 (토스트) - 작성/수정 UseCase 에서 repository 호출 전 적용 --- Koin/Domain/Model/Core/ErrorResponse.swift | 1 + .../Model/Recruit/RecruitPostRequest.swift | 19 +++++++++++++++++++ .../RecruitPost/ModifyRecruitUseCase.swift | 2 ++ .../RecruitPost/PostRecruitUseCase.swift | 2 ++ 4 files changed, 24 insertions(+) diff --git a/Koin/Domain/Model/Core/ErrorResponse.swift b/Koin/Domain/Model/Core/ErrorResponse.swift index d13163d55..3bfbc815d 100644 --- a/Koin/Domain/Model/Core/ErrorResponse.swift +++ b/Koin/Domain/Model/Core/ErrorResponse.swift @@ -37,6 +37,7 @@ extension ErrorResponse { static let typeCastingError = ErrorResponse(statusCode: nil, code: "TYPE_CASTING_ERROR", message: "타입 캐스팅 실패") static let networkError = ErrorResponse(statusCode: nil, code: "NETWORK_ERROR", message: "서버 응답 오류") static let invalidUrl = ErrorResponse(statusCode: nil, code: "INVALID_URL", message: "URL이 유효하지 않습니다.") + static let recruitRelatedUrlSchemeError = ErrorResponse(statusCode: nil, code: "RECRUIT_RELATED_URL_SCHEME_ERROR", message: "링크는 https:// 로 시작해야 합니다.") static let invalidApi = ErrorResponse(statusCode: nil, code: "INVALID_API", message: "API가 유효하지 않습니다.") static let fileManagerFailedDirectory = ErrorResponse(statusCode: nil, code: "FILEMANAGER_FAILED_DIRECTORY", message: "파일 저장 위치 찾기 실패") static let deleteKeywordError = ErrorResponse(statusCode: nil, code: "DELETE_KEYWORD_ERROR", message: "로그인에 실패하여 코어데이터에서 키워드 삭제") diff --git a/Koin/Domain/Model/Recruit/RecruitPostRequest.swift b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift index ceb29add1..2fb66f9f0 100644 --- a/Koin/Domain/Model/Recruit/RecruitPostRequest.swift +++ b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift @@ -86,3 +86,22 @@ extension RecruitRoleRequest { self.maximumParticipants = form.maximumParticipants } } + +extension RecruitPostRequest { + mutating func normalizeRelatedUrl() throws { + guard let url = relatedUrl?.trimmingCharacters(in: .whitespacesAndNewlines), + !url.isEmpty else { + relatedUrl = nil + return + } + + let scheme = "https://" + if url.lowercased().hasPrefix(scheme) { + relatedUrl = scheme + url.dropFirst(scheme.count) + } else if url.contains("://") { + throw ErrorResponse.recruitRelatedUrlSchemeError + } else { + relatedUrl = scheme + url + } + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitPost/ModifyRecruitUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitPost/ModifyRecruitUseCase.swift index 1bd5492a4..4ee293efb 100644 --- a/Koin/Domain/UseCase/Recruit/RecruitPost/ModifyRecruitUseCase.swift +++ b/Koin/Domain/UseCase/Recruit/RecruitPost/ModifyRecruitUseCase.swift @@ -20,6 +20,8 @@ final class DefaultModifyRecruitUseCase: ModifyRecruitUseCase { } func execute(id: Int, request: RecruitPostRequest) async throws -> Void { + var request = request + try request.normalizeRelatedUrl() try await repository.modify(id, request) } } diff --git a/Koin/Domain/UseCase/Recruit/RecruitPost/PostRecruitUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitPost/PostRecruitUseCase.swift index 71d372c42..fe266fe12 100644 --- a/Koin/Domain/UseCase/Recruit/RecruitPost/PostRecruitUseCase.swift +++ b/Koin/Domain/UseCase/Recruit/RecruitPost/PostRecruitUseCase.swift @@ -20,6 +20,8 @@ final class DefaultPostRecruitUseCase: PostRecruitUseCase { } func execute(request: RecruitPostRequest) async throws -> Int { + var request = request + try request.normalizeRelatedUrl() return try await repository.post(request) } } From 4519bd192e47e3d59bf81e18cccb4a5224e163f2 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 3 Oct 2026 00:04:07 +0900 Subject: [PATCH 063/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=9E=91=EC=84=B1/=EC=88=98=EC=A0=95=20API=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /team-recruitments, PUT /team-recruitments/{recruitmentId} 연결 - RecruitPostRequestDto(작성/수정 공용), RecruitPostResultDto 신설 - RecruitRoleRequest 에 id 추가 (수정 시 기존 역할 유지) - RecruitRoleTypeDto 인코딩 지원 --- .../Decodable/Recruit/RecruitEnumDto.swift | 11 ++- .../Recruit/RecruitPostResultDto.swift | 12 +++ .../Recruit/RecruitPostRequestDto.swift | 90 +++++++++++++++++++ .../Repository/DefaultRecruitRepository.swift | 22 +++-- .../Data/Service/Network/API/RecruitAPI.swift | 14 ++- Koin/Data/Service/RecruitService.swift | 10 +++ .../Model/Recruit/RecruitPostRequest.swift | 2 + koin.xcodeproj/project.pbxproj | 8 ++ 8 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 Koin/Data/DTOs/Decodable/Recruit/RecruitPostResultDto.swift create mode 100644 Koin/Data/DTOs/Encodable/Recruit/RecruitPostRequestDto.swift diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift index 57b61dc3e..f766fa291 100644 --- a/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift @@ -26,7 +26,7 @@ enum RecruitStatusDto: String, Decodable { case closed = "CLOSED" } -enum RecruitRoleTypeDto: String, Decodable { +enum RecruitRoleTypeDto: String, Codable { case roleBased = "ROLE_BASED" case general = "GENERAL" } @@ -116,6 +116,15 @@ extension RecruitStatusDto { } extension RecruitRoleTypeDto { + init(from model: RecruitRoleType) { + switch model { + case .roleBased: + self = .roleBased + case .general: + self = .general + } + } + func toDomain() -> RecruitRoleType { switch self { case .roleBased: diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitPostResultDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitPostResultDto.swift new file mode 100644 index 000000000..0cc3d1d55 --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitPostResultDto.swift @@ -0,0 +1,12 @@ +// +// RecruitPostResultDto.swift +// koin +// +// Created by 홍기정 on 10/2/26. +// + +import Foundation + +struct RecruitPostResultDto: Decodable { + let id: Int +} diff --git a/Koin/Data/DTOs/Encodable/Recruit/RecruitPostRequestDto.swift b/Koin/Data/DTOs/Encodable/Recruit/RecruitPostRequestDto.swift new file mode 100644 index 000000000..3a8b5a109 --- /dev/null +++ b/Koin/Data/DTOs/Encodable/Recruit/RecruitPostRequestDto.swift @@ -0,0 +1,90 @@ +// +// RecruitPostRequestDto.swift +// koin +// +// Created by 홍기정 on 10/2/26. +// + +import Foundation + +struct RecruitPostRequestDto: Encodable { + let category: RecruitCategoryDto + let title: String + let meetingType: RecruitMeetingTypeDto + let activityStartDate: String + let activityEndDate: String + let deadlineDate: String + let recruitmentType: RecruitRoleTypeDto + let maxParticipants: Int? + let roles: [RecruitRoleRequestDto] + let description: String + let relatedUrl: String? + let qualification: String? + + enum CodingKeys: String, CodingKey { + case category, title + case meetingType = "meeting_type" + case activityStartDate = "activity_start_date" + case activityEndDate = "activity_end_date" + case deadlineDate = "deadline_date" + case recruitmentType = "recruitment_type" + case maxParticipants = "max_participants" + case roles, description + case relatedUrl = "related_url" + case qualification + } +} + +struct RecruitRoleRequestDto: Encodable { + let id: Int? + let name: String + let maxParticipants: Int + + enum CodingKeys: String, CodingKey { + case id, name + case maxParticipants = "max_participants" + } +} + +extension RecruitPostRequestDto { + init?(from request: RecruitPostRequest) { + guard let category = request.category, + let title = request.title, + let meetingType = request.meetingType, + let startDate = request.startDate, + let endDate = request.endDate, + let deadline = request.deadline, + let description = request.description else { + return nil + } + + self.category = RecruitCategoryDto(from: category) + self.title = title + self.meetingType = RecruitMeetingTypeDto(from: meetingType) + self.activityStartDate = startDate.formatDateToYYYYMMDD(separator: "-") + self.activityEndDate = endDate.formatDateToYYYYMMDD(separator: "-") + self.deadlineDate = deadline.formatDateToYYYYMMDD(separator: "-") + self.recruitmentType = RecruitRoleTypeDto(from: request.type) + switch request.type { + case .roleBased: + self.maxParticipants = nil + self.roles = request.roles.map { RecruitRoleRequestDto(from: $0) } + case .general: + self.maxParticipants = request.numberOfGeneralMembers + self.roles = [] + } + self.description = description + self.relatedUrl = request.relatedUrl + + let qualification = request.qualification?.trimmingCharacters(in: .whitespacesAndNewlines) + self.qualification = (qualification?.isEmpty ?? true) ? nil : qualification + } +} + +extension RecruitRoleRequestDto { + init(from request: RecruitRoleRequest) { + self.id = request.id + self.name = request.name + self.maxParticipants = request.maximumParticipants + } +} diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift index 5132d9c70..b2928d95c 100644 --- a/Koin/Data/Repository/DefaultRecruitRepository.swift +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -30,6 +30,20 @@ final class DefaultRecruitRepository: RecruitRepository { try await service.deleteData(id) return true } + + func post(_ request: RecruitPostRequest) async throws -> Int { + guard let request = RecruitPostRequestDto(from: request) else { + throw ErrorResponse.unexpectedInternalError + } + return try await service.post(request).id + } + + func modify(_ id: Int, _ request: RecruitPostRequest) async throws -> Void { + guard let request = RecruitPostRequestDto(from: request) else { + throw ErrorResponse.unexpectedInternalError + } + try await service.modify(id, request) + } } extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 @@ -114,14 +128,6 @@ extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 try await mockRepository.markAllAsReadNotification() } - func post(_ request: RecruitPostRequest) async throws -> Int { - try await mockRepository.post(request) - } - - func modify(_ id: Int, _ request: RecruitPostRequest) async throws -> Void { - try await mockRepository.modify(id, request) - } - func fetchMyProfile() async throws -> RecruitProfile { try await mockRepository.fetchMyProfile() } diff --git a/Koin/Data/Service/Network/API/RecruitAPI.swift b/Koin/Data/Service/Network/API/RecruitAPI.swift index 8c8dd74cc..648ed7511 100644 --- a/Koin/Data/Service/Network/API/RecruitAPI.swift +++ b/Koin/Data/Service/Network/API/RecruitAPI.swift @@ -12,6 +12,8 @@ enum RecruitAPI { case fetchList(RecruitListRequestDto) case fetchData(Int) case deleteData(Int) + case post(RecruitPostRequestDto) + case modify(Int, RecruitPostRequestDto) } extension RecruitAPI: Router, URLRequestConvertible { @@ -25,6 +27,8 @@ extension RecruitAPI: Router, URLRequestConvertible { case .fetchList: return "/team-recruitments" case .fetchData(let id): return "/team-recruitments/\(id)" case .deleteData(let id): return "/team-recruitments/\(id)" + case .post: return "/team-recruitments" + case .modify(let id, _): return "/team-recruitments/\(id)" } } @@ -33,14 +37,18 @@ extension RecruitAPI: Router, URLRequestConvertible { case .fetchList: return .get case .fetchData: return .get case .deleteData: return .delete + case .post: return .post + case .modify: return .put } } public var headers: [String: String] { - let baseHeaders: [String: String] = [:] + var baseHeaders: [String: String] = [:] switch self { case .fetchList, .fetchData, .deleteData: break + case .post, .modify: + baseHeaders["Content-Type"] = "application/json" } return baseHeaders } @@ -51,6 +59,8 @@ extension RecruitAPI: Router, URLRequestConvertible { return try? request.toDictionary() case .fetchData, .deleteData: return nil + case .post(let request), .modify(_, let request): + return try? request.toDictionary() } } @@ -60,6 +70,8 @@ extension RecruitAPI: Router, URLRequestConvertible { return URLEncoding(arrayEncoding: .noBrackets) case .fetchData, .deleteData: return nil + case .post, .modify: + return JSONEncoding.default } } } diff --git a/Koin/Data/Service/RecruitService.swift b/Koin/Data/Service/RecruitService.swift index 7a521c3f9..ce3f48521 100644 --- a/Koin/Data/Service/RecruitService.swift +++ b/Koin/Data/Service/RecruitService.swift @@ -11,6 +11,8 @@ protocol RecruitService { func fetchList(_ request: RecruitListRequestDto) async throws -> RecruitListDto func fetchData(_ id: Int) async throws -> RecruitDataDto func deleteData(_ id: Int) async throws + func post(_ request: RecruitPostRequestDto) async throws -> RecruitPostResultDto + func modify(_ id: Int, _ request: RecruitPostRequestDto) async throws } final class DefaultRecruitService: RecruitService { @@ -28,4 +30,12 @@ final class DefaultRecruitService: RecruitService { func deleteData(_ id: Int) async throws { try await networkService.request(api: RecruitAPI.deleteData(id)) } + + func post(_ request: RecruitPostRequestDto) async throws -> RecruitPostResultDto { + try await networkService.requestWithResponse(api: RecruitAPI.post(request)) + } + + func modify(_ id: Int, _ request: RecruitPostRequestDto) async throws { + try await networkService.request(api: RecruitAPI.modify(id, request)) + } } diff --git a/Koin/Domain/Model/Recruit/RecruitPostRequest.swift b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift index 2fb66f9f0..084e6b681 100644 --- a/Koin/Domain/Model/Recruit/RecruitPostRequest.swift +++ b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift @@ -55,6 +55,7 @@ struct RecruitPostRequest { } struct RecruitRoleRequest { + var id: Int? = nil var name: String = "" var maximumParticipants: Int = 1 @@ -82,6 +83,7 @@ extension RecruitPostRequest { extension RecruitRoleRequest { init(from form: RecruitRole) { + self.id = form.id self.name = form.name self.maximumParticipants = form.maximumParticipants } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 50096a1eb..53c27c4ec 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 247DF973D072E88901E5C249 /* RecruitProfilePostDepartmentDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */; }; 2E6336C1CDB4445BA858DA5A /* RecruitDataContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */; }; 3DDE20379881F001713618FC /* RecruitDataDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA92583628DDFBC3545C197B /* RecruitDataDto.swift */; }; + 3E56520894546C11EB8F99B3 /* RecruitPostRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE562D9FEDE6181748BE67B /* RecruitPostRequestDto.swift */; }; 5006987DEF4B5F454D7EA2D5 /* RecruitPostTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */; }; 515B96092C6730C797C1F837 /* PostRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D447F2D75951DD4C3E08D4E /* PostRecruitUseCase.swift */; }; 678D20761DEF461CA6D4728A /* RecruitDataTextSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2FBEEB767094CE8B219095B /* RecruitDataTextSectionView.swift */; }; @@ -834,6 +835,7 @@ 8A7B1679CC9945559E674584 /* RecruitDataRolesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */; }; 91E82A6B67BF5BF911535B1C /* RecruitMyPostData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87039C64B7CB2AEB632113C6 /* RecruitMyPostData.swift */; }; 9059A862F4E9CDA304B2F082 /* RecruitEnumDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E31EA58151824C938383893 /* RecruitEnumDto.swift */; }; + 9419498912D565DE98568476 /* RecruitPostResultDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267C5F2D082AC60FDA16F85C /* RecruitPostResultDto.swift */; }; 999FC904C4F9ED4E2B829341 /* RecruitApplyBlockReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB6A15BFD7B896A787835F8E /* RecruitApplyBlockReason.swift */; }; 9C95E190C98F440DADB6F91B /* RecruitDataMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DADCC36CEEA42EC86AB75B6 /* RecruitDataMetadataView.swift */; }; A0B343754988C9398C4FE726 /* ModifyRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */; }; @@ -1227,9 +1229,11 @@ 0FCD163C2BA80EAA00C23A2F /* KeychainWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainWorker.swift; sourceTree = ""; }; 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostTextFieldView.swift; sourceTree = ""; }; 21AA98431B25CB5914BC2598 /* RecruitListDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitListDto.swift; sourceTree = ""; }; + 267C5F2D082AC60FDA16F85C /* RecruitPostResultDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitPostResultDto.swift; sourceTree = ""; }; 288BC1E04044DC3FB48524FC /* RecruitPostViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostViewModel.swift; sourceTree = ""; }; 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostDepartmentDropdownView.swift; sourceTree = ""; }; 2E31EA58151824C938383893 /* RecruitEnumDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitEnumDto.swift; sourceTree = ""; }; + 2FE562D9FEDE6181748BE67B /* RecruitPostRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitPostRequestDto.swift; sourceTree = ""; }; 3C3694E3ACBE05B8C7F15357 /* RecruitPostSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostSectionHeaderView.swift; sourceTree = ""; }; 4067CAF18D134F99A04BBA0D /* RecruitListRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitListRequestDto.swift; sourceTree = ""; }; 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostCategoryView.swift; sourceTree = ""; }; @@ -2464,6 +2468,7 @@ 7C0D8CC2306E837F00F7CE08 /* RecruitListSummaryDto.swift */, 7C0D8CC4306E83A500F7CE08 /* RecruitRoleDto.swift */, EA92583628DDFBC3545C197B /* RecruitDataDto.swift */, + 267C5F2D082AC60FDA16F85C /* RecruitPostResultDto.swift */, ); path = Recruit; sourceTree = ""; @@ -5558,6 +5563,7 @@ isa = PBXGroup; children = ( 4067CAF18D134F99A04BBA0D /* RecruitListRequestDto.swift */, + 2FE562D9FEDE6181748BE67B /* RecruitPostRequestDto.swift */, ); path = Recruit; sourceTree = ""; @@ -7861,6 +7867,8 @@ CE3D147031BC1F6BDD25184B /* DefaultRecruitRepository.swift in Sources */, 3DDE20379881F001713618FC /* RecruitDataDto.swift in Sources */, 999FC904C4F9ED4E2B829341 /* RecruitApplyBlockReason.swift in Sources */, + 3E56520894546C11EB8F99B3 /* RecruitPostRequestDto.swift in Sources */, + 9419498912D565DE98568476 /* RecruitPostResultDto.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 0ef1a14a9ec7422dbca8a4f8a1355e0693ec8cb3 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 3 Oct 2026 00:46:20 +0900 Subject: [PATCH 064/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EB=82=B4=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /team-recruitment-profiles/me 연결 (RecruitProfileDto 신설) - FetchMyProfileUseCase 반환 타입 RecruitProfile? 로 변경, 404 는 nil 반환 - 프로필/지원 화면 ViewModel 을 optional 결과 기준으로 정리 --- .../Decodable/Recruit/RecruitProfileDto.swift | 75 +++++++++++++++++++ .../Repository/DefaultRecruitRepository.swift | 8 +- .../Data/Service/Network/API/RecruitAPI.swift | 9 ++- Koin/Data/Service/RecruitService.swift | 5 ++ .../FetchMyProfileUseCase.swift | 10 ++- .../RecruitApply/RecruitApplyViewModel.swift | 6 +- .../RecruitProfileViewModel.swift | 10 +-- koin.xcodeproj/project.pbxproj | 6 +- 8 files changed, 108 insertions(+), 21 deletions(-) create mode 100644 Koin/Data/DTOs/Decodable/Recruit/RecruitProfileDto.swift diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitProfileDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitProfileDto.swift new file mode 100644 index 000000000..e1d5db7cc --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitProfileDto.swift @@ -0,0 +1,75 @@ +// +// RecruitProfileDto.swift +// koin +// +// Created by 홍기정 on 10/3/26. +// + +import Foundation + +struct RecruitProfileDto: Decodable { + let profileNickname: String + let department: String + let major: String? + let studentNumber: String + let preferredRole: String + let skills: [String] + let activities: [RecruitProfileActivityDto] + let selfIntroduction: String + + enum CodingKeys: String, CodingKey { + case profileNickname = "profile_nickname" + case department, major + case studentNumber = "student_number" + case preferredRole = "preferred_role" + case skills, activities + case selfIntroduction = "self_introduction" + } +} + +struct RecruitProfileActivityDto: Decodable { + let id: Int + let title: String + let startedAt: String + let endedAt: String? + let isOngoing: Bool + let description: String + + enum CodingKeys: String, CodingKey { + case id, title + case startedAt = "started_at" + case endedAt = "ended_at" + case isOngoing = "is_ongoing" + case description + } +} + +extension RecruitProfileDto { + func toDomain() -> RecruitProfile { + return RecruitProfile( + nickname: profileNickname, + department: department, + studentNumber: studentNumber, + preferredRole: preferredRole, + skills: skills, + activities: activities.compactMap { $0.toDomain() }, + selfIntroduction: selfIntroduction + ) + } +} + +extension RecruitProfileActivityDto { + func toDomain() -> RecruitProfileActivity? { + guard let startedAt = startedAt.toDateFromYYYYMMDD() else { + return nil + } + return RecruitProfileActivity( + id: id, + title: title, + startedAt: startedAt, + endedAt: endedAt?.toDateFromYYYYMMDD(), + isOngoing: isOngoing, + description: description + ) + } +} diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift index b2928d95c..355d40f62 100644 --- a/Koin/Data/Repository/DefaultRecruitRepository.swift +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -44,6 +44,10 @@ final class DefaultRecruitRepository: RecruitRepository { } try await service.modify(id, request) } + + func fetchMyProfile() async throws -> RecruitProfile { + try await service.fetchMyProfile().toDomain() + } } extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 @@ -128,10 +132,6 @@ extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 try await mockRepository.markAllAsReadNotification() } - func fetchMyProfile() async throws -> RecruitProfile { - try await mockRepository.fetchMyProfile() - } - func postBasicInfo(_ basicInfo: BasicInfo) async throws -> BasicInfo { try await mockRepository.postBasicInfo(basicInfo) } diff --git a/Koin/Data/Service/Network/API/RecruitAPI.swift b/Koin/Data/Service/Network/API/RecruitAPI.swift index 648ed7511..d2bb7f887 100644 --- a/Koin/Data/Service/Network/API/RecruitAPI.swift +++ b/Koin/Data/Service/Network/API/RecruitAPI.swift @@ -14,6 +14,7 @@ enum RecruitAPI { case deleteData(Int) case post(RecruitPostRequestDto) case modify(Int, RecruitPostRequestDto) + case fetchMyProfile } extension RecruitAPI: Router, URLRequestConvertible { @@ -29,6 +30,7 @@ extension RecruitAPI: Router, URLRequestConvertible { case .deleteData(let id): return "/team-recruitments/\(id)" case .post: return "/team-recruitments" case .modify(let id, _): return "/team-recruitments/\(id)" + case .fetchMyProfile: return "/team-recruitment-profiles/me" } } @@ -39,13 +41,14 @@ extension RecruitAPI: Router, URLRequestConvertible { case .deleteData: return .delete case .post: return .post case .modify: return .put + case .fetchMyProfile: return .get } } public var headers: [String: String] { var baseHeaders: [String: String] = [:] switch self { - case .fetchList, .fetchData, .deleteData: + case .fetchList, .fetchData, .deleteData, .fetchMyProfile: break case .post, .modify: baseHeaders["Content-Type"] = "application/json" @@ -57,7 +60,7 @@ extension RecruitAPI: Router, URLRequestConvertible { switch self { case .fetchList(let request): return try? request.toDictionary() - case .fetchData, .deleteData: + case .fetchData, .deleteData, .fetchMyProfile: return nil case .post(let request), .modify(_, let request): return try? request.toDictionary() @@ -68,7 +71,7 @@ extension RecruitAPI: Router, URLRequestConvertible { switch self { case .fetchList: return URLEncoding(arrayEncoding: .noBrackets) - case .fetchData, .deleteData: + case .fetchData, .deleteData, .fetchMyProfile: return nil case .post, .modify: return JSONEncoding.default diff --git a/Koin/Data/Service/RecruitService.swift b/Koin/Data/Service/RecruitService.swift index ce3f48521..c6ab9d5dc 100644 --- a/Koin/Data/Service/RecruitService.swift +++ b/Koin/Data/Service/RecruitService.swift @@ -13,6 +13,7 @@ protocol RecruitService { func deleteData(_ id: Int) async throws func post(_ request: RecruitPostRequestDto) async throws -> RecruitPostResultDto func modify(_ id: Int, _ request: RecruitPostRequestDto) async throws + func fetchMyProfile() async throws -> RecruitProfileDto } final class DefaultRecruitService: RecruitService { @@ -38,4 +39,8 @@ final class DefaultRecruitService: RecruitService { func modify(_ id: Int, _ request: RecruitPostRequestDto) async throws { try await networkService.request(api: RecruitAPI.modify(id, request)) } + + func fetchMyProfile() async throws -> RecruitProfileDto { + try await networkService.requestWithResponse(api: RecruitAPI.fetchMyProfile) + } } diff --git a/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift index 21fd349af..a131b0fef 100644 --- a/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift +++ b/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift @@ -8,7 +8,7 @@ import Foundation protocol FetchMyProfileUseCase { - func execute() async throws -> RecruitProfile + func execute() async throws -> RecruitProfile? } final class DefaultFetchMyProfileUseCase: FetchMyProfileUseCase { @@ -18,7 +18,11 @@ final class DefaultFetchMyProfileUseCase: FetchMyProfileUseCase { self.repository = repository } - func execute() async throws -> RecruitProfile { - try await repository.fetchMyProfile() + func execute() async throws -> RecruitProfile? { + do { + return try await repository.fetchMyProfile() + } catch let error as ErrorResponse where error.statusCode == 404 { + return nil + } } } diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift index d5c7629a4..7567a0cc1 100644 --- a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift @@ -86,7 +86,11 @@ extension RecruitApplyViewModel { defer { isLoadingProfile = false } do { - let profile = try await fetchMyProfileUseCase.execute() + guard let profile = try await fetchMyProfileUseCase.execute() else { + let message = "프로필을 불러오지 못했습니다." + outputSubject.send(.showToast(message)) + return + } outputSubject.send(.updateRecruitProfile(profile)) } catch { let message = (error as? ErrorResponse)?.message ?? "프로필을 불러오지 못했습니다." diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift index 2f371d53a..3ad26515b 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift @@ -58,15 +58,7 @@ extension RecruitProfileViewModel { profile = try await fetchMyProfileUseCase.execute() didLoad = true } catch { - guard let error = error as? ErrorResponse else { - return - } - if error.statusCode == 404 { - profile = nil - didLoad = true - } else { - errorMessage = error.message - } + errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription } } } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 53c27c4ec..5b7d57120 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -833,8 +833,9 @@ 83EBB87A2D0FDFD700346018 /* KoinPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83EBB8792D0FDFD700346018 /* KoinPickerView.swift */; }; 83EBB87E2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83EBB87D2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift */; }; 8A7B1679CC9945559E674584 /* RecruitDataRolesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */; }; - 91E82A6B67BF5BF911535B1C /* RecruitMyPostData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87039C64B7CB2AEB632113C6 /* RecruitMyPostData.swift */; }; + 8CBCEFF4A1B4841D6B422B46 /* RecruitProfileDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A900DEA87E917C84BCD7E04 /* RecruitProfileDto.swift */; }; 9059A862F4E9CDA304B2F082 /* RecruitEnumDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E31EA58151824C938383893 /* RecruitEnumDto.swift */; }; + 91E82A6B67BF5BF911535B1C /* RecruitMyPostData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87039C64B7CB2AEB632113C6 /* RecruitMyPostData.swift */; }; 9419498912D565DE98568476 /* RecruitPostResultDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267C5F2D082AC60FDA16F85C /* RecruitPostResultDto.swift */; }; 999FC904C4F9ED4E2B829341 /* RecruitApplyBlockReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB6A15BFD7B896A787835F8E /* RecruitApplyBlockReason.swift */; }; 9C95E190C98F440DADB6F91B /* RecruitDataMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DADCC36CEEA42EC86AB75B6 /* RecruitDataMetadataView.swift */; }; @@ -1227,6 +1228,7 @@ /* Begin PBXFileReference section */ 0FCD163C2BA80EAA00C23A2F /* KeychainWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainWorker.swift; sourceTree = ""; }; + 1A900DEA87E917C84BCD7E04 /* RecruitProfileDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitProfileDto.swift; sourceTree = ""; }; 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostTextFieldView.swift; sourceTree = ""; }; 21AA98431B25CB5914BC2598 /* RecruitListDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitListDto.swift; sourceTree = ""; }; 267C5F2D082AC60FDA16F85C /* RecruitPostResultDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitPostResultDto.swift; sourceTree = ""; }; @@ -2469,6 +2471,7 @@ 7C0D8CC4306E83A500F7CE08 /* RecruitRoleDto.swift */, EA92583628DDFBC3545C197B /* RecruitDataDto.swift */, 267C5F2D082AC60FDA16F85C /* RecruitPostResultDto.swift */, + 1A900DEA87E917C84BCD7E04 /* RecruitProfileDto.swift */, ); path = Recruit; sourceTree = ""; @@ -7869,6 +7872,7 @@ 999FC904C4F9ED4E2B829341 /* RecruitApplyBlockReason.swift in Sources */, 3E56520894546C11EB8F99B3 /* RecruitPostRequestDto.swift in Sources */, 9419498912D565DE98568476 /* RecruitPostResultDto.swift in Sources */, + 8CBCEFF4A1B4841D6B422B46 /* RecruitProfileDto.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From bd0786922ceeef47f678f162594b1b53b6d3b85e Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 15:08:43 +0900 Subject: [PATCH 065/123] =?UTF-8?q?refactor:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=ED=94=84=EB=A1=9C=ED=95=84=20UseCase=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FetchMyProfileUseCase → FetchMyRecruitProfileUseCase - PostRecruitProfileUseCase → UpsertMyRecruitProfileUseCase - RecruitRepository.postRecruitProfile → upsertMyProfile --- .../Repository/DefaultRecruitRepository.swift | 4 ++-- Koin/Data/Repository/MockRecruitRepository.swift | 2 +- Koin/Domain/Repository/RecruitRepository.swift | 2 +- ....swift => FetchMyRecruitProfileUseCase.swift} | 6 +++--- ...swift => UpsertMyRecruitProfileUseCase.swift} | 8 ++++---- .../RecruitApply/RecruitApplyViewModel.swift | 8 ++++---- .../RecruitDataHostingController.swift | 4 ++-- .../RecruitListHostingController.swift | 4 ++-- .../RecruitProfileHostingController.swift | 4 ++-- .../RecruitProfile/RecruitProfileViewModel.swift | 8 ++++---- .../RecruitProfilePostViewModel.swift | 8 ++++---- koin.xcodeproj/project.pbxproj | 16 ++++++++-------- 12 files changed, 37 insertions(+), 37 deletions(-) rename Koin/Domain/UseCase/Recruit/RecruitProfile/{FetchMyProfileUseCase.swift => FetchMyRecruitProfileUseCase.swift} (75%) rename Koin/Domain/UseCase/Recruit/RecruitProfile/{PostRecruitProfileUseCase.swift => UpsertMyRecruitProfileUseCase.swift} (63%) diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift index 355d40f62..f1db82682 100644 --- a/Koin/Data/Repository/DefaultRecruitRepository.swift +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -136,8 +136,8 @@ extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 try await mockRepository.postBasicInfo(basicInfo) } - func postRecruitProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile { - try await mockRepository.postRecruitProfile(request) + func upsertMyProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile { + try await mockRepository.upsertMyProfile(request) } func apply(_ request: RecruitApplyRequest) async throws -> Void { diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 196c49624..ff6e23ed0 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -123,7 +123,7 @@ final class MockRecruitRepository: RecruitRepository { return basicInfo } - func postRecruitProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile { + func upsertMyProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile { try await Task.sleep(nanoseconds: 300_000_000) return RecruitProfile( diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index 28e38efea..ef7a905ce 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -50,6 +50,6 @@ protocol RecruitRepository { func deleteData(id: Int) async throws -> Bool func fetchMyProfile() async throws -> RecruitProfile func postBasicInfo(_ basicInfo: BasicInfo) async throws -> BasicInfo - func postRecruitProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile + func upsertMyProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile func apply(_ request: RecruitApplyRequest) async throws -> Void } diff --git a/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyRecruitProfileUseCase.swift similarity index 75% rename from Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift rename to Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyRecruitProfileUseCase.swift index a131b0fef..6de46b5a8 100644 --- a/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyProfileUseCase.swift +++ b/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyRecruitProfileUseCase.swift @@ -1,5 +1,5 @@ // -// FetchMyProfileUseCase.swift +// FetchMyRecruitProfileUseCase.swift // koin // // Created by 홍기정 on 9/13/26. @@ -7,11 +7,11 @@ import Foundation -protocol FetchMyProfileUseCase { +protocol FetchMyRecruitProfileUseCase { func execute() async throws -> RecruitProfile? } -final class DefaultFetchMyProfileUseCase: FetchMyProfileUseCase { +final class DefaultFetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase { private let repository: RecruitRepository init(repository: RecruitRepository) { diff --git a/Koin/Domain/UseCase/Recruit/RecruitProfile/PostRecruitProfileUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitProfile/UpsertMyRecruitProfileUseCase.swift similarity index 63% rename from Koin/Domain/UseCase/Recruit/RecruitProfile/PostRecruitProfileUseCase.swift rename to Koin/Domain/UseCase/Recruit/RecruitProfile/UpsertMyRecruitProfileUseCase.swift index 589ae2054..212b1331a 100644 --- a/Koin/Domain/UseCase/Recruit/RecruitProfile/PostRecruitProfileUseCase.swift +++ b/Koin/Domain/UseCase/Recruit/RecruitProfile/UpsertMyRecruitProfileUseCase.swift @@ -1,5 +1,5 @@ // -// PostRecruitProfileUseCase.swift +// UpsertMyRecruitProfileUseCase.swift // koin // // Created by 홍기정 on 9/21/26. @@ -7,11 +7,11 @@ import Foundation -protocol PostRecruitProfileUseCase { +protocol UpsertMyRecruitProfileUseCase { func execute(request: RecruitProfileRequest) async throws -> RecruitProfile } -final class DefaultPostRecruitProfileUseCase: PostRecruitProfileUseCase { +final class DefaultUpsertMyRecruitProfileUseCase: UpsertMyRecruitProfileUseCase { private let repository: RecruitRepository init(repository: RecruitRepository) { @@ -19,6 +19,6 @@ final class DefaultPostRecruitProfileUseCase: PostRecruitProfileUseCase { } func execute(request: RecruitProfileRequest) async throws -> RecruitProfile { - try await repository.postRecruitProfile(request) + try await repository.upsertMyProfile(request) } } diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift index 7567a0cc1..60d476586 100644 --- a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift @@ -25,7 +25,7 @@ final class RecruitApplyViewModel: ViewModelProtocol { // MARK: - Properties private let fetchDeptListUseCase: FetchDeptListUseCase - private let fetchMyProfileUseCase: FetchMyProfileUseCase + private let fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase private let applyRecruitUseCase: ApplyRecruitUseCase private let outputSubject = PassthroughSubject() private var subscriptions = Set() @@ -35,11 +35,11 @@ final class RecruitApplyViewModel: ViewModelProtocol { // MARK: - Initializer init( fetchDeptListUseCase: FetchDeptListUseCase, - fetchMyProfileUseCase: FetchMyProfileUseCase, + fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase, applyRecruitUseCase: ApplyRecruitUseCase ) { self.fetchDeptListUseCase = fetchDeptListUseCase - self.fetchMyProfileUseCase = fetchMyProfileUseCase + self.fetchMyRecruitProfileUseCase = fetchMyRecruitProfileUseCase self.applyRecruitUseCase = applyRecruitUseCase } @@ -86,7 +86,7 @@ extension RecruitApplyViewModel { defer { isLoadingProfile = false } do { - guard let profile = try await fetchMyProfileUseCase.execute() else { + guard let profile = try await fetchMyRecruitProfileUseCase.execute() else { let message = "프로필을 불러오지 못했습니다." outputSubject.send(.showToast(message)) return diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index 3bde0ae31..b93acab2f 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -158,7 +158,7 @@ extension RecruitDataHostingController { let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let viewModel = RecruitApplyViewModel( fetchDeptListUseCase: MockFetchDeptListUseCase(), - fetchMyProfileUseCase: DefaultFetchMyProfileUseCase(repository: recruitRepository), + fetchMyRecruitProfileUseCase: DefaultFetchMyRecruitProfileUseCase(repository: recruitRepository), applyRecruitUseCase: DefaultApplyRecruitUseCase(repository: recruitRepository) ) let viewController = RecruitApplyViewController( @@ -176,7 +176,7 @@ extension RecruitDataHostingController { fetchDeptListUseCase: MockFetchDeptListUseCase(), fetchUserDataUseCase: DefaultFetchUserDataUseCase(userRepository: userRepository), postBasicInfoUseCase: DefaultPostBasicInfoUseCase(repository: recruitRepository), - postRecruitProfileUseCase: DefaultPostRecruitProfileUseCase(repository: recruitRepository), + upsertMyRecruitProfileUseCase: DefaultUpsertMyRecruitProfileUseCase(repository: recruitRepository), mode: .post ) let viewController = RecruitProfilePostViewController(viewModel: viewModel) { [weak self] _ in diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index 51c6a7f95..51e905f95 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -122,8 +122,8 @@ extension RecruitListHostingController { } private func showRecruitProfile() { let repository = DefaultRecruitRepository(service: DefaultRecruitService()) - let fetchMyProfileUseCase = DefaultFetchMyProfileUseCase(repository: repository) - let viewModel = RecruitProfileViewModel(fetchMyProfileUseCase: fetchMyProfileUseCase) + let fetchMyRecruitProfileUseCase = DefaultFetchMyRecruitProfileUseCase(repository: repository) + let viewModel = RecruitProfileViewModel(fetchMyRecruitProfileUseCase: fetchMyRecruitProfileUseCase) let viewController = RecruitProfileHostingController( rootView: RecruitProfileView(viewModel: viewModel) ) diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift index b1932160e..b1f62ccba 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift @@ -92,12 +92,12 @@ extension RecruitProfileHostingController { let fetchDeptListUseCase = MockFetchDeptListUseCase() let fetchUserDataUseCase = DefaultFetchUserDataUseCase(userRepository: userRepository) let postBasicInfoUseCase = DefaultPostBasicInfoUseCase(repository: recruitRepository) - let postRecruitProfileUseCase = DefaultPostRecruitProfileUseCase(repository: recruitRepository) + let upsertMyRecruitProfileUseCase = DefaultUpsertMyRecruitProfileUseCase(repository: recruitRepository) let viewModel = RecruitProfilePostViewModel( fetchDeptListUseCase: fetchDeptListUseCase, fetchUserDataUseCase: fetchUserDataUseCase, postBasicInfoUseCase: postBasicInfoUseCase, - postRecruitProfileUseCase: postRecruitProfileUseCase, + upsertMyRecruitProfileUseCase: upsertMyRecruitProfileUseCase, mode: mode ) return RecruitProfilePostViewController(viewModel: viewModel) { [weak self] profile in diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift index 3ad26515b..c72315749 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift @@ -24,11 +24,11 @@ final class RecruitProfileViewModel: SwiftUIViewModelProtocol { private(set) var errorMessage: String? // MARK: - UseCase - private let fetchMyProfileUseCase: FetchMyProfileUseCase + private let fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase // MARK: - Initializer - init(fetchMyProfileUseCase: FetchMyProfileUseCase) { - self.fetchMyProfileUseCase = fetchMyProfileUseCase + init(fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase) { + self.fetchMyRecruitProfileUseCase = fetchMyRecruitProfileUseCase } // MARK: - Public @@ -55,7 +55,7 @@ extension RecruitProfileViewModel { } do { - profile = try await fetchMyProfileUseCase.execute() + profile = try await fetchMyRecruitProfileUseCase.execute() didLoad = true } catch { errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift index 8e23c559e..6cbe495e1 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift @@ -36,7 +36,7 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { private let fetchDeptListUseCase: FetchDeptListUseCase private let fetchUserDataUseCase: FetchUserDataUseCase private let postBasicInfoUseCase: PostBasicInfoUseCase - private let postRecruitProfileUseCase: PostRecruitProfileUseCase + private let upsertMyRecruitProfileUseCase: UpsertMyRecruitProfileUseCase // MARK: - Publisher private let outputSubject = PassthroughSubject() @@ -48,13 +48,13 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { fetchDeptListUseCase: FetchDeptListUseCase, fetchUserDataUseCase: FetchUserDataUseCase, postBasicInfoUseCase: PostBasicInfoUseCase, - postRecruitProfileUseCase: PostRecruitProfileUseCase, + upsertMyRecruitProfileUseCase: UpsertMyRecruitProfileUseCase, mode: Mode ) { self.fetchDeptListUseCase = fetchDeptListUseCase self.fetchUserDataUseCase = fetchUserDataUseCase self.postBasicInfoUseCase = postBasicInfoUseCase - self.postRecruitProfileUseCase = postRecruitProfileUseCase + self.upsertMyRecruitProfileUseCase = upsertMyRecruitProfileUseCase self.mode = mode } @@ -133,7 +133,7 @@ extension RecruitProfilePostViewModel { Task { do { _ = try await postBasicInfoUseCase.execute(basicInfo: basicInfo) - let profile = try await postRecruitProfileUseCase.execute(request: request) + let profile = try await upsertMyRecruitProfileUseCase.execute(request: request) isSubmitting = false outputSubject.send(.updateLoading(false)) outputSubject.send(.postCompleted(profile)) diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 5b7d57120..e7871d13b 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -841,7 +841,7 @@ 9C95E190C98F440DADB6F91B /* RecruitDataMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DADCC36CEEA42EC86AB75B6 /* RecruitDataMetadataView.swift */; }; A0B343754988C9398C4FE726 /* ModifyRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */; }; A11B00000000000000000001 /* PostBasicInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11B00000000000000000002 /* PostBasicInfoUseCase.swift */; }; - A11B00000000000000000003 /* PostRecruitProfileUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11B00000000000000000004 /* PostRecruitProfileUseCase.swift */; }; + A11B00000000000000000003 /* UpsertMyRecruitProfileUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11B00000000000000000004 /* UpsertMyRecruitProfileUseCase.swift */; }; A11F00000000000000000001 /* RecruitMyPostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11E00000000000000000001 /* RecruitMyPostView.swift */; }; A11F00000000000000000002 /* RecruitMyPostSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11E00000000000000000002 /* RecruitMyPostSummaryView.swift */; }; A11F00000000000000000003 /* RecruitMyPostApplicantRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11E00000000000000000003 /* RecruitMyPostApplicantRowView.swift */; }; @@ -930,7 +930,7 @@ BEEF2000000000000000000B /* RecruitMyPostListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF1000000000000000000B /* RecruitMyPostListEmptyView.swift */; }; BEEF2000000000000000000C /* CloseRecruitMyPostUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF1000000000000000000C /* CloseRecruitMyPostUseCase.swift */; }; C0DE00000000000000000001 /* RecruitProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000002 /* RecruitProfile.swift */; }; - C0DE00000000000000000003 /* FetchMyProfileUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000004 /* FetchMyProfileUseCase.swift */; }; + C0DE00000000000000000003 /* FetchMyRecruitProfileUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000004 /* FetchMyRecruitProfileUseCase.swift */; }; C1838F7A4424DEA14DB2D107 /* RecruitPostCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */; }; C2B6232F34E3251E7E182808 /* RecruitAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54F105D18C46AE639FC3F3EE /* RecruitAPI.swift */; }; CAFE20000000000000000001 /* RecruitMyApplicationList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000001 /* RecruitMyApplicationList.swift */; }; @@ -2046,7 +2046,7 @@ 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataRolesView.swift; sourceTree = ""; }; A001E2B42845091D00D6C310 /* koin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = koin.app; sourceTree = BUILT_PRODUCTS_DIR; }; A11B00000000000000000002 /* PostBasicInfoUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostBasicInfoUseCase.swift; sourceTree = ""; }; - A11B00000000000000000004 /* PostRecruitProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRecruitProfileUseCase.swift; sourceTree = ""; }; + A11B00000000000000000004 /* UpsertMyRecruitProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpsertMyRecruitProfileUseCase.swift; sourceTree = ""; }; A11E00000000000000000001 /* RecruitMyPostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostView.swift; sourceTree = ""; }; A11E00000000000000000002 /* RecruitMyPostSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostSummaryView.swift; sourceTree = ""; }; A11E00000000000000000003 /* RecruitMyPostApplicantRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostApplicantRowView.swift; sourceTree = ""; }; @@ -2135,7 +2135,7 @@ BEEF1000000000000000000B /* RecruitMyPostListEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListEmptyView.swift; sourceTree = ""; }; BEEF1000000000000000000C /* CloseRecruitMyPostUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseRecruitMyPostUseCase.swift; sourceTree = ""; }; C0DE00000000000000000002 /* RecruitProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfile.swift; sourceTree = ""; }; - C0DE00000000000000000004 /* FetchMyProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMyProfileUseCase.swift; sourceTree = ""; }; + C0DE00000000000000000004 /* FetchMyRecruitProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMyRecruitProfileUseCase.swift; sourceTree = ""; }; CAFE10000000000000000001 /* RecruitMyApplicationList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationList.swift; sourceTree = ""; }; CAFE10000000000000000002 /* RecruitMyApplicationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationRow.swift; sourceTree = ""; }; CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationFilter.swift; sourceTree = ""; }; @@ -5616,9 +5616,9 @@ C0DE00000000000000000005 /* RecruitProfile */ = { isa = PBXGroup; children = ( - C0DE00000000000000000004 /* FetchMyProfileUseCase.swift */, + C0DE00000000000000000004 /* FetchMyRecruitProfileUseCase.swift */, A11B00000000000000000002 /* PostBasicInfoUseCase.swift */, - A11B00000000000000000004 /* PostRecruitProfileUseCase.swift */, + A11B00000000000000000004 /* UpsertMyRecruitProfileUseCase.swift */, ); path = RecruitProfile; sourceTree = ""; @@ -6814,9 +6814,9 @@ 7C0C189F2F68AB33001781B3 /* CallVanChatRequest.swift in Sources */, 7CDCD0CB3055D63F004204DA /* RecruitProfileViewModel.swift in Sources */, C0DE00000000000000000001 /* RecruitProfile.swift in Sources */, - C0DE00000000000000000003 /* FetchMyProfileUseCase.swift in Sources */, + C0DE00000000000000000003 /* FetchMyRecruitProfileUseCase.swift in Sources */, A11B00000000000000000001 /* PostBasicInfoUseCase.swift in Sources */, - A11B00000000000000000003 /* PostRecruitProfileUseCase.swift in Sources */, + A11B00000000000000000003 /* UpsertMyRecruitProfileUseCase.swift in Sources */, D2D7C0DD2C282E2700C85A85 /* DefaultLandRepository.swift in Sources */, 7CEB3B4B2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift in Sources */, 7CC42B132F56AB9D00940CE1 /* CallVanListCollectionView.swift in Sources */, From 62c36462ecbf2cbc121748a0c8604d86c397b677 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 15:09:34 +0900 Subject: [PATCH 066/123] =?UTF-8?q?refactor:=20=ED=95=99=EA=B3=BC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20Mock=20UseCase=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 팀원모집 프로필 작성/지원 화면의 학과 목록을 DefaultFetchDeptListUseCase 로 교체 - MockFetchDeptListUseCase 삭제 --- .../Timetable/MockFetchDeptListUseCase.swift | 25 ------------------- .../RecruitDataHostingController.swift | 4 +-- .../RecruitProfileHostingController.swift | 2 +- koin.xcodeproj/project.pbxproj | 4 --- 4 files changed, 3 insertions(+), 32 deletions(-) delete mode 100644 Koin/Domain/UseCase/Timetable/MockFetchDeptListUseCase.swift diff --git a/Koin/Domain/UseCase/Timetable/MockFetchDeptListUseCase.swift b/Koin/Domain/UseCase/Timetable/MockFetchDeptListUseCase.swift deleted file mode 100644 index 82596ddb6..000000000 --- a/Koin/Domain/UseCase/Timetable/MockFetchDeptListUseCase.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// MockFetchDeptListUseCase.swift -// koin -// -// Created by 홍기정 on 9/13/26. -// - -import Combine - -final class MockFetchDeptListUseCase: FetchDeptListUseCase { - func execute() -> AnyPublisher<[String], ErrorResponse> { - Just([ - "건축공학부", - "고용서비스정책학과", - "기계공학부", - "메카트로닉스공학부", - "산업경영학부", - "에너지신소재화학공학부", - "전기전자통신공학부", - "컴퓨터공학부" - ]) - .setFailureType(to: ErrorResponse.self) - .eraseToAnyPublisher() - } -} diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index b93acab2f..f8576d56d 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -157,7 +157,7 @@ extension RecruitDataHostingController { } let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let viewModel = RecruitApplyViewModel( - fetchDeptListUseCase: MockFetchDeptListUseCase(), + fetchDeptListUseCase: DefaultFetchDeptListUseCase(timetableRepository: DefaultTimetableRepository(service: DefaultTimetableService())), fetchMyRecruitProfileUseCase: DefaultFetchMyRecruitProfileUseCase(repository: recruitRepository), applyRecruitUseCase: DefaultApplyRecruitUseCase(repository: recruitRepository) ) @@ -173,7 +173,7 @@ extension RecruitDataHostingController { let userRepository = DefaultUserRepository(service: DefaultUserService()) let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let viewModel = RecruitProfilePostViewModel( - fetchDeptListUseCase: MockFetchDeptListUseCase(), + fetchDeptListUseCase: DefaultFetchDeptListUseCase(timetableRepository: DefaultTimetableRepository(service: DefaultTimetableService())), fetchUserDataUseCase: DefaultFetchUserDataUseCase(userRepository: userRepository), postBasicInfoUseCase: DefaultPostBasicInfoUseCase(repository: recruitRepository), upsertMyRecruitProfileUseCase: DefaultUpsertMyRecruitProfileUseCase(repository: recruitRepository), diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift index b1f62ccba..1180b9bc2 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift @@ -89,7 +89,7 @@ extension RecruitProfileHostingController { ) -> RecruitProfilePostViewController { let userRepository = DefaultUserRepository(service: DefaultUserService()) let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) - let fetchDeptListUseCase = MockFetchDeptListUseCase() + let fetchDeptListUseCase = DefaultFetchDeptListUseCase(timetableRepository: DefaultTimetableRepository(service: DefaultTimetableService())) let fetchUserDataUseCase = DefaultFetchUserDataUseCase(userRepository: userRepository) let postBasicInfoUseCase = DefaultPostBasicInfoUseCase(repository: recruitRepository) let upsertMyRecruitProfileUseCase = DefaultUpsertMyRecruitProfileUseCase(repository: recruitRepository) diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index e7871d13b..628035461 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -893,7 +893,6 @@ AA20000000000000000041 /* EnterFormState.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000041 /* EnterFormState.swift */; }; AA20000000000000000042 /* EnterFormStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000042 /* EnterFormStateTests.swift */; }; B0495459FC164809B16A96B6 /* RecruitPostTextViewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54351A8A0C1A4046C3155481 /* RecruitPostTextViewView.swift */; }; - B154A7D97E6AAEC2ED3D8119 /* MockFetchDeptListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E10C78030CB500BD595276 /* MockFetchDeptListUseCase.swift */; }; B438AEE42E756AB600E889C4 /* OrderImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B438AEE32E756AB600E889C4 /* OrderImage.swift */; }; B46B8CC22E76CB7300A8E797 /* ShopSummaryDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = B46B8CC12E76CB7300A8E797 /* ShopSummaryDto.swift */; }; B46B8CC42E76CC6300A8E797 /* FetchOrderShopSummaryFromShopUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B46B8CC32E76CC6300A8E797 /* FetchOrderShopSummaryFromShopUseCase.swift */; }; @@ -2394,7 +2393,6 @@ EC692285302700A400EE26ED /* koinUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = koinUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; ED038AB5420C8C565688CF6B /* DefaultRecruitRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultRecruitRepository.swift; sourceTree = ""; }; EFEC522ED41CF624AD3CB3A6 /* RecruitStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitStepView.swift; sourceTree = ""; }; - F0E10C78030CB500BD595276 /* MockFetchDeptListUseCase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockFetchDeptListUseCase.swift; sourceTree = ""; }; F1A000010000000000000002 /* ZoomingCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomingCollectionView.swift; sourceTree = ""; }; F1A000010000000000000004 /* ZoomingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomingCollectionViewCell.swift; sourceTree = ""; }; F670021A95E0AA103E8F7BED /* RecruitPostRoleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostRoleView.swift; sourceTree = ""; }; @@ -6039,7 +6037,6 @@ D2FCBEA32D08060C0097A388 /* RollbackFrameUseCase.swift */, D2FCBEA52D0877CC0097A388 /* FetchFramesUseCase.swift */, 7CF77B863005F642003642C1 /* FetchMainFrameUseCase.swift */, - F0E10C78030CB500BD595276 /* MockFetchDeptListUseCase.swift */, ); path = Timetable; sourceTree = ""; @@ -7859,7 +7856,6 @@ A51C00000000000000000004 /* RecruitProfilePostActivityTableView.swift in Sources */, A51C00000000000000000005 /* RecruitProfilePostActivityDisplayTableViewCell.swift in Sources */, A51C00000000000000000006 /* RecruitProfilePostActivityEditTableViewCell.swift in Sources */, - B154A7D97E6AAEC2ED3D8119 /* MockFetchDeptListUseCase.swift in Sources */, A5F8FD7A714E689BC4F3321B /* RecruitMyApplication.swift in Sources */, 91E82A6B67BF5BF911535B1C /* RecruitMyPostData.swift in Sources */, 9059A862F4E9CDA304B2F082 /* RecruitEnumDto.swift in Sources */, From 1c4b9ae95c3e91a04a4886a8a905a1b9bae5181b Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 15:10:23 +0900 Subject: [PATCH 067/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 학적 정보 조회/수정 API 연결 (GET /user/student/me/academic-info, PUT /user/student/academic-info) - FetchBasicInfoUseCase, ModifyBasicInfoUseCase (User 레이어), major 는 null 로 전송 - 팀원모집 프로필 생성/수정 API 연결 (PUT /team-recruitment-profiles/me) - RecruitProfileRequest 에 nickname 추가, 진행 중 활동 ended_at null 전송 - 저장 완료/실패 토스트 정리 --- .../User/StudentAcademicInfoDto.swift | 29 +++++++ .../Recruit/RecruitProfileRequestDto.swift | 77 +++++++++++++++++++ .../User/StudentAcademicInfoRequestDto.swift | 31 ++++++++ .../Repository/DefaultRecruitRepository.swift | 15 ++-- .../Repository/DefaultUserRepository.swift | 11 +++ .../Repository/MockRecruitRepository.swift | 5 -- .../Data/Service/Network/API/RecruitAPI.swift | 9 ++- Koin/Data/Service/Network/API/UserAPI.swift | 19 +++-- Koin/Data/Service/RecruitService.swift | 5 ++ Koin/Data/Service/UserService.swift | 10 +++ Koin/Domain/Model/Recruit/BasicInfo.swift | 9 +-- .../Domain/Model/Recruit/RecruitProfile.swift | 1 + .../Model/Recruit/RecruitProfileRequest.swift | 8 +- .../Domain/Repository/RecruitRepository.swift | 1 - Koin/Domain/Repository/UserRepository.swift | 2 + .../RecruitProfile/PostBasicInfoUseCase.swift | 24 ------ .../UseCase/User/FetchBasicInfoUseCase.swift | 24 ++++++ .../UseCase/User/PostBasicInfoUseCase.swift | 24 ++++++ .../RecruitDataHostingController.swift | 4 +- .../RecruitProfileHostingController.swift | 8 +- .../RecruitProfilePostViewController.swift | 6 +- .../RecruitProfilePostViewModel.swift | 66 +++++++--------- koin.xcodeproj/project.pbxproj | 18 ++++- 23 files changed, 304 insertions(+), 102 deletions(-) create mode 100644 Koin/Data/DTOs/Decodable/User/StudentAcademicInfoDto.swift create mode 100644 Koin/Data/DTOs/Encodable/Recruit/RecruitProfileRequestDto.swift create mode 100644 Koin/Data/DTOs/Encodable/User/StudentAcademicInfoRequestDto.swift delete mode 100644 Koin/Domain/UseCase/Recruit/RecruitProfile/PostBasicInfoUseCase.swift create mode 100644 Koin/Domain/UseCase/User/FetchBasicInfoUseCase.swift create mode 100644 Koin/Domain/UseCase/User/PostBasicInfoUseCase.swift diff --git a/Koin/Data/DTOs/Decodable/User/StudentAcademicInfoDto.swift b/Koin/Data/DTOs/Decodable/User/StudentAcademicInfoDto.swift new file mode 100644 index 000000000..c5481d1dc --- /dev/null +++ b/Koin/Data/DTOs/Decodable/User/StudentAcademicInfoDto.swift @@ -0,0 +1,29 @@ +// +// StudentAcademicInfoDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct StudentAcademicInfoDto: Decodable { + let nickname: String? + let department: String? + let studentNumber: String? + + enum CodingKeys: String, CodingKey { + case nickname, department + case studentNumber = "student_number" + } +} + +extension StudentAcademicInfoDto { + func toDomain() -> BasicInfo { + return BasicInfo( + nickname: nickname, + department: department, + studentNumber: studentNumber + ) + } +} diff --git a/Koin/Data/DTOs/Encodable/Recruit/RecruitProfileRequestDto.swift b/Koin/Data/DTOs/Encodable/Recruit/RecruitProfileRequestDto.swift new file mode 100644 index 000000000..a48010217 --- /dev/null +++ b/Koin/Data/DTOs/Encodable/Recruit/RecruitProfileRequestDto.swift @@ -0,0 +1,77 @@ +// +// RecruitProfileRequestDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct RecruitProfileRequestDto: Encodable { + let profileNickname: String + let preferredRole: String + let skills: [String] + let activities: [RecruitProfileActivityRequestDto] + let selfIntroduction: String + + enum CodingKeys: String, CodingKey { + case profileNickname = "profile_nickname" + case preferredRole = "preferred_role" + case skills, activities + case selfIntroduction = "self_introduction" + } +} + +struct RecruitProfileActivityRequestDto: Encodable { + let title: String + let startedAt: String + let endedAt: String? + let isOngoing: Bool + let description: String + + enum CodingKeys: String, CodingKey { + case title + case startedAt = "started_at" + case endedAt = "ended_at" + case isOngoing = "is_ongoing" + case description + } +} + +extension RecruitProfileRequestDto { + init?(from request: RecruitProfileRequest) { + guard let nickname = request.nickname, + let preferredRole = request.preferredRole, + let introduction = request.introduction else { + return nil + } + var activities: [RecruitProfileActivityRequestDto] = [] + for activity in request.activities { + guard let activity = RecruitProfileActivityRequestDto(from: activity) else { + return nil + } + activities.append(activity) + } + + self.profileNickname = nickname + self.preferredRole = preferredRole + self.skills = request.skills + self.activities = activities + self.selfIntroduction = introduction + } +} + +extension RecruitProfileActivityRequestDto { + init?(from request: RecruitProfileActivityRequest) { + guard let title = request.title, + let startedAt = request.startedAt, + let description = request.description else { + return nil + } + self.title = title + self.startedAt = startedAt.formatDateToYYYYMMDD(separator: "-") + self.endedAt = request.isOngoing ? nil : request.endedAt?.formatDateToYYYYMMDD(separator: "-") + self.isOngoing = request.isOngoing + self.description = description + } +} diff --git a/Koin/Data/DTOs/Encodable/User/StudentAcademicInfoRequestDto.swift b/Koin/Data/DTOs/Encodable/User/StudentAcademicInfoRequestDto.swift new file mode 100644 index 000000000..88d640dbf --- /dev/null +++ b/Koin/Data/DTOs/Encodable/User/StudentAcademicInfoRequestDto.swift @@ -0,0 +1,31 @@ +// +// StudentAcademicInfoRequestDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct StudentAcademicInfoRequestDto: Encodable { + let studentNumber: String + let department: String + let major: String? + + enum CodingKeys: String, CodingKey { + case studentNumber = "student_number" + case department, major + } +} + +extension StudentAcademicInfoRequestDto { + init?(from basicInfo: BasicInfo) { + guard let studentNumber = basicInfo.studentNumber, + let department = basicInfo.department else { + return nil + } + self.studentNumber = studentNumber + self.department = department + self.major = nil + } +} diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift index f1db82682..93da71c3a 100644 --- a/Koin/Data/Repository/DefaultRecruitRepository.swift +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -48,6 +48,13 @@ final class DefaultRecruitRepository: RecruitRepository { func fetchMyProfile() async throws -> RecruitProfile { try await service.fetchMyProfile().toDomain() } + + func upsertMyProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile { + guard let request = RecruitProfileRequestDto(from: request) else { + throw ErrorResponse.unexpectedInternalError + } + return try await service.upsertMyProfile(request).toDomain() + } } extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 @@ -132,14 +139,6 @@ extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 try await mockRepository.markAllAsReadNotification() } - func postBasicInfo(_ basicInfo: BasicInfo) async throws -> BasicInfo { - try await mockRepository.postBasicInfo(basicInfo) - } - - func upsertMyProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile { - try await mockRepository.upsertMyProfile(request) - } - func apply(_ request: RecruitApplyRequest) async throws -> Void { try await mockRepository.apply(request) } diff --git a/Koin/Data/Repository/DefaultUserRepository.swift b/Koin/Data/Repository/DefaultUserRepository.swift index 1fc09b8ea..c431f2493 100644 --- a/Koin/Data/Repository/DefaultUserRepository.swift +++ b/Koin/Data/Repository/DefaultUserRepository.swift @@ -106,4 +106,15 @@ final class DefaultUserRepository: UserRepository { func generalRegisterForm(requestModel: GeneralRegisterFormRequest) -> AnyPublisher { service.generalRegisterForm(requestModel: requestModel) } + + func fetchBasicInfo() async throws -> BasicInfo { + try await service.fetchStudentAcademicInfo().toDomain() + } + + func modifyBasicInfo(_ basicInfo: BasicInfo) async throws { + guard let request = StudentAcademicInfoRequestDto(from: basicInfo) else { + throw ErrorResponse.unexpectedInternalError + } + try await service.modifyStudentAcademicInfo(request) + } } diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index ff6e23ed0..77935cfa0 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -118,11 +118,6 @@ final class MockRecruitRepository: RecruitRepository { try await Task.sleep(nanoseconds: 300_000_000) } - func postBasicInfo(_ basicInfo: BasicInfo) async throws -> BasicInfo { - try await Task.sleep(nanoseconds: 300_000_000) - return basicInfo - } - func upsertMyProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile { try await Task.sleep(nanoseconds: 300_000_000) diff --git a/Koin/Data/Service/Network/API/RecruitAPI.swift b/Koin/Data/Service/Network/API/RecruitAPI.swift index d2bb7f887..600fe1a05 100644 --- a/Koin/Data/Service/Network/API/RecruitAPI.swift +++ b/Koin/Data/Service/Network/API/RecruitAPI.swift @@ -15,6 +15,7 @@ enum RecruitAPI { case post(RecruitPostRequestDto) case modify(Int, RecruitPostRequestDto) case fetchMyProfile + case upsertMyProfile(RecruitProfileRequestDto) } extension RecruitAPI: Router, URLRequestConvertible { @@ -31,6 +32,7 @@ extension RecruitAPI: Router, URLRequestConvertible { case .post: return "/team-recruitments" case .modify(let id, _): return "/team-recruitments/\(id)" case .fetchMyProfile: return "/team-recruitment-profiles/me" + case .upsertMyProfile: return "/team-recruitment-profiles/me" } } @@ -42,6 +44,7 @@ extension RecruitAPI: Router, URLRequestConvertible { case .post: return .post case .modify: return .put case .fetchMyProfile: return .get + case .upsertMyProfile: return .put } } @@ -50,7 +53,7 @@ extension RecruitAPI: Router, URLRequestConvertible { switch self { case .fetchList, .fetchData, .deleteData, .fetchMyProfile: break - case .post, .modify: + case .post, .modify, .upsertMyProfile: baseHeaders["Content-Type"] = "application/json" } return baseHeaders @@ -64,6 +67,8 @@ extension RecruitAPI: Router, URLRequestConvertible { return nil case .post(let request), .modify(_, let request): return try? request.toDictionary() + case .upsertMyProfile(let request): + return try? request.toDictionary() } } @@ -73,7 +78,7 @@ extension RecruitAPI: Router, URLRequestConvertible { return URLEncoding(arrayEncoding: .noBrackets) case .fetchData, .deleteData, .fetchMyProfile: return nil - case .post, .modify: + case .post, .modify, .upsertMyProfile: return JSONEncoding.default } } diff --git a/Koin/Data/Service/Network/API/UserAPI.swift b/Koin/Data/Service/Network/API/UserAPI.swift index 892779f08..bad7f98c2 100644 --- a/Koin/Data/Service/Network/API/UserAPI.swift +++ b/Koin/Data/Service/Network/API/UserAPI.swift @@ -35,6 +35,8 @@ enum UserAPI { case resetPasswordSms(ResetPasswordSmsRequest) case resetPasswordEmail(ResetPasswordEmailRequest) case changePassword(ChangePasswordRequest) + case fetchStudentAcademicInfo + case modifyStudentAcademicInfo(StudentAcademicInfoRequestDto) } extension UserAPI: Router, URLRequestConvertible { @@ -71,6 +73,8 @@ extension UserAPI: Router, URLRequestConvertible { case .resetPasswordSms: return "/users/password/reset/sms" case .resetPasswordEmail: return "/users/password/reset/email" case .changePassword: return "/users/password" + case .fetchStudentAcademicInfo: return "/user/student/me/academic-info" + case .modifyStudentAcademicInfo: return "/user/student/academic-info" } } @@ -78,7 +82,8 @@ extension UserAPI: Router, URLRequestConvertible { switch self { case .findPassword, .register, .login, .checkPassword, .refreshToken, .sendVerificationCode, .checkVerificationCode, .studentRegisterForm, .generalRegisterForm, .sendVerificationEmail, .checkVerificationEmail, .findIdSms, .findIdEmail, .resetPasswordSms, .resetPasswordEmail: return .post case .checkDuplicatedPhoneNumber, .checkDuplicatedNickname, .fetchStudentUserData, .checkAuth, .checkLogin, .checkDuplicatedId, .fetchGeneralUserData: return .get - case .modifyStudentUserData, .modifyGeneralUserData, .changePassword: return .put + case .modifyStudentUserData, .modifyGeneralUserData, .changePassword, .modifyStudentAcademicInfo: return .put + case .fetchStudentAcademicInfo: return .get case .revoke: return .delete } } @@ -87,9 +92,9 @@ extension UserAPI: Router, URLRequestConvertible { var baseHeaders: [String: String] = [:] switch self { - case .findPassword, .register, .checkDuplicatedPhoneNumber, .checkDuplicatedNickname, .login, .checkPassword, .modifyStudentUserData, .refreshToken, .sendVerificationCode, .checkVerificationCode, .checkDuplicatedId, .studentRegisterForm, .generalRegisterForm, .sendVerificationEmail, .checkVerificationEmail, .findIdEmail, .findIdSms, .modifyGeneralUserData, .resetPasswordSms, .resetPasswordEmail, .changePassword: + case .findPassword, .register, .checkDuplicatedPhoneNumber, .checkDuplicatedNickname, .login, .checkPassword, .modifyStudentUserData, .refreshToken, .sendVerificationCode, .checkVerificationCode, .checkDuplicatedId, .studentRegisterForm, .generalRegisterForm, .sendVerificationEmail, .checkVerificationEmail, .findIdEmail, .findIdSms, .modifyGeneralUserData, .resetPasswordSms, .resetPasswordEmail, .changePassword, .modifyStudentAcademicInfo: baseHeaders["Content-Type"] = "application/json" - case .fetchStudentUserData, .revoke, .checkAuth, .checkLogin, .fetchGeneralUserData: + case .fetchStudentUserData, .revoke, .checkAuth, .checkLogin, .fetchGeneralUserData, .fetchStudentAcademicInfo: break } return baseHeaders @@ -149,15 +154,19 @@ extension UserAPI: Router, URLRequestConvertible { return try? JSONEncoder().encode(request) case .changePassword(let request): return try? JSONEncoder().encode(request) + case .fetchStudentAcademicInfo: + return nil + case .modifyStudentAcademicInfo(let request): + return try? JSONEncoder().encode(request) } } public var encoding: Alamofire.ParameterEncoding? { switch self { - case .findPassword, .register, .login, .checkPassword, .modifyStudentUserData, .sendVerificationCode, .checkVerificationCode, .studentRegisterForm, .generalRegisterForm, .checkVerificationEmail, .sendVerificationEmail, .findIdSms, .findIdEmail, .modifyGeneralUserData, .resetPasswordSms, .resetPasswordEmail, .changePassword: return JSONEncoding.default + case .findPassword, .register, .login, .checkPassword, .modifyStudentUserData, .sendVerificationCode, .checkVerificationCode, .studentRegisterForm, .generalRegisterForm, .checkVerificationEmail, .sendVerificationEmail, .findIdSms, .findIdEmail, .modifyGeneralUserData, .resetPasswordSms, .resetPasswordEmail, .changePassword, .modifyStudentAcademicInfo: return JSONEncoding.default case .checkDuplicatedPhoneNumber, .checkDuplicatedNickname, .fetchStudentUserData, .checkAuth, .checkDuplicatedId, .fetchGeneralUserData: return URLEncoding.default case .checkLogin: return URLEncoding.queryString - case .revoke, .refreshToken: return nil + case .revoke, .refreshToken, .fetchStudentAcademicInfo: return nil } } } diff --git a/Koin/Data/Service/RecruitService.swift b/Koin/Data/Service/RecruitService.swift index c6ab9d5dc..e3a20e6c5 100644 --- a/Koin/Data/Service/RecruitService.swift +++ b/Koin/Data/Service/RecruitService.swift @@ -14,6 +14,7 @@ protocol RecruitService { func post(_ request: RecruitPostRequestDto) async throws -> RecruitPostResultDto func modify(_ id: Int, _ request: RecruitPostRequestDto) async throws func fetchMyProfile() async throws -> RecruitProfileDto + func upsertMyProfile(_ request: RecruitProfileRequestDto) async throws -> RecruitProfileDto } final class DefaultRecruitService: RecruitService { @@ -43,4 +44,8 @@ final class DefaultRecruitService: RecruitService { func fetchMyProfile() async throws -> RecruitProfileDto { try await networkService.requestWithResponse(api: RecruitAPI.fetchMyProfile) } + + func upsertMyProfile(_ request: RecruitProfileRequestDto) async throws -> RecruitProfileDto { + try await networkService.requestWithResponse(api: RecruitAPI.upsertMyProfile(request)) + } } diff --git a/Koin/Data/Service/UserService.swift b/Koin/Data/Service/UserService.swift index e32c7cb0a..80612234d 100644 --- a/Koin/Data/Service/UserService.swift +++ b/Koin/Data/Service/UserService.swift @@ -32,6 +32,8 @@ protocol UserService { func resetPasswordSms(requestModel: ResetPasswordSmsRequest) -> AnyPublisher func resetPasswordEmail(requestModel: ResetPasswordEmailRequest) -> AnyPublisher func changePassword(requestModel: ChangePasswordRequest) -> AnyPublisher + func fetchStudentAcademicInfo() async throws -> StudentAcademicInfoDto + func modifyStudentAcademicInfo(_ request: StudentAcademicInfoRequestDto) async throws } final class DefaultUserService: UserService { @@ -150,4 +152,12 @@ final class DefaultUserService: UserService { func generalRegisterForm(requestModel: GeneralRegisterFormRequest) -> AnyPublisher { networkService.request(api: UserAPI.generalRegisterForm(requestModel)) } + + func fetchStudentAcademicInfo() async throws -> StudentAcademicInfoDto { + try await networkService.requestWithResponse(api: UserAPI.fetchStudentAcademicInfo) + } + + func modifyStudentAcademicInfo(_ request: StudentAcademicInfoRequestDto) async throws { + try await networkService.request(api: UserAPI.modifyStudentAcademicInfo(request)) + } } diff --git a/Koin/Domain/Model/Recruit/BasicInfo.swift b/Koin/Domain/Model/Recruit/BasicInfo.swift index d93732e4a..6f3b2c0c4 100644 --- a/Koin/Domain/Model/Recruit/BasicInfo.swift +++ b/Koin/Domain/Model/Recruit/BasicInfo.swift @@ -15,12 +15,9 @@ struct BasicInfo { extension BasicInfo { var isValid: Bool { - guard let nickname, - !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let department, - !department.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let studentNumber, - !studentNumber.isEmpty else { + guard let nickname, !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let department, !department.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let studentNumber, !studentNumber.isEmpty else { return false } return true diff --git a/Koin/Domain/Model/Recruit/RecruitProfile.swift b/Koin/Domain/Model/Recruit/RecruitProfile.swift index efde87169..1addbccf6 100644 --- a/Koin/Domain/Model/Recruit/RecruitProfile.swift +++ b/Koin/Domain/Model/Recruit/RecruitProfile.swift @@ -28,6 +28,7 @@ extension RecruitProfile { func toRequest() -> RecruitProfileRequest { RecruitProfileRequest( + nickname: nickname, preferredRole: preferredRole, skills: skills, activities: activities.map { $0.toRequest() }, diff --git a/Koin/Domain/Model/Recruit/RecruitProfileRequest.swift b/Koin/Domain/Model/Recruit/RecruitProfileRequest.swift index e17ca96e4..9b9f2896f 100644 --- a/Koin/Domain/Model/Recruit/RecruitProfileRequest.swift +++ b/Koin/Domain/Model/Recruit/RecruitProfileRequest.swift @@ -8,6 +8,7 @@ import Foundation struct RecruitProfileRequest { + var nickname: String? = nil var preferredRole: String? = nil var skills: [String] = [] var activities: [RecruitProfileActivityRequest] = [] @@ -16,10 +17,9 @@ struct RecruitProfileRequest { extension RecruitProfileRequest { var isValid: Bool { - guard let preferredRole, - !preferredRole.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let introduction, - !introduction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + guard let nickname, !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let preferredRole, !preferredRole.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let introduction, !introduction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index ef7a905ce..f5160cee5 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -49,7 +49,6 @@ protocol RecruitRepository { func fetchData(_ id: Int) async throws -> RecruitData func deleteData(id: Int) async throws -> Bool func fetchMyProfile() async throws -> RecruitProfile - func postBasicInfo(_ basicInfo: BasicInfo) async throws -> BasicInfo func upsertMyProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile func apply(_ request: RecruitApplyRequest) async throws -> Void } diff --git a/Koin/Domain/Repository/UserRepository.swift b/Koin/Domain/Repository/UserRepository.swift index 871656779..7cfc375a3 100644 --- a/Koin/Domain/Repository/UserRepository.swift +++ b/Koin/Domain/Repository/UserRepository.swift @@ -31,4 +31,6 @@ protocol UserRepository { func resetPasswordSms(requestModel: ResetPasswordSmsRequest) -> AnyPublisher func resetPasswordEmail(requestModel: ResetPasswordEmailRequest) -> AnyPublisher func changePassword(requestModel: ChangePasswordRequest) -> AnyPublisher + func fetchBasicInfo() async throws -> BasicInfo + func modifyBasicInfo(_ basicInfo: BasicInfo) async throws } diff --git a/Koin/Domain/UseCase/Recruit/RecruitProfile/PostBasicInfoUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitProfile/PostBasicInfoUseCase.swift deleted file mode 100644 index 372615d11..000000000 --- a/Koin/Domain/UseCase/Recruit/RecruitProfile/PostBasicInfoUseCase.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// PostBasicInfoUseCase.swift -// koin -// -// Created by 홍기정 on 9/21/26. -// - -import Foundation - -protocol PostBasicInfoUseCase { - func execute(basicInfo: BasicInfo) async throws -> BasicInfo -} - -final class DefaultPostBasicInfoUseCase: PostBasicInfoUseCase { - private let repository: RecruitRepository - - init(repository: RecruitRepository) { - self.repository = repository - } - - func execute(basicInfo: BasicInfo) async throws -> BasicInfo { - try await repository.postBasicInfo(basicInfo) - } -} diff --git a/Koin/Domain/UseCase/User/FetchBasicInfoUseCase.swift b/Koin/Domain/UseCase/User/FetchBasicInfoUseCase.swift new file mode 100644 index 000000000..8d0d2f644 --- /dev/null +++ b/Koin/Domain/UseCase/User/FetchBasicInfoUseCase.swift @@ -0,0 +1,24 @@ +// +// FetchBasicInfoUseCase.swift +// koin +// +// Created by 홍기정 on 9/21/26. +// + +import Foundation + +protocol FetchBasicInfoUseCase { + func execute() async throws -> BasicInfo +} + +final class DefaultFetchBasicInfoUseCase: FetchBasicInfoUseCase { + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func execute() async throws -> BasicInfo { + try await repository.fetchBasicInfo() + } +} diff --git a/Koin/Domain/UseCase/User/PostBasicInfoUseCase.swift b/Koin/Domain/UseCase/User/PostBasicInfoUseCase.swift new file mode 100644 index 000000000..a832d4fc0 --- /dev/null +++ b/Koin/Domain/UseCase/User/PostBasicInfoUseCase.swift @@ -0,0 +1,24 @@ +// +// ModifyBasicInfoUseCase.swift +// koin +// +// Created by 홍기정 on 9/21/26. +// + +import Foundation + +protocol ModifyBasicInfoUseCase { + func execute(basicInfo: BasicInfo) async throws +} + +final class DefaultModifyBasicInfoUseCase: ModifyBasicInfoUseCase { + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func execute(basicInfo: BasicInfo) async throws { + try await repository.modifyBasicInfo(basicInfo) + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index f8576d56d..ab6cb033d 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -174,8 +174,8 @@ extension RecruitDataHostingController { let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let viewModel = RecruitProfilePostViewModel( fetchDeptListUseCase: DefaultFetchDeptListUseCase(timetableRepository: DefaultTimetableRepository(service: DefaultTimetableService())), - fetchUserDataUseCase: DefaultFetchUserDataUseCase(userRepository: userRepository), - postBasicInfoUseCase: DefaultPostBasicInfoUseCase(repository: recruitRepository), + fetchBasicInfoUseCase: DefaultFetchBasicInfoUseCase(repository: userRepository), + modifyBasicInfoUseCase: DefaultModifyBasicInfoUseCase(repository: userRepository), upsertMyRecruitProfileUseCase: DefaultUpsertMyRecruitProfileUseCase(repository: recruitRepository), mode: .post ) diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift index 1180b9bc2..6a90cdcac 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift @@ -90,13 +90,13 @@ extension RecruitProfileHostingController { let userRepository = DefaultUserRepository(service: DefaultUserService()) let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchDeptListUseCase = DefaultFetchDeptListUseCase(timetableRepository: DefaultTimetableRepository(service: DefaultTimetableService())) - let fetchUserDataUseCase = DefaultFetchUserDataUseCase(userRepository: userRepository) - let postBasicInfoUseCase = DefaultPostBasicInfoUseCase(repository: recruitRepository) + let fetchBasicInfoUseCase = DefaultFetchBasicInfoUseCase(repository: userRepository) + let modifyBasicInfoUseCase = DefaultModifyBasicInfoUseCase(repository: userRepository) let upsertMyRecruitProfileUseCase = DefaultUpsertMyRecruitProfileUseCase(repository: recruitRepository) let viewModel = RecruitProfilePostViewModel( fetchDeptListUseCase: fetchDeptListUseCase, - fetchUserDataUseCase: fetchUserDataUseCase, - postBasicInfoUseCase: postBasicInfoUseCase, + fetchBasicInfoUseCase: fetchBasicInfoUseCase, + modifyBasicInfoUseCase: modifyBasicInfoUseCase, upsertMyRecruitProfileUseCase: upsertMyRecruitProfileUseCase, mode: mode ) diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift index a57f531ff..a86daf127 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift @@ -57,9 +57,9 @@ final class RecruitProfilePostViewController: UIViewController { private var completionMessage: String { switch viewModel.mode { case .post: - return "프로필이 저장되었습니다." + return "프로필을 저장했습니다." case .modify: - return "프로필이 수정되었습니다." + return "프로필을 수정했습니다." } } @@ -232,7 +232,7 @@ extension RecruitProfilePostViewController { firstStepView.loadInfoButtonTappedPublisher .sink { [weak self] in - self?.inputSubject.send(.loadUserData) + self?.inputSubject.send(.fetchBasicInfo) } .store(in: &subscriptions) diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift index 6cbe495e1..f8142c86e 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift @@ -17,7 +17,7 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { enum Input { case viewDidLoad - case loadUserData + case fetchBasicInfo case submit(basicInfo: BasicInfo, request: RecruitProfileRequest) } @@ -34,8 +34,8 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { // MARK: - UseCase private let fetchDeptListUseCase: FetchDeptListUseCase - private let fetchUserDataUseCase: FetchUserDataUseCase - private let postBasicInfoUseCase: PostBasicInfoUseCase + private let fetchBasicInfoUseCase: FetchBasicInfoUseCase + private let modifyBasicInfoUseCase: ModifyBasicInfoUseCase private let upsertMyRecruitProfileUseCase: UpsertMyRecruitProfileUseCase // MARK: - Publisher @@ -46,14 +46,14 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { // MARK: - Initializer init( fetchDeptListUseCase: FetchDeptListUseCase, - fetchUserDataUseCase: FetchUserDataUseCase, - postBasicInfoUseCase: PostBasicInfoUseCase, + fetchBasicInfoUseCase: FetchBasicInfoUseCase, + modifyBasicInfoUseCase: ModifyBasicInfoUseCase, upsertMyRecruitProfileUseCase: UpsertMyRecruitProfileUseCase, mode: Mode ) { self.fetchDeptListUseCase = fetchDeptListUseCase - self.fetchUserDataUseCase = fetchUserDataUseCase - self.postBasicInfoUseCase = postBasicInfoUseCase + self.fetchBasicInfoUseCase = fetchBasicInfoUseCase + self.modifyBasicInfoUseCase = modifyBasicInfoUseCase self.upsertMyRecruitProfileUseCase = upsertMyRecruitProfileUseCase self.mode = mode } @@ -73,8 +73,8 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { let basicInfo = recruitProfile.toBasicInfo() self.outputSubject.send(.updateBasicInfo(basicInfo)) } - case .loadUserData: - fetchUserData() + case .fetchBasicInfo: + fetchBasicInfo() case let .submit(basicInfo, request): submit(basicInfo: basicInfo, request: request) } @@ -101,24 +101,16 @@ extension RecruitProfilePostViewModel { .store(in: &subscriptions) } - private func fetchUserData() { - fetchUserDataUseCase.execute() - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [weak self] completion in - guard case let .failure(error) = completion else { return } - self?.outputSubject.send(.showToast(error.message)) - }, - receiveValue: { [weak self] user in - guard let self else { return } - self.outputSubject.send(.updateBasicInfo(.init( - nickname: user.nickname ?? user.anonymousNickname, - department: user.major, - studentNumber: user.studentNumber - ))) - } - ) - .store(in: &subscriptions) + private func fetchBasicInfo() { + Task { + do { + let basicInfo = try await fetchBasicInfoUseCase.execute() + outputSubject.send(.updateBasicInfo(basicInfo)) + } catch { + let message = (error as? ErrorResponse)?.message ?? error.localizedDescription + outputSubject.send(.showToast(message)) + } + } } private func submit( @@ -126,21 +118,21 @@ extension RecruitProfilePostViewModel { request: RecruitProfileRequest ) { guard !isSubmitting else { return } - - isSubmitting = true - outputSubject.send(.updateLoading(true)) - Task { - do { - _ = try await postBasicInfoUseCase.execute(basicInfo: basicInfo) - let profile = try await upsertMyRecruitProfileUseCase.execute(request: request) + isSubmitting = true + outputSubject.send(.updateLoading(true)) + defer { isSubmitting = false outputSubject.send(.updateLoading(false)) + } + do { + var request = request + request.nickname = basicInfo.nickname + try await modifyBasicInfoUseCase.execute(basicInfo: basicInfo) + let profile = try await upsertMyRecruitProfileUseCase.execute(request: request) outputSubject.send(.postCompleted(profile)) } catch { - isSubmitting = false - outputSubject.send(.updateLoading(false)) - let message = (error as? ErrorResponse)?.message ?? "프로필 저장에 실패했습니다." + let message = (error as? ErrorResponse)?.message ?? error.localizedDescription outputSubject.send(.showToast(message)) } } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 628035461..7feaacfc9 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -18,8 +18,10 @@ 3E56520894546C11EB8F99B3 /* RecruitPostRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE562D9FEDE6181748BE67B /* RecruitPostRequestDto.swift */; }; 5006987DEF4B5F454D7EA2D5 /* RecruitPostTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */; }; 515B96092C6730C797C1F837 /* PostRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D447F2D75951DD4C3E08D4E /* PostRecruitUseCase.swift */; }; + 57B70985D5DEFF4D774733F6 /* StudentAcademicInfoRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D3B08333D4FA77CC935917B /* StudentAcademicInfoRequestDto.swift */; }; 678D20761DEF461CA6D4728A /* RecruitDataTextSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2FBEEB767094CE8B219095B /* RecruitDataTextSectionView.swift */; }; 682DA9853EC4F396EFEEF876 /* RecruitListDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21AA98431B25CB5914BC2598 /* RecruitListDto.swift */; }; + 7231A38A9BEC1DFBB8C2D978 /* RecruitProfileRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C8CD337DB4DF4456561F92 /* RecruitProfileRequestDto.swift */; }; 75EBF253F4468B2060B79212 /* RecruitPostSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3694E3ACBE05B8C7F15357 /* RecruitPostSectionHeaderView.swift */; }; 79759F6CDB325A7914C75CBE /* RecruitListRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4067CAF18D134F99A04BBA0D /* RecruitListRequestDto.swift */; }; 7C016540300A88940013DD7B /* ShuttleTicketViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C01653F300A88940013DD7B /* ShuttleTicketViewController.swift */; }; @@ -837,6 +839,7 @@ 9059A862F4E9CDA304B2F082 /* RecruitEnumDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E31EA58151824C938383893 /* RecruitEnumDto.swift */; }; 91E82A6B67BF5BF911535B1C /* RecruitMyPostData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87039C64B7CB2AEB632113C6 /* RecruitMyPostData.swift */; }; 9419498912D565DE98568476 /* RecruitPostResultDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267C5F2D082AC60FDA16F85C /* RecruitPostResultDto.swift */; }; + 98672455503C7463C3E5BA15 /* FetchBasicInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2525B9C0134B21C89671CB /* FetchBasicInfoUseCase.swift */; }; 999FC904C4F9ED4E2B829341 /* RecruitApplyBlockReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB6A15BFD7B896A787835F8E /* RecruitApplyBlockReason.swift */; }; 9C95E190C98F440DADB6F91B /* RecruitDataMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DADCC36CEEA42EC86AB75B6 /* RecruitDataMetadataView.swift */; }; A0B343754988C9398C4FE726 /* ModifyRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */; }; @@ -1188,6 +1191,7 @@ F1A000010000000000000001 /* ZoomingCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A000010000000000000002 /* ZoomingCollectionView.swift */; }; F1A000010000000000000003 /* ZoomingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A000010000000000000004 /* ZoomingCollectionViewCell.swift */; }; F7F814BB370C922B6D8AA6EF /* RecruitStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFEC522ED41CF624AD3CB3A6 /* RecruitStepView.swift */; }; + FC40DDB1CAB3573658F94721 /* StudentAcademicInfoDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5AA513615BE12CD677F848 /* StudentAcademicInfoDto.swift */; }; FE01000000000000000021 /* KoinDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01000000000000000011 /* KoinDropdown.swift */; }; FE01000000000000000023 /* KoinDropdownAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01000000000000000013 /* KoinDropdownAnimator.swift */; }; FE01000000000000000026 /* KoinDropdownConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01000000000000000016 /* KoinDropdownConfiguration.swift */; }; @@ -2042,7 +2046,10 @@ 87039C64B7CB2AEB632113C6 /* RecruitMyPostData.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyPostData.swift; sourceTree = ""; }; 8AC394F2327076B2F9AEE2A4 /* RecruitService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitService.swift; sourceTree = ""; }; 8CE127F2AE4B18899F6E5E0D /* RecruitPostScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostScheduleView.swift; sourceTree = ""; }; + 8D2525B9C0134B21C89671CB /* FetchBasicInfoUseCase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FetchBasicInfoUseCase.swift; sourceTree = ""; }; 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataRolesView.swift; sourceTree = ""; }; + 99C8CD337DB4DF4456561F92 /* RecruitProfileRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitProfileRequestDto.swift; sourceTree = ""; }; + 9D3B08333D4FA77CC935917B /* StudentAcademicInfoRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StudentAcademicInfoRequestDto.swift; sourceTree = ""; }; A001E2B42845091D00D6C310 /* koin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = koin.app; sourceTree = BUILT_PRODUCTS_DIR; }; A11B00000000000000000002 /* PostBasicInfoUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostBasicInfoUseCase.swift; sourceTree = ""; }; A11B00000000000000000004 /* UpsertMyRecruitProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpsertMyRecruitProfileUseCase.swift; sourceTree = ""; }; @@ -2396,6 +2403,7 @@ F1A000010000000000000002 /* ZoomingCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomingCollectionView.swift; sourceTree = ""; }; F1A000010000000000000004 /* ZoomingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomingCollectionViewCell.swift; sourceTree = ""; }; F670021A95E0AA103E8F7BED /* RecruitPostRoleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostRoleView.swift; sourceTree = ""; }; + FA5AA513615BE12CD677F848 /* StudentAcademicInfoDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StudentAcademicInfoDto.swift; sourceTree = ""; }; FB6A15BFD7B896A787835F8E /* RecruitApplyBlockReason.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitApplyBlockReason.swift; sourceTree = ""; }; FE01000000000000000011 /* KoinDropdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdown.swift; sourceTree = ""; }; FE01000000000000000013 /* KoinDropdownAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdownAnimator.swift; sourceTree = ""; }; @@ -5565,6 +5573,7 @@ children = ( 4067CAF18D134F99A04BBA0D /* RecruitListRequestDto.swift */, 2FE562D9FEDE6181748BE67B /* RecruitPostRequestDto.swift */, + 99C8CD337DB4DF4456561F92 /* RecruitProfileRequestDto.swift */, ); path = Recruit; sourceTree = ""; @@ -5615,7 +5624,6 @@ isa = PBXGroup; children = ( C0DE00000000000000000004 /* FetchMyRecruitProfileUseCase.swift */, - A11B00000000000000000002 /* PostBasicInfoUseCase.swift */, A11B00000000000000000004 /* UpsertMyRecruitProfileUseCase.swift */, ); path = RecruitProfile; @@ -6065,6 +6073,7 @@ D20AD7042E0367E00067760A /* ResetPasswordSmsRequest.swift */, D20AD7062E0367F10067760A /* ResetPasswordEmailRequest.swift */, D2C163D52E252C1200DC68A8 /* ChangePasswordRequest.swift */, + 9D3B08333D4FA77CC935917B /* StudentAcademicInfoRequestDto.swift */, ); path = User; sourceTree = ""; @@ -6120,6 +6129,7 @@ D874DB502DBF200C0098EED0 /* SendVerificationCodeDto.swift */, D20AD6F82E02A11D0067760A /* FindIdSmsResponse.swift */, D20AD6FA2E02A1280067760A /* FindIdEmailResponse.swift */, + FA5AA513615BE12CD677F848 /* StudentAcademicInfoDto.swift */, ); path = User; sourceTree = ""; @@ -6190,6 +6200,8 @@ D20AD7082E036BBB0067760A /* ResetPasswordSmsUseCase.swift */, D20AD70A2E036BC60067760A /* ResetPasswordEmailUseCase.swift */, D2C163D72E252DB500DC68A8 /* ChangePasswordUseCase.swift */, + 8D2525B9C0134B21C89671CB /* FetchBasicInfoUseCase.swift */, + A11B00000000000000000002 /* PostBasicInfoUseCase.swift */, ); path = User; sourceTree = ""; @@ -7869,6 +7881,10 @@ 3E56520894546C11EB8F99B3 /* RecruitPostRequestDto.swift in Sources */, 9419498912D565DE98568476 /* RecruitPostResultDto.swift in Sources */, 8CBCEFF4A1B4841D6B422B46 /* RecruitProfileDto.swift in Sources */, + FC40DDB1CAB3573658F94721 /* StudentAcademicInfoDto.swift in Sources */, + 57B70985D5DEFF4D774733F6 /* StudentAcademicInfoRequestDto.swift in Sources */, + 98672455503C7463C3E5BA15 /* FetchBasicInfoUseCase.swift in Sources */, + 7231A38A9BEC1DFBB8C2D978 /* RecruitProfileRequestDto.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 0b1c08ab6a0268dcfb5a549e89a8a65da6746ad9 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 15:59:03 +0900 Subject: [PATCH 068/123] =?UTF-8?q?refactor:=20=EC=A7=80=EC=9B=90=EC=84=9C?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EB=AA=A8=EB=8D=B8=EC=9D=84=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=84=9C=20=EC=9E=85=EB=A0=A5=EA=B0=92=EB=A7=8C=20?= =?UTF-8?q?=EA=B0=96=EB=8F=84=EB=A1=9D=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecruitApplyRequest 를 selectedRole / motivation / availableTime 으로 축소, isValid(for:) 로 검증 정리 - RecruitApplyViewController 상태를 basicInfo / profileRequest / applyRequest 로 분리 - ApplyRecruitUseCase, RecruitRepository.apply 에 recruitmentId 파라미터 추가 --- .../Repository/DefaultRecruitRepository.swift | 4 +- .../Repository/MockRecruitRepository.swift | 2 +- .../Model/Recruit/RecruitApplyRequest.swift | 30 +----- .../Domain/Repository/RecruitRepository.swift | 2 +- .../RecruitApply/ApplyRecruitUseCase.swift | 6 +- .../RecruitApplyViewController.swift | 94 ++++++++++++++----- .../RecruitApply/RecruitApplyViewModel.swift | 25 ++++- 7 files changed, 101 insertions(+), 62 deletions(-) diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift index 93da71c3a..ed74b92d5 100644 --- a/Koin/Data/Repository/DefaultRecruitRepository.swift +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -139,7 +139,7 @@ extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 try await mockRepository.markAllAsReadNotification() } - func apply(_ request: RecruitApplyRequest) async throws -> Void { - try await mockRepository.apply(request) + func apply(recruitmentId: Int, _ request: RecruitApplyRequest) async throws -> Void { + try await mockRepository.apply(recruitmentId: recruitmentId, request) } } diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 77935cfa0..1c2569323 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -114,7 +114,7 @@ final class MockRecruitRepository: RecruitRepository { ) } - func apply(_ request: RecruitApplyRequest) async throws -> Void { + func apply(recruitmentId: Int, _ request: RecruitApplyRequest) async throws -> Void { try await Task.sleep(nanoseconds: 300_000_000) } diff --git a/Koin/Domain/Model/Recruit/RecruitApplyRequest.swift b/Koin/Domain/Model/Recruit/RecruitApplyRequest.swift index 38a9a4e10..50a4aea27 100644 --- a/Koin/Domain/Model/Recruit/RecruitApplyRequest.swift +++ b/Koin/Domain/Model/Recruit/RecruitApplyRequest.swift @@ -8,38 +8,16 @@ import Foundation struct RecruitApplyRequest { - let recruit: RecruitData - var basicInfo = BasicInfo() - var skills: [String] = [] - var activities: [RecruitProfileActivityRequest] = [] - var introduction: String? var selectedRole: RecruitRole? var motivation: String? var availableTime: String? } extension RecruitApplyRequest { - var isFirstStepValid: Bool { - guard basicInfo.isValid, - let introduction, - !introduction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return false - } - - let normalizedSkills = skills.map { - $0.trimmingCharacters(in: .whitespacesAndNewlines) - } - return normalizedSkills.allSatisfy { !$0.isEmpty } - && Set(normalizedSkills).count == normalizedSkills.count - && activities.allSatisfy(\.isValid) - } - - var isSecondStepValid: Bool { - guard (recruit.type != .roleBased || selectedRole != nil), - let motivation, - !motivation.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let availableTime, - !availableTime.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + func isValid(for type: RecruitRoleType) -> Bool { + guard (type != .roleBased || selectedRole != nil), + let motivation, !motivation.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let availableTime, !availableTime.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } return true diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index f5160cee5..5e3a58a7c 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -50,5 +50,5 @@ protocol RecruitRepository { func deleteData(id: Int) async throws -> Bool func fetchMyProfile() async throws -> RecruitProfile func upsertMyProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile - func apply(_ request: RecruitApplyRequest) async throws -> Void + func apply(recruitmentId: Int, _ request: RecruitApplyRequest) async throws -> Void } diff --git a/Koin/Domain/UseCase/Recruit/RecruitApply/ApplyRecruitUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitApply/ApplyRecruitUseCase.swift index 9753d8b09..b9aaf0649 100644 --- a/Koin/Domain/UseCase/Recruit/RecruitApply/ApplyRecruitUseCase.swift +++ b/Koin/Domain/UseCase/Recruit/RecruitApply/ApplyRecruitUseCase.swift @@ -8,7 +8,7 @@ import Foundation protocol ApplyRecruitUseCase { - func execute(request: RecruitApplyRequest) async throws -> Void + func execute(recruitmentId: Int, request: RecruitApplyRequest) async throws -> Void } final class DefaultApplyRecruitUseCase: ApplyRecruitUseCase { @@ -18,7 +18,7 @@ final class DefaultApplyRecruitUseCase: ApplyRecruitUseCase { self.repository = repository } - func execute(request: RecruitApplyRequest) async throws -> Void { - try await repository.apply(request) + func execute(recruitmentId: Int, request: RecruitApplyRequest) async throws -> Void { + try await repository.apply(recruitmentId: recruitmentId, request) } } diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift index cbe980be6..5db639ddd 100644 --- a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift @@ -22,7 +22,10 @@ final class RecruitApplyViewController: UIViewController { private let inputSubject = PassthroughSubject() private var subscriptions = Set() - private var request: RecruitApplyRequest + private let recruit: RecruitData + private var basicInfo = BasicInfo() + private var profileRequest = RecruitProfileRequest() + private var applyRequest = RecruitApplyRequest() private var isFirstStep = true // MARK: - UI Components @@ -59,7 +62,7 @@ final class RecruitApplyViewController: UIViewController { private lazy var dropdownHost = KoinDropdownHost(scrollView: firstStepView) private lazy var departmentDropdownContentView = RecruitProfilePostDepartmentDropdownView { [weak self] department in guard let self else { return } - request.basicInfo.department = department + basicInfo.department = department firstStepView.configure(department: department) updateNextButtonState() } @@ -77,7 +80,7 @@ final class RecruitApplyViewController: UIViewController { ) { self.viewModel = viewModel self.delegate = delegate - self.request = RecruitApplyRequest(recruit: recruit) + self.recruit = recruit self.secondStepView = RecruitApplySecondStepView(recruit: recruit) super.init(nibName: nil, bundle: nil) } @@ -111,10 +114,7 @@ extension RecruitApplyViewController { .sink { [weak self] output in switch output { case let .updateRecruitProfile(profile): - self?.request.basicInfo = profile.toBasicInfo() - self?.request.skills = profile.skills - self?.request.activities = profile.activities.map { $0.toRequest() } - self?.request.introduction = profile.selfIntroduction + self?.updateRecruitProfile(profile) self?.firstStepView.configure(profile) self?.updateNextButtonState() case let .updateDepartments(departments): @@ -131,26 +131,42 @@ extension RecruitApplyViewController { .sink { [weak self] in self?.inputSubject.send(.loadRecruitProfile) } .store(in: &subscriptions) firstStepView.nicknameChangedPublisher - .sink { [weak self] value in self?.request.basicInfo.nickname = value; self?.updateNextButtonState() } + .sink { [weak self] value in + self?.basicInfo.nickname = value + self?.profileRequest.nickname = value + self?.updateNextButtonState() + } .store(in: &subscriptions) firstStepView.departmentButtonTappedPublisher .sink { [weak self] in guard let self else { return } view.endEditing(true) - restoreFirstStepContentInsetBottom { [weak self] in self?.departmentDropdown.toggle() } + restoreFirstStepContentInsetBottom { [weak self] in + self?.departmentDropdown.toggle() + } } .store(in: &subscriptions) firstStepView.studentNumberChangedPublisher - .sink { [weak self] value in self?.request.basicInfo.studentNumber = value; self?.updateNextButtonState() } + .sink { [weak self] value in self?.basicInfo.studentNumber = value + self?.updateNextButtonState() + } .store(in: &subscriptions) firstStepView.skillsChangedPublisher - .sink { [weak self] value in self?.request.skills = value; self?.updateNextButtonState() } + .sink { [weak self] value in self?.profileRequest.skills = value + self?.updateNextButtonState() + } .store(in: &subscriptions) firstStepView.activitiesChangedPublisher - .sink { [weak self] value in self?.request.activities = value; self?.updateNextButtonState() } + .sink { [weak self] value in + self?.profileRequest.activities = value + self?.updateNextButtonState() + } .store(in: &subscriptions) firstStepView.introductionChangedPublisher - .sink { [weak self] value in self?.request.introduction = value; self?.updateNextButtonState() } + .sink { [weak self] value in + self?.profileRequest.introduction = value + self?.updateNextButtonState() + } .store(in: &subscriptions) firstStepView.didChangeHeightPublisher .sink { [weak self] in @@ -168,13 +184,22 @@ extension RecruitApplyViewController { .store(in: &subscriptions) secondStepView.selectedRoleChangedPublisher - .sink { [weak self] value in self?.request.selectedRole = value; self?.updateApplyButtonState() } + .sink { [weak self] value + in self?.applyRequest.selectedRole = value + self?.updateApplyButtonState() + } .store(in: &subscriptions) secondStepView.motivationChangedPublisher - .sink { [weak self] value in self?.request.motivation = value; self?.updateApplyButtonState() } + .sink { [weak self] value in + self?.applyRequest.motivation = value + self?.updateApplyButtonState() + } .store(in: &subscriptions) secondStepView.availableTimeChangedPublisher - .sink { [weak self] value in self?.request.availableTime = value; self?.updateApplyButtonState() } + .sink { [weak self] value in + self?.applyRequest.availableTime = value + self?.updateApplyButtonState() + } .store(in: &subscriptions) } } @@ -187,7 +212,7 @@ extension RecruitApplyViewController { } @objc private func nextButtonTapped() { - guard request.isFirstStepValid, !firstStepView.isEditingActivity else { return } + guard isFirstStepValid, !firstStepView.isEditingActivity else { return } isFirstStep = false view.endEditing(true) dropdownHost.dismissPresented() @@ -204,17 +229,23 @@ extension RecruitApplyViewController { } @objc private func applyButtonTapped() { - guard request.isSecondStepValid else { return } + guard isSecondStepValid else { return } + let apply = { [weak self] in + guard let self else { return } + inputSubject.send(.apply( + recruitmentId: recruit.id, + basicInfo: basicInfo, + profileRequest: profileRequest, + applyRequest: applyRequest + )) + } let modalViewController = KoinModalViewController(configuration: .init( appearance: .new, content: .singleTitle(text: "해당 팀원 모집에 지원하시겠어요?"), button: .buttons( leftButtonTitle: "취소하기", rightButtonTitle: "지원하기", - rightButtonAction: { [weak self] in - guard let self else { return } - inputSubject.send(.apply(request)) - } + rightButtonAction: apply ) )) present(modalViewController, animated: true) @@ -309,14 +340,29 @@ extension RecruitApplyViewController { } extension RecruitApplyViewController { + private func updateRecruitProfile(_ profile: RecruitProfile) { + basicInfo = profile.toBasicInfo() + profileRequest = profile.toRequest() + } +} + +extension RecruitApplyViewController { + private var isFirstStepValid: Bool { + basicInfo.isValid && profileRequest.isValid + } + + private var isSecondStepValid: Bool { + applyRequest.isValid(for: recruit.type) + } + private func updateNextButtonState() { nextButton.updateState( - isEnabled: request.isFirstStepValid && !firstStepView.isEditingActivity + isEnabled: isFirstStepValid && !firstStepView.isEditingActivity ) } private func updateApplyButtonState() { - applyButton.updateState(isEnabled: request.isSecondStepValid) + applyButton.updateState(isEnabled: isSecondStepValid) } } diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift index 60d476586..37b51a0b1 100644 --- a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift @@ -13,7 +13,12 @@ final class RecruitApplyViewModel: ViewModelProtocol { enum Input { case viewDidLoad case loadRecruitProfile - case apply(RecruitApplyRequest) + case apply( + recruitmentId: Int, + basicInfo: BasicInfo, + profileRequest: RecruitProfileRequest, + applyRequest: RecruitApplyRequest + ) } enum Output { @@ -51,8 +56,13 @@ final class RecruitApplyViewModel: ViewModelProtocol { self?.fetchDepartments() case .loadRecruitProfile: self?.fetchRecruitProfile() - case let .apply(request): - self?.apply(request) + case let .apply(recruitmentId, basicInfo, profileRequest, applyRequest): + self?.apply( + recruitmentId: recruitmentId, + basicInfo: basicInfo, + profileRequest: profileRequest, + applyRequest: applyRequest + ) } } .store(in: &subscriptions) @@ -99,7 +109,12 @@ extension RecruitApplyViewModel { } } - private func apply(_ request: RecruitApplyRequest) { + private func apply( + recruitmentId: Int, + basicInfo: BasicInfo, + profileRequest: RecruitProfileRequest, + applyRequest: RecruitApplyRequest + ) { guard !isApplying else { return } isApplying = true @@ -108,7 +123,7 @@ extension RecruitApplyViewModel { defer { isApplying = false } do { - try await applyRecruitUseCase.execute(request: request) + try await applyRecruitUseCase.execute(recruitmentId: recruitmentId, request: applyRequest) outputSubject.send(.applyCompleted) } catch { let errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription From 974c5028fd08a52b9f1a5f2e100c6ab7ffe33f37 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 15:59:43 +0900 Subject: [PATCH 069/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=A7=80=EC=9B=90=EC=84=9C=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /team-recruitments/{recruitmentId}/applications 연결 (RecruitApplyRequestDto 신설) - 일반 모집 지원 시 role_id 를 null 로 명시 전송 - 지원 전 Step1 값을 학적 정보 / 팀원모집 프로필로 먼저 저장 --- .../Recruit/RecruitApplyRequestDto.swift | 38 +++++++++++++++++++ .../Repository/DefaultRecruitRepository.swift | 11 ++++-- .../Data/Service/Network/API/RecruitAPI.swift | 9 ++++- Koin/Data/Service/RecruitService.swift | 5 +++ .../RecruitApply/RecruitApplyViewModel.swift | 8 ++++ .../RecruitDataHostingController.swift | 3 ++ koin.xcodeproj/project.pbxproj | 4 ++ 7 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 Koin/Data/DTOs/Encodable/Recruit/RecruitApplyRequestDto.swift diff --git a/Koin/Data/DTOs/Encodable/Recruit/RecruitApplyRequestDto.swift b/Koin/Data/DTOs/Encodable/Recruit/RecruitApplyRequestDto.swift new file mode 100644 index 000000000..c9e2a3264 --- /dev/null +++ b/Koin/Data/DTOs/Encodable/Recruit/RecruitApplyRequestDto.swift @@ -0,0 +1,38 @@ +// +// RecruitApplyRequestDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct RecruitApplyRequestDto: Encodable { + let roleId: Int? + let motivation: String + let availability: String + + enum CodingKeys: String, CodingKey { + case roleId = "role_id" + case motivation, availability + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(roleId, forKey: .roleId) + try container.encode(motivation, forKey: .motivation) + try container.encode(availability, forKey: .availability) + } +} + +extension RecruitApplyRequestDto { + init?(from request: RecruitApplyRequest) { + guard let motivation = request.motivation, + let availableTime = request.availableTime else { + return nil + } + self.roleId = request.selectedRole?.id + self.motivation = motivation + self.availability = availableTime + } +} diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift index ed74b92d5..125a3b901 100644 --- a/Koin/Data/Repository/DefaultRecruitRepository.swift +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -49,6 +49,13 @@ final class DefaultRecruitRepository: RecruitRepository { try await service.fetchMyProfile().toDomain() } + func apply(recruitmentId: Int, _ request: RecruitApplyRequest) async throws -> Void { + guard let dto = RecruitApplyRequestDto(from: request) else { + throw ErrorResponse.unexpectedInternalError + } + try await service.apply(recruitmentId, dto) + } + func upsertMyProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile { guard let request = RecruitProfileRequestDto(from: request) else { throw ErrorResponse.unexpectedInternalError @@ -138,8 +145,4 @@ extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 func markAllAsReadNotification() async throws -> Void { try await mockRepository.markAllAsReadNotification() } - - func apply(recruitmentId: Int, _ request: RecruitApplyRequest) async throws -> Void { - try await mockRepository.apply(recruitmentId: recruitmentId, request) - } } diff --git a/Koin/Data/Service/Network/API/RecruitAPI.swift b/Koin/Data/Service/Network/API/RecruitAPI.swift index 600fe1a05..a12ed01fa 100644 --- a/Koin/Data/Service/Network/API/RecruitAPI.swift +++ b/Koin/Data/Service/Network/API/RecruitAPI.swift @@ -16,6 +16,7 @@ enum RecruitAPI { case modify(Int, RecruitPostRequestDto) case fetchMyProfile case upsertMyProfile(RecruitProfileRequestDto) + case apply(Int, RecruitApplyRequestDto) } extension RecruitAPI: Router, URLRequestConvertible { @@ -33,6 +34,7 @@ extension RecruitAPI: Router, URLRequestConvertible { case .modify(let id, _): return "/team-recruitments/\(id)" case .fetchMyProfile: return "/team-recruitment-profiles/me" case .upsertMyProfile: return "/team-recruitment-profiles/me" + case .apply(let id, _): return "/team-recruitments/\(id)/applications" } } @@ -45,6 +47,7 @@ extension RecruitAPI: Router, URLRequestConvertible { case .modify: return .put case .fetchMyProfile: return .get case .upsertMyProfile: return .put + case .apply: return .post } } @@ -53,7 +56,7 @@ extension RecruitAPI: Router, URLRequestConvertible { switch self { case .fetchList, .fetchData, .deleteData, .fetchMyProfile: break - case .post, .modify, .upsertMyProfile: + case .post, .modify, .upsertMyProfile, .apply: baseHeaders["Content-Type"] = "application/json" } return baseHeaders @@ -69,6 +72,8 @@ extension RecruitAPI: Router, URLRequestConvertible { return try? request.toDictionary() case .upsertMyProfile(let request): return try? request.toDictionary() + case .apply(_, let request): + return try? request.toDictionary() } } @@ -78,7 +83,7 @@ extension RecruitAPI: Router, URLRequestConvertible { return URLEncoding(arrayEncoding: .noBrackets) case .fetchData, .deleteData, .fetchMyProfile: return nil - case .post, .modify, .upsertMyProfile: + case .post, .modify, .upsertMyProfile, .apply: return JSONEncoding.default } } diff --git a/Koin/Data/Service/RecruitService.swift b/Koin/Data/Service/RecruitService.swift index e3a20e6c5..3882f5e83 100644 --- a/Koin/Data/Service/RecruitService.swift +++ b/Koin/Data/Service/RecruitService.swift @@ -15,6 +15,7 @@ protocol RecruitService { func modify(_ id: Int, _ request: RecruitPostRequestDto) async throws func fetchMyProfile() async throws -> RecruitProfileDto func upsertMyProfile(_ request: RecruitProfileRequestDto) async throws -> RecruitProfileDto + func apply(_ id: Int, _ request: RecruitApplyRequestDto) async throws } final class DefaultRecruitService: RecruitService { @@ -48,4 +49,8 @@ final class DefaultRecruitService: RecruitService { func upsertMyProfile(_ request: RecruitProfileRequestDto) async throws -> RecruitProfileDto { try await networkService.requestWithResponse(api: RecruitAPI.upsertMyProfile(request)) } + + func apply(_ id: Int, _ request: RecruitApplyRequestDto) async throws { + try await networkService.request(api: RecruitAPI.apply(id, request)) + } } diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift index 37b51a0b1..c552de143 100644 --- a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift @@ -31,6 +31,8 @@ final class RecruitApplyViewModel: ViewModelProtocol { // MARK: - Properties private let fetchDeptListUseCase: FetchDeptListUseCase private let fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase + private let modifyBasicInfoUseCase: ModifyBasicInfoUseCase + private let upsertMyRecruitProfileUseCase: UpsertMyRecruitProfileUseCase private let applyRecruitUseCase: ApplyRecruitUseCase private let outputSubject = PassthroughSubject() private var subscriptions = Set() @@ -41,10 +43,14 @@ final class RecruitApplyViewModel: ViewModelProtocol { init( fetchDeptListUseCase: FetchDeptListUseCase, fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase, + modifyBasicInfoUseCase: ModifyBasicInfoUseCase, + upsertMyRecruitProfileUseCase: UpsertMyRecruitProfileUseCase, applyRecruitUseCase: ApplyRecruitUseCase ) { self.fetchDeptListUseCase = fetchDeptListUseCase self.fetchMyRecruitProfileUseCase = fetchMyRecruitProfileUseCase + self.modifyBasicInfoUseCase = modifyBasicInfoUseCase + self.upsertMyRecruitProfileUseCase = upsertMyRecruitProfileUseCase self.applyRecruitUseCase = applyRecruitUseCase } @@ -123,6 +129,8 @@ extension RecruitApplyViewModel { defer { isApplying = false } do { + try await modifyBasicInfoUseCase.execute(basicInfo: basicInfo) + _ = try await upsertMyRecruitProfileUseCase.execute(request: profileRequest) try await applyRecruitUseCase.execute(recruitmentId: recruitmentId, request: applyRequest) outputSubject.send(.applyCompleted) } catch { diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index ab6cb033d..c0803d939 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -155,10 +155,13 @@ extension RecruitDataHostingController { guard let recruit = rootView.data else { return } + let userRepository = DefaultUserRepository(service: DefaultUserService()) let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let viewModel = RecruitApplyViewModel( fetchDeptListUseCase: DefaultFetchDeptListUseCase(timetableRepository: DefaultTimetableRepository(service: DefaultTimetableService())), fetchMyRecruitProfileUseCase: DefaultFetchMyRecruitProfileUseCase(repository: recruitRepository), + modifyBasicInfoUseCase: DefaultModifyBasicInfoUseCase(repository: userRepository), + upsertMyRecruitProfileUseCase: DefaultUpsertMyRecruitProfileUseCase(repository: recruitRepository), applyRecruitUseCase: DefaultApplyRecruitUseCase(repository: recruitRepository) ) let viewController = RecruitApplyViewController( diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 7feaacfc9..a68ec6b76 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -919,6 +919,7 @@ B68D2CE92EA505B800F3B479 /* ToastVariant.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68D2CE82EA505B800F3B479 /* ToastVariant.swift */; }; B68D2CEB2EA50C1E00F3B479 /* UIViewController+Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68D2CEA2EA50C1E00F3B479 /* UIViewController+Toast.swift */; }; BAD01845AA334A648E2FA2BD /* RecruitDataHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBC0EF8E0FE44F48744BEC7 /* RecruitDataHeaderView.swift */; }; + BD72DD59230BE11B09BF075F /* RecruitApplyRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8FC035A1EC83BD077A6AFCA /* RecruitApplyRequestDto.swift */; }; BEEF20000000000000000001 /* RecruitMyPostList.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000001 /* RecruitMyPostList.swift */; }; BEEF20000000000000000002 /* RecruitMyPostRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000002 /* RecruitMyPostRow.swift */; }; BEEF20000000000000000003 /* RecruitMyPostFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF10000000000000000003 /* RecruitMyPostFilter.swift */; }; @@ -2142,6 +2143,7 @@ BEEF1000000000000000000C /* CloseRecruitMyPostUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseRecruitMyPostUseCase.swift; sourceTree = ""; }; C0DE00000000000000000002 /* RecruitProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfile.swift; sourceTree = ""; }; C0DE00000000000000000004 /* FetchMyRecruitProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMyRecruitProfileUseCase.swift; sourceTree = ""; }; + C8FC035A1EC83BD077A6AFCA /* RecruitApplyRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitApplyRequestDto.swift; sourceTree = ""; }; CAFE10000000000000000001 /* RecruitMyApplicationList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationList.swift; sourceTree = ""; }; CAFE10000000000000000002 /* RecruitMyApplicationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationRow.swift; sourceTree = ""; }; CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationFilter.swift; sourceTree = ""; }; @@ -5574,6 +5576,7 @@ 4067CAF18D134F99A04BBA0D /* RecruitListRequestDto.swift */, 2FE562D9FEDE6181748BE67B /* RecruitPostRequestDto.swift */, 99C8CD337DB4DF4456561F92 /* RecruitProfileRequestDto.swift */, + C8FC035A1EC83BD077A6AFCA /* RecruitApplyRequestDto.swift */, ); path = Recruit; sourceTree = ""; @@ -7885,6 +7888,7 @@ 57B70985D5DEFF4D774733F6 /* StudentAcademicInfoRequestDto.swift in Sources */, 98672455503C7463C3E5BA15 /* FetchBasicInfoUseCase.swift in Sources */, 7231A38A9BEC1DFBB8C2D978 /* RecruitProfileRequestDto.swift in Sources */, + BD72DD59230BE11B09BF075F /* RecruitApplyRequestDto.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From abb1b8eff0b390179d7a79e6f299366c205237b7 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 16:00:23 +0900 Subject: [PATCH 070/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=A7=80=EC=9B=90=EC=84=9C=20=EC=A0=9C=EC=B6=9C=20?= =?UTF-8?q?=EC=A4=91=20=EB=A1=9C=EB=94=A9=20=EC=9D=B8=EB=94=94=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecruitApplyViewModel Output 에 updateLoading 추가 - 제출 시작/종료에 맞춰 IndicatorView show / dismiss --- .../RecruitApply/RecruitApplyViewController.swift | 12 ++++++++++++ .../Recruit/RecruitApply/RecruitApplyViewModel.swift | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift index 5db639ddd..148208757 100644 --- a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift @@ -119,6 +119,8 @@ extension RecruitApplyViewController { self?.updateNextButtonState() case let .updateDepartments(departments): self?.departmentDropdownContentView.configure(departments: departments) + case let .updateLoading(isLoading): + self?.updateLoading(isLoading) case .applyCompleted: self?.handleApplyCompleted() case let .showToast(message): @@ -346,6 +348,16 @@ extension RecruitApplyViewController { } } +extension RecruitApplyViewController { + private func updateLoading(_ isLoading: Bool) { + if isLoading { + IndicatorView.show() + } else { + IndicatorView.dismiss() + } + } +} + extension RecruitApplyViewController { private var isFirstStepValid: Bool { basicInfo.isValid && profileRequest.isValid diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift index c552de143..b4a49f7ce 100644 --- a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift @@ -24,6 +24,7 @@ final class RecruitApplyViewModel: ViewModelProtocol { enum Output { case updateRecruitProfile(RecruitProfile) case updateDepartments([String]) + case updateLoading(Bool) case applyCompleted case showToast(String) } @@ -124,9 +125,13 @@ extension RecruitApplyViewModel { guard !isApplying else { return } isApplying = true + outputSubject.send(.updateLoading(true)) Task { [weak self] in guard let self else { return } - defer { isApplying = false } + defer { + isApplying = false + outputSubject.send(.updateLoading(false)) + } do { try await modifyBasicInfoUseCase.execute(basicInfo: basicInfo) From ad3e31a281f24d896a25ebcf26efdae124bdc9e0 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 16:22:32 +0900 Subject: [PATCH 071/123] =?UTF-8?q?refactor:=20=EB=82=B4=EA=B0=80=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=ED=95=9C=20=EB=AA=A8=EC=A7=91=20Row=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C/D-day=20optional=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecruitMyApplicationRow 날짜 Date?, dDay String? 로 변경 - D-day 는 값이 있을 때만 표시, 활동 기간은 nil 이면 빈 문자열 --- Koin/Data/Repository/MockRecruitRepository.swift | 10 +++++----- .../Model/Recruit/RecruitMyApplicationRow.swift | 8 ++++---- .../Subviews/RecruitMyApplicationListRowView.swift | 14 ++++++++++---- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 1c2569323..877ed28ea 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -453,10 +453,10 @@ final class MockRecruitRepository: RecruitRepository { category: summary.category, title: summary.title, meetingType: summary.meetingType, - startDate: summary.startDate ?? Date(), - endDate: summary.endDate ?? Date(), - deadline: summary.deadline ?? Date(), - dDay: summary.dDay ?? "", + startDate: summary.startDate, + endDate: summary.endDate, + deadline: summary.deadline, + dDay: summary.dDay, state: summary.state, currentParticipants: summary.currentParticipants, maximumParticipants: summary.maximumParticipants, @@ -474,7 +474,7 @@ final class MockRecruitRepository: RecruitRepository { recruits = recruits.filter { $0.application.status == status } } if filter.sort == .deadlineAscending { - recruits.sort { $0.deadline < $1.deadline } + recruits.sort { ($0.deadline ?? .distantFuture) < ($1.deadline ?? .distantFuture) } } let totalCount = recruits.count diff --git a/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift b/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift index 83819a863..88f8b911e 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift @@ -13,10 +13,10 @@ struct RecruitMyApplicationRow: Identifiable, Equatable { let title: String let meetingType: RecruitMeetingType - let startDate: Date - let endDate: Date - let deadline: Date - let dDay: String + let startDate: Date? + let endDate: Date? + let deadline: Date? + let dDay: String? let state: RecruitState let currentParticipants: Int diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift index 2f93da87b..f0f32486d 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift @@ -47,9 +47,15 @@ extension RecruitMyApplicationListRowView { .background(Color.appColor(model.category.backgroundColor)) .clipShape(.capsule) - Text(model.state == .closed ? "모집완료" : model.dDay) - .font(.appFont(.pretendardMedium, size: 10)) - .foregroundStyle(Color.appColor(model.state == .closed ? .new600 : .danger700)) + if model.state == .closed { + Text("모집완료") + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(.new600)) + } else if let dDay = model.dDay { + Text(dDay) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(.danger700)) + } Spacer() @@ -96,7 +102,7 @@ extension RecruitMyApplicationListRowView { HStack(alignment: .center, spacing: 2) { Image.appImage(asset: .recruitDate) - Text("\(model.startDate.formatDateToYYYYMMDD(separator: ".")) ~ \(model.endDate.formatDateToYYYYMMDD(separator: "."))") + Text("\(model.startDate?.formatDateToYYYYMMDD(separator: ".") ?? "") ~ \(model.endDate?.formatDateToYYYYMMDD(separator: ".") ?? "")") .font(.appFont(.pretendardRegular, size: 10)) .foregroundStyle(Color.appColor(.neutral500)) } From e56a50b65db3c72a7e9091e8e345a670a0ad0211 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 16:23:04 +0900 Subject: [PATCH 072/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EB=82=B4=EA=B0=80=20=EC=A7=80=EC=9B=90=ED=95=9C=20?= =?UTF-8?q?=EB=AA=A8=EC=A7=91=20=EB=AA=A9=EB=A1=9D=20API=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /team-recruitments/me/applications 연결 - RecruitMyApplicationListRequestDto / RecruitMyApplicationListDto 신설 (recruitment 는 RecruitListSummaryDto 재사용) - RecruitApplicationStatusDto 인코딩 지원 --- .../Decodable/Recruit/RecruitEnumDto.swift | 13 +++- .../Recruit/RecruitMyApplicationListDto.swift | 75 +++++++++++++++++++ .../RecruitMyApplicationListRequestDto.swift | 24 ++++++ .../Repository/DefaultRecruitRepository.swift | 9 ++- .../Data/Service/Network/API/RecruitAPI.swift | 9 ++- Koin/Data/Service/RecruitService.swift | 5 ++ koin.xcodeproj/project.pbxproj | 8 ++ 7 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 Koin/Data/DTOs/Decodable/Recruit/RecruitMyApplicationListDto.swift create mode 100644 Koin/Data/DTOs/Encodable/Recruit/RecruitMyApplicationListRequestDto.swift diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift index f766fa291..408ab78e6 100644 --- a/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift @@ -31,7 +31,7 @@ enum RecruitRoleTypeDto: String, Codable { case general = "GENERAL" } -enum RecruitApplicationStatusDto: String, Decodable { +enum RecruitApplicationStatusDto: String, Codable { case pending = "PENDING" case accepted = "ACCEPTED" case rejected = "REJECTED" @@ -136,6 +136,17 @@ extension RecruitRoleTypeDto { } extension RecruitApplicationStatusDto { + init(from model: RecruitApplicationStatus) { + switch model { + case .pending: + self = .pending + case .accepted: + self = .accepted + case .denied: + self = .rejected + } + } + func toDomain() -> RecruitApplicationStatus { switch self { case .pending: diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitMyApplicationListDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyApplicationListDto.swift new file mode 100644 index 000000000..01feaa6ff --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyApplicationListDto.swift @@ -0,0 +1,75 @@ +// +// RecruitMyApplicationListDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct RecruitMyApplicationListDto: Decodable { + let applications: [RecruitMyApplicationRowDto] + let totalCount: Int + let currentCount: Int + let totalPage: Int + let currentPage: Int + + enum CodingKeys: String, CodingKey { + case applications + case totalCount = "total_count" + case currentCount = "current_count" + case totalPage = "total_page" + case currentPage = "current_page" + } +} + +struct RecruitMyApplicationRowDto: Decodable { + let applicationId: Int + let status: RecruitApplicationStatusDto + let teamChatRoomId: Int? + let recruitment: RecruitListSummaryDto + + enum CodingKeys: String, CodingKey { + case applicationId = "application_id" + case status + case teamChatRoomId = "team_chat_room_id" + case recruitment + } +} + +extension RecruitMyApplicationListDto { + func toDomain() -> RecruitMyApplicationList { + return RecruitMyApplicationList( + recruits: applications.map { $0.toDomain() }, + totalCount: totalCount, + totalPage: totalPage, + currentPage: currentPage + ) + } +} + +extension RecruitMyApplicationRowDto { + func toDomain() -> RecruitMyApplicationRow { + let recruit = recruitment.toDomain() + return RecruitMyApplicationRow( + id: recruit.id, + category: recruit.category, + title: recruit.title, + meetingType: recruit.meetingType, + startDate: recruit.startDate, + endDate: recruit.endDate, + deadline: recruit.deadline, + dDay: recruit.dDay, + state: recruit.state, + currentParticipants: recruit.currentParticipants, + maximumParticipants: recruit.maximumParticipants, + type: recruit.type, + roles: recruit.roles, + application: RecruitMyApplication( + id: applicationId, + status: status.toDomain() + ), + chatRoomId: teamChatRoomId + ) + } +} diff --git a/Koin/Data/DTOs/Encodable/Recruit/RecruitMyApplicationListRequestDto.swift b/Koin/Data/DTOs/Encodable/Recruit/RecruitMyApplicationListRequestDto.swift new file mode 100644 index 000000000..4c881a05b --- /dev/null +++ b/Koin/Data/DTOs/Encodable/Recruit/RecruitMyApplicationListRequestDto.swift @@ -0,0 +1,24 @@ +// +// RecruitMyApplicationListRequestDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct RecruitMyApplicationListRequestDto: Encodable { + let statuses: [RecruitApplicationStatusDto]? + let sort: RecruitListSortDto + let page: Int + let limit: Int? +} + +extension RecruitMyApplicationListRequestDto { + init(from filter: RecruitMyApplicationFilter) { + self.statuses = filter.status.map { [RecruitApplicationStatusDto(from: $0)] } + self.sort = RecruitListSortDto(from: filter.sort) + self.page = filter.page + self.limit = filter.limit + } +} diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift index 125a3b901..c8df66838 100644 --- a/Koin/Data/Repository/DefaultRecruitRepository.swift +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -56,6 +56,11 @@ final class DefaultRecruitRepository: RecruitRepository { try await service.apply(recruitmentId, dto) } + func fetchMyApplicationList(_ filter: RecruitMyApplicationFilter) async throws -> RecruitMyApplicationList { + let request = RecruitMyApplicationListRequestDto(from: filter) + return try await service.fetchMyApplicationList(request).toDomain() + } + func upsertMyProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile { guard let request = RecruitProfileRequestDto(from: request) else { throw ErrorResponse.unexpectedInternalError @@ -118,10 +123,6 @@ extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 try await mockRepository.decideApplicant(recruitmentId: recruitmentId, applicationId: applicationId, decision: decision) } - func fetchMyApplicationList(_ filter: RecruitMyApplicationFilter) async throws -> RecruitMyApplicationList { - try await mockRepository.fetchMyApplicationList(filter) - } - func closeMyPost(id: Int) async throws -> Bool { try await mockRepository.closeMyPost(id: id) } diff --git a/Koin/Data/Service/Network/API/RecruitAPI.swift b/Koin/Data/Service/Network/API/RecruitAPI.swift index a12ed01fa..681a2e5b0 100644 --- a/Koin/Data/Service/Network/API/RecruitAPI.swift +++ b/Koin/Data/Service/Network/API/RecruitAPI.swift @@ -17,6 +17,7 @@ enum RecruitAPI { case fetchMyProfile case upsertMyProfile(RecruitProfileRequestDto) case apply(Int, RecruitApplyRequestDto) + case fetchMyApplicationList(RecruitMyApplicationListRequestDto) } extension RecruitAPI: Router, URLRequestConvertible { @@ -35,6 +36,7 @@ extension RecruitAPI: Router, URLRequestConvertible { case .fetchMyProfile: return "/team-recruitment-profiles/me" case .upsertMyProfile: return "/team-recruitment-profiles/me" case .apply(let id, _): return "/team-recruitments/\(id)/applications" + case .fetchMyApplicationList: return "/team-recruitments/me/applications" } } @@ -48,13 +50,14 @@ extension RecruitAPI: Router, URLRequestConvertible { case .fetchMyProfile: return .get case .upsertMyProfile: return .put case .apply: return .post + case .fetchMyApplicationList: return .get } } public var headers: [String: String] { var baseHeaders: [String: String] = [:] switch self { - case .fetchList, .fetchData, .deleteData, .fetchMyProfile: + case .fetchList, .fetchData, .deleteData, .fetchMyProfile, .fetchMyApplicationList: break case .post, .modify, .upsertMyProfile, .apply: baseHeaders["Content-Type"] = "application/json" @@ -74,12 +77,14 @@ extension RecruitAPI: Router, URLRequestConvertible { return try? request.toDictionary() case .apply(_, let request): return try? request.toDictionary() + case .fetchMyApplicationList(let request): + return try? request.toDictionary() } } public var encoding: ParameterEncoding? { switch self { - case .fetchList: + case .fetchList, .fetchMyApplicationList: return URLEncoding(arrayEncoding: .noBrackets) case .fetchData, .deleteData, .fetchMyProfile: return nil diff --git a/Koin/Data/Service/RecruitService.swift b/Koin/Data/Service/RecruitService.swift index 3882f5e83..26386f293 100644 --- a/Koin/Data/Service/RecruitService.swift +++ b/Koin/Data/Service/RecruitService.swift @@ -16,6 +16,7 @@ protocol RecruitService { func fetchMyProfile() async throws -> RecruitProfileDto func upsertMyProfile(_ request: RecruitProfileRequestDto) async throws -> RecruitProfileDto func apply(_ id: Int, _ request: RecruitApplyRequestDto) async throws + func fetchMyApplicationList(_ request: RecruitMyApplicationListRequestDto) async throws -> RecruitMyApplicationListDto } final class DefaultRecruitService: RecruitService { @@ -53,4 +54,8 @@ final class DefaultRecruitService: RecruitService { func apply(_ id: Int, _ request: RecruitApplyRequestDto) async throws { try await networkService.request(api: RecruitAPI.apply(id, request)) } + + func fetchMyApplicationList(_ request: RecruitMyApplicationListRequestDto) async throws -> RecruitMyApplicationListDto { + try await networkService.requestWithResponse(api: RecruitAPI.fetchMyApplicationList(request)) + } } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index a68ec6b76..d07e05f84 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 2E6336C1CDB4445BA858DA5A /* RecruitDataContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */; }; 3DDE20379881F001713618FC /* RecruitDataDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA92583628DDFBC3545C197B /* RecruitDataDto.swift */; }; 3E56520894546C11EB8F99B3 /* RecruitPostRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE562D9FEDE6181748BE67B /* RecruitPostRequestDto.swift */; }; + 4AAF0644AD43FD83CE4E5E32 /* RecruitMyApplicationListDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 756A97197501167BE3516430 /* RecruitMyApplicationListDto.swift */; }; 5006987DEF4B5F454D7EA2D5 /* RecruitPostTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */; }; 515B96092C6730C797C1F837 /* PostRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D447F2D75951DD4C3E08D4E /* PostRecruitUseCase.swift */; }; 57B70985D5DEFF4D774733F6 /* StudentAcademicInfoRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D3B08333D4FA77CC935917B /* StudentAcademicInfoRequestDto.swift */; }; @@ -1186,6 +1187,7 @@ D8B4E0042DBFAD1A001FBC89 /* CheckVerificationCodeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B4E0032DBFAD1A001FBC89 /* CheckVerificationCodeRequest.swift */; }; D8F5C54C2E6F012500FB6708 /* LottieAnimationManageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F5C54B2E6F012500FB6708 /* LottieAnimationManageable.swift */; }; D8F5C5562E6F144300FB6708 /* floatingLogo.json in Resources */ = {isa = PBXBuildFile; fileRef = D8F5C5532E6F144300FB6708 /* floatingLogo.json */; }; + DE8E26BC32390F246B7F4CF1 /* RecruitMyApplicationListRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3143194BD51D3A85C2A959EB /* RecruitMyApplicationListRequestDto.swift */; }; E1A70C012D9A4F1100AGSEL1 /* AgreementSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A70C002D9A4F1100AGSEL0 /* AgreementSelection.swift */; }; E5DD3F6BE6C501B7B3BD659C /* RecruitPostRoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F670021A95E0AA103E8F7BED /* RecruitPostRoleView.swift */; }; F0F216EDAA69B52CBEC0E606 /* RecruitPostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 288BC1E04044DC3FB48524FC /* RecruitPostViewModel.swift */; }; @@ -1240,6 +1242,7 @@ 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostDepartmentDropdownView.swift; sourceTree = ""; }; 2E31EA58151824C938383893 /* RecruitEnumDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitEnumDto.swift; sourceTree = ""; }; 2FE562D9FEDE6181748BE67B /* RecruitPostRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitPostRequestDto.swift; sourceTree = ""; }; + 3143194BD51D3A85C2A959EB /* RecruitMyApplicationListRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListRequestDto.swift; sourceTree = ""; }; 3C3694E3ACBE05B8C7F15357 /* RecruitPostSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostSectionHeaderView.swift; sourceTree = ""; }; 4067CAF18D134F99A04BBA0D /* RecruitListRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitListRequestDto.swift; sourceTree = ""; }; 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostCategoryView.swift; sourceTree = ""; }; @@ -1250,6 +1253,7 @@ 645CEE62E516B130B4E2D784 /* RecruitMyApplication.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyApplication.swift; sourceTree = ""; }; 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataContentView.swift; sourceTree = ""; }; 73F70BC5F3AA1A34663C32CA /* RecruitPostMeetingTypeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostMeetingTypeView.swift; sourceTree = ""; }; + 756A97197501167BE3516430 /* RecruitMyApplicationListDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListDto.swift; sourceTree = ""; }; 7C01653F300A88940013DD7B /* ShuttleTicketViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShuttleTicketViewController.swift; sourceTree = ""; }; 7C016543300AA1450013DD7B /* DepartmentCategoryHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DepartmentCategoryHostingController.swift; sourceTree = ""; }; 7C016545300AA14C0013DD7B /* DepartmentCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DepartmentCategoryView.swift; sourceTree = ""; }; @@ -2480,6 +2484,7 @@ EA92583628DDFBC3545C197B /* RecruitDataDto.swift */, 267C5F2D082AC60FDA16F85C /* RecruitPostResultDto.swift */, 1A900DEA87E917C84BCD7E04 /* RecruitProfileDto.swift */, + 756A97197501167BE3516430 /* RecruitMyApplicationListDto.swift */, ); path = Recruit; sourceTree = ""; @@ -5577,6 +5582,7 @@ 2FE562D9FEDE6181748BE67B /* RecruitPostRequestDto.swift */, 99C8CD337DB4DF4456561F92 /* RecruitProfileRequestDto.swift */, C8FC035A1EC83BD077A6AFCA /* RecruitApplyRequestDto.swift */, + 3143194BD51D3A85C2A959EB /* RecruitMyApplicationListRequestDto.swift */, ); path = Recruit; sourceTree = ""; @@ -7889,6 +7895,8 @@ 98672455503C7463C3E5BA15 /* FetchBasicInfoUseCase.swift in Sources */, 7231A38A9BEC1DFBB8C2D978 /* RecruitProfileRequestDto.swift in Sources */, BD72DD59230BE11B09BF075F /* RecruitApplyRequestDto.swift in Sources */, + DE8E26BC32390F246B7F4CF1 /* RecruitMyApplicationListRequestDto.swift in Sources */, + 4AAF0644AD43FD83CE4E5E32 /* RecruitMyApplicationListDto.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From c6a67e52e5b2a26e04b3e95766de32fa5168cc2c Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 16:59:31 +0900 Subject: [PATCH 073/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=A7=80=EC=9B=90=EC=9E=90=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecruitMyPostData 에 totalPage / currentPage / hasNextPage 추가 - fetchMyPostData / FetchRecruitMyPostDataUseCase 에 page 파라미터 추가 - 지원자 목록 마지막 행 노출 시 다음 페이지 로드, 하단 ProgressView 표시 - 전체 로딩 오버레이는 최초 로드 시에만 표시 --- .../Repository/DefaultRecruitRepository.swift | 4 +-- .../Repository/MockRecruitRepository.swift | 10 +++++--- .../Model/Recruit/RecruitMyPostData.swift | 10 +++++++- .../Domain/Repository/RecruitRepository.swift | 2 +- .../FetchRecruitMyPostDataUseCase.swift | 6 ++--- .../RecruitMyPost/RecruitMyPostView.swift | 23 ++++++++++++++++- .../RecruitMyPostViewModel.swift | 25 ++++++++++++++++--- 7 files changed, 65 insertions(+), 15 deletions(-) diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift index c8df66838..e3f33bc63 100644 --- a/Koin/Data/Repository/DefaultRecruitRepository.swift +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -104,8 +104,8 @@ extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 try await mockRepository.fetchMyPostList(filter) } - func fetchMyPostData(_ id: Int) async throws -> RecruitMyPostData { - try await mockRepository.fetchMyPostData(id) + func fetchMyPostData(_ id: Int, page: Int) async throws -> RecruitMyPostData { + try await mockRepository.fetchMyPostData(id, page: page) } func fetchApplicant( diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 877ed28ea..81979659c 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -306,7 +306,7 @@ final class MockRecruitRepository: RecruitRepository { ) } - func fetchMyPostData(_ id: Int) async throws -> RecruitMyPostData { + func fetchMyPostData(_ id: Int, page: Int) async throws -> RecruitMyPostData { let response = try await fetchMyPostList(RecruitMyPostFilter()) guard let recruit = response.recruits.first(where: { $0.id == id }) else { throw ErrorResponse.unexpectedInternalError @@ -331,7 +331,9 @@ final class MockRecruitRepository: RecruitRepository { state: recruit.state, chatRoomId: recruit.chatRoomId ?? recruit.id, applicants: applicants, - totalCount: applicants.count + totalCount: applicants.count, + totalPage: 1, + currentPage: page ) } @@ -341,7 +343,7 @@ final class MockRecruitRepository: RecruitRepository { ) async throws -> RecruitApplicantData { try await Task.sleep(nanoseconds: 300_000_000) - let recruitment = try await fetchMyPostData(recruitmentId) + let recruitment = try await fetchMyPostData(recruitmentId, page: 1) guard let application = recruitment.applicants.first(where: { $0.applicationId == applicationId }) else { @@ -390,7 +392,7 @@ final class MockRecruitRepository: RecruitRepository { applicationId: Int, decision: RecruitApplicantDecision ) async throws -> Void { - let recruitment = try await fetchMyPostData(recruitmentId) + let recruitment = try await fetchMyPostData(recruitmentId, page: 1) guard recruitment.applicants.contains(where: { $0.applicationId == applicationId && $0.status == .pending }) else { diff --git a/Koin/Domain/Model/Recruit/RecruitMyPostData.swift b/Koin/Domain/Model/Recruit/RecruitMyPostData.swift index 824644cd0..c2f890d05 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyPostData.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyPostData.swift @@ -27,6 +27,14 @@ struct RecruitMyPostData: Identifiable, Equatable { let state: RecruitState let chatRoomId: Int? - let applicants: [RecruitApplicantRow] + var applicants: [RecruitApplicantRow] let totalCount: Int + let totalPage: Int + let currentPage: Int +} + +extension RecruitMyPostData { + var hasNextPage: Bool { + currentPage < totalPage + } } diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index 5e3a58a7c..abda1bea7 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -27,7 +27,7 @@ protocol RecruitRepository { ) async throws func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList - func fetchMyPostData(_ id: Int) async throws -> RecruitMyPostData + func fetchMyPostData(_ id: Int, page: Int) async throws -> RecruitMyPostData func fetchApplicant( recruitmentId: Int, applicationId: Int diff --git a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostDataUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostDataUseCase.swift index f50426f68..e722a060a 100644 --- a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostDataUseCase.swift +++ b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostDataUseCase.swift @@ -8,7 +8,7 @@ import Foundation protocol FetchRecruitMyPostDataUseCase { - func execute(id: Int) async throws -> RecruitMyPostData + func execute(id: Int, page: Int) async throws -> RecruitMyPostData } final class DefaultFetchRecruitMyPostDataUseCase: FetchRecruitMyPostDataUseCase { @@ -19,7 +19,7 @@ final class DefaultFetchRecruitMyPostDataUseCase: FetchRecruitMyPostDataUseCase self.repository = repository } - func execute(id: Int) async throws -> RecruitMyPostData { - try await repository.fetchMyPostData(id) + func execute(id: Int, page: Int) async throws -> RecruitMyPostData { + try await repository.fetchMyPostData(id, page: page) } } diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift index 4646c42fc..b4afb423e 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift @@ -33,7 +33,7 @@ struct RecruitMyPostView: ActionBindableView { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .loadingOverlay(viewModel.isLoading) + .loadingOverlay(viewModel.isLoading && viewModel.data == nil) .background(Color.appColor(.newBackground).ignoresSafeArea()) .onFirstAppear { viewModel.execute(.onFirstAppear) @@ -87,9 +87,18 @@ extension RecruitMyPostView { )) } ) + .onAppear { + loadNextPageIfNeeded(applicationId: applicant.applicationId) + } } } .padding(.top, 24) + + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 24) + .padding(.top, 12) + .isHidden(!(data.hasNextPage && viewModel.isLoading)) } } .padding(.horizontal, 24) @@ -115,3 +124,15 @@ extension RecruitMyPostView { } } } + +extension RecruitMyPostView { + private func loadNextPageIfNeeded(applicationId: Int) { + guard let data = viewModel.data, + data.applicants.last?.applicationId == applicationId, + data.hasNextPage, + !viewModel.isLoading else { + return + } + viewModel.execute(.loadNextPage) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift index ce7f78011..b96176400 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift @@ -14,6 +14,7 @@ final class RecruitMyPostViewModel: SwiftUIViewModelProtocol { enum Input { case onFirstAppear + case loadNextPage case didShowToast } @@ -40,6 +41,8 @@ final class RecruitMyPostViewModel: SwiftUIViewModelProtocol { switch input { case .onFirstAppear: fetchRecruitMyPost() + case .loadNextPage: + loadNextPage() case .didShowToast: errorMessage = nil } @@ -47,7 +50,14 @@ final class RecruitMyPostViewModel: SwiftUIViewModelProtocol { } extension RecruitMyPostViewModel { - private func fetchRecruitMyPost() { + private func loadNextPage() { + guard let data, data.hasNextPage else { + return + } + fetchRecruitMyPost(page: data.currentPage + 1) + } + + private func fetchRecruitMyPost(page: Int = 1) { guard !isLoading else { return } @@ -59,9 +69,18 @@ extension RecruitMyPostViewModel { } do { - let data = try await fetchRecruitMyPostDataUseCase.execute(id: recruitId) + var response = try await fetchRecruitMyPostDataUseCase.execute(id: recruitId, page: page) try Task.checkCancellation() - self.data = data + + guard response.currentPage == page else { + return + } + + if page > 1 { + response.applicants = (data?.applicants ?? []) + response.applicants + response.applicants.removeDuplicates() + } + self.data = response } catch is CancellationError { return } catch { From 5c422529382d1b4d2d21efdf0af8c5c510daa6e3 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 17:02:04 +0900 Subject: [PATCH 074/123] =?UTF-8?q?refactor:=20=EB=82=B4=EA=B0=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=ED=95=9C=20=EB=AA=A8=EC=A7=91=EA=B8=80=20Row?= =?UTF-8?q?/Data=20=EB=82=A0=EC=A7=9C=C2=B7D-day=20optional=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecruitMyPostRow / RecruitMyPostData 날짜 Date?, dDay String? 로 변경 - D-day 는 값이 있을 때만 표시, 활동 기간은 nil 이면 빈 문자열 --- Koin/Data/Repository/MockRecruitRepository.swift | 10 +++++----- Koin/Domain/Model/Recruit/RecruitMyPostData.swift | 8 ++++---- Koin/Domain/Model/Recruit/RecruitMyPostRow.swift | 8 ++++---- .../Subviews/RecruitMyPostSummaryView.swift | 14 ++++++++++---- .../Subviews/RecruitMyPostListRowView.swift | 14 ++++++++++---- 5 files changed, 33 insertions(+), 21 deletions(-) diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift index 81979659c..0b525c100 100644 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ b/Koin/Data/Repository/MockRecruitRepository.swift @@ -270,10 +270,10 @@ final class MockRecruitRepository: RecruitRepository { category: summary.category, title: summary.title, meetingType: summary.meetingType, - startDate: summary.startDate ?? Date(), - endDate: summary.endDate ?? Date(), - deadline: summary.deadline ?? Date(), - dDay: summary.dDay ?? "", + startDate: summary.startDate, + endDate: summary.endDate, + deadline: summary.deadline, + dDay: summary.dDay, currentParticipants: summary.currentParticipants, maximumParticipants: summary.maximumParticipants, type: summary.type, @@ -288,7 +288,7 @@ final class MockRecruitRepository: RecruitRepository { recruits = recruits.filter { $0.state == filter.state } } if filter.sort == .deadlineAscending { - recruits.sort { $0.deadline < $1.deadline } + recruits.sort { ($0.deadline ?? .distantFuture) < ($1.deadline ?? .distantFuture) } } let totalCount = recruits.count diff --git a/Koin/Domain/Model/Recruit/RecruitMyPostData.swift b/Koin/Domain/Model/Recruit/RecruitMyPostData.swift index c2f890d05..8735195b0 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyPostData.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyPostData.swift @@ -13,10 +13,10 @@ struct RecruitMyPostData: Identifiable, Equatable { let title: String let meetingType: RecruitMeetingType - let startDate: Date - let endDate: Date - let deadline: Date - let dDay: String + let startDate: Date? + let endDate: Date? + let deadline: Date? + let dDay: String? let currentParticipants: Int let maximumParticipants: Int diff --git a/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift b/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift index a354f60d0..3ede95b10 100644 --- a/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift +++ b/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift @@ -13,10 +13,10 @@ struct RecruitMyPostRow: Identifiable, Equatable { let title: String let meetingType: RecruitMeetingType - let startDate: Date - let endDate: Date - let deadline: Date - let dDay: String + let startDate: Date? + let endDate: Date? + let deadline: Date? + let dDay: String? let currentParticipants: Int let maximumParticipants: Int diff --git a/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift index 4adc2d95f..0079a7db0 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift @@ -42,9 +42,15 @@ extension RecruitMyPostSummaryView { .background(Color.appColor(model.category.backgroundColor)) .clipShape(.capsule) - Text(model.state == .closed ? "모집완료" : model.dDay) - .font(.appFont(.pretendardMedium, size: 10)) - .foregroundStyle(Color.appColor(model.state == .closed ? .new600 : .danger700)) + if model.state == .closed { + Text("모집완료") + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(.new600)) + } else if let dDay = model.dDay { + Text(dDay) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(.danger700)) + } } } @@ -81,7 +87,7 @@ extension RecruitMyPostSummaryView { ) metadataView( image: .recruitDate, - text: "\(model.startDate.formatDateToYYYYMMDD(separator: ".")) ~ \(model.endDate.formatDateToYYYYMMDD(separator: "."))" + text: "\(model.startDate?.formatDateToYYYYMMDD(separator: ".") ?? "") ~ \(model.endDate?.formatDateToYYYYMMDD(separator: ".") ?? "")" ) metadataView( image: .recruitMember, diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift index a21ec9081..a54592728 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift @@ -51,9 +51,15 @@ extension RecruitMyPostListRowView { .background(Color.appColor(model.category.backgroundColor)) .clipShape(.capsule) - Text(model.state == .closed ? "모집완료" : model.dDay) - .font(.appFont(.pretendardMedium, size: 10)) - .foregroundStyle(Color.appColor(model.state == .closed ? .new600 : .danger700)) + if model.state == .closed { + Text("모집완료") + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(.new600)) + } else if let dDay = model.dDay { + Text(dDay) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(.danger700)) + } Spacer() } @@ -111,7 +117,7 @@ extension RecruitMyPostListRowView { HStack(alignment: .center, spacing: 2) { Image.appImage(asset: .recruitDate) - Text("\(model.startDate.formatDateToYYYYMMDD(separator: ".")) ~ \(model.endDate.formatDateToYYYYMMDD(separator: "."))") + Text("\(model.startDate?.formatDateToYYYYMMDD(separator: ".") ?? "") ~ \(model.endDate?.formatDateToYYYYMMDD(separator: ".") ?? "")") .font(.appFont(.pretendardRegular, size: 10)) .foregroundStyle(Color.appColor(.neutral500)) } From dd201f64f653fc828cc4653d444e0c1273278edd Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 17:02:56 +0900 Subject: [PATCH 075/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EB=82=B4=EA=B0=80=20=EC=9E=91=EC=84=B1=ED=95=9C=20?= =?UTF-8?q?=EB=AA=A8=EC=A7=91=EA=B8=80=20=EB=AA=A9=EB=A1=9D/=EB=A7=88?= =?UTF-8?q?=EA=B0=90=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /team-recruitments/me/created, PUT /team-recruitments/{recruitmentId}/close 연결 - RecruitMyPostListRequestDto / RecruitMyPostListDto 신설 (카드 필드는 RecruitListSummaryDto 재사용) --- .../Recruit/RecruitMyPostListDto.swift | 76 +++++++++++++++++++ .../Recruit/RecruitMyPostListRequestDto.swift | 24 ++++++ .../Repository/DefaultRecruitRepository.swift | 18 +++-- .../Data/Service/Network/API/RecruitAPI.swift | 16 +++- Koin/Data/Service/RecruitService.swift | 10 +++ koin.xcodeproj/project.pbxproj | 8 ++ 6 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostListDto.swift create mode 100644 Koin/Data/DTOs/Encodable/Recruit/RecruitMyPostListRequestDto.swift diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostListDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostListDto.swift new file mode 100644 index 000000000..2f03aafdf --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostListDto.swift @@ -0,0 +1,76 @@ +// +// RecruitMyPostListDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct RecruitMyPostListDto: Decodable { + let recruitments: [RecruitMyPostRowDto] + let totalCount: Int + let currentCount: Int + let totalPage: Int + let currentPage: Int + + enum CodingKeys: String, CodingKey { + case recruitments + case totalCount = "total_count" + case currentCount = "current_count" + case totalPage = "total_page" + case currentPage = "current_page" + } +} + +struct RecruitMyPostRowDto: Decodable { + let recruitment: RecruitListSummaryDto + let canClose: Bool + let teamChatRoomId: Int? + + enum CodingKeys: String, CodingKey { + case canClose = "can_close" + case teamChatRoomId = "team_chat_room_id" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.recruitment = try RecruitListSummaryDto(from: decoder) + self.canClose = try container.decode(Bool.self, forKey: .canClose) + self.teamChatRoomId = try container.decodeIfPresent(Int.self, forKey: .teamChatRoomId) + } +} + +extension RecruitMyPostListDto { + func toDomain() -> RecruitMyPostList { + return RecruitMyPostList( + recruits: recruitments.map { $0.toDomain() }, + totalCount: totalCount, + totalPage: totalPage, + currentPage: currentPage + ) + } +} + +extension RecruitMyPostRowDto { + func toDomain() -> RecruitMyPostRow { + let recruit = recruitment.toDomain() + return RecruitMyPostRow( + id: recruit.id, + category: recruit.category, + title: recruit.title, + meetingType: recruit.meetingType, + startDate: recruit.startDate, + endDate: recruit.endDate, + deadline: recruit.deadline, + dDay: recruit.dDay, + currentParticipants: recruit.currentParticipants, + maximumParticipants: recruit.maximumParticipants, + type: recruit.type, + roles: recruit.roles, + state: recruit.state, + canClose: canClose, + chatRoomId: teamChatRoomId + ) + } +} diff --git a/Koin/Data/DTOs/Encodable/Recruit/RecruitMyPostListRequestDto.swift b/Koin/Data/DTOs/Encodable/Recruit/RecruitMyPostListRequestDto.swift new file mode 100644 index 000000000..f643f6425 --- /dev/null +++ b/Koin/Data/DTOs/Encodable/Recruit/RecruitMyPostListRequestDto.swift @@ -0,0 +1,24 @@ +// +// RecruitMyPostListRequestDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct RecruitMyPostListRequestDto: Encodable { + let status: RecruitListStatusDto + let sort: RecruitListSortDto + let page: Int + let limit: Int? +} + +extension RecruitMyPostListRequestDto { + init(from filter: RecruitMyPostFilter) { + self.status = RecruitListStatusDto(from: filter.state) + self.sort = RecruitListSortDto(from: filter.sort) + self.page = filter.page + self.limit = filter.limit + } +} diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift index e3f33bc63..62317656e 100644 --- a/Koin/Data/Repository/DefaultRecruitRepository.swift +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -61,6 +61,16 @@ final class DefaultRecruitRepository: RecruitRepository { return try await service.fetchMyApplicationList(request).toDomain() } + func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList { + let request = RecruitMyPostListRequestDto(from: filter) + return try await service.fetchMyPostList(request).toDomain() + } + + func closeMyPost(id: Int) async throws -> Bool { + try await service.closeMyPost(id) + return true + } + func upsertMyProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile { guard let request = RecruitProfileRequestDto(from: request) else { throw ErrorResponse.unexpectedInternalError @@ -100,10 +110,6 @@ extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 try await mockRepository.postChatMessage(recruitmentId: recruitmentId, chatRoomId: chatRoomId, request: request) } - func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList { - try await mockRepository.fetchMyPostList(filter) - } - func fetchMyPostData(_ id: Int, page: Int) async throws -> RecruitMyPostData { try await mockRepository.fetchMyPostData(id, page: page) } @@ -123,10 +129,6 @@ extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 try await mockRepository.decideApplicant(recruitmentId: recruitmentId, applicationId: applicationId, decision: decision) } - func closeMyPost(id: Int) async throws -> Bool { - try await mockRepository.closeMyPost(id: id) - } - func fetchNotificationList() async throws -> RecruitNotificationList { try await mockRepository.fetchNotificationList() } diff --git a/Koin/Data/Service/Network/API/RecruitAPI.swift b/Koin/Data/Service/Network/API/RecruitAPI.swift index 681a2e5b0..0558e2524 100644 --- a/Koin/Data/Service/Network/API/RecruitAPI.swift +++ b/Koin/Data/Service/Network/API/RecruitAPI.swift @@ -18,6 +18,8 @@ enum RecruitAPI { case upsertMyProfile(RecruitProfileRequestDto) case apply(Int, RecruitApplyRequestDto) case fetchMyApplicationList(RecruitMyApplicationListRequestDto) + case fetchMyPostList(RecruitMyPostListRequestDto) + case closeMyPost(Int) } extension RecruitAPI: Router, URLRequestConvertible { @@ -37,6 +39,8 @@ extension RecruitAPI: Router, URLRequestConvertible { case .upsertMyProfile: return "/team-recruitment-profiles/me" case .apply(let id, _): return "/team-recruitments/\(id)/applications" case .fetchMyApplicationList: return "/team-recruitments/me/applications" + case .fetchMyPostList: return "/team-recruitments/me/created" + case .closeMyPost(let id): return "/team-recruitments/\(id)/close" } } @@ -51,13 +55,15 @@ extension RecruitAPI: Router, URLRequestConvertible { case .upsertMyProfile: return .put case .apply: return .post case .fetchMyApplicationList: return .get + case .fetchMyPostList: return .get + case .closeMyPost: return .put } } public var headers: [String: String] { var baseHeaders: [String: String] = [:] switch self { - case .fetchList, .fetchData, .deleteData, .fetchMyProfile, .fetchMyApplicationList: + case .fetchList, .fetchData, .deleteData, .fetchMyProfile, .fetchMyApplicationList, .fetchMyPostList, .closeMyPost: break case .post, .modify, .upsertMyProfile, .apply: baseHeaders["Content-Type"] = "application/json" @@ -79,13 +85,19 @@ extension RecruitAPI: Router, URLRequestConvertible { return try? request.toDictionary() case .fetchMyApplicationList(let request): return try? request.toDictionary() + case .fetchMyPostList(let request): + return try? request.toDictionary() + case .closeMyPost: + return nil } } public var encoding: ParameterEncoding? { switch self { - case .fetchList, .fetchMyApplicationList: + case .fetchList, .fetchMyApplicationList, .fetchMyPostList: return URLEncoding(arrayEncoding: .noBrackets) + case .closeMyPost: + return nil case .fetchData, .deleteData, .fetchMyProfile: return nil case .post, .modify, .upsertMyProfile, .apply: diff --git a/Koin/Data/Service/RecruitService.swift b/Koin/Data/Service/RecruitService.swift index 26386f293..711502ad4 100644 --- a/Koin/Data/Service/RecruitService.swift +++ b/Koin/Data/Service/RecruitService.swift @@ -17,6 +17,8 @@ protocol RecruitService { func upsertMyProfile(_ request: RecruitProfileRequestDto) async throws -> RecruitProfileDto func apply(_ id: Int, _ request: RecruitApplyRequestDto) async throws func fetchMyApplicationList(_ request: RecruitMyApplicationListRequestDto) async throws -> RecruitMyApplicationListDto + func fetchMyPostList(_ request: RecruitMyPostListRequestDto) async throws -> RecruitMyPostListDto + func closeMyPost(_ id: Int) async throws } final class DefaultRecruitService: RecruitService { @@ -58,4 +60,12 @@ final class DefaultRecruitService: RecruitService { func fetchMyApplicationList(_ request: RecruitMyApplicationListRequestDto) async throws -> RecruitMyApplicationListDto { try await networkService.requestWithResponse(api: RecruitAPI.fetchMyApplicationList(request)) } + + func fetchMyPostList(_ request: RecruitMyPostListRequestDto) async throws -> RecruitMyPostListDto { + try await networkService.requestWithResponse(api: RecruitAPI.fetchMyPostList(request)) + } + + func closeMyPost(_ id: Int) async throws { + try await networkService.request(api: RecruitAPI.closeMyPost(id)) + } } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index d07e05f84..8c4107379 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 0E2253358E42231BE436FA27 /* RecruitPostScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE127F2AE4B18899F6E5E0D /* RecruitPostScheduleView.swift */; }; 0FCD163D2BA80EAA00C23A2F /* KeychainWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCD163C2BA80EAA00C23A2F /* KeychainWorker.swift */; }; 1537B6CFD3D7AF52A2E9E944 /* RecruitPostMeetingTypeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73F70BC5F3AA1A34663C32CA /* RecruitPostMeetingTypeView.swift */; }; + 1AD09EAFA1CF9EE0B81E886D /* RecruitMyPostListRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC82D6E2A559FEFE43E63F24 /* RecruitMyPostListRequestDto.swift */; }; 239837C1CD4A0D7A5D1D36C7 /* RecruitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC394F2327076B2F9AEE2A4 /* RecruitService.swift */; }; 247DF973D072E88901E5C249 /* RecruitProfilePostDepartmentDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */; }; 2E6336C1CDB4445BA858DA5A /* RecruitDataContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */; }; @@ -837,6 +838,7 @@ 83EBB87E2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83EBB87D2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift */; }; 8A7B1679CC9945559E674584 /* RecruitDataRolesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945FF00E918646E5AB601057 /* RecruitDataRolesView.swift */; }; 8CBCEFF4A1B4841D6B422B46 /* RecruitProfileDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A900DEA87E917C84BCD7E04 /* RecruitProfileDto.swift */; }; + 8F73C145755D31960169BE61 /* RecruitMyPostListDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = A064C42647B4EBB3454B2CA3 /* RecruitMyPostListDto.swift */; }; 9059A862F4E9CDA304B2F082 /* RecruitEnumDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E31EA58151824C938383893 /* RecruitEnumDto.swift */; }; 91E82A6B67BF5BF911535B1C /* RecruitMyPostData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87039C64B7CB2AEB632113C6 /* RecruitMyPostData.swift */; }; 9419498912D565DE98568476 /* RecruitPostResultDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267C5F2D082AC60FDA16F85C /* RecruitPostResultDto.swift */; }; @@ -2056,6 +2058,7 @@ 99C8CD337DB4DF4456561F92 /* RecruitProfileRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitProfileRequestDto.swift; sourceTree = ""; }; 9D3B08333D4FA77CC935917B /* StudentAcademicInfoRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StudentAcademicInfoRequestDto.swift; sourceTree = ""; }; A001E2B42845091D00D6C310 /* koin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = koin.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A064C42647B4EBB3454B2CA3 /* RecruitMyPostListDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListDto.swift; sourceTree = ""; }; A11B00000000000000000002 /* PostBasicInfoUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostBasicInfoUseCase.swift; sourceTree = ""; }; A11B00000000000000000004 /* UpsertMyRecruitProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpsertMyRecruitProfileUseCase.swift; sourceTree = ""; }; A11E00000000000000000001 /* RecruitMyPostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostView.swift; sourceTree = ""; }; @@ -2401,6 +2404,7 @@ D8B4E0032DBFAD1A001FBC89 /* CheckVerificationCodeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckVerificationCodeRequest.swift; sourceTree = ""; }; D8F5C54B2E6F012500FB6708 /* LottieAnimationManageable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieAnimationManageable.swift; sourceTree = ""; }; D8F5C5532E6F144300FB6708 /* floatingLogo.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = floatingLogo.json; sourceTree = ""; }; + DC82D6E2A559FEFE43E63F24 /* RecruitMyPostListRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListRequestDto.swift; sourceTree = ""; }; E1A70C002D9A4F1100AGSEL0 /* AgreementSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementSelection.swift; sourceTree = ""; }; EA92583628DDFBC3545C197B /* RecruitDataDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitDataDto.swift; sourceTree = ""; }; EC692285302700A400EE26ED /* koinUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = koinUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -2485,6 +2489,7 @@ 267C5F2D082AC60FDA16F85C /* RecruitPostResultDto.swift */, 1A900DEA87E917C84BCD7E04 /* RecruitProfileDto.swift */, 756A97197501167BE3516430 /* RecruitMyApplicationListDto.swift */, + A064C42647B4EBB3454B2CA3 /* RecruitMyPostListDto.swift */, ); path = Recruit; sourceTree = ""; @@ -5583,6 +5588,7 @@ 99C8CD337DB4DF4456561F92 /* RecruitProfileRequestDto.swift */, C8FC035A1EC83BD077A6AFCA /* RecruitApplyRequestDto.swift */, 3143194BD51D3A85C2A959EB /* RecruitMyApplicationListRequestDto.swift */, + DC82D6E2A559FEFE43E63F24 /* RecruitMyPostListRequestDto.swift */, ); path = Recruit; sourceTree = ""; @@ -7897,6 +7903,8 @@ BD72DD59230BE11B09BF075F /* RecruitApplyRequestDto.swift in Sources */, DE8E26BC32390F246B7F4CF1 /* RecruitMyApplicationListRequestDto.swift in Sources */, 4AAF0644AD43FD83CE4E5E32 /* RecruitMyApplicationListDto.swift in Sources */, + 1AD09EAFA1CF9EE0B81E886D /* RecruitMyPostListRequestDto.swift in Sources */, + 8F73C145755D31960169BE61 /* RecruitMyPostListDto.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 920c1536e727abc06bab12480308a4411d53be5a Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 17:03:32 +0900 Subject: [PATCH 076/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=A7=80=EC=9B=90=EC=9E=90=20=EB=AA=A9=EB=A1=9D/?= =?UTF-8?q?=EC=83=81=EC=84=B8/=EC=8A=B9=EC=9D=B8=C2=B7=EA=B1=B0=EC=A0=88?= =?UTF-8?q?=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /team-recruitments/{recruitmentId}/applications 연결 (RecruitMyPostDataDto) - GET /team-recruitments/{recruitmentId}/applications/{applicationId} 연결 (RecruitApplicantDataDto, 프로필 스냅샷) - PUT /team-recruitments/{recruitmentId}/applications/{applicationId}/status 연결 - 지원자 입학 연도를 두 자리 학번으로 표시 --- .../Recruit/RecruitApplicantDataDto.swift | 75 +++++++++++++ .../Recruit/RecruitMyPostDataDto.swift | 103 ++++++++++++++++++ .../RecruitApplicantDecisionRequestDto.swift | 23 ++++ .../RecruitApplicantListRequestDto.swift | 14 +++ .../Repository/DefaultRecruitRepository.swift | 40 +++---- .../Data/Service/Network/API/RecruitAPI.swift | 25 ++++- Koin/Data/Service/RecruitService.swift | 15 +++ .../RecruitMyPostApplicantRowView.swift | 4 +- koin.xcodeproj/project.pbxproj | 16 +++ 9 files changed, 288 insertions(+), 27 deletions(-) create mode 100644 Koin/Data/DTOs/Decodable/Recruit/RecruitApplicantDataDto.swift create mode 100644 Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostDataDto.swift create mode 100644 Koin/Data/DTOs/Encodable/Recruit/RecruitApplicantDecisionRequestDto.swift create mode 100644 Koin/Data/DTOs/Encodable/Recruit/RecruitApplicantListRequestDto.swift diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitApplicantDataDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitApplicantDataDto.swift new file mode 100644 index 000000000..a143c3829 --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitApplicantDataDto.swift @@ -0,0 +1,75 @@ +// +// RecruitApplicantDataDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct RecruitApplicantDataDto: Decodable { + let applicationId: Int + let status: RecruitApplicationStatusDto + let profileSnapshot: RecruitProfileSnapshotDto + let motivation: String + let availability: String + let role: RecruitApplicationRoleDto? + let canDecide: Bool + let canOpenDirectChat: Bool + + enum CodingKeys: String, CodingKey { + case applicationId = "application_id" + case status + case profileSnapshot = "profile_snapshot" + case motivation, availability, role + case canDecide = "can_decide" + case canOpenDirectChat = "can_open_direct_chat" + } +} + +struct RecruitProfileSnapshotDto: Decodable { + let nickname: String + let department: String + let studentYear: Int + let preferredRole: String + let skills: [String] + let activities: [RecruitProfileActivityDto] + let selfIntroduction: String + + enum CodingKeys: String, CodingKey { + case nickname, department + case studentYear = "student_year" + case preferredRole = "preferred_role" + case skills, activities + case selfIntroduction = "self_introduction" + } +} + +extension RecruitApplicantDataDto { + func toDomain() -> RecruitApplicantData { + return RecruitApplicantData( + applicationId: applicationId, + status: status.toDomain(), + profile: profileSnapshot.toDomain(), + motivation: motivation, + availableTime: availability, + role: role?.name, + canDecide: canDecide, + canDirectChat: canOpenDirectChat + ) + } +} + +extension RecruitProfileSnapshotDto { + func toDomain() -> RecruitProfile { + return RecruitProfile( + nickname: nickname, + department: department, + studentNumber: "\(studentYear)", + preferredRole: preferredRole, + skills: skills, + activities: activities.compactMap { $0.toDomain() }, + selfIntroduction: selfIntroduction + ) + } +} diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostDataDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostDataDto.swift new file mode 100644 index 000000000..2b15a6965 --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostDataDto.swift @@ -0,0 +1,103 @@ +// +// RecruitMyPostDataDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct RecruitMyPostDataDto: Decodable { + let recruitment: RecruitApplicantRecruitmentDto + let applications: [RecruitApplicantRowDto] + let totalCount: Int + let currentCount: Int + let totalPage: Int + let currentPage: Int + + enum CodingKeys: String, CodingKey { + case recruitment, applications + case totalCount = "total_count" + case currentCount = "current_count" + case totalPage = "total_page" + case currentPage = "current_page" + } +} + +struct RecruitApplicantRecruitmentDto: Decodable { + let recruitment: RecruitListSummaryDto + let teamChatRoomId: Int? + + enum CodingKeys: String, CodingKey { + case teamChatRoomId = "team_chat_room_id" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.recruitment = try RecruitListSummaryDto(from: decoder) + self.teamChatRoomId = try container.decodeIfPresent(Int.self, forKey: .teamChatRoomId) + } +} + +struct RecruitApplicantRowDto: Decodable { + let applicationId: Int + let nickname: String + let department: String + let studentYear: Int + let role: RecruitApplicationRoleDto? + let status: RecruitApplicationStatusDto + let canOpenDirectChat: Bool + + enum CodingKeys: String, CodingKey { + case applicationId = "application_id" + case nickname, department + case studentYear = "student_year" + case role, status + case canOpenDirectChat = "can_open_direct_chat" + } +} + +struct RecruitApplicationRoleDto: Decodable { + let id: Int + let name: String +} + +extension RecruitMyPostDataDto { + func toDomain() -> RecruitMyPostData { + let recruit = recruitment.recruitment.toDomain() + return RecruitMyPostData( + id: recruit.id, + category: recruit.category, + title: recruit.title, + meetingType: recruit.meetingType, + startDate: recruit.startDate, + endDate: recruit.endDate, + deadline: recruit.deadline, + dDay: recruit.dDay, + currentParticipants: recruit.currentParticipants, + maximumParticipants: recruit.maximumParticipants, + type: recruit.type, + roles: recruit.roles, + state: recruit.state, + chatRoomId: recruitment.teamChatRoomId, + applicants: applications.map { $0.toDomain() }, + totalCount: totalCount, + totalPage: totalPage, + currentPage: currentPage + ) + } +} + +extension RecruitApplicantRowDto { + func toDomain() -> RecruitApplicantRow { + return RecruitApplicantRow( + applicationId: applicationId, + nickname: nickname, + department: department, + studentYear: studentYear, + role: role?.name ?? "", + status: status.toDomain(), + canChat: canOpenDirectChat + ) + } +} diff --git a/Koin/Data/DTOs/Encodable/Recruit/RecruitApplicantDecisionRequestDto.swift b/Koin/Data/DTOs/Encodable/Recruit/RecruitApplicantDecisionRequestDto.swift new file mode 100644 index 000000000..b45cb91c7 --- /dev/null +++ b/Koin/Data/DTOs/Encodable/Recruit/RecruitApplicantDecisionRequestDto.swift @@ -0,0 +1,23 @@ +// +// RecruitApplicantDecisionRequestDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct RecruitApplicantDecisionRequestDto: Encodable { + let status: RecruitApplicationStatusDto +} + +extension RecruitApplicantDecisionRequestDto { + init(from decision: RecruitApplicantDecision) { + switch decision { + case .accepted: + self.status = .accepted + case .denied: + self.status = .rejected + } + } +} diff --git a/Koin/Data/DTOs/Encodable/Recruit/RecruitApplicantListRequestDto.swift b/Koin/Data/DTOs/Encodable/Recruit/RecruitApplicantListRequestDto.swift new file mode 100644 index 000000000..070654002 --- /dev/null +++ b/Koin/Data/DTOs/Encodable/Recruit/RecruitApplicantListRequestDto.swift @@ -0,0 +1,14 @@ +// +// RecruitApplicantListRequestDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct RecruitApplicantListRequestDto: Encodable { + let statuses: [RecruitApplicationStatusDto]? + let page: Int + let limit: Int +} diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift index 62317656e..e27e7caac 100644 --- a/Koin/Data/Repository/DefaultRecruitRepository.swift +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -66,6 +66,27 @@ final class DefaultRecruitRepository: RecruitRepository { return try await service.fetchMyPostList(request).toDomain() } + func fetchMyPostData(_ id: Int, page: Int) async throws -> RecruitMyPostData { + let request = RecruitApplicantListRequestDto(statuses: nil, page: page, limit: 10) + return try await service.fetchMyPostData(id, request).toDomain() + } + + func fetchApplicant( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitApplicantData { + try await service.fetchApplicant(recruitmentId: recruitmentId, applicationId: applicationId).toDomain() + } + + func decideApplicant( + recruitmentId: Int, + applicationId: Int, + decision: RecruitApplicantDecision + ) async throws -> Void { + let request = RecruitApplicantDecisionRequestDto(from: decision) + try await service.decideApplicant(recruitmentId: recruitmentId, applicationId: applicationId, request) + } + func closeMyPost(id: Int) async throws -> Bool { try await service.closeMyPost(id) return true @@ -110,25 +131,6 @@ extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 try await mockRepository.postChatMessage(recruitmentId: recruitmentId, chatRoomId: chatRoomId, request: request) } - func fetchMyPostData(_ id: Int, page: Int) async throws -> RecruitMyPostData { - try await mockRepository.fetchMyPostData(id, page: page) - } - - func fetchApplicant( - recruitmentId: Int, - applicationId: Int - ) async throws -> RecruitApplicantData { - try await mockRepository.fetchApplicant(recruitmentId: recruitmentId, applicationId: applicationId) - } - - func decideApplicant( - recruitmentId: Int, - applicationId: Int, - decision: RecruitApplicantDecision - ) async throws -> Void { - try await mockRepository.decideApplicant(recruitmentId: recruitmentId, applicationId: applicationId, decision: decision) - } - func fetchNotificationList() async throws -> RecruitNotificationList { try await mockRepository.fetchNotificationList() } diff --git a/Koin/Data/Service/Network/API/RecruitAPI.swift b/Koin/Data/Service/Network/API/RecruitAPI.swift index 0558e2524..17a1b5adb 100644 --- a/Koin/Data/Service/Network/API/RecruitAPI.swift +++ b/Koin/Data/Service/Network/API/RecruitAPI.swift @@ -20,6 +20,9 @@ enum RecruitAPI { case fetchMyApplicationList(RecruitMyApplicationListRequestDto) case fetchMyPostList(RecruitMyPostListRequestDto) case closeMyPost(Int) + case fetchMyPostData(Int, RecruitApplicantListRequestDto) + case fetchApplicant(recruitmentId: Int, applicationId: Int) + case decideApplicant(recruitmentId: Int, applicationId: Int, RecruitApplicantDecisionRequestDto) } extension RecruitAPI: Router, URLRequestConvertible { @@ -41,6 +44,9 @@ extension RecruitAPI: Router, URLRequestConvertible { case .fetchMyApplicationList: return "/team-recruitments/me/applications" case .fetchMyPostList: return "/team-recruitments/me/created" case .closeMyPost(let id): return "/team-recruitments/\(id)/close" + case .fetchMyPostData(let id, _): return "/team-recruitments/\(id)/applications" + case .fetchApplicant(let recruitmentId, let applicationId): return "/team-recruitments/\(recruitmentId)/applications/\(applicationId)" + case .decideApplicant(let recruitmentId, let applicationId, _): return "/team-recruitments/\(recruitmentId)/applications/\(applicationId)/status" } } @@ -57,15 +63,18 @@ extension RecruitAPI: Router, URLRequestConvertible { case .fetchMyApplicationList: return .get case .fetchMyPostList: return .get case .closeMyPost: return .put + case .fetchMyPostData: return .get + case .fetchApplicant: return .get + case .decideApplicant: return .put } } public var headers: [String: String] { var baseHeaders: [String: String] = [:] switch self { - case .fetchList, .fetchData, .deleteData, .fetchMyProfile, .fetchMyApplicationList, .fetchMyPostList, .closeMyPost: + case .fetchList, .fetchData, .deleteData, .fetchMyProfile, .fetchMyApplicationList, .fetchMyPostList, .closeMyPost, .fetchMyPostData, .fetchApplicant: break - case .post, .modify, .upsertMyProfile, .apply: + case .post, .modify, .upsertMyProfile, .apply, .decideApplicant: baseHeaders["Content-Type"] = "application/json" } return baseHeaders @@ -87,20 +96,24 @@ extension RecruitAPI: Router, URLRequestConvertible { return try? request.toDictionary() case .fetchMyPostList(let request): return try? request.toDictionary() - case .closeMyPost: + case .fetchMyPostData(_, let request): + return try? request.toDictionary() + case .decideApplicant(_, _, let request): + return try? request.toDictionary() + case .closeMyPost, .fetchApplicant: return nil } } public var encoding: ParameterEncoding? { switch self { - case .fetchList, .fetchMyApplicationList, .fetchMyPostList: + case .fetchList, .fetchMyApplicationList, .fetchMyPostList, .fetchMyPostData: return URLEncoding(arrayEncoding: .noBrackets) - case .closeMyPost: + case .closeMyPost, .fetchApplicant: return nil case .fetchData, .deleteData, .fetchMyProfile: return nil - case .post, .modify, .upsertMyProfile, .apply: + case .post, .modify, .upsertMyProfile, .apply, .decideApplicant: return JSONEncoding.default } } diff --git a/Koin/Data/Service/RecruitService.swift b/Koin/Data/Service/RecruitService.swift index 711502ad4..40c6c8940 100644 --- a/Koin/Data/Service/RecruitService.swift +++ b/Koin/Data/Service/RecruitService.swift @@ -19,6 +19,9 @@ protocol RecruitService { func fetchMyApplicationList(_ request: RecruitMyApplicationListRequestDto) async throws -> RecruitMyApplicationListDto func fetchMyPostList(_ request: RecruitMyPostListRequestDto) async throws -> RecruitMyPostListDto func closeMyPost(_ id: Int) async throws + func fetchMyPostData(_ id: Int, _ request: RecruitApplicantListRequestDto) async throws -> RecruitMyPostDataDto + func fetchApplicant(recruitmentId: Int, applicationId: Int) async throws -> RecruitApplicantDataDto + func decideApplicant(recruitmentId: Int, applicationId: Int, _ request: RecruitApplicantDecisionRequestDto) async throws } final class DefaultRecruitService: RecruitService { @@ -68,4 +71,16 @@ final class DefaultRecruitService: RecruitService { func closeMyPost(_ id: Int) async throws { try await networkService.request(api: RecruitAPI.closeMyPost(id)) } + + func fetchMyPostData(_ id: Int, _ request: RecruitApplicantListRequestDto) async throws -> RecruitMyPostDataDto { + try await networkService.requestWithResponse(api: RecruitAPI.fetchMyPostData(id, request)) + } + + func fetchApplicant(recruitmentId: Int, applicationId: Int) async throws -> RecruitApplicantDataDto { + try await networkService.requestWithResponse(api: RecruitAPI.fetchApplicant(recruitmentId: recruitmentId, applicationId: applicationId)) + } + + func decideApplicant(recruitmentId: Int, applicationId: Int, _ request: RecruitApplicantDecisionRequestDto) async throws { + try await networkService.request(api: RecruitAPI.decideApplicant(recruitmentId: recruitmentId, applicationId: applicationId, request)) + } } diff --git a/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostApplicantRowView.swift b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostApplicantRowView.swift index 307ebeed0..3ab1dab33 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostApplicantRowView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostApplicantRowView.swift @@ -88,11 +88,11 @@ extension RecruitMyPostApplicantRowView { HStack(spacing: 0) { Text(model.role) .foregroundStyle(Color.appColor(.new500)) - Text(" | \(model.department) · \(model.studentYear)학번") + Text(" | \(model.department) · \(String(format: "%02d", model.studentYear % 100))학번") .foregroundStyle(Color.appColor(.neutral500)) } } else { - Text("\(model.department) · \(model.studentYear)학번") + Text("\(model.department) · \(String(format: "%02d", model.studentYear % 100))학번") .foregroundStyle(Color.appColor(.neutral500)) } } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 8c4107379..554a8fbf2 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -17,10 +17,13 @@ 2E6336C1CDB4445BA858DA5A /* RecruitDataContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */; }; 3DDE20379881F001713618FC /* RecruitDataDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA92583628DDFBC3545C197B /* RecruitDataDto.swift */; }; 3E56520894546C11EB8F99B3 /* RecruitPostRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE562D9FEDE6181748BE67B /* RecruitPostRequestDto.swift */; }; + 408BFAF3EBEEF0B6899C434F /* RecruitApplicantDecisionRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D7BF08E4E28887C9FB48DA1 /* RecruitApplicantDecisionRequestDto.swift */; }; 4AAF0644AD43FD83CE4E5E32 /* RecruitMyApplicationListDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 756A97197501167BE3516430 /* RecruitMyApplicationListDto.swift */; }; 5006987DEF4B5F454D7EA2D5 /* RecruitPostTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */; }; 515B96092C6730C797C1F837 /* PostRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D447F2D75951DD4C3E08D4E /* PostRecruitUseCase.swift */; }; 57B70985D5DEFF4D774733F6 /* StudentAcademicInfoRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D3B08333D4FA77CC935917B /* StudentAcademicInfoRequestDto.swift */; }; + 5AE5AB8D26086C14A37D8929 /* RecruitApplicantListRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 827BD7E96347F476DB1BD966 /* RecruitApplicantListRequestDto.swift */; }; + 5F6F2CE2D04FA793C2A5FD33 /* RecruitMyPostDataDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A5115E53734B28CC6EBED6 /* RecruitMyPostDataDto.swift */; }; 678D20761DEF461CA6D4728A /* RecruitDataTextSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2FBEEB767094CE8B219095B /* RecruitDataTextSectionView.swift */; }; 682DA9853EC4F396EFEEF876 /* RecruitListDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21AA98431B25CB5914BC2598 /* RecruitListDto.swift */; }; 7231A38A9BEC1DFBB8C2D978 /* RecruitProfileRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C8CD337DB4DF4456561F92 /* RecruitProfileRequestDto.swift */; }; @@ -1191,6 +1194,7 @@ D8F5C5562E6F144300FB6708 /* floatingLogo.json in Resources */ = {isa = PBXBuildFile; fileRef = D8F5C5532E6F144300FB6708 /* floatingLogo.json */; }; DE8E26BC32390F246B7F4CF1 /* RecruitMyApplicationListRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3143194BD51D3A85C2A959EB /* RecruitMyApplicationListRequestDto.swift */; }; E1A70C012D9A4F1100AGSEL1 /* AgreementSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A70C002D9A4F1100AGSEL0 /* AgreementSelection.swift */; }; + E3DB42C328CC0291FC430D89 /* RecruitApplicantDataDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC1A22BED5A1C53E5ED9903 /* RecruitApplicantDataDto.swift */; }; E5DD3F6BE6C501B7B3BD659C /* RecruitPostRoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F670021A95E0AA103E8F7BED /* RecruitPostRoleView.swift */; }; F0F216EDAA69B52CBEC0E606 /* RecruitPostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 288BC1E04044DC3FB48524FC /* RecruitPostViewModel.swift */; }; F1A000010000000000000001 /* ZoomingCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A000010000000000000002 /* ZoomingCollectionView.swift */; }; @@ -1238,6 +1242,7 @@ 0FCD163C2BA80EAA00C23A2F /* KeychainWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainWorker.swift; sourceTree = ""; }; 1A900DEA87E917C84BCD7E04 /* RecruitProfileDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitProfileDto.swift; sourceTree = ""; }; 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostTextFieldView.swift; sourceTree = ""; }; + 1FC1A22BED5A1C53E5ED9903 /* RecruitApplicantDataDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitApplicantDataDto.swift; sourceTree = ""; }; 21AA98431B25CB5914BC2598 /* RecruitListDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitListDto.swift; sourceTree = ""; }; 267C5F2D082AC60FDA16F85C /* RecruitPostResultDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitPostResultDto.swift; sourceTree = ""; }; 288BC1E04044DC3FB48524FC /* RecruitPostViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostViewModel.swift; sourceTree = ""; }; @@ -1247,6 +1252,7 @@ 3143194BD51D3A85C2A959EB /* RecruitMyApplicationListRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListRequestDto.swift; sourceTree = ""; }; 3C3694E3ACBE05B8C7F15357 /* RecruitPostSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostSectionHeaderView.swift; sourceTree = ""; }; 4067CAF18D134F99A04BBA0D /* RecruitListRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitListRequestDto.swift; sourceTree = ""; }; + 4D7BF08E4E28887C9FB48DA1 /* RecruitApplicantDecisionRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitApplicantDecisionRequestDto.swift; sourceTree = ""; }; 4EE5F05DAB30AABDF79BC780 /* RecruitPostCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostCategoryView.swift; sourceTree = ""; }; 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyRecruitUseCase.swift; sourceTree = ""; }; 54351A8A0C1A4046C3155481 /* RecruitPostTextViewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostTextViewView.swift; sourceTree = ""; }; @@ -1973,6 +1979,7 @@ 7CF77B863005F642003642C1 /* FetchMainFrameUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMainFrameUseCase.swift; sourceTree = ""; }; 7D447F2D75951DD4C3E08D4E /* PostRecruitUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRecruitUseCase.swift; sourceTree = ""; }; 7DADCC36CEEA42EC86AB75B6 /* RecruitDataMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataMetadataView.swift; sourceTree = ""; }; + 827BD7E96347F476DB1BD966 /* RecruitApplicantListRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitApplicantListRequestDto.swift; sourceTree = ""; }; 82F7EBAB20247086024B51CB /* RecruitPostViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostViewController.swift; sourceTree = ""; }; 831ABDC32CA29A120099B70C /* NoticeKeywordsFetchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeKeywordsFetchResult.swift; sourceTree = ""; }; 831ABDC52CA2A7C20099B70C /* FetchRecentSearchedWordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecentSearchedWordUseCase.swift; sourceTree = ""; }; @@ -2405,6 +2412,7 @@ D8F5C54B2E6F012500FB6708 /* LottieAnimationManageable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieAnimationManageable.swift; sourceTree = ""; }; D8F5C5532E6F144300FB6708 /* floatingLogo.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = floatingLogo.json; sourceTree = ""; }; DC82D6E2A559FEFE43E63F24 /* RecruitMyPostListRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListRequestDto.swift; sourceTree = ""; }; + E1A5115E53734B28CC6EBED6 /* RecruitMyPostDataDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyPostDataDto.swift; sourceTree = ""; }; E1A70C002D9A4F1100AGSEL0 /* AgreementSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementSelection.swift; sourceTree = ""; }; EA92583628DDFBC3545C197B /* RecruitDataDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitDataDto.swift; sourceTree = ""; }; EC692285302700A400EE26ED /* koinUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = koinUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -2490,6 +2498,8 @@ 1A900DEA87E917C84BCD7E04 /* RecruitProfileDto.swift */, 756A97197501167BE3516430 /* RecruitMyApplicationListDto.swift */, A064C42647B4EBB3454B2CA3 /* RecruitMyPostListDto.swift */, + E1A5115E53734B28CC6EBED6 /* RecruitMyPostDataDto.swift */, + 1FC1A22BED5A1C53E5ED9903 /* RecruitApplicantDataDto.swift */, ); path = Recruit; sourceTree = ""; @@ -5589,6 +5599,8 @@ C8FC035A1EC83BD077A6AFCA /* RecruitApplyRequestDto.swift */, 3143194BD51D3A85C2A959EB /* RecruitMyApplicationListRequestDto.swift */, DC82D6E2A559FEFE43E63F24 /* RecruitMyPostListRequestDto.swift */, + 827BD7E96347F476DB1BD966 /* RecruitApplicantListRequestDto.swift */, + 4D7BF08E4E28887C9FB48DA1 /* RecruitApplicantDecisionRequestDto.swift */, ); path = Recruit; sourceTree = ""; @@ -7904,7 +7916,11 @@ DE8E26BC32390F246B7F4CF1 /* RecruitMyApplicationListRequestDto.swift in Sources */, 4AAF0644AD43FD83CE4E5E32 /* RecruitMyApplicationListDto.swift in Sources */, 1AD09EAFA1CF9EE0B81E886D /* RecruitMyPostListRequestDto.swift in Sources */, + 5AE5AB8D26086C14A37D8929 /* RecruitApplicantListRequestDto.swift in Sources */, + 408BFAF3EBEEF0B6899C434F /* RecruitApplicantDecisionRequestDto.swift in Sources */, 8F73C145755D31960169BE61 /* RecruitMyPostListDto.swift in Sources */, + 5F6F2CE2D04FA793C2A5FD33 /* RecruitMyPostDataDto.swift in Sources */, + E3DB42C328CC0291FC430D89 /* RecruitApplicantDataDto.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 4be18bb49b41ce95c90365e50c19befdf819a3c0 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 23:47:55 +0900 Subject: [PATCH 077/123] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitList/Subviews/RecruitListRowView.swift | 10 +++++++--- .../Subviews/RecruitMyApplicationListRowView.swift | 10 +++++++--- .../Subviews/RecruitMyPostSummaryView.swift | 4 +++- .../Subviews/RecruitMyPostListRowView.swift | 10 +++++++--- .../RecruitProfilePostViewModel.swift | 4 ++-- 5 files changed, 26 insertions(+), 12 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift index e84030f0a..6c0677aea 100644 --- a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift @@ -97,9 +97,13 @@ extension RecruitListRowView { HStack(alignment: .center, spacing: 2) { Image.appImage(asset: .recruitDate) - Text("\(model.startDate?.formatDateToYYYYMMDD(separator: ".") ?? "") ~ \(model.endDate?.formatDateToYYYYMMDD(separator: ".") ?? "")") - .font(.appFont(.pretendardRegular, size: 10)) - .foregroundStyle(Color.appColor(.neutral500)) + Text( + [model.startDate, model.endDate] + .compactMap { $0?.formatDateToYYYYMMDD(separator: ".") } + .joined(separator: " ~ ") + ) + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) } HStack(alignment: .center, spacing: 2) { diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift index f0f32486d..5cc27aeb9 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift @@ -102,9 +102,13 @@ extension RecruitMyApplicationListRowView { HStack(alignment: .center, spacing: 2) { Image.appImage(asset: .recruitDate) - Text("\(model.startDate?.formatDateToYYYYMMDD(separator: ".") ?? "") ~ \(model.endDate?.formatDateToYYYYMMDD(separator: ".") ?? "")") - .font(.appFont(.pretendardRegular, size: 10)) - .foregroundStyle(Color.appColor(.neutral500)) + Text( + [model.startDate, model.endDate] + .compactMap { $0?.formatDateToYYYYMMDD(separator: ".") } + .joined(separator: " ~ ") + ) + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) } HStack(alignment: .center, spacing: 2) { diff --git a/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift index 0079a7db0..623d6b772 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift @@ -87,7 +87,9 @@ extension RecruitMyPostSummaryView { ) metadataView( image: .recruitDate, - text: "\(model.startDate?.formatDateToYYYYMMDD(separator: ".") ?? "") ~ \(model.endDate?.formatDateToYYYYMMDD(separator: ".") ?? "")" + text: [model.startDate, model.endDate] + .compactMap { $0?.formatDateToYYYYMMDD(separator: ".") } + .joined(separator: " ~ ") ) metadataView( image: .recruitMember, diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift index a54592728..340cc21d6 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift @@ -117,9 +117,13 @@ extension RecruitMyPostListRowView { HStack(alignment: .center, spacing: 2) { Image.appImage(asset: .recruitDate) - Text("\(model.startDate?.formatDateToYYYYMMDD(separator: ".") ?? "") ~ \(model.endDate?.formatDateToYYYYMMDD(separator: ".") ?? "")") - .font(.appFont(.pretendardRegular, size: 10)) - .foregroundStyle(Color.appColor(.neutral500)) + Text( + [model.startDate, model.endDate] + .compactMap { $0?.formatDateToYYYYMMDD(separator: ".") } + .joined(separator: " ~ ") + ) + .font(.appFont(.pretendardRegular, size: 10)) + .foregroundStyle(Color.appColor(.neutral500)) } HStack(alignment: .center, spacing: 2) { diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift index f8142c86e..f79419d97 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift @@ -118,9 +118,9 @@ extension RecruitProfilePostViewModel { request: RecruitProfileRequest ) { guard !isSubmitting else { return } + isSubmitting = true + outputSubject.send(.updateLoading(true)) Task { - isSubmitting = true - outputSubject.send(.updateLoading(true)) defer { isSubmitting = false outputSubject.send(.updateLoading(false)) From 970572052cc131d5f116db93f199b4de5a010e31 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 23:48:07 +0900 Subject: [PATCH 078/123] =?UTF-8?q?fix:=20UI=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift index d53b3cc53..5714d9816 100644 --- a/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift @@ -27,7 +27,7 @@ struct RecruitDataHeaderView: View { .foregroundStyle(Color.appColor(.danger700)) } } - .frame(minHeight: 18) + .frame(maxWidth: .infinity, minHeight: 18, alignment: .leading) Text(data.title) .font(.appFont(.pretendardSemiBold, size: 18)) From a1ad7e7c1ae5ff0217aaf08307739b150d76d2f6 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 4 Oct 2026 23:53:31 +0900 Subject: [PATCH 079/123] =?UTF-8?q?fix:=20UI=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Presentation/Recruit/RecruitList/RecruitListView.swift | 2 +- .../RecruitMyApplicationList/RecruitMyApplicationListView.swift | 2 +- .../Recruit/RecruitMyPostList/RecruitMyPostListView.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift index eecf3b6d5..38081b3ee 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -89,7 +89,7 @@ struct RecruitListView: ActionBindableView { .padding(.horizontal, 22) .background { RecruitListEmptyView() - .isHidden(!viewModel.isEmpty) + .isHidden(!viewModel.isEmpty || viewModel.isLoading) } .refreshable { viewModel.execute(.refresh) diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift index 6f4b9c978..92e99130f 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift @@ -86,7 +86,7 @@ struct RecruitMyApplicationListView: ActionBindableView { RecruitMyApplicationListEmptyView { sendAction(.showAllRecruitList) } - .isHidden(!(viewModel.recruitList?.isEmpty ?? true)) + .isHidden(!(viewModel.recruitList?.isEmpty ?? true) || viewModel.isLoading) } .refreshable { viewModel.execute(.refresh) diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift index 0b9a2056d..f26c470e9 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift @@ -91,7 +91,7 @@ struct RecruitMyPostListView: ActionBindableView { .padding(.horizontal, 22) .background { RecruitMyPostListEmptyView() - .isHidden(!(viewModel.recruitList?.isEmpty ?? true)) + .isHidden(!(viewModel.recruitList?.isEmpty ?? true) || viewModel.isLoading) } .refreshable { viewModel.execute(.refresh) From 78d97a19db806e586756e252ca40a663465fdd1a Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 5 Oct 2026 00:14:47 +0900 Subject: [PATCH 080/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=B1=84=ED=8C=85=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /chatroom/team-recruitment/{recruitmentId}/{chatRoomId} 연결 (팀/개인 채팅방 정보) - POST /chatroom/team-recruitment/{recruitmentId}/applications/{applicationId}/direct 연결 (개인 채팅방 생성 또는 조회) - GET/POST /chatroom/team-recruitment/{recruitmentId}/{chatRoomId}/messages 연결 (메시지 조회/전송) - 메시지를 날짜별로 묶고 내 메시지 여부, 프로필 노출, 프로필 이미지 매핑 --- .../Recruit/RecruitChatDataDto.swift | 52 +++++++++++ .../Recruit/RecruitChatMessageDto.swift | 89 +++++++++++++++++++ .../Recruit/RecruitChatPostRequestDto.swift | 25 ++++++ .../Repository/DefaultRecruitRepository.swift | 16 ++-- .../Data/Service/Network/API/RecruitAPI.swift | 23 +++-- Koin/Data/Service/RecruitService.swift | 20 +++++ .../RecruitChat/RecruitChatViewModel.swift | 18 ++-- koin.xcodeproj/project.pbxproj | 12 +++ 8 files changed, 233 insertions(+), 22 deletions(-) create mode 100644 Koin/Data/DTOs/Decodable/Recruit/RecruitChatDataDto.swift create mode 100644 Koin/Data/DTOs/Decodable/Recruit/RecruitChatMessageDto.swift create mode 100644 Koin/Data/DTOs/Encodable/Recruit/RecruitChatPostRequestDto.swift diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitChatDataDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitChatDataDto.swift new file mode 100644 index 000000000..bade6d2eb --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitChatDataDto.swift @@ -0,0 +1,52 @@ +// +// RecruitChatDataDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct RecruitChatDataDto: Decodable { + let chatRoomId: Int + let roomName: String + let roomType: RecruitChatRoomTypeDto + let memberCount: Int? + let maxMemberCount: Int? + + enum CodingKeys: String, CodingKey { + case chatRoomId = "chat_room_id" + case roomName = "room_name" + case roomType = "room_type" + case memberCount = "member_count" + case maxMemberCount = "max_member_count" + } +} + +enum RecruitChatRoomTypeDto: String, Decodable { + case team = "TEAM" + case direct = "DIRECT" +} + +extension RecruitChatDataDto { + func toDomain() -> RecruitChatData { + return RecruitChatData( + chatRoomId: chatRoomId, + chatRoomName: roomName, + chatRoomType: roomType.toDomain(), + currentMemberCount: memberCount, + maximumMemberCount: maxMemberCount + ) + } +} + +extension RecruitChatRoomTypeDto { + func toDomain() -> RecruitChatRoomType { + switch self { + case .team: + return .team + case .direct: + return .direct + } + } +} diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitChatMessageDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitChatMessageDto.swift new file mode 100644 index 000000000..4a2be2211 --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitChatMessageDto.swift @@ -0,0 +1,89 @@ +// +// RecruitChatMessageDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct RecruitChatMessageDto: Decodable { + let messageId: Int + let userId: Int + let userNickname: String + let content: String + let timestamp: String + let isImage: Bool + let unreadCount: Int + + enum CodingKeys: String, CodingKey { + case messageId = "message_id" + case userId = "user_id" + case userNickname = "user_nickname" + case content, timestamp + case isImage = "is_image" + case unreadCount = "unread_count" + } +} + +extension Array where Element == RecruitChatMessageDto { + func toDomain(myUserId: Int) -> RecruitChatMessageList { + let profileImages: [ImageAsset] = [ + .callVanProfile0, .callVanProfile1, .callVanProfile2, .callVanProfile3, + .callVanProfile4, .callVanProfile5, .callVanProfile6, .callVanProfile7 + ] + let timestampFormatter = DateFormatter().then { + $0.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + $0.locale = Locale(identifier: "ko_KR") + $0.calendar = Calendar(identifier: .gregorian) + } + + var profileIndexByUserId: [Int: Int] = [:] + var previousUserId: Int? + var previousDate: String? + + let messages: [RecruitChatMessage] = sorted { $0.messageId < $1.messageId }.compactMap { dto in + guard let timestamp = timestampFormatter.date(from: dto.timestamp) else { + return nil + } + let date = timestamp.formatDateToYYYY년M월D일() + + let profileIndex = profileIndexByUserId[dto.userId] ?? Swift.min(profileIndexByUserId.count, profileImages.count - 1) + profileIndexByUserId[dto.userId] = profileIndex + + let showProfile = !(dto.userId == previousUserId && date == previousDate) + previousUserId = dto.userId + previousDate = date + + return RecruitChatMessage( + messageId: dto.messageId, + userId: dto.userId, + userNickname: dto.userNickname, + content: dto.content, + timestamp: timestamp, + isImage: dto.isImage, + unreadCount: dto.unreadCount, + isMine: dto.userId == myUserId, + showProfile: showProfile, + profileImage: profileImages[profileIndex] + ) + } + + var dates: [String] = [] + var sections: [[RecruitChatMessage]] = [] + for message in messages { + let date = message.timestamp.formatDateToYYYY년M월D일() + if dates.last == date { + sections[sections.count - 1].append(message) + } else { + dates.append(date) + sections.append([message]) + } + } + + return RecruitChatMessageList( + dates: dates.reversed(), + messages: sections.map { $0.reversed() }.reversed() + ) + } +} diff --git a/Koin/Data/DTOs/Encodable/Recruit/RecruitChatPostRequestDto.swift b/Koin/Data/DTOs/Encodable/Recruit/RecruitChatPostRequestDto.swift new file mode 100644 index 000000000..2cdc69ef2 --- /dev/null +++ b/Koin/Data/DTOs/Encodable/Recruit/RecruitChatPostRequestDto.swift @@ -0,0 +1,25 @@ +// +// RecruitChatPostRequestDto.swift +// koin +// +// Created by 홍기정 on 10/4/26. +// + +import Foundation + +struct RecruitChatPostRequestDto: Encodable { + let content: String + let isImage: Bool + + enum CodingKeys: String, CodingKey { + case content + case isImage = "is_image" + } +} + +extension RecruitChatPostRequestDto { + init(from request: RecruitChatPostRequest) { + self.content = request.content + self.isImage = request.isImage + } +} diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift index e27e7caac..ce7b44939 100644 --- a/Koin/Data/Repository/DefaultRecruitRepository.swift +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -98,29 +98,27 @@ final class DefaultRecruitRepository: RecruitRepository { } return try await service.upsertMyProfile(request).toDomain() } -} - -extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 func fetchTeamChatData( recruitmentId: Int, chatRoomId: Int ) async throws -> RecruitChatData { - try await mockRepository.fetchTeamChatData(recruitmentId: recruitmentId, chatRoomId: chatRoomId) + try await service.fetchChatData(recruitmentId: recruitmentId, chatRoomId: chatRoomId).toDomain() } func fetchDirectChatData( recruitmentId: Int, applicationId: Int ) async throws -> RecruitChatData { - try await mockRepository.fetchDirectChatData(recruitmentId: recruitmentId, applicationId: applicationId) + try await service.fetchDirectChatData(recruitmentId: recruitmentId, applicationId: applicationId).toDomain() } func fetchChatMessages( recruitmentId: Int, chatRoomId: Int ) async throws -> RecruitChatMessageList { - try await mockRepository.fetchChatMessages(recruitmentId: recruitmentId, chatRoomId: chatRoomId) + let messages = try await service.fetchChatMessages(recruitmentId: recruitmentId, chatRoomId: chatRoomId) + return messages.toDomain(myUserId: UserDataManager.shared.id) } func postChatMessage( @@ -128,8 +126,12 @@ extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 chatRoomId: Int, request: RecruitChatPostRequest ) async throws { - try await mockRepository.postChatMessage(recruitmentId: recruitmentId, chatRoomId: chatRoomId, request: request) + let request = RecruitChatPostRequestDto(from: request) + try await service.postChatMessage(recruitmentId: recruitmentId, chatRoomId: chatRoomId, request) } +} + +extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 func fetchNotificationList() async throws -> RecruitNotificationList { try await mockRepository.fetchNotificationList() diff --git a/Koin/Data/Service/Network/API/RecruitAPI.swift b/Koin/Data/Service/Network/API/RecruitAPI.swift index 17a1b5adb..f955e3fc2 100644 --- a/Koin/Data/Service/Network/API/RecruitAPI.swift +++ b/Koin/Data/Service/Network/API/RecruitAPI.swift @@ -23,6 +23,10 @@ enum RecruitAPI { case fetchMyPostData(Int, RecruitApplicantListRequestDto) case fetchApplicant(recruitmentId: Int, applicationId: Int) case decideApplicant(recruitmentId: Int, applicationId: Int, RecruitApplicantDecisionRequestDto) + case fetchChatData(recruitmentId: Int, chatRoomId: Int) + case fetchDirectChatData(recruitmentId: Int, applicationId: Int) + case fetchChatMessages(recruitmentId: Int, chatRoomId: Int) + case postChatMessage(recruitmentId: Int, chatRoomId: Int, RecruitChatPostRequestDto) } extension RecruitAPI: Router, URLRequestConvertible { @@ -47,6 +51,9 @@ extension RecruitAPI: Router, URLRequestConvertible { case .fetchMyPostData(let id, _): return "/team-recruitments/\(id)/applications" case .fetchApplicant(let recruitmentId, let applicationId): return "/team-recruitments/\(recruitmentId)/applications/\(applicationId)" case .decideApplicant(let recruitmentId, let applicationId, _): return "/team-recruitments/\(recruitmentId)/applications/\(applicationId)/status" + case .fetchChatData(let recruitmentId, let chatRoomId): return "/chatroom/team-recruitment/\(recruitmentId)/\(chatRoomId)" + case .fetchDirectChatData(let recruitmentId, let applicationId): return "/chatroom/team-recruitment/\(recruitmentId)/applications/\(applicationId)/direct" + case .fetchChatMessages(let recruitmentId, let chatRoomId), .postChatMessage(let recruitmentId, let chatRoomId, _): return "/chatroom/team-recruitment/\(recruitmentId)/\(chatRoomId)/messages" } } @@ -66,15 +73,19 @@ extension RecruitAPI: Router, URLRequestConvertible { case .fetchMyPostData: return .get case .fetchApplicant: return .get case .decideApplicant: return .put + case .fetchChatData: return .get + case .fetchDirectChatData: return .post + case .fetchChatMessages: return .get + case .postChatMessage: return .post } } public var headers: [String: String] { var baseHeaders: [String: String] = [:] switch self { - case .fetchList, .fetchData, .deleteData, .fetchMyProfile, .fetchMyApplicationList, .fetchMyPostList, .closeMyPost, .fetchMyPostData, .fetchApplicant: + case .fetchList, .fetchData, .deleteData, .fetchMyProfile, .fetchMyApplicationList, .fetchMyPostList, .closeMyPost, .fetchMyPostData, .fetchApplicant, .fetchChatData, .fetchDirectChatData, .fetchChatMessages: break - case .post, .modify, .upsertMyProfile, .apply, .decideApplicant: + case .post, .modify, .upsertMyProfile, .apply, .decideApplicant, .postChatMessage: baseHeaders["Content-Type"] = "application/json" } return baseHeaders @@ -100,7 +111,9 @@ extension RecruitAPI: Router, URLRequestConvertible { return try? request.toDictionary() case .decideApplicant(_, _, let request): return try? request.toDictionary() - case .closeMyPost, .fetchApplicant: + case .postChatMessage(_, _, let request): + return try? request.toDictionary() + case .closeMyPost, .fetchApplicant, .fetchChatData, .fetchDirectChatData, .fetchChatMessages: return nil } } @@ -109,11 +122,11 @@ extension RecruitAPI: Router, URLRequestConvertible { switch self { case .fetchList, .fetchMyApplicationList, .fetchMyPostList, .fetchMyPostData: return URLEncoding(arrayEncoding: .noBrackets) - case .closeMyPost, .fetchApplicant: + case .closeMyPost, .fetchApplicant, .fetchChatData, .fetchDirectChatData, .fetchChatMessages: return nil case .fetchData, .deleteData, .fetchMyProfile: return nil - case .post, .modify, .upsertMyProfile, .apply, .decideApplicant: + case .post, .modify, .upsertMyProfile, .apply, .decideApplicant, .postChatMessage: return JSONEncoding.default } } diff --git a/Koin/Data/Service/RecruitService.swift b/Koin/Data/Service/RecruitService.swift index 40c6c8940..2bb6a6b6e 100644 --- a/Koin/Data/Service/RecruitService.swift +++ b/Koin/Data/Service/RecruitService.swift @@ -22,6 +22,10 @@ protocol RecruitService { func fetchMyPostData(_ id: Int, _ request: RecruitApplicantListRequestDto) async throws -> RecruitMyPostDataDto func fetchApplicant(recruitmentId: Int, applicationId: Int) async throws -> RecruitApplicantDataDto func decideApplicant(recruitmentId: Int, applicationId: Int, _ request: RecruitApplicantDecisionRequestDto) async throws + func fetchChatData(recruitmentId: Int, chatRoomId: Int) async throws -> RecruitChatDataDto + func fetchDirectChatData(recruitmentId: Int, applicationId: Int) async throws -> RecruitChatDataDto + func fetchChatMessages(recruitmentId: Int, chatRoomId: Int) async throws -> [RecruitChatMessageDto] + func postChatMessage(recruitmentId: Int, chatRoomId: Int, _ request: RecruitChatPostRequestDto) async throws } final class DefaultRecruitService: RecruitService { @@ -83,4 +87,20 @@ final class DefaultRecruitService: RecruitService { func decideApplicant(recruitmentId: Int, applicationId: Int, _ request: RecruitApplicantDecisionRequestDto) async throws { try await networkService.request(api: RecruitAPI.decideApplicant(recruitmentId: recruitmentId, applicationId: applicationId, request)) } + + func fetchChatData(recruitmentId: Int, chatRoomId: Int) async throws -> RecruitChatDataDto { + try await networkService.requestWithResponse(api: RecruitAPI.fetchChatData(recruitmentId: recruitmentId, chatRoomId: chatRoomId)) + } + + func fetchDirectChatData(recruitmentId: Int, applicationId: Int) async throws -> RecruitChatDataDto { + try await networkService.requestWithResponse(api: RecruitAPI.fetchDirectChatData(recruitmentId: recruitmentId, applicationId: applicationId)) + } + + func fetchChatMessages(recruitmentId: Int, chatRoomId: Int) async throws -> [RecruitChatMessageDto] { + try await networkService.requestWithResponse(api: RecruitAPI.fetchChatMessages(recruitmentId: recruitmentId, chatRoomId: chatRoomId)) + } + + func postChatMessage(recruitmentId: Int, chatRoomId: Int, _ request: RecruitChatPostRequestDto) async throws { + try await networkService.request(api: RecruitAPI.postChatMessage(recruitmentId: recruitmentId, chatRoomId: chatRoomId, request)) + } } diff --git a/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift index c75532d34..3cb21fcc0 100644 --- a/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift @@ -110,7 +110,8 @@ extension RecruitChatViewModel { } didUpdateData = true } catch { - outputSubject.send(.showToast(errorMessage(from: error))) + let errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + outputSubject.send(.showToast(errorMessage)) } } @@ -181,7 +182,8 @@ extension RecruitChatViewModel { } catch is CancellationError { return } catch { - outputSubject.send(.showToast(errorMessage(from: error))) + let errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + outputSubject.send(.showToast(errorMessage)) } } } @@ -194,7 +196,8 @@ extension RecruitChatViewModel { let request = RecruitChatPostRequest(content: text, isImage: false) try await postMessage(request: request) } catch { - outputSubject.send(.showToast(errorMessage(from: error))) + let errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + outputSubject.send(.showToast(errorMessage)) } } } @@ -207,7 +210,8 @@ extension RecruitChatViewModel { let request = RecruitChatPostRequest(content: imageUrl, isImage: true) try await postMessage(request: request) } catch { - outputSubject.send(.showToast(errorMessage(from: error))) + let errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + outputSubject.send(.showToast(errorMessage)) } } } @@ -236,9 +240,3 @@ extension RecruitChatViewModel { ) } } - -extension RecruitChatViewModel { - private func errorMessage(from error: Error) -> String { - (error as? ErrorResponse)?.message ?? error.localizedDescription - } -} diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 554a8fbf2..b02f85e20 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -8,12 +8,14 @@ /* Begin PBXBuildFile section */ 004A06CB3468BBA8DCB4CB44 /* RecruitPostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F7EBAB20247086024B51CB /* RecruitPostViewController.swift */; }; + 0D3DC4E723268AE45A326D07 /* RecruitChatDataDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C398DC463A148F3E7ADB7ADE /* RecruitChatDataDto.swift */; }; 0E2253358E42231BE436FA27 /* RecruitPostScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE127F2AE4B18899F6E5E0D /* RecruitPostScheduleView.swift */; }; 0FCD163D2BA80EAA00C23A2F /* KeychainWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCD163C2BA80EAA00C23A2F /* KeychainWorker.swift */; }; 1537B6CFD3D7AF52A2E9E944 /* RecruitPostMeetingTypeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73F70BC5F3AA1A34663C32CA /* RecruitPostMeetingTypeView.swift */; }; 1AD09EAFA1CF9EE0B81E886D /* RecruitMyPostListRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC82D6E2A559FEFE43E63F24 /* RecruitMyPostListRequestDto.swift */; }; 239837C1CD4A0D7A5D1D36C7 /* RecruitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC394F2327076B2F9AEE2A4 /* RecruitService.swift */; }; 247DF973D072E88901E5C249 /* RecruitProfilePostDepartmentDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */; }; + 2C154F14B8028973E780375B /* RecruitChatPostRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 135CA058C5A41FBC30F6DD2F /* RecruitChatPostRequestDto.swift */; }; 2E6336C1CDB4445BA858DA5A /* RecruitDataContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */; }; 3DDE20379881F001713618FC /* RecruitDataDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA92583628DDFBC3545C197B /* RecruitDataDto.swift */; }; 3E56520894546C11EB8F99B3 /* RecruitPostRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE562D9FEDE6181748BE67B /* RecruitPostRequestDto.swift */; }; @@ -1172,6 +1174,7 @@ D2FCBEA62D0877CC0097A388 /* FetchFramesUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FCBEA52D0877CC0097A388 /* FetchFramesUseCase.swift */; }; D2FFA9702D5F4DC500EF8E56 /* CheckLoginUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FFA96F2D5F4DB600EF8E56 /* CheckLoginUseCase.swift */; }; D2FFA9852D6333BB00EF8E56 /* ReportLostItemRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FFA9842D6333BB00EF8E56 /* ReportLostItemRequest.swift */; }; + D6728313D8B09F12DCB47D44 /* RecruitChatMessageDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7C07F15438785121E093249 /* RecruitChatMessageDto.swift */; }; D80AD4E42E6C6BC30061334B /* waveLogo.json in Resources */ = {isa = PBXBuildFile; fileRef = D80AD4E32E6C6BC30061334B /* waveLogo.json */; }; D837FB122E254BD8002BFB9F /* CustomSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D837FB112E254BD8002BFB9F /* CustomSessionManager.swift */; }; D85939752DC12C9D00CE4CB2 /* CheckDuplicatedIdUsecase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85939742DC12C9D00CE4CB2 /* CheckDuplicatedIdUsecase.swift */; }; @@ -1240,6 +1243,7 @@ /* Begin PBXFileReference section */ 0FCD163C2BA80EAA00C23A2F /* KeychainWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainWorker.swift; sourceTree = ""; }; + 135CA058C5A41FBC30F6DD2F /* RecruitChatPostRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitChatPostRequestDto.swift; sourceTree = ""; }; 1A900DEA87E917C84BCD7E04 /* RecruitProfileDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitProfileDto.swift; sourceTree = ""; }; 1C70F17EDEED094B2D134CFD /* RecruitPostTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostTextFieldView.swift; sourceTree = ""; }; 1FC1A22BED5A1C53E5ED9903 /* RecruitApplicantDataDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitApplicantDataDto.swift; sourceTree = ""; }; @@ -2143,6 +2147,7 @@ B68D2CEA2EA50C1E00F3B479 /* UIViewController+Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Toast.swift"; sourceTree = ""; }; B6A0D6292EEB01E600A19521 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; B6A0D62B2EEB01F100A19521 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + B7C07F15438785121E093249 /* RecruitChatMessageDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitChatMessageDto.swift; sourceTree = ""; }; BEEF10000000000000000001 /* RecruitMyPostList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostList.swift; sourceTree = ""; }; BEEF10000000000000000002 /* RecruitMyPostRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostRow.swift; sourceTree = ""; }; BEEF10000000000000000003 /* RecruitMyPostFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostFilter.swift; sourceTree = ""; }; @@ -2157,6 +2162,7 @@ BEEF1000000000000000000C /* CloseRecruitMyPostUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseRecruitMyPostUseCase.swift; sourceTree = ""; }; C0DE00000000000000000002 /* RecruitProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfile.swift; sourceTree = ""; }; C0DE00000000000000000004 /* FetchMyRecruitProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMyRecruitProfileUseCase.swift; sourceTree = ""; }; + C398DC463A148F3E7ADB7ADE /* RecruitChatDataDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitChatDataDto.swift; sourceTree = ""; }; C8FC035A1EC83BD077A6AFCA /* RecruitApplyRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitApplyRequestDto.swift; sourceTree = ""; }; CAFE10000000000000000001 /* RecruitMyApplicationList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationList.swift; sourceTree = ""; }; CAFE10000000000000000002 /* RecruitMyApplicationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationRow.swift; sourceTree = ""; }; @@ -2500,6 +2506,8 @@ A064C42647B4EBB3454B2CA3 /* RecruitMyPostListDto.swift */, E1A5115E53734B28CC6EBED6 /* RecruitMyPostDataDto.swift */, 1FC1A22BED5A1C53E5ED9903 /* RecruitApplicantDataDto.swift */, + C398DC463A148F3E7ADB7ADE /* RecruitChatDataDto.swift */, + B7C07F15438785121E093249 /* RecruitChatMessageDto.swift */, ); path = Recruit; sourceTree = ""; @@ -5601,6 +5609,7 @@ DC82D6E2A559FEFE43E63F24 /* RecruitMyPostListRequestDto.swift */, 827BD7E96347F476DB1BD966 /* RecruitApplicantListRequestDto.swift */, 4D7BF08E4E28887C9FB48DA1 /* RecruitApplicantDecisionRequestDto.swift */, + 135CA058C5A41FBC30F6DD2F /* RecruitChatPostRequestDto.swift */, ); path = Recruit; sourceTree = ""; @@ -7921,6 +7930,9 @@ 8F73C145755D31960169BE61 /* RecruitMyPostListDto.swift in Sources */, 5F6F2CE2D04FA793C2A5FD33 /* RecruitMyPostDataDto.swift in Sources */, E3DB42C328CC0291FC430D89 /* RecruitApplicantDataDto.swift in Sources */, + 2C154F14B8028973E780375B /* RecruitChatPostRequestDto.swift in Sources */, + 0D3DC4E723268AE45A326D07 /* RecruitChatDataDto.swift in Sources */, + D6728313D8B09F12DCB47D44 /* RecruitChatMessageDto.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From a7845ba77e5e78491e217b620148c97aa39908fe Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 5 Oct 2026 00:55:10 +0900 Subject: [PATCH 081/123] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=B7=B0=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NotificationListView 에 Behavior(allAtOnce / pagination) 주입 - 맨 끝까지 스크롤 시 reachedBottomPublisher 발행, 첫 페이지가 화면보다 짧으면 자동 발행 - update(items:hasNextPage:) 로 추가 로딩 가능 여부 전달 - NotificationTableView 갱신을 reload(최초·새로고침) / append(페이지네이션, insertRows) 로 분리 - NotificationFooterView 에 안내 문구와 로딩 인디케이터를 두고 상태에 따라 전환 - 다음 페이지가 있으면 로딩, 마지막 페이지 또는 allAtOnce 면 안내 문구 표시 - 일반 알림 화면은 allAtOnce, 팀원모집 알림 화면은 pagination 으로 생성 --- .../Views/NotificationListView.swift | 27 ++++- .../NotificationFooterView.swift | 36 +++++- .../NotificationTableView.swift | 108 +++++++++++++++--- .../NotificationViewController.swift | 2 +- ...ecruitNotificationListViewController.swift | 2 +- koin.xcodeproj/project.pbxproj | 8 +- 6 files changed, 150 insertions(+), 33 deletions(-) diff --git a/Koin/Core/View/UIKit/Notification/Views/NotificationListView.swift b/Koin/Core/View/UIKit/Notification/Views/NotificationListView.swift index a01ec2a17..6f562700e 100644 --- a/Koin/Core/View/UIKit/Notification/Views/NotificationListView.swift +++ b/Koin/Core/View/UIKit/Notification/Views/NotificationListView.swift @@ -12,21 +12,28 @@ import Then final class NotificationListView: UIView { + enum Behavior { + case allAtOnce + case pagination + } + // MARK: - Properties let refreshPublisher = PassthroughSubject() let itemTappedPublisher = PassthroughSubject() let deletePublisher = PassthroughSubject() + let reachedBottomPublisher = PassthroughSubject() private var subscriptions = Set() // MARK: - UI Components - private let tableView = NotificationTableView() + private let tableView: NotificationTableView private let refreshControl = UIRefreshControl() private let loadingIndicator = UIActivityIndicatorView(style: .medium) private let emptyView = NotificationEmptyView() // MARK: - Initialization - override init(frame: CGRect) { - super.init(frame: frame) + init(behavior: Behavior) { + self.tableView = NotificationTableView(behavior: behavior) + super.init(frame: .zero) configureView() bind() } @@ -43,8 +50,12 @@ final class NotificationListView: UIView { loadingIndicator.stopAnimating() } - func update(items: [NotificationRowModel]) { - tableView.update(notifications: items) + func update(items: [NotificationRowModel], hasNextPage: Bool = false) { + if tableView.canAppend(items) { + tableView.append(notifications: items, hasNextPage: hasNextPage) + } else { + tableView.reload(notifications: items, hasNextPage: hasNextPage) + } updateStateViews(isEmpty: items.isEmpty) } @@ -75,6 +86,12 @@ private extension NotificationListView { deletePublisher.send(id) } .store(in: &subscriptions) + + tableView.reachedBottomPublisher + .sink { [weak self] in + self?.reachedBottomPublisher.send() + } + .store(in: &subscriptions) } } diff --git a/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationFooterView.swift b/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationFooterView.swift index a6ac8bedb..558189af2 100644 --- a/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationFooterView.swift +++ b/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationFooterView.swift @@ -11,16 +11,36 @@ import Then final class NotificationFooterView: UIView { + enum State { + case info + case loading + } + // MARK: - UI Components - private let label = UILabel() - + private let infoLabel = UILabel() + private let loadingIndicator = UIActivityIndicatorView(style: .medium).then { + $0.startAnimating() + } + + // MARK: - Properties + private var state: State = .info + + // MARK: - Initializer override init(frame: CGRect) { super.init(frame: frame) configureView() + update(state: .info) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + // MARK: - Public + func update(state: State) { + self.state = state + infoLabel.isHidden = state != .info + loadingIndicator.isHidden = state != .loading + } } private extension NotificationFooterView { @@ -34,7 +54,7 @@ private extension NotificationFooterView { private func setUpStyles() { backgroundColor = UIColor.ColorSystem.Neutral.gray0 - label.do { + infoLabel.do { $0.text = "14일이 지난 알림은 자동으로 삭제됩니다." $0.font = UIFont.appFont(.pretendardRegular, size: 14) $0.textColor = UIColor.ColorSystem.Neutral.gray500 @@ -44,16 +64,22 @@ private extension NotificationFooterView { } private func setUpLayouts() { - addSubview(label) + [infoLabel, loadingIndicator].forEach { + addSubview($0) + } } private func setUpConstraints() { - label.snp.makeConstraints { + infoLabel.snp.makeConstraints { $0.top.greaterThanOrEqualToSuperview().offset(20) $0.leading.equalToSuperview().offset(16) $0.trailing.equalToSuperview().offset(-16) $0.bottom.equalToSuperview().offset(-20).priority(.low) $0.height.equalTo(15) } + + loadingIndicator.snp.makeConstraints { + $0.center.equalToSuperview() + } } } diff --git a/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableView.swift b/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableView.swift index e08a3c77f..3b7bf4bcc 100644 --- a/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableView.swift +++ b/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableView.swift @@ -21,33 +21,70 @@ final class NotificationTableView: UITableView { // MARK: - Publisher let deletePublisher = PassthroughSubject() let tapNotificationPublisher = PassthroughSubject() + let reachedBottomPublisher = PassthroughSubject() // MARK: - UI Components - private let realFooterView = NotificationFooterView() + private let footerView = NotificationFooterView() // MARK: - Properties + private let behavior: NotificationListView.Behavior + private var hasNextPage = false + private var wasAtBottom = false private var notifications: [NotificationRowModel] = [] var isEmpty: Bool { notifications.isEmpty } + private var footerState: NotificationFooterView.State { + behavior == .pagination && hasNextPage ? .loading : .info + } private var lastBoundsSize: CGSize = .zero private var lastFooterHeight: CGFloat = 0 // MARK: - Initialization - init() { + init(behavior: NotificationListView.Behavior) { + self.behavior = behavior super.init(frame: .zero, style: .grouped) - setUpStyles() + configureView() + commonInit() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Public - func update(notifications: [NotificationRowModel]) { + func canAppend(_ notifications: [NotificationRowModel]) -> Bool { + let oldIds = self.notifications.map(\.id) + let newIds = notifications.map(\.id) + return behavior == .pagination + && !oldIds.isEmpty + && newIds.count > oldIds.count + && newIds.starts(with: oldIds) + } + + func reload(notifications: [NotificationRowModel], hasNextPage: Bool) { performBatchUpdates { self.notifications = notifications + self.hasNextPage = hasNextPage + footerView.update(state: footerState) recalculateFooterHeightIfNeeded() reloadSections([0], with: refreshControl?.isRefreshing == true ? .fade : .top) + } completion: { [weak self] _ in + self?.requestNextPageIfContentFits() + } + } + + func append(notifications: [NotificationRowModel], hasNextPage: Bool) { + let indexPaths = (self.notifications.count.. 0, bounds.height > 0 else { return } - let desiredHeight = desiredFooterHeight(for: notifications.count) + let desiredHeight: CGFloat + switch footerState { + case .info: + desiredHeight = desiredFooterHeight(for: notifications.count) + case .loading: + desiredHeight = Layout.footerMinHeight + } guard abs(lastFooterHeight - desiredHeight) > Layout.zeroTolerance else { return } lastFooterHeight = desiredHeight @@ -151,22 +194,22 @@ extension NotificationTableView: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { .leastNormalMagnitude } - + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { UIView() } - + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { notifications.isEmpty ? .leastNormalMagnitude : lastFooterHeight } - + func tableView(_ tableView: UITableView, estimatedHeightForFooterInSection section: Int) -> CGFloat { notifications.isEmpty ? .leastNormalMagnitude : lastFooterHeight } - + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { guard !notifications.isEmpty else { return nil } - return realFooterView + return footerView } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { @@ -177,7 +220,7 @@ extension NotificationTableView: UITableViewDelegate { tapNotificationPublisher.send(id) didSelectNotification(id: id) } - + func tableView( _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath @@ -185,7 +228,7 @@ extension NotificationTableView: UITableViewDelegate { guard notifications.indices.contains(indexPath.row) else { return nil } - + let item = notifications[indexPath.row] let deleteAction = UIContextualAction(style: .destructive, title: nil) { [weak self] _, _, completion in @@ -204,22 +247,49 @@ extension NotificationTableView: UITableViewDelegate { configuration.performsFirstActionWithFullSwipe = true return configuration } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard behavior == .pagination, hasNextPage else { + wasAtBottom = false + return + } + + let isAtBottom = contentOffset.y + bounds.height - adjustedContentInset.bottom + >= contentSize.height - Layout.zeroTolerance + + if isAtBottom && !wasAtBottom { + reachedBottomPublisher.send() + } + wasAtBottom = isAtBottom + } +} + +extension NotificationTableView { + private func requestNextPageIfContentFits() { + guard behavior == .pagination, + hasNextPage, + !notifications.isEmpty else { + return + } + let visibleHeight = bounds.height - adjustedContentInset.top - adjustedContentInset.bottom + guard contentSize.height <= visibleHeight else { + return + } + reachedBottomPublisher.send() + } } // MARK: - Configure extension NotificationTableView { - private func setUpStyles() { + private func configureView() { backgroundColor = UIColor.ColorSystem.Neutral.gray0 separatorStyle = .none showsVerticalScrollIndicator = false rowHeight = Layout.rowHeight - dataSource = self - delegate = self - tableFooterView = UIView( frame: .init( origin: .zero, @@ -229,7 +299,11 @@ extension NotificationTableView { ) ) ) - + } + + private func commonInit() { + dataSource = self + delegate = self register( NotificationTableViewCell.self, forCellReuseIdentifier: NotificationTableViewCell.identifier diff --git a/Koin/Presentation/Home/Notification/NotificationViewController.swift b/Koin/Presentation/Home/Notification/NotificationViewController.swift index 51a9d8ebd..8f55d302c 100644 --- a/Koin/Presentation/Home/Notification/NotificationViewController.swift +++ b/Koin/Presentation/Home/Notification/NotificationViewController.swift @@ -17,7 +17,7 @@ final class NotificationViewController: UIViewController { private var subscriptions = Set() // MARK: - UI Components - private let notificationListView = NotificationListView() + private let notificationListView = NotificationListView(behavior: .allAtOnce) // MARK: - Initialization init(viewModel: NotificationViewModel) { diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift index f463e69e2..c56092ca3 100644 --- a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift @@ -18,7 +18,7 @@ final class RecruitNotificationListViewController: UIViewController { private var notificationList: RecruitNotificationList? // MARK: - UI Components - private let notificationListView = NotificationListView() + private let notificationListView = NotificationListView(behavior: .pagination) // MARK: - Initialization init(viewModel: RecruitNotificationListViewModel) { diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index b02f85e20..c4855910e 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -316,7 +316,6 @@ 7C8A941E2FD0A94F00DEA6F5 /* NotificationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A941D2FD0A94F00DEA6F5 /* NotificationViewModel.swift */; }; 7C8A941F2FD0A94F00DEA6F5 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A941C2FD0A94F00DEA6F5 /* NotificationViewController.swift */; }; 7C8A94222FD0A96400DEA6F5 /* NotificationEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A94212FD0A96400DEA6F5 /* NotificationEmptyView.swift */; }; - 7C8A94272FD0A97400DEA6F5 /* NotificationFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A94242FD0A97400DEA6F5 /* NotificationFooterView.swift */; }; 7C8A94282FD0A97400DEA6F5 /* NotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A94262FD0A97400DEA6F5 /* NotificationTableViewCell.swift */; }; 7C8A94292FD0A97400DEA6F5 /* NotificationTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A94252FD0A97400DEA6F5 /* NotificationTableView.swift */; }; 7C8A942B2FD2B55F00DEA6F5 /* FetchCallVanRecruitingCountUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A942A2FD2B55F00DEA6F5 /* FetchCallVanRecruitingCountUseCase.swift */; }; @@ -956,6 +955,7 @@ CAFE2000000000000000000A /* RecruitMyApplicationListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE1000000000000000000A /* RecruitMyApplicationListHeaderView.swift */; }; CAFE2000000000000000000B /* RecruitMyApplicationListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE1000000000000000000B /* RecruitMyApplicationListRowView.swift */; }; CAFE2000000000000000000C /* RecruitMyApplicationListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE1000000000000000000C /* RecruitMyApplicationListEmptyView.swift */; }; + CC6BE28EE54649DEEBF56B9B /* NotificationFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C097425ADD10086931A8789D /* NotificationFooterView.swift */; }; CD3000000000000000000001 /* RecruitPostCategoryDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3000000000000000000002 /* RecruitPostCategoryDropdownView.swift */; }; CE3D147031BC1F6BDD25184B /* DefaultRecruitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED038AB5420C8C565688CF6B /* DefaultRecruitRepository.swift */; }; D0A71C4F2F1B8340009E2D71 /* KoinDropdownHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */; }; @@ -1548,7 +1548,6 @@ 7C8A941C2FD0A94F00DEA6F5 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; 7C8A941D2FD0A94F00DEA6F5 /* NotificationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewModel.swift; sourceTree = ""; }; 7C8A94212FD0A96400DEA6F5 /* NotificationEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationEmptyView.swift; sourceTree = ""; }; - 7C8A94242FD0A97400DEA6F5 /* NotificationFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFooterView.swift; sourceTree = ""; }; 7C8A94252FD0A97400DEA6F5 /* NotificationTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTableView.swift; sourceTree = ""; }; 7C8A94262FD0A97400DEA6F5 /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.swift; sourceTree = ""; }; 7C8A942A2FD2B55F00DEA6F5 /* FetchCallVanRecruitingCountUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchCallVanRecruitingCountUseCase.swift; sourceTree = ""; }; @@ -2160,6 +2159,7 @@ BEEF1000000000000000000A /* RecruitMyPostListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListRowView.swift; sourceTree = ""; }; BEEF1000000000000000000B /* RecruitMyPostListEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostListEmptyView.swift; sourceTree = ""; }; BEEF1000000000000000000C /* CloseRecruitMyPostUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseRecruitMyPostUseCase.swift; sourceTree = ""; }; + C097425ADD10086931A8789D /* NotificationFooterView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationFooterView.swift; sourceTree = ""; }; C0DE00000000000000000002 /* RecruitProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitProfile.swift; sourceTree = ""; }; C0DE00000000000000000004 /* FetchMyRecruitProfileUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMyRecruitProfileUseCase.swift; sourceTree = ""; }; C398DC463A148F3E7ADB7ADE /* RecruitChatDataDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitChatDataDto.swift; sourceTree = ""; }; @@ -3476,7 +3476,7 @@ children = ( 7C8A94252FD0A97400DEA6F5 /* NotificationTableView.swift */, 7C8A94262FD0A97400DEA6F5 /* NotificationTableViewCell.swift */, - 7C8A94242FD0A97400DEA6F5 /* NotificationFooterView.swift */, + C097425ADD10086931A8789D /* NotificationFooterView.swift */, ); path = NotificationTableView; sourceTree = ""; @@ -7511,7 +7511,6 @@ 7C6790952FCC48A60045E163 /* UIButton+.swift in Sources */, 7C50A1D13041F8BF00897A6F /* RecruitState.swift in Sources */, 7C50A1F43042CB4100897A6F /* RecruitListSort.swift in Sources */, - 7C8A94272FD0A97400DEA6F5 /* NotificationFooterView.swift in Sources */, 7C8A94282FD0A97400DEA6F5 /* NotificationTableViewCell.swift in Sources */, 7C8A94292FD0A97400DEA6F5 /* NotificationTableView.swift in Sources */, 7C6790962FCC48A60045E163 /* UIImageView+.swift in Sources */, @@ -7933,6 +7932,7 @@ 2C154F14B8028973E780375B /* RecruitChatPostRequestDto.swift in Sources */, 0D3DC4E723268AE45A326D07 /* RecruitChatDataDto.swift in Sources */, D6728313D8B09F12DCB47D44 /* RecruitChatMessageDto.swift in Sources */, + CC6BE28EE54649DEEBF56B9B /* NotificationFooterView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From e347df05d6f6c0381f803e484b07e88009056ae6 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 5 Oct 2026 02:37:04 +0900 Subject: [PATCH 082/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=95=8C=EB=A6=BC=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /team-recruitments/notifications 연결 (limit 50 고정, 페이지네이션) - 알림 읽음 / 전체 읽음 / 개별 삭제 / 전체 삭제 API 연결 - RecruitNotificationRow 를 API 응답 기준으로 재정의 (type 6종, targetType 4종) - 알 수 없는 type 은 목록에서 제외, 알 수 없는 target_type 은 none 처리 - 알림 목록 끝에서 다음 페이지 로드, 알림 탭 시 target_type 에 따라 화면 이동 - DefaultRecruitRepository 의 mock 위임 제거, MockRecruitRepository 삭제 --- .../Recruit/RecruitNotificationListDto.swift | 132 ++++ .../RecruitNotificationListRequestDto.swift | 20 + .../Repository/DefaultRecruitRepository.swift | 18 +- .../Repository/MockRecruitRepository.swift | 626 ------------------ .../Data/Service/Network/API/RecruitAPI.swift | 22 +- Koin/Data/Service/RecruitService.swift | 25 + .../Recruit/RecruitNotificationList.swift | 6 + .../Recruit/RecruitNotificationRow.swift | 30 +- .../Domain/Repository/RecruitRepository.swift | 2 +- .../FetchRecruitNotificationListUseCase.swift | 6 +- .../RecruitList/RecruitListViewModel.swift | 10 +- .../Mapper/RecruitNotificationRow+.swift | 28 +- ...ecruitNotificationListViewController.swift | 60 +- .../RecruitNotificationListViewModel.swift | 42 +- koin.xcodeproj/project.pbxproj | 12 +- 15 files changed, 367 insertions(+), 672 deletions(-) create mode 100644 Koin/Data/DTOs/Decodable/Recruit/RecruitNotificationListDto.swift create mode 100644 Koin/Data/DTOs/Encodable/Recruit/RecruitNotificationListRequestDto.swift delete mode 100644 Koin/Data/Repository/MockRecruitRepository.swift diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitNotificationListDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitNotificationListDto.swift new file mode 100644 index 000000000..c7be39bf2 --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitNotificationListDto.swift @@ -0,0 +1,132 @@ +// +// RecruitNotificationListDto.swift +// koin +// +// Created by 홍기정 on 10/5/26. +// + +import Foundation + +struct RecruitNotificationListDto: Decodable { + let notifications: [RecruitNotificationRowDto] + let totalPage: Int + let currentPage: Int + + enum CodingKeys: String, CodingKey { + case notifications + case totalPage = "total_page" + case currentPage = "current_page" + } +} + +struct RecruitNotificationRowDto: Decodable { + let id: Int + let type: String + let targetType: String + let recruitmentId: Int + let applicationId: Int? + let chatRoomId: Int? + let senderNickname: String? + let messagePreview: String + let isRead: Bool + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id, type + case targetType = "target_type" + case recruitmentId = "recruitment_id" + case applicationId = "application_id" + case chatRoomId = "chat_room_id" + case senderNickname = "sender_nickname" + case messagePreview = "message_preview" + case isRead = "is_read" + case createdAt = "created_at" + } +} + +enum RecruitNotificationTypeDto: String { + case newApplication = "NEW_APPLICATION" + case applicationAccepted = "APPLICATION_ACCEPTED" + case applicationRejected = "APPLICATION_REJECTED" + case recruitmentClosed = "RECRUITMENT_CLOSED" + case recruitmentDeleted = "RECRUITMENT_DELETED" + case newChatMessage = "NEW_CHAT_MESSAGE" +} + +enum RecruitNotificationTargetTypeDto: String { + case applicantManagement = "APPLICANT_MANAGEMENT" + case chatRoom = "CHAT_ROOM" + case myApplications = "MY_APPLICATIONS" + case none = "NONE" +} + +extension RecruitNotificationListDto { + func toDomain() -> RecruitNotificationList { + return RecruitNotificationList( + notifications: notifications.compactMap { $0.toDomain() }, + totalPage: totalPage, + currentPage: currentPage + ) + } +} + +extension RecruitNotificationRowDto { + func toDomain() -> RecruitNotificationRow? { + guard let type = RecruitNotificationTypeDto(rawValue: type) else { + return nil + } + let targetType = RecruitNotificationTargetTypeDto(rawValue: targetType) ?? .none + let createdAtFormatter = DateFormatter().then { + $0.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + $0.locale = Locale(identifier: "en_US_POSIX") + $0.calendar = Calendar(identifier: .gregorian) + $0.timeZone = TimeZone(identifier: "Asia/Seoul") + } + return RecruitNotificationRow( + id: id, + type: type.toDomain(), + targetType: targetType.toDomain(), + recruitmentId: recruitmentId, + applicationId: applicationId, + chatRoomId: chatRoomId, + senderNickname: senderNickname, + messagePreview: messagePreview, + createdAt: createdAtFormatter.date(from: String(createdAt.prefix(19))), + isRead: isRead + ) + } +} + +extension RecruitNotificationTypeDto { + func toDomain() -> RecruitNotificationType { + switch self { + case .newApplication: + return .newApplication + case .applicationAccepted: + return .applicationAccepted + case .applicationRejected: + return .applicationRejected + case .recruitmentClosed: + return .recruitmentClosed + case .recruitmentDeleted: + return .recruitmentDeleted + case .newChatMessage: + return .newChatMessage + } + } +} + +extension RecruitNotificationTargetTypeDto { + func toDomain() -> RecruitNotificationTargetType { + switch self { + case .applicantManagement: + return .applicantManagement + case .chatRoom: + return .chatRoom + case .myApplications: + return .myApplications + case .none: + return .none + } + } +} diff --git a/Koin/Data/DTOs/Encodable/Recruit/RecruitNotificationListRequestDto.swift b/Koin/Data/DTOs/Encodable/Recruit/RecruitNotificationListRequestDto.swift new file mode 100644 index 000000000..210dc02c1 --- /dev/null +++ b/Koin/Data/DTOs/Encodable/Recruit/RecruitNotificationListRequestDto.swift @@ -0,0 +1,20 @@ +// +// RecruitNotificationListRequestDto.swift +// koin +// +// Created by 홍기정 on 10/5/26. +// + +import Foundation + +struct RecruitNotificationListRequestDto: Encodable { + let page: Int + let limit: Int +} + +extension RecruitNotificationListRequestDto { + init(page: Int) { + self.page = page + self.limit = 5 + } +} diff --git a/Koin/Data/Repository/DefaultRecruitRepository.swift b/Koin/Data/Repository/DefaultRecruitRepository.swift index ce7b44939..b8577bc3e 100644 --- a/Koin/Data/Repository/DefaultRecruitRepository.swift +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -10,8 +10,6 @@ import Foundation final class DefaultRecruitRepository: RecruitRepository { private let service: RecruitService - - private let mockRepository = MockRecruitRepository() // TODO: API 연결 후 mock 위임 제거 init(service: RecruitService) { self.service = service @@ -129,27 +127,25 @@ final class DefaultRecruitRepository: RecruitRepository { let request = RecruitChatPostRequestDto(from: request) try await service.postChatMessage(recruitmentId: recruitmentId, chatRoomId: chatRoomId, request) } -} - -extension DefaultRecruitRepository { // TODO: API 연결 후 mock 위임 제거 - func fetchNotificationList() async throws -> RecruitNotificationList { - try await mockRepository.fetchNotificationList() + func fetchNotificationList(page: Int) async throws -> RecruitNotificationList { + let request = RecruitNotificationListRequestDto(page: page) + return try await service.fetchNotificationList(request).toDomain() } func deleteNotification(_ id: Int) async throws -> Void { - try await mockRepository.deleteNotification(id) + try await service.deleteNotification(id) } func deleteAllNotification() async throws -> Void { - try await mockRepository.deleteAllNotification() + try await service.deleteAllNotification() } func markAsReadNotification(_ id: Int) async throws -> Void { - try await mockRepository.markAsReadNotification(id) + try await service.markAsReadNotification(id) } func markAllAsReadNotification() async throws -> Void { - try await mockRepository.markAllAsReadNotification() + try await service.markAllAsReadNotification() } } diff --git a/Koin/Data/Repository/MockRecruitRepository.swift b/Koin/Data/Repository/MockRecruitRepository.swift deleted file mode 100644 index 0b525c100..000000000 --- a/Koin/Data/Repository/MockRecruitRepository.swift +++ /dev/null @@ -1,626 +0,0 @@ -// -// MockRecruitRepository.swift -// koin -// -// Created by 홍기정 on 8/29/26. -// - -import Foundation - -final class MockRecruitRepository: RecruitRepository { - private var postedChatMessages: [RecruitChatMessage] = [] - - func fetchTeamChatData( - recruitmentId: Int, - chatRoomId: Int - ) async throws -> RecruitChatData { - try await Task.sleep(nanoseconds: 300_000_000) - - return RecruitChatData( - chatRoomId: chatRoomId, - chatRoomName: "AI 아이디어 공모전 팀원 모집", - chatRoomType: .team, - currentMemberCount: 3, - maximumMemberCount: 6 - ) - } - - func fetchDirectChatData( - recruitmentId: Int, - applicationId: Int - ) async throws -> RecruitChatData { - try await Task.sleep(nanoseconds: 300_000_000) - - return RecruitChatData( - chatRoomId: applicationId + 20, - chatRoomName: "김철수", - chatRoomType: .direct, - currentMemberCount: nil, - maximumMemberCount: nil - ) - } - - func fetchChatMessages( - recruitmentId: Int, - chatRoomId: Int - ) async throws -> RecruitChatMessageList { - try await Task.sleep(nanoseconds: 300_000_000) - - let messages = [ - RecruitChatMessage( - messageId: 901, - userId: 22, - userNickname: "김철수", - content: "안녕하세요!", - timestamp: Date().addingTimeInterval(-120), - isImage: false, - unreadCount: 2, - isMine: false, - showProfile: true, - profileImage: .callVanProfile0 - ), - RecruitChatMessage( - messageId: 902, - userId: 23, - userNickname: "이영희", - content: "반갑습니다.", - timestamp: Date().addingTimeInterval(-60), - isImage: false, - unreadCount: 1, - isMine: true, - showProfile: true, - profileImage: .callVanProfile1 - ), - RecruitChatMessage( - messageId: 903, - userId: 22, - userNickname: "김철수", - content: "https://placehold.co/600x400/000000/FFFFFF/png", - timestamp: Date().addingTimeInterval(-30), - isImage: true, - unreadCount: 0, - isMine: false, - showProfile: true, - profileImage: .callVanProfile0 - ) - ] + postedChatMessages - - return RecruitChatMessageList( - dates: [Date().formatDateToYYYY년M월D일()], - messages: [Array(messages.reversed())] - ) - } - - func postChatMessage( - recruitmentId: Int, - chatRoomId: Int, - request: RecruitChatPostRequest - ) async throws { - try await Task.sleep(nanoseconds: 300_000_000) - - postedChatMessages.append( - RecruitChatMessage( - messageId: 904 + postedChatMessages.count, - userId: 1, - userNickname: "나", - content: request.content, - timestamp: Date(), - isImage: request.isImage, - unreadCount: 0, - isMine: true, - showProfile: true, - profileImage: .callVanProfile2 - ) - ) - } - - func apply(recruitmentId: Int, _ request: RecruitApplyRequest) async throws -> Void { - try await Task.sleep(nanoseconds: 300_000_000) - } - - func upsertMyProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile { - try await Task.sleep(nanoseconds: 300_000_000) - - return RecruitProfile( - nickname: "홍길동", - department: "컴퓨터공학부", - studentNumber: "2023100000", - preferredRole: request.preferredRole ?? "", - skills: request.skills, - activities: request.activities.enumerated().map { index, activity in - RecruitProfileActivity( - id: index + 1, - title: activity.title ?? "", - startedAt: activity.startedAt ?? Date(), - endedAt: activity.endedAt, - isOngoing: activity.isOngoing, - description: activity.description ?? "" - ) - }, - selfIntroduction: request.introduction ?? "" - ) - } - - func fetchMyProfile() async throws -> RecruitProfile { - guard UserDataManager.shared.isLoggedIn else { - throw ErrorResponse( - statusCode: 401, - code: "UNAUTHORIZED", - message: "로그인이 필요한 기능입니다." - ) - } - - try await Task.sleep(nanoseconds: 300_000_000) - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy.MM.dd" - - guard let startedAt = dateFormatter.date(from: "2025.03.03"), - let endedAt = dateFormatter.date(from: "2025.05.05") else { - throw ErrorResponse.dateFormatterFailedConvert - } - - return RecruitProfile( - nickname: "홍길동", - department: "컴퓨터공학부", - studentNumber: "2023100000", - preferredRole: "기획", - skills: ["정보처리기사"], - activities: [ - RecruitProfileActivity( - id: 1, - title: "AI 공모전", - startedAt: startedAt, - endedAt: endedAt, - isOngoing: false, - description: "기획 담당" - ) - ], - selfIntroduction: "안녕하세요." - ) - } - - func fetchList(_ filter: RecruitListFilter) async throws -> RecruitList { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy.MM.dd" - - let contest = RecruitRow( - id: 1, - category: .contest, - title: "AI 아이디어 공모전 팀원 모집", - meetingType: .online, - startDate: dateFormatter.date(from: "2026.07.26") ?? Date(), - endDate: dateFormatter.date(from: "2026.08.26") ?? Date(), - deadline: dateFormatter.date(from: "2026.08.26") ?? Date(), - dDay: "D-5", - state: .recruiting, - currentParticipants: 0, - maximumParticipants: 3, - type: .roleBased, - roles: [ - RecruitRole(id: 1, name: "프론트엔드", currentParticipants: 0, maximumParticipants: 1, isClosed: true), - RecruitRole(id: 2, name: "백엔드", currentParticipants: 0, maximumParticipants: 1, isClosed: false), - RecruitRole(id: 3, name: "디자인", currentParticipants: 0, maximumParticipants: 1, isClosed: false) - ] - ) - - let externalActivity = RecruitRow( - id: 2, - category: .externalActivity, - title: "2026 대외활동 팀원 모집", - meetingType: .mixed, - startDate: dateFormatter.date(from: "2026.07.26") ?? Date(), - endDate: dateFormatter.date(from: "2026.08.26") ?? Date(), - deadline: dateFormatter.date(from: "2026.08.26") ?? Date(), - dDay: "D-1", - state: .recruiting, - currentParticipants: 2, - maximumParticipants: 3, - type: .general, - roles: [] - ) - - let closedExternalActivity = RecruitRow( - id: 3, - category: .externalActivity, - title: "2026 대외활동 팀원 모집", - meetingType: .mixed, - startDate: dateFormatter.date(from: "2026.07.26") ?? Date(), - endDate: dateFormatter.date(from: "2026.08.26") ?? Date(), - deadline: dateFormatter.date(from: "2026.08.26") ?? Date(), - dDay: "D-day", - state: .closed, - currentParticipants: 5, - maximumParticipants: 5, - type: .general, - roles: [] - ) - - let studies = (4...7).map { id in - RecruitRow( - id: id, - category: .study, - title: "2026 스터디 팀원 모집", - meetingType: .mixed, - startDate: dateFormatter.date(from: "2026.07.26") ?? Date(), - endDate: dateFormatter.date(from: "2026.08.26") ?? Date(), - deadline: dateFormatter.date(from: "2026.08.26") ?? Date(), - dDay: "D-1", - state: .recruiting, - currentParticipants: 2, - maximumParticipants: 3, - type: .general, - roles: [] - ) - } - - return RecruitList( - recruits: [contest, externalActivity, closedExternalActivity] + studies, - totalCount: 7, - totalPage: 1, - currentPage: 1 - ) - } - - func fetchMyPostList(_ filter: RecruitMyPostFilter) async throws -> RecruitMyPostList { - let list = try await fetchList(RecruitListFilter()) - var recruits = list.recruits.map { summary in - RecruitMyPostRow( - id: summary.id, - category: summary.category, - title: summary.title, - meetingType: summary.meetingType, - startDate: summary.startDate, - endDate: summary.endDate, - deadline: summary.deadline, - dDay: summary.dDay, - currentParticipants: summary.currentParticipants, - maximumParticipants: summary.maximumParticipants, - type: summary.type, - roles: summary.roles, - state: summary.state, - canClose: summary.id != 3, - chatRoomId: summary.id.isMultiple(of: 2) ? summary.id : nil - ) - } - - if filter.state != .all { - recruits = recruits.filter { $0.state == filter.state } - } - if filter.sort == .deadlineAscending { - recruits.sort { ($0.deadline ?? .distantFuture) < ($1.deadline ?? .distantFuture) } - } - - let totalCount = recruits.count - let limit = max(filter.limit ?? totalCount, 1) - let totalPage = max(Int(ceil(Double(totalCount) / Double(limit))), 1) - let currentPage = min(max(filter.page, 1), totalPage) - let startIndex = min((currentPage - 1) * limit, totalCount) - let endIndex = min(startIndex + limit, totalCount) - - return RecruitMyPostList( - recruits: Array(recruits[startIndex.. RecruitMyPostData { - let response = try await fetchMyPostList(RecruitMyPostFilter()) - guard let recruit = response.recruits.first(where: { $0.id == id }) else { - throw ErrorResponse.unexpectedInternalError - } - let applicants = mockApplicants( - recruitId: recruit.id, - includesRole: recruit.type == .roleBased - ) - return RecruitMyPostData( - id: recruit.id, - category: recruit.category, - title: recruit.title, - meetingType: recruit.meetingType, - startDate: recruit.startDate, - endDate: recruit.endDate, - deadline: recruit.deadline, - dDay: recruit.dDay, - currentParticipants: recruit.currentParticipants, - maximumParticipants: recruit.maximumParticipants, - type: recruit.type, - roles: recruit.roles, - state: recruit.state, - chatRoomId: recruit.chatRoomId ?? recruit.id, - applicants: applicants, - totalCount: applicants.count, - totalPage: 1, - currentPage: page - ) - } - - func fetchApplicant( - recruitmentId: Int, - applicationId: Int - ) async throws -> RecruitApplicantData { - try await Task.sleep(nanoseconds: 300_000_000) - - let recruitment = try await fetchMyPostData(recruitmentId, page: 1) - guard let application = recruitment.applicants.first(where: { - $0.applicationId == applicationId - }) else { - throw ErrorResponse.unexpectedInternalError - } - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy.MM.dd" - guard let startedAt = dateFormatter.date(from: "2026.03.23"), - let endedAt = dateFormatter.date(from: "2026.04.06") else { - throw ErrorResponse.dateFormatterFailedConvert - } - - let role = application.role.isEmpty ? nil : application.role - return RecruitApplicantData( - applicationId: application.applicationId, - status: application.status, - profile: RecruitProfile( - nickname: application.nickname, - department: application.department, - studentNumber: String(format: "20%02d000000", application.studentYear), - preferredRole: role ?? "", - skills: ["정보처리기사"], - activities: [ - RecruitProfileActivity( - id: 1, - title: "AI 공모전", - startedAt: startedAt, - endedAt: endedAt, - isOngoing: false, - description: "AI 공모전에서 기획을 담당했고 @@@를 주제로 @@@를 만들었습니다" - ) - ], - selfIntroduction: "안녕하세요." - ), - motivation: "안녕하세요.", - availableTime: "월 수 금 20시 이후", - role: role, - canDecide: application.status == .pending, - canDirectChat: application.status == .accepted && application.canChat - ) - } - - func decideApplicant( - recruitmentId: Int, - applicationId: Int, - decision: RecruitApplicantDecision - ) async throws -> Void { - let recruitment = try await fetchMyPostData(recruitmentId, page: 1) - guard recruitment.applicants.contains(where: { - $0.applicationId == applicationId && $0.status == .pending - }) else { - throw ErrorResponse.unexpectedInternalError - } - try await Task.sleep(nanoseconds: 300_000_000) - } - - private func mockApplicants( - recruitId: Int, - includesRole: Bool - ) -> [RecruitApplicantRow] { - guard recruitId == 1 || recruitId == 2 else { - return [] - } - - return [ - RecruitApplicantRow( - applicationId: recruitId * 100 + 1, - nickname: "김철수", - department: "컴퓨터공학부", - studentYear: 23, - role: includesRole ? "백엔드" : "", - status: .denied, - canChat: false - ), - RecruitApplicantRow( - applicationId: recruitId * 100 + 2, - nickname: "김철수", - department: "컴퓨터공학부", - studentYear: 23, - role: includesRole ? "디자인" : "", - status: .accepted, - canChat: true - ), - RecruitApplicantRow( - applicationId: recruitId * 100 + 3, - nickname: "김철수", - department: "컴퓨터공학부", - studentYear: 23, - role: includesRole ? "프론트엔드" : "", - status: .pending, - canChat: false - ) - ] - } - - func closeMyPost(id: Int) async throws -> Bool { - true - } - - func fetchMyApplicationList( - _ filter: RecruitMyApplicationFilter - ) async throws -> RecruitMyApplicationList { - let list = try await fetchList(RecruitListFilter()) - let statuses = RecruitApplicationStatus.allCases - var recruits = list.recruits.enumerated().map { index, summary in - RecruitMyApplicationRow( - id: summary.id, - category: summary.category, - title: summary.title, - meetingType: summary.meetingType, - startDate: summary.startDate, - endDate: summary.endDate, - deadline: summary.deadline, - dDay: summary.dDay, - state: summary.state, - currentParticipants: summary.currentParticipants, - maximumParticipants: summary.maximumParticipants, - type: summary.type, - roles: summary.roles, - application: RecruitMyApplication( - id: summary.id * 100 + index, - status: statuses[index % statuses.count] - ), - chatRoomId: summary.id.isMultiple(of: 2) ? summary.id : nil - ) - } - - if let status = filter.status { - recruits = recruits.filter { $0.application.status == status } - } - if filter.sort == .deadlineAscending { - recruits.sort { ($0.deadline ?? .distantFuture) < ($1.deadline ?? .distantFuture) } - } - - let totalCount = recruits.count - let limit = max(filter.limit ?? totalCount, 1) - let totalPage = max(Int(ceil(Double(totalCount) / Double(limit))), 1) - let currentPage = min(max(filter.page, 1), totalPage) - let startIndex = min((currentPage - 1) * limit, totalCount) - let endIndex = min(startIndex + limit, totalCount) - - return RecruitMyApplicationList( - recruits: Array(recruits[startIndex.. RecruitData { - try await Task.sleep(nanoseconds: 300_000_000) - let list = try await fetchList(RecruitListFilter()) - guard let item = list.recruits.first(where: { $0.id == id }) else { - throw NSError(domain: "MockRecruitRepository", code: 404) - } - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy.MM.dd" - return RecruitData( - id: item.id, - category: item.category, - dDay: item.dDay, - state: item.state, - title: item.title, - meetingType: item.meetingType, - startDate: item.startDate, - endDate: item.endDate, - deadlineDate: item.deadline, - currentParticipants: item.currentParticipants, - maximumParticipants: item.maximumParticipants, - createdAt: dateFormatter.date(from: "2026.07.26") ?? Date(), - author: "@@@", - type: item.type, - roles: item.roles, - description: "소개소개소개소개소개소개소개소개소개소개소개소개소개소개", - relatedUrl: URL(string: "https://bcsdlab.com"), - qualification: "2학년이상\n참여율 높은 사람\n@@@", - application: nil, - isAuthor: UserDataManager.shared.isLoggedIn && item.id % 2 == 0, - canApply: true, - applyBlockReason: nil, - canManageApplicants: false, - teamChatAvailable: false, - teamChatRoomId: nil - ) - } - - func post(_ request: RecruitPostRequest) async throws -> Int { - return 1 - } - - func modify(_ id: Int, _ request: RecruitPostRequest) async throws -> Void { - return - } - - func fetchNotificationList() async throws -> RecruitNotificationList { - return RecruitNotificationList(notifications: [ - RecruitNotificationRow( - id: 1, - recruitmentId: 101, - chatRoomId: 1, - roomType: .team, - applicationId: nil, - type: .chat, - title: "팀원모집 @@님의 메시지", - content: "메세지메세지", - dateText: "2시간 전", - isRead: false - ), - RecruitNotificationRow( - id: 2, - recruitmentId: 0, - chatRoomId: 0, - roomType: .team, - applicationId: nil, - type: .default, - title: "팀원 모집 지원 승인", - content: "지원했던 AI 공모전 팀원 모집에 승인되었어요.", - dateText: "2시간 전", - isRead: false - ), - RecruitNotificationRow( - id: 3, - recruitmentId: 0, - chatRoomId: 0, - roomType: .team, - applicationId: nil, - type: .default, - title: "팀원 모집 지원 거절", - content: "지원했던 AI 공모전 팀원 모집에 승인 거절되었어요.\n다른 모집글에 지원해보세요.", - dateText: "2시간 전", - isRead: false - ), - RecruitNotificationRow( - id: 4, - recruitmentId: 0, - chatRoomId: 0, - roomType: .team, - applicationId: nil, - type: .default, - title: "팀원 모집글 삭제", - content: "지원했던 AI 공모전 팀원 모집글이 삭제되었어요.\n다른 모집글에 지원해보세요.", - dateText: "2시간 전", - isRead: false - ), - RecruitNotificationRow( - id: 5, - recruitmentId: 0, - chatRoomId: 0, - roomType: .team, - applicationId: nil, - type: .default, - title: "팀원 모집기간 종료", - content: "작성했던 AI 공모전 팀원 모집 기간이 종료되었어요.", - dateText: "2시간 전", - isRead: false - ) - ]) - } - - func deleteData(id: Int) async throws -> Bool { - true - } - - func deleteNotification(_ id: Int) async throws -> Void { - - } - - func deleteAllNotification() async throws -> Void { - - } - - func markAsReadNotification(_ id: Int) async throws -> Void { - - } - - func markAllAsReadNotification() async throws -> Void { - - } -} diff --git a/Koin/Data/Service/Network/API/RecruitAPI.swift b/Koin/Data/Service/Network/API/RecruitAPI.swift index f955e3fc2..c2949f10f 100644 --- a/Koin/Data/Service/Network/API/RecruitAPI.swift +++ b/Koin/Data/Service/Network/API/RecruitAPI.swift @@ -27,6 +27,11 @@ enum RecruitAPI { case fetchDirectChatData(recruitmentId: Int, applicationId: Int) case fetchChatMessages(recruitmentId: Int, chatRoomId: Int) case postChatMessage(recruitmentId: Int, chatRoomId: Int, RecruitChatPostRequestDto) + case fetchNotificationList(RecruitNotificationListRequestDto) + case markAsReadNotification(Int) + case markAllAsReadNotification + case deleteNotification(Int) + case deleteAllNotification } extension RecruitAPI: Router, URLRequestConvertible { @@ -54,6 +59,10 @@ extension RecruitAPI: Router, URLRequestConvertible { case .fetchChatData(let recruitmentId, let chatRoomId): return "/chatroom/team-recruitment/\(recruitmentId)/\(chatRoomId)" case .fetchDirectChatData(let recruitmentId, let applicationId): return "/chatroom/team-recruitment/\(recruitmentId)/applications/\(applicationId)/direct" case .fetchChatMessages(let recruitmentId, let chatRoomId), .postChatMessage(let recruitmentId, let chatRoomId, _): return "/chatroom/team-recruitment/\(recruitmentId)/\(chatRoomId)/messages" + case .fetchNotificationList, .deleteAllNotification: return "/team-recruitments/notifications" + case .markAsReadNotification(let id): return "/team-recruitments/notifications/\(id)/read" + case .markAllAsReadNotification: return "/team-recruitments/notifications/mark-all-read" + case .deleteNotification(let id): return "/team-recruitments/notifications/\(id)" } } @@ -77,13 +86,16 @@ extension RecruitAPI: Router, URLRequestConvertible { case .fetchDirectChatData: return .post case .fetchChatMessages: return .get case .postChatMessage: return .post + case .fetchNotificationList: return .get + case .markAsReadNotification, .markAllAsReadNotification: return .post + case .deleteNotification, .deleteAllNotification: return .delete } } public var headers: [String: String] { var baseHeaders: [String: String] = [:] switch self { - case .fetchList, .fetchData, .deleteData, .fetchMyProfile, .fetchMyApplicationList, .fetchMyPostList, .closeMyPost, .fetchMyPostData, .fetchApplicant, .fetchChatData, .fetchDirectChatData, .fetchChatMessages: + case .fetchList, .fetchData, .deleteData, .fetchMyProfile, .fetchMyApplicationList, .fetchMyPostList, .closeMyPost, .fetchMyPostData, .fetchApplicant, .fetchChatData, .fetchDirectChatData, .fetchChatMessages, .fetchNotificationList, .markAsReadNotification, .markAllAsReadNotification, .deleteNotification, .deleteAllNotification: break case .post, .modify, .upsertMyProfile, .apply, .decideApplicant, .postChatMessage: baseHeaders["Content-Type"] = "application/json" @@ -113,6 +125,10 @@ extension RecruitAPI: Router, URLRequestConvertible { return try? request.toDictionary() case .postChatMessage(_, _, let request): return try? request.toDictionary() + case .fetchNotificationList(let request): + return try? request.toDictionary() + case .markAsReadNotification, .markAllAsReadNotification, .deleteNotification, .deleteAllNotification: + return nil case .closeMyPost, .fetchApplicant, .fetchChatData, .fetchDirectChatData, .fetchChatMessages: return nil } @@ -120,8 +136,10 @@ extension RecruitAPI: Router, URLRequestConvertible { public var encoding: ParameterEncoding? { switch self { - case .fetchList, .fetchMyApplicationList, .fetchMyPostList, .fetchMyPostData: + case .fetchList, .fetchMyApplicationList, .fetchMyPostList, .fetchMyPostData, .fetchNotificationList: return URLEncoding(arrayEncoding: .noBrackets) + case .markAsReadNotification, .markAllAsReadNotification, .deleteNotification, .deleteAllNotification: + return nil case .closeMyPost, .fetchApplicant, .fetchChatData, .fetchDirectChatData, .fetchChatMessages: return nil case .fetchData, .deleteData, .fetchMyProfile: diff --git a/Koin/Data/Service/RecruitService.swift b/Koin/Data/Service/RecruitService.swift index 2bb6a6b6e..d6a0bd776 100644 --- a/Koin/Data/Service/RecruitService.swift +++ b/Koin/Data/Service/RecruitService.swift @@ -26,6 +26,11 @@ protocol RecruitService { func fetchDirectChatData(recruitmentId: Int, applicationId: Int) async throws -> RecruitChatDataDto func fetchChatMessages(recruitmentId: Int, chatRoomId: Int) async throws -> [RecruitChatMessageDto] func postChatMessage(recruitmentId: Int, chatRoomId: Int, _ request: RecruitChatPostRequestDto) async throws + func fetchNotificationList(_ request: RecruitNotificationListRequestDto) async throws -> RecruitNotificationListDto + func markAsReadNotification(_ id: Int) async throws + func markAllAsReadNotification() async throws + func deleteNotification(_ id: Int) async throws + func deleteAllNotification() async throws } final class DefaultRecruitService: RecruitService { @@ -103,4 +108,24 @@ final class DefaultRecruitService: RecruitService { func postChatMessage(recruitmentId: Int, chatRoomId: Int, _ request: RecruitChatPostRequestDto) async throws { try await networkService.request(api: RecruitAPI.postChatMessage(recruitmentId: recruitmentId, chatRoomId: chatRoomId, request)) } + + func fetchNotificationList(_ request: RecruitNotificationListRequestDto) async throws -> RecruitNotificationListDto { + try await networkService.requestWithResponse(api: RecruitAPI.fetchNotificationList(request)) + } + + func markAsReadNotification(_ id: Int) async throws { + try await networkService.request(api: RecruitAPI.markAsReadNotification(id)) + } + + func markAllAsReadNotification() async throws { + try await networkService.request(api: RecruitAPI.markAllAsReadNotification) + } + + func deleteNotification(_ id: Int) async throws { + try await networkService.request(api: RecruitAPI.deleteNotification(id)) + } + + func deleteAllNotification() async throws { + try await networkService.request(api: RecruitAPI.deleteAllNotification) + } } diff --git a/Koin/Domain/Model/Recruit/RecruitNotificationList.swift b/Koin/Domain/Model/Recruit/RecruitNotificationList.swift index 1654d6ae9..99657a953 100644 --- a/Koin/Domain/Model/Recruit/RecruitNotificationList.swift +++ b/Koin/Domain/Model/Recruit/RecruitNotificationList.swift @@ -9,9 +9,15 @@ import Foundation struct RecruitNotificationList { var notifications: [RecruitNotificationRow] + let totalPage: Int + let currentPage: Int } extension RecruitNotificationList { + var hasNextPage: Bool { + currentPage < totalPage + } + var hasUnread: Bool { !notifications.filter({ !$0.isRead }).isEmpty } diff --git a/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift b/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift index 6feef1fcc..0bc90298f 100644 --- a/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift +++ b/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift @@ -9,21 +9,29 @@ import Foundation struct RecruitNotificationRow { let id: Int + let type: RecruitNotificationType + let targetType: RecruitNotificationTargetType let recruitmentId: Int - let chatRoomId: Int - let roomType: RecruitChatRoomType let applicationId: Int? - - let type: RecruitNotificationType - - let title: String - let content: String - let dateText: String + let chatRoomId: Int? + let senderNickname: String? + let messagePreview: String + let createdAt: Date? var isRead: Bool } enum RecruitNotificationType { - case chat - case newApplicant - case `default` + case newApplication + case applicationAccepted + case applicationRejected + case recruitmentClosed + case recruitmentDeleted + case newChatMessage +} + +enum RecruitNotificationTargetType { + case applicantManagement + case chatRoom + case myApplications + case none } diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift index abda1bea7..126a47398 100644 --- a/Koin/Domain/Repository/RecruitRepository.swift +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -39,7 +39,7 @@ protocol RecruitRepository { ) async throws -> Void func fetchMyApplicationList(_ filter: RecruitMyApplicationFilter) async throws -> RecruitMyApplicationList func closeMyPost(id: Int) async throws -> Bool - func fetchNotificationList() async throws -> RecruitNotificationList + func fetchNotificationList(page: Int) async throws -> RecruitNotificationList func deleteNotification(_ id: Int) async throws -> Void func deleteAllNotification() async throws -> Void func markAsReadNotification(_ id: Int) async throws -> Void diff --git a/Koin/Domain/UseCase/Recruit/RecruitNotificationList/FetchRecruitNotificationListUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitNotificationList/FetchRecruitNotificationListUseCase.swift index 5bce96cde..2efe8b106 100644 --- a/Koin/Domain/UseCase/Recruit/RecruitNotificationList/FetchRecruitNotificationListUseCase.swift +++ b/Koin/Domain/UseCase/Recruit/RecruitNotificationList/FetchRecruitNotificationListUseCase.swift @@ -8,7 +8,7 @@ import Foundation protocol FetchRecruitNotificationListUseCase { - func execute() async throws -> RecruitNotificationList + func execute(page: Int) async throws -> RecruitNotificationList } final class DefaultFetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase { @@ -19,7 +19,7 @@ final class DefaultFetchRecruitNotificationListUseCase: FetchRecruitNotification self.repository = repository } - func execute() async throws -> RecruitNotificationList { - try await repository.fetchNotificationList() + func execute(page: Int) async throws -> RecruitNotificationList { + try await repository.fetchNotificationList(page: page) } } diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift index bb4d1f4bd..67dacd009 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift @@ -163,13 +163,13 @@ extension RecruitListViewModel { private func fetchHasUnreadNotification() { Task { do { - self.hasUnreadNotification = try await fetchRecruitNotificationListUseCase.execute().hasUnread + self.hasUnreadNotification = try await fetchRecruitNotificationListUseCase.execute(page: 1).hasUnread + } catch let error as ErrorResponse where error.statusCode == 401 { + return } catch { - if let error = (error as? ErrorResponse), - error.statusCode != 401 { - self.errorMessage = error.message - } + self.errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription } } } } + diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotificationRow+.swift b/Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotificationRow+.swift index 56fdc2983..fdf002833 100644 --- a/Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotificationRow+.swift +++ b/Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotificationRow+.swift @@ -10,18 +10,42 @@ import Foundation extension RecruitNotificationRow { var icon: ImageAsset { switch type { - case .chat: ImageAsset.recruitNotificationChat + case .newChatMessage: ImageAsset.recruitNotificationChat default: ImageAsset.recruitNotificationMember } } + var title: String { + switch type { + case .newApplication: + return "팀원모집 새로운 지원자" + case .applicationAccepted: + return "팀원모집 지원 승인" + case .applicationRejected: + return "팀원모집 지원 거절" + case .recruitmentClosed: + return "팀원모집 모집 마감" + case .recruitmentDeleted: + return "팀원모집 모집글 삭제" + case .newChatMessage: + return "팀원모집 \(senderNickname ?? "익명")님의 메시지" + } + } + + var dateText: String { + guard let createdAt else { + return "" + } + return NotificationHistoryItem.dateText(for: createdAt) + } + func toNotificationRowModel() -> NotificationRowModel { NotificationRowModel( id: "\(id)", isRead: isRead, icon: icon, title: title, - content: content, + content: messagePreview, dateText: dateText ) } diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift index c56092ca3..7c84dfd79 100644 --- a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift @@ -93,6 +93,12 @@ private extension RecruitNotificationListViewController { self?.inputSubject.send(.reload) } .store(in: &subscriptions) + + notificationListView.reachedBottomPublisher + .sink { [weak self] in + self?.inputSubject.send(.loadNextPage) + } + .store(in: &subscriptions) } } @@ -103,14 +109,64 @@ extension RecruitNotificationListViewController { notificationListView.update( items: notificationList.notifications.map { $0.toNotificationRowModel() - } + }, + hasNextPage: notificationList.hasNextPage ) } private func handleNavigation(id: Int) { guard let notification = notificationList?.notifications.first(where: { $0.id == id }) else { return } - // TODO + switch notification.targetType { + case .chatRoom: + if notification.type == .newChatMessage, let applicationId = notification.applicationId { + navigateToChat(roomSource: .direct(recruitmentId: notification.recruitmentId, applicationId: applicationId)) + } else if let chatRoomId = notification.chatRoomId { + navigateToChat(roomSource: .team(recruitmentId: notification.recruitmentId, chatRoomId: chatRoomId)) + } + case .applicantManagement: + navigateToApplicantManagement(recruitmentId: notification.recruitmentId) + case .myApplications: + navigateToMyApplications() + case .none: + return + } + } + + private func navigateToChat(roomSource: RecruitChatViewModel.RoomSource) { + let repository = DefaultRecruitRepository(service: DefaultRecruitService()) + let viewModel = RecruitChatViewModel( + roomSource: roomSource, + fetchTeamChatDataUseCase: DefaultFetchRecruitTeamChatDataUseCase(repository: repository), + fetchDirectChatDataUseCase: DefaultFetchRecruitDirectChatDataUseCase(repository: repository), + fetchChatMessagesUseCase: DefaultFetchRecruitChatMessagesUseCase(repository: repository), + postChatMessageUseCase: DefaultPostRecruitChatMessageUseCase(repository: repository), + uploadFileUseCase: DefaultUploadFileUseCase(coreRepository: DefaultCoreRepository(service: DefaultCoreService())) + ) + navigationController?.pushViewController(RecruitChatViewController(viewModel: viewModel), animated: true) + } + + private func navigateToApplicantManagement(recruitmentId: Int) { + let repository = DefaultRecruitRepository(service: DefaultRecruitService()) + let viewModel = RecruitMyPostViewModel( + fetchRecruitMyPostDataUseCase: DefaultFetchRecruitMyPostDataUseCase(repository: repository), + recruitId: recruitmentId + ) + let controller = RecruitMyPostHostingController( + rootView: RecruitMyPostView(viewModel: viewModel) + ) + navigationController?.pushViewController(controller, animated: true) + } + + private func navigateToMyApplications() { + let repository = DefaultRecruitRepository(service: DefaultRecruitService()) + let viewModel = RecruitMyApplicationListViewModel( + fetchRecruitMyApplicationListUseCase: DefaultFetchRecruitMyApplicationListUseCase(repository: repository) + ) + let controller = RecruitMyApplicationListHostingController( + rootView: RecruitMyApplicationListView(viewModel: viewModel) + ) + navigationController?.pushViewController(controller, animated: true) } } diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift index ac6f81408..b6ca0ac03 100644 --- a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift @@ -13,6 +13,7 @@ final class RecruitNotificationListViewModel: ViewModelProtocol { enum Input { case viewDidLoad case reload + case loadNextPage case didTapNotification(id: Int) case deleteNotification(id: Int) case deleteAllNotifications @@ -31,6 +32,8 @@ final class RecruitNotificationListViewModel: ViewModelProtocol { private let deleteRecruitNotificationUseCase: DeleteRecruitNotificationUseCase private let outputSubject = PassthroughSubject() private var subscriptions = Set() + private var notificationList: RecruitNotificationList? + private var isLoading = false // MARK: - Initializer init( @@ -50,14 +53,20 @@ final class RecruitNotificationListViewModel: ViewModelProtocol { .sink { [weak self] input in switch input { case .viewDidLoad, .reload: - self?.fetchNotificationList() + self?.fetchNotificationList(page: 1) + case .loadNextPage: + self?.loadNextPage() case .didTapNotification(let id): + self?.notificationList?.markAsRead(id: id) self?.markAsRead(id: id) case .deleteNotification(let id): + self?.notificationList?.delete(id: id) self?.deleteNotification(id: id) case .deleteAllNotifications: + self?.notificationList?.deleteAll() self?.deleteAllNotifications() case .markAllAsRead: + self?.notificationList?.markAllAsRead() self?.markAllAsRead() } } @@ -69,11 +78,34 @@ final class RecruitNotificationListViewModel: ViewModelProtocol { private extension RecruitNotificationListViewModel { - private func fetchNotificationList() { - Task { + private func loadNextPage() { + guard let notificationList, notificationList.hasNextPage else { + return + } + fetchNotificationList(page: notificationList.currentPage + 1) + } + + private func fetchNotificationList(page: Int) { + guard !isLoading else { + return + } + isLoading = true + Task { @MainActor in + defer { + isLoading = false + } do { - let notificationList = try await fetchRecruitNotificationListUseCase.execute() - outputSubject.send(.updateNotifications(notificationList)) + var response = try await fetchRecruitNotificationListUseCase.execute(page: page) + guard response.currentPage == page else { + return + } + if page > 1 { + let loadedIds = Set(notificationList?.notifications.map(\.id) ?? []) + response.notifications = (notificationList?.notifications ?? []) + + response.notifications.filter { !loadedIds.contains($0.id) } + } + notificationList = response + outputSubject.send(.updateNotifications(response)) } catch { if let message = (error as? ErrorResponse)?.message { outputSubject.send(.showToast(message)) diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index c4855910e..e1a50a675 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -179,7 +179,6 @@ 7C50A1FD3042E9AB00897A6F /* FetchRecruitListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1FC3042E9AB00897A6F /* FetchRecruitListUseCase.swift */; }; 7C50A1FF3042E9DC00897A6F /* RecruitList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A1FE3042E9DC00897A6F /* RecruitList.swift */; }; 7C50A2033042EF2C00897A6F /* RecruitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A2023042EF2C00897A6F /* RecruitRepository.swift */; }; - 7C50A2053042EF4F00897A6F /* MockRecruitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A2043042EF4F00897A6F /* MockRecruitRepository.swift */; }; 7C50A20930431F9900897A6F /* Array+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20830431F9900897A6F /* Array+.swift */; }; 7C50A20B304324F500897A6F /* RecruitNotificationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20A304324F500897A6F /* RecruitNotificationRow.swift */; }; 7C50A20D3043273A00897A6F /* RecruitNotificationList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */; }; @@ -846,6 +845,7 @@ 9059A862F4E9CDA304B2F082 /* RecruitEnumDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E31EA58151824C938383893 /* RecruitEnumDto.swift */; }; 91E82A6B67BF5BF911535B1C /* RecruitMyPostData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87039C64B7CB2AEB632113C6 /* RecruitMyPostData.swift */; }; 9419498912D565DE98568476 /* RecruitPostResultDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267C5F2D082AC60FDA16F85C /* RecruitPostResultDto.swift */; }; + 96488857FC811975AC91DC0C /* RecruitNotificationListRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310B3D108D7FB1767D7C3498 /* RecruitNotificationListRequestDto.swift */; }; 98672455503C7463C3E5BA15 /* FetchBasicInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2525B9C0134B21C89671CB /* FetchBasicInfoUseCase.swift */; }; 999FC904C4F9ED4E2B829341 /* RecruitApplyBlockReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB6A15BFD7B896A787835F8E /* RecruitApplyBlockReason.swift */; }; 9C95E190C98F440DADB6F91B /* RecruitDataMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DADCC36CEEA42EC86AB75B6 /* RecruitDataMetadataView.swift */; }; @@ -1199,6 +1199,7 @@ E1A70C012D9A4F1100AGSEL1 /* AgreementSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A70C002D9A4F1100AGSEL0 /* AgreementSelection.swift */; }; E3DB42C328CC0291FC430D89 /* RecruitApplicantDataDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC1A22BED5A1C53E5ED9903 /* RecruitApplicantDataDto.swift */; }; E5DD3F6BE6C501B7B3BD659C /* RecruitPostRoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F670021A95E0AA103E8F7BED /* RecruitPostRoleView.swift */; }; + E7EB12C06B3F32702DE1BDDB /* RecruitNotificationListDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70B18A7091B73080A7AEDDDE /* RecruitNotificationListDto.swift */; }; F0F216EDAA69B52CBEC0E606 /* RecruitPostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 288BC1E04044DC3FB48524FC /* RecruitPostViewModel.swift */; }; F1A000010000000000000001 /* ZoomingCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A000010000000000000002 /* ZoomingCollectionView.swift */; }; F1A000010000000000000003 /* ZoomingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A000010000000000000004 /* ZoomingCollectionViewCell.swift */; }; @@ -1253,6 +1254,7 @@ 2DEABD714DCF0FD2D5DBACD9 /* RecruitProfilePostDepartmentDropdownView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitProfilePostDepartmentDropdownView.swift; sourceTree = ""; }; 2E31EA58151824C938383893 /* RecruitEnumDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitEnumDto.swift; sourceTree = ""; }; 2FE562D9FEDE6181748BE67B /* RecruitPostRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitPostRequestDto.swift; sourceTree = ""; }; + 310B3D108D7FB1767D7C3498 /* RecruitNotificationListRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitNotificationListRequestDto.swift; sourceTree = ""; }; 3143194BD51D3A85C2A959EB /* RecruitMyApplicationListRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListRequestDto.swift; sourceTree = ""; }; 3C3694E3ACBE05B8C7F15357 /* RecruitPostSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostSectionHeaderView.swift; sourceTree = ""; }; 4067CAF18D134F99A04BBA0D /* RecruitListRequestDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitListRequestDto.swift; sourceTree = ""; }; @@ -1264,6 +1266,7 @@ 5CBC0EF8E0FE44F48744BEC7 /* RecruitDataHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataHeaderView.swift; sourceTree = ""; }; 645CEE62E516B130B4E2D784 /* RecruitMyApplication.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyApplication.swift; sourceTree = ""; }; 673826738C364FB68ECE18FE /* RecruitDataContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitDataContentView.swift; sourceTree = ""; }; + 70B18A7091B73080A7AEDDDE /* RecruitNotificationListDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitNotificationListDto.swift; sourceTree = ""; }; 73F70BC5F3AA1A34663C32CA /* RecruitPostMeetingTypeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitPostMeetingTypeView.swift; sourceTree = ""; }; 756A97197501167BE3516430 /* RecruitMyApplicationListDto.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecruitMyApplicationListDto.swift; sourceTree = ""; }; 7C01653F300A88940013DD7B /* ShuttleTicketViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShuttleTicketViewController.swift; sourceTree = ""; }; @@ -1414,7 +1417,6 @@ 7C50A1FC3042E9AB00897A6F /* FetchRecruitListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitListUseCase.swift; sourceTree = ""; }; 7C50A1FE3042E9DC00897A6F /* RecruitList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitList.swift; sourceTree = ""; }; 7C50A2023042EF2C00897A6F /* RecruitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitRepository.swift; sourceTree = ""; }; - 7C50A2043042EF4F00897A6F /* MockRecruitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRecruitRepository.swift; sourceTree = ""; }; 7C50A20830431F9900897A6F /* Array+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+.swift"; sourceTree = ""; }; 7C50A20A304324F500897A6F /* RecruitNotificationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotificationRow.swift; sourceTree = ""; }; 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitNotificationList.swift; sourceTree = ""; }; @@ -2508,6 +2510,7 @@ 1FC1A22BED5A1C53E5ED9903 /* RecruitApplicantDataDto.swift */, C398DC463A148F3E7ADB7ADE /* RecruitChatDataDto.swift */, B7C07F15438785121E093249 /* RecruitChatMessageDto.swift */, + 70B18A7091B73080A7AEDDDE /* RecruitNotificationListDto.swift */, ); path = Recruit; sourceTree = ""; @@ -5610,6 +5613,7 @@ 827BD7E96347F476DB1BD966 /* RecruitApplicantListRequestDto.swift */, 4D7BF08E4E28887C9FB48DA1 /* RecruitApplicantDecisionRequestDto.swift */, 135CA058C5A41FBC30F6DD2F /* RecruitChatPostRequestDto.swift */, + 310B3D108D7FB1767D7C3498 /* RecruitNotificationListRequestDto.swift */, ); path = Recruit; sourceTree = ""; @@ -6421,7 +6425,6 @@ 7CA60D642F6619B7007C08C1 /* DefaultCallVanRepository.swift */, 7CA686792FFCB758007937E9 /* DefaultNotificationHistoryRepository.swift */, 7C640401300F39BB00A9589F /* DefaultDepartmentRepository.swift */, - 7C50A2043042EF4F00897A6F /* MockRecruitRepository.swift */, ED038AB5420C8C565688CF6B /* DefaultRecruitRepository.swift */, ); path = Repository; @@ -6917,7 +6920,6 @@ 7CC42B392F58284100940CE1 /* CallVanNotificationTableView.swift in Sources */, 7CC42B332F58247D00940CE1 /* CallVanNotification.swift in Sources */, B46B8CC22E76CB7300A8E797 /* ShopSummaryDto.swift in Sources */, - 7C50A2053042EF4F00897A6F /* MockRecruitRepository.swift in Sources */, D2D462662D63B98F00C60864 /* LostItemFetchChatRoomUseCase.swift in Sources */, 7CDCD0DD3055EFA9004204DA /* RecruitProfilePostViewModel.swift in Sources */, 7CA0D6232EC74ADE00E9A282 /* ZoomedImageCollectionViewCell.swift in Sources */, @@ -7933,6 +7935,8 @@ 0D3DC4E723268AE45A326D07 /* RecruitChatDataDto.swift in Sources */, D6728313D8B09F12DCB47D44 /* RecruitChatMessageDto.swift in Sources */, CC6BE28EE54649DEEBF56B9B /* NotificationFooterView.swift in Sources */, + 96488857FC811975AC91DC0C /* RecruitNotificationListRequestDto.swift in Sources */, + E7EB12C06B3F32702DE1BDDB /* RecruitNotificationListDto.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 854c41577117ec60598b3ba2dbbd624be4464666 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 5 Oct 2026 02:53:38 +0900 Subject: [PATCH 083/123] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Data/DTOs/Decodable/Recruit/RecruitChatMessageDto.swift | 3 ++- .../Encodable/Recruit/RecruitNotificationListRequestDto.swift | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitChatMessageDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitChatMessageDto.swift index 4a2be2211..789271e1b 100644 --- a/Koin/Data/DTOs/Decodable/Recruit/RecruitChatMessageDto.swift +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitChatMessageDto.swift @@ -34,7 +34,8 @@ extension Array where Element == RecruitChatMessageDto { ] let timestampFormatter = DateFormatter().then { $0.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" - $0.locale = Locale(identifier: "ko_KR") + $0.locale = Locale(identifier: "en_US_POSIX") + $0.timeZone = TimeZone(identifier: "Asia/Seoul") $0.calendar = Calendar(identifier: .gregorian) } diff --git a/Koin/Data/DTOs/Encodable/Recruit/RecruitNotificationListRequestDto.swift b/Koin/Data/DTOs/Encodable/Recruit/RecruitNotificationListRequestDto.swift index 210dc02c1..802ff9f2f 100644 --- a/Koin/Data/DTOs/Encodable/Recruit/RecruitNotificationListRequestDto.swift +++ b/Koin/Data/DTOs/Encodable/Recruit/RecruitNotificationListRequestDto.swift @@ -15,6 +15,6 @@ struct RecruitNotificationListRequestDto: Encodable { extension RecruitNotificationListRequestDto { init(page: Int) { self.page = page - self.limit = 5 + self.limit = 50 } } From 34aa810eacf10e751934e071ad281f7b76e12cf7 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 5 Oct 2026 16:48:00 +0900 Subject: [PATCH 084/123] =?UTF-8?q?chore:=20=EC=82=AC=EC=86=8C=ED=95=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitMyPostApplicantView.swift | 4 ++-- .../RecruitMyPostApplicantViewModel.swift | 16 ++++------------ 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift index 5fab17762..371d8f51b 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift @@ -37,8 +37,8 @@ struct RecruitMyPostApplicantView: ActionBindableView { .onFirstAppear { viewModel.execute(.onFirstAppear) } - .onChange(of: viewModel.errorMessage) { - guard let message = viewModel.errorMessage else { + .onChange(of: viewModel.toastMessage) { + guard let message = viewModel.toastMessage else { return } sendAction(.showToast(message: message)) diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift index ae21c0461..3ee215fe4 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift @@ -21,7 +21,7 @@ final class RecruitMyPostApplicantViewModel: SwiftUIViewModelProtocol { // MARK: - State private(set) var application: RecruitApplicantData? private(set) var isLoading = false - private(set) var errorMessage: String? + private(set) var toastMessage: String? // MARK: - Properties let recruitmentId: Int @@ -50,7 +50,7 @@ final class RecruitMyPostApplicantViewModel: SwiftUIViewModelProtocol { case .decide(let decision): decideApplication(decision) case .didShowToast: - errorMessage = nil + toastMessage = nil } } } @@ -74,11 +74,7 @@ extension RecruitMyPostApplicantViewModel { ) self.application = application } catch { - if let error = error as? ErrorResponse { - errorMessage = error.message - } else { - errorMessage = error.localizedDescription - } + toastMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription } } } @@ -103,11 +99,7 @@ extension RecruitMyPostApplicantViewModel { self.application?.decided(as: decision) toastMessage = "지원자를 \(decision.rawValue)했어요." } catch { - if let error = error as? ErrorResponse { - errorMessage = error.message - } else { - errorMessage = error.localizedDescription - } + toastMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription } } } From af4c78a43ff5ec77b5204ce8bd55ba9cbacc2f4f Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 5 Oct 2026 17:56:43 +0900 Subject: [PATCH 085/123] =?UTF-8?q?fix:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EB=B3=80=EA=B2=BD=EC=8B=9C=20=EC=95=A1=EC=85=98=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 필터 변경시 최상단으로 스크롤 상태에 초기화되도록 합니다. --- .../Recruit/RecruitList/RecruitListView.swift | 87 +++++++++++-------- .../RecruitList/RecruitListViewModel.swift | 18 ++-- .../Subviews/RecruitListHeaderView.swift | 10 ++- model | 0 4 files changed, 68 insertions(+), 47 deletions(-) create mode 100644 model diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift index 38081b3ee..84c2e8375 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -52,49 +52,60 @@ struct RecruitListView: ActionBindableView { ) .padding(EdgeInsets(top: 20, leading: 0, bottom: 12, trailing: 0)) - ScrollView { - Text("전체(\(viewModel.recruitList?.totalCount ?? 0))") - .font(.appFont(.pretendardRegular, size: 12)) - .foregroundStyle(Color.appColor(.neutral500)) - .frame(height: 19) - .padding(EdgeInsets(top: 0, leading: 4, bottom: 12, trailing: 0)) - .frame(maxWidth: .infinity, alignment: .leading) - - LazyVStack(spacing: 8) { - ForEach(recruits) { recruit in - Button { - sendAction(.showRecruitData(id: recruit.id)) - } label: { - RecruitListRowView(model: recruit) - } - .frame(maxWidth: .infinity) - .buttonStyle(.plain) - .onAppear { - if recruits.last?.id == recruit.id, - viewModel.hasNextPage, - !viewModel.isLoading { - viewModel.execute(.loadNextPage) + ScrollViewReader { proxy in + ScrollView { + Text("전체(\(viewModel.recruitList?.totalCount ?? 0))") + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral500)) + .frame(height: 19) + .padding(EdgeInsets(top: 0, leading: 4, bottom: 12, trailing: 0)) + .frame(maxWidth: .infinity, alignment: .leading) + .id("TOP") + + LazyVStack(spacing: 8) { + ForEach(recruits) { recruit in + Button { + sendAction(.showRecruitData(id: recruit.id)) + } label: { + RecruitListRowView(model: recruit) + } + .frame(maxWidth: .infinity) + .buttonStyle(.plain) + .onAppear { + if recruits.last?.id == recruit.id, + viewModel.hasNextPage, + !viewModel.isLoading { + viewModel.execute(.loadNextPage) + } } } } + .frame(maxWidth: .infinity) + + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 24 + 43) + .padding(.top, 12) + .isHidden(!(viewModel.hasNextPage && viewModel.isLoading), shouldOccupySpace: true) + } + .padding(.horizontal, 22) + .background { + RecruitListEmptyView() + .isHidden(!viewModel.isEmpty || viewModel.isLoading) + } + .refreshable { + viewModel.execute(.refresh) + } + .scrollIndicators(.hidden) + .onChange(of: viewModel.shouldScrollToTop) { + if viewModel.shouldScrollToTop { + Task { + try? await Task.sleep(for: .milliseconds(200)) + proxy.scrollTo("TOP", anchor: .top) + } + } } - .frame(maxWidth: .infinity) - - ProgressView() - .frame(maxWidth: .infinity, alignment: .center) - .frame(height: 24 + 43) - .padding(.top, 12) - .isHidden(!(viewModel.hasNextPage && viewModel.isLoading), shouldOccupySpace: true) - } - .padding(.horizontal, 22) - .background { - RecruitListEmptyView() - .isHidden(!viewModel.isEmpty || viewModel.isLoading) - } - .refreshable { - viewModel.execute(.refresh) } - .scrollIndicators(.hidden) } .background(Color.appColor(.newBackground)) .overlay(alignment: .bottomTrailing) { diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift index 67dacd009..1c8bffb4a 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift @@ -29,6 +29,7 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { private(set) var isLoading: Bool = false private(set) var hasUnreadNotification: Bool = false private(set) var errorMessage: String? = nil + private(set) var shouldScrollToTop: Bool = false private var fetchListTask: Task? var isEmpty: Bool { @@ -94,7 +95,7 @@ extension RecruitListViewModel { ) { defer { isLoading = false - fetchList() + fetchList(shouldScrollToTop: true) } if let keyword { self.filterState.keyword = keyword @@ -108,7 +109,7 @@ extension RecruitListViewModel { private func deleteFilter(_ rawValue: String) { defer { isLoading = false - fetchList() + fetchList(shouldScrollToTop: true) } filterState.remove(item: rawValue) } @@ -120,7 +121,7 @@ extension RecruitListViewModel { private func resetFilter() { defer { isLoading = false - fetchList() + fetchList(shouldScrollToTop: true) } let keyword = filterState.keyword filterState = .init(keyword: keyword) @@ -128,7 +129,10 @@ extension RecruitListViewModel { } extension RecruitListViewModel { - private func fetchList(page: Int = 1) { + private func fetchList( + page: Int = 1, + shouldScrollToTop: Bool = false + ) { guard !isLoading else { return } @@ -136,6 +140,8 @@ extension RecruitListViewModel { fetchListTask = Task { do { isLoading = true + self.shouldScrollToTop = false + defer { isLoading = false } @@ -144,16 +150,16 @@ extension RecruitListViewModel { filterState.page = page var response = try await fetchRecruitListUseCase.execute(filter: filterState) - guard response.currentPage == page else { + guard !Task.isCancelled, response.currentPage == page else { filterState.page = cachedPage return } - if 1 < page { response.recruits = (recruitList?.recruits ?? []) + response.recruits response.recruits.removeDuplicates() } self.recruitList = response + self.shouldScrollToTop = shouldScrollToTop } catch { errorMessage = (error as? ErrorResponse)?.message } diff --git a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListHeaderView.swift b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListHeaderView.swift index 4fd9edecd..f13042096 100644 --- a/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListHeaderView.swift +++ b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListHeaderView.swift @@ -64,9 +64,13 @@ struct RecruitListHeaderView: View { .padding(.horizontal, 22) } .frame(height: 30) - .transition(.asymmetric( - insertion: .push(from: .top).combined(with: .opacity), - removal: .push(from: .bottom).combined(with: .opacity))) + .transition( + .asymmetric( + insertion: .push(from: .top).combined(with: .opacity), + removal: .push(from: .bottom).combined(with: .opacity) + ) + .animation(.easeInOut(duration: 0.2)) + ) } } .animation(.spring(duration: 0.2), value: filterState.nonDefaultItems) diff --git a/model b/model new file mode 100644 index 000000000..e69de29bb From 01f7687c831bad417bfcb4ddda4a0b89e7999b74 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 5 Oct 2026 19:23:59 +0900 Subject: [PATCH 086/123] =?UTF-8?q?feat:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=ED=91=B8=EC=8B=9C=EC=95=8C=EB=A6=BC=20=ED=83=AD=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Apps/SceneDelegate.swift | 26 ++++++++++++++++++- .../Views/NotificationListView.swift | 8 ++++++ .../NotificationTableView.swift | 11 ++++++++ .../Recruit/RecruitNotificationRow.swift | 14 +++++----- ...ecruitNotificationListViewController.swift | 19 ++++++++++++-- 5 files changed, 68 insertions(+), 10 deletions(-) diff --git a/Koin/Apps/SceneDelegate.swift b/Koin/Apps/SceneDelegate.swift index 6d1c01f91..e81df8824 100644 --- a/Koin/Apps/SceneDelegate.swift +++ b/Koin/Apps/SceneDelegate.swift @@ -115,7 +115,18 @@ extension SceneDelegate { return } handledMessageIds.append(messageId) - + + // 팀원모집 알림은 aps.category / schemeUri 없이 type, notification_id 로 온다. + if let type = userInfo["type"] as? String, + RecruitNotificationType(rawValue: type) != nil { + guard let notificationId = Int(userInfo["notification_id"] as? String) else { + return + } + let recruitNotificationListViewController = makeRecruitNotificationListViewController(notificationId: notificationId) + navigationController?.pushViewController(recruitNotificationListViewController, animated: true) + return + } + guard let aps = userInfo["aps"] as? [String: AnyObject], let category = aps["category"] as? String, let appPath = AppPath(rawValue: category), @@ -337,6 +348,19 @@ extension SceneDelegate { navigationController?.pushViewController(diningViewController, animated: true) } + private func makeRecruitNotificationListViewController(notificationId: Int) -> RecruitNotificationListViewController { + let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) + let fetchRecruitNotificationListUseCase = DefaultFetchRecruitNotificationListUseCase(repository: recruitRepository) + let markAsReadRecruitNotificationUseCase = DefaultMarkAsReadRecruitNotificationUseCase(repository: recruitRepository) + let deleteRecruitNotificationUseCase = DefaultDeleteRecruitNotificationUseCase(repository: recruitRepository) + let viewModel = RecruitNotificationListViewModel( + fetchRecruitNotificationListUseCase: fetchRecruitNotificationListUseCase, + markAsReadRecruitNotificationUseCase: markAsReadRecruitNotificationUseCase, + deleteRecruitNotificationUseCase: deleteRecruitNotificationUseCase + ) + return RecruitNotificationListViewController(viewModel: viewModel, notificationId: notificationId) + } + private func makeCallVanDataViewController(postId: Int) -> CallVanDataViewController { let callVanRepository = DefaultCallVanRepository(service: DefaultCallVanService()) let fetchCallVanDataUseCase = DefaultFetchCallVanDataUseCase(repository: callVanRepository) diff --git a/Koin/Core/View/UIKit/Notification/Views/NotificationListView.swift b/Koin/Core/View/UIKit/Notification/Views/NotificationListView.swift index 6f562700e..f3692753b 100644 --- a/Koin/Core/View/UIKit/Notification/Views/NotificationListView.swift +++ b/Koin/Core/View/UIKit/Notification/Views/NotificationListView.swift @@ -67,6 +67,14 @@ final class NotificationListView: UIView { tableView.deleteAll() updateStateViews(isEmpty: true) } + + func indexPath(forNotificationId id: String) -> IndexPath? { + tableView.indexPath(forNotificationId: id) + } + + func tapRow(at indexPath: IndexPath) { + tableView.tapRow(at: indexPath) + } } // MARK: - Bind diff --git a/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableView.swift b/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableView.swift index 3b7bf4bcc..644eee690 100644 --- a/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableView.swift +++ b/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableView.swift @@ -103,6 +103,17 @@ final class NotificationTableView: UITableView { notifications.removeAll() reloadSections([0], with: .fade) } + + func indexPath(forNotificationId id: String) -> IndexPath? { + guard let index = notifications.firstIndex(where: { $0.id == id }) else { + return nil + } + return IndexPath(row: index, section: 0) + } + + func tapRow(at indexPath: IndexPath) { + tableView(self, didSelectRowAt: indexPath) + } } extension NotificationTableView { diff --git a/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift b/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift index 0bc90298f..a43bb7be2 100644 --- a/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift +++ b/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift @@ -20,13 +20,13 @@ struct RecruitNotificationRow { var isRead: Bool } -enum RecruitNotificationType { - case newApplication - case applicationAccepted - case applicationRejected - case recruitmentClosed - case recruitmentDeleted - case newChatMessage +enum RecruitNotificationType: String { + case newApplication = "new_application" + case applicationAccepted = "application_accepted" + case applicationRejected = "application_rejected" + case recruitmentClosed = "recruitment_closed" + case recruitmentDeleted = "recruitment_deleted" + case newChatMessage = "new_chat_message" } enum RecruitNotificationTargetType { diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift index 7c84dfd79..8a3d9621d 100644 --- a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift @@ -16,13 +16,15 @@ final class RecruitNotificationListViewController: UIViewController { private let inputSubject = PassthroughSubject() private var subscriptions = Set() private var notificationList: RecruitNotificationList? + private var notificationId: Int? // MARK: - UI Components private let notificationListView = NotificationListView(behavior: .pagination) // MARK: - Initialization - init(viewModel: RecruitNotificationListViewModel) { + init(viewModel: RecruitNotificationListViewModel, notificationId: Int? = nil) { self.viewModel = viewModel + self.notificationId = notificationId super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { @@ -105,13 +107,26 @@ private extension RecruitNotificationListViewController { extension RecruitNotificationListViewController { private func updateNotificationList(_ notificationList: RecruitNotificationList) { self.notificationList = notificationList - + notificationListView.update( items: notificationList.notifications.map { $0.toNotificationRowModel() }, hasNextPage: notificationList.hasNextPage ) + tapNotificationIfNeeded() + } + + /// 푸시알림으로 진입한 경우, 최초 1회 notificationId 와 일치하는 row 를 탭 처리한다. + private func tapNotificationIfNeeded() { + guard let notificationId else { + return + } + self.notificationId = nil + guard let indexPath = notificationListView.indexPath(forNotificationId: String(notificationId)) else { + return + } + notificationListView.tapRow(at: indexPath) } private func handleNavigation(id: Int) { guard let notification = notificationList?.notifications.first(where: { $0.id == id }) else { From f9fa0f4474119760b9a063124b96628c73f587a4 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 5 Oct 2026 20:09:56 +0900 Subject: [PATCH 087/123] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift | 2 +- .../Recruit/RecruitMyPost/RecruitMyPostViewModel.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift index ac5a85701..3edb6be05 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift @@ -108,7 +108,7 @@ extension RecruitMyPostView { } .scrollIndicators(.hidden) .refreshable { - viewModel.execute(.onFirstAppear) + viewModel.execute(.refresh) } } } diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift index b96176400..45bd416fd 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift @@ -16,6 +16,7 @@ final class RecruitMyPostViewModel: SwiftUIViewModelProtocol { case onFirstAppear case loadNextPage case didShowToast + case refresh } // MARK: - State @@ -39,7 +40,7 @@ final class RecruitMyPostViewModel: SwiftUIViewModelProtocol { // MARK: - Public func execute(_ input: Input) { switch input { - case .onFirstAppear: + case .onFirstAppear, .refresh: fetchRecruitMyPost() case .loadNextPage: loadNextPage() From 23180aaa94ec9d23f7da2effc225e9c8d8f46443 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 5 Oct 2026 20:41:17 +0900 Subject: [PATCH 088/123] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Apps/SceneDelegate.swift | 5 +- .../Views/NotificationListView.swift | 4 +- .../NotificationTableView.swift | 4 +- .../Recruit/RecruitNotificationRow.swift | 2 +- ...ecruitNotificationListViewController.swift | 17 ++---- .../RecruitNotificationListViewModel.swift | 60 ++++++++++++++----- 6 files changed, 60 insertions(+), 32 deletions(-) diff --git a/Koin/Apps/SceneDelegate.swift b/Koin/Apps/SceneDelegate.swift index e81df8824..0cfff6045 100644 --- a/Koin/Apps/SceneDelegate.swift +++ b/Koin/Apps/SceneDelegate.swift @@ -356,9 +356,10 @@ extension SceneDelegate { let viewModel = RecruitNotificationListViewModel( fetchRecruitNotificationListUseCase: fetchRecruitNotificationListUseCase, markAsReadRecruitNotificationUseCase: markAsReadRecruitNotificationUseCase, - deleteRecruitNotificationUseCase: deleteRecruitNotificationUseCase + deleteRecruitNotificationUseCase: deleteRecruitNotificationUseCase, + notificationId: notificationId ) - return RecruitNotificationListViewController(viewModel: viewModel, notificationId: notificationId) + return RecruitNotificationListViewController(viewModel: viewModel) } private func makeCallVanDataViewController(postId: Int) -> CallVanDataViewController { diff --git a/Koin/Core/View/UIKit/Notification/Views/NotificationListView.swift b/Koin/Core/View/UIKit/Notification/Views/NotificationListView.swift index f3692753b..ba1b5c472 100644 --- a/Koin/Core/View/UIKit/Notification/Views/NotificationListView.swift +++ b/Koin/Core/View/UIKit/Notification/Views/NotificationListView.swift @@ -68,8 +68,8 @@ final class NotificationListView: UIView { updateStateViews(isEmpty: true) } - func indexPath(forNotificationId id: String) -> IndexPath? { - tableView.indexPath(forNotificationId: id) + func indexPath(for notificationId: Int) -> IndexPath? { + tableView.indexPath(for: notificationId) } func tapRow(at indexPath: IndexPath) { diff --git a/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableView.swift b/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableView.swift index 644eee690..299335ca3 100644 --- a/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableView.swift +++ b/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableView.swift @@ -104,8 +104,8 @@ final class NotificationTableView: UITableView { reloadSections([0], with: .fade) } - func indexPath(forNotificationId id: String) -> IndexPath? { - guard let index = notifications.firstIndex(where: { $0.id == id }) else { + func indexPath(for notificationId: Int) -> IndexPath? { + guard let index = notifications.firstIndex(where: { $0.id == String(notificationId) }) else { return nil } return IndexPath(row: index, section: 0) diff --git a/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift b/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift index a43bb7be2..28c5d4bea 100644 --- a/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift +++ b/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift @@ -7,7 +7,7 @@ import Foundation -struct RecruitNotificationRow { +struct RecruitNotificationRow: Equatable { let id: Int let type: RecruitNotificationType let targetType: RecruitNotificationTargetType diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift index 8a3d9621d..eb6c146f6 100644 --- a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift @@ -16,15 +16,13 @@ final class RecruitNotificationListViewController: UIViewController { private let inputSubject = PassthroughSubject() private var subscriptions = Set() private var notificationList: RecruitNotificationList? - private var notificationId: Int? // MARK: - UI Components private let notificationListView = NotificationListView(behavior: .pagination) // MARK: - Initialization - init(viewModel: RecruitNotificationListViewModel, notificationId: Int? = nil) { + init(viewModel: RecruitNotificationListViewModel) { self.viewModel = viewModel - self.notificationId = notificationId super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { @@ -63,6 +61,8 @@ private extension RecruitNotificationListViewController { showToastMessage(message: message) case .didFinishLoading: notificationListView.stopLoading() + case .tapNotification(let id): + tapNotification(id: id) } } .store(in: &subscriptions) @@ -114,16 +114,11 @@ extension RecruitNotificationListViewController { }, hasNextPage: notificationList.hasNextPage ) - tapNotificationIfNeeded() } - /// 푸시알림으로 진입한 경우, 최초 1회 notificationId 와 일치하는 row 를 탭 처리한다. - private func tapNotificationIfNeeded() { - guard let notificationId else { - return - } - self.notificationId = nil - guard let indexPath = notificationListView.indexPath(forNotificationId: String(notificationId)) else { + /// 푸시알림으로 진입한 경우, notificationId 와 일치하는 row 를 탭 처리한다. + private func tapNotification(id notificationId: Int) { + guard let indexPath = notificationListView.indexPath(for: notificationId) else { return } notificationListView.tapRow(at: indexPath) diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift index b6ca0ac03..b9138ea07 100644 --- a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift @@ -24,6 +24,7 @@ final class RecruitNotificationListViewModel: ViewModelProtocol { case updateNotifications(RecruitNotificationList) case showToast(String) case didFinishLoading + case tapNotification(id: Int) } // MARK: - Properties @@ -34,16 +35,19 @@ final class RecruitNotificationListViewModel: ViewModelProtocol { private var subscriptions = Set() private var notificationList: RecruitNotificationList? private var isLoading = false + private var notificationId: Int? // MARK: - Initializer init( fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase, markAsReadRecruitNotificationUseCase: MarkAsReadRecruitNotificationUseCase, - deleteRecruitNotificationUseCase: DeleteRecruitNotificationUseCase + deleteRecruitNotificationUseCase: DeleteRecruitNotificationUseCase, + notificationId: Int? = nil ) { self.fetchRecruitNotificationListUseCase = fetchRecruitNotificationListUseCase self.markAsReadRecruitNotificationUseCase = markAsReadRecruitNotificationUseCase self.deleteRecruitNotificationUseCase = deleteRecruitNotificationUseCase + self.notificationId = notificationId } // MARK: - Transform @@ -90,31 +94,59 @@ private extension RecruitNotificationListViewModel { return } isLoading = true - Task { @MainActor in + Task { defer { isLoading = false } do { - var response = try await fetchRecruitNotificationListUseCase.execute(page: page) - guard response.currentPage == page else { + guard try await fetchPage(page) else { return } - if page > 1 { - let loadedIds = Set(notificationList?.notifications.map(\.id) ?? []) - response.notifications = (notificationList?.notifications ?? []) - + response.notifications.filter { !loadedIds.contains($0.id) } - } - notificationList = response - outputSubject.send(.updateNotifications(response)) + try await searchNotificationIfNeeded() } catch { - if let message = (error as? ErrorResponse)?.message { - outputSubject.send(.showToast(message)) - } + notificationId = nil + let errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + outputSubject.send(.showToast(errorMessage)) outputSubject.send(.didFinishLoading) } } } + /// 한 페이지를 조회해 기존 목록에 이어붙인다. 응답 페이지가 요청과 다르면 false. + private func fetchPage(_ page: Int) async throws -> Bool { + var response = try await fetchRecruitNotificationListUseCase.execute(page: page) + guard response.currentPage == page else { + return false + } + if page > 1 { + response.notifications = (notificationList?.notifications ?? []) + response.notifications + response.notifications.removeDuplicates() + } + notificationList = response + outputSubject.send(.updateNotifications(response)) + return true + } + + /// 푸시알림으로 진입한 경우, notificationId 와 일치하는 알림을 찾을 때까지 다음 페이지를 호출한다. + private func searchNotificationIfNeeded() async throws { + guard let notificationId else { + return + } + defer { + self.notificationId = nil + } + while let notificationList { + if notificationList.notifications.contains(where: { $0.id == notificationId }) { + outputSubject.send(.tapNotification(id: notificationId)) + return + } + guard notificationList.hasNextPage, + try await fetchPage(notificationList.currentPage + 1) else { + return + } + } + } + private func deleteNotification(id: Int) { Task { do { From f68d79ce31669230fdfdfcc2ade15cfa1e1ed67d Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Mon, 5 Oct 2026 21:06:52 +0900 Subject: [PATCH 089/123] =?UTF-8?q?fix:=20=EB=94=94=EC=BD=94=EB=94=A9=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Data/DTOs/Decodable/Recruit/RecruitDataDto.swift | 7 +++++-- Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift | 5 ++++- Koin/Data/DTOs/Decodable/Recruit/RecruitListDto.swift | 2 +- .../DTOs/Decodable/Recruit/RecruitListSummaryDto.swift | 7 +++++-- .../Decodable/Recruit/RecruitMyApplicationListDto.swift | 8 +++++--- .../DTOs/Decodable/Recruit/RecruitMyPostDataDto.swift | 6 ++++-- .../DTOs/Decodable/Recruit/RecruitMyPostListDto.swift | 8 +++++--- Koin/Domain/Model/Core/ErrorResponse.swift | 1 + 8 files changed, 30 insertions(+), 14 deletions(-) diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitDataDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitDataDto.swift index a6d9deec1..6b6580b5f 100644 --- a/Koin/Data/DTOs/Decodable/Recruit/RecruitDataDto.swift +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitDataDto.swift @@ -72,7 +72,10 @@ struct RecruitMyApplicationDto: Decodable { } extension RecruitDataDto { - func toDomain() -> RecruitData { + func toDomain() throws -> RecruitData { + guard let state = status.toDomain() else { + throw ErrorResponse.deletedRecruitment + } let createdAtFormatter = DateFormatter().then { $0.dateFormat = "yyyy-MM-dd HH:mm:ss" $0.locale = Locale(identifier: "ko_KR") @@ -83,7 +86,7 @@ extension RecruitDataDto { id: id, category: category.toDomain(), dDay: dDay?.toDDay(), - state: status.toDomain(), + state: state, title: title, meetingType: meetingType.toDomain(), startDate: activityStartDate.toDateFromYYYYMMDD(), diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift index 408ab78e6..4eb78b6ca 100644 --- a/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift @@ -24,6 +24,7 @@ enum RecruitMeetingTypeDto: String, Codable { enum RecruitStatusDto: String, Decodable { case recruiting = "RECRUITING" case closed = "CLOSED" + case deleted = "DELETED" } enum RecruitRoleTypeDto: String, Codable { @@ -105,12 +106,14 @@ extension RecruitMeetingTypeDto { } extension RecruitStatusDto { - func toDomain() -> RecruitState { + func toDomain() -> RecruitState? { switch self { case .recruiting: return .recruiting case .closed: return .closed + case .deleted: + return nil } } } diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitListDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitListDto.swift index 084834770..1d36dced1 100644 --- a/Koin/Data/DTOs/Decodable/Recruit/RecruitListDto.swift +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitListDto.swift @@ -26,7 +26,7 @@ struct RecruitListDto: Decodable { extension RecruitListDto { func toDomain() -> RecruitList { return RecruitList( - recruits: recruitments.map { $0.toDomain() }, + recruits: recruitments.compactMap { $0.toDomain() }, totalCount: totalCount, totalPage: totalPage, currentPage: currentPage diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitListSummaryDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitListSummaryDto.swift index 36a05b433..90032a604 100644 --- a/Koin/Data/DTOs/Decodable/Recruit/RecruitListSummaryDto.swift +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitListSummaryDto.swift @@ -38,7 +38,10 @@ struct RecruitListSummaryDto: Decodable { } extension RecruitListSummaryDto { - func toDomain() -> RecruitRow { + func toDomain() -> RecruitRow? { + guard let state = status.toDomain() else { + return nil + } return RecruitRow( id: id, category: category.toDomain(), @@ -48,7 +51,7 @@ extension RecruitListSummaryDto { endDate: activityEndDate.toDateFromYYYYMMDD(), deadline: deadlineDate.toDateFromYYYYMMDD(), dDay: dDay?.toDDay(), - state: status.toDomain(), + state: state, currentParticipants: currentParticipants, maximumParticipants: maxParticipants, type: recruitmentType.toDomain(), diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitMyApplicationListDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyApplicationListDto.swift index 01feaa6ff..8dcf8a0fd 100644 --- a/Koin/Data/DTOs/Decodable/Recruit/RecruitMyApplicationListDto.swift +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyApplicationListDto.swift @@ -40,7 +40,7 @@ struct RecruitMyApplicationRowDto: Decodable { extension RecruitMyApplicationListDto { func toDomain() -> RecruitMyApplicationList { return RecruitMyApplicationList( - recruits: applications.map { $0.toDomain() }, + recruits: applications.compactMap { $0.toDomain() }, totalCount: totalCount, totalPage: totalPage, currentPage: currentPage @@ -49,8 +49,10 @@ extension RecruitMyApplicationListDto { } extension RecruitMyApplicationRowDto { - func toDomain() -> RecruitMyApplicationRow { - let recruit = recruitment.toDomain() + func toDomain() -> RecruitMyApplicationRow? { + guard let recruit = recruitment.toDomain() else { + return nil + } return RecruitMyApplicationRow( id: recruit.id, category: recruit.category, diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostDataDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostDataDto.swift index 2b15a6965..f77f30b97 100644 --- a/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostDataDto.swift +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostDataDto.swift @@ -63,8 +63,10 @@ struct RecruitApplicationRoleDto: Decodable { } extension RecruitMyPostDataDto { - func toDomain() -> RecruitMyPostData { - let recruit = recruitment.recruitment.toDomain() + func toDomain() throws -> RecruitMyPostData { + guard let recruit = recruitment.recruitment.toDomain() else { + throw ErrorResponse.deletedRecruitment + } return RecruitMyPostData( id: recruit.id, category: recruit.category, diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostListDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostListDto.swift index 2f03aafdf..2a2bc0382 100644 --- a/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostListDto.swift +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostListDto.swift @@ -44,7 +44,7 @@ struct RecruitMyPostRowDto: Decodable { extension RecruitMyPostListDto { func toDomain() -> RecruitMyPostList { return RecruitMyPostList( - recruits: recruitments.map { $0.toDomain() }, + recruits: recruitments.compactMap { $0.toDomain() }, totalCount: totalCount, totalPage: totalPage, currentPage: currentPage @@ -53,8 +53,10 @@ extension RecruitMyPostListDto { } extension RecruitMyPostRowDto { - func toDomain() -> RecruitMyPostRow { - let recruit = recruitment.toDomain() + func toDomain() -> RecruitMyPostRow? { + guard let recruit = recruitment.toDomain() else { + return nil + } return RecruitMyPostRow( id: recruit.id, category: recruit.category, diff --git a/Koin/Domain/Model/Core/ErrorResponse.swift b/Koin/Domain/Model/Core/ErrorResponse.swift index 3bfbc815d..39567130b 100644 --- a/Koin/Domain/Model/Core/ErrorResponse.swift +++ b/Koin/Domain/Model/Core/ErrorResponse.swift @@ -38,6 +38,7 @@ extension ErrorResponse { static let networkError = ErrorResponse(statusCode: nil, code: "NETWORK_ERROR", message: "서버 응답 오류") static let invalidUrl = ErrorResponse(statusCode: nil, code: "INVALID_URL", message: "URL이 유효하지 않습니다.") static let recruitRelatedUrlSchemeError = ErrorResponse(statusCode: nil, code: "RECRUIT_RELATED_URL_SCHEME_ERROR", message: "링크는 https:// 로 시작해야 합니다.") + static let deletedRecruitment = ErrorResponse(statusCode: nil, code: "DELETED_RECRUITMENT", message: "삭제된 모집글입니다.") static let invalidApi = ErrorResponse(statusCode: nil, code: "INVALID_API", message: "API가 유효하지 않습니다.") static let fileManagerFailedDirectory = ErrorResponse(statusCode: nil, code: "FILEMANAGER_FAILED_DIRECTORY", message: "파일 저장 위치 찾기 실패") static let deleteKeywordError = ErrorResponse(statusCode: nil, code: "DELETE_KEYWORD_ERROR", message: "로그인에 실패하여 코어데이터에서 키워드 삭제") From 3afc4a2d765c69d1bc6c06e3d476d49c59be42a9 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 6 Oct 2026 01:06:43 +0900 Subject: [PATCH 090/123] =?UTF-8?q?fix:=20=ED=8C=80=EC=9B=90=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 저장 버튼이 활성화되지 않는 버그를 수정했습니다. --- .../RecruitProfilePost/RecruitProfilePostViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift index a86daf127..930adf955 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift @@ -240,6 +240,7 @@ extension RecruitProfilePostViewController { .sink { [weak self] nickname in guard let self else { return } basicInfo.nickname = nickname + request.nickname = nickname nextButton.updateState(isEnabled: basicInfo.isValid) } .store(in: &subscriptions) From 7ebece75ff8e42e6b1ec6346db2cf822090cb99d Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 6 Oct 2026 01:16:05 +0900 Subject: [PATCH 091/123] =?UTF-8?q?Revert=20"fix:=20=ED=8C=80=EC=9B=90?= =?UTF-8?q?=EB=AA=A8=EC=A7=91=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitProfilePost/RecruitProfilePostViewController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift index 930adf955..a86daf127 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift @@ -240,7 +240,6 @@ extension RecruitProfilePostViewController { .sink { [weak self] nickname in guard let self else { return } basicInfo.nickname = nickname - request.nickname = nickname nextButton.updateState(isEnabled: basicInfo.isValid) } .store(in: &subscriptions) From 125db8b71653c6aec8fecb5f258dbc025cbad97a Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 6 Oct 2026 01:38:31 +0900 Subject: [PATCH 092/123] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=EC=9E=91=EC=84=B1=EC=8B=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=B2=84=ED=8A=BC=EC=9D=B4=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitProfilePostViewController.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift index a86daf127..38fda11ef 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift @@ -19,7 +19,7 @@ final class RecruitProfilePostViewController: UIViewController { private var subscriptions = Set() private var basicInfo = BasicInfo() - private var request = RecruitProfileRequest() + private var request: RecruitProfileRequest private var isFirstStep = true private var titleText: String { @@ -240,6 +240,7 @@ extension RecruitProfilePostViewController { .sink { [weak self] nickname in guard let self else { return } basicInfo.nickname = nickname + request.nickname = nickname nextButton.updateState(isEnabled: basicInfo.isValid) } .store(in: &subscriptions) @@ -314,8 +315,10 @@ extension RecruitProfilePostViewController { extension RecruitProfilePostViewController { private func configureBasicInfo(_ basicInfo: BasicInfo) { self.basicInfo = basicInfo + self.request.nickname = basicInfo.nickname firstStepView.configure(basicInfo) nextButton.updateState(isEnabled: basicInfo.isValid) + updateCompleteButtonState() } private func updateCompleteButtonState() { From 47afc6726364a1aba0f4365d306eda71f53b31e5 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Thu, 8 Oct 2026 14:09:44 +0900 Subject: [PATCH 093/123] =?UTF-8?q?fix:=20=EB=82=B4=EA=B0=80=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=ED=95=9C=20=EB=AA=A8=EC=A7=91=EA=B8=80=20EmptyView=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...uitMyApplicationListHostingController.swift | 4 +--- .../RecruitMyApplicationListView.swift | 12 ++++++------ .../RecruitMyApplicationListEmptyView.swift | 18 ++++++++++++++---- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift index 63b6cc64c..61dbaa2c7 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift @@ -74,9 +74,7 @@ extension RecruitMyApplicationListHostingController { private func showAllRecruitList() { guard let navigationController, - let recruitListController = navigationController.viewControllers - .compactMap({ $0 as? RecruitListHostingController }) - .first else { + let recruitListController = navigationController.viewControllers.first(where: { $0 is RecruitListHostingController }) else { return } navigationController.popToViewController(recruitListController, animated: true) diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift index 92e99130f..3209f8582 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift @@ -82,18 +82,18 @@ struct RecruitMyApplicationListView: ActionBindableView { Spacer(minLength: 24) } .padding(.horizontal, 22) - .background { - RecruitMyApplicationListEmptyView { - sendAction(.showAllRecruitList) - } - .isHidden(!(viewModel.recruitList?.isEmpty ?? true) || viewModel.isLoading) - } .refreshable { viewModel.execute(.refresh) } .scrollIndicators(.hidden) } .background(Color.appColor(.newBackground)) + .overlay { + RecruitMyApplicationListEmptyView { + sendAction(.showAllRecruitList) + } + .isHidden(!(viewModel.recruitList?.isEmpty ?? true) || viewModel.isLoading) + } .onFirstAppear { viewModel.execute(.onFirstAppear) } diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListEmptyView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListEmptyView.swift index 437fd5639..40517989e 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListEmptyView.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListEmptyView.swift @@ -12,11 +12,13 @@ struct RecruitMyApplicationListEmptyView: View { let onListButtonTapped: () -> Void var body: some View { - VStack(alignment: .center, spacing: 12) { + VStack(alignment: .center, spacing: 0) { Spacer() Image.appImage(asset: .sleepBcsdSymbol) - .frame(height: 75) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 98, height: 75) Text("지원한 모집글이 없어요.") .font(.appFont(.pretendardMedium, size: 16)) @@ -24,7 +26,7 @@ struct RecruitMyApplicationListEmptyView: View { .frame(height: 26) .padding(.bottom, 2) - Text("직접 모집글을 작성하여 팀원을 모집해보세요.") + Text("마음에 드는 모집글에 지원해보세요.") .font(.appFont(.pretendardRegular, size: 14)) .foregroundStyle(Color.appColor(.neutral600)) .frame(height: 22) @@ -35,15 +37,23 @@ struct RecruitMyApplicationListEmptyView: View { .foregroundStyle(Color.appColor(.new600)) .padding(.horizontal, 12) .frame(height: 42) + .background(Color.appColor(.neutral50)) .clipShape(.capsule) .border(.appColor(.new400), width: 1, radius: 21) .applySketchShadow( color: .appColor(.neutral800), - alpha: 0.09, + alpha: 0.08, x: 0, y: 4, blur: 10 ) + .applySketchShadow( + color: .appColor(.neutral800), + alpha: 0.04, + x: 0, + y: 1, + blur: 4 + ) } .buttonStyle(.plain) .padding(.top, 12) From 93233aa2423bf0a6cb8738bd884783d17bba362c Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 02:15:02 +0900 Subject: [PATCH 094/123] =?UTF-8?q?fix:=20=ED=8C=80=EC=9B=90=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=ED=94=84=EB=A1=9C=ED=95=84=EC=9D=B4=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=82=AC=EC=9A=A9=EC=9E=90=EA=B0=80=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=EA=B8=80=20=EC=9E=91=EC=84=B1=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EC=A7=84=EC=9E=85=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Category/CategoryHostingController.swift | 4 ++- .../RecruitListHostingController.swift | 30 ++++++++++++++++++- .../Recruit/RecruitList/RecruitListView.swift | 15 +++++++++- .../RecruitList/RecruitListViewModel.swift | 30 +++++++++++++++++-- 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/Koin/Presentation/Home/Category/CategoryHostingController.swift b/Koin/Presentation/Home/Category/CategoryHostingController.swift index 234e43637..fd9e47cd3 100644 --- a/Koin/Presentation/Home/Category/CategoryHostingController.swift +++ b/Koin/Presentation/Home/Category/CategoryHostingController.swift @@ -204,9 +204,11 @@ extension CategoryHostingController { let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchRecruitListUseCase = DefaultFetchRecruitListUseCase(repository: recruitRepository) let fetchRecruitNotificationListUseCase = DefaultFetchRecruitNotificationListUseCase(repository: recruitRepository) + let fetchMyRecruitProfileUseCase = DefaultFetchMyRecruitProfileUseCase(repository: recruitRepository) let viewModel = RecruitListViewModel( fetchRecruitListUseCase: fetchRecruitListUseCase, - fetchRecruitNotificationListUseCase: fetchRecruitNotificationListUseCase + fetchRecruitNotificationListUseCase: fetchRecruitNotificationListUseCase, + fetchMyRecruitProfileUseCase: fetchMyRecruitProfileUseCase ) let rootView = RecruitListView(viewModel: viewModel) return RecruitListHostingController(rootView: rootView) diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index 51e905f95..eb4cd7f69 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -53,6 +53,8 @@ final class RecruitListHostingController: UIHostingController, showToastMessage(message: message, bottomInset: toastMessageBottomInset) case .showLoginToast: showLoginToast() + case .showProfilePostToast: + showProfilePostToast() case .showRecruitPost: showRecruitPost() case .showRecruitData(let id): @@ -97,7 +99,6 @@ extension RecruitListHostingController { } private func showLoginToast() { - showToastMessageWithButton( message: "로그인이 필요한 기능입니다.", buttonTitle: "로그인", @@ -107,6 +108,18 @@ extension RecruitListHostingController { } return } + + private func showProfilePostToast() { + showToastMessageWithButton( + message: "팀원 모집 프로필이 필요합니다.", + buttonTitle: "작성하기", + bottomInset: toastMessageBottomInset + ) { [weak self] in + self?.showProfilePost() + } + return + } + private func showRecruitNotificationList() { let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchRecruitNotificationListUseCase = DefaultFetchRecruitNotificationListUseCase(repository: recruitRepository) @@ -141,6 +154,21 @@ extension RecruitListHostingController { let viewController = RecruitPostViewController(viewModel: viewModel) navigationController?.pushViewController(viewController, animated: true) } + private func showProfilePost() { + let userRepository = DefaultUserRepository(service: DefaultUserService()) + let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) + let viewModel = RecruitProfilePostViewModel( + fetchDeptListUseCase: DefaultFetchDeptListUseCase(timetableRepository: DefaultTimetableRepository(service: DefaultTimetableService())), + fetchBasicInfoUseCase: DefaultFetchBasicInfoUseCase(repository: userRepository), + modifyBasicInfoUseCase: DefaultModifyBasicInfoUseCase(repository: userRepository), + upsertMyRecruitProfileUseCase: DefaultUpsertMyRecruitProfileUseCase(repository: recruitRepository), + mode: .post + ) + let viewController = RecruitProfilePostViewController(viewModel: viewModel) { [weak self] profile in + self?.rootView.update(profile: profile) + } + navigationController?.pushViewController(viewController, animated: true) + } } extension RecruitListHostingController { diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift index 84c2e8375..c11d4187d 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -13,6 +13,7 @@ struct RecruitListView: ActionBindableView { case showFilterBottomSheet(filterState: RecruitListFilter, onApplyTapped: ([FilterGroupModel])->Void) case showToast(message: String) case showLoginToast + case showProfilePostToast case showRecruitPost case showRecruitData(id: Int) } @@ -133,7 +134,15 @@ struct RecruitListView: ActionBindableView { @ViewBuilder private var postButton: some View { Button { - UserDataManager.shared.isLoggedIn ? sendAction(.showRecruitPost) : sendAction(.showLoginToast) + guard UserDataManager.shared.isLoggedIn else { + sendAction(.showLoginToast) + return + } + guard viewModel.canPost else { + sendAction(.showProfilePostToast) + return + } + sendAction(.showRecruitPost) } label: { HStack(alignment: .center, spacing: 4) { Text("모집하기") @@ -153,4 +162,8 @@ struct RecruitListView: ActionBindableView { func delete(id: Int) { viewModel.execute(.delete(id: id)) } + + func update(profile: RecruitProfile) { + viewModel.execute(.updateProfile(profile)) + } } diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift index 1c8bffb4a..77b173409 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift @@ -21,6 +21,7 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { case loadNextPage case delete(id: Int) case didShowToast + case updateProfile(RecruitProfile) } // MARK: - State @@ -30,7 +31,6 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { private(set) var hasUnreadNotification: Bool = false private(set) var errorMessage: String? = nil private(set) var shouldScrollToTop: Bool = false - private var fetchListTask: Task? var isEmpty: Bool { if let recruitList { @@ -48,18 +48,27 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { var hasNextPage: Bool { currentPage < totalPage } + var canPost: Bool { + myRecruitProfile != nil + } - // MARK: - UseCase + // MARK: - Properties private let fetchRecruitListUseCase: FetchRecruitListUseCase private let fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase + private let fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase + + private var fetchListTask: Task? + private var myRecruitProfile: RecruitProfile? // MARK: - Initializer init( fetchRecruitListUseCase: FetchRecruitListUseCase, - fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase + fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase, + fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase ) { self.fetchRecruitListUseCase = fetchRecruitListUseCase self.fetchRecruitNotificationListUseCase = fetchRecruitNotificationListUseCase + self.fetchMyRecruitProfileUseCase = fetchMyRecruitProfileUseCase } // MARK: - Public @@ -69,9 +78,11 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { fetchList() case .onAppear: fetchHasUnreadNotification() + fetchMyRecruitProfile() case .refresh: fetchList() fetchHasUnreadNotification() + fetchMyRecruitProfile() case .updateFilter(let keyword, let filterState): updateFilter(keyword, filterState) case .deleteFilter(let rawvalue): @@ -84,6 +95,8 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { recruitList?.delete(id: id) case .didShowToast: errorMessage = nil + case .updateProfile(let profile): + myRecruitProfile = profile } } } @@ -179,3 +192,14 @@ extension RecruitListViewModel { } } +extension RecruitListViewModel { + private func fetchMyRecruitProfile() { + Task { + do { + myRecruitProfile = try await fetchMyRecruitProfileUseCase.execute() + } catch { + errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + } + } + } +} From 411d697f2fd15534ec5b1961baa6a51810216fbd Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 03:06:12 +0900 Subject: [PATCH 095/123] =?UTF-8?q?log:=200.=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 3 +++ .../Home/Category/CategoryHostingController.swift | 4 +++- .../RecruitList/RecruitListHostingController.swift | 5 +++++ .../Recruit/RecruitList/RecruitListView.swift | 8 ++++++++ .../Recruit/RecruitList/RecruitListViewModel.swift | 8 +++++++- 5 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index a3d9928ed..d374935b1 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -234,6 +234,9 @@ enum EventParameter { case callvanWriteDone = "callvan_write_done" case callvanWriteBack = "callvan_write_back" + // Recruit + case teamRecruitmentNotification = "team_recruitment_notification" + var team: String { return "CAMPUS" } diff --git a/Koin/Presentation/Home/Category/CategoryHostingController.swift b/Koin/Presentation/Home/Category/CategoryHostingController.swift index fd9e47cd3..aed09e6f5 100644 --- a/Koin/Presentation/Home/Category/CategoryHostingController.swift +++ b/Koin/Presentation/Home/Category/CategoryHostingController.swift @@ -205,10 +205,12 @@ extension CategoryHostingController { let fetchRecruitListUseCase = DefaultFetchRecruitListUseCase(repository: recruitRepository) let fetchRecruitNotificationListUseCase = DefaultFetchRecruitNotificationListUseCase(repository: recruitRepository) let fetchMyRecruitProfileUseCase = DefaultFetchMyRecruitProfileUseCase(repository: recruitRepository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitListViewModel( fetchRecruitListUseCase: fetchRecruitListUseCase, fetchRecruitNotificationListUseCase: fetchRecruitNotificationListUseCase, - fetchMyRecruitProfileUseCase: fetchMyRecruitProfileUseCase + fetchMyRecruitProfileUseCase: fetchMyRecruitProfileUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase ) let rootView = RecruitListView(viewModel: viewModel) return RecruitListHostingController(rootView: rootView) diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index eb4cd7f69..416ab5fd7 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -197,6 +197,11 @@ extension RecruitListHostingController { showLoginToast() return } + rootView.makeLogAnalyticsEvent( + label: EventParameter.EventLabel.Campus.teamRecruitmentNotification, + category: .click, + value: "알림" + ) showRecruitNotificationList() } diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift index c11d4187d..19e290b49 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -166,4 +166,12 @@ struct RecruitListView: ActionBindableView { func update(profile: RecruitProfile) { viewModel.execute(.updateProfile(profile)) } + + func makeLogAnalyticsEvent( + label: EventLabelType, + category: EventParameter.EventCategory, + value: Any + ) { + viewModel.execute(.logEvent(label, category, value)) + } } diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift index 77b173409..50055a7f6 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift @@ -22,6 +22,7 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { case delete(id: Int) case didShowToast case updateProfile(RecruitProfile) + case logEvent(EventLabelType, EventParameter.EventCategory, Any) } // MARK: - State @@ -56,6 +57,7 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { private let fetchRecruitListUseCase: FetchRecruitListUseCase private let fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase private let fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase private var fetchListTask: Task? private var myRecruitProfile: RecruitProfile? @@ -64,11 +66,13 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { init( fetchRecruitListUseCase: FetchRecruitListUseCase, fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase, - fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase + fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase ) { self.fetchRecruitListUseCase = fetchRecruitListUseCase self.fetchRecruitNotificationListUseCase = fetchRecruitNotificationListUseCase self.fetchMyRecruitProfileUseCase = fetchMyRecruitProfileUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase } // MARK: - Public @@ -97,6 +101,8 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { errorMessage = nil case .updateProfile(let profile): myRecruitProfile = profile + case let .logEvent(label, category, value): + logAnalyticsEventUseCase.execute(label: label, category: category, value: value) } } } From 920bd2d9ff34ada6e2d02049bce6a3e4fb287065 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 03:44:54 +0900 Subject: [PATCH 096/123] =?UTF-8?q?log:=200.=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EB=82=B4=EB=B6=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Apps/SceneDelegate.swift | 2 ++ .../RecruitList/RecruitListHostingController.swift | 4 +++- .../RecruitNotificationListViewController.swift | 9 +++++++++ .../RecruitNotificationListViewModel.swift | 10 ++++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Koin/Apps/SceneDelegate.swift b/Koin/Apps/SceneDelegate.swift index 0cfff6045..ba0b9f73c 100644 --- a/Koin/Apps/SceneDelegate.swift +++ b/Koin/Apps/SceneDelegate.swift @@ -353,10 +353,12 @@ extension SceneDelegate { let fetchRecruitNotificationListUseCase = DefaultFetchRecruitNotificationListUseCase(repository: recruitRepository) let markAsReadRecruitNotificationUseCase = DefaultMarkAsReadRecruitNotificationUseCase(repository: recruitRepository) let deleteRecruitNotificationUseCase = DefaultDeleteRecruitNotificationUseCase(repository: recruitRepository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitNotificationListViewModel( fetchRecruitNotificationListUseCase: fetchRecruitNotificationListUseCase, markAsReadRecruitNotificationUseCase: markAsReadRecruitNotificationUseCase, deleteRecruitNotificationUseCase: deleteRecruitNotificationUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase, notificationId: notificationId ) return RecruitNotificationListViewController(viewModel: viewModel) diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index 416ab5fd7..c43d6dbd5 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -125,10 +125,12 @@ extension RecruitListHostingController { let fetchRecruitNotificationListUseCase = DefaultFetchRecruitNotificationListUseCase(repository: recruitRepository) let markAsReadRecruitNotificationUseCase = DefaultMarkAsReadRecruitNotificationUseCase(repository: recruitRepository) let deleteRecruitNotificationUseCase = DefaultDeleteRecruitNotificationUseCase(repository: recruitRepository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitNotificationListViewModel( fetchRecruitNotificationListUseCase: fetchRecruitNotificationListUseCase, markAsReadRecruitNotificationUseCase: markAsReadRecruitNotificationUseCase, - deleteRecruitNotificationUseCase: deleteRecruitNotificationUseCase + deleteRecruitNotificationUseCase: deleteRecruitNotificationUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase ) let viewController = RecruitNotificationListViewController(viewModel: viewModel) navigationController?.pushViewController(viewController, animated: true) diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift index eb6c146f6..0a2bee8c2 100644 --- a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift @@ -16,6 +16,7 @@ final class RecruitNotificationListViewController: UIViewController { private let inputSubject = PassthroughSubject() private var subscriptions = Set() private var notificationList: RecruitNotificationList? + private var isUserTap = true // MARK: - UI Components private let notificationListView = NotificationListView(behavior: .pagination) @@ -84,6 +85,10 @@ private extension RecruitNotificationListViewController { let id = Int(id) else { return } + if isUserTap, + let title = notificationList?.notifications.first(where: { $0.id == id })?.title { + inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.notificationList, .click, title)) + } self.notificationList?.markAsRead(id: id) handleNavigation(id: id) inputSubject.send(.didTapNotification(id: id)) @@ -121,6 +126,10 @@ extension RecruitNotificationListViewController { guard let indexPath = notificationListView.indexPath(for: notificationId) else { return } + isUserTap = false + defer { + isUserTap = true + } notificationListView.tapRow(at: indexPath) } private func handleNavigation(id: Int) { diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift index b9138ea07..b62498fcd 100644 --- a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift @@ -18,6 +18,7 @@ final class RecruitNotificationListViewModel: ViewModelProtocol { case deleteNotification(id: Int) case deleteAllNotifications case markAllAsRead + case logEvent(EventLabelType, EventParameter.EventCategory, Any) } enum Output { @@ -31,6 +32,7 @@ final class RecruitNotificationListViewModel: ViewModelProtocol { private let fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase private let markAsReadRecruitNotificationUseCase: MarkAsReadRecruitNotificationUseCase private let deleteRecruitNotificationUseCase: DeleteRecruitNotificationUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase private let outputSubject = PassthroughSubject() private var subscriptions = Set() private var notificationList: RecruitNotificationList? @@ -42,11 +44,13 @@ final class RecruitNotificationListViewModel: ViewModelProtocol { fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase, markAsReadRecruitNotificationUseCase: MarkAsReadRecruitNotificationUseCase, deleteRecruitNotificationUseCase: DeleteRecruitNotificationUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase, notificationId: Int? = nil ) { self.fetchRecruitNotificationListUseCase = fetchRecruitNotificationListUseCase self.markAsReadRecruitNotificationUseCase = markAsReadRecruitNotificationUseCase self.deleteRecruitNotificationUseCase = deleteRecruitNotificationUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase self.notificationId = notificationId } @@ -72,6 +76,8 @@ final class RecruitNotificationListViewModel: ViewModelProtocol { case .markAllAsRead: self?.notificationList?.markAllAsRead() self?.markAllAsRead() + case let .logEvent(label, category, value): + self?.makeLogAnalyticsEvent(label: label, category: category, value: value) } } .store(in: &subscriptions) @@ -196,4 +202,8 @@ private extension RecruitNotificationListViewModel { } } } + + private func makeLogAnalyticsEvent(label: EventLabelType, category: EventParameter.EventCategory, value: Any) { + logAnalyticsEventUseCase.execute(label: label, category: category, value: value) + } } From 8bf720ae919f873e18d1dfad03c9a44c3050e6f2 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 04:13:52 +0900 Subject: [PATCH 097/123] =?UTF-8?q?log:=201.=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 1 + Koin/Presentation/Recruit/RecruitList/RecruitListView.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index d374935b1..32d48b106 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -236,6 +236,7 @@ enum EventParameter { // Recruit case teamRecruitmentNotification = "team_recruitment_notification" + case teamRecruitmentSearch = "team_recruitment_search" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift index 19e290b49..51e1a936f 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -37,6 +37,9 @@ struct RecruitListView: ActionBindableView { RecruitListHeaderView( filterState: viewModel.filterState, onSearchTapped: { keyword in + if !keyword.isEmpty { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentSearch, .click, keyword)) + } viewModel.execute(.updateFilter(keyword: keyword)) }, onFilterButtonTapped: { From 93b3b02345aaa441fd391736f661864e6f42c4ee Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 04:20:02 +0900 Subject: [PATCH 098/123] =?UTF-8?q?log:=202.=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 1 + Koin/Presentation/Recruit/RecruitList/RecruitListView.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index 32d48b106..a5016d238 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -237,6 +237,7 @@ enum EventParameter { // Recruit case teamRecruitmentNotification = "team_recruitment_notification" case teamRecruitmentSearch = "team_recruitment_search" + case teamRecruitmentFilter = "team_recruitment_filter" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift index 51e1a936f..485ee0e48 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -43,6 +43,7 @@ struct RecruitListView: ActionBindableView { viewModel.execute(.updateFilter(keyword: keyword)) }, onFilterButtonTapped: { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentFilter, .click, "필터")) sendAction(.showFilterBottomSheet( filterState: viewModel.filterState, onApplyTapped: { filterGroups in From 35f2edf56c00c3d4b66bff733b2340d4a7f69a35 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 04:37:26 +0900 Subject: [PATCH 099/123] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=9A=A9=20Filter?= =?UTF-8?q?BottonSheetView=20=EB=A1=9C=EA=B9=85=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=9C=20=ED=98=95=ED=83=9C=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FilterBottomSheet/FilterBottomSheetView.swift | 12 ++++++++---- .../CallVanList/CallVanListViewController.swift | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Koin/Core/View/UIKit/FilterBottomSheet/FilterBottomSheetView.swift b/Koin/Core/View/UIKit/FilterBottomSheet/FilterBottomSheetView.swift index 5af12b93e..ce28ca9b0 100644 --- a/Koin/Core/View/UIKit/FilterBottomSheet/FilterBottomSheetView.swift +++ b/Koin/Core/View/UIKit/FilterBottomSheet/FilterBottomSheetView.swift @@ -15,7 +15,8 @@ class FilterBottomSheetView: UIView { // MARK: - Properties weak var delegate: BottomSheetViewControllerBDelegate? private var groupModels: [FilterGroupModel] - private let onFilterItemTapped: ((FilterItemModel)->Bool)? + private let onFilterItemTapped: ((Int, FilterItemModel)->Bool)? + private let onResetTapped: (()->Void)? private let onApplyTapped: ([FilterGroupModel])->Void private var subscriptions: Set = [] @@ -36,11 +37,13 @@ class FilterBottomSheetView: UIView { // MARK: - Initializer init( groupModels: [FilterGroupModel], - onFilterItemTapped: ((FilterItemModel)->Bool)? = nil, + onFilterItemTapped: ((_ groupIndex: Int, _ item: FilterItemModel)->Bool)? = nil, + onResetTapped: (()->Void)? = nil, onApplyTapped: @escaping ([FilterGroupModel])->Void ) { self.groupModels = groupModels self.onFilterItemTapped = onFilterItemTapped + self.onResetTapped = onResetTapped self.onApplyTapped = onApplyTapped self.filterGroupViews = groupModels.map { group in FilterGroupView(filterGroup: group) @@ -70,10 +73,10 @@ class FilterBottomSheetView: UIView { private func didTapItem(groupIndex: Int, itemIndex: Int) { let tappedItem = groupModels[groupIndex].items[itemIndex] if let onFilterItemTapped { - guard onFilterItemTapped(tappedItem) else { + guard onFilterItemTapped(groupIndex, tappedItem) else { return } - } + } let before = groupModels[groupIndex].items.map(\.isSelected) groupModels[groupIndex].didTap(itemAt: itemIndex) let after = groupModels[groupIndex].items.map(\.isSelected) @@ -113,6 +116,7 @@ extension FilterBottomSheetView { changed: Self.changedIndexPaths(before: before, after: after) ) } + onResetTapped?() } @objc private func applyButtonTapped() { diff --git a/Koin/Presentation/CallVan/CallVanList/CallVanListViewController.swift b/Koin/Presentation/CallVan/CallVanList/CallVanListViewController.swift index bf8d9fc41..319dab57a 100644 --- a/Koin/Presentation/CallVan/CallVanList/CallVanListViewController.swift +++ b/Koin/Presentation/CallVan/CallVanList/CallVanListViewController.swift @@ -196,7 +196,7 @@ extension CallVanListViewController { } @objc private func filterButtonTapped() { - let onFilterItemTapped: (FilterItemModel)->Bool = { [weak self] filterItem in + let onFilterItemTapped: (Int, FilterItemModel)->Bool = { [weak self] _, filterItem in guard let self else { return false } if filterItem.title == CallVanMineOrJoined.mine.rawValue || filterItem.title == CallVanMineOrJoined.joined.rawValue { From 2bad46944b6e64aa8dfd56eccbefbbc411eb0f63 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 06:11:27 +0900 Subject: [PATCH 100/123] =?UTF-8?q?log:=202-1.=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 6 +++++ .../Model/Recruit/RecruitListSort.swift | 9 +++++++ .../Model/Recruit/RecruitMeetingType.swift | 11 +++++++++ Koin/Domain/Model/Recruit/RecruitState.swift | 11 +++++++++ .../Mapper/RecruitListFilter+.swift | 24 +++++++++++++++++++ .../RecruitListHostingController.swift | 8 +++++-- .../Recruit/RecruitList/RecruitListView.swift | 17 ++++++++++++- 7 files changed, 83 insertions(+), 3 deletions(-) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index a5016d238..d5e673fa0 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -238,6 +238,12 @@ enum EventParameter { case teamRecruitmentNotification = "team_recruitment_notification" case teamRecruitmentSearch = "team_recruitment_search" case teamRecruitmentFilter = "team_recruitment_filter" + case teamRecruitmentFilterStatus = "team_recruitment_filter_status" + case teamRecruitmentFilterSort = "team_recruitment_filter_sort" + case teamRecruitmentFilterCategory = "team_recruitment_filter_category" + case teamRecruitmentFilterMethod = "team_recruitment_filter_method" + case teamRecruitmentFilterReset = "team_recruitment_filter_reset" + case teamRecruitmentFilterApply = "team_recruitment_filter_apply" var team: String { return "CAMPUS" diff --git a/Koin/Domain/Model/Recruit/RecruitListSort.swift b/Koin/Domain/Model/Recruit/RecruitListSort.swift index 45310535a..f55402b88 100644 --- a/Koin/Domain/Model/Recruit/RecruitListSort.swift +++ b/Koin/Domain/Model/Recruit/RecruitListSort.swift @@ -19,4 +19,13 @@ enum RecruitListSort: String { 1 } } + + var logValue: String { + switch self { + case .latestDescending: + "최신순" + case .deadlineAscending: + "마감 임박순" + } + } } diff --git a/Koin/Domain/Model/Recruit/RecruitMeetingType.swift b/Koin/Domain/Model/Recruit/RecruitMeetingType.swift index 9776b133e..38c646d1f 100644 --- a/Koin/Domain/Model/Recruit/RecruitMeetingType.swift +++ b/Koin/Domain/Model/Recruit/RecruitMeetingType.swift @@ -33,6 +33,17 @@ enum RecruitMeetingType: String, CaseIterable { return .recruitPostMeetingTypeMixed } } + + var logValue: String { + switch self { + case .online: + "온라인" + case .offline: + "오프라인" + case .mixed: + "온·오프라인" + } + } } extension RecruitMeetingType { diff --git a/Koin/Domain/Model/Recruit/RecruitState.swift b/Koin/Domain/Model/Recruit/RecruitState.swift index 55c6cdf69..bcae050a7 100644 --- a/Koin/Domain/Model/Recruit/RecruitState.swift +++ b/Koin/Domain/Model/Recruit/RecruitState.swift @@ -22,4 +22,15 @@ enum RecruitState: String { 2 } } + + var logValue: String { + switch self { + case .all: + "전체" + case .recruiting: + "모집 중" + case .closed: + "모집 완료" + } + } } diff --git a/Koin/Presentation/Recruit/RecruitList/Mapper/RecruitListFilter+.swift b/Koin/Presentation/Recruit/RecruitList/Mapper/RecruitListFilter+.swift index 571e76bb8..46f90d1fe 100644 --- a/Koin/Presentation/Recruit/RecruitList/Mapper/RecruitListFilter+.swift +++ b/Koin/Presentation/Recruit/RecruitList/Mapper/RecruitListFilter+.swift @@ -77,3 +77,27 @@ extension RecruitListFilter { self.meetingType = RecruitMeetingType(rawValue: groupModels[3].selectedItems.first?.title ?? "") } } + +extension RecruitListFilter { + /// 필터 바텀시트 항목 탭 로깅 label, value. groupIndex 순서는 toGroupModels()와 같다. + static func logEvent(groupIndex: Int, title: String) -> (label: EventParameter.EventLabel.Campus, value: String)? { + switch groupIndex { + case 0: + guard let state = RecruitState(rawValue: title) else { + return nil + } + return (.teamRecruitmentFilterStatus, state.logValue) + case 1: + guard let sort = RecruitListSort(rawValue: title) else { + return nil + } + return (.teamRecruitmentFilterSort, sort.logValue) + case 2: + return (.teamRecruitmentFilterCategory, RecruitCategory(rawValue: title)?.rawValue ?? "전체") + case 3: + return (.teamRecruitmentFilterMethod, RecruitMeetingType(rawValue: title)?.logValue ?? "전체") + default: + return nil + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index c43d6dbd5..6092ad4e4 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -47,8 +47,8 @@ final class RecruitListHostingController: UIHostingController, switch action { case .configureRightButtons(let hasNotification): updateNotificationBarButton(hasNotification) - case .showFilterBottomSheet(let filterState, let onApplyTapped): - showFilterBottomSheet(filterState, onApplyTapped) + case .showFilterBottomSheet(let filterState, let onFilterItemTapped, let onResetTapped, let onApplyTapped): + showFilterBottomSheet(filterState, onFilterItemTapped, onResetTapped, onApplyTapped) case .showToast(let message): showToastMessage(message: message, bottomInset: toastMessageBottomInset) case .showLoginToast: @@ -176,10 +176,14 @@ extension RecruitListHostingController { extension RecruitListHostingController { private func showFilterBottomSheet( _ filterState: RecruitListFilter, + _ onFilterItemTapped: @escaping (Int, FilterItemModel)->Bool, + _ onResetTapped: @escaping ()->Void, _ onApplyTapped: @escaping ([FilterGroupModel])->Void ) { let filterBottomSheetView = FilterBottomSheetView( groupModels: filterState.toGroupModels(), + onFilterItemTapped: onFilterItemTapped, + onResetTapped: onResetTapped, onApplyTapped: onApplyTapped ) let bottomSheetViewController = BottomSheetViewControllerB(contentView: filterBottomSheetView) diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift index 485ee0e48..d7af5df13 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -10,7 +10,12 @@ import SwiftUI struct RecruitListView: ActionBindableView { enum Action { case configureRightButtons(hasUnreadNotification: Bool) - case showFilterBottomSheet(filterState: RecruitListFilter, onApplyTapped: ([FilterGroupModel])->Void) + case showFilterBottomSheet( + filterState: RecruitListFilter, + onFilterItemTapped: (Int, FilterItemModel)->Bool, + onResetTapped: ()->Void, + onApplyTapped: ([FilterGroupModel])->Void + ) case showToast(message: String) case showLoginToast case showProfilePostToast @@ -46,7 +51,17 @@ struct RecruitListView: ActionBindableView { viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentFilter, .click, "필터")) sendAction(.showFilterBottomSheet( filterState: viewModel.filterState, + onFilterItemTapped: { groupIndex, item in + if let event = RecruitListFilter.logEvent(groupIndex: groupIndex, title: item.title) { + viewModel.execute(.logEvent(event.label, .click, event.value)) + } + return true + }, + onResetTapped: { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentFilterReset, .click, "초기화")) + }, onApplyTapped: { filterGroups in + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentFilterApply, .click, "적용하기")) viewModel.execute(.updateFilter(filterState: RecruitListFilter(from: filterGroups))) } )) From c75d3e6ba677cb12106bda7bc2645ab1687de7ab Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 06:33:00 +0900 Subject: [PATCH 101/123] =?UTF-8?q?log:=203.=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=ED=81=B4=EB=A6=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 1 + Koin/Presentation/Recruit/RecruitList/RecruitListView.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index d5e673fa0..79d041fe7 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -244,6 +244,7 @@ enum EventParameter { case teamRecruitmentFilterMethod = "team_recruitment_filter_method" case teamRecruitmentFilterReset = "team_recruitment_filter_reset" case teamRecruitmentFilterApply = "team_recruitment_filter_apply" + case teamRecruitmentPostSelect = "team_recruitment_post_select" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift index d7af5df13..96578e606 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -85,6 +85,7 @@ struct RecruitListView: ActionBindableView { LazyVStack(spacing: 8) { ForEach(recruits) { recruit in Button { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentPostSelect, .click, recruit.title)) sendAction(.showRecruitData(id: recruit.id)) } label: { RecruitListRowView(model: recruit) From 95c455a9cf3953e6175f46ccf881953cc77d9a55 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 06:40:29 +0900 Subject: [PATCH 102/123] =?UTF-8?q?log:=204.=20=EB=AA=A8=EC=A7=91=EA=B8=80?= =?UTF-8?q?=20=EC=83=81=EC=84=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 2 ++ .../Recruit/RecruitData/RecruitDataView.swift | 5 ++++- .../RecruitData/RecruitDataViewModel.swift | 6 ++++++ .../RecruitListHostingController.swift | 8 +++++++- ...cruitMyApplicationListHostingController.swift | 2 ++ .../RecruitMyPostListHostingController.swift | 2 ++ .../RecruitPost/RecruitPostViewController.swift | 16 ++++++++++++++-- 7 files changed, 37 insertions(+), 4 deletions(-) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index 79d041fe7..0f1d431ee 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -245,6 +245,8 @@ enum EventParameter { case teamRecruitmentFilterReset = "team_recruitment_filter_reset" case teamRecruitmentFilterApply = "team_recruitment_filter_apply" case teamRecruitmentPostSelect = "team_recruitment_post_select" + case teamRecruitmentPostApply = "team_recruitment_post_apply" + case teamRecruitmentPostApplicantCheck = "team_recruitment_post_applicant_check" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift index 2ab790001..9fd2192b6 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift @@ -84,9 +84,12 @@ extension RecruitDataView { } if data.isAuthor { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentPostApplicantCheck, .click, data.title)) sendAction(.showApplicant) return - } else if data.canApply { + } + if data.canApply { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentPostApply, .click, data.title)) sendAction(.showApply) return } diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift index 5d96abf23..081cf0a89 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift @@ -17,6 +17,7 @@ final class RecruitDataViewModel: SwiftUIViewModelProtocol { case delete case applicationSubmitted case didShowToast + case logEvent(EventLabelType, EventParameter.EventCategory, Any) } // MARK: - State @@ -35,6 +36,7 @@ final class RecruitDataViewModel: SwiftUIViewModelProtocol { private let recruitId: Int private let fetchRecruitDataUseCase: FetchRecruitDataUseCase private let deleteRecruitDataUseCase: DeleteRecruitDataUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase private var fetchTask: Task? private var deleteTask: Task? @@ -42,10 +44,12 @@ final class RecruitDataViewModel: SwiftUIViewModelProtocol { init( fetchRecruitDataUseCase: FetchRecruitDataUseCase, deleteRecruitDataUseCase: DeleteRecruitDataUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase, recruitId: Int ) { self.fetchRecruitDataUseCase = fetchRecruitDataUseCase self.deleteRecruitDataUseCase = deleteRecruitDataUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase self.recruitId = recruitId } @@ -60,6 +64,8 @@ final class RecruitDataViewModel: SwiftUIViewModelProtocol { applicationSubmitted() case .didShowToast: errorMessage = nil + case let .logEvent(label, category, value): + logAnalyticsEventUseCase.execute(label: label, category: category, value: value) } } } diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index 6092ad4e4..89e01161b 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -72,7 +72,13 @@ extension RecruitListHostingController: RecruitDataHostingControllerDelegate { let repository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchUseCase = DefaultFetchRecruitDataUseCase(repository: repository) let deleteUseCase = DefaultDeleteRecruitDataUseCase(repository: repository) - let viewModel = RecruitDataViewModel(fetchRecruitDataUseCase: fetchUseCase, deleteRecruitDataUseCase: deleteUseCase, recruitId: id) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) + let viewModel = RecruitDataViewModel( + fetchRecruitDataUseCase: fetchUseCase, + deleteRecruitDataUseCase: deleteUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase, + recruitId: id + ) let controller = RecruitDataHostingController( rootView: RecruitDataView(viewModel: viewModel), delegate: self diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift index 61dbaa2c7..5f79cb12d 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift @@ -47,9 +47,11 @@ extension RecruitMyApplicationListHostingController { let repository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchUseCase = DefaultFetchRecruitDataUseCase(repository: repository) let deleteUseCase = DefaultDeleteRecruitDataUseCase(repository: repository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitDataViewModel( fetchRecruitDataUseCase: fetchUseCase, deleteRecruitDataUseCase: deleteUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase, recruitId: id ) let controller = RecruitDataHostingController( diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift index eb5419b3d..cd2f49e09 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift @@ -53,9 +53,11 @@ extension RecruitMyPostListHostingController: RecruitDataHostingControllerDelega let repository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchUseCase = DefaultFetchRecruitDataUseCase(repository: repository) let deleteUseCase = DefaultDeleteRecruitDataUseCase(repository: repository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitDataViewModel( fetchRecruitDataUseCase: fetchUseCase, deleteRecruitDataUseCase: deleteUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase, recruitId: id ) let controller = RecruitDataHostingController( diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift index 72ca792c7..9a4c5f9d1 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift @@ -304,7 +304,13 @@ extension RecruitPostViewController { let repository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchUseCase = DefaultFetchRecruitDataUseCase(repository: repository) let deleteUseCase = DefaultDeleteRecruitDataUseCase(repository: repository) - let viewModel = RecruitDataViewModel(fetchRecruitDataUseCase: fetchUseCase, deleteRecruitDataUseCase: deleteUseCase, recruitId: id) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) + let viewModel = RecruitDataViewModel( + fetchRecruitDataUseCase: fetchUseCase, + deleteRecruitDataUseCase: deleteUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase, + recruitId: id + ) let viewController = RecruitDataHostingController( rootView: RecruitDataView(viewModel: viewModel), delegate: nil @@ -321,7 +327,13 @@ extension RecruitPostViewController { let repository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchUseCase = DefaultFetchRecruitDataUseCase(repository: repository) let deleteUseCase = DefaultDeleteRecruitDataUseCase(repository: repository) - let viewModel = RecruitDataViewModel(fetchRecruitDataUseCase: fetchUseCase, deleteRecruitDataUseCase: deleteUseCase, recruitId: id) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) + let viewModel = RecruitDataViewModel( + fetchRecruitDataUseCase: fetchUseCase, + deleteRecruitDataUseCase: deleteUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase, + recruitId: id + ) let viewController = RecruitDataHostingController( rootView: RecruitDataView(viewModel: viewModel), delegate: nil From 557f3d6cd655168e17af0b2aae53f7bc297965dd Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 06:53:53 +0900 Subject: [PATCH 103/123] =?UTF-8?q?log:=204-1.=20=EB=AA=A8=EC=A7=91?= =?UTF-8?q?=EA=B8=80=20=EC=83=81=EC=84=B8=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 3 ++ .../RecruitDataHostingController.swift | 37 ++++++++++++++----- .../Recruit/RecruitData/RecruitDataView.swift | 8 ++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index 0f1d431ee..61511ee89 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -247,6 +247,9 @@ enum EventParameter { case teamRecruitmentPostSelect = "team_recruitment_post_select" case teamRecruitmentPostApply = "team_recruitment_post_apply" case teamRecruitmentPostApplicantCheck = "team_recruitment_post_applicant_check" + case teamRecruitmentPostDelete = "team_recruitment_post_delete" + case teamRecruitmentPostDeleteConfirm = "team_recruitment_post_delete_confirm" + case teamRecruitmentPostDeleteCancel = "team_recruitment_post_delete_cancel" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index c0803d939..ab5ca7d84 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -107,6 +107,11 @@ extension RecruitDataHostingController { self?.navigateToEdit() }, onDeleteButtonTapped: { [weak self] in + self?.rootView.makeLogAnalyticsEvent( + label: EventParameter.EventLabel.Campus.teamRecruitmentPostDelete, + category: .click, + value: "삭제하기" + ) self?.showDeleteModal() } ) @@ -118,19 +123,33 @@ extension RecruitDataHostingController { } private func showDeleteModal() { + let onCancelButtonTapped: ()->Void = { [weak self] in + self?.rootView.makeLogAnalyticsEvent( + label: EventParameter.EventLabel.Campus.teamRecruitmentPostDeleteCancel, + category: .click, + value: "취소하기" + ) + } let onDeleteButtonTapped: ()->Void = { [weak self] in + self?.rootView.makeLogAnalyticsEvent( + label: EventParameter.EventLabel.Campus.teamRecruitmentPostDeleteConfirm, + category: .click, + value: "삭제하기" + ) self?.rootView.didTapDelete() } - let modalViewController = KoinModalViewController(configuration: .init( - appearance: .new, - content: .singleTitle(text: "해당 모집글을 삭제하시겠습니까?"), - button: .buttons( - leftButtonTitle: "취소하기", - leftButtonAction: nil, - rightButtonTitle: "삭제하기", - rightButtonAction: onDeleteButtonTapped + let modalViewController = KoinModalViewController( + configuration: .init( + appearance: .new, + content: .singleTitle(text: "해당 모집글을 삭제하시겠습니까?"), + button: .buttons( + leftButtonTitle: "취소하기", + leftButtonAction: onCancelButtonTapped, + rightButtonTitle: "삭제하기", + rightButtonAction: onDeleteButtonTapped + ) ) - )) + ) present(modalViewController, animated: true) } } diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift index 9fd2192b6..46a66441f 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift @@ -75,6 +75,14 @@ struct RecruitDataView: ActionBindableView { func didSubmitApplication() { viewModel.execute(.applicationSubmitted) } + + func makeLogAnalyticsEvent( + label: EventLabelType, + category: EventParameter.EventCategory, + value: Any + ) { + viewModel.execute(.logEvent(label, category, value)) + } } extension RecruitDataView { From 9173c559d2063922b6e942017a66f54963115da7 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 07:30:07 +0900 Subject: [PATCH 104/123] =?UTF-8?q?fix:=20=EB=88=84=EB=9D=BD=EB=90=9C=20?= =?UTF-8?q?=EB=AA=A8=EC=A7=91=EA=B8=80=20=EC=88=98=EC=A0=95=ED=95=98?= =?UTF-8?q?=EA=B8=B0/=EB=93=B1=EB=A1=9D=ED=95=98=EA=B8=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitPostViewController.swift | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift index 9a4c5f9d1..6d1e52712 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift @@ -28,6 +28,22 @@ final class RecruitPostViewController: UIViewController { return "수정하기" } } + private var submitModalMessage: String { + switch viewModel.mode { + case .post: + return "해당 모집글을 등록하시겠습니까?" + case .modify: + return "해당 모집글을 수정하시겠습니까?" + } + } + private var submitLogValue: String { + switch viewModel.mode { + case .post: + return "등록하기" + case .modify: + return "수정 완료" + } + } private var completionMessage: String { switch viewModel.mode { case .post: @@ -381,10 +397,37 @@ extension RecruitPostViewController { @objc private func postButtonTapped() { guard !dropdownHost.isPresenting else { return } - postButton.isUserInteractionEnabled = false view.endEditing(true) + showSubmitModal() + } + + private func submit() { + postButton.isUserInteractionEnabled = false inputSubject.send(.submit(request)) } + private func showSubmitModal() { + let onCancelButtonTapped: ()->Void = { [weak self] in + self?.logEvent(post: .teamRecruitmentRecruitSubmitCancel, modify: .teamRecruitmentPostEditSubmitCancel, value: "취소하기") + } + let onSubmitButtonTapped: ()->Void = { [weak self] in + guard let self else { return } + logEvent(post: .teamRecruitmentRecruitSubmitConfirm, modify: .teamRecruitmentPostEditSubmitConfirm, value: submitButtonTitle) + submit() + } + let modalViewController = KoinModalViewController( + configuration: .init( + appearance: .new, + content: .singleTitle(text: submitModalMessage), + button: .buttons( + leftButtonTitle: "취소하기", + leftButtonAction: onCancelButtonTapped, + rightButtonTitle: submitButtonTitle, + rightButtonAction: onSubmitButtonTapped + ) + ) + ) + present(modalViewController, animated: true) + } } extension RecruitPostViewController { From 658003bb9d73f3fe4c43438b251b008541751c05 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 07:35:50 +0900 Subject: [PATCH 105/123] =?UTF-8?q?log:=204-2.=20=EB=AA=A8=EC=A7=91?= =?UTF-8?q?=EA=B8=80=ED=8E=B8=EC=A7=91,=205-1.=20=EB=AA=A8=EC=A7=91?= =?UTF-8?q?=EA=B8=80=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 13 +++++++++++ .../RecruitDataHostingController.swift | 9 +++++++- .../RecruitListHostingController.swift | 4 +++- .../RecruitPostViewController.swift | 22 +++++++++++++++++++ .../RecruitPost/RecruitPostViewModel.swift | 6 +++++ .../Subviews/RecruitPostRoleView.swift | 2 ++ 6 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index 61511ee89..0e0103944 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -250,6 +250,19 @@ enum EventParameter { case teamRecruitmentPostDelete = "team_recruitment_post_delete" case teamRecruitmentPostDeleteConfirm = "team_recruitment_post_delete_confirm" case teamRecruitmentPostDeleteCancel = "team_recruitment_post_delete_cancel" + case teamRecruitmentPostEdit = "team_recruitment_post_edit" + case teamRecruitmentPostEditCategory = "team_recruitment_post_edit_category" + case teamRecruitmentPostEditMethod = "team_recruitment_post_edit_method" + case teamRecruitmentPostEditRole = "team_recruitment_post_edit_role" + case teamRecruitmentPostEditSubmit = "team_recruitment_post_edit_submit" + case teamRecruitmentPostEditSubmitCancel = "team_recruitment_post_edit_submit_cancel" + case teamRecruitmentPostEditSubmitConfirm = "team_recruitment_post_edit_submit_confirm" + case teamRecruitmentRecruitCategory = "team_recruitment_recruit_category" + case teamRecruitmentRecruitMethod = "team_recruitment_recruit_method" + case teamRecruitmentRecruitRole = "team_recruitment_recruit_role" + case teamRecruitmentRecruitSubmit = "team_recruitment_recruit_submit" + case teamRecruitmentRecruitSubmitCancel = "team_recruitment_recruit_submit_cancel" + case teamRecruitmentRecruitSubmitConfirm = "team_recruitment_recruit_submit_confirm" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index ab5ca7d84..eb4f29b7b 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -104,6 +104,11 @@ extension RecruitDataHostingController { @objc private func rightBarButtonItemTapped() { let popUpViewController = RecruitDataPopUpViewController( onEditButtonTapped: { [weak self] in + self?.rootView.makeLogAnalyticsEvent( + label: EventParameter.EventLabel.Campus.teamRecruitmentPostEdit, + category: .click, + value: "편집하기" + ) self?.navigateToEdit() }, onDeleteButtonTapped: { [weak self] in @@ -222,10 +227,12 @@ extension RecruitDataHostingController { let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let postRecruitUseCase = DefaultPostRecruitUseCase(repository: recruitRepository) let modifyRecruitUseCase = DefaultModifyRecruitUseCase(repository: recruitRepository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitPostViewModel( mode: .modify(data: data), postRecruitUseCase: postRecruitUseCase, - modifyRecruitUseCase: modifyRecruitUseCase + modifyRecruitUseCase: modifyRecruitUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase ) let viewController = RecruitPostViewController(viewModel: viewModel) navigationController?.pushViewController(viewController, animated: true) diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index 89e01161b..35372c179 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -154,10 +154,12 @@ extension RecruitListHostingController { let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let postRecruitUseCase = DefaultPostRecruitUseCase(repository: recruitRepository) let modifyRecruitUseCase = DefaultModifyRecruitUseCase(repository: recruitRepository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitPostViewModel( mode: .post, postRecruitUseCase: postRecruitUseCase, - modifyRecruitUseCase: modifyRecruitUseCase + modifyRecruitUseCase: modifyRecruitUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase ) let viewController = RecruitPostViewController(viewModel: viewModel) navigationController?.pushViewController(viewController, animated: true) diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift index 6d1e52712..23553b1cd 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift @@ -106,6 +106,7 @@ final class RecruitPostViewController: UIViewController { private lazy var categoryDropdown = dropdownHost.makeDropdown( anchor: categoryView.dropdownAnchor, contentView: RecruitPostCategoryDropdownView { [weak self] category in + self?.logEvent(post: .teamRecruitmentRecruitCategory, modify: .teamRecruitmentPostEditCategory, value: category.rawValue) self?.request.category = category self?.categoryView.configure(category: category) }, @@ -201,6 +202,7 @@ extension RecruitPostViewController { }.store(in: &subscriptions) meetingTypeView.meetingTypeChangedPublisher.sink { [weak self] meetingType in + self?.logEvent(post: .teamRecruitmentRecruitMethod, modify: .teamRecruitmentPostEditMethod, value: meetingType.logValue) self?.request.meetingType = meetingType }.store(in: &subscriptions) @@ -250,9 +252,14 @@ extension RecruitPostViewController { }.store(in: &subscriptions) roleView.roleTypeChangedPublisher.sink { [weak self] roleType in + self?.logEvent(post: .teamRecruitmentRecruitRole, modify: .teamRecruitmentPostEditRole, value: "역할 구분 없이 모집하기") self?.request.type = roleType }.store(in: &subscriptions) + roleView.addRoleButtonTappedPublisher.sink { [weak self] in + self?.logEvent(post: .teamRecruitmentRecruitRole, modify: .teamRecruitmentPostEditRole, value: "역할 추가") + }.store(in: &subscriptions) + roleView.rolesChangedPublisher.sink { [weak self] roles in self?.request.roles = roles }.store(in: &subscriptions) @@ -398,6 +405,7 @@ extension RecruitPostViewController { @objc private func postButtonTapped() { guard !dropdownHost.isPresenting else { return } view.endEditing(true) + logEvent(post: .teamRecruitmentRecruitSubmit, modify: .teamRecruitmentPostEditSubmit, value: submitLogValue) showSubmitModal() } @@ -405,6 +413,20 @@ extension RecruitPostViewController { postButton.isUserInteractionEnabled = false inputSubject.send(.submit(request)) } + + private func logEvent( + post postLabel: EventParameter.EventLabel.Campus, + modify modifyLabel: EventParameter.EventLabel.Campus, + value: Any + ) { + switch viewModel.mode { + case .post: + inputSubject.send(.logEvent(postLabel, .click, value)) + case .modify: + inputSubject.send(.logEvent(modifyLabel, .click, value)) + } + } + private func showSubmitModal() { let onCancelButtonTapped: ()->Void = { [weak self] in self?.logEvent(post: .teamRecruitmentRecruitSubmitCancel, modify: .teamRecruitmentPostEditSubmitCancel, value: "취소하기") diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift index 0b4f43203..fe6225364 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift @@ -18,6 +18,7 @@ final class RecruitPostViewModel: ViewModelProtocol { enum Input { case viewDidLoad case submit(RecruitPostRequest) + case logEvent(EventLabelType, EventParameter.EventCategory, Any) } enum Output { case updateLoading(Bool) @@ -31,6 +32,7 @@ final class RecruitPostViewModel: ViewModelProtocol { // MARK: - Properties private let postRecruitUseCase: PostRecruitUseCase private let modifyRecruitUseCase: ModifyRecruitUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase private let outputSubject = PassthroughSubject() private var subscriptions: Set = [] @@ -42,10 +44,12 @@ final class RecruitPostViewModel: ViewModelProtocol { mode: Mode, postRecruitUseCase: PostRecruitUseCase, modifyRecruitUseCase: ModifyRecruitUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase ) { self.mode = mode self.postRecruitUseCase = postRecruitUseCase self.modifyRecruitUseCase = modifyRecruitUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase } // MARK: - Public @@ -57,6 +61,8 @@ final class RecruitPostViewModel: ViewModelProtocol { applyDataIfModifying() case let .submit(form): submit(form) + case let .logEvent(label, category, value): + logAnalyticsEventUseCase.execute(label: label, category: category, value: value) } }.store(in: &subscriptions) diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift index a1101389e..685d26625 100644 --- a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift @@ -16,6 +16,7 @@ final class RecruitPostRoleView: UIView { let rolesChangedPublisher = PassthroughSubject<[RecruitRoleRequest], Never>() let numberOfGeneralMembersChangedPublisher = PassthroughSubject() let didChangeHeightPublisher = PassthroughSubject() + let addRoleButtonTappedPublisher = PassthroughSubject() // MARK: - Properties private var subscriptions: Set = [] @@ -108,6 +109,7 @@ extension RecruitPostRoleView { guard roleType == .roleBased, roles.count < maximumNumberOfRoles, totalRoleMembers < maximumNumberOfMembers else { return } + addRoleButtonTappedPublisher.send() tableView.endEditing(true) roles.append(.init()) pendingRowChangeIndex = roles.count - 1 From f79b4935f99399f65c976e6b86d243f753ac58c7 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 07:38:16 +0900 Subject: [PATCH 106/123] =?UTF-8?q?log:=205.=20=EB=AA=A8=EC=A7=91=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=ED=81=B4=EB=A6=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 1 + Koin/Presentation/Recruit/RecruitList/RecruitListView.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index 0e0103944..b7a0ac9c7 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -257,6 +257,7 @@ enum EventParameter { case teamRecruitmentPostEditSubmit = "team_recruitment_post_edit_submit" case teamRecruitmentPostEditSubmitCancel = "team_recruitment_post_edit_submit_cancel" case teamRecruitmentPostEditSubmitConfirm = "team_recruitment_post_edit_submit_confirm" + case teamRecruitmentRecruit = "team_recruitment_recruit" case teamRecruitmentRecruitCategory = "team_recruitment_recruit_category" case teamRecruitmentRecruitMethod = "team_recruitment_recruit_method" case teamRecruitmentRecruitRole = "team_recruitment_recruit_role" diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift index 96578e606..86c6314bd 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -154,6 +154,7 @@ struct RecruitListView: ActionBindableView { @ViewBuilder private var postButton: some View { Button { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentRecruit, .click, "모집하기")) guard UserDataManager.shared.isLoggedIn else { sendAction(.showLoginToast) return From 25a979b1e42f390095a7eb12f14757bbfb8ba1b0 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 09:36:03 +0900 Subject: [PATCH 107/123] =?UTF-8?q?log:=206.=20=EC=A7=80=EC=9B=90=EC=84=9C?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 11 +++ .../RecruitApplyViewController.swift | 67 +++++++++++++++---- .../RecruitApply/RecruitApplyViewModel.swift | 8 ++- .../Subviews/RecruitApplyFirstStepView.swift | 12 ++++ .../RecruitApplyRoleSelectionView.swift | 2 +- .../Subviews/RecruitApplySecondStepView.swift | 2 +- .../RecruitDataHostingController.swift | 4 +- .../RecruitProfilePostActivityTableView.swift | 11 +++ .../RecruitProfilePostSkillTableView.swift | 8 ++- 9 files changed, 105 insertions(+), 20 deletions(-) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index b7a0ac9c7..c06d63297 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -264,6 +264,17 @@ enum EventParameter { case teamRecruitmentRecruitSubmit = "team_recruitment_recruit_submit" case teamRecruitmentRecruitSubmitCancel = "team_recruitment_recruit_submit_cancel" case teamRecruitmentRecruitSubmitConfirm = "team_recruitment_recruit_submit_confirm" + case teamRecruitmentApplyLoad = "team_recruitment_apply_load" + case teamRecruitmentApplyMajorSelect = "team_recruitment_apply_major_select" + case teamRecruitmentApplySkillAdd = "team_recruitment_apply_skill_add" + case teamRecruitmentApplyActivityAdd = "team_recruitment_apply_activity_add" + case teamRecruitmentApplyActivityModify = "team_recruitment_apply_activity_modify" + case teamRecruitmentApplyActivityModifyComplete = "team_recruitment_apply_activity_modify_complete" + case teamRecruitmentApplyNext = "team_recruitment_apply_next" + case teamRecruitmentApplyRoleSelect = "team_recruitment_apply_role_select" + case teamRecruitmentApplySubmit = "team_recruitment_apply_submit" + case teamRecruitmentApplySubmitConfirm = "team_recruitment_apply_submit_confirm" + case teamRecruitmentApplySubmitCancel = "team_recruitment_apply_submit_cancel" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift index 148208757..4ed51fac6 100644 --- a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift @@ -62,6 +62,7 @@ final class RecruitApplyViewController: UIViewController { private lazy var dropdownHost = KoinDropdownHost(scrollView: firstStepView) private lazy var departmentDropdownContentView = RecruitProfilePostDepartmentDropdownView { [weak self] department in guard let self else { return } + inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentApplyMajorSelect, .click, department)) basicInfo.department = department firstStepView.configure(department: department) updateNextButtonState() @@ -130,7 +131,30 @@ extension RecruitApplyViewController { .store(in: &subscriptions) firstStepView.loadInfoButtonTappedPublisher - .sink { [weak self] in self?.inputSubject.send(.loadRecruitProfile) } + .sink { [weak self] in + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentApplyLoad, .click, "회원정보 불러오기")) + self?.inputSubject.send(.loadRecruitProfile) + } + .store(in: &subscriptions) + firstStepView.skillAddButtonTappedPublisher + .sink { [weak self] in + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentApplySkillAdd, .click, "기술 / 자격증 추가")) + } + .store(in: &subscriptions) + firstStepView.activityAddButtonTappedPublisher + .sink { [weak self] in + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentApplyActivityAdd, .click, "활동 이력 추가")) + } + .store(in: &subscriptions) + firstStepView.activityEditButtonTappedPublisher + .sink { [weak self] in + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentApplyActivityModify, .click, "수정")) + } + .store(in: &subscriptions) + firstStepView.activityEditCompleteButtonTappedPublisher + .sink { [weak self] in + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentApplyActivityModifyComplete, .click, "수정하기")) + } .store(in: &subscriptions) firstStepView.nicknameChangedPublisher .sink { [weak self] value in @@ -186,8 +210,9 @@ extension RecruitApplyViewController { .store(in: &subscriptions) secondStepView.selectedRoleChangedPublisher - .sink { [weak self] value - in self?.applyRequest.selectedRole = value + .sink { [weak self] value in + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentApplyRoleSelect, .click, value.name)) + self?.applyRequest.selectedRole = value self?.updateApplyButtonState() } .store(in: &subscriptions) @@ -215,6 +240,7 @@ extension RecruitApplyViewController { @objc private func nextButtonTapped() { guard isFirstStepValid, !firstStepView.isEditingActivity else { return } + inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentApplyNext, .click, "다음")) isFirstStep = false view.endEditing(true) dropdownHost.dismissPresented() @@ -231,9 +257,19 @@ extension RecruitApplyViewController { } @objc private func applyButtonTapped() { - guard isSecondStepValid else { return } - let apply = { [weak self] in - guard let self else { return } + guard isSecondStepValid else { + return + } + inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentApplySubmit, .click, "지원하기")) + + let onCancelTapped: ()->Void = { [weak self] in + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentApplySubmitCancel, .click, "취소하기")) + } + let onApplyTapped = { [weak self] in + guard let self else { + return + } + inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentApplySubmitConfirm, .click, "지원하기")) inputSubject.send(.apply( recruitmentId: recruit.id, basicInfo: basicInfo, @@ -241,15 +277,18 @@ extension RecruitApplyViewController { applyRequest: applyRequest )) } - let modalViewController = KoinModalViewController(configuration: .init( - appearance: .new, - content: .singleTitle(text: "해당 팀원 모집에 지원하시겠어요?"), - button: .buttons( - leftButtonTitle: "취소하기", - rightButtonTitle: "지원하기", - rightButtonAction: apply + let modalViewController = KoinModalViewController( + configuration: .init( + appearance: .new, + content: .singleTitle(text: "해당 팀원 모집에 지원하시겠어요?"), + button: .buttons( + leftButtonTitle: "취소하기", + leftButtonAction: onCancelTapped, + rightButtonTitle: "지원하기", + rightButtonAction: onApplyTapped + ) ) - )) + ) present(modalViewController, animated: true) } diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift index b4a49f7ce..9f1933f2a 100644 --- a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift @@ -19,6 +19,7 @@ final class RecruitApplyViewModel: ViewModelProtocol { profileRequest: RecruitProfileRequest, applyRequest: RecruitApplyRequest ) + case logEvent(EventLabelType, EventParameter.EventCategory, Any) } enum Output { @@ -35,6 +36,7 @@ final class RecruitApplyViewModel: ViewModelProtocol { private let modifyBasicInfoUseCase: ModifyBasicInfoUseCase private let upsertMyRecruitProfileUseCase: UpsertMyRecruitProfileUseCase private let applyRecruitUseCase: ApplyRecruitUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase private let outputSubject = PassthroughSubject() private var subscriptions = Set() private var isLoadingProfile = false @@ -46,13 +48,15 @@ final class RecruitApplyViewModel: ViewModelProtocol { fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase, modifyBasicInfoUseCase: ModifyBasicInfoUseCase, upsertMyRecruitProfileUseCase: UpsertMyRecruitProfileUseCase, - applyRecruitUseCase: ApplyRecruitUseCase + applyRecruitUseCase: ApplyRecruitUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase ) { self.fetchDeptListUseCase = fetchDeptListUseCase self.fetchMyRecruitProfileUseCase = fetchMyRecruitProfileUseCase self.modifyBasicInfoUseCase = modifyBasicInfoUseCase self.upsertMyRecruitProfileUseCase = upsertMyRecruitProfileUseCase self.applyRecruitUseCase = applyRecruitUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase } func transform(with input: AnyPublisher) -> AnyPublisher { @@ -70,6 +74,8 @@ final class RecruitApplyViewModel: ViewModelProtocol { profileRequest: profileRequest, applyRequest: applyRequest ) + case let .logEvent(label, category, value): + self?.logAnalyticsEventUseCase.execute(label: label, category: category, value: value) } } .store(in: &subscriptions) diff --git a/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift index e7f8685c6..92b651214 100644 --- a/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift +++ b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift @@ -26,6 +26,18 @@ final class RecruitApplyFirstStepView: UIScrollView { var skillsChangedPublisher: AnyPublisher<[String], Never> { skillTableView.skillsChangedPublisher.eraseToAnyPublisher() } + var skillAddButtonTappedPublisher: AnyPublisher { + skillTableView.addButtonTappedPublisher.eraseToAnyPublisher() + } + var activityAddButtonTappedPublisher: AnyPublisher { + activityTableView.addButtonTappedPublisher.eraseToAnyPublisher() + } + var activityEditButtonTappedPublisher: AnyPublisher { + activityTableView.editButtonTappedPublisher.eraseToAnyPublisher() + } + var activityEditCompleteButtonTappedPublisher: AnyPublisher { + activityTableView.editCompleteButtonTappedPublisher.eraseToAnyPublisher() + } var activitiesChangedPublisher: AnyPublisher<[RecruitProfileActivityRequest], Never> { activityTableView.activitiesChangedPublisher.eraseToAnyPublisher() } diff --git a/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyRoleSelectionView.swift b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyRoleSelectionView.swift index 3ed6927f7..91b3f1917 100644 --- a/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyRoleSelectionView.swift +++ b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyRoleSelectionView.swift @@ -13,7 +13,7 @@ import UIKit final class RecruitApplyRoleSelectionView: UIView { // MARK: - Publisher - let selectedRoleChangedPublisher = PassthroughSubject() + let selectedRoleChangedPublisher = PassthroughSubject() // MARK: - Properties private let roles: [RecruitRole] diff --git a/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplySecondStepView.swift b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplySecondStepView.swift index 02ef5000c..076c6b195 100644 --- a/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplySecondStepView.swift +++ b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplySecondStepView.swift @@ -13,7 +13,7 @@ import UIKit final class RecruitApplySecondStepView: UIScrollView { // MARK: - Publisher - let selectedRoleChangedPublisher = PassthroughSubject() + let selectedRoleChangedPublisher = PassthroughSubject() var motivationChangedPublisher: AnyPublisher { motivationView.textChangedPublisher.eraseToAnyPublisher() } diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index eb4f29b7b..ffba51a46 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -181,12 +181,14 @@ extension RecruitDataHostingController { } let userRepository = DefaultUserRepository(service: DefaultUserService()) let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitApplyViewModel( fetchDeptListUseCase: DefaultFetchDeptListUseCase(timetableRepository: DefaultTimetableRepository(service: DefaultTimetableService())), fetchMyRecruitProfileUseCase: DefaultFetchMyRecruitProfileUseCase(repository: recruitRepository), modifyBasicInfoUseCase: DefaultModifyBasicInfoUseCase(repository: userRepository), upsertMyRecruitProfileUseCase: DefaultUpsertMyRecruitProfileUseCase(repository: recruitRepository), - applyRecruitUseCase: DefaultApplyRecruitUseCase(repository: recruitRepository) + applyRecruitUseCase: DefaultApplyRecruitUseCase(repository: recruitRepository), + logAnalyticsEventUseCase: logAnalyticsEventUseCase ) let viewController = RecruitApplyViewController( viewModel: viewModel, diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift index b552b3fef..74ad35479 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift @@ -28,6 +28,10 @@ final class RecruitProfilePostActivityTableView: UITableView { } // MARK: - Properties + let addButtonTappedPublisher = PassthroughSubject() + let editButtonTappedPublisher = PassthroughSubject() + let addCompleteButtonTappedPublisher = PassthroughSubject() + let editCompleteButtonTappedPublisher = PassthroughSubject() let activitiesChangedPublisher = PassthroughSubject<[RecruitProfileActivityRequest], Never>() let didChangeHeightPublisher = PassthroughSubject() @@ -98,6 +102,7 @@ final class RecruitProfilePostActivityTableView: UITableView { extension RecruitProfilePostActivityTableView { private func addActivity() { + addButtonTappedPublisher.send() dropdownHost?.dismissPresented() let indexPath = IndexPath(row: rows.count, section: 0) rows.append(Row( @@ -112,6 +117,7 @@ extension RecruitProfilePostActivityTableView { private func editActivity(at indexPath: IndexPath) { guard rows.indices.contains(indexPath.row), let committed = rows[indexPath.row].committed else { return } + editButtonTappedPublisher.send() dropdownHost?.dismissPresented() rows[indexPath.row].draft = committed rows[indexPath.row].mode = .editing @@ -122,6 +128,11 @@ extension RecruitProfilePostActivityTableView { private func completeActivity(at indexPath: IndexPath) { guard rows.indices.contains(indexPath.row), rows[indexPath.row].draft.isValid else { return } + if rows[indexPath.row].committed == nil { + addCompleteButtonTappedPublisher.send() + } else { + editCompleteButtonTappedPublisher.send() + } dropdownHost?.dismissPresented() rows[indexPath.row].committed = rows[indexPath.row].draft rows[indexPath.row].mode = .display diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift index 51fdcb696..54e4caff0 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift @@ -18,6 +18,7 @@ final class RecruitProfilePostSkillTableView: UITableView { // MARK: - Properties let skillsChangedPublisher = PassthroughSubject<[String], Never>() let didChangeHeightPublisher = PassthroughSubject() + let addButtonTappedPublisher = PassthroughSubject() private var skills: [String] = [] private var footerSubscriptions = Set() @@ -129,10 +130,13 @@ extension RecruitProfilePostSkillTableView: UITableViewDataSource { withIdentifier: RecruitProfilePostAddTableFooterView.identifier ) as? RecruitProfilePostAddTableFooterView else { return nil } - footer.configure(title: "보유기술/자격증 추가") + footer.configure(title: "기술 / 자격증 추가") footerSubscriptions.removeAll() footer.addButtonTappedPublisher - .sink { [weak self] in self?.addSkill() } + .sink { [weak self] in + self?.addButtonTappedPublisher.send() + self?.addSkill() + } .store(in: &footerSubscriptions) return footer } From 1e32c4f442bd2f1509093ca0a59ab1d961e7a109 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 09:45:54 +0900 Subject: [PATCH 108/123] =?UTF-8?q?log:=207.=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=ED=81=B4=EB=A6=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 1 + .../RecruitListHostingController.swift | 5 +++++ .../RecruitList/RecruitListViewModel.swift | 16 +++++++++++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index c06d63297..934c311ce 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -275,6 +275,7 @@ enum EventParameter { case teamRecruitmentApplySubmit = "team_recruitment_apply_submit" case teamRecruitmentApplySubmitConfirm = "team_recruitment_apply_submit_confirm" case teamRecruitmentApplySubmitCancel = "team_recruitment_apply_submit_cancel" + case teamRecruitmentProfile = "team_recruitment_profile" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index 35372c179..9c72c24b9 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -224,6 +224,11 @@ extension RecruitListHostingController { showLoginToast() return } + rootView.makeLogAnalyticsEvent( + label: EventParameter.EventLabel.Campus.teamRecruitmentProfile, + category: .click, + value: "프로필" + ) showRecruitProfile() } } diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift index 50055a7f6..428428b1a 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift @@ -102,7 +102,7 @@ final class RecruitListViewModel: SwiftUIViewModelProtocol { case .updateProfile(let profile): myRecruitProfile = profile case let .logEvent(label, category, value): - logAnalyticsEventUseCase.execute(label: label, category: category, value: value) + makeLogAnalyticsEvent(label: label, category: category, value: value) } } } @@ -209,3 +209,17 @@ extension RecruitListViewModel { } } } + +extension RecruitListViewModel { + private func makeLogAnalyticsEvent( + label: EventLabelType, + category: EventParameter.EventCategory, + value: Any + ) { + logAnalyticsEventUseCase.execute( + label: label, + category: category, + value: value + ) + } +} From 1d305599c3c72ed6571fa799f8f1baf1e9438bbf Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 09:54:27 +0900 Subject: [PATCH 109/123] =?UTF-8?q?log:=207-1.=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EB=82=B4=EB=B6=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 4 ++++ .../RecruitList/RecruitListHostingController.swift | 6 +++++- .../Recruit/RecruitProfile/RecruitProfileView.swift | 4 ++++ .../RecruitProfile/RecruitProfileViewModel.swift | 10 +++++++++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index 934c311ce..b7b2ddd90 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -276,6 +276,10 @@ enum EventParameter { case teamRecruitmentApplySubmitConfirm = "team_recruitment_apply_submit_confirm" case teamRecruitmentApplySubmitCancel = "team_recruitment_apply_submit_cancel" case teamRecruitmentProfile = "team_recruitment_profile" + case teamRecruitmentProfileModify = "team_recruitment_profile_modify" + case teamRecruitmentProfileCreate = "team_recruitment_profile_create" + case teamRecruitmentProfileCreated = "team_recruitment_profile_created" + case teamRecruitmentProfileApplied = "team_recruitment_profile_applied" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index 9c72c24b9..2df65c3d9 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -144,7 +144,11 @@ extension RecruitListHostingController { private func showRecruitProfile() { let repository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchMyRecruitProfileUseCase = DefaultFetchMyRecruitProfileUseCase(repository: repository) - let viewModel = RecruitProfileViewModel(fetchMyRecruitProfileUseCase: fetchMyRecruitProfileUseCase) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) + let viewModel = RecruitProfileViewModel( + fetchMyRecruitProfileUseCase: fetchMyRecruitProfileUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase + ) let viewController = RecruitProfileHostingController( rootView: RecruitProfileView(viewModel: viewModel) ) diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift index d4383a8fc..1c369236b 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift @@ -37,10 +37,12 @@ struct RecruitProfileView: ActionBindableView { VStack(spacing: 24) { if let profile = viewModel.profile { RecruitProfileCardView(profile: profile) { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentProfileModify, .click, "프로필 수정하기")) sendAction(.showProfileModify(profile: profile)) } } else { RecruitProfileEmptyCardView { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentProfileCreate, .click, "프로필 작성하기")) sendAction(.showProfilePost) } } @@ -50,6 +52,7 @@ struct RecruitProfileView: ActionBindableView { title: viewModel.profile == nil ? "내가 작성한 모집글 모아보기" : "내가 작성한 모집글", description: "작성자 모집글과 지원자를 한눈에 확인할 수 있어요." ) { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentProfileCreated, .click, "내가 작성한 모집글")) sendAction(.showMyPost) } @@ -58,6 +61,7 @@ struct RecruitProfileView: ActionBindableView { title: viewModel.profile == nil ? "내가 지원한 모집글 모아보기" : "내가 지원한 모집글", description: "지원한 모집글과 지원 상태를 확인 할 수 있어요." ) { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentProfileApplied, .click, "내가 지원한 모집글")) sendAction(.showMyApplication) } } diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift index c72315749..6e1f4e47f 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift @@ -15,6 +15,7 @@ final class RecruitProfileViewModel: SwiftUIViewModelProtocol { case didAppear case didShowToast case profileUpdated(RecruitProfile) + case logEvent(EventLabelType, EventParameter.EventCategory, Any) } // MARK: - State @@ -25,10 +26,15 @@ final class RecruitProfileViewModel: SwiftUIViewModelProtocol { // MARK: - UseCase private let fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase // MARK: - Initializer - init(fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase) { + init( + fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase + ) { self.fetchMyRecruitProfileUseCase = fetchMyRecruitProfileUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase } // MARK: - Public @@ -40,6 +46,8 @@ final class RecruitProfileViewModel: SwiftUIViewModelProtocol { errorMessage = nil case let .profileUpdated(profile): self.profile = profile + case let .logEvent(label, category, value): + logAnalyticsEventUseCase.execute(label: label, category: category, value: value) } } } From e6a32796640b0b376988f676553a53d1970d79d0 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 10:39:03 +0900 Subject: [PATCH 110/123] =?UTF-8?q?log:=207-1-1.=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=88=98=EC=A0=95=ED=95=98=EA=B8=B0=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80,=207-1-2.=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=ED=95=98=EA=B8=B0=20=EB=82=B4=EB=B6=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 23 ++++ .../RecruitDataHostingController.swift | 2 + .../RecruitListHostingController.swift | 2 + .../RecruitProfileHostingController.swift | 2 + .../RecruitProfilePostViewController.swift | 100 ++++++++++++++++++ .../RecruitProfilePostViewModel.swift | 20 ++++ .../RecruitProfilePostSecondStepView.swift | 15 +++ 7 files changed, 164 insertions(+) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index b7b2ddd90..8344165c1 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -280,6 +280,28 @@ enum EventParameter { case teamRecruitmentProfileCreate = "team_recruitment_profile_create" case teamRecruitmentProfileCreated = "team_recruitment_profile_created" case teamRecruitmentProfileApplied = "team_recruitment_profile_applied" + case teamRecruitmentProfileModifyLoad = "team_recruitment_profile_modify_load" + case teamRecruitmentProfileModifyMajorSelect = "team_recruitment_profile_modify_major_select" + case teamRecruitmentProfileModifyNext = "team_recruitment_profile_modify_next" + case teamRecruitmentProfileModifySkillAdd = "team_recruitment_profile_modify_skill_add" + case teamRecruitmentProfileModifyActivityModify = "team_recruitment_profile_modify_activity_modify" + case teamRecruitmentProfileModifyActivityModifyComplete = "team_recruitment_profile_modify_activity_modify_complete" + case teamRecruitmentProfileModifyActivityAdd = "team_recruitment_profile_modify_activity_add" + case teamRecruitmentProfileModifyActivityComplete = "team_recruitment_profile_modify_activity_complete" + case teamRecruitmentProfileModifySubmit = "team_recruitment_profile_modify_submit" + case teamRecruitmentProfileModifySubmitConfirm = "team_recruitment_profile_modify_submit_confirm" + case teamRecruitmentProfileModifySubmitCancel = "team_recruitment_profile_modify_submit_cancel" + case teamRecruitmentProfileCreateLoad = "team_recruitment_profile_create_load" + case teamRecruitmentProfileCreateMajor = "team_recruitment_profile_create_major" + case teamRecruitmentProfileCreateNext = "team_recruitment_profile_create_next" + case teamRecruitmentProfileCreateSkillAdd = "team_recruitment_profile_create_skill_add" + case teamRecruitmentProfileCreateActivityAdd = "team_recruitment_profile_create_activity_add" + case teamRecruitmentProfileCreateActivityAddComplete = "team_recruitment_profile_create_activity_add_complete" + case teamRecruitmentProfileCreateActivityModify = "team_recruitment_profile_create_activity_modify" + case teamRecruitmentProfileCreateActivityModifyComplete = "team_recruitment_profile_create_activity_modify_complete" + case teamRecruitmentProfileCreateSubmit = "team_recruitment_profile_create_submit" + case teamRecruitmentProfileCreateSubmitCancel = "team_recruitment_profile_create_submit_cancel" + case teamRecruitmentProfileCreateSubmitConfirm = "team_recruitment_profile_create_submit_confirm" var team: String { return "CAMPUS" @@ -324,6 +346,7 @@ enum EventParameter { case pageExit = "page_exit" case update = "update" case notification + case result } enum EventLabelNeededDuration: String { diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index ffba51a46..f32b5a0eb 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -201,11 +201,13 @@ extension RecruitDataHostingController { private func navigateToProfilePost() { let userRepository = DefaultUserRepository(service: DefaultUserService()) let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitProfilePostViewModel( fetchDeptListUseCase: DefaultFetchDeptListUseCase(timetableRepository: DefaultTimetableRepository(service: DefaultTimetableService())), fetchBasicInfoUseCase: DefaultFetchBasicInfoUseCase(repository: userRepository), modifyBasicInfoUseCase: DefaultModifyBasicInfoUseCase(repository: userRepository), upsertMyRecruitProfileUseCase: DefaultUpsertMyRecruitProfileUseCase(repository: recruitRepository), + logAnalyticsEventUseCase: logAnalyticsEventUseCase, mode: .post ) let viewController = RecruitProfilePostViewController(viewModel: viewModel) { [weak self] _ in diff --git a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift index 2df65c3d9..9073e44b8 100644 --- a/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -171,11 +171,13 @@ extension RecruitListHostingController { private func showProfilePost() { let userRepository = DefaultUserRepository(service: DefaultUserService()) let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitProfilePostViewModel( fetchDeptListUseCase: DefaultFetchDeptListUseCase(timetableRepository: DefaultTimetableRepository(service: DefaultTimetableService())), fetchBasicInfoUseCase: DefaultFetchBasicInfoUseCase(repository: userRepository), modifyBasicInfoUseCase: DefaultModifyBasicInfoUseCase(repository: userRepository), upsertMyRecruitProfileUseCase: DefaultUpsertMyRecruitProfileUseCase(repository: recruitRepository), + logAnalyticsEventUseCase: logAnalyticsEventUseCase, mode: .post ) let viewController = RecruitProfilePostViewController(viewModel: viewModel) { [weak self] profile in diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift index 6a90cdcac..41c2be5c6 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift @@ -93,11 +93,13 @@ extension RecruitProfileHostingController { let fetchBasicInfoUseCase = DefaultFetchBasicInfoUseCase(repository: userRepository) let modifyBasicInfoUseCase = DefaultModifyBasicInfoUseCase(repository: userRepository) let upsertMyRecruitProfileUseCase = DefaultUpsertMyRecruitProfileUseCase(repository: recruitRepository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitProfilePostViewModel( fetchDeptListUseCase: fetchDeptListUseCase, fetchBasicInfoUseCase: fetchBasicInfoUseCase, modifyBasicInfoUseCase: modifyBasicInfoUseCase, upsertMyRecruitProfileUseCase: upsertMyRecruitProfileUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase, mode: mode ) return RecruitProfilePostViewController(viewModel: viewModel) { [weak self] profile in diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift index 38fda11ef..42a654045 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift @@ -97,6 +97,11 @@ final class RecruitProfilePostViewController: UIViewController { private lazy var secondStepDropdownHost = KoinDropdownHost(scrollView: secondStepView) private lazy var departmentDropdownContentView = RecruitProfilePostDepartmentDropdownView { [weak self] department in guard let self else { return } + makeLogAnalyticsEvent( + post: .teamRecruitmentProfileCreateMajor, + modify: .teamRecruitmentProfileModifyMajorSelect, + value: department + ) basicInfo.department = department firstStepView.configure(department: department) nextButton.updateState(isEnabled: basicInfo.isValid) @@ -158,6 +163,11 @@ extension RecruitProfilePostViewController { guard isFirstStep && basicInfo.isValid else { return } + makeLogAnalyticsEvent( + post: .teamRecruitmentProfileCreateNext, + modify: .teamRecruitmentProfileModifyNext, + value: "다음" + ) showSecondStep() } @@ -169,8 +179,26 @@ extension RecruitProfilePostViewController { guard basicInfo.isValid && request.isValid else { return } + makeLogAnalyticsEvent( + post: .teamRecruitmentProfileCreateSubmit, + modify: .teamRecruitmentProfileModifySubmit, + value: completeButtonTitle + ) + let leftButtonAction: ()->Void = { [weak self] in + self?.makeLogAnalyticsEvent( + post: .teamRecruitmentProfileCreateSubmitCancel, + modify: .teamRecruitmentProfileModifySubmitCancel, + value: "취소하기" + ) + } let rightButtonAction = { [weak self] in guard let self else { return } + makeLogAnalyticsEvent( + post: .teamRecruitmentProfileCreateSubmitConfirm, + modify: .teamRecruitmentProfileModifySubmitConfirm, + modifyCategory: .result, + value: completeModalButtonTitle + ) inputSubject.send(.submit(basicInfo: basicInfo, request: request)) } let modalViewController = KoinModalViewController( @@ -179,6 +207,7 @@ extension RecruitProfilePostViewController { content: .singleTitle(text: completeModalTitle), button: .buttons( leftButtonTitle: "취소하기", + leftButtonAction: leftButtonAction, rightButtonTitle: completeModalButtonTitle, rightButtonAction: rightButtonAction ) @@ -232,6 +261,11 @@ extension RecruitProfilePostViewController { firstStepView.loadInfoButtonTappedPublisher .sink { [weak self] in + self?.makeLogAnalyticsEvent( + post: .teamRecruitmentProfileCreateLoad, + modify: .teamRecruitmentProfileModifyLoad, + value: "회원정보 불러오기" + ) self?.inputSubject.send(.fetchBasicInfo) } .store(in: &subscriptions) @@ -295,6 +329,56 @@ extension RecruitProfilePostViewController { } .store(in: &subscriptions) + secondStepView.skillAddButtonTappedPublisher + .sink { [weak self] in + self?.makeLogAnalyticsEvent( + post: .teamRecruitmentProfileCreateSkillAdd, + modify: .teamRecruitmentProfileModifySkillAdd, + value: "기술 / 자격증 추가" + ) + } + .store(in: &subscriptions) + + secondStepView.activityAddButtonTappedPublisher + .sink { [weak self] in + self?.makeLogAnalyticsEvent( + post: .teamRecruitmentProfileCreateActivityAdd, + modify: .teamRecruitmentProfileModifyActivityAdd, + value: "활동 이력 추가" + ) + } + .store(in: &subscriptions) + + secondStepView.activityEditButtonTappedPublisher + .sink { [weak self] in + self?.makeLogAnalyticsEvent( + post: .teamRecruitmentProfileCreateActivityModify, + modify: .teamRecruitmentProfileModifyActivityModify, + value: "수정" + ) + } + .store(in: &subscriptions) + + secondStepView.activityAddCompleteButtonTappedPublisher + .sink { [weak self] in + self?.makeLogAnalyticsEvent( + post: .teamRecruitmentProfileCreateActivityAddComplete, + modify: .teamRecruitmentProfileModifyActivityComplete, + value: "완료" + ) + } + .store(in: &subscriptions) + + secondStepView.activityEditCompleteButtonTappedPublisher + .sink { [weak self] in + self?.makeLogAnalyticsEvent( + post: .teamRecruitmentProfileCreateActivityModifyComplete, + modify: .teamRecruitmentProfileModifyActivityModifyComplete, + value: "수정하기" + ) + } + .store(in: &subscriptions) + secondStepView.didChangeHeightPublisher .sink { [weak self] in guard let self else { return } @@ -485,3 +569,19 @@ extension RecruitProfilePostViewController { } } } + +extension RecruitProfilePostViewController { + private func makeLogAnalyticsEvent( + post postLabel: EventParameter.EventLabel.Campus, + modify modifyLabel: EventParameter.EventLabel.Campus, + modifyCategory: EventParameter.EventCategory = .click, + value: Any + ) { + switch viewModel.mode { + case .post: + inputSubject.send(.logEvent(postLabel, .click, value)) + case .modify: + inputSubject.send(.logEvent(modifyLabel, modifyCategory, value)) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift index f79419d97..5249ca421 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift @@ -19,6 +19,7 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { case viewDidLoad case fetchBasicInfo case submit(basicInfo: BasicInfo, request: RecruitProfileRequest) + case logEvent(EventLabelType, EventParameter.EventCategory, Any) } enum Output { @@ -37,6 +38,7 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { private let fetchBasicInfoUseCase: FetchBasicInfoUseCase private let modifyBasicInfoUseCase: ModifyBasicInfoUseCase private let upsertMyRecruitProfileUseCase: UpsertMyRecruitProfileUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase // MARK: - Publisher private let outputSubject = PassthroughSubject() @@ -49,12 +51,14 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { fetchBasicInfoUseCase: FetchBasicInfoUseCase, modifyBasicInfoUseCase: ModifyBasicInfoUseCase, upsertMyRecruitProfileUseCase: UpsertMyRecruitProfileUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase, mode: Mode ) { self.fetchDeptListUseCase = fetchDeptListUseCase self.fetchBasicInfoUseCase = fetchBasicInfoUseCase self.modifyBasicInfoUseCase = modifyBasicInfoUseCase self.upsertMyRecruitProfileUseCase = upsertMyRecruitProfileUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase self.mode = mode } @@ -77,6 +81,8 @@ final class RecruitProfilePostViewModel: ViewModelProtocol { fetchBasicInfo() case let .submit(basicInfo, request): submit(basicInfo: basicInfo, request: request) + case let .logEvent(label, category, value): + makeLogAnalyticsEvent(label: label, category: category, value: value) } } .store(in: &subscriptions) @@ -138,3 +144,17 @@ extension RecruitProfilePostViewModel { } } } + +extension RecruitProfilePostViewModel { + private func makeLogAnalyticsEvent( + label: EventLabelType, + category: EventParameter.EventCategory, + value: Any + ) { + logAnalyticsEventUseCase.execute( + label: label, + category: category, + value: value + ) + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift index 30acae5e5..953d42e21 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift @@ -18,6 +18,21 @@ final class RecruitProfilePostSecondStepView: UIScrollView { let activitiesChangedPublisher = PassthroughSubject<[RecruitProfileActivityRequest], Never>() let introductionChangedPublisher = PassthroughSubject() let didChangeHeightPublisher = PassthroughSubject() + var skillAddButtonTappedPublisher: AnyPublisher { + skillTableView.addButtonTappedPublisher.eraseToAnyPublisher() + } + var activityAddButtonTappedPublisher: AnyPublisher { + activityTableView.addButtonTappedPublisher.eraseToAnyPublisher() + } + var activityEditButtonTappedPublisher: AnyPublisher { + activityTableView.editButtonTappedPublisher.eraseToAnyPublisher() + } + var activityAddCompleteButtonTappedPublisher: AnyPublisher { + activityTableView.addCompleteButtonTappedPublisher.eraseToAnyPublisher() + } + var activityEditCompleteButtonTappedPublisher: AnyPublisher { + activityTableView.editCompleteButtonTappedPublisher.eraseToAnyPublisher() + } // MARK: - Properties private var subscriptions = Set() From 82198fbdbce9f1617edb163d769be601f227b873 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 10:44:11 +0900 Subject: [PATCH 111/123] =?UTF-8?q?log:=208.=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 1 + .../RecruitMyApplicationListView.swift | 5 ++++ .../RecruitMyApplicationListViewModel.swift | 24 ++++++++++++++++++- ...ecruitNotificationListViewController.swift | 4 +++- .../RecruitProfileHostingController.swift | 4 +++- 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index 8344165c1..a1dae5a31 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -302,6 +302,7 @@ enum EventParameter { case teamRecruitmentProfileCreateSubmit = "team_recruitment_profile_create_submit" case teamRecruitmentProfileCreateSubmitCancel = "team_recruitment_profile_create_submit_cancel" case teamRecruitmentProfileCreateSubmitConfirm = "team_recruitment_profile_create_submit_confirm" + case teamRecruitmentAppliedPostFilter = "team_recruitment_applied_post_filter" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift index 3209f8582..5d959a2e6 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift @@ -35,6 +35,11 @@ struct RecruitMyApplicationListView: ActionBindableView { RecruitMyApplicationListHeaderView( totalCount: viewModel.recruitList?.totalCount ?? 0, onFilterButtonTapped: { + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentAppliedPostFilter, + .click, + "필터" + )) sendAction(.showFilterBottomSheet( filterState: viewModel.filterState, onApplyTapped: { groupModels in diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListViewModel.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListViewModel.swift index 56f025298..b8770534d 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListViewModel.swift @@ -17,6 +17,7 @@ final class RecruitMyApplicationListViewModel: SwiftUIViewModelProtocol { case updateFilter(RecruitMyApplicationFilter) case loadNextPage case didShowToast + case logEvent(EventLabelType, EventParameter.EventCategory, Any) } // MARK: - State @@ -27,14 +28,19 @@ final class RecruitMyApplicationListViewModel: SwiftUIViewModelProtocol { // MARK: - Properties private let fetchRecruitMyApplicationListUseCase: FetchRecruitMyApplicationListUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase var recruits: [RecruitMyApplicationRow] { recruitList?.recruits ?? [] } // MARK: - Initializer - init(fetchRecruitMyApplicationListUseCase: FetchRecruitMyApplicationListUseCase) { + init( + fetchRecruitMyApplicationListUseCase: FetchRecruitMyApplicationListUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase + ) { self.fetchRecruitMyApplicationListUseCase = fetchRecruitMyApplicationListUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase } // MARK: - Public @@ -50,6 +56,8 @@ final class RecruitMyApplicationListViewModel: SwiftUIViewModelProtocol { loadNextPage() case .didShowToast: errorMessage = nil + case let .logEvent(label, category, value): + makeLogAnalyticsEvent(label: label, category: category, value: value) } } } @@ -108,3 +116,17 @@ extension RecruitMyApplicationListViewModel { } } } + +extension RecruitMyApplicationListViewModel { + private func makeLogAnalyticsEvent( + label: EventLabelType, + category: EventParameter.EventCategory, + value: Any + ) { + logAnalyticsEventUseCase.execute( + label: label, + category: category, + value: value + ) + } +} diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift index 0a2bee8c2..90afa7d72 100644 --- a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift @@ -179,8 +179,10 @@ extension RecruitNotificationListViewController { private func navigateToMyApplications() { let repository = DefaultRecruitRepository(service: DefaultRecruitService()) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitMyApplicationListViewModel( - fetchRecruitMyApplicationListUseCase: DefaultFetchRecruitMyApplicationListUseCase(repository: repository) + fetchRecruitMyApplicationListUseCase: DefaultFetchRecruitMyApplicationListUseCase(repository: repository), + logAnalyticsEventUseCase: logAnalyticsEventUseCase ) let controller = RecruitMyApplicationListHostingController( rootView: RecruitMyApplicationListView(viewModel: viewModel) diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift index 41c2be5c6..b2e6b2a57 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift @@ -75,8 +75,10 @@ extension RecruitProfileHostingController { private func navigateToMyApplication() { let repository = DefaultRecruitRepository(service: DefaultRecruitService()) let useCase = DefaultFetchRecruitMyApplicationListUseCase(repository: repository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitMyApplicationListViewModel( - fetchRecruitMyApplicationListUseCase: useCase + fetchRecruitMyApplicationListUseCase: useCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase ) let controller = RecruitMyApplicationListHostingController( rootView: RecruitMyApplicationListView(viewModel: viewModel) From 56882494a725f32b04aa79d5b6c5f55e8d097ec5 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 11:09:38 +0900 Subject: [PATCH 112/123] =?UTF-8?q?log:=208-1.=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 4 ++++ .../Mapper/RecruitMyApplicationFilter+.swift | 17 ++++++++++++++++ ...itMyApplicationListHostingController.swift | 8 ++++++-- .../RecruitMyApplicationListView.swift | 20 +++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index a1dae5a31..259bd98f3 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -303,6 +303,10 @@ enum EventParameter { case teamRecruitmentProfileCreateSubmitCancel = "team_recruitment_profile_create_submit_cancel" case teamRecruitmentProfileCreateSubmitConfirm = "team_recruitment_profile_create_submit_confirm" case teamRecruitmentAppliedPostFilter = "team_recruitment_applied_post_filter" + case teamRecruitmentAppliedPostFilterStatus = "team_recruitment_applied_post_filter_status" + case teamRecruitmentAppliedPostFilterSort = "team_recruitment_applied_post_filter_sort" + case teamRecruitmentAppliedPostFilterReset = "team_recruitment_applied_post_filter_reset" + case teamRecruitmentAppliedPostFilterApply = "team_recruitment_applied_post_filter_apply" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift index 427fc730c..57ec9e514 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift @@ -47,3 +47,20 @@ extension RecruitMyApplicationFilter { self.sort = sort } } + +extension RecruitMyApplicationFilter { + static func logEvent(groupIndex: Int, title: String) -> (label: EventParameter.EventLabel.Campus, value: String)? { + switch groupIndex { + case 0: + return ( + .teamRecruitmentAppliedPostFilterStatus, RecruitApplicationStatus(rawValue: title)?.rawValue ?? "전체") + case 1: + guard let sort = RecruitListSort(rawValue: title) else { + return nil + } + return (.teamRecruitmentAppliedPostFilterSort, sort.logValue) + default: + return nil + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift index 5f79cb12d..c0f7d99f9 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift @@ -28,8 +28,8 @@ final class RecruitMyApplicationListHostingController: UIHostingController Bool, + _ onResetTapped: @escaping () -> Void, _ onApplyTapped: @escaping ([FilterGroupModel]) -> Void ) { let filterBottomSheetView = FilterBottomSheetView( groupModels: filterState.toGroupModels(), + onFilterItemTapped: onFilterItemTapped, + onResetTapped: onResetTapped, onApplyTapped: onApplyTapped ) let bottomSheetViewController = BottomSheetViewControllerB(contentView: filterBottomSheetView) diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift index 5d959a2e6..4840d99e5 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift @@ -12,6 +12,8 @@ struct RecruitMyApplicationListView: ActionBindableView { enum Action { case showFilterBottomSheet( filterState: RecruitMyApplicationFilter, + onFilterItemTapped: (Int, FilterItemModel) -> Bool, + onResetTapped: () -> Void, onApplyTapped: ([FilterGroupModel]) -> Void ) case showToast(message: String) @@ -42,7 +44,25 @@ struct RecruitMyApplicationListView: ActionBindableView { )) sendAction(.showFilterBottomSheet( filterState: viewModel.filterState, + onFilterItemTapped: { groupIndex, item in + if let event = RecruitMyApplicationFilter.logEvent(groupIndex: groupIndex, title: item.title) { + viewModel.execute(.logEvent(event.label, .click, event.value)) + } + return true + }, + onResetTapped: { + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentAppliedPostFilterReset, + .click, + "초기화" + )) + }, onApplyTapped: { groupModels in + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentAppliedPostFilterApply, + .click, + "적용하기" + )) guard let filter = RecruitMyApplicationFilter(from: groupModels) else { return } From a4b8c10271b5b6fcea3d69077b350041f9b524bc Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 11:10:06 +0900 Subject: [PATCH 113/123] =?UTF-8?q?log:=208-2.=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 1 + .../RecruitMyApplicationListView.swift | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index 259bd98f3..21123c908 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -307,6 +307,7 @@ enum EventParameter { case teamRecruitmentAppliedPostFilterSort = "team_recruitment_applied_post_filter_sort" case teamRecruitmentAppliedPostFilterReset = "team_recruitment_applied_post_filter_reset" case teamRecruitmentAppliedPostFilterApply = "team_recruitment_applied_post_filter_apply" + case teamRecruitmentAppliedPostChat = "team_recruitment_applied_post_chat" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift index 4840d99e5..1553f0d36 100644 --- a/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift @@ -83,6 +83,11 @@ struct RecruitMyApplicationListView: ActionBindableView { sendAction(.showRecruitData(id: recruit.id)) }, onShowChat: { chatRoomId in + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentAppliedPostChat, + .click, + recruit.title + )) sendAction(.showChat(recruitmentId: recruit.id, chatRoomId: chatRoomId)) } ) From d0fc326bc5d7a92351df037935b3ed25f4fe3bf4 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 11:17:50 +0900 Subject: [PATCH 114/123] =?UTF-8?q?log:=209.=20=EB=82=B4=EA=B0=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=ED=95=9C=20=EB=AA=A8=EC=A7=91=EA=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 5 ++++ .../RecruitMyPostListHostingController.swift | 20 +++++++++++++--- .../RecruitMyPostListView.swift | 23 +++++++++++++++++++ .../RecruitMyPostListViewModel.swift | 22 +++++++++++++++++- .../RecruitProfileHostingController.swift | 4 +++- 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index 21123c908..3d9acd45d 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -308,6 +308,11 @@ enum EventParameter { case teamRecruitmentAppliedPostFilterReset = "team_recruitment_applied_post_filter_reset" case teamRecruitmentAppliedPostFilterApply = "team_recruitment_applied_post_filter_apply" case teamRecruitmentAppliedPostChat = "team_recruitment_applied_post_chat" + case teamRecruitmentCreatedPostApplicant = "team_recruitment_created_post_applicant" + case teamRecruitmentCreatedPostClose = "team_recruitment_created_post_close" + case teamRecruitmentCreatedPostCloseCancel = "team_recruitment_created_post_close_cancel" + case teamRecruitmentCreatedPostCloseConfirm = "team_recruitment_created_post_close_confirm" + case teamRecruitmentCreatedPostChat = "team_recruitment_created_post_chat" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift index cd2f49e09..b88d83476 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift @@ -94,6 +94,21 @@ extension RecruitMyPostListHostingController: RecruitDataHostingControllerDelega } private func showCloseModal(recruitId: Int) { + let onCancelButtonTapped: ()->Void = { [weak self] in + self?.rootView.makeLogAnalyticsEvent( + label: EventParameter.EventLabel.Campus.teamRecruitmentCreatedPostCloseCancel, + category: .click, + value: "취소하기" + ) + } + let onCloseButtonTapped: ()->Void = { [weak self] in + self?.rootView.makeLogAnalyticsEvent( + label: EventParameter.EventLabel.Campus.teamRecruitmentCreatedPostCloseConfirm, + category: .click, + value: "마감하기" + ) + self?.rootView.close(id: recruitId) + } let modalViewController = KoinModalViewController(configuration: .init( appearance: .new, content: .titles( @@ -102,10 +117,9 @@ extension RecruitMyPostListHostingController: RecruitDataHostingControllerDelega ), button: .buttons( leftButtonTitle: "취소하기", + leftButtonAction: onCancelButtonTapped, rightButtonTitle: "마감하기", - rightButtonAction: { [weak self] in - self?.rootView.close(id: recruitId) - } + rightButtonAction: onCloseButtonTapped ), layout: .init(width: 320) )) diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift index f26c470e9..ecf841f16 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift @@ -59,12 +59,27 @@ struct RecruitMyPostListView: ActionBindableView { sendAction(.showRecruitData(id: recruit.id)) }, onShowChat: { chatRoomId in + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentCreatedPostChat, + .click, + recruit.title + )) sendAction(.showChat(recruitmentId: recruit.id, chatRoomId: chatRoomId)) }, onShowApplicants: { + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentCreatedPostApplicant, + .click, + recruit.title + )) sendAction(.showApplicants(recruitId: recruit.id)) }, onCloseRecruit: { + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentCreatedPostClose, + .click, + recruit.title + )) sendAction(.showCloseModal(recruitId: recruit.id)) } ) @@ -136,4 +151,12 @@ extension RecruitMyPostListView { func close(id: Int) { viewModel.execute(.close(id: id)) } + + func makeLogAnalyticsEvent( + label: EventLabelType, + category: EventParameter.EventCategory, + value: Any + ) { + viewModel.execute(.logEvent(label, category, value)) + } } diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListViewModel.swift b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListViewModel.swift index 9913f3172..d96880f51 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListViewModel.swift @@ -19,6 +19,7 @@ final class RecruitMyPostListViewModel: SwiftUIViewModelProtocol { case close(id: Int) case delete(id: Int) case didShowToast + case logEvent(EventLabelType, EventParameter.EventCategory, Any) } // MARK: - State @@ -31,6 +32,7 @@ final class RecruitMyPostListViewModel: SwiftUIViewModelProtocol { // MARK: - Properties private let fetchRecruitMyPostListUseCase: FetchRecruitMyPostListUseCase private let closeRecruitMyPostUseCase: CloseRecruitMyPostUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase var recruits: [RecruitMyPostRow] { recruitList?.recruits ?? [] @@ -39,10 +41,12 @@ final class RecruitMyPostListViewModel: SwiftUIViewModelProtocol { // MARK: - Initializer init( fetchRecruitMyPostListUseCase: FetchRecruitMyPostListUseCase, - closeRecruitMyPostUseCase: CloseRecruitMyPostUseCase + closeRecruitMyPostUseCase: CloseRecruitMyPostUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase ) { self.fetchRecruitMyPostListUseCase = fetchRecruitMyPostListUseCase self.closeRecruitMyPostUseCase = closeRecruitMyPostUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase } // MARK: - Public @@ -63,6 +67,8 @@ final class RecruitMyPostListViewModel: SwiftUIViewModelProtocol { case .didShowToast: errorMessage = nil successMessage = nil + case let .logEvent(label, category, value): + makeLogAnalyticsEvent(label: label, category: category, value: value) } } } @@ -135,3 +141,17 @@ extension RecruitMyPostListViewModel { } } } + +extension RecruitMyPostListViewModel { + private func makeLogAnalyticsEvent( + label: EventLabelType, + category: EventParameter.EventCategory, + value: Any + ) { + logAnalyticsEventUseCase.execute( + label: label, + category: category, + value: value + ) + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift index b2e6b2a57..ef88b6623 100644 --- a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift @@ -62,9 +62,11 @@ extension RecruitProfileHostingController { let repository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchUseCase = DefaultFetchRecruitMyPostListUseCase(repository: repository) let closeUseCase = DefaultCloseRecruitMyPostUseCase(repository: repository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitMyPostListViewModel( fetchRecruitMyPostListUseCase: fetchUseCase, - closeRecruitMyPostUseCase: closeUseCase + closeRecruitMyPostUseCase: closeUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase ) let controller = RecruitMyPostListHostingController( rootView: RecruitMyPostListView(viewModel: viewModel) From 330dff894158023783c20120e0bdee0d91cccd7b Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 12:45:54 +0900 Subject: [PATCH 115/123] =?UTF-8?q?log:=209-1.=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EB=B0=8F=20=EB=82=B4=EB=B6=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 5 ++++ .../Mapper/RecruitMyPostFilter+.swift | 19 ++++++++++++++ .../RecruitMyPostListHostingController.swift | 8 ++++-- .../RecruitMyPostListView.swift | 25 +++++++++++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index 3d9acd45d..3632d3882 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -313,6 +313,11 @@ enum EventParameter { case teamRecruitmentCreatedPostCloseCancel = "team_recruitment_created_post_close_cancel" case teamRecruitmentCreatedPostCloseConfirm = "team_recruitment_created_post_close_confirm" case teamRecruitmentCreatedPostChat = "team_recruitment_created_post_chat" + case teamRecruitmentCreatedPostFilter = "team_recruitment_created_post_filter" + case teamRecruitmentCreatedPostFilterStatus = "team_recruitment_created_post_filter_status" + case teamRecruitmentCreatedPostFilterSort = "team_recruitment_created_post_filter_sort" + case teamRecruitmentCreatedPostFilterReset = "team_recruitment_created_post_filter_reset" + case teamRecruitmentCreatedPostFilterApply = "team_recruitment_created_post_filter_apply" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/Mapper/RecruitMyPostFilter+.swift b/Koin/Presentation/Recruit/RecruitMyPostList/Mapper/RecruitMyPostFilter+.swift index db63983a2..0b3cef21d 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/Mapper/RecruitMyPostFilter+.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/Mapper/RecruitMyPostFilter+.swift @@ -46,3 +46,22 @@ extension RecruitMyPostFilter { self.sort = sort } } + +extension RecruitMyPostFilter { + static func logEvent(groupIndex: Int, title: String) -> (label: EventParameter.EventLabel.Campus, value: String)? { + switch groupIndex { + case 0: + guard let state = RecruitState(rawValue: title) else { + return nil + } + return (.teamRecruitmentCreatedPostFilterStatus, state == .closed ? "모집 마감" : state.logValue) + case 1: + guard let sort = RecruitListSort(rawValue: title) else { + return nil + } + return (.teamRecruitmentCreatedPostFilterSort, sort.logValue) + default: + return nil + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift index b88d83476..2b28171b2 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift @@ -28,8 +28,8 @@ final class RecruitMyPostListHostingController: UIHostingController Bool, + _ onResetTapped: @escaping () -> Void, _ onApplyTapped: @escaping ([FilterGroupModel]) -> Void ) { let filterBottomSheetView = FilterBottomSheetView( groupModels: filterState.toGroupModels(), + onFilterItemTapped: onFilterItemTapped, + onResetTapped: onResetTapped, onApplyTapped: onApplyTapped ) let bottomSheetViewController = BottomSheetViewControllerB(contentView: filterBottomSheetView) diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift index ecf841f16..d2c67661c 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift @@ -12,6 +12,8 @@ struct RecruitMyPostListView: ActionBindableView { enum Action { case showFilterBottomSheet( filterState: RecruitMyPostFilter, + onFilterItemTapped: (Int, FilterItemModel) -> Bool, + onResetTapped: () -> Void, onApplyTapped: ([FilterGroupModel]) -> Void ) case showToast(message: String) @@ -36,9 +38,32 @@ struct RecruitMyPostListView: ActionBindableView { RecruitMyPostListHeaderView( totalCount: viewModel.recruitList?.totalCount ?? 0, onFilterButtonTapped: { + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentCreatedPostFilter, + .click, + "필터" + )) sendAction(.showFilterBottomSheet( filterState: viewModel.filterState, + onFilterItemTapped: { groupIndex, item in + if let event = RecruitMyPostFilter.logEvent(groupIndex: groupIndex, title: item.title) { + viewModel.execute(.logEvent(event.label, .click, event.value)) + } + return true + }, + onResetTapped: { + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentCreatedPostFilterReset, + .click, + "초기화" + )) + }, onApplyTapped: { groupModels in + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentCreatedPostFilterApply, + .click, + "적용하기" + )) guard let filter = RecruitMyPostFilter(from: groupModels) else { return } From f1251b33d4b46f33bddbcc828260f322e3184004 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 13:00:57 +0900 Subject: [PATCH 116/123] =?UTF-8?q?log:=2010.=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=EC=9E=90=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 2 ++ .../RecruitDataHostingController.swift | 2 ++ .../RecruitMyPost/RecruitMyPostView.swift | 10 ++++++++++ .../RecruitMyPostViewModel.swift | 20 +++++++++++++++++++ .../RecruitMyPostListHostingController.swift | 2 ++ ...ecruitNotificationListViewController.swift | 2 ++ 6 files changed, 38 insertions(+) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index 3632d3882..f7aef49d0 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -318,6 +318,8 @@ enum EventParameter { case teamRecruitmentCreatedPostFilterSort = "team_recruitment_created_post_filter_sort" case teamRecruitmentCreatedPostFilterReset = "team_recruitment_created_post_filter_reset" case teamRecruitmentCreatedPostFilterApply = "team_recruitment_created_post_filter_apply" + case teamRecruitmentCreatedPostApplicantSelect = "team_recruitment_created_post_applicant_select" + case teamRecruitmentCreatedPostApplicantChat = "team_recruitment_created_post_applicant_chat" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index f32b5a0eb..2805fa355 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -166,8 +166,10 @@ extension RecruitDataHostingController { } let repository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchRecruitMyPostDataUseCase = DefaultFetchRecruitMyPostDataUseCase(repository: repository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitMyPostViewModel( fetchRecruitMyPostDataUseCase: fetchRecruitMyPostDataUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase, recruitId: id ) let controller = RecruitMyPostHostingController( diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift index 3edb6be05..69b194499 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift @@ -75,12 +75,22 @@ extension RecruitMyPostView { RecruitMyPostApplicantRowView( model: applicant, onApplicationTapped: { + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentCreatedPostApplicantSelect, + .click, + "지원자 선택" + )) sendAction(.showApplicant( recruitmentId: data.id, applicationId: applicant.applicationId )) }, onDirectChatTapped: { + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentCreatedPostApplicantChat, + .click, + "채팅" + )) sendAction(.showDirectChat( recruitmentId: data.id, applicationId: applicant.applicationId diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift index 45bd416fd..af2c27d30 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift @@ -17,6 +17,7 @@ final class RecruitMyPostViewModel: SwiftUIViewModelProtocol { case loadNextPage case didShowToast case refresh + case logEvent(EventLabelType, EventParameter.EventCategory, Any) } // MARK: - State @@ -27,13 +28,16 @@ final class RecruitMyPostViewModel: SwiftUIViewModelProtocol { // MARK: - Properties private let recruitId: Int private let fetchRecruitMyPostDataUseCase: FetchRecruitMyPostDataUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase // MARK: - Initializer init( fetchRecruitMyPostDataUseCase: FetchRecruitMyPostDataUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase, recruitId: Int ) { self.fetchRecruitMyPostDataUseCase = fetchRecruitMyPostDataUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase self.recruitId = recruitId } @@ -46,6 +50,8 @@ final class RecruitMyPostViewModel: SwiftUIViewModelProtocol { loadNextPage() case .didShowToast: errorMessage = nil + case let .logEvent(label, category, value): + makeLogAnalyticsEvent(label: label, category: category, value: value) } } } @@ -90,3 +96,17 @@ extension RecruitMyPostViewModel { } } } + +extension RecruitMyPostViewModel { + private func makeLogAnalyticsEvent( + label: EventLabelType, + category: EventParameter.EventCategory, + value: Any + ) { + logAnalyticsEventUseCase.execute( + label: label, + category: category, + value: value + ) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift index 2b28171b2..00a2d5256 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift @@ -83,8 +83,10 @@ extension RecruitMyPostListHostingController: RecruitDataHostingControllerDelega private func showApplicants(recruitId: Int) { let repository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchRecruitMyPostDataUseCase = DefaultFetchRecruitMyPostDataUseCase(repository: repository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitMyPostViewModel( fetchRecruitMyPostDataUseCase: fetchRecruitMyPostDataUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase, recruitId: recruitId ) let controller = RecruitMyPostHostingController( diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift index 90afa7d72..ea50ed4ec 100644 --- a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift @@ -167,8 +167,10 @@ extension RecruitNotificationListViewController { private func navigateToApplicantManagement(recruitmentId: Int) { let repository = DefaultRecruitRepository(service: DefaultRecruitService()) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitMyPostViewModel( fetchRecruitMyPostDataUseCase: DefaultFetchRecruitMyPostDataUseCase(repository: repository), + logAnalyticsEventUseCase: logAnalyticsEventUseCase, recruitId: recruitmentId ) let controller = RecruitMyPostHostingController( From 3a5cb8ab9a83670de8ce0207cdef7e3310362ddf Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 14:52:53 +0900 Subject: [PATCH 117/123] =?UTF-8?q?log:=2011.=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=EC=9E=90=20=EC=83=81=EC=84=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/Core/Logger/EventParameter.swift | 6 +++ .../RecruitMyPostHostingController.swift | 4 +- ...ruitMyPostApplicantHostingController.swift | 15 ++++--- .../RecruitMyPostApplicantView.swift | 45 ++++++++++++++++++- .../RecruitMyPostApplicantViewModel.swift | 22 ++++++++- 5 files changed, 82 insertions(+), 10 deletions(-) diff --git a/Koin/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index f7aef49d0..645f900bd 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -320,6 +320,12 @@ enum EventParameter { case teamRecruitmentCreatedPostFilterApply = "team_recruitment_created_post_filter_apply" case teamRecruitmentCreatedPostApplicantSelect = "team_recruitment_created_post_applicant_select" case teamRecruitmentCreatedPostApplicantChat = "team_recruitment_created_post_applicant_chat" + case teamRecruitmentCreatedPostApplicantApprove = "team_recruitment_created_post_applicant_approve" + case teamRecruitmentCreatedPostApplicantApproveConfirm = "team_recruitment_created_post_applicant_approve_confirm" + case teamRecruitmentCreatedPostApplicantApproveCancel = "team_recruitment_created_post_applicant_approve_cancel" + case teamRecruitmentCreatedPostApplicantReject = "team_recruitment_created_post_applicant_reject" + case teamRecruitmentCreatedPostApplicantRejectConfirm = "team_recruitment_created_post_applicant_reject_confirm" + case teamRecruitmentCreatedPostApplicantRejectCancel = "team_recruitment_created_post_applicant_reject_cancel" var team: String { return "CAMPUS" diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift index 3a60fd65c..f009f100c 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift @@ -49,11 +49,13 @@ extension RecruitMyPostHostingController { let repository = DefaultRecruitRepository(service: DefaultRecruitService()) let fetchUseCase = DefaultFetchRecruitApplicantUseCase(repository: repository) let decideUseCase = DefaultDecideRecruitApplicantUseCase(repository: repository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = RecruitMyPostApplicantViewModel( recruitmentId: recruitmentId, applicationId: applicationId, fetchUseCase: fetchUseCase, - decideUseCase: decideUseCase + decideUseCase: decideUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase ) let controller = RecruitMyPostApplicantHostingController( rootView: RecruitMyPostApplicantView(viewModel: viewModel) diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift index c77502e3f..9682e4768 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift @@ -29,8 +29,8 @@ final class RecruitMyPostApplicantHostingController: UIHostingController Void, + _ onDecideTapped: @escaping () -> Void + ) { let modalViewController = KoinModalViewController(configuration: .init( appearance: .new, content: .titles( @@ -49,10 +53,9 @@ extension RecruitMyPostApplicantHostingController { ), button: .buttons( leftButtonTitle: "취소하기", + leftButtonAction: onCancelTapped, rightButtonTitle: "\(decision.rawValue)하기", - rightButtonAction: { [weak self] in - self?.rootView.decide(decision) - } + rightButtonAction: onDecideTapped ) )) present(modalViewController, animated: true) diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift index 371d8f51b..47f34d3f0 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift @@ -10,7 +10,11 @@ import SwiftUI struct RecruitMyPostApplicantView: ActionBindableView { enum Action { - case showDecisionModal(RecruitApplicantDecision) + case showDecisionModal( + decision: RecruitApplicantDecision, + onCancelTapped: () -> Void, + onDecideTapped: () -> Void + ) case showDirectChat(recruitmentId: Int, applicationId: Int) case showToast(message: String) } @@ -104,7 +108,8 @@ extension RecruitMyPostApplicantView { application: application, isLoading: viewModel.isLoading, onDecisionTapped: { decision in - sendAction(.showDecisionModal(decision)) + makeLogAnalyticsEvent(decision) + showDecisionModal(decision) }, onDirectChatTapped: { sendAction(.showDirectChat( @@ -134,3 +139,39 @@ extension RecruitMyPostApplicantView { .frame(maxWidth: .infinity, alignment: .leading) } } + +extension RecruitMyPostApplicantView { + private func makeLogAnalyticsEvent(_ decision: RecruitApplicantDecision) { + let label: EventParameter.EventLabel.Campus + switch decision { + case .accepted: + label = .teamRecruitmentCreatedPostApplicantApprove + case .denied: + label = .teamRecruitmentCreatedPostApplicantReject + } + viewModel.execute(.logEvent(label, .click, "\(decision.rawValue)하기")) + } + + private func showDecisionModal(_ decision: RecruitApplicantDecision) { + let cancelLabel: EventParameter.EventLabel.Campus + let decideLabel: EventParameter.EventLabel.Campus + switch decision { + case .accepted: + cancelLabel = .teamRecruitmentCreatedPostApplicantApproveCancel + decideLabel = .teamRecruitmentCreatedPostApplicantApproveConfirm + case .denied: + cancelLabel = .teamRecruitmentCreatedPostApplicantRejectCancel + decideLabel = .teamRecruitmentCreatedPostApplicantRejectConfirm + } + sendAction(.showDecisionModal( + decision: decision, + onCancelTapped: { + viewModel.execute(.logEvent(cancelLabel, .click, "취소하기")) + }, + onDecideTapped: { + viewModel.execute(.logEvent(decideLabel, .click, "\(decision.rawValue)하기")) + decide(decision) + } + )) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift index 3ee215fe4..d4b597683 100644 --- a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift @@ -16,6 +16,7 @@ final class RecruitMyPostApplicantViewModel: SwiftUIViewModelProtocol { case onFirstAppear case decide(RecruitApplicantDecision) case didShowToast + case logEvent(EventLabelType, EventParameter.EventCategory, Any) } // MARK: - State @@ -28,18 +29,21 @@ final class RecruitMyPostApplicantViewModel: SwiftUIViewModelProtocol { private let applicationId: Int private let fetchUseCase: FetchRecruitApplicantUseCase private let decideUseCase: DecideRecruitApplicantUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase // MARK: - Initializer init( recruitmentId: Int, applicationId: Int, fetchUseCase: FetchRecruitApplicantUseCase, - decideUseCase: DecideRecruitApplicantUseCase + decideUseCase: DecideRecruitApplicantUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase ) { self.recruitmentId = recruitmentId self.applicationId = applicationId self.fetchUseCase = fetchUseCase self.decideUseCase = decideUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase } // MARK: - Public @@ -51,6 +55,8 @@ final class RecruitMyPostApplicantViewModel: SwiftUIViewModelProtocol { decideApplication(decision) case .didShowToast: toastMessage = nil + case let .logEvent(label, category, value): + makeLogAnalyticsEvent(label: label, category: category, value: value) } } } @@ -104,3 +110,17 @@ extension RecruitMyPostApplicantViewModel { } } } + +extension RecruitMyPostApplicantViewModel { + private func makeLogAnalyticsEvent( + label: EventLabelType, + category: EventParameter.EventCategory, + value: Any + ) { + logAnalyticsEventUseCase.execute( + label: label, + category: category, + value: value + ) + } +} From 05dfe20ebeb646daa3078649bb2b4e88a34d60ef Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 18:37:42 +0900 Subject: [PATCH 118/123] =?UTF-8?q?log:=2010.=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=EC=9E=90=20=EA=B4=80=EB=A6=AC=20-=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=9C=20=EB=A1=9C=EA=B9=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Recruit/RecruitMyPost/RecruitMyPostView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift index 69b194499..d83bc8a32 100644 --- a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift @@ -55,6 +55,11 @@ extension RecruitMyPostView { VStack(alignment: .leading, spacing: 0) { RecruitMyPostSummaryView(model: data) { guard let chatRoomId = data.chatRoomId else { return } + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentCreatedPostApplicantChat, + .click, + "채팅" + )) sendAction(.showChat(recruitmentId: data.id, chatRoomId: chatRoomId)) } From 56bdb2dbde9e0cc63c8d4d16ff966f009f9a6868 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 18:53:55 +0900 Subject: [PATCH 119/123] =?UTF-8?q?feat:=20=EB=AA=A8=EC=A7=91=EA=B8=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1/=EC=88=98=EC=A0=95=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=8A=A4=ED=81=AC=EB=A1=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitDataHostingController.swift | 23 +++++++--- .../RecruitPostViewController.swift | 44 ++++++++++++++++--- .../Subviews/RecruitPostRoleTableView.swift | 23 +++++++--- .../Subviews/RecruitPostRoleView.swift | 17 ++++--- 4 files changed, 83 insertions(+), 24 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift index 2805fa355..e13df265f 100644 --- a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -51,7 +51,7 @@ final class RecruitDataHostingController: UIHostingController, case .didDelete: handleDelete() case let .showToast(message): - showToastMessage(message: message, bottomInset: 72) + showToastMessage(message: message) case .showLoginToast: showLoginToast() case .showProfileRequiredToast: @@ -104,12 +104,20 @@ extension RecruitDataHostingController { @objc private func rightBarButtonItemTapped() { let popUpViewController = RecruitDataPopUpViewController( onEditButtonTapped: { [weak self] in - self?.rootView.makeLogAnalyticsEvent( + guard let self, + let data = rootView.data else { + return + } + rootView.makeLogAnalyticsEvent( label: EventParameter.EventLabel.Campus.teamRecruitmentPostEdit, category: .click, value: "편집하기" ) - self?.navigateToEdit() + guard data.state == .recruiting else { + showToastMessage(message: "마감된 팀원 모집글입니다.") + return + } + navigateToEdit(data: data) }, onDeleteButtonTapped: { [weak self] in self?.rootView.makeLogAnalyticsEvent( @@ -157,6 +165,10 @@ extension RecruitDataHostingController { ) present(modalViewController, animated: true) } + + private func showToastMessage(message: String) { + showToastMessage(message: message, bottomInset: 72) + } } extension RecruitDataHostingController { @@ -226,10 +238,7 @@ extension RecruitDataHostingController { navigationController?.popViewController(animated: true) } - private func navigateToEdit() { - guard let data = rootView.data else { - return - } + private func navigateToEdit(data: RecruitData) { let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) let postRecruitUseCase = DefaultPostRecruitUseCase(repository: recruitRepository) let modifyRecruitUseCase = DefaultModifyRecruitUseCase(repository: recruitRepository) diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift index 23553b1cd..daf15d4a3 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift @@ -202,8 +202,17 @@ extension RecruitPostViewController { }.store(in: &subscriptions) meetingTypeView.meetingTypeChangedPublisher.sink { [weak self] meetingType in - self?.logEvent(post: .teamRecruitmentRecruitMethod, modify: .teamRecruitmentPostEditMethod, value: meetingType.logValue) - self?.request.meetingType = meetingType + guard let self else { + return + } + logEvent(post: .teamRecruitmentRecruitMethod, modify: .teamRecruitmentPostEditMethod, value: meetingType.logValue) + request.meetingType = meetingType + + let rect = meetingTypeView.convert( + meetingTypeView.bounds, + to: self.scrollView + ) + self.scrollView.scrollRectToVisible(rect.insetBy(dx: 0, dy: 12), animated: true) }.store(in: &subscriptions) scheduleView.startDateButtonTappedPublisher.sink { [weak self] in @@ -268,15 +277,36 @@ extension RecruitPostViewController { self?.request.numberOfGeneralMembers = numberOfGeneralMembers }.store(in: &subscriptions) - roleView.didChangeHeightPublisher.sink { [weak self] in - guard let self else { return } + roleView.didChangeHeightPublisher.sink { [weak self] heightDifference in + guard let self else { + return + } + view.layoutIfNeeded() + UIView.animate( springDuration: 0.25, options: [.beginFromCurrentState, .allowUserInteraction] - ) { - self.roleView.applyPendingSizeChange() - self.view.layoutIfNeeded() + ) { [weak self] in + guard let self else { + return + } + roleView.applyPendingSizeChange() + view.layoutIfNeeded() + + guard 0 < heightDifference else { + return + } + let bottomRect = roleView.convert( + CGRect( + x: 0, + y: roleView.bounds.maxY - 1, + width: roleView.bounds.width, + height: 1 + ), + to: scrollView + ) + scrollView.scrollRectToVisible(bottomRect, animated: false) } }.store(in: &subscriptions) diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableView.swift index 12298825e..fdd921067 100644 --- a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableView.swift +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableView.swift @@ -9,6 +9,9 @@ import UIKit import Combine final class RecruitPostRoleTableView: UITableView { + static let roleRowHeight = 44 + static let roleRowSpacing = 8 + // MARK: - Publishers let nameChangedPublisher = PassthroughSubject<(index: Int, text: String, isComposing: Bool), Never>() let nameEditingEndedPublisher = PassthroughSubject<(index: Int, text: String), Never>() @@ -30,9 +33,16 @@ final class RecruitPostRoleTableView: UITableView { } override var intrinsicContentSize: CGSize { - let rows = displayedRowCount * 44 - let spacing = max(0, displayedRowCount - 1) * 8 - return CGSize(width: UIView.noIntrinsicMetric, height: CGFloat(rows + spacing)) + CGSize( + width: UIView.noIntrinsicMetric, + height: CGFloat(Self.contentHeight(for: displayedRowCount)) + ) + } + + static func contentHeight(for rowCount: Int) -> Int { + let rows = rowCount * roleRowHeight + let spacing = max(0, rowCount - 1) * roleRowSpacing + return rows + spacing } // MARK: - Initializer @@ -218,7 +228,10 @@ extension RecruitPostRoleTableView: UITableViewDataSource, UITableViewDelegate { } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - indexPath.row == displayedRowCount - 1 ? 44 : 52 + let height = indexPath.row == displayedRowCount - 1 + ? Self.roleRowHeight + : Self.roleRowHeight + Self.roleRowSpacing + return CGFloat(height) } } @@ -282,7 +295,7 @@ extension RecruitPostRoleTableView { separatorStyle = .none isScrollEnabled = false showsVerticalScrollIndicator = false - rowHeight = 44 + rowHeight = CGFloat(Self.roleRowHeight) sectionHeaderHeight = 0 sectionFooterHeight = 0 dataSource = self diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift index 685d26625..2a733de20 100644 --- a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift @@ -15,7 +15,7 @@ final class RecruitPostRoleView: UIView { let roleTypeChangedPublisher = PassthroughSubject() let rolesChangedPublisher = PassthroughSubject<[RecruitRoleRequest], Never>() let numberOfGeneralMembersChangedPublisher = PassthroughSubject() - let didChangeHeightPublisher = PassthroughSubject() + let didChangeHeightPublisher = PassthroughSubject() let addRoleButtonTappedPublisher = PassthroughSubject() // MARK: - Properties @@ -109,11 +109,12 @@ extension RecruitPostRoleView { guard roleType == .roleBased, roles.count < maximumNumberOfRoles, totalRoleMembers < maximumNumberOfMembers else { return } + let previousRowCount = displayedRowCount addRoleButtonTappedPublisher.send() tableView.endEditing(true) roles.append(.init()) pendingRowChangeIndex = roles.count - 1 - didChangeHeightPublisher.send() + didChangeHeightPublisher.send(heightDifference(from: previousRowCount)) rolesChangedPublisher.send(roles) } @@ -127,7 +128,7 @@ extension RecruitPostRoleView { roles = [] numberOfGeneralMembers = minimumNumberOfMembers if previousRowCount != displayedRowCount { - didChangeHeightPublisher.send() + didChangeHeightPublisher.send(heightDifference(from: previousRowCount)) } else { updateViews() } @@ -139,7 +140,7 @@ extension RecruitPostRoleView { roles = [.init()] numberOfGeneralMembers = minimumNumberOfMembers if previousRowCount != displayedRowCount { - didChangeHeightPublisher.send() + didChangeHeightPublisher.send(heightDifference(from: previousRowCount)) } else { updateViews() } @@ -181,10 +182,11 @@ extension RecruitPostRoleView { private func deleteRole(at index: Int) { guard roleType == .roleBased, roles.count > 1, roles.indices.contains(index) else { return } + let previousRowCount = displayedRowCount tableView.endEditing(true) roles.remove(at: index) pendingRowChangeIndex = index - didChangeHeightPublisher.send() + didChangeHeightPublisher.send(heightDifference(from: previousRowCount)) rolesChangedPublisher.send(roles) } } @@ -198,6 +200,11 @@ extension RecruitPostRoleView { roles.reduce(0) { $0 + $1.maximumParticipants } } + private func heightDifference(from previousRowCount: Int) -> Int { + RecruitPostRoleTableView.contentHeight(for: displayedRowCount) + - RecruitPostRoleTableView.contentHeight(for: previousRowCount) + } + private func updateViews(rowChangeIndex: Int? = nil) { addRoleButton.isHidden = roleType == .general addRoleButton.isEnabled = roleType == .roleBased From d1cf902f611ab48e97d3a358c4fce50c4cd226f0 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 21:44:26 +0900 Subject: [PATCH 120/123] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=ED=99=94=EB=A9=B4=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitApplyViewController.swift | 4 +- .../Subviews/RecruitApplyFirstStepView.swift | 25 +++++++++-- .../RecruitProfilePostViewController.swift | 2 +- .../RecruitProfilePostSecondStepView.swift | 2 +- .../RecruitProfilePostActivityTableView.swift | 42 ++++++++++++++++--- .../RecruitProfilePostSkillTableView.swift | 8 ++-- 6 files changed, 69 insertions(+), 14 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift index 4ed51fac6..cae6a5e33 100644 --- a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift @@ -195,7 +195,7 @@ extension RecruitApplyViewController { } .store(in: &subscriptions) firstStepView.didChangeHeightPublisher - .sink { [weak self] in + .sink { [weak self] (section, heightChanged) in guard let self else { return } view.layoutIfNeeded() UIView.animate( @@ -205,6 +205,8 @@ extension RecruitApplyViewController { self.firstStepView.applyPendingSizeChange() self.updateNextButtonState() self.view.layoutIfNeeded() + guard heightChanged > 0 else { return } + self.firstStepView.scrollBottomToVisible(of: section) } } .store(in: &subscriptions) diff --git a/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift index 92b651214..19378cafb 100644 --- a/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift +++ b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift @@ -12,10 +12,15 @@ import UIKit final class RecruitApplyFirstStepView: UIScrollView { + enum Section { + case skill + case activity + } + // MARK: - Publisher let loadInfoButtonTappedPublisher = PassthroughSubject() let departmentButtonTappedPublisher = PassthroughSubject() - let didChangeHeightPublisher = PassthroughSubject() + let didChangeHeightPublisher = PassthroughSubject<(Section, Int), Never>() var nicknameChangedPublisher: AnyPublisher { nicknameView.textChangedPublisher.eraseToAnyPublisher() @@ -163,13 +168,27 @@ final class RecruitApplyFirstStepView: UIScrollView { skillTableView.applyPendingSizeChange() activityTableView.applyPendingSizeChange() } + + func scrollBottomToVisible(of section: Section) { + let tableView: UITableView = switch section { + case .skill: + skillTableView + case .activity: + activityTableView + } + let bottomRect = tableView.convert( + CGRect(x: 0, y: tableView.bounds.maxY - 1, width: tableView.bounds.width, height: 1), + to: self + ) + scrollRectToVisible(bottomRect, animated: false) + } } extension RecruitApplyFirstStepView { private func bind() { Publishers.Merge( - skillTableView.didChangeHeightPublisher, - activityTableView.didChangeHeightPublisher + skillTableView.didChangeHeightPublisher.map { (.skill, $0) }, + activityTableView.didChangeHeightPublisher.map { (.activity, $0) } ) .subscribe(didChangeHeightPublisher) .store(in: &subscriptions) diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift index 42a654045..3051e1640 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift @@ -380,7 +380,7 @@ extension RecruitProfilePostViewController { .store(in: &subscriptions) secondStepView.didChangeHeightPublisher - .sink { [weak self] in + .sink { [weak self] _ in guard let self else { return } view.layoutIfNeeded() UIView.animate( diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift index 953d42e21..93ade5c78 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift @@ -17,7 +17,7 @@ final class RecruitProfilePostSecondStepView: UIScrollView { let skillsChangedPublisher = PassthroughSubject<[String], Never>() let activitiesChangedPublisher = PassthroughSubject<[RecruitProfileActivityRequest], Never>() let introductionChangedPublisher = PassthroughSubject() - let didChangeHeightPublisher = PassthroughSubject() + let didChangeHeightPublisher = PassthroughSubject() var skillAddButtonTappedPublisher: AnyPublisher { skillTableView.addButtonTappedPublisher.eraseToAnyPublisher() } diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift index 74ad35479..7c4d77d4b 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift @@ -33,7 +33,7 @@ final class RecruitProfilePostActivityTableView: UITableView { let addCompleteButtonTappedPublisher = PassthroughSubject() let editCompleteButtonTappedPublisher = PassthroughSubject() let activitiesChangedPublisher = PassthroughSubject<[RecruitProfileActivityRequest], Never>() - let didChangeHeightPublisher = PassthroughSubject() + let didChangeHeightPublisher = PassthroughSubject() private var rows: [Row] = [] private var footerSubscriptions = Set() @@ -111,23 +111,25 @@ extension RecruitProfilePostActivityTableView { mode: .editing )) pendingSizeChange = .insert(indexPath) - didChangeHeightPublisher.send() + didChangeHeightPublisher.send(Int(height(for: rows[indexPath.row]))) } private func editActivity(at indexPath: IndexPath) { guard rows.indices.contains(indexPath.row), let committed = rows[indexPath.row].committed else { return } + let previousHeight = rectForRow(at: indexPath).height editButtonTappedPublisher.send() dropdownHost?.dismissPresented() rows[indexPath.row].draft = committed rows[indexPath.row].mode = .editing pendingSizeChange = .reload(indexPath) - didChangeHeightPublisher.send() + didChangeHeightPublisher.send(Int(height(for: rows[indexPath.row]) - previousHeight)) } private func completeActivity(at indexPath: IndexPath) { guard rows.indices.contains(indexPath.row), rows[indexPath.row].draft.isValid else { return } + let previousHeight = rectForRow(at: indexPath).height if rows[indexPath.row].committed == nil { addCompleteButtonTappedPublisher.send() } else { @@ -137,20 +139,50 @@ extension RecruitProfilePostActivityTableView { rows[indexPath.row].committed = rows[indexPath.row].draft rows[indexPath.row].mode = .display pendingSizeChange = .reload(indexPath) - didChangeHeightPublisher.send() + didChangeHeightPublisher.send(Int(height(for: rows[indexPath.row]) - previousHeight)) activitiesChangedPublisher.send(rows.compactMap(\.committed)) } private func deleteActivity(at indexPath: IndexPath) { guard rows.indices.contains(indexPath.row) else { return } + let previousHeight = rectForRow(at: indexPath).height dropdownHost?.dismissPresented() rows.remove(at: indexPath.row) pendingSizeChange = .delete(indexPath) - didChangeHeightPublisher.send() + didChangeHeightPublisher.send(-Int(previousHeight)) activitiesChangedPublisher.send(rows.compactMap(\.committed)) } + + private func height(for row: Row) -> CGFloat { + let cell: UITableViewCell + switch row.mode { + case .display: + let displayCell = RecruitProfilePostActivityDisplayTableViewCell( + style: .default, + reuseIdentifier: nil + ) + displayCell.configure(activity: row.draft) + cell = displayCell + case .editing: + let editCell = RecruitProfilePostActivityEditTableViewCell( + style: .default, + reuseIdentifier: nil + ) + editCell.configure(activity: row.draft) + cell = editCell + } + + cell.bounds.size.width = bounds.width + cell.setNeedsLayout() + cell.layoutIfNeeded() + return cell.contentView.systemLayoutSizeFitting( + CGSize(width: bounds.width, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ).height + } } extension RecruitProfilePostActivityTableView: UITableViewDataSource { diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift index 54e4caff0..45c27a5ec 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift @@ -17,7 +17,7 @@ final class RecruitProfilePostSkillTableView: UITableView { // MARK: - Properties let skillsChangedPublisher = PassthroughSubject<[String], Never>() - let didChangeHeightPublisher = PassthroughSubject() + let didChangeHeightPublisher = PassthroughSubject() let addButtonTappedPublisher = PassthroughSubject() private var skills: [String] = [] @@ -64,18 +64,20 @@ final class RecruitProfilePostSkillTableView: UITableView { extension RecruitProfilePostSkillTableView { private func addSkill() { + let previousHeight = intrinsicContentSize.height let indexPath = IndexPath(row: skills.count, section: 0) skills.append("") pendingSizeChange = .insert(indexPath) - didChangeHeightPublisher.send() + didChangeHeightPublisher.send(Int(intrinsicContentSize.height - previousHeight)) skillsChangedPublisher.send(skills) } private func deleteSkill(at indexPath: IndexPath) { guard skills.indices.contains(indexPath.row) else { return } + let previousHeight = intrinsicContentSize.height skills.remove(at: indexPath.row) pendingSizeChange = .delete(indexPath) - didChangeHeightPublisher.send() + didChangeHeightPublisher.send(Int(intrinsicContentSize.height - previousHeight)) skillsChangedPublisher.send(skills) } } From 9d44fc565ed9ce39d3325563df0afa53e2db060d Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sat, 10 Oct 2026 22:07:20 +0900 Subject: [PATCH 121/123] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1/=EC=88=98=EC=A0=95=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=8A=A4=ED=81=AC=EB=A1=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitProfilePostViewController.swift | 4 ++- .../RecruitProfilePostSecondStepView.swift | 25 ++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift index 3051e1640..240011a48 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift @@ -380,7 +380,7 @@ extension RecruitProfilePostViewController { .store(in: &subscriptions) secondStepView.didChangeHeightPublisher - .sink { [weak self] _ in + .sink { [weak self] (section, heightChanged) in guard let self else { return } view.layoutIfNeeded() UIView.animate( @@ -390,6 +390,8 @@ extension RecruitProfilePostViewController { self.secondStepView.applyPendingSizeChange() self.updateCompleteButtonState() self.view.layoutIfNeeded() + guard heightChanged > 0 else { return } + self.secondStepView.scrollBottomToVisible(of: section) } } .store(in: &subscriptions) diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift index 93ade5c78..e7f25fd43 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift @@ -12,12 +12,17 @@ import UIKit final class RecruitProfilePostSecondStepView: UIScrollView { + enum Section { + case skill + case activity + } + // MARK: - Publishers let preferredRoleChangedPublisher = PassthroughSubject() let skillsChangedPublisher = PassthroughSubject<[String], Never>() let activitiesChangedPublisher = PassthroughSubject<[RecruitProfileActivityRequest], Never>() let introductionChangedPublisher = PassthroughSubject() - let didChangeHeightPublisher = PassthroughSubject() + let didChangeHeightPublisher = PassthroughSubject<(Section, Int), Never>() var skillAddButtonTappedPublisher: AnyPublisher { skillTableView.addButtonTappedPublisher.eraseToAnyPublisher() } @@ -100,6 +105,20 @@ final class RecruitProfilePostSecondStepView: UIScrollView { activityTableView.applyPendingSizeChange() } + func scrollBottomToVisible(of section: Section) { + let tableView: UITableView = switch section { + case .skill: + skillTableView + case .activity: + activityTableView + } + let bottomRect = tableView.convert( + CGRect(x: 0, y: tableView.bounds.maxY - 1, width: tableView.bounds.width, height: 1), + to: self + ) + scrollRectToVisible(bottomRect, animated: false) + } + private func bind() { preferredRoleView.textChangedPublisher .subscribe(preferredRoleChangedPublisher) @@ -115,8 +134,8 @@ final class RecruitProfilePostSecondStepView: UIScrollView { .store(in: &subscriptions) Publishers.Merge( - skillTableView.didChangeHeightPublisher, - activityTableView.didChangeHeightPublisher + skillTableView.didChangeHeightPublisher.map { (.skill, $0) }, + activityTableView.didChangeHeightPublisher.map { (.activity, $0) } ) .subscribe(didChangeHeightPublisher) .store(in: &subscriptions) From 3682ea4fc9ee50197ea09fd1c7cdd2cc325497c0 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 11 Oct 2026 15:45:51 +0900 Subject: [PATCH 122/123] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitApplyViewController.swift | 4 +-- .../Subviews/RecruitApplyFirstStepView.swift | 25 ++++++++++++------- .../RecruitPostViewController.swift | 4 +-- .../Subviews/RecruitPostRoleView.swift | 10 ++++---- .../RecruitProfilePostViewController.swift | 4 +-- .../RecruitProfilePostSecondStepView.swift | 25 ++++++++++++------- .../RecruitProfilePostActivityTableView.swift | 10 ++++---- .../RecruitProfilePostSkillTableView.swift | 6 ++--- 8 files changed, 51 insertions(+), 37 deletions(-) diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift index cae6a5e33..65af13046 100644 --- a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift @@ -195,7 +195,7 @@ extension RecruitApplyViewController { } .store(in: &subscriptions) firstStepView.didChangeHeightPublisher - .sink { [weak self] (section, heightChanged) in + .sink { [weak self] (section, indexPath, heightChanged) in guard let self else { return } view.layoutIfNeeded() UIView.animate( @@ -206,7 +206,7 @@ extension RecruitApplyViewController { self.updateNextButtonState() self.view.layoutIfNeeded() guard heightChanged > 0 else { return } - self.firstStepView.scrollBottomToVisible(of: section) + self.firstStepView.scrollRowToVisible(at: indexPath, in: section) } } .store(in: &subscriptions) diff --git a/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift index 19378cafb..4543a9fa1 100644 --- a/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift +++ b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift @@ -20,7 +20,11 @@ final class RecruitApplyFirstStepView: UIScrollView { // MARK: - Publisher let loadInfoButtonTappedPublisher = PassthroughSubject() let departmentButtonTappedPublisher = PassthroughSubject() - let didChangeHeightPublisher = PassthroughSubject<(Section, Int), Never>() + let didChangeHeightPublisher = PassthroughSubject<( + section: Section, + indexPath: IndexPath, + heightChanged: Int + ), Never>() var nicknameChangedPublisher: AnyPublisher { nicknameView.textChangedPublisher.eraseToAnyPublisher() @@ -169,26 +173,29 @@ final class RecruitApplyFirstStepView: UIScrollView { activityTableView.applyPendingSizeChange() } - func scrollBottomToVisible(of section: Section) { + func scrollRowToVisible(at indexPath: IndexPath, in section: Section) { let tableView: UITableView = switch section { case .skill: skillTableView case .activity: activityTableView } - let bottomRect = tableView.convert( - CGRect(x: 0, y: tableView.bounds.maxY - 1, width: tableView.bounds.width, height: 1), - to: self - ) - scrollRectToVisible(bottomRect, animated: false) + guard indexPath.section < tableView.numberOfSections, + indexPath.row < tableView.numberOfRows(inSection: indexPath.section) else { return } + let rowRect = tableView.convert(tableView.rectForRow(at: indexPath), to: self) + scrollRectToVisible(rowRect, animated: false) } } extension RecruitApplyFirstStepView { private func bind() { Publishers.Merge( - skillTableView.didChangeHeightPublisher.map { (.skill, $0) }, - activityTableView.didChangeHeightPublisher.map { (.activity, $0) } + skillTableView.didChangeHeightPublisher.map { + (section: .skill, indexPath: $0.indexPath, heightChanged: $0.heightChanged) + }, + activityTableView.didChangeHeightPublisher.map { + (section: .activity, indexPath: $0.indexPath, heightChanged: $0.heightChanged) + } ) .subscribe(didChangeHeightPublisher) .store(in: &subscriptions) diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift index daf15d4a3..e5e4b5067 100644 --- a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift @@ -277,7 +277,7 @@ extension RecruitPostViewController { self?.request.numberOfGeneralMembers = numberOfGeneralMembers }.store(in: &subscriptions) - roleView.didChangeHeightPublisher.sink { [weak self] heightDifference in + roleView.didChangeHeightPublisher.sink { [weak self] heightChanged in guard let self else { return } @@ -294,7 +294,7 @@ extension RecruitPostViewController { roleView.applyPendingSizeChange() view.layoutIfNeeded() - guard 0 < heightDifference else { + guard 0 < heightChanged else { return } let bottomRect = roleView.convert( diff --git a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift index 2a733de20..00297c21c 100644 --- a/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift @@ -114,7 +114,7 @@ extension RecruitPostRoleView { tableView.endEditing(true) roles.append(.init()) pendingRowChangeIndex = roles.count - 1 - didChangeHeightPublisher.send(heightDifference(from: previousRowCount)) + didChangeHeightPublisher.send(heightChanged(from: previousRowCount)) rolesChangedPublisher.send(roles) } @@ -128,7 +128,7 @@ extension RecruitPostRoleView { roles = [] numberOfGeneralMembers = minimumNumberOfMembers if previousRowCount != displayedRowCount { - didChangeHeightPublisher.send(heightDifference(from: previousRowCount)) + didChangeHeightPublisher.send(heightChanged(from: previousRowCount)) } else { updateViews() } @@ -140,7 +140,7 @@ extension RecruitPostRoleView { roles = [.init()] numberOfGeneralMembers = minimumNumberOfMembers if previousRowCount != displayedRowCount { - didChangeHeightPublisher.send(heightDifference(from: previousRowCount)) + didChangeHeightPublisher.send(heightChanged(from: previousRowCount)) } else { updateViews() } @@ -186,7 +186,7 @@ extension RecruitPostRoleView { tableView.endEditing(true) roles.remove(at: index) pendingRowChangeIndex = index - didChangeHeightPublisher.send(heightDifference(from: previousRowCount)) + didChangeHeightPublisher.send(heightChanged(from: previousRowCount)) rolesChangedPublisher.send(roles) } } @@ -200,7 +200,7 @@ extension RecruitPostRoleView { roles.reduce(0) { $0 + $1.maximumParticipants } } - private func heightDifference(from previousRowCount: Int) -> Int { + private func heightChanged(from previousRowCount: Int) -> Int { RecruitPostRoleTableView.contentHeight(for: displayedRowCount) - RecruitPostRoleTableView.contentHeight(for: previousRowCount) } diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift index 240011a48..519cb1a23 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift @@ -380,7 +380,7 @@ extension RecruitProfilePostViewController { .store(in: &subscriptions) secondStepView.didChangeHeightPublisher - .sink { [weak self] (section, heightChanged) in + .sink { [weak self] (section, indexPath, heightChanged) in guard let self else { return } view.layoutIfNeeded() UIView.animate( @@ -391,7 +391,7 @@ extension RecruitProfilePostViewController { self.updateCompleteButtonState() self.view.layoutIfNeeded() guard heightChanged > 0 else { return } - self.secondStepView.scrollBottomToVisible(of: section) + self.secondStepView.scrollRowToVisible(at: indexPath, in: section) } } .store(in: &subscriptions) diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift index e7f25fd43..bb4ec4170 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift @@ -22,7 +22,11 @@ final class RecruitProfilePostSecondStepView: UIScrollView { let skillsChangedPublisher = PassthroughSubject<[String], Never>() let activitiesChangedPublisher = PassthroughSubject<[RecruitProfileActivityRequest], Never>() let introductionChangedPublisher = PassthroughSubject() - let didChangeHeightPublisher = PassthroughSubject<(Section, Int), Never>() + let didChangeHeightPublisher = PassthroughSubject<( + section: Section, + indexPath: IndexPath, + heightChanged: Int + ), Never>() var skillAddButtonTappedPublisher: AnyPublisher { skillTableView.addButtonTappedPublisher.eraseToAnyPublisher() } @@ -105,18 +109,17 @@ final class RecruitProfilePostSecondStepView: UIScrollView { activityTableView.applyPendingSizeChange() } - func scrollBottomToVisible(of section: Section) { + func scrollRowToVisible(at indexPath: IndexPath, in section: Section) { let tableView: UITableView = switch section { case .skill: skillTableView case .activity: activityTableView } - let bottomRect = tableView.convert( - CGRect(x: 0, y: tableView.bounds.maxY - 1, width: tableView.bounds.width, height: 1), - to: self - ) - scrollRectToVisible(bottomRect, animated: false) + guard indexPath.section < tableView.numberOfSections, + indexPath.row < tableView.numberOfRows(inSection: indexPath.section) else { return } + let rowRect = tableView.convert(tableView.rectForRow(at: indexPath), to: self) + scrollRectToVisible(rowRect, animated: false) } private func bind() { @@ -134,8 +137,12 @@ final class RecruitProfilePostSecondStepView: UIScrollView { .store(in: &subscriptions) Publishers.Merge( - skillTableView.didChangeHeightPublisher.map { (.skill, $0) }, - activityTableView.didChangeHeightPublisher.map { (.activity, $0) } + skillTableView.didChangeHeightPublisher.map { + (section: .skill, indexPath: $0.indexPath, heightChanged: $0.heightChanged) + }, + activityTableView.didChangeHeightPublisher.map { + (section: .activity, indexPath: $0.indexPath, heightChanged: $0.heightChanged) + } ) .subscribe(didChangeHeightPublisher) .store(in: &subscriptions) diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift index 7c4d77d4b..bca95f00e 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift @@ -33,7 +33,7 @@ final class RecruitProfilePostActivityTableView: UITableView { let addCompleteButtonTappedPublisher = PassthroughSubject() let editCompleteButtonTappedPublisher = PassthroughSubject() let activitiesChangedPublisher = PassthroughSubject<[RecruitProfileActivityRequest], Never>() - let didChangeHeightPublisher = PassthroughSubject() + let didChangeHeightPublisher = PassthroughSubject<(indexPath: IndexPath, heightChanged: Int), Never>() private var rows: [Row] = [] private var footerSubscriptions = Set() @@ -111,7 +111,7 @@ extension RecruitProfilePostActivityTableView { mode: .editing )) pendingSizeChange = .insert(indexPath) - didChangeHeightPublisher.send(Int(height(for: rows[indexPath.row]))) + didChangeHeightPublisher.send((indexPath, Int(height(for: rows[indexPath.row])))) } private func editActivity(at indexPath: IndexPath) { @@ -123,7 +123,7 @@ extension RecruitProfilePostActivityTableView { rows[indexPath.row].draft = committed rows[indexPath.row].mode = .editing pendingSizeChange = .reload(indexPath) - didChangeHeightPublisher.send(Int(height(for: rows[indexPath.row]) - previousHeight)) + didChangeHeightPublisher.send((indexPath, Int(height(for: rows[indexPath.row]) - previousHeight))) } private func completeActivity(at indexPath: IndexPath) { @@ -139,7 +139,7 @@ extension RecruitProfilePostActivityTableView { rows[indexPath.row].committed = rows[indexPath.row].draft rows[indexPath.row].mode = .display pendingSizeChange = .reload(indexPath) - didChangeHeightPublisher.send(Int(height(for: rows[indexPath.row]) - previousHeight)) + didChangeHeightPublisher.send((indexPath, Int(height(for: rows[indexPath.row]) - previousHeight))) activitiesChangedPublisher.send(rows.compactMap(\.committed)) } @@ -150,7 +150,7 @@ extension RecruitProfilePostActivityTableView { dropdownHost?.dismissPresented() rows.remove(at: indexPath.row) pendingSizeChange = .delete(indexPath) - didChangeHeightPublisher.send(-Int(previousHeight)) + didChangeHeightPublisher.send((indexPath, -Int(previousHeight))) activitiesChangedPublisher.send(rows.compactMap(\.committed)) } diff --git a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift index 45c27a5ec..e8ba5bf2d 100644 --- a/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift @@ -17,7 +17,7 @@ final class RecruitProfilePostSkillTableView: UITableView { // MARK: - Properties let skillsChangedPublisher = PassthroughSubject<[String], Never>() - let didChangeHeightPublisher = PassthroughSubject() + let didChangeHeightPublisher = PassthroughSubject<(indexPath: IndexPath, heightChanged: Int), Never>() let addButtonTappedPublisher = PassthroughSubject() private var skills: [String] = [] @@ -68,7 +68,7 @@ extension RecruitProfilePostSkillTableView { let indexPath = IndexPath(row: skills.count, section: 0) skills.append("") pendingSizeChange = .insert(indexPath) - didChangeHeightPublisher.send(Int(intrinsicContentSize.height - previousHeight)) + didChangeHeightPublisher.send((indexPath, Int(intrinsicContentSize.height - previousHeight))) skillsChangedPublisher.send(skills) } @@ -77,7 +77,7 @@ extension RecruitProfilePostSkillTableView { let previousHeight = intrinsicContentSize.height skills.remove(at: indexPath.row) pendingSizeChange = .delete(indexPath) - didChangeHeightPublisher.send(Int(intrinsicContentSize.height - previousHeight)) + didChangeHeightPublisher.send((indexPath, Int(intrinsicContentSize.height - previousHeight))) skillsChangedPublisher.send(skills) } } From ea673cd29c919e8f5c85aa88c6cd4deff510dda5 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Sun, 11 Oct 2026 15:48:30 +0900 Subject: [PATCH 123/123] update 5.2.1 --- koin.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index e1a50a675..0ce4c4558 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -8179,7 +8179,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = koin/koin.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 446; + CURRENT_PROJECT_VERSION = 451; DEVELOPMENT_TEAM = K626UYGR25; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -8198,7 +8198,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.2.0; + MARKETING_VERSION = 5.2.1; PRODUCT_BUNDLE_IDENTIFIER = com.bcsdlab.koin.stage; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -8214,7 +8214,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = koin/koin.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 446; + CURRENT_PROJECT_VERSION = 451; DEVELOPMENT_TEAM = K626UYGR25; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -8233,7 +8233,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.2.0; + MARKETING_VERSION = 5.2.1; PRODUCT_BUNDLE_IDENTIFIER = com.bcsdlab.koin; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES;