From 0a791598e6675a6f74d4d2503d84e616be7b46c6 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sat, 21 Feb 2026 18:54:10 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20#34:=20TravelTool=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=ED=95=B4=EB=8B=B9?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9D=B4=EB=8F=99=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84,=20RIBs=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dependency+Project.swift | 9 ++- Projects/Features/TabBarFeature/Project.swift | 3 +- .../TabBarFeature/Sources/TabBarBuilder.swift | 7 ++- .../Sources/TabBarInteractor.swift | 6 ++ .../TabBarFeature/Sources/TabBarRouter.swift | 16 +++++- .../Sources/TabBarViewController.swift | 13 ++--- .../Features/TravelToolFeature/Project.swift | 25 +++++++++ .../Sources/TravelToolBuilder.swift | 48 ++++++++++++++++ .../Sources/TravelToolInteractor.swift | 55 +++++++++++++++++++ .../Sources/TravelToolRouter.swift | 39 +++++++++++++ .../Sources/TravelToolViewController.swift | 26 +++++++++ 11 files changed, 233 insertions(+), 14 deletions(-) create mode 100644 Projects/Features/TravelToolFeature/Project.swift create mode 100644 Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift create mode 100644 Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift create mode 100644 Projects/Features/TravelToolFeature/Sources/TravelToolRouter.swift create mode 100644 Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift 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/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..b8abf56 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift @@ -10,6 +10,7 @@ import Domain import HomeFeature import RIBs import TravelFeature +import TravelToolFeature // MARK: - TabBarDependency @@ -19,7 +20,7 @@ public protocol TabBarDependency: Dependency { // MARK: - TabBarComponent -final class TabBarComponent: Component, HomeDependency, TravelDependency { +final class TabBarComponent: Component, HomeDependency, TravelDependency, TravelToolDependency { var homeUsecase: HomeUsecaseProtocol { dependency.homeUsecase } @@ -47,12 +48,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..89bf549 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift @@ -10,6 +10,7 @@ import Foundation import HomeFeature import RIBs import RxSwift +import TravelToolFeature // MARK: - TabBarRouting @@ -85,3 +86,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..d32b82c 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 } } @@ -30,17 +31,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() } 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..5fd30f2 --- /dev/null +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift @@ -0,0 +1,48 @@ +// +// TravelToolBuilder.swift +// TravelToolFeature +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +// MARK: - TravelToolDependency + +public protocol TravelToolDependency: Dependency { +} + +// MARK: - TravelToolComponent + +final class TravelToolComponent: Component { +} + +// 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) + 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..2f7b0f0 --- /dev/null +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift @@ -0,0 +1,55 @@ +// +// TravelToolInteractor.swift +// TravelToolFeature +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation +import RIBs +import RxSwift + +// MARK: - TravelToolListener + +public protocol TravelToolListener: AnyObject { +} + +// MARK: - TravelToolPresentable + +protocol TravelToolPresentable: Presentable { + var listener: TravelToolPresentableListener? { get set } +} + +// MARK: - TravelToolPresentableListener + +protocol TravelToolPresentableListener: AnyObject { +} + +// MARK: - TravelToolInteractor + +final class TravelToolInteractor: PresentableInteractor, TravelToolInteractable { + + weak var router: TravelToolRouting? + weak var listener: TravelToolListener? + + private let disposeBag = DisposeBag() + + override init(presenter: TravelToolPresentable) { + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + } + + override func willResignActive() { + super.willResignActive() + } +} + +// MARK: - TravelToolPresentableListener + +extension TravelToolInteractor: TravelToolPresentableListener { +} 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..6d25e6b --- /dev/null +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift @@ -0,0 +1,26 @@ +// +// TravelToolViewController.swift +// TravelToolFeature +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs +import UIKit + +// MARK: - TravelToolViewController + +final class TravelToolViewController: UIViewController, TravelToolPresentable, TravelToolViewControllable { + + // MARK: - Properties + + weak var listener: TravelToolPresentableListener? + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + } +} From 7f36d7e1a8c13477925763e549e76b5df8078d44 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sat, 21 Feb 2026 23:00:43 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20#34:=20=EB=82=B4=20=EB=8B=A4?= =?UTF-8?q?=EA=B0=80=EC=98=A4=EB=8A=94=20=EC=97=AC=ED=96=89=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=EC=84=9C=20=EB=B3=B4=EC=97=AC=EC=A7=80?= =?UTF-8?q?=EB=8A=94=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/TravelToolBuilder.swift | 10 +- .../Sources/TravelToolInteractor.swift | 78 ++++++++++++- .../Sources/TravelToolViewController.swift | 34 ++++++ .../Sources/Views/TravelToolEmptyView.swift | 81 +++++++++++++ .../Sources/Views/TravelToolOnGoingView.swift | 110 ++++++++++++++++++ .../Views/TravelToolTripCardView.swift | 88 ++++++++++++++ .../Views/TravelToolUpComingView.swift | 100 ++++++++++++++++ 7 files changed, 499 insertions(+), 2 deletions(-) create mode 100644 Projects/Features/TravelToolFeature/Sources/Views/TravelToolEmptyView.swift create mode 100644 Projects/Features/TravelToolFeature/Sources/Views/TravelToolOnGoingView.swift create mode 100644 Projects/Features/TravelToolFeature/Sources/Views/TravelToolTripCardView.swift create mode 100644 Projects/Features/TravelToolFeature/Sources/Views/TravelToolUpComingView.swift diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift index 5fd30f2..117cddf 100644 --- a/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift @@ -6,16 +6,21 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import Domain import RIBs // MARK: - TravelToolDependency public protocol TravelToolDependency: Dependency { + var homeUsecase: HomeUsecaseProtocol { get } } // MARK: - TravelToolComponent final class TravelToolComponent: Component { + var homeUsecase: HomeUsecaseProtocol { + dependency.homeUsecase + } } // MARK: - TravelToolBuildable @@ -35,7 +40,10 @@ public final class TravelToolBuilder: Builder, TravelToolB public func build(withListener listener: TravelToolListener) -> TravelToolRouting { let component = TravelToolComponent(dependency: dependency) let viewController = TravelToolViewController() - let interactor = TravelToolInteractor(presenter: viewController) + let interactor = TravelToolInteractor( + presenter: viewController, + usecase: component.homeUsecase + ) interactor.listener = listener let router = TravelToolRouter( diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift index 2f7b0f0..96a0293 100644 --- a/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift @@ -7,6 +7,8 @@ // import Foundation + +import Domain import RIBs import RxSwift @@ -19,6 +21,8 @@ public protocol TravelToolListener: AnyObject { protocol TravelToolPresentable: Presentable { var listener: TravelToolPresentableListener? { get set } + + func updateTripCard(_ state: TravelToolTripState) } // MARK: - TravelToolPresentableListener @@ -33,19 +37,80 @@ final class TravelToolInteractor: PresentableInteractor, weak var router: TravelToolRouting? weak var listener: TravelToolListener? + private let usecase: HomeUsecaseProtocol + private var fetchTask: Task? private let disposeBag = DisposeBag() - override init(presenter: TravelToolPresentable) { + init(presenter: TravelToolPresentable, usecase: HomeUsecaseProtocol) { + self.usecase = usecase 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, !Task.isCancelled else { return } + + let state: TravelToolTripState = await { + do { + let summary = try await self.usecase.fetchMyTripInfo() + return self.convertToState(summary) + } catch { + return .empty + } + }() + + guard !Task.isCancelled else { return } + await MainActor.run { + presenter.updateTripCard(state) + } + } + } + + 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, + 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 + } } } @@ -53,3 +118,14 @@ final class TravelToolInteractor: PresentableInteractor, extension TravelToolInteractor: TravelToolPresentableListener { } + +// 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/TravelToolViewController.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift index 6d25e6b..b94c460 100644 --- a/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift @@ -9,6 +9,8 @@ import RIBs import UIKit +import DSKit + // MARK: - TravelToolViewController final class TravelToolViewController: UIViewController, TravelToolPresentable, TravelToolViewControllable { @@ -17,10 +19,42 @@ final class TravelToolViewController: UIViewController, TravelToolPresentable, T weak var listener: TravelToolPresentableListener? + // MARK: - UI + + private let tripCardView = TravelToolTripCardView() + // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() + + setStyle() + setUI() + setLayout() + } + + // MARK: - TravelToolPresentable + + func updateTripCard(_ state: TravelToolTripState) { + tripCardView.configure(state) + } +} + +// MARK: - Private + +private extension TravelToolViewController { + func setStyle() { view.backgroundColor = .white } + + func setUI() { + view.addSubview(tripCardView) + } + + func setLayout() { + tripCardView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $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/TravelToolOnGoingView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolOnGoingView.swift new file mode 100644 index 0000000..45ecd4e --- /dev/null +++ b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolOnGoingView.swift @@ -0,0 +1,110 @@ +// +// TravelToolOnGoingView.swift +// TravelToolFeature +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +final class TravelToolOnGoingView: UIView { + private let titleLabel = UILabel() + private let dateLabel = UILabel() + private let durationLabel = UILabel() + private let placeLabel = UILabel() + private let imageView = UIImageView() + private let routeCardView = UIView() + + override 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, + duration: String, + place: String, + imageUrl: String + ) { + titleLabel.setText(.subTitleMSB, text: title, color: DSKitAsset.Colors.black700.color) + dateLabel.setText(.bodyMR, text: date, color: DSKitAsset.Colors.black500.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 + } + } +} + +private extension TravelToolOnGoingView { + func setStyle() { + backgroundColor = .clear + + imageView.do { + $0.layer.cornerRadius = 4.adjustedH + $0.backgroundColor = .systemGray6 + $0.clipsToBounds = true + $0.contentMode = .scaleAspectFill + } + + routeCardView.do { + $0.backgroundColor = DSKitAsset.Colors.white.color + $0.layer.cornerRadius = 16.adjustedH + $0.clipsToBounds = true + } + } + + func setUI() { + routeCardView.addSubviews(durationLabel, placeLabel, imageView) + addSubviews(titleLabel, dateLabel, routeCardView) + } + + func setLayout() { + titleLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview().inset(16.adjusted) + } + + dateLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(4.adjustedH) + $0.leading.equalToSuperview().inset(16.adjusted) + } + + routeCardView.snp.makeConstraints { + $0.top.equalTo(dateLabel.snp.bottom).offset(16.adjustedH) + $0.directionalHorizontalEdges.equalToSuperview().inset(16.adjusted) + $0.height.equalTo(84.adjustedH) + $0.bottom.equalToSuperview().inset(23.adjustedH).priority(.low) + } + + durationLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(16.5.adjustedH) + $0.leading.equalToSuperview().inset(16.adjusted) + } + + placeLabel.snp.makeConstraints { + $0.top.equalTo(durationLabel.snp.bottom).offset(10.adjustedH) + $0.leading.equalToSuperview().inset(16.adjusted) + $0.trailing.lessThanOrEqualTo(imageView.snp.leading).offset(-8.adjusted) + } + + imageView.snp.makeConstraints { + $0.top.equalToSuperview().inset(12.adjustedH) + $0.trailing.bottom.equalToSuperview().inset(16.adjusted) + $0.width.equalTo(imageView.snp.height) + } + } +} diff --git a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolTripCardView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolTripCardView.swift new file mode 100644 index 0000000..60109d2 --- /dev/null +++ b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolTripCardView.swift @@ -0,0 +1,88 @@ +// +// 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, + duration: String, + place: String, + imageUrl: String + ) +} + +final class TravelToolTripCardView: UIView { + private let emptyView = TravelToolEmptyView() + private let upComingView = TravelToolUpComingView() + private let onGoingView = TravelToolOnGoingView() + 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 duration, let place, let imageUrl): + onGoingView.isHidden = false + onGoingView.configure( + title: title, + date: date, + 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/TravelToolUpComingView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolUpComingView.swift new file mode 100644 index 0000000..436eabf --- /dev/null +++ b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolUpComingView.swift @@ -0,0 +1,100 @@ +// +// TravelToolUpComingView.swift +// TravelToolFeature +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +final class TravelToolUpComingView: UIView { + private let imageView = UIImageView() + private let badge = UIView() + private let dDayLabel = UILabel() + private let titleLabel = UILabel() + private let dateLabel = 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(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 + } + } +} + +private extension TravelToolUpComingView { + 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 + $0.clipsToBounds = true + } + + titleLabel.do { + $0.numberOfLines = 1 + } + } + + func setUI() { + badge.addSubview(dDayLabel) + addSubviews(imageView, badge, titleLabel, dateLabel) + } + + func setLayout() { + imageView.snp.makeConstraints { + $0.leading.equalToSuperview().inset(16.adjusted) + $0.top.bottom.equalToSuperview().inset(8.adjustedH) + $0.size.equalTo(64.adjustedH) + } + + badge.snp.makeConstraints { + $0.top.equalTo(imageView).offset(7.adjustedH) + $0.leading.equalTo(imageView.snp.trailing).offset(12.adjusted) + $0.height.equalTo(26.adjustedH) + } + + dDayLabel.snp.makeConstraints { + $0.directionalHorizontalEdges.equalToSuperview().inset(12.adjusted) + $0.directionalVerticalEdges.equalToSuperview().inset(4.adjustedH) + } + + titleLabel.snp.makeConstraints { + $0.leading.equalTo(badge.snp.trailing).offset(8.adjusted) + $0.centerY.equalTo(badge) + $0.trailing.lessThanOrEqualToSuperview().inset(16.adjusted) + } + + dateLabel.snp.makeConstraints { + $0.top.equalTo(badge.snp.bottom).offset(8.adjustedH) + $0.leading.equalTo(badge) + } + } +} From cc88efe91e2e9b86ec697479d56a981053292460 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sat, 21 Feb 2026 23:53:03 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20#34:=20Google=20Weather=20API=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20=EC=9E=84=EC=8B=9C=20UI?= =?UTF-8?q?=EC=97=90=20=EB=82=A0=EC=94=A8=20=ED=91=9C=EC=8B=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../ProjectDescriptionHelpers/InfoPlist.swift | 3 +- .../Sources/Application/AppComponent.swift | 7 ++ .../Sources/DI/WeatherServiceFactory.swift | 16 ++++ .../Weather/WeatherRepository.swift | 28 ++++++ .../Transform/UserTravelTransform.swift | 4 +- .../Sources/Transform/WeatherTransform.swift | 29 ++++++ .../Weather/WeatherRepositoryInterface.swift | 13 +++ .../Sources/Model/Home/MyTripSummary.swift | 10 +- .../Sources/Model/Weather/WeatherInfo.swift | 28 ++++++ .../MainFeature/Sources/MainBuilder.swift | 9 +- .../RootFeature/Sources/RootBuilder.swift | 9 +- .../TabBarFeature/Sources/TabBarBuilder.swift | 5 + .../Sources/TravelToolBuilder.swift | 8 +- .../Sources/TravelToolInteractor.swift | 47 ++++++++-- .../Sources/TravelToolViewController.swift | 19 +++- .../Sources/Views/TravelToolWeatherView.swift | 91 +++++++++++++++++++ .../Sources/DTO/Weather/WeatherResponse.swift | 30 ++++++ .../Extensions/MoyaProvider+Async.swift | 31 +++++++ .../Foundation/NetworkConfiguration.swift | 8 ++ .../Sources/Service/WeatherService.swift | 26 ++++++ .../Sources/TargetType/WeatherAPI.swift | 52 +++++++++++ 21 files changed, 457 insertions(+), 16 deletions(-) create mode 100644 Projects/Data/Sources/DI/WeatherServiceFactory.swift create mode 100644 Projects/Data/Sources/Repository/Weather/WeatherRepository.swift create mode 100644 Projects/Data/Sources/Transform/WeatherTransform.swift create mode 100644 Projects/Domain/Sources/Interface/Weather/WeatherRepositoryInterface.swift create mode 100644 Projects/Domain/Sources/Model/Weather/WeatherInfo.swift create mode 100644 Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift create mode 100644 Projects/Modules/Networks/Sources/DTO/Weather/WeatherResponse.swift create mode 100644 Projects/Modules/Networks/Sources/Service/WeatherService.swift create mode 100644 Projects/Modules/Networks/Sources/TargetType/WeatherAPI.swift 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/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..418a7c0 --- /dev/null +++ b/Projects/Data/Sources/Repository/Weather/WeatherRepository.swift @@ -0,0 +1,28 @@ +// +// 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 fetchCurrentWeather(latitude: Double, longitude: Double) async throws -> WeatherInfo { + do { + let response = try await service.getCurrentWeather(latitude: latitude, longitude: longitude) + return response.toDomain() + } catch { + throw error.toNDGLError() + } + } +} diff --git a/Projects/Data/Sources/Transform/UserTravelTransform.swift b/Projects/Data/Sources/Transform/UserTravelTransform.swift index c1ba86c..012fc53 100644 --- a/Projects/Data/Sources/Transform/UserTravelTransform.swift +++ b/Projects/Data/Sources/Transform/UserTravelTransform.swift @@ -25,7 +25,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..1e3caf0 --- /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 CurrentWeatherResponse { + func toDomain() -> WeatherInfo { + let iconUrl: String + if let baseUri = iconBaseUri, let icon = icon { + iconUrl = "\(baseUri)/\(icon).png" + } else { + iconUrl = "" + } + + return WeatherInfo( + temperature: temperature.degrees, + description: weatherCondition.description.text, + iconUrl: iconUrl, + humidity: relativeHumidity ?? 0 + ) + } +} diff --git a/Projects/Domain/Sources/Interface/Weather/WeatherRepositoryInterface.swift b/Projects/Domain/Sources/Interface/Weather/WeatherRepositoryInterface.swift new file mode 100644 index 0000000..833c324 --- /dev/null +++ b/Projects/Domain/Sources/Interface/Weather/WeatherRepositoryInterface.swift @@ -0,0 +1,13 @@ +// +// WeatherRepositoryInterface.swift +// Domain +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public protocol WeatherRepositoryInterface { + func fetchCurrentWeather(latitude: Double, longitude: Double) async throws -> WeatherInfo +} diff --git a/Projects/Domain/Sources/Model/Home/MyTripSummary.swift b/Projects/Domain/Sources/Model/Home/MyTripSummary.swift index 894937f..1ca93da 100644 --- a/Projects/Domain/Sources/Model/Home/MyTripSummary.swift +++ b/Projects/Domain/Sources/Model/Home/MyTripSummary.swift @@ -33,14 +33,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 +52,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..0e9bcfa --- /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 WeatherInfo { + public let temperature: Double + public let description: String + public let iconUrl: String + public let humidity: Int + + public init( + temperature: Double, + description: String, + iconUrl: String, + humidity: Int + ) { + self.temperature = temperature + self.description = description + self.iconUrl = iconUrl + self.humidity = humidity + } +} 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/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/TabBarFeature/Sources/TabBarBuilder.swift b/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift index b8abf56..1174f6b 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift @@ -16,6 +16,7 @@ import TravelToolFeature public protocol TabBarDependency: Dependency { var homeUsecase: HomeUsecaseProtocol { get } + var weatherRepository: WeatherRepositoryInterface { get } } // MARK: - TabBarComponent @@ -24,6 +25,10 @@ final class TabBarComponent: Component, HomeDependency, Travel var homeUsecase: HomeUsecaseProtocol { dependency.homeUsecase } + + var weatherRepository: WeatherRepositoryInterface { + dependency.weatherRepository + } } // MARK: - TabBarBuildable diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift index 117cddf..6d01a6c 100644 --- a/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolBuilder.swift @@ -13,6 +13,7 @@ import RIBs public protocol TravelToolDependency: Dependency { var homeUsecase: HomeUsecaseProtocol { get } + var weatherRepository: WeatherRepositoryInterface { get } } // MARK: - TravelToolComponent @@ -21,6 +22,10 @@ final class TravelToolComponent: Component { var homeUsecase: HomeUsecaseProtocol { dependency.homeUsecase } + + var weatherRepository: WeatherRepositoryInterface { + dependency.weatherRepository + } } // MARK: - TravelToolBuildable @@ -42,7 +47,8 @@ public final class TravelToolBuilder: Builder, TravelToolB let viewController = TravelToolViewController() let interactor = TravelToolInteractor( presenter: viewController, - usecase: component.homeUsecase + usecase: component.homeUsecase, + weatherRepository: component.weatherRepository ) interactor.listener = listener diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift index 96a0293..94adbaa 100644 --- a/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift @@ -23,6 +23,7 @@ protocol TravelToolPresentable: Presentable { var listener: TravelToolPresentableListener? { get set } func updateTripCard(_ state: TravelToolTripState) + func updateWeather(_ info: WeatherInfo?) } // MARK: - TravelToolPresentableListener @@ -38,11 +39,17 @@ final class TravelToolInteractor: PresentableInteractor, 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) { + init( + presenter: TravelToolPresentable, + usecase: HomeUsecaseProtocol, + weatherRepository: WeatherRepositoryInterface + ) { self.usecase = usecase + self.weatherRepository = weatherRepository super.init(presenter: presenter) presenter.listener = self } @@ -66,18 +73,46 @@ final class TravelToolInteractor: PresentableInteractor, fetchTask = Task { [weak self] in guard let self, !Task.isCancelled else { return } - let state: TravelToolTripState = await { + let summary: MyTripSummary? = await { do { - let summary = try await self.usecase.fetchMyTripInfo() - return self.convertToState(summary) + return try await self.usecase.fetchMyTripInfo() } catch { - return .empty + return nil + } + }() + + guard !Task.isCancelled else { return } + + let state: TravelToolTripState + if let summary { + state = self.convertToState(summary) + } else { + state = .empty + } + + await MainActor.run { + self.presenter.updateTripCard(state) + } + + guard let summary, !Task.isCancelled else { + await MainActor.run { self.presenter.updateWeather(nil) } + return + } + + let weatherInfo: WeatherInfo? = await { + do { + return try await self.weatherRepository.fetchCurrentWeather( + latitude: summary.tripSchedule.latitude, + longitude: summary.tripSchedule.longitude + ) + } catch { + return nil } }() guard !Task.isCancelled else { return } await MainActor.run { - presenter.updateTripCard(state) + self.presenter.updateWeather(weatherInfo) } } } diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift index b94c460..41b3efc 100644 --- a/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift @@ -9,6 +9,7 @@ import RIBs import UIKit +import Domain import DSKit // MARK: - TravelToolViewController @@ -22,6 +23,7 @@ final class TravelToolViewController: UIViewController, TravelToolPresentable, T // MARK: - UI private let tripCardView = TravelToolTripCardView() + private let weatherView = TravelToolWeatherView() // MARK: - Lifecycle @@ -38,6 +40,15 @@ final class TravelToolViewController: UIViewController, TravelToolPresentable, T func updateTripCard(_ state: TravelToolTripState) { tripCardView.configure(state) } + + func updateWeather(_ info: WeatherInfo?) { + if let info { + weatherView.isHidden = false + weatherView.configure(with: info) + } else { + weatherView.isHidden = true + } + } } // MARK: - Private @@ -45,10 +56,11 @@ final class TravelToolViewController: UIViewController, TravelToolPresentable, T private extension TravelToolViewController { func setStyle() { view.backgroundColor = .white + weatherView.isHidden = true } func setUI() { - view.addSubview(tripCardView) + view.addSubviews(tripCardView, weatherView) } func setLayout() { @@ -56,5 +68,10 @@ private extension TravelToolViewController { $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/TravelToolWeatherView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift new file mode 100644 index 0000000..a80eefa --- /dev/null +++ b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift @@ -0,0 +1,91 @@ +// +// TravelToolWeatherView.swift +// TravelToolFeature +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import Domain +import DSKit + +final class TravelToolWeatherView: UIView { + private let iconImageView = UIImageView() + private let temperatureLabel = UILabel() + private let descriptionLabel = UILabel() + private let humidityLabel = UILabel() + private let infoStackView = 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(with info: WeatherInfo) { + temperatureLabel.setText( + .subTitleMSB, + text: "\(Int(info.temperature))°", + color: DSKitAsset.Colors.black700.color + ) + descriptionLabel.setText( + .bodyMR, + text: info.description, + color: DSKitAsset.Colors.black600.color + ) + humidityLabel.setText( + .bodyMR, + text: "습도 \(info.humidity)%", + color: DSKitAsset.Colors.black400.color + ) + + if let url = URL(string: info.iconUrl) { + iconImageView.kf.setImage(with: url) + } + } +} + +private extension TravelToolWeatherView { + func setStyle() { + backgroundColor = DSKitAsset.Colors.black50.color + layer.cornerRadius = 8.adjustedH + clipsToBounds = true + + iconImageView.do { + $0.contentMode = .scaleAspectFit + } + + infoStackView.do { + $0.axis = .vertical + $0.spacing = 4.adjustedH + $0.alignment = .leading + } + } + + func setUI() { + addSubviews(iconImageView, infoStackView) + infoStackView.addArrangedSubviews(temperatureLabel, descriptionLabel, humidityLabel) + } + + func setLayout() { + iconImageView.snp.makeConstraints { + $0.leading.equalToSuperview().inset(16.adjusted) + $0.centerY.equalToSuperview() + $0.size.equalTo(48.adjustedH) + } + + infoStackView.snp.makeConstraints { + $0.leading.equalTo(iconImageView.snp.trailing).offset(12.adjusted) + $0.trailing.lessThanOrEqualToSuperview().inset(16.adjusted) + $0.top.bottom.equalToSuperview().inset(12.adjustedH) + } + } +} 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..630c7e0 --- /dev/null +++ b/Projects/Modules/Networks/Sources/DTO/Weather/WeatherResponse.swift @@ -0,0 +1,30 @@ +// +// WeatherResponse.swift +// Networks +// +// Created by kimnahun on 2026-02-21. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public struct CurrentWeatherResponse: Decodable { + public let temperature: TemperatureResponse + public let weatherCondition: WeatherConditionResponse + public let relativeHumidity: Int? + public let iconBaseUri: String? + public let icon: String? +} + +public struct TemperatureResponse: Decodable { + public let degrees: Double + public let unit: String +} + +public struct WeatherConditionResponse: Decodable { + public let description: WeatherDescriptionResponse +} + +public struct WeatherDescriptionResponse: Decodable { + public let text: 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..495a257 --- /dev/null +++ b/Projects/Modules/Networks/Sources/Service/WeatherService.swift @@ -0,0 +1,26 @@ +// +// 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 getCurrentWeather(latitude: Double, longitude: Double) async throws -> CurrentWeatherResponse +} + +public final class WeatherService: WeatherServiceProtocol { + private let provider: MoyaProvider + + public init(provider: MoyaProvider = MoyaProvider()) { + self.provider = provider + } + + public func getCurrentWeather(latitude: Double, longitude: Double) async throws -> CurrentWeatherResponse { + try await provider.asyncThrowsRequestRaw(.getCurrentWeather(latitude: latitude, longitude: longitude)) + } +} diff --git a/Projects/Modules/Networks/Sources/TargetType/WeatherAPI.swift b/Projects/Modules/Networks/Sources/TargetType/WeatherAPI.swift new file mode 100644 index 0000000..908d10e --- /dev/null +++ b/Projects/Modules/Networks/Sources/TargetType/WeatherAPI.swift @@ -0,0 +1,52 @@ +// +// 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 getCurrentWeather(latitude: Double, longitude: Double) +} + +extension WeatherAPI: TargetType { + public var baseURL: URL { + URL(string: "https://weather.googleapis.com")! + } + + public var path: String { + switch self { + case .getCurrentWeather: + return "/v1/currentConditions:lookup" + } + } + + public var method: Moya.Method { + switch self { + case .getCurrentWeather: + return .get + } + } + + public var task: Moya.Task { + switch self { + case .getCurrentWeather(let latitude, let longitude): + return .requestParameters( + parameters: [ + "key": NetworkConfiguration.weatherApiKey, + "location.latitude": latitude, + "location.longitude": longitude + ], + encoding: URLEncoding.queryString + ) + } + } + + public var headers: [String: String]? { + ["Content-Type": "application/json"] + } +} From 0edff571975cf3e42c488d9b7970cb1ba4de5652 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sun, 22 Feb 2026 00:38:57 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20#34:=20=ED=95=B4=EB=8B=B9=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=EC=97=90=20=EB=82=A0=EC=94=A8=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=9E=88=EC=9D=84=20=EB=95=8C=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Weather/WeatherRepository.swift | 14 +- .../Transform/UserTravelTransform.swift | 2 + .../Sources/Transform/WeatherTransform.swift | 26 +- .../Weather/WeatherRepositoryInterface.swift | 6 +- .../Sources/Model/Home/MyTripSummary.swift | 8 +- .../Sources/Model/Weather/WeatherInfo.swift | 26 +- .../HomeFeature/Sources/HomeInteractor.swift | 18 +- .../Sources/TravelToolInteractor.swift | 97 ++++-- .../Sources/TravelToolViewController.swift | 12 +- .../Sources/Views/TravelToolWeatherView.swift | 325 +++++++++++++++--- .../ic_weather_cloud.imageset/Contents.json | 15 + .../ic_weather_cloud.svg | 27 ++ .../ic_weather_moon.imageset/Contents.json | 15 + .../ic_weather_moon.svg | 27 ++ .../Contents.json | 15 + .../ic_weather_moon_clouds.svg | 75 ++++ .../Contents.json | 15 + .../ic_weather_moon_rain.svg | 104 ++++++ .../ic_weather_rain.imageset/Contents.json | 15 + .../ic_weather_rain.svg | 66 ++++ .../Contents.json | 15 + .../ic_weather_sun_clouds01.svg | 61 ++++ .../Contents.json | 15 + .../ic_weather_sun_clouds02.svg | 50 +++ .../Contents.json | 15 + .../ic_weather_sun_rain.svg | 66 ++++ .../ic_weather_sunny.imageset/Contents.json | 15 + .../ic_weather_sunny.svg | 27 ++ .../ic_weather_thunder.imageset/Contents.json | 15 + .../ic_weather_thunder.svg | 91 +++++ .../Sources/DTO/Weather/WeatherResponse.swift | 38 +- .../Sources/Service/WeatherService.swift | 8 +- .../Sources/TargetType/WeatherAPI.swift | 13 +- 33 files changed, 1200 insertions(+), 137 deletions(-) create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_cloud.imageset/Contents.json create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_cloud.imageset/ic_weather_cloud.svg create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon.imageset/Contents.json create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon.imageset/ic_weather_moon.svg create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_clouds.imageset/Contents.json create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_clouds.imageset/ic_weather_moon_clouds.svg create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_rain.imageset/Contents.json create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_moon_rain.imageset/ic_weather_moon_rain.svg create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_rain.imageset/Contents.json create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_rain.imageset/ic_weather_rain.svg create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds01.imageset/Contents.json create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds01.imageset/ic_weather_sun_clouds01.svg create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds02.imageset/Contents.json create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_clouds02.imageset/ic_weather_sun_clouds02.svg create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_rain.imageset/Contents.json create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sun_rain.imageset/ic_weather_sun_rain.svg create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sunny.imageset/Contents.json create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_sunny.imageset/ic_weather_sunny.svg create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_thunder.imageset/Contents.json create mode 100644 Projects/Modules/DSKit/Resources/Assets.xcassets/ic_weather_thunder.imageset/ic_weather_thunder.svg diff --git a/Projects/Data/Sources/Repository/Weather/WeatherRepository.swift b/Projects/Data/Sources/Repository/Weather/WeatherRepository.swift index 418a7c0..13c6e9f 100644 --- a/Projects/Data/Sources/Repository/Weather/WeatherRepository.swift +++ b/Projects/Data/Sources/Repository/Weather/WeatherRepository.swift @@ -17,10 +17,18 @@ public final class WeatherRepository: WeatherRepositoryInterface { self.service = service } - public func fetchCurrentWeather(latitude: Double, longitude: Double) async throws -> WeatherInfo { + public func fetchForecast( + latitude: Double, + longitude: Double, + days: Int + ) async throws -> [DailyWeatherInfo] { do { - let response = try await service.getCurrentWeather(latitude: latitude, longitude: longitude) - return response.toDomain() + 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 012fc53..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( diff --git a/Projects/Data/Sources/Transform/WeatherTransform.swift b/Projects/Data/Sources/Transform/WeatherTransform.swift index 1e3caf0..3990659 100644 --- a/Projects/Data/Sources/Transform/WeatherTransform.swift +++ b/Projects/Data/Sources/Transform/WeatherTransform.swift @@ -10,20 +10,20 @@ import Foundation import Domain import Networks -extension CurrentWeatherResponse { - func toDomain() -> WeatherInfo { - let iconUrl: String - if let baseUri = iconBaseUri, let icon = icon { - iconUrl = "\(baseUri)/\(icon).png" - } else { - iconUrl = "" - } +extension ForecastDayResponse { + func toDomain() -> DailyWeatherInfo? { + var components = DateComponents() + components.year = displayDate.year + components.month = displayDate.month + components.day = displayDate.day - return WeatherInfo( - temperature: temperature.degrees, - description: weatherCondition.description.text, - iconUrl: iconUrl, - humidity: relativeHumidity ?? 0 + 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 index 833c324..3314d80 100644 --- a/Projects/Domain/Sources/Interface/Weather/WeatherRepositoryInterface.swift +++ b/Projects/Domain/Sources/Interface/Weather/WeatherRepositoryInterface.swift @@ -9,5 +9,9 @@ import Foundation public protocol WeatherRepositoryInterface { - func fetchCurrentWeather(latitude: Double, longitude: Double) async throws -> WeatherInfo + 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 1ca93da..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 diff --git a/Projects/Domain/Sources/Model/Weather/WeatherInfo.swift b/Projects/Domain/Sources/Model/Weather/WeatherInfo.swift index 0e9bcfa..1e0bba0 100644 --- a/Projects/Domain/Sources/Model/Weather/WeatherInfo.swift +++ b/Projects/Domain/Sources/Model/Weather/WeatherInfo.swift @@ -8,21 +8,21 @@ import Foundation -public struct WeatherInfo { - public let temperature: Double - public let description: String - public let iconUrl: String - public let humidity: Int +public struct DailyWeatherInfo { + public let date: Date + public let maxTemperature: Double + public let minTemperature: Double + public let weatherType: String public init( - temperature: Double, - description: String, - iconUrl: String, - humidity: Int + date: Date, + maxTemperature: Double, + minTemperature: Double, + weatherType: String ) { - self.temperature = temperature - self.description = description - self.iconUrl = iconUrl - self.humidity = humidity + self.date = date + self.maxTemperature = maxTemperature + self.minTemperature = minTemperature + self.weatherType = weatherType } } diff --git a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift index a684446..8fb9bc3 100644 --- a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift +++ b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift @@ -112,16 +112,16 @@ final class HomeInteractor: PresentableInteractor, HomeInteract 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 - } - }() + let usecase = self.usecase + var myTripBanner: HomePresentationModel.Banner + do { + let tripInfo = try await usecase.fetchMyTripInfo() + myTripBanner = tripInfo.toPresention() + } catch { + myTripBanner = .empty + } async let categories = self.usecase.fetchCategoryList().map { $0.toHomeModel() } async let populars = self.usecase.fetchPopularTripList().map { $0.toPopularHomeModel() } diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift index 94adbaa..21204d0 100644 --- a/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift @@ -23,7 +23,7 @@ protocol TravelToolPresentable: Presentable { var listener: TravelToolPresentableListener? { get set } func updateTripCard(_ state: TravelToolTripState) - func updateWeather(_ info: WeatherInfo?) + func updateWeather(_ state: TravelToolWeatherState) } // MARK: - TravelToolPresentableListener @@ -71,48 +71,89 @@ final class TravelToolInteractor: PresentableInteractor, fetchTask?.cancel() fetchTask = Task { [weak self] in - guard let self, !Task.isCancelled else { return } - - let summary: MyTripSummary? = await { - do { - return try await self.usecase.fetchMyTripInfo() - } catch { - return nil - } - }() + guard let self else { return } + + // 1. 여행 정보 조회 + var summary: MyTripSummary? + do { + summary = try await self.usecase.fetchMyTripInfo() + } catch { + summary = nil + } guard !Task.isCancelled else { return } - let state: TravelToolTripState + // 2. 트립 카드 업데이트 + let tripState: TravelToolTripState if let summary { - state = self.convertToState(summary) + tripState = self.convertToState(summary) } else { - state = .empty + tripState = .empty } - await MainActor.run { - self.presenter.updateTripCard(state) + await MainActor.run { [tripState] in + self.presenter.updateTripCard(tripState) } - guard let summary, !Task.isCancelled else { - await MainActor.run { self.presenter.updateWeather(nil) } + // 3. 여행이 없으면 noTrip 상태 + guard let summary else { + await MainActor.run { + self.presenter.updateWeather(.noTrip) + } return } - let weatherInfo: WeatherInfo? = await { - do { - return try await self.weatherRepository.fetchCurrentWeather( - latitude: summary.tripSchedule.latitude, - longitude: summary.tripSchedule.longitude - ) - } catch { - return nil + 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 } - await MainActor.run { - self.presenter.updateWeather(weatherInfo) + + // 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) + } } } } diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift index 41b3efc..b91e754 100644 --- a/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift @@ -38,16 +38,11 @@ final class TravelToolViewController: UIViewController, TravelToolPresentable, T // MARK: - TravelToolPresentable func updateTripCard(_ state: TravelToolTripState) { - tripCardView.configure(state) + tripCardView.configure(state) } - func updateWeather(_ info: WeatherInfo?) { - if let info { - weatherView.isHidden = false - weatherView.configure(with: info) - } else { - weatherView.isHidden = true - } + func updateWeather(_ state: TravelToolWeatherState) { + weatherView.configure(state) } } @@ -56,7 +51,6 @@ final class TravelToolViewController: UIViewController, TravelToolPresentable, T private extension TravelToolViewController { func setStyle() { view.backgroundColor = .white - weatherView.isHidden = true } func setUI() { diff --git a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift index a80eefa..f816f96 100644 --- a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift +++ b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift @@ -11,12 +11,40 @@ 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 iconImageView = UIImageView() - private let temperatureLabel = UILabel() - private let descriptionLabel = UILabel() - private let humidityLabel = UILabel() - private let infoStackView = UIStackView() + 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) @@ -24,68 +52,285 @@ final class TravelToolWeatherView: UIView { setStyle() setUI() setLayout() + collectionView.dataSource = self + collectionView.delegate = self } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func configure(with info: WeatherInfo) { - temperatureLabel.setText( - .subTitleMSB, - text: "\(Int(info.temperature))°", - color: DSKitAsset.Colors.black700.color - ) - descriptionLabel.setText( - .bodyMR, - text: info.description, - color: DSKitAsset.Colors.black600.color - ) - humidityLabel.setText( - .bodyMR, - text: "습도 \(info.humidity)%", - color: DSKitAsset.Colors.black400.color - ) + 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 - if let url = URL(string: info.iconUrl) { - iconImageView.kf.setImage(with: url) + 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 = DSKitAsset.Colors.black50.color - layer.cornerRadius = 8.adjustedH - clipsToBounds = true + backgroundColor = .clear + + 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 } - infoStackView.do { + 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 - $0.alignment = .leading + } + + if let subMessage { + subMessageLabel.do { + $0.setText(.bodyMR, text: subMessage, color: DSKitAsset.Colors.black400.color) + $0.textAlignment = .center + } } } - func setUI() { - addSubviews(iconImageView, infoStackView) - infoStackView.addArrangedSubviews(temperatureLabel, descriptionLabel, humidityLabel) + private func setUI(hasSubMessage: Bool) { + addSubview(stackView) + stackView.addArrangedSubviews(imageView, messageLabel) + if hasSubMessage { + stackView.addArrangedSubview(subMessageLabel) + } } - func setLayout() { - iconImageView.snp.makeConstraints { - $0.leading.equalToSuperview().inset(16.adjusted) - $0.centerY.equalToSuperview() - $0.size.equalTo(48.adjustedH) + 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 - infoStackView.snp.makeConstraints { - $0.leading.equalTo(iconImageView.snp.trailing).offset(12.adjusted) - $0.trailing.lessThanOrEqualToSuperview().inset(16.adjusted) - $0.top.bottom.equalToSuperview().inset(12.adjustedH) +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/Networks/Sources/DTO/Weather/WeatherResponse.swift b/Projects/Modules/Networks/Sources/DTO/Weather/WeatherResponse.swift index 630c7e0..b6ea517 100644 --- a/Projects/Modules/Networks/Sources/DTO/Weather/WeatherResponse.swift +++ b/Projects/Modules/Networks/Sources/DTO/Weather/WeatherResponse.swift @@ -8,23 +8,41 @@ import Foundation -public struct CurrentWeatherResponse: Decodable { - public let temperature: TemperatureResponse - public let weatherCondition: WeatherConditionResponse - public let relativeHumidity: Int? - public let iconBaseUri: String? - public let icon: String? +// MARK: - Forecast Response + +public struct ForecastResponse: Decodable { + public let forecastDays: [ForecastDayResponse] } -public struct TemperatureResponse: Decodable { - public let degrees: Double - public let unit: String +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 description: WeatherDescriptionResponse + 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/Service/WeatherService.swift b/Projects/Modules/Networks/Sources/Service/WeatherService.swift index 495a257..d311974 100644 --- a/Projects/Modules/Networks/Sources/Service/WeatherService.swift +++ b/Projects/Modules/Networks/Sources/Service/WeatherService.swift @@ -10,7 +10,7 @@ import Foundation import Moya public protocol WeatherServiceProtocol { - func getCurrentWeather(latitude: Double, longitude: Double) async throws -> CurrentWeatherResponse + func getForecast(latitude: Double, longitude: Double, days: Int) async throws -> ForecastResponse } public final class WeatherService: WeatherServiceProtocol { @@ -20,7 +20,9 @@ public final class WeatherService: WeatherServiceProtocol { self.provider = provider } - public func getCurrentWeather(latitude: Double, longitude: Double) async throws -> CurrentWeatherResponse { - try await provider.asyncThrowsRequestRaw(.getCurrentWeather(latitude: latitude, longitude: longitude)) + 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 index 908d10e..88b587b 100644 --- a/Projects/Modules/Networks/Sources/TargetType/WeatherAPI.swift +++ b/Projects/Modules/Networks/Sources/TargetType/WeatherAPI.swift @@ -10,7 +10,7 @@ import Foundation import Moya public enum WeatherAPI { - case getCurrentWeather(latitude: Double, longitude: Double) + case getForecast(latitude: Double, longitude: Double, days: Int) } extension WeatherAPI: TargetType { @@ -20,26 +20,27 @@ extension WeatherAPI: TargetType { public var path: String { switch self { - case .getCurrentWeather: - return "/v1/currentConditions:lookup" + case .getForecast: + return "/v1/forecast/days:lookup" } } public var method: Moya.Method { switch self { - case .getCurrentWeather: + case .getForecast: return .get } } public var task: Moya.Task { switch self { - case .getCurrentWeather(let latitude, let longitude): + case .getForecast(let latitude, let longitude, let days): return .requestParameters( parameters: [ "key": NetworkConfiguration.weatherApiKey, "location.latitude": latitude, - "location.longitude": longitude + "location.longitude": longitude, + "days": days ], encoding: URLEncoding.queryString ) From 4a1b92c3c538846fd6f3205708cf360a0d50ed6e Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sun, 22 Feb 2026 02:43:13 +0900 Subject: [PATCH 5/9] =?UTF-8?q?design:=20#34:=20=EB=B0=B1=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=9C=84=EC=B9=98=20=EC=A1=B0=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/FollowDetailViewController.swift | 3 +- .../PlaceDetailViewController.swift | 24 +++++++++++-- .../TripCalendarViewController.swift | 28 +++++++++++++-- .../Sources/TravelInteractor.swift | 4 --- .../Sources/TravelViewController.swift | 34 +++++-------------- .../Sources/Component/NDGLNavigationBar.swift | 2 +- 6 files changed, 57 insertions(+), 38 deletions(-) diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift index 6a68d2a..9f9a864 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 { 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/TravelFeature/Sources/TravelInteractor.swift b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift index 42b410e..42da925 100644 --- a/Projects/Features/TravelFeature/Sources/TravelInteractor.swift +++ b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift @@ -29,7 +29,6 @@ protocol TravelPresentable: Presentable { protocol TravelPresentableListener: AnyObject { func didTapTrip(_ trip: UpcomingTrip) - func didTapMenuButton() } // MARK: - TravelInteractor @@ -115,7 +114,4 @@ extension TravelInteractor: TravelPresentableListener { func didTapTrip(_ trip: UpcomingTrip) { } - - func didTapMenuButton() { - } } diff --git a/Projects/Features/TravelFeature/Sources/TravelViewController.swift b/Projects/Features/TravelFeature/Sources/TravelViewController.swift index 3136ab5..0b93905 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() @@ -77,25 +72,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 +106,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/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) } From bd9dc924e1f6a38a5abf886970a9c1fccbb6f4fd Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sun, 22 Feb 2026 03:13:44 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20#34:=20=ED=99=88=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EC=9D=B8=EA=B8=B0=EC=97=AC=ED=96=89=20api=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EB=90=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/FollowDetailInteractor.swift | 25 ++++--- .../Sources/FollowDetailViewController.swift | 12 ++++ .../HomeFeature/Sources/HomeInteractor.swift | 71 ++++++++++++++----- .../Sources/HomeViewController.swift | 2 + .../MainFeature/Sources/MainInteractor.swift | 6 +- .../MainFeature/Sources/MainRouter.swift | 4 ++ .../Sources/TabBarInteractor.swift | 1 + .../TabBarFeature/Sources/TabBarRouter.swift | 5 ++ .../Sources/TabBarViewController.swift | 2 +- .../Sources/TravelInteractor.swift | 5 ++ .../Sources/TravelViewController.swift | 1 + .../Sources/TravelToolInteractor.swift | 4 ++ .../Sources/TravelToolViewController.swift | 5 ++ .../Views/TravelToolUpComingView.swift | 4 ++ .../Sources/Views/TravelToolWeatherView.swift | 5 ++ 15 files changed, 122 insertions(+), 30 deletions(-) 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 9f9a864..730b0e6 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift @@ -254,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/HomeFeature/Sources/HomeInteractor.swift b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift index 8fb9bc3..d124111 100644 --- a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift +++ b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift @@ -95,21 +95,16 @@ 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 } @@ -122,24 +117,32 @@ final class HomeInteractor: PresentableInteractor, HomeInteract } catch { myTripBanner = .empty } - - async let categories = self.usecase.fetchCategoryList().map { $0.toHomeModel() } - async let populars = self.usecase.fetchPopularTripList().map { $0.toPopularHomeModel() } + + 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/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/TabBarFeature/Sources/TabBarInteractor.swift b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift index 89bf549..f2c055d 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift @@ -16,6 +16,7 @@ import TravelToolFeature public protocol TabBarRouting: ViewableRouting { func attachTabs() + func switchToTab(at index: Int) } // MARK: - TabBarPresentable diff --git a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift index d32b82c..7f4ba55 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift @@ -23,6 +23,7 @@ protocol TabBarInteractable: Interactable, HomeListener, TravelListener, TravelT public protocol TabBarViewControllable: ViewControllable { func setViewControllers(_ viewControllers: [ViewControllable]) + func switchToTab(at index: Int) } // MARK: - TabBarRouter @@ -57,6 +58,10 @@ final class TabBarRouter: ViewableRouter, TravelIn extension TravelInteractor: TravelPresentableListener { + func viewWillAppear() { + loadTrips() + } + func didTapTrip(_ trip: UpcomingTrip) { } } diff --git a/Projects/Features/TravelFeature/Sources/TravelViewController.swift b/Projects/Features/TravelFeature/Sources/TravelViewController.swift index 0b93905..ad57434 100644 --- a/Projects/Features/TravelFeature/Sources/TravelViewController.swift +++ b/Projects/Features/TravelFeature/Sources/TravelViewController.swift @@ -65,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 diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift index 21204d0..4f7a752 100644 --- a/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift @@ -29,6 +29,7 @@ protocol TravelToolPresentable: Presentable { // MARK: - TravelToolPresentableListener protocol TravelToolPresentableListener: AnyObject { + func viewWillAppear() } // MARK: - TravelToolInteractor @@ -193,6 +194,9 @@ final class TravelToolInteractor: PresentableInteractor, // MARK: - TravelToolPresentableListener extension TravelToolInteractor: TravelToolPresentableListener { + func viewWillAppear() { + fetchTripInfo() + } } // MARK: - Date Extension diff --git a/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift index b91e754..702c3fb 100644 --- a/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolViewController.swift @@ -35,6 +35,11 @@ final class TravelToolViewController: UIViewController, TravelToolPresentable, T setLayout() } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + listener?.viewWillAppear() + } + // MARK: - TravelToolPresentable func updateTripCard(_ state: TravelToolTripState) { diff --git a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolUpComingView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolUpComingView.swift index 436eabf..9ba12eb 100644 --- a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolUpComingView.swift +++ b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolUpComingView.swift @@ -56,10 +56,14 @@ private extension TravelToolUpComingView { $0.backgroundColor = DSKitAsset.Colors.black100.color $0.layer.cornerRadius = 26.adjustedH / 2 $0.clipsToBounds = true + $0.setContentCompressionResistancePriority(.required, for: .horizontal) + $0.setContentHuggingPriority(.required, for: .horizontal) } titleLabel.do { $0.numberOfLines = 1 + $0.lineBreakMode = .byTruncatingTail + $0.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) } } diff --git a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift index f816f96..2c97e27 100644 --- a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift +++ b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolWeatherView.swift @@ -89,6 +89,11 @@ private extension TravelToolWeatherView { func setStyle() { backgroundColor = .clear + titleLabel.do { + $0.numberOfLines = 1 + $0.lineBreakMode = .byTruncatingTail + } + contentStackView.do { $0.axis = .vertical $0.alignment = .fill From 221355d9453fdf300a24e8cdca44eba5697d447f Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sun, 22 Feb 2026 03:23:00 +0900 Subject: [PATCH 7/9] =?UTF-8?q?design:=20#34:=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=ED=95=9C=20=ED=9B=84=EC=97=90=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=84=98=EC=96=B4=EA=B0=80=EB=8A=94?= =?UTF-8?q?=EA=B2=8C=20=EC=95=84=EB=8B=88=EA=B3=A0=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EC=9D=B4=EB=A3=A8?= =?UTF-8?q?=EC=96=B4=EC=A7=80=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SearchFeature/Sources/SearchBuilder.swift | 9 +- .../Sources/SearchInteractor.swift | 61 ++- .../SearchFeature/Sources/SearchRouter.swift | 32 +- .../Sources/SearchViewController.swift | 368 ++++++++++++++---- 4 files changed, 355 insertions(+), 115 deletions(-) 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 } } From 2558cb8689f5934c3a97f44a1a12feb4b45e776e Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sun, 22 Feb 2026 03:44:57 +0900 Subject: [PATCH 8/9] =?UTF-8?q?chore:=20PrivacyInfo.xcprivacy=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- Projects/App/Resources/PrivacyInfo.xcprivacy | 31 ++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 Projects/App/Resources/PrivacyInfo.xcprivacy 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 + + + + + From 1be36e075311471efd440af346fa1af0442fdaa3 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sun, 22 Feb 2026 03:58:34 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20#34=20-=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?UI=20DSKit=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Views/Cells/HomeBannerCell.swift | 4 +- .../Sources/TravelToolInteractor.swift | 3 + .../Sources/Views/TravelToolOnGoingView.swift | 110 ------------------ .../Views/TravelToolTripCardView.swift | 10 +- .../Views/TravelToolUpComingView.swift | 104 ----------------- .../Sources/Component/NDGLOnGoingView.swift} | 65 ++++++----- .../Sources/Component/NDGLUpComingView.swift} | 53 +++++---- 7 files changed, 74 insertions(+), 275 deletions(-) delete mode 100644 Projects/Features/TravelToolFeature/Sources/Views/TravelToolOnGoingView.swift delete mode 100644 Projects/Features/TravelToolFeature/Sources/Views/TravelToolUpComingView.swift rename Projects/{Features/HomeFeature/Sources/Views/Component/HomeBannerOnGoingView.swift => Modules/DSKit/Sources/Component/NDGLOnGoingView.swift} (91%) rename Projects/{Features/HomeFeature/Sources/Views/Component/HomeBannerUpCommingView.swift => Modules/DSKit/Sources/Component/NDGLUpComingView.swift} (87%) 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/TravelToolFeature/Sources/TravelToolInteractor.swift b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift index 4f7a752..58a0183 100644 --- a/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift +++ b/Projects/Features/TravelToolFeature/Sources/TravelToolInteractor.swift @@ -9,6 +9,7 @@ import Foundation import Domain +import DSKit import RIBs import RxSwift @@ -173,6 +174,8 @@ final class TravelToolInteractor: PresentableInteractor, return .onGoing( title: "\(summary.title) \(schedule.day)일차 입니다!", date: duration, + transportIcon: DSKitAsset.Assets.icBus2.image, + transport: "대중교통", duration: "\(schedule.estimatedDuration)분", place: schedule.placeName, imageUrl: schedule.thumbnailUrl diff --git a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolOnGoingView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolOnGoingView.swift deleted file mode 100644 index 45ecd4e..0000000 --- a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolOnGoingView.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// TravelToolOnGoingView.swift -// TravelToolFeature -// -// Created by kimnahun on 2026-02-21. -// Copyright © 2026 NDGL-iOS. All rights reserved. -// - -import UIKit - -import DSKit - -final class TravelToolOnGoingView: UIView { - private let titleLabel = UILabel() - private let dateLabel = UILabel() - private let durationLabel = UILabel() - private let placeLabel = UILabel() - private let imageView = UIImageView() - private let routeCardView = UIView() - - override 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, - duration: String, - place: String, - imageUrl: String - ) { - titleLabel.setText(.subTitleMSB, text: title, color: DSKitAsset.Colors.black700.color) - dateLabel.setText(.bodyMR, text: date, color: DSKitAsset.Colors.black500.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 - } - } -} - -private extension TravelToolOnGoingView { - func setStyle() { - backgroundColor = .clear - - imageView.do { - $0.layer.cornerRadius = 4.adjustedH - $0.backgroundColor = .systemGray6 - $0.clipsToBounds = true - $0.contentMode = .scaleAspectFill - } - - routeCardView.do { - $0.backgroundColor = DSKitAsset.Colors.white.color - $0.layer.cornerRadius = 16.adjustedH - $0.clipsToBounds = true - } - } - - func setUI() { - routeCardView.addSubviews(durationLabel, placeLabel, imageView) - addSubviews(titleLabel, dateLabel, routeCardView) - } - - func setLayout() { - titleLabel.snp.makeConstraints { - $0.top.leading.equalToSuperview().inset(16.adjusted) - } - - dateLabel.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(4.adjustedH) - $0.leading.equalToSuperview().inset(16.adjusted) - } - - routeCardView.snp.makeConstraints { - $0.top.equalTo(dateLabel.snp.bottom).offset(16.adjustedH) - $0.directionalHorizontalEdges.equalToSuperview().inset(16.adjusted) - $0.height.equalTo(84.adjustedH) - $0.bottom.equalToSuperview().inset(23.adjustedH).priority(.low) - } - - durationLabel.snp.makeConstraints { - $0.top.equalToSuperview().inset(16.5.adjustedH) - $0.leading.equalToSuperview().inset(16.adjusted) - } - - placeLabel.snp.makeConstraints { - $0.top.equalTo(durationLabel.snp.bottom).offset(10.adjustedH) - $0.leading.equalToSuperview().inset(16.adjusted) - $0.trailing.lessThanOrEqualTo(imageView.snp.leading).offset(-8.adjusted) - } - - imageView.snp.makeConstraints { - $0.top.equalToSuperview().inset(12.adjustedH) - $0.trailing.bottom.equalToSuperview().inset(16.adjusted) - $0.width.equalTo(imageView.snp.height) - } - } -} diff --git a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolTripCardView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolTripCardView.swift index 60109d2..92a9152 100644 --- a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolTripCardView.swift +++ b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolTripCardView.swift @@ -16,6 +16,8 @@ enum TravelToolTripState { case onGoing( title: String, date: String, + transportIcon: UIImage?, + transport: String, duration: String, place: String, imageUrl: String @@ -24,8 +26,8 @@ enum TravelToolTripState { final class TravelToolTripCardView: UIView { private let emptyView = TravelToolEmptyView() - private let upComingView = TravelToolUpComingView() - private let onGoingView = TravelToolOnGoingView() + private let upComingView = NDGLUpComingView() + private let onGoingView = NDGLOnGoingView() private let stackView = UIStackView() override init(frame: CGRect) { @@ -49,11 +51,13 @@ final class TravelToolTripCardView: UIView { 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 duration, let place, let 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 diff --git a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolUpComingView.swift b/Projects/Features/TravelToolFeature/Sources/Views/TravelToolUpComingView.swift deleted file mode 100644 index 9ba12eb..0000000 --- a/Projects/Features/TravelToolFeature/Sources/Views/TravelToolUpComingView.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// TravelToolUpComingView.swift -// TravelToolFeature -// -// Created by kimnahun on 2026-02-21. -// Copyright © 2026 NDGL-iOS. All rights reserved. -// - -import UIKit - -import DSKit - -final class TravelToolUpComingView: UIView { - private let imageView = UIImageView() - private let badge = UIView() - private let dDayLabel = UILabel() - private let titleLabel = UILabel() - private let dateLabel = 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(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 - } - } -} - -private extension TravelToolUpComingView { - 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 - $0.clipsToBounds = true - $0.setContentCompressionResistancePriority(.required, for: .horizontal) - $0.setContentHuggingPriority(.required, for: .horizontal) - } - - titleLabel.do { - $0.numberOfLines = 1 - $0.lineBreakMode = .byTruncatingTail - $0.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - } - } - - func setUI() { - badge.addSubview(dDayLabel) - addSubviews(imageView, badge, titleLabel, dateLabel) - } - - func setLayout() { - imageView.snp.makeConstraints { - $0.leading.equalToSuperview().inset(16.adjusted) - $0.top.bottom.equalToSuperview().inset(8.adjustedH) - $0.size.equalTo(64.adjustedH) - } - - badge.snp.makeConstraints { - $0.top.equalTo(imageView).offset(7.adjustedH) - $0.leading.equalTo(imageView.snp.trailing).offset(12.adjusted) - $0.height.equalTo(26.adjustedH) - } - - dDayLabel.snp.makeConstraints { - $0.directionalHorizontalEdges.equalToSuperview().inset(12.adjusted) - $0.directionalVerticalEdges.equalToSuperview().inset(4.adjustedH) - } - - titleLabel.snp.makeConstraints { - $0.leading.equalTo(badge.snp.trailing).offset(8.adjusted) - $0.centerY.equalTo(badge) - $0.trailing.lessThanOrEqualToSuperview().inset(16.adjusted) - } - - dateLabel.snp.makeConstraints { - $0.top.equalTo(badge.snp.bottom).offset(8.adjustedH) - $0.leading.equalTo(badge) - } - } -} 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)