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..bc416ac 100644 --- a/Projects/Data/Sources/Repository/Place/PlaceRepository.swift +++ b/Projects/Data/Sources/Repository/Place/PlaceRepository.swift @@ -13,14 +13,25 @@ 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() async throws -> Int { + + 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 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 b0df8f0..43c95fb 100644 --- a/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift +++ b/Projects/Data/Sources/Repository/UserTravel /UserTravelRepository.swift @@ -49,4 +49,50 @@ 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() + } + } + + 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 + ReplaceItineraryItemRequest( + googlePlaceId: place.place.googlePlaceId, + 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/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/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/Place/PlaceRepositoryInterface.swift b/Projects/Domain/Sources/Interface/Place/PlaceRepositoryInterface.swift index 3d76534..942dff9 100644 --- a/Projects/Domain/Sources/Interface/Place/PlaceRepositoryInterface.swift +++ b/Projects/Domain/Sources/Interface/Place/PlaceRepositoryInterface.swift @@ -9,7 +9,8 @@ import Foundation public protocol PlaceRepositoryInterface { - func searchPlaces() async throws -> Int //임시 + func searchPlaces(keyword: String) async throws -> [PlaceSearchResult] + 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 05893c1..1f6b91e 100644 --- a/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift +++ b/Projects/Domain/Sources/Interface/UserTravel/UserTravelRepositoryInterface.swift @@ -13,4 +13,8 @@ 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] + 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/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 0c67861..74ca735 100644 --- a/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift +++ b/Projects/Domain/Sources/UseCase/FollowDetailUsecase.swift @@ -11,9 +11,15 @@ 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] + 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 } public final class FollowDetailUsecase { @@ -40,6 +46,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) @@ -52,4 +66,20 @@ 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) + } + + 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/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 4755792..f3e524f 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailBuilder.swift @@ -18,16 +18,23 @@ public protocol FollowDetailDependency: Dependency { // MARK: - FollowDetailComponent -final class FollowDetailComponent: Component, TripCalendarDependency, PlaceDetailDependency { +final class FollowDetailComponent: Component, TripCalendarDependency, PlaceDetailDependency, AddPlaceDependency { var followDetailUsecase: FollowDetailUsecaseProtocol { dependency.followDetailUsecase } } +// 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,24 +45,26 @@ 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 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 e1b73cf..cf85404 100644 --- a/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift +++ b/Projects/Features/FollowFeature/Sources/FollowDetailInteractor.swift @@ -23,19 +23,26 @@ public protocol FollowDetailListener: AnyObject { protocol FollowDetailPresentable: Presentable { var listener: FollowDetailPresentableListener? { get set } + func configureMode(isMyTravel: Bool) func showLoading() func hideLoading() func updateTravelDetail(_ detail: TravelDetail) func updatePlaces(_ places: [TravelPlace]) func showPlaceDetail(_ place: TravelPlace) func showTripCreatedModal(onLater: @escaping () -> Void, onViewTrip: @escaping () -> Void) + func showToast(_ message: String) + func exitEditMode() } // MARK: - FollowDetailPresentableListener protocol FollowDetailPresentableListener: AnyObject { func detachFollowDetail() + 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) @@ -49,12 +56,18 @@ final class FollowDetailInteractor: PresentableInteractor [TravelPlace] { + placesByDay.values.flatMap { $0 } } func didSelectDay(_ day: Int) { @@ -171,6 +237,43 @@ extension FollowDetailInteractor: PlaceDetailListener { } } +// MARK: - AddPlaceListener + +extension FollowDetailInteractor: AddPlaceListener { + func addPlaceDidCancel() { + router?.detachAddPlace() + } + + func addPlaceDidComplete(with place: PlaceSearchResult) { + router?.detachAddPlace() + + let sequence = (placesByDay[currentDay]?.count ?? 0) + 1 + + Task { + await MainActor.run { presenter.showLoading() } + + 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) + } + } + } +} + // MARK: - TripCalendarListener extension FollowDetailInteractor: TripCalendarListener { @@ -184,7 +287,7 @@ extension FollowDetailInteractor: TripCalendarListener { private func createUserTravel(startDate: Date, endDate: Date) { let request = CreateTravelRequest( - templateId: recommendationId, + templateId: travelId, startDate: startDate, endDate: endDate ) 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 Void, onViewTrip: @escaping () -> Void) { let modal = NDGLModalViewController( title: "여행이 준비됐어요", diff --git a/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift b/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift index b4e91a1..dae1511 100644 --- a/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift +++ b/Projects/Features/FollowFeature/Sources/Views/Cells/PlaceCell.swift @@ -20,6 +20,10 @@ final class PlaceCell: UICollectionViewCell { // MARK: - Properties var onContainerTapped: (() -> 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..38a19d0 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,107 @@ 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 + } + + /// 현재 표시 중인 장소 목록 (드래그 재정렬 이후 순서 반영) + 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/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( diff --git a/Projects/Features/MainFeature/Sources/MainInteractor.swift b/Projects/Features/MainFeature/Sources/MainInteractor.swift index e43463a..cfb3b7b 100644 --- a/Projects/Features/MainFeature/Sources/MainInteractor.swift +++ b/Projects/Features/MainFeature/Sources/MainInteractor.swift @@ -15,6 +15,7 @@ import RxSwift public protocol MainRouting: ViewableRouting { func attachFollow(with recommendationId: Int) + func attachMyTravelDetail(with userTravelId: Int) func detachFollow() func attachPopularTravel() func detachPopularTravel() @@ -99,6 +100,10 @@ final class MainInteractor: 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/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 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/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 124527a..549385d 100644 --- a/Projects/Modules/Networks/Sources/Service/UserTravelService.swift +++ b/Projects/Modules/Networks/Sources/Service/UserTravelService.swift @@ -15,6 +15,9 @@ 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 + func addItinerary(travelId: Int, request: AddItineraryRequest) async throws + func replaceItinerary(travelId: Int, request: ReplaceItineraryRequest) async throws } public final class UserTravelService: UserTravelServiceProtocol { @@ -39,4 +42,16 @@ 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)) + } + + 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/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" + ] + } +} 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 3db8718..7bde141 100644 --- a/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift +++ b/Projects/Modules/Networks/Sources/TargetType/UserTravelAPI.swift @@ -15,6 +15,9 @@ public enum UserTravelAPI { case getContentCard(id: Int) 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) } extension UserTravelAPI: TargetType { @@ -32,18 +35,24 @@ extension UserTravelAPI: TargetType { return "/api/v1/travels/upcoming" case .getUpcomingList: return "api/v1/travels/upcoming/list" + case .getItinerary(let id, _), .addItinerary(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: + case .getContentCard, .getUpcoming, .getUpcomingList, .getItinerary: return .get + case .addItinerary: + return .post + case .replaceItinerary: + return .put } } - + public var task: Moya.Task { switch self { case .createUserTravel(let request): @@ -52,11 +61,20 @@ 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 + ) + case .addItinerary(_, let request): + return .requestJSONEncodable(request) + case .replaceItinerary(_, let request): + return .requestJSONEncodable(request) } }