From c0dc3f8029e45acc2831fd8f622d08b44058e100 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Mon, 23 Feb 2026 22:55:36 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20#37=20-=20=EC=98=A4=ED=86=A0?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/PopularTravelInteractor.swift | 78 ++++++++++++------- .../Sources/PopularTravelViewController.swift | 4 +- .../Sources/Component/PopularInfoCell.swift | 3 +- 3 files changed, 52 insertions(+), 33 deletions(-) diff --git a/Projects/Features/PopularTravelFeature/Sources/PopularTravelInteractor.swift b/Projects/Features/PopularTravelFeature/Sources/PopularTravelInteractor.swift index 83c17b0..d9e3464 100644 --- a/Projects/Features/PopularTravelFeature/Sources/PopularTravelInteractor.swift +++ b/Projects/Features/PopularTravelFeature/Sources/PopularTravelInteractor.swift @@ -42,11 +42,14 @@ final class PopularTravelInteractor: PresentableInteractor? + private var fetchTripsTask: Task? private let usecase: HomeUsecaseProtocol private let disposeBag = DisposeBag() - - private let popularTravelDataRelay = BehaviorRelay(value: nil) + + private let categoriesRelay = BehaviorRelay<[PopularTravelPresentationModel.Category]>(value: []) + private let popularTripsRelay = BehaviorRelay<[PopularTravelPresentationModel.PopularTrip]>(value: []) private let selectedCategoryRelay = BehaviorRelay(value: nil) + private let isLoadingRelay = BehaviorRelay(value: true) init(presenter: PopularTravelPresentable, usecase: HomeUsecaseProtocol) { self.usecase = usecase @@ -63,31 +66,33 @@ final class PopularTravelInteractor: PresentableInteractor [PopularTravelSectionModel] in + .map { categories, popularTrips, selectedId -> [PopularTravelSectionModel] in return [ - .init(section: .category, items: model.category.map { + .init(section: .category, items: categories.map { .category($0, isSelected: $0.id == selectedId) }), - .init(section: .popularTrip, items: model.popularTrip.map { .popularTrip($0) }) + .init(section: .popularTrip, items: popularTrips.map { .popularTrip($0) }) ] } .subscribe(with: self) { owner, sections in owner.presenter.update(with: sections) } .disposed(by: disposeBag) - - popularTravelDataRelay - .map { $0 == nil } + + isLoadingRelay .subscribe(with: self) { owner, isLoading in owner.presenter.setLoading(isLoading) } @@ -96,33 +101,47 @@ final class PopularTravelInteractor: PresentableInteractor Date: Mon, 23 Feb 2026 23:03:14 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20#37=20-=20=ED=99=88=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=EC=84=9C=EB=8F=84=20viewwillappear=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=97=AC=ED=96=89=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeFeature/Sources/HomeInteractor.swift | 27 ++++++++++++++++++- .../Sources/HomeViewController.swift | 6 +++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift index 8216e80..78a5c44 100644 --- a/Projects/Features/HomeFeature/Sources/HomeInteractor.swift +++ b/Projects/Features/HomeFeature/Sources/HomeInteractor.swift @@ -28,7 +28,7 @@ public protocol HomeRouting: ViewableRouting { // MARK: - HomePresentable protocol HomePresentable: Presentable { var listener: HomePresentableListener? { get set } - + func update(with sections: [HomeSectionModel]) func setLoading(_ isLoading: Bool) func showErrorView(_ isError: Bool) @@ -150,6 +150,26 @@ final class HomeInteractor: PresentableInteractor, HomeInteract } } + private func refreshBanner() { + Task { [weak self] in + guard let self else { return } + do { + let tripInfo = try await self.usecase.fetchMyTripInfo() + let banner = tripInfo.toPresention() + guard let model = self.homeDataRelay.value else { return } + let updated = HomePresentationModel( + banner: banner, + category: model.category, + popularTrip: model.popularTrip, + recommendedTrip: model.recommendedTrip + ) + self.homeDataRelay.accept(updated) + } catch { + // 여행 없으면 empty 유지 + } + } + } + private func fetchPopularTrips(categoryId: Int) { Task { [weak self] in guard let self else { return } @@ -181,6 +201,11 @@ extension HomeInteractor: HomePresentableListener { fetchHomeData() } + func viewWillAppear() { + guard homeDataRelay.value != nil else { return } + refreshBanner() + } + func reloadBtnTapped() { fetchHomeData() } diff --git a/Projects/Features/HomeFeature/Sources/HomeViewController.swift b/Projects/Features/HomeFeature/Sources/HomeViewController.swift index 72f53a8..6b2d8fa 100644 --- a/Projects/Features/HomeFeature/Sources/HomeViewController.swift +++ b/Projects/Features/HomeFeature/Sources/HomeViewController.swift @@ -24,6 +24,7 @@ protocol HomePresentableListener: AnyObject { func moreBtnTapped() func reloadBtnTapped() func viewDidLoad() + func viewWillAppear() } // MARK: - HomeViewController @@ -64,6 +65,7 @@ final class HomeViewController: UIViewController, HomeViewControllable { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) + listener?.viewWillAppear() } } @@ -211,11 +213,11 @@ private extension HomeViewController { for: indexPath, item: banner ) - case .category(let category): + case .category(let category, let isSelected): return collectionView.dequeueConfiguredReusableCell( using: categoryRegistration, for: indexPath, - item: category + item: (category, isSelected) ) case .popularTrip(let tripList): return collectionView.dequeueConfiguredReusableCell( From 570c5d522d58354ee8d1a104df61246abb0305ef Mon Sep 17 00:00:00 2001 From: kimnahun Date: Mon, 23 Feb 2026 23:27:40 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20#37=20-=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=90=9C=20=EB=82=B4=20=EC=97=AC=ED=96=89=EC=9D=84=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EB=8F=84=EB=A1=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserTravel /UserTravelRepository.swift | 16 +++++ .../Transform/UserTravelTransform.swift | 44 ++++++++++++ .../UserTravelRepositoryInterface.swift | 2 + .../Sources/UseCase/FollowDetailUsecase.swift | 10 +++ .../Sources/FollowDetailBuilder.swift | 13 +++- .../Sources/FollowDetailInteractor.swift | 72 ++++++++++++------- .../MainFeature/Sources/MainInteractor.swift | 5 ++ .../MainFeature/Sources/MainRouter.swift | 13 +++- .../Sources/MyTravelInteractor.swift | 5 +- .../Sources/TabBarInteractor.swift | 11 ++- .../Sources/DTO/Travel/TravelDTO.swift | 18 +++++ .../Sources/Service/UserTravelService.swift | 5 ++ .../Sources/TargetType/UserTravelAPI.swift | 14 +++- 13 files changed, 190 insertions(+), 38 deletions(-) diff --git a/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift b/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift index b0df8f0..5077834 100644 --- a/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift +++ b/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift @@ -49,4 +49,20 @@ public final class UserTravelRepository: UserTravelRepositoryInterface { throw error.toNDGLError() } } + + public func fetchUserTravelDetail(id: Int) async throws -> TravelDetail { + do { + return try await service.getContentCard(id: id).toDomain() + } catch { + throw error.toNDGLError() + } + } + + public func fetchItinerary(travelId: Int, day: Int) async throws -> [TravelPlace] { + do { + return try await service.getItinerary(travelId: travelId, day: day).toDomain() + } catch { + throw error.toNDGLError() + } + } } diff --git a/Projects/Data/Sources/Transform/UserTravelTransform.swift b/Projects/Data/Sources/Transform/UserTravelTransform.swift index 7e9c085..ee6afef 100644 --- a/Projects/Data/Sources/Transform/UserTravelTransform.swift +++ b/Projects/Data/Sources/Transform/UserTravelTransform.swift @@ -12,6 +12,27 @@ import Domain import Networks +extension UserContentCardResponse { + func toDomain() -> TravelDetail { + TravelDetail( + travelId: userTravelId, + country: country, + city: city, + budgetPerPerson: 0, + nights: nights, + days: days, + youtube: YouTubeInfo( + title: title, + youtuber: "", + thumbnail: nil, + profileImage: nil, + link: nil, + summary: "" + ) + ) + } +} + extension UpcomingResponse { func toDomain() -> MyTripSummary { let schedule: Schedule? @@ -49,6 +70,29 @@ extension CreateUserTravelResponse { } } +extension UserTravelItineraryResponse { + func toDomain() -> [TravelPlace] { + itineraries.compactMap { $0.toDomain() } + } +} + +extension UserTravelPlaceResponse { + func toDomain() -> TravelPlace? { + guard let place else { return nil } + return TravelPlace( + id: id, + day: day, + sequence: sequence, + distanceKm: distanceKm, + transportation: transportation?.map { $0.toDomain() } ?? [], + youtubeTips: travelerTips ?? [], + planB: planB?.map { $0.toDomain() } ?? [], + estimatedDuration: estimatedDuration, + place: place.toDomain() + ) + } +} + extension UpcomingListResponse { func toDomain() -> [UpcomingInfo] { self.content.map { diff --git a/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift b/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift index 05893c1..dbdfa85 100644 --- a/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift +++ b/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift @@ -13,4 +13,6 @@ public protocol UserTravelRepositoryInterface { // func fetchContentCard(id: Int) async throws -> func fetchUpcoming() async throws -> MyTripSummary func fetchUpcomingList(page: Int?, size: Int?) async throws -> [UpcomingInfo] + func fetchUserTravelDetail(id: Int) async throws -> TravelDetail + func fetchItinerary(travelId: Int, day: Int) async throws -> [TravelPlace] } diff --git a/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift b/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift index 0c67861..d55d9ec 100644 --- a/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift +++ b/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift @@ -11,6 +11,8 @@ import Foundation public protocol FollowDetailUsecaseProtocol { func fetchTravelDetail(id: Int) async throws -> TravelDetail func fetchPlaces(travelId: Int, day: Int) async throws -> [TravelPlace] + func fetchMyTravelDetail(id: Int) async throws -> TravelDetail + func fetchMyTravelPlaces(travelId: Int, day: Int) async throws -> [TravelPlace] func createUserTravel(request: CreateTravelRequest) async throws -> CreateTravelResponse func fetchPlaceDetail(googlePlaceId: String) async throws -> PlaceDetail func fetchPlacePhotos(googlePlaceId: String) async throws -> [PlacePhoto] @@ -40,6 +42,14 @@ extension FollowDetailUsecase: FollowDetailUsecaseProtocol { public func fetchPlaces(travelId: Int, day: Int) async throws -> [TravelPlace] { try await travelTemplateRepository.fetchPlaces(travelId: travelId, day: day) } + + public func fetchMyTravelDetail(id: Int) async throws -> TravelDetail { + try await userTravelRepository.fetchUserTravelDetail(id: id) + } + + public func fetchMyTravelPlaces(travelId: Int, day: Int) async throws -> [TravelPlace] { + try await userTravelRepository.fetchItinerary(travelId: travelId, day: day) + } public func createUserTravel(request: CreateTravelRequest) async throws -> CreateTravelResponse { try await userTravelRepository.createUserTravel(request: request) diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift b/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift index 4755792..e932a43 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift @@ -24,10 +24,17 @@ final class FollowDetailComponent: Component, TripCalend } } +// MARK: - FollowDetailMode + +public enum FollowDetailMode { + case template(id: Int) + case myTravel(id: Int) +} + // MARK: - FollowDetailBuildable public protocol FollowDetailBuildable: Buildable { - func build(withListener listener: FollowDetailListener, recommendationId: Int) -> FollowDetailRouting + func build(withListener listener: FollowDetailListener, mode: FollowDetailMode) -> FollowDetailRouting } // MARK: - FollowDetailBuilder @@ -38,13 +45,13 @@ public final class FollowDetailBuilder: Builder, FollowD super.init(dependency: dependency) } - public func build(withListener listener: FollowDetailListener, recommendationId: Int) -> FollowDetailRouting { + public func build(withListener listener: FollowDetailListener, mode: FollowDetailMode) -> FollowDetailRouting { let component = FollowDetailComponent(dependency: dependency) let viewController = FollowDetailViewController() let interactor = FollowDetailInteractor( presenter: viewController, followDetailUsecase: component.followDetailUsecase, - recommendationId: recommendationId + mode: mode ) interactor.listener = listener diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift index e1b73cf..050eb88 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -49,12 +49,18 @@ final class FollowDetailInteractor: PresentableInteractor, MainInteract func routeToFollow(with recommendationId: Int) { router?.attachFollow(with: recommendationId) } + + func routeToMyTravelDetail(with userTravelId: Int) { + router?.attachMyTravelDetail(with: userTravelId) + } func routeToSetting() { router?.attachSetting() diff --git a/Projects/Features/MainFeature/Sources/MainRouter.swift b/Projects/Features/MainFeature/Sources/MainRouter.swift index 6ed769c..eff187b 100644 --- a/Projects/Features/MainFeature/Sources/MainRouter.swift +++ b/Projects/Features/MainFeature/Sources/MainRouter.swift @@ -65,7 +65,18 @@ final class MainRouter: ViewableRouter, guard followRouter == nil else { return } let router = followBuilder.build( withListener: interactor, - recommendationId: recommendationId + mode: .template(id: recommendationId) + ) + self.followRouter = router + attachChild(router) + viewController.pushViewController(router.viewControllable, animated: true) + } + + func attachMyTravelDetail(with userTravelId: Int) { + guard followRouter == nil else { return } + let router = followBuilder.build( + withListener: interactor, + mode: .myTravel(id: userTravelId) ) self.followRouter = router attachChild(router) diff --git a/Projects/Features/MyTravelFeature/Sources/MyTravelInteractor.swift b/Projects/Features/MyTravelFeature/Sources/MyTravelInteractor.swift index c014842..dafaddf 100644 --- a/Projects/Features/MyTravelFeature/Sources/MyTravelInteractor.swift +++ b/Projects/Features/MyTravelFeature/Sources/MyTravelInteractor.swift @@ -29,6 +29,7 @@ protocol MyTravelPresentable: Presentable { public protocol MyTravelListener: AnyObject { func myTraveDidTapFollowDetail(with recommendationId: Int) + func myTraveDidTapMyTravelDetail(with userTravelId: Int) func myTraveDidTapSearch() func myTraveDidTapPopularTravel() } @@ -137,9 +138,9 @@ extension MyTravelInteractor: MyTravelPresentableListener { case .recommendTrip(let trip): listener?.myTraveDidTapFollowDetail(with: trip.id) case .upcomingList(let upcoming): - listener?.myTraveDidTapFollowDetail(with: upcoming.id) + listener?.myTraveDidTapMyTravelDetail(with: upcoming.id) case .banner(let myTrip): - listener?.myTraveDidTapFollowDetail(with: myTrip.id) + listener?.myTraveDidTapMyTravelDetail(with: myTrip.id) } } diff --git a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift index 171d32b..5fde585 100644 --- a/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift +++ b/Projects/Features/TabBarFeature/Sources/TabBarInteractor.swift @@ -32,6 +32,7 @@ protocol TabBarPresentable: Presentable { public protocol TabBarListener: AnyObject { func routeToFollow(with recommendationId: Int) + func routeToMyTravelDetail(with userTravelId: Int) func routeToSetting() func routeToSearch() func routeToPopularTravel() @@ -97,12 +98,16 @@ extension TabBarInteractor: MyTravelListener { func myTraveDidTapFollowDetail(with recommendationId: Int) { listener?.routeToFollow(with: recommendationId) } - + + func myTraveDidTapMyTravelDetail(with userTravelId: Int) { + listener?.routeToMyTravelDetail(with: userTravelId) + } + func myTraveDidTapSearch() { listener?.routeToSearch() } - + func myTraveDidTapPopularTravel() { listener?.routeToPopularTravel() - } + } } diff --git a/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift b/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift index f09a1d3..f063ea5 100644 --- a/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift +++ b/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift @@ -27,3 +27,21 @@ public struct CreateUserTravelRequest: Encodable, Sendable { public struct CreateUserTravelResponse: Decodable, Sendable { public let userTravelId: Int } + +// MARK: - User Travel Itinerary Response + +public struct UserTravelItineraryResponse: Decodable, Sendable { + public let itineraries: [UserTravelPlaceResponse] +} + +public struct UserTravelPlaceResponse: Decodable, Sendable { + public let id: Int + public let day: Int + public let sequence: Int + public let distanceKm: Double? + public let transportation: [TransportationResponse]? + public let travelerTips: [String]? + public let planB: [PlanBResponse]? + public let estimatedDuration: Int? + public let place: PlaceResponse? +} diff --git a/Projects/Modules/Networks/Sources/Service/UserTravelService.swift b/Projects/Modules/Networks/Sources/Service/UserTravelService.swift index 124527a..972a86a 100644 --- a/Projects/Modules/Networks/Sources/Service/UserTravelService.swift +++ b/Projects/Modules/Networks/Sources/Service/UserTravelService.swift @@ -15,6 +15,7 @@ public protocol UserTravelServiceProtocol { func getContentCard(id: Int) async throws -> UserContentCardResponse func getUpcoming() async throws -> UpcomingResponse func getUpcomingList(page: Int?, size: Int?) async throws -> UpcomingListResponse + func getItinerary(travelId: Int, day: Int) async throws -> UserTravelItineraryResponse } public final class UserTravelService: UserTravelServiceProtocol { @@ -39,4 +40,8 @@ public final class UserTravelService: UserTravelServiceProtocol { public func getUpcomingList(page: Int?, size: Int?) async throws -> UpcomingListResponse { try await provider.asyncThowsRequest(.getUpcomingList(page: page, size: size)) } + + public func getItinerary(travelId: Int, day: Int) async throws -> UserTravelItineraryResponse { + try await provider.asyncThowsRequest(.getItinerary(id: travelId, day: day)) + } } diff --git a/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift b/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift index 3db8718..2e84c04 100644 --- a/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift +++ b/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift @@ -15,6 +15,7 @@ public enum UserTravelAPI { case getContentCard(id: Int) case getUpcoming case getUpcomingList(page: Int?, size: Int?) + case getItinerary(id: Int, day: Int) } extension UserTravelAPI: TargetType { @@ -32,6 +33,8 @@ extension UserTravelAPI: TargetType { return "/api/v1/travels/upcoming" case .getUpcomingList: return "api/v1/travels/upcoming/list" + case .getItinerary(let id, _): + return "/api/v1/travels/\(id)/itinerary" } } @@ -39,7 +42,7 @@ extension UserTravelAPI: TargetType { switch self { case .createUserTravel: return .post - case .getContentCard, .getUpcoming, .getUpcomingList: + case .getContentCard, .getUpcoming, .getUpcomingList, .getItinerary: return .get } } @@ -52,11 +55,16 @@ extension UserTravelAPI: TargetType { 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) + case .getItinerary(_, let day): + return .requestParameters( + parameters: ["day": day], + encoding: URLEncoding.queryString + ) } } From 73f9f503d24ee8d326be455a4d0381d34bfad00c Mon Sep 17 00:00:00 2001 From: kimnahun Date: Tue, 24 Feb 2026 00:07:56 +0900 Subject: [PATCH 4/8] =?UTF-8?q?design:=20#37=20-=20=EC=97=AC=ED=96=89=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EC=B4=88=EC=95=88=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/FollowDetailInteractor.swift | 11 ++ .../Sources/FollowDetailViewController.swift | 153 +++++++++++++++++- .../Sources/Views/Cells/PlaceCell.swift | 109 ++++++++++++- .../PlaceListCollectionView.swift | 90 ++++++++++- 4 files changed, 344 insertions(+), 19 deletions(-) diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift index 050eb88..ac0aeb2 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -23,6 +23,7 @@ public protocol FollowDetailListener: AnyObject { protocol FollowDetailPresentable: Presentable { var listener: FollowDetailPresentableListener? { get set } + func configureMode(isMyTravel: Bool) func showLoading() func hideLoading() func updateTravelDetail(_ detail: TravelDetail) @@ -35,6 +36,7 @@ protocol FollowDetailPresentable: Presentable { protocol FollowDetailPresentableListener: AnyObject { func detachFollowDetail() + func viewDidLoad() func didTapAddToTrip() func didSelectDay(_ day: Int) func didSelectPlace(_ place: TravelPlace) @@ -76,6 +78,11 @@ final class FollowDetailInteractor: PresentableInteractor Void)? + var onDragHandlePan: ((UIPanGestureRecognizer) -> Void)? + + private var isChecked: Bool = false + private var containerTrailingConstraint: Constraint? // MARK: - UI Components @@ -31,6 +35,30 @@ final class PlaceCell: UICollectionViewCell { private let sequenceLabel = UILabel() + // 체크박스 (편집 모드) + private let checkboxView = UIView().then { + $0.layer.borderWidth = 1.5 + $0.layer.borderColor = UIColor(hexCode: "#28A745").cgColor + $0.layer.cornerRadius = 4 + $0.backgroundColor = .clear + $0.isHidden = true + } + + private let checkmarkImageView = UIImageView().then { + $0.image = UIImage(systemName: "checkmark") + $0.tintColor = .white + $0.contentMode = .scaleAspectFit + $0.isHidden = true + } + + // 드래그 핸들 (편집 모드) + private let dragHandleImageView = UIImageView().then { + $0.image = UIImage(systemName: "line.3.horizontal") + $0.tintColor = UIColor(hexCode: "#BDBDBD") + $0.contentMode = .scaleAspectFit + $0.isHidden = true + } + // 메인 컨테이너 private let containerView = UIView().then { $0.backgroundColor = UIColor(hexCode: "#FFFFFF") @@ -87,6 +115,8 @@ final class PlaceCell: UICollectionViewCell { thumbnailImageView.image = nil travelTimeContainerView.isHidden = false onContainerTapped = nil + onDragHandlePan = nil + setEditMode(false) } // MARK: - Gestures @@ -95,12 +125,25 @@ final class PlaceCell: UICollectionViewCell { let tapGesture = UITapGestureRecognizer(target: self, action: #selector(containerViewTapped)) containerView.addGestureRecognizer(tapGesture) containerView.isUserInteractionEnabled = true + + // 체크박스 자체를 눌러도 동일하게 동작 + let checkboxTapGesture = UITapGestureRecognizer(target: self, action: #selector(containerViewTapped)) + checkboxView.addGestureRecognizer(checkboxTapGesture) + checkboxView.isUserInteractionEnabled = true + + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handleDragHandlePan(_:))) + dragHandleImageView.addGestureRecognizer(panGesture) + dragHandleImageView.isUserInteractionEnabled = true } @objc private func containerViewTapped() { onContainerTapped?() } + @objc private func handleDragHandlePan(_ gesture: UIPanGestureRecognizer) { + onDragHandlePan?(gesture) + } + // MARK: - Setup private func setupUI() { @@ -108,6 +151,13 @@ final class PlaceCell: UICollectionViewCell { contentView.addSubview(sequenceView) sequenceView.addSubview(sequenceLabel) + // 체크박스 (편집 모드) + contentView.addSubview(checkboxView) + checkboxView.addSubview(checkmarkImageView) + + // 드래그 핸들 (편집 모드) + contentView.addSubview(dragHandleImageView) + // 메인 컨테이너 contentView.addSubview(containerView) @@ -124,13 +174,6 @@ final class PlaceCell: UICollectionViewCell { } private func setupConstraints() { - // 메인 컨테이너 - containerView.snp.makeConstraints { - $0.top.equalToSuperview().offset(7.5) - $0.trailing.equalToSuperview() - $0.height.equalTo(84) - } - // 순서 뷰 (왼쪽, centerY를 container에 맞춤) sequenceView.snp.makeConstraints { $0.leading.equalToSuperview() @@ -142,9 +185,31 @@ final class PlaceCell: UICollectionViewCell { $0.center.equalToSuperview() } - // 컨테이너 leading은 순서뷰 trailing에서 띄움 + // 체크박스 (편집 모드, 순서 뷰와 동일 위치) + checkboxView.snp.makeConstraints { + $0.leading.equalToSuperview() + $0.centerY.equalTo(containerView) + $0.size.equalTo(20) + } + + checkmarkImageView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(12) + } + + // 드래그 핸들 (편집 모드, 오른쪽) + dragHandleImageView.snp.makeConstraints { + $0.trailing.equalToSuperview() + $0.centerY.equalTo(containerView) + $0.size.equalTo(20) + } + + // 메인 컨테이너 containerView.snp.makeConstraints { + $0.top.equalToSuperview().offset(7.5) $0.leading.equalTo(sequenceView.snp.trailing).offset(8) + containerTrailingConstraint = $0.trailing.equalToSuperview().constraint + $0.height.equalTo(84) } // 체류 시간 @@ -186,6 +251,34 @@ final class PlaceCell: UICollectionViewCell { } } + // MARK: - Edit Mode + + func setEditMode(_ isEditing: Bool, isChecked: Bool = false) { + sequenceView.isHidden = isEditing + checkboxView.isHidden = !isEditing + dragHandleImageView.isHidden = !isEditing + + if isEditing { + containerTrailingConstraint?.update(inset: 28) + } else { + containerTrailingConstraint?.update(inset: 0) + } + + self.isChecked = isChecked + updateCheckboxAppearance() + setNeedsLayout() + } + + private func updateCheckboxAppearance() { + if isChecked { + checkboxView.backgroundColor = UIColor(hexCode: "#28A745") + checkmarkImageView.isHidden = false + } else { + checkboxView.backgroundColor = .clear + checkmarkImageView.isHidden = true + } + } + // MARK: - Configuration private func formatTravelTime(place: TravelPlace) -> String { diff --git a/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift b/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift index c922f96..c29fb1d 100644 --- a/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift @@ -20,6 +20,12 @@ final class PlaceListCollectionView: UICollectionView { weak var placeDelegate: PlaceListCollectionViewDelegate? private var diffableDataSource: UICollectionViewDiffableDataSource? private var places: [TravelPlace] = [] + private var isEditMode: Bool = false + private var selectedIds: Set = [] + + var isAllSelected: Bool { + !places.isEmpty && selectedIds.count == places.count + } // MARK: - Initialization @@ -50,26 +56,94 @@ final class PlaceListCollectionView: UICollectionView { diffableDataSource = UICollectionViewDiffableDataSource( collectionView: self ) { [weak self] collectionView, indexPath, place in - guard let self = self, + guard let self, let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: PlaceCell.identifier, - for: indexPath - ) as? PlaceCell else { + withReuseIdentifier: PlaceCell.identifier, + for: indexPath + ) as? PlaceCell else { return UICollectionViewCell() } - let isLast = indexPath.item == (self.places.count) - 1 + let isLast = indexPath.item == self.places.count - 1 cell.configure(with: place, isLast: isLast) - cell.onContainerTapped = { [weak self] in - guard let self = self else { return } - self.placeDelegate?.placeListCollectionView(self, didSelectPlace: place) + cell.setEditMode(self.isEditMode, isChecked: self.selectedIds.contains(place.id)) + + cell.onContainerTapped = { [weak self, weak cell] in + guard let self else { return } + if self.isEditMode { + // 체크박스 토글 + let isNowSelected = !self.selectedIds.contains(place.id) + if isNowSelected { + self.selectedIds.insert(place.id) + } else { + self.selectedIds.remove(place.id) + } + cell?.setEditMode(true, isChecked: isNowSelected) + } else { + self.placeDelegate?.placeListCollectionView(self, didSelectPlace: place) + } } + + cell.onDragHandlePan = { [weak self, weak cell] gesture in + guard let self, let cell else { return } + let location = gesture.location(in: self) + switch gesture.state { + case .began: + if let ip = self.indexPath(for: cell) { + self.beginInteractiveMovementForItem(at: ip) + } + case .changed: + self.updateInteractiveMovementTargetPosition(location) + case .ended: + self.endInteractiveMovement() + default: + self.cancelInteractiveMovement() + } + } + return cell } + + // 재정렬 핸들러 (iOS 14+) + diffableDataSource?.reorderingHandlers.canReorderItem = { [weak self] _ in + return self?.isEditMode ?? false + } + diffableDataSource?.reorderingHandlers.didReorder = { [weak self] transaction in + self?.places = transaction.finalSnapshot.itemIdentifiers(inSection: 0) + } } // MARK: - Public Methods + func setEditMode(_ isEditing: Bool) { + isEditMode = isEditing + if !isEditing { + selectedIds.removeAll() + } + for cell in visibleCells.compactMap({ $0 as? PlaceCell }) { + if let indexPath = indexPath(for: cell) { + let place = places[indexPath.item] + cell.setEditMode(isEditing, isChecked: isEditing && selectedIds.contains(place.id)) + } + } + } + + /// 전체 선택/해제 토글. 전체 선택 상태가 되면 true 반환. + func toggleSelectAll() -> Bool { + if isAllSelected { + selectedIds.removeAll() + } else { + selectedIds = Set(places.map { $0.id }) + } + for cell in visibleCells.compactMap({ $0 as? PlaceCell }) { + if let indexPath = indexPath(for: cell) { + let place = places[indexPath.item] + cell.setEditMode(true, isChecked: selectedIds.contains(place.id)) + } + } + return isAllSelected + } + func applySnapshot(places: [TravelPlace]) { self.places = places From cdf28c740ef6e8c696dce02ef6b79630e6699e7e Mon Sep 17 00:00:00 2001 From: kimnahun Date: Tue, 24 Feb 2026 03:36:01 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20#37=20-=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Application/AppComponent.swift | 3 +- .../DI/GooglePlacesServiceFactory.swift | 14 ++ .../Repository/Place/PlaceRepository.swift | 17 +- .../Sources/Transform/PlaceTransform.swift | 13 ++ .../Place/PlaceRepositoryInterface.swift | 1 + .../Model/Follow/PlaceSearchResult.swift | 31 +++ .../Sources/UseCase/FollowDetailUsecase.swift | 5 + .../Sources/AddPlace/AddPlaceBuilder.swift | 53 +++++ .../Sources/AddPlace/AddPlaceInteractor.swift | 117 ++++++++++ .../Sources/AddPlace/AddPlaceRouter.swift | 34 +++ .../AddPlace/AddPlaceViewController.swift | 210 ++++++++++++++++++ .../Sources/FollowDetailBuilder.swift | 6 +- .../Sources/FollowDetailInteractor.swift | 18 ++ .../Sources/FollowDetailRouter.swift | 32 ++- .../Sources/FollowDetailViewController.swift | 5 + .../DTO/Place/GooglePlacesResponse.swift | 29 +++ .../Sources/Service/GooglePlacesService.swift | 26 +++ .../Sources/TargetType/GooglePlacesAPI.swift | 45 ++++ 18 files changed, 651 insertions(+), 8 deletions(-) create mode 100644 Projects/Data/Sources/DI/GooglePlacesServiceFactory.swift create mode 100644 Projects/Domain/Sources/Model/Follow/PlaceSearchResult.swift create mode 100644 Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceBuilder.swift create mode 100644 Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceInteractor.swift create mode 100644 Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceRouter.swift create mode 100644 Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceViewController.swift create mode 100644 Projects/Modules/Networks/Sources/DTO/Place/GooglePlacesResponse.swift create mode 100644 Projects/Modules/Networks/Sources/Service/GooglePlacesService.swift create mode 100644 Projects/Modules/Networks/Sources/TargetType/GooglePlacesAPI.swift diff --git a/Projects/App/Sources/Application/AppComponent.swift b/Projects/App/Sources/Application/AppComponent.swift index 89c29f7..b73d0ec 100644 --- a/Projects/App/Sources/Application/AppComponent.swift +++ b/Projects/App/Sources/Application/AppComponent.swift @@ -37,7 +37,8 @@ final class AppComponent: Component, RootDependency { private var placeRepository: PlaceRepositoryInterface { shared { let service = makePlaceService() - return PlaceRepository(service: service) + let googlePlacesService = makeGooglePlacesService() + return PlaceRepository(service: service, googlePlacesService: googlePlacesService) } } diff --git a/Projects/Data/Sources/DI/GooglePlacesServiceFactory.swift b/Projects/Data/Sources/DI/GooglePlacesServiceFactory.swift new file mode 100644 index 0000000..3a3a261 --- /dev/null +++ b/Projects/Data/Sources/DI/GooglePlacesServiceFactory.swift @@ -0,0 +1,14 @@ +// +// GooglePlacesServiceFactory.swift +// Data +// +// Created by kimnahun on 2026-02-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation +import Networks + +public func makeGooglePlacesService() -> GooglePlacesServiceProtocol { + GooglePlacesService() +} diff --git a/Projects/Data/Sources/Repository/Place/PlaceRepository.swift b/Projects/Data/Sources/Repository/Place/PlaceRepository.swift index 0f82162..de94914 100644 --- a/Projects/Data/Sources/Repository/Place/PlaceRepository.swift +++ b/Projects/Data/Sources/Repository/Place/PlaceRepository.swift @@ -13,11 +13,22 @@ import Networks public final class PlaceRepository: PlaceRepositoryInterface { private let service: PlaceServiceProtocol - - public init(service: PlaceServiceProtocol) { + private let googlePlacesService: GooglePlacesServiceProtocol + + public init(service: PlaceServiceProtocol, googlePlacesService: GooglePlacesServiceProtocol) { self.service = service + self.googlePlacesService = googlePlacesService } - + + public func searchPlaces(keyword: String) async throws -> [PlaceSearchResult] { + do { + let response = try await googlePlacesService.searchText(keyword: keyword) + return (response.places ?? []).compactMap { $0.toDomain() } + } catch { + throw error.toNDGLError() + } + } + public func searchPlaces() async throws -> Int { do { return try await service.searchPlaces() diff --git a/Projects/Data/Sources/Transform/PlaceTransform.swift b/Projects/Data/Sources/Transform/PlaceTransform.swift index c883d13..4548744 100644 --- a/Projects/Data/Sources/Transform/PlaceTransform.swift +++ b/Projects/Data/Sources/Transform/PlaceTransform.swift @@ -54,3 +54,16 @@ extension PlacePhotoResponse { ) } } + +extension GooglePlaceItem { + func toDomain() -> PlaceSearchResult? { + guard let location = location else { return nil } + return PlaceSearchResult( + googlePlaceId: id, + name: displayName?.text ?? "", + address: formattedAddress ?? "", + latitude: location.latitude, + longitude: location.longitude + ) + } +} diff --git a/Projects/Domain/Sources/Interface/Place/PlaceRepositoryInterface.swift b/Projects/Domain/Sources/Interface/Place/PlaceRepositoryInterface.swift index 3d76534..60780e1 100644 --- a/Projects/Domain/Sources/Interface/Place/PlaceRepositoryInterface.swift +++ b/Projects/Domain/Sources/Interface/Place/PlaceRepositoryInterface.swift @@ -9,6 +9,7 @@ import Foundation public protocol PlaceRepositoryInterface { + func searchPlaces(keyword: String) async throws -> [PlaceSearchResult] func searchPlaces() async throws -> Int //임시 func fetchPlacePhotos(googlePlaceId: String) async throws -> [PlacePhoto] func fetchPlaceDetail(googlePlaceId: String) async throws -> PlaceDetail diff --git a/Projects/Domain/Sources/Model/Follow/PlaceSearchResult.swift b/Projects/Domain/Sources/Model/Follow/PlaceSearchResult.swift new file mode 100644 index 0000000..ab7bdfd --- /dev/null +++ b/Projects/Domain/Sources/Model/Follow/PlaceSearchResult.swift @@ -0,0 +1,31 @@ +// +// PlaceSearchResult.swift +// Domain +// +// Created by kimnahun on 2026-02-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation + +public struct PlaceSearchResult { + public let googlePlaceId: String + public let name: String + public let address: String + public let latitude: Double + public let longitude: Double + + public init( + googlePlaceId: String, + name: String, + address: String, + latitude: Double, + longitude: Double + ) { + self.googlePlaceId = googlePlaceId + self.name = name + self.address = address + self.latitude = latitude + self.longitude = longitude + } +} diff --git a/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift b/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift index d55d9ec..19a5cfc 100644 --- a/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift +++ b/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift @@ -16,6 +16,7 @@ public protocol FollowDetailUsecaseProtocol { func createUserTravel(request: CreateTravelRequest) async throws -> CreateTravelResponse func fetchPlaceDetail(googlePlaceId: String) async throws -> PlaceDetail func fetchPlacePhotos(googlePlaceId: String) async throws -> [PlacePhoto] + func searchPlaces(keyword: String) async throws -> [PlaceSearchResult] } public final class FollowDetailUsecase { @@ -62,4 +63,8 @@ extension FollowDetailUsecase: FollowDetailUsecaseProtocol { public func fetchPlacePhotos(googlePlaceId: String) async throws -> [PlacePhoto] { try await placeRepository.fetchPlacePhotos(googlePlaceId: googlePlaceId) } + + public func searchPlaces(keyword: String) async throws -> [PlaceSearchResult] { + try await placeRepository.searchPlaces(keyword: keyword) + } } diff --git a/Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceBuilder.swift b/Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceBuilder.swift new file mode 100644 index 0000000..81493d3 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceBuilder.swift @@ -0,0 +1,53 @@ +// +// AddPlaceBuilder.swift +// FollowFeature +// +// Created by kimnahun on 2026-02-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain + +import RIBs + +// MARK: - AddPlaceDependency + +protocol AddPlaceDependency: Dependency { + var followDetailUsecase: FollowDetailUsecaseProtocol { get } +} + +// MARK: - AddPlaceComponent + +final class AddPlaceComponent: Component { + var followDetailUsecase: FollowDetailUsecaseProtocol { + dependency.followDetailUsecase + } +} + +// MARK: - AddPlaceBuildable + +protocol AddPlaceBuildable: Buildable { + func build(withListener listener: AddPlaceListener) -> AddPlaceRouting +} + +// MARK: - AddPlaceBuilder + +final class AddPlaceBuilder: Builder, AddPlaceBuildable { + + override init(dependency: AddPlaceDependency) { + super.init(dependency: dependency) + } + + func build(withListener listener: AddPlaceListener) -> AddPlaceRouting { + let component = AddPlaceComponent(dependency: dependency) + let viewController = AddPlaceViewController() + let interactor = AddPlaceInteractor( + presenter: viewController, + followDetailUsecase: component.followDetailUsecase + ) + interactor.listener = listener + + let router = AddPlaceRouter(interactor: interactor, viewController: viewController) + return router + } +} diff --git a/Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceInteractor.swift b/Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceInteractor.swift new file mode 100644 index 0000000..20d2a9f --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceInteractor.swift @@ -0,0 +1,117 @@ +// +// AddPlaceInteractor.swift +// FollowFeature +// +// Created by kimnahun on 2026-02-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain + +import RIBs +import RxSwift + +// MARK: - AddPlacePresentable + +protocol AddPlacePresentable: Presentable { + var listener: AddPlacePresentableListener? { get set } + + func showResult(_ result: PlaceSearchResult) + func showNoResults() + func setLoading(_ isLoading: Bool) + func setAddButtonEnabled(_ enabled: Bool) +} + +// MARK: - AddPlaceListener + +public protocol AddPlaceListener: AnyObject { + func addPlaceDidCancel() + func addPlaceDidComplete(with place: PlaceSearchResult) +} + +// MARK: - AddPlacePresentableListener + +protocol AddPlacePresentableListener: AnyObject { + func didTapBack() + func search(keyword: String) + func didTapAddButton() +} + +// MARK: - AddPlaceInteractor + +final class AddPlaceInteractor: PresentableInteractor, AddPlaceInteractable { + + weak var router: AddPlaceRouting? + weak var listener: AddPlaceListener? + + private let followDetailUsecase: FollowDetailUsecaseProtocol + private var selectedPlace: PlaceSearchResult? + private var searchTask: Task? + + init(presenter: AddPlacePresentable, followDetailUsecase: FollowDetailUsecaseProtocol) { + self.followDetailUsecase = followDetailUsecase + super.init(presenter: presenter) + presenter.listener = self + } + + override func willResignActive() { + super.willResignActive() + searchTask?.cancel() + searchTask = nil + } + + // MARK: - Private + + private func performSearch(keyword: String) { + searchTask?.cancel() + + presenter.setLoading(true) + + searchTask = Task { [weak self] in + guard let self, !Task.isCancelled else { return } + + do { + let results = try await followDetailUsecase.searchPlaces(keyword: keyword) + + await MainActor.run { [weak self] in + guard let self, !Task.isCancelled else { return } + self.presenter.setLoading(false) + + if let first = results.first { + self.selectedPlace = first + self.presenter.showResult(first) + self.presenter.setAddButtonEnabled(true) + } else { + self.selectedPlace = nil + self.presenter.showNoResults() + self.presenter.setAddButtonEnabled(false) + } + } + } catch { + await MainActor.run { [weak self] in + self?.presenter.setLoading(false) + self?.presenter.showNoResults() + self?.presenter.setAddButtonEnabled(false) + } + } + } + } +} + +// MARK: - AddPlacePresentableListener + +extension AddPlaceInteractor: AddPlacePresentableListener { + + func didTapBack() { + listener?.addPlaceDidCancel() + } + + func search(keyword: String) { + performSearch(keyword: keyword) + } + + func didTapAddButton() { + guard let place = selectedPlace else { return } + listener?.addPlaceDidComplete(with: place) + } +} diff --git a/Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceRouter.swift b/Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceRouter.swift new file mode 100644 index 0000000..65bf480 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceRouter.swift @@ -0,0 +1,34 @@ +// +// AddPlaceRouter.swift +// FollowFeature +// +// Created by kimnahun on 2026-02-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import RIBs + +// MARK: - AddPlaceInteractable + +protocol AddPlaceInteractable: Interactable { + var router: AddPlaceRouting? { get set } + var listener: AddPlaceListener? { get set } +} + +// MARK: - AddPlaceViewControllable + +protocol AddPlaceViewControllable: ViewControllable { } + +// MARK: - AddPlaceRouting + +protocol AddPlaceRouting: ViewableRouting { } + +// MARK: - AddPlaceRouter + +final class AddPlaceRouter: ViewableRouter, AddPlaceRouting { + + override init(interactor: AddPlaceInteractable, viewController: AddPlaceViewControllable) { + super.init(interactor: interactor, viewController: viewController) + interactor.router = self + } +} diff --git a/Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceViewController.swift b/Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceViewController.swift new file mode 100644 index 0000000..8953d39 --- /dev/null +++ b/Projects/Features/FollowFeature/Sources/AddPlace/AddPlaceViewController.swift @@ -0,0 +1,210 @@ +// +// AddPlaceViewController.swift +// FollowFeature +// +// Created by kimnahun on 2026-02-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Domain +import DSKit +import MapKit +import RIBs +import RxSwift +import SnapKit +import Then +import UIKit + +// MARK: - AddPlaceViewController + +final class AddPlaceViewController: UIViewController, AddPlacePresentable, AddPlaceViewControllable { + + // MARK: - Properties + + weak var listener: AddPlacePresentableListener? + + private let disposeBag = DisposeBag() + private var currentAnnotation: MKAnnotation? + + // MARK: - UI Components + + private let mapView = MKMapView() + + private let searchBar = NDGLSearchBar( + placeholder: "장소를 검색해 보세요", + DSKitAsset.Assets.icChevronLeft3.image, + DSKitAsset.Assets.icSearch2.image + ) + + private let resultCardView = UIView().then { + $0.backgroundColor = UIColor(hexCode: "#FFFFFF") + $0.layer.cornerRadius = 12 + $0.layer.shadowColor = UIColor.black.cgColor + $0.layer.shadowOpacity = 0.1 + $0.layer.shadowOffset = CGSize(width: 0, height: -4) + $0.layer.shadowRadius = 8 + $0.isHidden = true + } + + private let placeNameLabel = UILabel() + private let placeAddressLabel = UILabel() + + private let bottomContainerView = UIView().then { + $0.backgroundColor = UIColor(hexCode: "#FFFFFF") + } + + private let addButton = BottomPlacedButton(title: "일정 추가하기") + + private let loadingIndicator = UIActivityIndicatorView(style: .large).then { + $0.hidesWhenStopped = true + $0.color = UIColor(hexCode: "#111111") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupConstraints() + setupBindings() + setAddButtonEnabled(false) + } + + // MARK: - Setup + + private func setupUI() { + view.addSubview(mapView) + view.addSubview(searchBar) + view.addSubview(bottomContainerView) + bottomContainerView.addSubview(resultCardView) + resultCardView.addSubview(placeNameLabel) + resultCardView.addSubview(placeAddressLabel) + bottomContainerView.addSubview(addButton) + view.addSubview(loadingIndicator) + } + + private func setupConstraints() { + mapView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + searchBar.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.horizontalEdges.equalToSuperview() + } + + bottomContainerView.snp.makeConstraints { + $0.leading.trailing.bottom.equalToSuperview() + } + + resultCardView.snp.makeConstraints { + $0.top.equalToSuperview().offset(12) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + } + + placeNameLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(16) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + } + + placeAddressLabel.snp.makeConstraints { + $0.top.equalTo(placeNameLabel.snp.bottom).offset(4) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.bottom.equalToSuperview().offset(-16) + } + + addButton.snp.makeConstraints { + $0.top.equalTo(resultCardView.snp.bottom).offset(12) + $0.leading.equalToSuperview().offset(16) + $0.trailing.equalToSuperview().offset(-16) + $0.height.equalTo(52) + $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) + } + + loadingIndicator.snp.makeConstraints { + $0.center.equalToSuperview() + } + } + + private func setupBindings() { + searchBar.leadingButtonDidTap + .subscribe(with: self) { owner, _ in + owner.listener?.didTapBack() + } + .disposed(by: disposeBag) + + searchBar.searchButtonClicked + .subscribe(with: self) { owner, _ in + // handled via searchBar.searchText on return key + } + .disposed(by: disposeBag) + + searchBar.searchText + .orEmpty + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) + .distinctUntilChanged() + .filter { !$0.isEmpty } + .subscribe(with: self) { owner, keyword in + owner.listener?.search(keyword: keyword) + } + .disposed(by: disposeBag) + + addButton.addTarget(self, action: #selector(addButtonTapped), for: .touchUpInside) + } + + @objc private func addButtonTapped() { + listener?.didTapAddButton() + } + + // MARK: - AddPlacePresentable + + func showResult(_ result: PlaceSearchResult) { + // Update map + if let existing = currentAnnotation { + mapView.removeAnnotation(existing) + } + + let coordinate = CLLocationCoordinate2D(latitude: result.latitude, longitude: result.longitude) + let annotation = MKPointAnnotation() + annotation.coordinate = coordinate + annotation.title = result.name + mapView.addAnnotation(annotation) + currentAnnotation = annotation + + let region = MKCoordinateRegion( + center: coordinate, + latitudinalMeters: 1000, + longitudinalMeters: 1000 + ) + mapView.setRegion(region, animated: true) + + // Update result card + placeNameLabel.setText(.bodyLSB, text: result.name, color: UIColor(hexCode: "#111111")) + placeAddressLabel.setText(.bodySR, text: result.address, color: UIColor(hexCode: "#757575")) + resultCardView.isHidden = false + } + + func showNoResults() { + resultCardView.isHidden = true + if let existing = currentAnnotation { + mapView.removeAnnotation(existing) + currentAnnotation = nil + } + } + + func setLoading(_ isLoading: Bool) { + if isLoading { + loadingIndicator.startAnimating() + } else { + loadingIndicator.stopAnimating() + } + } + + func setAddButtonEnabled(_ enabled: Bool) { + addButton.isUserInteractionEnabled = enabled + addButton.alpha = enabled ? 1.0 : 0.4 + } +} diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift b/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift index e932a43..f3e524f 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift @@ -18,7 +18,7 @@ public protocol FollowDetailDependency: Dependency { // MARK: - FollowDetailComponent -final class FollowDetailComponent: Component, TripCalendarDependency, PlaceDetailDependency { +final class FollowDetailComponent: Component, TripCalendarDependency, PlaceDetailDependency, AddPlaceDependency { var followDetailUsecase: FollowDetailUsecaseProtocol { dependency.followDetailUsecase } @@ -57,12 +57,14 @@ public final class FollowDetailBuilder: Builder, FollowD let tripCalendarBuilder = TripCalendarBuilder(dependency: component) let placeDetailBuilder = PlaceDetailBuilder(dependency: component) + let addPlaceBuilder = AddPlaceBuilder(dependency: component) let router = FollowDetailRouter( interactor: interactor, viewController: viewController, tripCalendarBuilder: tripCalendarBuilder, - placeDetailBuilder: placeDetailBuilder + placeDetailBuilder: placeDetailBuilder, + addPlaceBuilder: addPlaceBuilder ) return router diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift index ac0aeb2..1b1dbbd 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -38,6 +38,7 @@ protocol FollowDetailPresentableListener: AnyObject { func detachFollowDetail() func viewDidLoad() func didTapAddToTrip() + func didTapAddPlace() func didSelectDay(_ day: Int) func didSelectPlace(_ place: TravelPlace) func didTapPlaceDetailChevron(_ place: TravelPlace) @@ -178,6 +179,10 @@ extension FollowDetailInteractor: FollowDetailPresentableListener { } } + func didTapAddPlace() { + router?.routeToAddPlace() + } + func didSelectDay(_ day: Int) { guard day != currentDay else { return } currentDay = day @@ -202,6 +207,19 @@ extension FollowDetailInteractor: PlaceDetailListener { } } +// MARK: - AddPlaceListener + +extension FollowDetailInteractor: AddPlaceListener { + func addPlaceDidCancel() { + router?.detachAddPlace() + } + + func addPlaceDidComplete(with place: PlaceSearchResult) { + router?.detachAddPlace() + // 일정에 장소 추가하는 로직은 추후 구현 + } +} + // MARK: - TripCalendarListener extension FollowDetailInteractor: TripCalendarListener { diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift b/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift index 53b0c58..1374699 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailRouter.swift @@ -11,7 +11,7 @@ import RIBs // MARK: - FollowDetailInteractable -protocol FollowDetailInteractable: Interactable, TripCalendarListener, PlaceDetailListener { +protocol FollowDetailInteractable: Interactable, TripCalendarListener, PlaceDetailListener, AddPlaceListener { var router: FollowDetailRouting? { get set } var listener: FollowDetailListener? { get set } } @@ -30,6 +30,8 @@ public protocol FollowDetailRouting: ViewableRouting { func detachTripCalendar() func routeToPlaceDetail(travelPlace: TravelPlace, youtuberName: String) func detachPlaceDetail() + func routeToAddPlace() + func detachAddPlace() } // MARK: - FollowDetailRouter @@ -42,14 +44,19 @@ final class FollowDetailRouter: ViewableRouter GooglePlacesSearchResponse +} + +public final class GooglePlacesService: GooglePlacesServiceProtocol { + private let provider: MoyaProvider + + public init(provider: MoyaProvider = MoyaProvider()) { + self.provider = provider + } + + public func searchText(keyword: String) async throws -> GooglePlacesSearchResponse { + try await provider.asyncThrowsRequestRaw(.searchText(keyword: keyword)) + } +} diff --git a/Projects/Modules/Networks/Sources/TargetType/GooglePlacesAPI.swift b/Projects/Modules/Networks/Sources/TargetType/GooglePlacesAPI.swift new file mode 100644 index 0000000..a0b6add --- /dev/null +++ b/Projects/Modules/Networks/Sources/TargetType/GooglePlacesAPI.swift @@ -0,0 +1,45 @@ +// +// GooglePlacesAPI.swift +// Networks +// +// Created by kimnahun on 2026-02-24. +// Copyright © 2026 NDGL-iOS. All rights reserved. +// + +import Foundation +import Moya + +public enum GooglePlacesAPI { + case searchText(keyword: String) +} + +extension GooglePlacesAPI: TargetType { + public var baseURL: URL { + URL(string: "https://places.googleapis.com")! + } + + public var path: String { + "/v1/places:searchText" + } + + public var method: Moya.Method { + .post + } + + public var task: Moya.Task { + switch self { + case .searchText(let keyword): + let body: [String: Any] = ["textQuery": keyword] + let data = (try? JSONSerialization.data(withJSONObject: body)) ?? Data() + return .requestData(data) + } + } + + public var headers: [String: String]? { + [ + "Content-Type": "application/json", + "X-Goog-Api-Key": NetworkConfiguration.weatherApiKey, + "X-Goog-FieldMask": "places.id,places.displayName,places.formattedAddress,places.location" + ] + } +} From 81b1c4804274b25e5d5331f54597dffc465a3f75 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Tue, 24 Feb 2026 04:38:14 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20#37=20-=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserTravel /UserTravelRepository.swift | 21 +++++++ .../UserTravelRepositoryInterface.swift | 1 + .../Sources/UseCase/FollowDetailUsecase.swift | 5 ++ .../Sources/FollowDetailInteractor.swift | 56 ++++++++++++++++++- .../Sources/FollowDetailViewController.swift | 16 ++++++ .../PlaceListCollectionView.swift | 13 +++++ .../Sources/DTO/Travel/TravelDTO.swift | 35 ++++++++++++ .../Sources/Service/UserTravelService.swift | 5 ++ .../Sources/TargetType/UserTravelAPI.swift | 11 +++- 9 files changed, 157 insertions(+), 6 deletions(-) diff --git a/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift b/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift index 5077834..317d58d 100644 --- a/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift +++ b/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift @@ -65,4 +65,25 @@ public final class UserTravelRepository: UserTravelRepositoryInterface { throw error.toNDGLError() } } + + public func replaceItinerary(travelId: Int, places: [TravelPlace]) async throws { + do { + let items = places.enumerated().map { index, place in + ReplaceItineraryItemRequest( + placeId: place.id, + day: place.day, + sequence: index + 1, + startTime: nil, + estimatedDuration: place.estimatedDuration, + travelerTip: nil + ) + } + try await service.replaceItinerary( + travelId: travelId, + request: ReplaceItineraryRequest(itineraries: items) + ) + } catch { + throw error.toNDGLError() + } + } } diff --git a/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift b/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift index dbdfa85..0bfbeb8 100644 --- a/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift +++ b/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift @@ -15,4 +15,5 @@ public protocol UserTravelRepositoryInterface { func fetchUpcomingList(page: Int?, size: Int?) async throws -> [UpcomingInfo] func fetchUserTravelDetail(id: Int) async throws -> TravelDetail func fetchItinerary(travelId: Int, day: Int) async throws -> [TravelPlace] + func replaceItinerary(travelId: Int, places: [TravelPlace]) async throws } diff --git a/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift b/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift index 19a5cfc..e3d33f1 100644 --- a/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift +++ b/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift @@ -17,6 +17,7 @@ public protocol FollowDetailUsecaseProtocol { func fetchPlaceDetail(googlePlaceId: String) async throws -> PlaceDetail func fetchPlacePhotos(googlePlaceId: String) async throws -> [PlacePhoto] func searchPlaces(keyword: String) async throws -> [PlaceSearchResult] + func replaceItinerary(travelId: Int, places: [TravelPlace]) async throws } public final class FollowDetailUsecase { @@ -67,4 +68,8 @@ extension FollowDetailUsecase: FollowDetailUsecaseProtocol { public func searchPlaces(keyword: String) async throws -> [PlaceSearchResult] { try await placeRepository.searchPlaces(keyword: keyword) } + + public func replaceItinerary(travelId: Int, places: [TravelPlace]) async throws { + try await userTravelRepository.replaceItinerary(travelId: travelId, places: places) + } } diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift index 1b1dbbd..ff277c8 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -30,6 +30,8 @@ protocol FollowDetailPresentable: Presentable { func updatePlaces(_ places: [TravelPlace]) func showPlaceDetail(_ place: TravelPlace) func showTripCreatedModal(onLater: @escaping () -> Void, onViewTrip: @escaping () -> Void) + func showToast(_ message: String) + func exitEditMode() } // MARK: - FollowDetailPresentableListener @@ -39,6 +41,8 @@ protocol FollowDetailPresentableListener: AnyObject { func viewDidLoad() func didTapAddToTrip() func didTapAddPlace() + func didDeletePlaces(remaining: [TravelPlace]) + func editCompleted(orderedPlaces: [TravelPlace]) func didSelectDay(_ day: Int) func didSelectPlace(_ place: TravelPlace) func didTapPlaceDetailChevron(_ place: TravelPlace) @@ -174,8 +178,7 @@ extension FollowDetailInteractor: FollowDetailPresentableListener { let totalDays = travelDetail?.days ?? 1 router?.routeToTripCalendar(templateTotalDays: totalDays) case .myTravel: - // 내 여행 수정 플로우 (추후 구현) - break + router?.routeToAddPlace() } } @@ -183,6 +186,33 @@ extension FollowDetailInteractor: FollowDetailPresentableListener { router?.routeToAddPlace() } + func didDeletePlaces(remaining: [TravelPlace]) { + placesByDay[currentDay] = remaining + presenter.showToast("장소가 삭제되었습니다.") + } + + func editCompleted(orderedPlaces: [TravelPlace]) { + placesByDay[currentDay] = orderedPlaces + + Task { + do { + try await followDetailUsecase.replaceItinerary( + travelId: travelId, + places: buildAllPlaces() + ) + await MainActor.run { + self.presenter.showToast("편집이 완료되었습니다.") + } + } catch { + print(error) + } + } + } + + private func buildAllPlaces() -> [TravelPlace] { + placesByDay.values.flatMap { $0 } + } + func didSelectDay(_ day: Int) { guard day != currentDay else { return } currentDay = day @@ -216,7 +246,27 @@ extension FollowDetailInteractor: AddPlaceListener { func addPlaceDidComplete(with place: PlaceSearchResult) { router?.detachAddPlace() - // 일정에 장소 추가하는 로직은 추후 구현 + + let existing = placesByDay[currentDay] ?? [] + let newPlace = TravelPlace( + id: -(existing.count + 1), + day: currentDay, + sequence: existing.count + 1, + place: PlaceInfo( + googlePlaceId: place.googlePlaceId, + thumbnail: nil, + latitude: place.latitude, + longitude: place.longitude, + name: place.name, + regularOpeningHours: nil, + googleMapsUri: "" + ) + ) + + let updated = existing + [newPlace] + placesByDay[currentDay] = updated + presenter.updatePlaces(updated) + presenter.showToast("내 일정에 추가되었습니다.") } } diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift index 0937c0b..750d1b3 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailViewController.swift @@ -304,6 +304,7 @@ final class FollowDetailViewController: UIViewController, FollowDetailPresentabl editModeEntryButton.addTarget(self, action: #selector(editModeEntryButtonTapped), for: .touchUpInside) editCompleteButton.addTarget(self, action: #selector(editCompleteButtonTapped), for: .touchUpInside) selectAllButton.addTarget(self, action: #selector(selectAllButtonTapped), for: .touchUpInside) + deleteSelectionButton.addTarget(self, action: #selector(deleteSelectionButtonTapped), for: .touchUpInside) addPlaceButton.addTarget(self, action: #selector(addPlaceButtonTapped), for: .touchUpInside) navigationBar.leadingButtonDidTap @@ -328,9 +329,16 @@ final class FollowDetailViewController: UIViewController, FollowDetailPresentabl } @objc private func editCompleteButtonTapped() { + let orderedPlaces = placeListCollectionView.currentPlaces + listener?.editCompleted(orderedPlaces: orderedPlaces) toggleEditMode(entering: false) } + @objc private func deleteSelectionButtonTapped() { + let remaining = placeListCollectionView.deleteSelected() + listener?.didDeletePlaces(remaining: remaining) + } + @objc private func selectAllButtonTapped() { let allSelected = placeListCollectionView.toggleSelectAll() selectAllButton.setTitle(allSelected ? "전체 해제" : "전체 선택", for: .normal) @@ -406,6 +414,14 @@ extension FollowDetailViewController { } } + func showToast(_ message: String) { + Toast.show(type: .success, message: message, bottomPadding: 120) + } + + func exitEditMode() { + toggleEditMode(entering: false) + } + func showTripCreatedModal(onLater: @escaping () -> Void, onViewTrip: @escaping () -> Void) { let modal = NDGLModalViewController( title: "여행이 준비됐어요", diff --git a/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift b/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift index c29fb1d..38a19d0 100644 --- a/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift +++ b/Projects/Features/FollowFeature/Sources/Views/CollectionViews/PlaceListCollectionView.swift @@ -144,6 +144,19 @@ final class PlaceListCollectionView: UICollectionView { return isAllSelected } + /// 현재 표시 중인 장소 목록 (드래그 재정렬 이후 순서 반영) + var currentPlaces: [TravelPlace] { + places + } + + /// 선택된 장소를 삭제하고 남은 목록을 반환 + func deleteSelected() -> [TravelPlace] { + let remaining = places.filter { !selectedIds.contains($0.id) } + selectedIds.removeAll() + applySnapshot(places: remaining) + return remaining + } + func applySnapshot(places: [TravelPlace]) { self.places = places diff --git a/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift b/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift index f063ea5..d2d9e4a 100644 --- a/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift +++ b/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift @@ -28,6 +28,41 @@ public struct CreateUserTravelResponse: Decodable, Sendable { public let userTravelId: Int } +// MARK: - Replace Itinerary Request + +public struct ReplaceItineraryRequest: Encodable, Sendable { + public let itineraries: [ReplaceItineraryItemRequest] + + public init(itineraries: [ReplaceItineraryItemRequest]) { + self.itineraries = itineraries + } +} + +public struct ReplaceItineraryItemRequest: Encodable, Sendable { + public let placeId: Int + public let day: Int + public let sequence: Int + public let startTime: String? + public let estimatedDuration: Int? + public let travelerTip: String? + + public init( + placeId: Int, + day: Int, + sequence: Int, + startTime: String? = nil, + estimatedDuration: Int? = nil, + travelerTip: String? = nil + ) { + self.placeId = placeId + self.day = day + self.sequence = sequence + self.startTime = startTime + self.estimatedDuration = estimatedDuration + self.travelerTip = travelerTip + } +} + // MARK: - User Travel Itinerary Response public struct UserTravelItineraryResponse: Decodable, Sendable { diff --git a/Projects/Modules/Networks/Sources/Service/UserTravelService.swift b/Projects/Modules/Networks/Sources/Service/UserTravelService.swift index 972a86a..804ff2f 100644 --- a/Projects/Modules/Networks/Sources/Service/UserTravelService.swift +++ b/Projects/Modules/Networks/Sources/Service/UserTravelService.swift @@ -16,6 +16,7 @@ public protocol UserTravelServiceProtocol { func getUpcoming() async throws -> UpcomingResponse func getUpcomingList(page: Int?, size: Int?) async throws -> UpcomingListResponse func getItinerary(travelId: Int, day: Int) async throws -> UserTravelItineraryResponse + func replaceItinerary(travelId: Int, request: ReplaceItineraryRequest) async throws } public final class UserTravelService: UserTravelServiceProtocol { @@ -44,4 +45,8 @@ public final class UserTravelService: UserTravelServiceProtocol { public func getItinerary(travelId: Int, day: Int) async throws -> UserTravelItineraryResponse { try await provider.asyncThowsRequest(.getItinerary(id: travelId, day: day)) } + + public func replaceItinerary(travelId: Int, request: ReplaceItineraryRequest) async throws { + let _: BaseResponse = try await provider.asyncThrowsRequestRaw(.replaceItinerary(id: travelId, request: request)) + } } diff --git a/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift b/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift index 2e84c04..66a6b40 100644 --- a/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift +++ b/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift @@ -16,6 +16,7 @@ public enum UserTravelAPI { case getUpcoming case getUpcomingList(page: Int?, size: Int?) case getItinerary(id: Int, day: Int) + case replaceItinerary(id: Int, request: ReplaceItineraryRequest) } extension UserTravelAPI: TargetType { @@ -33,20 +34,22 @@ extension UserTravelAPI: TargetType { return "/api/v1/travels/upcoming" case .getUpcomingList: return "api/v1/travels/upcoming/list" - case .getItinerary(let id, _): + case .getItinerary(let id, _), .replaceItinerary(let id, _): return "/api/v1/travels/\(id)/itinerary" } } - + public var method: Moya.Method { switch self { case .createUserTravel: return .post case .getContentCard, .getUpcoming, .getUpcomingList, .getItinerary: return .get + case .replaceItinerary: + return .put } } - + public var task: Moya.Task { switch self { case .createUserTravel(let request): @@ -65,6 +68,8 @@ extension UserTravelAPI: TargetType { parameters: ["day": day], encoding: URLEncoding.queryString ) + case .replaceItinerary(_, let request): + return .requestJSONEncodable(request) } } From c434dac3aa354c15d6e093896252cf6060b483e5 Mon Sep 17 00:00:00 2001 From: kimnahun Date: Tue, 24 Feb 2026 04:54:54 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20#37=20-=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20api=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/Place/PlaceRepository.swift | 4 +- .../UserTravel /UserTravelRepository.swift | 9 ++++ .../Place/PlaceRepositoryInterface.swift | 2 +- .../UserTravelRepositoryInterface.swift | 1 + .../Sources/UseCase/FollowDetailUsecase.swift | 10 +++++ .../Sources/FollowDetailInteractor.swift | 42 ++++++++++--------- .../Sources/DTO/Travel/TravelDTO.swift | 14 +++++++ .../Sources/Service/PlaceService.swift | 6 +-- .../Sources/Service/UserTravelService.swift | 5 +++ .../Sources/TargetType/PlaceAPI.swift | 12 +++--- .../Sources/TargetType/UserTravelAPI.swift | 7 +++- 11 files changed, 81 insertions(+), 31 deletions(-) diff --git a/Projects/Data/Sources/Repository/Place/PlaceRepository.swift b/Projects/Data/Sources/Repository/Place/PlaceRepository.swift index de94914..bc416ac 100644 --- a/Projects/Data/Sources/Repository/Place/PlaceRepository.swift +++ b/Projects/Data/Sources/Repository/Place/PlaceRepository.swift @@ -29,9 +29,9 @@ public final class PlaceRepository: PlaceRepositoryInterface { } } - public func searchPlaces() async throws -> Int { + public func registerPlace(googlePlaceId: String) async throws { do { - return try await service.searchPlaces() + try await service.registerPlace(googlePlaceId: googlePlaceId) } catch { throw error.toNDGLError() } diff --git a/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift b/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift index 317d58d..9ec1a60 100644 --- a/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift +++ b/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift @@ -66,6 +66,15 @@ public final class UserTravelRepository: UserTravelRepositoryInterface { } } + public func addItinerary(travelId: Int, googlePlaceId: String, day: Int, sequence: Int) async throws { + do { + let request = AddItineraryRequest(googlePlaceId: googlePlaceId, day: day, sequence: sequence) + try await service.addItinerary(travelId: travelId, request: request) + } catch { + throw error.toNDGLError() + } + } + public func replaceItinerary(travelId: Int, places: [TravelPlace]) async throws { do { let items = places.enumerated().map { index, place in diff --git a/Projects/Domain/Sources/Interface/Place/PlaceRepositoryInterface.swift b/Projects/Domain/Sources/Interface/Place/PlaceRepositoryInterface.swift index 60780e1..942dff9 100644 --- a/Projects/Domain/Sources/Interface/Place/PlaceRepositoryInterface.swift +++ b/Projects/Domain/Sources/Interface/Place/PlaceRepositoryInterface.swift @@ -10,7 +10,7 @@ import Foundation public protocol PlaceRepositoryInterface { func searchPlaces(keyword: String) async throws -> [PlaceSearchResult] - func searchPlaces() async throws -> Int //임시 + func registerPlace(googlePlaceId: String) async throws func fetchPlacePhotos(googlePlaceId: String) async throws -> [PlacePhoto] func fetchPlaceDetail(googlePlaceId: String) async throws -> PlaceDetail } diff --git a/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift b/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift index 0bfbeb8..1f6b91e 100644 --- a/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift +++ b/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift @@ -15,5 +15,6 @@ public protocol UserTravelRepositoryInterface { func fetchUpcomingList(page: Int?, size: Int?) async throws -> [UpcomingInfo] func fetchUserTravelDetail(id: Int) async throws -> TravelDetail func fetchItinerary(travelId: Int, day: Int) async throws -> [TravelPlace] + func addItinerary(travelId: Int, googlePlaceId: String, day: Int, sequence: Int) async throws func replaceItinerary(travelId: Int, places: [TravelPlace]) async throws } diff --git a/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift b/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift index e3d33f1..74ca735 100644 --- a/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift +++ b/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift @@ -17,6 +17,8 @@ public protocol FollowDetailUsecaseProtocol { func fetchPlaceDetail(googlePlaceId: String) async throws -> PlaceDetail func fetchPlacePhotos(googlePlaceId: String) async throws -> [PlacePhoto] func searchPlaces(keyword: String) async throws -> [PlaceSearchResult] + func registerPlace(googlePlaceId: String) async throws + func addItinerary(travelId: Int, googlePlaceId: String, day: Int, sequence: Int) async throws func replaceItinerary(travelId: Int, places: [TravelPlace]) async throws } @@ -69,6 +71,14 @@ extension FollowDetailUsecase: FollowDetailUsecaseProtocol { try await placeRepository.searchPlaces(keyword: keyword) } + public func registerPlace(googlePlaceId: String) async throws { + try await placeRepository.registerPlace(googlePlaceId: googlePlaceId) + } + + public func addItinerary(travelId: Int, googlePlaceId: String, day: Int, sequence: Int) async throws { + try await userTravelRepository.addItinerary(travelId: travelId, googlePlaceId: googlePlaceId, day: day, sequence: sequence) + } + public func replaceItinerary(travelId: Int, places: [TravelPlace]) async throws { try await userTravelRepository.replaceItinerary(travelId: travelId, places: places) } diff --git a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift index ff277c8..cf85404 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -247,26 +247,30 @@ extension FollowDetailInteractor: AddPlaceListener { func addPlaceDidComplete(with place: PlaceSearchResult) { router?.detachAddPlace() - let existing = placesByDay[currentDay] ?? [] - let newPlace = TravelPlace( - id: -(existing.count + 1), - day: currentDay, - sequence: existing.count + 1, - place: PlaceInfo( - googlePlaceId: place.googlePlaceId, - thumbnail: nil, - latitude: place.latitude, - longitude: place.longitude, - name: place.name, - regularOpeningHours: nil, - googleMapsUri: "" - ) - ) + let sequence = (placesByDay[currentDay]?.count ?? 0) + 1 + + Task { + await MainActor.run { presenter.showLoading() } - let updated = existing + [newPlace] - placesByDay[currentDay] = updated - presenter.updatePlaces(updated) - presenter.showToast("내 일정에 추가되었습니다.") + do { + try await followDetailUsecase.registerPlace(googlePlaceId: place.googlePlaceId) + try await followDetailUsecase.addItinerary( + travelId: travelId, + googlePlaceId: place.googlePlaceId, + day: currentDay, + sequence: sequence + ) + placesByDay[currentDay] = nil + await MainActor.run { + presenter.hideLoading() + presenter.showToast("내 일정에 추가되었습니다.") + } + loadPlaces(for: currentDay) + } catch { + await MainActor.run { presenter.hideLoading() } + print(error) + } + } } } diff --git a/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift b/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift index d2d9e4a..f5fcfda 100644 --- a/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift +++ b/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift @@ -28,6 +28,20 @@ public struct CreateUserTravelResponse: Decodable, Sendable { public let userTravelId: Int } +// MARK: - Add Itinerary Request + +public struct AddItineraryRequest: Encodable, Sendable { + public let googlePlaceId: String + public let day: Int + public let sequence: Int + + public init(googlePlaceId: String, day: Int, sequence: Int) { + self.googlePlaceId = googlePlaceId + self.day = day + self.sequence = sequence + } +} + // MARK: - Replace Itinerary Request public struct ReplaceItineraryRequest: Encodable, Sendable { diff --git a/Projects/Modules/Networks/Sources/Service/PlaceService.swift b/Projects/Modules/Networks/Sources/Service/PlaceService.swift index 9097623..924d3a6 100644 --- a/Projects/Modules/Networks/Sources/Service/PlaceService.swift +++ b/Projects/Modules/Networks/Sources/Service/PlaceService.swift @@ -11,7 +11,7 @@ import Foundation import Moya public protocol PlaceServiceProtocol { - func searchPlaces() async throws -> Int + func registerPlace(googlePlaceId: String) async throws func getPlacePhotos(googlePlaceId: String) async throws -> PlacePhotosResponse func getPlaceDetails(googlePlaceId: String) async throws -> PlaceDetailResponse } @@ -23,8 +23,8 @@ public final class PlaceService: PlaceServiceProtocol { self.provider = provider } - public func searchPlaces() async throws -> Int { - try await provider.asyncThowsRequest(.searchPlaces) + public func registerPlace(googlePlaceId: String) async throws { + let _: BaseResponse = try await provider.asyncThrowsRequestRaw(.registerPlace(googlePlaceId: googlePlaceId)) } public func getPlacePhotos(googlePlaceId: String) async throws -> PlacePhotosResponse { diff --git a/Projects/Modules/Networks/Sources/Service/UserTravelService.swift b/Projects/Modules/Networks/Sources/Service/UserTravelService.swift index 804ff2f..549385d 100644 --- a/Projects/Modules/Networks/Sources/Service/UserTravelService.swift +++ b/Projects/Modules/Networks/Sources/Service/UserTravelService.swift @@ -16,6 +16,7 @@ public protocol UserTravelServiceProtocol { func getUpcoming() async throws -> UpcomingResponse func getUpcomingList(page: Int?, size: Int?) async throws -> UpcomingListResponse func getItinerary(travelId: Int, day: Int) async throws -> UserTravelItineraryResponse + func addItinerary(travelId: Int, request: AddItineraryRequest) async throws func replaceItinerary(travelId: Int, request: ReplaceItineraryRequest) async throws } @@ -46,6 +47,10 @@ public final class UserTravelService: UserTravelServiceProtocol { try await provider.asyncThowsRequest(.getItinerary(id: travelId, day: day)) } + public func addItinerary(travelId: Int, request: AddItineraryRequest) async throws { + let _: BaseResponse = try await provider.asyncThrowsRequestRaw(.addItinerary(id: travelId, request: request)) + } + public func replaceItinerary(travelId: Int, request: ReplaceItineraryRequest) async throws { let _: BaseResponse = try await provider.asyncThrowsRequestRaw(.replaceItinerary(id: travelId, request: request)) } diff --git a/Projects/Modules/Networks/Sources/TargetType/PlaceAPI.swift b/Projects/Modules/Networks/Sources/TargetType/PlaceAPI.swift index e7f5017..c85048c 100644 --- a/Projects/Modules/Networks/Sources/TargetType/PlaceAPI.swift +++ b/Projects/Modules/Networks/Sources/TargetType/PlaceAPI.swift @@ -11,7 +11,7 @@ import Foundation import Moya public enum PlaceAPI { - case searchPlaces // 아직 적용x + case registerPlace(googlePlaceId: String) case getPlacePhotos(googlePlaceId: String) case getPlaceDetails(googlePlaceId: String) } @@ -23,7 +23,7 @@ extension PlaceAPI: TargetType { public var path: String { switch self { - case .searchPlaces: + case .registerPlace: return "/api/v1/places" case .getPlacePhotos: return "/api/v1/places/photos" @@ -34,7 +34,7 @@ extension PlaceAPI: TargetType { public var method: Moya.Method { switch self { - case .searchPlaces: + case .registerPlace: return .post case .getPlacePhotos, .getPlaceDetails: return .get @@ -43,8 +43,10 @@ extension PlaceAPI: TargetType { public var task: Moya.Task { switch self { - case .searchPlaces: - return .requestPlain + case .registerPlace(let googlePlaceId): + let body = ["googlePlaceId": googlePlaceId] + let data = (try? JSONSerialization.data(withJSONObject: body)) ?? Data() + return .requestData(data) case .getPlacePhotos(let googlePlaceId), .getPlaceDetails(let googlePlaceId): return .requestParameters( parameters: ["googlePlaceId": googlePlaceId], diff --git a/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift b/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift index 66a6b40..7bde141 100644 --- a/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift +++ b/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift @@ -16,6 +16,7 @@ public enum UserTravelAPI { case getUpcoming case getUpcomingList(page: Int?, size: Int?) case getItinerary(id: Int, day: Int) + case addItinerary(id: Int, request: AddItineraryRequest) case replaceItinerary(id: Int, request: ReplaceItineraryRequest) } @@ -34,7 +35,7 @@ extension UserTravelAPI: TargetType { return "/api/v1/travels/upcoming" case .getUpcomingList: return "api/v1/travels/upcoming/list" - case .getItinerary(let id, _), .replaceItinerary(let id, _): + case .getItinerary(let id, _), .addItinerary(let id, _), .replaceItinerary(let id, _): return "/api/v1/travels/\(id)/itinerary" } } @@ -45,6 +46,8 @@ extension UserTravelAPI: TargetType { return .post case .getContentCard, .getUpcoming, .getUpcomingList, .getItinerary: return .get + case .addItinerary: + return .post case .replaceItinerary: return .put } @@ -68,6 +71,8 @@ extension UserTravelAPI: TargetType { parameters: ["day": day], encoding: URLEncoding.queryString ) + case .addItinerary(_, let request): + return .requestJSONEncodable(request) case .replaceItinerary(_, let request): return .requestJSONEncodable(request) } From 292aa92c5bda5ca159122ad452824c985a48c09a Mon Sep 17 00:00:00 2001 From: kimnahun Date: Tue, 24 Feb 2026 22:49:33 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20#37=20-=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=EC=8B=9C=20=EC=9D=BC=EB=B0=98=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EB=8F=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/UserTravel /UserTravelRepository.swift | 2 +- .../Modules/Networks/Sources/DTO/Travel/TravelDTO.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift b/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift index 9ec1a60..43c95fb 100644 --- a/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift +++ b/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift @@ -79,7 +79,7 @@ public final class UserTravelRepository: UserTravelRepositoryInterface { do { let items = places.enumerated().map { index, place in ReplaceItineraryItemRequest( - placeId: place.id, + googlePlaceId: place.place.googlePlaceId, day: place.day, sequence: index + 1, startTime: nil, diff --git a/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift b/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift index f5fcfda..5750001 100644 --- a/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift +++ b/Projects/Modules/Networks/Sources/DTO/Travel/TravelDTO.swift @@ -53,7 +53,7 @@ public struct ReplaceItineraryRequest: Encodable, Sendable { } public struct ReplaceItineraryItemRequest: Encodable, Sendable { - public let placeId: Int + public let googlePlaceId: String public let day: Int public let sequence: Int public let startTime: String? @@ -61,14 +61,14 @@ public struct ReplaceItineraryItemRequest: Encodable, Sendable { public let travelerTip: String? public init( - placeId: Int, + googlePlaceId: String, day: Int, sequence: Int, startTime: String? = nil, estimatedDuration: Int? = nil, travelerTip: String? = nil ) { - self.placeId = placeId + self.googlePlaceId = googlePlaceId self.day = day self.sequence = sequence self.startTime = startTime