From 57a4e79882bd6ca6ae904350ed240f4ad04b078d Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Sat, 21 Feb 2026 17:05:47 +0900 Subject: [PATCH 01/23] =?UTF-8?q?del:=20#33=20-=20=EB=A0=88=EA=B1=B0?= =?UTF-8?q?=EC=8B=9C=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Model/UpcomingTrip.swift | 52 ----- .../Sources/TravelInteractor.swift | 122 ------------ .../Sources/TravelViewController.swift | 177 ------------------ .../Sources/Views/UpcomingTripCell.swift | 124 ------------ 4 files changed, 475 deletions(-) delete mode 100644 Projects/Features/TravelFeature/Sources/Model/UpcomingTrip.swift delete mode 100644 Projects/Features/TravelFeature/Sources/TravelInteractor.swift delete mode 100644 Projects/Features/TravelFeature/Sources/TravelViewController.swift delete mode 100644 Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift diff --git a/Projects/Features/TravelFeature/Sources/Model/UpcomingTrip.swift b/Projects/Features/TravelFeature/Sources/Model/UpcomingTrip.swift deleted file mode 100644 index d872f01..0000000 --- a/Projects/Features/TravelFeature/Sources/Model/UpcomingTrip.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// 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/TravelInteractor.swift b/Projects/Features/TravelFeature/Sources/TravelInteractor.swift deleted file mode 100644 index e4da809..0000000 --- a/Projects/Features/TravelFeature/Sources/TravelInteractor.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// 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 viewWillAppear() -} - -// 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 viewWillAppear() { - loadTrips() - } - - func didTapTrip(_ trip: UpcomingTrip) { - } -} diff --git a/Projects/Features/TravelFeature/Sources/TravelViewController.swift b/Projects/Features/TravelFeature/Sources/TravelViewController.swift deleted file mode 100644 index ad57434..0000000 --- a/Projects/Features/TravelFeature/Sources/TravelViewController.swift +++ /dev/null @@ -1,177 +0,0 @@ -// -// 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 navigationBar = NDGLNavigationBar( - title: "다가오는 여행" - ) - - 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) - listener?.viewWillAppear() - } - - // MARK: - Setup - - private func setupUI() { - view.backgroundColor = UIColor(hexCode: "#FFFFFF") - - [navigationBar, collectionView, emptyStateLabel, loadingIndicator].forEach { - view.addSubview($0) - } - } - - private func setupConstraints() { - navigationBar.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide) - $0.horizontalEdges.equalToSuperview() - } - - collectionView.snp.makeConstraints { - $0.top.equalTo(navigationBar.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() { - } -} - -// 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 deleted file mode 100644 index 58785d5..0000000 --- a/Projects/Features/TravelFeature/Sources/Views/UpcomingTripCell.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// UpcomingTripCell.swift -// TravelFeature -// -// Created by kimnahun on 2026-01-24. -// Copyright © 2026 NDGL-iOS. All rights reserved. -// - -import DSKit -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] - ) - } - } -} From e915b24b2cf0c44248a4e4facf4bb31300353cb3 Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Sat, 21 Feb 2026 17:06:25 +0900 Subject: [PATCH 02/23] =?UTF-8?q?chore:=20#33=20-=20MyTravel=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dependency+Project.swift | 14 +++--- .../Project.swift | 8 ++-- .../Sources/MyTravelBuilder.swift | 40 ++++++++++++++++ .../Sources/MyTravelInteractor.swift | 46 ++++++++++++++++++ .../Sources/MyTravelRouter.swift | 27 +++++++++++ .../Sources/MyTravelViewController.swift | 22 +++++++++ Projects/Features/TabBarFeature/Project.swift | 2 +- .../TravelFeature/Sources/TravelBuilder.swift | 48 ------------------- .../TravelFeature/Sources/TravelRouter.swift | 39 --------------- 9 files changed, 147 insertions(+), 99 deletions(-) rename Projects/Features/{TravelFeature => MyTravelFeature}/Project.swift (72%) create mode 100644 Projects/Features/MyTravelFeature/Sources/MyTravelBuilder.swift create mode 100644 Projects/Features/MyTravelFeature/Sources/MyTravelInteractor.swift create mode 100644 Projects/Features/MyTravelFeature/Sources/MyTravelRouter.swift create mode 100644 Projects/Features/MyTravelFeature/Sources/MyTravelViewController.swift delete mode 100644 Projects/Features/TravelFeature/Sources/TravelBuilder.swift delete mode 100644 Projects/Features/TravelFeature/Sources/TravelRouter.swift diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift index 3e96764..7166ce2 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency+Project.swift @@ -13,10 +13,10 @@ public extension TargetDependency { public struct Home {} public struct TabBar {} public struct Follow {} - public struct Travel {} public struct Search {} public struct Setting {} public struct PopularTravel {} + public struct MyTravel {} public struct TravelTool {} } @@ -63,12 +63,6 @@ public extension TargetDependency.Features.Follow { static let feature = TargetDependency.Features.project(name: "Feature", group: group) } -public extension TargetDependency.Features.Travel { - static let group = "Travel" - - static let feature = TargetDependency.Features.project(name: "Feature", group: group) -} - public extension TargetDependency.Features.Search { static let group = "Search" @@ -93,6 +87,12 @@ public extension TargetDependency.Features.PopularTravel { static let feature = TargetDependency.Features.project(name: "Feature", group: group) } +public extension TargetDependency.Features.MyTravel { + static let group = "MyTravel" + + static let feature = TargetDependency.Features.project(name: "Feature", group: group) +} + public extension TargetDependency.Features.TravelTool { static let group = "TravelTool" diff --git a/Projects/Features/TravelFeature/Project.swift b/Projects/Features/MyTravelFeature/Project.swift similarity index 72% rename from Projects/Features/TravelFeature/Project.swift rename to Projects/Features/MyTravelFeature/Project.swift index fb738ae..1f4cb51 100644 --- a/Projects/Features/TravelFeature/Project.swift +++ b/Projects/Features/MyTravelFeature/Project.swift @@ -1,8 +1,8 @@ // // Project.swift -// TravelFeature +// ProjectDescriptionHelpers // -// Created by kimnahun on 2026-01-24. +// Created by 최안용 on 2026/02/21. // import ProjectDescription @@ -10,10 +10,10 @@ import ProjectDescriptionHelpers import DependencyPlugin let project = Project.makeModule( - name: "TravelFeature", + name: "MyTravelFeature", targets: [ .makeFrameworkTarget( - name: "TravelFeature", + name: "MyTravelFeature", dependencies: [ .Features.baseFeatureDependency ], diff --git a/Projects/Features/MyTravelFeature/Sources/MyTravelBuilder.swift b/Projects/Features/MyTravelFeature/Sources/MyTravelBuilder.swift new file mode 100644 index 0000000..ffb72a2 --- /dev/null +++ b/Projects/Features/MyTravelFeature/Sources/MyTravelBuilder.swift @@ -0,0 +1,40 @@ +// +// MyTravelBuilder.swift +// MyTravelFeature +// +// Created by 최안용 on 2/21/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +protocol MyTravelDependency: Dependency { + // TODO: Declare the set of dependencies required by this RIB, but cannot be + // created by this RIB. +} + +final class MyTravelComponent: Component { + + // TODO: Declare 'fileprivate' dependencies that are only used by this RIB. +} + +// MARK: - Builder + +protocol MyTravelBuildable: Buildable { + func build(withListener listener: MyTravelListener) -> MyTravelRouting +} + +final class MyTravelBuilder: Builder, MyTravelBuildable { + + override init(dependency: MyTravelDependency) { + super.init(dependency: dependency) + } + + func build(withListener listener: MyTravelListener) -> MyTravelRouting { + let component = MyTravelComponent(dependency: dependency) + let viewController = MyTravelViewController() + let interactor = MyTravelInteractor(presenter: viewController) + interactor.listener = listener + return MyTravelRouter(interactor: interactor, viewController: viewController) + } +} diff --git a/Projects/Features/MyTravelFeature/Sources/MyTravelInteractor.swift b/Projects/Features/MyTravelFeature/Sources/MyTravelInteractor.swift new file mode 100644 index 0000000..9fb64cd --- /dev/null +++ b/Projects/Features/MyTravelFeature/Sources/MyTravelInteractor.swift @@ -0,0 +1,46 @@ +// +// MyTravelInteractor.swift +// MyTravelFeature +// +// Created by 최안용 on 2/21/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs +import RxSwift + +protocol MyTravelRouting: ViewableRouting { + // TODO: Declare methods the interactor can invoke to manage sub-tree via the router. +} + +protocol MyTravelPresentable: Presentable { + var listener: MyTravelPresentableListener? { get set } + // TODO: Declare methods the interactor can invoke the presenter to present data. +} + +protocol MyTravelListener: AnyObject { + // TODO: Declare methods the interactor can invoke to communicate with other RIBs. +} + +final class MyTravelInteractor: PresentableInteractor, MyTravelInteractable, MyTravelPresentableListener { + + weak var router: MyTravelRouting? + weak var listener: MyTravelListener? + + // TODO: Add additional dependencies to constructor. Do not perform any logic + // in constructor. + override init(presenter: MyTravelPresentable) { + super.init(presenter: presenter) + presenter.listener = self + } + + override func didBecomeActive() { + super.didBecomeActive() + // TODO: Implement business logic here. + } + + override func willResignActive() { + super.willResignActive() + // TODO: Pause any business logic. + } +} diff --git a/Projects/Features/MyTravelFeature/Sources/MyTravelRouter.swift b/Projects/Features/MyTravelFeature/Sources/MyTravelRouter.swift new file mode 100644 index 0000000..9241956 --- /dev/null +++ b/Projects/Features/MyTravelFeature/Sources/MyTravelRouter.swift @@ -0,0 +1,27 @@ +// +// MyTravelRouter.swift +// MyTravelFeature +// +// Created by 최안용 on 2/21/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +protocol MyTravelInteractable: Interactable { + var router: MyTravelRouting? { get set } + var listener: MyTravelListener? { get set } +} + +protocol MyTravelViewControllable: ViewControllable { + // TODO: Declare methods the router invokes to manipulate the view hierarchy. +} + +final class MyTravelRouter: ViewableRouter, MyTravelRouting { + + // TODO: Constructor inject child builder protocols to allow building children. + override init(interactor: MyTravelInteractable, viewController: MyTravelViewControllable) { + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } +} diff --git a/Projects/Features/MyTravelFeature/Sources/MyTravelViewController.swift b/Projects/Features/MyTravelFeature/Sources/MyTravelViewController.swift new file mode 100644 index 0000000..df9c400 --- /dev/null +++ b/Projects/Features/MyTravelFeature/Sources/MyTravelViewController.swift @@ -0,0 +1,22 @@ +// +// MyTravelViewController.swift +// MyTravelFeature +// +// Created by 최안용 on 2/21/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs +import RxSwift +import UIKit + +protocol MyTravelPresentableListener: AnyObject { + // TODO: Declare properties and methods that the view controller can invoke to perform + // business logic, such as signIn(). This protocol is implemented by the corresponding + // interactor class. +} + +final class MyTravelViewController: UIViewController, MyTravelPresentable, MyTravelViewControllable { + + weak var listener: MyTravelPresentableListener? +} diff --git a/Projects/Features/TabBarFeature/Project.swift b/Projects/Features/TabBarFeature/Project.swift index 7fbb8f0..6dd5e7c 100644 --- a/Projects/Features/TabBarFeature/Project.swift +++ b/Projects/Features/TabBarFeature/Project.swift @@ -16,7 +16,7 @@ let project = Project.makeModule( name: "TabBarFeature", dependencies: [ .Features.Home.feature, - .Features.Travel.feature, + .Features.MyTravel.feature .Features.TravelTool.feature ], scripts: [.swiftLint], diff --git a/Projects/Features/TravelFeature/Sources/TravelBuilder.swift b/Projects/Features/TravelFeature/Sources/TravelBuilder.swift deleted file mode 100644 index 66cc048..0000000 --- a/Projects/Features/TravelFeature/Sources/TravelBuilder.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// 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/TravelRouter.swift b/Projects/Features/TravelFeature/Sources/TravelRouter.swift deleted file mode 100644 index c033252..0000000 --- a/Projects/Features/TravelFeature/Sources/TravelRouter.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// 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 - } -} From 32a8a918ad17c602f3c20872b4c2749795951ce7 Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 17:34:46 +0900 Subject: [PATCH 03/23] =?UTF-8?q?feat:=20#33=20-=20upcomingList=20api=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20MyPravelUsecase=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserTravel /UserTravelRepository.swift | 8 +++ .../Transform/UserTravelTransform.swift | 23 ++++++-- .../UserTravelRepositoryInterface.swift | 2 + .../Sources/Model/Home/UpcomingInfo.swift | 49 ++++++++++++++++ .../Sources/UseCase/MyTravelUsecase.swift | 58 +++++++++++++++++++ .../DTO/Home/UpcomingListResponse.swift | 28 +++++++++ .../DTO/Home/UserContentCardResponse.swift | 21 +++++++ .../Sources/Service/UserTravelService.swift | 10 ++++ .../Sources/TargetType/UserTravelAPI.swift | 17 +++++- 9 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 Projects/Domain/Sources/Model/Home/UpcomingInfo.swift create mode 100644 Projects/Domain/Sources/UseCase/MyTravelUsecase.swift create mode 100644 Projects/Modules/Networks/Sources/DTO/Home/UpcomingListResponse.swift create mode 100644 Projects/Modules/Networks/Sources/DTO/Home/UserContentCardResponse.swift diff --git a/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift b/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift index 38099be..b0df8f0 100644 --- a/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift +++ b/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift @@ -41,4 +41,12 @@ public final class UserTravelRepository: UserTravelRepositoryInterface { throw error.toNDGLError() } } + + public func fetchUpcomingList(page: Int?, size: Int?) async throws -> [UpcomingInfo] { + do { + return try await service.getUpcomingList(page: page, size: size).toDomain() + } catch { + throw error.toNDGLError() + } + } } diff --git a/Projects/Data/Sources/Transform/UserTravelTransform.swift b/Projects/Data/Sources/Transform/UserTravelTransform.swift index 1fbe82c..7e9c085 100644 --- a/Projects/Data/Sources/Transform/UserTravelTransform.swift +++ b/Projects/Data/Sources/Transform/UserTravelTransform.swift @@ -49,11 +49,22 @@ extension CreateUserTravelResponse { } } -extension String { - func toDate() -> Date? { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd" - return formatter.date(from: self) +extension UpcomingListResponse { + func toDomain() -> [UpcomingInfo] { + self.content.map { + .init( + id: $0.id, + title: $0.title, + country: $0.country, + city: $0.city, + startDate: $0.startDate.toDate() ?? .now, + endDate: $0.endDate.toDate() ?? .now, + nights: $0.nights, + days: $0.days, + templateId: $0.templateId, + thumbnail: $0.thumbnail, + profileImage: $0.profileImage + ) + } } } diff --git a/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift b/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift index bcbaa39..05893c1 100644 --- a/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift +++ b/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift @@ -10,5 +10,7 @@ import Foundation public protocol UserTravelRepositoryInterface { func createUserTravel(request: CreateTravelRequest) async throws -> CreateTravelResponse +// func fetchContentCard(id: Int) async throws -> func fetchUpcoming() async throws -> MyTripSummary + func fetchUpcomingList(page: Int?, size: Int?) async throws -> [UpcomingInfo] } diff --git a/Projects/Domain/Sources/Model/Home/UpcomingInfo.swift b/Projects/Domain/Sources/Model/Home/UpcomingInfo.swift new file mode 100644 index 0000000..6a515e1 --- /dev/null +++ b/Projects/Domain/Sources/Model/Home/UpcomingInfo.swift @@ -0,0 +1,49 @@ +// +// UpcomingInfo.swift +// Domain +// +// Created by 최안용 on 2/22/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public struct UpcomingInfo { + public let id: Int + public let title: String + public let country: String + public let city: String + public let startDate: Date + public let endDate: Date + public let nights: Int + public let days: Int + public let templateId: Int + public let thumbnail: String? + public let profileImage: String? + + public init( + id: Int, + title: String, + country: String, + city: String, + startDate: Date, + endDate: Date, + nights: Int, + days: Int, + templateId: Int, + thumbnail: String?, + profileImage: String? + ) { + self.id = id + self.title = title + self.country = country + self.city = city + self.startDate = startDate + self.endDate = endDate + self.nights = nights + self.days = days + self.templateId = templateId + self.thumbnail = thumbnail + self.profileImage = profileImage + } +} diff --git a/Projects/Domain/Sources/UseCase/MyTravelUsecase.swift b/Projects/Domain/Sources/UseCase/MyTravelUsecase.swift new file mode 100644 index 0000000..06bab9d --- /dev/null +++ b/Projects/Domain/Sources/UseCase/MyTravelUsecase.swift @@ -0,0 +1,58 @@ +// +// MyTravelUsecase.swift +// Domain +// +// Created by 최안용 on 2/22/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public protocol MyTravelUsecaseProtocol { + func fetchMyTripInfo() async throws -> MyTripSummary + func fetchUpcomingList(page: Int?, size: Int?) async throws -> [UpcomingInfo] + func fetchRecommendTripList(page: Int?, size: Int?) async throws -> [TripInfo] +} + +public extension MyTravelUsecaseProtocol { + func fetchUpcomingList( + page: Int? = nil, + size: Int? = nil + ) async throws -> [UpcomingInfo] { + try await fetchUpcomingList(page: page, size: size) + } + + func fetchRecommendTripList( + page: Int? = nil, + size: Int? = nil + ) async throws -> [TripInfo] { + try await fetchRecommendTripList(page: page, size: size) + } +} + +public final class MyTravelUsecase { + private let travelTemplateRepository: TravelTemplateRepositoryInterface + private let userTravelRepository: UserTravelRepositoryInterface + + public init( + travelTemplateRepository: TravelTemplateRepositoryInterface, + userTravelRepository: UserTravelRepositoryInterface + ) { + self.travelTemplateRepository = travelTemplateRepository + self.userTravelRepository = userTravelRepository + } +} + +extension MyTravelUsecase: MyTravelUsecaseProtocol { + public func fetchMyTripInfo() async throws -> MyTripSummary { + try await userTravelRepository.fetchUpcoming() + } + + public func fetchUpcomingList(page: Int?, size: Int?) async throws -> [UpcomingInfo] { + try await userTravelRepository.fetchUpcomingList(page: page, size: size) + } + + public func fetchRecommendTripList(page: Int?, size: Int?) async throws -> [TripInfo] { + try await travelTemplateRepository.fetchRecommendTripList(page: page, size: size) + } +} diff --git a/Projects/Modules/Networks/Sources/DTO/Home/UpcomingListResponse.swift b/Projects/Modules/Networks/Sources/DTO/Home/UpcomingListResponse.swift new file mode 100644 index 0000000..d3cadfb --- /dev/null +++ b/Projects/Modules/Networks/Sources/DTO/Home/UpcomingListResponse.swift @@ -0,0 +1,28 @@ +// +// UpcomingListResponse.swift +// Networks +// +// Created by 최안용 on 2/22/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public struct UpcomingListResponse: Decodable { + public let content: [UpcomingContentResponse] + public let hasNext: Bool +} + +public struct UpcomingContentResponse: Decodable { + public let id: Int + public let title: String + public let country: String + public let city: String + public let startDate: String + public let endDate: String + public let nights: Int + public let days: Int + public let templateId: Int + public let thumbnail: String? + public let profileImage: String? +} diff --git a/Projects/Modules/Networks/Sources/DTO/Home/UserContentCardResponse.swift b/Projects/Modules/Networks/Sources/DTO/Home/UserContentCardResponse.swift new file mode 100644 index 0000000..a790a5a --- /dev/null +++ b/Projects/Modules/Networks/Sources/DTO/Home/UserContentCardResponse.swift @@ -0,0 +1,21 @@ +// +// UserContentCardResponse.swift +// Networks +// +// Created by 최안용 on 2/22/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public struct UserContentCardResponse: Decodable { + public let userTravelId: Int + public let templateId: Int + public let title: String + public let country: String + public let city: String + public let startDate: String + public let endDate: String + public let nights: Int + public let days: Int +} diff --git a/Projects/Modules/Networks/Sources/Service/UserTravelService.swift b/Projects/Modules/Networks/Sources/Service/UserTravelService.swift index 2ebf742..124527a 100644 --- a/Projects/Modules/Networks/Sources/Service/UserTravelService.swift +++ b/Projects/Modules/Networks/Sources/Service/UserTravelService.swift @@ -12,7 +12,9 @@ import Moya public protocol UserTravelServiceProtocol { func createUserTravel(request: CreateUserTravelRequest) async throws -> CreateUserTravelResponse + func getContentCard(id: Int) async throws -> UserContentCardResponse func getUpcoming() async throws -> UpcomingResponse + func getUpcomingList(page: Int?, size: Int?) async throws -> UpcomingListResponse } public final class UserTravelService: UserTravelServiceProtocol { @@ -26,7 +28,15 @@ public final class UserTravelService: UserTravelServiceProtocol { try await provider.asyncThowsRequest(.createUserTravel(request: request)) } + public func getContentCard(id: Int) async throws -> UserContentCardResponse { + try await provider.asyncThowsRequest(.getContentCard(id: id)) + } + public func getUpcoming() async throws -> UpcomingResponse { try await provider.asyncThowsRequest(.getUpcoming) } + + public func getUpcomingList(page: Int?, size: Int?) async throws -> UpcomingListResponse { + try await provider.asyncThowsRequest(.getUpcomingList(page: page, size: size)) + } } diff --git a/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift b/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift index 1ff3559..3db8718 100644 --- a/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift +++ b/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift @@ -12,7 +12,9 @@ import Moya public enum UserTravelAPI { case createUserTravel(request: CreateUserTravelRequest) + case getContentCard(id: Int) case getUpcoming + case getUpcomingList(page: Int?, size: Int?) } extension UserTravelAPI: TargetType { @@ -24,8 +26,12 @@ extension UserTravelAPI: TargetType { switch self { case .createUserTravel: return "/api/v1/travels" + case .getContentCard(id: let id): + return "/api/v1/travels/\(id)/content-card" case .getUpcoming: return "/api/v1/travels/upcoming" + case .getUpcomingList: + return "api/v1/travels/upcoming/list" } } @@ -33,7 +39,7 @@ extension UserTravelAPI: TargetType { switch self { case .createUserTravel: return .post - case .getUpcoming: + case .getContentCard, .getUpcoming, .getUpcomingList: return .get } } @@ -42,8 +48,15 @@ extension UserTravelAPI: TargetType { switch self { case .createUserTravel(let request): return .requestJSONEncodable(request) - case .getUpcoming: + case .getContentCard, .getUpcoming: return .requestPlain + case .getUpcomingList(let page, let size): + var params: [String: Any] = [:] + + if let page { params["page"] = page } + if let size { params["size"] = size } + + return .requestParameters(parameters: params, encoding: URLEncoding.queryString) } } From 32b9db01aa711528560dfc7c1776d563e6c9bd9b Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 17:35:33 +0900 Subject: [PATCH 04/23] =?UTF-8?q?chore:=20#33=20-=20DSKit=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Component}/RecommendInfoCell.swift | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) rename Projects/{Features/HomeFeature/Sources/Views/Cells => Modules/DSKit/Sources/Component}/RecommendInfoCell.swift (78%) diff --git a/Projects/Features/HomeFeature/Sources/Views/Cells/RecommendInfoCell.swift b/Projects/Modules/DSKit/Sources/Component/RecommendInfoCell.swift similarity index 78% rename from Projects/Features/HomeFeature/Sources/Views/Cells/RecommendInfoCell.swift rename to Projects/Modules/DSKit/Sources/Component/RecommendInfoCell.swift index 48714d3..62ec836 100644 --- a/Projects/Features/HomeFeature/Sources/Views/Cells/RecommendInfoCell.swift +++ b/Projects/Modules/DSKit/Sources/Component/RecommendInfoCell.swift @@ -1,18 +1,18 @@ // // RecommendInfoCell.swift -// HomeFeature +// DSKit // -// Created by 최안용 on 1/31/26. +// Created by 최안용 on 2/22/26. // Copyright © 2026 NDGL-iOS. All rights reserved. // import UIKit -import DSKit +import Kingfisher -final class RecommendInfoCell: UICollectionViewCell { - static let defaultWidth = 240.adjusted - static let defaultHeight = 253.adjustedH +public final class RecommendInfoCell: UICollectionViewCell { + public static let defaultWidth = 240.adjusted + public static let defaultHeight = 253.adjustedH // MARK: - UI Components private let thumbnailView = UIImageView() @@ -30,7 +30,7 @@ final class RecommendInfoCell: UICollectionViewCell { private let infoStackView = UIStackView() // MARK: - Init - override init(frame: CGRect) { + override public init(frame: CGRect) { super.init(frame: frame) setStyle() setUI() @@ -55,16 +55,23 @@ final class RecommendInfoCell: UICollectionViewCell { } // MARK: - Configure - func configure(_ model: HomePresentationModel.RecommendedTrip) { - if let url = URL(string: model.thumbnailUrl) { + public func configure( + title: String, + thumbnailUrl: String, + countryCode: String, + creator: String, + city: String, + schedule: String + ) { + if let url = URL(string: thumbnailUrl) { thumbnailView.kf.setImage(with: url, options: [.transition(.fade(0.3))]) } - nationalFlagLabel.text = model.country.toFlag() - nationLabel.setText(.bodyMM, text: model.country.toKoreanCountryName(), color: DSKitAsset.Colors.black400.color) - titleLabel.setText(.bodyLSB, text: model.title, color: DSKitAsset.Colors.black700.color) - nameLabel.setText(.bodyMM, text: model.creator, color: DSKitAsset.Colors.black400.color) - cityLabel.setText(.bodyMM, text: model.city, color: DSKitAsset.Colors.black400.color) - scheduleLabel.setText(.bodyMM, text: model.schedule, color: DSKitAsset.Colors.black400.color) + nationalFlagLabel.text = countryCode.toFlag() + nationLabel.setText(.bodyMM, text: countryCode.toKoreanCountryName(), color: DSKitAsset.Colors.black400.color) + titleLabel.setText(.bodyLSB, text: title, color: DSKitAsset.Colors.black700.color) + nameLabel.setText(.bodyMM, text: creator, color: DSKitAsset.Colors.black400.color) + cityLabel.setText(.bodyMM, text: city, color: DSKitAsset.Colors.black400.color) + scheduleLabel.setText(.bodyMM, text: schedule, color: DSKitAsset.Colors.black400.color) } } From c4a8b5ce71250e23f2fb5712717f9f7e823e74ef Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 17:36:03 +0900 Subject: [PATCH 05/23] =?UTF-8?q?design:=20#33=20-=20EmptyUpcomingCell=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Component/EmptyUpcomingCell.swift | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 Projects/Modules/DSKit/Sources/Component/EmptyUpcomingCell.swift diff --git a/Projects/Modules/DSKit/Sources/Component/EmptyUpcomingCell.swift b/Projects/Modules/DSKit/Sources/Component/EmptyUpcomingCell.swift new file mode 100644 index 0000000..e13b480 --- /dev/null +++ b/Projects/Modules/DSKit/Sources/Component/EmptyUpcomingCell.swift @@ -0,0 +1,141 @@ +// +// EmptyUpcomingCell.swift +// DSKit +// +// Created by 최안용 on 2/23/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import RxCocoa +import RxSwift + +public final class EmptyUpcomingCell: UICollectionViewCell { + public static let defaultWidth = 328.adjusted + public static let defaultHeight = 216.adjustedH + + private let imageView = UIImageView() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let searchBtn = UIButton() + + private let titleStackView = UIStackView() + private let subStackView = UIStackView() + private let containerStackView = UIStackView() + + public var disposeBag = DisposeBag() + + public var buttonDidTap: Observable { + searchBtn.rx.tap.asObservable() + } + + override public init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func prepareForReuse() { + disposeBag = DisposeBag() + } +} + +private extension EmptyUpcomingCell { + func setStyle() { + imageView.do { + $0.image = DSKitAsset.Assets.icTripBag.image + $0.contentMode = .scaleAspectFit + } + + titleLabel.do { + $0.setText( + .subTitleMSB, + text: "아직 예정된 여행이 없어요.", + color: DSKitAsset.Colors.black500.color, + alignment: .center + ) + } + + subtitleLabel.do { + $0.setText( + .bodyLR, + text: "따라가기 영상을 담아두면 여행 준비가 쉬워져요.", + color: DSKitAsset.Colors.black400.color, + alignment: .center + ) + } + + searchBtn.do { + var config = UIButton.Configuration.plain() + config.background.backgroundColor = DSKitAsset.Colors.black200.color + + var fontAttributes = UIFont.NDGL.bodyMSB.attributes + fontAttributes[.foregroundColor] = DSKitAsset.Colors.black800.color + + config.attributedTitle = AttributedString( + "새로운 여행지 찾아보기", + attributes: AttributeContainer(fontAttributes) + ) + + config.image = DSKitAsset.Assets.icSearch1.image.resize(targetSize: 20.adjusted) + config.imagePlacement = .trailing + config.imagePadding = 8.adjusted + + config.background.cornerRadius = 8.adjustedH + config.contentInsets = NSDirectionalEdgeInsets( + top: 10.adjustedH, + leading: 16.adjusted, + bottom: 10.adjustedH, + trailing: 16.adjusted + ) + $0.configuration = config + } + + titleStackView.do { + $0.axis = .vertical + $0.spacing = 4.adjustedH + $0.alignment = .center + } + + subStackView.do { + $0.axis = .vertical + $0.spacing = 12.adjustedH + $0.alignment = .center + } + + containerStackView.do { + $0.axis = .vertical + $0.spacing = 16.adjustedH + $0.alignment = .center + } + } + + func setUI() { + titleStackView.addArrangedSubviews(titleLabel, subtitleLabel) + subStackView.addArrangedSubviews(titleStackView, searchBtn) + containerStackView.addArrangedSubviews(imageView, subStackView) + contentView.addSubview(containerStackView) + } + + func setLayout() { + containerStackView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + imageView.snp.makeConstraints { + $0.size.equalTo(100.adjustedH) + } + + searchBtn.snp.makeConstraints { + $0.width.equalTo(184.adjusted) + $0.height.equalTo(40.adjustedH) + } + } +} From 2250ca2678151f38449e65eaab7694cfb0d18347 Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 17:36:27 +0900 Subject: [PATCH 06/23] =?UTF-8?q?chore:=20#33=20-=20,=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Features/TabBarFeature/Project.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/Features/TabBarFeature/Project.swift b/Projects/Features/TabBarFeature/Project.swift index 6dd5e7c..5cac296 100644 --- a/Projects/Features/TabBarFeature/Project.swift +++ b/Projects/Features/TabBarFeature/Project.swift @@ -16,7 +16,7 @@ let project = Project.makeModule( name: "TabBarFeature", dependencies: [ .Features.Home.feature, - .Features.MyTravel.feature + .Features.MyTravel.feature, .Features.TravelTool.feature ], scripts: [.swiftLint], From 0ff2d8df5abb97080c72f088f76dbc87768fad0a Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 17:37:02 +0900 Subject: [PATCH 07/23] =?UTF-8?q?chore:=20#33=20-=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=20=ED=95=A8=EC=88=98=20Core=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Extensions/Foundation+/Date+.swift | 18 ++++++++++++++++++ .../Extensions/Foundation+/String+.swift | 7 +++++++ 2 files changed, 25 insertions(+) create mode 100644 Projects/Core/Sources/Extensions/Foundation+/Date+.swift diff --git a/Projects/Core/Sources/Extensions/Foundation+/Date+.swift b/Projects/Core/Sources/Extensions/Foundation+/Date+.swift new file mode 100644 index 0000000..6581cca --- /dev/null +++ b/Projects/Core/Sources/Extensions/Foundation+/Date+.swift @@ -0,0 +1,18 @@ +// +// Date+.swift +// Core +// +// Created by 최안용 on 2/22/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public extension Date { + func toKoreanMMdd() -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "M월 d일" + return formatter.string(from: self) + } +} diff --git a/Projects/Core/Sources/Extensions/Foundation+/String+.swift b/Projects/Core/Sources/Extensions/Foundation+/String+.swift index 8286cdd..9db8874 100644 --- a/Projects/Core/Sources/Extensions/Foundation+/String+.swift +++ b/Projects/Core/Sources/Extensions/Foundation+/String+.swift @@ -31,4 +31,11 @@ public extension String { let locale = Locale(identifier: "ko_KR") return locale.localizedString(forRegionCode: self) ?? "알 수 없음" } + + func toDate() -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: self) + } } From 38435c3941dd9a36b6ff617b29d0c97c9c7805e9 Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 17:37:54 +0900 Subject: [PATCH 08/23] =?UTF-8?q?feat:=20#33=20-=20MyTravel=20Compositiona?= =?UTF-8?q?lLayout=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyTravelCompositionalLayout.swift | 182 ++++++++++++++++++ .../CollectionView/Item/MyTravelItem.swift | 15 ++ .../Registration/MyTravelRegistraion.swift | 61 ++++++ .../SectionKind/MyTravelSectionKind.swift | 25 +++ 4 files changed, 283 insertions(+) create mode 100644 Projects/Features/MyTravelFeature/Sources/Views/CollectionView/CompositionalLayout/MyTravelCompositionalLayout.swift create mode 100644 Projects/Features/MyTravelFeature/Sources/Views/CollectionView/Item/MyTravelItem.swift create mode 100644 Projects/Features/MyTravelFeature/Sources/Views/CollectionView/Registration/MyTravelRegistraion.swift create mode 100644 Projects/Features/MyTravelFeature/Sources/Views/CollectionView/SectionKind/MyTravelSectionKind.swift diff --git a/Projects/Features/MyTravelFeature/Sources/Views/CollectionView/CompositionalLayout/MyTravelCompositionalLayout.swift b/Projects/Features/MyTravelFeature/Sources/Views/CollectionView/CompositionalLayout/MyTravelCompositionalLayout.swift new file mode 100644 index 0000000..2bf948d --- /dev/null +++ b/Projects/Features/MyTravelFeature/Sources/Views/CollectionView/CompositionalLayout/MyTravelCompositionalLayout.swift @@ -0,0 +1,182 @@ +// +// MyTravelCompositionalLayout.swift +// MyTravelFeature +// +// Created by 최안용 on 2/23/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +extension MyTravelViewController { + func createLayout() -> UICollectionViewCompositionalLayout { + return UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in + guard let sectionKind = self?.dataSource?.sectionIdentifier(for: sectionIndex) else { + return self?.emptyLayout() + } + + switch sectionKind { + case .banner: + return self?.createBannerSection() + + case .upcomingTrips(let isEmpty): + if isEmpty { + return self?.createEmptyUpcomingSection() + } else { + return self?.createUpcomingListSection() + } + + case .recommendedTrip: + return self?.createRecommendedTripSection() + } + } + } +} + +private extension MyTravelViewController { + func createBannerSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(80) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .absolute(MyTravelBannerCell.defaultWidth), + heightDimension: .estimated(80) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + repeatingSubitem: item, + count: 1 + ) + + let section = NSCollectionLayoutSection(group: group) + section.orthogonalScrollingBehavior = .none + section.contentInsets = .init( + top: 0, + leading: 24.adjusted, + bottom: 40.adjustedH, + trailing: 24.adjusted + ) + return section + } + + func createEmptyUpcomingSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .absolute(EmptyUpcomingCell.defaultWidth), + heightDimension: .absolute(EmptyUpcomingCell.defaultHeight) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + repeatingSubitem: item, + count: 1 + ) + + let section = NSCollectionLayoutSection(group: group) + section.orthogonalScrollingBehavior = .none + section.contentInsets = .init( + top: 20.adjustedH, + leading: 24.adjusted, + bottom: 40.adjustedH, + trailing: 24.adjusted + ) + section.boundarySupplementaryItems = [createHeaderLayout()] + + return section + } + + func createUpcomingListSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(UpcomingCell.defaultHeight) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(UpcomingCell.defaultHeight) + ) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 16.adjustedH + + section.contentInsets = .init( + top: 16.adjustedH, + leading: 24.adjusted, + bottom: 40.adjustedH, + trailing: 24.adjusted + ) + section.boundarySupplementaryItems = [createHeaderLayout()] + + return section + } + + func createRecommendedTripSection() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(RecommendInfoCell.defaultHeight) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .absolute(RecommendInfoCell.defaultWidth), + heightDimension: .estimated(RecommendInfoCell.defaultHeight) + ) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 16.adjusted + section.contentInsets = .init( + top: 20.adjustedH, + leading: 24.adjusted, + bottom: 81.adjustedH, + trailing: 24.adjusted + ) + section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary + + section.boundarySupplementaryItems = [createHeaderLayout()] + + return section + } + + func emptyLayout() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0) + ) + let group = NSCollectionLayoutGroup(layoutSize: groupSize) + + let section = NSCollectionLayoutSection(group: group) + + return section + } + + func createHeaderLayout() -> NSCollectionLayoutBoundarySupplementaryItem { + let headerSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(60) + ) + + return NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .topLeading + ) + } +} diff --git a/Projects/Features/MyTravelFeature/Sources/Views/CollectionView/Item/MyTravelItem.swift b/Projects/Features/MyTravelFeature/Sources/Views/CollectionView/Item/MyTravelItem.swift new file mode 100644 index 0000000..d038f0b --- /dev/null +++ b/Projects/Features/MyTravelFeature/Sources/Views/CollectionView/Item/MyTravelItem.swift @@ -0,0 +1,15 @@ +// +// MyTravelItem.swift +// MyTravelFeature +// +// Created by 최안용 on 2/23/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +enum MyTravelItem: Hashable { + case banner(MyTravelPresentationModel.Banner) + case upcomingList(MyTravelPresentationModel.Upcoming) + case recommendTrip(MyTravelPresentationModel.RecommendedTrip) +} diff --git a/Projects/Features/MyTravelFeature/Sources/Views/CollectionView/Registration/MyTravelRegistraion.swift b/Projects/Features/MyTravelFeature/Sources/Views/CollectionView/Registration/MyTravelRegistraion.swift new file mode 100644 index 0000000..9626e09 --- /dev/null +++ b/Projects/Features/MyTravelFeature/Sources/Views/CollectionView/Registration/MyTravelRegistraion.swift @@ -0,0 +1,61 @@ +// +// MyTravelRegistraion.swift +// MyTravelFeature +// +// Created by 최안용 on 2/23/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +extension MyTravelViewController { + func createEmptyUpcomingCellRegistration() -> UICollectionView.CellRegistration { + return UICollectionView.CellRegistration { [weak self] cell, _, _ in + guard let self else { return } + + cell.buttonDidTap + .bind(to: self.newTravelBtnTapped) + .disposed(by: cell.disposeBag) + } + } + + func createBannerCellRegistration() -> UICollectionView.CellRegistration { + return UICollectionView.CellRegistration { cell, indexPath, item in + cell.configure(item) + } + } + + func createRecommedTripCellRegistration() -> UICollectionView.CellRegistration { + return UICollectionView.CellRegistration { cell, indexPath, item in + cell.configure( + title: item.title, + thumbnailUrl: item.thumbnailUrl, + countryCode: item.country, + creator: item.creator, + city: item.city, + schedule: item.schedule + ) + } + } + + func createUpcomingCell() -> UICollectionView.CellRegistration { + return UICollectionView.CellRegistration { cell, indexPath, item in + cell.configure(title: item.title, date: item.duration, dDay: item.dDay, imageUrl: item.profileImage) + } + } + + func createHeaderRegistration() -> UICollectionView.SupplementaryRegistration { + return UICollectionView.SupplementaryRegistration( + elementKind: UICollectionView.elementKindSectionHeader) { [weak self] headerView, elementKind, indexPath in + guard let self = self else { return } + + let sections = self.dataSource.snapshot().sectionIdentifiers + guard indexPath.section < sections.count else { return } + + let sectionKind = sections[indexPath.section] + headerView.configure(title: sectionKind.headerTitle) + } + } +} diff --git a/Projects/Features/MyTravelFeature/Sources/Views/CollectionView/SectionKind/MyTravelSectionKind.swift b/Projects/Features/MyTravelFeature/Sources/Views/CollectionView/SectionKind/MyTravelSectionKind.swift new file mode 100644 index 0000000..a9b2143 --- /dev/null +++ b/Projects/Features/MyTravelFeature/Sources/Views/CollectionView/SectionKind/MyTravelSectionKind.swift @@ -0,0 +1,25 @@ +// +// MyTravelSectionKind.swift +// MyTravelFeature +// +// Created by 최안용 on 2/22/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +enum MyTravelSectionKind: Hashable { + case banner + case upcomingTrips(isEmpty: Bool) + case recommendedTrip + + var headerTitle: String { + switch self { + case .upcomingTrips: + return "다가오는 여행" + case .recommendedTrip: + return "추천하는 따라가기 여행" + default: return "" + } + } +} From 9f8545f9c21b14e693f22d427d7c7c7f02f999d0 Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 17:38:16 +0900 Subject: [PATCH 09/23] =?UTF-8?q?design:=20#33=20-=20MyTravelBannerCell=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/Cells/MyTravelBannerCell.swift | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 Projects/Features/MyTravelFeature/Sources/Views/Cells/MyTravelBannerCell.swift diff --git a/Projects/Features/MyTravelFeature/Sources/Views/Cells/MyTravelBannerCell.swift b/Projects/Features/MyTravelFeature/Sources/Views/Cells/MyTravelBannerCell.swift new file mode 100644 index 0000000..0386282 --- /dev/null +++ b/Projects/Features/MyTravelFeature/Sources/Views/Cells/MyTravelBannerCell.swift @@ -0,0 +1,137 @@ +// +// MyTravelBannerCell.swift +// MyTravelFeature +// +// Created by 최안용 on 2/23/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +final class MyTravelBannerCell: UICollectionViewCell { + static let defaultWidth = 327.adjusted + + private var type: MyTravelBannerType? + + private let upCommingView = NDGLUpComingView() + private let onGoingView = NDGLOnGoingView() + private let stackView = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + [upCommingView, onGoingView].forEach { $0.isHidden = true } + + upCommingView.prepareForReuse() + onGoingView.prepareForReuse() + } + + func configure(_ model: MyTravelPresentationModel.Banner) { + [upCommingView, onGoingView].forEach { $0.isHidden = true } + + let now = Date() + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: now) + let startOfTravel = calendar.startOfDay(for: model.startDay) + let startOfEnd = calendar.startOfDay(for: model.endDay) + + if startOfToday >= startOfTravel && startOfToday <= startOfEnd { + let schedule = model.tripSchedule + self.type = .onGoing( + title: model.title, + date: model.duration, + transportIcon: DSKitAsset.Assets.icBus2.image, + duration: "\(schedule.estimatedDuration)분", + place: schedule.placeName, + imageUrl: schedule.thumbnailUrl + ) + } else { + let dDayValue = calendar.dateComponents([.day], from: startOfToday, to: startOfTravel).day ?? 0 + self.type = .upComming( + title: model.title, + date: model.duration, + dDay: dDayValue, + imageUrl: model.tripSchedule.thumbnailUrl + ) + } + + updateViewWithCurrentType() + } +} + +private extension MyTravelBannerCell { + func updateViewWithCurrentType() { + switch type { + case .upComming(let title, let date, let dDay, let imageUrl): + upCommingView.isHidden = false + onGoingView.isHidden = true + upCommingView.configure(title: title, date: date, dDay: dDay, imageUrl: imageUrl) + + case .onGoing(let title, let date, let transportIcon, let duration, let place, let imageUrl): + onGoingView.isHidden = false + upCommingView.isHidden = true + onGoingView.configure( + title: title, + date: date, + transportIcon: transportIcon, + transport: "대중교통", + duration: duration, + place: place, + imageUrl: imageUrl + ) + case .none: + break + } + } + + func setStyle() { + contentView.do { + $0.backgroundColor = DSKitAsset.Colors.black50.color + $0.layer.cornerRadius = 8.adjustedH + $0.clipsToBounds = true + } + + stackView.do { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .fill + } + } + + func setUI() { + contentView.addSubview(stackView) + stackView.addArrangedSubviews(upCommingView, onGoingView) + } + + func setLayout() { + stackView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } +} + +enum MyTravelBannerType: Hashable { + case upComming(title: String, date: String, dDay: Int, imageUrl: String) + case onGoing( + title: String, + date: String, + transportIcon: UIImage?, + duration: String, + place: String, + imageUrl: String + ) +} From 32ab446ac226eaf1a183da7fcd3419a543932b16 Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 17:38:37 +0900 Subject: [PATCH 10/23] =?UTF-8?q?design:=20#33=200=20-=20UpcomingCell=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Views/Cells/UpcomingCell.swift | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 Projects/Features/MyTravelFeature/Sources/Views/Cells/UpcomingCell.swift diff --git a/Projects/Features/MyTravelFeature/Sources/Views/Cells/UpcomingCell.swift b/Projects/Features/MyTravelFeature/Sources/Views/Cells/UpcomingCell.swift new file mode 100644 index 0000000..fa8fee9 --- /dev/null +++ b/Projects/Features/MyTravelFeature/Sources/Views/Cells/UpcomingCell.swift @@ -0,0 +1,125 @@ +// +// UpcomingCell.swift +// MyTravelFeature +// +// Created by 최안용 on 2/23/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +final class UpcomingCell: UICollectionViewCell { + static let defaultWidth = 328.adjusted + static let defaultHeight = 80.adjustedH + + private let imageView = UIImageView() + private let badge = UIView() + private let dDayLabel = UILabel() + private let titleLabel = UILabel() + private let dateLabel = UILabel() + private let titleStackView = UIStackView() + private let infoStackView = UIStackView() + private let stackView = UIStackView() + + override public init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func configure(title: String, date: String, dDay: Int, imageUrl: String) { + titleLabel.setText(.subTitleMSB, text: title, color: DSKitAsset.Colors.black700.color) + dateLabel.setText(.bodyMR, text: date, color: DSKitAsset.Colors.black600.color) + dDayLabel.setText(.bodyMM, text: "D-\(dDay)", color: DSKitAsset.Colors.black400.color) + + if let url = URL(string: imageUrl) { + imageView.kf.setImage(with: url) + } else { + imageView.backgroundColor = .systemGray5 + } + } + + override public func prepareForReuse() { + imageView.kf.cancelDownloadTask() + titleLabel.text = nil + dateLabel.text = nil + dDayLabel.text = nil + imageView.image = nil + } + +} + +private extension UpcomingCell { + func setStyle() { + backgroundColor = .clear + + imageView.do { + $0.layer.cornerRadius = 64.adjustedH / 2 + $0.clipsToBounds = true + $0.contentMode = .scaleAspectFill + } + + badge.do { + $0.backgroundColor = DSKitAsset.Colors.black100.color + $0.layer.cornerRadius = 26.adjustedH / 2 + $0.clipsToBounds = true + $0.setContentCompressionResistancePriority(.required, for: .horizontal) + $0.setContentHuggingPriority(.required, for: .horizontal) + } + + titleLabel.do { + $0.numberOfLines = 1 + $0.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + + titleStackView.do { + $0.axis = .horizontal + $0.spacing = 8.adjusted + $0.alignment = .center + } + + infoStackView.do { + $0.axis = .vertical + $0.spacing = 6.adjustedH + $0.alignment = .leading + } + + stackView.do { + $0.axis = .horizontal + $0.spacing = 12.adjusted + $0.alignment = .center + } + } + + func setUI() { + badge.addSubview(dDayLabel) + titleStackView.addArrangedSubviews(badge, titleLabel) + infoStackView.addArrangedSubviews(titleStackView, dateLabel) + stackView.addArrangedSubviews(imageView, infoStackView) + addSubview(stackView) + } + + func setLayout() { + imageView.snp.makeConstraints { + $0.size.equalTo(64.adjustedH) + } + + dDayLabel.snp.makeConstraints { + $0.directionalHorizontalEdges.equalToSuperview().inset(12.adjusted) + $0.directionalVerticalEdges.equalToSuperview().inset(4.adjustedH) + } + + stackView.snp.makeConstraints { + $0.directionalHorizontalEdges.equalToSuperview().inset(16.adjusted) + $0.directionalVerticalEdges.equalToSuperview().inset(8.adjustedH).priority(.high) + } + } +} From 2c8fdf4e396371ab2a807f4a2a80269c1c0f4d91 Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 17:39:00 +0900 Subject: [PATCH 11/23] =?UTF-8?q?design:=20#33=20-=20MyTravelHeaderView=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/Cells/MyTravelHeaderView.swift | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 Projects/Features/MyTravelFeature/Sources/Views/Cells/MyTravelHeaderView.swift diff --git a/Projects/Features/MyTravelFeature/Sources/Views/Cells/MyTravelHeaderView.swift b/Projects/Features/MyTravelFeature/Sources/Views/Cells/MyTravelHeaderView.swift new file mode 100644 index 0000000..098cf02 --- /dev/null +++ b/Projects/Features/MyTravelFeature/Sources/Views/Cells/MyTravelHeaderView.swift @@ -0,0 +1,58 @@ +// +// MyTravelHeaderView.swift +// MyTravelFeature +// +// Created by 최안용 on 2/23/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +final class MyTravelHeaderView: UICollectionReusableView { + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + titleLabel.text = nil + } + + func configure(title: String) { + titleLabel.do { + $0.setText(.subTitleLSB, text: title, color: DSKitAsset.Colors.black900.color) + } + } +} + +private extension MyTravelHeaderView { + func setStyle() { + titleLabel.do { + $0.numberOfLines = 2 + } + } + + func setUI() { + addSubview(titleLabel) + } + + func setLayout() { + titleLabel.snp.makeConstraints { + $0.leading.equalToSuperview() + $0.directionalVerticalEdges.equalToSuperview() + } + } +} From 954ee2a9bcc28d79a81cf0517651fbe1ff78b8fd Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 17:39:46 +0900 Subject: [PATCH 12/23] =?UTF-8?q?feat:=20#33=20-=20MyTravelPresentationMod?= =?UTF-8?q?el=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Models/MyTravelPresentationModel.swift | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 Projects/Features/MyTravelFeature/Sources/Models/MyTravelPresentationModel.swift diff --git a/Projects/Features/MyTravelFeature/Sources/Models/MyTravelPresentationModel.swift b/Projects/Features/MyTravelFeature/Sources/Models/MyTravelPresentationModel.swift new file mode 100644 index 0000000..df1f8cb --- /dev/null +++ b/Projects/Features/MyTravelFeature/Sources/Models/MyTravelPresentationModel.swift @@ -0,0 +1,138 @@ +// +// MyTravelPresentationModel.swift +// MyTravelFeature +// +// Created by 최안용 on 2/22/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +import Core +import Domain + +struct MyTravelPresentationModel { + let banner: MyTravelPresentationModel.Banner + let recommendedTrips: [MyTravelPresentationModel.RecommendedTrip] + let upcomingList: [MyTravelPresentationModel.Upcoming] + + struct Banner: Hashable { + let id: Int + let title: String + let startDay: Date + let endDay: Date + let duration: String + let tripSchedule: Schedule + } + + struct Schedule: Hashable { + let id: Int + let day: Int + let placeName: String + let thumbnailUrl: String + let transport: String + let estimatedDuration: Int + } + + struct RecommendedTrip: Hashable { + let id: Int + let title: String + let thumbnailUrl: String + let creator: String + let country: String + let schedule: String + let city: String + } + + struct Upcoming: Hashable { + let id: Int + let title: String + let profileImage: String + let dDay: Int + let duration: String + } +} + +extension MyTravelPresentationModel.Schedule { + static var empty: Self { + return .init( + id: 0, + day: 0, + placeName: "", + thumbnailUrl: "", + transport: "", + estimatedDuration: 0 + ) + } +} + +extension MyTravelPresentationModel.Banner { + static var empty: Self { + return .init( + id: 0, + title: "", + startDay: .now, + endDay: .now, + duration: "", + tripSchedule: .empty + ) + } +} + +extension MyTripSummary { + func toMyTravelModel() -> MyTravelPresentationModel.Banner { + let schedule: MyTravelPresentationModel.Schedule + if let tripSchedule = self.tripSchedule { + schedule = .init( + id: tripSchedule.id, + day: tripSchedule.day, + placeName: tripSchedule.placeName, + thumbnailUrl: tripSchedule.thumbnailUrl, + transport: tripSchedule.transport, + estimatedDuration: tripSchedule.estimatedDuration + ) + } else { + schedule = .empty + } + + return MyTravelPresentationModel.Banner( + id: self.id, + title: self.title, + startDay: self.startDay, + endDay: self.endDay, + duration: "\(self.startDay.toKoreanMMdd())~\(self.endDay.toKoreanMMdd())", + tripSchedule: schedule + ) + } +} + +extension TripInfo { + func toRecommendMyTravelModel() -> MyTravelPresentationModel.RecommendedTrip { + return MyTravelPresentationModel.RecommendedTrip( + id: self.id, + title: self.title, + thumbnailUrl: self.thumbnailUrl, + creator: self.creator, + country: self.country, + schedule: "\(self.nights)박 \(self.days)일", + city: self.city + ) + } +} + +extension UpcomingInfo { + func toMyTravelModel() -> MyTravelPresentationModel.Upcoming { + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: Date()) + let startOfTravel = calendar.startOfDay(for: self.startDate) + let dDayValue = calendar.dateComponents([.day], from: startOfToday, to: startOfTravel).day ?? 0 + + return MyTravelPresentationModel.Upcoming( + id: self.id, + title: self.title, + profileImage: self.profileImage ?? "", + dDay: dDayValue, + duration: "\(self.startDate.toKoreanMMdd())~\(self.endDate.toKoreanMMdd())" + ) + } +} From 30bf10906b27eaa2c09150af1c26f65d1bc29450 Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 18:04:28 +0900 Subject: [PATCH 13/23] =?UTF-8?q?feat:=20#33=20-=20MyTravelFeature=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TabBarFeature/Sources/TabBarBuilder.swift | 16 +++++++++----- .../Sources/TabBarInteractor.swift | 16 +++++++++++++- .../TabBarFeature/Sources/TabBarRouter.swift | 22 +++++++++---------- .../Sources/TabBarViewController.swift | 6 ++--- 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift b/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift index 1174f6b..dd4f591 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarBuilder.swift @@ -8,20 +8,26 @@ import Domain import HomeFeature -import RIBs -import TravelFeature +import MyTravelFeature import TravelToolFeature +import RIBs + // MARK: - TabBarDependency public protocol TabBarDependency: Dependency { var homeUsecase: HomeUsecaseProtocol { get } var weatherRepository: WeatherRepositoryInterface { get } + var myTravelUsecase: MyTravelUsecaseProtocol { get } } // MARK: - TabBarComponent -final class TabBarComponent: Component, HomeDependency, TravelDependency, TravelToolDependency { +final class TabBarComponent: Component, HomeDependency, MyTravelDependency, TravelToolDependency { + var myTravelUsecase: MyTravelUsecaseProtocol { + dependency.myTravelUsecase + } + var homeUsecase: HomeUsecaseProtocol { dependency.homeUsecase } @@ -52,14 +58,14 @@ public final class TabBarBuilder: Builder, TabBarBuildable { interactor.listener = listener let homeBuilder = HomeBuilder(dependency: component) - let travelBuilder = TravelBuilder(dependency: component) + let myTravelBuilder = MyTravelBuilder(dependency: component) let travelToolBuilder = TravelToolBuilder(dependency: component) let router = TabBarRouter( interactor: interactor, viewController: viewController, homeBuilder: homeBuilder, - travelBuilder: travelBuilder, + myTravelBuilder: myTravelBuilder, travelToolBuilder: travelToolBuilder ) diff --git a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift index f2c055d..171d32b 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift @@ -11,6 +11,7 @@ import HomeFeature import RIBs import RxSwift import TravelToolFeature +import MyTravelFeature // MARK: - TabBarRouting @@ -39,7 +40,6 @@ public protocol TabBarListener: AnyObject { // MARK: - TabBarInteractor final class TabBarInteractor: PresentableInteractor, TabBarInteractable { - weak var router: TabBarRouting? weak var listener: TabBarListener? @@ -92,3 +92,17 @@ extension TabBarInteractor: HomeListener { extension TabBarInteractor: TravelToolListener { } + +extension TabBarInteractor: MyTravelListener { + func myTraveDidTapFollowDetail(with recommendationId: Int) { + listener?.routeToFollow(with: recommendationId) + } + + func myTraveDidTapSearch() { + listener?.routeToSearch() + } + + func myTraveDidTapPopularTravel() { + listener?.routeToPopularTravel() + } +} diff --git a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift index 7f4ba55..2ed1e4e 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarRouter.swift @@ -9,12 +9,12 @@ import RIBs import HomeFeature -import TravelFeature +import MyTravelFeature import TravelToolFeature // MARK: - TabBarInteractable -protocol TabBarInteractable: Interactable, HomeListener, TravelListener, TravelToolListener { +protocol TabBarInteractable: Interactable, HomeListener, MyTravelListener, TravelToolListener { var router: TabBarRouting? { get set } var listener: TabBarListener? { get set } } @@ -31,21 +31,21 @@ public protocol TabBarViewControllable: ViewControllable { final class TabBarRouter: ViewableRouter, TabBarRouting { private let homeBuilder: HomeBuildable - private let travelBuilder: TravelBuildable + private let myTravelBuilder: MyTravelBuildable private let travelToolBuilder: TravelToolBuildable private var homeRouter: HomeRouting? - private var travelRouter: TravelRouting? + private var myTravelRouter: MyTravelRouting? private var travelToolRouter: TravelToolRouting? init( interactor: TabBarInteractable, viewController: TabBarViewControllable, homeBuilder: HomeBuildable, - travelBuilder: TravelBuildable, + myTravelBuilder: MyTravelBuildable, travelToolBuilder: TravelToolBuildable ) { self.homeBuilder = homeBuilder - self.travelBuilder = travelBuilder + self.myTravelBuilder = myTravelBuilder self.travelToolBuilder = travelToolBuilder super.init(interactor: interactor, viewController: viewController) interactor.router = self @@ -63,7 +63,7 @@ final class TabBarRouter: ViewableRouter Date: Mon, 23 Feb 2026 18:06:16 +0900 Subject: [PATCH 14/23] =?UTF-8?q?feat:=20#33=20-=20MyTravelUsecase=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/Application/AppComponent.swift | 9 +++++++++ Projects/Features/MainFeature/Sources/MainBuilder.swift | 5 +++++ Projects/Features/RootFeature/Sources/RootBuilder.swift | 5 +++++ 3 files changed, 19 insertions(+) diff --git a/Projects/App/Sources/Application/AppComponent.swift b/Projects/App/Sources/Application/AppComponent.swift index 8bb7018..89c29f7 100644 --- a/Projects/App/Sources/Application/AppComponent.swift +++ b/Projects/App/Sources/Application/AppComponent.swift @@ -73,6 +73,15 @@ final class AppComponent: Component, RootDependency { TemplatesSearchUsecase(travelTemplateRepository: travelTemplateRepository) } } + + var myTravelUsecase: MyTravelUsecaseProtocol { + shared { + MyTravelUsecase( + travelTemplateRepository: travelTemplateRepository, + userTravelRepository: userTravelRepository + ) + } + } var authRepository: AuthRepositoryInterface { shared { makeAuthRepository() } diff --git a/Projects/Features/MainFeature/Sources/MainBuilder.swift b/Projects/Features/MainFeature/Sources/MainBuilder.swift index 38b783c..ca900c0 100644 --- a/Projects/Features/MainFeature/Sources/MainBuilder.swift +++ b/Projects/Features/MainFeature/Sources/MainBuilder.swift @@ -20,9 +20,14 @@ public protocol MainDependency: Dependency { var followDetailUsecase: FollowDetailUsecaseProtocol { get } var templateSearchUsecase: TemplatesSearchUsecaseProtocol { get } var weatherRepository: WeatherRepositoryInterface { get } + var myTravelUsecase: MyTravelUsecaseProtocol { get } } final class MainComponent: Component, FollowDetailDependency, PopularTravelDependency,SearchDependency, SettingDependency, TabBarDependency { + var myTravelUsecase: MyTravelUsecaseProtocol { + dependency.myTravelUsecase + } + var searchUsecase: TemplatesSearchUsecaseProtocol { dependency.templateSearchUsecase } diff --git a/Projects/Features/RootFeature/Sources/RootBuilder.swift b/Projects/Features/RootFeature/Sources/RootBuilder.swift index 2b9f59f..13722de 100644 --- a/Projects/Features/RootFeature/Sources/RootBuilder.swift +++ b/Projects/Features/RootFeature/Sources/RootBuilder.swift @@ -15,6 +15,7 @@ import RIBs public protocol RootDependency: Dependency { var homeUsecase: HomeUsecaseProtocol { get } + var myTravelUsecase: MyTravelUsecaseProtocol { get } var followDetailUsecase: FollowDetailUsecaseProtocol { get } var authRepository: AuthRepositoryInterface { get } var tokenRepository: TokenRepositoryProtocol { get } @@ -40,6 +41,10 @@ final class RootComponent: Component, MainDependency { var weatherRepository: WeatherRepositoryInterface { dependency.weatherRepository } + + var myTravelUsecase: MyTravelUsecaseProtocol { + dependency.myTravelUsecase + } } // MARK: - RootBuildable From 8a27b759bd41b71d4088ee10c673fb7ce10f6b84 Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 18:06:38 +0900 Subject: [PATCH 15/23] =?UTF-8?q?feat:=20#33=20-=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyTravelFeature/Sources/Views/Cells/UpcomingCell.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Projects/Features/MyTravelFeature/Sources/Views/Cells/UpcomingCell.swift b/Projects/Features/MyTravelFeature/Sources/Views/Cells/UpcomingCell.swift index fa8fee9..77be011 100644 --- a/Projects/Features/MyTravelFeature/Sources/Views/Cells/UpcomingCell.swift +++ b/Projects/Features/MyTravelFeature/Sources/Views/Cells/UpcomingCell.swift @@ -41,13 +41,16 @@ final class UpcomingCell: UICollectionViewCell { dDayLabel.setText(.bodyMM, text: "D-\(dDay)", color: DSKitAsset.Colors.black400.color) if let url = URL(string: imageUrl) { - imageView.kf.setImage(with: url) + imageView.kf.setImage(with: url, options: [.transition(.fade(0.3))]) } else { + print(imageUrl) imageView.backgroundColor = .systemGray5 } } override public func prepareForReuse() { + super.prepareForReuse() + imageView.kf.cancelDownloadTask() titleLabel.text = nil dateLabel.text = nil From aab867048b3c5d6d4666b07e1c43ab92d66fe646 Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 18:07:07 +0900 Subject: [PATCH 16/23] =?UTF-8?q?fix:=20#33=20-=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Models/MyTravelPresentationModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/Features/MyTravelFeature/Sources/Models/MyTravelPresentationModel.swift b/Projects/Features/MyTravelFeature/Sources/Models/MyTravelPresentationModel.swift index df1f8cb..2055502 100644 --- a/Projects/Features/MyTravelFeature/Sources/Models/MyTravelPresentationModel.swift +++ b/Projects/Features/MyTravelFeature/Sources/Models/MyTravelPresentationModel.swift @@ -130,7 +130,7 @@ extension UpcomingInfo { return MyTravelPresentationModel.Upcoming( id: self.id, title: self.title, - profileImage: self.profileImage ?? "", + profileImage: self.thumbnail ?? "", dDay: dDayValue, duration: "\(self.startDate.toKoreanMMdd())~\(self.endDate.toKoreanMMdd())" ) From 29ae32951276413d18fdeea2af413ab09423b2df Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 18:07:36 +0900 Subject: [PATCH 17/23] =?UTF-8?q?refactor:=20#33=20-=20=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=A7=A4=EA=B0=9C=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CollectionView/Registration/HomeRegistration.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Projects/Features/HomeFeature/Sources/Views/CollectionView/Registration/HomeRegistration.swift b/Projects/Features/HomeFeature/Sources/Views/CollectionView/Registration/HomeRegistration.swift index f1558f3..92e66c5 100644 --- a/Projects/Features/HomeFeature/Sources/Views/CollectionView/Registration/HomeRegistration.swift +++ b/Projects/Features/HomeFeature/Sources/Views/CollectionView/Registration/HomeRegistration.swift @@ -47,7 +47,14 @@ extension HomeViewController { func createRecommedTripCellRegistration() -> UICollectionView.CellRegistration { return UICollectionView.CellRegistration { cell, indexPath, item in - cell.configure(item) + cell.configure( + title: item.title, + thumbnailUrl: item.thumbnailUrl, + countryCode: item.country, + creator: item.creator, + city: item.city, + schedule: item.schedule + ) } } From fe9cc186a448cd326aea3d348cbdc52161c86512 Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 18:08:02 +0900 Subject: [PATCH 18/23] =?UTF-8?q?del:=20#33=20-=20Core=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=9D=B4=EC=A0=84=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Models/HomePresentationModel.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Projects/Features/HomeFeature/Sources/Models/HomePresentationModel.swift b/Projects/Features/HomeFeature/Sources/Models/HomePresentationModel.swift index f03e6eb..745b2f8 100644 --- a/Projects/Features/HomeFeature/Sources/Models/HomePresentationModel.swift +++ b/Projects/Features/HomeFeature/Sources/Models/HomePresentationModel.swift @@ -149,12 +149,3 @@ extension TripInfo { ) } } - -extension Date { - func toKoreanMMdd() -> String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "ko_KR") - formatter.dateFormat = "M월 d일" - return formatter.string(from: self) - } -} From b489aa62de10a1c37c64d65e1939e85463099f8d Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 18:08:29 +0900 Subject: [PATCH 19/23] =?UTF-8?q?fix:=20#33=20-=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=86=B5=EC=8B=A0=20=EC=83=9D=EB=AA=85=EC=A3=BC?= =?UTF-8?q?=EA=B8=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Features/HomeFeature/Sources/HomeInteractor.swift | 6 ++---- .../Features/HomeFeature/Sources/HomeViewController.swift | 6 +++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift index d124111..8216e80 100644 --- a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift +++ b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift @@ -64,9 +64,6 @@ final class HomeInteractor: PresentableInteractor, HomeInteract override func didBecomeActive() { super.didBecomeActive() - - setupStream() - fetchHomeData() } override func willResignActive() { @@ -179,7 +176,8 @@ final class HomeInteractor: PresentableInteractor, HomeInteract // MARK: - HomePresentableListener extension HomeInteractor: HomePresentableListener { - func viewWillAppear() { + func viewDidLoad() { + setupStream() fetchHomeData() } diff --git a/Projects/Features/HomeFeature/Sources/HomeViewController.swift b/Projects/Features/HomeFeature/Sources/HomeViewController.swift index 6161f6e..72f53a8 100644 --- a/Projects/Features/HomeFeature/Sources/HomeViewController.swift +++ b/Projects/Features/HomeFeature/Sources/HomeViewController.swift @@ -23,7 +23,7 @@ protocol HomePresentableListener: AnyObject { func itemSelected(item: HomeItem) func moreBtnTapped() func reloadBtnTapped() - func viewWillAppear() + func viewDidLoad() } // MARK: - HomeViewController @@ -58,12 +58,12 @@ final class HomeViewController: UIViewController, HomeViewControllable { setCollectionView() setDataSource() bindInteractor() + listener?.viewDidLoad() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) - listener?.viewWillAppear() } } @@ -239,7 +239,7 @@ private extension HomeViewController { let headerRegistration = createHeaderRegistration() let popularFooterRegistration = createPopularFooterRegistration() - dataSource?.supplementaryViewProvider = { collectionView, kind, indexPath in + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in guard HomeSectionKind(rawValue: indexPath.section) != nil else { return UICollectionReusableView() } From b5e3ac656f4a97fee9e7579443084b62c188ab32 Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 18:08:55 +0900 Subject: [PATCH 20/23] =?UTF-8?q?feat:=20#33=20-=20MyTravel=20RIBs=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/MyTravelBuilder.swift | 23 +- .../Sources/MyTravelInteractor.swift | 133 ++++++++-- .../Sources/MyTravelRouter.swift | 8 +- .../Sources/MyTravelViewController.swift | 242 +++++++++++++++++- 4 files changed, 373 insertions(+), 33 deletions(-) diff --git a/Projects/Features/MyTravelFeature/Sources/MyTravelBuilder.swift b/Projects/Features/MyTravelFeature/Sources/MyTravelBuilder.swift index ffb72a2..0c8eac1 100644 --- a/Projects/Features/MyTravelFeature/Sources/MyTravelBuilder.swift +++ b/Projects/Features/MyTravelFeature/Sources/MyTravelBuilder.swift @@ -6,35 +6,38 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import Domain + import RIBs -protocol MyTravelDependency: Dependency { - // TODO: Declare the set of dependencies required by this RIB, but cannot be - // created by this RIB. +public protocol MyTravelDependency: Dependency { + var myTravelUsecase: MyTravelUsecaseProtocol { get } } final class MyTravelComponent: Component { - - // TODO: Declare 'fileprivate' dependencies that are only used by this RIB. + var myTravelUsecase: MyTravelUsecaseProtocol { + dependency.myTravelUsecase + } } // MARK: - Builder -protocol MyTravelBuildable: Buildable { +public protocol MyTravelBuildable: Buildable { func build(withListener listener: MyTravelListener) -> MyTravelRouting } -final class MyTravelBuilder: Builder, MyTravelBuildable { +public final class MyTravelBuilder: Builder, MyTravelBuildable { - override init(dependency: MyTravelDependency) { + override public init(dependency: MyTravelDependency) { super.init(dependency: dependency) } - func build(withListener listener: MyTravelListener) -> MyTravelRouting { + public func build(withListener listener: MyTravelListener) -> MyTravelRouting { let component = MyTravelComponent(dependency: dependency) let viewController = MyTravelViewController() - let interactor = MyTravelInteractor(presenter: viewController) + let interactor = MyTravelInteractor(presenter: viewController, usecase: component.myTravelUsecase) interactor.listener = listener + return MyTravelRouter(interactor: interactor, viewController: viewController) } } diff --git a/Projects/Features/MyTravelFeature/Sources/MyTravelInteractor.swift b/Projects/Features/MyTravelFeature/Sources/MyTravelInteractor.swift index 9fb64cd..c014842 100644 --- a/Projects/Features/MyTravelFeature/Sources/MyTravelInteractor.swift +++ b/Projects/Features/MyTravelFeature/Sources/MyTravelInteractor.swift @@ -6,41 +6,144 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import Foundation + +import Domain + import RIBs +import RxCocoa +import RxRelay import RxSwift -protocol MyTravelRouting: ViewableRouting { - // TODO: Declare methods the interactor can invoke to manage sub-tree via the router. +public protocol MyTravelRouting: ViewableRouting { + } protocol MyTravelPresentable: Presentable { var listener: MyTravelPresentableListener? { get set } - // TODO: Declare methods the interactor can invoke the presenter to present data. + + func update(with sections: [(MyTravelSectionKind, [MyTravelItem])]) + func setLoading(_ isLoading: Bool) + func showErrorView(_ isError: Bool) } -protocol MyTravelListener: AnyObject { - // TODO: Declare methods the interactor can invoke to communicate with other RIBs. +public protocol MyTravelListener: AnyObject { + func myTraveDidTapFollowDetail(with recommendationId: Int) + func myTraveDidTapSearch() + func myTraveDidTapPopularTravel() } -final class MyTravelInteractor: PresentableInteractor, MyTravelInteractable, MyTravelPresentableListener { - +final class MyTravelInteractor: PresentableInteractor, MyTravelInteractable { weak var router: MyTravelRouting? weak var listener: MyTravelListener? - - // TODO: Add additional dependencies to constructor. Do not perform any logic - // in constructor. - override init(presenter: MyTravelPresentable) { + + private var fetchDataTask: Task? + private let myTravelRelay = BehaviorRelay<[(MyTravelSectionKind, [MyTravelItem])]>(value: []) + private let usecase: MyTravelUsecaseProtocol + private let disposeBag = DisposeBag() + + init(presenter: MyTravelPresentable, usecase: MyTravelUsecaseProtocol) { + self.usecase = usecase super.init(presenter: presenter) presenter.listener = self } - + override func didBecomeActive() { super.didBecomeActive() - // TODO: Implement business logic here. } - + override func willResignActive() { super.willResignActive() - // TODO: Pause any business logic. + + fetchDataTask?.cancel() + fetchDataTask = nil + } + + private func fetchMyTravelData() { + fetchDataTask?.cancel() + + presenter.setLoading(true) + presenter.showErrorView(false) + + fetchDataTask = Task { [weak self] in + guard let self, !Task.isCancelled else { return } + + var myTripBanner: MyTravelPresentationModel.Banner? + var upcomingList: [MyTravelPresentationModel.Upcoming] = [] + var sections: [(MyTravelSectionKind, [MyTravelItem])] = [] + + do { + do { + myTripBanner = try await usecase.fetchMyTripInfo().toMyTravelModel() + } catch { + print("Log: MyTripInfo 로드 실패 - \(error)") + myTripBanner = nil + } + + upcomingList = try await usecase.fetchUpcomingList().map { $0.toMyTravelModel() } + + var recommendItems: [MyTravelPresentationModel.RecommendedTrip] = [] + if myTripBanner == nil && upcomingList.isEmpty { + recommendItems = try await usecase.fetchRecommendTripList().map { $0.toRecommendMyTravelModel() } + } + + if let banner = myTripBanner { + sections.append((.banner, [.banner(banner)])) + } + + if upcomingList.isEmpty { + sections.append((.upcomingTrips(isEmpty: true), [.banner(.empty)])) + } else { + sections.append( + (.upcomingTrips(isEmpty: false), + upcomingList.map { MyTravelItem.upcomingList($0) }) + ) + } + + if !recommendItems.isEmpty { + sections.append((.recommendedTrip, recommendItems.map { MyTravelItem.recommendTrip($0) })) + } + + await MainActor.run { [sections] in + self.presenter.setLoading(false) + self.presenter.update(with: sections) + } + + } catch { + await MainActor.run { + self.presenter.setLoading(false) + self.presenter.showErrorView(true) + } + } + } + } +} + +extension MyTravelInteractor: MyTravelPresentableListener { + func myTraveDidTapPopularTravel() { + listener?.myTraveDidTapPopularTravel() + } + + func viewWillAppear() { + fetchMyTravelData() + } + + func myTraveDidTapSearch() { + listener?.myTraveDidTapSearch() + } + + func itemSelected(item: MyTravelItem) { + switch item { + case .recommendTrip(let trip): + listener?.myTraveDidTapFollowDetail(with: trip.id) + case .upcomingList(let upcoming): + listener?.myTraveDidTapFollowDetail(with: upcoming.id) + case .banner(let myTrip): + listener?.myTraveDidTapFollowDetail(with: myTrip.id) + } + } + + func reloadBtnTapped() { + fetchMyTravelData() } } diff --git a/Projects/Features/MyTravelFeature/Sources/MyTravelRouter.swift b/Projects/Features/MyTravelFeature/Sources/MyTravelRouter.swift index 9241956..b0a833a 100644 --- a/Projects/Features/MyTravelFeature/Sources/MyTravelRouter.swift +++ b/Projects/Features/MyTravelFeature/Sources/MyTravelRouter.swift @@ -14,13 +14,15 @@ protocol MyTravelInteractable: Interactable { } protocol MyTravelViewControllable: ViewControllable { - // TODO: Declare methods the router invokes to manipulate the view hierarchy. + } final class MyTravelRouter: ViewableRouter, MyTravelRouting { - // TODO: Constructor inject child builder protocols to allow building children. - override init(interactor: MyTravelInteractable, viewController: MyTravelViewControllable) { + override init( + interactor: MyTravelInteractable, + viewController: MyTravelViewControllable + ) { super.init(interactor: interactor, viewController: viewController) interactor.router = self } diff --git a/Projects/Features/MyTravelFeature/Sources/MyTravelViewController.swift b/Projects/Features/MyTravelFeature/Sources/MyTravelViewController.swift index df9c400..9ae95f2 100644 --- a/Projects/Features/MyTravelFeature/Sources/MyTravelViewController.swift +++ b/Projects/Features/MyTravelFeature/Sources/MyTravelViewController.swift @@ -6,17 +6,249 @@ // Copyright © 2026 NDGL-iOS. All rights reserved. // +import UIKit + +import Domain +import DSKit + import RIBs +import RxCocoa import RxSwift -import UIKit protocol MyTravelPresentableListener: AnyObject { - // TODO: Declare properties and methods that the view controller can invoke to perform - // business logic, such as signIn(). This protocol is implemented by the corresponding - // interactor class. + func viewWillAppear() + func myTraveDidTapSearch() + func itemSelected(item: MyTravelItem) + func reloadBtnTapped() + func myTraveDidTapPopularTravel() } -final class MyTravelViewController: UIViewController, MyTravelPresentable, MyTravelViewControllable { +final class MyTravelViewController: UIViewController, MyTravelViewControllable { weak var listener: MyTravelPresentableListener? + + private let disposeBag = DisposeBag() + + private let navigationBar = NDGLNavigationBar( + style: .white, + trailingIcon: DSKitAsset.Assets.icSearch2.image + ) + private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let networkErrorView = NDGLErrorView() + + var dataSource: UICollectionViewDiffableDataSource! = nil + let newTravelBtnTapped = PublishSubject() + + override func viewDidLoad() { + super.viewDidLoad() + + setStyle() + setUI() + setLayout() + + setCollectionView() + setDataSource() + bindInteractor() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + listener?.viewWillAppear() + } +} + +private extension MyTravelViewController { + func setStyle() { + view.backgroundColor = DSKitAsset.Colors.white.color + + collectionView.do { + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + $0.backgroundColor = .clear + $0.contentInset = .zero + $0.isScrollEnabled = true + $0.contentInset = .init(top: 19.adjustedH, left: 0, bottom: 0, right: 0) + } + + loadingIndicator.do { + $0.color = DSKitAsset.Colors.green300.color + } + + networkErrorView.do { + $0.isHidden = true + } + } + + func setUI() { + view.addSubviews(collectionView, navigationBar, loadingIndicator, networkErrorView) + } + + func setLayout() { + navigationBar.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.directionalHorizontalEdges.equalToSuperview() + } + + collectionView.snp.makeConstraints { + $0.top.equalTo(navigationBar.snp.bottom) + $0.bottom.equalToSuperview() + $0.directionalHorizontalEdges.equalToSuperview() + } + + loadingIndicator.snp.makeConstraints { + $0.center.equalToSuperview() + } + + networkErrorView.snp.makeConstraints { + $0.top.equalTo(navigationBar.snp.bottom) + $0.directionalHorizontalEdges.equalToSuperview() + $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-68.adjustedH) + } + } + + func setCollectionView() { + collectionView.do { + $0.register( + MyTravelBannerCell.self, + forCellWithReuseIdentifier: MyTravelBannerCell.cellIdentifier + ) + + $0.register( + EmptyUpcomingCell.self, + forCellWithReuseIdentifier: EmptyUpcomingCell.cellIdentifier + ) + + $0.register( + UpcomingCell.self, + forCellWithReuseIdentifier: UpcomingCell.cellIdentifier + ) + + $0.register( + RecommendInfoCell.self, + forCellWithReuseIdentifier: RecommendInfoCell.cellIdentifier + ) + + $0.register( + MyTravelHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: MyTravelHeaderView.reusableViewIdentifier + ) + } + } +} + +extension MyTravelViewController: MyTravelPresentable { + func update(with sections: [(MyTravelSectionKind, [MyTravelItem])]) { + DispatchQueue.main.async { [weak self] in + guard let self, let dataSource = self.dataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + + sections.forEach { sectionKind, items in + snapshot.appendSections([sectionKind]) + snapshot.appendItems(items, toSection: sectionKind) + } + + self.dataSource.apply(snapshot, animatingDifferences: true) + } + } + + func setLoading(_ isLoading: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if isLoading { + self.loadingIndicator.startAnimating() + self.collectionView.alpha = 0.5 + } else { + self.loadingIndicator.stopAnimating() + self.collectionView.alpha = 1.0 + } + self.loadingIndicator.isHidden = !isLoading + } + } + + func showErrorView(_ isError: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.networkErrorView.isHidden = !isError + self.collectionView.isHidden = isError + if isError { + self.loadingIndicator.stopAnimating() + } + } + } +} + +private extension MyTravelViewController { + func setDataSource() { + let bannerReg = createBannerCellRegistration() + let upcomingReg = createUpcomingCell() + let recommendReg = createRecommedTripCellRegistration() + + let emptyReg = createEmptyUpcomingCellRegistration() + + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in + + switch item { + case .banner(let banner): + if banner.id == 0 { + return collectionView.dequeueConfiguredReusableCell(using: emptyReg, for: indexPath, item: banner) + } + return collectionView.dequeueConfiguredReusableCell(using: bannerReg, for: indexPath, item: banner) + + case .upcomingList(let upcoming): + return collectionView.dequeueConfiguredReusableCell(using: upcomingReg, for: indexPath, item: upcoming) + + case .recommendTrip(let trip): + return collectionView.dequeueConfiguredReusableCell(using: recommendReg, for: indexPath, item: trip) + } + } + + configureSupplementaryView() + } + + func configureSupplementaryView() { + let headerRegistration = createHeaderRegistration() + + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + if kind == UICollectionView.elementKindSectionHeader { + return collectionView.dequeueConfiguredReusableSupplementary( + using: headerRegistration, + for: indexPath + ) + } + + return nil + } + } + + func bindInteractor() { + navigationBar.trailingButtonDidTap + .subscribe(with: self) { owner, _ in + owner.listener?.myTraveDidTapSearch() + } + .disposed(by: disposeBag) + + collectionView.rx.itemSelected + .compactMap { [weak self] indexPath in + self?.dataSource?.itemIdentifier(for: indexPath) + } + .subscribe(with: self) { owner, item in + owner.listener?.itemSelected(item: item) + } + .disposed(by: disposeBag) + + networkErrorView.buttonDidTap + .subscribe(with: self) { owner, _ in + owner.listener?.reloadBtnTapped() + } + .disposed(by: disposeBag) + + newTravelBtnTapped + .subscribe(with: self) { owner, _ in + owner.listener?.myTraveDidTapPopularTravel() + } + .disposed(by: disposeBag) + } } From 882edea9c09120c26ed7b1201f5f2eae1a9d369f Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 20:13:26 +0900 Subject: [PATCH 21/23] =?UTF-8?q?feat:=20#33=20-=20UserManager=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Storage/UserDefaultWrapper.swift | 31 +++++++++++++++++ .../Core/Sources/Storage/UserManager.swift | 33 +++++++++++++++++++ .../SectionKind/HomeSectionKind.swift | 4 ++- .../RootFeature/Sources/RootInteractor.swift | 12 +++---- .../Sources/SettingViewController.swift | 3 +- 5 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 Projects/Core/Sources/Storage/UserDefaultWrapper.swift create mode 100644 Projects/Core/Sources/Storage/UserManager.swift diff --git a/Projects/Core/Sources/Storage/UserDefaultWrapper.swift b/Projects/Core/Sources/Storage/UserDefaultWrapper.swift new file mode 100644 index 0000000..456e440 --- /dev/null +++ b/Projects/Core/Sources/Storage/UserDefaultWrapper.swift @@ -0,0 +1,31 @@ +// +// UserDefaultWrapper.swift +// Core +// +// Created by 최안용 on 2/23/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +@propertyWrapper public struct UserDefaultWrapper { + public var wrappedValue: T? { + get { + return UserDefaults.standard.object(forKey: self.key.rawValue) as? T + } + + set { + if newValue == nil { + UserDefaults.standard.removeObject(forKey: self.key.rawValue) + } else { + UserDefaults.standard.setValue(newValue, forKey: self.key.rawValue) + } + } + } + + private let key: UserDefaultKeys + + public init(key: UserDefaultKeys) { + self.key = key + } +} diff --git a/Projects/Core/Sources/Storage/UserManager.swift b/Projects/Core/Sources/Storage/UserManager.swift new file mode 100644 index 0000000..28daf9f --- /dev/null +++ b/Projects/Core/Sources/Storage/UserManager.swift @@ -0,0 +1,33 @@ +// +// UserManager.swift +// Core +// +// Created by 최안용 on 2/23/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public enum UserDefaultKeys: String { + case uuid = "uuid" + case nickname = "nickname" + case isFirstOpen = "isFirstOpen" +} + +public final class UserManager { + @UserDefaultWrapper(key: .uuid) public var uuid: String? + @UserDefaultWrapper(key: .nickname) public var nickname: String? + @UserDefaultWrapper(key: .isFirstOpen) private var isFirstOpen: Bool? + + public static let shared = UserManager() + + private init() {} + + public func isFirstOpenApp() -> Bool { + guard let isFirstOpen else { + self.isFirstOpen = true + return true + } + return !isFirstOpen + } +} diff --git a/Projects/Features/HomeFeature/Sources/Views/CollectionView/SectionKind/HomeSectionKind.swift b/Projects/Features/HomeFeature/Sources/Views/CollectionView/SectionKind/HomeSectionKind.swift index c3e5590..ba349cf 100644 --- a/Projects/Features/HomeFeature/Sources/Views/CollectionView/SectionKind/HomeSectionKind.swift +++ b/Projects/Features/HomeFeature/Sources/Views/CollectionView/SectionKind/HomeSectionKind.swift @@ -8,6 +8,8 @@ import Foundation +import Core + enum HomeSectionKind: Int, CaseIterable { case banner case category @@ -19,7 +21,7 @@ enum HomeSectionKind: Int, CaseIterable { case .category: return "인기 여행 따라가기" case .recommendedTrip: - let nickname = UserDefaults.standard.string(forKey: "nickname") ?? "알 수 없음" + let nickname = UserManager.shared.nickname ?? "알 수 없음" return "\(nickname)님께 추천하는\n따라가기 여행 콘텐츠에요!" default: return "" } diff --git a/Projects/Features/RootFeature/Sources/RootInteractor.swift b/Projects/Features/RootFeature/Sources/RootInteractor.swift index 389c8fc..a18c20a 100644 --- a/Projects/Features/RootFeature/Sources/RootInteractor.swift +++ b/Projects/Features/RootFeature/Sources/RootInteractor.swift @@ -8,7 +8,9 @@ import Foundation +import Core import Domain + import RIBs import RxSwift @@ -71,9 +73,8 @@ final class RootInteractor: PresentableInteractor, RootInteract let loginResult = try await self.authRepository.login(uuid: uuid) self.tokenRepository.save(loginResult.accessToken, for: .accessToken) - // 임시 - UserDefaults.standard.set(loginResult.uuid, forKey: "uuid") - UserDefaults.standard.set(loginResult.nickname, forKey: "nickname") + UserManager.shared.uuid = loginResult.uuid + UserManager.shared.nickname = loginResult.nickname } else { let fcmToken = self.tokenRepository.get(.fcmToken) ?? UUID().uuidString let signupResult = try await self.authRepository.signup( @@ -82,9 +83,8 @@ final class RootInteractor: PresentableInteractor, RootInteract self.tokenRepository.save(signupResult.uuid, for: .uuid) self.tokenRepository.save(signupResult.accessToken, for: .accessToken) - // 임시 - UserDefaults.standard.set(signupResult.uuid, forKey: "uuid") - UserDefaults.standard.set(signupResult.nickname, forKey: "nickname") + UserManager.shared.uuid = signupResult.uuid + UserManager.shared.nickname = signupResult.nickname let loginResult = try await self.authRepository.login(uuid: signupResult.uuid) self.tokenRepository.save(loginResult.accessToken, for: .accessToken) diff --git a/Projects/Features/SettingFeature/Sources/SettingViewController.swift b/Projects/Features/SettingFeature/Sources/SettingViewController.swift index c946b72..f6d5df0 100644 --- a/Projects/Features/SettingFeature/Sources/SettingViewController.swift +++ b/Projects/Features/SettingFeature/Sources/SettingViewController.swift @@ -8,6 +8,7 @@ import UIKit +import Core import DSKit import RIBs @@ -48,7 +49,7 @@ final class SettingViewController: UIViewController, SettingPresentable, Setting } func copyToClipboard() { - guard let uuid = UserDefaults.standard.string(forKey: "uuid") else { + guard let uuid = UserManager.shared.uuid else { Toast.show( type: .normal, message: "식별코드를 불러올 수 없습니다.", From 0e66c9ac7a0d00732cc9d2c10f49dfb0f99b7b40 Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 20:13:44 +0900 Subject: [PATCH 22/23] =?UTF-8?q?design:=20#33=20-=20padding=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyTravelFeature/Sources/MyTravelViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/Features/MyTravelFeature/Sources/MyTravelViewController.swift b/Projects/Features/MyTravelFeature/Sources/MyTravelViewController.swift index 9ae95f2..195f74f 100644 --- a/Projects/Features/MyTravelFeature/Sources/MyTravelViewController.swift +++ b/Projects/Features/MyTravelFeature/Sources/MyTravelViewController.swift @@ -69,7 +69,7 @@ private extension MyTravelViewController { $0.backgroundColor = .clear $0.contentInset = .zero $0.isScrollEnabled = true - $0.contentInset = .init(top: 19.adjustedH, left: 0, bottom: 0, right: 0) + $0.contentInset = .init(top: 21.adjustedH, left: 0, bottom: 0, right: 0) } loadingIndicator.do { From 889ad9d2c72feeb2a57b9a4d761d3f6bb0fa909b Mon Sep 17 00:00:00 2001 From: ChoiAnYong Date: Mon, 23 Feb 2026 20:14:10 +0900 Subject: [PATCH 23/23] =?UTF-8?q?feat:=20#33=20-=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=9D=B4=EC=9A=A9=EC=95=BD=EA=B4=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MainFeature/Sources/MainInteractor.swift | 14 ++ .../Sources/MainViewController.swift | 16 ++- .../VC/ServiceNoticeViewController.swift | 127 ++++++++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 Projects/Features/MainFeature/Sources/VC/ServiceNoticeViewController.swift diff --git a/Projects/Features/MainFeature/Sources/MainInteractor.swift b/Projects/Features/MainFeature/Sources/MainInteractor.swift index e61f19f..e43463a 100644 --- a/Projects/Features/MainFeature/Sources/MainInteractor.swift +++ b/Projects/Features/MainFeature/Sources/MainInteractor.swift @@ -8,6 +8,8 @@ import Foundation +import Core + import RIBs import RxSwift @@ -26,6 +28,8 @@ public protocol MainRouting: ViewableRouting { protocol MainPresentable: Presentable { var listener: MainPresentableListener? { get set } + + func showServiceNoticeModal() } public protocol MainListener: AnyObject { } @@ -49,6 +53,16 @@ final class MainInteractor: PresentableInteractor, MainInteract super.willResignActive() } + func viewDidLoad() { + checkFirstOpenStatus() + } + + private func checkFirstOpenStatus() { + if UserManager.shared.isFirstOpenApp() { + presenter.showServiceNoticeModal() + } + } + func detachFollowDetail() { router?.detachFollow() } diff --git a/Projects/Features/MainFeature/Sources/MainViewController.swift b/Projects/Features/MainFeature/Sources/MainViewController.swift index 4f870d7..5f25ebd 100644 --- a/Projects/Features/MainFeature/Sources/MainViewController.swift +++ b/Projects/Features/MainFeature/Sources/MainViewController.swift @@ -8,12 +8,13 @@ import UIKit +import Core import DSKit import RIBs protocol MainPresentableListener: AnyObject { - + func viewDidLoad() } final class MainViewController: UINavigationController, MainPresentable, MainViewControllable { @@ -30,6 +31,7 @@ final class MainViewController: UINavigationController, MainPresentable, MainVie setStyle() setupDelegate() + listener?.viewDidLoad() } func setViewControllers(_ viewControllables: [ViewControllable]) { @@ -48,6 +50,18 @@ final class MainViewController: UINavigationController, MainPresentable, MainVie func containsInStack(_ viewControllable: ViewControllable) -> Bool { self.viewControllers.contains(viewControllable.uiviewController) } + + func showServiceNoticeModal() { + let noticeVC = ServiceNoticeViewController() + noticeVC.modalPresentationStyle = .overFullScreen + noticeVC.modalTransitionStyle = .crossDissolve + + noticeVC.termsHandler = { + URLHelper.openURL("https://repeated-tapir-33f.notion.site/2c8cbdc5a3838070a8d8ccdcd0631c9a?source=copy_link") + } + + self.present(noticeVC, animated: true) + } } private extension MainViewController { diff --git a/Projects/Features/MainFeature/Sources/VC/ServiceNoticeViewController.swift b/Projects/Features/MainFeature/Sources/VC/ServiceNoticeViewController.swift new file mode 100644 index 0000000..f738e10 --- /dev/null +++ b/Projects/Features/MainFeature/Sources/VC/ServiceNoticeViewController.swift @@ -0,0 +1,127 @@ +// +// ServiceNoticeViewController.swift +// MainFeature +// +// Created by 최안용 on 2/23/26. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import UIKit + +import DSKit + +final class ServiceNoticeViewController: UIViewController { + var termsHandler: (() -> Void)? + + private let titleLabel = UILabel() + private let descriptionLabel = UILabel() + private let termsButton = UIButton() + private let confirmButton = NDGLBtn(title: "확인했어요", style: .primary, size: .medium) + private let containerView = UIView() + + override func viewDidLoad() { + super.viewDidLoad() + + setStyle() + setUI() + setLayout() + setAddTarget() + } +} + +private extension ServiceNoticeViewController { + func setStyle() { + view.backgroundColor = UIColor.init(hexCode: "#000000", alpha: 0.7) + + titleLabel.do { + $0.numberOfLines = 2 + $0.setText( + .subTitleLSB, + text: "서비스 이용 전\n반드시 확인하세요.", + color: DSKitAsset.Colors.black900.color, + alignment: .center + ) + } + + descriptionLabel.do { + $0.numberOfLines = 0 + $0.setText( + .bodyLM, + text: "본 서비스는 AI 기술을 활용하여\n여행 정보를 분석·재구성하여 제공하는\n참고용 서비스입니다. 아래 내용을\n충분히 확인 후 이용해주세요.", + color: DSKitAsset.Colors.black500.color, + alignment: .center + ) + } + + termsButton.do { + var config = UIButton.Configuration.plain() + config.contentInsets = .zero + config.background = .clear() + $0.configuration = config + + let title = "이용 약관 확인하기" + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.NDGL.bodyMM.font, + .foregroundColor: DSKitAsset.Colors.black400.color, + .underlineStyle: NSUnderlineStyle.single.rawValue, + .underlineColor: DSKitAsset.Colors.black400.color, + .baselineOffset: 2.0 + ] + + $0.setAttributedTitle(NSAttributedString(string: title, attributes: attributes), for: .normal) + } + + containerView.do { + $0.backgroundColor = DSKitAsset.Colors.white.color + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + } + } + + func setUI() { + view.addSubview(containerView) + containerView.addSubviews(titleLabel, descriptionLabel, termsButton, confirmButton) + } + + func setLayout() { + containerView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.width.equalTo(314.adjusted) + $0.height.equalTo(318.adjustedH) + } + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(28.adjustedH) + $0.centerX.equalToSuperview() + } + + descriptionLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(16.adjustedH) + $0.centerX.equalToSuperview() + } + + termsButton.snp.makeConstraints { + $0.top.equalTo(descriptionLabel.snp.bottom).offset(12.adjustedH) + $0.centerX.equalToSuperview() + } + + confirmButton.snp.makeConstraints { + $0.top.equalTo(termsButton.snp.bottom).offset(28.adjustedH) + $0.horizontalEdges.equalToSuperview().inset(28.adjusted) + } + } + + func setAddTarget() { + termsButton.addTarget(self, action: #selector(termsButtonTapped), for: .touchUpInside) + + confirmButton.addTarget(self, action: #selector(confirmButtonTapped), for: .touchUpInside) + } + + @objc func termsButtonTapped() { + termsHandler?() + } + + @objc func confirmButtonTapped() { + self.dismiss(animated: true) + } +}