From c03a8e68febf8390162927f03182fd5a41ce048e Mon Sep 17 00:00:00 2001 From: JunYoung Date: Wed, 6 May 2026 14:53:17 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat/#330:=20tooltip=20=ED=91=9C=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecommendationMainFactoryImpl.swift | 4 +++- .../RecommendationMainReactor.swift | 23 ++++++++++++++----- .../RecommendationMainViewController.swift | 22 ++++++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainFactoryImpl.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainFactoryImpl.swift index c34af806..7367e8f3 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainFactoryImpl.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainFactoryImpl.swift @@ -6,6 +6,8 @@ public struct RecommendationMainFactoryImpl: RecommendationMainFactory { public init() {} public func make() -> BaseViewController { - return RecommendationMainViewController() + let vc = RecommendationMainViewController() + vc.reactor = RecommendationMainReactor() + return vc } } diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift index 4731fcd6..fe3e33e5 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift @@ -5,11 +5,17 @@ import RxSwift final class RecommendationMainReactor: Reactor { // MARK: - Reactor - enum Action { } + enum Action { + case informationButtonTapped + } - enum Mutation { } + enum Mutation { + case informationButtonToggle + } - struct State { } + struct State { + var informationButtonIsOn: Bool = false + } // MARK: - properties var initialState: State @@ -22,14 +28,19 @@ final class RecommendationMainReactor: Reactor { // MARK: - Reactor Methods func mutate(action: Action) -> Observable { - switch action { } + switch action { + case .informationButtonTapped: + return .just(.informationButtonToggle) + } } func reduce(state: State, mutation: Mutation) -> State { var newState = state - switch mutation { } - + switch mutation { + case .informationButtonToggle: + newState.informationButtonIsOn.toggle() + } return newState } } diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift index 7af69f72..6a6157db 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift @@ -56,9 +56,31 @@ extension RecommendationMainViewController { } func bindUserActions(reactor: Reactor) { + mainView.informationButton.rx.tap + .map { Reactor.Action.informationButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) } func bindViewState(reactor: Reactor) { + reactor.state + .observe(on: MainScheduler.instance) + .map { $0.informationButtonIsOn } + .distinctUntilChanged() + .withUnretained(self) + .subscribe { owner, toolTipIsOn in + if toolTipIsOn { + TooltipFactory.show( + text: "같은 레벨·직업 유저들이 자주 언급한 \n사냥터를 기반으로 추천해요.", + anchorView: owner.mainView.informationButton, + tooltipPosition: .topTrailing + ) + } else { + TooltipFactory.dismiss() + } + } + .disposed(by: disposeBag) + } } From e1b0cccdeaa1efae4d6a472337cfc852e644ffd9 Mon Sep 17 00:00:00 2001 From: JunYoung Date: Wed, 6 May 2026 15:15:25 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix/#330:=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=B6=A9=EB=8F=8C=20=EB=AC=B8=EC=A0=9C=20frame=20+?= =?UTF-8?q?=20snapkit=20=EC=97=90=EC=84=9C=20snapkit=20=EB=8B=A8=EC=9D=BC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Tooltip/TooltipView.swift | 1 + .../Layouts/Factory/TooltipFactory.swift | 14 ++++---------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipView.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipView.swift index 4eddb2ed..89c244eb 100644 --- a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipView.swift +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tooltip/TooltipView.swift @@ -29,6 +29,7 @@ final class TooltipView: UIView { init(text: String, tooltipPosition: TooltipPosition) { self.tooltipPosition = tooltipPosition super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false addViews() setupConstraints() configureUI(text: text) diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/TooltipFactory.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/TooltipFactory.swift index 55c86e10..5f6e56d7 100644 --- a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/TooltipFactory.swift +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Layouts/Factory/TooltipFactory.swift @@ -51,10 +51,6 @@ public extension TooltipFactory { let frame = anchorView.convert(anchorView.bounds, to: window) - tooltip.frame.origin = CGPoint(x: 0, y: 0) - tooltip.setNeedsLayout() - tooltip.layoutIfNeeded() - let tooltipSize = tooltip.systemLayoutSizeFitting( UIView.layoutFittingCompressedSize ) @@ -84,12 +80,10 @@ public extension TooltipFactory { y = frame.maxY + 8 } - tooltip.frame = CGRect( - x: x, - y: y, - width: tooltipSize.width, - height: tooltipSize.height - ) + tooltip.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(x) + make.top.equalToSuperview().offset(y) + } tooltip.alpha = 0 UIView.animate(withDuration: 0.25) { From 5f8f0ab40860a8fcfe9e3dacafb92e537ad7699c Mon Sep 17 00:00:00 2001 From: JunYoung Date: Wed, 6 May 2026 15:29:27 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix/#330:=20tagChip,=20CardList=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MLSDesignSystem/Components/CardList.swift | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift index 16101abf..2c7725ad 100644 --- a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift @@ -47,6 +47,8 @@ public final class CardList: UIView { static let iconSize: CGFloat = 24 static let mapImageSize: CGFloat = 40 static let tagHeight: CGFloat = 24 + static let tagHorizontalInset: CGFloat = 10 + static let tagVerticalInset: CGFloat = 4 } // MARK: - Properties @@ -78,7 +80,7 @@ public final class CardList: UIView { public let imageView = ItemImageView(image: nil, cornerRadius: Constant.imageRadius, inset: Constant.imageInset, backgroundColor: .listMap) private lazy var textLabelStackView: UIStackView = { - let view = UIStackView(arrangedSubviews: [rankTag, mainTextLabel, subTextLabel]) + let view = UIStackView(arrangedSubviews: [rankContainer, mainTextLabel, subTextLabel]) view.axis = .vertical view.spacing = Constant.stackViewSpacing view.alignment = .leading @@ -128,8 +130,21 @@ public final class CardList: UIView { }() private let badge = Badge(style: .currentQuest) + + private let rankContainer = { + let view = UIView() + view.backgroundColor = .primary50 + view.layer.cornerRadius = 12 + view.clipsToBounds = true + return view + }() - private let rankTag = TagChip(style: .text, text: "순위") + private let rankTag = { + let label = UILabel() + label.font = .korFont(style: .semiBold, size: 14) + label.textColor = .primary700 + return label + }() public init() { super.init(frame: .zero) @@ -153,6 +168,7 @@ private extension CardList { addSubview(iconButton) addSubview(dropInfoStack) addSubview(badge) + rankContainer.addSubview(rankTag) } func setupConstraints() { @@ -177,9 +193,14 @@ private extension CardList { make.trailing.equalToSuperview().inset(Constant.cardTrailingInset) } - rankTag.snp.makeConstraints { make in + rankContainer.snp.makeConstraints { make in make.height.equalTo(Constant.tagHeight) } + + rankTag.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.tagHorizontalInset) + make.verticalEdges.equalToSuperview().inset(Constant.tagVerticalInset) + } iconButton.snp.makeConstraints { make in make.centerY.equalToSuperview() @@ -250,24 +271,24 @@ public extension CardList { iconButton.isHidden = true dropInfoStack.isHidden = true badge.isHidden = true - rankTag.isHidden = true + rankContainer.isHidden = true case .detailStackText: iconButton.isHidden = true dropInfoStack.isHidden = false badge.isHidden = true - rankTag.isHidden = true + rankContainer.isHidden = true case .detailStackBadge(let type): iconButton.isHidden = true dropInfoStack.isHidden = false badge.isHidden = false - rankTag.isHidden = true + rankContainer.isHidden = true badge.update(style: type) case .recommended(let rank): iconButton.isHidden = false dropInfoStack.isHidden = true subTextLabel.isHidden = true badge.isHidden = true - rankTag.isHidden = false + rankContainer.isHidden = false rankTag.text = "\(rank)위" default: iconButton.isHidden = false From 0a02142ac995fa7ccc81d5c6c87ddcf60aafaad1 Mon Sep 17 00:00:00 2001 From: JunYoung Date: Wed, 13 May 2026 18:14:49 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat/#330:=20api=20=EC=97=B0=EB=8F=99=20(?= =?UTF-8?q?=EC=B6=94=EC=B2=9C,=20=EB=82=B4=20=EC=A0=95=EB=B3=B4,=20?= =?UTF-8?q?=EC=A7=81=EC=97=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MLS/MLS.xcodeproj/project.pbxproj | 7 ++ .../Data/DTOs/JobDTO.swift | 6 ++ .../Data/DTOs/RecommendationMapDTO.swift | 27 ++++++++ .../Data/DTOs/RecommendationResponseDTO.swift | 6 ++ .../Data/DTOs/UserProfileDTO.swift | 26 ++++++++ .../Endpoints/RecommendationEndPoint.swift | 38 +++++++++++ .../RecommendationRepositoryImpl.swift | 31 +++++++++ .../RecommendationMainFactoryImpl.swift | 19 +++++- .../RecommendationMainReactor.swift | 59 ++++++++++++++++- .../RecommendationMainViewController.swift | 64 +++++++++++++++++-- .../Entities/RecommendationMap.swift | 17 +++++ .../Entities/UserProfile.swift | 13 ++++ .../RecommendationRepository.swift | 7 ++ .../FailingMockRecommendationRepository.swift | 25 ++++++++ .../MLSRecommendationFeatureTesting.swift | 2 - .../MockRecommendationRepository.swift | 31 +++++++++ .../SceneDelegate.swift | 7 +- 17 files changed, 370 insertions(+), 15 deletions(-) create mode 100644 MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/JobDTO.swift create mode 100644 MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/RecommendationMapDTO.swift create mode 100644 MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/RecommendationResponseDTO.swift create mode 100644 MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/UserProfileDTO.swift create mode 100644 MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Endpoints/RecommendationEndPoint.swift create mode 100644 MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Repositories/RecommendationRepositoryImpl.swift create mode 100644 MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Entities/RecommendationMap.swift create mode 100644 MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Entities/UserProfile.swift create mode 100644 MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Repositories/RecommendationRepository.swift create mode 100644 MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureTesting/FailingMockRecommendationRepository.swift delete mode 100644 MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureTesting/MLSRecommendationFeatureTesting.swift create mode 100644 MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureTesting/MockRecommendationRepository.swift diff --git a/MLS/MLS.xcodeproj/project.pbxproj b/MLS/MLS.xcodeproj/project.pbxproj index 95e87f02..ccf38faf 100644 --- a/MLS/MLS.xcodeproj/project.pbxproj +++ b/MLS/MLS.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 085A7F752DAF99570046663F /* .swiftlint.yml in Resources */ = {isa = PBXBuildFile; fileRef = 085A7F742DAF99570046663F /* .swiftlint.yml */; }; 085BDF5E2DF6B6B3009CFB90 /* DataMock.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 085BDF5D2DF6B6B3009CFB90 /* DataMock.framework */; }; 085BDF5F2DF6B6B3009CFB90 /* DataMock.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 085BDF5D2DF6B6B3009CFB90 /* DataMock.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 088636972FB4664E00006D4A /* MLSRecommendationFeatureTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 088636962FB4664E00006D4A /* MLSRecommendationFeatureTesting */; }; 08DA51B42E1B9827009097A6 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 08DA51B32E1B9827009097A6 /* FirebaseFirestore */; }; 08DA51B62E1B9827009097A6 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 08DA51B52E1B9827009097A6 /* FirebaseMessaging */; }; 08DA58A72E1E5BE3009097A6 /* DictionaryFeature.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 08DA58A62E1E5BE3009097A6 /* DictionaryFeature.framework */; }; @@ -282,6 +283,7 @@ files = ( 08F7DC802F9DEA8100EF5C06 /* MLSRecommendationFeature in Frameworks */, 08F7DC822F9DEA8100EF5C06 /* MLSRecommendationFeatureInterface in Frameworks */, + 088636972FB4664E00006D4A /* MLSRecommendationFeatureTesting in Frameworks */, 08F7DC842F9DEA8100EF5C06 /* MLSCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -490,6 +492,7 @@ 08F7DC812F9DEA8100EF5C06 /* MLSRecommendationFeature */, 08F7DC832F9DEA8100EF5C06 /* MLSRecommendationFeatureInterface */, 08F7DC852F9DEA8100EF5C06 /* MLSCore */, + 088636962FB4664E00006D4A /* MLSRecommendationFeatureTesting */, ); productName = MLSRecommendationFeatureExample; productReference = 08F7DC612F9DEA8000EF5C06 /* MLSRecommendationFeatureExample.app */; @@ -1320,6 +1323,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 088636962FB4664E00006D4A /* MLSRecommendationFeatureTesting */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSRecommendationFeatureTesting; + }; 08DA51B32E1B9827009097A6 /* FirebaseFirestore */ = { isa = XCSwiftPackageProductDependency; package = 08DA51B22E1B9827009097A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/JobDTO.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/JobDTO.swift new file mode 100644 index 00000000..5fb8b4cf --- /dev/null +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/JobDTO.swift @@ -0,0 +1,6 @@ +struct JobDTO: Decodable { + let jobId: Int + let jobName: String + let jobLevel: Int + let parentJobId: Int? +} diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/RecommendationMapDTO.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/RecommendationMapDTO.swift new file mode 100644 index 00000000..3e5d1743 --- /dev/null +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/RecommendationMapDTO.swift @@ -0,0 +1,27 @@ +import MLSRecommendationFeatureInterface + +struct RecommendationMapDTO: Decodable { + let mapId: Int + let score: Int + let iconUrl: String + let nameKr: String + let bookmarkId: Int? +} + +extension RecommendationMapDTO { + func toDomain() -> RecommendationMap { + RecommendationMap( + mapId: mapId, + score: score, + iconUrl: iconUrl, + nameKr: nameKr, + bookmarkId: bookmarkId + ) + } +} + +extension Array where Element == RecommendationMapDTO { + func toDomain() -> [RecommendationMap] { + map { $0.toDomain() } + } +} diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/RecommendationResponseDTO.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/RecommendationResponseDTO.swift new file mode 100644 index 00000000..151a5e74 --- /dev/null +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/RecommendationResponseDTO.swift @@ -0,0 +1,6 @@ +struct RecommendationResponseDTO: Decodable { + let success: Bool + let code: String? + let message: String? + let data: [RecommendationMapDTO]? +} diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/UserProfileDTO.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/UserProfileDTO.swift new file mode 100644 index 00000000..c273f8a8 --- /dev/null +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/UserProfileDTO.swift @@ -0,0 +1,26 @@ +import MLSRecommendationFeatureInterface + +struct UserProfileDTO: Decodable { + let id: String + let provider: String + let nickname: String + let fcmToken: String? + let marketingAgreement: Bool? + let noticeAgreement: Bool? + let patchNoteAgreement: Bool? + let eventAgreement: Bool? + let jobId: Int? + let level: Int? + let profileImageUrl: String +} + +extension UserProfileDTO { + func toDomain() -> UserProfile { + UserProfile( + nickname: nickname, + jobId: jobId, + level: level, + profileImageUrl: profileImageUrl + ) + } +} diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Endpoints/RecommendationEndPoint.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Endpoints/RecommendationEndPoint.swift new file mode 100644 index 00000000..f568d491 --- /dev/null +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Endpoints/RecommendationEndPoint.swift @@ -0,0 +1,38 @@ +import MLSCore + +enum RecommendationEndPoint { + static let base = "https://mapleland.2megabytes.me" + + static func fetchRecommendations(level: Int, jobId: Int, limit: Int?) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/maps/recommendations", + method: .GET, + query: FetchQuery(level: level, jobId: jobId, limit: limit) + ) + } + + static func fetchProfile() -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/auth/me", + method: .GET + ) + } + + static func fetchJob(jobId: Int) -> ResponsableEndPoint { + .init( + baseURL: base, + path: "/api/v1/jobs/\(jobId)", + method: .GET + ) + } +} + +private extension RecommendationEndPoint { + struct FetchQuery: Encodable { + let level: Int + let jobId: Int + let limit: Int? + } +} diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Repositories/RecommendationRepositoryImpl.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Repositories/RecommendationRepositoryImpl.swift new file mode 100644 index 00000000..f62e4e14 --- /dev/null +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Repositories/RecommendationRepositoryImpl.swift @@ -0,0 +1,31 @@ +import MLSCore +import MLSRecommendationFeatureInterface +import RxSwift + +final class RecommendationRepositoryImpl: RecommendationRepository { + private let provider: NetworkProvider + private let interceptor: Interceptor? + + init(provider: NetworkProvider, interceptor: Interceptor?) { + self.provider = provider + self.interceptor = interceptor + } + + func fetchProfile() -> Observable { + let endpoint = RecommendationEndPoint.fetchProfile() + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + .map { $0.toDomain() } + } + + func fetchJobName(jobId: Int) -> Observable { + let endpoint = RecommendationEndPoint.fetchJob(jobId: jobId) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + .map { $0.jobName } + } + + func fetchRecommendations(level: Int, jobId: Int, limit: Int?) -> Observable<[RecommendationMap]> { + let endpoint = RecommendationEndPoint.fetchRecommendations(level: level, jobId: jobId, limit: limit) + return provider.requestData(endPoint: endpoint, interceptor: interceptor) + .map { $0.data?.toDomain() ?? [] } + } +} diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainFactoryImpl.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainFactoryImpl.swift index 7367e8f3..11809c0f 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainFactoryImpl.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainFactoryImpl.swift @@ -2,12 +2,27 @@ import MLSCore import MLSRecommendationFeatureInterface public struct RecommendationMainFactoryImpl: RecommendationMainFactory { + private let repository: RecommendationRepository + private let level: Int + private let jobId: Int - public init() {} + public init( + repository: RecommendationRepository, + level: Int, + jobId: Int + ) { + self.repository = repository + self.level = level + self.jobId = jobId + } public func make() -> BaseViewController { let vc = RecommendationMainViewController() - vc.reactor = RecommendationMainReactor() + vc.reactor = RecommendationMainReactor( + repository: repository, + level: level, + jobId: jobId + ) return vc } } diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift index fe3e33e5..3cbe661a 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift @@ -1,3 +1,5 @@ +import MLSRecommendationFeatureInterface + import ReactorKit import RxCocoa import RxSwift @@ -6,29 +8,72 @@ final class RecommendationMainReactor: Reactor { // MARK: - Reactor enum Action { + case viewDidLoad case informationButtonTapped } enum Mutation { + case setProfile(UserProfile) + case setJobName(String) + case setRecommendations([RecommendationMap]) + case setLoading(Bool) case informationButtonToggle } struct State { + var profile: UserProfile? = nil + var jobName: String = "" + var recommendations: [RecommendationMap] = [] + var isLoading: Bool = false var informationButtonIsOn: Bool = false } - // MARK: - properties + // MARK: - Properties var initialState: State var disposeBag = DisposeBag() - // MARK: - init - init() { + private let repository: RecommendationRepository + private let level: Int + private let jobId: Int + + // MARK: - Init + init( + repository: RecommendationRepository, + level: Int, + jobId: Int + ) { + self.repository = repository + self.level = level + self.jobId = jobId self.initialState = State() } // MARK: - Reactor Methods func mutate(action: Action) -> Observable { switch action { + case .viewDidLoad: + let fetchProfile = repository.fetchProfile() + .flatMap { [weak self] profile -> Observable in + guard let self else { return .empty() } + let setProfile = Observable.just(Mutation.setProfile(profile)) + guard let jobId = profile.jobId else { return setProfile } + let setJobName = self.repository.fetchJobName(jobId: jobId) + .map { Mutation.setJobName($0) } + .catch { _ in .empty() } + return Observable.concat([setProfile, setJobName]) + } + .catch { _ in .empty() } + + let fetchRecommendations = repository.fetchRecommendations(level: level, jobId: jobId, limit: 5) + .map { Mutation.setRecommendations($0) } + .catch { _ in .empty() } + + return Observable.concat([ + .just(.setLoading(true)), + Observable.merge(fetchProfile, fetchRecommendations), + .just(.setLoading(false)) + ]) + case .informationButtonTapped: return .just(.informationButtonToggle) } @@ -38,6 +83,14 @@ final class RecommendationMainReactor: Reactor { var newState = state switch mutation { + case .setProfile(let profile): + newState.profile = profile + case .setJobName(let jobName): + newState.jobName = jobName + case .setRecommendations(let maps): + newState.recommendations = maps + case .setLoading(let isLoading): + newState.isLoading = isLoading case .informationButtonToggle: newState.informationButtonIsOn.toggle() } diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift index 6a6157db..8ab1621e 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift @@ -2,6 +2,7 @@ import UIKit import MLSCore import MLSDesignSystem +import MLSRecommendationFeatureInterface import ReactorKit import RxCocoa @@ -42,17 +43,19 @@ private extension RecommendationMainViewController { } func configureUI() { - mainView.profileView.configure(imageURL: nil, nickName: "익명의 판타지", job: "도적", level: 275) mainView.collectionView.delegate = self mainView.collectionView.dataSource = self mainView.collectionView.register(CardListCell.self, forCellWithReuseIdentifier: CardListCell.identifier) } } +// MARK: - Bind extension RecommendationMainViewController { func bind(reactor: Reactor) { bindUserActions(reactor: reactor) bindViewState(reactor: reactor) + + reactor.action.onNext(.viewDidLoad) } func bindUserActions(reactor: Reactor) { @@ -62,6 +65,29 @@ extension RecommendationMainViewController { .disposed(by: disposeBag) } + func bindProfile(reactor: Reactor) { + let profileStream = reactor.state + .observe(on: MainScheduler.instance) + .compactMap { $0.profile } + + let jobNameStream = reactor.state + .observe(on: MainScheduler.instance) + .map { $0.jobName } + + Observable.combineLatest(profileStream, jobNameStream) + .withUnretained(self) + .subscribe { owner, pair in + let (profile, jobName) = pair + owner.mainView.profileView.configure( + imageURL: profile.profileImageUrl, + nickName: profile.nickname, + job: jobName, + level: profile.level ?? 0 + ) + } + .disposed(by: disposeBag) + } + func bindViewState(reactor: Reactor) { reactor.state .observe(on: MainScheduler.instance) @@ -80,22 +106,46 @@ extension RecommendationMainViewController { } } .disposed(by: disposeBag) - + + bindProfile(reactor: reactor) + + reactor.state + .observe(on: MainScheduler.instance) + .map { $0.recommendations } + .distinctUntilChanged { $0.map(\.mapId) == $1.map(\.mapId) } + .withUnretained(self) + .subscribe { owner, _ in + owner.mainView.collectionView.reloadData() + } + .disposed(by: disposeBag) } } +// MARK: - UICollectionViewDelegate, UICollectionViewDataSource extension RecommendationMainViewController: UICollectionViewDelegate, UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return 5 + return reactor?.currentState.recommendations.count ?? 0 } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CardListCell.identifier, for: indexPath) as? CardListCell else { + guard + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CardListCell.identifier, for: indexPath) as? CardListCell, + let map = reactor?.currentState.recommendations[indexPath.item] + else { return UICollectionViewCell() } - cell.cardView.setMainText(text: "최대 줄은 두 줄입니다.\n넘어갈시 말줄임 처리 합니다.") - cell.cardView.setImage(image: UIImage(systemName: "person")!, backgroundColor: .green) - cell.cardView.setType(type: .recommended(rank: 1)) + + cell.cardView.setMainText(text: map.nameKr) + cell.cardView.setType(type: .recommended(rank: map.score)) + cell.cardView.isIconSelected = map.isBookmarked + + ImageLoader.shared.loadImage(stringURL: map.iconUrl) { image in + guard let image else { return } + DispatchQueue.main.async { + cell.cardView.setImage(image: image, backgroundColor: .clear) + } + } + return cell } } diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Entities/RecommendationMap.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Entities/RecommendationMap.swift new file mode 100644 index 00000000..a566ea83 --- /dev/null +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Entities/RecommendationMap.swift @@ -0,0 +1,17 @@ +public struct RecommendationMap { + public let mapId: Int + public let score: Int + public let iconUrl: String + public let nameKr: String + public let bookmarkId: Int? + + public var isBookmarked: Bool { bookmarkId != nil } + + public init(mapId: Int, score: Int, iconUrl: String, nameKr: String, bookmarkId: Int?) { + self.mapId = mapId + self.score = score + self.iconUrl = iconUrl + self.nameKr = nameKr + self.bookmarkId = bookmarkId + } +} diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Entities/UserProfile.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Entities/UserProfile.swift new file mode 100644 index 00000000..8e8e0c1b --- /dev/null +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Entities/UserProfile.swift @@ -0,0 +1,13 @@ +public struct UserProfile { + public let nickname: String + public let jobId: Int? + public let level: Int? + public let profileImageUrl: String + + public init(nickname: String, jobId: Int?, level: Int?, profileImageUrl: String) { + self.nickname = nickname + self.jobId = jobId + self.level = level + self.profileImageUrl = profileImageUrl + } +} diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Repositories/RecommendationRepository.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Repositories/RecommendationRepository.swift new file mode 100644 index 00000000..58c67792 --- /dev/null +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Repositories/RecommendationRepository.swift @@ -0,0 +1,7 @@ +import RxSwift + +public protocol RecommendationRepository { + func fetchProfile() -> Observable + func fetchJobName(jobId: Int) -> Observable + func fetchRecommendations(level: Int, jobId: Int, limit: Int?) -> Observable<[RecommendationMap]> +} diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureTesting/FailingMockRecommendationRepository.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureTesting/FailingMockRecommendationRepository.swift new file mode 100644 index 00000000..01b83327 --- /dev/null +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureTesting/FailingMockRecommendationRepository.swift @@ -0,0 +1,25 @@ +import MLSRecommendationFeatureInterface + +import RxSwift + +/// 모든 요청이 항상 실패하는 Mock +public final class FailingMockRecommendationRepository: RecommendationRepository { + + public init() {} + + public func fetchProfile() -> Observable { + return .error(RecommendationRepositoryError.fetchFailed) + } + + public func fetchJobName(jobId: Int) -> Observable { + return .error(RecommendationRepositoryError.fetchFailed) + } + + public func fetchRecommendations(level: Int, jobId: Int, limit: Int?) -> Observable<[RecommendationMap]> { + return .error(RecommendationRepositoryError.fetchFailed) + } +} + +public enum RecommendationRepositoryError: Error { + case fetchFailed +} diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureTesting/MLSRecommendationFeatureTesting.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureTesting/MLSRecommendationFeatureTesting.swift deleted file mode 100644 index 0d4fa4b7..00000000 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureTesting/MLSRecommendationFeatureTesting.swift +++ /dev/null @@ -1,2 +0,0 @@ -// MLSRecommendationFeatureTesting -// 단위 테스트 및 Example 앱에서 사용할 Mock 객체를 제공합니다. diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureTesting/MockRecommendationRepository.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureTesting/MockRecommendationRepository.swift new file mode 100644 index 00000000..dca1d630 --- /dev/null +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureTesting/MockRecommendationRepository.swift @@ -0,0 +1,31 @@ +import MLSRecommendationFeatureInterface + +import RxSwift + +public final class MockRecommendationRepository: RecommendationRepository { + + public init() {} + + public func fetchProfile() -> Observable { + return .just(UserProfile( + nickname: "익명의 판타지", + jobId: 4, + level: 275, + profileImageUrl: "" + )) + } + + public func fetchJobName(jobId: Int) -> Observable { + return .just("도적") + } + + public func fetchRecommendations(level: Int, jobId: Int, limit: Int?) -> Observable<[RecommendationMap]> { + return .just([ + RecommendationMap(mapId: 100000000, score: 10, iconUrl: "", nameKr: "헤네시스", bookmarkId: nil), + RecommendationMap(mapId: 100000001, score: 9, iconUrl: "", nameKr: "엘리니아", bookmarkId: 1), + RecommendationMap(mapId: 100000002, score: 8, iconUrl: "", nameKr: "페리온", bookmarkId: nil), + RecommendationMap(mapId: 100000003, score: 7, iconUrl: "", nameKr: "슬리피우드", bookmarkId: 2), + RecommendationMap(mapId: 100000004, score: 6, iconUrl: "", nameKr: "노틸러스 항구", bookmarkId: nil) + ]) + } +} diff --git a/MLS/MLSRecommendationFeatureExample/SceneDelegate.swift b/MLS/MLSRecommendationFeatureExample/SceneDelegate.swift index 64e1cd19..b6c35d4c 100644 --- a/MLS/MLSRecommendationFeatureExample/SceneDelegate.swift +++ b/MLS/MLSRecommendationFeatureExample/SceneDelegate.swift @@ -3,6 +3,7 @@ import UIKit import MLSCore import MLSRecommendationFeature import MLSRecommendationFeatureInterface +import MLSRecommendationFeatureTesting class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -28,7 +29,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func registerDependencies() { DIContainer.register(type: RecommendationMainFactory.self) { - RecommendationMainFactoryImpl() + RecommendationMainFactoryImpl( + repository: MockRecommendationRepository(), + level: 100, + jobId: 100 + ) } } } From dd09800af8ebfc0268b0ae5eeb14ac599ce70a2d Mon Sep 17 00:00:00 2001 From: JunYoung Date: Wed, 13 May 2026 23:46:38 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat/#330:=20=EC=B6=94=EC=B2=9C=20=ED=83=AD?= =?UTF-8?q?=20=EB=B3=B8=EC=95=B1=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppCoordinator, AppDelegate에 RecommendationFeature 등록 - MLSCore에 TokenInterceptor 추가 (키체인 직접 접근) - RecommendationRepositoryImpl 내부에서 NetworkProvider, Interceptor 생성 - UserProfileDTO, JobDTO 응답 래퍼 구조 반영 - RecommendationEndPoint 쿼리 파라미터 Int32 타입 적용 - viewWillAppear마다 데이터 갱신 - 추천 화면 탭바 영역 레이아웃 수정 - MLSCore NetworkProviderImpl 로깅 Loggable 적용 --- MLS/MLS.xcodeproj/project.pbxproj | 273 +++++++++--------- MLS/MLS/Application/AppCoordinator.swift | 24 +- MLS/MLS/Application/AppDelegate.swift | 13 + .../MLSCore/Network/NetworkProviderImpl.swift | 35 +-- .../MLSCore/Network/TokenInterceptor.swift | 32 ++ .../Tabbar/BottomTabBarController.swift | 7 +- .../Data/DTOs/JobDTO.swift | 7 + .../Data/DTOs/RecommendationMapDTO.swift | 2 +- .../Data/DTOs/UserProfileDTO.swift | 19 +- .../Endpoints/RecommendationEndPoint.swift | 12 +- .../RecommendationRepositoryImpl.swift | 18 +- .../RecommendationMainFactoryImpl.swift | 16 +- .../RecommendationMainReactor.swift | 44 +-- .../RecommendationMainViewController.swift | 11 +- .../Views/RecommendationMainView.swift | 4 +- .../Entities/RecommendationMap.swift | 4 +- .../SceneDelegate.swift | 4 +- .../Components/Tabbar/BottomTabBar.swift | 9 +- .../Tabbar/BottomTabBarController.swift | 7 +- 19 files changed, 305 insertions(+), 236 deletions(-) create mode 100644 MLS/MLSCore/Sources/MLSCore/Network/TokenInterceptor.swift diff --git a/MLS/MLS.xcodeproj/project.pbxproj b/MLS/MLS.xcodeproj/project.pbxproj index 814700ff..6c989cc7 100644 --- a/MLS/MLS.xcodeproj/project.pbxproj +++ b/MLS/MLS.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ 084A25562DB93BC800C395C0 /* Data.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 084A25542DB93BC800C395C0 /* Data.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 084A25D72DB93E2C00C395C0 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 084A25D62DB93E2C00C395C0 /* Core.framework */; }; 084A25D82DB93E2C00C395C0 /* Core.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 084A25D62DB93E2C00C395C0 /* Core.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 085598AC2FB4A6B7003F315F /* MLSRecommendationFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 085598AB2FB4A6B7003F315F /* MLSRecommendationFeature */; }; + 085598AE2FB4A6BD003F315F /* MLSRecommendationFeatureInterface in Frameworks */ = {isa = PBXBuildFile; productRef = 085598AD2FB4A6BD003F315F /* MLSRecommendationFeatureInterface */; }; 0858ABFC2DCFDBF20060EBCA /* DesignSystem.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0858ABFB2DCFDBF20060EBCA /* DesignSystem.framework */; }; 0858ABFD2DCFDBF20060EBCA /* DesignSystem.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0858ABFB2DCFDBF20060EBCA /* DesignSystem.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 085A7F752DAF99570046663F /* .swiftlint.yml in Resources */ = {isa = PBXBuildFile; fileRef = 085A7F742DAF99570046663F /* .swiftlint.yml */; }; @@ -147,8 +149,8 @@ 08DA58A62E1E5BE3009097A6 /* DictionaryFeature.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = DictionaryFeature.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 08DA58A92E1E5BEB009097A6 /* DictionaryFeatureInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = DictionaryFeatureInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 08F7A9232F86745C00EF5C06 /* MLSAuthFeatureExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLSAuthFeatureExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 77217FBE2F9A04CF000915EF /* MLSMyPageFeatureExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLSMyPageFeatureExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 08F7DC612F9DEA8000EF5C06 /* MLSRecommendationFeatureExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLSRecommendationFeatureExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 77217FBE2F9A04CF000915EF /* MLSMyPageFeatureExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MLSMyPageFeatureExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 772199F12E0E7EC800A7B58C /* AuthFeatureInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AuthFeatureInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7721A5032E0EE7AE00A7B58C /* BaseFeature.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = BaseFeature.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 77660AD12DD0D361007A4EF3 /* KakaoConfig.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = KakaoConfig.xcconfig; sourceTree = ""; }; @@ -180,20 +182,19 @@ ); target = 08F7A9222F86745C00EF5C06 /* MLSAuthFeatureExample */; }; - 77217FCF2F9A04D0000915EF /* Exceptions for "MLSMyPageFeatureExample" folder in "MLSMyPageFeatureExample" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = 77217FBD2F9A04CF000915EF /* MLSMyPageFeatureExample */; - }; - - 08F7DC722F9DEA8100EF5C06 /* Exceptions for "MLSRecommendationFeatureExample" folder in "MLSRecommendationFeatureExample" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = 08F7DC602F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */; + 08F7DC722F9DEA8100EF5C06 /* Exceptions for "MLSRecommendationFeatureExample" folder in "MLSRecommendationFeatureExample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 08F7DC602F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */; + }; + 77217FCF2F9A04D0000915EF /* Exceptions for "MLSMyPageFeatureExample" folder in "MLSMyPageFeatureExample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 77217FBD2F9A04CF000915EF /* MLSMyPageFeatureExample */; }; 77FA688B2F72C7380064B6EB /* Exceptions for "MLSDesignSystemExample" folder in "MLSDesignSystemExample" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; @@ -221,14 +222,6 @@ path = MLSAuthFeatureExample; sourceTree = ""; }; - 77217FBF2F9A04CF000915EF /* MLSMyPageFeatureExample */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - 77217FCF2F9A04D0000915EF /* Exceptions for "MLSMyPageFeatureExample" folder in "MLSMyPageFeatureExample" target */, - ); - path = MLSMyPageFeatureExample; - sourceTree = ""; - }; 08F7DC622F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -237,6 +230,14 @@ path = MLSRecommendationFeatureExample; sourceTree = ""; }; + 77217FBF2F9A04CF000915EF /* MLSMyPageFeatureExample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 77217FCF2F9A04D0000915EF /* Exceptions for "MLSMyPageFeatureExample" folder in "MLSMyPageFeatureExample" target */, + ); + path = MLSMyPageFeatureExample; + sourceTree = ""; + }; 77BEB0412DBA84B0002FFCFC /* MLSTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = MLSTests; @@ -271,6 +272,7 @@ 08DA51B42E1B9827009097A6 /* FirebaseFirestore in Frameworks */, 7777F7042E9EAB8400F53D68 /* BookmarkFeatureInterface.framework in Frameworks */, 084A25552DB93BC800C395C0 /* Data.framework in Frameworks */, + 085598AC2FB4A6B7003F315F /* MLSRecommendationFeature in Frameworks */, 084A25D72DB93E2C00C395C0 /* Core.framework in Frameworks */, 08ED49282DCFDED4002C21A2 /* RxCocoa in Frameworks */, 7777F7082E9EAC0D00F53D68 /* MyPageFeature.framework in Frameworks */, @@ -279,6 +281,7 @@ 77660AD52DD0D3DD007A4EF3 /* KakaoSDKAuth in Frameworks */, 772199F22E0E7EC800A7B58C /* AuthFeatureInterface.framework in Frameworks */, 779A49102E1AD26D00ABDE4F /* BookmarkFeatureInterface.framework in Frameworks */, + 085598AE2FB4A6BD003F315F /* MLSRecommendationFeatureInterface in Frameworks */, 084A25522DB93BC500C395C0 /* Domain.framework in Frameworks */, 77660AD72DD0D3DD007A4EF3 /* KakaoSDKUser in Frameworks */, 085BDF5E2DF6B6B3009CFB90 /* DataMock.framework in Frameworks */, @@ -298,17 +301,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 77217FBB2F9A04CF000915EF /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 77217FD42F9A05D7000915EF /* MLSMyPageFeature in Frameworks */, - 77DE6DEF2F9F9EA1007FD8AC /* MLSAuthFeatureTesting in Frameworks */, - 773681502F9A2CE3002DC773 /* MLSMyPageFeatureTesting in Frameworks */, - 7736814E2F9A2CE3002DC773 /* MLSMyPageFeatureInterface in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 08F7DC5E2F9DEA8000EF5C06 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -320,6 +312,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 77217FBB2F9A04CF000915EF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 77217FD42F9A05D7000915EF /* MLSMyPageFeature in Frameworks */, + 77DE6DEF2F9F9EA1007FD8AC /* MLSAuthFeatureTesting in Frameworks */, + 773681502F9A2CE3002DC773 /* MLSMyPageFeatureTesting in Frameworks */, + 7736814E2F9A2CE3002DC773 /* MLSMyPageFeatureInterface in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 77BEB03D2DBA84B0002FFCFC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -476,6 +479,8 @@ 08DA51B52E1B9827009097A6 /* FirebaseMessaging */, 770ADB1E2E433EDA00270506 /* RxKeyboard */, 77B1F9942EE06A4E00AE4B4D /* RxGesture */, + 085598AB2FB4A6B7003F315F /* MLSRecommendationFeature */, + 085598AD2FB4A6BD003F315F /* MLSRecommendationFeatureInterface */, ); productName = MLS; productReference = 087D3EE82DA7972C002F924D /* MLS.app */; @@ -506,32 +511,6 @@ productReference = 08F7A9232F86745C00EF5C06 /* MLSAuthFeatureExample.app */; productType = "com.apple.product-type.application"; }; - 77217FBD2F9A04CF000915EF /* MLSMyPageFeatureExample */ = { - isa = PBXNativeTarget; - buildConfigurationList = 77217FD02F9A04D0000915EF /* Build configuration list for PBXNativeTarget "MLSMyPageFeatureExample" */; - buildPhases = ( - 77217FBA2F9A04CF000915EF /* Sources */, - 77217FBB2F9A04CF000915EF /* Frameworks */, - 77217FBC2F9A04CF000915EF /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 77217FBF2F9A04CF000915EF /* MLSMyPageFeatureExample */, - ); - name = MLSMyPageFeatureExample; - packageProductDependencies = ( - 77217FD32F9A05D7000915EF /* MLSMyPageFeature */, - 7736814D2F9A2CE3002DC773 /* MLSMyPageFeatureInterface */, - 7736814F2F9A2CE3002DC773 /* MLSMyPageFeatureTesting */, - 77DE6DEE2F9F9EA1007FD8AC /* MLSAuthFeatureTesting */, - ); - productName = MLSMyPageFeatureExample; - productReference = 77217FBE2F9A04CF000915EF /* MLSMyPageFeatureExample.app */; - productType = "com.apple.product-type.application"; - }; 08F7DC602F9DEA8000EF5C06 /* MLSRecommendationFeatureExample */ = { isa = PBXNativeTarget; buildConfigurationList = 08F7DC732F9DEA8100EF5C06 /* Build configuration list for PBXNativeTarget "MLSRecommendationFeatureExample" */; @@ -558,6 +537,32 @@ productReference = 08F7DC612F9DEA8000EF5C06 /* MLSRecommendationFeatureExample.app */; productType = "com.apple.product-type.application"; }; + 77217FBD2F9A04CF000915EF /* MLSMyPageFeatureExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 77217FD02F9A04D0000915EF /* Build configuration list for PBXNativeTarget "MLSMyPageFeatureExample" */; + buildPhases = ( + 77217FBA2F9A04CF000915EF /* Sources */, + 77217FBB2F9A04CF000915EF /* Frameworks */, + 77217FBC2F9A04CF000915EF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 77217FBF2F9A04CF000915EF /* MLSMyPageFeatureExample */, + ); + name = MLSMyPageFeatureExample; + packageProductDependencies = ( + 77217FD32F9A05D7000915EF /* MLSMyPageFeature */, + 7736814D2F9A2CE3002DC773 /* MLSMyPageFeatureInterface */, + 7736814F2F9A2CE3002DC773 /* MLSMyPageFeatureTesting */, + 77DE6DEE2F9F9EA1007FD8AC /* MLSAuthFeatureTesting */, + ); + productName = MLSMyPageFeatureExample; + productReference = 77217FBE2F9A04CF000915EF /* MLSMyPageFeatureExample.app */; + productType = "com.apple.product-type.application"; + }; 77BEB03F2DBA84B0002FFCFC /* MLSTests */ = { isa = PBXNativeTarget; buildConfigurationList = 77BEB0482DBA84B0002FFCFC /* Build configuration list for PBXNativeTarget "MLSTests" */; @@ -624,12 +629,12 @@ 08F7A9222F86745C00EF5C06 = { CreatedOnToolsVersion = 26.1.1; }; - 77217FBD2F9A04CF000915EF = { - CreatedOnToolsVersion = 26.1.1; - }; 08F7DC602F9DEA8000EF5C06 = { CreatedOnToolsVersion = 26.1.1; }; + 77217FBD2F9A04CF000915EF = { + CreatedOnToolsVersion = 26.1.1; + }; 77BEB03F2DBA84B0002FFCFC = { CreatedOnToolsVersion = 16.2; TestTargetID = 087D3EE72DA7972C002F924D; @@ -716,13 +721,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 77217FBC2F9A04CF000915EF /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 08F7DC5F2F9DEA8000EF5C06 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -730,6 +728,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 77217FBC2F9A04CF000915EF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 77BEB03E2DBA84B0002FFCFC /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -782,13 +787,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 77217FBA2F9A04CF000915EF /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 08F7DC5D2F9DEA8000EF5C06 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -796,6 +794,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 77217FBA2F9A04CF000915EF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 77BEB03C2DBA84B0002FFCFC /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1098,151 +1103,151 @@ }; name = Release; }; - 77217FD12F9A04D0000915EF /* Debug */ = { + 08F7DC742F9DEA8100EF5C06 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = W5QTRMS954; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = MLSMyPageFeatureExample/Info.plist; + INFOPLIST_FILE = MLSRecommendationFeatureExample/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 26.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLSDesignSystemExample.MLSMyPageFeatureExample; + PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLS.RecommendationFeatureExample; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = MLSRecommendationFeatureProvisioningProfileDev; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; - 77217FD22F9A04D0000915EF /* Release */ = { + 08F7DC752F9DEA8100EF5C06 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = W5QTRMS954; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = MLSMyPageFeatureExample/Info.plist; + INFOPLIST_FILE = MLSRecommendationFeatureExample/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 26.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLSDesignSystemExample.MLSMyPageFeatureExample; + PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLS.RecommendationFeatureExample; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = MLSRecommendationFeatureProvisioningProfile; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; - 08F7DC742F9DEA8100EF5C06 /* Debug */ = { + 77217FD12F9A04D0000915EF /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = W5QTRMS954; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = MLSRecommendationFeatureExample/Info.plist; + INFOPLIST_FILE = MLSMyPageFeatureExample/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.1; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLS.RecommendationFeatureExample; + PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLSDesignSystemExample.MLSMyPageFeatureExample; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = MLSRecommendationFeatureProvisioningProfileDev; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; - 08F7DC752F9DEA8100EF5C06 /* Release */ = { + 77217FD22F9A04D0000915EF /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = W5QTRMS954; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = MLSRecommendationFeatureExample/Info.plist; + INFOPLIST_FILE = MLSMyPageFeatureExample/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.1; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLS.RecommendationFeatureExample; + PRODUCT_BUNDLE_IDENTIFIER = com.donggle.MLSDesignSystemExample.MLSMyPageFeatureExample; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = MLSRecommendationFeatureProvisioningProfile; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; @@ -1390,15 +1395,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 77217FD02F9A04D0000915EF /* Build configuration list for PBXNativeTarget "MLSMyPageFeatureExample" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 77217FD12F9A04D0000915EF /* Debug */, - 77217FD22F9A04D0000915EF /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 08F7DC732F9DEA8100EF5C06 /* Build configuration list for PBXNativeTarget "MLSRecommendationFeatureExample" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1408,6 +1404,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 77217FD02F9A04D0000915EF /* Build configuration list for PBXNativeTarget "MLSMyPageFeatureExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 77217FD12F9A04D0000915EF /* Debug */, + 77217FD22F9A04D0000915EF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 77BEB0482DBA84B0002FFCFC /* Build configuration list for PBXNativeTarget "MLSTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1488,6 +1493,14 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 085598AB2FB4A6B7003F315F /* MLSRecommendationFeature */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSRecommendationFeature; + }; + 085598AD2FB4A6BD003F315F /* MLSRecommendationFeatureInterface */ = { + isa = XCSwiftPackageProductDependency; + productName = MLSRecommendationFeatureInterface; + }; 088636962FB4664E00006D4A /* MLSRecommendationFeatureTesting */ = { isa = XCSwiftPackageProductDependency; productName = MLSRecommendationFeatureTesting; diff --git a/MLS/MLS/Application/AppCoordinator.swift b/MLS/MLS/Application/AppCoordinator.swift index 947e4dea..03f1764b 100644 --- a/MLS/MLS/Application/AppCoordinator.swift +++ b/MLS/MLS/Application/AppCoordinator.swift @@ -6,6 +6,7 @@ import BaseFeature import BookmarkFeatureInterface import DesignSystem import DictionaryFeatureInterface +import MLSRecommendationFeatureInterface import MyPageFeatureInterface import RxSwift @@ -13,6 +14,7 @@ import RxSwift public final class AppCoordinator: AppCoordinatorProtocol { // MARK: - Properties public var window: UIWindow? + private let recommendationMainFactory: RecommendationMainFactory private let dictionaryMainViewFactory: DictionaryMainViewFactory private let bookmarkMainFactory: BookmarkMainFactory private let myPageMainFactory: MyPageMainFactory @@ -23,12 +25,14 @@ public final class AppCoordinator: AppCoordinatorProtocol { // MARK: - Init public init( window: UIWindow?, + recommendationMainFactory: RecommendationMainFactory, dictionaryMainViewFactory: DictionaryMainViewFactory, bookmarkMainFactory: BookmarkMainFactory, myPageMainFactory: MyPageMainFactory, loginFactory: LoginFactory ) { self.window = window + self.recommendationMainFactory = recommendationMainFactory self.dictionaryMainViewFactory = dictionaryMainViewFactory self.bookmarkMainFactory = bookmarkMainFactory self.myPageMainFactory = myPageMainFactory @@ -37,11 +41,21 @@ public final class AppCoordinator: AppCoordinatorProtocol { // MARK: - Public Methods public func showMainTab() { - let tabBar = BottomTabBarController(viewControllers: [ - dictionaryMainViewFactory.make(), - bookmarkMainFactory.make(), - myPageMainFactory.make() - ]) + let tabItems: [TabItem] = [ + TabItem(title: "추천", icon: UIImage(systemName: "star.fill") ?? UIImage()), + TabItem(title: "도감", icon: DesignSystemAsset.image(named: "dictionary") ?? UIImage()), + TabItem(title: "북마크", icon: DesignSystemAsset.image(named: "bookmarkList") ?? UIImage()), + TabItem(title: "MY", icon: DesignSystemAsset.image(named: "mypage") ?? UIImage()) + ] + let tabBar = BottomTabBarController( + viewControllers: [ + recommendationMainFactory.make(), + dictionaryMainViewFactory.make(), + bookmarkMainFactory.make(), + myPageMainFactory.make() + ], + tabItems: tabItems + ) let navigationController = UINavigationController(rootViewController: tabBar) navigationController.isNavigationBarHidden = true diff --git a/MLS/MLS/Application/AppDelegate.swift b/MLS/MLS/Application/AppDelegate.swift index 909c3fae..ed7678b5 100644 --- a/MLS/MLS/Application/AppDelegate.swift +++ b/MLS/MLS/Application/AppDelegate.swift @@ -16,6 +16,8 @@ import Domain import DomainInterface import Firebase import KakaoSDKCommon +import MLSRecommendationFeature +import MLSRecommendationFeatureInterface import MyPageFeature import MyPageFeatureInterface import os @@ -127,6 +129,9 @@ private extension AppDelegate { DIContainer.register(type: AppCoordinatorProtocol.self) { AppCoordinator( window: nil, + recommendationMainFactory: DIContainer.resolve( + type: RecommendationMainFactory.self + ), dictionaryMainViewFactory: DIContainer.resolve( type: DictionaryMainViewFactory.self ), @@ -214,6 +219,9 @@ private extension AppDelegate { tokenInterceptor: DIContainer.resolve(type: Interceptor.self, name: "tokenInterceptor") ) } + DIContainer.register(type: RecommendationRepository.self) { + RecommendationRepositoryImpl() + } } func registerUseCase() { @@ -1248,5 +1256,10 @@ private extension AppDelegate { DIContainer.register(type: PolicyFactory.self) { PolicyFactoryImpl() } + DIContainer.register(type: RecommendationMainFactory.self) { + RecommendationMainFactoryImpl( + repository: DIContainer.resolve(type: RecommendationRepository.self) + ) + } } } diff --git a/MLS/MLSCore/Sources/MLSCore/Network/NetworkProviderImpl.swift b/MLS/MLSCore/Sources/MLSCore/Network/NetworkProviderImpl.swift index 38b7be28..fadff14a 100644 --- a/MLS/MLSCore/Sources/MLSCore/Network/NetworkProviderImpl.swift +++ b/MLS/MLSCore/Sources/MLSCore/Network/NetworkProviderImpl.swift @@ -2,7 +2,7 @@ import Foundation import RxSwift -public final class NetworkProviderImpl: NetworkProvider { +public final class NetworkProviderImpl: NetworkProvider, Loggable { private let session: URLSession @@ -17,32 +17,32 @@ public final class NetworkProviderImpl: NetworkProvider { public func requestData(endPoint: T, interceptor: Interceptor?) -> Observable { return Observable.create { [weak self] observer in - print("🚀 requestData: 요청 시작 - \(endPoint)") + self?.logDebug("Core requestData: 요청 시작 - \(endPoint)") self?.sendRequest(endPoint: endPoint, interceptor: interceptor, completion: { result in switch result { case .success(let data): - print("✅ requestData: 응답 수신") + self?.logDebug("Core requestData: 응답 수신") if let data = data { - print("📦 requestData: 응답 데이터 있음 - \(String(data: data, encoding: .utf8) ?? "디코딩 실패")") + self?.logDebug("Core requestData: 응답 데이터 있음 - \(String(data: data, encoding: .utf8) ?? "디코딩 실패")") do { let decoded = try JSONDecoder().decode(T.Response.self, from: data) - print("🎯 requestData: 디코딩 성공 - \(decoded)") + self?.logDebug("Core requestData: 디코딩 성공 - \(decoded)") observer.onNext(decoded) observer.onCompleted() } catch { - print("❌ requestData: 디코딩 실패 - \(error)") + self?.logError("Core requestData: 디코딩 실패 - \(error)") observer.onError(NetworkError.decodeError(error)) } } else { - print("⚠️ requestData: 응답 데이터 없음") + self?.logWarning("Core requestData: 응답 데이터 없음") observer.onError(NetworkError.noData) } case .failure(let error): - print("🔥 requestData: 네트워크 실패 - \(error)") + self?.logError("🔥 requestData: 네트워크 실패 - \(error)") observer.onError(error) } }) @@ -53,7 +53,7 @@ public final class NetworkProviderImpl: NetworkProvider { errors .enumerated() .flatMap { attempt, error -> Observable in - print("🔁 requestData: 재시도 \(attempt + 1)회 - 에러: \(error)") + self.logWarning("🔁 requestData: 재시도 \(attempt + 1)회 - 에러: \(error)") if attempt < self.retryAttempt, let networkError = error as? NetworkError, networkError == .retry { return Observable.just(()) } else { @@ -94,10 +94,6 @@ public final class NetworkProviderImpl: NetworkProvider { } private extension NetworkProviderImpl { - /// 엔드 포인트를 이용하여 요청을 보내기 위한 함수 - /// - Parameters: - /// - endPoint: 요청을 위한 엔드포인트 객체 - /// - completion: 응답 결과 func sendRequest(endPoint: T, interceptor: Interceptor?, completion: @escaping (Result) -> Void) { do { var request = try endPoint.getUrlRequest() @@ -113,7 +109,7 @@ private extension NetworkProviderImpl { completion(.success(data)) case .failure(let error): completion(.failure(error)) - print("API 통신에러 \(error)") + logError("API 통신에러 \(error)") } } task.resume() @@ -122,12 +118,6 @@ private extension NetworkProviderImpl { } } - /// 통신간의 유효성 검사를 위한 함수 - /// - Parameters: - /// - data: 통신 결과로 돌려받은 데이터 - /// - response: 상태코드를 포함한 통신 응답 - /// - error: 통신간에 발생한 에러 - /// - Returns: 유효성검사 결과에 따른 데이터와 에러 func checkValidation( data: Data?, response: URLResponse?, @@ -135,7 +125,6 @@ private extension NetworkProviderImpl { interceptor: Interceptor? ) -> Result { - // 1️⃣ 네트워크 레벨 에러 먼저 체크 if let error { if let urlError = error as? URLError, urlError.code == .unsupportedURL { return .failure(.urlRequest(error)) @@ -143,14 +132,11 @@ private extension NetworkProviderImpl { return .failure(.network(error)) } - // 2️⃣ HTTP 응답 객체 확인 guard let httpResponse = response as? HTTPURLResponse else { return .failure(.httpError) } - // 3️⃣ 상태 코드 기반 검사 guard (200 ... 299).contains(httpResponse.statusCode) else { - // ❗️여기서만 인터셉터 개입 if let interceptor = interceptor, interceptor.retry(data: data, response: response, error: error) { return .failure(.retry) @@ -160,7 +146,6 @@ private extension NetworkProviderImpl { return .failure(.statusError(httpResponse.statusCode, errorMessage)) } - // ✅ 성공 응답 return .success(data) } } diff --git a/MLS/MLSCore/Sources/MLSCore/Network/TokenInterceptor.swift b/MLS/MLSCore/Sources/MLSCore/Network/TokenInterceptor.swift new file mode 100644 index 00000000..9df5df11 --- /dev/null +++ b/MLS/MLSCore/Sources/MLSCore/Network/TokenInterceptor.swift @@ -0,0 +1,32 @@ +import Foundation +import Security + +public final class TokenInterceptor: Interceptor { + + public init() {} + + public func adapt(_ request: URLRequest) -> URLRequest { + guard let token = fetchAccessToken() else { return request } + var adapted = request + adapted.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return adapted + } + + public func retry(data: Data?, response: URLResponse?, error: Error?) -> Bool { + return false + } + + private func fetchAccessToken() -> String? { + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: "keyChain", + kSecAttrAccount: "accessToken", + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne + ] + var ref: AnyObject? + guard SecItemCopyMatching(query, &ref) == errSecSuccess, + let data = ref as? Data else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tabbar/BottomTabBarController.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tabbar/BottomTabBarController.swift index 0a3ebd03..c2cea63a 100644 --- a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tabbar/BottomTabBarController.swift +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/Tabbar/BottomTabBarController.swift @@ -14,13 +14,14 @@ public final class BottomTabBarController: UITabBarController { private let customTabBar: BottomTabBar // MARK: - Init - public init(viewControllers: [UIViewController], initialIndex: Int = 0) { - tabItems = [ + public init(viewControllers: [UIViewController], tabItems: [TabItem]? = nil, initialIndex: Int = 0) { + let resolvedItems = tabItems ?? [ TabItem(title: "도감", icon: DesignSystemAsset.image(named: "dictionary")), TabItem(title: "북마크", icon: DesignSystemAsset.image(named: "bookmarkList")), TabItem(title: "MY", icon: DesignSystemAsset.image(named: "mypage")) ] - customTabBar = BottomTabBar(tabItems: tabItems, selectedIndex: initialIndex) + self.tabItems = resolvedItems + customTabBar = BottomTabBar(tabItems: resolvedItems, selectedIndex: initialIndex) super.init(nibName: nil, bundle: nil) configureUI(controllers: viewControllers) selectedIndex = initialIndex diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/JobDTO.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/JobDTO.swift index 5fb8b4cf..375b28f8 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/JobDTO.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/JobDTO.swift @@ -1,3 +1,10 @@ +struct JobResponseDTO: Decodable { + let success: Bool + let code: String? + let message: String? + let data: JobDTO? +} + struct JobDTO: Decodable { let jobId: Int let jobName: String diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/RecommendationMapDTO.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/RecommendationMapDTO.swift index 3e5d1743..543e92ed 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/RecommendationMapDTO.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/RecommendationMapDTO.swift @@ -2,7 +2,7 @@ import MLSRecommendationFeatureInterface struct RecommendationMapDTO: Decodable { let mapId: Int - let score: Int + let score: Double let iconUrl: String let nameKr: String let bookmarkId: Int? diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/UserProfileDTO.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/UserProfileDTO.swift index c273f8a8..2e87b83e 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/UserProfileDTO.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/DTOs/UserProfileDTO.swift @@ -1,9 +1,16 @@ import MLSRecommendationFeatureInterface +struct UserProfileResponseDTO: Decodable { + let success: Bool + let code: String? + let message: String? + let data: UserProfileDTO? +} + struct UserProfileDTO: Decodable { - let id: String - let provider: String - let nickname: String + let id: String? + let provider: String? + let nickname: String? let fcmToken: String? let marketingAgreement: Bool? let noticeAgreement: Bool? @@ -11,16 +18,16 @@ struct UserProfileDTO: Decodable { let eventAgreement: Bool? let jobId: Int? let level: Int? - let profileImageUrl: String + let profileImageUrl: String? } extension UserProfileDTO { func toDomain() -> UserProfile { UserProfile( - nickname: nickname, + nickname: nickname ?? "", jobId: jobId, level: level, - profileImageUrl: profileImageUrl + profileImageUrl: profileImageUrl ?? "" ) } } diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Endpoints/RecommendationEndPoint.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Endpoints/RecommendationEndPoint.swift index f568d491..db59aa51 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Endpoints/RecommendationEndPoint.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Endpoints/RecommendationEndPoint.swift @@ -8,11 +8,11 @@ enum RecommendationEndPoint { baseURL: base, path: "/api/v1/maps/recommendations", method: .GET, - query: FetchQuery(level: level, jobId: jobId, limit: limit) + query: FetchQuery(level: Int32(level), jobId: Int32(jobId), limit: limit.map { Int32($0) }) ) } - static func fetchProfile() -> ResponsableEndPoint { + static func fetchProfile() -> ResponsableEndPoint { .init( baseURL: base, path: "/api/v1/auth/me", @@ -20,7 +20,7 @@ enum RecommendationEndPoint { ) } - static func fetchJob(jobId: Int) -> ResponsableEndPoint { + static func fetchJob(jobId: Int) -> ResponsableEndPoint { .init( baseURL: base, path: "/api/v1/jobs/\(jobId)", @@ -31,8 +31,8 @@ enum RecommendationEndPoint { private extension RecommendationEndPoint { struct FetchQuery: Encodable { - let level: Int - let jobId: Int - let limit: Int? + let level: Int32 + let jobId: Int32 + let limit: Int32? } } diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Repositories/RecommendationRepositoryImpl.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Repositories/RecommendationRepositoryImpl.swift index f62e4e14..4b5b3c7d 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Repositories/RecommendationRepositoryImpl.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Data/Repositories/RecommendationRepositoryImpl.swift @@ -2,28 +2,28 @@ import MLSCore import MLSRecommendationFeatureInterface import RxSwift -final class RecommendationRepositoryImpl: RecommendationRepository { +public final class RecommendationRepositoryImpl: RecommendationRepository { private let provider: NetworkProvider private let interceptor: Interceptor? - init(provider: NetworkProvider, interceptor: Interceptor?) { - self.provider = provider - self.interceptor = interceptor + public init() { + self.provider = NetworkProviderImpl() + self.interceptor = TokenInterceptor() } - func fetchProfile() -> Observable { + public func fetchProfile() -> Observable { let endpoint = RecommendationEndPoint.fetchProfile() return provider.requestData(endPoint: endpoint, interceptor: interceptor) - .map { $0.toDomain() } + .compactMap { $0.data?.toDomain() } } - func fetchJobName(jobId: Int) -> Observable { + public func fetchJobName(jobId: Int) -> Observable { let endpoint = RecommendationEndPoint.fetchJob(jobId: jobId) return provider.requestData(endPoint: endpoint, interceptor: interceptor) - .map { $0.jobName } + .compactMap { $0.data?.jobName } } - func fetchRecommendations(level: Int, jobId: Int, limit: Int?) -> Observable<[RecommendationMap]> { + public func fetchRecommendations(level: Int, jobId: Int, limit: Int?) -> Observable<[RecommendationMap]> { let endpoint = RecommendationEndPoint.fetchRecommendations(level: level, jobId: jobId, limit: limit) return provider.requestData(endPoint: endpoint, interceptor: interceptor) .map { $0.data?.toDomain() ?? [] } diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainFactoryImpl.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainFactoryImpl.swift index 11809c0f..15ccbaea 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainFactoryImpl.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainFactoryImpl.swift @@ -3,26 +3,14 @@ import MLSRecommendationFeatureInterface public struct RecommendationMainFactoryImpl: RecommendationMainFactory { private let repository: RecommendationRepository - private let level: Int - private let jobId: Int - public init( - repository: RecommendationRepository, - level: Int, - jobId: Int - ) { + public init(repository: RecommendationRepository) { self.repository = repository - self.level = level - self.jobId = jobId } public func make() -> BaseViewController { let vc = RecommendationMainViewController() - vc.reactor = RecommendationMainReactor( - repository: repository, - level: level, - jobId: jobId - ) + vc.reactor = RecommendationMainReactor(repository: repository) return vc } } diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift index 3cbe661a..342a7ac9 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift @@ -8,7 +8,7 @@ final class RecommendationMainReactor: Reactor { // MARK: - Reactor enum Action { - case viewDidLoad + case viewWillAppear case informationButtonTapped } @@ -33,44 +33,44 @@ final class RecommendationMainReactor: Reactor { var disposeBag = DisposeBag() private let repository: RecommendationRepository - private let level: Int - private let jobId: Int // MARK: - Init - init( - repository: RecommendationRepository, - level: Int, - jobId: Int - ) { + init(repository: RecommendationRepository) { self.repository = repository - self.level = level - self.jobId = jobId self.initialState = State() } // MARK: - Reactor Methods func mutate(action: Action) -> Observable { switch action { - case .viewDidLoad: - let fetchProfile = repository.fetchProfile() + case .viewWillAppear: + let fetchAll = repository.fetchProfile() .flatMap { [weak self] profile -> Observable in guard let self else { return .empty() } let setProfile = Observable.just(Mutation.setProfile(profile)) - guard let jobId = profile.jobId else { return setProfile } - let setJobName = self.repository.fetchJobName(jobId: jobId) - .map { Mutation.setJobName($0) } - .catch { _ in .empty() } - return Observable.concat([setProfile, setJobName]) + let setJobName: Observable + if let jobId = profile.jobId { + setJobName = repository.fetchJobName(jobId: jobId) + .map { Mutation.setJobName($0) } + .catch { _ in .empty() } + } else { + setJobName = .empty() + } + let setRecommendations: Observable + if let level = profile.level, level >= 1, let jobId = profile.jobId { + setRecommendations = repository.fetchRecommendations(level: level, jobId: jobId, limit: 5) + .map { Mutation.setRecommendations($0) } + .catch { _ in .empty() } + } else { + setRecommendations = .empty() + } + return Observable.concat([setProfile, setJobName, setRecommendations]) } .catch { _ in .empty() } - let fetchRecommendations = repository.fetchRecommendations(level: level, jobId: jobId, limit: 5) - .map { Mutation.setRecommendations($0) } - .catch { _ in .empty() } - return Observable.concat([ .just(.setLoading(true)), - Observable.merge(fetchProfile, fetchRecommendations), + fetchAll, .just(.setLoading(false)) ]) diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift index 8ab1621e..3f785d3a 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift @@ -38,7 +38,7 @@ private extension RecommendationMainViewController { func setupConstraints() { mainView.snp.makeConstraints { make in - make.edges.equalToSuperview() + make.edges.equalTo(view.safeAreaLayoutGuide) } } @@ -54,8 +54,6 @@ extension RecommendationMainViewController { func bind(reactor: Reactor) { bindUserActions(reactor: reactor) bindViewState(reactor: reactor) - - reactor.action.onNext(.viewDidLoad) } func bindUserActions(reactor: Reactor) { @@ -63,6 +61,11 @@ extension RecommendationMainViewController { .map { Reactor.Action.informationButtonTapped } .bind(to: reactor.action) .disposed(by: disposeBag) + + rx.viewWillAppear + .map { Reactor.Action.viewWillAppear } + .bind(to: reactor.action) + .disposed(by: disposeBag) } func bindProfile(reactor: Reactor) { @@ -136,7 +139,7 @@ extension RecommendationMainViewController: UICollectionViewDelegate, UICollecti } cell.cardView.setMainText(text: map.nameKr) - cell.cardView.setType(type: .recommended(rank: map.score)) + cell.cardView.setType(type: .recommended(rank: indexPath.row + 1)) cell.cardView.isIconSelected = map.isBookmarked ImageLoader.shared.loadImage(stringURL: map.iconUrl) { image in diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/Views/RecommendationMainView.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/Views/RecommendationMainView.swift index 31dea5e0..d9f881b9 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/Views/RecommendationMainView.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/Views/RecommendationMainView.swift @@ -19,6 +19,7 @@ internal final class RecommendationMainView: UIView { static let collectionViewHorizontalInset: CGFloat = 16 static let cellHeight: CGFloat = 104 static let cellSpacing: CGFloat = 8 + static let bottomTabHeight: CGFloat = 64 } // MARK: - Properties @@ -98,7 +99,8 @@ private extension RecommendationMainView { grayBackgroundView.snp.makeConstraints { make in make.top.equalTo(profileView.snp.bottom).offset(Constant.grayViewTopOffset) - make.horizontalEdges.bottom.equalToSuperview() + make.horizontalEdges.equalToSuperview() + make.bottom.equalToSuperview().inset(Constant.bottomTabHeight) } informationButton.snp.makeConstraints { make in diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Entities/RecommendationMap.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Entities/RecommendationMap.swift index a566ea83..c066a3a2 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Entities/RecommendationMap.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeatureInterface/Entities/RecommendationMap.swift @@ -1,13 +1,13 @@ public struct RecommendationMap { public let mapId: Int - public let score: Int + public let score: Double public let iconUrl: String public let nameKr: String public let bookmarkId: Int? public var isBookmarked: Bool { bookmarkId != nil } - public init(mapId: Int, score: Int, iconUrl: String, nameKr: String, bookmarkId: Int?) { + public init(mapId: Int, score: Double, iconUrl: String, nameKr: String, bookmarkId: Int?) { self.mapId = mapId self.score = score self.iconUrl = iconUrl diff --git a/MLS/MLSRecommendationFeatureExample/SceneDelegate.swift b/MLS/MLSRecommendationFeatureExample/SceneDelegate.swift index b6c35d4c..3b80d692 100644 --- a/MLS/MLSRecommendationFeatureExample/SceneDelegate.swift +++ b/MLS/MLSRecommendationFeatureExample/SceneDelegate.swift @@ -30,9 +30,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func registerDependencies() { DIContainer.register(type: RecommendationMainFactory.self) { RecommendationMainFactoryImpl( - repository: MockRecommendationRepository(), - level: 100, - jobId: 100 + repository: MockRecommendationRepository() ) } } diff --git a/MLS/Presentation/DesignSystem/DesignSystem/Components/Tabbar/BottomTabBar.swift b/MLS/Presentation/DesignSystem/DesignSystem/Components/Tabbar/BottomTabBar.swift index bf1c2a71..ab2a2a50 100644 --- a/MLS/Presentation/DesignSystem/DesignSystem/Components/Tabbar/BottomTabBar.swift +++ b/MLS/Presentation/DesignSystem/DesignSystem/Components/Tabbar/BottomTabBar.swift @@ -4,8 +4,13 @@ import SnapKit // MARK: - Model public struct TabItem { - var title: String - var icon: UIImage + public var title: String + public var icon: UIImage + + public init(title: String, icon: UIImage) { + self.title = title + self.icon = icon + } } public final class BottomTabBar: UIStackView { diff --git a/MLS/Presentation/DesignSystem/DesignSystem/Components/Tabbar/BottomTabBarController.swift b/MLS/Presentation/DesignSystem/DesignSystem/Components/Tabbar/BottomTabBarController.swift index 1a89a6b0..5dc4a601 100644 --- a/MLS/Presentation/DesignSystem/DesignSystem/Components/Tabbar/BottomTabBarController.swift +++ b/MLS/Presentation/DesignSystem/DesignSystem/Components/Tabbar/BottomTabBarController.swift @@ -14,13 +14,14 @@ public final class BottomTabBarController: UITabBarController { private let customTabBar: BottomTabBar // MARK: - Init - public init(viewControllers: [UIViewController], initialIndex: Int = 0) { - tabItems = [ + public init(viewControllers: [UIViewController], tabItems: [TabItem]? = nil, initialIndex: Int = 0) { + let resolvedItems = tabItems ?? [ TabItem(title: "도감", icon: .dictionary), TabItem(title: "북마크", icon: .bookmarkList), TabItem(title: "MY", icon: .mypage) ] - customTabBar = BottomTabBar(tabItems: tabItems, selectedIndex: initialIndex) + self.tabItems = resolvedItems + customTabBar = BottomTabBar(tabItems: resolvedItems, selectedIndex: initialIndex) super.init(nibName: nil, bundle: nil) configureUI(controllers: viewControllers) selectedIndex = initialIndex From f8ffdfe9b4afff04841052e8e261ad7c9cca642a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 May 2026 15:01:28 +0000 Subject: [PATCH 6/8] style/#330: Apply SwiftLint autocorrect --- .../Sources/MLSDesignSystem/Components/CardList.swift | 4 ++-- .../RecommendationMain/RecommendationMainReactor.swift | 2 +- .../RecommendationMain/RecommendationMainViewController.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift index 2c7725ad..c5d967e7 100644 --- a/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift +++ b/MLS/MLSDesignSystem/Sources/MLSDesignSystem/Components/CardList.swift @@ -130,7 +130,7 @@ public final class CardList: UIView { }() private let badge = Badge(style: .currentQuest) - + private let rankContainer = { let view = UIView() view.backgroundColor = .primary50 @@ -196,7 +196,7 @@ private extension CardList { rankContainer.snp.makeConstraints { make in make.height.equalTo(Constant.tagHeight) } - + rankTag.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(Constant.tagHorizontalInset) make.verticalEdges.equalToSuperview().inset(Constant.tagVerticalInset) diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift index 342a7ac9..561e03d4 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift @@ -21,7 +21,7 @@ final class RecommendationMainReactor: Reactor { } struct State { - var profile: UserProfile? = nil + var profile: UserProfile? var jobName: String = "" var recommendations: [RecommendationMap] = [] var isLoading: Bool = false diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift index 3f785d3a..546104b0 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift @@ -61,7 +61,7 @@ extension RecommendationMainViewController { .map { Reactor.Action.informationButtonTapped } .bind(to: reactor.action) .disposed(by: disposeBag) - + rx.viewWillAppear .map { Reactor.Action.viewWillAppear } .bind(to: reactor.action) From 40635df8d11bd9d17bf365f47e71c44121df5121 Mon Sep 17 00:00:00 2001 From: JunYoung Date: Thu, 14 May 2026 00:09:30 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix/#330:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecommendationMain/RecommendationMainReactor.swift | 3 ++- .../RecommendationMainViewController.swift | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift index 561e03d4..683b3131 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainReactor.swift @@ -64,7 +64,8 @@ final class RecommendationMainReactor: Reactor { } else { setRecommendations = .empty() } - return Observable.concat([setProfile, setJobName, setRecommendations]) + let parallelRequests = Observable.merge([setJobName, setRecommendations]) + return Observable.concat([setProfile, parallelRequests]) } .catch { _ in .empty() } diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift index 546104b0..5046ef77 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/RecommendationMainViewController.swift @@ -142,10 +142,11 @@ extension RecommendationMainViewController: UICollectionViewDelegate, UICollecti cell.cardView.setType(type: .recommended(rank: indexPath.row + 1)) cell.cardView.isIconSelected = map.isBookmarked - ImageLoader.shared.loadImage(stringURL: map.iconUrl) { image in - guard let image else { return } + ImageLoader.shared.loadImage(stringURL: map.iconUrl) { [weak self] image in + guard let self, let image else { return } DispatchQueue.main.async { - cell.cardView.setImage(image: image, backgroundColor: .clear) + guard let currentCell = self.mainView.collectionView.cellForItem(at: indexPath) as? CardListCell else { return } + currentCell.cardView.setImage(image: image, backgroundColor: .clear) } } From 46ee4cb994913306c1be37f3fa21e3353ecfafe7 Mon Sep 17 00:00:00 2001 From: JunYoung Date: Thu, 14 May 2026 00:09:59 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix/#330:=20=EC=9E=84=EC=8B=9C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecommendationMain/Views/RecommendationProfileView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/Views/RecommendationProfileView.swift b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/Views/RecommendationProfileView.swift index a7a622ea..e86c9b2c 100644 --- a/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/Views/RecommendationProfileView.swift +++ b/MLS/MLSRecommendationFeature/Sources/MLSRecommendationFeature/Presentation/RecommendationMain/Views/RecommendationProfileView.swift @@ -65,7 +65,6 @@ private extension RecommendationProfileView { } func configureUI() { - profileImageView.backgroundColor = .red alignment = .center spacing = Constant.outerStackViewSpacing