diff --git a/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift b/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift index 7661742..a1ad675 100644 --- a/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift +++ b/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift @@ -32,7 +32,8 @@ public extension Project { "NSAllowsArbitraryLoads": .boolean(true) ]), "ITSAppUsesNonExemptEncryption": .boolean(false), - "BASE_URL": .string("$(BASE_URL)") + "BASE_URL": .string("$(BASE_URL)"), + "X_API_KEY": .string("$(X_API_KEY)") ] static let demoInfoPlist: [String: Plist.Value] = [ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_Spring_IOS_1024.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_Spring_IOS_1024.png new file mode 100644 index 0000000..4399da8 Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_Spring_IOS_1024.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png deleted file mode 100644 index 105677a..0000000 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png and /dev/null differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 45047c5..d35dd26 100644 --- a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "AppIcon~ios-marketing.png", + "filename" : "AppIcon_Spring_IOS_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Projects/App/Resources/LaunchLogo.png b/Projects/App/Resources/LaunchLogo.png new file mode 100644 index 0000000..82a0d4a Binary files /dev/null and b/Projects/App/Resources/LaunchLogo.png differ diff --git a/Projects/App/Resources/LaunchScreen.storyboard b/Projects/App/Resources/LaunchScreen.storyboard index 08b3fa0..abc69cb 100644 --- a/Projects/App/Resources/LaunchScreen.storyboard +++ b/Projects/App/Resources/LaunchScreen.storyboard @@ -1,8 +1,9 @@ - - + + + - - + + @@ -12,32 +13,24 @@ - + - - + + + + + + + + - + + - - - - - - + + - @@ -45,4 +38,10 @@ + + + + + + diff --git a/Projects/App/Sources/Application/AppComponent.swift b/Projects/App/Sources/Application/AppComponent.swift index f131497..e9d3ea1 100644 --- a/Projects/App/Sources/Application/AppComponent.swift +++ b/Projects/App/Sources/Application/AppComponent.swift @@ -60,6 +60,12 @@ final class AppComponent: Component, RootDependency { ) } } + + var templateSearchUsecase: TemplatesSearchUsecaseProtocol { + shared { + TemplatesSearchUsecase(travelTemplateRepository: travelTemplateRepository) + } + } var authRepository: AuthRepositoryInterface { shared { makeAuthRepository() } diff --git a/Projects/Core/Sources/Extensions/UIKit+/UIViewController+.swift b/Projects/Core/Sources/Extensions/UIKit+/UIViewController+.swift new file mode 100644 index 0000000..235d549 --- /dev/null +++ b/Projects/Core/Sources/Extensions/UIKit+/UIViewController+.swift @@ -0,0 +1,22 @@ +// +// UIViewController+.swift +// Core +// +// Created by 최안용 on 2/18/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +public extension UIViewController { + func hideKeyboard() { + let tap = UITapGestureRecognizer(target: self, action: #selector(UIViewController.dismissKeyboard)) + tap.cancelsTouchesInView = false + view.addGestureRecognizer(tap) + } + + @objc + func dismissKeyboard() { + view.endEditing(true) + } +} diff --git a/Projects/Core/Sources/Utils/URLHelper.swift b/Projects/Core/Sources/Utils/URLHelper.swift new file mode 100644 index 0000000..6199005 --- /dev/null +++ b/Projects/Core/Sources/Utils/URLHelper.swift @@ -0,0 +1,16 @@ +// +// URLHelper.swift +// Core +// +// Created by 최안용 on 2/20/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +public struct URLHelper { + public static func openURL(_ urlString: String) { + guard let url = URL(string: urlString) else { return } + UIApplication.shared.open(url) + } +} diff --git a/Projects/Data/Sources/Repository/TravelTemplate /TravelTemplateRepository.swift b/Projects/Data/Sources/Repository/TravelTemplate /TravelTemplateRepository.swift index c4e0383..349ec4e 100644 --- a/Projects/Data/Sources/Repository/TravelTemplate /TravelTemplateRepository.swift +++ b/Projects/Data/Sources/Repository/TravelTemplate /TravelTemplateRepository.swift @@ -34,9 +34,9 @@ public final class TravelTemplateRepository: TravelTemplateRepositoryInterface { } } - public func searchTemplate() async throws -> Int { + public func searchTemplate(keyword: String, page: Int?, size: Int?) async throws -> [TripInfo] { do { - return try await service.searchTemplate() + return try await service.searchTemplate(keyword: keyword, page: page, size: size).toDomain() } catch { throw error.toNDGLError() } diff --git a/Projects/Domain/Sources/Interface/Place/PlaceRepositoryProtocol.swift b/Projects/Domain/Sources/Interface/Place/PlaceRepositoryInterface.swift similarity index 91% rename from Projects/Domain/Sources/Interface/Place/PlaceRepositoryProtocol.swift rename to Projects/Domain/Sources/Interface/Place/PlaceRepositoryInterface.swift index fc3c8d6..3d76534 100644 --- a/Projects/Domain/Sources/Interface/Place/PlaceRepositoryProtocol.swift +++ b/Projects/Domain/Sources/Interface/Place/PlaceRepositoryInterface.swift @@ -1,5 +1,5 @@ // -// PlaceRepositoryProtocol.swift +// PlaceRepositoryInterface.swift // Domain // // Created by 최안용 on 2/14/26. diff --git a/Projects/Domain/Sources/Interface/TravelProgram/TravelProgramRepositoryProtocol.swift b/Projects/Domain/Sources/Interface/TravelProgram/TravelProgramRepositoryInterface.swift similarity index 84% rename from Projects/Domain/Sources/Interface/TravelProgram/TravelProgramRepositoryProtocol.swift rename to Projects/Domain/Sources/Interface/TravelProgram/TravelProgramRepositoryInterface.swift index 31cf128..967747c 100644 --- a/Projects/Domain/Sources/Interface/TravelProgram/TravelProgramRepositoryProtocol.swift +++ b/Projects/Domain/Sources/Interface/TravelProgram/TravelProgramRepositoryInterface.swift @@ -1,5 +1,5 @@ // -// TravelProgramRepositoryProtocol.swift +// TravelProgramRepositoryInterface.swift // Domain // // Created by 최안용 on 2/14/26. diff --git a/Projects/Domain/Sources/Interface/TravelTemplate/TravelTemplateRepositoryProtocol.swift b/Projects/Domain/Sources/Interface/TravelTemplate/TravelTemplateRepositoryInterface.swift similarity index 78% rename from Projects/Domain/Sources/Interface/TravelTemplate/TravelTemplateRepositoryProtocol.swift rename to Projects/Domain/Sources/Interface/TravelTemplate/TravelTemplateRepositoryInterface.swift index c55edc5..1573c5e 100644 --- a/Projects/Domain/Sources/Interface/TravelTemplate/TravelTemplateRepositoryProtocol.swift +++ b/Projects/Domain/Sources/Interface/TravelTemplate/TravelTemplateRepositoryInterface.swift @@ -1,5 +1,5 @@ // -// TravelTemplateRepositoryProtocol.swift +// TravelTemplateRepositoryInterface.swift // Domain // // Created by 최안용 on 2/14/26. @@ -11,7 +11,7 @@ import Foundation public protocol TravelTemplateRepositoryInterface { func fetchPlaces(travelId: Int, day: Int) async throws -> [TravelPlace] func fetchTravelDetail(id: Int) async throws -> TravelDetail - func searchTemplate() async throws -> Int + func searchTemplate(keyword: String, page: Int?, size: Int?) async throws -> [TripInfo] func fetchPopularTripList(id: Int?, page: Int?, size: Int?) async throws -> [TripInfo] func fetchRecommendTripList(page: Int?, size: Int?) async throws -> [TripInfo] } diff --git a/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryProtocol.swift b/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift similarity index 89% rename from Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryProtocol.swift rename to Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift index 0329166..bcbaa39 100644 --- a/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryProtocol.swift +++ b/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift @@ -1,5 +1,5 @@ // -// UserTravelRepositoryProtocol.swift +// UserTravelRepositoryInterface.swift // Domain // // Created by 최안용 on 2/14/26. diff --git a/Projects/Domain/Sources/UseCase/TemplatesSearchUsecase.swift b/Projects/Domain/Sources/UseCase/TemplatesSearchUsecase.swift new file mode 100644 index 0000000..6a6a653 --- /dev/null +++ b/Projects/Domain/Sources/UseCase/TemplatesSearchUsecase.swift @@ -0,0 +1,37 @@ +// +// TemplatesSearchUsecase.swift +// Domain +// +// Created by 최안용 on 2/19/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public protocol TemplatesSearchUsecaseProtocol { + func searchTemplate(keyword: String, page: Int?, size: Int?) async throws -> [TripInfo] +} + +public extension TemplatesSearchUsecaseProtocol { + func searchTemplate( + keyword: String, + page: Int? = nil, + size: Int? = nil + ) async throws -> [TripInfo] { + try await self.searchTemplate(keyword: keyword, page: page, size: size) + } +} + +public final class TemplatesSearchUsecase { + private let travelTemplateRepository: TravelTemplateRepositoryInterface + + public init(travelTemplateRepository: TravelTemplateRepositoryInterface) { + self.travelTemplateRepository = travelTemplateRepository + } +} + +extension TemplatesSearchUsecase: TemplatesSearchUsecaseProtocol { + public func searchTemplate(keyword: String, page: Int?, size: Int?) async throws -> [TripInfo] { + try await travelTemplateRepository.searchTemplate(keyword: keyword, page: page, size: size) + } +} diff --git a/Projects/Features/HomeFeature/Sources/HomeViewController.swift b/Projects/Features/HomeFeature/Sources/HomeViewController.swift index 90910ee..a662c1d 100644 --- a/Projects/Features/HomeFeature/Sources/HomeViewController.swift +++ b/Projects/Features/HomeFeature/Sources/HomeViewController.swift @@ -44,7 +44,7 @@ final class HomeViewController: UIViewController, HomeViewControllable { private let loadingIndicator = UIActivityIndicatorView(style: .medium) private let networkErrorView = NDGLErrorView() - private var dataSource: UICollectionViewDiffableDataSource? + private var dataSource: UICollectionViewDiffableDataSource! = nil // MARK: - Lifecycle diff --git a/Projects/Features/HomeFeature/Sources/Views/CollectionView/SectionKind/HomeSectionKind.swift b/Projects/Features/HomeFeature/Sources/Views/CollectionView/SectionKind/HomeSectionKind.swift index 1dc7642..c3e5590 100644 --- a/Projects/Features/HomeFeature/Sources/Views/CollectionView/SectionKind/HomeSectionKind.swift +++ b/Projects/Features/HomeFeature/Sources/Views/CollectionView/SectionKind/HomeSectionKind.swift @@ -17,10 +17,11 @@ enum HomeSectionKind: Int, CaseIterable { var headerTitle: String { switch self { case .category: - "인기 여행 따라가기" + return "인기 여행 따라가기" case .recommendedTrip: - "나혜주님께 추천하는\n따라가기 여행 콘텐츠에요!" - default: "" + let nickname = UserDefaults.standard.string(forKey: "nickname") ?? "알 수 없음" + return "\(nickname)님께 추천하는\n따라가기 여행 콘텐츠에요!" + default: return "" } } } diff --git a/Projects/Features/MainFeature/Sources/MainBuilder.swift b/Projects/Features/MainFeature/Sources/MainBuilder.swift index 01f43a2..e985c49 100644 --- a/Projects/Features/MainFeature/Sources/MainBuilder.swift +++ b/Projects/Features/MainFeature/Sources/MainBuilder.swift @@ -7,11 +7,10 @@ // import Domain -import Data import FollowFeature import PopularTravelFeature -import SettingFeature import SearchFeature +import SettingFeature import TabBarFeature import RIBs @@ -19,9 +18,14 @@ import RIBs public protocol MainDependency: Dependency { var homeUsecase: HomeUsecaseProtocol { get } var followDetailUsecase: FollowDetailUsecaseProtocol { get } + var templateSearchUsecase: TemplatesSearchUsecaseProtocol { get } } final class MainComponent: Component, FollowDetailDependency, PopularTravelDependency,SearchDependency, SettingDependency, TabBarDependency { + var searchUsecase: TemplatesSearchUsecaseProtocol { + dependency.templateSearchUsecase + } + var followDetailUsecase: FollowDetailUsecaseProtocol { dependency.followDetailUsecase } diff --git a/Projects/Features/MainFeature/Sources/MainInteractor.swift b/Projects/Features/MainFeature/Sources/MainInteractor.swift index 9d34d62..dfa86dd 100644 --- a/Projects/Features/MainFeature/Sources/MainInteractor.swift +++ b/Projects/Features/MainFeature/Sources/MainInteractor.swift @@ -91,4 +91,8 @@ final class MainInteractor: PresentableInteractor, MainInteract func routeToSearch() { router?.attachSearch() } + + func attachFollowDetail(with recommendationId: Int) { + router?.attachFollow(with: recommendationId) + } } diff --git a/Projects/Features/MainFeature/Sources/MainRouter.swift b/Projects/Features/MainFeature/Sources/MainRouter.swift index 70ec49a..f55d55b 100644 --- a/Projects/Features/MainFeature/Sources/MainRouter.swift +++ b/Projects/Features/MainFeature/Sources/MainRouter.swift @@ -107,14 +107,14 @@ final class MainRouter: ViewableRouter, let router = searchBuilder.build(withListener: interactor) self.searchRouter = router attachChild(router) - viewController.pushViewController(router.viewControllable, animated: true) + viewController.pushViewController(router.viewControllable, animated: false) } func detachSearch() { guard let router = searchRouter else { return } if viewController.containsInStack(router.viewControllable) { - viewController.popRootViewController(animated: true) + viewController.popRootViewController(animated: false) } detachChild(router) diff --git a/Projects/Features/RootFeature/Sources/RootBuilder.swift b/Projects/Features/RootFeature/Sources/RootBuilder.swift index c40282f..64ffd5e 100644 --- a/Projects/Features/RootFeature/Sources/RootBuilder.swift +++ b/Projects/Features/RootFeature/Sources/RootBuilder.swift @@ -18,11 +18,16 @@ public protocol RootDependency: Dependency { var followDetailUsecase: FollowDetailUsecaseProtocol { get } var authRepository: AuthRepositoryInterface { get } var tokenRepository: TokenRepositoryProtocol { get } + var templateSearchUsecase: TemplatesSearchUsecaseProtocol { get } } // MARK: - RootComponent final class RootComponent: Component, MainDependency { + var templateSearchUsecase: TemplatesSearchUsecaseProtocol { + dependency.templateSearchUsecase + } + var followDetailUsecase: FollowDetailUsecaseProtocol { dependency.followDetailUsecase } diff --git a/Projects/Features/RootFeature/Sources/RootInteractor.swift b/Projects/Features/RootFeature/Sources/RootInteractor.swift index a45d8d5..389c8fc 100644 --- a/Projects/Features/RootFeature/Sources/RootInteractor.swift +++ b/Projects/Features/RootFeature/Sources/RootInteractor.swift @@ -70,6 +70,10 @@ final class RootInteractor: PresentableInteractor, RootInteract if let uuid = self.tokenRepository.get(.uuid) { let loginResult = try await self.authRepository.login(uuid: uuid) self.tokenRepository.save(loginResult.accessToken, for: .accessToken) + + // 임시 + UserDefaults.standard.set(loginResult.uuid, forKey: "uuid") + UserDefaults.standard.set(loginResult.nickname, forKey: "nickname") } else { let fcmToken = self.tokenRepository.get(.fcmToken) ?? UUID().uuidString let signupResult = try await self.authRepository.signup( @@ -77,7 +81,11 @@ final class RootInteractor: PresentableInteractor, RootInteract ) self.tokenRepository.save(signupResult.uuid, for: .uuid) self.tokenRepository.save(signupResult.accessToken, for: .accessToken) - + + // 임시 + UserDefaults.standard.set(signupResult.uuid, forKey: "uuid") + UserDefaults.standard.set(signupResult.nickname, forKey: "nickname") + let loginResult = try await self.authRepository.login(uuid: signupResult.uuid) self.tokenRepository.save(loginResult.accessToken, for: .accessToken) } diff --git a/Projects/Features/SearchFeature/Sources/ChildRIBs/Models/SearchResultPresentationModel.swift b/Projects/Features/SearchFeature/Sources/ChildRIBs/Models/SearchResultPresentationModel.swift new file mode 100644 index 0000000..8a3181b --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/ChildRIBs/Models/SearchResultPresentationModel.swift @@ -0,0 +1,39 @@ +// +// SearchResultPresentationModel.swift +// SearchFeature +// +// Created by 최안용 on 2/18/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +import Domain + +struct SearchResultPresentationModel { + let resultTrip: [SearchResultPresentationModel.ResultTrip] + + struct ResultTrip: Hashable { + let id: Int + let title: String + let thumbnailUrl: String + let creator: String + let schedule: String + let country: String + let city: String + } +} + +extension TripInfo { + func toSearchResultModel() -> SearchResultPresentationModel.ResultTrip { + return SearchResultPresentationModel.ResultTrip( + id: self.id, + title: self.title, + thumbnailUrl: self.thumbnailUrl, + creator: self.creator, + schedule: "\(self.nights)박 \(self.days)일", + country: self.country, + city: self.city + ) + } +} diff --git a/Projects/Features/SearchFeature/Sources/ChildRIBs/SearchResultBuilder.swift b/Projects/Features/SearchFeature/Sources/ChildRIBs/SearchResultBuilder.swift new file mode 100644 index 0000000..b87ac45 --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/ChildRIBs/SearchResultBuilder.swift @@ -0,0 +1,43 @@ +// +// SearchResultBuilder.swift +// SearchFeature +// +// Created by 최안용 on 2/18/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain + +import RIBs + +protocol SearchResultDependency: Dependency { + var searchUsecase: TemplatesSearchUsecaseProtocol { get } +} + +final class SearchResultComponent: Component { + var searchUsecase: TemplatesSearchUsecaseProtocol { + dependency.searchUsecase + } +} + +// MARK: - Builder + +protocol SearchResultBuildable: Buildable { + func build(withListener listener: SearchResultListener, searchKeyword: String) -> SearchResultRouting +} + +final class SearchResultBuilder: Builder, SearchResultBuildable { + + + override init(dependency: SearchResultDependency) { + super.init(dependency: dependency) + } + + func build(withListener listener: SearchResultListener, searchKeyword: String) -> SearchResultRouting { + let component = SearchResultComponent(dependency: dependency) + let viewController = SearchResultViewController() + let interactor = SearchResultInteractor(presenter: viewController, searchKeyword: searchKeyword, usecase: component.searchUsecase) + interactor.listener = listener + return SearchResultRouter(interactor: interactor, viewController: viewController) + } +} diff --git a/Projects/Features/SearchFeature/Sources/ChildRIBs/SearchResultInteractor.swift b/Projects/Features/SearchFeature/Sources/ChildRIBs/SearchResultInteractor.swift new file mode 100644 index 0000000..efd7713 --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/ChildRIBs/SearchResultInteractor.swift @@ -0,0 +1,108 @@ +// +// SearchResultInteractor.swift +// SearchFeature +// +// Created by 최안용 on 2/18/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain + +import RIBs +import RxCocoa +import RxRelay +import RxSwift + +protocol SearchResultRouting: ViewableRouting { + +} + +protocol SearchResultPresentable: Presentable { + var listener: SearchResultPresentableListener? { get set } + + func update(with model: SearchResultPresentationModel) + func setLoading(_ isLoading: Bool) + func showErrorView(_ isError: Bool) +} + +protocol SearchResultListener: AnyObject { + func detachSearchResult() + func popularTravelDidTapFollowDetail(with recommendationId: Int) +} + +final class SearchResultInteractor: PresentableInteractor, SearchResultInteractable { + weak var router: SearchResultRouting? + weak var listener: SearchResultListener? + + private let searchKeyword: String + private let usecase: TemplatesSearchUsecaseProtocol + private var fetchDataTask: Task? + private let disposeBag = DisposeBag() + + private let searchResultRelay = BehaviorRelay(value: nil) + + init(presenter: SearchResultPresentable, searchKeyword: String, usecase: TemplatesSearchUsecaseProtocol) { + self.searchKeyword = searchKeyword + self.usecase = usecase + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + + fetchData() + } + + override func willResignActive() { + super.willResignActive() + + fetchDataTask?.cancel() + fetchDataTask = nil + } + + private func fetchData() { + fetchDataTask?.cancel() + + presenter.setLoading(true) + presenter.showErrorView(false) + + fetchDataTask = Task { [weak self] in + guard let self, !Task.isCancelled else { return } + + do { + let result = try await self.usecase.searchTemplate(keyword: searchKeyword) + + let model = SearchResultPresentationModel(resultTrip: result.map { $0.toSearchResultModel() }) + + self.searchResultRelay.accept(model) + self.presenter.update(with: model) + self.presenter.setLoading(false) + } catch { + presenter.setLoading(false) + presenter.showErrorView(true) + } + } + } +} + +extension SearchResultInteractor: SearchResultPresentableListener { + func detachSearchResult() { + listener?.detachSearchResult() + } + + func searchBtnTapped() { + + } + + func itemSelected(item: SearchResultItem) { + switch item { + case .resultTrip(let trip): + listener?.popularTravelDidTapFollowDetail(with: trip.id) + } + } + + func reloadBtnTapped() { + fetchData() + } +} diff --git a/Projects/Features/SearchFeature/Sources/ChildRIBs/SearchResultRouter.swift b/Projects/Features/SearchFeature/Sources/ChildRIBs/SearchResultRouter.swift new file mode 100644 index 0000000..da4e6c6 --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/ChildRIBs/SearchResultRouter.swift @@ -0,0 +1,27 @@ +// +// SearchResultRouter.swift +// SearchFeature +// +// Created by 최안용 on 2/18/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +protocol SearchResultInteractable: Interactable { + var router: SearchResultRouting? { get set } + var listener: SearchResultListener? { get set } +} + +protocol SearchResultViewControllable: ViewControllable { + // TODO: Declare methods the router invokes to manipulate the view hierarchy. +} + +final class SearchResultRouter: ViewableRouter, SearchResultRouting { + + // TODO: Constructor inject child builder protocols to allow building children. + override init(interactor: SearchResultInteractable, viewController: SearchResultViewControllable) { + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } +} diff --git a/Projects/Features/SearchFeature/Sources/ChildRIBs/SearchResultViewController.swift b/Projects/Features/SearchFeature/Sources/ChildRIBs/SearchResultViewController.swift new file mode 100644 index 0000000..aa7a4a6 --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/ChildRIBs/SearchResultViewController.swift @@ -0,0 +1,223 @@ +// +// SearchResultViewController.swift +// SearchFeature +// +// Created by 최안용 on 2/18/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +import RIBs +import RxSwift + +protocol SearchResultPresentableListener: AnyObject { + func detachSearchResult() + func searchBtnTapped() + func itemSelected(item: SearchResultItem) + func reloadBtnTapped() +} + +final class SearchResultViewController: UIViewController, SearchResultViewControllable { + weak var listener: SearchResultPresentableListener? + + private let disposeBag = DisposeBag() + + private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let networkErrorView = NDGLErrorView() + private let emptyView = EmptyView() + private var dataSource: UICollectionViewDiffableDataSource! = nil + + override func viewDidLoad() { + super.viewDidLoad() + + setStyle() + setUI() + setLayout() + setCollectionView() + setDataSource() + bindInteractor() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if isMovingFromParent { + listener?.detachSearchResult() + } + } +} + +private extension SearchResultViewController { + func setStyle() { + view.backgroundColor = DSKitAsset.Colors.white.color + + collectionView.do { + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + $0.backgroundColor = .clear + $0.isScrollEnabled = true + $0.contentInset = .init(top: 18.adjustedH, left: 0, bottom: 0, right: 0) + } + + loadingIndicator.do { + $0.color = DSKitAsset.Colors.green300.color + } + + networkErrorView.do { + $0.isHidden = true + } + + emptyView.do { + $0.isHidden = true + $0.changeType(.noResults) + } + } + + func setUI() { + view.addSubviews(collectionView, loadingIndicator, networkErrorView, emptyView) + } + + func setLayout() { + collectionView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.bottom.equalToSuperview() + $0.directionalHorizontalEdges.equalToSuperview() + } + + loadingIndicator.snp.makeConstraints { + $0.center.equalToSuperview() + } + + networkErrorView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.directionalHorizontalEdges.equalToSuperview() + $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16.adjustedH) + } + + emptyView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + func setCollectionView() { + collectionView.do { + $0.register( + PopularInfoCell.self, + forCellWithReuseIdentifier: PopularInfoCell.cellIdentifier + ) + + $0.register( + SearchResultHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: SearchResultHeaderView.reusableViewIdentifier + ) + } + } +} + +private extension SearchResultViewController { + func applySnapShot(_ items: [SearchResultPresentationModel.ResultTrip]) { + var snapshot = NSDiffableDataSourceSnapshot() + + + snapshot.appendSections([.resultTrip]) + let resultItems = items.map { SearchResultItem.resultTrip($0) } + snapshot.appendItems(resultItems, toSection: .resultTrip) + dataSource?.apply(snapshot, animatingDifferences: true) + } + + func setDataSource() { + let resultTripRegistration = createResultTripCellRegistration() + + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in + switch item { + case .resultTrip(let tripList): + return collectionView.dequeueConfiguredReusableCell( + using: resultTripRegistration, + for: indexPath, + item: tripList + ) + } + } + + configureSupplementaryView() + } + + func configureSupplementaryView() { + let headerRegistration = createHeaderRegistration(dataSource: dataSource) + + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + guard SearchResultSectionKind(rawValue: indexPath.section) != nil else { + return UICollectionReusableView() + } + + if kind == UICollectionView.elementKindSectionHeader { + return collectionView.dequeueConfiguredReusableSupplementary( + using: headerRegistration, + for: indexPath + ) + } + + return nil + } + } + + func bindInteractor() { + collectionView.rx.itemSelected + .compactMap { [weak self] indexPath in + self?.dataSource.itemIdentifier(for: indexPath) + } + .subscribe(with: self) { owner, item in + owner.listener?.itemSelected(item: item) + } + .disposed(by: disposeBag) + + networkErrorView.buttonDidTap + .subscribe(with: self) { owner, _ in + owner.listener?.reloadBtnTapped() + } + .disposed(by: disposeBag) + } +} + +extension SearchResultViewController: SearchResultPresentable { + func update(with model: SearchResultPresentationModel) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + self.applySnapShot(model.resultTrip) + + let isEmpty = model.resultTrip.isEmpty + + self.emptyView.isHidden = !isEmpty + self.collectionView.isHidden = isEmpty + } + } + + func setLoading(_ isLoading: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + if isLoading { + self.loadingIndicator.startAnimating() + self.collectionView.alpha = 0.5 + } else { + self.loadingIndicator.stopAnimating() + self.collectionView.alpha = 1.0 + } + } + } + + func showErrorView(_ isError: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + self.networkErrorView.isHidden = !isError + self.collectionView.isHidden = isError + } + } +} diff --git a/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/Cells/SearchResultHeaderView.swift b/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/Cells/SearchResultHeaderView.swift new file mode 100644 index 0000000..1c3c786 --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/Cells/SearchResultHeaderView.swift @@ -0,0 +1,59 @@ +// +// SearchResultHeaderView.swift +// SearchFeature +// +// Created by 최안용 on 2/18/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +final class SearchResultHeaderView: UICollectionReusableView { + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + titleLabel.text = nil + } + + func configure(count: Int) { + titleLabel.do { + $0.setText(.bodyMM, text: "총 \(count)개", color: DSKitAsset.Colors.black400.color) + } + } +} + +private extension SearchResultHeaderView { + func setStyle() { + titleLabel.do { + $0.numberOfLines = 1 + } + } + + func setUI() { + addSubview(titleLabel) + } + + func setLayout() { + titleLabel.snp.makeConstraints { + $0.leading.equalToSuperview() + $0.directionalVerticalEdges.equalToSuperview() + } + } +} + diff --git a/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/CollectionView/CompositionalLayout/SearchResultCompositionalLayout.swift b/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/CollectionView/CompositionalLayout/SearchResultCompositionalLayout.swift new file mode 100644 index 0000000..96d1063 --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/CollectionView/CompositionalLayout/SearchResultCompositionalLayout.swift @@ -0,0 +1,89 @@ +// +// SearchResultCompositionalLayout.swift +// SearchFeature +// +// Created by 최안용 on 2/18/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +extension SearchResultViewController { + func createLayout() -> UICollectionViewCompositionalLayout { + return UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in + guard let sectionKind = SearchResultSectionKind(rawValue: sectionIndex) else { + return self?.emptyLayout() + } + + switch sectionKind { + case .resultTrip: + return self?.createPopularTripSection() + } + } + } +} + +private extension SearchResultViewController { + func createPopularTripSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(PopularInfoCell.defaultHeight) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(PopularInfoCell.defaultHeight) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: [item] + ) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 16.adjustedH + + section.contentInsets = .init( + top: 16.adjustedH, + leading: 24.adjusted, + bottom: 12.adjustedH, + trailing: 24.adjusted + ) + section.orthogonalScrollingBehavior = .none + + let headerSize = NSCollectionLayoutSize( + widthDimension: .estimated(43.adjusted), + heightDimension: .absolute(30.adjustedH) + ) + + let header = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .topLeading + ) + + section.boundarySupplementaryItems = [header] + + return section + } + + func emptyLayout() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0) + ) + let group = NSCollectionLayoutGroup(layoutSize: groupSize) + + let section = NSCollectionLayoutSection(group: group) + + return section + } +} diff --git a/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/CollectionView/Item/SearchResultItem.swift b/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/CollectionView/Item/SearchResultItem.swift new file mode 100644 index 0000000..ca26924 --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/CollectionView/Item/SearchResultItem.swift @@ -0,0 +1,13 @@ +// +// SearchResultItem.swift +// SearchFeature +// +// Created by 최안용 on 2/18/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +enum SearchResultItem: Hashable { + case resultTrip(SearchResultPresentationModel.ResultTrip) +} diff --git a/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/CollectionView/Registration/SearchResultRegistration.swift b/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/CollectionView/Registration/SearchResultRegistration.swift new file mode 100644 index 0000000..c827b9a --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/CollectionView/Registration/SearchResultRegistration.swift @@ -0,0 +1,41 @@ +// +// SearchResultRegistration.swift +// SearchFeature +// +// Created by 최안용 on 2/18/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +extension SearchResultViewController { + func createResultTripCellRegistration() + -> UICollectionView.CellRegistration { + return UICollectionView.CellRegistration { cell, indexPath, item in + cell.configure( + thumbnailUrl: item.thumbnailUrl, + city: item.city, + title: item.title, + nation: item.country, + schedule: item.schedule + ) + } + } + + func createHeaderRegistration( + dataSource: UICollectionViewDiffableDataSource + ) -> UICollectionView.SupplementaryRegistration { + return UICollectionView.SupplementaryRegistration( + elementKind: UICollectionView.elementKindSectionHeader + ) { [weak dataSource] headerView, elementKind, indexPath in + guard let dataSource else { return } + + let snapshot = dataSource.snapshot() + let itemCount = snapshot.numberOfItems(inSection: .resultTrip) + + headerView.configure(count: itemCount) + } + } +} diff --git a/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/CollectionView/SectionKind/SearchResultSectionKind.swift b/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/CollectionView/SectionKind/SearchResultSectionKind.swift new file mode 100644 index 0000000..038d804 --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/ChildRIBs/Views/CollectionView/SectionKind/SearchResultSectionKind.swift @@ -0,0 +1,13 @@ +// +// SearchResultSectionKind.swift +// SearchFeature +// +// Created by 최안용 on 2/18/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +enum SearchResultSectionKind: Int, CaseIterable { + case resultTrip +} diff --git a/Projects/Features/SearchFeature/Sources/SearchBuilder.swift b/Projects/Features/SearchFeature/Sources/SearchBuilder.swift index b91ca9e..f216023 100644 --- a/Projects/Features/SearchFeature/Sources/SearchBuilder.swift +++ b/Projects/Features/SearchFeature/Sources/SearchBuilder.swift @@ -6,14 +6,18 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import Domain + import RIBs public protocol SearchDependency: Dependency { - + var searchUsecase: TemplatesSearchUsecaseProtocol { get } } -final class SearchComponent: Component { - +final class SearchComponent: Component, SearchResultDependency { + var searchUsecase: TemplatesSearchUsecaseProtocol { + dependency.searchUsecase + } } public protocol SearchBuildable: Buildable { @@ -29,9 +33,15 @@ public final class SearchBuilder: Builder, SearchBuildable { public func build(withListener listener: SearchListener) -> SearchRouting { let component = SearchComponent(dependency: dependency) let viewController = SearchViewController() - let interactor = SearchInteractor(presenter: viewController) + let interactor = SearchInteractor(presenter: viewController, usecase: component.searchUsecase) interactor.listener = listener - return SearchRouter(interactor: interactor, viewController: viewController) + let searchResultBuilder = SearchResultBuilder(dependency: component) + + return SearchRouter( + interactor: interactor, + viewController: viewController, + searchResultBuilder: searchResultBuilder + ) } } diff --git a/Projects/Features/SearchFeature/Sources/SearchInteractor.swift b/Projects/Features/SearchFeature/Sources/SearchInteractor.swift index 2920ae4..9746542 100644 --- a/Projects/Features/SearchFeature/Sources/SearchInteractor.swift +++ b/Projects/Features/SearchFeature/Sources/SearchInteractor.swift @@ -6,30 +6,33 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import Domain + import RIBs import RxSwift public protocol SearchRouting: ViewableRouting { - + func attachSearchResult(keyword: String) + func detachSearchResult() } protocol SearchPresentable: Presentable { var listener: SearchPresentableListener? { get set } - // TODO: Declare methods the interactor can invoke the presenter to present data. } public protocol SearchListener: AnyObject { + func attachFollowDetail(with recommendationId: Int) func detachSearch() } final class SearchInteractor: PresentableInteractor, SearchInteractable, SearchPresentableListener { - weak var router: SearchRouting? weak var listener: SearchListener? - // TODO: Add additional dependencies to constructor. Do not perform any logic - // in constructor. - override init(presenter: SearchPresentable) { + private let usecase: TemplatesSearchUsecaseProtocol + + init(presenter: SearchPresentable, usecase: TemplatesSearchUsecaseProtocol) { + self.usecase = usecase super.init(presenter: presenter) presenter.listener = self } @@ -44,6 +47,18 @@ final class SearchInteractor: PresentableInteractor, SearchIn // TODO: Pause any business logic. } + func search(keyword: String) { + router?.attachSearchResult(keyword: keyword) + } + + func detachSearchResult() { + router?.detachSearchResult() + } + + func popularTravelDidTapFollowDetail(with recommendationId: Int) { + listener?.attachFollowDetail(with: recommendationId) + } + func detachSearch() { listener?.detachSearch() } diff --git a/Projects/Features/SearchFeature/Sources/SearchRouter.swift b/Projects/Features/SearchFeature/Sources/SearchRouter.swift index f6860c5..b1b0999 100644 --- a/Projects/Features/SearchFeature/Sources/SearchRouter.swift +++ b/Projects/Features/SearchFeature/Sources/SearchRouter.swift @@ -8,20 +8,45 @@ import RIBs -protocol SearchInteractable: Interactable { +protocol SearchInteractable: Interactable, SearchResultListener { var router: SearchRouting? { get set } var listener: SearchListener? { get set } } protocol SearchViewControllable: ViewControllable { - + func pushChild(_ viewControllable: ViewControllable) + func popChild(_ animated: Bool) } final class SearchRouter: ViewableRouter, SearchRouting { - - // TODO: Constructor inject child builder protocols to allow building children. - override init(interactor: SearchInteractable, viewController: SearchViewControllable) { + private let searchResultBuilder: SearchResultBuildable + private var searchResultRouter: SearchResultRouting? + + init( + interactor: SearchInteractable, + viewController: SearchViewControllable, + searchResultBuilder: SearchResultBuildable + ) { + self.searchResultBuilder = searchResultBuilder super.init(interactor: interactor, viewController: viewController) interactor.router = self } + + func attachSearchResult(keyword: String) { + guard searchResultRouter == nil else { return } + + let router = searchResultBuilder.build( + withListener: interactor, + searchKeyword: keyword + ) + self.searchResultRouter = router + attachChild(router) + viewController.pushChild(router.viewControllable) + } + + func detachSearchResult() { + guard let routing = searchResultRouter else { return } + detachChild(routing) + self.searchResultRouter = nil + } } diff --git a/Projects/Features/SearchFeature/Sources/SearchViewController.swift b/Projects/Features/SearchFeature/Sources/SearchViewController.swift index d57d0b0..3aedc6c 100644 --- a/Projects/Features/SearchFeature/Sources/SearchViewController.swift +++ b/Projects/Features/SearchFeature/Sources/SearchViewController.swift @@ -14,10 +14,12 @@ import RIBs import RxSwift protocol SearchPresentableListener: AnyObject { + func search(keyword: String) + func detachSearchResult() func detachSearch() } -final class SearchViewController: UIViewController, SearchPresentable, SearchViewControllable{ +final class SearchViewController: UIViewController, SearchPresentable, SearchViewControllable { weak var listener: SearchPresentableListener? private let searchBar = NDGLSearchBar( @@ -25,18 +27,20 @@ final class SearchViewController: UIViewController, SearchPresentable, SearchVi DSKitAsset.Assets.icChevronLeft3.image, DSKitAsset.Assets.icSearch2.image ) - private let emptyImageView = UIImageView() - private let titleLabel = UILabel() - private let containerView = UIView() - + + private let contentNavigationController = UINavigationController() + private let emptyView = EmptyView() private let disposeBag = DisposeBag() override func viewDidLoad() { super.viewDidLoad() + hideKeyboard() setStyle() setUI() + setContentNavigation() setLayout() + searchBar.focus() bindKeyboard() setupActions() } @@ -45,32 +49,35 @@ final class SearchViewController: UIViewController, SearchPresentable, SearchVi super.viewDidDisappear(animated) if isMovingFromParent { + contentNavigationController.interactivePopGestureRecognizer?.delegate = nil listener?.detachSearch() } } + + func pushChild(_ viewControllable: ViewControllable) { + contentNavigationController.pushViewController( + viewControllable.uiviewController, + animated: true + ) + } + + func popChild(_ animated: Bool) { + contentNavigationController.popViewController(animated: animated) + } } private extension SearchViewController { func setStyle() { - emptyImageView.do { - $0.image = DSKitAsset.Assets.icEmptySearch.image - $0.contentMode = .scaleAspectFit - } + view.backgroundColor = DSKitAsset.Colors.white.color - titleLabel.do { - $0.setText( - .bodyLM, - text: "좋아하는 유튜버나 가고 싶은\n여행지를 검색해봐요", - color: DSKitAsset.Colors.black400.color, - alignment: .center - ) - $0.numberOfLines = 2 + contentNavigationController.do { + $0.isNavigationBarHidden = true + $0.view.backgroundColor = .clear } } func setUI() { - view.addSubviews(searchBar, containerView) - containerView.addSubviews(emptyImageView, titleLabel) + view.addSubviews(searchBar) } func setLayout() { @@ -79,22 +86,25 @@ private extension SearchViewController { $0.directionalHorizontalEdges.equalToSuperview() } - containerView.snp.makeConstraints { + contentNavigationController.view.snp.makeConstraints { $0.top.equalTo(searchBar.snp.bottom) - $0.directionalHorizontalEdges.equalToSuperview() - $0.bottom.equalToSuperview() - } - - emptyImageView.snp.makeConstraints { - $0.width.equalTo(215.adjusted) - $0.height.equalTo(198.adjustedH) - $0.center.equalToSuperview() + $0.directionalHorizontalEdges.bottom.equalToSuperview() } + } + + func setContentNavigation() { + let rootVC = UIViewController() + rootVC.view.backgroundColor = DSKitAsset.Colors.white.color + rootVC.view.addSubview(emptyView) + emptyView.snp.makeConstraints { $0.edges.equalToSuperview() } - titleLabel.snp.makeConstraints { - $0.top.equalTo(emptyImageView.snp.bottom).offset(16) - $0.centerX.equalToSuperview() - } + contentNavigationController.setViewControllers([rootVC], animated: false) + + addChild(contentNavigationController) + view.addSubview(contentNavigationController.view) + contentNavigationController.didMove(toParent: self) + + contentNavigationController.interactivePopGestureRecognizer?.delegate = self } func bindKeyboard() { @@ -103,7 +113,7 @@ private extension SearchViewController { guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return } let keyboardHeight = keyboardFrame.cgRectValue.height - self?.containerView.snp.updateConstraints { + self?.contentNavigationController.view.snp.updateConstraints { $0.bottom.equalToSuperview().inset(keyboardHeight) } @@ -115,7 +125,7 @@ private extension SearchViewController { NotificationCenter.default.rx.notification(UIResponder.keyboardWillHideNotification) .subscribe(onNext: { [weak self] _ in - self?.containerView.snp.updateConstraints { + self?.contentNavigationController.view.snp.updateConstraints { $0.bottom.equalToSuperview() } @@ -129,8 +139,51 @@ private extension SearchViewController { func setupActions() { searchBar.leadingButtonDidTap .subscribe(with: self) { owner, _ in - owner.navigationController?.popViewController(animated: true) + if owner.contentNavigationController.viewControllers.count > 1 { + owner.contentNavigationController.popViewController(animated: true) + } else { + owner.listener?.detachSearch() + } } .disposed(by: disposeBag) + + searchBar.searchButtonClicked + .withLatestFrom(searchBar.searchText) + .compactMap { $0 } + .filter { !$0.isEmpty } + .subscribe(with: self) { owner, text in + owner.listener?.search(keyword: text) + } + .disposed(by: disposeBag) + + searchBar.trailingButtonDidTap + .withLatestFrom(searchBar.searchText) + .compactMap { $0 } + .filter { !$0.isEmpty } + .subscribe(with: self) { owner, text in + owner.listener?.search(keyword: text) + } + .disposed(by: disposeBag) + + searchBar.editingDidBegin + .subscribe(with: self) { owner, _ in + if owner.contentNavigationController.viewControllers.count > 1 { + owner.contentNavigationController.popViewController(animated: false) + } + } + .disposed(by: disposeBag) + } +} + +extension SearchViewController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return gestureRecognizer == contentNavigationController.interactivePopGestureRecognizer + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer == contentNavigationController.interactivePopGestureRecognizer { + return contentNavigationController.viewControllers.count > 1 + } + return true } } diff --git a/Projects/Features/SearchFeature/Sources/Views/EmptyView.swift b/Projects/Features/SearchFeature/Sources/Views/EmptyView.swift new file mode 100644 index 0000000..cbde3e9 --- /dev/null +++ b/Projects/Features/SearchFeature/Sources/Views/EmptyView.swift @@ -0,0 +1,141 @@ +// +// EmptyView.swift +// SearchFeature +// +// Created by 최안용 on 2/18/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +final class EmptyView: UIView { + private let imageView = UIImageView() + private let titleLabel = UILabel() + private let subTitleLabel = UILabel() + private let containerStackView = UIStackView() + private let titleStackView = UIStackView() + + private var type: EmptyViewType = .start + + override init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func changeType(_ type: EmptyViewType) { + self.type = type + + imageView.image = type.image + + titleLabel.setText( + .subTitleMSB, + text: type.title, + color: DSKitAsset.Colors.black500.color, + alignment: .center + ) + + subTitleLabel.setText( + .bodyLR, + text: type.subTitle, + color: DSKitAsset.Colors.black400.color, + alignment: .center + ) + } +} + +private extension EmptyView { + func setStyle() { + imageView.do { + $0.image = type.image + $0.contentMode = .scaleAspectFit + } + + titleLabel.do { + $0.setText( + .subTitleMSB, + text: type.title, + color: DSKitAsset.Colors.black500.color, + alignment: .center + ) + } + + subTitleLabel.do { + $0.numberOfLines = 0 + $0.setText( + .bodyLR, + text: type.subTitle, + color: DSKitAsset.Colors.black400.color, + alignment: .center + ) + } + + containerStackView.do { + $0.axis = .vertical + $0.spacing = 16.adjustedH + $0.alignment = .center + } + + titleStackView.do { + $0.axis = .vertical + $0.spacing = 4.adjustedH + $0.alignment = .center + } + } + + func setUI() { + titleStackView.addArrangedSubviews(titleLabel, subTitleLabel) + containerStackView.addArrangedSubviews(imageView, titleStackView) + addSubviews(containerStackView) + } + + func setLayout() { + imageView.snp.makeConstraints { + $0.size.equalTo(100.adjustedH) + } + + containerStackView.snp.makeConstraints { + $0.center.equalToSuperview() + } + } +} + +enum EmptyViewType { + case start + case noResults + + var image: UIImage { + switch self { + case .start: + DSKitAsset.Assets.icTripBag.image + case .noResults: + DSKitAsset.Assets.icEmptySearch.image + } + } + + var title: String { + switch self { + case .start: + "어디로 떠나볼까요?" + case .noResults: + "검색 결과가 없어요" + } + } + + var subTitle: String { + switch self { + case .start: + "좋아하는 유튜버나 가고 싶은\n여행지를 검색 할 수 있어요." + case .noResults: + "철자를 확인하거나\n다른 키워드로 검색해보세요." + } + } +} diff --git a/Projects/Features/SettingFeature/Sources/SettingInteractor.swift b/Projects/Features/SettingFeature/Sources/SettingInteractor.swift index 5a9386a..bccedd8 100644 --- a/Projects/Features/SettingFeature/Sources/SettingInteractor.swift +++ b/Projects/Features/SettingFeature/Sources/SettingInteractor.swift @@ -6,6 +6,8 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import Core + import RIBs import RxSwift @@ -15,6 +17,8 @@ public protocol SettingRouting: ViewableRouting { public protocol SettingPresentable: Presentable { var listener: SettingPresentableListener? { get set } + + func copyToClipboard() } public protocol SettingListener: AnyObject { @@ -48,16 +52,18 @@ final class SettingInteractor: PresentableInteractor, Settin func didTapMenu(item: SettingCellItem) { // 각 메뉴 타이틀에 따른 동작 처리 switch item { - case .notification: - print("알림") +// case .notification: +// print("알림") case .faq: - print("FAQ") + URLHelper.openURL("https://repeated-tapir-33f.notion.site/FAQ-30ccbdc5a38380d6af4af7b7c412921e?source=copy_link") case .recommendLink: - print("추천 링크") + URLHelper.openURL("https://forms.gle/3q1uhQVeeKRrz11y5") case .identificationCode: - print("내 식별코드") + presenter.copyToClipboard() case .terms: - print("서비스 약관") + URLHelper.openURL("https://repeated-tapir-33f.notion.site/2c8cbdc5a3838070a8d8ccdcd0631c9a?source=copy_link") + case .personalInformation: + URLHelper.openURL("https://repeated-tapir-33f.notion.site/30ccbdc5a38380e3a50ace64a9b0f398?source=copy_link") default: break } } diff --git a/Projects/Features/SettingFeature/Sources/SettingViewController.swift b/Projects/Features/SettingFeature/Sources/SettingViewController.swift index a644eeb..c946b72 100644 --- a/Projects/Features/SettingFeature/Sources/SettingViewController.swift +++ b/Projects/Features/SettingFeature/Sources/SettingViewController.swift @@ -46,6 +46,27 @@ final class SettingViewController: UIViewController, SettingPresentable, Setting listener?.detachSetting() } } + + func copyToClipboard() { + guard let uuid = UserDefaults.standard.string(forKey: "uuid") else { + Toast.show( + type: .normal, + message: "식별코드를 불러올 수 없습니다.", + bottomPadding: 50 + ) + return + } + + UIPasteboard.general.string = uuid + Toast.show( + type: .success, + message: "클립보드 저장이 완료되었습니다.", + bottomPadding: 50 + ) + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + + } } private extension SettingViewController { @@ -117,11 +138,11 @@ extension SettingViewController: UITableViewDataSource, UITableViewDelegate { cell.configure(title: menu.title, type: type) - if menu == .notification { - cell.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: tableView.bounds.width) - } else { +// if menu == .notification { +// cell.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: tableView.bounds.width) +// } else { cell.separatorInset = UIEdgeInsets(top: 0, left: 25.adjusted, bottom: 0, right: 24.adjusted) - } +// } return cell } diff --git a/Projects/Features/SettingFeature/Sources/UITableView/SettingCellItem.swift b/Projects/Features/SettingFeature/Sources/UITableView/SettingCellItem.swift index 0d83393..d837df8 100644 --- a/Projects/Features/SettingFeature/Sources/UITableView/SettingCellItem.swift +++ b/Projects/Features/SettingFeature/Sources/UITableView/SettingCellItem.swift @@ -9,28 +9,30 @@ import Foundation public enum SettingCellItem: Int, CaseIterable { - case notification +// case notification case faq case recommendLink case identificationCode case terms + case personalInformation case version var title: String { switch self { - case .notification: return "알림 설정" +// case .notification: return "알림 설정" case .faq: return "FAQ" case .recommendLink: return "콘텐츠 추천 링크 넣기" case .identificationCode: return "내 식별코드" case .terms: return "서비스 약관" + case .personalInformation: return "개인정보처리 방침" case .version: return "버전 정보" } } var cellType: SettingCellType { switch self { - case .notification: - return .toggle(isOn: true) +// case .notification: +// return .toggle(isOn: true) case .version: let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" return .detailText(text: version) diff --git a/Projects/Features/SettingFeature/Sources/UITableView/SettingSection.swift b/Projects/Features/SettingFeature/Sources/UITableView/SettingSection.swift index 3b360da..65d81ef 100644 --- a/Projects/Features/SettingFeature/Sources/UITableView/SettingSection.swift +++ b/Projects/Features/SettingFeature/Sources/UITableView/SettingSection.swift @@ -13,7 +13,7 @@ enum SettingSection: Int, CaseIterable { var items: [SettingCellItem] { switch self { - case .menu: [.notification, .faq, .recommendLink, .terms, .version] + case .menu: [.faq, .recommendLink, .identificationCode, .terms, .personalInformation, .version] } } } diff --git a/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift b/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift index 2b79f2e..106d1f2 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift @@ -59,7 +59,7 @@ public final class TabBarViewController: UITabBarController, TabBarPresentable, let homeVC = viewControllers[0].uiviewController let travelVC = viewControllers[1].uiviewController - let infoDummy = UIViewController().then { $0.view.backgroundColor = .yellow } + let infoDummy = UIViewController().then { $0.view.backgroundColor = .white } let infoNav = UINavigationController(rootViewController: infoDummy) let homeNav = UINavigationController(rootViewController: homeVC) diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_check.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_check.imageset/Contents.json new file mode 100644 index 0000000..0775798 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_check.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ic_check.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_check.imageset/ic_check.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_check.imageset/ic_check.svg new file mode 100644 index 0000000..92af6d1 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_check.imageset/ic_check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_trip_bag.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_trip_bag.imageset/Contents.json new file mode 100644 index 0000000..a74e680 --- /dev/null +++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_trip_bag.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "ic_trip_bag.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ic_trip_bag@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_trip_bag@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_trip_bag.imageset/ic_trip_bag.png b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_trip_bag.imageset/ic_trip_bag.png new file mode 100644 index 0000000..d9ea4c3 Binary files /dev/null and b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_trip_bag.imageset/ic_trip_bag.png differ diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_trip_bag.imageset/ic_trip_bag@2x.png b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_trip_bag.imageset/ic_trip_bag@2x.png new file mode 100644 index 0000000..ff94f52 Binary files /dev/null and b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_trip_bag.imageset/ic_trip_bag@2x.png differ diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_trip_bag.imageset/ic_trip_bag@3x.png b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_trip_bag.imageset/ic_trip_bag@3x.png new file mode 100644 index 0000000..4b3ff23 Binary files /dev/null and b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_trip_bag.imageset/ic_trip_bag@3x.png differ diff --git a/Projects/Modules/DSKit/Sources/Component/NDGLSearchBar.swift b/Projects/Modules/DSKit/Sources/Component/NDGLSearchBar.swift index b0e2187..727a54a 100644 --- a/Projects/Modules/DSKit/Sources/Component/NDGLSearchBar.swift +++ b/Projects/Modules/DSKit/Sources/Component/NDGLSearchBar.swift @@ -59,11 +59,23 @@ public final class NDGLSearchBar: UIView { trailingButton.rx.tap.asObservable() } + public var searchButtonClicked: ControlEvent { + textField.rx.controlEvent(.editingDidEndOnExit) + } + + public var editingDidBegin: ControlEvent { + textField.rx.controlEvent(.editingDidBegin) + } + /// 텍스트 변경 이벤트 public var searchText: ControlProperty { textField.rx.text } + public func focus() { + textField.becomeFirstResponder() + } + // MARK: - Initializer /// NDGLSearchBar를 초기화합니다. diff --git a/Projects/Modules/DSKit/Sources/Component/NDGLToastView.swift b/Projects/Modules/DSKit/Sources/Component/NDGLToastView.swift new file mode 100644 index 0000000..584cac8 --- /dev/null +++ b/Projects/Modules/DSKit/Sources/Component/NDGLToastView.swift @@ -0,0 +1,88 @@ +// +// NDGLToastView.swift +// DSKit +// +// Created by 최안용 on 2/20/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +public final class NDGLToastView: UIView { + private let type: ToastType + private let message: String + + private let iconImageView = UIImageView() + private let titleLabel = UILabel() + private let stackView = UIStackView() + + public init(type: ToastType, message: String) { + self.type = type + self.message = message + super.init(frame: .zero) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +private extension NDGLToastView { + func setStyle() { + self.do { + $0.backgroundColor = DSKitAsset.Colors.black500.color + $0.clipsToBounds = true + $0.layer.cornerRadius = 8.0 + } + + if let image = type.icon { + iconImageView.image = image + } else { + iconImageView.isHidden = true + } + + titleLabel.setText(.bodyMSB, text: message, color: DSKitAsset.Colors.white.color) + + stackView.do { + $0.axis = .horizontal + $0.alignment = .center + $0.spacing = 8.adjusted + } + } + + func setUI() { + addSubview(stackView) + stackView.addArrangedSubviews(iconImageView, titleLabel) + } + + func setLayout() { + iconImageView.snp.makeConstraints { + $0.size.equalTo(24.adjustedH) + } + + stackView.snp.makeConstraints { + $0.directionalVerticalEdges.equalToSuperview().inset(11.adjustedH) + $0.directionalHorizontalEdges.equalToSuperview().inset(12.adjusted) + } + } +} + +public extension NDGLToastView { + enum ToastType { + case success + case normal + + var icon: UIImage? { + switch self { + case .success: + return DSKitAsset.Assets.icCheck.image + default: return nil + } + } + } +} diff --git a/Projects/Modules/DSKit/Sources/Component/Toast.swift b/Projects/Modules/DSKit/Sources/Component/Toast.swift new file mode 100644 index 0000000..f47ffaf --- /dev/null +++ b/Projects/Modules/DSKit/Sources/Component/Toast.swift @@ -0,0 +1,47 @@ +// +// Toast.swift +// DSKit +// +// Created by 최안용 on 2/20/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +public final class Toast { + @MainActor + public static func show( + type: NDGLToastView.ToastType, + message: String, + bottomPadding: CGFloat + ) { + guard let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene else { return } + guard let window = scene.windows.first(where: { $0.isKeyWindow }) else { return } + + let previousToast = window.subviews.first { $0 is NDGLToastView } + previousToast?.removeFromSuperview() + + let toast = NDGLToastView(type: type, message: message) + toast.isUserInteractionEnabled = false + toast.alpha = 0.0 + + window.addSubview(toast) + + toast.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.bottom.equalTo(window.safeAreaLayoutGuide).inset(bottomPadding.adjustedH) + $0.width.greaterThanOrEqualTo(327.adjusted) + $0.height.greaterThanOrEqualTo(46.adjustedH) + } + + UIView.animate(withDuration: 0.4, delay: 0.0, options: .curveEaseIn, animations: { + toast.alpha = 1.0 + }, completion: { _ in + UIView.animate(withDuration: 0.4, delay: 1.0, options: .curveEaseOut, animations: { + toast.alpha = 0.0 + }) { _ in + toast.removeFromSuperview() + } + }) + } +} diff --git a/Projects/Modules/Networks/Sources/Foundation/NetworkConfiguration.swift b/Projects/Modules/Networks/Sources/Foundation/NetworkConfiguration.swift index acc88b7..e6ed600 100644 --- a/Projects/Modules/Networks/Sources/Foundation/NetworkConfiguration.swift +++ b/Projects/Modules/Networks/Sources/Foundation/NetworkConfiguration.swift @@ -19,4 +19,12 @@ public enum NetworkConfiguration { } return url } + + public static var apiKey: String { + let bundle = Bundle.main + guard let apiHeader = bundle.infoDictionary?["X_API_KEY"] as? String else { + fatalError("X-API-KEY not found in Info.plist") + } + return apiHeader + } } diff --git a/Projects/Modules/Networks/Sources/Service/TravelTemplateService.swift b/Projects/Modules/Networks/Sources/Service/TravelTemplateService.swift index e34d796..2a2aad7 100644 --- a/Projects/Modules/Networks/Sources/Service/TravelTemplateService.swift +++ b/Projects/Modules/Networks/Sources/Service/TravelTemplateService.swift @@ -13,7 +13,7 @@ import Moya public protocol TravelTemplateServiceProtocol { func getItinerary(travelId: Int, day: Int) async throws -> FollowItineraryResponse func getContentCard(id: Int) async throws -> FollowContentCardResponse - func searchTemplate() async throws -> Int + func searchTemplate(keyword: String, page: Int?, size: Int?) async throws -> TripResponse func getPopularTripList(id: Int?, page: Int?, size: Int?) async throws -> TripResponse func getRecommendTripList(page: Int?, size: Int?) async throws -> TripResponse } @@ -33,8 +33,8 @@ public final class TravelTemplateService: TravelTemplateServiceProtocol { try await provider.asyncThowsRequest(.getContentCard(id: id)) } - public func searchTemplate() async throws -> Int { - try await provider.asyncThowsRequest(.searchTemplate) + public func searchTemplate(keyword: String, page: Int?, size: Int?) async throws -> TripResponse { + try await provider.asyncThowsRequest(.searchTemplate(keyword: keyword, page: page, size: size)) } public func getPopularTripList(id: Int?, page: Int?, size: Int?) async throws -> TripResponse { diff --git a/Projects/Modules/Networks/Sources/TargetType/AuthAPI.swift b/Projects/Modules/Networks/Sources/TargetType/AuthAPI.swift index 9866431..de9d243 100644 --- a/Projects/Modules/Networks/Sources/TargetType/AuthAPI.swift +++ b/Projects/Modules/Networks/Sources/TargetType/AuthAPI.swift @@ -45,7 +45,11 @@ extension AuthAPI: TargetType { } public var headers: [String: String]? { - return ["Content-Type": "application/json"] + var headers = ["Content-Type": "application/json"] + #if !DEBUG + headers["X-API-KEY"] = NetworkConfiguration.apiKey + #endif + + return headers } - } diff --git a/Projects/Modules/Networks/Sources/TargetType/PlaceAPI.swift b/Projects/Modules/Networks/Sources/TargetType/PlaceAPI.swift index be325be..e7f5017 100644 --- a/Projects/Modules/Networks/Sources/TargetType/PlaceAPI.swift +++ b/Projects/Modules/Networks/Sources/TargetType/PlaceAPI.swift @@ -53,7 +53,12 @@ extension PlaceAPI: TargetType { } } - public var headers: [String : String]? { - ["Content-Type": "application/json"] + public var headers: [String: String]? { + var headers = ["Content-Type": "application/json"] + #if !DEBUG + headers["X-API-KEY"] = NetworkConfiguration.apiKey + #endif + + return headers } } diff --git a/Projects/Modules/Networks/Sources/TargetType/TravelProgramAPI.swift b/Projects/Modules/Networks/Sources/TargetType/TravelProgramAPI.swift index af5c0a0..5928870 100644 --- a/Projects/Modules/Networks/Sources/TargetType/TravelProgramAPI.swift +++ b/Projects/Modules/Networks/Sources/TargetType/TravelProgramAPI.swift @@ -40,9 +40,12 @@ extension TravelProgramAPI: TargetType { } } - public var headers: [String : String]? { - ["Content-Type": "application/json"] + public var headers: [String: String]? { + var headers = ["Content-Type": "application/json"] + #if !DEBUG + headers["X-API-KEY"] = NetworkConfiguration.apiKey + #endif + + return headers } - - } diff --git a/Projects/Modules/Networks/Sources/TargetType/TravelTemplateAPI.swift b/Projects/Modules/Networks/Sources/TargetType/TravelTemplateAPI.swift index 14b66ef..32ad5b5 100644 --- a/Projects/Modules/Networks/Sources/TargetType/TravelTemplateAPI.swift +++ b/Projects/Modules/Networks/Sources/TargetType/TravelTemplateAPI.swift @@ -13,7 +13,7 @@ import Moya public enum TravelTemplateAPI { case getItinerary(id: Int, day: Int?) case getContentCard(id: Int) - case searchTemplate // 아직 적용 x + case searchTemplate(keyword: String, page: Int?, size: Int?) case getPopularTripList(id: Int?, page: Int?, size: Int?) case getRecommendTripList(page: Int?, size: Int?) } @@ -57,8 +57,15 @@ extension TravelTemplateAPI: TargetType { return .requestPlain case .getContentCard(let id): return .requestPlain - case .searchTemplate: - return .requestPlain + case .searchTemplate(let keyword, let page, let size): + var params: [String: Any] = [:] + + params["keyword"] = keyword + + if let page { params["page"] = page } + if let size { params["size"] = size } + + return .requestParameters(parameters: params, encoding: URLEncoding.queryString) case .getPopularTripList(let id, let page, let size): var params: [String: Any] = [:] @@ -77,7 +84,12 @@ extension TravelTemplateAPI: TargetType { } } - public var headers: [String : String]? { - ["Content-Type": "application/json"] + public var headers: [String: String]? { + var headers = ["Content-Type": "application/json"] + #if !DEBUG + headers["X-API-KEY"] = NetworkConfiguration.apiKey + #endif + + return headers } } diff --git a/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift b/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift index 2f4a695..1ff3559 100644 --- a/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift +++ b/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift @@ -47,9 +47,12 @@ extension UserTravelAPI: TargetType { } } - public var headers: [String : String]? { - ["Content-Type": "application/json"] + public var headers: [String: String]? { + var headers = ["Content-Type": "application/json"] + #if !DEBUG + headers["X-API-KEY"] = NetworkConfiguration.apiKey + #endif + + return headers } - - } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index eb2f3b6..2c2878a 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -47,6 +47,12 @@ platform :ios do begin # 1. 인증서 동기화 match(type: "appstore", readonly: true) + build_num = date_based_build_number + + increment_build_number( + build_number: build_num, + xcodeproj: "./Projects/App/App.xcodeproj" + ) # 2. 앱 빌드 build_app( @@ -122,4 +128,11 @@ def send_discord_message(message:, success:, type: "CI") description: message, success: success ) +end + +######################################### +# 🗓 날짜 기반 빌드 번호 함수 +######################################### +def date_based_build_number + Time.new.strftime("%Y.%m%d.%H%M") end \ No newline at end of file