From e2322f509edfcdaa310984dbb9bef6da73c81d68 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Fri, 23 Jan 2026 17:27:02 +0900 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20#10=20-=20=EB=94=B0=EB=9D=BC?= =?UTF-8?q?=EA=B0=80=EA=B8=B0=20=EC=BB=A8=ED=85=90=EC=B8=A0=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20RIBs=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dependency+Project.swift | 7 ++ Projects/Features/FollowFeature/Project.swift | 25 ++++++ .../Sources/FollowDetailBuilder.swift | 53 +++++++++++++ .../Sources/FollowDetailInteractor.swift | 63 +++++++++++++++ .../Sources/FollowDetailRouter.swift | 41 ++++++++++ .../Sources/FollowDetailViewController.swift | 78 +++++++++++++++++++ Projects/Features/HomeFeature/Project.swift | 3 +- .../HomeFeature/Sources/HomeBuilder.swift | 8 +- .../HomeFeature/Sources/HomeInteractor.swift | 12 ++- .../HomeFeature/Sources/HomeRouter.swift | 38 +++++++-- .../Sources/HomeViewController.swift | 17 ++++ 11 files changed, 335 insertions(+), 10 deletions(-) create mode 100644 Projects/Features/FollowFeature/Project.swift create mode 100644 Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift create mode 100644 Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift create mode 100644 Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift create mode 100644 Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift index 015be31..86baa9e 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift @@ -11,6 +11,7 @@ public extension TargetDependency { struct Features { public struct Home {} public struct TabBar {} + public struct Follow {} } struct Modules {} @@ -49,3 +50,9 @@ public extension TargetDependency.Features.TabBar { static let feature = TargetDependency.Features.project(name: "Feature", group: group) } + +public extension TargetDependency.Features.Follow { + static let group = "Follow" + + static let feature = TargetDependency.Features.project(name: "Feature", group: group) +} diff --git a/Projects/Features/FollowFeature/Project.swift b/Projects/Features/FollowFeature/Project.swift new file mode 100644 index 0000000..1d74644 --- /dev/null +++ b/Projects/Features/FollowFeature/Project.swift @@ -0,0 +1,25 @@ +// +// Project.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// + +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: "FollowFeature", + targets: [ + .makeFrameworkTarget( + name: "FollowFeature", + dependencies: [ + .Features.baseFeatureDependency + ], + scripts: [.swiftLint], + isStatic: true, + hasResources: false + ) + ] +) diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift b/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift new file mode 100644 index 0000000..2171dca --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift @@ -0,0 +1,53 @@ +// +// FollowDetailBuilder.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +// MARK: - FollowDetailDependency + +public protocol FollowDetailDependency: Dependency { + // 부모 RIB로부터 주입받을 의존성 정의 +} + +// MARK: - FollowDetailComponent + +final class FollowDetailComponent: Component { + // 자식 RIB에 전달할 의존성 +} + +// MARK: - FollowDetailBuildable + +public protocol FollowDetailBuildable: Buildable { + func build(withListener listener: FollowDetailListener, recommendationId: Int) -> FollowDetailRouting +} + +// MARK: - FollowDetailBuilder + +public final class FollowDetailBuilder: Builder, FollowDetailBuildable { + + public override init(dependency: FollowDetailDependency) { + super.init(dependency: dependency) + } + + public func build(withListener listener: FollowDetailListener, recommendationId: Int) -> FollowDetailRouting { + _ = FollowDetailComponent(dependency: dependency) + let viewController = FollowDetailViewController() + let interactor = FollowDetailInteractor( + presenter: viewController, + recommendationId: recommendationId + ) + interactor.listener = listener + + let router = FollowDetailRouter( + interactor: interactor, + viewController: viewController + ) + + return router + } +} diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift new file mode 100644 index 0000000..871792d --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -0,0 +1,63 @@ +// +// FollowDetailInteractor.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs +import RxSwift + +// MARK: - FollowDetailListener + +public protocol FollowDetailListener: AnyObject { + func followDetailDidTapClose() +} + +// MARK: - FollowDetailPresentable + +protocol FollowDetailPresentable: Presentable { + var listener: FollowDetailPresentableListener? { get set } +} + +// MARK: - FollowDetailPresentableListener + +protocol FollowDetailPresentableListener: AnyObject { + func didTapCloseButton() +} + +// MARK: - FollowDetailInteractor + +final class FollowDetailInteractor: PresentableInteractor, FollowDetailInteractable { + + weak var router: FollowDetailRouting? + weak var listener: FollowDetailListener? + + private let disposeBag = DisposeBag() + private let recommendationId: Int + + init(presenter: FollowDetailPresentable, recommendationId: Int) { + self.recommendationId = recommendationId + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + // TODO: recommendationId를 사용하여 데이터 로드 + print("FollowDetail loaded with recommendationId: \(recommendationId)") + } + + override func willResignActive() { + super.willResignActive() + } +} + +// MARK: - FollowDetailPresentableListener + +extension FollowDetailInteractor: FollowDetailPresentableListener { + func didTapCloseButton() { + listener?.followDetailDidTapClose() + } +} diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift b/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift new file mode 100644 index 0000000..05c6cf7 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift @@ -0,0 +1,41 @@ +// +// FollowDetailRouter.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +// MARK: - FollowDetailInteractable + +protocol FollowDetailInteractable: Interactable { + var router: FollowDetailRouting? { get set } + var listener: FollowDetailListener? { get set } +} + +// MARK: - FollowDetailViewControllable + +public protocol FollowDetailViewControllable: ViewControllable { + // ViewController에 요청할 화면 전환 메서드 정의 +} + +// MARK: - FollowDetailRouting + +public protocol FollowDetailRouting: ViewableRouting { + // 자식 RIB으로 라우팅하는 메서드 정의 +} + +// MARK: - FollowDetailRouter + +final class FollowDetailRouter: ViewableRouter, FollowDetailRouting { + + override init( + interactor: FollowDetailInteractable, + viewController: FollowDetailViewControllable + ) { + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } +} diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift new file mode 100644 index 0000000..dd44f85 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift @@ -0,0 +1,78 @@ +// +// FollowDetailViewController.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import RIBs +import RxSwift + +// MARK: - FollowDetailViewController + +public final class FollowDetailViewController: UIViewController, FollowDetailPresentable, FollowDetailViewControllable { + + // MARK: - Properties + + weak var listener: FollowDetailPresentableListener? + + private let disposeBag = DisposeBag() + + // MARK: - UI Components + + private let placeholderLabel: UILabel = { + let label = UILabel() + + label.textAlignment = .center + label.textColor = .gray + label.font = .systemFont(ofSize: 16, weight: .medium) + return label + }() + + // MARK: - Initialization + + public init() { + super.init(nibName: nil, bundle: nil) + hidesBottomBarWhenPushed = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupConstraints() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(false, animated: animated) + } + + private func setupUI() { + view.backgroundColor = .white + view.addSubview(placeholderLabel) + } + + private func setupConstraints() { + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + placeholderLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + placeholderLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + // MARK: - Actions + + @objc private func backButtonTapped() { + listener?.didTapCloseButton() + } +} diff --git a/Projects/Features/HomeFeature/Project.swift b/Projects/Features/HomeFeature/Project.swift index 4c3baad..ede9ea3 100644 --- a/Projects/Features/HomeFeature/Project.swift +++ b/Projects/Features/HomeFeature/Project.swift @@ -15,7 +15,8 @@ let project = Project.makeModule( .makeFrameworkTarget( name: "HomeFeature", dependencies: [ - .Features.baseFeatureDependency + .Features.baseFeatureDependency, + .Features.Follow.feature ], scripts: [.swiftLint], isStatic: true, diff --git a/Projects/Features/HomeFeature/Sources/HomeBuilder.swift b/Projects/Features/HomeFeature/Sources/HomeBuilder.swift index 421ee76..ea88eb1 100644 --- a/Projects/Features/HomeFeature/Sources/HomeBuilder.swift +++ b/Projects/Features/HomeFeature/Sources/HomeBuilder.swift @@ -7,6 +7,7 @@ // import Domain +import FollowFeature import RIBs // MARK: - HomeDependency @@ -17,7 +18,7 @@ public protocol HomeDependency: Dependency { // MARK: - HomeComponent -final class HomeComponent: Component { +final class HomeComponent: Component, FollowDetailDependency { var travelRepository: TravelRepositoryProtocol { // TODO: 실제 API 연동 시 실제 Repository로 교체 MockTravelRepository() @@ -47,9 +48,12 @@ public final class HomeBuilder: Builder, HomeBuildable { ) interactor.listener = listener + let followDetailBuilder = FollowDetailBuilder(dependency: component) + let router = HomeRouter( interactor: interactor, - viewController: viewController + viewController: viewController, + followDetailBuilder: followDetailBuilder ) return router diff --git a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift index 8a36f3e..753326d 100644 --- a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift +++ b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift @@ -7,6 +7,7 @@ // import Domain +import FollowFeature import RIBs import RxSwift @@ -132,8 +133,7 @@ extension HomeInteractor: HomePresentableListener { func didSelectRecommendation(at index: Int) { guard index < recommendations.count else { return } let recommendation = recommendations[index] - // TODO: 상세 화면으로 이동 - print("Selected recommendation: \(recommendation.title)") + router?.routeToFollowDetail(with: recommendation.id) } func didTapShowMoreTrips() { @@ -150,3 +150,11 @@ extension HomeInteractor: HomePresentableListener { loadHomeData() } } + +// MARK: - FollowDetailListener + +extension HomeInteractor: FollowDetailListener { + func followDetailDidTapClose() { + router?.detachFollowDetail() + } +} diff --git a/Projects/Features/HomeFeature/Sources/HomeRouter.swift b/Projects/Features/HomeFeature/Sources/HomeRouter.swift index ff062b3..19c3f31 100644 --- a/Projects/Features/HomeFeature/Sources/HomeRouter.swift +++ b/Projects/Features/HomeFeature/Sources/HomeRouter.swift @@ -8,9 +8,11 @@ import RIBs +import FollowFeature + // MARK: - HomeInteractable -protocol HomeInteractable: Interactable { +protocol HomeInteractable: Interactable, FollowDetailListener { var router: HomeRouting? { get set } var listener: HomeListener? { get set } } @@ -18,24 +20,50 @@ protocol HomeInteractable: Interactable { // MARK: - HomeViewControllable public protocol HomeViewControllable: ViewControllable { - // ViewController에 요청할 화면 전환 메서드 정의 + func push(_ viewController: ViewControllable) + func pop() } // MARK: - HomeRouting public protocol HomeRouting: ViewableRouting { - // 자식 RIB으로 라우팅하는 메서드 정의 + func routeToFollowDetail(with recommendationId: Int) + func detachFollowDetail() } // MARK: - HomeRouter final class HomeRouter: ViewableRouter, HomeRouting { - override init( + private let followDetailBuilder: FollowDetailBuildable + private var followDetailRouter: FollowDetailRouting? + + init( interactor: HomeInteractable, - viewController: HomeViewControllable + viewController: HomeViewControllable, + followDetailBuilder: FollowDetailBuildable ) { + self.followDetailBuilder = followDetailBuilder super.init(interactor: interactor, viewController: viewController) interactor.router = self } + + // MARK: - HomeRouting + + func routeToFollowDetail(with recommendationId: Int) { + guard followDetailRouter == nil else { return } + + let router = followDetailBuilder.build(withListener: interactor, recommendationId: recommendationId) + followDetailRouter = router + attachChild(router) + viewController.push(router.viewControllable) + } + + func detachFollowDetail() { + guard let router = followDetailRouter else { return } + + viewController.pop() + detachChild(router) + followDetailRouter = nil + } } diff --git a/Projects/Features/HomeFeature/Sources/HomeViewController.swift b/Projects/Features/HomeFeature/Sources/HomeViewController.swift index 5830f45..3af6829 100644 --- a/Projects/Features/HomeFeature/Sources/HomeViewController.swift +++ b/Projects/Features/HomeFeature/Sources/HomeViewController.swift @@ -82,6 +82,11 @@ final class HomeViewController: UIViewController, HomePresentable, HomeViewContr setupConstraints() } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + } + // MARK: - Setup private func setupDelegates() { @@ -242,3 +247,15 @@ extension HomeViewController: RecommendContentCollectionViewDelegate { listener?.didSelectRecommendation(at: index) } } + +// MARK: - HomeViewControllable + +extension HomeViewController { + func push(_ viewController: ViewControllable) { + navigationController?.pushViewController(viewController.uiviewController, animated: true) + } + + func pop() { + navigationController?.popViewController(animated: true) + } +} From dcf520df7c51aaa5446517f4e19a2203203ad910 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Fri, 23 Jan 2026 18:21:55 +0900 Subject: [PATCH 02/20] =?UTF-8?q?design:=20#10=20-=20=EB=94=B0=EB=9D=BC?= =?UTF-8?q?=EA=B0=80=EA=B8=B0=20=EC=BB=A8=ED=85=90=EC=B8=A0=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=20UI=20=EC=B6=94=EA=B0=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FollowDetailRepositoryProtocol.swift | 17 ++ .../Sources/Model/Travel/TravelDetail.swift | 64 ++++++ .../Sources/Model/Travel/TravelPlace.swift | 71 ++++++ .../Sources/FollowDetailBuilder.swift | 9 +- .../Sources/FollowDetailInteractor.swift | 95 +++++++- .../Sources/FollowDetailViewController.swift | 199 ++++++++++++++-- .../Mock/MockFollowDetailRepository.swift | 175 ++++++++++++++ .../Sources/Views/BudgetView.swift | 82 +++++++ .../Sources/Views/Cells/DayCell.swift | 85 +++++++ .../Sources/Views/Cells/PlaceCell.swift | 172 ++++++++++++++ .../CollectionViews/DayCollectionView.swift | 96 ++++++++ .../PlaceListCollectionView.swift | 96 ++++++++ .../Sources/Views/MediaInfoView.swift | 213 ++++++++++++++++++ .../Sources/Views/TravelMapView.swift | 179 +++++++++++++++ 14 files changed, 1532 insertions(+), 21 deletions(-) create mode 100644 Projects/Domain/Sources/Interface/Travel/FollowDetailRepositoryProtocol.swift create mode 100644 Projects/Domain/Sources/Model/Travel/TravelDetail.swift create mode 100644 Projects/Domain/Sources/Model/Travel/TravelPlace.swift create mode 100644 Projects/Features/FollowFeature/Sources/Mock/MockFollowDetailRepository.swift create mode 100644 Projects/Features/FollowFeature/Sources/Views/BudgetView.swift create mode 100644 Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift create mode 100644 Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift create mode 100644 Projects/Features/FollowFeature/Sources/Views/CollectionViews/DayCollectionView.swift create mode 100644 Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift create mode 100644 Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift create mode 100644 Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift diff --git a/Projects/Domain/Sources/Interface/Travel/FollowDetailRepositoryProtocol.swift b/Projects/Domain/Sources/Interface/Travel/FollowDetailRepositoryProtocol.swift new file mode 100644 index 0000000..0c4e301 --- /dev/null +++ b/Projects/Domain/Sources/Interface/Travel/FollowDetailRepositoryProtocol.swift @@ -0,0 +1,17 @@ +// +// FollowDetailRepositoryProtocol.swift +// Domain +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public protocol FollowDetailRepositoryProtocol { + /// 여행 상세 정보 조회 + func fetchTravelDetail(id: Int) async -> TravelDetail? + + /// 일차별 장소 목록 조회 + func fetchPlaces(travelId: Int, day: Int) async -> [TravelPlace] +} diff --git a/Projects/Domain/Sources/Model/Travel/TravelDetail.swift b/Projects/Domain/Sources/Model/Travel/TravelDetail.swift new file mode 100644 index 0000000..50dff2c --- /dev/null +++ b/Projects/Domain/Sources/Model/Travel/TravelDetail.swift @@ -0,0 +1,64 @@ +// +// TravelDetail.swift +// Domain +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +/// 여행 상세 정보 +public struct TravelDetail: Hashable { + public let travelId: String + public let country: String + public let city: String + public let budgetPerPerson: Int + public let nights: Int + public let days: Int + public let youtube: YouTubeInfo + + public init( + travelId: String, + country: String, + city: String, + budgetPerPerson: Int, + nights: Int, + days: Int, + youtube: YouTubeInfo + ) { + self.travelId = travelId + self.country = country + self.city = city + self.budgetPerPerson = budgetPerPerson + self.nights = nights + self.days = days + self.youtube = youtube + } +} + +/// 유튜브 정보 +public struct YouTubeInfo: Hashable { + public let title: String + public let youtuber: String + public let thumbnail: String? + public let profileImage: String? + public let link: String + public let summary: String + + public init( + title: String, + youtuber: String, + thumbnail: String?, + profileImage: String?, + link: String, + summary: String + ) { + self.title = title + self.youtuber = youtuber + self.thumbnail = thumbnail + self.profileImage = profileImage + self.link = link + self.summary = summary + } +} diff --git a/Projects/Domain/Sources/Model/Travel/TravelPlace.swift b/Projects/Domain/Sources/Model/Travel/TravelPlace.swift new file mode 100644 index 0000000..1e72eee --- /dev/null +++ b/Projects/Domain/Sources/Model/Travel/TravelPlace.swift @@ -0,0 +1,71 @@ +// +// TravelPlace.swift +// Domain +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +/// 여행 장소 정보 +public struct TravelPlace: Hashable { + public let id: Int + public let day: Int + public let sequence: Int + public let travelerTip: String + public let estimatedDuration: Int + public let place: PlaceInfo + + public init( + id: Int, + day: Int, + sequence: Int, + travelerTip: String, + estimatedDuration: Int, + place: PlaceInfo + ) { + self.id = id + self.day = day + self.sequence = sequence + self.travelerTip = travelerTip + self.estimatedDuration = estimatedDuration + self.place = place + } +} + +/// 장소 상세 정보 +public struct PlaceInfo: Hashable { + public let googlePlaceId: String + public let latitude: Double + public let longitude: Double + public let name: String + public let regularOpeningHours: String? + public let category: PlaceCategory + + public init( + googlePlaceId: String, + latitude: Double, + longitude: Double, + name: String, + regularOpeningHours: String?, + category: PlaceCategory = .etc + ) { + self.googlePlaceId = googlePlaceId + self.latitude = latitude + self.longitude = longitude + self.name = name + self.regularOpeningHours = regularOpeningHours + self.category = category + } +} + +/// 장소 카테고리 +public enum PlaceCategory: String, Hashable { + case transportation = "교통수단" + case tourism = "관광명소" + case restaurant = "음식점" + case cafe = "카페" + case accommodation = "숙소" + case etc = "기타" +} diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift b/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift index 2171dca..63556a6 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift @@ -6,6 +6,7 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import Domain import RIBs // MARK: - FollowDetailDependency @@ -17,7 +18,10 @@ public protocol FollowDetailDependency: Dependency { // MARK: - FollowDetailComponent final class FollowDetailComponent: Component { - // 자식 RIB에 전달할 의존성 + var repository: FollowDetailRepositoryProtocol { + // TODO: 실제 API 연동 시 실제 Repository로 교체 + MockFollowDetailRepository() + } } // MARK: - FollowDetailBuildable @@ -35,10 +39,11 @@ public final class FollowDetailBuilder: Builder, FollowD } public func build(withListener listener: FollowDetailListener, recommendationId: Int) -> FollowDetailRouting { - _ = FollowDetailComponent(dependency: dependency) + let component = FollowDetailComponent(dependency: dependency) let viewController = FollowDetailViewController() let interactor = FollowDetailInteractor( presenter: viewController, + repository: component.repository, recommendationId: recommendationId ) interactor.listener = listener diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift index 871792d..a0e56f0 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -6,6 +6,7 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import Domain import RIBs import RxSwift @@ -19,12 +20,21 @@ public protocol FollowDetailListener: AnyObject { protocol FollowDetailPresentable: Presentable { var listener: FollowDetailPresentableListener? { get set } + + func showLoading() + func hideLoading() + func updateTravelDetail(_ detail: TravelDetail) + func updatePlaces(_ places: [TravelPlace]) + func updateBudget(_ budget: Int) } // MARK: - FollowDetailPresentableListener protocol FollowDetailPresentableListener: AnyObject { func didTapCloseButton() + func didTapAddToTrip() + func didSelectDay(_ day: Int) + func didSelectPlace(_ place: TravelPlace) } // MARK: - FollowDetailInteractor @@ -34,10 +44,22 @@ final class FollowDetailInteractor: PresentableInteractor TravelDetail? { + // 네트워크 지연 시뮬레이션 + try? await Task.sleep(nanoseconds: 100_000_000) + + return TravelDetail( + travelId: "TRAVEL_001", + country: "태국", + city: "방콕", + budgetPerPerson: 1_200_000, + nights: 3, + days: 4, + youtube: YouTubeInfo( + title: "방콕 풀코스, 동남아 안 가본 곽튜브와 함께 【방콕】", + youtuber: "빠니보틀", + thumbnail: "https://i.ytimg.com/vi/F2utz6L76D0/mqdefault.jpg", + profileImage: nil, + link: "https://www.youtube.com/watch?v=F2utz6L76D0", + summary: "빠니보틀은 주말을 이용해 직장인들도 충분히 다녀올 수 있는 '금요일 퇴근 후 방콕 여행'의 가능성을 보여주며, 곽튜브와의 티격태격 케미를 통해 방콕의 매력을 소개합니다" + ) + ) + } + + func fetchPlaces(travelId: Int, day: Int) async -> [TravelPlace] { + // 네트워크 지연 시뮬레이션 + try? await Task.sleep(nanoseconds: 300_000_000) + + // 일차별로 다른 Mock 데이터 반환 + switch day { + case 1: + return [ + TravelPlace( + id: 1, + day: 1, + sequence: 1, + travelerTip: "인도 국제 공항에서 입국 심사가 오래 걸릴 수 있으니 여유를 가지세요.", + estimatedDuration: 60, + place: PlaceInfo( + googlePlaceId: "ChIJSc8jdZORQTURu6BMwxrKbGg", + latitude: 35.6585805, + longitude: 139.7454329, + name: "인도 국제 공항", + regularOpeningHours: "00:00~24:00", + category: .transportation + ) + ), + TravelPlace( + id: 2, + day: 1, + sequence: 2, + travelerTip: "바라나시 시장 투어는 현지 가이드와 함께 하는 것이 좋습니다.", + estimatedDuration: 90, + place: PlaceInfo( + googlePlaceId: "ChIJN1t_tDeuEmsRUsoyG83frY4", + latitude: 35.6592606, + longitude: 139.7002586, + name: "바라나시 시장 투어", + regularOpeningHours: "06:00~18:00", + category: .tourism + ) + ), + TravelPlace( + id: 3, + day: 1, + sequence: 3, + travelerTip: "현지인들이 추천하는 맛집입니다. 탄두리 치킨이 맛있어요.", + estimatedDuration: 60, + place: PlaceInfo( + googlePlaceId: "ChIJabc123", + latitude: 35.6600000, + longitude: 139.7100000, + name: "짱짱 탄두리 치킨", + regularOpeningHours: "11:00~22:00", + category: .restaurant + ) + ), + TravelPlace( + id: 4, + day: 1, + sequence: 4, + travelerTip: "현지 커피를 맛볼 수 있는 카페입니다.", + estimatedDuration: 30, + place: PlaceInfo( + googlePlaceId: "ChIJdef456", + latitude: 35.6610000, + longitude: 139.7150000, + name: "맛있다 카페", + regularOpeningHours: "08:00~20:00", + category: .cafe + ) + ), + TravelPlace( + id: 5, + day: 1, + sequence: 5, + travelerTip: "깔끔한 숙소입니다. 조식이 포함되어 있어요.", + estimatedDuration: 480, + place: PlaceInfo( + googlePlaceId: "ChIJghi789", + latitude: 35.6620000, + longitude: 139.7200000, + name: "쿨쿨호텔", + regularOpeningHours: nil, + category: .accommodation + ) + ) + ] + case 2: + return [ + TravelPlace( + id: 6, + day: 2, + sequence: 1, + travelerTip: "아침 일찍 가면 사람이 적어서 좋습니다.", + estimatedDuration: 120, + place: PlaceInfo( + googlePlaceId: "ChIJaaa111", + latitude: 35.6700000, + longitude: 139.7300000, + name: "타지마할", + regularOpeningHours: "06:00~18:00", + category: .tourism + ) + ), + TravelPlace( + id: 7, + day: 2, + sequence: 2, + travelerTip: "현지 전통 음식을 맛볼 수 있습니다.", + estimatedDuration: 60, + place: PlaceInfo( + googlePlaceId: "ChIJbbb222", + latitude: 35.6710000, + longitude: 139.7310000, + name: "전통 음식점", + regularOpeningHours: "10:00~21:00", + category: .restaurant + ) + ) + ] + case 3: + return [ + TravelPlace( + id: 8, + day: 3, + sequence: 1, + travelerTip: "쇼핑하기 좋은 곳입니다.", + estimatedDuration: 180, + place: PlaceInfo( + googlePlaceId: "ChIJccc333", + latitude: 35.6800000, + longitude: 139.7400000, + name: "현지 시장", + regularOpeningHours: "09:00~20:00", + category: .tourism + ) + ) + ] + default: + return [] + } + } +} diff --git a/Projects/Features/FollowFeature/Sources/Views/BudgetView.swift b/Projects/Features/FollowFeature/Sources/Views/BudgetView.swift new file mode 100644 index 0000000..36460d9 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/BudgetView.swift @@ -0,0 +1,82 @@ +// +// BudgetView.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import DSKit +import UIKit +import SnapKit +import Then + +final class BudgetView: UIView { + + // MARK: - UI Components + + private let iconImageView = UIImageView().then { + $0.image = DSKitAsset.Assets.icPiggybank1.image + $0.tintColor = UIColor.NDGL.Icon.secondary + $0.contentMode = .scaleAspectFit + } + + private let titleLabel = UILabel() + + private let budgetLabel = UILabel() + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + backgroundColor = UIColor.NDGL.Bg.primary + layer.cornerRadius = 8 + layer.borderWidth = 1 + layer.borderColor = UIColor.NDGL.Border.secondary.cgColor + + [iconImageView, titleLabel, budgetLabel].forEach { + addSubview($0) + } + + titleLabel.setText(.bodyMM, text: "1인 기준 여행 예산 :", color: UIColor.NDGL.Text.secondary) + } + + private func setupConstraints() { + iconImageView.snp.makeConstraints { + $0.leading.equalToSuperview().offset(16) + $0.centerY.equalToSuperview() + $0.size.equalTo(20) + } + + titleLabel.snp.makeConstraints { + $0.leading.equalTo(iconImageView.snp.trailing).offset(8) + $0.centerY.equalToSuperview() + } + + budgetLabel.snp.makeConstraints { + $0.leading.equalTo(titleLabel.snp.trailing).offset(4) + $0.centerY.equalToSuperview() + } + } + + // MARK: - Configuration + + func configure(budget: Int) { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formattedNumber = formatter.string(from: NSNumber(value: budget)) ?? "\(budget)" + budgetLabel.setText(.bodyMSB, text: "\(formattedNumber)원", color: UIColor.NDGL.Text.primary) + } +} diff --git a/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift b/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift new file mode 100644 index 0000000..91e9c6b --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift @@ -0,0 +1,85 @@ +// +// DayCell.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import DSKit +import UIKit +import SnapKit +import Then + +final class DayCell: UICollectionViewCell { + + static let identifier = "DayCell" + + // MARK: - UI Components + + private let containerView = UIView().then { + $0.layer.cornerRadius = 16 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.NDGL.Border.primary.cgColor + $0.backgroundColor = UIColor.NDGL.Bg.primary + } + + private let dayLabel = UILabel() + + // MARK: - Properties + + override var isSelected: Bool { + didSet { + updateSelectionState() + } + } + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + contentView.addSubview(containerView) + containerView.addSubview(dayLabel) + } + + private func setupConstraints() { + containerView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + dayLabel.snp.makeConstraints { + $0.center.equalToSuperview() + } + } + + // MARK: - Configuration + + func configure(day: Int) { + dayLabel.setText(.bodyMM, text: "\(day)일차", color: UIColor.NDGL.Text.secondary) + updateSelectionState() + } + + private func updateSelectionState() { + if isSelected { + containerView.backgroundColor = UIColor.NDGL.Bg.Interactive.secondary + containerView.layer.borderColor = UIColor.clear.cgColor + dayLabel.setText(.bodyMSB, text: dayLabel.text ?? "", color: UIColor.NDGL.Text.Interactive.inverse) + } else { + containerView.backgroundColor = UIColor.NDGL.Bg.primary + containerView.layer.borderColor = UIColor.NDGL.Border.primary.cgColor + dayLabel.setText(.bodyMM, text: dayLabel.text ?? "", color: UIColor.NDGL.Text.secondary) + } + } +} diff --git a/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift b/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift new file mode 100644 index 0000000..0249b6f --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift @@ -0,0 +1,172 @@ +// +// PlaceCell.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import Domain +import DSKit +import UIKit +import SnapKit +import Then + +final class PlaceCell: UICollectionViewCell { + + static let identifier = "PlaceCell" + + // MARK: - UI Components + + private let containerView = UIView().then { + $0.backgroundColor = UIColor.NDGL.Bg.primary + } + + private let categoryTagView = UIView().then { + $0.backgroundColor = UIColor.NDGL.Bg.Interactive.selected + $0.layer.cornerRadius = 4 + } + + private let categoryLabel = UILabel() + + private let durationLabel = UILabel() + + private let sequenceView = UIView().then { + $0.backgroundColor = UIColor.NDGL.Bg.Interactive.primary + $0.layer.cornerRadius = 12 + } + + private let sequenceLabel = UILabel() + + private let placeNameLabel = UILabel() + + private let tipLabel = UILabel().then { + $0.numberOfLines = 2 + } + + private let thumbnailImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.backgroundColor = UIColor.NDGL.Bg.Interactive.subtle02 + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + } + + private let separatorView = UIView().then { + $0.backgroundColor = UIColor.NDGL.Border.secondary + } + + private let timeInfoLabel = UILabel() + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + contentView.addSubview(containerView) + + [categoryTagView, durationLabel, sequenceView, placeNameLabel, tipLabel, thumbnailImageView, separatorView, timeInfoLabel].forEach { + containerView.addSubview($0) + } + + categoryTagView.addSubview(categoryLabel) + sequenceView.addSubview(sequenceLabel) + } + + private func setupConstraints() { + containerView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + // 카테고리 태그 + categoryTagView.snp.makeConstraints { + $0.top.equalToSuperview().offset(8) + $0.leading.equalToSuperview() + $0.height.equalTo(20) + } + + categoryLabel.snp.makeConstraints { + $0.verticalEdges.equalToSuperview().inset(2) + $0.horizontalEdges.equalToSuperview().inset(8) + } + + // 예상 체류 시간 + durationLabel.snp.makeConstraints { + $0.centerY.equalTo(categoryTagView) + $0.leading.equalTo(categoryTagView.snp.trailing).offset(8) + } + + // 순서 뷰 + sequenceView.snp.makeConstraints { + $0.top.equalTo(categoryTagView.snp.bottom).offset(8) + $0.leading.equalToSuperview() + $0.size.equalTo(24) + } + + sequenceLabel.snp.makeConstraints { + $0.center.equalToSuperview() + } + + // 장소명 + placeNameLabel.snp.makeConstraints { + $0.centerY.equalTo(sequenceView) + $0.leading.equalTo(sequenceView.snp.trailing).offset(8) + $0.trailing.equalTo(thumbnailImageView.snp.leading).offset(-8) + } + + // 팁 + tipLabel.snp.makeConstraints { + $0.top.equalTo(placeNameLabel.snp.bottom).offset(4) + $0.leading.equalTo(placeNameLabel) + $0.trailing.equalTo(thumbnailImageView.snp.leading).offset(-8) + } + + // 썸네일 + thumbnailImageView.snp.makeConstraints { + $0.top.equalTo(categoryTagView.snp.bottom).offset(8) + $0.trailing.equalToSuperview() + $0.size.equalTo(72) + } + + // 구분선 + separatorView.snp.makeConstraints { + $0.leading.trailing.bottom.equalToSuperview() + $0.height.equalTo(1) + } + + // 시간 정보 + timeInfoLabel.snp.makeConstraints { + $0.top.equalTo(thumbnailImageView.snp.bottom).offset(8) + $0.leading.equalToSuperview() + $0.bottom.equalTo(separatorView.snp.top).offset(-8) + } + } + + // MARK: - Configuration + + func configure(with place: TravelPlace, isLast: Bool = false) { + categoryLabel.setText(.bodySSB, text: place.place.category.rawValue, color: UIColor.NDGL.Text.Interactive.primary) + durationLabel.setText(.bodySR, text: "• \(place.estimatedDuration)분 체류 예상", color: UIColor.NDGL.Text.tertiary) + sequenceLabel.setText(.bodySSB, text: "\(place.sequence)", color: UIColor.NDGL.Text.Interactive.inverse) + placeNameLabel.setText(.bodyLSB, text: place.place.name, color: UIColor.NDGL.Text.primary) + tipLabel.setText(.bodySR, text: place.travelerTip ?? "", color: UIColor.NDGL.Text.tertiary) + separatorView.isHidden = isLast + + // 다음 장소까지 이동 시간 (Mock) + if !isLast { + timeInfoLabel.setText(.bodySR, text: "약 30분 • 28.8km", color: UIColor.NDGL.Text.tertiary) + } else { + timeInfoLabel.setText(.bodySR, text: "", color: UIColor.NDGL.Text.tertiary) + } + } +} diff --git a/Projects/Features/FollowFeature/Sources/Views/CollectionViews/DayCollectionView.swift b/Projects/Features/FollowFeature/Sources/Views/CollectionViews/DayCollectionView.swift new file mode 100644 index 0000000..1b60098 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/CollectionViews/DayCollectionView.swift @@ -0,0 +1,96 @@ +// +// DayCollectionView.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +protocol DayCollectionViewDelegate: AnyObject { + func dayCollectionView(_ collectionView: DayCollectionView, didSelectDay day: Int) +} + +final class DayCollectionView: UICollectionView { + + // MARK: - Types + + struct DayItem: Hashable { + let day: Int + } + + // MARK: - Properties + + weak var dayDelegate: DayCollectionViewDelegate? + private var diffableDataSource: UICollectionViewDiffableDataSource? + private var totalDays: Int = 0 + + // MARK: - Initialization + + init() { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumInteritemSpacing = 8 + layout.itemSize = CGSize(width: 60, height: 32) + + super.init(frame: .zero, collectionViewLayout: layout) + setupCollectionView() + setupDataSource() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupCollectionView() { + backgroundColor = .clear + showsHorizontalScrollIndicator = false + delegate = self + register(DayCell.self, forCellWithReuseIdentifier: DayCell.identifier) + } + + private func setupDataSource() { + diffableDataSource = UICollectionViewDiffableDataSource( + collectionView: self + ) { collectionView, indexPath, item in + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: DayCell.identifier, + for: indexPath + ) as? DayCell else { + return UICollectionViewCell() + } + cell.configure(day: item.day) + return cell + } + } + + // MARK: - Public Methods + + func applySnapshot(totalDays: Int, selectedDay: Int = 1) { + self.totalDays = totalDays + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + + let items = (1...totalDays).map { DayItem(day: $0) } + snapshot.appendItems(items, toSection: 0) + + diffableDataSource?.apply(snapshot, animatingDifferences: false) { [weak self] in + // 선택 상태 설정 + let indexPath = IndexPath(item: selectedDay - 1, section: 0) + self?.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally) + } + } +} + +// MARK: - UICollectionViewDelegate + +extension DayCollectionView: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let day = indexPath.item + 1 + dayDelegate?.dayCollectionView(self, didSelectDay: day) + } +} diff --git a/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift b/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift new file mode 100644 index 0000000..cbcc728 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift @@ -0,0 +1,96 @@ +// +// PlaceListCollectionView.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain +import UIKit + +protocol PlaceListCollectionViewDelegate: AnyObject { + func placeListCollectionView(_ collectionView: PlaceListCollectionView, didSelectPlace place: TravelPlace) +} + +final class PlaceListCollectionView: UICollectionView { + + // MARK: - Properties + + weak var placeDelegate: PlaceListCollectionViewDelegate? + private var diffableDataSource: UICollectionViewDiffableDataSource? + private var places: [TravelPlace] = [] + + // MARK: - Initialization + + init() { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .vertical + layout.minimumLineSpacing = 0 + layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize + + super.init(frame: .zero, collectionViewLayout: layout) + setupCollectionView() + setupDataSource() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupCollectionView() { + backgroundColor = .clear + isScrollEnabled = false + delegate = self + register(PlaceCell.self, forCellWithReuseIdentifier: PlaceCell.identifier) + } + + private func setupDataSource() { + diffableDataSource = UICollectionViewDiffableDataSource( + collectionView: self + ) { [weak self] collectionView, indexPath, place in + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: PlaceCell.identifier, + for: indexPath + ) as? PlaceCell else { + return UICollectionViewCell() + } + + let isLast = indexPath.item == (self?.places.count ?? 0) - 1 + cell.configure(with: place, isLast: isLast) + return cell + } + } + + // MARK: - Public Methods + + func applySnapshot(places: [TravelPlace]) { + self.places = places + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(places, toSection: 0) + + diffableDataSource?.apply(snapshot, animatingDifferences: false) + } +} + +// MARK: - UICollectionViewDelegate + +extension PlaceListCollectionView: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard indexPath.item < places.count else { return } + let place = places[indexPath.item] + placeDelegate?.placeListCollectionView(self, didSelectPlace: place) + } +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension PlaceListCollectionView: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + return CGSize(width: collectionView.bounds.width, height: 140) + } +} diff --git a/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift new file mode 100644 index 0000000..c8ee529 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift @@ -0,0 +1,213 @@ +// +// MediaInfoView.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import Domain +import DSKit +import UIKit +import SnapKit +import Then + +protocol MediaInfoViewDelegate: AnyObject { + func mediaInfoViewDidToggleExpand(_ view: MediaInfoView, isExpanded: Bool) +} + +final class MediaInfoView: UIView { + + // MARK: - Properties + + weak var delegate: MediaInfoViewDelegate? + private var isExpanded: Bool = false + + // MARK: - UI Components (항상 보이는 영역) + + private let profileImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.backgroundColor = UIColor.NDGL.Bg.disabled + $0.layer.cornerRadius = 20 + $0.clipsToBounds = true + } + + private let youtuberNameLabel = UILabel() + + private let locationLabel = UILabel() + + private let titleLabel = UILabel().then { + $0.numberOfLines = 2 + } + + private let toggleButton = UIButton(type: .system).then { + $0.setImage(DSKitAsset.Assets.icChevronDown1.image, for: .normal) + $0.tintColor = UIColor.NDGL.Icon.tertiary + } + + // MARK: - UI Components (펼쳤을 때만 보이는 영역) + + private let expandedContainerView = UIView().then { + $0.isHidden = true + } + + private let thumbnailImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.backgroundColor = UIColor.NDGL.Bg.Interactive.subtle02 + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + } + + private let budgetTitleLabel = UILabel() + + private let budgetValueLabel = UILabel() + + private let summaryTitleLabel = UILabel() + + private let summaryLabel = UILabel().then { + $0.numberOfLines = 0 + } + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + setupActions() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + backgroundColor = UIColor.NDGL.Bg.Interactive.subtle02 + + [profileImageView, youtuberNameLabel, locationLabel, titleLabel, toggleButton, expandedContainerView].forEach { + addSubview($0) + } + + [thumbnailImageView, budgetTitleLabel, budgetValueLabel, summaryTitleLabel, summaryLabel].forEach { + expandedContainerView.addSubview($0) + } + + // 타이포그래피 설정 + budgetTitleLabel.setText(.bodyMM, text: "1인 기준 전체 예산 :", color: UIColor.NDGL.Text.secondary) + summaryTitleLabel.setText(.bodyMSB, text: "영상 요약", color: UIColor.NDGL.Text.primary) + } + + private func setupConstraints() { + profileImageView.snp.makeConstraints { + $0.top.equalToSuperview().offset(16) + $0.leading.equalToSuperview().offset(16) + $0.size.equalTo(40) + } + + youtuberNameLabel.snp.makeConstraints { + $0.top.equalTo(profileImageView) + $0.leading.equalTo(profileImageView.snp.trailing).offset(12) + } + + locationLabel.snp.makeConstraints { + $0.top.equalTo(youtuberNameLabel.snp.bottom).offset(2) + $0.leading.equalTo(youtuberNameLabel) + } + + toggleButton.snp.makeConstraints { + $0.top.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.size.equalTo(44) + } + + titleLabel.snp.makeConstraints { + $0.top.equalTo(profileImageView.snp.bottom).offset(12) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalTo(toggleButton.snp.leading).offset(-8) + } + + expandedContainerView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(16) + $0.leading.trailing.bottom.equalToSuperview() + } + + thumbnailImageView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(180) + } + + budgetTitleLabel.snp.makeConstraints { + $0.top.equalTo(thumbnailImageView.snp.bottom).offset(16) + $0.leading.equalToSuperview().offset(16) + } + + budgetValueLabel.snp.makeConstraints { + $0.centerY.equalTo(budgetTitleLabel) + $0.leading.equalTo(budgetTitleLabel.snp.trailing).offset(4) + } + + summaryTitleLabel.snp.makeConstraints { + $0.top.equalTo(budgetTitleLabel.snp.bottom).offset(16) + $0.leading.equalToSuperview().offset(16) + } + + summaryLabel.snp.makeConstraints { + $0.top.equalTo(summaryTitleLabel.snp.bottom).offset(8) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.bottom.equalToSuperview().offset(-16) + } + } + + private func setupActions() { + toggleButton.addTarget(self, action: #selector(toggleButtonTapped), for: .touchUpInside) + } + + // MARK: - Actions + + @objc private func toggleButtonTapped() { + isExpanded.toggle() + updateExpandedState() + delegate?.mediaInfoViewDidToggleExpand(self, isExpanded: isExpanded) + } + + private func updateExpandedState() { + expandedContainerView.isHidden = !isExpanded + let image = isExpanded ? DSKitAsset.Assets.icChevronUp1.image : DSKitAsset.Assets.icChevronDown1.image + toggleButton.setImage(image, for: .normal) + } + + // MARK: - Configuration + + func configure(with detail: TravelDetail) { + youtuberNameLabel.setText(.bodySM, text: detail.youtube.youtuber, color: UIColor.NDGL.Text.secondary) + locationLabel.setText(.bodySR, text: "\(detail.country) · \(detail.nights)박\(detail.days)일", color: UIColor.NDGL.Text.tertiary) + titleLabel.setText(.bodyLSB, text: detail.youtube.title, color: UIColor.NDGL.Text.primary) + budgetValueLabel.setText(.bodyMSB, text: formatBudget(detail.budgetPerPerson), color: UIColor.NDGL.Text.primary) + summaryLabel.setText(.bodyMR, text: detail.youtube.summary, color: UIColor.NDGL.Text.secondary) + + // TODO: 이미지 로딩 (Kingfisher 사용) + } + + private func formatBudget(_ budget: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formattedNumber = formatter.string(from: NSNumber(value: budget)) ?? "\(budget)" + return "\(formattedNumber)원" + } + + // MARK: - Public Methods + + func getCollapsedHeight() -> CGFloat { + return 120 + } + + func getExpandedHeight() -> CGFloat { + return 450 + } +} diff --git a/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift b/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift new file mode 100644 index 0000000..412d2c5 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift @@ -0,0 +1,179 @@ +// +// TravelMapView.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import Domain +import DSKit +import MapKit +import UIKit +import SnapKit +import Then + +final class TravelMapView: UIView { + + // MARK: - UI Components + + private let mapView = MKMapView().then { + $0.layer.cornerRadius = 12 + $0.clipsToBounds = true + $0.isScrollEnabled = false + $0.isZoomEnabled = false + } + + // MARK: - Properties + + private var places: [TravelPlace] = [] + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + addSubview(mapView) + mapView.delegate = self + } + + private func setupConstraints() { + mapView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + // MARK: - Configuration + + func configure(with places: [TravelPlace]) { + self.places = places + + // 기존 어노테이션 제거 + mapView.removeAnnotations(mapView.annotations) + mapView.removeOverlays(mapView.overlays) + + guard !places.isEmpty else { return } + + // 어노테이션 추가 + var coordinates: [CLLocationCoordinate2D] = [] + + for place in places { + let coordinate = CLLocationCoordinate2D( + latitude: place.place.latitude, + longitude: place.place.longitude + ) + coordinates.append(coordinate) + + let annotation = PlaceAnnotation( + coordinate: coordinate, + title: place.place.name, + sequence: place.sequence + ) + mapView.addAnnotation(annotation) + } + + // 경로 선 추가 + if coordinates.count > 1 { + let polyline = MKPolyline(coordinates: coordinates, count: coordinates.count) + mapView.addOverlay(polyline) + } + + // 지도 영역 설정 + if let firstCoordinate = coordinates.first { + let region = MKCoordinateRegion( + center: firstCoordinate, + latitudinalMeters: 10000, + longitudinalMeters: 10000 + ) + mapView.setRegion(region, animated: false) + } + } +} + +// MARK: - MKMapViewDelegate + +extension TravelMapView: MKMapViewDelegate { + func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { + guard let placeAnnotation = annotation as? PlaceAnnotation else { return nil } + + let identifier = "PlaceAnnotation" + var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) + + if annotationView == nil { + annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: identifier) + annotationView?.canShowCallout = true + } else { + annotationView?.annotation = annotation + } + + // 커스텀 원형 뷰 생성 + let circleView = createCircleView(sequence: placeAnnotation.sequence) + annotationView?.image = circleView.asImage() + + return annotationView + } + + func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { + if let polyline = overlay as? MKPolyline { + let renderer = MKPolylineRenderer(polyline: polyline) + renderer.strokeColor = UIColor.NDGL.Bg.Interactive.primary + renderer.lineWidth = 2 + renderer.lineDashPattern = [4, 4] + return renderer + } + return MKOverlayRenderer(overlay: overlay) + } + + private func createCircleView(sequence: Int) -> UIView { + let size: CGFloat = 30 + let view = UIView(frame: CGRect(x: 0, y: 0, width: size, height: size)) + view.backgroundColor = UIColor.NDGL.Bg.Interactive.primary + view.layer.cornerRadius = size / 2 + + let label = UILabel(frame: view.bounds) + label.text = "\(sequence)" + label.textColor = UIColor.NDGL.Text.Interactive.inverse + label.font = DSKitFontFamily.Pretendard.bold.font(size: 14) + label.textAlignment = .center + view.addSubview(label) + + return view + } +} + +// MARK: - PlaceAnnotation + +final class PlaceAnnotation: NSObject, MKAnnotation { + let coordinate: CLLocationCoordinate2D + let title: String? + let sequence: Int + + init(coordinate: CLLocationCoordinate2D, title: String?, sequence: Int) { + self.coordinate = coordinate + self.title = title + self.sequence = sequence + super.init() + } +} + +// MARK: - UIView Extension + +private extension UIView { + func asImage() -> UIImage { + let renderer = UIGraphicsImageRenderer(bounds: bounds) + return renderer.image { context in + layer.render(in: context.cgContext) + } + } +} From 997dfb20f3d24358ecb39c269c63747613979683 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Fri, 23 Jan 2026 18:55:58 +0900 Subject: [PATCH 03/20] =?UTF-8?q?feat:=20#10=20-=20follow=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20api=20=ED=98=B8=EC=B6=9C=EB=A1=9C=20=EB=8C=80?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/DI/FollowRepositoryFactory.swift | 19 +++++ .../Repository/Follow/FollowRepository.swift | 41 ++++++++++ .../Data/Sources/Repository/RepoEmpty.swift | 8 -- .../Transform/Follow/FollowTransform.swift | 74 +++++++++++++++++++ .../Data/Sources/Transform/TransEmpty.swift | 8 -- .../FollowRepositoryProtocol.swift} | 4 +- .../Sources/Model/Follow/FollowError.swift | 20 +++++ .../Sources/FollowDetailBuilder.swift | 7 +- .../Sources/FollowDetailInteractor.swift | 4 +- .../Mock/MockFollowDetailRepository.swift | 2 +- .../Sources/Views/MediaInfoView.swift | 50 +++++++++++-- Projects/Features/HomeFeature/Project.swift | 3 +- .../HomeFeature/Sources/HomeBuilder.swift | 6 ++ .../HomeFeature/Sources/HomeInteractor.swift | 5 +- .../Sources/DTO/Follow/FollowDTO.swift | 54 ++++++++++++++ .../ErrorMapping/FollowError+Mapping.swift | 23 ++++++ .../Sources/Service/FollowService.swift | 34 +++++++++ .../Sources/TargetType/FollowAPI.swift | 59 +++++++++++++++ 18 files changed, 386 insertions(+), 35 deletions(-) create mode 100644 Projects/Data/Sources/DI/FollowRepositoryFactory.swift create mode 100644 Projects/Data/Sources/Repository/Follow/FollowRepository.swift delete mode 100644 Projects/Data/Sources/Repository/RepoEmpty.swift create mode 100644 Projects/Data/Sources/Transform/Follow/FollowTransform.swift delete mode 100644 Projects/Data/Sources/Transform/TransEmpty.swift rename Projects/Domain/Sources/Interface/{Travel/FollowDetailRepositoryProtocol.swift => Follow/FollowRepositoryProtocol.swift} (78%) create mode 100644 Projects/Domain/Sources/Model/Follow/FollowError.swift create mode 100644 Projects/Modules/Networks/Sources/DTO/Follow/FollowDTO.swift create mode 100644 Projects/Modules/Networks/Sources/ErrorMapping/FollowError+Mapping.swift create mode 100644 Projects/Modules/Networks/Sources/Service/FollowService.swift create mode 100644 Projects/Modules/Networks/Sources/TargetType/FollowAPI.swift diff --git a/Projects/Data/Sources/DI/FollowRepositoryFactory.swift b/Projects/Data/Sources/DI/FollowRepositoryFactory.swift new file mode 100644 index 0000000..c47cd1c --- /dev/null +++ b/Projects/Data/Sources/DI/FollowRepositoryFactory.swift @@ -0,0 +1,19 @@ +// +// FollowRepositoryFactory.swift +// Data +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain +import Foundation +import Networks + +public func makeFollowService() -> FollowServiceProtocol { + FollowService() +} + +public func makeFollowRepository(service: FollowServiceProtocol) -> FollowRepositoryProtocol { + FollowRepository(service: service) +} diff --git a/Projects/Data/Sources/Repository/Follow/FollowRepository.swift b/Projects/Data/Sources/Repository/Follow/FollowRepository.swift new file mode 100644 index 0000000..e148592 --- /dev/null +++ b/Projects/Data/Sources/Repository/Follow/FollowRepository.swift @@ -0,0 +1,41 @@ +// +// FollowRepository.swift +// Data +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain +import Foundation +import Networks + +public final class FollowRepository: FollowRepositoryProtocol, @unchecked Sendable { + private let service: FollowServiceProtocol + + public init(service: FollowServiceProtocol) { + self.service = service + } + + public func fetchTravelDetail(id: Int) async -> TravelDetail? { + let result = await service.getContentCard(id: id) + + switch result { + case .success(let response): + return response.toDomain() + case .failure, .networkFailure: + return nil + } + } + + public func fetchPlaces(travelId: Int, day: Int) async -> [TravelPlace] { + let result = await service.getItinerary(id: travelId, day: day) + + switch result { + case .success(let response): + return response.toDomain() + case .failure, .networkFailure: + return [] + } + } +} diff --git a/Projects/Data/Sources/Repository/RepoEmpty.swift b/Projects/Data/Sources/Repository/RepoEmpty.swift deleted file mode 100644 index 17afcba..0000000 --- a/Projects/Data/Sources/Repository/RepoEmpty.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// RepoEmpty.swift -// 27th-App-Team-1-iOSManifests -// -// Created by 최안용 on 1/13/26. -// - -import Foundation diff --git a/Projects/Data/Sources/Transform/Follow/FollowTransform.swift b/Projects/Data/Sources/Transform/Follow/FollowTransform.swift new file mode 100644 index 0000000..c39420d --- /dev/null +++ b/Projects/Data/Sources/Transform/Follow/FollowTransform.swift @@ -0,0 +1,74 @@ +// +// FollowTransform.swift +// Data +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain +import Foundation +import Networks + +// MARK: - ContentCard Response to TravelDetail + +extension FollowContentCardResponse { + func toDomain() -> TravelDetail { + TravelDetail( + travelId: travelId, + country: country, + city: city, + budgetPerPerson: budgetPerPerson, + nights: nights, + days: days, + youtube: youtube.toDomain() + ) + } +} + +extension YouTubeResponse { + func toDomain() -> YouTubeInfo { + YouTubeInfo( + title: title, + youtuber: name, + thumbnail: thumbnail, + profileImage: profileImage, + link: link, + summary: summary + ) + } +} + +// MARK: - Itinerary Response to TravelPlace + +extension FollowItineraryResponse { + func toDomain() -> [TravelPlace] { + places.map { $0.toDomain() } + } +} + +extension FollowPlaceResponse { + func toDomain() -> TravelPlace { + TravelPlace( + id: id, + day: day, + sequence: sequence, + travelerTip: travelerTip ?? "", + estimatedDuration: estimatedDuration, + place: place.toDomain() + ) + } +} + +extension PlaceResponse { + func toDomain() -> PlaceInfo { + PlaceInfo( + googlePlaceId: googlePlaceId, + latitude: latitude, + longitude: longitude, + name: name, + regularOpeningHours: regularOpeningHours, + category: PlaceCategory(rawValue: category ?? "") ?? .etc + ) + } +} diff --git a/Projects/Data/Sources/Transform/TransEmpty.swift b/Projects/Data/Sources/Transform/TransEmpty.swift deleted file mode 100644 index f83abee..0000000 --- a/Projects/Data/Sources/Transform/TransEmpty.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// TransEmpty.swift -// 27th-App-Team-1-iOSManifests -// -// Created by 최안용 on 1/13/26. -// - -import Foundation diff --git a/Projects/Domain/Sources/Interface/Travel/FollowDetailRepositoryProtocol.swift b/Projects/Domain/Sources/Interface/Follow/FollowRepositoryProtocol.swift similarity index 78% rename from Projects/Domain/Sources/Interface/Travel/FollowDetailRepositoryProtocol.swift rename to Projects/Domain/Sources/Interface/Follow/FollowRepositoryProtocol.swift index 0c4e301..621286d 100644 --- a/Projects/Domain/Sources/Interface/Travel/FollowDetailRepositoryProtocol.swift +++ b/Projects/Domain/Sources/Interface/Follow/FollowRepositoryProtocol.swift @@ -1,5 +1,5 @@ // -// FollowDetailRepositoryProtocol.swift +// FollowRepositoryProtocol.swift // Domain // // Created by kimnahun on 2026-01-23. @@ -8,7 +8,7 @@ import Foundation -public protocol FollowDetailRepositoryProtocol { +public protocol FollowRepositoryProtocol { /// 여행 상세 정보 조회 func fetchTravelDetail(id: Int) async -> TravelDetail? diff --git a/Projects/Domain/Sources/Model/Follow/FollowError.swift b/Projects/Domain/Sources/Model/Follow/FollowError.swift new file mode 100644 index 0000000..a5b1166 --- /dev/null +++ b/Projects/Domain/Sources/Model/Follow/FollowError.swift @@ -0,0 +1,20 @@ +// +// FollowError.swift +// Domain +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public enum FollowError: Error, Sendable { + /// 여행 템플릿을 찾을 수 없음 + case notFound(message: String) + /// 서버 에러 + case serverError(message: String) + /// 네트워크 에러 + case networkError(message: String) + /// 알 수 없는 에러 + case unknown(code: String, message: String) +} diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift b/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift index 63556a6..9b446b7 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift @@ -12,15 +12,14 @@ import RIBs // MARK: - FollowDetailDependency public protocol FollowDetailDependency: Dependency { - // 부모 RIB로부터 주입받을 의존성 정의 + var followRepository: FollowRepositoryProtocol { get } } // MARK: - FollowDetailComponent final class FollowDetailComponent: Component { - var repository: FollowDetailRepositoryProtocol { - // TODO: 실제 API 연동 시 실제 Repository로 교체 - MockFollowDetailRepository() + var repository: FollowRepositoryProtocol { + dependency.followRepository } } diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift index a0e56f0..3dbb75f 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -44,7 +44,7 @@ final class FollowDetailInteractor: PresentableInteractor TravelDetail? { // 네트워크 지연 시뮬레이션 diff --git a/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift index c8ee529..b66fd90 100644 --- a/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift @@ -9,9 +9,10 @@ import Core import Domain import DSKit -import UIKit +import Kingfisher import SnapKit import Then +import UIKit protocol MediaInfoViewDelegate: AnyObject { func mediaInfoViewDidToggleExpand(_ view: MediaInfoView, isExpanded: Bool) @@ -50,6 +51,7 @@ final class MediaInfoView: UIView { private let expandedContainerView = UIView().then { $0.isHidden = true + $0.clipsToBounds = true } private let thumbnailImageView = UIImageView().then { @@ -73,6 +75,7 @@ final class MediaInfoView: UIView { override init(frame: CGRect) { super.init(frame: frame) + clipsToBounds = true setupUI() setupConstraints() setupActions() @@ -129,9 +132,10 @@ final class MediaInfoView: UIView { $0.trailing.equalTo(toggleButton.snp.leading).offset(-8) } + // expandedContainerView는 top만 연결, bottom은 연결하지 않음 expandedContainerView.snp.makeConstraints { $0.top.equalTo(titleLabel.snp.bottom).offset(16) - $0.leading.trailing.bottom.equalToSuperview() + $0.leading.trailing.equalToSuperview() } thumbnailImageView.snp.makeConstraints { @@ -186,12 +190,44 @@ final class MediaInfoView: UIView { func configure(with detail: TravelDetail) { youtuberNameLabel.setText(.bodySM, text: detail.youtube.youtuber, color: UIColor.NDGL.Text.secondary) - locationLabel.setText(.bodySR, text: "\(detail.country) · \(detail.nights)박\(detail.days)일", color: UIColor.NDGL.Text.tertiary) + locationLabel.setText( + .bodySR, + text: "\(detail.country) · \(detail.nights)박\(detail.days)일", + color: UIColor.NDGL.Text.tertiary + ) titleLabel.setText(.bodyLSB, text: detail.youtube.title, color: UIColor.NDGL.Text.primary) - budgetValueLabel.setText(.bodyMSB, text: formatBudget(detail.budgetPerPerson), color: UIColor.NDGL.Text.primary) + budgetValueLabel.setText( + .bodyMSB, + text: formatBudget(detail.budgetPerPerson), + color: UIColor.NDGL.Text.primary + ) summaryLabel.setText(.bodyMR, text: detail.youtube.summary, color: UIColor.NDGL.Text.secondary) - // TODO: 이미지 로딩 (Kingfisher 사용) + // 프로필 이미지 로딩 + if let profileURLString = detail.youtube.profileImage, + let profileURL = URL(string: profileURLString) { + profileImageView.kf.setImage( + with: profileURL, + placeholder: nil, + options: [ + .transition(.fade(0.2)), + .cacheOriginalImage + ] + ) + } + + // 썸네일 이미지 로딩 + if let thumbnailURLString = detail.youtube.thumbnail, + let thumbnailURL = URL(string: thumbnailURLString) { + thumbnailImageView.kf.setImage( + with: thumbnailURL, + placeholder: nil, + options: [ + .transition(.fade(0.2)), + .cacheOriginalImage + ] + ) + } } private func formatBudget(_ budget: Int) -> String { @@ -204,10 +240,10 @@ final class MediaInfoView: UIView { // MARK: - Public Methods func getCollapsedHeight() -> CGFloat { - return 120 + 120 } func getExpandedHeight() -> CGFloat { - return 450 + 450 } } diff --git a/Projects/Features/HomeFeature/Project.swift b/Projects/Features/HomeFeature/Project.swift index ede9ea3..7bd4377 100644 --- a/Projects/Features/HomeFeature/Project.swift +++ b/Projects/Features/HomeFeature/Project.swift @@ -16,7 +16,8 @@ let project = Project.makeModule( name: "HomeFeature", dependencies: [ .Features.baseFeatureDependency, - .Features.Follow.feature + .Features.Follow.feature, + .data ], scripts: [.swiftLint], isStatic: true, diff --git a/Projects/Features/HomeFeature/Sources/HomeBuilder.swift b/Projects/Features/HomeFeature/Sources/HomeBuilder.swift index ea88eb1..5d90f20 100644 --- a/Projects/Features/HomeFeature/Sources/HomeBuilder.swift +++ b/Projects/Features/HomeFeature/Sources/HomeBuilder.swift @@ -6,6 +6,7 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import Data import Domain import FollowFeature import RIBs @@ -23,6 +24,11 @@ final class HomeComponent: Component, FollowDetailDependency { // TODO: 실제 API 연동 시 실제 Repository로 교체 MockTravelRepository() } + + var followRepository: FollowRepositoryProtocol { + let service = makeFollowService() + return makeFollowRepository(service: service) + } } // MARK: - HomeBuildable diff --git a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift index 753326d..128783f 100644 --- a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift +++ b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift @@ -132,8 +132,9 @@ extension HomeInteractor: HomePresentableListener { func didSelectRecommendation(at index: Int) { guard index < recommendations.count else { return } - let recommendation = recommendations[index] - router?.routeToFollowDetail(with: recommendation.id) + // TODO: 실제 API 연동 시 recommendation.id 사용 + // 현재는 테스트를 위해 항상 id 1로 이동 + router?.routeToFollowDetail(with: 1) } func didTapShowMoreTrips() { diff --git a/Projects/Modules/Networks/Sources/DTO/Follow/FollowDTO.swift b/Projects/Modules/Networks/Sources/DTO/Follow/FollowDTO.swift new file mode 100644 index 0000000..016ad77 --- /dev/null +++ b/Projects/Modules/Networks/Sources/DTO/Follow/FollowDTO.swift @@ -0,0 +1,54 @@ +// +// FollowDTO.swift +// Networks +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +// MARK: - Content Card Response (여행 템플릿 상세) + +public struct FollowContentCardResponse: Decodable, Sendable { + public let travelId: String + public let country: String + public let city: String + public let budgetPerPerson: Int + public let nights: Int + public let days: Int + public let youtube: YouTubeResponse +} + +public struct YouTubeResponse: Decodable, Sendable { + public let title: String + public let name: String + public let thumbnail: String? + public let profileImage: String? + public let link: String + public let summary: String +} + +// MARK: - Itinerary Response (여행 일정) + +public struct FollowItineraryResponse: Decodable, Sendable { + public let places: [FollowPlaceResponse] +} + +public struct FollowPlaceResponse: Decodable, Sendable { + public let id: Int + public let day: Int + public let sequence: Int + public let travelerTip: String? + public let estimatedDuration: Int + public let place: PlaceResponse +} + +public struct PlaceResponse: Decodable, Sendable { + public let googlePlaceId: String + public let latitude: Double + public let longitude: Double + public let name: String + public let regularOpeningHours: String? + public let category: String? +} diff --git a/Projects/Modules/Networks/Sources/ErrorMapping/FollowError+Mapping.swift b/Projects/Modules/Networks/Sources/ErrorMapping/FollowError+Mapping.swift new file mode 100644 index 0000000..4aca5cc --- /dev/null +++ b/Projects/Modules/Networks/Sources/ErrorMapping/FollowError+Mapping.swift @@ -0,0 +1,23 @@ +// +// FollowError+Mapping.swift +// Networks +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain +import Foundation + +extension FollowError { + public init(code: String, message: String, errors: [ErrorResponse.ErrorDetail]) { + switch code { + case "TRAVEL-02-001": + self = .notFound(message: message) + case "COMM-08-001": + self = .serverError(message: message) + default: + self = .unknown(code: code, message: message) + } + } +} diff --git a/Projects/Modules/Networks/Sources/Service/FollowService.swift b/Projects/Modules/Networks/Sources/Service/FollowService.swift new file mode 100644 index 0000000..8e45df1 --- /dev/null +++ b/Projects/Modules/Networks/Sources/Service/FollowService.swift @@ -0,0 +1,34 @@ +// +// FollowService.swift +// Networks +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain +import Foundation +import Moya + +public protocol FollowServiceProtocol: Sendable { + /// 여행 템플릿 상세 조회 + func getContentCard(id: Int) async -> NetworkResult + /// 여행 템플릿 일정 조회 + func getItinerary(id: Int, day: Int?) async -> NetworkResult +} + +public final class FollowService: FollowServiceProtocol, @unchecked Sendable { + private let provider: MoyaProvider + + public init(provider: MoyaProvider = MoyaProvider()) { + self.provider = provider + } + + public func getContentCard(id: Int) async -> NetworkResult { + await provider.request(.getContentCard(id: id), errorMapper: FollowError.init) + } + + public func getItinerary(id: Int, day: Int?) async -> NetworkResult { + await provider.request(.getItinerary(id: id, day: day), errorMapper: FollowError.init) + } +} diff --git a/Projects/Modules/Networks/Sources/TargetType/FollowAPI.swift b/Projects/Modules/Networks/Sources/TargetType/FollowAPI.swift new file mode 100644 index 0000000..4cba198 --- /dev/null +++ b/Projects/Modules/Networks/Sources/TargetType/FollowAPI.swift @@ -0,0 +1,59 @@ +// +// FollowAPI.swift +// Networks +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Alamofire +import Foundation +import Moya + +public enum FollowAPI { + /// 여행 템플릿 상세 조회 + case getContentCard(id: Int) + /// 여행 템플릿 일정 조회 + case getItinerary(id: Int, day: Int?) +} + +extension FollowAPI: TargetType { + public var baseURL: URL { + NetworkConfiguration.baseURL + } + + public var path: String { + switch self { + case .getContentCard(let id): + return "/api/v1/travel-templates/\(id)/content-card" + case .getItinerary(let id, _): + return "/api/v1/travel-templates/\(id)/itinerary" + } + } + + public var method: Moya.Method { + switch self { + case .getContentCard, .getItinerary: + return .get + } + } + + public var task: Moya.Task { + switch self { + case .getContentCard: + return .requestPlain + case .getItinerary(_, let day): + if let day = day { + return .requestParameters( + parameters: ["day": day], + encoding: URLEncoding.queryString + ) + } + return .requestPlain + } + } + + public var headers: [String: String]? { + ["Content-Type": "application/json"] + } +} From c8db0e3f4c9dcdff25b7a16892181be7e10187ce Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sat, 24 Jan 2026 14:00:29 +0900 Subject: [PATCH 04/20] =?UTF-8?q?design:=20=EB=94=B0=EB=9D=BC=EA=B0=80?= =?UTF-8?q?=EA=B8=B0=20=EC=BB=A8=ED=85=90=EC=B8=A0=20Headerview=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/FollowDetailViewController.swift | 17 +- .../Sources/Views/MediaInfoView.swift | 204 ++++++++++++------ 2 files changed, 144 insertions(+), 77 deletions(-) diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift index 24a3a9f..9b03f61 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift @@ -116,7 +116,6 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre mediaInfoView.snp.makeConstraints { $0.top.leading.trailing.equalToSuperview() - $0.height.equalTo(120) } dayCollectionView.snp.makeConstraints { @@ -176,17 +175,6 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre listener?.didTapAddToTrip() } - // MARK: - Private Methods - - private func updateMediaInfoViewHeight(_ height: CGFloat) { - mediaInfoView.snp.updateConstraints { - $0.height.equalTo(height) - } - - UIView.animate(withDuration: 0.3) { [weak self] in - self?.view.layoutIfNeeded() - } - } } // MARK: - FollowDetailPresentable @@ -225,8 +213,9 @@ extension FollowDetailViewController { extension FollowDetailViewController: MediaInfoViewDelegate { func mediaInfoViewDidToggleExpand(_ view: MediaInfoView, isExpanded: Bool) { - let newHeight = isExpanded ? view.getExpandedHeight() : view.getCollapsedHeight() - updateMediaInfoViewHeight(newHeight) + UIView.animate(withDuration: 0.3) { [weak self] in + self?.view.layoutIfNeeded() + } } } diff --git a/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift index b66fd90..72cef7e 100644 --- a/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift @@ -25,18 +25,32 @@ final class MediaInfoView: UIView { weak var delegate: MediaInfoViewDelegate? private var isExpanded: Bool = false + // 동적 높이를 위한 bottom constraint + private var collapsedBottomConstraint: Constraint? + private var expandedBottomConstraint: Constraint? + // MARK: - UI Components (항상 보이는 영역) private let profileImageView = UIImageView().then { $0.contentMode = .scaleAspectFill $0.backgroundColor = UIColor.NDGL.Bg.disabled - $0.layer.cornerRadius = 20 + $0.layer.cornerRadius = 28 $0.clipsToBounds = true } - private let youtuberNameLabel = UILabel() + // 여행 정보 (icVideo1 + 4px + "유튜버 · 국가 · 3박4일") + private let travelInfoStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 4 + $0.alignment = .center + } - private let locationLabel = UILabel() + private let travelInfoIconView = UIImageView().then { + $0.image = DSKitAsset.Assets.icVideo1.image + $0.contentMode = .scaleAspectFit + } + + private let travelInfoLabel = UILabel() private let titleLabel = UILabel().then { $0.numberOfLines = 2 @@ -44,7 +58,7 @@ final class MediaInfoView: UIView { private let toggleButton = UIButton(type: .system).then { $0.setImage(DSKitAsset.Assets.icChevronDown1.image, for: .normal) - $0.tintColor = UIColor.NDGL.Icon.tertiary + $0.tintColor = UIColor.NDGL.Icon.disabled } // MARK: - UI Components (펼쳤을 때만 보이는 영역) @@ -61,9 +75,35 @@ final class MediaInfoView: UIView { $0.clipsToBounds = true } - private let budgetTitleLabel = UILabel() + // 예산 정보 (icPiggybank1 + 8px + "1인 기준 예산 얼마") + private let budgetStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 8 + $0.alignment = .center + } + + private let budgetIconView = UIImageView().then { + $0.image = DSKitAsset.Assets.icPiggybank1.image + $0.contentMode = .scaleAspectFit + } - private let budgetValueLabel = UILabel() + private let budgetLabel = UILabel() + + private let separatorView = UIView().then { + $0.backgroundColor = UIColor.NDGL.Border.secondary + } + + // 영상 요약 타이틀 (icBook1 + 8px + "영상 요약") + private let summaryTitleStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 8 + $0.alignment = .center + } + + private let summaryIconView = UIImageView().then { + $0.image = DSKitAsset.Assets.icBook1.image + $0.contentMode = .scaleAspectFit + } private let summaryTitleLabel = UILabel() @@ -79,6 +119,7 @@ final class MediaInfoView: UIView { setupUI() setupConstraints() setupActions() + layer.cornerRadius = 20 } required init?(coder: NSCoder) { @@ -90,80 +131,105 @@ final class MediaInfoView: UIView { private func setupUI() { backgroundColor = UIColor.NDGL.Bg.Interactive.subtle02 - [profileImageView, youtuberNameLabel, locationLabel, titleLabel, toggleButton, expandedContainerView].forEach { + // 여행 정보 스택뷰 구성 + [travelInfoIconView, travelInfoLabel].forEach { + travelInfoStackView.addArrangedSubview($0) + } + + // 예산 스택뷰 구성 + [budgetIconView, budgetLabel].forEach { + budgetStackView.addArrangedSubview($0) + } + + // 요약 타이틀 스택뷰 구성 + [summaryIconView, summaryTitleLabel].forEach { + summaryTitleStackView.addArrangedSubview($0) + } + + [profileImageView, travelInfoStackView, titleLabel, toggleButton, expandedContainerView].forEach { addSubview($0) } - [thumbnailImageView, budgetTitleLabel, budgetValueLabel, summaryTitleLabel, summaryLabel].forEach { + [thumbnailImageView, budgetStackView, separatorView, summaryTitleStackView, summaryLabel].forEach { expandedContainerView.addSubview($0) } // 타이포그래피 설정 - budgetTitleLabel.setText(.bodyMM, text: "1인 기준 전체 예산 :", color: UIColor.NDGL.Text.secondary) summaryTitleLabel.setText(.bodyMSB, text: "영상 요약", color: UIColor.NDGL.Text.primary) } private func setupConstraints() { profileImageView.snp.makeConstraints { - $0.top.equalToSuperview().offset(16) - $0.leading.equalToSuperview().offset(16) - $0.size.equalTo(40) + $0.top.equalToSuperview().offset(8) + $0.leading.equalToSuperview().offset(24) + $0.size.equalTo(56) + // collapsed 상태의 bottom constraint + collapsedBottomConstraint = $0.bottom.equalToSuperview().offset(-8).constraint } - youtuberNameLabel.snp.makeConstraints { - $0.top.equalTo(profileImageView) - $0.leading.equalTo(profileImageView.snp.trailing).offset(12) + travelInfoIconView.snp.makeConstraints { + $0.size.equalTo(16) } - locationLabel.snp.makeConstraints { - $0.top.equalTo(youtuberNameLabel.snp.bottom).offset(2) - $0.leading.equalTo(youtuberNameLabel) - } - - toggleButton.snp.makeConstraints { - $0.top.equalToSuperview().offset(16) - $0.trailing.equalToSuperview().offset(-16) - $0.size.equalTo(44) + travelInfoStackView.snp.makeConstraints { + $0.top.equalTo(profileImageView.snp.top) + $0.leading.equalTo(profileImageView.snp.trailing).offset(8) } titleLabel.snp.makeConstraints { - $0.top.equalTo(profileImageView.snp.bottom).offset(12) - $0.leading.equalToSuperview().offset(16) + $0.bottom.equalTo(profileImageView.snp.bottom) + $0.leading.equalTo(travelInfoStackView) $0.trailing.equalTo(toggleButton.snp.leading).offset(-8) } - // expandedContainerView는 top만 연결, bottom은 연결하지 않음 + toggleButton.snp.makeConstraints { + $0.centerY.equalTo(titleLabel) + $0.trailing.equalToSuperview().offset(-24) + $0.size.equalTo(28) + } + expandedContainerView.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(16) - $0.leading.trailing.equalToSuperview() + $0.top.equalTo(profileImageView.snp.bottom).offset(25) + $0.leading.trailing.equalToSuperview().inset(24) + // expanded 상태의 bottom constraint + expandedBottomConstraint = $0.bottom.equalToSuperview().offset(-16).constraint } + // 초기 상태: collapsed + expandedBottomConstraint?.deactivate() thumbnailImageView.snp.makeConstraints { $0.top.equalToSuperview() - $0.leading.equalToSuperview().offset(16) - $0.trailing.equalToSuperview().offset(-16) - $0.height.equalTo(180) + $0.leading.trailing.equalToSuperview() + $0.height.equalTo(150) + } + + budgetIconView.snp.makeConstraints { + $0.size.equalTo(20) } - budgetTitleLabel.snp.makeConstraints { - $0.top.equalTo(thumbnailImageView.snp.bottom).offset(16) - $0.leading.equalToSuperview().offset(16) + budgetStackView.snp.makeConstraints { + $0.top.equalTo(thumbnailImageView.snp.bottom).offset(24) + $0.leading.equalToSuperview().offset(12) } - budgetValueLabel.snp.makeConstraints { - $0.centerY.equalTo(budgetTitleLabel) - $0.leading.equalTo(budgetTitleLabel.snp.trailing).offset(4) + separatorView.snp.makeConstraints { + $0.top.equalTo(budgetStackView.snp.bottom).offset(10) + $0.horizontalEdges.equalToSuperview().inset(12) + $0.height.equalTo(1) } - summaryTitleLabel.snp.makeConstraints { - $0.top.equalTo(budgetTitleLabel.snp.bottom).offset(16) - $0.leading.equalToSuperview().offset(16) + summaryIconView.snp.makeConstraints { + $0.size.equalTo(20) + } + + summaryTitleStackView.snp.makeConstraints { + $0.top.equalTo(separatorView.snp.bottom).offset(10) + $0.leading.equalTo(separatorView) } summaryLabel.snp.makeConstraints { - $0.top.equalTo(summaryTitleLabel.snp.bottom).offset(8) - $0.leading.equalToSuperview().offset(16) - $0.trailing.equalToSuperview().offset(-16) + $0.top.equalTo(summaryTitleStackView.snp.bottom).offset(8) + $0.horizontalEdges.equalTo(separatorView) $0.bottom.equalToSuperview().offset(-16) } } @@ -184,24 +250,33 @@ final class MediaInfoView: UIView { expandedContainerView.isHidden = !isExpanded let image = isExpanded ? DSKitAsset.Assets.icChevronUp1.image : DSKitAsset.Assets.icChevronDown1.image toggleButton.setImage(image, for: .normal) + + // Bottom constraint 토글 + if isExpanded { + collapsedBottomConstraint?.deactivate() + expandedBottomConstraint?.activate() + } else { + expandedBottomConstraint?.deactivate() + collapsedBottomConstraint?.activate() + } } // MARK: - Configuration func configure(with detail: TravelDetail) { - youtuberNameLabel.setText(.bodySM, text: detail.youtube.youtuber, color: UIColor.NDGL.Text.secondary) - locationLabel.setText( - .bodySR, - text: "\(detail.country) · \(detail.nights)박\(detail.days)일", - color: UIColor.NDGL.Text.tertiary - ) - titleLabel.setText(.bodyLSB, text: detail.youtube.title, color: UIColor.NDGL.Text.primary) - budgetValueLabel.setText( - .bodyMSB, - text: formatBudget(detail.budgetPerPerson), - color: UIColor.NDGL.Text.primary - ) - summaryLabel.setText(.bodyMR, text: detail.youtube.summary, color: UIColor.NDGL.Text.secondary) + // 여행 정보 라벨 (유튜버 · 국가 · 3박4일) + let travelInfoText = "\(detail.youtube.youtuber) · \(detail.country) · \(detail.nights)박\(detail.days)일" + travelInfoLabel.setText(.bodyMSB, text: travelInfoText, color: UIColor.NDGL.Text.tertiary) + + // 제목 + titleLabel.setText(.subTitleLSB, text: detail.youtube.title, color: UIColor.NDGL.Text.primary) + + // 예산 라벨 (1인 기준 예산 + 금액) - 파란색 + let budgetText = "1인 기준 예산 \(formatBudget(detail.budgetPerPerson))" + budgetLabel.setText(.bodyLR, text: budgetText, color: DSKitAsset.Colors.primary500.color) + + // 요약 라벨 + summaryLabel.setText(.bodyMM, text: detail.youtube.summary, color: UIColor.NDGL.Text.secondary) // 프로필 이미지 로딩 if let profileURLString = detail.youtube.profileImage, @@ -239,11 +314,14 @@ final class MediaInfoView: UIView { // MARK: - Public Methods - func getCollapsedHeight() -> CGFloat { - 120 - } - - func getExpandedHeight() -> CGFloat { - 450 + func calculateHeight() -> CGFloat { + setNeedsLayout() + layoutIfNeeded() + let targetSize = CGSize(width: bounds.width, height: UIView.layoutFittingCompressedSize.height) + return systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ).height } } From 1c3d8bb73e632867fe0b089ef0759f711d28fce3 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sat, 24 Jan 2026 14:54:49 +0900 Subject: [PATCH 05/20] =?UTF-8?q?design:=20#10=20-=20detailviewcontroller?= =?UTF-8?q?=20sstickeyheader=EA=B5=AC=ED=98=84,=20ui=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Transform/Follow/FollowTransform.swift | 6 +- .../Sources/Model/Travel/TravelPlace.swift | 18 +--- .../Sources/FollowDetailViewController.swift | 96 +++++++++++++++++-- .../Mock/MockFollowDetailRepository.swift | 34 +++---- .../Sources/Views/Cells/DayCell.swift | 19 ++-- .../Sources/Views/Cells/PlaceCell.swift | 57 ++++++----- .../CollectionViews/DayCollectionView.swift | 2 +- .../Sources/Views/MediaInfoView.swift | 31 +++--- .../Sources/Views/TravelMapView.swift | 4 +- .../HomeFeature/Sources/HomeRouter.swift | 7 +- .../Sources/DTO/Follow/FollowDTO.swift | 4 +- 11 files changed, 179 insertions(+), 99 deletions(-) diff --git a/Projects/Data/Sources/Transform/Follow/FollowTransform.swift b/Projects/Data/Sources/Transform/Follow/FollowTransform.swift index c39420d..32f04f0 100644 --- a/Projects/Data/Sources/Transform/Follow/FollowTransform.swift +++ b/Projects/Data/Sources/Transform/Follow/FollowTransform.swift @@ -43,7 +43,7 @@ extension YouTubeResponse { extension FollowItineraryResponse { func toDomain() -> [TravelPlace] { - places.map { $0.toDomain() } + itineraries.map { $0.toDomain() } } } @@ -64,11 +64,11 @@ extension PlaceResponse { func toDomain() -> PlaceInfo { PlaceInfo( googlePlaceId: googlePlaceId, + thumbnail: thumbnail, latitude: latitude, longitude: longitude, name: name, - regularOpeningHours: regularOpeningHours, - category: PlaceCategory(rawValue: category ?? "") ?? .etc + regularOpeningHours: regularOpeningHours ) } } diff --git a/Projects/Domain/Sources/Model/Travel/TravelPlace.swift b/Projects/Domain/Sources/Model/Travel/TravelPlace.swift index 1e72eee..63ebf16 100644 --- a/Projects/Domain/Sources/Model/Travel/TravelPlace.swift +++ b/Projects/Domain/Sources/Model/Travel/TravelPlace.swift @@ -37,35 +37,25 @@ public struct TravelPlace: Hashable { /// 장소 상세 정보 public struct PlaceInfo: Hashable { public let googlePlaceId: String + public let thumbnail: String? public let latitude: Double public let longitude: Double public let name: String public let regularOpeningHours: String? - public let category: PlaceCategory public init( googlePlaceId: String, + thumbnail: String? = nil, latitude: Double, longitude: Double, name: String, - regularOpeningHours: String?, - category: PlaceCategory = .etc + regularOpeningHours: String? ) { self.googlePlaceId = googlePlaceId + self.thumbnail = thumbnail self.latitude = latitude self.longitude = longitude self.name = name self.regularOpeningHours = regularOpeningHours - self.category = category } } - -/// 장소 카테고리 -public enum PlaceCategory: String, Hashable { - case transportation = "교통수단" - case tourism = "관광명소" - case restaurant = "음식점" - case cafe = "카페" - case accommodation = "숙소" - case etc = "기타" -} diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift index 9b03f61..10eee76 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift @@ -9,11 +9,11 @@ import Core import Domain import DSKit -import UIKit import RIBs import RxSwift import SnapKit import Then +import UIKit // MARK: - FollowDetailViewController @@ -24,8 +24,11 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre weak var listener: FollowDetailPresentableListener? private let disposeBag = DisposeBag() + private var dayCollectionViewOriginY: CGFloat = CGFloat.greatestFiniteMagnitude + private var currentSelectedDay: Int = 1 + private var totalDays: Int = 0 - // MARK: - UI Components + // MARK: - UI Components (스크롤 영역) private let scrollView = UIScrollView().then { $0.showsVerticalScrollIndicator = true @@ -44,6 +47,17 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre private let placeListCollectionView = PlaceListCollectionView() + // MARK: - UI Components (스티키 헤더) + + private let stickyHeaderView = UIView().then { + $0.backgroundColor = UIColor.NDGL.Bg.primary + $0.isHidden = true + } + + private let stickyDayCollectionView = DayCollectionView() + + // MARK: - UI Components (고정 버튼/로딩) + private let addToTripButton = UIButton(type: .system).then { $0.setTitle("내 여행에 담기", for: .normal) $0.setTitleColor(UIColor.NDGL.Text.Interactive.inverse, for: .normal) @@ -82,21 +96,39 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre navigationController?.setNavigationBarHidden(false, animated: animated) } - // MARK: - Setup + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + // 네비게이션 백버튼으로 돌아갈 때 RIB detach 처리 + if isMovingFromParent { + listener?.didTapCloseButton() + } + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + // dayCollectionView의 scrollView 내 위치 계산 + dayCollectionViewOriginY = dayCollectionView.frame.origin.y + } + // MARK: - Setup private func setupUI() { view.backgroundColor = UIColor.NDGL.Bg.primary - [scrollView, addToTripButton, loadingIndicator].forEach { - view.addSubview($0) - } - + // 스크롤 영역 + view.addSubview(scrollView) scrollView.addSubview(contentView) - [mediaInfoView, dayCollectionView, budgetView, mapView, placeListCollectionView].forEach { contentView.addSubview($0) } + + // 스티키 헤더 (scrollView 위에 배치) + view.addSubview(stickyHeaderView) + stickyHeaderView.addSubview(stickyDayCollectionView) + + // 버튼 및 로딩 + view.addSubview(addToTripButton) + view.addSubview(loadingIndicator) } private func setupConstraints() { @@ -104,6 +136,7 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre $0.center.equalToSuperview() } + // 스크롤 영역 scrollView.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide) $0.leading.trailing.bottom.equalToSuperview() @@ -122,7 +155,7 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre $0.top.equalTo(mediaInfoView.snp.bottom).offset(24) $0.leading.equalToSuperview().offset(16) $0.trailing.equalToSuperview().offset(-16) - $0.height.equalTo(40) + $0.height.equalTo(30) } budgetView.snp.makeConstraints { @@ -147,6 +180,20 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre $0.height.greaterThanOrEqualTo(400) } + // 스티키 헤더 + stickyHeaderView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.leading.trailing.equalToSuperview() + $0.height.equalTo(62) + } + + stickyDayCollectionView.snp.makeConstraints { + $0.top.equalToSuperview().offset(16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(30) + } + addToTripButton.snp.makeConstraints { $0.leading.equalToSuperview().offset(16) $0.trailing.equalToSuperview().offset(-16) @@ -156,8 +203,10 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre } private func setupDelegates() { + scrollView.delegate = self mediaInfoView.delegate = self dayCollectionView.dayDelegate = self + stickyDayCollectionView.dayDelegate = self placeListCollectionView.placeDelegate = self } @@ -175,6 +224,26 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre listener?.didTapAddToTrip() } + // MARK: - Private Methods + + private func syncDaySelection(day: Int) { + currentSelectedDay = day + let indexPath = IndexPath(item: day - 1, section: 0) + dayCollectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally) + stickyDayCollectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredHorizontally) + } +} + +// MARK: - UIScrollViewDelegate + +extension FollowDetailViewController: UIScrollViewDelegate { + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + let offsetY = scrollView.contentOffset.y + + // dayCollectionView가 화면 상단에 도달하면 스티키 헤더 표시 + let threshold = dayCollectionViewOriginY - 16 + stickyHeaderView.isHidden = offsetY < threshold + } } // MARK: - FollowDetailPresentable @@ -190,7 +259,11 @@ extension FollowDetailViewController { func updateTravelDetail(_ detail: TravelDetail) { mediaInfoView.configure(with: detail) + totalDays = detail.days + + // 두 컬렉션뷰 모두 업데이트 dayCollectionView.applySnapshot(totalDays: detail.days, selectedDay: 1) + stickyDayCollectionView.applySnapshot(totalDays: detail.days, selectedDay: 1) } func updatePlaces(_ places: [TravelPlace]) { @@ -215,6 +288,9 @@ extension FollowDetailViewController: MediaInfoViewDelegate { func mediaInfoViewDidToggleExpand(_ view: MediaInfoView, isExpanded: Bool) { UIView.animate(withDuration: 0.3) { [weak self] in self?.view.layoutIfNeeded() + } completion: { [weak self] _ in + // 레이아웃 변경 후 dayCollectionView 위치 재계산 + self?.dayCollectionViewOriginY = self?.dayCollectionView.frame.origin.y ?? 0 } } } @@ -223,6 +299,8 @@ extension FollowDetailViewController: MediaInfoViewDelegate { extension FollowDetailViewController: DayCollectionViewDelegate { func dayCollectionView(_ collectionView: DayCollectionView, didSelectDay day: Int) { + // 두 컬렉션뷰 선택 상태 동기화 + syncDaySelection(day: day) listener?.didSelectDay(day) } } diff --git a/Projects/Features/FollowFeature/Sources/Mock/MockFollowDetailRepository.swift b/Projects/Features/FollowFeature/Sources/Mock/MockFollowDetailRepository.swift index 3c65e3e..b9ecaa0 100644 --- a/Projects/Features/FollowFeature/Sources/Mock/MockFollowDetailRepository.swift +++ b/Projects/Features/FollowFeature/Sources/Mock/MockFollowDetailRepository.swift @@ -28,7 +28,7 @@ final class MockFollowDetailRepository: FollowRepositoryProtocol { thumbnail: "https://i.ytimg.com/vi/F2utz6L76D0/mqdefault.jpg", profileImage: nil, link: "https://www.youtube.com/watch?v=F2utz6L76D0", - summary: "빠니보틀은 주말을 이용해 직장인들도 충분히 다녀올 수 있는 '금요일 퇴근 후 방콕 여행'의 가능성을 보여주며, 곽튜브와의 티격태격 케미를 통해 방콕의 매력을 소개합니다" + summary: "빠니보틀은 주말을 이용해 직장인들도 충분히 다녀올 수 있는 '금요일 퇴근 후 방콕 여행'의 가능성을 보여주며" ) ) } @@ -49,11 +49,11 @@ final class MockFollowDetailRepository: FollowRepositoryProtocol { estimatedDuration: 60, place: PlaceInfo( googlePlaceId: "ChIJSc8jdZORQTURu6BMwxrKbGg", + thumbnail: "https://example.com/airport.jpg", latitude: 35.6585805, longitude: 139.7454329, name: "인도 국제 공항", - regularOpeningHours: "00:00~24:00", - category: .transportation + regularOpeningHours: "00:00~24:00" ) ), TravelPlace( @@ -64,11 +64,11 @@ final class MockFollowDetailRepository: FollowRepositoryProtocol { estimatedDuration: 90, place: PlaceInfo( googlePlaceId: "ChIJN1t_tDeuEmsRUsoyG83frY4", + thumbnail: "https://example.com/market.jpg", latitude: 35.6592606, longitude: 139.7002586, name: "바라나시 시장 투어", - regularOpeningHours: "06:00~18:00", - category: .tourism + regularOpeningHours: "06:00~18:00" ) ), TravelPlace( @@ -79,11 +79,11 @@ final class MockFollowDetailRepository: FollowRepositoryProtocol { estimatedDuration: 60, place: PlaceInfo( googlePlaceId: "ChIJabc123", + thumbnail: "https://example.com/chicken.jpg", latitude: 35.6600000, longitude: 139.7100000, name: "짱짱 탄두리 치킨", - regularOpeningHours: "11:00~22:00", - category: .restaurant + regularOpeningHours: "11:00~22:00" ) ), TravelPlace( @@ -94,11 +94,11 @@ final class MockFollowDetailRepository: FollowRepositoryProtocol { estimatedDuration: 30, place: PlaceInfo( googlePlaceId: "ChIJdef456", + thumbnail: "https://example.com/cafe.jpg", latitude: 35.6610000, longitude: 139.7150000, name: "맛있다 카페", - regularOpeningHours: "08:00~20:00", - category: .cafe + regularOpeningHours: "08:00~20:00" ) ), TravelPlace( @@ -109,11 +109,11 @@ final class MockFollowDetailRepository: FollowRepositoryProtocol { estimatedDuration: 480, place: PlaceInfo( googlePlaceId: "ChIJghi789", + thumbnail: "https://example.com/hotel.jpg", latitude: 35.6620000, longitude: 139.7200000, name: "쿨쿨호텔", - regularOpeningHours: nil, - category: .accommodation + regularOpeningHours: nil ) ) ] @@ -127,11 +127,11 @@ final class MockFollowDetailRepository: FollowRepositoryProtocol { estimatedDuration: 120, place: PlaceInfo( googlePlaceId: "ChIJaaa111", + thumbnail: "https://example.com/tajmahal.jpg", latitude: 35.6700000, longitude: 139.7300000, name: "타지마할", - regularOpeningHours: "06:00~18:00", - category: .tourism + regularOpeningHours: "06:00~18:00" ) ), TravelPlace( @@ -142,11 +142,11 @@ final class MockFollowDetailRepository: FollowRepositoryProtocol { estimatedDuration: 60, place: PlaceInfo( googlePlaceId: "ChIJbbb222", + thumbnail: "https://example.com/restaurant.jpg", latitude: 35.6710000, longitude: 139.7310000, name: "전통 음식점", - regularOpeningHours: "10:00~21:00", - category: .restaurant + regularOpeningHours: "10:00~21:00" ) ) ] @@ -160,11 +160,11 @@ final class MockFollowDetailRepository: FollowRepositoryProtocol { estimatedDuration: 180, place: PlaceInfo( googlePlaceId: "ChIJccc333", + thumbnail: "https://example.com/market2.jpg", latitude: 35.6800000, longitude: 139.7400000, name: "현지 시장", - regularOpeningHours: "09:00~20:00", - category: .tourism + regularOpeningHours: "09:00~20:00" ) ) ] diff --git a/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift b/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift index 91e9c6b..5a730d2 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift @@ -19,9 +19,9 @@ final class DayCell: UICollectionViewCell { // MARK: - UI Components private let containerView = UIView().then { - $0.layer.cornerRadius = 16 + $0.layer.cornerRadius = 15 $0.layer.borderWidth = 1 - $0.layer.borderColor = UIColor.NDGL.Border.primary.cgColor + $0.layer.borderColor = UIColor.NDGL.Border.secondary.cgColor $0.backgroundColor = UIColor.NDGL.Bg.primary } @@ -67,19 +67,22 @@ final class DayCell: UICollectionViewCell { // MARK: - Configuration func configure(day: Int) { - dayLabel.setText(.bodyMM, text: "\(day)일차", color: UIColor.NDGL.Text.secondary) + dayLabel.text = "\(day)일차" updateSelectionState() } private func updateSelectionState() { if isSelected { - containerView.backgroundColor = UIColor.NDGL.Bg.Interactive.secondary - containerView.layer.borderColor = UIColor.clear.cgColor - dayLabel.setText(.bodyMSB, text: dayLabel.text ?? "", color: UIColor.NDGL.Text.Interactive.inverse) + containerView.backgroundColor = UIColor.init(hexCode: "#2C2C2C") + containerView.layer.borderWidth = 0 + dayLabel.font = DSKitFontFamily.Pretendard.medium.font(size: 14) + dayLabel.textColor = UIColor.NDGL.Text.Interactive.inverse } else { containerView.backgroundColor = UIColor.NDGL.Bg.primary - containerView.layer.borderColor = UIColor.NDGL.Border.primary.cgColor - dayLabel.setText(.bodyMM, text: dayLabel.text ?? "", color: UIColor.NDGL.Text.secondary) + containerView.layer.borderWidth = 1 + containerView.layer.borderColor = UIColor.NDGL.Border.secondary.cgColor + dayLabel.font = DSKitFontFamily.Pretendard.medium.font(size: 14) + dayLabel.textColor = UIColor.NDGL.Text.disabled } } } diff --git a/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift b/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift index 0249b6f..0a35ede 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift @@ -9,9 +9,10 @@ import Core import Domain import DSKit -import UIKit +import Kingfisher import SnapKit import Then +import UIKit final class PlaceCell: UICollectionViewCell { @@ -23,13 +24,6 @@ final class PlaceCell: UICollectionViewCell { $0.backgroundColor = UIColor.NDGL.Bg.primary } - private let categoryTagView = UIView().then { - $0.backgroundColor = UIColor.NDGL.Bg.Interactive.selected - $0.layer.cornerRadius = 4 - } - - private let categoryLabel = UILabel() - private let durationLabel = UILabel() private let sequenceView = UIView().then { @@ -70,16 +64,21 @@ final class PlaceCell: UICollectionViewCell { fatalError("init(coder:) has not been implemented") } + override func prepareForReuse() { + super.prepareForReuse() + thumbnailImageView.kf.cancelDownloadTask() + thumbnailImageView.image = nil + } + // MARK: - Setup private func setupUI() { contentView.addSubview(containerView) - [categoryTagView, durationLabel, sequenceView, placeNameLabel, tipLabel, thumbnailImageView, separatorView, timeInfoLabel].forEach { + [durationLabel, sequenceView, placeNameLabel, tipLabel, thumbnailImageView, separatorView, timeInfoLabel].forEach { containerView.addSubview($0) } - categoryTagView.addSubview(categoryLabel) sequenceView.addSubview(sequenceLabel) } @@ -88,27 +87,15 @@ final class PlaceCell: UICollectionViewCell { $0.edges.equalToSuperview() } - // 카테고리 태그 - categoryTagView.snp.makeConstraints { - $0.top.equalToSuperview().offset(8) - $0.leading.equalToSuperview() - $0.height.equalTo(20) - } - - categoryLabel.snp.makeConstraints { - $0.verticalEdges.equalToSuperview().inset(2) - $0.horizontalEdges.equalToSuperview().inset(8) - } - // 예상 체류 시간 durationLabel.snp.makeConstraints { - $0.centerY.equalTo(categoryTagView) - $0.leading.equalTo(categoryTagView.snp.trailing).offset(8) + $0.top.equalToSuperview().offset(8) + $0.leading.equalToSuperview() } // 순서 뷰 sequenceView.snp.makeConstraints { - $0.top.equalTo(categoryTagView.snp.bottom).offset(8) + $0.top.equalTo(durationLabel.snp.bottom).offset(8) $0.leading.equalToSuperview() $0.size.equalTo(24) } @@ -133,7 +120,7 @@ final class PlaceCell: UICollectionViewCell { // 썸네일 thumbnailImageView.snp.makeConstraints { - $0.top.equalTo(categoryTagView.snp.bottom).offset(8) + $0.top.equalTo(durationLabel.snp.bottom).offset(8) $0.trailing.equalToSuperview() $0.size.equalTo(72) } @@ -155,13 +142,25 @@ final class PlaceCell: UICollectionViewCell { // MARK: - Configuration func configure(with place: TravelPlace, isLast: Bool = false) { - categoryLabel.setText(.bodySSB, text: place.place.category.rawValue, color: UIColor.NDGL.Text.Interactive.primary) - durationLabel.setText(.bodySR, text: "• \(place.estimatedDuration)분 체류 예상", color: UIColor.NDGL.Text.tertiary) + durationLabel.setText(.bodySR, text: "\(place.estimatedDuration)분 체류 예상", color: UIColor.NDGL.Text.tertiary) sequenceLabel.setText(.bodySSB, text: "\(place.sequence)", color: UIColor.NDGL.Text.Interactive.inverse) placeNameLabel.setText(.bodyLSB, text: place.place.name, color: UIColor.NDGL.Text.primary) - tipLabel.setText(.bodySR, text: place.travelerTip ?? "", color: UIColor.NDGL.Text.tertiary) + tipLabel.setText(.bodySR, text: place.travelerTip, color: UIColor.NDGL.Text.tertiary) separatorView.isHidden = isLast + // 썸네일 이미지 로딩 + if let thumbnailURLString = place.place.thumbnail, + let thumbnailURL = URL(string: thumbnailURLString) { + thumbnailImageView.kf.setImage( + with: thumbnailURL, + placeholder: nil, + options: [ + .transition(.fade(0.2)), + .cacheOriginalImage + ] + ) + } + // 다음 장소까지 이동 시간 (Mock) if !isLast { timeInfoLabel.setText(.bodySR, text: "약 30분 • 28.8km", color: UIColor.NDGL.Text.tertiary) diff --git a/Projects/Features/FollowFeature/Sources/Views/CollectionViews/DayCollectionView.swift b/Projects/Features/FollowFeature/Sources/Views/CollectionViews/DayCollectionView.swift index 1b60098..d8a4927 100644 --- a/Projects/Features/FollowFeature/Sources/Views/CollectionViews/DayCollectionView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/CollectionViews/DayCollectionView.swift @@ -32,7 +32,7 @@ final class DayCollectionView: UICollectionView { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .horizontal layout.minimumInteritemSpacing = 8 - layout.itemSize = CGSize(width: 60, height: 32) + layout.itemSize = CGSize(width: 72, height: 30) super.init(frame: .zero, collectionViewLayout: layout) setupCollectionView() diff --git a/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift index 72cef7e..d71eefb 100644 --- a/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift @@ -53,11 +53,12 @@ final class MediaInfoView: UIView { private let travelInfoLabel = UILabel() private let titleLabel = UILabel().then { - $0.numberOfLines = 2 + $0.numberOfLines = 1 + $0.lineBreakMode = .byTruncatingTail } private let toggleButton = UIButton(type: .system).then { - $0.setImage(DSKitAsset.Assets.icChevronDown1.image, for: .normal) + $0.setImage(DSKitAsset.Assets.icChevronDown3.image, for: .normal) $0.tintColor = UIColor.NDGL.Icon.disabled } @@ -69,7 +70,7 @@ final class MediaInfoView: UIView { } private let thumbnailImageView = UIImageView().then { - $0.contentMode = .scaleAspectFill + $0.contentMode = .scaleAspectFit $0.backgroundColor = UIColor.NDGL.Bg.Interactive.subtle02 $0.layer.cornerRadius = 8 $0.clipsToBounds = true @@ -160,15 +161,13 @@ final class MediaInfoView: UIView { private func setupConstraints() { profileImageView.snp.makeConstraints { - $0.top.equalToSuperview().offset(8) + $0.top.equalToSuperview().offset(16) $0.leading.equalToSuperview().offset(24) $0.size.equalTo(56) - // collapsed 상태의 bottom constraint - collapsedBottomConstraint = $0.bottom.equalToSuperview().offset(-8).constraint } travelInfoIconView.snp.makeConstraints { - $0.size.equalTo(16) + $0.size.equalTo(24) } travelInfoStackView.snp.makeConstraints { @@ -177,19 +176,21 @@ final class MediaInfoView: UIView { } titleLabel.snp.makeConstraints { - $0.bottom.equalTo(profileImageView.snp.bottom) + $0.top.equalTo(travelInfoStackView.snp.bottom).offset(4) $0.leading.equalTo(travelInfoStackView) $0.trailing.equalTo(toggleButton.snp.leading).offset(-8) + // collapsed 상태의 bottom constraint (titleLabel 기준) + collapsedBottomConstraint = $0.bottom.equalToSuperview().offset(-16).constraint } toggleButton.snp.makeConstraints { - $0.centerY.equalTo(titleLabel) + $0.top.equalTo(titleLabel) $0.trailing.equalToSuperview().offset(-24) $0.size.equalTo(28) } expandedContainerView.snp.makeConstraints { - $0.top.equalTo(profileImageView.snp.bottom).offset(25) + $0.top.equalTo(titleLabel.snp.bottom).offset(16) $0.leading.trailing.equalToSuperview().inset(24) // expanded 상태의 bottom constraint expandedBottomConstraint = $0.bottom.equalToSuperview().offset(-16).constraint @@ -200,7 +201,8 @@ final class MediaInfoView: UIView { thumbnailImageView.snp.makeConstraints { $0.top.equalToSuperview() $0.leading.trailing.equalToSuperview() - $0.height.equalTo(150) + // 327:150 비율 유지 + $0.height.equalTo(thumbnailImageView.snp.width).multipliedBy(150.0 / 327.0) } budgetIconView.snp.makeConstraints { @@ -248,9 +250,12 @@ final class MediaInfoView: UIView { private func updateExpandedState() { expandedContainerView.isHidden = !isExpanded - let image = isExpanded ? DSKitAsset.Assets.icChevronUp1.image : DSKitAsset.Assets.icChevronDown1.image + let image = isExpanded ? DSKitAsset.Assets.icChevronUp3.image : DSKitAsset.Assets.icChevronDown3.image toggleButton.setImage(image, for: .normal) + // 타이틀 줄 수 토글 (collapsed: 1줄, expanded: 무제한) + titleLabel.numberOfLines = isExpanded ? 0 : 1 + // Bottom constraint 토글 if isExpanded { collapsedBottomConstraint?.deactivate() @@ -266,7 +271,7 @@ final class MediaInfoView: UIView { func configure(with detail: TravelDetail) { // 여행 정보 라벨 (유튜버 · 국가 · 3박4일) let travelInfoText = "\(detail.youtube.youtuber) · \(detail.country) · \(detail.nights)박\(detail.days)일" - travelInfoLabel.setText(.bodyMSB, text: travelInfoText, color: UIColor.NDGL.Text.tertiary) + travelInfoLabel.setText(.bodyMSB, text: travelInfoText, color: UIColor.NDGL.Text.disabled) // 제목 titleLabel.setText(.subTitleLSB, text: detail.youtube.title, color: UIColor.NDGL.Text.primary) diff --git a/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift b/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift index 412d2c5..b06fc6e 100644 --- a/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift @@ -127,8 +127,8 @@ extension TravelMapView: MKMapViewDelegate { func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { if let polyline = overlay as? MKPolyline { let renderer = MKPolylineRenderer(polyline: polyline) - renderer.strokeColor = UIColor.NDGL.Bg.Interactive.primary - renderer.lineWidth = 2 + renderer.strokeColor = .black + renderer.lineWidth = 1 renderer.lineDashPattern = [4, 4] return renderer } diff --git a/Projects/Features/HomeFeature/Sources/HomeRouter.swift b/Projects/Features/HomeFeature/Sources/HomeRouter.swift index 19c3f31..480e5e4 100644 --- a/Projects/Features/HomeFeature/Sources/HomeRouter.swift +++ b/Projects/Features/HomeFeature/Sources/HomeRouter.swift @@ -62,7 +62,12 @@ final class HomeRouter: ViewableRouter, func detachFollowDetail() { guard let router = followDetailRouter else { return } - viewController.pop() + // FollowDetail VC가 아직 네비게이션 스택에 있는 경우에만 pop + if let navController = viewController.uiviewController.navigationController, + navController.viewControllers.contains(router.viewControllable.uiviewController) { + viewController.pop() + } + detachChild(router) followDetailRouter = nil } diff --git a/Projects/Modules/Networks/Sources/DTO/Follow/FollowDTO.swift b/Projects/Modules/Networks/Sources/DTO/Follow/FollowDTO.swift index 016ad77..e3b5ae9 100644 --- a/Projects/Modules/Networks/Sources/DTO/Follow/FollowDTO.swift +++ b/Projects/Modules/Networks/Sources/DTO/Follow/FollowDTO.swift @@ -32,7 +32,7 @@ public struct YouTubeResponse: Decodable, Sendable { // MARK: - Itinerary Response (여행 일정) public struct FollowItineraryResponse: Decodable, Sendable { - public let places: [FollowPlaceResponse] + public let itineraries: [FollowPlaceResponse] } public struct FollowPlaceResponse: Decodable, Sendable { @@ -46,9 +46,9 @@ public struct FollowPlaceResponse: Decodable, Sendable { public struct PlaceResponse: Decodable, Sendable { public let googlePlaceId: String + public let thumbnail: String? public let latitude: Double public let longitude: Double public let name: String public let regularOpeningHours: String? - public let category: String? } From a7dc7ec6757181677c3c84aac6500a5360e5ff03 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sat, 24 Jan 2026 15:10:44 +0900 Subject: [PATCH 06/20] =?UTF-8?q?design:=20#10:=20=EC=BB=AC=EB=A0=89?= =?UTF-8?q?=EC=85=98=EB=B7=B0=EC=9D=98=20=EB=82=B4=EC=9A=A9=20dto=EC=97=90?= =?UTF-8?q?=20=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/FollowDetailViewController.swift | 6 +- .../Sources/Views/Cells/PlaceCell.swift | 165 +++++++++++++----- .../PlaceListCollectionView.swift | 5 +- 3 files changed, 124 insertions(+), 52 deletions(-) diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift index 10eee76..60e2a20 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift @@ -270,8 +270,10 @@ extension FollowDetailViewController { mapView.configure(with: places) placeListCollectionView.applySnapshot(places: places) - // PlaceList 높이 동적 업데이트 - let height = CGFloat(places.count * 140) + // PlaceList 높이 동적 업데이트 (셀 높이 135 + spacing 8) + let cellHeight: CGFloat = 135 + let spacing: CGFloat = 8 + let height = CGFloat(places.count) * cellHeight + CGFloat(max(0, places.count - 1)) * spacing placeListCollectionView.snp.updateConstraints { $0.height.greaterThanOrEqualTo(max(400, height)) } diff --git a/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift b/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift index 0a35ede..cccddac 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift @@ -20,12 +20,7 @@ final class PlaceCell: UICollectionViewCell { // MARK: - UI Components - private let containerView = UIView().then { - $0.backgroundColor = UIColor.NDGL.Bg.primary - } - - private let durationLabel = UILabel() - + // 순서 뷰 (셀 바깥 왼쪽) private let sequenceView = UIView().then { $0.backgroundColor = UIColor.NDGL.Bg.Interactive.primary $0.layer.cornerRadius = 12 @@ -33,12 +28,37 @@ final class PlaceCell: UICollectionViewCell { private let sequenceLabel = UILabel() - private let placeNameLabel = UILabel() + // 메인 컨테이너 (보더 있는 영역) + private let containerView = UIView().then { + $0.backgroundColor = UIColor.NDGL.Bg.primary + $0.layer.cornerRadius = 12 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.NDGL.Border.subtle.cgColor + $0.clipsToBounds = true + } + + // 카테고리 태그 + private let categoryTagView = UIView().then { + $0.backgroundColor = UIColor.NDGL.Bg.Interactive.subtle02 + $0.layer.cornerRadius = 4 + } + private let categoryLabel = UILabel() + + // 체류 시간 + private let durationLabel = UILabel() + + // 장소명 + private let placeNameLabel = UILabel().then { + $0.numberOfLines = 1 + } + + // 팁/설명 private let tipLabel = UILabel().then { $0.numberOfLines = 2 } + // 썸네일 private let thumbnailImageView = UIImageView().then { $0.contentMode = .scaleAspectFill $0.backgroundColor = UIColor.NDGL.Bg.Interactive.subtle02 @@ -46,11 +66,16 @@ final class PlaceCell: UICollectionViewCell { $0.clipsToBounds = true } - private let separatorView = UIView().then { - $0.backgroundColor = UIColor.NDGL.Border.secondary - } + // 이동 시간 정보 (컨테이너 아래) + private let travelTimeContainerView = UIView() - private let timeInfoLabel = UILabel() + private let travelTimeLabel = UILabel() + + private let chevronImageView = UIImageView().then { + $0.image = DSKitAsset.Assets.icChevronRight3.image + $0.contentMode = .scaleAspectFit + $0.tintColor = UIColor.NDGL.Icon.disabled + } // MARK: - Initialization @@ -68,34 +93,37 @@ final class PlaceCell: UICollectionViewCell { super.prepareForReuse() thumbnailImageView.kf.cancelDownloadTask() thumbnailImageView.image = nil + travelTimeContainerView.isHidden = false } // MARK: - Setup private func setupUI() { + // 순서 뷰 + contentView.addSubview(sequenceView) + sequenceView.addSubview(sequenceLabel) + + // 메인 컨테이너 contentView.addSubview(containerView) - [durationLabel, sequenceView, placeNameLabel, tipLabel, thumbnailImageView, separatorView, timeInfoLabel].forEach { + // 컨테이너 내부 요소들 + [categoryTagView, durationLabel, placeNameLabel, tipLabel, thumbnailImageView].forEach { containerView.addSubview($0) } - sequenceView.addSubview(sequenceLabel) - } - - private func setupConstraints() { - containerView.snp.makeConstraints { - $0.edges.equalToSuperview() - } + categoryTagView.addSubview(categoryLabel) - // 예상 체류 시간 - durationLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(8) - $0.leading.equalToSuperview() + // 이동 시간 정보 + contentView.addSubview(travelTimeContainerView) + [travelTimeLabel, chevronImageView].forEach { + travelTimeContainerView.addSubview($0) } + } - // 순서 뷰 + private func setupConstraints() { + // 순서 뷰 (왼쪽 바깥) sequenceView.snp.makeConstraints { - $0.top.equalTo(durationLabel.snp.bottom).offset(8) + $0.top.equalToSuperview().offset(12) $0.leading.equalToSuperview() $0.size.equalTo(24) } @@ -104,53 +132,92 @@ final class PlaceCell: UICollectionViewCell { $0.center.equalToSuperview() } + // 메인 컨테이너 + containerView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.equalTo(sequenceView.snp.trailing).offset(8) + $0.trailing.equalToSuperview() + $0.height.equalTo(99) + } + + // 카테고리 태그 + categoryTagView.snp.makeConstraints { + $0.top.equalToSuperview().offset(12) + $0.leading.equalToSuperview().offset(12) + $0.height.equalTo(20) + } + + categoryLabel.snp.makeConstraints { + $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6)) + } + + // 체류 시간 + durationLabel.snp.makeConstraints { + $0.centerY.equalTo(categoryTagView) + $0.leading.equalTo(categoryTagView.snp.trailing).offset(8) + } + // 장소명 placeNameLabel.snp.makeConstraints { - $0.centerY.equalTo(sequenceView) - $0.leading.equalTo(sequenceView.snp.trailing).offset(8) - $0.trailing.equalTo(thumbnailImageView.snp.leading).offset(-8) + $0.top.equalTo(categoryTagView.snp.bottom).offset(8) + $0.leading.equalToSuperview().offset(12) + $0.trailing.equalTo(thumbnailImageView.snp.leading).offset(-12) } // 팁 tipLabel.snp.makeConstraints { $0.top.equalTo(placeNameLabel.snp.bottom).offset(4) $0.leading.equalTo(placeNameLabel) - $0.trailing.equalTo(thumbnailImageView.snp.leading).offset(-8) + $0.trailing.equalTo(placeNameLabel) } // 썸네일 thumbnailImageView.snp.makeConstraints { - $0.top.equalTo(durationLabel.snp.bottom).offset(8) - $0.trailing.equalToSuperview() - $0.size.equalTo(72) + $0.top.equalToSuperview().offset(12) + $0.trailing.equalToSuperview().offset(-12) + $0.size.equalTo(56) } - // 구분선 - separatorView.snp.makeConstraints { - $0.leading.trailing.bottom.equalToSuperview() - $0.height.equalTo(1) + // 이동 시간 컨테이너 + travelTimeContainerView.snp.makeConstraints { + $0.top.equalTo(containerView.snp.bottom).offset(8) + $0.leading.equalTo(containerView).offset(12) + $0.trailing.equalTo(containerView).offset(-12) + $0.height.equalTo(20) } - // 시간 정보 - timeInfoLabel.snp.makeConstraints { - $0.top.equalTo(thumbnailImageView.snp.bottom).offset(8) - $0.leading.equalToSuperview() - $0.bottom.equalTo(separatorView.snp.top).offset(-8) + travelTimeLabel.snp.makeConstraints { + $0.leading.centerY.equalToSuperview() + } + + chevronImageView.snp.makeConstraints { + $0.leading.equalTo(travelTimeLabel.snp.trailing).offset(4) + $0.centerY.equalToSuperview() + $0.size.equalTo(16) } } // MARK: - Configuration func configure(with place: TravelPlace, isLast: Bool = false) { - durationLabel.setText(.bodySR, text: "\(place.estimatedDuration)분 체류 예상", color: UIColor.NDGL.Text.tertiary) sequenceLabel.setText(.bodySSB, text: "\(place.sequence)", color: UIColor.NDGL.Text.Interactive.inverse) + + // 카테고리 (기본값: 교통수단) + categoryLabel.setText(.bodySR, text: "교통수단", color: UIColor.NDGL.Text.secondary) + + // 체류 시간 + durationLabel.setText(.bodySR, text: "\(place.estimatedDuration)분 체류 예상", color: UIColor.NDGL.Text.tertiary) + + // 장소명 placeNameLabel.setText(.bodyLSB, text: place.place.name, color: UIColor.NDGL.Text.primary) + + // 팁 tipLabel.setText(.bodySR, text: place.travelerTip, color: UIColor.NDGL.Text.tertiary) - separatorView.isHidden = isLast // 썸네일 이미지 로딩 if let thumbnailURLString = place.place.thumbnail, let thumbnailURL = URL(string: thumbnailURLString) { + thumbnailImageView.isHidden = false thumbnailImageView.kf.setImage( with: thumbnailURL, placeholder: nil, @@ -159,13 +226,17 @@ final class PlaceCell: UICollectionViewCell { .cacheOriginalImage ] ) + } else { + thumbnailImageView.isHidden = true } - // 다음 장소까지 이동 시간 (Mock) - if !isLast { - timeInfoLabel.setText(.bodySR, text: "약 30분 • 28.8km", color: UIColor.NDGL.Text.tertiary) + // 이동 시간 정보 (마지막 아이템이면 숨김) + if isLast { + travelTimeContainerView.isHidden = true } else { - timeInfoLabel.setText(.bodySR, text: "", color: UIColor.NDGL.Text.tertiary) + travelTimeContainerView.isHidden = false + // TODO: 실제 이동 시간 데이터로 교체 + travelTimeLabel.setText(.bodySR, text: "약 30분 • 28.8km", color: UIColor.NDGL.Text.tertiary) } } } diff --git a/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift b/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift index cbcc728..9377c42 100644 --- a/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift @@ -26,8 +26,7 @@ final class PlaceListCollectionView: UICollectionView { init() { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .vertical - layout.minimumLineSpacing = 0 - layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize + layout.minimumLineSpacing = 8 super.init(frame: .zero, collectionViewLayout: layout) setupCollectionView() @@ -91,6 +90,6 @@ extension PlaceListCollectionView: UICollectionViewDelegate { extension PlaceListCollectionView: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - return CGSize(width: collectionView.bounds.width, height: 140) + return CGSize(width: collectionView.bounds.width, height: 135) } } From 19633d9021076acdcb12b47408e29be23c538bfb Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sat, 24 Jan 2026 16:35:06 +0900 Subject: [PATCH 07/20] =?UTF-8?q?design:=20#10=20-=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EC=9A=B4=20=EC=97=AC=ED=96=89=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20RIBs=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/FollowDetailBuilder.swift | 7 +- .../Sources/FollowDetailInteractor.swift | 21 +- .../Sources/FollowDetailRouter.swift | 36 +- .../Sources/FollowDetailViewController.swift | 23 +- .../TripCalendar/TripCalendarBuilder.swift | 47 ++ .../TripCalendar/TripCalendarInteractor.swift | 64 +++ .../TripCalendar/TripCalendarRouter.swift | 39 ++ .../TripCalendarViewController.swift | 144 ++++++ .../Views/Calendar/CalendarDayCell.swift | 175 +++++++ .../Sources/Views/Calendar/CalendarView.swift | 445 ++++++++++++++++++ .../HomeFeature/Sources/HomeInteractor.swift | 6 +- .../Component/BottomPlacedButton.swift | 90 ++++ 12 files changed, 1077 insertions(+), 20 deletions(-) create mode 100644 Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarBuilder.swift create mode 100644 Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarInteractor.swift create mode 100644 Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarRouter.swift create mode 100644 Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift create mode 100644 Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift create mode 100644 Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift create mode 100644 Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift b/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift index 9b446b7..e01bedd 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift @@ -17,7 +17,7 @@ public protocol FollowDetailDependency: Dependency { // MARK: - FollowDetailComponent -final class FollowDetailComponent: Component { +final class FollowDetailComponent: Component, TripCalendarDependency { var repository: FollowRepositoryProtocol { dependency.followRepository } @@ -47,9 +47,12 @@ public final class FollowDetailBuilder: Builder, FollowD ) interactor.listener = listener + let tripCalendarBuilder = TripCalendarBuilder(dependency: component) + let router = FollowDetailRouter( interactor: interactor, - viewController: viewController + viewController: viewController, + tripCalendarBuilder: tripCalendarBuilder ) return router diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift index 3dbb75f..1bdeb65 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -7,6 +7,7 @@ // import Domain +import Foundation import RIBs import RxSwift @@ -128,8 +129,7 @@ extension FollowDetailInteractor: FollowDetailPresentableListener { } func didTapAddToTrip() { - // TODO: 내 여행에 담기 기능 구현 - print("Add to trip tapped for travel: \(recommendationId)") + router?.routeToTripCalendar() } func didSelectDay(_ day: Int) { @@ -148,3 +148,20 @@ extension FollowDetailInteractor: FollowDetailPresentableListener { print("Selected place: \(place.place.name)") } } + +// MARK: - TripCalendarListener + +extension FollowDetailInteractor: TripCalendarListener { + func tripCalendarDidSelectRange(startDate: Date, endDate: Date) { + router?.detachTripCalendar() + + // TODO: 실제 여행 저장 API 호출 + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + print("Trip added: \(formatter.string(from: startDate)) ~ \(formatter.string(from: endDate))") + } + + func tripCalendarDidCancel() { + router?.detachTripCalendar() + } +} diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift b/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift index 05c6cf7..3aafbff 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift @@ -10,7 +10,7 @@ import RIBs // MARK: - FollowDetailInteractable -protocol FollowDetailInteractable: Interactable { +protocol FollowDetailInteractable: Interactable, TripCalendarListener { var router: FollowDetailRouting? { get set } var listener: FollowDetailListener? { get set } } @@ -18,24 +18,50 @@ protocol FollowDetailInteractable: Interactable { // MARK: - FollowDetailViewControllable public protocol FollowDetailViewControllable: ViewControllable { - // ViewController에 요청할 화면 전환 메서드 정의 + func present(_ viewController: ViewControllable) + func dismiss(_ viewController: ViewControllable) } // MARK: - FollowDetailRouting public protocol FollowDetailRouting: ViewableRouting { - // 자식 RIB으로 라우팅하는 메서드 정의 + func routeToTripCalendar() + func detachTripCalendar() } // MARK: - FollowDetailRouter final class FollowDetailRouter: ViewableRouter, FollowDetailRouting { - override init( + private let tripCalendarBuilder: TripCalendarBuildable + private var tripCalendarRouter: TripCalendarRouting? + + init( interactor: FollowDetailInteractable, - viewController: FollowDetailViewControllable + viewController: FollowDetailViewControllable, + tripCalendarBuilder: TripCalendarBuildable ) { + self.tripCalendarBuilder = tripCalendarBuilder super.init(interactor: interactor, viewController: viewController) interactor.router = self } + + // MARK: - FollowDetailRouting + + func routeToTripCalendar() { + guard tripCalendarRouter == nil else { return } + + let router = tripCalendarBuilder.build(withListener: interactor) + tripCalendarRouter = router + attachChild(router) + viewController.present(router.viewControllable) + } + + func detachTripCalendar() { + guard let router = tripCalendarRouter else { return } + + viewController.dismiss(router.viewControllable) + detachChild(router) + tripCalendarRouter = nil + } } diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift index 60e2a20..16d66fa 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift @@ -58,14 +58,8 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre // MARK: - UI Components (고정 버튼/로딩) - private let addToTripButton = UIButton(type: .system).then { - $0.setTitle("내 여행에 담기", for: .normal) - $0.setTitleColor(UIColor.NDGL.Text.Interactive.inverse, for: .normal) - $0.titleLabel?.font = DSKitFontFamily.Pretendard.semiBold.font(size: 16) - $0.backgroundColor = UIColor.NDGL.Bg.Interactive.primary - $0.layer.cornerRadius = 12 - } - + private let addToTripButton = BottomPlacedButton(title: "여행 따라가기") + private let loadingIndicator = UIActivityIndicatorView(style: .large).then { $0.hidesWhenStopped = true } @@ -284,6 +278,19 @@ extension FollowDetailViewController { } } +// MARK: - FollowDetailViewControllable + +extension FollowDetailViewController { + public func present(_ viewController: ViewControllable) { + viewController.uiviewController.modalPresentationStyle = .fullScreen + present(viewController.uiviewController, animated: true) + } + + public func dismiss(_ viewController: ViewControllable) { + viewController.uiviewController.dismiss(animated: true) + } +} + // MARK: - MediaInfoViewDelegate extension FollowDetailViewController: MediaInfoViewDelegate { diff --git a/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarBuilder.swift b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarBuilder.swift new file mode 100644 index 0000000..e121ec6 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarBuilder.swift @@ -0,0 +1,47 @@ +// +// TripCalendarBuilder.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +// MARK: - TripCalendarDependency + +protocol TripCalendarDependency: Dependency { +} + +// MARK: - TripCalendarComponent + +final class TripCalendarComponent: Component { +} + +// MARK: - TripCalendarBuildable + +protocol TripCalendarBuildable: Buildable { + func build(withListener listener: TripCalendarListener) -> TripCalendarRouting +} + +// MARK: - TripCalendarBuilder + +final class TripCalendarBuilder: Builder, TripCalendarBuildable { + + override init(dependency: TripCalendarDependency) { + super.init(dependency: dependency) + } + + func build(withListener listener: TripCalendarListener) -> TripCalendarRouting { + let viewController = TripCalendarViewController() + let interactor = TripCalendarInteractor(presenter: viewController) + interactor.listener = listener + + let router = TripCalendarRouter( + interactor: interactor, + viewController: viewController + ) + + return router + } +} diff --git a/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarInteractor.swift b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarInteractor.swift new file mode 100644 index 0000000..9fb3825 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarInteractor.swift @@ -0,0 +1,64 @@ +// +// TripCalendarInteractor.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation +import RIBs +import RxSwift + +// MARK: - TripCalendarListener + +protocol TripCalendarListener: AnyObject { + func tripCalendarDidSelectRange(startDate: Date, endDate: Date) + func tripCalendarDidCancel() +} + +// MARK: - TripCalendarPresentable + +protocol TripCalendarPresentable: Presentable { + var listener: TripCalendarPresentableListener? { get set } +} + +// MARK: - TripCalendarPresentableListener + +protocol TripCalendarPresentableListener: AnyObject { + func didTapBackButton() + func didTapCompleteButton(startDate: Date, endDate: Date) +} + +// MARK: - TripCalendarInteractor + +final class TripCalendarInteractor: PresentableInteractor, TripCalendarInteractable { + + weak var router: TripCalendarRouting? + weak var listener: TripCalendarListener? + + override init(presenter: TripCalendarPresentable) { + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + } + + override func willResignActive() { + super.willResignActive() + } +} + +// MARK: - TripCalendarPresentableListener + +extension TripCalendarInteractor: TripCalendarPresentableListener { + func didTapBackButton() { + listener?.tripCalendarDidCancel() + } + + func didTapCompleteButton(startDate: Date, endDate: Date) { + listener?.tripCalendarDidSelectRange(startDate: startDate, endDate: endDate) + } +} diff --git a/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarRouter.swift b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarRouter.swift new file mode 100644 index 0000000..a187c29 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarRouter.swift @@ -0,0 +1,39 @@ +// +// TripCalendarRouter.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +// MARK: - TripCalendarInteractable + +protocol TripCalendarInteractable: Interactable { + var router: TripCalendarRouting? { get set } + var listener: TripCalendarListener? { get set } +} + +// MARK: - TripCalendarViewControllable + +protocol TripCalendarViewControllable: ViewControllable { +} + +// MARK: - TripCalendarRouting + +protocol TripCalendarRouting: ViewableRouting { +} + +// MARK: - TripCalendarRouter + +final class TripCalendarRouter: ViewableRouter, TripCalendarRouting { + + override init( + interactor: TripCalendarInteractable, + viewController: TripCalendarViewControllable + ) { + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } +} diff --git a/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift new file mode 100644 index 0000000..b36a18c --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift @@ -0,0 +1,144 @@ +// +// TripCalendarViewController.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import DSKit +import RIBs +import SnapKit +import Then +import UIKit + +// MARK: - TripCalendarViewController + +final class TripCalendarViewController: UIViewController, TripCalendarPresentable, TripCalendarViewControllable { + + // MARK: - Properties + + weak var listener: TripCalendarPresentableListener? + private var selectedStartDate: Date? + private var selectedEndDate: Date? + + // MARK: - UI Components + + private let navigationBar = UIView() + + private let backButton = UIButton(type: .system).then { + $0.setImage(DSKitAsset.Assets.icChevronLeft3.image, for: .normal) + $0.tintColor = UIColor.NDGL.Icon.primary + } + + private let titleLabel = UILabel() + + private let calendarView = CalendarView() + + private let completeButton = BottomPlacedButton(title: "완료") + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupConstraints() + setupActions() + updateCompleteButtonState() + } + + // MARK: - Setup + + private func setupUI() { + view.backgroundColor = UIColor.NDGL.Bg.primary + + [navigationBar, calendarView, completeButton].forEach { + view.addSubview($0) + } + + [backButton, titleLabel].forEach { + navigationBar.addSubview($0) + } + + titleLabel.setText(.subTitleMSB, text: "새로운 여행 만들기", color: UIColor.NDGL.Text.primary, alignment: .center) + + calendarView.delegate = self + } + + private func setupConstraints() { + navigationBar.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.leading.trailing.equalToSuperview() + $0.height.equalTo(44) + } + + backButton.snp.makeConstraints { + $0.leading.equalToSuperview().offset(16) + $0.centerY.equalToSuperview() + $0.size.equalTo(24) + } + + titleLabel.snp.makeConstraints { + $0.center.equalToSuperview() + } + + calendarView.snp.makeConstraints { + $0.top.equalTo(navigationBar.snp.bottom).offset(16) + $0.leading.trailing.equalToSuperview() + $0.height.equalTo(350) + } + + completeButton.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(24) + $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) + $0.height.equalTo(52) + } + } + + private func setupActions() { + backButton.addTarget(self, action: #selector(backButtonTapped), for: .touchUpInside) + completeButton.addTarget(self, action: #selector(completeButtonTapped), for: .touchUpInside) + } + + // MARK: - Actions + + @objc private func backButtonTapped() { + listener?.didTapBackButton() + } + + @objc private func completeButtonTapped() { + guard let start = selectedStartDate, let end = selectedEndDate else { return } + listener?.didTapCompleteButton(startDate: start, endDate: end) + } + + // MARK: - Private Methods + + private func updateCompleteButtonState() { + let isEnabled = selectedStartDate != nil && selectedEndDate != nil + + if isEnabled { + completeButton.backgroundColor = UIColor(hexCode: "#111111") + completeButton.isEnabled = true + } else { + completeButton.backgroundColor = UIColor.NDGL.Bg.disabled + completeButton.isEnabled = false + } + } +} + +// MARK: - CalendarViewDelegate + +extension TripCalendarViewController: CalendarViewDelegate { + func calendarView(_ view: CalendarView, didSelectRange startDate: Date, endDate: Date) { + selectedStartDate = startDate + selectedEndDate = endDate + updateCompleteButtonState() + } + + func calendarViewDidClearSelection(_ view: CalendarView) { + selectedStartDate = nil + selectedEndDate = nil + updateCompleteButtonState() + } +} diff --git a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift new file mode 100644 index 0000000..baa8846 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift @@ -0,0 +1,175 @@ +// +// CalendarDayCell.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import DSKit +import SnapKit +import Then +import UIKit + +final class CalendarDayCell: UICollectionViewCell { + + static let identifier = "CalendarDayCell" + + // MARK: - UI Components + + private let backgroundCircleView = UIView().then { + $0.isHidden = true + } + + private let rangeBackgroundView = UIView().then { + $0.backgroundColor = UIColor(hexCode: "#C6F6D5") // green200 + $0.isHidden = true + } + + private let dayLabel = UILabel() + + // MARK: - Properties + + enum SelectionState { + case none + case startDate + case endDate + case inRange + } + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + backgroundCircleView.isHidden = true + rangeBackgroundView.isHidden = true + dayLabel.text = nil + } + + // MARK: - Setup + + private func setupUI() { + contentView.addSubview(rangeBackgroundView) + contentView.addSubview(backgroundCircleView) + contentView.addSubview(dayLabel) + + backgroundCircleView.layer.cornerRadius = 18 + } + + private func setupConstraints() { + rangeBackgroundView.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.centerY.equalToSuperview() + $0.height.equalTo(36) + } + + backgroundCircleView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(36) + } + + dayLabel.snp.makeConstraints { + $0.center.equalToSuperview() + } + } + + // MARK: - Configuration + + func configure( + day: Int?, + isCurrentMonth: Bool, + isSunday: Bool, + isPastDate: Bool, + selectionState: SelectionState + ) { + guard let day = day else { + dayLabel.text = nil + backgroundCircleView.isHidden = true + rangeBackgroundView.isHidden = true + return + } + + dayLabel.text = "\(day)" + + // Reset + backgroundCircleView.isHidden = true + backgroundCircleView.layer.borderWidth = 0 + rangeBackgroundView.isHidden = true + + // Determine text color + var textColor: UIColor + + if !isCurrentMonth { + textColor = UIColor.NDGL.Text.disabled + } else if isPastDate { + // Past dates - dimmed but Sunday still shows red tint + if isSunday { + textColor = DSKitAsset.Colors.red300.color + } else { + textColor = UIColor.NDGL.Text.disabled + } + } else if isSunday { + textColor = DSKitAsset.Colors.red500.color + } else { + textColor = UIColor.NDGL.Text.primary + } + + // Apply selection state + switch selectionState { + case .startDate, .endDate: + backgroundCircleView.backgroundColor = UIColor(hexCode: "#38A169") // green500 + backgroundCircleView.isHidden = false + textColor = UIColor.NDGL.Text.Interactive.inverse + + case .inRange: + rangeBackgroundView.isHidden = false + // Keep original text color + + case .none: + break + } + + dayLabel.setText(.bodyMM, text: "\(day)", color: textColor) + } + + func configureRangeBackground(showLeft: Bool, showRight: Bool) { + if showLeft || showRight { + rangeBackgroundView.isHidden = false + + if showLeft && showRight { + rangeBackgroundView.snp.remakeConstraints { + $0.leading.trailing.equalToSuperview() + $0.centerY.equalToSuperview() + $0.height.equalTo(36) + } + } else if showLeft { + rangeBackgroundView.snp.remakeConstraints { + $0.leading.equalToSuperview() + $0.trailing.equalTo(contentView.snp.centerX) + $0.centerY.equalToSuperview() + $0.height.equalTo(36) + } + } else if showRight { + rangeBackgroundView.snp.remakeConstraints { + $0.leading.equalTo(contentView.snp.centerX) + $0.trailing.equalToSuperview() + $0.centerY.equalToSuperview() + $0.height.equalTo(36) + } + } + } else { + rangeBackgroundView.isHidden = true + } + } +} diff --git a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift new file mode 100644 index 0000000..7b6aafd --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift @@ -0,0 +1,445 @@ +// +// CalendarView.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import DSKit +import SnapKit +import Then +import UIKit + +protocol CalendarViewDelegate: AnyObject { + func calendarView(_ view: CalendarView, didSelectRange startDate: Date, endDate: Date) + func calendarViewDidClearSelection(_ view: CalendarView) +} + +final class CalendarView: UIView { + + // MARK: - Properties + + weak var delegate: CalendarViewDelegate? + + private var currentDate = Date() + private var selectedStartDate: Date? + private var selectedEndDate: Date? + + private let calendar = Calendar.current + private var days: [Int?] = [] + + // MARK: - UI Components + + private let monthYearButton = UIButton(type: .system).then { + $0.setTitleColor(UIColor.NDGL.Text.primary, for: .normal) + $0.titleLabel?.font = DSKitFontFamily.Pretendard.semiBold.font(size: 18) + } + + private let previousMonthButton = UIButton(type: .system).then { + $0.setImage(DSKitAsset.Assets.icChevronLeft3.image, for: .normal) + $0.tintColor = UIColor.NDGL.Icon.primary + } + + private let nextMonthButton = UIButton(type: .system).then { + $0.setImage(DSKitAsset.Assets.icChevronRight3.image, for: .normal) + $0.tintColor = UIColor.NDGL.Icon.primary + } + + private let weekdayStackView = UIStackView().then { + $0.axis = .horizontal + $0.distribution = .fillEqually + } + + private lazy var collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.minimumLineSpacing = 0 + layout.minimumInteritemSpacing = 0 + + let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) + cv.backgroundColor = .clear + cv.delegate = self + cv.dataSource = self + cv.register(CalendarDayCell.self, forCellWithReuseIdentifier: CalendarDayCell.identifier) + return cv + }() + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + setupActions() + setupWeekdayLabels() + updateCalendar() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + backgroundColor = UIColor.NDGL.Bg.primary + + [monthYearButton, previousMonthButton, nextMonthButton, weekdayStackView, collectionView].forEach { + addSubview($0) + } + } + + private func setupConstraints() { + monthYearButton.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.equalToSuperview().offset(16) + $0.height.equalTo(28) + } + + nextMonthButton.snp.makeConstraints { + $0.centerY.equalTo(monthYearButton) + $0.trailing.equalToSuperview().offset(-16) + $0.size.equalTo(24) + } + + previousMonthButton.snp.makeConstraints { + $0.centerY.equalTo(monthYearButton) + $0.trailing.equalTo(nextMonthButton.snp.leading).offset(-8) + $0.size.equalTo(24) + } + + weekdayStackView.snp.makeConstraints { + $0.top.equalTo(monthYearButton.snp.bottom).offset(24) + $0.leading.trailing.equalToSuperview().inset(16) + $0.height.equalTo(20) + } + + collectionView.snp.makeConstraints { + $0.top.equalTo(weekdayStackView.snp.bottom).offset(8) + $0.leading.trailing.equalToSuperview().inset(16) + $0.bottom.equalToSuperview() + } + } + + private func setupActions() { + previousMonthButton.addTarget(self, action: #selector(previousMonthTapped), for: .touchUpInside) + nextMonthButton.addTarget(self, action: #selector(nextMonthTapped), for: .touchUpInside) + monthYearButton.addTarget(self, action: #selector(monthYearButtonTapped), for: .touchUpInside) + } + + private func setupWeekdayLabels() { + let weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + + for (index, weekday) in weekdays.enumerated() { + let label = UILabel() + let color = index == 0 ? DSKitAsset.Colors.red500.color : UIColor.NDGL.Text.secondary + label.setText(.bodySR, text: weekday, color: color, alignment: .center) + weekdayStackView.addArrangedSubview(label) + } + } + + // MARK: - Actions + + @objc private func previousMonthTapped() { + currentDate = calendar.date(byAdding: .month, value: -1, to: currentDate) ?? currentDate + updateCalendar() + } + + @objc private func nextMonthTapped() { + currentDate = calendar.date(byAdding: .month, value: 1, to: currentDate) ?? currentDate + updateCalendar() + } + + @objc private func monthYearButtonTapped() { + showMonthYearPicker() + } + + private func showMonthYearPicker() { + guard let parentVC = findViewController() else { return } + + let alertController = UIAlertController(title: "년월 선택\n\n\n\n\n\n\n", message: nil, preferredStyle: .actionSheet) + + let pickerView = UIPickerView() + pickerView.delegate = self + pickerView.dataSource = self + + // 현재 선택된 년월로 초기화 + let currentYear = calendar.component(.year, from: currentDate) + let currentMonth = calendar.component(.month, from: currentDate) + let minYear = calendar.component(.year, from: Date()) + + pickerView.selectRow(currentYear - minYear, inComponent: 0, animated: false) + pickerView.selectRow(currentMonth - 1, inComponent: 1, animated: false) + + alertController.view.addSubview(pickerView) + pickerView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + pickerView.centerXAnchor.constraint(equalTo: alertController.view.centerXAnchor), + pickerView.topAnchor.constraint(equalTo: alertController.view.topAnchor, constant: 30), + pickerView.widthAnchor.constraint(equalToConstant: 250), + pickerView.heightAnchor.constraint(equalToConstant: 150) + ]) + + let selectAction = UIAlertAction(title: "선택", style: .default) { [weak self] _ in + guard let self = self else { return } + let selectedYear = minYear + pickerView.selectedRow(inComponent: 0) + let selectedMonth = pickerView.selectedRow(inComponent: 1) + 1 + + var components = DateComponents() + components.year = selectedYear + components.month = selectedMonth + components.day = 1 + + if let date = self.calendar.date(from: components) { + self.currentDate = date + self.updateCalendar() + } + } + alertController.addAction(selectAction) + alertController.addAction(UIAlertAction(title: "취소", style: .cancel)) + + parentVC.present(alertController, animated: true) + } + + private func findViewController() -> UIViewController? { + var responder: UIResponder? = self + while let nextResponder = responder?.next { + if let viewController = nextResponder as? UIViewController { + return viewController + } + responder = nextResponder + } + return nil + } + + // MARK: - Calendar Logic + + private func updateCalendar() { + updateMonthYearLabel() + generateDays() + collectionView.reloadData() + } + + private func updateMonthYearLabel() { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy년 M월" + let title = formatter.string(from: currentDate) + " ▾" + monthYearButton.setTitle(title, for: .normal) + } + + private func generateDays() { + days.removeAll() + + let components = calendar.dateComponents([.year, .month], from: currentDate) + guard let firstDayOfMonth = calendar.date(from: components), + let range = calendar.range(of: .day, in: .month, for: currentDate) else { + return + } + + let firstWeekday = calendar.component(.weekday, from: firstDayOfMonth) + + // Add empty cells for days before the first day of the month + for _ in 1.. Date? { + var components = calendar.dateComponents([.year, .month], from: currentDate) + components.day = day + return calendar.date(from: components) + } + + private func isPastDate(_ date: Date) -> Bool { + let today = calendar.startOfDay(for: Date()) + return date < today + } + + private func isSameDay(_ date1: Date, _ date2: Date) -> Bool { + return calendar.isDate(date1, inSameDayAs: date2) + } + + private func selectionStateFor(date: Date) -> CalendarDayCell.SelectionState { + if let start = selectedStartDate, isSameDay(date, start) { + return .startDate + } + if let end = selectedEndDate, isSameDay(date, end) { + return .endDate + } + if let start = selectedStartDate, let end = selectedEndDate { + if date > start && date < end { + return .inRange + } + } + return .none + } + + private func isInRange(_ date: Date) -> Bool { + guard let start = selectedStartDate, let end = selectedEndDate else { + return false + } + return date >= start && date <= end + } + + // MARK: - Public Methods + + func getSelectedRange() -> (start: Date, end: Date)? { + guard let start = selectedStartDate, let end = selectedEndDate else { + return nil + } + return (start, end) + } +} + +// MARK: - UICollectionViewDataSource + +extension CalendarView: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return days.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: CalendarDayCell.identifier, + for: indexPath + ) as? CalendarDayCell else { + return UICollectionViewCell() + } + + let day = days[indexPath.item] + let isSunday = indexPath.item % 7 == 0 + + var selectionState: CalendarDayCell.SelectionState = .none + var isPast = false + + if let day = day, let date = dateFor(day: day) { + isPast = isPastDate(date) + selectionState = selectionStateFor(date: date) + } + + cell.configure( + day: day, + isCurrentMonth: day != nil, + isSunday: isSunday, + isPastDate: isPast, + selectionState: selectionState + ) + + // Configure range background AFTER configure (to not be reset) + if let day = day, let date = dateFor(day: day) { + let isStart = selectedStartDate != nil && isSameDay(date, selectedStartDate!) + let isEnd = selectedEndDate != nil && isSameDay(date, selectedEndDate!) + let inRange = isInRange(date) + + if isStart && selectedEndDate != nil { + cell.configureRangeBackground(showLeft: false, showRight: true) + } else if isEnd && selectedStartDate != nil { + cell.configureRangeBackground(showLeft: true, showRight: false) + } else if inRange && !isStart && !isEnd { + cell.configureRangeBackground(showLeft: true, showRight: true) + } else { + cell.configureRangeBackground(showLeft: false, showRight: false) + } + } + + return cell + } +} + +// MARK: - UICollectionViewDelegate + +extension CalendarView: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let day = days[indexPath.item], + let date = dateFor(day: day), + !isPastDate(date) else { + return + } + + if selectedStartDate == nil { + // First selection - set as start date + selectedStartDate = date + selectedEndDate = nil + } else if selectedEndDate == nil { + // Second selection + if date < selectedStartDate! { + // If selected date is before start, swap + selectedEndDate = selectedStartDate + selectedStartDate = date + } else if isSameDay(date, selectedStartDate!) { + // Tapped same date - clear selection + selectedStartDate = nil + selectedEndDate = nil + delegate?.calendarViewDidClearSelection(self) + } else { + selectedEndDate = date + } + } else { + // Both dates selected - start new selection + selectedStartDate = date + selectedEndDate = nil + } + + collectionView.reloadData() + + // Notify delegate if range is complete + if let start = selectedStartDate, let end = selectedEndDate { + delegate?.calendarView(self, didSelectRange: start, endDate: end) + } else { + delegate?.calendarViewDidClearSelection(self) + } + } +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension CalendarView: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let width = collectionView.bounds.width / 7 + return CGSize(width: width, height: 44) + } +} + +// MARK: - UIPickerViewDataSource + +extension CalendarView: UIPickerViewDataSource { + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 2 // Year, Month + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + if component == 0 { + // Year: current year ~ 2099 + let currentYear = calendar.component(.year, from: Date()) + return 2099 - currentYear + 1 + } else { + // Month: 1 ~ 12 + return 12 + } + } +} + +// MARK: - UIPickerViewDelegate + +extension CalendarView: UIPickerViewDelegate { + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + if component == 0 { + let currentYear = calendar.component(.year, from: Date()) + return "\(currentYear + row)년" + } else { + return "\(row + 1)월" + } + } +} diff --git a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift index 128783f..fb4fb17 100644 --- a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift +++ b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift @@ -125,9 +125,9 @@ extension HomeInteractor: HomePresentableListener { guard section < categories.count else { return } let category = categories[section] guard let trips = tripsByCategory[category], index < trips.count else { return } - let trip = trips[index] - // TODO: 상세 화면으로 이동 - print("Selected popular trip: \(trip.title)") + // TODO: 실제 API 연동 시 trip.id 사용 + // 현재는 테스트를 위해 항상 id 1로 이동 + router?.routeToFollowDetail(with: 1) } func didSelectRecommendation(at index: Int) { diff --git a/Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift b/Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift new file mode 100644 index 0000000..d95422a --- /dev/null +++ b/Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift @@ -0,0 +1,90 @@ +// +// BottomPlacedButton.swift +// DSKit +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import SnapKit +import UIKit + +public final class BottomPlacedButton: UIButton { + + // MARK: - Properties + + private let iconImageView = UIImageView() + private let titleTextLabel = UILabel() + private let contentStackView = UIStackView() + + // MARK: - Initialization + + public init(title: String, icon: DSKitImages? = nil) { + super.init(frame: .zero) + setupUI() + setupConstraints() + configure(title: title, icon: icon) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + backgroundColor = UIColor.init(hexCode: "#111111") + layer.cornerRadius = 8 + + contentStackView.axis = .horizontal + contentStackView.spacing = 8 + contentStackView.alignment = .center + contentStackView.isUserInteractionEnabled = false + + iconImageView.contentMode = .scaleAspectFit + iconImageView.tintColor = UIColor.NDGL.Text.Interactive.inverse + iconImageView.isUserInteractionEnabled = false + + titleTextLabel.isUserInteractionEnabled = false + + addSubview(contentStackView) + contentStackView.addArrangedSubview(iconImageView) + contentStackView.addArrangedSubview(titleTextLabel) + } + + private func setupConstraints() { + contentStackView.snp.makeConstraints { + $0.center.equalToSuperview() + } + + iconImageView.snp.makeConstraints { + $0.size.equalTo(24) + } + } + + // MARK: - Configuration + + public func configure(title: String, icon: DSKitImages? = nil) { + titleTextLabel.setText( + .subTitleMSB, + text: title, + color: UIColor.NDGL.Text.Interactive.inverse + ) + + if let icon = icon { + iconImageView.image = icon.image + iconImageView.isHidden = false + } else { + iconImageView.isHidden = true + } + } + + // MARK: - Touch Handling + + public override var isHighlighted: Bool { + didSet { + alpha = isHighlighted ? 0.7 : 1.0 + } + } +} From 06d398f0bb73ecf24dd5e36752269108da9dd18b Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sat, 24 Jan 2026 16:42:35 +0900 Subject: [PATCH 08/20] =?UTF-8?q?design:=20#10=20-=20navigationbar=20backb?= =?UTF-8?q?utton=20=EB=8F=99=EC=9E=91=20native=20conponent=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/FollowDetailRouter.swift | 7 ++- .../Sources/FollowDetailViewController.swift | 5 +- .../TripCalendarViewController.swift | 52 +++++-------------- 3 files changed, 22 insertions(+), 42 deletions(-) diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift b/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift index 3aafbff..fe8a3ee 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift @@ -60,7 +60,12 @@ final class FollowDetailRouter: ViewableRouter Date: Sat, 24 Jan 2026 17:25:09 +0900 Subject: [PATCH 09/20] =?UTF-8?q?design:=20#10=20-=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=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 --- .../Sources/Model/Travel/PopularTrip.swift | 2 +- .../Sources/FollowDetailInteractor.swift | 4 +- .../Sources/FollowDetailViewController.swift | 19 ++ .../Views/PlaceDetailBottomSheetView.swift | 247 +++++++++++++++++ .../Component/BottomSheetViewController.swift | 258 ++++++++++++++++++ 5 files changed, 527 insertions(+), 3 deletions(-) create mode 100644 Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift create mode 100644 Projects/Modules/DSKit/Sources/Component/BottomSheetViewController.swift diff --git a/Projects/Domain/Sources/Model/Travel/PopularTrip.swift b/Projects/Domain/Sources/Model/Travel/PopularTrip.swift index 1f69c7d..b8f370f 100644 --- a/Projects/Domain/Sources/Model/Travel/PopularTrip.swift +++ b/Projects/Domain/Sources/Model/Travel/PopularTrip.swift @@ -39,7 +39,7 @@ public struct PopularTrip: Hashable { public enum TripCategory: String, CaseIterable, Hashable { case all = "전체" - case vietnam = "베니트남" + case vietnam = "베트남" case europe = "유럽" case hongkong = "홍콩/마카오" case singapore = "싱가포르" diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift index 1bdeb65..6115f2d 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -27,6 +27,7 @@ protocol FollowDetailPresentable: Presentable { func updateTravelDetail(_ detail: TravelDetail) func updatePlaces(_ places: [TravelPlace]) func updateBudget(_ budget: Int) + func showPlaceDetail(_ place: TravelPlace) } // MARK: - FollowDetailPresentableListener @@ -144,8 +145,7 @@ extension FollowDetailInteractor: FollowDetailPresentableListener { } func didSelectPlace(_ place: TravelPlace) { - // TODO: 장소 상세 화면으로 이동 - print("Selected place: \(place.place.name)") + presenter.showPlaceDetail(place) } } diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift index 68dc32d..6403437 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift @@ -276,6 +276,25 @@ extension FollowDetailViewController { func updateBudget(_ budget: Int) { budgetView.configure(budget: budget) } + + func showPlaceDetail(_ place: TravelPlace) { + let contentView = PlaceDetailBottomSheetView() + contentView.configure(with: place) + + let configuration = BottomSheetConfiguration( + showDim: true, + dimColor: UIColor.black.withAlphaComponent(0.7), + showIndicator: true + ) + + let bottomSheet = BottomSheetViewController( + contentView: contentView, + contentHeight: 280, + configuration: configuration + ) + + present(bottomSheet, animated: false) + } } // MARK: - FollowDetailViewControllable diff --git a/Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift b/Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift new file mode 100644 index 0000000..fbd30f0 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift @@ -0,0 +1,247 @@ +// +// PlaceDetailBottomSheetView.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import Domain +import DSKit +import SnapKit +import Then +import UIKit + +final class PlaceDetailBottomSheetView: UIView { + + // MARK: - UI Components + + // 상단 타이틀 영역 + private let titleLabel = UILabel().then { + $0.numberOfLines = 1 + } + + private let chevronButton = UIButton(type: .system).then { + $0.setImage(DSKitAsset.Assets.icChevronRight3.image, for: .normal) + $0.tintColor = UIColor.NDGL.Icon.primary + } + + // 카테고리 + 체류시간 + private let categoryInfoStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 4 + $0.alignment = .center + } + + private let categoryLabel = UILabel() + + private let dotLabel = UILabel() + + private let durationLabel = UILabel() + + private let categoryChevronImageView = UIImageView().then { + $0.image = DSKitAsset.Assets.icChevronRight3.image + $0.tintColor = UIColor.NDGL.Icon.disabled + $0.contentMode = .scaleAspectFit + } + + // 영업시간 + private let openingHoursLabel = UILabel() + + // 시간 추가 + private let timeStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 8 + $0.alignment = .center + } + + private let timeIconImageView = UIImageView().then { + $0.image = DSKitAsset.Assets.icClock1.image + $0.tintColor = UIColor.NDGL.Icon.secondary + $0.contentMode = .scaleAspectFit + } + + private let timeLabel = UILabel() + + // 비용 추가 + private let costStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 8 + $0.alignment = .center + } + + private let costIconImageView = UIImageView().then { + $0.image = DSKitAsset.Assets.icCard1.image + $0.tintColor = UIColor.NDGL.Icon.secondary + $0.contentMode = .scaleAspectFit + } + + private let costLabel = UILabel() + + // 길찾기 버튼 + private let findRouteButton = UIButton(type: .system).then { + $0.backgroundColor = UIColor.NDGL.Bg.primary + $0.layer.cornerRadius = 8 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor.NDGL.Border.secondary.cgColor + } + + private let findRouteStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 4 + $0.alignment = .center + $0.isUserInteractionEnabled = false + } + + private let findRouteLabel = UILabel() + + private let findRouteIconImageView = UIImageView().then { + $0.image = DSKitAsset.Assets.icMap1.image + $0.tintColor = UIColor.NDGL.Icon.primary + $0.contentMode = .scaleAspectFit + } + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + backgroundColor = .white + + [titleLabel, chevronButton, categoryInfoStackView, openingHoursLabel, + timeStackView, costStackView, findRouteButton].forEach { + addSubview($0) + } + + // 카테고리 스택뷰 + [categoryLabel, dotLabel, durationLabel, categoryChevronImageView].forEach { + categoryInfoStackView.addArrangedSubview($0) + } + + // 시간 스택뷰 + [timeIconImageView, timeLabel].forEach { + timeStackView.addArrangedSubview($0) + } + + // 비용 스택뷰 + [costIconImageView, costLabel].forEach { + costStackView.addArrangedSubview($0) + } + + // 길찾기 버튼 내부 + findRouteButton.addSubview(findRouteStackView) + [findRouteLabel, findRouteIconImageView].forEach { + findRouteStackView.addArrangedSubview($0) + } + + // 기본 텍스트 설정 + dotLabel.setText(.bodySR, text: "•", color: UIColor.NDGL.Text.tertiary) + findRouteLabel.setText(.bodyMSB, text: "길찾기", color: UIColor.NDGL.Text.primary) + } + + private func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(24) + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalTo(chevronButton.snp.leading).offset(-8) + } + + chevronButton.snp.makeConstraints { + $0.centerY.equalTo(titleLabel) + $0.trailing.equalToSuperview().offset(-24) + $0.size.equalTo(24) + } + + categoryInfoStackView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(8) + $0.leading.equalToSuperview().offset(24) + } + + categoryChevronImageView.snp.makeConstraints { + $0.size.equalTo(16) + } + + openingHoursLabel.snp.makeConstraints { + $0.top.equalTo(categoryInfoStackView.snp.bottom).offset(4) + $0.leading.equalToSuperview().offset(24) + } + + timeIconImageView.snp.makeConstraints { + $0.size.equalTo(20) + } + + timeStackView.snp.makeConstraints { + $0.top.equalTo(openingHoursLabel.snp.bottom).offset(16) + $0.leading.equalToSuperview().offset(24) + } + + costIconImageView.snp.makeConstraints { + $0.size.equalTo(20) + } + + costStackView.snp.makeConstraints { + $0.top.equalTo(timeStackView.snp.bottom).offset(8) + $0.leading.equalToSuperview().offset(24) + } + + findRouteButton.snp.makeConstraints { + $0.top.equalTo(costStackView.snp.bottom).offset(24) + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalToSuperview().offset(-24) + $0.height.equalTo(48) + $0.bottom.equalToSuperview().offset(-16) + } + + findRouteStackView.snp.makeConstraints { + $0.center.equalToSuperview() + } + + findRouteIconImageView.snp.makeConstraints { + $0.size.equalTo(20) + } + } + + // MARK: - Configuration + + func configure(with place: TravelPlace) { + // 타이틀 + titleLabel.setText(.subTitleLSB, text: place.place.name, color: UIColor.NDGL.Text.primary) + + // 카테고리 (기본값: 관광명소) + categoryLabel.setText(.bodySR, text: "🏔 관광명소", color: DSKitAsset.Colors.primary500.color) + + // 체류 예상 시간 + let hours = place.estimatedDuration / 60 + let minutes = place.estimatedDuration % 60 + let durationText: String + if hours > 0 && minutes > 0 { + durationText = "\(hours)시간 \(minutes)분 체류 예상" + } else if hours > 0 { + durationText = "\(hours)시간 체류 예상" + } else { + durationText = "\(minutes)분 체류 예상" + } + durationLabel.setText(.bodySR, text: durationText, color: UIColor.NDGL.Text.tertiary) + + // 영업시간 + let openingHours = place.place.regularOpeningHours ?? "-" + openingHoursLabel.setText(.bodySR, text: "영업시간 \(openingHours)", color: UIColor.NDGL.Text.secondary) + + // 시간 추가 (기본값) + timeLabel.setText(.bodySR, text: "시간 추가", color: UIColor.NDGL.Text.tertiary) + + // 비용 추가 (기본값) + costLabel.setText(.bodySR, text: "비용 추가", color: UIColor.NDGL.Text.tertiary) + } +} diff --git a/Projects/Modules/DSKit/Sources/Component/BottomSheetViewController.swift b/Projects/Modules/DSKit/Sources/Component/BottomSheetViewController.swift new file mode 100644 index 0000000..9aa566b --- /dev/null +++ b/Projects/Modules/DSKit/Sources/Component/BottomSheetViewController.swift @@ -0,0 +1,258 @@ +// +// BottomSheetViewController.swift +// DSKit +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import SnapKit +import UIKit + +// MARK: - BottomSheetConfiguration + +public struct BottomSheetConfiguration { + public let showDim: Bool + public let dimColor: UIColor + public let showIndicator: Bool + public let cornerRadius: CGFloat + public let dismissOnTapDim: Bool + + public init( + showDim: Bool = true, + dimColor: UIColor = UIColor.black.withAlphaComponent(0.7), + showIndicator: Bool = true, + cornerRadius: CGFloat = 20, + dismissOnTapDim: Bool = true + ) { + self.showDim = showDim + self.dimColor = dimColor + self.showIndicator = showIndicator + self.cornerRadius = cornerRadius + self.dismissOnTapDim = dismissOnTapDim + } + + public static let `default` = BottomSheetConfiguration() +} + +// MARK: - BottomSheetViewController + +open class BottomSheetViewController: UIViewController { + + // MARK: - Properties + + private let configuration: BottomSheetConfiguration + private let contentView: UIView + private var contentHeight: CGFloat + + // MARK: - UI Components + + private lazy var dimView: UIView = { + let view = UIView() + view.backgroundColor = configuration.dimColor + view.alpha = 0 + if configuration.dismissOnTapDim { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dimViewTapped)) + view.addGestureRecognizer(tapGesture) + } + return view + }() + + private lazy var containerView: UIView = { + let view = UIView() + view.backgroundColor = .white + view.layer.cornerRadius = configuration.cornerRadius + view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + view.clipsToBounds = true + return view + }() + + private lazy var indicatorView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(red: 0.85, green: 0.85, blue: 0.85, alpha: 1.0) + view.layer.cornerRadius = 2.5 + return view + }() + + // MARK: - Initialization + + public init( + contentView: UIView, + contentHeight: CGFloat, + configuration: BottomSheetConfiguration = .default + ) { + self.contentView = contentView + self.contentHeight = contentHeight + self.configuration = configuration + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + open override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupConstraints() + setupPanGesture() + } + + open override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + showBottomSheet() + } + + // MARK: - Setup + + private func setupUI() { + view.backgroundColor = .clear + + if configuration.showDim { + view.addSubview(dimView) + } + + view.addSubview(containerView) + + if configuration.showIndicator { + containerView.addSubview(indicatorView) + } + + containerView.addSubview(contentView) + } + + private func setupConstraints() { + if configuration.showDim { + dimView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + let indicatorHeight: CGFloat = configuration.showIndicator ? 24 : 0 + let totalHeight = contentHeight + indicatorHeight + view.safeAreaInsets.bottom + + containerView.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview().offset(totalHeight) + $0.height.equalTo(totalHeight) + } + + if configuration.showIndicator { + indicatorView.snp.makeConstraints { + $0.top.equalToSuperview().offset(8) + $0.centerX.equalToSuperview() + $0.width.equalTo(36) + $0.height.equalTo(5) + } + + contentView.snp.makeConstraints { + $0.top.equalTo(indicatorView.snp.bottom).offset(8) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(containerView.safeAreaLayoutGuide) + } + } else { + contentView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(containerView.safeAreaLayoutGuide) + } + } + } + + private func setupPanGesture() { + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) + containerView.addGestureRecognizer(panGesture) + } + + // MARK: - Animation + + private func showBottomSheet() { + let indicatorHeight: CGFloat = configuration.showIndicator ? 24 : 0 + let totalHeight = contentHeight + indicatorHeight + view.safeAreaInsets.bottom + + containerView.snp.updateConstraints { + $0.bottom.equalToSuperview().offset(0) + } + + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { [weak self] in + self?.dimView.alpha = 1 + self?.view.layoutIfNeeded() + } + } + + private func hideBottomSheet(completion: (() -> Void)? = nil) { + let indicatorHeight: CGFloat = configuration.showIndicator ? 24 : 0 + let totalHeight = contentHeight + indicatorHeight + view.safeAreaInsets.bottom + + containerView.snp.updateConstraints { + $0.bottom.equalToSuperview().offset(totalHeight) + } + + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseIn) { [weak self] in + self?.dimView.alpha = 0 + self?.view.layoutIfNeeded() + } completion: { _ in + completion?() + } + } + + // MARK: - Actions + + @objc private func dimViewTapped() { + dismissBottomSheet() + } + + @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: view) + let velocity = gesture.velocity(in: view) + + switch gesture.state { + case .changed: + if translation.y > 0 { + containerView.transform = CGAffineTransform(translationX: 0, y: translation.y) + let progress = translation.y / contentHeight + dimView.alpha = max(0, 1 - progress) + } + + case .ended: + if translation.y > contentHeight * 0.3 || velocity.y > 500 { + dismissBottomSheet() + } else { + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { [weak self] in + self?.containerView.transform = .identity + self?.dimView.alpha = 1 + } + } + + default: + break + } + } + + // MARK: - Public Methods + + public func dismissBottomSheet() { + hideBottomSheet { [weak self] in + self?.dismiss(animated: false) + } + } + + public func updateContentHeight(_ height: CGFloat) { + self.contentHeight = height + + let indicatorHeight: CGFloat = configuration.showIndicator ? 24 : 0 + let totalHeight = height + indicatorHeight + view.safeAreaInsets.bottom + + containerView.snp.updateConstraints { + $0.height.equalTo(totalHeight) + } + + UIView.animate(withDuration: 0.3) { [weak self] in + self?.view.layoutIfNeeded() + } + } +} From 6a978af96957badc8641c771519cececa4f0cbf5 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sat, 24 Jan 2026 18:06:47 +0900 Subject: [PATCH 10/20] =?UTF-8?q?fix:=20#10=20-=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Components/NDGLTabItem.swift | 12 ++--- .../Sources/TabBarViewController.swift | 53 +++++++++++++++++-- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/Projects/Features/TabBarFeature/Sources/Components/NDGLTabItem.swift b/Projects/Features/TabBarFeature/Sources/Components/NDGLTabItem.swift index e38e03b..d5eb918 100644 --- a/Projects/Features/TabBarFeature/Sources/Components/NDGLTabItem.swift +++ b/Projects/Features/TabBarFeature/Sources/Components/NDGLTabItem.swift @@ -64,23 +64,23 @@ private extension NDGLTabItem { func setLayout() { self.snp.makeConstraints { - $0.size.equalTo(56.adjusted) + $0.width.equalTo(56.adjusted).priority(.high) } - + containerStackView.snp.makeConstraints { $0.center.equalToSuperview() } - + iconView.snp.makeConstraints { $0.size.equalTo(24.adjusted) } } - + func updateState(animation: Bool) { let duration = animation ? 0.4 : 0.0 - + self.snp.updateConstraints { - $0.width.equalTo(isTabSelected ? 184.adjusted : 56.adjusted) + $0.width.equalTo(isTabSelected ? 184.adjusted : 56.adjusted).priority(.high) } UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5) { diff --git a/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift b/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift index a87655d..f7f388d 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift @@ -52,11 +52,19 @@ public final class TabBarViewController: UITabBarController, TabBarPresentable, guard let homeVC = viewControllers.first?.uiviewController else { return } let infoDummy = UIViewController().then { $0.view.backgroundColor = .yellow } let myTripDummy = UIViewController().then { $0.view.backgroundColor = .green } - - let finalControllers = [infoDummy, homeVC, myTripDummy] - + + // Wrap each VC in a NavigationController for push navigation + let infoNav = UINavigationController(rootViewController: infoDummy) + let homeNav = UINavigationController(rootViewController: homeVC) + let myTripNav = UINavigationController(rootViewController: myTripDummy) + + // Set delegate to handle tab bar visibility + [infoNav, homeNav, myTripNav].forEach { $0.delegate = self } + + let finalControllers = [infoNav, homeNav, myTripNav] + super.setViewControllers(finalControllers, animated: false) - + setupTabItems() } } @@ -175,3 +183,40 @@ private extension TabBarViewController { } } } + +// MARK: - UINavigationControllerDelegate + +extension TabBarViewController: UINavigationControllerDelegate { + public func navigationController( + _ navigationController: UINavigationController, + willShow viewController: UIViewController, + animated: Bool + ) { + // Hide custom tab bar when pushing (more than 1 VC in stack) + let shouldHideTabBar = navigationController.viewControllers.count > 1 + + guard animated else { + customTabBarContainer.isHidden = shouldHideTabBar + customTabBarContainer.alpha = shouldHideTabBar ? 0 : 1 + return + } + + if shouldHideTabBar { + // Hiding: animate alpha to 0, then hide + UIView.animate(withDuration: 0.3) { + self.customTabBarContainer.alpha = 0 + } completion: { _ in + self.customTabBarContainer.isHidden = true + } + } else { + // Showing: unhide first with alpha 0, then animate to 1 + customTabBarContainer.isHidden = false + customTabBarContainer.alpha = 0 + customTabBarContainer.layoutIfNeeded() + + UIView.animate(withDuration: 0.3) { + self.customTabBarContainer.alpha = 1 + } + } + } +} From 4f7722f58bac2570af96a35d861832cbd8f0828a Mon Sep 17 00:00:00 2001 From: kimnahun Date: Sat, 24 Jan 2026 18:29:46 +0900 Subject: [PATCH 11/20] =?UTF-8?q?feat:=20#10=20-=20=EC=97=AC=ED=96=89?= =?UTF-8?q?=EC=9D=84=20=EB=8B=B4=EC=9C=BC=EB=A9=B4=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=9D=B4=EB=8F=99=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dependency+Project.swift | 7 + .../Sources/FollowDetailInteractor.swift | 10 +- .../HomeFeature/Sources/HomeInteractor.swift | 9 +- Projects/Features/TabBarFeature/Project.swift | 3 +- .../TabBarFeature/Sources/TabBarBuilder.swift | 9 +- .../Sources/TabBarInteractor.swift | 15 ++ .../TabBarFeature/Sources/TabBarRouter.swift | 20 +- .../Sources/TabBarViewController.swift | 24 ++- Projects/Features/TravelFeature/Project.swift | 25 +++ .../Sources/Model/UpcomingTrip.swift | 52 +++++ .../TravelFeature/Sources/TravelBuilder.swift | 49 +++++ .../Sources/TravelInteractor.swift | 126 ++++++++++++ .../TravelFeature/Sources/TravelRouter.swift | 39 ++++ .../Sources/TravelViewController.swift | 191 ++++++++++++++++++ .../Sources/Views/UpcomingTripCell.swift | 128 ++++++++++++ 15 files changed, 689 insertions(+), 18 deletions(-) create mode 100644 Projects/Features/TravelFeature/Project.swift create mode 100644 Projects/Features/TravelFeature/Sources/Model/UpcomingTrip.swift create mode 100644 Projects/Features/TravelFeature/Sources/TravelBuilder.swift create mode 100644 Projects/Features/TravelFeature/Sources/TravelInteractor.swift create mode 100644 Projects/Features/TravelFeature/Sources/TravelRouter.swift create mode 100644 Projects/Features/TravelFeature/Sources/TravelViewController.swift create mode 100644 Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift index 86baa9e..f12a831 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift @@ -12,6 +12,7 @@ public extension TargetDependency { public struct Home {} public struct TabBar {} public struct Follow {} + public struct Travel {} } struct Modules {} @@ -56,3 +57,9 @@ public extension TargetDependency.Features.Follow { static let feature = TargetDependency.Features.project(name: "Feature", group: group) } + +public extension TargetDependency.Features.Travel { + static let group = "Travel" + + static let feature = TargetDependency.Features.project(name: "Feature", group: group) +} diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift index 6115f2d..5f7fefe 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -15,6 +15,7 @@ import RxSwift public protocol FollowDetailListener: AnyObject { func followDetailDidTapClose() + func followDetailDidAddTrip(title: String, startDate: Date, endDate: Date) } // MARK: - FollowDetailPresentable @@ -155,10 +156,11 @@ extension FollowDetailInteractor: TripCalendarListener { func tripCalendarDidSelectRange(startDate: Date, endDate: Date) { router?.detachTripCalendar() - // TODO: 실제 여행 저장 API 호출 - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - print("Trip added: \(formatter.string(from: startDate)) ~ \(formatter.string(from: endDate))") + // 여행 제목 (city + "여행") + let tripTitle = "\(travelDetail?.city ?? "새로운") 여행" + + // Home으로 돌아가면서 Travel 탭으로 이동하도록 알림 + listener?.followDetailDidAddTrip(title: tripTitle, startDate: startDate, endDate: endDate) } func tripCalendarDidCancel() { diff --git a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift index fb4fb17..2918b70 100644 --- a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift +++ b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift @@ -8,13 +8,14 @@ import Domain import FollowFeature +import Foundation import RIBs import RxSwift // MARK: - HomeListener public protocol HomeListener: AnyObject { - // 부모 RIB에 전달할 이벤트 정의 + func homeDidAddTrip(title: String, startDate: Date, endDate: Date) } // MARK: - HomePresentable @@ -158,4 +159,10 @@ extension HomeInteractor: FollowDetailListener { func followDetailDidTapClose() { router?.detachFollowDetail() } + + func followDetailDidAddTrip(title: String, startDate: Date, endDate: Date) { + router?.detachFollowDetail() + // TabBar에 알려서 Travel 탭으로 이동 + listener?.homeDidAddTrip(title: title, startDate: startDate, endDate: endDate) + } } diff --git a/Projects/Features/TabBarFeature/Project.swift b/Projects/Features/TabBarFeature/Project.swift index ba5c6e6..7f7d9af 100644 --- a/Projects/Features/TabBarFeature/Project.swift +++ b/Projects/Features/TabBarFeature/Project.swift @@ -15,7 +15,8 @@ let project = Project.makeModule( .makeFrameworkTarget( name: "TabBarFeature", dependencies: [ - .Features.Home.feature + .Features.Home.feature, + .Features.Travel.feature ], scripts: [.swiftLint], isStatic: true, diff --git a/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift b/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift index f0e47ed..1cda6b3 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift @@ -9,6 +9,7 @@ import RIBs import HomeFeature +import TravelFeature // MARK: - TabBarDependency @@ -18,8 +19,8 @@ public protocol TabBarDependency: Dependency { // MARK: - TabBarComponent -final class TabBarComponent: Component, HomeDependency { - // Home RIB에 전달할 의존성 +final class TabBarComponent: Component, HomeDependency, TravelDependency { + // Home, Travel RIB에 전달할 의존성 } // MARK: - TabBarBuildable @@ -43,11 +44,13 @@ public final class TabBarBuilder: Builder, TabBarBuildable { interactor.listener = listener let homeBuilder = HomeBuilder(dependency: component) + let travelBuilder = TravelBuilder(dependency: component) let router = TabBarRouter( interactor: interactor, viewController: viewController, - homeBuilder: homeBuilder + homeBuilder: homeBuilder, + travelBuilder: travelBuilder ) return router diff --git a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift index dd481cb..e67fe37 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift @@ -6,6 +6,8 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import Foundation +import HomeFeature import RIBs import RxSwift @@ -19,6 +21,8 @@ public protocol TabBarListener: AnyObject { protocol TabBarPresentable: Presentable { var listener: TabBarPresentableListener? { get set } + + func switchToTab(at index: Int) } // MARK: - TabBarInteractor @@ -49,3 +53,14 @@ final class TabBarInteractor: PresentableInteractor, TabBarIn extension TabBarInteractor: TabBarPresentableListener { // ViewController에서 Interactor로 전달하는 이벤트 처리 } + +// MARK: - HomeListener + +extension TabBarInteractor: HomeListener { + func homeDidAddTrip(title: String, startDate: Date, endDate: Date) { + // Travel 탭(index 2)으로 이동 + presenter.switchToTab(at: 2) + + // TODO: TravelInteractor에 새 여행 추가 알림 + } +} diff --git a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift index e265071..a0210dd 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift @@ -9,10 +9,11 @@ import RIBs import HomeFeature +import TravelFeature // MARK: - TabBarInteractable -protocol TabBarInteractable: Interactable, HomeListener { +protocol TabBarInteractable: Interactable, HomeListener, TravelListener { var router: TabBarRouting? { get set } var listener: TabBarListener? { get set } } @@ -34,14 +35,18 @@ public protocol TabBarRouting: ViewableRouting { final class TabBarRouter: ViewableRouter, TabBarRouting { private let homeBuilder: HomeBuildable + private let travelBuilder: TravelBuildable private var homeRouter: HomeRouting? + private var travelRouter: TravelRouting? init( interactor: TabBarInteractable, viewController: TabBarViewControllable, - homeBuilder: HomeBuildable + homeBuilder: HomeBuildable, + travelBuilder: TravelBuildable ) { self.homeBuilder = homeBuilder + self.travelBuilder = travelBuilder super.init(interactor: interactor, viewController: viewController) interactor.router = self } @@ -58,7 +63,14 @@ final class TabBarRouter: ViewableRouter= 2, + let homeVC = viewControllers[0].uiviewController as? UIViewController, + let travelVC = viewControllers[1].uiviewController as? UIViewController else { + return + } + let infoDummy = UIViewController().then { $0.view.backgroundColor = .yellow } - let myTripDummy = UIViewController().then { $0.view.backgroundColor = .green } // Wrap each VC in a NavigationController for push navigation let infoNav = UINavigationController(rootViewController: infoDummy) let homeNav = UINavigationController(rootViewController: homeVC) - let myTripNav = UINavigationController(rootViewController: myTripDummy) + let travelNav = UINavigationController(rootViewController: travelVC) // Set delegate to handle tab bar visibility - [infoNav, homeNav, myTripNav].forEach { $0.delegate = self } + [infoNav, homeNav, travelNav].forEach { $0.delegate = self } - let finalControllers = [infoNav, homeNav, myTripNav] + let finalControllers = [infoNav, homeNav, travelNav] super.setViewControllers(finalControllers, animated: false) setupTabItems() } + + func switchToTab(at index: Int) { + guard index < tabItems.count else { return } + + // 탭바 보이게 설정 + customTabBarContainer.isHidden = false + customTabBarContainer.alpha = 1 + + updateSelection(at: index) + } } private extension TabBarViewController { diff --git a/Projects/Features/TravelFeature/Project.swift b/Projects/Features/TravelFeature/Project.swift new file mode 100644 index 0000000..fb738ae --- /dev/null +++ b/Projects/Features/TravelFeature/Project.swift @@ -0,0 +1,25 @@ +// +// Project.swift +// TravelFeature +// +// Created by kimnahun on 2026-01-24. +// + +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: "TravelFeature", + targets: [ + .makeFrameworkTarget( + name: "TravelFeature", + dependencies: [ + .Features.baseFeatureDependency + ], + scripts: [.swiftLint], + isStatic: true, + hasResources: false + ) + ] +) diff --git a/Projects/Features/TravelFeature/Sources/Model/UpcomingTrip.swift b/Projects/Features/TravelFeature/Sources/Model/UpcomingTrip.swift new file mode 100644 index 0000000..d872f01 --- /dev/null +++ b/Projects/Features/TravelFeature/Sources/Model/UpcomingTrip.swift @@ -0,0 +1,52 @@ +// +// UpcomingTrip.swift +// TravelFeature +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public struct UpcomingTrip: Hashable { + public let id: Int + public let title: String + public let thumbnailURL: String? + public let startDate: Date + public let endDate: Date + + public init( + id: Int, + title: String, + thumbnailURL: String?, + startDate: Date, + endDate: Date + ) { + self.id = id + self.title = title + self.thumbnailURL = thumbnailURL + self.startDate = startDate + self.endDate = endDate + } + + // D-day 계산 + public var dDay: Int { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let start = calendar.startOfDay(for: startDate) + let components = calendar.dateComponents([.day], from: today, to: start) + return components.day ?? 0 + } + + // 날짜 범위 문자열 + public var dateRangeString: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "M월 d일" + + let startString = formatter.string(from: startDate) + let endString = formatter.string(from: endDate) + + return "\(startString)~\(endString)" + } +} diff --git a/Projects/Features/TravelFeature/Sources/TravelBuilder.swift b/Projects/Features/TravelFeature/Sources/TravelBuilder.swift new file mode 100644 index 0000000..b6b09e4 --- /dev/null +++ b/Projects/Features/TravelFeature/Sources/TravelBuilder.swift @@ -0,0 +1,49 @@ +// +// TravelBuilder.swift +// TravelFeature +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +// MARK: - TravelDependency + +public protocol TravelDependency: Dependency { + // 부모 RIB로부터 주입받을 의존성 정의 +} + +// MARK: - TravelComponent + +final class TravelComponent: Component { +} + +// MARK: - TravelBuildable + +public protocol TravelBuildable: Buildable { + func build(withListener listener: TravelListener) -> TravelRouting +} + +// MARK: - TravelBuilder + +public final class TravelBuilder: Builder, TravelBuildable { + + public override init(dependency: TravelDependency) { + super.init(dependency: dependency) + } + + public func build(withListener listener: TravelListener) -> TravelRouting { + let component = TravelComponent(dependency: dependency) + let viewController = TravelViewController() + let interactor = TravelInteractor(presenter: viewController) + interactor.listener = listener + + let router = TravelRouter( + interactor: interactor, + viewController: viewController + ) + + return router + } +} diff --git a/Projects/Features/TravelFeature/Sources/TravelInteractor.swift b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift new file mode 100644 index 0000000..3827ba8 --- /dev/null +++ b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift @@ -0,0 +1,126 @@ +// +// TravelInteractor.swift +// TravelFeature +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation +import RIBs +import RxSwift + +// MARK: - TravelListener + +public protocol TravelListener: AnyObject { + // 부모 RIB에 전달할 이벤트 정의 +} + +// MARK: - TravelPresentable + +protocol TravelPresentable: Presentable { + var listener: TravelPresentableListener? { get set } + + func showLoading() + func hideLoading() + func updateTrips(_ trips: [UpcomingTrip]) +} + +// MARK: - TravelPresentableListener + +protocol TravelPresentableListener: AnyObject { + func didTapTrip(_ trip: UpcomingTrip) + func didTapMenuButton() +} + +// MARK: - TravelInteractor + +final class TravelInteractor: PresentableInteractor, TravelInteractable { + + weak var router: TravelRouting? + weak var listener: TravelListener? + + private let disposeBag = DisposeBag() + + // MARK: - Data (Source of Truth) + + private var trips: [UpcomingTrip] = [] + + override init(presenter: TravelPresentable) { + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + loadTrips() + } + + override func willResignActive() { + super.willResignActive() + } + + // MARK: - Private Methods + + private func loadTrips() { + presenter.showLoading() + + // Mock data + let mockTrips = [ + UpcomingTrip( + id: 1, + title: "바르셀로나 여행", + thumbnailURL: "https://picsum.photos/400/300?random=1", + startDate: createDate(month: 2, day: 1), + endDate: createDate(month: 2, day: 12) + ), + UpcomingTrip( + id: 2, + title: "파리 여행", + thumbnailURL: "https://picsum.photos/400/300?random=2", + startDate: createDate(month: 3, day: 1), + endDate: createDate(month: 3, day: 12) + ), + UpcomingTrip( + id: 3, + title: "런던 여행", + thumbnailURL: "https://picsum.photos/400/300?random=3", + startDate: createDate(month: 3, day: 13), + endDate: createDate(month: 3, day: 23) + ) + ] + + self.trips = mockTrips + presenter.hideLoading() + presenter.updateTrips(mockTrips) + } + + private func createDate(month: Int, day: Int) -> Date { + var components = DateComponents() + components.year = Calendar.current.component(.year, from: Date()) + components.month = month + components.day = day + return Calendar.current.date(from: components) ?? Date() + } + + // MARK: - Public Methods + + func addTrip(_ trip: UpcomingTrip) { + trips.insert(trip, at: 0) + presenter.updateTrips(trips) + } +} + +// MARK: - TravelPresentableListener + +extension TravelInteractor: TravelPresentableListener { + func didTapTrip(_ trip: UpcomingTrip) { + // TODO: Navigate to trip detail + print("Tapped trip: \(trip.title)") + } + + func didTapMenuButton() { + // TODO: Show menu + print("Menu button tapped") + } +} diff --git a/Projects/Features/TravelFeature/Sources/TravelRouter.swift b/Projects/Features/TravelFeature/Sources/TravelRouter.swift new file mode 100644 index 0000000..c033252 --- /dev/null +++ b/Projects/Features/TravelFeature/Sources/TravelRouter.swift @@ -0,0 +1,39 @@ +// +// TravelRouter.swift +// TravelFeature +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +// MARK: - TravelInteractable + +protocol TravelInteractable: Interactable { + var router: TravelRouting? { get set } + var listener: TravelListener? { get set } +} + +// MARK: - TravelViewControllable + +public protocol TravelViewControllable: ViewControllable { +} + +// MARK: - TravelRouting + +public protocol TravelRouting: ViewableRouting { +} + +// MARK: - TravelRouter + +final class TravelRouter: ViewableRouter, TravelRouting { + + override init( + interactor: TravelInteractable, + viewController: TravelViewControllable + ) { + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } +} diff --git a/Projects/Features/TravelFeature/Sources/TravelViewController.swift b/Projects/Features/TravelFeature/Sources/TravelViewController.swift new file mode 100644 index 0000000..6de6871 --- /dev/null +++ b/Projects/Features/TravelFeature/Sources/TravelViewController.swift @@ -0,0 +1,191 @@ +// +// TravelViewController.swift +// TravelFeature +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import DSKit +import RIBs +import RxSwift +import SnapKit +import Then +import UIKit + +// MARK: - TravelViewController + +public final class TravelViewController: UIViewController, TravelPresentable, TravelViewControllable { + + // MARK: - Properties + + weak var listener: TravelPresentableListener? + + private let disposeBag = DisposeBag() + + // MARK: - UI Components + + private let titleLabel = UILabel().then { + $0.setText(.subTitleLSB, text: "다가오는 여행", color: .NDGL.Text.primary) + } + + private let menuButton = UIButton(type: .system).then { + $0.setImage(UIImage(systemName: "line.3.horizontal"), for: .normal) + $0.tintColor = UIColor.NDGL.Icon.primary + } + + private let collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .vertical + layout.minimumLineSpacing = 16 + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .clear + collectionView.showsVerticalScrollIndicator = false + collectionView.contentInset.bottom = 100 + return collectionView + }() + + private let emptyStateLabel = UILabel().then { + $0.setText(.bodyMR, text: "아직 등록된 여행이 없어요", color: .NDGL.Text.tertiary) + $0.isHidden = true + } + + private let loadingIndicator = UIActivityIndicatorView(style: .large).then { + $0.hidesWhenStopped = true + } + + // MARK: - Data + + private var trips: [UpcomingTrip] = [] + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupConstraints() + setupCollectionView() + setupActions() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + } + + // MARK: - Setup + + private func setupUI() { + view.backgroundColor = UIColor.NDGL.Bg.primary + + [titleLabel, menuButton, 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) + } + + collectionView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(24) + $0.leading.equalToSuperview().offset(24) + $0.trailing.equalToSuperview().offset(-24) + $0.bottom.equalToSuperview() + } + + emptyStateLabel.snp.makeConstraints { + $0.center.equalToSuperview() + } + + loadingIndicator.snp.makeConstraints { + $0.center.equalToSuperview() + } + } + + private func setupCollectionView() { + collectionView.delegate = self + collectionView.dataSource = self + collectionView.register(UpcomingTripCell.self, forCellWithReuseIdentifier: UpcomingTripCell.identifier) + } + + private func setupActions() { + menuButton.addTarget(self, action: #selector(menuButtonTapped), for: .touchUpInside) + } + + // MARK: - Actions + + @objc private func menuButtonTapped() { + listener?.didTapMenuButton() + } +} + +// MARK: - TravelPresentable + +extension TravelViewController { + func showLoading() { + loadingIndicator.startAnimating() + } + + func hideLoading() { + loadingIndicator.stopAnimating() + } + + func updateTrips(_ trips: [UpcomingTrip]) { + self.trips = trips + emptyStateLabel.isHidden = !trips.isEmpty + collectionView.reloadData() + } +} + +// MARK: - UICollectionViewDataSource + +extension TravelViewController: UICollectionViewDataSource { + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return trips.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: UpcomingTripCell.identifier, + for: indexPath + ) as? UpcomingTripCell else { + return UICollectionViewCell() + } + + cell.configure(with: trips[indexPath.item]) + return cell + } +} + +// MARK: - UICollectionViewDelegate + +extension TravelViewController: UICollectionViewDelegate { + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let trip = trips[indexPath.item] + listener?.didTapTrip(trip) + } +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension TravelViewController: UICollectionViewDelegateFlowLayout { + public func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let width = collectionView.bounds.width + return CGSize(width: width, height: 72) + } +} diff --git a/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift b/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift new file mode 100644 index 0000000..5406309 --- /dev/null +++ b/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift @@ -0,0 +1,128 @@ +// +// UpcomingTripCell.swift +// TravelFeature +// +// Created by kimnahun on 2026-01-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import DSKit +import Kingfisher +import SnapKit +import Then +import UIKit + +final class UpcomingTripCell: UICollectionViewCell { + + static let identifier = "UpcomingTripCell" + + // MARK: - UI Components + + private let thumbnailImageView = UIImageView().then { + $0.contentMode = .scaleAspectFill + $0.backgroundColor = .systemGray5 + $0.layer.cornerRadius = 24 + $0.clipsToBounds = true + } + + private let dDayBadge = UIView().then { + $0.backgroundColor = DSKitAsset.Colors.primary500.color + $0.layer.cornerRadius = 10 + } + + private let dDayLabel = UILabel() + + private let titleLabel = UILabel() + + private let dateLabel = UILabel() + + // D-day + 날짜를 세로로 묶는 스택뷰 + private let dDayStackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 4 + $0.alignment = .leading + } + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + thumbnailImageView.kf.cancelDownloadTask() + thumbnailImageView.image = nil + thumbnailImageView.backgroundColor = .systemGray5 + } + + // MARK: - Setup + + private func setupUI() { + [thumbnailImageView, dDayStackView, titleLabel].forEach { + contentView.addSubview($0) + } + + dDayBadge.addSubview(dDayLabel) + + [dDayBadge, dateLabel].forEach { + dDayStackView.addArrangedSubview($0) + } + } + + private func setupConstraints() { + thumbnailImageView.snp.makeConstraints { + $0.leading.equalToSuperview() + $0.centerY.equalToSuperview() + $0.size.equalTo(48) + } + + dDayStackView.snp.makeConstraints { + $0.leading.equalTo(thumbnailImageView.snp.trailing).offset(12) + $0.centerY.equalToSuperview() + } + + dDayBadge.snp.makeConstraints { + $0.height.equalTo(20) + } + + dDayLabel.snp.makeConstraints { + $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8)) + } + + titleLabel.snp.makeConstraints { + $0.leading.equalTo(dDayStackView.snp.trailing).offset(8) + $0.trailing.equalToSuperview() + $0.centerY.equalTo(dDayBadge) + } + } + + // MARK: - Configuration + + func configure(with trip: UpcomingTrip) { + // D-day + let dDay = trip.dDay + let dDayText = dDay > 0 ? "D-\(dDay)" : (dDay == 0 ? "D-Day" : "D+\(abs(dDay))") + dDayLabel.setText(.bodySSB, text: dDayText, color: .white) + + // Title + titleLabel.setText(.bodyMSB, text: trip.title, color: UIColor.NDGL.Text.primary) + + // Date range + dateLabel.setText(.bodySR, text: trip.dateRangeString, color: UIColor.NDGL.Text.tertiary) + + // Thumbnail + if let urlString = trip.thumbnailURL, let url = URL(string: urlString) { + thumbnailImageView.kf.setImage( + with: url, + options: [.transition(.fade(0.2)), .cacheOriginalImage] + ) + } + } +} From 1838e01c011d6a32cc8f0730d84782052ebd55fd Mon Sep 17 00:00:00 2001 From: kimnahun Date: Thu, 29 Jan 2026 02:23:14 +0900 Subject: [PATCH 12/20] =?UTF-8?q?fix:=20#10=20-=20=ED=83=AD=EB=B0=94=20?= =?UTF-8?q?=EC=9E=94=EC=83=81=EC=9D=B4=20=EB=B3=B4=EC=9D=B4=EB=8D=98=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/FollowDetailViewController.swift | 1 - .../Sources/TabBarViewController.swift | 23 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift index 6403437..95b8b61 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift @@ -68,7 +68,6 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre public init() { super.init(nibName: nil, bundle: nil) - hidesBottomBarWhenPushed = true } required init?(coder: NSCoder) { diff --git a/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift b/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift index 360b6f9..11575c4 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift @@ -41,12 +41,18 @@ public final class TabBarViewController: UITabBarController, TabBarPresentable, override public func viewDidLoad() { super.viewDidLoad() - + setupBaseTabBar() setStyle() setUI() setLayout() } + + override public func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + // 시스템 탭바가 다시 나타나지 않도록 확실하게 숨김 + tabBar.isHidden = true + } public func setViewControllers(_ viewControllers: [ViewControllable]) { guard viewControllers.count >= 2, @@ -75,11 +81,20 @@ public final class TabBarViewController: UITabBarController, TabBarPresentable, func switchToTab(at index: Int) { guard index < tabItems.count else { return } - // 탭바 보이게 설정 - customTabBarContainer.isHidden = false - customTabBarContainer.alpha = 1 + // 모든 탭의 네비게이션 스택을 루트로 pop + viewControllers?.forEach { viewController in + if let navController = viewController as? UINavigationController { + navController.popToRootViewController(animated: false) + } + } updateSelection(at: index) + + // 탭 전환이 완료된 후 탭바를 확실하게 보이게 설정 + DispatchQueue.main.async { + self.customTabBarContainer.isHidden = false + self.customTabBarContainer.alpha = 1 + } } } From 708cade2a525a3cac700ea5732ba14b365a7abff Mon Sep 17 00:00:00 2001 From: kimnahun Date: Thu, 29 Jan 2026 02:30:06 +0900 Subject: [PATCH 13/20] =?UTF-8?q?refactor:=20#10=20-=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/FollowDetailViewController.swift | 82 +++++-------- .../TripCalendarViewController.swift | 20 ++-- .../Sources/TabBarInteractor.swift | 6 +- .../TabBarFeature/Sources/TabBarRouter.swift | 3 +- .../Sources/TabBarViewController.swift | 113 ++++++++---------- .../TravelFeature/Sources/TravelBuilder.swift | 1 - .../Sources/TravelInteractor.swift | 4 +- .../Sources/TravelViewController.swift | 30 ++--- 8 files changed, 112 insertions(+), 147 deletions(-) diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift index 95b8b61..845c32a 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift @@ -6,6 +6,8 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import UIKit + import Core import Domain import DSKit @@ -13,22 +15,21 @@ import RIBs import RxSwift import SnapKit import Then -import UIKit // MARK: - FollowDetailViewController -public final class FollowDetailViewController: UIViewController, FollowDetailPresentable, FollowDetailViewControllable { +final class FollowDetailViewController: UIViewController, FollowDetailPresentable, FollowDetailViewControllable { // MARK: - Properties weak var listener: FollowDetailPresentableListener? private let disposeBag = DisposeBag() - private var dayCollectionViewOriginY: CGFloat = CGFloat.greatestFiniteMagnitude + private var dayCollectionViewOriginY: CGFloat = .greatestFiniteMagnitude private var currentSelectedDay: Int = 1 private var totalDays: Int = 0 - // MARK: - UI Components (스크롤 영역) + // MARK: - UI Components (Scroll) private let scrollView = UIScrollView().then { $0.showsVerticalScrollIndicator = true @@ -47,7 +48,7 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre private let placeListCollectionView = PlaceListCollectionView() - // MARK: - UI Components (스티키 헤더) + // MARK: - UI Components (Sticky Header) private let stickyHeaderView = UIView().then { $0.backgroundColor = UIColor.NDGL.Bg.primary @@ -56,27 +57,17 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre private let stickyDayCollectionView = DayCollectionView() - // MARK: - UI Components (고정 버튼/로딩) + // MARK: - UI Components (Fixed) private let addToTripButton = BottomPlacedButton(title: "여행 따라가기") - + private let loadingIndicator = UIActivityIndicatorView(style: .large).then { $0.hidesWhenStopped = true } - // MARK: - Initialization - - public init() { - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - // MARK: - Lifecycle - public override func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() setupUI() setupConstraints() @@ -84,22 +75,20 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre setupActions() } - public override func viewWillAppear(_ animated: Bool) { + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(false, animated: animated) } - public override func viewDidDisappear(_ animated: Bool) { + override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - // 네비게이션 백버튼으로 돌아갈 때 RIB detach 처리 if isMovingFromParent { listener?.didTapCloseButton() } } - public override func viewDidLayoutSubviews() { + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - // dayCollectionView의 scrollView 내 위치 계산 dayCollectionViewOriginY = dayCollectionView.frame.origin.y } @@ -108,18 +97,15 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre private func setupUI() { view.backgroundColor = UIColor.NDGL.Bg.primary - // 스크롤 영역 view.addSubview(scrollView) scrollView.addSubview(contentView) [mediaInfoView, dayCollectionView, budgetView, mapView, placeListCollectionView].forEach { contentView.addSubview($0) } - // 스티키 헤더 (scrollView 위에 배치) view.addSubview(stickyHeaderView) stickyHeaderView.addSubview(stickyDayCollectionView) - // 버튼 및 로딩 view.addSubview(addToTripButton) view.addSubview(loadingIndicator) } @@ -129,7 +115,6 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre $0.center.equalToSuperview() } - // 스크롤 영역 scrollView.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide) $0.leading.trailing.bottom.equalToSuperview() @@ -173,7 +158,6 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre $0.height.greaterThanOrEqualTo(400) } - // 스티키 헤더 stickyHeaderView.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide) $0.leading.trailing.equalToSuperview() @@ -209,16 +193,10 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre // MARK: - Actions - @objc private func backButtonTapped() { - listener?.didTapCloseButton() - } - @objc private func addToTripButtonTapped() { listener?.didTapAddToTrip() } - // MARK: - Private Methods - private func syncDaySelection(day: Int) { currentSelectedDay = day let indexPath = IndexPath(item: day - 1, section: 0) @@ -227,21 +205,10 @@ public final class FollowDetailViewController: UIViewController, FollowDetailPre } } -// MARK: - UIScrollViewDelegate - -extension FollowDetailViewController: UIScrollViewDelegate { - public func scrollViewDidScroll(_ scrollView: UIScrollView) { - let offsetY = scrollView.contentOffset.y - - // dayCollectionView가 화면 상단에 도달하면 스티키 헤더 표시 - let threshold = dayCollectionViewOriginY - 16 - stickyHeaderView.isHidden = offsetY < threshold - } -} - // MARK: - FollowDetailPresentable extension FollowDetailViewController { + func showLoading() { loadingIndicator.startAnimating() } @@ -254,7 +221,6 @@ extension FollowDetailViewController { mediaInfoView.configure(with: detail) totalDays = detail.days - // 두 컬렉션뷰 모두 업데이트 dayCollectionView.applySnapshot(totalDays: detail.days, selectedDay: 1) stickyDayCollectionView.applySnapshot(totalDays: detail.days, selectedDay: 1) } @@ -263,7 +229,6 @@ extension FollowDetailViewController { mapView.configure(with: places) placeListCollectionView.applySnapshot(places: places) - // PlaceList 높이 동적 업데이트 (셀 높이 135 + spacing 8) let cellHeight: CGFloat = 135 let spacing: CGFloat = 8 let height = CGFloat(places.count) * cellHeight + CGFloat(max(0, places.count - 1)) * spacing @@ -299,23 +264,35 @@ extension FollowDetailViewController { // MARK: - FollowDetailViewControllable extension FollowDetailViewController { - public func present(_ viewController: ViewControllable) { + + func present(_ viewController: ViewControllable) { navigationController?.pushViewController(viewController.uiviewController, animated: true) } - public func dismiss(_ viewController: ViewControllable) { + func dismiss(_ viewController: ViewControllable) { navigationController?.popViewController(animated: true) } } +// MARK: - UIScrollViewDelegate + +extension FollowDetailViewController: UIScrollViewDelegate { + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let offsetY = scrollView.contentOffset.y + let threshold = dayCollectionViewOriginY - 16 + stickyHeaderView.isHidden = offsetY < threshold + } +} + // MARK: - MediaInfoViewDelegate extension FollowDetailViewController: MediaInfoViewDelegate { + func mediaInfoViewDidToggleExpand(_ view: MediaInfoView, isExpanded: Bool) { UIView.animate(withDuration: 0.3) { [weak self] in self?.view.layoutIfNeeded() } completion: { [weak self] _ in - // 레이아웃 변경 후 dayCollectionView 위치 재계산 self?.dayCollectionViewOriginY = self?.dayCollectionView.frame.origin.y ?? 0 } } @@ -324,8 +301,8 @@ extension FollowDetailViewController: MediaInfoViewDelegate { // MARK: - DayCollectionViewDelegate extension FollowDetailViewController: DayCollectionViewDelegate { + func dayCollectionView(_ collectionView: DayCollectionView, didSelectDay day: Int) { - // 두 컬렉션뷰 선택 상태 동기화 syncDaySelection(day: day) listener?.didSelectDay(day) } @@ -334,6 +311,7 @@ extension FollowDetailViewController: DayCollectionViewDelegate { // MARK: - PlaceListCollectionViewDelegate extension FollowDetailViewController: PlaceListCollectionViewDelegate { + func placeListCollectionView(_ collectionView: PlaceListCollectionView, didSelectPlace place: TravelPlace) { listener?.didSelectPlace(place) } diff --git a/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift index 194442f..5c0f76b 100644 --- a/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift +++ b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift @@ -6,12 +6,13 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import UIKit + import Core import DSKit import RIBs import SnapKit import Then -import UIKit // MARK: - TripCalendarViewController @@ -20,6 +21,7 @@ final class TripCalendarViewController: UIViewController, TripCalendarPresentabl // MARK: - Properties weak var listener: TripCalendarPresentableListener? + private var selectedStartDate: Date? private var selectedEndDate: Date? @@ -33,9 +35,9 @@ final class TripCalendarViewController: UIViewController, TripCalendarPresentabl override func viewDidLoad() { super.viewDidLoad() - setupNavigation() setupUI() setupConstraints() + setupDelegates() setupActions() updateCompleteButtonState() } @@ -49,18 +51,13 @@ final class TripCalendarViewController: UIViewController, TripCalendarPresentabl // MARK: - Setup - private func setupNavigation() { - title = "새로운 여행 만들기" - } - private func setupUI() { + title = "새로운 여행 만들기" view.backgroundColor = UIColor.NDGL.Bg.primary [calendarView, completeButton].forEach { view.addSubview($0) } - - calendarView.delegate = self } private func setupConstraints() { @@ -77,6 +74,10 @@ final class TripCalendarViewController: UIViewController, TripCalendarPresentabl } } + private func setupDelegates() { + calendarView.delegate = self + } + private func setupActions() { completeButton.addTarget(self, action: #selector(completeButtonTapped), for: .touchUpInside) } @@ -88,8 +89,6 @@ final class TripCalendarViewController: UIViewController, TripCalendarPresentabl listener?.didTapCompleteButton(startDate: start, endDate: end) } - // MARK: - Private Methods - private func updateCompleteButtonState() { let isEnabled = selectedStartDate != nil && selectedEndDate != nil @@ -106,6 +105,7 @@ final class TripCalendarViewController: UIViewController, TripCalendarPresentabl // MARK: - CalendarViewDelegate extension TripCalendarViewController: CalendarViewDelegate { + func calendarView(_ view: CalendarView, didSelectRange startDate: Date, endDate: Date) { selectedStartDate = startDate selectedEndDate = endDate diff --git a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift index e67fe37..e226fa0 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift @@ -14,7 +14,6 @@ import RxSwift // MARK: - TabBarListener public protocol TabBarListener: AnyObject { - // 부모 RIB에 전달할 이벤트 정의 } // MARK: - TabBarPresentable @@ -51,16 +50,13 @@ final class TabBarInteractor: PresentableInteractor, TabBarIn // MARK: - TabBarPresentableListener extension TabBarInteractor: TabBarPresentableListener { - // ViewController에서 Interactor로 전달하는 이벤트 처리 } // MARK: - HomeListener extension TabBarInteractor: HomeListener { + func homeDidAddTrip(title: String, startDate: Date, endDate: Date) { - // Travel 탭(index 2)으로 이동 presenter.switchToTab(at: 2) - - // TODO: TravelInteractor에 새 여행 추가 알림 } } diff --git a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift index a0210dd..095779e 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift @@ -59,6 +59,8 @@ final class TabBarRouter: ViewableRouter= 2, let homeVC = viewControllers[0].uiviewController as? UIViewController, @@ -63,25 +61,19 @@ public final class TabBarViewController: UITabBarController, TabBarPresentable, let infoDummy = UIViewController().then { $0.view.backgroundColor = .yellow } - // Wrap each VC in a NavigationController for push navigation let infoNav = UINavigationController(rootViewController: infoDummy) let homeNav = UINavigationController(rootViewController: homeVC) let travelNav = UINavigationController(rootViewController: travelVC) - // Set delegate to handle tab bar visibility [infoNav, homeNav, travelNav].forEach { $0.delegate = self } - let finalControllers = [infoNav, homeNav, travelNav] - - super.setViewControllers(finalControllers, animated: false) - + super.setViewControllers([infoNav, homeNav, travelNav], animated: false) setupTabItems() } func switchToTab(at index: Int) { guard index < tabItems.count else { return } - // 모든 탭의 네비게이션 스택을 루트로 pop viewControllers?.forEach { viewController in if let navController = viewController as? UINavigationController { navController.popToRootViewController(animated: false) @@ -90,7 +82,6 @@ public final class TabBarViewController: UITabBarController, TabBarPresentable, updateSelection(at: index) - // 탭 전환이 완료된 후 탭바를 확실하게 보이게 설정 DispatchQueue.main.async { self.customTabBarContainer.isHidden = false self.customTabBarContainer.alpha = 1 @@ -98,31 +89,11 @@ public final class TabBarViewController: UITabBarController, TabBarPresentable, } } +// MARK: - Setup + private extension TabBarViewController { - func setupBaseTabBar() { - self.tabBar.isHidden = true - } - - func setupTabItems() { - tabItems.forEach { $0.removeFromSuperview() } - tabItems.removeAll() - - TabBarItemType.allCases.forEach { tabType in - let item = NDGLTabItem().then { - $0.setup(title: tabType.title, image: tabType.image) - $0.tag = tabType.rawValue - $0.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside) - } - tabItems.append(item) - tabStackView.addArrangedSubview(item) - } - DispatchQueue.main.async { - self.updateSelection(at: 1, animated: false) - } - } - - func setStyle() { + func setupStyle() { customTabBarContainer.do { if #available(iOS 26.0, *) { let glass = UIGlassEffect(style: .regular) @@ -136,13 +107,13 @@ private extension TabBarViewController { $0.clipsToBounds = true $0.backgroundColor = .clear } - + tabStackView.do { $0.axis = .horizontal $0.distribution = .fill $0.spacing = 4 } - + indicatorView.do { if #available(iOS 26.0, *) { let glass = UIGlassEffect(style: .regular) @@ -152,61 +123,83 @@ private extension TabBarViewController { } else { $0.effect = UIBlurEffect(style: .dark) } - $0.layer.cornerRadius = 28.adjustedH $0.clipsToBounds = true $0.backgroundColor = .clear } } - - func setUI() { + + func setupUI() { view.addSubview(customTabBarContainer) customTabBarContainer.contentView.addSubviews(indicatorView, tabStackView) } - - func setLayout() { + + func setupConstraints() { customTabBarContainer.snp.makeConstraints { $0.centerX.equalToSuperview() $0.bottom.equalTo(view.safeAreaLayoutGuide) $0.width.equalTo(328.adjusted) $0.height.equalTo(68.adjustedH) } - + tabStackView.snp.makeConstraints { $0.directionalHorizontalEdges.equalToSuperview().inset(12) $0.directionalVerticalEdges.equalToSuperview().inset(6) } - + indicatorView.snp.makeConstraints { $0.center.equalToSuperview() $0.size.equalTo(56.adjusted) } } - + + func setupTabItems() { + tabItems.forEach { $0.removeFromSuperview() } + tabItems.removeAll() + + TabBarItemType.allCases.forEach { tabType in + let item = NDGLTabItem().then { + $0.setup(title: tabType.title, image: tabType.image) + $0.tag = tabType.rawValue + $0.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside) + } + tabItems.append(item) + tabStackView.addArrangedSubview(item) + } + + DispatchQueue.main.async { + self.updateSelection(at: 1, animated: false) + } + } +} + +// MARK: - Actions + +private extension TabBarViewController { + @objc func tabTapped(_ sender: NDGLTabItem) { updateSelection(at: sender.tag) } - + func updateSelection(at index: Int, animated: Bool = true) { - self.selectedIndex = index + selectedIndex = index let targetItem = tabItems[index] - + tabItems.enumerated().forEach { tabIndex, item in item.isTabSelected = (tabIndex == index) } let duration = animated ? 0.4 : 0.0 - + UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5) { self.indicatorView.snp.remakeConstraints { $0.center.equalTo(targetItem) $0.width.equalTo(targetItem.snp.width) $0.height.equalTo(56.adjustedH) } - self.customTabBarContainer.contentView.layoutIfNeeded() } - + if animated { UIImpactFeedbackGenerator(style: .light).impactOccurred() } @@ -216,12 +209,12 @@ private extension TabBarViewController { // MARK: - UINavigationControllerDelegate extension TabBarViewController: UINavigationControllerDelegate { + public func navigationController( _ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool ) { - // Hide custom tab bar when pushing (more than 1 VC in stack) let shouldHideTabBar = navigationController.viewControllers.count > 1 guard animated else { @@ -231,14 +224,12 @@ extension TabBarViewController: UINavigationControllerDelegate { } if shouldHideTabBar { - // Hiding: animate alpha to 0, then hide UIView.animate(withDuration: 0.3) { self.customTabBarContainer.alpha = 0 } completion: { _ in self.customTabBarContainer.isHidden = true } } else { - // Showing: unhide first with alpha 0, then animate to 1 customTabBarContainer.isHidden = false customTabBarContainer.alpha = 0 customTabBarContainer.layoutIfNeeded() diff --git a/Projects/Features/TravelFeature/Sources/TravelBuilder.swift b/Projects/Features/TravelFeature/Sources/TravelBuilder.swift index b6b09e4..66cc048 100644 --- a/Projects/Features/TravelFeature/Sources/TravelBuilder.swift +++ b/Projects/Features/TravelFeature/Sources/TravelBuilder.swift @@ -11,7 +11,6 @@ import RIBs // MARK: - TravelDependency public protocol TravelDependency: Dependency { - // 부모 RIB로부터 주입받을 의존성 정의 } // MARK: - TravelComponent diff --git a/Projects/Features/TravelFeature/Sources/TravelInteractor.swift b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift index 3827ba8..a93f7db 100644 --- a/Projects/Features/TravelFeature/Sources/TravelInteractor.swift +++ b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift @@ -13,7 +13,6 @@ import RxSwift // MARK: - TravelListener public protocol TravelListener: AnyObject { - // 부모 RIB에 전달할 이벤트 정의 } // MARK: - TravelPresentable @@ -114,13 +113,12 @@ final class TravelInteractor: PresentableInteractor, TravelIn // MARK: - TravelPresentableListener extension TravelInteractor: TravelPresentableListener { + func didTapTrip(_ trip: UpcomingTrip) { // TODO: Navigate to trip detail - print("Tapped trip: \(trip.title)") } func didTapMenuButton() { // TODO: Show menu - print("Menu button tapped") } } diff --git a/Projects/Features/TravelFeature/Sources/TravelViewController.swift b/Projects/Features/TravelFeature/Sources/TravelViewController.swift index 6de6871..5be33f7 100644 --- a/Projects/Features/TravelFeature/Sources/TravelViewController.swift +++ b/Projects/Features/TravelFeature/Sources/TravelViewController.swift @@ -6,23 +6,25 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import UIKit + import Core import DSKit import RIBs import RxSwift import SnapKit import Then -import UIKit // MARK: - TravelViewController -public final class TravelViewController: UIViewController, TravelPresentable, TravelViewControllable { +final class TravelViewController: UIViewController, TravelPresentable, TravelViewControllable { // MARK: - Properties weak var listener: TravelPresentableListener? private let disposeBag = DisposeBag() + private var trips: [UpcomingTrip] = [] // MARK: - UI Components @@ -56,21 +58,17 @@ public final class TravelViewController: UIViewController, TravelPresentable, Tr $0.hidesWhenStopped = true } - // MARK: - Data - - private var trips: [UpcomingTrip] = [] - // MARK: - Lifecycle - public override func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() setupUI() setupConstraints() - setupCollectionView() + setupDelegates() setupActions() } - public override func viewWillAppear(_ animated: Bool) { + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) } @@ -113,7 +111,7 @@ public final class TravelViewController: UIViewController, TravelPresentable, Tr } } - private func setupCollectionView() { + private func setupDelegates() { collectionView.delegate = self collectionView.dataSource = self collectionView.register(UpcomingTripCell.self, forCellWithReuseIdentifier: UpcomingTripCell.identifier) @@ -133,6 +131,7 @@ public final class TravelViewController: UIViewController, TravelPresentable, Tr // MARK: - TravelPresentable extension TravelViewController { + func showLoading() { loadingIndicator.startAnimating() } @@ -151,11 +150,12 @@ extension TravelViewController { // MARK: - UICollectionViewDataSource extension TravelViewController: UICollectionViewDataSource { - public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return trips.count } - public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell( withReuseIdentifier: UpcomingTripCell.identifier, for: indexPath @@ -171,7 +171,8 @@ extension TravelViewController: UICollectionViewDataSource { // MARK: - UICollectionViewDelegate extension TravelViewController: UICollectionViewDelegate { - public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let trip = trips[indexPath.item] listener?.didTapTrip(trip) } @@ -180,7 +181,8 @@ extension TravelViewController: UICollectionViewDelegate { // MARK: - UICollectionViewDelegateFlowLayout extension TravelViewController: UICollectionViewDelegateFlowLayout { - public func collectionView( + + func collectionView( _ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath From c9706d4e7ca41f7a3a5599391d7c13d6ea50d14f Mon Sep 17 00:00:00 2001 From: kimnahun Date: Thu, 29 Jan 2026 02:48:54 +0900 Subject: [PATCH 14/20] =?UTF-8?q?refactor:=20#10=20-=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=EC=97=90=20=EB=82=A8=EC=9D=80=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=EB=AC=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/FollowDetailViewController.swift | 3 +-- .../TripCalendarViewController.swift | 3 +-- .../Views/Calendar/CalendarDayCell.swift | 7 +----- .../Sources/Views/Calendar/CalendarView.swift | 24 +++++-------------- .../Sources/Views/Cells/DayCell.swift | 2 +- .../Sources/Views/TravelMapView.swift | 2 +- .../Sources/TravelInteractor.swift | 1 - .../Sources/TravelViewController.swift | 7 +++--- .../Sources/Views/UpcomingTripCell.swift | 3 --- 9 files changed, 15 insertions(+), 37 deletions(-) diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift index 845c32a..5099712 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift @@ -6,8 +6,6 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // -import UIKit - import Core import Domain import DSKit @@ -15,6 +13,7 @@ import RIBs import RxSwift import SnapKit import Then +import UIKit // MARK: - FollowDetailViewController diff --git a/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift index 5c0f76b..0d612a3 100644 --- a/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift +++ b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift @@ -6,13 +6,12 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // -import UIKit - import Core import DSKit import RIBs import SnapKit import Then +import UIKit // MARK: - TripCalendarViewController diff --git a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift index baa8846..aa7add9 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift @@ -102,18 +102,15 @@ final class CalendarDayCell: UICollectionViewCell { dayLabel.text = "\(day)" - // Reset backgroundCircleView.isHidden = true backgroundCircleView.layer.borderWidth = 0 rangeBackgroundView.isHidden = true - // Determine text color var textColor: UIColor if !isCurrentMonth { textColor = UIColor.NDGL.Text.disabled } else if isPastDate { - // Past dates - dimmed but Sunday still shows red tint if isSunday { textColor = DSKitAsset.Colors.red300.color } else { @@ -125,16 +122,14 @@ final class CalendarDayCell: UICollectionViewCell { textColor = UIColor.NDGL.Text.primary } - // Apply selection state switch selectionState { case .startDate, .endDate: - backgroundCircleView.backgroundColor = UIColor(hexCode: "#38A169") // green500 + backgroundCircleView.backgroundColor = UIColor(hexCode: "#38A169") backgroundCircleView.isHidden = false textColor = UIColor.NDGL.Text.Interactive.inverse case .inRange: rangeBackgroundView.isHidden = false - // Keep original text color case .none: break diff --git a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift index 7b6aafd..34276d5 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift @@ -240,17 +240,14 @@ final class CalendarView: UIView { let firstWeekday = calendar.component(.weekday, from: firstDayOfMonth) - // Add empty cells for days before the first day of the month for _ in 1.. Int { - return 2 // Year, Month + 2 } func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { if component == 0 { - // Year: current year ~ 2099 let currentYear = calendar.component(.year, from: Date()) return 2099 - currentYear + 1 } else { - // Month: 1 ~ 12 return 12 } } diff --git a/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift b/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift index 5a730d2..1ee4e63 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift @@ -8,9 +8,9 @@ import Core import DSKit -import UIKit import SnapKit import Then +import UIKit final class DayCell: UICollectionViewCell { diff --git a/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift b/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift index b06fc6e..65e42ae 100644 --- a/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift @@ -10,9 +10,9 @@ import Core import Domain import DSKit import MapKit -import UIKit import SnapKit import Then +import UIKit final class TravelMapView: UIView { diff --git a/Projects/Features/TravelFeature/Sources/TravelInteractor.swift b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift index a93f7db..8503ebb 100644 --- a/Projects/Features/TravelFeature/Sources/TravelInteractor.swift +++ b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift @@ -64,7 +64,6 @@ final class TravelInteractor: PresentableInteractor, TravelIn private func loadTrips() { presenter.showLoading() - // Mock data let mockTrips = [ UpcomingTrip( id: 1, diff --git a/Projects/Features/TravelFeature/Sources/TravelViewController.swift b/Projects/Features/TravelFeature/Sources/TravelViewController.swift index 5be33f7..a33e414 100644 --- a/Projects/Features/TravelFeature/Sources/TravelViewController.swift +++ b/Projects/Features/TravelFeature/Sources/TravelViewController.swift @@ -6,14 +6,13 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // -import UIKit - import Core import DSKit import RIBs import RxSwift import SnapKit import Then +import UIKit // MARK: - TravelViewController @@ -159,7 +158,8 @@ extension TravelViewController: UICollectionViewDataSource { guard let cell = collectionView.dequeueReusableCell( withReuseIdentifier: UpcomingTripCell.identifier, for: indexPath - ) as? UpcomingTripCell else { + ) as? UpcomingTripCell, + indexPath.item < trips.count else { return UICollectionViewCell() } @@ -173,6 +173,7 @@ extension TravelViewController: UICollectionViewDataSource { extension TravelViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard indexPath.item < trips.count else { return } let trip = trips[indexPath.item] listener?.didTapTrip(trip) } diff --git a/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift b/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift index 5406309..d193458 100644 --- a/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift +++ b/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift @@ -111,13 +111,10 @@ final class UpcomingTripCell: UICollectionViewCell { let dDayText = dDay > 0 ? "D-\(dDay)" : (dDay == 0 ? "D-Day" : "D+\(abs(dDay))") dDayLabel.setText(.bodySSB, text: dDayText, color: .white) - // Title titleLabel.setText(.bodyMSB, text: trip.title, color: UIColor.NDGL.Text.primary) - // Date range dateLabel.setText(.bodySR, text: trip.dateRangeString, color: UIColor.NDGL.Text.tertiary) - // Thumbnail if let urlString = trip.thumbnailURL, let url = URL(string: urlString) { thumbnailImageView.kf.setImage( with: url, From 2abae859f406792fb05b116ca04c6ed27a2c735a Mon Sep 17 00:00:00 2001 From: kimnahun Date: Thu, 29 Jan 2026 02:53:01 +0900 Subject: [PATCH 15/20] =?UTF-8?q?del:=20#10=20-=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EC=9D=98=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift | 2 +- .../Views/CollectionViews/YoutuberContentCollectionView.swift | 3 +-- Projects/Features/TravelFeature/Sources/TravelInteractor.swift | 2 -- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift index aa7add9..1c902e1 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift @@ -23,7 +23,7 @@ final class CalendarDayCell: UICollectionViewCell { } private let rangeBackgroundView = UIView().then { - $0.backgroundColor = UIColor(hexCode: "#C6F6D5") // green200 + $0.backgroundColor = UIColor(hexCode: "#C6F6D5") $0.isHidden = true } diff --git a/Projects/Features/HomeFeature/Sources/Views/CollectionViews/YoutuberContentCollectionView.swift b/Projects/Features/HomeFeature/Sources/Views/CollectionViews/YoutuberContentCollectionView.swift index 2068eb8..0ee929e 100644 --- a/Projects/Features/HomeFeature/Sources/Views/CollectionViews/YoutuberContentCollectionView.swift +++ b/Projects/Features/HomeFeature/Sources/Views/CollectionViews/YoutuberContentCollectionView.swift @@ -57,7 +57,7 @@ final class YoutuberContentCollectionView: UICollectionView { // Group - 세로로 3개씩 묶음 let groupSize = NSCollectionLayoutSize( widthDimension: .absolute(sectionWidth), - heightDimension: .absolute(88 * 3 + 12 * 2) // 3 items + 2 spacings + heightDimension: .absolute(88 * 3 + 12 * 2) ) let group = NSCollectionLayoutGroup.vertical( layoutSize: groupSize, @@ -65,7 +65,6 @@ final class YoutuberContentCollectionView: UICollectionView { ) group.interItemSpacing = .fixed(12) - // Section let section = NSCollectionLayoutSection(group: group) section.orthogonalScrollingBehavior = .groupPaging diff --git a/Projects/Features/TravelFeature/Sources/TravelInteractor.swift b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift index 8503ebb..42b410e 100644 --- a/Projects/Features/TravelFeature/Sources/TravelInteractor.swift +++ b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift @@ -114,10 +114,8 @@ final class TravelInteractor: PresentableInteractor, TravelIn extension TravelInteractor: TravelPresentableListener { func didTapTrip(_ trip: UpcomingTrip) { - // TODO: Navigate to trip detail } func didTapMenuButton() { - // TODO: Show menu } } From d446b0ac4411eaa59949e9ffb98ada35f69ad043 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Thu, 29 Jan 2026 02:56:04 +0900 Subject: [PATCH 16/20] =?UTF-8?q?refactor:=20#10=20-=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20guard=EB=AC=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TabBarFeature/Sources/TabBarViewController.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift b/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift index b0ef7a8..ac20c18 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift @@ -53,12 +53,13 @@ public final class TabBarViewController: UITabBarController, TabBarPresentable, // MARK: - TabBarViewControllable public func setViewControllers(_ viewControllers: [ViewControllable]) { - guard viewControllers.count >= 2, - let homeVC = viewControllers[0].uiviewController as? UIViewController, - let travelVC = viewControllers[1].uiviewController as? UIViewController else { + guard viewControllers.count >= 2 else { return } + let homeVC = viewControllers[0].uiviewController + let travelVC = viewControllers[1].uiviewController + let infoDummy = UIViewController().then { $0.view.backgroundColor = .yellow } let infoNav = UINavigationController(rootViewController: infoDummy) From ac388504eea40dd190245c7519808f97a92ee8a6 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Thu, 29 Jan 2026 14:32:45 +0900 Subject: [PATCH 17/20] =?UTF-8?q?fix:=20#10=20-=20api=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=20mainactor=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=9E=A0?= =?UTF-8?q?=EC=9E=AC=EC=A0=81=20race=20condition=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/FollowDetailInteractor.swift | 55 +++++++++++-------- .../HomeFeature/Sources/HomeInteractor.swift | 23 ++++---- 2 files changed, 44 insertions(+), 34 deletions(-) diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift index 5f7fefe..99167b1 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -80,43 +80,55 @@ final class FollowDetailInteractor: PresentableInteractor, HomeInteract // MARK: - Private Methods private func loadHomeData() { - presenter.showLoading() + Task { + await MainActor.run { + presenter.showLoading() + } - Task { @MainActor in async let myTripsResult = repository.fetchMyTrips() async let tripsByCategoryResult = repository.fetchAllPopularTrips() async let recommendationsResult = repository.fetchRecommendations() @@ -94,14 +96,15 @@ final class HomeInteractor: PresentableInteractor, HomeInteract recommendationsResult ) - self.myTrips = myTripsData - self.tripsByCategory = tripsByCategoryData - self.recommendations = recommendationsData - - presenter.hideLoading() - presenter.updateMyTrips(myTripsData) - presenter.updatePopularTrips(tripsByCategoryData, categories: categories) - presenter.updateRecommendations(recommendationsData) + await MainActor.run { + self.myTrips = myTripsData + self.tripsByCategory = tripsByCategoryData + self.recommendations = recommendationsData + presenter.hideLoading() + presenter.updateMyTrips(myTripsData) + presenter.updatePopularTrips(tripsByCategoryData, categories: categories) + presenter.updateRecommendations(recommendationsData) + } } } } From 165fb33c636b059c6454db14b80a88d82f25a232 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Thu, 29 Jan 2026 14:43:15 +0900 Subject: [PATCH 18/20] =?UTF-8?q?chore:=20#10=20-=20UIColor=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8A=94=20=EB=B6=80=EB=B6=84=20hexcode?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD.=20(=EB=94=94=EC=9E=90=EC=9D=B8?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=B3=80=EA=B2=BD=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/FollowDetailViewController.swift | 4 +-- .../TripCalendarViewController.swift | 4 +-- .../Sources/Views/BudgetView.swift | 10 +++---- .../Views/Calendar/CalendarDayCell.swift | 8 +++--- .../Sources/Views/Calendar/CalendarView.swift | 10 +++---- .../Sources/Views/Cells/DayCell.swift | 14 +++++----- .../Sources/Views/Cells/PlaceCell.swift | 24 ++++++++-------- .../Sources/Views/MediaInfoView.swift | 18 ++++++------ .../Views/PlaceDetailBottomSheetView.swift | 28 +++++++++---------- .../Sources/Views/TravelMapView.swift | 4 +-- .../Sources/HomeViewController.swift | 10 +++---- .../Sources/Views/Cells/CategoryCell.swift | 8 +++--- .../Views/Cells/RecommendContentCell.swift | 8 +++--- .../Views/Cells/YoutuberContentCell.swift | 4 +-- .../Sources/Views/MyTravelView.swift | 6 ++-- .../Sources/Components/NDGLTabItem.swift | 6 ++-- .../Sources/TabBarViewController.swift | 2 +- .../Sources/TravelViewController.swift | 8 +++--- .../Sources/Views/UpcomingTripCell.swift | 4 +-- .../Component/BottomPlacedButton.swift | 4 +-- 20 files changed, 92 insertions(+), 92 deletions(-) diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift index 5099712..ad59824 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift @@ -50,7 +50,7 @@ final class FollowDetailViewController: UIViewController, FollowDetailPresentabl // MARK: - UI Components (Sticky Header) private let stickyHeaderView = UIView().then { - $0.backgroundColor = UIColor.NDGL.Bg.primary + $0.backgroundColor = UIColor(hexCode: "#FFFFFF") $0.isHidden = true } @@ -94,7 +94,7 @@ final class FollowDetailViewController: UIViewController, FollowDetailPresentabl // MARK: - Setup private func setupUI() { - view.backgroundColor = UIColor.NDGL.Bg.primary + view.backgroundColor = UIColor(hexCode: "#FFFFFF") view.addSubview(scrollView) scrollView.addSubview(contentView) diff --git a/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift index 0d612a3..24ab76f 100644 --- a/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift +++ b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift @@ -52,7 +52,7 @@ final class TripCalendarViewController: UIViewController, TripCalendarPresentabl private func setupUI() { title = "새로운 여행 만들기" - view.backgroundColor = UIColor.NDGL.Bg.primary + view.backgroundColor = UIColor(hexCode: "#FFFFFF") [calendarView, completeButton].forEach { view.addSubview($0) @@ -95,7 +95,7 @@ final class TripCalendarViewController: UIViewController, TripCalendarPresentabl completeButton.backgroundColor = UIColor(hexCode: "#111111") completeButton.isEnabled = true } else { - completeButton.backgroundColor = UIColor.NDGL.Bg.disabled + completeButton.backgroundColor = UIColor(hexCode: "#B3B3B3") completeButton.isEnabled = false } } diff --git a/Projects/Features/FollowFeature/Sources/Views/BudgetView.swift b/Projects/Features/FollowFeature/Sources/Views/BudgetView.swift index 36460d9..ca50ed9 100644 --- a/Projects/Features/FollowFeature/Sources/Views/BudgetView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/BudgetView.swift @@ -18,7 +18,7 @@ final class BudgetView: UIView { private let iconImageView = UIImageView().then { $0.image = DSKitAsset.Assets.icPiggybank1.image - $0.tintColor = UIColor.NDGL.Icon.secondary + $0.tintColor = UIColor(hexCode: "#2C2C2C") $0.contentMode = .scaleAspectFit } @@ -41,16 +41,16 @@ final class BudgetView: UIView { // MARK: - Setup private func setupUI() { - backgroundColor = UIColor.NDGL.Bg.primary + backgroundColor = UIColor(hexCode: "#FFFFFF") layer.cornerRadius = 8 layer.borderWidth = 1 - layer.borderColor = UIColor.NDGL.Border.secondary.cgColor + layer.borderColor = UIColor(hexCode: "#D9D9D9").cgColor [iconImageView, titleLabel, budgetLabel].forEach { addSubview($0) } - titleLabel.setText(.bodyMM, text: "1인 기준 여행 예산 :", color: UIColor.NDGL.Text.secondary) + titleLabel.setText(.bodyMM, text: "1인 기준 여행 예산 :", color: UIColor(hexCode: "#2C2C2C")) } private func setupConstraints() { @@ -77,6 +77,6 @@ final class BudgetView: UIView { let formatter = NumberFormatter() formatter.numberStyle = .decimal let formattedNumber = formatter.string(from: NSNumber(value: budget)) ?? "\(budget)" - budgetLabel.setText(.bodyMSB, text: "\(formattedNumber)원", color: UIColor.NDGL.Text.primary) + budgetLabel.setText(.bodyMSB, text: "\(formattedNumber)원", color: UIColor(hexCode: "#111111")) } } diff --git a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift index 1c902e1..80768e8 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift @@ -109,24 +109,24 @@ final class CalendarDayCell: UICollectionViewCell { var textColor: UIColor if !isCurrentMonth { - textColor = UIColor.NDGL.Text.disabled + textColor = UIColor(hexCode: "#757575") } else if isPastDate { if isSunday { textColor = DSKitAsset.Colors.red300.color } else { - textColor = UIColor.NDGL.Text.disabled + textColor = UIColor(hexCode: "#757575") } } else if isSunday { textColor = DSKitAsset.Colors.red500.color } else { - textColor = UIColor.NDGL.Text.primary + textColor = UIColor(hexCode: "#111111") } switch selectionState { case .startDate, .endDate: backgroundCircleView.backgroundColor = UIColor(hexCode: "#38A169") backgroundCircleView.isHidden = false - textColor = UIColor.NDGL.Text.Interactive.inverse + textColor = UIColor(hexCode: "#FFFFFF") case .inRange: rangeBackgroundView.isHidden = false diff --git a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift index 34276d5..9bb7cff 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift @@ -33,18 +33,18 @@ final class CalendarView: UIView { // MARK: - UI Components private let monthYearButton = UIButton(type: .system).then { - $0.setTitleColor(UIColor.NDGL.Text.primary, for: .normal) + $0.setTitleColor(UIColor(hexCode: "#111111"), for: .normal) $0.titleLabel?.font = DSKitFontFamily.Pretendard.semiBold.font(size: 18) } private let previousMonthButton = UIButton(type: .system).then { $0.setImage(DSKitAsset.Assets.icChevronLeft3.image, for: .normal) - $0.tintColor = UIColor.NDGL.Icon.primary + $0.tintColor = UIColor(hexCode: "#111111") } private let nextMonthButton = UIButton(type: .system).then { $0.setImage(DSKitAsset.Assets.icChevronRight3.image, for: .normal) - $0.tintColor = UIColor.NDGL.Icon.primary + $0.tintColor = UIColor(hexCode: "#111111") } private let weekdayStackView = UIStackView().then { @@ -83,7 +83,7 @@ final class CalendarView: UIView { // MARK: - Setup private func setupUI() { - backgroundColor = UIColor.NDGL.Bg.primary + backgroundColor = UIColor(hexCode: "#FFFFFF") [monthYearButton, previousMonthButton, nextMonthButton, weekdayStackView, collectionView].forEach { addSubview($0) @@ -133,7 +133,7 @@ final class CalendarView: UIView { for (index, weekday) in weekdays.enumerated() { let label = UILabel() - let color = index == 0 ? DSKitAsset.Colors.red500.color : UIColor.NDGL.Text.secondary + let color = index == 0 ? DSKitAsset.Colors.red500.color : UIColor(hexCode: "#2C2C2C") label.setText(.bodySR, text: weekday, color: color, alignment: .center) weekdayStackView.addArrangedSubview(label) } diff --git a/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift b/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift index 1ee4e63..adc0292 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift @@ -21,8 +21,8 @@ final class DayCell: UICollectionViewCell { private let containerView = UIView().then { $0.layer.cornerRadius = 15 $0.layer.borderWidth = 1 - $0.layer.borderColor = UIColor.NDGL.Border.secondary.cgColor - $0.backgroundColor = UIColor.NDGL.Bg.primary + $0.layer.borderColor = UIColor(hexCode: "#D9D9D9").cgColor + $0.backgroundColor = UIColor(hexCode: "#FFFFFF") } private let dayLabel = UILabel() @@ -73,16 +73,16 @@ final class DayCell: UICollectionViewCell { private func updateSelectionState() { if isSelected { - containerView.backgroundColor = UIColor.init(hexCode: "#2C2C2C") + containerView.backgroundColor = UIColor(hexCode: "#2C2C2C") containerView.layer.borderWidth = 0 dayLabel.font = DSKitFontFamily.Pretendard.medium.font(size: 14) - dayLabel.textColor = UIColor.NDGL.Text.Interactive.inverse + dayLabel.textColor = UIColor(hexCode: "#FFFFFF") } else { - containerView.backgroundColor = UIColor.NDGL.Bg.primary + containerView.backgroundColor = UIColor(hexCode: "#FFFFFF") containerView.layer.borderWidth = 1 - containerView.layer.borderColor = UIColor.NDGL.Border.secondary.cgColor + containerView.layer.borderColor = UIColor(hexCode: "#D9D9D9").cgColor dayLabel.font = DSKitFontFamily.Pretendard.medium.font(size: 14) - dayLabel.textColor = UIColor.NDGL.Text.disabled + dayLabel.textColor = UIColor(hexCode: "#757575") } } } diff --git a/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift b/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift index cccddac..cde0851 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift @@ -22,7 +22,7 @@ final class PlaceCell: UICollectionViewCell { // 순서 뷰 (셀 바깥 왼쪽) private let sequenceView = UIView().then { - $0.backgroundColor = UIColor.NDGL.Bg.Interactive.primary + $0.backgroundColor = UIColor(hexCode: "#28A745") $0.layer.cornerRadius = 12 } @@ -30,16 +30,16 @@ final class PlaceCell: UICollectionViewCell { // 메인 컨테이너 (보더 있는 영역) private let containerView = UIView().then { - $0.backgroundColor = UIColor.NDGL.Bg.primary + $0.backgroundColor = UIColor(hexCode: "#FFFFFF") $0.layer.cornerRadius = 12 $0.layer.borderWidth = 1 - $0.layer.borderColor = UIColor.NDGL.Border.subtle.cgColor + $0.layer.borderColor = UIColor(hexCode: "#F5F5F5").cgColor $0.clipsToBounds = true } // 카테고리 태그 private let categoryTagView = UIView().then { - $0.backgroundColor = UIColor.NDGL.Bg.Interactive.subtle02 + $0.backgroundColor = UIColor(hexCode: "#F5F5F5") $0.layer.cornerRadius = 4 } @@ -61,7 +61,7 @@ final class PlaceCell: UICollectionViewCell { // 썸네일 private let thumbnailImageView = UIImageView().then { $0.contentMode = .scaleAspectFill - $0.backgroundColor = UIColor.NDGL.Bg.Interactive.subtle02 + $0.backgroundColor = UIColor(hexCode: "#F5F5F5") $0.layer.cornerRadius = 8 $0.clipsToBounds = true } @@ -74,7 +74,7 @@ final class PlaceCell: UICollectionViewCell { private let chevronImageView = UIImageView().then { $0.image = DSKitAsset.Assets.icChevronRight3.image $0.contentMode = .scaleAspectFit - $0.tintColor = UIColor.NDGL.Icon.disabled + $0.tintColor = UIColor(hexCode: "#757575") } // MARK: - Initialization @@ -200,19 +200,19 @@ final class PlaceCell: UICollectionViewCell { // MARK: - Configuration func configure(with place: TravelPlace, isLast: Bool = false) { - sequenceLabel.setText(.bodySSB, text: "\(place.sequence)", color: UIColor.NDGL.Text.Interactive.inverse) + sequenceLabel.setText(.bodySSB, text: "\(place.sequence)", color: UIColor(hexCode: "#FFFFFF")) // 카테고리 (기본값: 교통수단) - categoryLabel.setText(.bodySR, text: "교통수단", color: UIColor.NDGL.Text.secondary) + categoryLabel.setText(.bodySR, text: "교통수단", color: UIColor(hexCode: "#2C2C2C")) // 체류 시간 - durationLabel.setText(.bodySR, text: "\(place.estimatedDuration)분 체류 예상", color: UIColor.NDGL.Text.tertiary) + durationLabel.setText(.bodySR, text: "\(place.estimatedDuration)분 체류 예상", color: UIColor(hexCode: "#444444")) // 장소명 - placeNameLabel.setText(.bodyLSB, text: place.place.name, color: UIColor.NDGL.Text.primary) + placeNameLabel.setText(.bodyLSB, text: place.place.name, color: UIColor(hexCode: "#111111")) // 팁 - tipLabel.setText(.bodySR, text: place.travelerTip, color: UIColor.NDGL.Text.tertiary) + tipLabel.setText(.bodySR, text: place.travelerTip, color: UIColor(hexCode: "#444444")) // 썸네일 이미지 로딩 if let thumbnailURLString = place.place.thumbnail, @@ -236,7 +236,7 @@ final class PlaceCell: UICollectionViewCell { } else { travelTimeContainerView.isHidden = false // TODO: 실제 이동 시간 데이터로 교체 - travelTimeLabel.setText(.bodySR, text: "약 30분 • 28.8km", color: UIColor.NDGL.Text.tertiary) + travelTimeLabel.setText(.bodySR, text: "약 30분 • 28.8km", color: UIColor(hexCode: "#444444")) } } } diff --git a/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift index d71eefb..758d7d7 100644 --- a/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift @@ -33,7 +33,7 @@ final class MediaInfoView: UIView { private let profileImageView = UIImageView().then { $0.contentMode = .scaleAspectFill - $0.backgroundColor = UIColor.NDGL.Bg.disabled + $0.backgroundColor = UIColor(hexCode: "#B3B3B3") $0.layer.cornerRadius = 28 $0.clipsToBounds = true } @@ -59,7 +59,7 @@ final class MediaInfoView: UIView { private let toggleButton = UIButton(type: .system).then { $0.setImage(DSKitAsset.Assets.icChevronDown3.image, for: .normal) - $0.tintColor = UIColor.NDGL.Icon.disabled + $0.tintColor = UIColor(hexCode: "#757575") } // MARK: - UI Components (펼쳤을 때만 보이는 영역) @@ -71,7 +71,7 @@ final class MediaInfoView: UIView { private let thumbnailImageView = UIImageView().then { $0.contentMode = .scaleAspectFit - $0.backgroundColor = UIColor.NDGL.Bg.Interactive.subtle02 + $0.backgroundColor = UIColor(hexCode: "#F5F5F5") $0.layer.cornerRadius = 8 $0.clipsToBounds = true } @@ -91,7 +91,7 @@ final class MediaInfoView: UIView { private let budgetLabel = UILabel() private let separatorView = UIView().then { - $0.backgroundColor = UIColor.NDGL.Border.secondary + $0.backgroundColor = UIColor(hexCode: "#D9D9D9") } // 영상 요약 타이틀 (icBook1 + 8px + "영상 요약") @@ -130,7 +130,7 @@ final class MediaInfoView: UIView { // MARK: - Setup private func setupUI() { - backgroundColor = UIColor.NDGL.Bg.Interactive.subtle02 + backgroundColor = UIColor(hexCode: "#F5F5F5") // 여행 정보 스택뷰 구성 [travelInfoIconView, travelInfoLabel].forEach { @@ -156,7 +156,7 @@ final class MediaInfoView: UIView { } // 타이포그래피 설정 - summaryTitleLabel.setText(.bodyMSB, text: "영상 요약", color: UIColor.NDGL.Text.primary) + summaryTitleLabel.setText(.bodyMSB, text: "영상 요약", color: UIColor(hexCode: "#111111")) } private func setupConstraints() { @@ -271,17 +271,17 @@ final class MediaInfoView: UIView { func configure(with detail: TravelDetail) { // 여행 정보 라벨 (유튜버 · 국가 · 3박4일) let travelInfoText = "\(detail.youtube.youtuber) · \(detail.country) · \(detail.nights)박\(detail.days)일" - travelInfoLabel.setText(.bodyMSB, text: travelInfoText, color: UIColor.NDGL.Text.disabled) + travelInfoLabel.setText(.bodyMSB, text: travelInfoText, color: UIColor(hexCode: "#757575")) // 제목 - titleLabel.setText(.subTitleLSB, text: detail.youtube.title, color: UIColor.NDGL.Text.primary) + titleLabel.setText(.subTitleLSB, text: detail.youtube.title, color: UIColor(hexCode: "#111111")) // 예산 라벨 (1인 기준 예산 + 금액) - 파란색 let budgetText = "1인 기준 예산 \(formatBudget(detail.budgetPerPerson))" budgetLabel.setText(.bodyLR, text: budgetText, color: DSKitAsset.Colors.primary500.color) // 요약 라벨 - summaryLabel.setText(.bodyMM, text: detail.youtube.summary, color: UIColor.NDGL.Text.secondary) + summaryLabel.setText(.bodyMM, text: detail.youtube.summary, color: UIColor(hexCode: "#2C2C2C")) // 프로필 이미지 로딩 if let profileURLString = detail.youtube.profileImage, diff --git a/Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift b/Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift index fbd30f0..72fc8d7 100644 --- a/Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift @@ -24,7 +24,7 @@ final class PlaceDetailBottomSheetView: UIView { private let chevronButton = UIButton(type: .system).then { $0.setImage(DSKitAsset.Assets.icChevronRight3.image, for: .normal) - $0.tintColor = UIColor.NDGL.Icon.primary + $0.tintColor = UIColor(hexCode: "#111111") } // 카테고리 + 체류시간 @@ -42,7 +42,7 @@ final class PlaceDetailBottomSheetView: UIView { private let categoryChevronImageView = UIImageView().then { $0.image = DSKitAsset.Assets.icChevronRight3.image - $0.tintColor = UIColor.NDGL.Icon.disabled + $0.tintColor = UIColor(hexCode: "#757575") $0.contentMode = .scaleAspectFit } @@ -58,7 +58,7 @@ final class PlaceDetailBottomSheetView: UIView { private let timeIconImageView = UIImageView().then { $0.image = DSKitAsset.Assets.icClock1.image - $0.tintColor = UIColor.NDGL.Icon.secondary + $0.tintColor = UIColor(hexCode: "#2C2C2C") $0.contentMode = .scaleAspectFit } @@ -73,7 +73,7 @@ final class PlaceDetailBottomSheetView: UIView { private let costIconImageView = UIImageView().then { $0.image = DSKitAsset.Assets.icCard1.image - $0.tintColor = UIColor.NDGL.Icon.secondary + $0.tintColor = UIColor(hexCode: "#2C2C2C") $0.contentMode = .scaleAspectFit } @@ -81,10 +81,10 @@ final class PlaceDetailBottomSheetView: UIView { // 길찾기 버튼 private let findRouteButton = UIButton(type: .system).then { - $0.backgroundColor = UIColor.NDGL.Bg.primary + $0.backgroundColor = UIColor(hexCode: "#FFFFFF") $0.layer.cornerRadius = 8 $0.layer.borderWidth = 1 - $0.layer.borderColor = UIColor.NDGL.Border.secondary.cgColor + $0.layer.borderColor = UIColor(hexCode: "#D9D9D9").cgColor } private let findRouteStackView = UIStackView().then { @@ -98,7 +98,7 @@ final class PlaceDetailBottomSheetView: UIView { private let findRouteIconImageView = UIImageView().then { $0.image = DSKitAsset.Assets.icMap1.image - $0.tintColor = UIColor.NDGL.Icon.primary + $0.tintColor = UIColor(hexCode: "#111111") $0.contentMode = .scaleAspectFit } @@ -146,8 +146,8 @@ final class PlaceDetailBottomSheetView: UIView { } // 기본 텍스트 설정 - dotLabel.setText(.bodySR, text: "•", color: UIColor.NDGL.Text.tertiary) - findRouteLabel.setText(.bodyMSB, text: "길찾기", color: UIColor.NDGL.Text.primary) + dotLabel.setText(.bodySR, text: "•", color: UIColor(hexCode: "#444444")) + findRouteLabel.setText(.bodyMSB, text: "길찾기", color: UIColor(hexCode: "#111111")) } private func setupConstraints() { @@ -216,7 +216,7 @@ final class PlaceDetailBottomSheetView: UIView { func configure(with place: TravelPlace) { // 타이틀 - titleLabel.setText(.subTitleLSB, text: place.place.name, color: UIColor.NDGL.Text.primary) + titleLabel.setText(.subTitleLSB, text: place.place.name, color: UIColor(hexCode: "#111111")) // 카테고리 (기본값: 관광명소) categoryLabel.setText(.bodySR, text: "🏔 관광명소", color: DSKitAsset.Colors.primary500.color) @@ -232,16 +232,16 @@ final class PlaceDetailBottomSheetView: UIView { } else { durationText = "\(minutes)분 체류 예상" } - durationLabel.setText(.bodySR, text: durationText, color: UIColor.NDGL.Text.tertiary) + durationLabel.setText(.bodySR, text: durationText, color: UIColor(hexCode: "#444444")) // 영업시간 let openingHours = place.place.regularOpeningHours ?? "-" - openingHoursLabel.setText(.bodySR, text: "영업시간 \(openingHours)", color: UIColor.NDGL.Text.secondary) + openingHoursLabel.setText(.bodySR, text: "영업시간 \(openingHours)", color: UIColor(hexCode: "#2C2C2C")) // 시간 추가 (기본값) - timeLabel.setText(.bodySR, text: "시간 추가", color: UIColor.NDGL.Text.tertiary) + timeLabel.setText(.bodySR, text: "시간 추가", color: UIColor(hexCode: "#444444")) // 비용 추가 (기본값) - costLabel.setText(.bodySR, text: "비용 추가", color: UIColor.NDGL.Text.tertiary) + costLabel.setText(.bodySR, text: "비용 추가", color: UIColor(hexCode: "#444444")) } } diff --git a/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift b/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift index 65e42ae..f5e0604 100644 --- a/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/TravelMapView.swift @@ -138,12 +138,12 @@ extension TravelMapView: MKMapViewDelegate { private func createCircleView(sequence: Int) -> UIView { let size: CGFloat = 30 let view = UIView(frame: CGRect(x: 0, y: 0, width: size, height: size)) - view.backgroundColor = UIColor.NDGL.Bg.Interactive.primary + view.backgroundColor = UIColor(hexCode: "#28A745") view.layer.cornerRadius = size / 2 let label = UILabel(frame: view.bounds) label.text = "\(sequence)" - label.textColor = UIColor.NDGL.Text.Interactive.inverse + label.textColor = UIColor(hexCode: "#FFFFFF") label.font = DSKitFontFamily.Pretendard.bold.font(size: 14) label.textAlignment = .center view.addSubview(label) diff --git a/Projects/Features/HomeFeature/Sources/HomeViewController.swift b/Projects/Features/HomeFeature/Sources/HomeViewController.swift index 3af6829..7ada74a 100644 --- a/Projects/Features/HomeFeature/Sources/HomeViewController.swift +++ b/Projects/Features/HomeFeature/Sources/HomeViewController.swift @@ -41,7 +41,7 @@ final class HomeViewController: UIViewController, HomePresentable, HomeViewContr private let myTravelView = MyTravelView() private let followGuideLabel = UILabel().then { - $0.setText(.subTitleLSB, text: "인기 여행 따라가기", color: .NDGL.Text.primary) + $0.setText(.subTitleLSB, text: "인기 여행 따라가기", color: UIColor(hexCode: "#111111")) } private let categoryCollectionView = CategoryCollectionView() @@ -50,23 +50,23 @@ final class HomeViewController: UIViewController, HomePresentable, HomeViewContr private let showOtherTravelButton = UIButton().then { $0.setTitle("여행 따라가기 더보기", for: .normal) - $0.setTitleColor(UIColor.NDGL.Text.secondary, for: .normal) + $0.setTitleColor(UIColor(hexCode: "#2C2C2C"), for: .normal) $0.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) - $0.backgroundColor = UIColor.NDGL.Bg.primary + $0.backgroundColor = UIColor(hexCode: "#FFFFFF") $0.layer.cornerRadius = 8 $0.layer.borderWidth = 1.0 $0.layer.borderColor = UIColor(hexCode: "#D9D9D9").cgColor } private let recommendContentGuideLabel = UILabel().then { - $0.setText(.subTitleLSB, text: "OOO님께 추천하는\n따라가기 여행 콘텐츠에요!", color: .NDGL.Text.primary) + $0.setText(.subTitleLSB, text: "OOO님께 추천하는\n따라가기 여행 콘텐츠에요!", color: UIColor(hexCode: "#111111")) $0.numberOfLines = 2 } private let recommendContentCollectionView = RecommendContentCollectionView() private let addFloatingButton = UIButton().then { - $0.backgroundColor = UIColor.NDGL.Bg.Interactive.primary + $0.backgroundColor = UIColor(hexCode: "#28A745") $0.layer.cornerRadius = 28 $0.setImage(DSKitAsset.Assets.icPlus2.image, for: .normal) $0.tintColor = .white diff --git a/Projects/Features/HomeFeature/Sources/Views/Cells/CategoryCell.swift b/Projects/Features/HomeFeature/Sources/Views/Cells/CategoryCell.swift index 34b6b81..6e09893 100644 --- a/Projects/Features/HomeFeature/Sources/Views/Cells/CategoryCell.swift +++ b/Projects/Features/HomeFeature/Sources/Views/Cells/CategoryCell.swift @@ -84,7 +84,7 @@ final class CategoryCell: UICollectionViewCell { // MARK: - Configuration func configure(title: String, isSelected: Bool, isFirstItem: Bool) { - titleLabel.setText(.bodyMSB, text: title, color: isSelected ? UIColor.NDGL.Text.Interactive.inverse : UIColor(hexCode: "#757575")) + titleLabel.setText(.bodyMSB, text: title, color: isSelected ? UIColor(hexCode: "#FFFFFF") : UIColor(hexCode: "#757575")) iconImageView.isHidden = isFirstItem if isSelected { @@ -92,9 +92,9 @@ final class CategoryCell: UICollectionViewCell { containerView.layer.borderColor = UIColor.clear.cgColor iconImageView.tintColor = .white } else { - containerView.backgroundColor = UIColor.NDGL.Bg.primary - containerView.layer.borderColor = UIColor.NDGL.Border.secondary.cgColor - iconImageView.tintColor = UIColor.NDGL.Text.secondary + containerView.backgroundColor = UIColor(hexCode: "#FFFFFF") + containerView.layer.borderColor = UIColor(hexCode: "#D9D9D9").cgColor + iconImageView.tintColor = UIColor(hexCode: "#2C2C2C") } } } diff --git a/Projects/Features/HomeFeature/Sources/Views/Cells/RecommendContentCell.swift b/Projects/Features/HomeFeature/Sources/Views/Cells/RecommendContentCell.swift index 0baa879..7644164 100644 --- a/Projects/Features/HomeFeature/Sources/Views/Cells/RecommendContentCell.swift +++ b/Projects/Features/HomeFeature/Sources/Views/Cells/RecommendContentCell.swift @@ -42,7 +42,7 @@ final class RecommendContentCell: UICollectionViewCell { private let playIcon = UIImageView().then { $0.image = UIImage(systemName: "play.rectangle.fill") - $0.tintColor = UIColor.NDGL.Text.tertiary + $0.tintColor = UIColor(hexCode: "#444444") $0.contentMode = .scaleAspectFit } @@ -107,9 +107,9 @@ final class RecommendContentCell: UICollectionViewCell { // MARK: - Configuration func configure(with recommendation: Recommendation) { - titleLabel.setText(.bodyMSB, text: recommendation.title, color: UIColor.NDGL.Text.primary) - authorLabel.setText(.bodySR, text: recommendation.authorName, color: UIColor.NDGL.Text.tertiary) - durationLabel.setText(.bodySR, text: " · \(recommendation.duration)", color: UIColor.NDGL.Text.tertiary) + titleLabel.setText(.bodyMSB, text: recommendation.title, color: UIColor(hexCode: "#111111")) + authorLabel.setText(.bodySR, text: recommendation.authorName, color: UIColor(hexCode: "#444444")) + durationLabel.setText(.bodySR, text: " · \(recommendation.duration)", color: UIColor(hexCode: "#444444")) // URL 저장 및 이미지 로딩 (교차 검증) let thumbnailURL = recommendation.thumbnailURL diff --git a/Projects/Features/HomeFeature/Sources/Views/Cells/YoutuberContentCell.swift b/Projects/Features/HomeFeature/Sources/Views/Cells/YoutuberContentCell.swift index 59a2c2c..3bb16b6 100644 --- a/Projects/Features/HomeFeature/Sources/Views/Cells/YoutuberContentCell.swift +++ b/Projects/Features/HomeFeature/Sources/Views/Cells/YoutuberContentCell.swift @@ -98,8 +98,8 @@ final class YoutuberContentCell: UICollectionViewCell { } contentView.isHidden = false - titleLabel.setText(.bodyMSB, text: trip.title, color: UIColor.NDGL.Text.primary) - infoLabel.setText(.bodySM, text: "\(trip.authorName) · \(trip.destination) · \(trip.duration)", color: UIColor.NDGL.Text.tertiary) + titleLabel.setText(.bodyMSB, text: trip.title, color: UIColor(hexCode: "#111111")) + infoLabel.setText(.bodySM, text: "\(trip.authorName) · \(trip.destination) · \(trip.duration)", color: UIColor(hexCode: "#444444")) // URL 저장 및 이미지 로딩 (교차 검증) let thumbnailURL = trip.thumbnailURL diff --git a/Projects/Features/HomeFeature/Sources/Views/MyTravelView.swift b/Projects/Features/HomeFeature/Sources/Views/MyTravelView.swift index b915b47..f5eaa21 100644 --- a/Projects/Features/HomeFeature/Sources/Views/MyTravelView.swift +++ b/Projects/Features/HomeFeature/Sources/Views/MyTravelView.swift @@ -15,10 +15,10 @@ import Then final class MyTravelView: UIView { private let messageLabel = UILabel().then { - $0.setText(.bodyLSB, text: "아직 등록된 여행지가 없어요", color: UIColor.NDGL.Text.primary) + $0.setText(.bodyLSB, text: "아직 등록된 여행지가 없어요", color: UIColor(hexCode: "#111111")) } private let subMessageLabel = UILabel().then { - $0.setText(.bodyMM, text: "새 여행 일정을 만들어 보세요!", color: UIColor.NDGL.Text.disabled) + $0.setText(.bodyMM, text: "새 여행 일정을 만들어 보세요!", color: UIColor(hexCode: "#757575")) } private let imageView = UIImageView(image: DSKitAsset.Assets.icAirplane1.image) @@ -32,7 +32,7 @@ final class MyTravelView: UIView { required init?(coder: NSCoder) { fatalError() } private func setupUI() { - backgroundColor = UIColor.NDGL.Bg.primary + backgroundColor = UIColor(hexCode: "#FFFFFF") layer.cornerRadius = 4 layer.borderWidth = 1.0 layer.borderColor = UIColor.init(hexCode: "#F1F1F1").cgColor diff --git a/Projects/Features/TabBarFeature/Sources/Components/NDGLTabItem.swift b/Projects/Features/TabBarFeature/Sources/Components/NDGLTabItem.swift index d5eb918..cb8c954 100644 --- a/Projects/Features/TabBarFeature/Sources/Components/NDGLTabItem.swift +++ b/Projects/Features/TabBarFeature/Sources/Components/NDGLTabItem.swift @@ -34,7 +34,7 @@ final class NDGLTabItem: UIControl { func setup(title: String, image: UIImage) { iconView.image = image - titleLabel.setText(.bodyLM, text: title, color: .NDGL.Text.Interactive.inverse) + titleLabel.setText(.bodyLM, text: title, color: UIColor(hexCode: "#FFFFFF")) updateState(animation: false) } } @@ -88,8 +88,8 @@ private extension NDGLTabItem { self.titleLabel.alpha = self.isTabSelected ? 1 : 0 self.iconView.tintColor = self.isTabSelected - ? .NDGL.Icon.Interactive.inverse - : .NDGL.Icon.secondary + ? UIColor(hexCode: "#FFFFFF") + : UIColor(hexCode: "#2C2C2C") self.containerStackView.layoutIfNeeded() diff --git a/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift b/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift index ac20c18..80c4464 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift @@ -119,7 +119,7 @@ private extension TabBarViewController { if #available(iOS 26.0, *) { let glass = UIGlassEffect(style: .regular) glass.isInteractive = true - glass.tintColor = UIColor.NDGL.Text.primary + glass.tintColor = UIColor(hexCode: "#111111") $0.effect = glass } else { $0.effect = UIBlurEffect(style: .dark) diff --git a/Projects/Features/TravelFeature/Sources/TravelViewController.swift b/Projects/Features/TravelFeature/Sources/TravelViewController.swift index a33e414..3136ab5 100644 --- a/Projects/Features/TravelFeature/Sources/TravelViewController.swift +++ b/Projects/Features/TravelFeature/Sources/TravelViewController.swift @@ -28,12 +28,12 @@ final class TravelViewController: UIViewController, TravelPresentable, TravelVie // MARK: - UI Components private let titleLabel = UILabel().then { - $0.setText(.subTitleLSB, text: "다가오는 여행", color: .NDGL.Text.primary) + $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.NDGL.Icon.primary + $0.tintColor = UIColor(hexCode: "#111111") } private let collectionView: UICollectionView = { @@ -49,7 +49,7 @@ final class TravelViewController: UIViewController, TravelPresentable, TravelVie }() private let emptyStateLabel = UILabel().then { - $0.setText(.bodyMR, text: "아직 등록된 여행이 없어요", color: .NDGL.Text.tertiary) + $0.setText(.bodyMR, text: "아직 등록된 여행이 없어요", color: UIColor(hexCode: "#444444")) $0.isHidden = true } @@ -75,7 +75,7 @@ final class TravelViewController: UIViewController, TravelPresentable, TravelVie // MARK: - Setup private func setupUI() { - view.backgroundColor = UIColor.NDGL.Bg.primary + view.backgroundColor = UIColor(hexCode: "#FFFFFF") [titleLabel, menuButton, collectionView, emptyStateLabel, loadingIndicator].forEach { view.addSubview($0) diff --git a/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift b/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift index d193458..dafbe82 100644 --- a/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift +++ b/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift @@ -111,9 +111,9 @@ final class UpcomingTripCell: UICollectionViewCell { let dDayText = dDay > 0 ? "D-\(dDay)" : (dDay == 0 ? "D-Day" : "D+\(abs(dDay))") dDayLabel.setText(.bodySSB, text: dDayText, color: .white) - titleLabel.setText(.bodyMSB, text: trip.title, color: UIColor.NDGL.Text.primary) + titleLabel.setText(.bodyMSB, text: trip.title, color: UIColor(hexCode: "#111111")) - dateLabel.setText(.bodySR, text: trip.dateRangeString, color: UIColor.NDGL.Text.tertiary) + dateLabel.setText(.bodySR, text: trip.dateRangeString, color: UIColor(hexCode: "#444444")) if let urlString = trip.thumbnailURL, let url = URL(string: urlString) { thumbnailImageView.kf.setImage( diff --git a/Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift b/Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift index d95422a..b10b5f1 100644 --- a/Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift +++ b/Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift @@ -43,7 +43,7 @@ public final class BottomPlacedButton: UIButton { contentStackView.isUserInteractionEnabled = false iconImageView.contentMode = .scaleAspectFit - iconImageView.tintColor = UIColor.NDGL.Text.Interactive.inverse + iconImageView.tintColor = UIColor(hexCode: "#FFFFFF") iconImageView.isUserInteractionEnabled = false titleTextLabel.isUserInteractionEnabled = false @@ -69,7 +69,7 @@ public final class BottomPlacedButton: UIButton { titleTextLabel.setText( .subTitleMSB, text: title, - color: UIColor.NDGL.Text.Interactive.inverse + color: UIColor(hexCode: "#FFFFFF") ) if let icon = icon { From df1d09a4bef3ef703e96692e3b245ec912f68315 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Thu, 29 Jan 2026 15:51:48 +0900 Subject: [PATCH 19/20] =?UTF-8?q?chore:=20#10=20-=20DSKitAsset.Colors=20?= =?UTF-8?q?=EC=83=89=EC=83=81=EC=9D=84=20=EC=93=B0=EB=8A=94=EA=B3=B3=20hex?= =?UTF-8?q?code=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/Calendar/CalendarDayCell.swift | 4 +-- .../Sources/Views/Calendar/CalendarView.swift | 2 +- .../Sources/Views/MediaInfoView.swift | 2 +- .../Views/PlaceDetailBottomSheetView.swift | 2 +- .../Sources/Views/UpcomingTripCell.swift | 2 +- .../DSKit/Sources/Component/NDGLBtn.swift | 26 +++++++++---------- .../Sources/Component/NDGLNavigationBar.swift | 10 +++---- .../Sources/Component/NDGLSearchBar.swift | 16 ++++++------ 8 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift index 80768e8..94fcfbc 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift @@ -112,12 +112,12 @@ final class CalendarDayCell: UICollectionViewCell { textColor = UIColor(hexCode: "#757575") } else if isPastDate { if isSunday { - textColor = DSKitAsset.Colors.red300.color + textColor = UIColor(hexCode: "#FFA2A2") } else { textColor = UIColor(hexCode: "#757575") } } else if isSunday { - textColor = DSKitAsset.Colors.red500.color + textColor = UIColor(hexCode: "#FB2C36") } else { textColor = UIColor(hexCode: "#111111") } diff --git a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift index 9bb7cff..851c5ae 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift @@ -133,7 +133,7 @@ final class CalendarView: UIView { for (index, weekday) in weekdays.enumerated() { let label = UILabel() - let color = index == 0 ? DSKitAsset.Colors.red500.color : UIColor(hexCode: "#2C2C2C") + let color = index == 0 ? UIColor(hexCode: "#FB2C36") : UIColor(hexCode: "#2C2C2C") label.setText(.bodySR, text: weekday, color: color, alignment: .center) weekdayStackView.addArrangedSubview(label) } diff --git a/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift index 758d7d7..9b9f932 100644 --- a/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift @@ -278,7 +278,7 @@ final class MediaInfoView: UIView { // 예산 라벨 (1인 기준 예산 + 금액) - 파란색 let budgetText = "1인 기준 예산 \(formatBudget(detail.budgetPerPerson))" - budgetLabel.setText(.bodyLR, text: budgetText, color: DSKitAsset.Colors.primary500.color) + budgetLabel.setText(.bodyLR, text: budgetText, color: UIColor(hexCode: "#28A745")) // 요약 라벨 summaryLabel.setText(.bodyMM, text: detail.youtube.summary, color: UIColor(hexCode: "#2C2C2C")) diff --git a/Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift b/Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift index 72fc8d7..1d17853 100644 --- a/Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift @@ -219,7 +219,7 @@ final class PlaceDetailBottomSheetView: UIView { titleLabel.setText(.subTitleLSB, text: place.place.name, color: UIColor(hexCode: "#111111")) // 카테고리 (기본값: 관광명소) - categoryLabel.setText(.bodySR, text: "🏔 관광명소", color: DSKitAsset.Colors.primary500.color) + categoryLabel.setText(.bodySR, text: "🏔 관광명소", color: UIColor(hexCode: "#757575")) // 체류 예상 시간 let hours = place.estimatedDuration / 60 diff --git a/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift b/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift index dafbe82..272d553 100644 --- a/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift +++ b/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift @@ -26,7 +26,7 @@ final class UpcomingTripCell: UICollectionViewCell { } private let dDayBadge = UIView().then { - $0.backgroundColor = DSKitAsset.Colors.primary500.color + $0.backgroundColor = UIColor(hexCode: "#757575") $0.layer.cornerRadius = 10 } diff --git a/Projects/Modules/DSKit/Sources/Component/NDGLBtn.swift b/Projects/Modules/DSKit/Sources/Component/NDGLBtn.swift index 769860d..396b415 100644 --- a/Projects/Modules/DSKit/Sources/Component/NDGLBtn.swift +++ b/Projects/Modules/DSKit/Sources/Component/NDGLBtn.swift @@ -100,8 +100,8 @@ private extension NDGLBtn { switch button.state { case .disabled: - backgroundColor = DSKitAsset.Colors.black300.color - foregroundColor = DSKitAsset.Colors.black400.color + backgroundColor = UIColor(hexCode: "#B3B3B3") + foregroundColor = UIColor(hexCode: "#757575") case .highlighted: backgroundColor = self.style.backgroundColor.withAlphaComponent(0.9) foregroundColor = self.style.contentsColor.withAlphaComponent(0.9) @@ -157,26 +157,26 @@ public enum NDGLBtnStyle { /// 스타일별 전경색(텍스트 및 아이콘) var contentsColor: UIColor { switch self { - case .primary: DSKitAsset.Colors.white.color - case .secondary: DSKitAsset.Colors.black700.color - case .destructive: DSKitAsset.Colors.red500.color - case .outline: DSKitAsset.Colors.black600.color + case .primary: UIColor(hexCode: "#FFFFFF") + case .secondary: UIColor(hexCode: "#2C2C2C") + case .destructive: UIColor(hexCode: "#FB2C36") + case .outline: UIColor(hexCode: "#383838") } } - + /// 스타일별 배경색 var backgroundColor: UIColor { switch self { - case .primary: DSKitAsset.Colors.black900.color - case .secondary: DSKitAsset.Colors.black50.color - case .destructive: DSKitAsset.Colors.red50.color - case .outline: DSKitAsset.Colors.white.color + case .primary: UIColor(hexCode: "#111111") + case .secondary: UIColor(hexCode: "#F5F5F5") + case .destructive: UIColor(hexCode: "#FEF2F2") + case .outline: UIColor(hexCode: "#FFFFFF") } } - + /// `outline` 스타일 시 적용되는 테두리 컬러 var strokeColor: UIColor { - DSKitAsset.Colors.black200.color + UIColor(hexCode: "#D9D9D9") } } diff --git a/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift b/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift index 8cf5a8b..fea0873 100644 --- a/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift +++ b/Projects/Modules/DSKit/Sources/Component/NDGLNavigationBar.swift @@ -109,12 +109,12 @@ private extension NDGLNavigationBar { titleLabel.setText( .bodyLM, text: title, - color: DSKitAsset.Colors.black700.color, + color: UIColor(hexCode: "#2C2C2C"), alignment: .center ) } - - let normalColor = DSKitAsset.Colors.black600.color + + let normalColor = UIColor(hexCode: "#383838") [(leadingButton, leading), (trailingButton, trailing), (trailing2Button, trailing2)] .forEach { button, image in @@ -188,9 +188,9 @@ public enum NDGLNavigationBarStyle { var backgroundColor: UIColor { switch self { case .white: - return DSKitAsset.Colors.white.color + return UIColor(hexCode: "#FFFFFF") case .gray: - return DSKitAsset.Colors.black50.color + return UIColor(hexCode: "#F5F5F5") } } } diff --git a/Projects/Modules/DSKit/Sources/Component/NDGLSearchBar.swift b/Projects/Modules/DSKit/Sources/Component/NDGLSearchBar.swift index b20106b..2cf865f 100644 --- a/Projects/Modules/DSKit/Sources/Component/NDGLSearchBar.swift +++ b/Projects/Modules/DSKit/Sources/Component/NDGLSearchBar.swift @@ -93,30 +93,30 @@ public final class NDGLSearchBar: UIView { private extension NDGLSearchBar { func setStyle(_ placeholder: String, _ leading: UIImage, _ trailing: UIImage) { searchContainerView.do { - $0.backgroundColor = DSKitAsset.Colors.black100.color + $0.backgroundColor = UIColor(hexCode: "#E6E6E6") $0.layer.cornerRadius = 22.adjustedH $0.clipsToBounds = true } - + textField.do { var placeHolderAttributes = UIFont.NDGL.bodyLR.attributes - placeHolderAttributes[.foregroundColor] = DSKitAsset.Colors.black400.color + placeHolderAttributes[.foregroundColor] = UIColor(hexCode: "#757575") $0.attributedPlaceholder = NSAttributedString( string: placeholder, attributes: placeHolderAttributes ) $0.font = UIFont.NDGL.bodyLR.font - $0.textColor = DSKitAsset.Colors.black700.color - $0.tintColor = DSKitAsset.Colors.black400.color - + $0.textColor = UIColor(hexCode: "#2C2C2C") + $0.tintColor = UIColor(hexCode: "#757575") + $0.autocapitalizationType = .none $0.autocorrectionType = .no $0.spellCheckingType = .no $0.returnKeyType = .search } - - let normalColor = DSKitAsset.Colors.black600.color + + let normalColor = UIColor(hexCode: "#383838") [(leadingButton, leading), (trailingButton, trailing)].forEach { button, image in var config = UIButton.Configuration.plain() config.image = image.resize(targetSize: 28.adjustedH).withRenderingMode(.alwaysTemplate) From c0d66d1834dfa581c22e105bbd5e3f0af3d26dc5 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Thu, 29 Jan 2026 15:57:45 +0900 Subject: [PATCH 20/20] =?UTF-8?q?rename:=20#10:=20tabbaritemtype=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/TabBarFeature/Sources/TabBarViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift b/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift index 6ddff03..954ed28 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarViewController.swift @@ -27,7 +27,7 @@ public final class TabBarViewController: UITabBarController, TabBarPresentable, weak var listener: TabBarPresentableListener? private let disposeBag = DisposeBag() - private let tabTypes: [TabBarItemType] = [.information, .home, .myTrip] + private let tabTypes: [TabBarItemType] = [.travelTool, .home, .myTrip] // MARK: - UI Components