diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift index 015be31..f12a831 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift @@ -11,6 +11,8 @@ public extension TargetDependency { struct Features { public struct Home {} public struct TabBar {} + public struct Follow {} + public struct Travel {} } struct Modules {} @@ -49,3 +51,15 @@ 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) +} + +public extension TargetDependency.Features.Travel { + static let group = "Travel" + + static let feature = TargetDependency.Features.project(name: "Feature", group: group) +} 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..32f04f0 --- /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] { + itineraries.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, + thumbnail: thumbnail, + latitude: latitude, + longitude: longitude, + name: name, + regularOpeningHours: regularOpeningHours + ) + } +} 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/Follow/FollowRepositoryProtocol.swift b/Projects/Domain/Sources/Interface/Follow/FollowRepositoryProtocol.swift new file mode 100644 index 0000000..621286d --- /dev/null +++ b/Projects/Domain/Sources/Interface/Follow/FollowRepositoryProtocol.swift @@ -0,0 +1,17 @@ +// +// FollowRepositoryProtocol.swift +// Domain +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public protocol FollowRepositoryProtocol { + /// 여행 상세 정보 조회 + func fetchTravelDetail(id: Int) async -> TravelDetail? + + /// 일차별 장소 목록 조회 + func fetchPlaces(travelId: Int, day: Int) async -> [TravelPlace] +} 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/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/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..63ebf16 --- /dev/null +++ b/Projects/Domain/Sources/Model/Travel/TravelPlace.swift @@ -0,0 +1,61 @@ +// +// 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 thumbnail: String? + public let latitude: Double + public let longitude: Double + public let name: String + public let regularOpeningHours: String? + + public init( + googlePlaceId: String, + thumbnail: String? = nil, + latitude: Double, + longitude: Double, + name: String, + regularOpeningHours: String? + ) { + self.googlePlaceId = googlePlaceId + self.thumbnail = thumbnail + self.latitude = latitude + self.longitude = longitude + self.name = name + self.regularOpeningHours = regularOpeningHours + } +} 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..e01bedd --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift @@ -0,0 +1,60 @@ +// +// FollowDetailBuilder.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain +import RIBs + +// MARK: - FollowDetailDependency + +public protocol FollowDetailDependency: Dependency { + var followRepository: FollowRepositoryProtocol { get } +} + +// MARK: - FollowDetailComponent + +final class FollowDetailComponent: Component, TripCalendarDependency { + var repository: FollowRepositoryProtocol { + dependency.followRepository + } +} + +// 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 { + let component = FollowDetailComponent(dependency: dependency) + let viewController = FollowDetailViewController() + let interactor = FollowDetailInteractor( + presenter: viewController, + repository: component.repository, + recommendationId: recommendationId + ) + interactor.listener = listener + + let tripCalendarBuilder = TripCalendarBuilder(dependency: component) + + let router = FollowDetailRouter( + interactor: interactor, + viewController: viewController, + tripCalendarBuilder: tripCalendarBuilder + ) + + return router + } +} diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift new file mode 100644 index 0000000..99167b1 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -0,0 +1,176 @@ +// +// FollowDetailInteractor.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain +import Foundation +import RIBs +import RxSwift + +// MARK: - FollowDetailListener + +public protocol FollowDetailListener: AnyObject { + func followDetailDidTapClose() + func followDetailDidAddTrip(title: String, startDate: Date, endDate: Date) +} + +// MARK: - FollowDetailPresentable + +protocol FollowDetailPresentable: Presentable { + var listener: FollowDetailPresentableListener? { get set } + + func showLoading() + func hideLoading() + func updateTravelDetail(_ detail: TravelDetail) + func updatePlaces(_ places: [TravelPlace]) + func updateBudget(_ budget: Int) + func showPlaceDetail(_ place: TravelPlace) +} + +// MARK: - FollowDetailPresentableListener + +protocol FollowDetailPresentableListener: AnyObject { + func didTapCloseButton() + func didTapAddToTrip() + func didSelectDay(_ day: Int) + func didSelectPlace(_ place: TravelPlace) +} + +// MARK: - FollowDetailInteractor + +final class FollowDetailInteractor: PresentableInteractor, FollowDetailInteractable { + + weak var router: FollowDetailRouting? + weak var listener: FollowDetailListener? + + private let repository: FollowRepositoryProtocol + private let disposeBag = DisposeBag() + + // MARK: - Data (Source of Truth) + + private let recommendationId: Int + private var travelDetail: TravelDetail? + private var currentDay: Int = 1 + private var placesByDay: [Int: [TravelPlace]] = [:] + + init( + presenter: FollowDetailPresentable, + repository: FollowRepositoryProtocol, + recommendationId: Int + ) { + self.repository = repository + self.recommendationId = recommendationId + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + loadTravelDetail() + } + + override func willResignActive() { + super.willResignActive() + } + + // MARK: - Private Methods + + private func loadTravelDetail() { + Task { + await MainActor.run { + presenter.showLoading() + } + + guard let detail = await repository.fetchTravelDetail(id: recommendationId) else { + await MainActor.run { + presenter.hideLoading() + } + return + } + + let places = await repository.fetchPlaces(travelId: recommendationId, day: 1) + + await MainActor.run { + self.travelDetail = detail + self.placesByDay[1] = places + presenter.updateTravelDetail(detail) + presenter.updatePlaces(places) + updateBudgetForDay(1) + presenter.hideLoading() + } + } + } + + private func loadPlaces(for day: Int) { + if let cachedPlaces = placesByDay[day] { + presenter.updatePlaces(cachedPlaces) + updateBudgetForDay(day) + return + } + + Task { + await MainActor.run { + presenter.showLoading() + } + + let places = await repository.fetchPlaces(travelId: recommendationId, day: day) + + await MainActor.run { + self.placesByDay[day] = places + presenter.updatePlaces(places) + updateBudgetForDay(day) + presenter.hideLoading() + } + } + } + + private func updateBudgetForDay(_ day: Int) { + guard let detail = travelDetail else { return } + let dailyBudget = detail.budgetPerPerson / detail.days + presenter.updateBudget(dailyBudget) + } +} + +// MARK: - FollowDetailPresentableListener + +extension FollowDetailInteractor: FollowDetailPresentableListener { + func didTapCloseButton() { + listener?.followDetailDidTapClose() + } + + func didTapAddToTrip() { + router?.routeToTripCalendar() + } + + func didSelectDay(_ day: Int) { + guard day != currentDay else { return } + currentDay = day + loadPlaces(for: day) + } + + func didSelectPlace(_ place: TravelPlace) { + presenter.showPlaceDetail(place) + } +} + +// MARK: - TripCalendarListener + +extension FollowDetailInteractor: TripCalendarListener { + func tripCalendarDidSelectRange(startDate: Date, endDate: Date) { + router?.detachTripCalendar() + + // 여행 제목 (city + "여행") + let tripTitle = "\(travelDetail?.city ?? "새로운") 여행" + + // Home으로 돌아가면서 Travel 탭으로 이동하도록 알림 + listener?.followDetailDidAddTrip(title: tripTitle, startDate: startDate, endDate: endDate) + } + + func tripCalendarDidCancel() { + router?.detachTripCalendar() + } +} diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift b/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift new file mode 100644 index 0000000..fe8a3ee --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift @@ -0,0 +1,72 @@ +// +// FollowDetailRouter.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +// MARK: - FollowDetailInteractable + +protocol FollowDetailInteractable: Interactable, TripCalendarListener { + var router: FollowDetailRouting? { get set } + var listener: FollowDetailListener? { get set } +} + +// MARK: - FollowDetailViewControllable + +public protocol FollowDetailViewControllable: ViewControllable { + func present(_ viewController: ViewControllable) + func dismiss(_ viewController: ViewControllable) +} + +// MARK: - FollowDetailRouting + +public protocol FollowDetailRouting: ViewableRouting { + func routeToTripCalendar() + func detachTripCalendar() +} + +// MARK: - FollowDetailRouter + +final class FollowDetailRouter: ViewableRouter, FollowDetailRouting { + + private let tripCalendarBuilder: TripCalendarBuildable + private var tripCalendarRouter: TripCalendarRouting? + + init( + interactor: FollowDetailInteractable, + 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 } + + // TripCalendar VC가 아직 네비게이션 스택에 있는 경우에만 pop + if let navController = viewController.uiviewController.navigationController, + navController.viewControllers.contains(router.viewControllable.uiviewController) { + viewController.dismiss(router.viewControllable) + } + + detachChild(router) + tripCalendarRouter = nil + } +} diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift new file mode 100644 index 0000000..ad59824 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift @@ -0,0 +1,317 @@ +// +// FollowDetailViewController.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import Domain +import DSKit +import RIBs +import RxSwift +import SnapKit +import Then +import UIKit + +// MARK: - FollowDetailViewController + +final class FollowDetailViewController: UIViewController, FollowDetailPresentable, FollowDetailViewControllable { + + // MARK: - Properties + + weak var listener: FollowDetailPresentableListener? + + private let disposeBag = DisposeBag() + private var dayCollectionViewOriginY: CGFloat = .greatestFiniteMagnitude + private var currentSelectedDay: Int = 1 + private var totalDays: Int = 0 + + // MARK: - UI Components (Scroll) + + private let scrollView = UIScrollView().then { + $0.showsVerticalScrollIndicator = true + $0.contentInset.bottom = 100 + } + + private let contentView = UIView() + + private let mediaInfoView = MediaInfoView() + + private let dayCollectionView = DayCollectionView() + + private let budgetView = BudgetView() + + private let mapView = TravelMapView() + + private let placeListCollectionView = PlaceListCollectionView() + + // MARK: - UI Components (Sticky Header) + + private let stickyHeaderView = UIView().then { + $0.backgroundColor = UIColor(hexCode: "#FFFFFF") + $0.isHidden = true + } + + private let stickyDayCollectionView = DayCollectionView() + + // MARK: - UI Components (Fixed) + + private let addToTripButton = BottomPlacedButton(title: "여행 따라가기") + + private let loadingIndicator = UIActivityIndicatorView(style: .large).then { + $0.hidesWhenStopped = true + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupConstraints() + setupDelegates() + setupActions() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(false, animated: animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent { + listener?.didTapCloseButton() + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + dayCollectionViewOriginY = dayCollectionView.frame.origin.y + } + + // MARK: - Setup + + private func setupUI() { + view.backgroundColor = UIColor(hexCode: "#FFFFFF") + + view.addSubview(scrollView) + scrollView.addSubview(contentView) + [mediaInfoView, dayCollectionView, budgetView, mapView, placeListCollectionView].forEach { + contentView.addSubview($0) + } + + view.addSubview(stickyHeaderView) + stickyHeaderView.addSubview(stickyDayCollectionView) + + view.addSubview(addToTripButton) + view.addSubview(loadingIndicator) + } + + private func setupConstraints() { + loadingIndicator.snp.makeConstraints { + $0.center.equalToSuperview() + } + + scrollView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.leading.trailing.bottom.equalToSuperview() + } + + contentView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.width.equalToSuperview() + } + + mediaInfoView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + } + + dayCollectionView.snp.makeConstraints { + $0.top.equalTo(mediaInfoView.snp.bottom).offset(24) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(30) + } + + budgetView.snp.makeConstraints { + $0.top.equalTo(dayCollectionView.snp.bottom).offset(16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(44) + } + + mapView.snp.makeConstraints { + $0.top.equalTo(budgetView.snp.bottom).offset(16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(200) + } + + placeListCollectionView.snp.makeConstraints { + $0.top.equalTo(mapView.snp.bottom).offset(16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.bottom.equalToSuperview().offset(-16) + $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) + $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) + $0.height.equalTo(52) + } + } + + private func setupDelegates() { + scrollView.delegate = self + mediaInfoView.delegate = self + dayCollectionView.dayDelegate = self + stickyDayCollectionView.dayDelegate = self + placeListCollectionView.placeDelegate = self + } + + private func setupActions() { + addToTripButton.addTarget(self, action: #selector(addToTripButtonTapped), for: .touchUpInside) + } + + // MARK: - Actions + + @objc private func addToTripButtonTapped() { + listener?.didTapAddToTrip() + } + + 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: - FollowDetailPresentable + +extension FollowDetailViewController { + + func showLoading() { + loadingIndicator.startAnimating() + } + + func hideLoading() { + loadingIndicator.stopAnimating() + } + + 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]) { + mapView.configure(with: places) + placeListCollectionView.applySnapshot(places: places) + + 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)) + } + } + + 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 + +extension FollowDetailViewController { + + func present(_ viewController: ViewControllable) { + navigationController?.pushViewController(viewController.uiviewController, animated: true) + } + + 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 + self?.dayCollectionViewOriginY = self?.dayCollectionView.frame.origin.y ?? 0 + } + } +} + +// MARK: - DayCollectionViewDelegate + +extension FollowDetailViewController: DayCollectionViewDelegate { + + func dayCollectionView(_ collectionView: DayCollectionView, didSelectDay day: Int) { + syncDaySelection(day: day) + listener?.didSelectDay(day) + } +} + +// MARK: - PlaceListCollectionViewDelegate + +extension FollowDetailViewController: PlaceListCollectionViewDelegate { + + func placeListCollectionView(_ collectionView: PlaceListCollectionView, didSelectPlace place: TravelPlace) { + listener?.didSelectPlace(place) + } +} diff --git a/Projects/Features/FollowFeature/Sources/Mock/MockFollowDetailRepository.swift b/Projects/Features/FollowFeature/Sources/Mock/MockFollowDetailRepository.swift new file mode 100644 index 0000000..b9ecaa0 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Mock/MockFollowDetailRepository.swift @@ -0,0 +1,175 @@ +// +// MockFollowDetailRepository.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain +import Foundation + +final class MockFollowDetailRepository: FollowRepositoryProtocol { + + func fetchTravelDetail(id: Int) async -> 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", + thumbnail: "https://example.com/airport.jpg", + latitude: 35.6585805, + longitude: 139.7454329, + name: "인도 국제 공항", + regularOpeningHours: "00:00~24:00" + ) + ), + TravelPlace( + id: 2, + day: 1, + sequence: 2, + travelerTip: "바라나시 시장 투어는 현지 가이드와 함께 하는 것이 좋습니다.", + 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" + ) + ), + TravelPlace( + id: 3, + day: 1, + sequence: 3, + travelerTip: "현지인들이 추천하는 맛집입니다. 탄두리 치킨이 맛있어요.", + estimatedDuration: 60, + place: PlaceInfo( + googlePlaceId: "ChIJabc123", + thumbnail: "https://example.com/chicken.jpg", + latitude: 35.6600000, + longitude: 139.7100000, + name: "짱짱 탄두리 치킨", + regularOpeningHours: "11:00~22:00" + ) + ), + TravelPlace( + id: 4, + day: 1, + sequence: 4, + travelerTip: "현지 커피를 맛볼 수 있는 카페입니다.", + estimatedDuration: 30, + place: PlaceInfo( + googlePlaceId: "ChIJdef456", + thumbnail: "https://example.com/cafe.jpg", + latitude: 35.6610000, + longitude: 139.7150000, + name: "맛있다 카페", + regularOpeningHours: "08:00~20:00" + ) + ), + TravelPlace( + id: 5, + day: 1, + sequence: 5, + travelerTip: "깔끔한 숙소입니다. 조식이 포함되어 있어요.", + estimatedDuration: 480, + place: PlaceInfo( + googlePlaceId: "ChIJghi789", + thumbnail: "https://example.com/hotel.jpg", + latitude: 35.6620000, + longitude: 139.7200000, + name: "쿨쿨호텔", + regularOpeningHours: nil + ) + ) + ] + case 2: + return [ + TravelPlace( + id: 6, + day: 2, + sequence: 1, + travelerTip: "아침 일찍 가면 사람이 적어서 좋습니다.", + estimatedDuration: 120, + place: PlaceInfo( + googlePlaceId: "ChIJaaa111", + thumbnail: "https://example.com/tajmahal.jpg", + latitude: 35.6700000, + longitude: 139.7300000, + name: "타지마할", + regularOpeningHours: "06:00~18:00" + ) + ), + TravelPlace( + id: 7, + day: 2, + sequence: 2, + travelerTip: "현지 전통 음식을 맛볼 수 있습니다.", + estimatedDuration: 60, + place: PlaceInfo( + googlePlaceId: "ChIJbbb222", + thumbnail: "https://example.com/restaurant.jpg", + latitude: 35.6710000, + longitude: 139.7310000, + name: "전통 음식점", + regularOpeningHours: "10:00~21:00" + ) + ) + ] + case 3: + return [ + TravelPlace( + id: 8, + day: 3, + sequence: 1, + travelerTip: "쇼핑하기 좋은 곳입니다.", + estimatedDuration: 180, + place: PlaceInfo( + googlePlaceId: "ChIJccc333", + thumbnail: "https://example.com/market2.jpg", + latitude: 35.6800000, + longitude: 139.7400000, + name: "현지 시장", + regularOpeningHours: "09:00~20:00" + ) + ) + ] + default: + return [] + } + } +} 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..24ab76f --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/TripCalendar/TripCalendarViewController.swift @@ -0,0 +1,119 @@ +// +// 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 calendarView = CalendarView() + + private let completeButton = BottomPlacedButton(title: "완료") + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupConstraints() + setupDelegates() + setupActions() + updateCompleteButtonState() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent { + listener?.didTapBackButton() + } + } + + // MARK: - Setup + + private func setupUI() { + title = "새로운 여행 만들기" + view.backgroundColor = UIColor(hexCode: "#FFFFFF") + + [calendarView, completeButton].forEach { + view.addSubview($0) + } + } + + private func setupConstraints() { + calendarView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide).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 setupDelegates() { + calendarView.delegate = self + } + + private func setupActions() { + completeButton.addTarget(self, action: #selector(completeButtonTapped), for: .touchUpInside) + } + + // MARK: - Actions + + @objc private func completeButtonTapped() { + guard let start = selectedStartDate, let end = selectedEndDate else { return } + listener?.didTapCompleteButton(startDate: start, endDate: end) + } + + private func updateCompleteButtonState() { + let isEnabled = selectedStartDate != nil && selectedEndDate != nil + + if isEnabled { + completeButton.backgroundColor = UIColor(hexCode: "#111111") + completeButton.isEnabled = true + } else { + completeButton.backgroundColor = UIColor(hexCode: "#B3B3B3") + 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/BudgetView.swift b/Projects/Features/FollowFeature/Sources/Views/BudgetView.swift new file mode 100644 index 0000000..ca50ed9 --- /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(hexCode: "#2C2C2C") + $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(hexCode: "#FFFFFF") + layer.cornerRadius = 8 + layer.borderWidth = 1 + layer.borderColor = UIColor(hexCode: "#D9D9D9").cgColor + + [iconImageView, titleLabel, budgetLabel].forEach { + addSubview($0) + } + + titleLabel.setText(.bodyMM, text: "1인 기준 여행 예산 :", color: UIColor(hexCode: "#2C2C2C")) + } + + 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(hexCode: "#111111")) + } +} 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..94fcfbc --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarDayCell.swift @@ -0,0 +1,170 @@ +// +// 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") + $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)" + + backgroundCircleView.isHidden = true + backgroundCircleView.layer.borderWidth = 0 + rangeBackgroundView.isHidden = true + + var textColor: UIColor + + if !isCurrentMonth { + textColor = UIColor(hexCode: "#757575") + } else if isPastDate { + if isSunday { + textColor = UIColor(hexCode: "#FFA2A2") + } else { + textColor = UIColor(hexCode: "#757575") + } + } else if isSunday { + textColor = UIColor(hexCode: "#FB2C36") + } else { + textColor = UIColor(hexCode: "#111111") + } + + switch selectionState { + case .startDate, .endDate: + backgroundCircleView.backgroundColor = UIColor(hexCode: "#38A169") + backgroundCircleView.isHidden = false + textColor = UIColor(hexCode: "#FFFFFF") + + case .inRange: + rangeBackgroundView.isHidden = false + + 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..851c5ae --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/Calendar/CalendarView.swift @@ -0,0 +1,433 @@ +// +// 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(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(hexCode: "#111111") + } + + private let nextMonthButton = UIButton(type: .system).then { + $0.setImage(DSKitAsset.Assets.icChevronRight3.image, for: .normal) + $0.tintColor = UIColor(hexCode: "#111111") + } + + 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(hexCode: "#FFFFFF") + + [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 ? UIColor(hexCode: "#FB2C36") : UIColor(hexCode: "#2C2C2C") + 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) + + 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 + ) + + if let day = day, let date = dateFor(day: day) { + let isStart = selectedStartDate.map { isSameDay(date, $0) } ?? false + let isEnd = selectedEndDate.map { isSameDay(date, $0) } ?? false + 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 { + selectedStartDate = date + selectedEndDate = nil + } else if let startDate = selectedStartDate, selectedEndDate == nil { + if date < startDate { + selectedEndDate = selectedStartDate + selectedStartDate = date + } else if isSameDay(date, startDate) { + selectedStartDate = nil + selectedEndDate = nil + delegate?.calendarViewDidClearSelection(self) + } else { + selectedEndDate = date + } + } else { + selectedStartDate = date + selectedEndDate = nil + } + + collectionView.reloadData() + + 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 { + 2 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + if component == 0 { + let currentYear = calendar.component(.year, from: Date()) + return 2099 - currentYear + 1 + } else { + 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/FollowFeature/Sources/Views/Cells/DayCell.swift b/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift new file mode 100644 index 0000000..adc0292 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/Cells/DayCell.swift @@ -0,0 +1,88 @@ +// +// DayCell.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import DSKit +import SnapKit +import Then +import UIKit + +final class DayCell: UICollectionViewCell { + + static let identifier = "DayCell" + + // MARK: - UI Components + + private let containerView = UIView().then { + $0.layer.cornerRadius = 15 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor(hexCode: "#D9D9D9").cgColor + $0.backgroundColor = UIColor(hexCode: "#FFFFFF") + } + + 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.text = "\(day)일차" + updateSelectionState() + } + + private func updateSelectionState() { + if isSelected { + containerView.backgroundColor = UIColor(hexCode: "#2C2C2C") + containerView.layer.borderWidth = 0 + dayLabel.font = DSKitFontFamily.Pretendard.medium.font(size: 14) + dayLabel.textColor = UIColor(hexCode: "#FFFFFF") + } else { + containerView.backgroundColor = UIColor(hexCode: "#FFFFFF") + containerView.layer.borderWidth = 1 + containerView.layer.borderColor = UIColor(hexCode: "#D9D9D9").cgColor + dayLabel.font = DSKitFontFamily.Pretendard.medium.font(size: 14) + 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 new file mode 100644 index 0000000..cde0851 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift @@ -0,0 +1,242 @@ +// +// PlaceCell.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import Domain +import DSKit +import Kingfisher +import SnapKit +import Then +import UIKit + +final class PlaceCell: UICollectionViewCell { + + static let identifier = "PlaceCell" + + // MARK: - UI Components + + // 순서 뷰 (셀 바깥 왼쪽) + private let sequenceView = UIView().then { + $0.backgroundColor = UIColor(hexCode: "#28A745") + $0.layer.cornerRadius = 12 + } + + private let sequenceLabel = UILabel() + + // 메인 컨테이너 (보더 있는 영역) + private let containerView = UIView().then { + $0.backgroundColor = UIColor(hexCode: "#FFFFFF") + $0.layer.cornerRadius = 12 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor(hexCode: "#F5F5F5").cgColor + $0.clipsToBounds = true + } + + // 카테고리 태그 + private let categoryTagView = UIView().then { + $0.backgroundColor = UIColor(hexCode: "#F5F5F5") + $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(hexCode: "#F5F5F5") + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + } + + // 이동 시간 정보 (컨테이너 아래) + private let travelTimeContainerView = UIView() + + private let travelTimeLabel = UILabel() + + private let chevronImageView = UIImageView().then { + $0.image = DSKitAsset.Assets.icChevronRight3.image + $0.contentMode = .scaleAspectFit + $0.tintColor = UIColor(hexCode: "#757575") + } + + // 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 + travelTimeContainerView.isHidden = false + } + + // MARK: - Setup + + private func setupUI() { + // 순서 뷰 + contentView.addSubview(sequenceView) + sequenceView.addSubview(sequenceLabel) + + // 메인 컨테이너 + contentView.addSubview(containerView) + + // 컨테이너 내부 요소들 + [categoryTagView, durationLabel, placeNameLabel, tipLabel, thumbnailImageView].forEach { + containerView.addSubview($0) + } + + categoryTagView.addSubview(categoryLabel) + + // 이동 시간 정보 + contentView.addSubview(travelTimeContainerView) + [travelTimeLabel, chevronImageView].forEach { + travelTimeContainerView.addSubview($0) + } + } + + private func setupConstraints() { + // 순서 뷰 (왼쪽 바깥) + sequenceView.snp.makeConstraints { + $0.top.equalToSuperview().offset(12) + $0.leading.equalToSuperview() + $0.size.equalTo(24) + } + + sequenceLabel.snp.makeConstraints { + $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.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(placeNameLabel) + } + + // 썸네일 + thumbnailImageView.snp.makeConstraints { + $0.top.equalToSuperview().offset(12) + $0.trailing.equalToSuperview().offset(-12) + $0.size.equalTo(56) + } + + // 이동 시간 컨테이너 + 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) + } + + 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) { + sequenceLabel.setText(.bodySSB, text: "\(place.sequence)", color: UIColor(hexCode: "#FFFFFF")) + + // 카테고리 (기본값: 교통수단) + categoryLabel.setText(.bodySR, text: "교통수단", color: UIColor(hexCode: "#2C2C2C")) + + // 체류 시간 + durationLabel.setText(.bodySR, text: "\(place.estimatedDuration)분 체류 예상", color: UIColor(hexCode: "#444444")) + + // 장소명 + placeNameLabel.setText(.bodyLSB, text: place.place.name, color: UIColor(hexCode: "#111111")) + + // 팁 + tipLabel.setText(.bodySR, text: place.travelerTip, color: UIColor(hexCode: "#444444")) + + // 썸네일 이미지 로딩 + if let thumbnailURLString = place.place.thumbnail, + let thumbnailURL = URL(string: thumbnailURLString) { + thumbnailImageView.isHidden = false + thumbnailImageView.kf.setImage( + with: thumbnailURL, + placeholder: nil, + options: [ + .transition(.fade(0.2)), + .cacheOriginalImage + ] + ) + } else { + thumbnailImageView.isHidden = true + } + + // 이동 시간 정보 (마지막 아이템이면 숨김) + if isLast { + travelTimeContainerView.isHidden = true + } else { + travelTimeContainerView.isHidden = false + // TODO: 실제 이동 시간 데이터로 교체 + travelTimeLabel.setText(.bodySR, text: "약 30분 • 28.8km", color: UIColor(hexCode: "#444444")) + } + } +} 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..d8a4927 --- /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: 72, height: 30) + + 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..9377c42 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift @@ -0,0 +1,95 @@ +// +// 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 = 8 + + 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: 135) + } +} diff --git a/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift new file mode 100644 index 0000000..9b9f932 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/Views/MediaInfoView.swift @@ -0,0 +1,332 @@ +// +// MediaInfoView.swift +// FollowFeature +// +// Created by kimnahun on 2026-01-23. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Core +import Domain +import DSKit +import Kingfisher +import SnapKit +import Then +import UIKit + +protocol MediaInfoViewDelegate: AnyObject { + func mediaInfoViewDidToggleExpand(_ view: MediaInfoView, isExpanded: Bool) +} + +final class MediaInfoView: UIView { + + // MARK: - Properties + + 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(hexCode: "#B3B3B3") + $0.layer.cornerRadius = 28 + $0.clipsToBounds = true + } + + // 여행 정보 (icVideo1 + 4px + "유튜버 · 국가 · 3박4일") + private let travelInfoStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 4 + $0.alignment = .center + } + + 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 = 1 + $0.lineBreakMode = .byTruncatingTail + } + + private let toggleButton = UIButton(type: .system).then { + $0.setImage(DSKitAsset.Assets.icChevronDown3.image, for: .normal) + $0.tintColor = UIColor(hexCode: "#757575") + } + + // MARK: - UI Components (펼쳤을 때만 보이는 영역) + + private let expandedContainerView = UIView().then { + $0.isHidden = true + $0.clipsToBounds = true + } + + private let thumbnailImageView = UIImageView().then { + $0.contentMode = .scaleAspectFit + $0.backgroundColor = UIColor(hexCode: "#F5F5F5") + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + } + + // 예산 정보 (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 budgetLabel = UILabel() + + private let separatorView = UIView().then { + $0.backgroundColor = UIColor(hexCode: "#D9D9D9") + } + + // 영상 요약 타이틀 (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() + + private let summaryLabel = UILabel().then { + $0.numberOfLines = 0 + } + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + clipsToBounds = true + setupUI() + setupConstraints() + setupActions() + layer.cornerRadius = 20 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + backgroundColor = UIColor(hexCode: "#F5F5F5") + + // 여행 정보 스택뷰 구성 + [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, budgetStackView, separatorView, summaryTitleStackView, summaryLabel].forEach { + expandedContainerView.addSubview($0) + } + + // 타이포그래피 설정 + summaryTitleLabel.setText(.bodyMSB, text: "영상 요약", color: UIColor(hexCode: "#111111")) + } + + private func setupConstraints() { + profileImageView.snp.makeConstraints { + $0.top.equalToSuperview().offset(16) + $0.leading.equalToSuperview().offset(24) + $0.size.equalTo(56) + } + + travelInfoIconView.snp.makeConstraints { + $0.size.equalTo(24) + } + + travelInfoStackView.snp.makeConstraints { + $0.top.equalTo(profileImageView.snp.top) + $0.leading.equalTo(profileImageView.snp.trailing).offset(8) + } + + titleLabel.snp.makeConstraints { + $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.top.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().inset(24) + // expanded 상태의 bottom constraint + expandedBottomConstraint = $0.bottom.equalToSuperview().offset(-16).constraint + } + // 초기 상태: collapsed + expandedBottomConstraint?.deactivate() + + thumbnailImageView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.trailing.equalToSuperview() + // 327:150 비율 유지 + $0.height.equalTo(thumbnailImageView.snp.width).multipliedBy(150.0 / 327.0) + } + + budgetIconView.snp.makeConstraints { + $0.size.equalTo(20) + } + + budgetStackView.snp.makeConstraints { + $0.top.equalTo(thumbnailImageView.snp.bottom).offset(24) + $0.leading.equalToSuperview().offset(12) + } + + separatorView.snp.makeConstraints { + $0.top.equalTo(budgetStackView.snp.bottom).offset(10) + $0.horizontalEdges.equalToSuperview().inset(12) + $0.height.equalTo(1) + } + + 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(summaryTitleStackView.snp.bottom).offset(8) + $0.horizontalEdges.equalTo(separatorView) + $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.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() + expandedBottomConstraint?.activate() + } else { + expandedBottomConstraint?.deactivate() + collapsedBottomConstraint?.activate() + } + } + + // MARK: - Configuration + + 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(hexCode: "#757575")) + + // 제목 + titleLabel.setText(.subTitleLSB, text: detail.youtube.title, color: UIColor(hexCode: "#111111")) + + // 예산 라벨 (1인 기준 예산 + 금액) - 파란색 + let budgetText = "1인 기준 예산 \(formatBudget(detail.budgetPerPerson))" + budgetLabel.setText(.bodyLR, text: budgetText, color: UIColor(hexCode: "#28A745")) + + // 요약 라벨 + summaryLabel.setText(.bodyMM, text: detail.youtube.summary, color: UIColor(hexCode: "#2C2C2C")) + + // 프로필 이미지 로딩 + 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 { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formattedNumber = formatter.string(from: NSNumber(value: budget)) ?? "\(budget)" + return "\(formattedNumber)원" + } + + // MARK: - Public Methods + + func calculateHeight() -> CGFloat { + setNeedsLayout() + layoutIfNeeded() + let targetSize = CGSize(width: bounds.width, height: UIView.layoutFittingCompressedSize.height) + return systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ).height + } +} diff --git a/Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift b/Projects/Features/FollowFeature/Sources/Views/PlaceDetailBottomSheetView.swift new file mode 100644 index 0000000..1d17853 --- /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(hexCode: "#111111") + } + + // 카테고리 + 체류시간 + 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(hexCode: "#757575") + $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(hexCode: "#2C2C2C") + $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(hexCode: "#2C2C2C") + $0.contentMode = .scaleAspectFit + } + + private let costLabel = UILabel() + + // 길찾기 버튼 + private let findRouteButton = UIButton(type: .system).then { + $0.backgroundColor = UIColor(hexCode: "#FFFFFF") + $0.layer.cornerRadius = 8 + $0.layer.borderWidth = 1 + $0.layer.borderColor = UIColor(hexCode: "#D9D9D9").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(hexCode: "#111111") + $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(hexCode: "#444444")) + findRouteLabel.setText(.bodyMSB, text: "길찾기", color: UIColor(hexCode: "#111111")) + } + + 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(hexCode: "#111111")) + + // 카테고리 (기본값: 관광명소) + categoryLabel.setText(.bodySR, text: "🏔 관광명소", color: UIColor(hexCode: "#757575")) + + // 체류 예상 시간 + 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(hexCode: "#444444")) + + // 영업시간 + let openingHours = place.place.regularOpeningHours ?? "-" + openingHoursLabel.setText(.bodySR, text: "영업시간 \(openingHours)", color: UIColor(hexCode: "#2C2C2C")) + + // 시간 추가 (기본값) + timeLabel.setText(.bodySR, text: "시간 추가", color: UIColor(hexCode: "#444444")) + + // 비용 추가 (기본값) + 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 new file mode 100644 index 0000000..f5e0604 --- /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 SnapKit +import Then +import UIKit + +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 = .black + renderer.lineWidth = 1 + 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(hexCode: "#28A745") + view.layer.cornerRadius = size / 2 + + let label = UILabel(frame: view.bounds) + label.text = "\(sequence)" + label.textColor = UIColor(hexCode: "#FFFFFF") + 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) + } + } +} diff --git a/Projects/Features/HomeFeature/Project.swift b/Projects/Features/HomeFeature/Project.swift index 4c3baad..7bd4377 100644 --- a/Projects/Features/HomeFeature/Project.swift +++ b/Projects/Features/HomeFeature/Project.swift @@ -15,7 +15,9 @@ let project = Project.makeModule( .makeFrameworkTarget( name: "HomeFeature", dependencies: [ - .Features.baseFeatureDependency + .Features.baseFeatureDependency, + .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 421ee76..5d90f20 100644 --- a/Projects/Features/HomeFeature/Sources/HomeBuilder.swift +++ b/Projects/Features/HomeFeature/Sources/HomeBuilder.swift @@ -6,7 +6,9 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import Data import Domain +import FollowFeature import RIBs // MARK: - HomeDependency @@ -17,11 +19,16 @@ public protocol HomeDependency: Dependency { // MARK: - HomeComponent -final class HomeComponent: Component { +final class HomeComponent: Component, FollowDetailDependency { var travelRepository: TravelRepositoryProtocol { // TODO: 실제 API 연동 시 실제 Repository로 교체 MockTravelRepository() } + + var followRepository: FollowRepositoryProtocol { + let service = makeFollowService() + return makeFollowRepository(service: service) + } } // MARK: - HomeBuildable @@ -47,9 +54,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..10f9a9a 100644 --- a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift +++ b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift @@ -7,13 +7,15 @@ // 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 @@ -79,9 +81,11 @@ final class HomeInteractor: 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() @@ -92,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) + } } } } @@ -124,16 +129,16 @@ 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) { guard index < recommendations.count else { return } - let recommendation = recommendations[index] - // TODO: 상세 화면으로 이동 - print("Selected recommendation: \(recommendation.title)") + // TODO: 실제 API 연동 시 recommendation.id 사용 + // 현재는 테스트를 위해 항상 id 1로 이동 + router?.routeToFollowDetail(with: 1) } func didTapShowMoreTrips() { @@ -150,3 +155,17 @@ extension HomeInteractor: HomePresentableListener { loadHomeData() } } + +// MARK: - FollowDetailListener + +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/HomeFeature/Sources/HomeRouter.swift b/Projects/Features/HomeFeature/Sources/HomeRouter.swift index ff062b3..480e5e4 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,55 @@ 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 } + + // 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/Features/HomeFeature/Sources/HomeViewController.swift b/Projects/Features/HomeFeature/Sources/HomeViewController.swift index e2e86b5..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: UIColor(hexCode: "#2C2C2C")) + $0.setText(.subTitleLSB, text: "인기 여행 따라가기", color: UIColor(hexCode: "#111111")) } private let categoryCollectionView = CategoryCollectionView() @@ -52,21 +52,21 @@ final class HomeViewController: UIViewController, HomePresentable, HomeViewContr $0.setTitle("여행 따라가기 더보기", for: .normal) $0.setTitleColor(UIColor(hexCode: "#2C2C2C"), for: .normal) $0.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) - $0.backgroundColor = UIColor(hexCode: "#2C2C2C") + $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: UIColor(hexCode: "#2C2C2C")) + $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(hexCode: "#2C2C2C") + $0.backgroundColor = UIColor(hexCode: "#28A745") $0.layer.cornerRadius = 28 $0.setImage(DSKitAsset.Assets.icPlus2.image, for: .normal) $0.tintColor = .white @@ -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) + } +} diff --git a/Projects/Features/HomeFeature/Sources/Views/Cells/CategoryCell.swift b/Projects/Features/HomeFeature/Sources/Views/Cells/CategoryCell.swift index 0b64e10..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(hexCode: "#757575") : UIColor(hexCode: "#757575")) + titleLabel.setText(.bodyMSB, text: title, color: isSelected ? UIColor(hexCode: "#FFFFFF") : UIColor(hexCode: "#757575")) iconImageView.isHidden = isFirstItem if isSelected { @@ -92,8 +92,8 @@ final class CategoryCell: UICollectionViewCell { containerView.layer.borderColor = UIColor.clear.cgColor iconImageView.tintColor = .white } else { - containerView.backgroundColor = UIColor(hexCode: "#2C2C2C") - containerView.layer.borderColor = UIColor(hexCode: "#2C2C2C").cgColor + 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 56aec60..f09209b 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(hexCode: "#2C2C2C") + $0.tintColor = UIColor(hexCode: "#444444") $0.contentMode = .scaleAspectFit } 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/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/Components/NDGLTabItem.swift b/Projects/Features/TabBarFeature/Sources/Components/NDGLTabItem.swift index 1810efd..091cb23 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/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..e226fa0 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift @@ -6,19 +6,22 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import Foundation +import HomeFeature import RIBs import RxSwift // MARK: - TabBarListener public protocol TabBarListener: AnyObject { - // 부모 RIB에 전달할 이벤트 정의 } // MARK: - TabBarPresentable protocol TabBarPresentable: Presentable { var listener: TabBarPresentableListener? { get set } + + func switchToTab(at index: Int) } // MARK: - TabBarInteractor @@ -47,5 +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) { + presenter.switchToTab(at: 2) + } } diff --git a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift index e265071..095779e 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 } @@ -54,11 +59,19 @@ final class TabBarRouter: ViewableRouter= 2 else { + return + } + + let homeVC = viewControllers[0].uiviewController + let travelVC = viewControllers[1].uiviewController + let infoDummy = UIViewController().then { $0.view.backgroundColor = .yellow } - let myTripDummy = UIViewController().then { $0.view.backgroundColor = .green } - - let finalControllers = [infoDummy, homeVC, myTripDummy] - - super.setViewControllers(finalControllers, animated: false) - + + let infoNav = UINavigationController(rootViewController: infoDummy) + let homeNav = UINavigationController(rootViewController: homeVC) + let travelNav = UINavigationController(rootViewController: travelVC) + + [infoNav, homeNav, travelNav].forEach { $0.delegate = self } + + super.setViewControllers([infoNav, homeNav, travelNav], animated: false) setupTabItems() } -} -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) + func switchToTab(at index: Int) { + guard index < tabItems.count else { return } + + viewControllers?.forEach { viewController in + if let navController = viewController as? UINavigationController { + navController.popToRootViewController(animated: false) } - tabItems.append(item) - tabStackView.addArrangedSubview(item) } + updateSelection(at: index) + DispatchQueue.main.async { - self.updateSelection(at: 1, animated: false) + self.customTabBarContainer.isHidden = false + self.customTabBarContainer.alpha = 1 } } - - func setStyle() { +} + +// MARK: - Setup + +private extension TabBarViewController { + + func setupStyle() { customTabBarContainer.do { if #available(iOS 26.0, *) { let glass = UIGlassEffect(style: .regular) @@ -98,13 +108,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) @@ -114,63 +124,120 @@ 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() } } } + +// MARK: - UINavigationControllerDelegate + +extension TabBarViewController: UINavigationControllerDelegate { + + public func navigationController( + _ navigationController: UINavigationController, + willShow viewController: UIViewController, + animated: Bool + ) { + let shouldHideTabBar = navigationController.viewControllers.count > 1 + + guard animated else { + customTabBarContainer.isHidden = shouldHideTabBar + customTabBarContainer.alpha = shouldHideTabBar ? 0 : 1 + return + } + + if shouldHideTabBar { + UIView.animate(withDuration: 0.3) { + self.customTabBarContainer.alpha = 0 + } completion: { _ in + self.customTabBarContainer.isHidden = true + } + } else { + customTabBarContainer.isHidden = false + customTabBarContainer.alpha = 0 + customTabBarContainer.layoutIfNeeded() + + UIView.animate(withDuration: 0.3) { + self.customTabBarContainer.alpha = 1 + } + } + } +} 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..66cc048 --- /dev/null +++ b/Projects/Features/TravelFeature/Sources/TravelBuilder.swift @@ -0,0 +1,48 @@ +// +// 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 { +} + +// 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..42b410e --- /dev/null +++ b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift @@ -0,0 +1,121 @@ +// +// 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 { +} + +// 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() + + 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) { + } + + func didTapMenuButton() { + } +} 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..3136ab5 --- /dev/null +++ b/Projects/Features/TravelFeature/Sources/TravelViewController.swift @@ -0,0 +1,194 @@ +// +// 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 + +final class TravelViewController: UIViewController, TravelPresentable, TravelViewControllable { + + // MARK: - Properties + + weak var listener: TravelPresentableListener? + + private let disposeBag = DisposeBag() + private var trips: [UpcomingTrip] = [] + + // MARK: - UI Components + + private let titleLabel = UILabel().then { + $0.setText(.subTitleLSB, text: "다가오는 여행", color: UIColor(hexCode: "#111111")) + } + + private let menuButton = UIButton(type: .system).then { + $0.setImage(UIImage(systemName: "line.3.horizontal"), for: .normal) + $0.tintColor = UIColor(hexCode: "#111111") + } + + private let 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: UIColor(hexCode: "#444444")) + $0.isHidden = true + } + + private let loadingIndicator = UIActivityIndicatorView(style: .large).then { + $0.hidesWhenStopped = true + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupConstraints() + setupDelegates() + setupActions() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + } + + // MARK: - Setup + + private func setupUI() { + view.backgroundColor = UIColor(hexCode: "#FFFFFF") + + [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 setupDelegates() { + 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 { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return trips.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: UpcomingTripCell.identifier, + for: indexPath + ) as? UpcomingTripCell, + indexPath.item < trips.count else { + return UICollectionViewCell() + } + + cell.configure(with: trips[indexPath.item]) + return cell + } +} + +// MARK: - UICollectionViewDelegate + +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) + } +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension TravelViewController: UICollectionViewDelegateFlowLayout { + + 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..272d553 --- /dev/null +++ b/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift @@ -0,0 +1,125 @@ +// +// 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 = UIColor(hexCode: "#757575") + $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) + + titleLabel.setText(.bodyMSB, text: trip.title, color: UIColor(hexCode: "#111111")) + + dateLabel.setText(.bodySR, text: trip.dateRangeString, color: UIColor(hexCode: "#444444")) + + if let urlString = trip.thumbnailURL, let url = URL(string: urlString) { + thumbnailImageView.kf.setImage( + with: url, + options: [.transition(.fade(0.2)), .cacheOriginalImage] + ) + } + } +} diff --git a/Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift b/Projects/Modules/DSKit/Sources/Component/BottomPlacedButton.swift new file mode 100644 index 0000000..b10b5f1 --- /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(hexCode: "#FFFFFF") + 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(hexCode: "#FFFFFF") + ) + + 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 + } + } +} 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() + } + } +} 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) 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..e3b5ae9 --- /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 itineraries: [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 thumbnail: String? + public let latitude: Double + public let longitude: Double + public let name: String + public let regularOpeningHours: 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"] + } +}