diff --git a/koinUnitTests/Support/PublisherTestSupport.swift b/Common/Support/Publisher+.swift similarity index 64% rename from koinUnitTests/Support/PublisherTestSupport.swift rename to Common/Support/Publisher+.swift index 8dceeb16e..f4ce12519 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. @@ -14,9 +14,6 @@ enum PublisherTestError: Error { extension Publisher { /// Combine 파이프라인이 방출하는 첫 번째 값을 async로 받아온다. - /// - /// 프로덕션 코드가 `AnyPublisher`를 반환하기 때문에, Swift Testing의 `async` 테스트에서 - /// `await`로 결과를 기다리기 위해 `Publisher.values`(AsyncSequence)를 경유한다. func firstValue() async throws -> Output { for try await value in values { return value diff --git a/Koin/Apps/SceneDelegate.swift b/Koin/Apps/SceneDelegate.swift index 6d1c01f91..ba0b9f73c 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,22 @@ 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 logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) + let viewModel = RecruitNotificationListViewModel( + fetchRecruitNotificationListUseCase: fetchRecruitNotificationListUseCase, + markAsReadRecruitNotificationUseCase: markAsReadRecruitNotificationUseCase, + deleteRecruitNotificationUseCase: deleteRecruitNotificationUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase, + notificationId: notificationId + ) + return RecruitNotificationListViewController(viewModel: viewModel) + } + private func makeCallVanDataViewController(postId: Int) -> CallVanDataViewController { let callVanRepository = DefaultCallVanRepository(service: DefaultCallVanService()) let fetchCallVanDataUseCase = DefaultFetchCallVanDataUseCase(repository: callVanRepository) 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 88% rename from Koin/Core/Extensions/Asset/ImageAsset.swift rename to Koin/Core/Asset/ImageAsset.swift index 187145877..25fbde18a 100644 --- a/Koin/Core/Extensions/Asset/ImageAsset.swift +++ b/Koin/Core/Asset/ImageAsset.swift @@ -214,6 +214,33 @@ public enum ImageAsset: String { case noticeNotLoginToolTip case noticeSearch + // MARK: - Recruit + case recruitDataLocation + case recruitDataCalendar + case recruitDataMembers + case recruitDataClock + case recruitDataAuthor + case recruitDataRoleClosed + case recruitDataRoleOpen + case recruitBell + case recruitBellDot + case recruitDate + case recruitFilter + case recruitFilterX + case recruitLocation + case recruitMember + case recruitNotificationChat + case recruitNotificationMember + case recruitPencil + case recruitPostMeetingTypeMixed + case recruitPostMeetingTypeOffline + case recruitPostMeetingTypeOnline + case recruitPostRolePlus + case recruitProfile + case recruitProfileMyApplication + case recruitProfileMyPost + case recruitSearch + // MARK: - Shop case call case callBenefit @@ -256,7 +283,6 @@ public enum ImageAsset: String { // MARK: - Home case categoryBusiness - case categoryRecruit case categoryBusSearch case categoryBusTimetable case categoryCallVan @@ -266,6 +292,7 @@ public enum ImageAsset: String { case categoryFacility case categoryLand case categoryLostitem + case categoryRecruit case categoryShop case categoryTimetable case chevronRightRounded 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/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/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") 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/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/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/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 + )) } } 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/Core/Logger/EventParameter.swift b/Koin/Core/Logger/EventParameter.swift index a3d9928ed..645f900bd 100644 --- a/Koin/Core/Logger/EventParameter.swift +++ b/Koin/Core/Logger/EventParameter.swift @@ -234,6 +234,99 @@ enum EventParameter { case callvanWriteDone = "callvan_write_done" case callvanWriteBack = "callvan_write_back" + // Recruit + 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" + 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" + 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 teamRecruitmentRecruit = "team_recruitment_recruit" + 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" + 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" + 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" + 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" + 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" + 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" + 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" + 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" } @@ -277,6 +370,7 @@ enum EventParameter { case pageExit = "page_exit" case update = "update" case notification + case result } enum EventLabelNeededDuration: String { 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/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/Presentation/Shared/Chat/Models/ChatListModel.swift b/Koin/Core/View/UIKit/Chat/Models/ChatListModel.swift similarity index 100% rename from Koin/Presentation/Shared/Chat/Models/ChatListModel.swift rename to Koin/Core/View/UIKit/Chat/Models/ChatListModel.swift diff --git a/Koin/Presentation/Shared/Chat/Models/ChatMessageRowModel.swift b/Koin/Core/View/UIKit/Chat/Models/ChatMessageRowModel.swift similarity index 100% rename from Koin/Presentation/Shared/Chat/Models/ChatMessageRowModel.swift rename to Koin/Core/View/UIKit/Chat/Models/ChatMessageRowModel.swift diff --git a/Koin/Presentation/Shared/Chat/Views/ChatInputView.swift b/Koin/Core/View/UIKit/Chat/Views/ChatInputView.swift similarity index 100% rename from Koin/Presentation/Shared/Chat/Views/ChatInputView.swift rename to Koin/Core/View/UIKit/Chat/Views/ChatInputView.swift diff --git a/Koin/Presentation/Shared/Chat/Views/ChatListView.swift b/Koin/Core/View/UIKit/Chat/Views/ChatListView.swift similarity index 100% rename from Koin/Presentation/Shared/Chat/Views/ChatListView.swift rename to Koin/Core/View/UIKit/Chat/Views/ChatListView.swift diff --git a/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatDateHeaderView.swift b/Koin/Core/View/UIKit/Chat/Views/ChatTableView/ChatDateHeaderView.swift similarity index 91% rename from Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatDateHeaderView.swift rename to Koin/Core/View/UIKit/Chat/Views/ChatTableView/ChatDateHeaderView.swift index d4e449d85..a5d401aa8 100644 --- a/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatDateHeaderView.swift +++ b/Koin/Core/View/UIKit/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/Core/View/UIKit/Chat/Views/ChatTableView/ChatLeftCell.swift similarity index 97% rename from Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatLeftCell.swift rename to Koin/Core/View/UIKit/Chat/Views/ChatTableView/ChatLeftCell.swift index 5a42889c5..0f1981706 100644 --- a/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatLeftCell.swift +++ b/Koin/Core/View/UIKit/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/Core/View/UIKit/Chat/Views/ChatTableView/ChatRightCell.swift similarity index 97% rename from Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatRightCell.swift rename to Koin/Core/View/UIKit/Chat/Views/ChatTableView/ChatRightCell.swift index 71a0bb4e0..353f0191a 100644 --- a/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatRightCell.swift +++ b/Koin/Core/View/UIKit/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 diff --git a/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatTableView.swift b/Koin/Core/View/UIKit/Chat/Views/ChatTableView/ChatTableView.swift similarity index 100% rename from Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatTableView.swift rename to Koin/Core/View/UIKit/Chat/Views/ChatTableView/ChatTableView.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/Presentation/Shared/FilterBottomSheet/FilterBottomSheetView.swift b/Koin/Core/View/UIKit/FilterBottomSheet/FilterBottomSheetView.swift similarity index 95% rename from Koin/Presentation/Shared/FilterBottomSheet/FilterBottomSheetView.swift rename to Koin/Core/View/UIKit/FilterBottomSheet/FilterBottomSheetView.swift index 3f45112f7..ce28ca9b0 100644 --- a/Koin/Presentation/Shared/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: @escaping (FilterItemModel)->Bool, + 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) @@ -69,10 +72,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(groupIndex, tappedItem) else { + return + } } - let before = groupModels[groupIndex].items.map(\.isSelected) groupModels[groupIndex].didTap(itemAt: itemIndex) let after = groupModels[groupIndex].items.map(\.isSelected) @@ -112,6 +116,7 @@ extension FilterBottomSheetView { changed: Self.changedIndexPaths(before: before, after: after) ) } + onResetTapped?() } @objc private func applyButtonTapped() { diff --git a/Koin/Presentation/Shared/FilterBottomSheet/Model/FilterGroupModel.swift b/Koin/Core/View/UIKit/FilterBottomSheet/Model/FilterGroupModel.swift similarity index 100% rename from Koin/Presentation/Shared/FilterBottomSheet/Model/FilterGroupModel.swift rename to Koin/Core/View/UIKit/FilterBottomSheet/Model/FilterGroupModel.swift diff --git a/Koin/Presentation/Shared/FilterBottomSheet/Model/FilterItemModel.swift b/Koin/Core/View/UIKit/FilterBottomSheet/Model/FilterItemModel.swift similarity index 100% rename from Koin/Presentation/Shared/FilterBottomSheet/Model/FilterItemModel.swift rename to Koin/Core/View/UIKit/FilterBottomSheet/Model/FilterItemModel.swift diff --git a/Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupCollectionView/FilterGroupCollectionView.swift b/Koin/Core/View/UIKit/FilterBottomSheet/Subviews/FilterGroupCollectionView/FilterGroupCollectionView.swift similarity index 100% rename from Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupCollectionView/FilterGroupCollectionView.swift rename to Koin/Core/View/UIKit/FilterBottomSheet/Subviews/FilterGroupCollectionView/FilterGroupCollectionView.swift diff --git a/Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupCollectionView/FilterGroupCollectionViewCell.swift b/Koin/Core/View/UIKit/FilterBottomSheet/Subviews/FilterGroupCollectionView/FilterGroupCollectionViewCell.swift similarity index 100% rename from Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupCollectionView/FilterGroupCollectionViewCell.swift rename to Koin/Core/View/UIKit/FilterBottomSheet/Subviews/FilterGroupCollectionView/FilterGroupCollectionViewCell.swift diff --git a/Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupView.swift b/Koin/Core/View/UIKit/FilterBottomSheet/Subviews/FilterGroupView.swift similarity index 100% rename from Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupView.swift rename to Koin/Core/View/UIKit/FilterBottomSheet/Subviews/FilterGroupView.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 88% rename from Koin/Core/View/KoinDropdown/KoinDropdown.swift rename to Koin/Core/View/UIKit/KoinDropdown/KoinDropdown.swift index 400beca96..4283b335c 100644 --- a/Koin/Core/View/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/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 95% rename from Koin/Core/View/KoinDropdown/KoinDropdownHost.swift rename to Koin/Core/View/UIKit/KoinDropdown/KoinDropdownHost.swift index 44179ffa4..24471c16e 100644 --- a/Koin/Core/View/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/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 85% rename from Koin/Core/View/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift rename to Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewDateDelegate.swift index e35b2ef10..bf3b9a630 100644 --- a/Koin/Core/View/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,21 @@ extension KoinPickerDropDownViewDateDelegate: KoinPickerDropDownViewDelegate { extension KoinPickerDropDownViewDateDelegate { - private func resetDates(range: Range) { + func resetDates(from startDate: Date, to endDate: Date) { let calendar = Calendar.current - let startDay = calendar.startOfDay(for: Date()) + 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) + } + + private func resetDates(range: Range, startDate: Date? = nil) { + let calendar = Calendar.current + 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/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewTimeDelegate.swift b/Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewTimeDelegate.swift similarity index 99% rename from Koin/Core/View/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewTimeDelegate.swift rename to Koin/Core/View/UIKit/KoinPickerDropDownView/Delegates/KoinPickerDropDownViewTimeDelegate.swift index c923547fa..a6c28e292 100644 --- a/Koin/Core/View/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/KoinPickerDropDownView/KoinPickerDropDownView.swift b/Koin/Core/View/UIKit/KoinPickerDropDownView/KoinPickerDropDownView.swift similarity index 94% rename from Koin/Core/View/KoinPickerDropDownView/KoinPickerDropDownView.swift rename to Koin/Core/View/UIKit/KoinPickerDropDownView/KoinPickerDropDownView.swift index 8efa05348..cc9ed5584 100644 --- a/Koin/Core/View/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/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/Presentation/Shared/Notification/Models/NotificationRowModel.swift b/Koin/Core/View/UIKit/Notification/Models/NotificationRowModel.swift similarity index 100% rename from Koin/Presentation/Shared/Notification/Models/NotificationRowModel.swift rename to Koin/Core/View/UIKit/Notification/Models/NotificationRowModel.swift diff --git a/Koin/Presentation/Shared/Notification/Views/NotificationEmptyView.swift b/Koin/Core/View/UIKit/Notification/Views/NotificationEmptyView.swift similarity index 100% rename from Koin/Presentation/Shared/Notification/Views/NotificationEmptyView.swift rename to Koin/Core/View/UIKit/Notification/Views/NotificationEmptyView.swift diff --git a/Koin/Presentation/Shared/Notification/Views/NotificationListView.swift b/Koin/Core/View/UIKit/Notification/Views/NotificationListView.swift similarity index 75% rename from Koin/Presentation/Shared/Notification/Views/NotificationListView.swift rename to Koin/Core/View/UIKit/Notification/Views/NotificationListView.swift index c29ababb1..ba1b5c472 100644 --- a/Koin/Presentation/Shared/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() } @@ -39,8 +46,16 @@ final class NotificationListView: UIView { loadingIndicator.startAnimating() } - func update(items: [NotificationRowModel]) { - tableView.update(notifications: items) + func stopLoading() { + loadingIndicator.stopAnimating() + } + + 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) } @@ -52,6 +67,14 @@ final class NotificationListView: UIView { tableView.deleteAll() updateStateViews(isEmpty: true) } + + func indexPath(for notificationId: Int) -> IndexPath? { + tableView.indexPath(for: notificationId) + } + + func tapRow(at indexPath: IndexPath) { + tableView.tapRow(at: indexPath) + } } // MARK: - Bind @@ -71,6 +94,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/Presentation/Shared/Notification/Views/NotificationPopUpViewController.swift b/Koin/Core/View/UIKit/Notification/Views/NotificationPopUpViewController.swift similarity index 100% rename from Koin/Presentation/Shared/Notification/Views/NotificationPopUpViewController.swift rename to Koin/Core/View/UIKit/Notification/Views/NotificationPopUpViewController.swift diff --git a/Koin/Presentation/Shared/Notification/Views/NotificationTableView/NotificationFooterView.swift b/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationFooterView.swift similarity index 63% rename from Koin/Presentation/Shared/Notification/Views/NotificationTableView/NotificationFooterView.swift rename to Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationFooterView.swift index a6ac8bedb..558189af2 100644 --- a/Koin/Presentation/Shared/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/Presentation/Shared/Notification/Views/NotificationTableView/NotificationTableView.swift b/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableView.swift similarity index 68% rename from Koin/Presentation/Shared/Notification/Views/NotificationTableView/NotificationTableView.swift rename to Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableView.swift index e08a3c77f..299335ca3 100644 --- a/Koin/Presentation/Shared/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.. IndexPath? { + guard let index = notifications.firstIndex(where: { $0.id == String(notificationId) }) else { + return nil + } + return IndexPath(row: index, section: 0) + } + + func tapRow(at indexPath: IndexPath) { + tableView(self, didSelectRowAt: indexPath) + } } extension NotificationTableView { @@ -111,7 +159,13 @@ extension NotificationTableView { private func recalculateFooterHeightIfNeeded() { guard bounds.width > 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 +205,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 +231,7 @@ extension NotificationTableView: UITableViewDelegate { tapNotificationPublisher.send(id) didSelectNotification(id: id) } - + func tableView( _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath @@ -185,7 +239,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 +258,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 +310,11 @@ extension NotificationTableView { ) ) ) - + } + + private func commonInit() { + dataSource = self + delegate = self register( NotificationTableViewCell.self, forCellReuseIdentifier: NotificationTableViewCell.identifier diff --git a/Koin/Presentation/Shared/Notification/Views/NotificationTableView/NotificationTableViewCell.swift b/Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableViewCell.swift similarity index 100% rename from Koin/Presentation/Shared/Notification/Views/NotificationTableView/NotificationTableViewCell.swift rename to Koin/Core/View/UIKit/Notification/Views/NotificationTableView/NotificationTableViewCell.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 93% rename from Koin/Core/View/StatefulButton.swift rename to Koin/Core/View/UIKit/StatefulButton.swift index d869c9eaf..aa9624192 100644 --- a/Koin/Core/View/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 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/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 = "" 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/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..789271e1b --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitChatMessageDto.swift @@ -0,0 +1,90 @@ +// +// 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: "en_US_POSIX") + $0.timeZone = TimeZone(identifier: "Asia/Seoul") + $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/Decodable/Recruit/RecruitDataDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitDataDto.swift new file mode 100644 index 000000000..6b6580b5f --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitDataDto.swift @@ -0,0 +1,122 @@ +// +// 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() 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") + $0.calendar = Calendar(identifier: .gregorian) + } + + return RecruitData( + id: id, + category: category.toDomain(), + dDay: dDay?.toDDay(), + state: state, + 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 new file mode 100644 index 000000000..4eb78b6ca --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitEnumDto.swift @@ -0,0 +1,186 @@ +// +// 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" + case deleted = "DELETED" +} + +enum RecruitRoleTypeDto: String, Codable { + case roleBased = "ROLE_BASED" + case general = "GENERAL" +} + +enum RecruitApplicationStatusDto: String, Codable { + 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 { + 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 + case .deleted: + return nil + } + } +} + +extension RecruitRoleTypeDto { + init(from model: RecruitRoleType) { + switch model { + case .roleBased: + self = .roleBased + case .general: + self = .general + } + } + + func toDomain() -> RecruitRoleType { + switch self { + case .roleBased: + return .roleBased + case .general: + return .general + } + } +} + +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: + 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/RecruitListDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitListDto.swift new file mode 100644 index 000000000..1d36dced1 --- /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.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 new file mode 100644 index 000000000..90032a604 --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitListSummaryDto.swift @@ -0,0 +1,61 @@ +// +// 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? { + guard let state = status.toDomain() else { + return nil + } + return RecruitRow( + id: id, + category: category.toDomain(), + title: title, + meetingType: meetingType.toDomain(), + startDate: activityStartDate.toDateFromYYYYMMDD(), + endDate: activityEndDate.toDateFromYYYYMMDD(), + deadline: deadlineDate.toDateFromYYYYMMDD(), + dDay: dDay?.toDDay(), + state: state, + currentParticipants: currentParticipants, + maximumParticipants: maxParticipants, + type: recruitmentType.toDomain(), + roles: roles.map { $0.toDomain() } + ) + } +} diff --git a/Koin/Data/DTOs/Decodable/Recruit/RecruitMyApplicationListDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyApplicationListDto.swift new file mode 100644 index 000000000..8dcf8a0fd --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyApplicationListDto.swift @@ -0,0 +1,77 @@ +// +// 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.compactMap { $0.toDomain() }, + totalCount: totalCount, + totalPage: totalPage, + currentPage: currentPage + ) + } +} + +extension RecruitMyApplicationRowDto { + func toDomain() -> RecruitMyApplicationRow? { + guard let recruit = recruitment.toDomain() else { + return nil + } + 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/Decodable/Recruit/RecruitMyPostDataDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostDataDto.swift new file mode 100644 index 000000000..f77f30b97 --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostDataDto.swift @@ -0,0 +1,105 @@ +// +// 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() throws -> RecruitMyPostData { + guard let recruit = recruitment.recruitment.toDomain() else { + throw ErrorResponse.deletedRecruitment + } + 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/Decodable/Recruit/RecruitMyPostListDto.swift b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostListDto.swift new file mode 100644 index 000000000..2a2bc0382 --- /dev/null +++ b/Koin/Data/DTOs/Decodable/Recruit/RecruitMyPostListDto.swift @@ -0,0 +1,78 @@ +// +// 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.compactMap { $0.toDomain() }, + totalCount: totalCount, + totalPage: totalPage, + currentPage: currentPage + ) + } +} + +extension RecruitMyPostRowDto { + func toDomain() -> RecruitMyPostRow? { + guard let recruit = recruitment.toDomain() else { + return nil + } + 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/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/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/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/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/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/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/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/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/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/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/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/DTOs/Encodable/Recruit/RecruitNotificationListRequestDto.swift b/Koin/Data/DTOs/Encodable/Recruit/RecruitNotificationListRequestDto.swift new file mode 100644 index 000000000..802ff9f2f --- /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 = 50 + } +} 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/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 new file mode 100644 index 000000000..b8577bc3e --- /dev/null +++ b/Koin/Data/Repository/DefaultRecruitRepository.swift @@ -0,0 +1,151 @@ +// +// DefaultRecruitRepository.swift +// koin +// +// Created by 홍기정 on 10/1/26. +// + +import Foundation + +final class DefaultRecruitRepository: RecruitRepository { + + private let service: RecruitService + + 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() + } + + 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 + } + + 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) + } + + func fetchMyProfile() async throws -> RecruitProfile { + 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 fetchMyApplicationList(_ filter: RecruitMyApplicationFilter) async throws -> RecruitMyApplicationList { + let request = RecruitMyApplicationListRequestDto(from: filter) + 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 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 + } + + func upsertMyProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile { + guard let request = RecruitProfileRequestDto(from: request) else { + throw ErrorResponse.unexpectedInternalError + } + return try await service.upsertMyProfile(request).toDomain() + } + + func fetchTeamChatData( + recruitmentId: Int, + chatRoomId: Int + ) async throws -> RecruitChatData { + try await service.fetchChatData(recruitmentId: recruitmentId, chatRoomId: chatRoomId).toDomain() + } + + func fetchDirectChatData( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitChatData { + try await service.fetchDirectChatData(recruitmentId: recruitmentId, applicationId: applicationId).toDomain() + } + + func fetchChatMessages( + recruitmentId: Int, + chatRoomId: Int + ) async throws -> RecruitChatMessageList { + let messages = try await service.fetchChatMessages(recruitmentId: recruitmentId, chatRoomId: chatRoomId) + return messages.toDomain(myUserId: UserDataManager.shared.id) + } + + func postChatMessage( + recruitmentId: Int, + chatRoomId: Int, + request: RecruitChatPostRequest + ) async throws { + let request = RecruitChatPostRequestDto(from: request) + try await service.postChatMessage(recruitmentId: recruitmentId, chatRoomId: chatRoomId, request) + } + + 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 service.deleteNotification(id) + } + + func deleteAllNotification() async throws -> Void { + try await service.deleteAllNotification() + } + + func markAsReadNotification(_ id: Int) async throws -> Void { + try await service.markAsReadNotification(id) + } + + func markAllAsReadNotification() async throws -> Void { + try await service.markAllAsReadNotification() + } +} 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/Service/Network/API/RecruitAPI.swift b/Koin/Data/Service/Network/API/RecruitAPI.swift new file mode 100644 index 000000000..c2949f10f --- /dev/null +++ b/Koin/Data/Service/Network/API/RecruitAPI.swift @@ -0,0 +1,151 @@ +// +// RecruitAPI.swift +// koin +// +// Created by 홍기정 on 10/1/26. +// + +import Foundation +import Alamofire + +enum RecruitAPI { + case fetchList(RecruitListRequestDto) + case fetchData(Int) + case deleteData(Int) + case post(RecruitPostRequestDto) + case modify(Int, RecruitPostRequestDto) + case fetchMyProfile + case upsertMyProfile(RecruitProfileRequestDto) + case apply(Int, RecruitApplyRequestDto) + 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) + 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) + case fetchNotificationList(RecruitNotificationListRequestDto) + case markAsReadNotification(Int) + case markAllAsReadNotification + case deleteNotification(Int) + case deleteAllNotification +} + +extension RecruitAPI: Router, URLRequestConvertible { + + public var baseURL: String { + return Bundle.main.baseUrl + } + + public var path: String { + switch self { + 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)" + 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" + 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" + 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)" + } + } + + public var method: Alamofire.HTTPMethod { + switch self { + case .fetchList: return .get + case .fetchData: return .get + case .deleteData: return .delete + case .post: return .post + case .modify: return .put + case .fetchMyProfile: return .get + case .upsertMyProfile: return .put + case .apply: return .post + case .fetchMyApplicationList: return .get + case .fetchMyPostList: return .get + case .closeMyPost: return .put + 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 + 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, .fetchNotificationList, .markAsReadNotification, .markAllAsReadNotification, .deleteNotification, .deleteAllNotification: + break + case .post, .modify, .upsertMyProfile, .apply, .decideApplicant, .postChatMessage: + baseHeaders["Content-Type"] = "application/json" + } + return baseHeaders + } + + public var parameters: Any? { + switch self { + case .fetchList(let request): + return try? request.toDictionary() + case .fetchData, .deleteData, .fetchMyProfile: + return nil + case .post(let request), .modify(_, let request): + return try? request.toDictionary() + case .upsertMyProfile(let request): + return try? request.toDictionary() + case .apply(_, let request): + return try? request.toDictionary() + case .fetchMyApplicationList(let request): + return try? request.toDictionary() + case .fetchMyPostList(let request): + return try? request.toDictionary() + case .fetchMyPostData(_, let request): + return try? request.toDictionary() + case .decideApplicant(_, _, let request): + 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 + } + } + + public var encoding: ParameterEncoding? { + switch self { + 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: + return nil + case .post, .modify, .upsertMyProfile, .apply, .decideApplicant, .postChatMessage: + 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 new file mode 100644 index 000000000..d6a0bd776 --- /dev/null +++ b/Koin/Data/Service/RecruitService.swift @@ -0,0 +1,131 @@ +// +// RecruitService.swift +// koin +// +// Created by 홍기정 on 10/1/26. +// + +import Foundation + +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 + 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 + 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 + 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 + 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 { + + private let networkService = NetworkService.shared + + 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)) + } + + 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)) + } + + 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)) + } + + 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)) + } + + 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)) + } + + 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)) + } + + 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)) + } + + 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/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/Core/ErrorResponse.swift b/Koin/Domain/Model/Core/ErrorResponse.swift index 02ec10f8f..39567130b 100644 --- a/Koin/Domain/Model/Core/ErrorResponse.swift +++ b/Koin/Domain/Model/Core/ErrorResponse.swift @@ -37,8 +37,14 @@ 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 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: "로그인에 실패하여 코어데이터에서 키워드 삭제") 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/BasicInfo.swift b/Koin/Domain/Model/Recruit/BasicInfo.swift new file mode 100644 index 000000000..6f3b2c0c4 --- /dev/null +++ b/Koin/Domain/Model/Recruit/BasicInfo.swift @@ -0,0 +1,25 @@ +// +// 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.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/RecruitApplicantData.swift b/Koin/Domain/Model/Recruit/RecruitApplicantData.swift new file mode 100644 index 000000000..0563a2de4 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitApplicantData.swift @@ -0,0 +1,33 @@ +// +// RecruitApplicantData.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import Foundation + +struct RecruitApplicantData { + let applicationId: Int + private(set) var status: RecruitApplicationStatus + let profile: RecruitProfile + let motivation: String + let availableTime: String + let role: String? + private(set) var canDecide: Bool + private(set) var canDirectChat: Bool +} + +extension RecruitApplicantData { + mutating func decided(as decision: RecruitApplicantDecision) { + switch decision { + case .accepted: + self.status = .accepted + self.canDirectChat = true + case .denied: + self.status = .denied + } + + canDecide = false + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitApplicantDecision.swift b/Koin/Domain/Model/Recruit/RecruitApplicantDecision.swift new file mode 100644 index 000000000..12de5ae3e --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitApplicantDecision.swift @@ -0,0 +1,13 @@ +// +// RecruitApplicantDecision.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +enum RecruitApplicantDecision: String, Equatable { + case accepted = "승인" + case denied = "거절" +} diff --git a/Koin/Domain/Model/Recruit/RecruitApplicantRow.swift b/Koin/Domain/Model/Recruit/RecruitApplicantRow.swift new file mode 100644 index 000000000..c87846890 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitApplicantRow.swift @@ -0,0 +1,24 @@ +// +// RecruitApplicantRow.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +struct RecruitApplicantRow: Identifiable, Equatable { + let applicationId: Int + let nickname: String + let department: String + let studentYear: Int + let role: String + let status: RecruitApplicationStatus + let canChat: Bool +} + +extension RecruitApplicantRow { + var id: Int { + return applicationId + } +} diff --git a/Koin/Domain/Model/Recruit/RecruitApplicationStatus.swift b/Koin/Domain/Model/Recruit/RecruitApplicationStatus.swift new file mode 100644 index 000000000..83c7c6b97 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitApplicationStatus.swift @@ -0,0 +1,25 @@ +// +// RecruitApplicationStatus.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +enum RecruitApplicationStatus: 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/RecruitApplyBlockReason.swift b/Koin/Domain/Model/Recruit/RecruitApplyBlockReason.swift new file mode 100644 index 000000000..90c7d52c9 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitApplyBlockReason.swift @@ -0,0 +1,53 @@ +// +// 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 +} + +extension RecruitApplyBlockReason { + var buttonText: String { + switch self { + case .loginRequired, .profileRequired: + return "지원하기" + case .alreadyApplied: + return "지원완료" + case .recruitmentDeleted, .ownRecruitment, .recruitmentClosed, .deadlinePassed, .roleClosed: + 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/Domain/Model/Recruit/RecruitApplyRequest.swift b/Koin/Domain/Model/Recruit/RecruitApplyRequest.swift new file mode 100644 index 000000000..50a4aea27 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitApplyRequest.swift @@ -0,0 +1,25 @@ +// +// RecruitApplyRequest.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import Foundation + +struct RecruitApplyRequest { + var selectedRole: RecruitRole? + var motivation: String? + var availableTime: String? +} + +extension RecruitApplyRequest { + 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/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/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/RecruitChatMessageList.swift b/Koin/Domain/Model/Recruit/RecruitChatMessageList.swift new file mode 100644 index 000000000..48f46d339 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitChatMessageList.swift @@ -0,0 +1,13 @@ +// +// RecruitChatMessageList.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +struct RecruitChatMessageList { + 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/RecruitData.swift b/Koin/Domain/Model/Recruit/RecruitData.swift new file mode 100644 index 000000000..c870e1249 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitData.swift @@ -0,0 +1,73 @@ +// +// 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 state: RecruitState + let title: String + + let meetingType: RecruitMeetingType + let startDate: Date? + let endDate: Date? + let deadlineDate: Date? + let currentParticipants: Int + let maximumParticipants: Int + let createdAt: Date? + let author: String? + + let type: RecruitRoleType + let roles: [RecruitRole] + + let description: String + let relatedUrl: URL? + let qualification: String? + + let application: RecruitMyApplication? + + let isAuthor: Bool + private(set) var canApply: Bool + private(set) var applyBlockReason: RecruitApplyBlockReason? + let canManageApplicants: Bool + let teamChatAvailable: Bool + let teamChatRoomId: Int? +} + +extension RecruitData { + mutating func markAsAlreadyApplied() { + canApply = false + 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/Domain/Model/Recruit/RecruitList.swift b/Koin/Domain/Model/Recruit/RecruitList.swift new file mode 100644 index 000000000..c5509879e --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitList.swift @@ -0,0 +1,23 @@ +// +// RecruitList.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +struct RecruitList { + var recruits: [RecruitRow] + + var totalCount: Int + let totalPage: Int + let currentPage: Int +} + +extension RecruitList { + mutating func delete(id: Int) { + recruits.removeAll(where: { $0.id == id }) + totalCount -= 1 + } +} 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..f55402b88 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitListSort.swift @@ -0,0 +1,31 @@ +// +// 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 + } + } + + 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 new file mode 100644 index 000000000..38c646d1f --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitMeetingType.swift @@ -0,0 +1,62 @@ +// +// 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 + } + } + + var imageAsset: ImageAsset { + switch self { + case .online: + return .recruitPostMeetingTypeOnline + case .offline: + return .recruitPostMeetingTypeOffline + case .mixed: + return .recruitPostMeetingTypeMixed + } + } + + var logValue: String { + switch self { + case .online: + "온라인" + case .offline: + "오프라인" + case .mixed: + "온·오프라인" + } + } +} + +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/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/RecruitMyApplicationFilter.swift b/Koin/Domain/Model/Recruit/RecruitMyApplicationFilter.swift new file mode 100644 index 000000000..e4db2a078 --- /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: RecruitApplicationStatus? = 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..64cb98b7b --- /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: [RecruitMyApplicationRow] + + 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/RecruitMyApplicationRow.swift b/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift new file mode 100644 index 000000000..88f8b911e --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitMyApplicationRow.swift @@ -0,0 +1,30 @@ +// +// RecruitMyApplicationRow.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +struct RecruitMyApplicationRow: 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 state: RecruitState + + let currentParticipants: Int + let maximumParticipants: Int + + let type: RecruitRoleType + let roles: [RecruitRole] + + let application: RecruitMyApplication + let chatRoomId: Int? +} diff --git a/Koin/Domain/Model/Recruit/RecruitMyPostData.swift b/Koin/Domain/Model/Recruit/RecruitMyPostData.swift new file mode 100644 index 000000000..8735195b0 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitMyPostData.swift @@ -0,0 +1,40 @@ +// +// 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? + + var applicants: [RecruitApplicantRow] + let totalCount: Int + let totalPage: Int + let currentPage: Int +} + +extension RecruitMyPostData { + var hasNextPage: Bool { + currentPage < totalPage + } +} 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..aa06b44d6 --- /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: [RecruitMyPostRow] + + 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/RecruitMyPostRow.swift b/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift new file mode 100644 index 000000000..3ede95b10 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitMyPostRow.swift @@ -0,0 +1,30 @@ +// +// RecruitMyPostRow.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +struct RecruitMyPostRow: 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/Model/Recruit/RecruitNotificationList.swift b/Koin/Domain/Model/Recruit/RecruitNotificationList.swift new file mode 100644 index 000000000..99657a953 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitNotificationList.swift @@ -0,0 +1,46 @@ +// +// RecruitNotificationList.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +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 + } + + 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/Model/Recruit/RecruitNotificationRow.swift b/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift new file mode 100644 index 000000000..28c5d4bea --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitNotificationRow.swift @@ -0,0 +1,37 @@ +// +// RecruitNotificationRow.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +struct RecruitNotificationRow: Equatable { + let id: Int + let type: RecruitNotificationType + let targetType: RecruitNotificationTargetType + let recruitmentId: Int + let applicationId: Int? + let chatRoomId: Int? + let senderNickname: String? + let messagePreview: String + let createdAt: Date? + var isRead: Bool +} + +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 { + case applicantManagement + case chatRoom + case myApplications + case none +} diff --git a/Koin/Domain/Model/Recruit/RecruitPostRequest.swift b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift new file mode 100644 index 000000000..084e6b681 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitPostRequest.swift @@ -0,0 +1,109 @@ +// +// 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 { + guard let category, + let title, + !title.isEmpty, + let meetingType, + let startDate, + let endDate, + let deadline, + let description, + !description.isEmpty, + !(endDate < startDate), + !(endDate < deadline) else { + return false + } + + switch type { + case .roleBased: + guard !roles.isEmpty else { + return false + } + for role in roles { + guard role.isValid else { + return false + } + } + return true + case .general: + return roles.isEmpty + } + } +} + +struct RecruitRoleRequest { + var id: Int? = nil + var name: String = "" + var maximumParticipants: Int = 1 + + var isValid: Bool { + !name.isEmpty && 1 <= maximumParticipants && maximumParticipants <= 10 + } +} + +extension RecruitPostRequest { + 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 + } +} + +extension RecruitRoleRequest { + init(from form: RecruitRole) { + self.id = form.id + self.name = form.name + 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/Model/Recruit/RecruitProfile.swift b/Koin/Domain/Model/Recruit/RecruitProfile.swift new file mode 100644 index 000000000..1addbccf6 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitProfile.swift @@ -0,0 +1,38 @@ +// +// RecruitProfile.swift +// koin +// +// Created by 홍기정 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 +} + +extension RecruitProfile { + func toBasicInfo() -> BasicInfo { + BasicInfo( + nickname: nickname, + department: department, + studentNumber: studentNumber + ) + } + + func toRequest() -> RecruitProfileRequest { + RecruitProfileRequest( + nickname: nickname, + 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 new file mode 100644 index 000000000..121d084f4 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitProfileActivity.swift @@ -0,0 +1,45 @@ +// +// 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 + + 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 { + 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 new file mode 100644 index 000000000..b60634223 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitProfileActivityRequest.swift @@ -0,0 +1,41 @@ +// +// RecruitProfileActivityRequest.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import Foundation + +struct RecruitProfileActivityRequest { + 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.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let startedAt, + let description, + !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 + } + + 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 new file mode 100644 index 000000000..9b9f2896f --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitProfileRequest.swift @@ -0,0 +1,36 @@ +// +// 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 +} + +extension RecruitProfileRequest { + var isValid: Bool { + 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 + } + + 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/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/RecruitRow.swift b/Koin/Domain/Model/Recruit/RecruitRow.swift new file mode 100644 index 000000000..ffdcf560d --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitRow.swift @@ -0,0 +1,27 @@ +// +// RecruitRow.swift +// koin +// +// Created by 홍기정 on 9/12/26. +// + +import Foundation + +struct RecruitRow: 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 state: RecruitState + + let currentParticipants: Int + let maximumParticipants: Int + + let type: RecruitRoleType + let roles: [RecruitRole] +} diff --git a/Koin/Domain/Model/Recruit/RecruitState.swift b/Koin/Domain/Model/Recruit/RecruitState.swift new file mode 100644 index 000000000..bcae050a7 --- /dev/null +++ b/Koin/Domain/Model/Recruit/RecruitState.swift @@ -0,0 +1,36 @@ +// +// 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 + } + } + + var logValue: String { + switch self { + case .all: + "전체" + case .recruiting: + "모집 중" + case .closed: + "모집 완료" + } + } +} diff --git a/Koin/Domain/Repository/RecruitRepository.swift b/Koin/Domain/Repository/RecruitRepository.swift new file mode 100644 index 000000000..126a47398 --- /dev/null +++ b/Koin/Domain/Repository/RecruitRepository.swift @@ -0,0 +1,54 @@ +// +// RecruitRepository.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +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 -> RecruitChatMessageList + 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 fetchMyPostData(_ id: Int, page: Int) async throws -> RecruitMyPostData + func fetchApplicant( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitApplicantData + func decideApplicant( + recruitmentId: Int, + applicationId: Int, + decision: RecruitApplicantDecision + ) async throws -> Void + func fetchMyApplicationList(_ filter: RecruitMyApplicationFilter) async throws -> RecruitMyApplicationList + func closeMyPost(id: Int) async throws -> Bool + 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 + 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 + func fetchMyProfile() async throws -> RecruitProfile + func upsertMyProfile(_ request: RecruitProfileRequest) async throws -> RecruitProfile + func apply(recruitmentId: Int, _ 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/RecruitApply/ApplyRecruitUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitApply/ApplyRecruitUseCase.swift new file mode 100644 index 000000000..b9aaf0649 --- /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(recruitmentId: Int, request: RecruitApplyRequest) async throws -> Void +} + +final class DefaultApplyRecruitUseCase: ApplyRecruitUseCase { + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(recruitmentId: Int, request: RecruitApplyRequest) async throws -> Void { + try await repository.apply(recruitmentId: recruitmentId, request) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitChatMessagesUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitChat/FetchRecruitChatMessagesUseCase.swift new file mode 100644 index 000000000..b674a3956 --- /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 -> RecruitChatMessageList +} + +final class DefaultFetchRecruitChatMessagesUseCase: FetchRecruitChatMessagesUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute( + recruitmentId: Int, + chatRoomId: Int + ) async throws -> RecruitChatMessageList { + 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/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/RecruitList/FetchRecruitListUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitList/FetchRecruitListUseCase.swift new file mode 100644 index 000000000..5a2dcdc94 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitList/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/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/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/DecideRecruitApplicantUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/DecideRecruitApplicantUseCase.swift new file mode 100644 index 000000000..4913d1e1f --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/DecideRecruitApplicantUseCase.swift @@ -0,0 +1,37 @@ +// +// DecideRecruitApplicantUseCase.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +protocol DecideRecruitApplicantUseCase { + func execute( + recruitmentId: Int, + applicationId: Int, + decision: RecruitApplicantDecision + ) async throws -> Void +} + +final class DefaultDecideRecruitApplicantUseCase: DecideRecruitApplicantUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute( + recruitmentId: Int, + applicationId: Int, + decision: RecruitApplicantDecision + ) async throws -> Void { + try await repository.decideApplicant( + recruitmentId: recruitmentId, + applicationId: applicationId, + decision: decision + ) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitApplicantUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitApplicantUseCase.swift new file mode 100644 index 000000000..cbc7f30c9 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitApplicantUseCase.swift @@ -0,0 +1,34 @@ +// +// FetchRecruitApplicantUseCase.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import Foundation + +protocol FetchRecruitApplicantUseCase { + func execute( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitApplicantData +} + +final class DefaultFetchRecruitApplicantUseCase: FetchRecruitApplicantUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute( + recruitmentId: Int, + applicationId: Int + ) async throws -> RecruitApplicantData { + try await repository.fetchApplicant( + recruitmentId: recruitmentId, + applicationId: applicationId + ) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostDataUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitMyPostList/FetchRecruitMyPostDataUseCase.swift new file mode 100644 index 000000000..e722a060a --- /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, page: Int) async throws -> RecruitMyPostData +} + +final class DefaultFetchRecruitMyPostDataUseCase: FetchRecruitMyPostDataUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(id: Int, page: Int) async throws -> RecruitMyPostData { + try await repository.fetchMyPostData(id, page: page) + } +} 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/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/RecruitNotificationList/FetchRecruitNotificationListUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitNotificationList/FetchRecruitNotificationListUseCase.swift new file mode 100644 index 000000000..2efe8b106 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitNotificationList/FetchRecruitNotificationListUseCase.swift @@ -0,0 +1,25 @@ +// +// FetchRecruitNotificationListUseCase.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import Foundation + +protocol FetchRecruitNotificationListUseCase { + func execute(page: Int) async throws -> RecruitNotificationList +} + +final class DefaultFetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase { + + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(page: Int) async throws -> RecruitNotificationList { + try await repository.fetchNotificationList(page: page) + } +} 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/Domain/UseCase/Recruit/RecruitPost/ModifyRecruitUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitPost/ModifyRecruitUseCase.swift new file mode 100644 index 000000000..4ee293efb --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitPost/ModifyRecruitUseCase.swift @@ -0,0 +1,27 @@ +// +// 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 { + 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 new file mode 100644 index 000000000..fe266fe12 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitPost/PostRecruitUseCase.swift @@ -0,0 +1,27 @@ +// +// 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 { + var request = request + try request.normalizeRelatedUrl() + return try await repository.post(request) + } +} diff --git a/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyRecruitProfileUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyRecruitProfileUseCase.swift new file mode 100644 index 000000000..6de46b5a8 --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitProfile/FetchMyRecruitProfileUseCase.swift @@ -0,0 +1,28 @@ +// +// FetchMyRecruitProfileUseCase.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import Foundation + +protocol FetchMyRecruitProfileUseCase { + func execute() async throws -> RecruitProfile? +} + +final class DefaultFetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase { + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + 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/Domain/UseCase/Recruit/RecruitProfile/UpsertMyRecruitProfileUseCase.swift b/Koin/Domain/UseCase/Recruit/RecruitProfile/UpsertMyRecruitProfileUseCase.swift new file mode 100644 index 000000000..212b1331a --- /dev/null +++ b/Koin/Domain/UseCase/Recruit/RecruitProfile/UpsertMyRecruitProfileUseCase.swift @@ -0,0 +1,24 @@ +// +// UpsertMyRecruitProfileUseCase.swift +// koin +// +// Created by 홍기정 on 9/21/26. +// + +import Foundation + +protocol UpsertMyRecruitProfileUseCase { + func execute(request: RecruitProfileRequest) async throws -> RecruitProfile +} + +final class DefaultUpsertMyRecruitProfileUseCase: UpsertMyRecruitProfileUseCase { + private let repository: RecruitRepository + + init(repository: RecruitRepository) { + self.repository = repository + } + + func execute(request: RecruitProfileRequest) async throws -> RecruitProfile { + try await repository.upsertMyProfile(request) + } +} 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/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 { diff --git a/Koin/Presentation/CallVan/CallVanPost/CallVanPostViewController.swift b/Koin/Presentation/CallVan/CallVanPost/CallVanPostViewController.swift index d4d97e864..9294adb5a 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/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, Hostin case .showBusiness: presentBusiness() case .showRecruit: - showRecruit() + navigationController?.pushViewController(makeRecruitListViewController(), animated: true) case .showLoginToast: showToastMessageWithButton( @@ -199,6 +199,22 @@ extension CategoryHostingController { let viewModel = LandViewModel(fetchLandListUseCase: fetchLandListUseCase, logAnalyticsEventUseCase: logAnalyticsEventUseCase) return LandViewController(viewModel: viewModel) } + + private func makeRecruitListViewController() -> UIViewController { + let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) + 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, + logAnalyticsEventUseCase: logAnalyticsEventUseCase + ) + let rootView = RecruitListView(viewModel: viewModel) + return RecruitListHostingController(rootView: rootView) + } private func presentBusiness() { if let url = URL(string: "https://owner.koreatech.in/") { @@ -206,14 +222,4 @@ extension CategoryHostingController { present(safariViewController, animated: true) } } - - private func showRecruit() { - guard var components = URLComponents(string: Bundle.main.baseUrl), - let host = components.host else { return } - components.host = host.hasPrefix("api.") ? String(host.dropFirst("api.".count)) : host - components.path = "/team" - guard let url = components.url else { return } - let safariViewController = SFSafariViewController(url: url) - present(safariViewController, animated: true) - } } diff --git a/Koin/Presentation/Home/HomeTabBar/HomeTabbarController.swift b/Koin/Presentation/Home/HomeTabBar/HomeTabbarController.swift index 7344ef4fa..ffcde4e62 100644 --- a/Koin/Presentation/Home/HomeTabBar/HomeTabbarController.swift +++ b/Koin/Presentation/Home/HomeTabBar/HomeTabbarController.swift @@ -156,7 +156,7 @@ extension HomeTabBarController { var navigationBarStyle: NavigationBarStyle switch items[index].tab { case .home, .category, .profile: - navigationBarStyle = .order + navigationBarStyle = .newBackground case .board: navigationBarStyle = .empty } 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..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) { @@ -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/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) ) diff --git a/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift new file mode 100644 index 000000000..65af13046 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewController.swift @@ -0,0 +1,516 @@ +// +// 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 let recruit: RecruitData + private var basicInfo = BasicInfo() + private var profileRequest = RecruitProfileRequest() + private var applyRequest = 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 } + inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentApplyMajorSelect, .click, department)) + 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.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?.updateRecruitProfile(profile) + self?.firstStepView.configure(profile) + 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): + self?.showToastMessage(message: message, bottomInset: 72) + } + } + .store(in: &subscriptions) + + firstStepView.loadInfoButtonTappedPublisher + .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 + 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() + } + } + .store(in: &subscriptions) + firstStepView.studentNumberChangedPublisher + .sink { [weak self] value in self?.basicInfo.studentNumber = value + self?.updateNextButtonState() + } + .store(in: &subscriptions) + firstStepView.skillsChangedPublisher + .sink { [weak self] value in self?.profileRequest.skills = value + self?.updateNextButtonState() + } + .store(in: &subscriptions) + firstStepView.activitiesChangedPublisher + .sink { [weak self] value in + self?.profileRequest.activities = value + self?.updateNextButtonState() + } + .store(in: &subscriptions) + firstStepView.introductionChangedPublisher + .sink { [weak self] value in + self?.profileRequest.introduction = value + self?.updateNextButtonState() + } + .store(in: &subscriptions) + firstStepView.didChangeHeightPublisher + .sink { [weak self] (section, indexPath, heightChanged) in + guard let self else { return } + view.layoutIfNeeded() + UIView.animate( + springDuration: 0.25, + options: [.beginFromCurrentState, .allowUserInteraction] + ) { + self.firstStepView.applyPendingSizeChange() + self.updateNextButtonState() + self.view.layoutIfNeeded() + guard heightChanged > 0 else { return } + self.firstStepView.scrollRowToVisible(at: indexPath, in: section) + } + } + .store(in: &subscriptions) + + secondStepView.selectedRoleChangedPublisher + .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) + secondStepView.motivationChangedPublisher + .sink { [weak self] value in + self?.applyRequest.motivation = value + self?.updateApplyButtonState() + } + .store(in: &subscriptions) + secondStepView.availableTimeChangedPublisher + .sink { [weak self] value in + self?.applyRequest.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 isFirstStepValid, !firstStepView.isEditingActivity else { return } + inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentApplyNext, .click, "다음")) + 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 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, + profileRequest: profileRequest, + applyRequest: applyRequest + )) + } + let modalViewController = KoinModalViewController( + configuration: .init( + appearance: .new, + content: .singleTitle(text: "해당 팀원 모집에 지원하시겠어요?"), + button: .buttons( + leftButtonTitle: "취소하기", + leftButtonAction: onCancelTapped, + rightButtonTitle: "지원하기", + rightButtonAction: onApplyTapped + ) + ) + ) + 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 updateRecruitProfile(_ profile: RecruitProfile) { + basicInfo = profile.toBasicInfo() + profileRequest = profile.toRequest() + } +} + +extension RecruitApplyViewController { + private func updateLoading(_ isLoading: Bool) { + if isLoading { + IndicatorView.show() + } else { + IndicatorView.dismiss() + } + } +} + +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: isFirstStepValid && !firstStepView.isEditingActivity + ) + } + + private func updateApplyButtonState() { + applyButton.updateState(isEnabled: 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..9f1933f2a --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitApply/RecruitApplyViewModel.swift @@ -0,0 +1,153 @@ +// +// 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( + recruitmentId: Int, + basicInfo: BasicInfo, + profileRequest: RecruitProfileRequest, + applyRequest: RecruitApplyRequest + ) + case logEvent(EventLabelType, EventParameter.EventCategory, Any) + } + + enum Output { + case updateRecruitProfile(RecruitProfile) + case updateDepartments([String]) + case updateLoading(Bool) + case applyCompleted + case showToast(String) + } + + // MARK: - Properties + private let fetchDeptListUseCase: FetchDeptListUseCase + private let fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase + 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 + private var isApplying = false + + // MARK: - Initializer + init( + fetchDeptListUseCase: FetchDeptListUseCase, + fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase, + modifyBasicInfoUseCase: ModifyBasicInfoUseCase, + upsertMyRecruitProfileUseCase: UpsertMyRecruitProfileUseCase, + 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 { + input + .sink { [weak self] input in + switch input { + case .viewDidLoad: + self?.fetchDepartments() + case .loadRecruitProfile: + self?.fetchRecruitProfile() + case let .apply(recruitmentId, basicInfo, profileRequest, applyRequest): + self?.apply( + recruitmentId: recruitmentId, + basicInfo: basicInfo, + profileRequest: profileRequest, + applyRequest: applyRequest + ) + case let .logEvent(label, category, value): + self?.logAnalyticsEventUseCase.execute(label: label, category: category, value: value) + } + } + .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 { + guard let profile = try await fetchMyRecruitProfileUseCase.execute() else { + let message = "프로필을 불러오지 못했습니다." + outputSubject.send(.showToast(message)) + return + } + outputSubject.send(.updateRecruitProfile(profile)) + } catch { + let message = (error as? ErrorResponse)?.message ?? "프로필을 불러오지 못했습니다." + outputSubject.send(.showToast(message)) + } + } + } + + private func apply( + recruitmentId: Int, + basicInfo: BasicInfo, + profileRequest: RecruitProfileRequest, + applyRequest: RecruitApplyRequest + ) { + guard !isApplying else { return } + + isApplying = true + outputSubject.send(.updateLoading(true)) + Task { [weak self] in + guard let self else { return } + defer { + isApplying = false + outputSubject.send(.updateLoading(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 { + let errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + outputSubject.send(.showToast(errorMessage)) + } + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift new file mode 100644 index 000000000..4543a9fa1 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitApply/Subviews/RecruitApplyFirstStepView.swift @@ -0,0 +1,304 @@ +// +// RecruitApplyFirstStepView.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import Combine +import SnapKit +import Then +import UIKit + +final class RecruitApplyFirstStepView: UIScrollView { + + enum Section { + case skill + case activity + } + + // MARK: - Publisher + let loadInfoButtonTappedPublisher = PassthroughSubject() + let departmentButtonTappedPublisher = PassthroughSubject() + let didChangeHeightPublisher = PassthroughSubject<( + section: Section, + indexPath: IndexPath, + heightChanged: Int + ), Never>() + + var nicknameChangedPublisher: AnyPublisher { + nicknameView.textChangedPublisher.eraseToAnyPublisher() + } + var studentNumberChangedPublisher: AnyPublisher { + studentNumberView.textChangedPublisher.eraseToAnyPublisher() + } + 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() + } + 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() + } + + func scrollRowToVisible(at indexPath: IndexPath, in section: Section) { + let tableView: UITableView = switch section { + case .skill: + skillTableView + case .activity: + activityTableView + } + 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 { + (section: .skill, indexPath: $0.indexPath, heightChanged: $0.heightChanged) + }, + activityTableView.didChangeHeightPublisher.map { + (section: .activity, indexPath: $0.indexPath, heightChanged: $0.heightChanged) + } + ) + .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..91b3f1917 --- /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..076c6b195 --- /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/RecruitChat/Mapper/ChatListModel+RecruitChat.swift b/Koin/Presentation/Recruit/RecruitChat/Mapper/ChatListModel+RecruitChat.swift new file mode 100644 index 000000000..b8c16548a --- /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: RecruitChatMessageList) { + 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..11b23223c --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewController.swift @@ -0,0 +1,231 @@ +// +// 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) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + inputSubject.send(.viewWillAppear) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + inputSubject.send(.viewDidDisappear) + } +} + +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(.viewDidDisappear) + } + .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..3cb21fcc0 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitChat/RecruitChatViewModel.swift @@ -0,0 +1,242 @@ +// +// 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 viewWillAppear + case viewDidDisappear + case sendText(text: String) + case sendImage(image: Data) + } + + enum Output { + case updateData(RecruitChatData) + case updateMessages(RecruitChatMessageList) + 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 + } + } + private var didUpdateData = false + + // 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 .viewWillAppear: + self?.startPolling() + case .viewDidDisappear: + 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 fetchChatRoomDataIfNeeded() async { + guard !didUpdateData else { + return + } + + do { + switch roomSource { + case .direct(_, let applicationId): + try await fetchDirectChatData(applicationId) + case .team: + try await fetchTeamChatData() + } + didUpdateData = true + } catch { + let errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + outputSubject.send(.showToast(errorMessage)) + } + } + + 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)) + } +} + +extension RecruitChatViewModel { + + private func startPolling() { + guard pollingTask == nil else { + return + } + + pollingTask = Task { [weak self] in + guard let self else { + return + } + + await fetchChatRoomDataIfNeeded() + + while !Task.isCancelled { + 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 is CancellationError { + return + } catch { + let errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + outputSubject.send(.showToast(errorMessage)) + } + } +} + +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 { + let errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + outputSubject.send(.showToast(errorMessage)) + } + } + } + + 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 { + let errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + outputSubject.send(.showToast(errorMessage)) + } + } + } + + 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 + } + + try await postChatMessageUseCase.execute( + recruitmentId: recruitmentId, + chatRoomId: chatRoomId, + request: request + ) + } +} diff --git a/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift new file mode 100644 index 000000000..e13df265f --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataHostingController.swift @@ -0,0 +1,262 @@ +// +// 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 .showProfileRequiredToast: + showProfileRequiredToast() + 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: "로그인하기", + bottomInset: 72 + ) { [weak self] in + self?.navigateToLogin() + } + } + + private func showProfileRequiredToast() { + showToastMessageWithButton( + message: RecruitApplyBlockReason.profileRequired.toastMessage, + buttonTitle: "작성하기", + bottomInset: 72 + ) { [weak self] in + self?.navigateToProfilePost() + } + } +} + +extension RecruitDataHostingController { + @objc private func rightBarButtonItemTapped() { + let popUpViewController = RecruitDataPopUpViewController( + onEditButtonTapped: { [weak self] in + guard let self, + let data = rootView.data else { + return + } + rootView.makeLogAnalyticsEvent( + label: EventParameter.EventLabel.Campus.teamRecruitmentPostEdit, + category: .click, + value: "편집하기" + ) + guard data.state == .recruiting else { + showToastMessage(message: "마감된 팀원 모집글입니다.") + return + } + navigateToEdit(data: data) + }, + onDeleteButtonTapped: { [weak self] in + self?.rootView.makeLogAnalyticsEvent( + label: EventParameter.EventLabel.Campus.teamRecruitmentPostDelete, + category: .click, + value: "삭제하기" + ) + self?.showDeleteModal() + } + ) + popUpViewController.modalPresentationStyle = .overFullScreen + navigationController?.present( + popUpViewController, + animated: false + ) + } + + 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: onCancelButtonTapped, + rightButtonTitle: "삭제하기", + rightButtonAction: onDeleteButtonTapped + ) + ) + ) + present(modalViewController, animated: true) + } + + private func showToastMessage(message: String) { + showToastMessage(message: message, bottomInset: 72) + } +} + +extension RecruitDataHostingController { + private func navigateToApplicant() { + guard let id = rootView.id else { + return + } + 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( + rootView: RecruitMyPostView(viewModel: viewModel) + ) + navigationController?.pushViewController(controller, animated: true) + } + private func navigateToApply() { + guard let recruit = rootView.data else { + return + } + 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), + logAnalyticsEventUseCase: logAnalyticsEventUseCase + ) + let viewController = RecruitApplyViewController( + viewModel: viewModel, + recruit: recruit, + delegate: self + ) + navigationController?.pushViewController(viewController, animated: true) + } + + 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 + self?.rootView.reload() + } + navigationController?.pushViewController(viewController, animated: true) + } + + private func handleDelete() { + guard let id = rootView.id else { + return + } + delegate?.delete(id: id) + navigationController?.popViewController(animated: true) + } + + private func navigateToEdit(data: RecruitData) { + 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, + logAnalyticsEventUseCase: logAnalyticsEventUseCase + ) + let viewController = RecruitPostViewController(viewModel: viewModel) + 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 new file mode 100644 index 000000000..46a66441f --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataView.swift @@ -0,0 +1,127 @@ +// +// 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 showProfileRequiredToast + 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 + } + var data: RecruitData? { + viewModel.data + } + + // 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) + } + + func reload() { + viewModel.execute(.load) + } + + func didSubmitApplication() { + viewModel.execute(.applicationSubmitted) + } + + func makeLogAnalyticsEvent( + label: EventLabelType, + category: EventParameter.EventCategory, + value: Any + ) { + viewModel.execute(.logEvent(label, category, value)) + } +} + +extension RecruitDataView { + private func didTapButton() { + guard let data = viewModel.data else { + return + } + + if data.isAuthor { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentPostApplicantCheck, .click, data.title)) + sendAction(.showApplicant) + return + } + if data.canApply { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentPostApply, .click, data.title)) + 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 + } + 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..081cf0a89 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/RecruitDataViewModel.swift @@ -0,0 +1,118 @@ +// +// 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 applicationSubmitted + case didShowToast + case logEvent(EventLabelType, EventParameter.EventCategory, Any) + } + + // MARK: - State + private(set) var data: RecruitData? + private(set) var errorMessage: String? + private(set) var didDelete = 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 let logAnalyticsEventUseCase: LogAnalyticsEventUseCase + private var fetchTask: Task? + private var deleteTask: Task? + + // MARK: - Initializer + init( + fetchRecruitDataUseCase: FetchRecruitDataUseCase, + deleteRecruitDataUseCase: DeleteRecruitDataUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase, + recruitId: Int + ) { + self.fetchRecruitDataUseCase = fetchRecruitDataUseCase + self.deleteRecruitDataUseCase = deleteRecruitDataUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase + self.recruitId = recruitId + } + + // MARK: - Public + func execute(_ input: Input) { + switch input { + case .load: + load() + case .delete: + delete() + case .applicationSubmitted: + applicationSubmitted() + case .didShowToast: + errorMessage = nil + case let .logEvent(label, category, value): + logAnalyticsEventUseCase.execute(label: label, category: category, value: value) + } + } +} + +extension RecruitDataViewModel { + private func load() { + guard !isLoading else { return } + + fetchTask = Task { + defer { + fetchTask = nil + } + do { + let data = try await fetchRecruitDataUseCase.execute(id: recruitId) + self.data = data + } catch { + errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + } + } + } + + private func delete() { + guard let data, !isLoading else { + return + } + deleteTask = Task { + defer { + deleteTask = nil + } + do { + let result = try await deleteRecruitDataUseCase.execute(id: data.id) + guard result == true else { + errorMessage = "오류가 발생했습니다." + return + } + self.didDelete = true + } catch { + errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + self.didDelete = false + } + } + } +} + +extension RecruitDataViewModel { + private func applicationSubmitted() { + data?.markAsAlreadyApplied() + load() + } +} 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..42ed158ac --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataContentView.swift @@ -0,0 +1,71 @@ +// +// RecruitDataContentView.swift +// koin +// + +import SwiftUI + +struct RecruitDataContentView: View { + let data: RecruitData? + let onButtonTapped: () -> Void + + init( + data: RecruitData?, + onButtonTapped: @escaping () -> Void + ) { + self.data = data + self.onButtonTapped = onButtonTapped + } + + var body: some View { + if let data { + VStack(spacing: 16) { + 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) + } + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize, axes: .vertical) + + RecruitDataButton( + text: data.buttonText, + isEnabled: data.isButtonEnabled, + action: onButtonTapped + ) + .padding(.horizontal, 32) + } + } 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..5714d9816 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitData/Subviews/RecruitDataHeaderView.swift @@ -0,0 +1,38 @@ +// +// 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()) + 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(maxWidth: .infinity, minHeight: 18, alignment: .leading) + + 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..0950de62a --- /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?.formatDateToYYYYMMDD(separator: ".") ?? "") ~ \(data.endDate?.formatDateToYYYYMMDD(separator: ".") ?? "")" + ) + metadataRow( + icon: .recruitDataMembers, + title: "모집 인원", + value: "\(data.currentParticipants)/\(data.maximumParticipants)명" + ) + metadataRow( + icon: .recruitDataClock, + title: "작성일", + value: data.createdAt?.formatDateToYYYYMMDD(separator: ".") ?? "" + ) + 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/Mapper/RecruitListFilter+.swift b/Koin/Presentation/Recruit/RecruitList/Mapper/RecruitListFilter+.swift new file mode 100644 index 000000000..46f90d1fe --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitList/Mapper/RecruitListFilter+.swift @@ -0,0 +1,103 @@ +// +// 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 ?? "") + } +} + +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 new file mode 100644 index 000000000..9073e44b8 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListHostingController.swift @@ -0,0 +1,280 @@ +// +// RecruitListHostingController.swift +// koin +// +// Created by 홍기정 on 8/28/26. +// + +import SwiftUI + +final class RecruitListHostingController: UIHostingController, HostingControllerProtocol { + + // MARK: - Layout + private var toastMessageBottomInset: CGFloat { + 24 + 43 + 12 + } + + // 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 onFilterItemTapped, let onResetTapped, let onApplyTapped): + showFilterBottomSheet(filterState, onFilterItemTapped, onResetTapped, onApplyTapped) + case .showToast(let message): + showToastMessage(message: message, bottomInset: toastMessageBottomInset) + case .showLoginToast: + showLoginToast() + case .showProfilePostToast: + showProfilePostToast() + 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 = 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( + rootView: RecruitDataView(viewModel: viewModel), + delegate: self + ) + navigationController?.pushViewController(controller, animated: true) + } +} + +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: "로그인", + bottomInset: toastMessageBottomInset + ) { [weak self] in + self?.navigateToLogin() + } + 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) + 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 + ) + let viewController = RecruitNotificationListViewController(viewModel: viewModel) + navigationController?.pushViewController(viewController, animated: true) + } + private func showRecruitProfile() { + let repository = DefaultRecruitRepository(service: DefaultRecruitService()) + let fetchMyRecruitProfileUseCase = DefaultFetchMyRecruitProfileUseCase(repository: repository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) + let viewModel = RecruitProfileViewModel( + fetchMyRecruitProfileUseCase: fetchMyRecruitProfileUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase + ) + let viewController = RecruitProfileHostingController( + rootView: RecruitProfileView(viewModel: viewModel) + ) + navigationController?.pushViewController(viewController, animated: true) + } + private func showRecruitPost() { + 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, + logAnalyticsEventUseCase: logAnalyticsEventUseCase + ) + let viewController = RecruitPostViewController(viewModel: viewModel) + navigationController?.pushViewController(viewController, animated: true) + } + 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 + self?.rootView.update(profile: profile) + } + navigationController?.pushViewController(viewController, animated: true) + } +} + +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) + 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 + } + rootView.makeLogAnalyticsEvent( + label: EventParameter.EventLabel.Campus.teamRecruitmentNotification, + category: .click, + value: "알림" + ) + showRecruitNotificationList() + } + + @objc private func profileButtonTapped() { + guard UserDataManager.shared.isLoggedIn else { + showLoginToast() + return + } + rootView.makeLogAnalyticsEvent( + label: EventParameter.EventLabel.Campus.teamRecruitmentProfile, + category: .click, + value: "프로필" + ) + 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..86c6314bd --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListView.swift @@ -0,0 +1,198 @@ +// +// RecruitListView.swift +// koin +// +// Created by 홍기정 on 8/28/26. +// + +import SwiftUI + +struct RecruitListView: ActionBindableView { + enum Action { + case configureRightButtons(hasUnreadNotification: Bool) + case showFilterBottomSheet( + filterState: RecruitListFilter, + onFilterItemTapped: (Int, FilterItemModel)->Bool, + onResetTapped: ()->Void, + onApplyTapped: ([FilterGroupModel])->Void + ) + case showToast(message: String) + case showLoginToast + case showProfilePostToast + case showRecruitPost + case showRecruitData(id: Int) + } + + // MARK: - Properties + var sendAction: ((Action) -> Void) = { _ in } + @State private var viewModel: RecruitListViewModel + + var recruits: [RecruitRow] { + viewModel.recruitList?.recruits ?? [] + } + + // MARK: - Initializer + init(viewModel: RecruitListViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body + var body: some View { + VStack { + 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: { + 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))) + } + )) + }, + onFilterDeleted: { rawValue in + viewModel.execute(.deleteFilter(rawvalue: rawValue)) + } + ) + .padding(EdgeInsets(top: 20, leading: 0, bottom: 12, trailing: 0)) + + 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 { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentPostSelect, .click, recruit.title)) + 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) + } + } + } + } + } + .background(Color.appColor(.newBackground)) + .overlay(alignment: .bottomTrailing) { + postButton + .padding(EdgeInsets(top: 0, leading: 0, bottom: 24, trailing: 30)) + } + .onFirstAppear { + viewModel.execute(.onFirstAppear) + } + .onAppear { + viewModel.execute(.onAppear) + } + .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 { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentRecruit, .click, "모집하기")) + guard UserDataManager.shared.isLoggedIn else { + sendAction(.showLoginToast) + return + } + guard viewModel.canPost else { + sendAction(.showProfilePostToast) + return + } + sendAction(.showRecruitPost) + } 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) + } + + // MARK: - Public + func delete(id: Int) { + viewModel.execute(.delete(id: id)) + } + + 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 new file mode 100644 index 000000000..428428b1a --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitList/RecruitListViewModel.swift @@ -0,0 +1,225 @@ +// +// RecruitListViewModel.swift +// koin +// +// Created by 홍기정 on 8/28/26. +// + +import Foundation +import Observation + +@Observable +final class RecruitListViewModel: SwiftUIViewModelProtocol { + + enum Input { + case onFirstAppear + case onAppear + case refresh + case updateFilter(keyword: String? = nil, filterState: RecruitListFilter? = nil) + case deleteFilter(rawvalue: String) + case resetFilter + case loadNextPage + case delete(id: Int) + case didShowToast + case updateProfile(RecruitProfile) + case logEvent(EventLabelType, EventParameter.EventCategory, Any) + } + + // 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 + private(set) var shouldScrollToTop: Bool = false + + 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 + } + var canPost: Bool { + myRecruitProfile != nil + } + + // MARK: - Properties + private let fetchRecruitListUseCase: FetchRecruitListUseCase + private let fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase + private let fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase + + private var fetchListTask: Task? + private var myRecruitProfile: RecruitProfile? + + // MARK: - Initializer + init( + fetchRecruitListUseCase: FetchRecruitListUseCase, + fetchRecruitNotificationListUseCase: FetchRecruitNotificationListUseCase, + fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase + ) { + self.fetchRecruitListUseCase = fetchRecruitListUseCase + self.fetchRecruitNotificationListUseCase = fetchRecruitNotificationListUseCase + self.fetchMyRecruitProfileUseCase = fetchMyRecruitProfileUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase + } + + // MARK: - Public + func execute(_ input: Input) { + switch input { + case .onFirstAppear: + fetchList() + case .onAppear: + fetchHasUnreadNotification() + fetchMyRecruitProfile() + case .refresh: + fetchList() + fetchHasUnreadNotification() + fetchMyRecruitProfile() + case .updateFilter(let keyword, let filterState): + updateFilter(keyword, filterState) + case .deleteFilter(let rawvalue): + deleteFilter(rawvalue) + case .resetFilter: + resetFilter() + case .loadNextPage: + loadNextPage() + case .delete(let id): + recruitList?.delete(id: id) + case .didShowToast: + errorMessage = nil + case .updateProfile(let profile): + myRecruitProfile = profile + case let .logEvent(label, category, value): + makeLogAnalyticsEvent(label: label, category: category, value: value) + } + } +} + +extension RecruitListViewModel { + private func updateFilter( + _ keyword: String?, + _ filterState: RecruitListFilter? + ) { + defer { + isLoading = false + fetchList(shouldScrollToTop: true) + } + if let keyword { + self.filterState.keyword = keyword + } else if let filterState { + var filterState = filterState + filterState.keyword = self.filterState.keyword + self.filterState = filterState + } + } + + private func deleteFilter(_ rawValue: String) { + defer { + isLoading = false + fetchList(shouldScrollToTop: true) + } + filterState.remove(item: rawValue) + } + + private func loadNextPage() { + fetchList(page: currentPage + 1) + } + + private func resetFilter() { + defer { + isLoading = false + fetchList(shouldScrollToTop: true) + } + let keyword = filterState.keyword + filterState = .init(keyword: keyword) + } +} + +extension RecruitListViewModel { + private func fetchList( + page: Int = 1, + shouldScrollToTop: Bool = false + ) { + guard !isLoading else { + return + } + fetchListTask?.cancel() + fetchListTask = Task { + do { + isLoading = true + self.shouldScrollToTop = false + + defer { + isLoading = false + } + + let cachedPage = filterState.page + filterState.page = page + var response = try await fetchRecruitListUseCase.execute(filter: filterState) + + 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 + } + } + } + + private func fetchHasUnreadNotification() { + Task { + do { + self.hasUnreadNotification = try await fetchRecruitNotificationListUseCase.execute(page: 1).hasUnread + } catch let error as ErrorResponse where error.statusCode == 401 { + return + } catch { + self.errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + } + } + } +} + +extension RecruitListViewModel { + private func fetchMyRecruitProfile() { + Task { + do { + myRecruitProfile = try await fetchMyRecruitProfileUseCase.execute() + } catch { + errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + } + } + } +} + +extension RecruitListViewModel { + private func makeLogAnalyticsEvent( + label: EventLabelType, + category: EventParameter.EventCategory, + value: Any + ) { + logAnalyticsEventUseCase.execute( + label: label, + category: category, + value: value + ) + } +} 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..f13042096 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListHeaderView.swift @@ -0,0 +1,125 @@ +// +// 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(.easeInOut(duration: 0.2)) + ) + } + } + .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..6c0677aea --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitList/Subviews/RecruitListRowView.swift @@ -0,0 +1,119 @@ +// +// RecruitListRowView.swift +// koin +// +// Created by 홍기정 on 8/29/26. +// + +import SwiftUI + +struct RecruitListRowView: View { + + let model: RecruitRow + + init(model: RecruitRow) { + 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) + dataView + } + .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) + + 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)) + } + } + } + + @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 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, model.endDate] + .compactMap { $0?.formatDateToYYYYMMDD(separator: ".") } + .joined(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) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift new file mode 100644 index 000000000..57ec9e514 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Mapper/RecruitMyApplicationFilter+.swift @@ -0,0 +1,66 @@ +// +// RecruitMyApplicationFilter+.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import Foundation + +extension RecruitMyApplicationFilter { + func toGroupModels() -> [FilterGroupModel] { + var groupModels = [ + FilterGroupModel( + title: "지원 상태", + hasAllButton: true, + items: RecruitApplicationStatus.allCases.map(\.rawValue), + behavior: .single + ), + FilterGroupModel( + title: "정렬", + hasAllButton: false, + items: [ + RecruitListSort.latestDescending.rawValue, + RecruitListSort.deadlineAscending.rawValue + ], + behavior: .single + ) + ] + + if let status, + let statusIndex = RecruitApplicationStatus.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 = RecruitApplicationStatus( + rawValue: groupModels[0].selectedItems.first?.title ?? "" + ) + 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 new file mode 100644 index 000000000..c0f7d99f9 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListHostingController.swift @@ -0,0 +1,103 @@ +// +// 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 onFilterItemTapped, let onResetTapped, let onApplyTapped): + showFilterBottomSheet(filterState, onFilterItemTapped, onResetTapped, onApplyTapped) + case .showToast(let message): + showToastMessage(message: message) + case .showRecruitData(let id): + showRecruitData(id: id) + case .showChat(let recruitmentId, let chatRoomId): + showChat(recruitmentId: recruitmentId, chatRoomId: chatRoomId) + case .showAllRecruitList: + showAllRecruitList() + } + } +} + +extension RecruitMyApplicationListHostingController { + private func showRecruitData(id: Int) { + 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( + rootView: RecruitDataView(viewModel: viewModel), + delegate: nil + ) + navigationController?.pushViewController(controller, animated: true) + } + + private func showChat(recruitmentId: Int, chatRoomId: Int) { + let repository = DefaultRecruitRepository(service: DefaultRecruitService()) + let viewModel = RecruitChatViewModel( + roomSource: .team(recruitmentId: recruitmentId, chatRoomId: chatRoomId), + 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 showAllRecruitList() { + guard let navigationController, + let recruitListController = navigationController.viewControllers.first(where: { $0 is RecruitListHostingController }) else { + return + } + navigationController.popToViewController(recruitListController, animated: true) + } +} + +extension RecruitMyApplicationListHostingController { + private func showFilterBottomSheet( + _ filterState: RecruitMyApplicationFilter, + _ 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) + 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..1553f0d36 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListView.swift @@ -0,0 +1,149 @@ +// +// RecruitMyApplicationListView.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import SwiftUI + +struct RecruitMyApplicationListView: ActionBindableView { + + enum Action { + case showFilterBottomSheet( + filterState: RecruitMyApplicationFilter, + onFilterItemTapped: (Int, FilterItemModel) -> Bool, + onResetTapped: () -> Void, + onApplyTapped: ([FilterGroupModel]) -> Void + ) + case showToast(message: String) + case showRecruitData(id: Int) + case showChat(recruitmentId: Int, 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: { + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentAppliedPostFilter, + .click, + "필터" + )) + 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 + } + 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 + viewModel.execute(.logEvent( + EventParameter.EventLabel.Campus.teamRecruitmentAppliedPostChat, + .click, + recruit.title + )) + sendAction(.showChat(recruitmentId: recruit.id, 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) + .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) + } + .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..b8770534d --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/RecruitMyApplicationListViewModel.swift @@ -0,0 +1,132 @@ +// +// 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 + case logEvent(EventLabelType, EventParameter.EventCategory, Any) + } + + // 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 + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase + + var recruits: [RecruitMyApplicationRow] { + recruitList?.recruits ?? [] + } + + // MARK: - Initializer + init( + fetchRecruitMyApplicationListUseCase: FetchRecruitMyApplicationListUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase + ) { + self.fetchRecruitMyApplicationListUseCase = fetchRecruitMyApplicationListUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase + } + + // 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 + case let .logEvent(label, category, value): + makeLogAnalyticsEvent(label: label, category: category, value: value) + } + } +} + +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 + } + } + } +} + +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/RecruitMyApplicationList/Subviews/RecruitMyApplicationListEmptyView.swift b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListEmptyView.swift new file mode 100644 index 000000000..40517989e --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListEmptyView.swift @@ -0,0 +1,64 @@ +// +// 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: 0) { + Spacer() + + Image.appImage(asset: .sleepBcsdSymbol) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 98, 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) + .background(Color.appColor(.neutral50)) + .clipShape(.capsule) + .border(.appColor(.new400), width: 1, radius: 21) + .applySketchShadow( + color: .appColor(.neutral800), + 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) + + 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..5cc27aeb9 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyApplicationList/Subviews/RecruitMyApplicationListRowView.swift @@ -0,0 +1,141 @@ +// +// RecruitMyApplicationListRowView.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import SwiftUI + +struct RecruitMyApplicationListRowView: View { + + let model: RecruitMyApplicationRow + 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) + + 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() + + Text(model.application.status.rawValue) + .font(.appFont(.pretendardMedium, size: 10)) + .foregroundStyle(Color.appColor(model.application.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 { + 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] + .compactMap { $0?.formatDateToYYYYMMDD(separator: ".") } + .joined(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) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, model.chatRoomId == nil ? 0 : 60) + .overlay(alignment: .trailing) { + 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) + } + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift new file mode 100644 index 000000000..f009f100c --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostHostingController.swift @@ -0,0 +1,86 @@ +// +// RecruitMyPostHostingController.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +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) + } + + // MARK: - Public + func execute(action: RootView.Action) { + switch action { + case .showToast(let message): + showToastMessage(message: message) + case .showApplicant(let recruitmentId, let applicationId): + showApplicant( + recruitmentId: recruitmentId, + applicationId: applicationId + ) + case .showDirectChat(let recruitmentId, let applicationId): + showDirectChat(recruitmentId: recruitmentId, applicationId: applicationId) + case .showChat(let recruitmentId, let chatRoomId): + showChat(recruitmentId: recruitmentId, chatRoomId: chatRoomId) + } + } +} + +extension RecruitMyPostHostingController { + private func showApplicant(recruitmentId: Int, applicationId: Int) { + 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, + logAnalyticsEventUseCase: logAnalyticsEventUseCase + ) + let controller = RecruitMyPostApplicantHostingController( + rootView: RecruitMyPostApplicantView(viewModel: viewModel) + ) + navigationController?.pushViewController(controller, animated: true) + } + + private func showDirectChat(recruitmentId: Int, applicationId: Int) { + pushRecruitChat(roomSource: .direct(recruitmentId: recruitmentId, applicationId: applicationId)) + } + + private func showChat(recruitmentId: Int, chatRoomId: Int) { + pushRecruitChat(roomSource: .team(recruitmentId: recruitmentId, chatRoomId: chatRoomId)) + } + + private func pushRecruitChat(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) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift new file mode 100644 index 000000000..d83bc8a32 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostView.swift @@ -0,0 +1,156 @@ +// +// RecruitMyPostView.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import SwiftUI + +struct RecruitMyPostView: ActionBindableView { + + enum Action { + case showToast(message: String) + case showApplicant(recruitmentId: Int, applicationId: Int) + case showDirectChat(recruitmentId: Int, applicationId: Int) + case showChat(recruitmentId: Int, chatRoomId: Int) + } + + // 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 && viewModel.data == nil) + .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: RecruitMyPostData) -> some View { + GeometryReader { proxy in + ScrollView { + 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)) + } + + applicantListHeader + .padding(.top, 24) + .padding(.leading, 4) + + if data.applicants.isEmpty { + Spacer(minLength: 0) + + RecruitMyPostEmptyView() + .frame(maxWidth: .infinity) + + Spacer(minLength: 0) + } else { + LazyVStack(spacing: 12) { + ForEach(data.applicants) { applicant in + 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 + )) + } + ) + .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) + .frame(minHeight: max(proxy.size.height - 44, 0), alignment: .top) + .padding(.top, 20) + .padding(.bottom, 24) + } + .scrollIndicators(.hidden) + .refreshable { + viewModel.execute(.refresh) + } + } + } + + 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?.totalCount ?? 0)명") + .font(.appFont(.pretendardRegular, size: 12)) + .foregroundStyle(Color.appColor(.neutral500)) + .frame(height: 19) + } + } +} + +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 new file mode 100644 index 000000000..af2c27d30 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPost/RecruitMyPostViewModel.swift @@ -0,0 +1,112 @@ +// +// RecruitMyPostViewModel.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import Foundation +import Observation + +@MainActor +@Observable +final class RecruitMyPostViewModel: SwiftUIViewModelProtocol { + + enum Input { + case onFirstAppear + case loadNextPage + case didShowToast + case refresh + case logEvent(EventLabelType, EventParameter.EventCategory, Any) + } + + // MARK: - State + private(set) var data: RecruitMyPostData? + private(set) var isLoading = false + private(set) var errorMessage: String? + + // 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 + } + + // MARK: - Public + func execute(_ input: Input) { + switch input { + case .onFirstAppear, .refresh: + fetchRecruitMyPost() + case .loadNextPage: + loadNextPage() + case .didShowToast: + errorMessage = nil + case let .logEvent(label, category, value): + makeLogAnalyticsEvent(label: label, category: category, value: value) + } + } +} + +extension RecruitMyPostViewModel { + 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 + } + + Task { + isLoading = true + defer { + isLoading = false + } + + do { + var response = try await fetchRecruitMyPostDataUseCase.execute(id: recruitId, page: page) + try Task.checkCancellation() + + 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 { + errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + } + } + } +} + +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/RecruitMyPost/Subviews/RecruitMyPostApplicantRowView.swift b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostApplicantRowView.swift new file mode 100644 index 000000000..3ab1dab33 --- /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: RecruitApplicantRow + + init( + model: RecruitApplicantRow, + 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) · \(String(format: "%02d", model.studentYear % 100))학번") + .foregroundStyle(Color.appColor(.neutral500)) + } + } else { + Text("\(model.department) · \(String(format: "%02d", model.studentYear % 100))학번") + .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..623d6b772 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPost/Subviews/RecruitMyPostSummaryView.swift @@ -0,0 +1,124 @@ +// +// RecruitMyPostSummaryView.swift +// koin +// +// Created by 홍기정 on 9/27/26. +// + +import SwiftUI + +struct RecruitMyPostSummaryView: View { + + let model: RecruitMyPostData + 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) + + 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)) + } + } + } + + 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, model.endDate] + .compactMap { $0?.formatDateToYYYYMMDD(separator: ".") } + .joined(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/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift new file mode 100644 index 000000000..9682e4768 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantHostingController.swift @@ -0,0 +1,76 @@ +// +// 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, let onCancelTapped, let onDecideTapped): + showDecisionModal(decision, onCancelTapped, onDecideTapped) + case .showDirectChat(let recruitmentId, let applicationId): + showDirectChat(recruitmentId: recruitmentId, applicationId: applicationId) + case .showToast(let message): + showToastMessage(message: message, bottomInset: 80) + } + } +} + +extension RecruitMyPostApplicantHostingController { + private func showDecisionModal( + _ decision: RecruitApplicantDecision, + _ onCancelTapped: @escaping () -> Void, + _ onDecideTapped: @escaping () -> Void + ) { + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .new, + content: .titles( + mainTitleText: "해당 지원자를 \(decision.rawValue)하시겠어요?", + subTitleText: "\(decision.rawValue) 후에는 '\(decision.rawValue)' 상태로 변경되며\n지원자에게 \(decision.rawValue) 알림이 전송됩니다.", + ), + button: .buttons( + leftButtonTitle: "취소하기", + leftButtonAction: onCancelTapped, + rightButtonTitle: "\(decision.rawValue)하기", + rightButtonAction: onDecideTapped + ) + )) + present(modalViewController, animated: true) + } + + private func showDirectChat(recruitmentId: Int, applicationId: Int) { + let repository = DefaultRecruitRepository(service: DefaultRecruitService()) + let viewModel = RecruitChatViewModel( + roomSource: .direct(recruitmentId: recruitmentId, applicationId: applicationId), + 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) + } +} diff --git a/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift new file mode 100644 index 000000000..47f34d3f0 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantView.swift @@ -0,0 +1,177 @@ +// +// RecruitMyPostApplicantView.swift +// koin +// +// Created by 홍기정 on 9/28/26. +// + +import SwiftUI + +struct RecruitMyPostApplicantView: ActionBindableView { + + enum Action { + case showDecisionModal( + decision: RecruitApplicantDecision, + onCancelTapped: () -> Void, + onDecideTapped: () -> Void + ) + case showDirectChat(recruitmentId: Int, applicationId: Int) + 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.toastMessage) { + guard let message = viewModel.toastMessage else { + return + } + sendAction(.showToast(message: message)) + viewModel.execute(.didShowToast) + } + } + + // MARK: - Public + func decide(_ decision: RecruitApplicantDecision) { + viewModel.execute(.decide(decision)) + } +} + + +extension RecruitMyPostApplicantView { + private func contentView(_ application: RecruitApplicantData) -> 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 + makeLogAnalyticsEvent(decision) + showDecisionModal(decision) + }, + onDirectChatTapped: { + sendAction(.showDirectChat( + recruitmentId: viewModel.recruitmentId, + applicationId: application.applicationId + )) + } + ) + .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) + } +} + +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 new file mode 100644 index 000000000..d4b597683 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostApplicant/RecruitMyPostApplicantViewModel.swift @@ -0,0 +1,126 @@ +// +// 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(RecruitApplicantDecision) + case didShowToast + case logEvent(EventLabelType, EventParameter.EventCategory, Any) + } + + // MARK: - State + private(set) var application: RecruitApplicantData? + private(set) var isLoading = false + private(set) var toastMessage: String? + + // MARK: - Properties + let recruitmentId: Int + 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, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase + ) { + self.recruitmentId = recruitmentId + self.applicationId = applicationId + self.fetchUseCase = fetchUseCase + self.decideUseCase = decideUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase + } + + // MARK: - Public + func execute(_ input: Input) { + switch input { + case .onFirstAppear: + fetchApplication() + case .decide(let decision): + decideApplication(decision) + case .didShowToast: + toastMessage = nil + case let .logEvent(label, category, value): + makeLogAnalyticsEvent(label: label, category: category, value: value) + } + } +} + +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 { + toastMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + } + } + } + + private func decideApplication(_ decision: RecruitApplicantDecision) { + 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) + toastMessage = "지원자를 \(decision.rawValue)했어요." + } catch { + toastMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + } + } + } +} + +extension RecruitMyPostApplicantViewModel { + private func makeLogAnalyticsEvent( + label: EventLabelType, + category: EventParameter.EventCategory, + value: Any + ) { + logAnalyticsEventUseCase.execute( + label: label, + category: category, + value: value + ) + } +} 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..e5d4639b8 --- /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: RecruitApplicantData + let isLoading: Bool + let onDecisionTapped: (RecruitApplicantDecision) -> 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..467f014f0 --- /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: RecruitApplicantData + + 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/Presentation/Recruit/RecruitMyPostList/Mapper/RecruitMyPostFilter+.swift b/Koin/Presentation/Recruit/RecruitMyPostList/Mapper/RecruitMyPostFilter+.swift new file mode 100644 index 000000000..0b3cef21d --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostList/Mapper/RecruitMyPostFilter+.swift @@ -0,0 +1,67 @@ +// +// 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 + } +} + +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 new file mode 100644 index 000000000..00a2d5256 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListHostingController.swift @@ -0,0 +1,149 @@ +// +// 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 onFilterItemTapped, let onResetTapped, let onApplyTapped): + showFilterBottomSheet(filterState, onFilterItemTapped, onResetTapped, onApplyTapped) + case .showToast(let message): + showToastMessage(message: message) + case .showRecruitData(let id): + showRecruitData(id: id) + case .showChat(let recruitmentId, let chatRoomId): + showChat(recruitmentId: recruitmentId, 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 = 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( + rootView: RecruitDataView(viewModel: viewModel), + delegate: self + ) + navigationController?.pushViewController(controller, animated: true) + } + + private func showChat(recruitmentId: Int, chatRoomId: Int) { + let repository = DefaultRecruitRepository(service: DefaultRecruitService()) + let viewModel = RecruitChatViewModel( + roomSource: .team(recruitmentId: recruitmentId, chatRoomId: chatRoomId), + 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 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( + rootView: RecruitMyPostView(viewModel: viewModel) + ) + navigationController?.pushViewController(controller, animated: true) + } + + 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( + mainTitleText: "해당 모집글을 마감하시겠어요?", + subTitleText: "마감 후에는 더이상 지원자를 받을 수 없습니다." + ), + button: .buttons( + leftButtonTitle: "취소하기", + leftButtonAction: onCancelButtonTapped, + rightButtonTitle: "마감하기", + rightButtonAction: onCloseButtonTapped + ), + layout: .init(width: 320) + )) + present(modalViewController, animated: true) + } +} + +extension RecruitMyPostListHostingController { + private func showFilterBottomSheet( + _ filterState: RecruitMyPostFilter, + _ 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) + 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..d2c67661c --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListView.swift @@ -0,0 +1,187 @@ +// +// RecruitMyPostListView.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import SwiftUI + +struct RecruitMyPostListView: ActionBindableView { + + enum Action { + case showFilterBottomSheet( + filterState: RecruitMyPostFilter, + onFilterItemTapped: (Int, FilterItemModel) -> Bool, + onResetTapped: () -> Void, + onApplyTapped: ([FilterGroupModel]) -> Void + ) + case showToast(message: String) + case showRecruitData(id: Int) + case showChat(recruitmentId: Int, 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: { + 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 + } + 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 + 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)) + } + ) + .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) || viewModel.isLoading) + } + .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)) + } + + 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 new file mode 100644 index 000000000..d96880f51 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostList/RecruitMyPostListViewModel.swift @@ -0,0 +1,157 @@ +// +// 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 + case logEvent(EventLabelType, EventParameter.EventCategory, Any) + } + + // 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 + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase + + var recruits: [RecruitMyPostRow] { + recruitList?.recruits ?? [] + } + + // MARK: - Initializer + init( + fetchRecruitMyPostListUseCase: FetchRecruitMyPostListUseCase, + closeRecruitMyPostUseCase: CloseRecruitMyPostUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase + ) { + self.fetchRecruitMyPostListUseCase = fetchRecruitMyPostListUseCase + self.closeRecruitMyPostUseCase = closeRecruitMyPostUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase + } + + // 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 + case let .logEvent(label, category, value): + makeLogAnalyticsEvent(label: label, category: category, value: value) + } + } +} + +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 + } + } + } +} + +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/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..340cc21d6 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitMyPostList/Subviews/RecruitMyPostListRowView.swift @@ -0,0 +1,164 @@ +// +// RecruitMyPostListRowView.swift +// koin +// +// Created by 홍기정 on 9/24/26. +// + +import SwiftUI + +struct RecruitMyPostListRowView: View { + + let model: RecruitMyPostRow + 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) + + 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() + } + .padding(.trailing, model.chatRoomId == nil ? 0 : 44) + .overlay(alignment: .trailing) { + 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, model.endDate] + .compactMap { $0?.formatDateToYYYYMMDD(separator: ".") } + .joined(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 == true { + 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/RecruitNotificationList/Mapper/RecruitNotificationRow+.swift b/Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotificationRow+.swift new file mode 100644 index 000000000..fdf002833 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitNotificationList/Mapper/RecruitNotificationRow+.swift @@ -0,0 +1,52 @@ +// +// RecruitNotificationRow+.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import Foundation + +extension RecruitNotificationRow { + var icon: ImageAsset { + switch type { + 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: messagePreview, + dateText: dateText + ) + } +} diff --git a/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift new file mode 100644 index 000000000..ea50ed4ec --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewController.swift @@ -0,0 +1,253 @@ +// +// 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? + private var isUserTap = true + + // MARK: - UI Components + private let notificationListView = NotificationListView(behavior: .pagination) + + // 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) + case .didFinishLoading: + notificationListView.stopLoading() + case .tapNotification(let id): + tapNotification(id: id) + } + } + .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)) + } + .store(in: &subscriptions) + + notificationListView.itemTappedPublisher + .sink { [weak self] id in + guard let self, + 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)) + } + .store(in: &subscriptions) + + notificationListView.refreshPublisher + .sink { [weak self] in + self?.inputSubject.send(.reload) + } + .store(in: &subscriptions) + + notificationListView.reachedBottomPublisher + .sink { [weak self] in + self?.inputSubject.send(.loadNextPage) + } + .store(in: &subscriptions) + } +} + +extension RecruitNotificationListViewController { + private func updateNotificationList(_ notificationList: RecruitNotificationList) { + self.notificationList = notificationList + + notificationListView.update( + items: notificationList.notifications.map { + $0.toNotificationRowModel() + }, + hasNextPage: notificationList.hasNextPage + ) + } + + /// 푸시알림으로 진입한 경우, notificationId 와 일치하는 row 를 탭 처리한다. + private func tapNotification(id notificationId: Int) { + guard let indexPath = notificationListView.indexPath(for: notificationId) else { + return + } + isUserTap = false + defer { + isUserTap = true + } + notificationListView.tapRow(at: indexPath) + } + private func handleNavigation(id: Int) { + guard let notification = notificationList?.notifications.first(where: { $0.id == id }) else { + return + } + 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 logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) + let viewModel = RecruitMyPostViewModel( + fetchRecruitMyPostDataUseCase: DefaultFetchRecruitMyPostDataUseCase(repository: repository), + logAnalyticsEventUseCase: logAnalyticsEventUseCase, + recruitId: recruitmentId + ) + let controller = RecruitMyPostHostingController( + rootView: RecruitMyPostView(viewModel: viewModel) + ) + navigationController?.pushViewController(controller, animated: true) + } + + private func navigateToMyApplications() { + let repository = DefaultRecruitRepository(service: DefaultRecruitService()) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) + let viewModel = RecruitMyApplicationListViewModel( + fetchRecruitMyApplicationListUseCase: DefaultFetchRecruitMyApplicationListUseCase(repository: repository), + logAnalyticsEventUseCase: logAnalyticsEventUseCase + ) + let controller = RecruitMyApplicationListHostingController( + rootView: RecruitMyApplicationListView(viewModel: viewModel) + ) + navigationController?.pushViewController(controller, animated: true) + } +} + +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() + } + ) + 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..b62498fcd --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitNotificationList/RecruitNotificationListViewModel.swift @@ -0,0 +1,209 @@ +// +// RecruitNotificationListViewModel.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import Combine +import Foundation + +final class RecruitNotificationListViewModel: ViewModelProtocol { + + enum Input { + case viewDidLoad + case reload + case loadNextPage + case didTapNotification(id: Int) + case deleteNotification(id: Int) + case deleteAllNotifications + case markAllAsRead + case logEvent(EventLabelType, EventParameter.EventCategory, Any) + } + + enum Output { + case updateNotifications(RecruitNotificationList) + case showToast(String) + case didFinishLoading + case tapNotification(id: Int) + } + + // MARK: - Properties + 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? + private var isLoading = false + private var notificationId: Int? + + // MARK: - Initializer + init( + 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 + } + + // 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(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() + case let .logEvent(label, category, value): + self?.makeLogAnalyticsEvent(label: label, category: category, value: value) + } + } + .store(in: &subscriptions) + + return outputSubject.eraseToAnyPublisher() + } +} + +private extension RecruitNotificationListViewModel { + + 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 { + defer { + isLoading = false + } + do { + guard try await fetchPage(page) else { + return + } + try await searchNotificationIfNeeded() + } catch { + 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 { + try await deleteRecruitNotificationUseCase.execute(id: id) + outputSubject.send(.showToast("알림이 삭제되었습니다.")) + } catch { + if let message = (error as? ErrorResponse)?.message { + outputSubject.send(.showToast(message)) + } + } + } + } + + private func deleteAllNotifications() { + Task { + do { + try await deleteRecruitNotificationUseCase.execute(id: nil) + outputSubject.send(.showToast("알림이 삭제되었습니다.")) + } 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)) + } + } + } + } + + private func makeLogAnalyticsEvent(label: EventLabelType, category: EventParameter.EventCategory, value: Any) { + logAnalyticsEventUseCase.execute(label: label, category: category, value: value) + } +} diff --git a/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift new file mode 100644 index 000000000..e5e4b5067 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewController.swift @@ -0,0 +1,596 @@ +// +// 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.mode { + case .post: + return "모집글 작성" + case .modify: + return "모집글 수정" + } + } + private var submitButtonTitle: String { + switch viewModel.mode { + case .post: + return "등록하기" + case .modify: + 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: + return "모집글이 등록되었습니다." + case .modify: + 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 = RecruitTextFieldView( + 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 = RecruitTextFieldView( + title: "관련 Url", + isRequired: false, + limit: nil, + placeholder: "공모전/대외활동 등 모집글 관련 Url을 작성해주세요.", + keyboardType: .URL + ) + private let qualificationView = RecruitPostTextViewView( + title: "지원 자격", + isRequired: false, + limit: 500, + placeholder: "지원 자격 또는 우대사항을 작성해주세요." + ) + + private let postButton = UIButton() + + // 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?.logEvent(post: .teamRecruitmentRecruitCategory, modify: .teamRecruitmentPostEditCategory, value: category.rawValue) + 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(data): + updateForm(data) + updateViews(data) + case let .updateLoading(isLoading): + updateLoading(isLoading) + case .postCompleted(let id): + handlePostCompleted(id: id) + case .modifyCompleted(let id): + handleModifyCompleted(id: id) + case let .showToast(message): + showToastMessage(message: message, bottomInset: 72) + } + }.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 + 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 + 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?.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) + + roleView.numberOfGeneralMembersChangedPublisher.sink { [weak self] numberOfGeneralMembers in + self?.request.numberOfGeneralMembers = numberOfGeneralMembers + }.store(in: &subscriptions) + + roleView.didChangeHeightPublisher.sink { [weak self] heightChanged in + guard let self else { + return + } + + view.layoutIfNeeded() + + UIView.animate( + springDuration: 0.25, + options: [.beginFromCurrentState, .allowUserInteraction] + ) { [weak self] in + guard let self else { + return + } + roleView.applyPendingSizeChange() + view.layoutIfNeeded() + + guard 0 < heightChanged 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) + + 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 updateLoading(_ isLoading: Bool) { + postButton.isUserInteractionEnabled = !isLoading + if isLoading { + IndicatorView.show() + postButton.backgroundColor = .appColor(.neutral400) + } else { + IndicatorView.dismiss() + validate() + } + } + + private func handlePostCompleted(id: Int) { + 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 viewController = RecruitDataHostingController( + rootView: RecruitDataView(viewModel: viewModel), + delegate: nil + ) + replaceTopViewController(viewController, animated: true) + showToastMessage(message: completionMessage, bottomInset: 60) + } + + private func handleModifyCompleted(id: Int) { + guard var viewControllers = navigationController?.viewControllers else { + return + } + + 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 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() { + postButton.isEnabled = request.isValid + postButton.backgroundColor = request.isValid ? UIColor.appColor(.new500) : UIColor.appColor(.neutral400) + } + + 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: data.startDate, + endDate: data.endDate, + deadlineDate: data.deadlineDate + ) + roleView.configure( + roleType: data.type, + roles: data.roles.map { RecruitRoleRequest(from: $0) }, + numberOfGeneralMembers: data.maximumParticipants, + ) + descriptionView.configure(text: data.description) + relatedUrlView.configure(text: data.relatedUrl?.absoluteString) + qualificationView.configure(text: data.qualification) + } +} + +extension RecruitPostViewController { + + private func setAddTargets() { + postButton.addTarget(self, action: #selector(postButtonTapped), for: .touchUpInside) + } + + @objc private func postButtonTapped() { + guard !dropdownHost.isPresenting else { return } + view.endEditing(true) + logEvent(post: .teamRecruitmentRecruitSubmit, modify: .teamRecruitmentPostEditSubmit, value: submitLogValue) + showSubmitModal() + } + + private func submit() { + 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: "취소하기") + } + 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 { + 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 { [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() + } + .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 + } + postButton.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, postButton].forEach { + view.addSubview($0) + } + } + + private func setUpConstraints() { + scrollView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.leading.trailing.equalToSuperview().inset(24) + $0.bottom.equalTo(postButton.snp.top).offset(-16) + } + contentStackView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.width.equalToSuperview() + } + 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 new file mode 100644 index 000000000..fe6225364 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/RecruitPostViewModel.swift @@ -0,0 +1,109 @@ +// +// RecruitPostViewModel.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import Foundation +import Combine + +final class RecruitPostViewModel: ViewModelProtocol { + + enum Mode { + case post + case modify(data: RecruitData) + } + + enum Input { + case viewDidLoad + case submit(RecruitPostRequest) + case logEvent(EventLabelType, EventParameter.EventCategory, Any) + } + enum Output { + case updateLoading(Bool) + case updateForm(RecruitData) + + case postCompleted(id: Int) + case modifyCompleted(id: Int) + case showToast(String) + } + + // MARK: - Properties + private let postRecruitUseCase: PostRecruitUseCase + private let modifyRecruitUseCase: ModifyRecruitUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase + + private let outputSubject = PassthroughSubject() + private var subscriptions: Set = [] + + let mode: Mode + + // MARK: - Initializer + init( + mode: Mode, + postRecruitUseCase: PostRecruitUseCase, + modifyRecruitUseCase: ModifyRecruitUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase + ) { + self.mode = mode + self.postRecruitUseCase = postRecruitUseCase + self.modifyRecruitUseCase = modifyRecruitUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase + } + + // MARK: - Public + func transform(with input: AnyPublisher) -> AnyPublisher { + input.sink { [weak self] input in + guard let self else { return } + switch input { + case .viewDidLoad: + applyDataIfModifying() + case let .submit(form): + submit(form) + case let .logEvent(label, category, value): + logAnalyticsEventUseCase.execute(label: label, category: category, value: value) + } + }.store(in: &subscriptions) + + return outputSubject.eraseToAnyPublisher() + } +} + +extension RecruitPostViewModel { + + private func applyDataIfModifying() { + guard case let .modify(data) = mode else { + return + } + outputSubject.send(.updateForm(data)) + } +} + +extension RecruitPostViewModel { + + private func submit(_ request: RecruitPostRequest) { + + Task { + do { + outputSubject.send(.updateLoading(true)) + defer { + outputSubject.send(.updateLoading(false)) + } + + switch mode { + case .post: + let id = try await postRecruitUseCase.execute(request: request) + outputSubject.send(.postCompleted(id: id)) + case let .modify(data): + try await modifyRecruitUseCase.execute(id: data.id, request: request) + outputSubject.send(.modifyCompleted(id: data.id)) + } + } catch { + 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..fdd921067 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableView.swift @@ -0,0 +1,305 @@ +// +// RecruitPostRoleTableView.swift +// koin +// +// Created by 홍기정 on 9/11/26. +// + +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>() + 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 { + 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 + 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 { + let height = indexPath.row == displayedRowCount - 1 + ? Self.roleRowHeight + : Self.roleRowHeight + Self.roleRowSpacing + return CGFloat(height) + } +} + +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 = CGFloat(Self.roleRowHeight) + 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..7bdcd4bc3 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleTableViewCell.swift @@ -0,0 +1,255 @@ +// +// RecruitPostRoleTableViewCell.swift +// koin +// +// Created by 홍기정 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 + let image = button.configuration?.image + button.configuration?.image = image?.withTintColor(.appColor(button.isEnabled ? .neutral600 : .neutral400)) + } + } + + 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..00297c21c --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostRoleView.swift @@ -0,0 +1,336 @@ +// +// 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() + let addRoleButtonTappedPublisher = 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 } + let previousRowCount = displayedRowCount + addRoleButtonTappedPublisher.send() + tableView.endEditing(true) + roles.append(.init()) + pendingRowChangeIndex = roles.count - 1 + didChangeHeightPublisher.send(heightChanged(from: previousRowCount)) + 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(heightChanged(from: previousRowCount)) + } else { + updateViews() + } + roleTypeChangedPublisher.send(roleType) + rolesChangedPublisher.send(roles) + numberOfGeneralMembersChangedPublisher.send(numberOfGeneralMembers) + case .general: + roleType = .roleBased + roles = [.init()] + numberOfGeneralMembers = minimumNumberOfMembers + if previousRowCount != displayedRowCount { + didChangeHeightPublisher.send(heightChanged(from: previousRowCount)) + } 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 } + let previousRowCount = displayedRowCount + tableView.endEditing(true) + roles.remove(at: index) + pendingRowChangeIndex = index + didChangeHeightPublisher.send(heightChanged(from: previousRowCount)) + 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 heightChanged(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 + && 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..98fb2e629 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostScheduleView.swift @@ -0,0 +1,269 @@ +// +// 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()) + + let today = Date() + configure(startDate: today, endDate: today, deadlineDate: today, updateLabelText: false) + } + 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) + } + } + + 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 { + 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..607c11b0e --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostSectionHeaderView.swift @@ -0,0 +1,115 @@ +// +// RecruitPostSectionHeaderView.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import UIKit +import SnapKit +import Then + +final class RecruitPostSectionHeaderView: UIView { + + // MARK: - Properties + private let titleFont: UIFont + + // MARK: - UI Components + private let titleLabel = UILabel() + private let isRequiredLabel = UILabel() + private let counterLabel = UILabel() + + // 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, + 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 = titleFont + $0.textColor = .appColor(.neutral800) + } + isRequiredLabel.do { + $0.text = "*" + $0.font = titleFont + $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..6bff899d8 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitPost/Subviews/RecruitPostTextFieldView.swift @@ -0,0 +1,145 @@ +// +// RecruitTextFieldView.swift +// koin +// +// Created by 홍기정 on 8/30/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class RecruitTextFieldView: 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, + keyboardType: UIKeyboardType = .default + ) { + self.headerView = RecruitPostSectionHeaderView( + title: title, + isRequired: isRequired, + limit: limit + ) + self.limit = limit + super.init(frame: .zero) + configureView() + textField.delegate = self + setAddTargets() + setUpPlaceholder(placeholder) + textField.keyboardType = keyboardType + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(text: String?) { + if textField.text != text { + textField.text = text + } + updateCounter(text: text) + } +} + +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) + 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 RecruitTextFieldView { + + 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..c03b3cb09 --- /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 ?? true) + headerView.updateCounter(current: text?.count ?? 0, 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/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift new file mode 100644 index 000000000..ef88b6623 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileHostingController.swift @@ -0,0 +1,113 @@ +// +// 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 .showMyPost: + navigateToMyPost() + case .showMyApplication: + navigateToMyApplication() + case let .showToast(message): + showToastMessage(message: message) + } + } +} + +extension RecruitProfileHostingController { + private func navigateToProfilePost() { + let controller = makeProfilePostViewController(mode: .post) + navigationController?.pushViewController(controller, animated: true) + } + + private func navigateToProfileModify(profile: RecruitProfile) { + let controller = makeProfilePostViewController(mode: .modify(profile)) + navigationController?.pushViewController(controller, animated: true) + } + + private func navigateToMyPost() { + 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, + logAnalyticsEventUseCase: logAnalyticsEventUseCase + ) + let controller = RecruitMyPostListHostingController( + rootView: RecruitMyPostListView(viewModel: viewModel) + ) + navigationController?.pushViewController(controller, animated: true) + } + + 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, + logAnalyticsEventUseCase: logAnalyticsEventUseCase + ) + let controller = RecruitMyApplicationListHostingController( + rootView: RecruitMyApplicationListView(viewModel: viewModel) + ) + navigationController?.pushViewController(controller, animated: true) + } + + private func makeProfilePostViewController( + mode: RecruitProfilePostViewModel.Mode + ) -> RecruitProfilePostViewController { + let userRepository = DefaultUserRepository(service: DefaultUserService()) + let recruitRepository = DefaultRecruitRepository(service: DefaultRecruitService()) + let fetchDeptListUseCase = DefaultFetchDeptListUseCase(timetableRepository: DefaultTimetableRepository(service: DefaultTimetableService())) + 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 + self?.rootView.updateProfile(profile) + } + } +} diff --git a/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift new file mode 100644 index 000000000..1c369236b --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileView.swift @@ -0,0 +1,86 @@ +// +// RecruitProfileView.swift +// koin +// +// Created by 홍기정 on 9/13/26. +// + +import SwiftUI + +struct RecruitProfileView: ActionBindableView { + enum Action { + case showProfilePost + case showProfileModify(profile: RecruitProfile) + case showMyPost + case showMyApplication + 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: - Public + func updateProfile(_ profile: RecruitProfile) { + viewModel.execute(.profileUpdated(profile)) + } + + // MARK: - Body + var body: some View { + ScrollView { + if viewModel.didLoad { + 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) + } + } + + RecruitProfileActionCardView( + icon: .recruitProfileMyPost, + title: viewModel.profile == nil ? "내가 작성한 모집글 모아보기" : "내가 작성한 모집글", + description: "작성자 모집글과 지원자를 한눈에 확인할 수 있어요." + ) { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentProfileCreated, .click, "내가 작성한 모집글")) + sendAction(.showMyPost) + } + + RecruitProfileActionCardView( + icon: .recruitProfileMyApplication, + title: viewModel.profile == nil ? "내가 지원한 모집글 모아보기" : "내가 지원한 모집글", + description: "지원한 모집글과 지원 상태를 확인 할 수 있어요." + ) { + viewModel.execute(.logEvent(EventParameter.EventLabel.Campus.teamRecruitmentProfileApplied, .click, "내가 지원한 모집글")) + sendAction(.showMyApplication) + } + } + .padding(.top, 16) + .padding(.horizontal, 24) + .frame(maxWidth: .infinity) + } else { + Color.appColor(.newBackground) + } + } + .background(Color.appColor(.newBackground).ignoresSafeArea()) + .loadingOverlay(viewModel.isLoading) + .onFirstAppear { + 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..6e1f4e47f --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfile/RecruitProfileViewModel.swift @@ -0,0 +1,73 @@ +// +// 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 + case profileUpdated(RecruitProfile) + case logEvent(EventLabelType, EventParameter.EventCategory, Any) + } + + // 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 fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase + + // MARK: - Initializer + init( + fetchMyRecruitProfileUseCase: FetchMyRecruitProfileUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase + ) { + self.fetchMyRecruitProfileUseCase = fetchMyRecruitProfileUseCase + self.logAnalyticsEventUseCase = logAnalyticsEventUseCase + } + + // MARK: - Public + func execute(_ input: Input) { + switch input { + case .didAppear: + fetchMyProfile() + case .didShowToast: + errorMessage = nil + case let .profileUpdated(profile): + self.profile = profile + case let .logEvent(label, category, value): + logAnalyticsEventUseCase.execute(label: label, category: category, value: value) + } + } +} + +extension RecruitProfileViewModel { + private func fetchMyProfile() { + guard !isLoading else { return } + + Task { + isLoading = true + defer { + isLoading = false + } + + do { + profile = try await fetchMyRecruitProfileUseCase.execute() + didLoad = true + } catch { + errorMessage = (error as? ErrorResponse)?.message ?? error.localizedDescription + } + } + } +} 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/Presentation/Recruit/RecruitProfilePost/FirstStepView/RecruitProfilePostFirstStepView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/RecruitProfilePostFirstStepView.swift new file mode 100644 index 000000000..a9cc37922 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/RecruitProfilePostFirstStepView.swift @@ -0,0 +1,193 @@ +// +// 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 + $0.backgroundColor = .appColor(.neutral0) + } + 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/FirstStepView/Subviews/RecruitDropdownTriggerButton.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitDropdownTriggerButton.swift new file mode 100644 index 000000000..1a9d270d7 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/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/FirstStepView/Subviews/RecruitProfilePostDepartmentDropdownView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentDropdownView.swift new file mode 100644 index 000000000..44e429ad9 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitProfilePostDepartmentDropdownView.swift @@ -0,0 +1,142 @@ +// +// 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() + + 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 scrollView = UIScrollView() + private let stackView = UIStackView() + private var buttons: [UIButton] = [] + + // MARK: - Initializer + init( + onSelect: @escaping (String) -> Void + ) { + self.onSelect = onSelect + super.init(frame: .zero) + configureView() + } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(departments: [String]) { + self.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 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 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/RecruitSectionHeaderView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitSectionHeaderView.swift new file mode 100644 index 000000000..d1adf5bf4 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/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/FirstStepView/Subviews/RecruitStepView.swift b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/Subviews/RecruitStepView.swift new file mode 100644 index 000000000..1290d311f --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/FirstStepView/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(isFirstStep ? .new400 : .neutral300) + $0.layer.cornerRadius = 16 + } + secondCircleView.do { + $0.backgroundColor = .appColor(isFirstStep ? .neutral300 : .new400) + $0.layer.cornerRadius = 16 + } + connectorView.backgroundColor = .appColor(.neutral300) + + firstNumberLabel.do { + $0.text = "1" + $0.font = .appFont(.pretendardSemiBold, size: 15) + $0.textColor = .appColor(isFirstStep ? .neutral0 : .neutral800) + $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/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift new file mode 100644 index 000000000..519cb1a23 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewController.swift @@ -0,0 +1,589 @@ +// +// 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 onCompleted: (RecruitProfile) -> Void + 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 "수정하기" + } + } + 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 "수정하기" + } + } + private var completionMessage: 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: "다음", + 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), + enabledColor: .appColor(.new500), + disabledColor: .appColor(.neutral400), + enabledTextColor: .appColor(.neutral0), + disabledTextColor: .appColor(.neutral0), + cornerRadius: 16 + ) + + // MARK: - Dropdown + 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 } + makeLogAnalyticsEvent( + post: .teamRecruitmentProfileCreateMajor, + modify: .teamRecruitmentProfileModifyMajorSelect, + value: department + ) + basicInfo.department = department + firstStepView.configure(department: department) + nextButton.updateState(isEnabled: basicInfo.isValid) + } + private lazy var departmentDropdown = firstStepDropdownHost.makeDropdown( + anchor: firstStepView.departmentDropdownAnchor, + contentView: departmentDropdownContentView, + configuration: .init(topPadding: 12, shadow: .shadow2) + ) + + // MARK: - Initializer + init( + viewModel: RecruitProfilePostViewModel, + onCompleted: @escaping (RecruitProfile) -> Void + ) { + self.viewModel = viewModel + self.onCompleted = onCompleted + 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) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + title = titleText + configureView() + secondStepView.prepareDropdown(host: secondStepDropdownHost) + secondStepView.configure(request) + completeButton.updateState(isEnabled: request.isValid) + 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 + } + makeLogAnalyticsEvent( + post: .teamRecruitmentProfileCreateNext, + modify: .teamRecruitmentProfileModifyNext, + value: "다음" + ) + showSecondStep() + } + + @objc private func prevButtonTapped() { + showFirstStep() + } + + @objc private func completeButtonTapped() { + 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( + configuration: .init( + appearance: .new, + content: .singleTitle(text: completeModalTitle), + button: .buttons( + leftButtonTitle: "취소하기", + leftButtonAction: leftButtonAction, + rightButtonTitle: completeModalButtonTitle, + rightButtonAction: rightButtonAction + ) + ) + ) + present(modalViewController, animated: true) + } + + private func showFirstStep() { + showStep(isFirstStep: true) + } + + private func showSecondStep() { + showStep(isFirstStep: false) + } + + private func showStep(isFirstStep: Bool) { + view.endEditing(true) + firstStepDropdownHost.dismissPresented() + secondStepDropdownHost.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 .updateLoading(isLoading): + updateLoading(isLoading) + case let .postCompleted(profile): + handleCompletion(profile) + case let .showToast(message): + showToastMessage(message: message, bottomInset: 72) + } + } + .store(in: &subscriptions) + + firstStepView.loadInfoButtonTappedPublisher + .sink { [weak self] in + self?.makeLogAnalyticsEvent( + post: .teamRecruitmentProfileCreateLoad, + modify: .teamRecruitmentProfileModifyLoad, + value: "회원정보 불러오기" + ) + self?.inputSubject.send(.fetchBasicInfo) + } + .store(in: &subscriptions) + + firstStepView.nicknameChangedPublisher + .sink { [weak self] nickname in + guard let self else { return } + basicInfo.nickname = nickname + request.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) + + 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.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] (section, indexPath, heightChanged) in + guard let self else { return } + view.layoutIfNeeded() + UIView.animate( + springDuration: 0.25, + options: [.beginFromCurrentState, .allowUserInteraction] + ) { + self.secondStepView.applyPendingSizeChange() + self.updateCompleteButtonState() + self.view.layoutIfNeeded() + guard heightChanged > 0 else { return } + self.secondStepView.scrollRowToVisible(at: indexPath, in: section) + } + } + .store(in: &subscriptions) + } +} + +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() { + completeButton.updateState(isEnabled: request.isValid && !secondStepView.isEditing) + } + + 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 { + 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 + } + 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, 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) + } + + 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) + } + } +} + +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 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?.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 + } + } +} + +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 new file mode 100644 index 000000000..5249ca421 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/RecruitProfilePostViewModel.swift @@ -0,0 +1,160 @@ +// +// 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 fetchBasicInfo + case submit(basicInfo: BasicInfo, request: RecruitProfileRequest) + case logEvent(EventLabelType, EventParameter.EventCategory, Any) + } + + enum Output { + case updateBasicInfo(BasicInfo) + case updateDepartments([String]) + case updateLoading(Bool) + case postCompleted(RecruitProfile) + case showToast(String) + } + + // MARK: - State + let mode: Mode + + // MARK: - UseCase + private let fetchDeptListUseCase: FetchDeptListUseCase + private let fetchBasicInfoUseCase: FetchBasicInfoUseCase + private let modifyBasicInfoUseCase: ModifyBasicInfoUseCase + private let upsertMyRecruitProfileUseCase: UpsertMyRecruitProfileUseCase + private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase + + // MARK: - Publisher + private let outputSubject = PassthroughSubject() + private var subscriptions = Set() + private var isSubmitting = false + + // MARK: - Initializer + init( + fetchDeptListUseCase: FetchDeptListUseCase, + 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 + } + + // MARK: - Public + func transform(with input: AnyPublisher) -> AnyPublisher { + input.sink { [weak self] input in + guard let self else { return } + switch input { + case .viewDidLoad: + fetchDepartments() + + switch mode { + case .post: + return + case .modify(let recruitProfile): + let basicInfo = recruitProfile.toBasicInfo() + self.outputSubject.send(.updateBasicInfo(basicInfo)) + } + case .fetchBasicInfo: + 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) + + return outputSubject.eraseToAnyPublisher() + } +} + +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 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( + basicInfo: BasicInfo, + request: RecruitProfileRequest + ) { + guard !isSubmitting else { return } + isSubmitting = true + outputSubject.send(.updateLoading(true)) + Task { + 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 { + let message = (error as? ErrorResponse)?.message ?? error.localizedDescription + outputSubject.send(.showToast(message)) + } + } + } +} + +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 new file mode 100644 index 000000000..bb4ec4170 --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/RecruitProfilePostSecondStepView.swift @@ -0,0 +1,184 @@ +// +// RecruitProfilePostSecondStepView.swift +// koin +// +// Created by 홍기정 on 9/15/26. +// + +import Combine +import SnapKit +import Then +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<( + section: Section, + indexPath: IndexPath, + heightChanged: Int + ), Never>() + 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() + + var isEditing: Bool { + activityTableView.hasEditingRow + } + + // MARK: - UI Components + 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() + } + + func scrollRowToVisible(at indexPath: IndexPath, in section: Section) { + let tableView: UITableView = switch section { + case .skill: + skillTableView + case .activity: + activityTableView + } + 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() { + 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.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) + } + + 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..5531cdf40 --- /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..bca95f00e --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostActivityTableView.swift @@ -0,0 +1,325 @@ +// +// 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 addButtonTappedPublisher = PassthroughSubject() + let editButtonTappedPublisher = PassthroughSubject() + let addCompleteButtonTappedPublisher = PassthroughSubject() + let editCompleteButtonTappedPublisher = PassthroughSubject() + let activitiesChangedPublisher = PassthroughSubject<[RecruitProfileActivityRequest], Never>() + let didChangeHeightPublisher = PassthroughSubject<(indexPath: IndexPath, heightChanged: Int), Never>() + + private var rows: [Row] = [] + private var footerSubscriptions = Set() + + private weak var dropdownHost: KoinDropdownHost? + 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) + 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() { + addButtonTappedPublisher.send() + dropdownHost?.dismissPresented() + let indexPath = IndexPath(row: rows.count, section: 0) + rows.append(Row( + draft: RecruitProfileActivityRequest(), + committed: nil, + mode: .editing + )) + pendingSizeChange = .insert(indexPath) + didChangeHeightPublisher.send((indexPath, 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((indexPath, 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 { + editCompleteButtonTappedPublisher.send() + } + dropdownHost?.dismissPresented() + rows[indexPath.row].committed = rows[indexPath.row].draft + rows[indexPath.row].mode = .display + pendingSizeChange = .reload(indexPath) + didChangeHeightPublisher.send((indexPath, 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((indexPath, -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 { + 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..e8ba5bf2d --- /dev/null +++ b/Koin/Presentation/Recruit/RecruitProfilePost/SecondStepView/Subviews/RecruitProfilePostSkillTableView.swift @@ -0,0 +1,180 @@ +// +// 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<(indexPath: IndexPath, heightChanged: Int), Never>() + let addButtonTappedPublisher = 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 previousHeight = intrinsicContentSize.height + let indexPath = IndexPath(row: skills.count, section: 0) + skills.append("") + pendingSizeChange = .insert(indexPath) + didChangeHeightPublisher.send((indexPath, 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((indexPath, Int(intrinsicContentSize.height - previousHeight))) + 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?.addButtonTappedPublisher.send() + 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..b579e35e0 --- /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/Shop/Shop/ShopViewController.swift b/Koin/Presentation/Shop/Shop/ShopViewController.swift index b7162ae48..132961099 100644 --- a/Koin/Presentation/Shop/Shop/ShopViewController.swift +++ b/Koin/Presentation/Shop/Shop/ShopViewController.swift @@ -42,7 +42,7 @@ final class ShopViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - configureNavigationBar(style: .order) + configureNavigationBar(style: .newBackground) inputSubject.send(.getUserScreenAction(Date(), .enterVC)) inputSubject.send(.getUserScreenAction(Date(), .beginEvent, .shopCategories)) inputSubject.send(.getUserScreenAction(Date(), .beginEvent, .shopCategoriesBack)) diff --git a/Koin/Presentation/Shop/ShopSearch/ShopSearchViewController.swift b/Koin/Presentation/Shop/ShopSearch/ShopSearchViewController.swift index f8413f93d..29b338faa 100644 --- a/Koin/Presentation/Shop/ShopSearch/ShopSearchViewController.swift +++ b/Koin/Presentation/Shop/ShopSearch/ShopSearchViewController.swift @@ -84,7 +84,7 @@ final class ShopSearchViewController: UIViewController { } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - configureNavigationBar(style: .order) + configureNavigationBar(style: .newBackground) } override func viewDidAppear(_ animated: Bool) { 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/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 @@ + + + + + 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..7b0f267b2 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitNotificationChat.imageset/recruitNotificationChat.svg @@ -0,0 +1,4 @@ + + + + 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/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 @@ + + + + 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/recruitProfileMyApplication.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplication.imageset/Contents.json new file mode 100644 index 000000000..43b4743dd --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplication.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitProfileMyApplication.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplication.imageset/recruitProfileMyApplication.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplication.imageset/recruitProfileMyApplication.svg new file mode 100644 index 000000000..c175505bc --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyApplication.imageset/recruitProfileMyApplication.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPost.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPost.imageset/Contents.json new file mode 100644 index 000000000..a7c064bb7 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPost.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "recruitProfileMyPost.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPost.imageset/recruitProfileMyPost.svg b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPost.imageset/recruitProfileMyPost.svg new file mode 100644 index 000000000..347d468b5 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Recruit/recruitProfileMyPost.imageset/recruitProfileMyPost.svg @@ -0,0 +1,6 @@ + + + + + + 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 @@ + + + + diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index e4f130a92..0ce4c4558 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -7,7 +7,30 @@ objects = { /* 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 */; }; + 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 */; }; + 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 */; }; @@ -45,6 +68,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 */; }; @@ -134,7 +159,38 @@ 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 /* 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 /* 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 */; }; + 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 */; }; + 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 */; }; + 7C50A20F3043278300897A6F /* FetchRecruitNotificationListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C50A20E3043278300897A6F /* FetchRecruitNotificationListUseCase.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 */; }; @@ -259,7 +315,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 */; }; @@ -333,6 +388,12 @@ 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 /* 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 */; }; 7CB061932F32100900E83CA9 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 7CB061922F32100900E83CA9 /* Alamofire */; }; 7CB061992F32186800E83CA9 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7CB061982F32186800E83CA9 /* SnapKit */; }; @@ -373,6 +434,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 */; }; @@ -418,6 +482,42 @@ 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 7CE1342E306A980D0011B052 /* FetchRecruitTeamChatDataUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE1342D306A980D0011B052 /* FetchRecruitTeamChatDataUseCase.swift */; }; + 7CE13430306ABEB90011B052 /* Publisher+.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000001 /* Publisher+.swift */; }; + 7CE13431306A40000011B052 /* RecruitMyPostApplicantRoleChipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13430306A40000011B052 /* RecruitMyPostApplicantRoleChipView.swift */; }; + 7CE13432306C01BC0011B052 /* RecruitChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE13431306C01BC0011B052 /* RecruitChatMessage.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 */; }; @@ -640,6 +740,10 @@ 7CED923B2EF2D26000457128 /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED923A2EF2D26000457128 /* Coordinator.swift */; }; 7CED92462EF3DC2800457128 /* OrderCategoryCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED92442EF3DC2800457128 /* OrderCategoryCollectionViewCell.swift */; }; 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 /* RecruitApplicantRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF1D58E3069772700295ED9 /* RecruitApplicantRow.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 */; }; @@ -735,7 +839,42 @@ 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 */; }; - AA20000000000000000001 /* PublisherTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000001 /* PublisherTestSupport.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 */; }; + 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 */; }; + A0B343754988C9398C4FE726 /* ModifyRecruitUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */; }; + A11B00000000000000000001 /* PostBasicInfoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11B00000000000000000002 /* PostBasicInfoUseCase.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 */; }; + A11F00000000000000000004 /* RecruitMyPostEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11E00000000000000000004 /* RecruitMyPostEmptyView.swift */; }; + 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 /* 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 */; }; + A2C130000000000000000001 /* FetchRecruitDirectChatDataUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2C130000000000000000002 /* FetchRecruitDirectChatDataUseCase.swift */; }; + A2C140000000000000000001 /* RecruitChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2C140000000000000000002 /* RecruitChatViewController.swift */; }; + A2C140000000000000000003 /* RecruitChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2C140000000000000000004 /* RecruitChatViewModel.swift */; }; + A2C140000000000000000005 /* ChatListModel+RecruitChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2C140000000000000000006 /* ChatListModel+RecruitChat.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 */; }; + 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 */; }; AA20000000000000000004 /* DateProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000004 /* DateProviderTests.swift */; }; @@ -746,21 +885,24 @@ AA20000000000000000009 /* DiningLoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000009 /* DiningLoggingTests.swift */; }; AA20000000000000000010 /* DiningTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000010 /* DiningTypeTests.swift */; }; AA20000000000000000020 /* AgreementSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000020 /* AgreementSelectionTests.swift */; }; + AA20000000000000000021 /* AgreementTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000021 /* AgreementTextTests.swift */; }; + AA20000000000000000022 /* CustomSessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000022 /* CustomSessionManagerTests.swift */; }; + AA20000000000000000023 /* RegisterFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000023 /* RegisterFormViewModelTests.swift */; }; + AA20000000000000000024 /* RegisterFormViewModelStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000024 /* RegisterFormViewModelStubs.swift */; }; + AA20000000000000000030 /* CertificationInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000030 /* CertificationInput.swift */; }; + AA20000000000000000031 /* VerificationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000031 /* VerificationSession.swift */; }; AA20000000000000000032 /* CertificationInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000032 /* CertificationInputTests.swift */; }; AA20000000000000000033 /* VerificationSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000033 /* VerificationSessionTests.swift */; }; AA20000000000000000034 /* RegisterFormViewModelCertificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000034 /* RegisterFormViewModelCertificationTests.swift */; }; - AA20000000000000000036 /* RegisterFormViewModelSelectTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000036 /* RegisterFormViewModelSelectTypeTests.swift */; }; AA20000000000000000035 /* CertificationUseCaseSpies.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000035 /* CertificationUseCaseSpies.swift */; }; + AA20000000000000000036 /* RegisterFormViewModelSelectTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000036 /* RegisterFormViewModelSelectTypeTests.swift */; }; AA20000000000000000037 /* RegisterInfoInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000037 /* RegisterInfoInput.swift */; }; - AA20000000000000000041 /* EnterFormState.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000041 /* EnterFormState.swift */; }; - AA20000000000000000042 /* EnterFormStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000042 /* EnterFormStateTests.swift */; }; AA20000000000000000038 /* RegisterInfoInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000038 /* RegisterInfoInputTests.swift */; }; AA20000000000000000039 /* RegisterFormViewModelEnterFormTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000039 /* RegisterFormViewModelEnterFormTests.swift */; }; AA20000000000000000040 /* EnterFormUseCaseSpies.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000040 /* EnterFormUseCaseSpies.swift */; }; - AA20000000000000000021 /* AgreementTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000021 /* AgreementTextTests.swift */; }; - AA20000000000000000022 /* CustomSessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000022 /* CustomSessionManagerTests.swift */; }; - AA20000000000000000023 /* RegisterFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000023 /* RegisterFormViewModelTests.swift */; }; - AA20000000000000000024 /* RegisterFormViewModelStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000024 /* RegisterFormViewModelStubs.swift */; }; + 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 */; }; 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 */; }; @@ -783,10 +925,43 @@ B68D2CE72EA505B000F3B479 /* ToastMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68D2CE62EA505B000F3B479 /* ToastMessageView.swift */; }; 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 */; }; + 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 /* 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 */; }; + CAFE20000000000000000002 /* RecruitMyApplicationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000002 /* RecruitMyApplicationRow.swift */; }; + CAFE20000000000000000003 /* RecruitMyApplicationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE10000000000000000003 /* RecruitMyApplicationFilter.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 */; }; + 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 */; }; + 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 */; }; 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 */; }; @@ -999,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 */; }; @@ -1019,14 +1195,20 @@ 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 */; }; - AA20000000000000000030 /* CertificationInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000030 /* CertificationInput.swift */; }; - AA20000000000000000031 /* VerificationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000031 /* VerificationSession.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 */; }; + 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 */; }; + FE0B6B2358364622BC242C74 /* RecruitDataButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AB6864692D44B9849E0447 /* RecruitDataButton.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1062,6 +1244,31 @@ /* 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; 7C016543300AA1450013DD7B /* DepartmentCategoryHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DepartmentCategoryHostingController.swift; sourceTree = ""; }; 7C016545300AA14C0013DD7B /* DepartmentCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DepartmentCategoryView.swift; sourceTree = ""; }; @@ -1099,6 +1306,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 = ""; }; @@ -1188,7 +1397,38 @@ 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 /* 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 /* 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; @@ -1310,7 +1550,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 = ""; }; @@ -1384,6 +1623,12 @@ 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 /* 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 = ""; }; 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 = ""; }; @@ -1408,6 +1653,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 = ""; }; @@ -1453,6 +1701,41 @@ 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 7CE1342D306A980D0011B052 /* FetchRecruitTeamChatDataUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitTeamChatDataUseCase.swift; sourceTree = ""; }; + 7CE13430306A40000011B052 /* RecruitMyPostApplicantRoleChipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostApplicantRoleChipView.swift; sourceTree = ""; }; + 7CE13431306C01BC0011B052 /* RecruitChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitChatMessage.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 = ""; }; @@ -1675,6 +1958,10 @@ 7CED923A2EF2D26000457128 /* Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Coordinator.swift; sourceTree = ""; }; 7CED92432EF3DC2800457128 /* OrderCategoryCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderCategoryCollectionView.swift; sourceTree = ""; }; 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 /* RecruitApplicantRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitApplicantRow.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 = ""; }; @@ -1695,6 +1982,10 @@ 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 = ""; }; + 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 = ""; }; 831C469F2C63C94000CF84C0 /* GetUserScreenTimeUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = GetUserScreenTimeUseCase.swift; path = ScreenTime/GetUserScreenTimeUseCase.swift; sourceTree = ""; }; @@ -1771,8 +2062,39 @@ 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 = ""; }; + 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; }; - AA10000000000000000001 /* PublisherTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublisherTestSupport.swift; sourceTree = ""; }; + 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 = ""; }; + A11E00000000000000000002 /* RecruitMyPostSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostSummaryView.swift; sourceTree = ""; }; + A11E00000000000000000003 /* RecruitMyPostApplicantRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostApplicantRowView.swift; sourceTree = ""; }; + A11E00000000000000000004 /* RecruitMyPostEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitMyPostEmptyView.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 = ""; }; + A2C110000000000000000002 /* RecruitChatData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitChatData.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 = ""; }; + A2C130000000000000000002 /* FetchRecruitDirectChatDataUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecruitDirectChatDataUseCase.swift; sourceTree = ""; }; + A2C140000000000000000002 /* RecruitChatViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitChatViewController.swift; sourceTree = ""; }; + A2C140000000000000000004 /* RecruitChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecruitChatViewModel.swift; sourceTree = ""; }; + A2C140000000000000000006 /* ChatListModel+RecruitChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatListModel+RecruitChat.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 /* 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 = ""; }; @@ -1783,21 +2105,24 @@ AA10000000000000000009 /* DiningLoggingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningLoggingTests.swift; sourceTree = ""; }; AA10000000000000000010 /* DiningTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningTypeTests.swift; sourceTree = ""; }; AA10000000000000000020 /* AgreementSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementSelectionTests.swift; sourceTree = ""; }; + AA10000000000000000021 /* AgreementTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementTextTests.swift; sourceTree = ""; }; + AA10000000000000000022 /* CustomSessionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomSessionManagerTests.swift; sourceTree = ""; }; + AA10000000000000000023 /* RegisterFormViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFormViewModelTests.swift; sourceTree = ""; }; + AA10000000000000000024 /* RegisterFormViewModelStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFormViewModelStubs.swift; sourceTree = ""; }; + AA10000000000000000030 /* CertificationInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificationInput.swift; sourceTree = ""; }; + AA10000000000000000031 /* VerificationSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationSession.swift; sourceTree = ""; }; AA10000000000000000032 /* CertificationInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificationInputTests.swift; sourceTree = ""; }; AA10000000000000000033 /* VerificationSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationSessionTests.swift; sourceTree = ""; }; AA10000000000000000034 /* RegisterFormViewModelCertificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFormViewModelCertificationTests.swift; sourceTree = ""; }; - AA10000000000000000036 /* RegisterFormViewModelSelectTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFormViewModelSelectTypeTests.swift; sourceTree = ""; }; AA10000000000000000035 /* CertificationUseCaseSpies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificationUseCaseSpies.swift; sourceTree = ""; }; + AA10000000000000000036 /* RegisterFormViewModelSelectTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFormViewModelSelectTypeTests.swift; sourceTree = ""; }; AA10000000000000000037 /* RegisterInfoInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterInfoInput.swift; sourceTree = ""; }; - AA10000000000000000041 /* EnterFormState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterFormState.swift; sourceTree = ""; }; - AA10000000000000000042 /* EnterFormStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterFormStateTests.swift; sourceTree = ""; }; AA10000000000000000038 /* RegisterInfoInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterInfoInputTests.swift; sourceTree = ""; }; AA10000000000000000039 /* RegisterFormViewModelEnterFormTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFormViewModelEnterFormTests.swift; sourceTree = ""; }; AA10000000000000000040 /* EnterFormUseCaseSpies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterFormUseCaseSpies.swift; sourceTree = ""; }; - AA10000000000000000021 /* AgreementTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementTextTests.swift; sourceTree = ""; }; - AA10000000000000000022 /* CustomSessionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomSessionManagerTests.swift; sourceTree = ""; }; - AA10000000000000000023 /* RegisterFormViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFormViewModelTests.swift; sourceTree = ""; }; - AA10000000000000000024 /* RegisterFormViewModelStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFormViewModelStubs.swift; sourceTree = ""; }; + AA10000000000000000041 /* EnterFormState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterFormState.swift; sourceTree = ""; }; + AA10000000000000000042 /* EnterFormStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterFormStateTests.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 = ""; }; @@ -1823,10 +2148,41 @@ 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; 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 = ""; }; @@ -2042,6 +2398,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 = ""; }; @@ -2062,12 +2419,18 @@ 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 = ""; }; + 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 = ""; }; - AA10000000000000000030 /* CertificationInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificationInput.swift; sourceTree = ""; }; - AA10000000000000000031 /* VerificationSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationSession.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 = ""; }; 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 = ""; }; FE01000000000000000016 /* KoinDropdownConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdownConfiguration.swift; sourceTree = ""; }; @@ -2114,6 +2477,44 @@ /* 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 = ""; + }; + 4C4575D89FC0D358F532B18E /* Recruit */ = { + isa = PBXGroup; + children = ( + 2E31EA58151824C938383893 /* RecruitEnumDto.swift */, + 21AA98431B25CB5914BC2598 /* RecruitListDto.swift */, + 7C0D8CC2306E837F00F7CE08 /* RecruitListSummaryDto.swift */, + 7C0D8CC4306E83A500F7CE08 /* RecruitRoleDto.swift */, + EA92583628DDFBC3545C197B /* RecruitDataDto.swift */, + 267C5F2D082AC60FDA16F85C /* RecruitPostResultDto.swift */, + 1A900DEA87E917C84BCD7E04 /* RecruitProfileDto.swift */, + 756A97197501167BE3516430 /* RecruitMyApplicationListDto.swift */, + A064C42647B4EBB3454B2CA3 /* RecruitMyPostListDto.swift */, + E1A5115E53734B28CC6EBED6 /* RecruitMyPostDataDto.swift */, + 1FC1A22BED5A1C53E5ED9903 /* RecruitApplicantDataDto.swift */, + C398DC463A148F3E7ADB7ADE /* RecruitChatDataDto.swift */, + B7C07F15438785121E093249 /* RecruitChatMessageDto.swift */, + 70B18A7091B73080A7AEDDDE /* RecruitNotificationListDto.swift */, + ); + path = Recruit; + sourceTree = ""; + }; 7C016541300A9FF80013DD7B /* Department */ = { isa = PBXGroup; children = ( @@ -2447,7 +2848,7 @@ 7C457B282FCE1BA80011D338 /* Notification */ = { isa = PBXGroup; children = ( - D19A0001300000000000000A /* Support */, + D19A0001300000000000000A /* Mapper */, 7C8A941C2FD0A94F00DEA6F5 /* NotificationViewController.swift */, 7C8A941D2FD0A94F00DEA6F5 /* NotificationViewModel.swift */, ); @@ -2617,6 +3018,189 @@ 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 */, + BEEF30000000000000000001 /* RecruitMyPostList */, + CAFE30000000000000000001 /* RecruitMyApplicationList */, + 7CACF86130436C1900AC2828 /* RecruitNotificationList */, + 7CACF873304441AA00AC2828 /* RecruitPost */, + 7CDCD0AE30556C89004204DA /* RecruitData */, + 7CDCD0C43055D58B004204DA /* RecruitProfile */, + 7CDCD0D93055EF87004204DA /* RecruitProfilePost */, + 7C52085D306902C10009BDD3 /* RecruitApply */, + 7C520862306947220009BDD3 /* RecruitMyPost */, + 7CE13413306A2BA20011B052 /* RecruitMyPostApplicant */, + A2C140000000000000000007 /* RecruitChat */, + ); + 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 */, + 7C50A1FE3042E9DC00897A6F /* RecruitList.swift */, + 7C50A1F33042CB4100897A6F /* RecruitListSort.swift */, + 7C50A1CB3041E29000897A6F /* RecruitListFilter.swift */, + 7C50A1E23041F91400897A6F /* RecruitRow.swift */, + BEEF10000000000000000001 /* RecruitMyPostList.swift */, + BEEF10000000000000000003 /* RecruitMyPostFilter.swift */, + BEEF10000000000000000002 /* RecruitMyPostRow.swift */, + CAFE10000000000000000001 /* RecruitMyApplicationList.swift */, + CAFE10000000000000000003 /* RecruitMyApplicationFilter.swift */, + CAFE10000000000000000002 /* RecruitMyApplicationRow.swift */, + CAFE10000000000000000004 /* RecruitApplicationStatus.swift */, + 7C50A20C3043273A00897A6F /* RecruitNotificationList.swift */, + 7C50A20A304324F500897A6F /* RecruitNotificationRow.swift */, + 7C50A1D63041F91400897A6F /* RecruitData.swift */, + FB6A15BFD7B896A787835F8E /* RecruitApplyBlockReason.swift */, + 7CAC2DDB304C34E900AC2828 /* RecruitPostRequest.swift */, + C0DE00000000000000000002 /* RecruitProfile.swift */, + 7CDCD0D73055EECF004204DA /* RecruitProfileActivity.swift */, + 7CDCD0DF305602A2004204DA /* RecruitProfileRequest.swift */, + 7CDCD0E1305602F6004204DA /* RecruitProfileActivityRequest.swift */, + 7CDCD0E330560758004204DA /* BasicInfo.swift */, + 7C520868306902C10009BDD3 /* RecruitApplyRequest.swift */, + 7CF1D58E3069772700295ED9 /* RecruitApplicantRow.swift */, + 7CE13419306A2D100011B052 /* RecruitApplicantData.swift */, + 7CE1341D306A2FA30011B052 /* RecruitApplicantDecision.swift */, + A2C110000000000000000002 /* RecruitChatData.swift */, + A2C110000000000000000004 /* RecruitChatMessageList.swift */, + 7CE13431306C01BC0011B052 /* RecruitChatMessage.swift */, + A2C110000000000000000006 /* RecruitChatPostRequest.swift */, + 645CEE62E516B130B4E2D784 /* RecruitMyApplication.swift */, + 87039C64B7CB2AEB632113C6 /* RecruitMyPostData.swift */, + ); + path = Recruit; + sourceTree = ""; + }; + 7C50A1DA3042013B00897A6F /* UIKit */ = { + isa = PBXGroup; + children = ( + 7C8B37A73040286C00A7EB5A /* FilterBottomSheet */, + D19A1001300000000000000C /* Chat */, + D19A00013000000000000008 /* Notification */, + 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 */, + 7C61D3502F22B344005FAC3A /* ExtendedTouchAreaView.swift */, + 7C65FDCA2FE69A00007831CE /* AutoScrollableInfiniteCarouselCollectionView */, + ); + path = UIKit; + sourceTree = ""; + }; + 7C50A1DB3042014000897A6F /* SwiftUI */ = { + isa = PBXGroup; + children = ( + 7C50A1DC3042016300897A6F /* LeftAlignedLayout.swift */, + ); + path = SwiftUI; + sourceTree = ""; + }; + 7C50A1F83042DB2000897A6F /* Mapper */ = { + isa = PBXGroup; + children = ( + 7C50A1F93042DB3200897A6F /* RecruitListFilter+.swift */, + ); + path = Mapper; + sourceTree = ""; + }; + 7C50A1FB3042E99E00897A6F /* Recruit */ = { + isa = PBXGroup; + children = ( + A1D000010000000000000003 /* RecruitData */, + C0DE00000000000000000005 /* RecruitProfile */, + C1309DAEB0F857F481B1A4B0 /* RecruitPost */, + 7CACF86C3044096200AC2828 /* RecruitList */, + BEEF30000000000000000004 /* RecruitMyPostList */, + CAFE30000000000000000004 /* RecruitMyApplicationList */, + 7CACF86E3044097800AC2828 /* RecruitNotificationList */, + 7C52085F30692C000009BDD3 /* RecruitApply */, + A2C120000000000000000009 /* RecruitChat */, + ); + 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 = ""; + }; + 7C520862306947220009BDD3 /* RecruitMyPost */ = { + isa = PBXGroup; + children = ( + A11D00000000000000000001 /* Subviews */, + 7CF1D58C30695E8A00295ED9 /* RecruitMyPostHostingController.swift */, + A11E00000000000000000001 /* RecruitMyPostView.swift */, + 7CF1D58A30695E7900295ED9 /* RecruitMyPostViewModel.swift */, + ); + path = RecruitMyPost; + sourceTree = ""; + }; 7C58FE842F5F9CC700A2737B /* CallVanData */ = { isa = PBXGroup; children = ( @@ -2744,6 +3328,7 @@ 7C8A94452FD50E8400DEA6F5 /* String+byCharWrapping.swift */, 7C67906B2FCC48A60045E163 /* TimeInterval+.swift */, 7C67909D2FCC48BF0045E163 /* Bundle+APIKey.swift */, + 7C50A20830431F9900897A6F /* Array+.swift */, ); path = Common; sourceTree = ""; @@ -2818,6 +3403,7 @@ 7C7F56B52FFB9F4A00847151 /* Common */ = { isa = PBXGroup; children = ( + 7CE1342F306ABEAC0011B052 /* Support */, 7CA686742FFCB6D8007937E9 /* Core */, 7CA686732FFCB6BE007937E9 /* NotificationHistory */, ); @@ -2893,7 +3479,7 @@ children = ( 7C8A94252FD0A97400DEA6F5 /* NotificationTableView.swift */, 7C8A94262FD0A97400DEA6F5 /* NotificationTableViewCell.swift */, - 7C8A94242FD0A97400DEA6F5 /* NotificationFooterView.swift */, + C097425ADD10086931A8789D /* NotificationFooterView.swift */, ); path = NotificationTableView; sourceTree = ""; @@ -3088,6 +3674,52 @@ 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 /* RecruitNotificationRow+.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 = ""; + }; + 7CACF873304441AA00AC2828 /* RecruitPost */ = { + isa = PBXGroup; + children = ( + 35728BBC0BEFE046C56D7CDF /* Subviews */, + 82F7EBAB20247086024B51CB /* RecruitPostViewController.swift */, + 288BC1E04044DC3FB48524FC /* RecruitPostViewModel.swift */, + ); + path = RecruitPost; + sourceTree = ""; + }; 7CB061C12F32312E00E83CA9 /* helpers */ = { isa = PBXGroup; children = ( @@ -3180,6 +3812,48 @@ 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 */, + 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 = ( @@ -3402,6 +4076,96 @@ 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 = ""; + }; + 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 = ""; + }; + 7CDCD0D93055EF87004204DA /* RecruitProfilePost */ = { + isa = PBXGroup; + children = ( + 7CB8EB4A30615FC500300E99 /* FirstStepView */, + 7CB8EB4B30615FCB00300E99 /* SecondStepView */, + 7CDCD0DA3055EFA7004204DA /* RecruitProfilePostViewController.swift */, + 7CDCD0DC3055EFA9004204DA /* RecruitProfilePostViewModel.swift */, + ); + 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 = ""; + }; + 7CE1342F306ABEAC0011B052 /* Support */ = { + isa = PBXGroup; + children = ( + AA10000000000000000001 /* Publisher+.swift */, + ); + path = Support; + sourceTree = ""; + }; 7CED8EC52EF2D1F900457128 /* ShopCollectionView */ = { isa = PBXGroup; children = ( @@ -4428,7 +5192,6 @@ isa = PBXGroup; children = ( 7C86751030062FF3003CB942 /* Core */, - D19A00013000000000000007 /* Shared */, 7C457B2A2FCE1BA80011D338 /* Home */, 7C016541300A9FF80013DD7B /* Department */, 7CED8EF42EF2D1F900457128 /* LostItem */, @@ -4442,6 +5205,7 @@ 7CED90A62EF2D1F900457128 /* Facility */, 7CED90BA2EF2D1F900457128 /* Land */, 7CC42B082F56AAAD00940CE1 /* CallVan */, + 7C50A1B53041A25D00897A6F /* Recruit */, ); path = Presentation; sourceTree = ""; @@ -4675,10 +5439,57 @@ name = Products; sourceTree = ""; }; + A11D00000000000000000001 /* Subviews */ = { + isa = PBXGroup; + children = ( + A11E00000000000000000002 /* RecruitMyPostSummaryView.swift */, + A11E00000000000000000003 /* RecruitMyPostApplicantRowView.swift */, + A11E00000000000000000004 /* RecruitMyPostEmptyView.swift */, + ); + path = Subviews; + sourceTree = ""; + }; + A1D000010000000000000003 /* RecruitData */ = { + isa = PBXGroup; + children = ( + A1D000010000000000000002 /* FetchRecruitDataUseCase.swift */, + 7CDCD0BC30559E70004204DA /* DeleteRecruitDataUseCase.swift */, + ); + path = RecruitData; + sourceTree = ""; + }; + A2C120000000000000000009 /* RecruitChat */ = { + isa = PBXGroup; + children = ( + 7CE1342D306A980D0011B052 /* FetchRecruitTeamChatDataUseCase.swift */, + A2C130000000000000000002 /* FetchRecruitDirectChatDataUseCase.swift */, + A2C120000000000000000006 /* FetchRecruitChatMessagesUseCase.swift */, + A2C120000000000000000008 /* PostRecruitChatMessageUseCase.swift */, + ); + path = RecruitChat; + sourceTree = ""; + }; + A2C140000000000000000007 /* RecruitChat */ = { + isa = PBXGroup; + children = ( + A2C140000000000000000008 /* Mapper */, + A2C140000000000000000002 /* RecruitChatViewController.swift */, + A2C140000000000000000004 /* RecruitChatViewModel.swift */, + ); + path = RecruitChat; + sourceTree = ""; + }; + A2C140000000000000000008 /* Mapper */ = { + isa = PBXGroup; + children = ( + A2C140000000000000000006 /* ChatListModel+RecruitChat.swift */, + ); + path = Mapper; + sourceTree = ""; + }; AA30000000000000000001 /* Support */ = { isa = PBXGroup; children = ( - AA10000000000000000001 /* PublisherTestSupport.swift */, AA10000000000000000002 /* DiningFixtures.swift */, AA10000000000000000007 /* DiningLoggingTestSupport.swift */, ); @@ -4790,14 +5601,119 @@ path = Configuration; sourceTree = ""; }; - D19A00013000000000000007 /* Shared */ = { + B99A68507F04177F19780924 /* Recruit */ = { isa = PBXGroup; children = ( - 7C8B37A73040286C00A7EB5A /* FilterBottomSheet */, - D19A1001300000000000000C /* Chat */, - D19A00013000000000000008 /* Notification */, + 4067CAF18D134F99A04BBA0D /* RecruitListRequestDto.swift */, + 2FE562D9FEDE6181748BE67B /* RecruitPostRequestDto.swift */, + 99C8CD337DB4DF4456561F92 /* RecruitProfileRequestDto.swift */, + C8FC035A1EC83BD077A6AFCA /* RecruitApplyRequestDto.swift */, + 3143194BD51D3A85C2A959EB /* RecruitMyApplicationListRequestDto.swift */, + DC82D6E2A559FEFE43E63F24 /* RecruitMyPostListRequestDto.swift */, + 827BD7E96347F476DB1BD966 /* RecruitApplicantListRequestDto.swift */, + 4D7BF08E4E28887C9FB48DA1 /* RecruitApplicantDecisionRequestDto.swift */, + 135CA058C5A41FBC30F6DD2F /* RecruitChatPostRequestDto.swift */, + 310B3D108D7FB1767D7C3498 /* RecruitNotificationListRequestDto.swift */, + ); + path = Recruit; + 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 */, + 7CF1D5903069772700295ED9 /* FetchRecruitMyPostDataUseCase.swift */, + BEEF10000000000000000004 /* FetchRecruitMyPostListUseCase.swift */, + 7CE13417306A2CBF0011B052 /* FetchRecruitApplicantUseCase.swift */, + 7CE1341B306A2F930011B052 /* DecideRecruitApplicantUseCase.swift */, + ); + path = RecruitMyPostList; + sourceTree = ""; + }; + C0DE00000000000000000005 /* RecruitProfile */ = { + isa = PBXGroup; + children = ( + C0DE00000000000000000004 /* FetchMyRecruitProfileUseCase.swift */, + A11B00000000000000000004 /* UpsertMyRecruitProfileUseCase.swift */, + ); + path = RecruitProfile; + sourceTree = ""; + }; + C1309DAEB0F857F481B1A4B0 /* RecruitPost */ = { + isa = PBXGroup; + children = ( + 5231222D2717BC99A6697EAB /* ModifyRecruitUseCase.swift */, + 7D447F2D75951DD4C3E08D4E /* PostRecruitUseCase.swift */, + ); + path = RecruitPost; + sourceTree = ""; + }; + CAFE30000000000000000001 /* RecruitMyApplicationList */ = { + isa = PBXGroup; + children = ( + CAFE30000000000000000002 /* Mapper */, + CAFE30000000000000000003 /* Subviews */, + CAFE10000000000000000009 /* RecruitMyApplicationListHostingController.swift */, + CAFE10000000000000000008 /* RecruitMyApplicationListView.swift */, + CAFE10000000000000000007 /* RecruitMyApplicationListViewModel.swift */, ); - path = Shared; + 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 = ""; }; D19A00013000000000000008 /* Notification */ = { @@ -4817,12 +5733,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 */ = { @@ -5042,7 +5958,6 @@ D27DD0712BA608310081FD36 /* Extensions */ = { isa = PBXGroup; children = ( - 7C6790AB2FCC56FF0045E163 /* Asset */, 7C67906C2FCC48A60045E163 /* Common */, 7C67906D2FCC48A60045E163 /* SwiftUI */, 7C6790832FCC48A60045E163 /* UIKit */, @@ -5076,6 +5991,7 @@ D23479DD2BDB715700FA91BD /* Timetable */, D23479D82BDB5EEF00FA91BD /* Land */, D208A7A32CA1118D007040E7 /* AbTest */, + 4C4575D89FC0D358F532B18E /* Recruit */, ); path = Decodable; sourceTree = ""; @@ -5088,6 +6004,7 @@ B65422F52EA12BE700018F15 /* DesignSystem */, 8354BE482C7A0394009D4D7A /* CoreData */, D27DD0832BA608310081FD36 /* Workers */, + 7C6790AB2FCC56FF0045E163 /* Asset */, D27DD0712BA608310081FD36 /* Extensions */, D2D380E42BA6EAE7009AD5A9 /* View */, D27DD07E2BA608310081FD36 /* Logger */, @@ -5196,6 +6113,7 @@ D20AD7042E0367E00067760A /* ResetPasswordSmsRequest.swift */, D20AD7062E0367F10067760A /* ResetPasswordEmailRequest.swift */, D2C163D52E252C1200DC68A8 /* ChangePasswordRequest.swift */, + 9D3B08333D4FA77CC935917B /* StudentAcademicInfoRequestDto.swift */, ); path = User; sourceTree = ""; @@ -5251,6 +6169,7 @@ D874DB502DBF200C0098EED0 /* SendVerificationCodeDto.swift */, D20AD6F82E02A11D0067760A /* FindIdSmsResponse.swift */, D20AD6FA2E02A1280067760A /* FindIdEmailResponse.swift */, + FA5AA513615BE12CD677F848 /* StudentAcademicInfoDto.swift */, ); path = User; sourceTree = ""; @@ -5321,6 +6240,8 @@ D20AD7082E036BBB0067760A /* ResetPasswordSmsUseCase.swift */, D20AD70A2E036BC60067760A /* ResetPasswordEmailUseCase.swift */, D2C163D72E252DB500DC68A8 /* ChangePasswordUseCase.swift */, + 8D2525B9C0134B21C89671CB /* FetchBasicInfoUseCase.swift */, + A11B00000000000000000002 /* PostBasicInfoUseCase.swift */, ); path = User; sourceTree = ""; @@ -5342,24 +6263,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 */, - 7C61D3502F22B344005FAC3A /* ExtendedTouchAreaView.swift */, - 7C65FDCA2FE69A00007831CE /* AutoScrollableInfiniteCarouselCollectionView */, + 7C50A1DA3042013B00897A6F /* UIKit */, + 7C50A1DB3042014000897A6F /* SwiftUI */, ); path = View; sourceTree = ""; @@ -5379,6 +6284,7 @@ D29F21E62C4F5E1F00994554 /* Timetable */, D29F21DC2C4F2DFF00994554 /* User */, D208A7A42CA111D5007040E7 /* AbTest */, + B99A68507F04177F19780924 /* Recruit */, ); path = Encodable; sourceTree = ""; @@ -5449,6 +6355,7 @@ D2FB234D2BFD45930098BA2B /* Shop */, D249CD242C12DC16000813F9 /* Dining */, 7C016572300BD89E0013DD7B /* Department */, + 7C50A1FB3042E99E00897A6F /* Recruit */, D208A7A12CA11102007040E7 /* AbTest */, ); path = UseCase; @@ -5471,6 +6378,7 @@ D249CD392C158EEC000813F9 /* Dining */, 7CC42B1A2F56F21A00940CE1 /* CallVan */, 7C016552300AA2BE0013DD7B /* Department */, + 7C50A1CA3041E27E00897A6F /* Recruit */, ); path = Model; sourceTree = ""; @@ -5494,6 +6402,7 @@ 7C8A943F2FD426B400DEA6F5 /* HomeRepository.swift */, 7CA686682FFC3242007937E9 /* NotificationHistoryRepository.swift */, 7C6403FF300F395300A9589F /* DepartmentRepository.swift */, + 7C50A2023042EF2C00897A6F /* RecruitRepository.swift */, ); path = Repository; sourceTree = ""; @@ -5516,6 +6425,7 @@ 7CA60D642F6619B7007C08C1 /* DefaultCallVanRepository.swift */, 7CA686792FFCB758007937E9 /* DefaultNotificationHistoryRepository.swift */, 7C640401300F39BB00A9589F /* DefaultDepartmentRepository.swift */, + ED038AB5420C8C565688CF6B /* DefaultRecruitRepository.swift */, ); path = Repository; sourceTree = ""; @@ -5579,6 +6489,7 @@ 7CA60D6C2F661A6D007C08C1 /* CallVanAPI.swift */, 7C8A94432FD4279B00DEA6F5 /* HomeAPI.swift */, 7C6403FD300F36F200A9589F /* DepartmentAPI.swift */, + 54F105D18C46AE639FC3F3EE /* RecruitAPI.swift */, ); path = API; sourceTree = ""; @@ -5611,6 +6522,7 @@ 7CA60D662F6619F4007C08C1 /* CallVanService.swift */, 7C8A94412FD4278800DEA6F5 /* HomeService.swift */, 7C64040E300F428200A9589F /* DepartmentService.swift */, + 8AC394F2327076B2F9AEE2A4 /* RecruitService.swift */, D2FB23582BFD72C60098BA2B /* Network */, 7C16AA352F3DE8F50071ABE6 /* CoreData */, ); @@ -5745,8 +6657,6 @@ EC69228A302700A400EE26ED /* PBXTargetDependency */, ); name = koinUnitTests; - packageProductDependencies = ( - ); productName = koinUnitTests; productReference = EC692285302700A400EE26ED /* koinUnitTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -5883,6 +6793,27 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A2C140000000000000000001 /* RecruitChatViewController.swift in Sources */, + A2C140000000000000000003 /* RecruitChatViewModel.swift in Sources */, + A2C140000000000000000005 /* ChatListModel+RecruitChat.swift in Sources */, + A2C110000000000000000001 /* RecruitChatData.swift in Sources */, + A2C110000000000000000003 /* RecruitChatMessageList.swift in Sources */, + A2C110000000000000000005 /* RecruitChatPostRequest.swift in Sources */, + A2C120000000000000000005 /* FetchRecruitChatMessagesUseCase.swift in Sources */, + A2C120000000000000000007 /* PostRecruitChatMessageUseCase.swift in Sources */, + 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 */, @@ -5920,8 +6851,20 @@ D2FB23532BFD47E30098BA2B /* FetchShopCategoryListUseCase.swift in Sources */, 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 */, + C0DE00000000000000000001 /* RecruitProfile.swift in Sources */, + C0DE00000000000000000003 /* FetchMyRecruitProfileUseCase.swift in Sources */, + A11B00000000000000000001 /* PostBasicInfoUseCase.swift in Sources */, + A11B00000000000000000003 /* UpsertMyRecruitProfileUseCase.swift in Sources */, D2D7C0DD2C282E2700C85A85 /* DefaultLandRepository.swift in Sources */, 7CEB3B4B2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift in Sources */, 7CC42B132F56AB9D00940CE1 /* CallVanListCollectionView.swift in Sources */, @@ -5953,6 +6896,7 @@ 7CA6868D2FFCE867007937E9 /* UpdateNotificationHistoryUseCase.swift in Sources */, 833D9C5C2C7371D900982145 /* FetchNoticeDataRequest.swift in Sources */, 7CED922B2EF2D25900457128 /* DIContainer+Notice.swift in Sources */, + 7CF1D58B30695E7900295ED9 /* RecruitMyPostViewModel.swift in Sources */, 7CED922C2EF2D25900457128 /* DIContainer+Dining.swift in Sources */, 7CED922D2EF2D25900457128 /* DIContainer+Land.swift in Sources */, 7CED922F2EF2D25900457128 /* DIContainer+TimeTable.swift in Sources */, @@ -5977,6 +6921,7 @@ 7CC42B332F58247D00940CE1 /* CallVanNotification.swift in Sources */, B46B8CC22E76CB7300A8E797 /* ShopSummaryDto.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 */, @@ -6003,6 +6948,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 */, @@ -6059,8 +7005,49 @@ 7C05D0782FBC29ED00F4D825 /* ShopBenefitViewModel.swift in Sources */, D29F21F32C4FD23F00994554 /* LandItem.swift in Sources */, 7C7F56B72FFB9FE000847151 /* NotificationHistoryRecord.swift in Sources */, + 7C50A1FD3042E9AB00897A6F /* FetchRecruitListUseCase.swift in Sources */, + BEEF20000000000000000001 /* RecruitMyPostList.swift in Sources */, + BEEF20000000000000000002 /* RecruitMyPostRow.swift in Sources */, + BEEF20000000000000000003 /* RecruitMyPostFilter.swift in Sources */, + BEEF20000000000000000004 /* FetchRecruitMyPostListUseCase.swift in Sources */, + BEEF20000000000000000005 /* RecruitMyPostFilter+.swift in Sources */, + BEEF20000000000000000006 /* RecruitMyPostListViewModel.swift in Sources */, + BEEF20000000000000000007 /* RecruitMyPostListView.swift in Sources */, + BEEF20000000000000000008 /* RecruitMyPostListHostingController.swift in Sources */, + BEEF20000000000000000009 /* RecruitMyPostListHeaderView.swift in Sources */, + BEEF2000000000000000000A /* RecruitMyPostListRowView.swift in Sources */, + BEEF2000000000000000000B /* RecruitMyPostListEmptyView.swift in Sources */, + BEEF2000000000000000000C /* CloseRecruitMyPostUseCase.swift in Sources */, + CAFE20000000000000000001 /* RecruitMyApplicationList.swift in Sources */, + CAFE20000000000000000002 /* RecruitMyApplicationRow.swift in Sources */, + CAFE20000000000000000003 /* RecruitMyApplicationFilter.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 /* RecruitApplicantDecision.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 */, + A11F00000000000000000001 /* RecruitMyPostView.swift in Sources */, + A11F00000000000000000002 /* RecruitMyPostSummaryView.swift in Sources */, + A11F00000000000000000003 /* RecruitMyPostApplicantRowView.swift in Sources */, + A11F00000000000000000004 /* RecruitMyPostEmptyView.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 */, D27DD0E22BA609740081FD36 /* MenuDto.swift in Sources */, D27A5D9C2C6BE56500C4275F /* FetchShopListRequest.swift in Sources */, @@ -6088,6 +7075,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 */, @@ -6110,6 +7098,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 */, @@ -6133,6 +7122,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 */, @@ -6145,6 +7135,8 @@ 7CA63A332F1B1B0C00226F86 /* LostItemListFilterViewController.swift in Sources */, 7CA63A302F1B13F100226F86 /* LostItemStats.swift in Sources */, 7C8A942F2FD2C15400DEA6F5 /* ShopEventCountDto.swift in Sources */, + 7CF1D58F3069772700295ED9 /* RecruitApplicantRow.swift in Sources */, + 7CF1D5913069772700295ED9 /* FetchRecruitMyPostDataUseCase.swift in Sources */, D27DD0CF2BA608310081FD36 /* CollectionViewDelegate.swift in Sources */, D22C545A2C68B033000826DA /* FetchShopReviewRequest.swift in Sources */, 83EBB87E2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift in Sources */, @@ -6175,6 +7167,7 @@ 7CED90C12EF2D1F900457128 /* ShopSummaryMenuGroupCollectionView.swift in Sources */, 7CED90C32EF2D1F900457128 /* ShopSummaryPriceTableViewCell.swift in Sources */, 7CB11C102FB1A5BA004E0C80 /* LostItemKeywordCollectionViewAllCell.swift in Sources */, + 7CF1D58D30695E8A00295ED9 /* RecruitMyPostHostingController.swift in Sources */, 7CED90C62EF2D1F900457128 /* TimetableCollectionViewCell.swift in Sources */, 7C016556300AA2E10013DD7B /* DepartmentCategory.swift in Sources */, 7CED90CA2EF2D1F900457128 /* LandOptionCollectionViewCell.swift in Sources */, @@ -6182,11 +7175,14 @@ 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 */, 7C6403D6300BF12500A9589F /* SearchDepartmentByCategoryUseCase.swift in Sources */, 7CED90D52EF2D1F900457128 /* SelectDeptModalViewController.swift in Sources */, @@ -6194,6 +7190,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 */, @@ -6202,7 +7199,9 @@ 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 */, + 7CDCD0B230556CBA004204DA /* RecruitDataViewModel.swift in Sources */, 7CED90E32EF2D1F900457128 /* FindPhoneIdViewController.swift in Sources */, 7CED90E42EF2D1F900457128 /* SubstituteTimetableModalViewController.swift in Sources */, 7CED90E72EF2D1F900457128 /* ForceUpdateViewController.swift in Sources */, @@ -6250,6 +7249,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 */, @@ -6274,6 +7274,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 */, @@ -6335,6 +7336,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 */, @@ -6356,8 +7358,10 @@ 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 */, 7C457B402FCE1BA80011D338 /* HomeTabBarButton.swift in Sources */, 7C457B422FCE1BA80011D338 /* MobilityView.swift in Sources */, 7C457B442FCE1BA80011D338 /* CallVanRecruitmentButton.swift in Sources */, @@ -6446,6 +7450,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 */, @@ -6484,6 +7489,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 */, @@ -6497,15 +7503,21 @@ 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 */, + 7CDCD0D83055EECF004204DA /* RecruitProfileActivity.swift in Sources */, + 7CDCD0B430556CC2004204DA /* RecruitDataHostingController.swift in Sources */, 7C6790952FCC48A60045E163 /* UIButton+.swift in Sources */, - 7C8A94272FD0A97400DEA6F5 /* NotificationFooterView.swift in Sources */, + 7C50A1D13041F8BF00897A6F /* RecruitState.swift in Sources */, + 7C50A1F43042CB4100897A6F /* RecruitListSort.swift in Sources */, 7C8A94282FD0A97400DEA6F5 /* NotificationTableViewCell.swift in Sources */, 7C8A94292FD0A97400DEA6F5 /* NotificationTableView.swift in Sources */, 7C6790962FCC48A60045E163 /* UIImageView+.swift in Sources */, 7C8A942D2FD2C01A00DEA6F5 /* FetchEventCountUseCase.swift in Sources */, + 7CACF86930436E7700AC2828 /* RecruitNotificationRow+.swift in Sources */, 7C6790972FCC48A60045E163 /* CALayer+.swift in Sources */, 7C6790982FCC48A60045E163 /* UITextFiled+Publisher.swift in Sources */, 7C6790992FCC48A60045E163 /* UIFont+.swift in Sources */, @@ -6526,19 +7538,23 @@ 7C016576300BD9610013DD7B /* FetchDepartmentByCategoryUseCase.swift in Sources */, 7CED91C92EF2D1F900457128 /* CategoryCollectionView.swift in Sources */, 7CED91CB2EF2D1F900457128 /* ShopSummaryImagesCollectionViewCell.swift in Sources */, + 7CE1341A306A2D100011B052 /* RecruitApplicantData.swift in Sources */, 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 */, 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 */, @@ -6546,6 +7562,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 */, @@ -6563,10 +7580,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 */, @@ -6576,6 +7595,8 @@ 7C04A4802FB3380700214992 /* NotiSubscribeRequest.swift in Sources */, 7C04A4812FB3380700214992 /* SendDeviceTokenRequest.swift in Sources */, 7CED92062EF2D1F900457128 /* ShopSearchTableViewShopNameCell.swift in Sources */, + 7C50A1D73041F91400897A6F /* RecruitData.swift in Sources */, + 7C50A1E13041F91400897A6F /* RecruitRow.swift in Sources */, 7CED92072EF2D1F900457128 /* SortTypeBottomSheetViewController.swift in Sources */, 7CED92082EF2D1F900457128 /* DeleteLectureModalViewController.swift in Sources */, 7CED92092EF2D1F900457128 /* AgreementFormViewController.swift in Sources */, @@ -6593,6 +7614,8 @@ 7CED92172EF2D1F900457128 /* DiningOperatingTimeFooterView.swift in Sources */, 7C372EE62F1C0CC400149729 /* LostItemListData.swift in Sources */, 7CED92192EF2D1F900457128 /* ReportLostItemViewModel.swift in Sources */, + 7CE13432306C01BC0011B052 /* RecruitChatMessage.swift in Sources */, + 7CDCD0E2305602F6004204DA /* RecruitProfileActivityRequest.swift in Sources */, 7CED921A2EF2D1F900457128 /* ShopViewController.swift in Sources */, D86E2E152E7D860E006D63AF /* ShuttleBusTimetable.swift in Sources */, 7C01656D300B49F80013DD7B /* DepartmentHostingController.swift in Sources */, @@ -6613,6 +7636,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 */, @@ -6623,6 +7647,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 */, @@ -6636,8 +7661,10 @@ 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 /* RecruitNotificationRow.swift in Sources */, D29F21C12C4DDF5300994554 /* DefaultUserRepository.swift in Sources */, 7C016546300AA14C0013DD7B /* DepartmentCategoryView.swift in Sources */, 7C7BD71D302B2B0A003C5A15 /* AppImage.swift in Sources */, @@ -6645,10 +7672,13 @@ 7CF77B3E300254CD003642C1 /* ProfileViewModel.swift in Sources */, D2FB234F2BFD46DE0098BA2B /* FetchShopListUseCase.swift in Sources */, 7CC42B4B2F59941200940CE1 /* FetchCallVanListUseCase.swift in Sources */, + 7CE1342E306A980D0011B052 /* FetchRecruitTeamChatDataUseCase.swift in Sources */, + A2C130000000000000000001 /* FetchRecruitDirectChatDataUseCase.swift in Sources */, D2CA2DD02C1CAC550036F02C /* ShopData.swift in Sources */, 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 */, @@ -6657,6 +7687,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 */, @@ -6673,7 +7704,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 */, @@ -6691,10 +7722,12 @@ 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 */, 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 */, @@ -6742,10 +7775,12 @@ 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 */, 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 */, @@ -6759,6 +7794,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 */, @@ -6774,6 +7816,7 @@ 7CA63A272F1AF80000226F86 /* LostItemListViewModel.swift in Sources */, D2FB23712C0412C70098BA2B /* EventParameter.swift in Sources */, D2CA2DD22C1CAE4E0036F02C /* ShopEvent.swift in Sources */, + 7CE1341C306A2F930011B052 /* DecideRecruitApplicantUseCase.swift in Sources */, D22C54832C6A59C4000826DA /* UploadFileUseCase.swift in Sources */, D208A7AE2CA117AA007040E7 /* AbTestAPI.swift in Sources */, D208A7CB2CABB755007040E7 /* CoreService.swift in Sources */, @@ -6781,10 +7824,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 */, @@ -6797,7 +7843,9 @@ D29F21CC2C4E157E00994554 /* FetchDeptListUseCase.swift in Sources */, D208A7A62CA111EB007040E7 /* AssignAbTestRequest.swift in Sources */, D223D61B2D9C0FEC00C29BE6 /* FetchBannerUseCase.swift in Sources */, + 7CE13418306A2CBF0011B052 /* FetchRecruitApplicantUseCase.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 */, @@ -6827,6 +7875,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 */, @@ -6836,15 +7885,58 @@ 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 */, 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 */, D249CD2C2C12E32F000813F9 /* DefaultDiningRepository.swift in Sources */, + F7F814BB370C922B6D8AA6EF /* RecruitStepView.swift in Sources */, + 247DF973D072E88901E5C249 /* RecruitProfilePostDepartmentDropdownView.swift in Sources */, + 7CDCD0E6305695E8004204DA /* RecruitSectionHeaderView.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 */, + 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 */, + 3DDE20379881F001713618FC /* RecruitDataDto.swift in Sources */, + 999FC904C4F9ED4E2B829341 /* RecruitApplyBlockReason.swift in Sources */, + 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 */, + BD72DD59230BE11B09BF075F /* RecruitApplyRequestDto.swift in Sources */, + 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 */, + 2C154F14B8028973E780375B /* RecruitChatPostRequestDto.swift in Sources */, + 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; }; @@ -6852,7 +7944,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 */, @@ -7087,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; @@ -7106,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; @@ -7122,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; @@ -7141,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; diff --git a/model b/model new file mode 100644 index 000000000..e69de29bb