diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift
index 3b943f2..3e96764 100644
--- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift
+++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift
@@ -17,6 +17,7 @@ public extension TargetDependency {
public struct Search {}
public struct Setting {}
public struct PopularTravel {}
+ public struct TravelTool {}
}
struct Modules {}
@@ -88,6 +89,12 @@ public extension TargetDependency.Features.Main {
public extension TargetDependency.Features.PopularTravel {
static let group = "PopularTravel"
-
+
+ static let feature = TargetDependency.Features.project(name: "Feature", group: group)
+}
+
+public extension TargetDependency.Features.TravelTool {
+ static let group = "TravelTool"
+
static let feature = TargetDependency.Features.project(name: "Feature", group: group)
}
diff --git a/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift b/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift
index a1ad675..d8c05ce 100644
--- a/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift
+++ b/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift
@@ -33,7 +33,8 @@ public extension Project {
]),
"ITSAppUsesNonExemptEncryption": .boolean(false),
"BASE_URL": .string("$(BASE_URL)"),
- "X_API_KEY": .string("$(X_API_KEY)")
+ "X_API_KEY": .string("$(X_API_KEY)"),
+ "GOOGLE_WEATHER_API_KEY": .string("$(GOOGLE_WEATHER_API_KEY)")
]
static let demoInfoPlist: [String: Plist.Value] = [
diff --git a/Projects/App/Resources/PrivacyInfo.xcprivacy b/Projects/App/Resources/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..ce82588
--- /dev/null
+++ b/Projects/App/Resources/PrivacyInfo.xcprivacy
@@ -0,0 +1,31 @@
+
+
+
+
+
+ NSPrivacyTracking
+
+
+
+ NSPrivacyTrackingDomains
+
+
+
+ NSPrivacyCollectedDataTypes
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryUserDefaults
+ NSPrivacyAccessedAPITypeReasons
+
+ CA92.1
+
+
+
+
+
diff --git a/Projects/App/Sources/Application/AppComponent.swift b/Projects/App/Sources/Application/AppComponent.swift
index e9d3ea1..8bb7018 100644
--- a/Projects/App/Sources/Application/AppComponent.swift
+++ b/Projects/App/Sources/Application/AppComponent.swift
@@ -41,6 +41,13 @@ final class AppComponent: Component, RootDependency {
}
}
+ var weatherRepository: WeatherRepositoryInterface {
+ shared {
+ let service = makeWeatherService()
+ return WeatherRepository(service: service)
+ }
+ }
+
var homeUsecase: HomeUsecaseProtocol {
shared {
HomeUsecase(
diff --git a/Projects/Data/Sources/DI/WeatherServiceFactory.swift b/Projects/Data/Sources/DI/WeatherServiceFactory.swift
new file mode 100644
index 0000000..b11d397
--- /dev/null
+++ b/Projects/Data/Sources/DI/WeatherServiceFactory.swift
@@ -0,0 +1,16 @@
+//
+// WeatherServiceFactory.swift
+// Data
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import Foundation
+import Networks
+import Moya
+
+public func makeWeatherService() -> WeatherServiceProtocol {
+ let provider = MoyaProvider()
+ return WeatherService(provider: provider)
+}
diff --git a/Projects/Data/Sources/Repository/Weather/WeatherRepository.swift b/Projects/Data/Sources/Repository/Weather/WeatherRepository.swift
new file mode 100644
index 0000000..13c6e9f
--- /dev/null
+++ b/Projects/Data/Sources/Repository/Weather/WeatherRepository.swift
@@ -0,0 +1,36 @@
+//
+// WeatherRepository.swift
+// Data
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import Foundation
+import Domain
+import Networks
+
+public final class WeatherRepository: WeatherRepositoryInterface {
+ private let service: WeatherServiceProtocol
+
+ public init(service: WeatherServiceProtocol) {
+ self.service = service
+ }
+
+ public func fetchForecast(
+ latitude: Double,
+ longitude: Double,
+ days: Int
+ ) async throws -> [DailyWeatherInfo] {
+ do {
+ let response = try await service.getForecast(
+ latitude: latitude,
+ longitude: longitude,
+ days: days
+ )
+ return response.forecastDays.compactMap { $0.toDomain() }
+ } catch {
+ throw error.toNDGLError()
+ }
+ }
+}
diff --git a/Projects/Data/Sources/Transform/UserTravelTransform.swift b/Projects/Data/Sources/Transform/UserTravelTransform.swift
index c1ba86c..d73f988 100644
--- a/Projects/Data/Sources/Transform/UserTravelTransform.swift
+++ b/Projects/Data/Sources/Transform/UserTravelTransform.swift
@@ -17,6 +17,8 @@ extension UpcomingResponse {
return .init(
id: self.userTravelId,
title: self.title,
+ city: self.city,
+ country: self.country,
startDay: self.startDate.toDate() ?? .now,
endDay: self.endDate.toDate() ?? .now,
tripSchedule: .init(
@@ -25,7 +27,9 @@ extension UpcomingResponse {
placeName: self.upcomingUserTravelPlace.place.name,
thumbnailUrl: self.upcomingUserTravelPlace.place.thumbnail ?? "",
transport: self.upcomingUserTravelPlace.place.category,
- estimatedDuration: self.upcomingUserTravelPlace.estimatedDuration
+ estimatedDuration: self.upcomingUserTravelPlace.estimatedDuration,
+ latitude: self.upcomingUserTravelPlace.place.latitude,
+ longitude: self.upcomingUserTravelPlace.place.longitude
)
)
}
diff --git a/Projects/Data/Sources/Transform/WeatherTransform.swift b/Projects/Data/Sources/Transform/WeatherTransform.swift
new file mode 100644
index 0000000..3990659
--- /dev/null
+++ b/Projects/Data/Sources/Transform/WeatherTransform.swift
@@ -0,0 +1,29 @@
+//
+// WeatherTransform.swift
+// Data
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import Foundation
+import Domain
+import Networks
+
+extension ForecastDayResponse {
+ func toDomain() -> DailyWeatherInfo? {
+ var components = DateComponents()
+ components.year = displayDate.year
+ components.month = displayDate.month
+ components.day = displayDate.day
+
+ guard let date = Calendar.current.date(from: components) else { return nil }
+
+ return DailyWeatherInfo(
+ date: date,
+ maxTemperature: maxTemperature?.degrees ?? 0,
+ minTemperature: minTemperature?.degrees ?? 0,
+ weatherType: daytimeForecast?.weatherCondition.type ?? "CLOUDY"
+ )
+ }
+}
diff --git a/Projects/Domain/Sources/Interface/Weather/WeatherRepositoryInterface.swift b/Projects/Domain/Sources/Interface/Weather/WeatherRepositoryInterface.swift
new file mode 100644
index 0000000..3314d80
--- /dev/null
+++ b/Projects/Domain/Sources/Interface/Weather/WeatherRepositoryInterface.swift
@@ -0,0 +1,17 @@
+//
+// WeatherRepositoryInterface.swift
+// Domain
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import Foundation
+
+public protocol WeatherRepositoryInterface {
+ func fetchForecast(
+ latitude: Double,
+ longitude: Double,
+ days: Int
+ ) async throws -> [DailyWeatherInfo]
+}
diff --git a/Projects/Domain/Sources/Model/Home/MyTripSummary.swift b/Projects/Domain/Sources/Model/Home/MyTripSummary.swift
index 894937f..f529c73 100644
--- a/Projects/Domain/Sources/Model/Home/MyTripSummary.swift
+++ b/Projects/Domain/Sources/Model/Home/MyTripSummary.swift
@@ -12,13 +12,17 @@ import Foundation
public struct MyTripSummary {
public let id: Int
public let title: String
+ public let city: String
+ public let country: String
public let startDay: Date
public let endDay: Date
public let tripSchedule: Schedule
-
- public init(id: Int, title: String, startDay: Date, endDay: Date, tripSchedule: Schedule) {
+
+ public init(id: Int, title: String, city: String, country: String, startDay: Date, endDay: Date, tripSchedule: Schedule) {
self.id = id
self.title = title
+ self.city = city
+ self.country = country
self.startDay = startDay
self.endDay = endDay
self.tripSchedule = tripSchedule
@@ -33,14 +37,18 @@ public struct Schedule {
public let thumbnailUrl: String
public let transport: String
public let estimatedDuration: Int
-
+ public let latitude: Double
+ public let longitude: Double
+
public init(
id: Int,
day: Int,
placeName: String,
thumbnailUrl: String,
transport: String,
- estimatedDuration: Int
+ estimatedDuration: Int,
+ latitude: Double,
+ longitude: Double
) {
self.id = id
self.day = day
@@ -48,5 +56,7 @@ public struct Schedule {
self.thumbnailUrl = thumbnailUrl
self.transport = transport
self.estimatedDuration = estimatedDuration
+ self.latitude = latitude
+ self.longitude = longitude
}
}
diff --git a/Projects/Domain/Sources/Model/Weather/WeatherInfo.swift b/Projects/Domain/Sources/Model/Weather/WeatherInfo.swift
new file mode 100644
index 0000000..1e0bba0
--- /dev/null
+++ b/Projects/Domain/Sources/Model/Weather/WeatherInfo.swift
@@ -0,0 +1,28 @@
+//
+// WeatherInfo.swift
+// Domain
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import Foundation
+
+public struct DailyWeatherInfo {
+ public let date: Date
+ public let maxTemperature: Double
+ public let minTemperature: Double
+ public let weatherType: String
+
+ public init(
+ date: Date,
+ maxTemperature: Double,
+ minTemperature: Double,
+ weatherType: String
+ ) {
+ self.date = date
+ self.maxTemperature = maxTemperature
+ self.minTemperature = minTemperature
+ self.weatherType = weatherType
+ }
+}
diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift
index df8db57..e1b73cf 100644
--- a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift
+++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift
@@ -15,7 +15,7 @@ import RxSwift
public protocol FollowDetailListener: AnyObject {
func detachFollowDetail()
- func followDetailDidAddTrip(title: String, startDate: Date, endDate: Date)
+ func followDetailDidViewTrip()
}
// MARK: - FollowDetailPresentable
@@ -28,6 +28,7 @@ protocol FollowDetailPresentable: Presentable {
func updateTravelDetail(_ detail: TravelDetail)
func updatePlaces(_ places: [TravelPlace])
func showPlaceDetail(_ place: TravelPlace)
+ func showTripCreatedModal(onLater: @escaping () -> Void, onViewTrip: @escaping () -> Void)
}
// MARK: - FollowDetailPresentableListener
@@ -192,16 +193,24 @@ extension FollowDetailInteractor: TripCalendarListener {
await MainActor.run {
presenter.showLoading()
}
-
+
do {
let response = try await followDetailUsecase.createUserTravel(request: request)
-
- await MainActor.run {
+
+ await MainActor.run { [weak self] in
+ guard let self else { return }
presenter.hideLoading()
- router?.detachTripCalendar()
-
- let tripTitle = "\(travelDetail?.city ?? "새로운") 여행"
- listener?.followDetailDidAddTrip(title: tripTitle, startDate: startDate, endDate: endDate)
+
+ presenter.showTripCreatedModal(
+ onLater: { [weak self] in
+ self?.router?.detachTripCalendar()
+ self?.listener?.detachFollowDetail()
+ },
+ onViewTrip: { [weak self] in
+ self?.router?.detachTripCalendar()
+ self?.listener?.followDetailDidViewTrip()
+ }
+ )
print("여행 생성 성공 - userTravelId: \(response.userTravelId)")
}
} catch {
diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift
index 6a68d2a..730b0e6 100644
--- a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift
+++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift
@@ -31,8 +31,7 @@ final class FollowDetailViewController: UIViewController, FollowDetailPresentabl
// MARK: - UI Components (Scroll)
private let navigationBar = NDGLNavigationBar(
style: .gray,
- leadingIcon: DSKitAsset.Assets.icChevronLeft3.image,
- trailingIcon: DSKitAsset.Assets.icShare1.image
+ leadingIcon: DSKitAsset.Assets.icChevronLeft3.image
)
private let scrollView = UIScrollView().then {
@@ -255,6 +254,18 @@ extension FollowDetailViewController {
}
}
+ func showTripCreatedModal(onLater: @escaping () -> Void, onViewTrip: @escaping () -> Void) {
+ let modal = NDGLModalViewController(
+ title: "여행이 준비됐어요",
+ subtitle: "이제 선택한 여행을\n그대로 따라갈 수 있어요.",
+ cancelButtonTitle: "나중에 보러가기",
+ actionButtonTitle: "여행 보러가기"
+ )
+ modal.onCancelTapped = onLater
+ modal.onActionTapped = onViewTrip
+ present(modal, animated: true)
+ }
+
func showPlaceDetail(_ place: TravelPlace) {
let contentView = PlaceDetailBottomSheetView()
contentView.configure(with: place)
diff --git a/Projects/Features/FollowFeature/Sources/PlaceDetail/PlaceDetailViewController.swift b/Projects/Features/FollowFeature/Sources/PlaceDetail/PlaceDetailViewController.swift
index 7d0b3d8..c363075 100644
--- a/Projects/Features/FollowFeature/Sources/PlaceDetail/PlaceDetailViewController.swift
+++ b/Projects/Features/FollowFeature/Sources/PlaceDetail/PlaceDetailViewController.swift
@@ -10,6 +10,7 @@ import Domain
import DSKit
import Kingfisher
import RIBs
+import RxSwift
import SnapKit
import UIKit
@@ -20,8 +21,15 @@ final class PlaceDetailViewController: UIViewController, PlaceDetailPresentable,
// MARK: - Properties
weak var listener: PlaceDetailPresentableListener?
+ private let disposeBag = DisposeBag()
private var segmentOriginY: CGFloat = .greatestFiniteMagnitude
+ // MARK: - UI Components (Navigation)
+
+ private let navigationBar = NDGLNavigationBar(
+ leadingIcon: DSKitAsset.Assets.icChevronLeft3.image
+ )
+
// MARK: - UI Components (Fixed Header)
private let fixedHeaderView: UIView = {
@@ -183,7 +191,7 @@ final class PlaceDetailViewController: UIViewController, PlaceDetailPresentable,
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
- navigationController?.setNavigationBarHidden(false, animated: animated)
+ navigationController?.setNavigationBarHidden(true, animated: animated)
}
override func viewDidDisappear(_ animated: Bool) {
@@ -203,6 +211,7 @@ final class PlaceDetailViewController: UIViewController, PlaceDetailPresentable,
private func setupUI() {
view.backgroundColor = .white
+ view.addSubview(navigationBar)
view.addSubview(fixedHeaderView)
[nameLabel, ratingLabel, reviewCountLabel].forEach { fixedHeaderView.addSubview($0) }
@@ -224,8 +233,13 @@ final class PlaceDetailViewController: UIViewController, PlaceDetailPresentable,
}
private func setupConstraints() {
- fixedHeaderView.snp.makeConstraints {
+ navigationBar.snp.makeConstraints {
$0.top.equalTo(view.safeAreaLayoutGuide)
+ $0.horizontalEdges.equalToSuperview()
+ }
+
+ fixedHeaderView.snp.makeConstraints {
+ $0.top.equalTo(navigationBar.snp.bottom)
$0.leading.trailing.equalToSuperview()
}
@@ -358,6 +372,12 @@ final class PlaceDetailViewController: UIViewController, PlaceDetailPresentable,
private func setupActions() {
segmentedControl.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
stickySegmentedControl.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
+
+ navigationBar.leadingButtonDidTap
+ .subscribe(with: self) { owner, _ in
+ owner.navigationController?.popViewController(animated: true)
+ }
+ .disposed(by: disposeBag)
}
// MARK: - Actions
diff --git a/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift
index 56a8e04..d9d2cf1 100644
--- a/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift
+++ b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift
@@ -9,6 +9,7 @@
import Core
import DSKit
import RIBs
+import RxSwift
import SnapKit
import Then
import UIKit
@@ -21,11 +22,17 @@ final class TripCalendarViewController: UIViewController, TripCalendarPresentabl
weak var listener: TripCalendarPresentableListener?
+ private let disposeBag = DisposeBag()
private var selectedStartDate: Date?
private var selectedEndDate: Date?
// MARK: - UI Components
+ private let navigationBar = NDGLNavigationBar(
+ title: "여행 따라가기",
+ leadingIcon: DSKitAsset.Assets.icChevronLeft3.image
+ )
+
private let calendarView = CalendarView()
private let completeButton = BottomPlacedButton(title: "완료")
@@ -50,18 +57,27 @@ final class TripCalendarViewController: UIViewController, TripCalendarPresentabl
// MARK: - Setup
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ navigationController?.setNavigationBarHidden(true, animated: animated)
+ }
+
private func setupUI() {
- title = "여행 따라가기"
view.backgroundColor = UIColor(hexCode: "#FFFFFF")
- [calendarView, completeButton].forEach {
+ [navigationBar, calendarView, completeButton].forEach {
view.addSubview($0)
}
}
private func setupConstraints() {
+ navigationBar.snp.makeConstraints {
+ $0.top.equalTo(view.safeAreaLayoutGuide)
+ $0.horizontalEdges.equalToSuperview()
+ }
+
calendarView.snp.makeConstraints {
- $0.top.equalTo(view.safeAreaLayoutGuide).offset(16)
+ $0.top.equalTo(navigationBar.snp.bottom).offset(16)
$0.leading.trailing.equalToSuperview()
}
@@ -78,6 +94,12 @@ final class TripCalendarViewController: UIViewController, TripCalendarPresentabl
private func setupActions() {
completeButton.addTarget(self, action: #selector(completeButtonTapped), for: .touchUpInside)
+
+ navigationBar.leadingButtonDidTap
+ .subscribe(with: self) { owner, _ in
+ owner.navigationController?.popViewController(animated: true)
+ }
+ .disposed(by: disposeBag)
}
// MARK: - Actions
diff --git a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift
index a684446..d124111 100644
--- a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift
+++ b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift
@@ -95,51 +95,54 @@ final class HomeInteractor: PresentableInteractor, HomeInteract
owner.presenter.update(with: sections)
}
.disposed(by: disposeBag)
-
- homeDataRelay
- .map { $0 == nil }
- .subscribe(with: self) { owner, isLoading in
- owner.presenter.setLoading(isLoading)
- }
- .disposed(by: disposeBag)
}
+ private static let allCategoryId = -1
+
private func fetchHomeData() {
fetchDataTask?.cancel()
-
+
presenter.setLoading(true)
presenter.showErrorView(false)
-
+
fetchDataTask = Task { [weak self] in
guard let self, !Task.isCancelled else { return }
-
+
do {
- let myTripBanner: HomePresentationModel.Banner = await {
- do {
- return try await self.usecase.fetchMyTripInfo().toPresention()
- } catch {
-
- return .empty
- }
- }()
-
- async let categories = self.usecase.fetchCategoryList().map { $0.toHomeModel() }
- async let populars = self.usecase.fetchPopularTripList().map { $0.toPopularHomeModel() }
+ let usecase = self.usecase
+ var myTripBanner: HomePresentationModel.Banner
+ do {
+ let tripInfo = try await usecase.fetchMyTripInfo()
+ myTripBanner = tripInfo.toPresention()
+ } catch {
+ myTripBanner = .empty
+ }
+
+ let allCategory = HomePresentationModel.Category(
+ id: HomeInteractor.allCategoryId,
+ creator: "전체",
+ viedoType: .none
+ )
+
+ async let apiCategories = self.usecase.fetchCategoryList().map { $0.toHomeModel() }
+ async let populars = self.usecase.fetchPopularTripList(id: nil).map { $0.toPopularHomeModel() }
async let recommended = self.usecase.fetchRecommendTripList().map { $0.toRecommendHomeModel() }
-
+
+ let categories = try await [allCategory] + apiCategories
+
let model = try await HomePresentationModel(
banner: myTripBanner,
category: categories,
popularTrip: populars,
recommendedTrip: recommended
)
-
+
guard !Task.isCancelled else { return }
-
- if self.selectedCategoryRelay.value == nil, let firstId = model.category.first?.id {
- self.selectedCategoryRelay.accept(firstId)
+
+ if self.selectedCategoryRelay.value == nil {
+ self.selectedCategoryRelay.accept(HomeInteractor.allCategoryId)
}
-
+
homeDataRelay.accept(model)
presenter.setLoading(false)
} catch let error {
@@ -149,10 +152,37 @@ final class HomeInteractor: PresentableInteractor, HomeInteract
}
}
}
+
+ private func fetchPopularTrips(categoryId: Int) {
+ Task { [weak self] in
+ guard let self else { return }
+
+ do {
+ let apiId: Int? = categoryId == HomeInteractor.allCategoryId ? nil : categoryId
+ let populars = try await self.usecase.fetchPopularTripList(id: apiId).map { $0.toPopularHomeModel() }
+
+ guard !Task.isCancelled, let model = self.homeDataRelay.value else { return }
+
+ let updated = HomePresentationModel(
+ banner: model.banner,
+ category: model.category,
+ popularTrip: populars,
+ recommendedTrip: model.recommendedTrip
+ )
+ self.homeDataRelay.accept(updated)
+ } catch {
+ print(error)
+ }
+ }
+ }
}
// MARK: - HomePresentableListener
extension HomeInteractor: HomePresentableListener {
+ func viewWillAppear() {
+ fetchHomeData()
+ }
+
func reloadBtnTapped() {
fetchHomeData()
}
@@ -168,7 +198,10 @@ extension HomeInteractor: HomePresentableListener {
func itemSelected(item: HomeItem) {
switch item {
case .category(let category, _):
- selectedCategoryRelay.accept(category.id)
+ let categoryId = category.id
+ guard categoryId != selectedCategoryRelay.value else { return }
+ selectedCategoryRelay.accept(categoryId)
+ fetchPopularTrips(categoryId: categoryId)
case .popularTrip(let trip):
listener?.homeDidTapFollowDetail(with: trip.id)
case .recommendedTrip(let trip):
diff --git a/Projects/Features/HomeFeature/Sources/HomeViewController.swift b/Projects/Features/HomeFeature/Sources/HomeViewController.swift
index a662c1d..6161f6e 100644
--- a/Projects/Features/HomeFeature/Sources/HomeViewController.swift
+++ b/Projects/Features/HomeFeature/Sources/HomeViewController.swift
@@ -23,6 +23,7 @@ protocol HomePresentableListener: AnyObject {
func itemSelected(item: HomeItem)
func moreBtnTapped()
func reloadBtnTapped()
+ func viewWillAppear()
}
// MARK: - HomeViewController
@@ -62,6 +63,7 @@ final class HomeViewController: UIViewController, HomeViewControllable {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
+ listener?.viewWillAppear()
}
}
diff --git a/Projects/Features/HomeFeature/Sources/Views/Cells/HomeBannerCell.swift b/Projects/Features/HomeFeature/Sources/Views/Cells/HomeBannerCell.swift
index 2ff02ec..a539516 100644
--- a/Projects/Features/HomeFeature/Sources/Views/Cells/HomeBannerCell.swift
+++ b/Projects/Features/HomeFeature/Sources/Views/Cells/HomeBannerCell.swift
@@ -16,8 +16,8 @@ final class HomeBannerCell: UICollectionViewCell {
private var type: HomeBannerType = .empty
private let emptyView = HomeBannerEmptyView()
- private let upCommingView = HomeBannerUpCommingView()
- private let onGoingView = HomeBannerOnGoingView()
+ private let upCommingView = NDGLUpComingView()
+ private let onGoingView = NDGLOnGoingView()
private let stackView = UIStackView()
override init(frame: CGRect) {
diff --git a/Projects/Features/MainFeature/Sources/MainBuilder.swift b/Projects/Features/MainFeature/Sources/MainBuilder.swift
index e985c49..38b783c 100644
--- a/Projects/Features/MainFeature/Sources/MainBuilder.swift
+++ b/Projects/Features/MainFeature/Sources/MainBuilder.swift
@@ -19,20 +19,25 @@ public protocol MainDependency: Dependency {
var homeUsecase: HomeUsecaseProtocol { get }
var followDetailUsecase: FollowDetailUsecaseProtocol { get }
var templateSearchUsecase: TemplatesSearchUsecaseProtocol { get }
+ var weatherRepository: WeatherRepositoryInterface { get }
}
final class MainComponent: Component, FollowDetailDependency, PopularTravelDependency,SearchDependency, SettingDependency, TabBarDependency {
var searchUsecase: TemplatesSearchUsecaseProtocol {
dependency.templateSearchUsecase
}
-
+
var followDetailUsecase: FollowDetailUsecaseProtocol {
dependency.followDetailUsecase
}
-
+
var homeUsecase: HomeUsecaseProtocol {
dependency.homeUsecase
}
+
+ var weatherRepository: WeatherRepositoryInterface {
+ dependency.weatherRepository
+ }
}
// MARK: - Builder
diff --git a/Projects/Features/MainFeature/Sources/MainInteractor.swift b/Projects/Features/MainFeature/Sources/MainInteractor.swift
index dfa86dd..e61f19f 100644
--- a/Projects/Features/MainFeature/Sources/MainInteractor.swift
+++ b/Projects/Features/MainFeature/Sources/MainInteractor.swift
@@ -21,6 +21,7 @@ public protocol MainRouting: ViewableRouting {
func attachSetting()
func detachSetting()
func attachTabBar()
+ func switchToTab(at index: Int)
}
protocol MainPresentable: Presentable {
@@ -52,8 +53,9 @@ final class MainInteractor: PresentableInteractor, MainInteract
router?.detachFollow()
}
- func followDetailDidAddTrip(title: String, startDate: Date, endDate: Date) {
- // 이건 뭐임
+ func followDetailDidViewTrip() {
+ router?.detachFollow()
+ router?.switchToTab(at: 1)
}
func popularTravelDidTapFollowDetail(with recommendationId: Int) {
diff --git a/Projects/Features/MainFeature/Sources/MainRouter.swift b/Projects/Features/MainFeature/Sources/MainRouter.swift
index f55d55b..6ed769c 100644
--- a/Projects/Features/MainFeature/Sources/MainRouter.swift
+++ b/Projects/Features/MainFeature/Sources/MainRouter.swift
@@ -140,6 +140,10 @@ final class MainRouter: ViewableRouter,
self.settingRouter = nil
}
+ func switchToTab(at index: Int) {
+ tabBarRouter?.switchToTab(at: index)
+ }
+
func attachTabBar() {
guard tabBarRouter == nil else { return }
let router = tabBarBuilder.build(withListener: interactor)
diff --git a/Projects/Features/RootFeature/Sources/RootBuilder.swift b/Projects/Features/RootFeature/Sources/RootBuilder.swift
index 64ffd5e..2b9f59f 100644
--- a/Projects/Features/RootFeature/Sources/RootBuilder.swift
+++ b/Projects/Features/RootFeature/Sources/RootBuilder.swift
@@ -19,6 +19,7 @@ public protocol RootDependency: Dependency {
var authRepository: AuthRepositoryInterface { get }
var tokenRepository: TokenRepositoryProtocol { get }
var templateSearchUsecase: TemplatesSearchUsecaseProtocol { get }
+ var weatherRepository: WeatherRepositoryInterface { get }
}
// MARK: - RootComponent
@@ -27,14 +28,18 @@ final class RootComponent: Component, MainDependency {
var templateSearchUsecase: TemplatesSearchUsecaseProtocol {
dependency.templateSearchUsecase
}
-
+
var followDetailUsecase: FollowDetailUsecaseProtocol {
dependency.followDetailUsecase
}
-
+
var homeUsecase: HomeUsecaseProtocol {
dependency.homeUsecase
}
+
+ var weatherRepository: WeatherRepositoryInterface {
+ dependency.weatherRepository
+ }
}
// MARK: - RootBuildable
diff --git a/Projects/Features/SearchFeature/Sources/SearchBuilder.swift b/Projects/Features/SearchFeature/Sources/SearchBuilder.swift
index f216023..dec196d 100644
--- a/Projects/Features/SearchFeature/Sources/SearchBuilder.swift
+++ b/Projects/Features/SearchFeature/Sources/SearchBuilder.swift
@@ -14,7 +14,7 @@ public protocol SearchDependency: Dependency {
var searchUsecase: TemplatesSearchUsecaseProtocol { get }
}
-final class SearchComponent: Component, SearchResultDependency {
+final class SearchComponent: Component {
var searchUsecase: TemplatesSearchUsecaseProtocol {
dependency.searchUsecase
}
@@ -35,13 +35,10 @@ public final class SearchBuilder: Builder, SearchBuildable {
let viewController = SearchViewController()
let interactor = SearchInteractor(presenter: viewController, usecase: component.searchUsecase)
interactor.listener = listener
-
- let searchResultBuilder = SearchResultBuilder(dependency: component)
-
+
return SearchRouter(
interactor: interactor,
- viewController: viewController,
- searchResultBuilder: searchResultBuilder
+ viewController: viewController
)
}
}
diff --git a/Projects/Features/SearchFeature/Sources/SearchInteractor.swift b/Projects/Features/SearchFeature/Sources/SearchInteractor.swift
index 9746542..5a0320c 100644
--- a/Projects/Features/SearchFeature/Sources/SearchInteractor.swift
+++ b/Projects/Features/SearchFeature/Sources/SearchInteractor.swift
@@ -12,12 +12,14 @@ import RIBs
import RxSwift
public protocol SearchRouting: ViewableRouting {
- func attachSearchResult(keyword: String)
- func detachSearchResult()
}
protocol SearchPresentable: Presentable {
var listener: SearchPresentableListener? { get set }
+
+ func update(with model: SearchResultPresentationModel)
+ func setLoading(_ isLoading: Bool)
+ func showErrorView(_ isError: Bool)
}
public protocol SearchListener: AnyObject {
@@ -30,7 +32,9 @@ final class SearchInteractor: PresentableInteractor, SearchIn
weak var listener: SearchListener?
private let usecase: TemplatesSearchUsecaseProtocol
-
+ private var fetchDataTask: Task?
+ private var lastKeyword: String?
+
init(presenter: SearchPresentable, usecase: TemplatesSearchUsecaseProtocol) {
self.usecase = usecase
super.init(presenter: presenter)
@@ -39,27 +43,56 @@ final class SearchInteractor: PresentableInteractor, SearchIn
override func didBecomeActive() {
super.didBecomeActive()
- // TODO: Implement business logic here.
}
override func willResignActive() {
super.willResignActive()
- // TODO: Pause any business logic.
+
+ fetchDataTask?.cancel()
+ fetchDataTask = nil
}
-
+
func search(keyword: String) {
- router?.attachSearchResult(keyword: keyword)
+ lastKeyword = keyword
+ fetchData(keyword: keyword)
}
-
- func detachSearchResult() {
- router?.detachSearchResult()
+
+ func itemSelected(item: SearchResultItem) {
+ switch item {
+ case .resultTrip(let trip):
+ listener?.attachFollowDetail(with: trip.id)
+ }
}
-
- func popularTravelDidTapFollowDetail(with recommendationId: Int) {
- listener?.attachFollowDetail(with: recommendationId)
+
+ func reloadBtnTapped() {
+ guard let keyword = lastKeyword else { return }
+ fetchData(keyword: keyword)
}
-
+
func detachSearch() {
listener?.detachSearch()
}
+
+ private func fetchData(keyword: String) {
+ 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: keyword)
+
+ let model = SearchResultPresentationModel(resultTrip: result.map { $0.toSearchResultModel() })
+
+ self.presenter.update(with: model)
+ self.presenter.setLoading(false)
+ } catch {
+ self.presenter.setLoading(false)
+ self.presenter.showErrorView(true)
+ }
+ }
+ }
}
diff --git a/Projects/Features/SearchFeature/Sources/SearchRouter.swift b/Projects/Features/SearchFeature/Sources/SearchRouter.swift
index b1b0999..576e885 100644
--- a/Projects/Features/SearchFeature/Sources/SearchRouter.swift
+++ b/Projects/Features/SearchFeature/Sources/SearchRouter.swift
@@ -8,45 +8,21 @@
import RIBs
-protocol SearchInteractable: Interactable, SearchResultListener {
+protocol SearchInteractable: Interactable {
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 {
- private let searchResultBuilder: SearchResultBuildable
- private var searchResultRouter: SearchResultRouting?
-
- init(
+
+ override init(
interactor: SearchInteractable,
- viewController: SearchViewControllable,
- searchResultBuilder: SearchResultBuildable
+ viewController: SearchViewControllable
) {
- 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 3aedc6c..4656cfc 100644
--- a/Projects/Features/SearchFeature/Sources/SearchViewController.swift
+++ b/Projects/Features/SearchFeature/Sources/SearchViewController.swift
@@ -15,108 +15,195 @@ import RxSwift
protocol SearchPresentableListener: AnyObject {
func search(keyword: String)
- func detachSearchResult()
func detachSearch()
+ func itemSelected(item: SearchResultItem)
+ func reloadBtnTapped()
}
final class SearchViewController: UIViewController, SearchPresentable, SearchViewControllable {
weak var listener: SearchPresentableListener?
-
+
private let searchBar = NDGLSearchBar(
placeholder: "검색어를 입력하세요",
DSKitAsset.Assets.icChevronLeft3.image,
DSKitAsset.Assets.icSearch2.image
)
-
- private let contentNavigationController = UINavigationController()
- private let emptyView = EmptyView()
+
+ private let startEmptyView = EmptyView()
+ private let noResultEmptyView = EmptyView()
+ private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())
+ private let loadingIndicator = UIActivityIndicatorView(style: .medium)
+ private let networkErrorView = NDGLErrorView()
+ private var dataSource: UICollectionViewDiffableDataSource!
+
+ private let contentContainerView = UIView()
private let disposeBag = DisposeBag()
-
+
override func viewDidLoad() {
super.viewDidLoad()
-
+
hideKeyboard()
setStyle()
setUI()
- setContentNavigation()
setLayout()
+ setCollectionView()
+ setDataSource()
searchBar.focus()
bindKeyboard()
setupActions()
+ bindInteractor()
}
-
+
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
-
+
if isMovingFromParent {
- contentNavigationController.interactivePopGestureRecognizer?.delegate = nil
listener?.detachSearch()
}
}
-
- func pushChild(_ viewControllable: ViewControllable) {
- contentNavigationController.pushViewController(
- viewControllable.uiviewController,
- animated: true
- )
+}
+
+// MARK: - SearchPresentable
+
+extension SearchViewController {
+ 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.startEmptyView.isHidden = true
+ self.noResultEmptyView.isHidden = !isEmpty
+ self.collectionView.isHidden = isEmpty
+ }
}
-
- func popChild(_ animated: Bool) {
- contentNavigationController.popViewController(animated: animated)
+
+ func setLoading(_ isLoading: Bool) {
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+
+ if isLoading {
+ self.startEmptyView.isHidden = true
+ 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
+ }
}
}
private extension SearchViewController {
func setStyle() {
view.backgroundColor = DSKitAsset.Colors.white.color
-
- contentNavigationController.do {
- $0.isNavigationBarHidden = true
- $0.view.backgroundColor = .clear
+
+ contentContainerView.do {
+ $0.backgroundColor = .clear
+ }
+
+ 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)
+ $0.isHidden = true
+ }
+
+ loadingIndicator.do {
+ $0.color = DSKitAsset.Colors.green300.color
+ }
+
+ networkErrorView.do {
+ $0.isHidden = true
+ }
+
+ noResultEmptyView.do {
+ $0.isHidden = true
+ $0.changeType(.noResults)
+ }
+
+ startEmptyView.do {
+ $0.changeType(.start)
}
}
-
+
func setUI() {
- view.addSubviews(searchBar)
+ view.addSubviews(searchBar, contentContainerView)
+ contentContainerView.addSubviews(startEmptyView, collectionView, loadingIndicator, networkErrorView, noResultEmptyView)
}
-
+
func setLayout() {
searchBar.snp.makeConstraints {
$0.top.equalTo(view.safeAreaLayoutGuide)
$0.directionalHorizontalEdges.equalToSuperview()
}
-
- contentNavigationController.view.snp.makeConstraints {
+
+ contentContainerView.snp.makeConstraints {
$0.top.equalTo(searchBar.snp.bottom)
$0.directionalHorizontalEdges.bottom.equalToSuperview()
}
+
+ startEmptyView.snp.makeConstraints {
+ $0.edges.equalToSuperview()
+ }
+
+ collectionView.snp.makeConstraints {
+ $0.edges.equalToSuperview()
+ }
+
+ loadingIndicator.snp.makeConstraints {
+ $0.center.equalToSuperview()
+ }
+
+ networkErrorView.snp.makeConstraints {
+ $0.top.equalToSuperview()
+ $0.directionalHorizontalEdges.equalToSuperview()
+ $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16.adjustedH)
+ }
+
+ noResultEmptyView.snp.makeConstraints {
+ $0.edges.equalToSuperview()
+ }
}
-
- func setContentNavigation() {
- let rootVC = UIViewController()
- rootVC.view.backgroundColor = DSKitAsset.Colors.white.color
- rootVC.view.addSubview(emptyView)
- emptyView.snp.makeConstraints { $0.edges.equalToSuperview() }
-
- contentNavigationController.setViewControllers([rootVC], animated: false)
- addChild(contentNavigationController)
- view.addSubview(contentNavigationController.view)
- contentNavigationController.didMove(toParent: self)
+ func setCollectionView() {
+ collectionView.do {
+ $0.register(
+ PopularInfoCell.self,
+ forCellWithReuseIdentifier: PopularInfoCell.cellIdentifier
+ )
- contentNavigationController.interactivePopGestureRecognizer?.delegate = self
+ $0.register(
+ SearchResultHeaderView.self,
+ forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
+ withReuseIdentifier: SearchResultHeaderView.reusableViewIdentifier
+ )
+ }
}
-
+
func bindKeyboard() {
NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification)
.subscribe(onNext: { [weak self] notification in
guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }
let keyboardHeight = keyboardFrame.cgRectValue.height
-
- self?.contentNavigationController.view.snp.updateConstraints {
+
+ self?.contentContainerView.snp.updateConstraints {
$0.bottom.equalToSuperview().inset(keyboardHeight)
}
-
+
UIView.animate(withDuration: 0.3) {
self?.view.layoutIfNeeded()
}
@@ -125,28 +212,24 @@ private extension SearchViewController {
NotificationCenter.default.rx.notification(UIResponder.keyboardWillHideNotification)
.subscribe(onNext: { [weak self] _ in
- self?.contentNavigationController.view.snp.updateConstraints {
+ self?.contentContainerView.snp.updateConstraints {
$0.bottom.equalToSuperview()
}
-
+
UIView.animate(withDuration: 0.3) {
self?.view.layoutIfNeeded()
}
})
.disposed(by: disposeBag)
}
-
+
func setupActions() {
searchBar.leadingButtonDidTap
.subscribe(with: self) { owner, _ in
- if owner.contentNavigationController.viewControllers.count > 1 {
- owner.contentNavigationController.popViewController(animated: true)
- } else {
- owner.listener?.detachSearch()
- }
+ owner.listener?.detachSearch()
}
.disposed(by: disposeBag)
-
+
searchBar.searchButtonClicked
.withLatestFrom(searchBar.searchText)
.compactMap { $0 }
@@ -155,7 +238,7 @@ private extension SearchViewController {
owner.listener?.search(keyword: text)
}
.disposed(by: disposeBag)
-
+
searchBar.trailingButtonDidTap
.withLatestFrom(searchBar.searchText)
.compactMap { $0 }
@@ -164,26 +247,177 @@ private extension SearchViewController {
owner.listener?.search(keyword: text)
}
.disposed(by: disposeBag)
-
- searchBar.editingDidBegin
+ }
+
+ 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
- if owner.contentNavigationController.viewControllers.count > 1 {
- owner.contentNavigationController.popViewController(animated: false)
- }
+ owner.listener?.reloadBtnTapped()
}
.disposed(by: disposeBag)
}
}
-extension SearchViewController: UIGestureRecognizerDelegate {
- func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
- return gestureRecognizer == contentNavigationController.interactivePopGestureRecognizer
+// MARK: - CompositionalLayout
+
+extension SearchViewController {
+ 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()
+ }
+ }
}
- func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
- if gestureRecognizer == contentNavigationController.interactivePopGestureRecognizer {
- return contentNavigationController.viewControllers.count > 1
+ private 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
+ }
+
+ private 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
+ }
+}
+
+// MARK: - Registration
+
+extension SearchViewController {
+ 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)
}
- return true
}
}
diff --git a/Projects/Features/TabBarFeature/Project.swift b/Projects/Features/TabBarFeature/Project.swift
index 7f7d9af..7fbb8f0 100644
--- a/Projects/Features/TabBarFeature/Project.swift
+++ b/Projects/Features/TabBarFeature/Project.swift
@@ -16,7 +16,8 @@ let project = Project.makeModule(
name: "TabBarFeature",
dependencies: [
.Features.Home.feature,
- .Features.Travel.feature
+ .Features.Travel.feature,
+ .Features.TravelTool.feature
],
scripts: [.swiftLint],
isStatic: true,
diff --git a/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift b/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift
index 9191e3d..1174f6b 100644
--- a/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift
+++ b/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift
@@ -10,19 +10,25 @@ import Domain
import HomeFeature
import RIBs
import TravelFeature
+import TravelToolFeature
// MARK: - TabBarDependency
public protocol TabBarDependency: Dependency {
var homeUsecase: HomeUsecaseProtocol { get }
+ var weatherRepository: WeatherRepositoryInterface { get }
}
// MARK: - TabBarComponent
-final class TabBarComponent: Component, HomeDependency, TravelDependency {
+final class TabBarComponent: Component, HomeDependency, TravelDependency, TravelToolDependency {
var homeUsecase: HomeUsecaseProtocol {
dependency.homeUsecase
}
+
+ var weatherRepository: WeatherRepositoryInterface {
+ dependency.weatherRepository
+ }
}
// MARK: - TabBarBuildable
@@ -47,12 +53,14 @@ public final class TabBarBuilder: Builder, TabBarBuildable {
let homeBuilder = HomeBuilder(dependency: component)
let travelBuilder = TravelBuilder(dependency: component)
+ let travelToolBuilder = TravelToolBuilder(dependency: component)
let router = TabBarRouter(
interactor: interactor,
viewController: viewController,
homeBuilder: homeBuilder,
- travelBuilder: travelBuilder
+ travelBuilder: travelBuilder,
+ travelToolBuilder: travelToolBuilder
)
return router
diff --git a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift
index e5fdc77..f2c055d 100644
--- a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift
+++ b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift
@@ -10,11 +10,13 @@ import Foundation
import HomeFeature
import RIBs
import RxSwift
+import TravelToolFeature
// MARK: - TabBarRouting
public protocol TabBarRouting: ViewableRouting {
func attachTabs()
+ func switchToTab(at index: Int)
}
// MARK: - TabBarPresentable
@@ -85,3 +87,8 @@ extension TabBarInteractor: HomeListener {
presenter.switchToTab(at: 2)
}
}
+
+// MARK: - TravelToolListener
+
+extension TabBarInteractor: TravelToolListener {
+}
diff --git a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift
index aec5c6d..7f4ba55 100644
--- a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift
+++ b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift
@@ -10,10 +10,11 @@ import RIBs
import HomeFeature
import TravelFeature
+import TravelToolFeature
// MARK: - TabBarInteractable
-protocol TabBarInteractable: Interactable, HomeListener, TravelListener {
+protocol TabBarInteractable: Interactable, HomeListener, TravelListener, TravelToolListener {
var router: TabBarRouting? { get set }
var listener: TabBarListener? { get set }
}
@@ -22,6 +23,7 @@ protocol TabBarInteractable: Interactable, HomeListener, TravelListener {
public protocol TabBarViewControllable: ViewControllable {
func setViewControllers(_ viewControllers: [ViewControllable])
+ func switchToTab(at index: Int)
}
// MARK: - TabBarRouter
@@ -30,17 +32,21 @@ final class TabBarRouter: ViewableRouter= 2 else {
+ guard viewControllers.count >= 3 else {
return
}
- let homeVC = viewControllers[0].uiviewController
- let travelVC = viewControllers[1].uiviewController
+ let travelToolVC = viewControllers[0].uiviewController
+ let homeVC = viewControllers[1].uiviewController
+ let travelVC = viewControllers[2].uiviewController
- let infoDummy = UIViewController().then { $0.view.backgroundColor = .white }
-
- let infoNav = UINavigationController(rootViewController: infoDummy)
+ let travelToolNav = UINavigationController(rootViewController: travelToolVC)
let homeNav = UINavigationController(rootViewController: homeVC)
let travelNav = UINavigationController(rootViewController: travelVC)
- super.setViewControllers([infoNav, homeNav, travelNav], animated: false)
+ super.setViewControllers([travelToolNav, homeNav, travelNav], animated: false)
setupTabItems()
}
- func switchToTab(at index: Int) {
+ public func switchToTab(at index: Int) {
guard index < tabItems.count else { return }
updateSelection(at: index)
diff --git a/Projects/Features/TravelFeature/Sources/TravelInteractor.swift b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift
index 42b410e..e4da809 100644
--- a/Projects/Features/TravelFeature/Sources/TravelInteractor.swift
+++ b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift
@@ -29,7 +29,7 @@ protocol TravelPresentable: Presentable {
protocol TravelPresentableListener: AnyObject {
func didTapTrip(_ trip: UpcomingTrip)
- func didTapMenuButton()
+ func viewWillAppear()
}
// MARK: - TravelInteractor
@@ -113,9 +113,10 @@ final class TravelInteractor: PresentableInteractor, TravelIn
extension TravelInteractor: TravelPresentableListener {
- func didTapTrip(_ trip: UpcomingTrip) {
+ func viewWillAppear() {
+ loadTrips()
}
- func didTapMenuButton() {
+ func didTapTrip(_ trip: UpcomingTrip) {
}
}
diff --git a/Projects/Features/TravelFeature/Sources/TravelViewController.swift b/Projects/Features/TravelFeature/Sources/TravelViewController.swift
index 3136ab5..ad57434 100644
--- a/Projects/Features/TravelFeature/Sources/TravelViewController.swift
+++ b/Projects/Features/TravelFeature/Sources/TravelViewController.swift
@@ -27,14 +27,9 @@ final class TravelViewController: UIViewController, TravelPresentable, TravelVie
// MARK: - UI Components
- private let titleLabel = UILabel().then {
- $0.setText(.subTitleLSB, text: "다가오는 여행", color: UIColor(hexCode: "#111111"))
- }
-
- private let menuButton = UIButton(type: .system).then {
- $0.setImage(UIImage(systemName: "line.3.horizontal"), for: .normal)
- $0.tintColor = UIColor(hexCode: "#111111")
- }
+ private let navigationBar = NDGLNavigationBar(
+ title: "다가오는 여행"
+ )
private let collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
@@ -70,6 +65,7 @@ final class TravelViewController: UIViewController, TravelPresentable, TravelVie
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
+ listener?.viewWillAppear()
}
// MARK: - Setup
@@ -77,25 +73,19 @@ final class TravelViewController: UIViewController, TravelPresentable, TravelVie
private func setupUI() {
view.backgroundColor = UIColor(hexCode: "#FFFFFF")
- [titleLabel, menuButton, collectionView, emptyStateLabel, loadingIndicator].forEach {
+ [navigationBar, collectionView, emptyStateLabel, loadingIndicator].forEach {
view.addSubview($0)
}
}
private func setupConstraints() {
- titleLabel.snp.makeConstraints {
- $0.top.equalTo(view.safeAreaLayoutGuide).offset(16)
- $0.leading.equalToSuperview().offset(24)
- }
-
- menuButton.snp.makeConstraints {
- $0.centerY.equalTo(titleLabel)
- $0.trailing.equalToSuperview().offset(-24)
- $0.size.equalTo(24)
+ navigationBar.snp.makeConstraints {
+ $0.top.equalTo(view.safeAreaLayoutGuide)
+ $0.horizontalEdges.equalToSuperview()
}
collectionView.snp.makeConstraints {
- $0.top.equalTo(titleLabel.snp.bottom).offset(24)
+ $0.top.equalTo(navigationBar.snp.bottom).offset(24)
$0.leading.equalToSuperview().offset(24)
$0.trailing.equalToSuperview().offset(-24)
$0.bottom.equalToSuperview()
@@ -117,13 +107,6 @@ final class TravelViewController: UIViewController, TravelPresentable, TravelVie
}
private func setupActions() {
- menuButton.addTarget(self, action: #selector(menuButtonTapped), for: .touchUpInside)
- }
-
- // MARK: - Actions
-
- @objc private func menuButtonTapped() {
- listener?.didTapMenuButton()
}
}
diff --git a/Projects/Features/TravelToolFeature/Project.swift b/Projects/Features/TravelToolFeature/Project.swift
new file mode 100644
index 0000000..3f6ef9e
--- /dev/null
+++ b/Projects/Features/TravelToolFeature/Project.swift
@@ -0,0 +1,25 @@
+//
+// Project.swift
+// TravelToolFeature
+//
+// Created by kimnahun on 2026-02-21.
+//
+
+import ProjectDescription
+import ProjectDescriptionHelpers
+import DependencyPlugin
+
+let project = Project.makeModule(
+ name: "TravelToolFeature",
+ targets: [
+ .makeFrameworkTarget(
+ name: "TravelToolFeature",
+ dependencies: [
+ .Features.baseFeatureDependency
+ ],
+ scripts: [.swiftLint],
+ isStatic: true,
+ hasResources: false
+ )
+ ]
+)
diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift
new file mode 100644
index 0000000..6d01a6c
--- /dev/null
+++ b/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift
@@ -0,0 +1,62 @@
+//
+// TravelToolBuilder.swift
+// TravelToolFeature
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import Domain
+import RIBs
+
+// MARK: - TravelToolDependency
+
+public protocol TravelToolDependency: Dependency {
+ var homeUsecase: HomeUsecaseProtocol { get }
+ var weatherRepository: WeatherRepositoryInterface { get }
+}
+
+// MARK: - TravelToolComponent
+
+final class TravelToolComponent: Component {
+ var homeUsecase: HomeUsecaseProtocol {
+ dependency.homeUsecase
+ }
+
+ var weatherRepository: WeatherRepositoryInterface {
+ dependency.weatherRepository
+ }
+}
+
+// MARK: - TravelToolBuildable
+
+public protocol TravelToolBuildable: Buildable {
+ func build(withListener listener: TravelToolListener) -> TravelToolRouting
+}
+
+// MARK: - TravelToolBuilder
+
+public final class TravelToolBuilder: Builder, TravelToolBuildable {
+
+ public override init(dependency: TravelToolDependency) {
+ super.init(dependency: dependency)
+ }
+
+ public func build(withListener listener: TravelToolListener) -> TravelToolRouting {
+ let component = TravelToolComponent(dependency: dependency)
+ let viewController = TravelToolViewController()
+ let interactor = TravelToolInteractor(
+ presenter: viewController,
+ usecase: component.homeUsecase,
+ weatherRepository: component.weatherRepository
+ )
+ interactor.listener = listener
+
+ let router = TravelToolRouter(
+ interactor: interactor,
+ viewController: viewController
+ )
+
+ return router
+ }
+}
diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift
new file mode 100644
index 0000000..58a0183
--- /dev/null
+++ b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift
@@ -0,0 +1,214 @@
+//
+// TravelToolInteractor.swift
+// TravelToolFeature
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import Foundation
+
+import Domain
+import DSKit
+import RIBs
+import RxSwift
+
+// MARK: - TravelToolListener
+
+public protocol TravelToolListener: AnyObject {
+}
+
+// MARK: - TravelToolPresentable
+
+protocol TravelToolPresentable: Presentable {
+ var listener: TravelToolPresentableListener? { get set }
+
+ func updateTripCard(_ state: TravelToolTripState)
+ func updateWeather(_ state: TravelToolWeatherState)
+}
+
+// MARK: - TravelToolPresentableListener
+
+protocol TravelToolPresentableListener: AnyObject {
+ func viewWillAppear()
+}
+
+// MARK: - TravelToolInteractor
+
+final class TravelToolInteractor: PresentableInteractor, TravelToolInteractable {
+
+ weak var router: TravelToolRouting?
+ weak var listener: TravelToolListener?
+
+ private let usecase: HomeUsecaseProtocol
+ private let weatherRepository: WeatherRepositoryInterface
+ private var fetchTask: Task?
+ private let disposeBag = DisposeBag()
+
+ init(
+ presenter: TravelToolPresentable,
+ usecase: HomeUsecaseProtocol,
+ weatherRepository: WeatherRepositoryInterface
+ ) {
+ self.usecase = usecase
+ self.weatherRepository = weatherRepository
+ super.init(presenter: presenter)
+ presenter.listener = self
+ }
+
+ override func didBecomeActive() {
+ super.didBecomeActive()
+
+ fetchTripInfo()
+ }
+
+ override func willResignActive() {
+ super.willResignActive()
+
+ fetchTask?.cancel()
+ fetchTask = nil
+ }
+
+ private func fetchTripInfo() {
+ fetchTask?.cancel()
+
+ fetchTask = Task { [weak self] in
+ guard let self else { return }
+
+ // 1. 여행 정보 조회
+ var summary: MyTripSummary?
+ do {
+ summary = try await self.usecase.fetchMyTripInfo()
+ } catch {
+ summary = nil
+ }
+
+ guard !Task.isCancelled else { return }
+
+ // 2. 트립 카드 업데이트
+ let tripState: TravelToolTripState
+ if let summary {
+ tripState = self.convertToState(summary)
+ } else {
+ tripState = .empty
+ }
+
+ await MainActor.run { [tripState] in
+ self.presenter.updateTripCard(tripState)
+ }
+
+ // 3. 여행이 없으면 noTrip 상태
+ guard let summary else {
+ await MainActor.run {
+ self.presenter.updateWeather(.noTrip)
+ }
+ return
+ }
+
+ guard !Task.isCancelled else { return }
+
+ // 4. 여행 기간 일수 계산
+ let calendar = Calendar.current
+ let startOfToday = calendar.startOfDay(for: Date())
+ let startOfEnd = calendar.startOfDay(for: summary.endDay)
+ let daysFromToday = (calendar.dateComponents([.day], from: startOfToday, to: startOfEnd).day ?? 0) + 1
+
+ guard daysFromToday > 0 else {
+ await MainActor.run {
+ self.presenter.updateWeather(.preparing)
+ }
+ return
+ }
+
+ // 5. 날씨 예보 조회
+ let forecastDays = min(daysFromToday, 10)
+ var forecasts: [DailyWeatherInfo]?
+
+ do {
+ let all = try await self.weatherRepository.fetchForecast(
+ latitude: summary.tripSchedule.latitude,
+ longitude: summary.tripSchedule.longitude,
+ days: forecastDays
+ )
+
+ let startOfTravel = calendar.startOfDay(for: summary.startDay)
+ let endOfTravel = calendar.startOfDay(for: summary.endDay)
+
+ let filtered = all.filter { info in
+ let day = calendar.startOfDay(for: info.date)
+ return day >= startOfTravel && day <= endOfTravel
+ }
+
+ forecasts = filtered.isEmpty ? nil : filtered
+ } catch {
+ forecasts = nil
+ }
+
+ guard !Task.isCancelled else { return }
+
+ // 6. 날씨 뷰 업데이트
+ let city = summary.city
+ await MainActor.run { [forecasts] in
+ if let forecasts {
+ self.presenter.updateWeather(
+ .hasWeather(title: city, forecasts: forecasts)
+ )
+ } else {
+ self.presenter.updateWeather(.preparing)
+ }
+ }
+ }
+ }
+
+ private func convertToState(_ summary: MyTripSummary) -> TravelToolTripState {
+ let calendar = Calendar.current
+ let now = Date()
+ let startOfToday = calendar.startOfDay(for: now)
+ let startOfTravel = calendar.startOfDay(for: summary.startDay)
+ let startOfEnd = calendar.startOfDay(for: summary.endDay)
+
+ let duration = "\(summary.startDay.toTravelToolKoreanMMdd())~\(summary.endDay.toTravelToolKoreanMMdd())"
+
+ if startOfToday >= startOfTravel && startOfToday <= startOfEnd {
+ let schedule = summary.tripSchedule
+ return .onGoing(
+ title: "\(summary.title) \(schedule.day)일차 입니다!",
+ date: duration,
+ transportIcon: DSKitAsset.Assets.icBus2.image,
+ transport: "대중교통",
+ duration: "\(schedule.estimatedDuration)분",
+ place: schedule.placeName,
+ imageUrl: schedule.thumbnailUrl
+ )
+ } else if startOfToday < startOfTravel {
+ let dDayValue = calendar.dateComponents([.day], from: startOfToday, to: startOfTravel).day ?? 0
+ return .upComing(
+ title: summary.title,
+ date: duration,
+ dDay: dDayValue,
+ imageUrl: summary.tripSchedule.thumbnailUrl
+ )
+ } else {
+ return .empty
+ }
+ }
+}
+
+// MARK: - TravelToolPresentableListener
+
+extension TravelToolInteractor: TravelToolPresentableListener {
+ func viewWillAppear() {
+ fetchTripInfo()
+ }
+}
+
+// MARK: - Date Extension
+
+extension Date {
+ func toTravelToolKoreanMMdd() -> String {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "ko_KR")
+ formatter.dateFormat = "M월 d일"
+ return formatter.string(from: self)
+ }
+}
diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolRouter.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolRouter.swift
new file mode 100644
index 0000000..b374110
--- /dev/null
+++ b/Projects/Features/TravelToolFeature/Sources/TravelToolRouter.swift
@@ -0,0 +1,39 @@
+//
+// TravelToolRouter.swift
+// TravelToolFeature
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import RIBs
+
+// MARK: - TravelToolInteractable
+
+protocol TravelToolInteractable: Interactable {
+ var router: TravelToolRouting? { get set }
+ var listener: TravelToolListener? { get set }
+}
+
+// MARK: - TravelToolViewControllable
+
+public protocol TravelToolViewControllable: ViewControllable {
+}
+
+// MARK: - TravelToolRouting
+
+public protocol TravelToolRouting: ViewableRouting {
+}
+
+// MARK: - TravelToolRouter
+
+final class TravelToolRouter: ViewableRouter, TravelToolRouting {
+
+ override init(
+ interactor: TravelToolInteractable,
+ viewController: TravelToolViewControllable
+ ) {
+ super.init(interactor: interactor, viewController: viewController)
+ interactor.router = self
+ }
+}
diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift
new file mode 100644
index 0000000..702c3fb
--- /dev/null
+++ b/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift
@@ -0,0 +1,76 @@
+//
+// TravelToolViewController.swift
+// TravelToolFeature
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import RIBs
+import UIKit
+
+import Domain
+import DSKit
+
+// MARK: - TravelToolViewController
+
+final class TravelToolViewController: UIViewController, TravelToolPresentable, TravelToolViewControllable {
+
+ // MARK: - Properties
+
+ weak var listener: TravelToolPresentableListener?
+
+ // MARK: - UI
+
+ private let tripCardView = TravelToolTripCardView()
+ private let weatherView = TravelToolWeatherView()
+
+ // MARK: - Lifecycle
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ setStyle()
+ setUI()
+ setLayout()
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ listener?.viewWillAppear()
+ }
+
+ // MARK: - TravelToolPresentable
+
+ func updateTripCard(_ state: TravelToolTripState) {
+ tripCardView.configure(state)
+ }
+
+ func updateWeather(_ state: TravelToolWeatherState) {
+ weatherView.configure(state)
+ }
+}
+
+// MARK: - Private
+
+private extension TravelToolViewController {
+ func setStyle() {
+ view.backgroundColor = .white
+ }
+
+ func setUI() {
+ view.addSubviews(tripCardView, weatherView)
+ }
+
+ func setLayout() {
+ tripCardView.snp.makeConstraints {
+ $0.top.equalTo(view.safeAreaLayoutGuide)
+ $0.directionalHorizontalEdges.equalToSuperview().inset(24.adjusted)
+ }
+
+ weatherView.snp.makeConstraints {
+ $0.top.equalTo(tripCardView.snp.bottom).offset(16.adjustedH)
+ $0.directionalHorizontalEdges.equalToSuperview().inset(24.adjusted)
+ }
+ }
+}
diff --git a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolEmptyView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolEmptyView.swift
new file mode 100644
index 0000000..4f99ef1
--- /dev/null
+++ b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolEmptyView.swift
@@ -0,0 +1,81 @@
+//
+// TravelToolEmptyView.swift
+// TravelToolFeature
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import UIKit
+
+import DSKit
+
+final class TravelToolEmptyView: UIView {
+ private let titleLabel = UILabel()
+ private let subTitleLabel = UILabel()
+ private let imageView = UIImageView()
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ setStyle()
+ setUI()
+ setLayout()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
+
+private extension TravelToolEmptyView {
+ func setStyle() {
+ backgroundColor = .clear
+
+ titleLabel.do {
+ $0.setText(
+ .bodyLSB,
+ text: "아직 등록된 여행지가 없어요",
+ color: DSKitAsset.Colors.black700.color
+ )
+ }
+
+ subTitleLabel.do {
+ $0.setText(
+ .bodyMM,
+ text: "새 여행 일정을 만들어 보세요!",
+ color: DSKitAsset.Colors.black400.color
+ )
+ }
+
+ imageView.do {
+ $0.image = DSKitAsset.Assets.icEmptyTrip.image
+ $0.contentMode = .scaleAspectFit
+ }
+ }
+
+ func setUI() {
+ addSubviews(titleLabel, subTitleLabel, imageView)
+ }
+
+ func setLayout() {
+ titleLabel.snp.makeConstraints {
+ $0.top.equalToSuperview().inset(17.5.adjustedH)
+ $0.leading.equalToSuperview().inset(16.adjusted)
+ $0.trailing.lessThanOrEqualTo(imageView.snp.leading)
+ }
+
+ subTitleLabel.snp.makeConstraints {
+ $0.top.equalTo(titleLabel.snp.bottom).offset(6.adjustedH)
+ $0.leading.equalToSuperview().inset(16.adjusted)
+ $0.trailing.lessThanOrEqualTo(imageView.snp.leading)
+ $0.bottom.equalToSuperview().inset(17.5.adjustedH)
+ }
+
+ imageView.snp.makeConstraints {
+ $0.trailing.equalToSuperview().inset(16.adjusted)
+ $0.centerY.equalToSuperview()
+ $0.size.equalTo(76.adjustedH)
+ }
+ }
+}
diff --git a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolTripCardView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolTripCardView.swift
new file mode 100644
index 0000000..92a9152
--- /dev/null
+++ b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolTripCardView.swift
@@ -0,0 +1,92 @@
+//
+// TravelToolTripCardView.swift
+// TravelToolFeature
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import UIKit
+
+import DSKit
+
+enum TravelToolTripState {
+ case empty
+ case upComing(title: String, date: String, dDay: Int, imageUrl: String)
+ case onGoing(
+ title: String,
+ date: String,
+ transportIcon: UIImage?,
+ transport: String,
+ duration: String,
+ place: String,
+ imageUrl: String
+ )
+}
+
+final class TravelToolTripCardView: UIView {
+ private let emptyView = TravelToolEmptyView()
+ private let upComingView = NDGLUpComingView()
+ private let onGoingView = NDGLOnGoingView()
+ private let stackView = UIStackView()
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ setStyle()
+ setUI()
+ setLayout()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func configure(_ state: TravelToolTripState) {
+ [emptyView, upComingView, onGoingView].forEach { $0.isHidden = true }
+
+ switch state {
+ case .empty:
+ emptyView.isHidden = false
+ case .upComing(let title, let date, let dDay, let imageUrl):
+ upComingView.isHidden = false
+ upComingView.configure(title: title, date: date, dDay: dDay, imageUrl: imageUrl)
+ case .onGoing(let title, let date, let transportIcon, let transport, let duration, let place, let imageUrl):
+ onGoingView.isHidden = false
+ onGoingView.configure(
+ title: title,
+ date: date,
+ transportIcon: transportIcon,
+ transport: transport,
+ duration: duration,
+ place: place,
+ imageUrl: imageUrl
+ )
+ }
+ }
+}
+
+private extension TravelToolTripCardView {
+ func setStyle() {
+ backgroundColor = DSKitAsset.Colors.black50.color
+ layer.cornerRadius = 8.adjustedH
+ clipsToBounds = true
+
+ stackView.do {
+ $0.axis = .vertical
+ $0.alignment = .fill
+ $0.distribution = .fill
+ }
+ }
+
+ func setUI() {
+ addSubview(stackView)
+ stackView.addArrangedSubviews(emptyView, upComingView, onGoingView)
+ }
+
+ func setLayout() {
+ stackView.snp.makeConstraints {
+ $0.edges.equalToSuperview()
+ }
+ }
+}
diff --git a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift
new file mode 100644
index 0000000..2c97e27
--- /dev/null
+++ b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift
@@ -0,0 +1,341 @@
+//
+// TravelToolWeatherView.swift
+// TravelToolFeature
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import UIKit
+
+import Domain
+import DSKit
+
+// MARK: - Weather State
+
+enum TravelToolWeatherState {
+ case noTrip
+ case preparing
+ case hasWeather(title: String, forecasts: [DailyWeatherInfo])
+}
+
+// MARK: - TravelToolWeatherView
+
+final class TravelToolWeatherView: UIView {
+ private let titleLabel = UILabel()
+ private let contentStackView = UIStackView()
+ private let noTripView = WeatherEmptyContentView(
+ message: "아직 예정된 여행이 없어요.",
+ subMessage: "따라가기 영상을 담아두면 여행 준비가 쉬워져요."
+ )
+ private let preparingView = WeatherEmptyContentView(
+ message: "아직 날씨 정보를 준비하고 있어요.",
+ subMessage: nil
+ )
+ private let collectionView: UICollectionView = {
+ let layout = UICollectionViewFlowLayout()
+ layout.scrollDirection = .horizontal
+ layout.minimumInteritemSpacing = 0
+ layout.minimumLineSpacing = 0
+ let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
+ cv.showsHorizontalScrollIndicator = false
+ cv.backgroundColor = .clear
+ cv.register(WeatherDayCell.self, forCellWithReuseIdentifier: WeatherDayCell.identifier)
+ return cv
+ }()
+
+ private var forecasts: [DailyWeatherInfo] = []
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ setStyle()
+ setUI()
+ setLayout()
+ collectionView.dataSource = self
+ collectionView.delegate = self
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func configure(_ state: TravelToolWeatherState) {
+ noTripView.isHidden = true
+ preparingView.isHidden = true
+ collectionView.isHidden = true
+
+ switch state {
+ case .noTrip:
+ titleLabel.setText(.subTitleMSB, text: "여행 중 날씨", color: DSKitAsset.Colors.black700.color)
+ noTripView.isHidden = false
+
+ case .preparing:
+ titleLabel.setText(.subTitleMSB, text: "여행 중 날씨", color: DSKitAsset.Colors.black700.color)
+ preparingView.isHidden = false
+
+ case .hasWeather(let title, let forecasts):
+ titleLabel.setText(.subTitleMSB, text: "\(title) 여행 중 날씨", color: DSKitAsset.Colors.black700.color)
+ self.forecasts = forecasts
+ collectionView.isHidden = false
+ collectionView.reloadData()
+ }
+ }
+}
+
+// MARK: - Private
+
+private extension TravelToolWeatherView {
+ func setStyle() {
+ backgroundColor = .clear
+
+ titleLabel.do {
+ $0.numberOfLines = 1
+ $0.lineBreakMode = .byTruncatingTail
+ }
+
+ contentStackView.do {
+ $0.axis = .vertical
+ $0.alignment = .fill
+ $0.distribution = .fill
+ }
+ }
+
+ func setUI() {
+ addSubviews(titleLabel, contentStackView)
+ contentStackView.addArrangedSubviews(noTripView, preparingView, collectionView)
+ }
+
+ func setLayout() {
+ titleLabel.snp.makeConstraints {
+ $0.top.equalToSuperview().inset(20.adjustedH)
+ $0.leading.equalToSuperview().inset(20.adjusted)
+ $0.trailing.lessThanOrEqualToSuperview().inset(20.adjusted)
+ }
+
+ contentStackView.snp.makeConstraints {
+ $0.top.equalTo(titleLabel.snp.bottom).offset(16.adjustedH)
+ $0.directionalHorizontalEdges.equalToSuperview()
+ $0.bottom.equalToSuperview().inset(20.adjustedH)
+ }
+
+ collectionView.snp.makeConstraints {
+ $0.height.equalTo(140.adjustedH)
+ }
+ }
+}
+
+// MARK: - UICollectionViewDataSource
+
+extension TravelToolWeatherView: UICollectionViewDataSource {
+ func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
+ forecasts.count
+ }
+
+ func collectionView(
+ _ collectionView: UICollectionView,
+ cellForItemAt indexPath: IndexPath
+ ) -> UICollectionViewCell {
+ guard let cell = collectionView.dequeueReusableCell(
+ withReuseIdentifier: WeatherDayCell.identifier,
+ for: indexPath
+ ) as? WeatherDayCell else {
+ return UICollectionViewCell()
+ }
+ cell.configure(with: forecasts[indexPath.item])
+ return cell
+ }
+}
+
+// MARK: - UICollectionViewDelegateFlowLayout
+
+extension TravelToolWeatherView: UICollectionViewDelegateFlowLayout {
+ func collectionView(
+ _ collectionView: UICollectionView,
+ layout collectionViewLayout: UICollectionViewLayout,
+ sizeForItemAt indexPath: IndexPath
+ ) -> CGSize {
+ CGSize(width: 100.adjusted, height: 140.adjustedH)
+ }
+
+ func collectionView(
+ _ collectionView: UICollectionView,
+ layout collectionViewLayout: UICollectionViewLayout,
+ insetForSectionAt section: Int
+ ) -> UIEdgeInsets {
+ UIEdgeInsets(top: 0, left: 20.adjusted, bottom: 0, right: 20.adjusted)
+ }
+}
+
+// MARK: - WeatherDayCell
+
+final class WeatherDayCell: UICollectionViewCell {
+ static let identifier = "WeatherDayCell"
+
+ private let dateLabel = UILabel()
+ private let dayOfWeekLabel = UILabel()
+ private let iconImageView = UIImageView()
+ private let tempLabel = UILabel()
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ setStyle()
+ setUI()
+ setLayout()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func configure(with info: DailyWeatherInfo) {
+ let dateFormatter = DateFormatter()
+ dateFormatter.locale = Locale(identifier: "ko_KR")
+
+ dateFormatter.dateFormat = "MM.dd"
+ dateLabel.setText(.bodyLSB, text: dateFormatter.string(from: info.date), color: DSKitAsset.Colors.black700.color)
+
+ dateFormatter.dateFormat = "EEEE"
+ dayOfWeekLabel.setText(.bodySR, text: dateFormatter.string(from: info.date), color: DSKitAsset.Colors.black400.color)
+
+ tempLabel.setText(
+ .bodyMR,
+ text: "\(Int(info.maxTemperature))° / \(Int(info.minTemperature))°",
+ color: DSKitAsset.Colors.black600.color
+ )
+
+ iconImageView.image = WeatherIconMapper.icon(for: info.weatherType)
+ }
+
+ private func setStyle() {
+ iconImageView.do {
+ $0.contentMode = .scaleAspectFit
+ }
+
+ dateLabel.textAlignment = .center
+ dayOfWeekLabel.textAlignment = .center
+ tempLabel.textAlignment = .center
+ }
+
+ private func setUI() {
+ contentView.addSubviews(dateLabel, dayOfWeekLabel, iconImageView, tempLabel)
+ }
+
+ private func setLayout() {
+ dateLabel.snp.makeConstraints {
+ $0.top.equalToSuperview()
+ $0.centerX.equalToSuperview()
+ }
+
+ dayOfWeekLabel.snp.makeConstraints {
+ $0.top.equalTo(dateLabel.snp.bottom).offset(2.adjustedH)
+ $0.centerX.equalToSuperview()
+ }
+
+ iconImageView.snp.makeConstraints {
+ $0.top.equalTo(dayOfWeekLabel.snp.bottom).offset(8.adjustedH)
+ $0.centerX.equalToSuperview()
+ $0.size.equalTo(48.adjustedH)
+ }
+
+ tempLabel.snp.makeConstraints {
+ $0.top.equalTo(iconImageView.snp.bottom).offset(8.adjustedH)
+ $0.centerX.equalToSuperview()
+ }
+ }
+}
+
+// MARK: - WeatherEmptyContentView
+
+final class WeatherEmptyContentView: UIView {
+ private let imageView = UIImageView()
+ private let messageLabel = UILabel()
+ private let subMessageLabel = UILabel()
+ private let stackView = UIStackView()
+
+ init(message: String, subMessage: String?) {
+ super.init(frame: .zero)
+
+ setStyle(message: message, subMessage: subMessage)
+ setUI(hasSubMessage: subMessage != nil)
+ setLayout()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func setStyle(message: String, subMessage: String?) {
+ imageView.do {
+ $0.image = DSKitAsset.Assets.icEmptyTrip.image
+ $0.contentMode = .scaleAspectFit
+ }
+
+ messageLabel.do {
+ $0.setText(.bodyLSB, text: message, color: DSKitAsset.Colors.black700.color)
+ $0.textAlignment = .center
+ }
+
+ stackView.do {
+ $0.axis = .vertical
+ $0.alignment = .center
+ $0.spacing = 4.adjustedH
+ }
+
+ if let subMessage {
+ subMessageLabel.do {
+ $0.setText(.bodyMR, text: subMessage, color: DSKitAsset.Colors.black400.color)
+ $0.textAlignment = .center
+ }
+ }
+ }
+
+ private func setUI(hasSubMessage: Bool) {
+ addSubview(stackView)
+ stackView.addArrangedSubviews(imageView, messageLabel)
+ if hasSubMessage {
+ stackView.addArrangedSubview(subMessageLabel)
+ }
+ }
+
+ private func setLayout() {
+ imageView.snp.makeConstraints {
+ $0.size.equalTo(120.adjustedH)
+ }
+
+ stackView.snp.makeConstraints {
+ $0.top.equalToSuperview().inset(8.adjustedH)
+ $0.centerX.equalToSuperview()
+ $0.bottom.equalToSuperview().inset(8.adjustedH)
+ }
+ }
+}
+
+// MARK: - WeatherIconMapper
+
+enum WeatherIconMapper {
+ static func icon(for type: String) -> UIImage {
+ switch type {
+ case "CLEAR":
+ return DSKitAsset.Assets.icWeatherSunny.image
+ case "MOSTLY_CLEAR":
+ return DSKitAsset.Assets.icWeatherSunClouds01.image
+ case "PARTLY_CLOUDY":
+ return DSKitAsset.Assets.icWeatherSunClouds01.image
+ case "MOSTLY_CLOUDY":
+ return DSKitAsset.Assets.icWeatherSunClouds02.image
+ case "CLOUDY", "FOGGY":
+ return DSKitAsset.Assets.icWeatherCloud.image
+ case "LIGHT_RAIN", "SCATTERED_SHOWERS":
+ return DSKitAsset.Assets.icWeatherSunRain.image
+ case "RAIN", "HEAVY_RAIN", "SHOWERS":
+ return DSKitAsset.Assets.icWeatherRain.image
+ case "LIGHT_SNOW", "SNOW", "HEAVY_SNOW", "BLIZZARD", "FLURRIES":
+ return DSKitAsset.Assets.icWeatherCloud.image
+ case "THUNDERSTORM", "THUNDERSTORMS":
+ return DSKitAsset.Assets.icWeatherThunder.image
+ default:
+ return DSKitAsset.Assets.icWeatherCloud.image
+ }
+ }
+}
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_cloud.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_cloud.imageset/Contents.json
new file mode 100644
index 0000000..d94c488
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_cloud.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "ic_weather_cloud.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_cloud.imageset/ic_weather_cloud.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_cloud.imageset/ic_weather_cloud.svg
new file mode 100644
index 0000000..fba0b2a
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_cloud.imageset/ic_weather_cloud.svg
@@ -0,0 +1,27 @@
+
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon.imageset/Contents.json
new file mode 100644
index 0000000..d4f9226
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "ic_weather_moon.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon.imageset/ic_weather_moon.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon.imageset/ic_weather_moon.svg
new file mode 100644
index 0000000..7c6bb7c
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon.imageset/ic_weather_moon.svg
@@ -0,0 +1,27 @@
+
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_clouds.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_clouds.imageset/Contents.json
new file mode 100644
index 0000000..b2a7ee3
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_clouds.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "ic_weather_moon_clouds.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_clouds.imageset/ic_weather_moon_clouds.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_clouds.imageset/ic_weather_moon_clouds.svg
new file mode 100644
index 0000000..eaeb2e0
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_clouds.imageset/ic_weather_moon_clouds.svg
@@ -0,0 +1,75 @@
+
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_rain.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_rain.imageset/Contents.json
new file mode 100644
index 0000000..3d59864
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_rain.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "ic_weather_moon_rain.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_rain.imageset/ic_weather_moon_rain.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_rain.imageset/ic_weather_moon_rain.svg
new file mode 100644
index 0000000..3c9099f
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_rain.imageset/ic_weather_moon_rain.svg
@@ -0,0 +1,104 @@
+
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_rain.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_rain.imageset/Contents.json
new file mode 100644
index 0000000..2eddb6e
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_rain.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "ic_weather_rain.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_rain.imageset/ic_weather_rain.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_rain.imageset/ic_weather_rain.svg
new file mode 100644
index 0000000..6fd0c92
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_rain.imageset/ic_weather_rain.svg
@@ -0,0 +1,66 @@
+
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds01.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds01.imageset/Contents.json
new file mode 100644
index 0000000..c9b19c1
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds01.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "ic_weather_sun_clouds01.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds01.imageset/ic_weather_sun_clouds01.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds01.imageset/ic_weather_sun_clouds01.svg
new file mode 100644
index 0000000..f15ed70
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds01.imageset/ic_weather_sun_clouds01.svg
@@ -0,0 +1,61 @@
+
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds02.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds02.imageset/Contents.json
new file mode 100644
index 0000000..5e51ea4
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds02.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "ic_weather_sun_clouds02.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds02.imageset/ic_weather_sun_clouds02.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds02.imageset/ic_weather_sun_clouds02.svg
new file mode 100644
index 0000000..0b6747c
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds02.imageset/ic_weather_sun_clouds02.svg
@@ -0,0 +1,50 @@
+
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_rain.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_rain.imageset/Contents.json
new file mode 100644
index 0000000..0909d46
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_rain.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "ic_weather_sun_rain.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_rain.imageset/ic_weather_sun_rain.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_rain.imageset/ic_weather_sun_rain.svg
new file mode 100644
index 0000000..1f15c77
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_rain.imageset/ic_weather_sun_rain.svg
@@ -0,0 +1,66 @@
+
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sunny.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sunny.imageset/Contents.json
new file mode 100644
index 0000000..2a8713f
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sunny.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "ic_weather_sunny.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sunny.imageset/ic_weather_sunny.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sunny.imageset/ic_weather_sunny.svg
new file mode 100644
index 0000000..f333a70
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sunny.imageset/ic_weather_sunny.svg
@@ -0,0 +1,27 @@
+
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_thunder.imageset/Contents.json b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_thunder.imageset/Contents.json
new file mode 100644
index 0000000..07faeba
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_thunder.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "ic_weather_thunder.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_thunder.imageset/ic_weather_thunder.svg b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_thunder.imageset/ic_weather_thunder.svg
new file mode 100644
index 0000000..7b46233
--- /dev/null
+++ b/Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_thunder.imageset/ic_weather_thunder.svg
@@ -0,0 +1,91 @@
+
diff --git a/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift b/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift
index b0a1f72..6c2d27f 100644
--- a/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift
+++ b/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift
@@ -168,7 +168,7 @@ private extension NDGLNavigationBar {
}
containerStackView.snp.makeConstraints {
- $0.directionalHorizontalEdges.equalToSuperview().inset(24.adjusted)
+ $0.directionalHorizontalEdges.equalToSuperview().inset(14.adjusted)
$0.directionalVerticalEdges.equalToSuperview().inset(4.adjustedH)
}
diff --git a/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerOnGoingView.swift b/Projects/Modules/DSKit/Sources/Component/NDGLOnGoingView.swift
similarity index 91%
rename from Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerOnGoingView.swift
rename to Projects/Modules/DSKit/Sources/Component/NDGLOnGoingView.swift
index 7c75970..2c47b69 100644
--- a/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerOnGoingView.swift
+++ b/Projects/Modules/DSKit/Sources/Component/NDGLOnGoingView.swift
@@ -1,15 +1,18 @@
//
-// HomeBannerOnGoingView.swift
-// HomeFeature
+// NDGLOnGoingView.swift
+// DSKit
//
-// Created by 최안용 on 2/3/26.
+// Created by kimnahun on 2026-02-22.
// Copyright © 2026 NDGL-iOS. All rights reserved.
//
import UIKit
-import DSKit
-final class HomeBannerOnGoingView: UIView {
+import Kingfisher
+import SnapKit
+import Then
+
+public final class NDGLOnGoingView: UIView {
private let titleLabel = UILabel()
private let dateLabel = UILabel()
private let iconImageView = UIImageView()
@@ -24,20 +27,20 @@ final class HomeBannerOnGoingView: UIView {
private let routeStackView = UIStackView()
private let routeCardView = UIView()
private let containerStackView = UIStackView()
-
- override init(frame: CGRect) {
+
+ override public init(frame: CGRect) {
super.init(frame: frame)
-
+
setStyle()
setUI()
setLayout()
}
-
+
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
-
- func configure(
+
+ public func configure(
title: String,
date: String,
transportIcon: UIImage?,
@@ -52,15 +55,15 @@ final class HomeBannerOnGoingView: UIView {
transportLabel.setText(.bodySM, text: transport, color: DSKitAsset.Colors.black400.color)
durationLabel.setText(.bodySM, text: "\(duration) 체류 예상", color: DSKitAsset.Colors.black400.color)
placeLabel.setText(.bodyLSB, text: place, color: DSKitAsset.Colors.black900.color)
-
+
if let url = URL(string: imageUrl) {
imageView.kf.setImage(with: url)
} else {
imageView.backgroundColor = .systemGray5
}
}
-
- func prepareForReuse() {
+
+ public func prepareForReuse() {
imageView.kf.cancelDownloadTask()
titleLabel.text = nil
dateLabel.text = nil
@@ -72,61 +75,61 @@ final class HomeBannerOnGoingView: UIView {
}
}
-private extension HomeBannerOnGoingView {
+private extension NDGLOnGoingView {
func setStyle() {
backgroundColor = .clear
-
+
dotLabel.do {
$0.setText(.bodyMM, text: "•", color: DSKitAsset.Colors.black400.color)
}
-
+
iconImageView.do {
$0.tintColor = DSKitAsset.Colors.black500.color
}
-
+
imageView.do {
$0.layer.cornerRadius = 4.adjustedH
$0.backgroundColor = .systemGray6
$0.clipsToBounds = true
}
-
+
titleStackView.do {
$0.axis = .vertical
$0.spacing = 4.adjustedH
$0.alignment = .leading
}
-
+
subInfoStackView.do {
$0.axis = .horizontal
$0.spacing = 4.adjusted
$0.alignment = .center
}
-
+
infoStackView.do {
$0.axis = .vertical
$0.spacing = 10.adjustedH
$0.alignment = .leading
}
-
+
routeStackView.do {
$0.axis = .horizontal
$0.spacing = 12.adjusted
$0.alignment = .center
}
-
+
routeCardView.do {
$0.backgroundColor = DSKitAsset.Colors.white.color
$0.layer.cornerRadius = 16.adjustedH
$0.clipsToBounds = true
}
-
+
containerStackView.do {
$0.axis = .vertical
$0.spacing = 16.adjustedH
$0.alignment = .leading
}
}
-
+
func setUI() {
titleStackView.addArrangedSubviews(titleLabel, dateLabel)
subInfoStackView.addArrangedSubviews(iconImageView, transportLabel, dotLabel, durationLabel)
@@ -136,30 +139,30 @@ private extension HomeBannerOnGoingView {
containerStackView.addArrangedSubviews(titleStackView, routeCardView)
addSubview(containerStackView)
}
-
+
func setLayout() {
iconImageView.snp.makeConstraints {
$0.size.equalTo(14.adjustedH)
}
-
+
imageView.snp.makeConstraints {
$0.size.equalTo(56.adjustedH)
}
-
+
routeStackView.snp.makeConstraints {
$0.directionalHorizontalEdges.equalToSuperview().inset(16.adjusted)
$0.top.equalToSuperview().inset(12.adjustedH)
$0.bottom.equalToSuperview().inset(16.adjustedH)
}
-
+
routeCardView.snp.makeConstraints {
$0.width.equalToSuperview()
}
-
+
titleStackView.snp.makeConstraints {
$0.directionalHorizontalEdges.equalToSuperview()
}
-
+
containerStackView.snp.makeConstraints {
$0.directionalHorizontalEdges.equalToSuperview().inset(16.adjusted)
$0.top.equalToSuperview().inset(16.adjustedH)
diff --git a/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerUpCommingView.swift b/Projects/Modules/DSKit/Sources/Component/NDGLUpComingView.swift
similarity index 87%
rename from Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerUpCommingView.swift
rename to Projects/Modules/DSKit/Sources/Component/NDGLUpComingView.swift
index b1756bb..d01c1bd 100644
--- a/Projects/Features/HomeFeature/Sources/Views/Component/HomeBannerUpCommingView.swift
+++ b/Projects/Modules/DSKit/Sources/Component/NDGLUpComingView.swift
@@ -1,15 +1,18 @@
//
-// HomeBannerUpCommingView.swift
-// HomeFeature
+// NDGLUpComingView.swift
+// DSKit
//
-// Created by 최안용 on 2/3/26.
+// Created by kimnahun on 2026-02-22.
// Copyright © 2026 NDGL-iOS. All rights reserved.
//
import UIKit
-import DSKit
-final class HomeBannerUpCommingView: UIView {
+import Kingfisher
+import SnapKit
+import Then
+
+public final class NDGLUpComingView: UIView {
private let imageView = UIImageView()
private let badge = UIView()
private let dDayLabel = UILabel()
@@ -18,32 +21,32 @@ final class HomeBannerUpCommingView: UIView {
private let titleStackView = UIStackView()
private let infoStackView = UIStackView()
private let stackView = UIStackView()
-
- override init(frame: CGRect) {
+
+ override public init(frame: CGRect) {
super.init(frame: frame)
-
+
setStyle()
setUI()
setLayout()
}
-
+
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
-
- func configure(title: String, date: String, dDay: Int, imageUrl: String) {
+
+ public func configure(title: String, date: String, dDay: Int, imageUrl: String) {
titleLabel.setText(.subTitleMSB, text: title, color: DSKitAsset.Colors.black700.color)
dateLabel.setText(.bodyMR, text: date, color: DSKitAsset.Colors.black600.color)
dDayLabel.setText(.bodyMM, text: "D-\(dDay)", color: DSKitAsset.Colors.black400.color)
-
+
if let url = URL(string: imageUrl) {
imageView.kf.setImage(with: url)
} else {
imageView.backgroundColor = .systemGray5
}
}
-
- func prepareForReuse() {
+
+ public func prepareForReuse() {
imageView.kf.cancelDownloadTask()
titleLabel.text = nil
dateLabel.text = nil
@@ -52,16 +55,16 @@ final class HomeBannerUpCommingView: UIView {
}
}
-private extension HomeBannerUpCommingView {
+private extension NDGLUpComingView {
func setStyle() {
backgroundColor = .clear
-
+
imageView.do {
$0.layer.cornerRadius = 64.adjustedH / 2
$0.clipsToBounds = true
$0.contentMode = .scaleAspectFill
}
-
+
badge.do {
$0.backgroundColor = DSKitAsset.Colors.black100.color
$0.layer.cornerRadius = 26.adjustedH / 2
@@ -69,31 +72,31 @@ private extension HomeBannerUpCommingView {
$0.setContentCompressionResistancePriority(.required, for: .horizontal)
$0.setContentHuggingPriority(.required, for: .horizontal)
}
-
+
titleLabel.do {
$0.numberOfLines = 1
$0.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
-
+
titleStackView.do {
$0.axis = .horizontal
$0.spacing = 8.adjusted
$0.alignment = .center
}
-
+
infoStackView.do {
$0.axis = .vertical
$0.spacing = 6.adjustedH
$0.alignment = .leading
}
-
+
stackView.do {
$0.axis = .horizontal
$0.spacing = 12.adjusted
$0.alignment = .center
}
}
-
+
func setUI() {
badge.addSubview(dDayLabel)
titleStackView.addArrangedSubviews(badge, titleLabel)
@@ -101,17 +104,17 @@ private extension HomeBannerUpCommingView {
stackView.addArrangedSubviews(imageView, infoStackView)
addSubview(stackView)
}
-
+
func setLayout() {
imageView.snp.makeConstraints {
$0.size.equalTo(64.adjustedH)
}
-
+
dDayLabel.snp.makeConstraints {
$0.directionalHorizontalEdges.equalToSuperview().inset(12.adjusted)
$0.directionalVerticalEdges.equalToSuperview().inset(4.adjustedH)
}
-
+
stackView.snp.makeConstraints {
$0.directionalHorizontalEdges.equalToSuperview().inset(16.adjusted)
$0.directionalVerticalEdges.equalToSuperview().inset(8.adjustedH).priority(.high)
diff --git a/Projects/Modules/Networks/Sources/DTO/Weather/WeatherResponse.swift b/Projects/Modules/Networks/Sources/DTO/Weather/WeatherResponse.swift
new file mode 100644
index 0000000..b6ea517
--- /dev/null
+++ b/Projects/Modules/Networks/Sources/DTO/Weather/WeatherResponse.swift
@@ -0,0 +1,48 @@
+//
+// WeatherResponse.swift
+// Networks
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import Foundation
+
+// MARK: - Forecast Response
+
+public struct ForecastResponse: Decodable {
+ public let forecastDays: [ForecastDayResponse]
+}
+
+public struct ForecastDayResponse: Decodable {
+ public let displayDate: DateResponse
+ public let daytimeForecast: ForecastPeriodResponse?
+ public let maxTemperature: TemperatureResponse?
+ public let minTemperature: TemperatureResponse?
+}
+
+public struct DateResponse: Decodable {
+ public let year: Int
+ public let month: Int
+ public let day: Int
+}
+
+public struct ForecastPeriodResponse: Decodable {
+ public let weatherCondition: WeatherConditionResponse
+}
+
+public struct WeatherConditionResponse: Decodable {
+ public let type: String?
+ public let iconBaseUri: String?
+ public let icon: String?
+ public let description: WeatherDescriptionResponse?
+}
+
+public struct WeatherDescriptionResponse: Decodable {
+ public let text: String
+}
+
+public struct TemperatureResponse: Decodable {
+ public let degrees: Double
+ public let unit: String?
+}
diff --git a/Projects/Modules/Networks/Sources/Extensions/MoyaProvider+Async.swift b/Projects/Modules/Networks/Sources/Extensions/MoyaProvider+Async.swift
index d977a0c..88f32ce 100644
--- a/Projects/Modules/Networks/Sources/Extensions/MoyaProvider+Async.swift
+++ b/Projects/Modules/Networks/Sources/Extensions/MoyaProvider+Async.swift
@@ -143,6 +143,37 @@ extension MoyaProvider {
}
}
+ func asyncThrowsRequestRaw(_ target: Target) async throws -> T {
+ try await withCheckedThrowingContinuation { continuation in
+ NetworkLogger.logRequest(target)
+
+ request(target) { result in
+ switch result {
+ case .success(let response):
+ NetworkLogger.logResponse(response)
+
+ guard (200...299).contains(response.statusCode) else {
+ continuation.resume(
+ throwing: NetworkError.unknown("Status Code: \(response.statusCode)")
+ )
+ return
+ }
+
+ do {
+ let decoded = try JSONDecoder().decode(T.self, from: response.data)
+ continuation.resume(returning: decoded)
+ } catch {
+ NetworkLogger.logDecodingError(error, data: response.data)
+ continuation.resume(throwing: NetworkError.decodingFailed)
+ }
+ case .failure(let error):
+ NetworkLogger.logError(error)
+ continuation.resume(throwing: NetworkError.unknown(error.localizedDescription))
+ }
+ }
+ }
+ }
+
private static func mapMoyaError(_ error: MoyaError) -> NetworkError {
switch error {
case .underlying(let nsError as NSError, _)
diff --git a/Projects/Modules/Networks/Sources/Foundation/NetworkConfiguration.swift b/Projects/Modules/Networks/Sources/Foundation/NetworkConfiguration.swift
index e6ed600..93703f9 100644
--- a/Projects/Modules/Networks/Sources/Foundation/NetworkConfiguration.swift
+++ b/Projects/Modules/Networks/Sources/Foundation/NetworkConfiguration.swift
@@ -27,4 +27,12 @@ public enum NetworkConfiguration {
}
return apiHeader
}
+
+ public static var weatherApiKey: String {
+ let bundle = Bundle.main
+ guard let key = bundle.infoDictionary?["GOOGLE_WEATHER_API_KEY"] as? String else {
+ fatalError("GOOGLE_WEATHER_API_KEY not found in Info.plist")
+ }
+ return key
+ }
}
diff --git a/Projects/Modules/Networks/Sources/Service/WeatherService.swift b/Projects/Modules/Networks/Sources/Service/WeatherService.swift
new file mode 100644
index 0000000..d311974
--- /dev/null
+++ b/Projects/Modules/Networks/Sources/Service/WeatherService.swift
@@ -0,0 +1,28 @@
+//
+// WeatherService.swift
+// Networks
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import Foundation
+import Moya
+
+public protocol WeatherServiceProtocol {
+ func getForecast(latitude: Double, longitude: Double, days: Int) async throws -> ForecastResponse
+}
+
+public final class WeatherService: WeatherServiceProtocol {
+ private let provider: MoyaProvider
+
+ public init(provider: MoyaProvider = MoyaProvider()) {
+ self.provider = provider
+ }
+
+ public func getForecast(latitude: Double, longitude: Double, days: Int) async throws -> ForecastResponse {
+ try await provider.asyncThrowsRequestRaw(
+ .getForecast(latitude: latitude, longitude: longitude, days: days)
+ )
+ }
+}
diff --git a/Projects/Modules/Networks/Sources/TargetType/WeatherAPI.swift b/Projects/Modules/Networks/Sources/TargetType/WeatherAPI.swift
new file mode 100644
index 0000000..88b587b
--- /dev/null
+++ b/Projects/Modules/Networks/Sources/TargetType/WeatherAPI.swift
@@ -0,0 +1,53 @@
+//
+// WeatherAPI.swift
+// Networks
+//
+// Created by kimnahun on 2026-02-21.
+// Copyright © 2026 NDGL-iOS. All rights reserved.
+//
+
+import Foundation
+import Moya
+
+public enum WeatherAPI {
+ case getForecast(latitude: Double, longitude: Double, days: Int)
+}
+
+extension WeatherAPI: TargetType {
+ public var baseURL: URL {
+ URL(string: "https://weather.googleapis.com")!
+ }
+
+ public var path: String {
+ switch self {
+ case .getForecast:
+ return "/v1/forecast/days:lookup"
+ }
+ }
+
+ public var method: Moya.Method {
+ switch self {
+ case .getForecast:
+ return .get
+ }
+ }
+
+ public var task: Moya.Task {
+ switch self {
+ case .getForecast(let latitude, let longitude, let days):
+ return .requestParameters(
+ parameters: [
+ "key": NetworkConfiguration.weatherApiKey,
+ "location.latitude": latitude,
+ "location.longitude": longitude,
+ "days": days
+ ],
+ encoding: URLEncoding.queryString
+ )
+ }
+ }
+
+ public var headers: [String: String]? {
+ ["Content-Type": "application/json"]
+ }
+}