diff --git a/WhereAreYou/WhereAreYou/Core/Date+Format.swift b/WhereAreYou/WhereAreYou/Core/Date+Format.swift index 9b7f697..8f53f41 100644 --- a/WhereAreYou/WhereAreYou/Core/Date+Format.swift +++ b/WhereAreYou/WhereAreYou/Core/Date+Format.swift @@ -17,6 +17,14 @@ extension Date { Self.koreanDateTimeFormatter.string(from: self) } + var koreanShortDateTimeString: String { + Self.koreanShortDateTimeFormatter.string(from: self) + } + + var koreanTimeString: String { + Self.koreanTimeFormatter.string(from: self) + } + private static let koreanDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "ko_KR") @@ -31,4 +39,18 @@ extension Date { return formatter }() + private static let koreanShortDateTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "M월 d일 (E) a h:mm" + return formatter + }() + + private static let koreanTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "HH:mm" + return formatter + }() + } diff --git a/WhereAreYou/WhereAreYou/Data/MockLocationRepository.swift b/WhereAreYou/WhereAreYou/Data/MockLocationRepository.swift new file mode 100644 index 0000000..03fb1b2 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/MockLocationRepository.swift @@ -0,0 +1,18 @@ +// +// MockLocationRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-24. +// + +import Foundation + +final class MockLocationRepository: LocationRepository { + + func getCurrentLocation(completion: @escaping (Result) -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + completion(.success(Coordinate(latitude: 37.5665, longitude: 126.9780))) + } + } + +} diff --git a/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift new file mode 100644 index 0000000..cd4f755 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift @@ -0,0 +1,49 @@ +// +// MockPlaceSearchRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-24. +// + +import Foundation + +final class MockPlaceSearchRepository: PlaceSearchRepository { + + func searchPlaces( + keyword: String, + completion: @escaping (Result<[Place], Error>) -> Void + ) { + let trimmed = keyword.trimmingCharacters(in: .whitespaces) + let results: [Place] + + if trimmed.isEmpty { + results = [] + } else { + results = Self.mockPlaces + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + completion(.success(results)) + } + } + + private static let mockPlaces: [Place] = [ + Place(id: "gangnam", name: "강남역", address: "서울 강남구 강남대로 396", + coordinate: Coordinate(latitude: 37.4979, longitude: 127.0276), type: .subway), + Place(id: "hongdae", name: "홍대입구역", address: "서울 마포구 양화로 160", + coordinate: Coordinate(latitude: 37.5571, longitude: 126.9236), type: .subway), + Place(id: "jamsil", name: "잠실역", address: "서울 송파구 올림픽로 지하 265", + coordinate: Coordinate(latitude: 37.5133, longitude: 127.1001), type: .subway), + Place(id: "seoul_station", name: "서울역", address: "서울 용산구 한강대로 405", + coordinate: Coordinate(latitude: 37.5547, longitude: 126.9707), type: .station), + Place(id: "itaewon", name: "이태원역", address: "서울 용산구 이태원로 지하 180", + coordinate: Coordinate(latitude: 37.5345, longitude: 126.9946), type: .subway), + Place(id: "yeouido", name: "여의도역", address: "서울 영등포구 의사당대로 지하 166", + coordinate: Coordinate(latitude: 37.5217, longitude: 126.9244), type: .subway), + Place(id: "busan_station", name: "부산역", address: "부산 동구 중앙대로 206", + coordinate: Coordinate(latitude: 35.1152, longitude: 129.0408), type: .station), + Place(id: "haeundae", name: "해운대역", address: "부산 해운대구 해운대로 지하 772", + coordinate: Coordinate(latitude: 35.1631, longitude: 129.1635), type: .subway), + ] + +} diff --git a/WhereAreYou/WhereAreYou/Data/MockRouteSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/MockRouteSearchRepository.swift new file mode 100644 index 0000000..08de48f --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/MockRouteSearchRepository.swift @@ -0,0 +1,98 @@ +// +// MockRouteSearchRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-22. +// + +import Foundation + +final class MockRouteSearchRepository: RouteSearchRepository { + + func searchRoutes( + departure: Place, + destination: Place, + departureTime: Date, + transportType: TransportType, + completion: @escaping (Result<[Route], Error>) -> Void + ) { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + let routes = Self.makeMockRoutes( + departure: departure, + destination: destination, + departureTime: departureTime, + transportType: transportType + ) + completion(.success(routes)) + } + } + + private static func makeMockRoutes( + departure: Place, + destination: Place, + departureTime: Date, + transportType: TransportType + ) -> [Route] { + let parking = Place( + id: "parking", name: "주차장", address: "", + coordinate: Coordinate(latitude: 0, longitude: 0), type: .other + ) + let busStop = Place( + id: "bus_stop", name: "정류장", address: "", + coordinate: Coordinate(latitude: 0, longitude: 0), type: .station + ) + + switch transportType { + case .car: + return [ + Route( + departureTime: departureTime, + arrivalTime: departureTime.addingTimeInterval(285 * 60), + step: [ + RouteStep(departurePoint: departure, destination: parking, estimatedTime: 10, distance: 0.5, transportType: .walk), + RouteStep(departurePoint: parking, destination: destination, estimatedTime: 260, distance: 280, transportType: .car), + RouteStep(departurePoint: parking, destination: destination, estimatedTime: 10, distance: 0.5, transportType: .walk) + ], + totalDistance: 281 + ), + Route( + departureTime: departureTime, + arrivalTime: departureTime.addingTimeInterval(285 * 60), + step: [ + RouteStep(departurePoint: departure, destination: parking, estimatedTime: 10, distance: 0.5, transportType: .walk), + RouteStep(departurePoint: parking, destination: destination, estimatedTime: 260, distance: 300, transportType: .car), + RouteStep(departurePoint: parking, destination: destination, estimatedTime: 10, distance: 0.8, transportType: .walk) + ], + totalDistance: 301.3 + ) + ] + + case .transit: + return [ + Route( + departureTime: departureTime, + arrivalTime: departureTime.addingTimeInterval(195 * 60), + step: [ + RouteStep(departurePoint: departure, destination: busStop, estimatedTime: 5, distance: 0.3, transportType: .walk), + RouteStep(departurePoint: busStop, destination: destination, estimatedTime: 180, distance: 200, transportType: .transit), + RouteStep(departurePoint: busStop, destination: destination, estimatedTime: 10, distance: 0.5, transportType: .walk) + ], + totalDistance: 200.8 + ) + ] + + case .walk: + return [ + Route( + departureTime: departureTime, + arrivalTime: departureTime.addingTimeInterval(600 * 60), + step: [ + RouteStep(departurePoint: departure, destination: destination, estimatedTime: 600, distance: 40, transportType: .walk) + ], + totalDistance: 40 + ) + ] + } + } + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Appointment.swift b/WhereAreYou/WhereAreYou/Domain/Entity/Appointment.swift similarity index 100% rename from WhereAreYou/WhereAreYou/Domain/Appointment.swift rename to WhereAreYou/WhereAreYou/Domain/Entity/Appointment.swift diff --git a/WhereAreYou/WhereAreYou/Domain/LocationSharingScope.swift b/WhereAreYou/WhereAreYou/Domain/Entity/LocationSharingScope.swift similarity index 100% rename from WhereAreYou/WhereAreYou/Domain/LocationSharingScope.swift rename to WhereAreYou/WhereAreYou/Domain/Entity/LocationSharingScope.swift diff --git a/WhereAreYou/WhereAreYou/Domain/Place.swift b/WhereAreYou/WhereAreYou/Domain/Entity/Place.swift similarity index 100% rename from WhereAreYou/WhereAreYou/Domain/Place.swift rename to WhereAreYou/WhereAreYou/Domain/Entity/Place.swift diff --git a/WhereAreYou/WhereAreYou/Domain/PlaceType.swift b/WhereAreYou/WhereAreYou/Domain/Entity/PlaceType.swift similarity index 100% rename from WhereAreYou/WhereAreYou/Domain/PlaceType.swift rename to WhereAreYou/WhereAreYou/Domain/Entity/PlaceType.swift diff --git a/WhereAreYou/WhereAreYou/Domain/Route.swift b/WhereAreYou/WhereAreYou/Domain/Entity/Route.swift similarity index 97% rename from WhereAreYou/WhereAreYou/Domain/Route.swift rename to WhereAreYou/WhereAreYou/Domain/Entity/Route.swift index 5f30d0b..3830d99 100644 --- a/WhereAreYou/WhereAreYou/Domain/Route.swift +++ b/WhereAreYou/WhereAreYou/Domain/Entity/Route.swift @@ -13,5 +13,5 @@ struct Route { let arrivalTime: Date let step: [RouteStep] let totalDistance: Double - + } diff --git a/WhereAreYou/WhereAreYou/Domain/RouteStep.swift b/WhereAreYou/WhereAreYou/Domain/Entity/RouteStep.swift similarity index 100% rename from WhereAreYou/WhereAreYou/Domain/RouteStep.swift rename to WhereAreYou/WhereAreYou/Domain/Entity/RouteStep.swift diff --git a/WhereAreYou/WhereAreYou/Domain/TransportType.swift b/WhereAreYou/WhereAreYou/Domain/Entity/TransportType.swift similarity index 78% rename from WhereAreYou/WhereAreYou/Domain/TransportType.swift rename to WhereAreYou/WhereAreYou/Domain/Entity/TransportType.swift index cf21027..5cd1da4 100644 --- a/WhereAreYou/WhereAreYou/Domain/TransportType.swift +++ b/WhereAreYou/WhereAreYou/Domain/Entity/TransportType.swift @@ -5,11 +5,10 @@ // Created by 이상유 on 2026-07-14. // -enum TransportType { +enum TransportType: CaseIterable { case walk case car case transit - case bicycle } diff --git a/WhereAreYou/WhereAreYou/Domain/User.swift b/WhereAreYou/WhereAreYou/Domain/Entity/User.swift similarity index 100% rename from WhereAreYou/WhereAreYou/Domain/User.swift rename to WhereAreYou/WhereAreYou/Domain/Entity/User.swift diff --git a/WhereAreYou/WhereAreYou/Domain/Repository/LocationRepository.swift b/WhereAreYou/WhereAreYou/Domain/Repository/LocationRepository.swift new file mode 100644 index 0000000..481aa67 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Repository/LocationRepository.swift @@ -0,0 +1,12 @@ +// +// LocationRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-24. +// + +protocol LocationRepository { + + func getCurrentLocation(completion: @escaping (Result) -> Void) + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Repository/PlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Domain/Repository/PlaceSearchRepository.swift new file mode 100644 index 0000000..4830fac --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Repository/PlaceSearchRepository.swift @@ -0,0 +1,15 @@ +// +// PlaceSearchRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-24. +// + +protocol PlaceSearchRepository { + + func searchPlaces( + keyword: String, + completion: @escaping (Result<[Place], Error>) -> Void + ) + +} diff --git a/WhereAreYou/WhereAreYou/Domain/Repository/RouteSearchRepository.swift b/WhereAreYou/WhereAreYou/Domain/Repository/RouteSearchRepository.swift new file mode 100644 index 0000000..f1bfc8d --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/Repository/RouteSearchRepository.swift @@ -0,0 +1,20 @@ +// +// RouteSearchRepository.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-22. +// + +import Foundation + +protocol RouteSearchRepository { + + func searchRoutes( + departure: Place, + destination: Place, + departureTime: Date, + transportType: TransportType, + completion: @escaping (Result<[Route], Error>) -> Void + ) + +} diff --git a/WhereAreYou/WhereAreYou/Domain/UseCase/GetCurrentLocationUseCase.swift b/WhereAreYou/WhereAreYou/Domain/UseCase/GetCurrentLocationUseCase.swift new file mode 100644 index 0000000..bedd8a1 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/UseCase/GetCurrentLocationUseCase.swift @@ -0,0 +1,20 @@ +// +// GetCurrentLocationUseCase.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-24. +// + +final class GetCurrentLocationUseCase { + + private let repository: LocationRepository + + init(repository: LocationRepository) { + self.repository = repository + } + + func execute(completion: @escaping (Result) -> Void) { + repository.getCurrentLocation(completion: completion) + } + +} diff --git a/WhereAreYou/WhereAreYou/Domain/UseCase/SearchPlacesUseCase.swift b/WhereAreYou/WhereAreYou/Domain/UseCase/SearchPlacesUseCase.swift new file mode 100644 index 0000000..4f735cd --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/UseCase/SearchPlacesUseCase.swift @@ -0,0 +1,23 @@ +// +// SearchPlacesUseCase.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-24. +// + +final class SearchPlacesUseCase { + + private let repository: PlaceSearchRepository + + init(repository: PlaceSearchRepository) { + self.repository = repository + } + + func execute( + keyword: String, + completion: @escaping (Result<[Place], Error>) -> Void + ) { + repository.searchPlaces(keyword: keyword, completion: completion) + } + +} diff --git a/WhereAreYou/WhereAreYou/Domain/UseCase/SearchRoutesUseCase.swift b/WhereAreYou/WhereAreYou/Domain/UseCase/SearchRoutesUseCase.swift new file mode 100644 index 0000000..68f8398 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/UseCase/SearchRoutesUseCase.swift @@ -0,0 +1,34 @@ +// +// SearchRoutesUseCase.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-22. +// + +import Foundation + +final class SearchRoutesUseCase { + + private let repository: RouteSearchRepository + + init(repository: RouteSearchRepository) { + self.repository = repository + } + + func execute( + departure: Place, + destination: Place, + departureTime: Date, + transportType: TransportType, + completion: @escaping (Result<[Route], Error>) -> Void + ) { + repository.searchRoutes( + departure: departure, + destination: destination, + departureTime: departureTime, + transportType: transportType, + completion: completion + ) + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift index b406b2c..ee313d3 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift @@ -10,14 +10,15 @@ import UIKit final class SearchPlaceCard: UIView { var onSearchBarTap: (() -> Void)? - var onSearchTap: (() -> Void)? + var onSearchTap: ((String) -> Void)? var onResetFilterTap: (() -> Void)? var onFilterTap: ((PlaceType) -> Void)? var onPlaceTap: ((PlaceInfo) -> Void)? + var onClose: (() -> Void)? - private let card = CardContainerView() + private let card: CardContainerView private let searchBar = SearchBar() private let filterSection = FilterTagBox() @@ -35,20 +36,37 @@ final class SearchPlaceCard: UIView { return stack }() - private let places: [PlaceInfo] + private let emptyResultLabel: UILabel = { + let label = UILabel() + label.text = "장소를 검색해주세요." + label.font = .preferredFont(forTextStyle: .subheadline) + label.textColor = .secondaryLabel + label.textAlignment = .center + return label + }() + + private var resultDivider = { + let divider = UIView() + divider.backgroundColor = .separator + divider.heightAnchor.constraint(equalToConstant: 1).isActive = true + return divider + }() + + private var places: [PlaceInfo] = [] private let selectionButtonTitle: String private var cells: [PlaceCell] = [] - init(_ searchResult: [PlaceInfo], selectionButtonTitle: String) { - self.places = searchResult + init(selectionButtonTitle: String, headerStyle: CardContainerView.HeaderStyle = .none) { self.selectionButtonTitle = selectionButtonTitle + self.card = CardContainerView(headerStyle: headerStyle) super.init(frame: .zero) setUpLayout() setUpActions() + card.onClose = { [weak self] in self?.onClose?() } } required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented — use init(_:selectionButtonTitle:)") + fatalError("init(coder:) has not been implemented — use init(selectionButtonTitle:)") } // MARK: - Layout @@ -67,38 +85,35 @@ final class SearchPlaceCard: UIView { card.contentStack.addArrangedSubview(searchBar) card.contentStack.addArrangedSubview(filterSection) + card.contentStack.addArrangedSubview(resultDivider) + card.contentStack.addArrangedSubview(resultScrollView) - guard !places.isEmpty else { return } + setUpResultScrollViewConstraints() - card.contentStack.addArrangedSubview(Self.makeDivider()) - card.contentStack.addArrangedSubview(resultScrollView) - setUpResultScrollView() + resultDivider.isHidden = true + emptyResultLabel.isHidden = false } - private func setUpResultScrollView() { + private func setUpResultScrollViewConstraints() { resultScrollView.translatesAutoresizingMaskIntoConstraints = false resultStack.translatesAutoresizingMaskIntoConstraints = false + emptyResultLabel.translatesAutoresizingMaskIntoConstraints = false + resultScrollView.addSubview(resultStack) + resultScrollView.addSubview(emptyResultLabel) NSLayoutConstraint.activate([ + resultScrollView.heightAnchor.constraint(equalToConstant: 300), + resultStack.topAnchor.constraint(equalTo: resultScrollView.contentLayoutGuide.topAnchor), resultStack.bottomAnchor.constraint(equalTo: resultScrollView.contentLayoutGuide.bottomAnchor), resultStack.leadingAnchor.constraint(equalTo: resultScrollView.contentLayoutGuide.leadingAnchor), resultStack.trailingAnchor.constraint(equalTo: resultScrollView.contentLayoutGuide.trailingAnchor), - resultStack.widthAnchor.constraint(equalTo: resultScrollView.frameLayoutGuide.widthAnchor) - ]) + resultStack.widthAnchor.constraint(equalTo: resultScrollView.frameLayoutGuide.widthAnchor), - cells = places.map { place in - let cell = PlaceCell(place, buttonText: selectionButtonTitle) - if let lastCell = resultStack.arrangedSubviews.last { - let divider = Self.makeDivider() - resultStack.addArrangedSubview(divider) - resultStack.setCustomSpacing(10, after: lastCell) - resultStack.setCustomSpacing(10, after: divider) - } - resultStack.addArrangedSubview(cell) - return cell - } + emptyResultLabel.centerXAnchor.constraint(equalTo: resultScrollView.frameLayoutGuide.centerXAnchor), + emptyResultLabel.centerYAnchor.constraint(equalTo: resultScrollView.frameLayoutGuide.centerYAnchor), + ]) } private static func makeDivider() -> UIView { @@ -111,14 +126,13 @@ final class SearchPlaceCard: UIView { // MARK: - Actions private func setUpActions() { - searchBar.onSearchTap = { [weak self] in self?.onSearchTap?() } + searchBar.onSearchTap = { [weak self] in + guard let self else { return } + self.onSearchTap?(self.searchBar.text ?? "") + } filterSection.onResetTap = { [weak self] in self?.onResetFilterTap?() } filterSection.onFilterTap = { [weak self] placeType in self?.onFilterTap?(placeType) } - for (place, cell) in zip(places, cells) { - cell.onButtonTap = { [weak self] in self?.onPlaceTap?(place) } - } - let dismissKeyboardGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) dismissKeyboardGesture.cancelsTouchesInView = false dismissKeyboardGesture.delegate = self @@ -129,10 +143,48 @@ final class SearchPlaceCard: UIView { // MARK: - Public Configuration + func updatePlaces(_ newPlaces: [PlaceInfo]) { + places = newPlaces + rebuildResultCells() + } + func configureSelectedFilters(_ selected: [PlaceType]) { filterSection.configure(selected: selected) } + func updateEmptyResultMessage(hasSearched: Bool) { + emptyResultLabel.text = hasSearched ? "검색 결과가 존재하지 않습니다." : "장소를 검색해주세요." + } + + // MARK: - Result Cells + + private func rebuildResultCells() { + resultStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + cells = [] + + guard !places.isEmpty else { + emptyResultLabel.isHidden = false + resultDivider.isHidden = true + return + } + + emptyResultLabel.isHidden = true + resultDivider.isHidden = false + + cells = places.map { place in + let cell = PlaceCell(place, buttonText: selectionButtonTitle) + if let lastView = resultStack.arrangedSubviews.last { + let divider = Self.makeDivider() + resultStack.addArrangedSubview(divider) + resultStack.setCustomSpacing(10, after: lastView) + resultStack.setCustomSpacing(10, after: divider) + } + resultStack.addArrangedSubview(cell) + cell.onButtonTap = { [weak self] in self?.onPlaceTap?(place) } + return cell + } + } + } extension SearchPlaceCard: UIGestureRecognizerDelegate { diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift new file mode 100644 index 0000000..eee23b2 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift @@ -0,0 +1,160 @@ +// +// SearchPlaceCardViewController.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-24. +// + +import UIKit +import Combine + +final class SearchPlaceCardViewController: UIViewController { + + enum Style { + case plain + case dimmed(title: String) + } + + var onPlaceSelected: ((Place) -> Void)? + + private let style: Style + private let viewModel: SearchPlaceCardViewModel + private var cancellables = Set() + + private var searchCard: SearchPlaceCard + private var dimView: UIView? + + init(viewModel: SearchPlaceCardViewModel, selectionButtonTitle: String, style: Style = .plain) { + self.style = style + self.viewModel = viewModel + + switch style { + case .plain: + self.searchCard = SearchPlaceCard(selectionButtonTitle: selectionButtonTitle) + case .dimmed(let title): + self.searchCard = SearchPlaceCard( + selectionButtonTitle: selectionButtonTitle, + headerStyle: .titleWithCloseButton(title) + ) + } + + super.init(nibName: nil, bundle: nil) + + if case .dimmed = style { + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setUpLayout() + setUpActions() + bindViewModel() + } + + // MARK: - Layout + + private func setUpLayout() { + searchCard.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(searchCard) + + switch style { + case .dimmed: + let dim = UIView() + dim.backgroundColor = .black.withAlphaComponent(0.4) + dim.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(dim, belowSubview: searchCard) + self.dimView = dim + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dimTapped(_:))) + dim.addGestureRecognizer(tapGesture) + + NSLayoutConstraint.activate([ + dim.topAnchor.constraint(equalTo: view.topAnchor), + dim.leadingAnchor.constraint(equalTo: view.leadingAnchor), + dim.trailingAnchor.constraint(equalTo: view.trailingAnchor), + dim.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + searchCard.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + searchCard.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + searchCard.centerYAnchor.constraint(equalTo: view.centerYAnchor), + searchCard.heightAnchor.constraint(lessThanOrEqualTo: view.heightAnchor, multiplier: 0.7), + ]) + + case .plain: + NSLayoutConstraint.activate([ + searchCard.topAnchor.constraint(equalTo: view.topAnchor), + searchCard.leadingAnchor.constraint(equalTo: view.leadingAnchor), + searchCard.trailingAnchor.constraint(equalTo: view.trailingAnchor), + searchCard.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + } + + // MARK: - Actions + + private func setUpActions() { + searchCard.onSearchTap = { [weak self] keyword in + self?.viewModel.search(keyword: keyword) + } + + searchCard.onPlaceTap = { [weak self] placeInfo in + guard let self, let place = self.viewModel.place(for: placeInfo) else { return } + self.onPlaceSelected?(place) + if case .dimmed = self.style { self.dismiss(animated: true) } + } + + searchCard.onFilterTap = { [weak self] placeType in + self?.viewModel.toggleFilter(placeType) + } + + searchCard.onResetFilterTap = { [weak self] in + self?.viewModel.resetFilters() + } + + searchCard.onClose = { [weak self] in + self?.dismiss(animated: true) + } + } + + @objc private func dimTapped(_ gesture: UITapGestureRecognizer) { + let location = gesture.location(in: view) + if !searchCard.frame.contains(location) { + dismiss(animated: true) + } + } + + // MARK: - ViewModel binding + + private func bindViewModel() { + viewModel.$filteredPlaces + .receive(on: DispatchQueue.main) + .sink { [weak self] places in + let infos = places.map { + PlaceInfo(id: $0.id, name: $0.name, address: $0.address, tag: $0.type) + } + self?.searchCard.updatePlaces(infos) + } + .store(in: &cancellables) + + viewModel.$selectedFilters + .receive(on: DispatchQueue.main) + .sink { [weak self] filters in + self?.searchCard.configureSelectedFilters(filters) + } + .store(in: &cancellables) + + viewModel.$hasSearched + .receive(on: DispatchQueue.main) + .sink { [weak self] hasSearched in + self?.searchCard.updateEmptyResultMessage(hasSearched: hasSearched) + } + .store(in: &cancellables) + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift new file mode 100644 index 0000000..55ffeea --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift @@ -0,0 +1,63 @@ +// +// SearchPlaceCardViewModel.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-24. +// + +import Foundation +import Combine + +final class SearchPlaceCardViewModel { + + @Published private(set) var filteredPlaces: [Place] = [] + @Published private(set) var selectedFilters: [PlaceType] = [] + @Published private(set) var hasSearched = false + + private var allPlaces: [Place] = [] + private let searchPlacesUseCase: SearchPlacesUseCase + + init(searchPlacesUseCase: SearchPlacesUseCase) { + self.searchPlacesUseCase = searchPlacesUseCase + } + + func search(keyword: String) { + hasSearched = true + searchPlacesUseCase.execute(keyword: keyword) { [weak self] result in + guard let self else { return } + DispatchQueue.main.async { + if case .success(let places) = result { + self.allPlaces = places + self.applyFilter() + } + } + } + } + + func toggleFilter(_ placeType: PlaceType) { + if let index = selectedFilters.firstIndex(of: placeType) { + selectedFilters.remove(at: index) + } else { + selectedFilters.append(placeType) + } + applyFilter() + } + + func resetFilters() { + selectedFilters = [] + applyFilter() + } + + func place(for placeInfo: PlaceInfo) -> Place? { + allPlaces.first { $0.id == placeInfo.id } + } + + private func applyFilter() { + if selectedFilters.isEmpty { + filteredPlaces = allPlaces + } else { + filteredPlaces = allPlaces.filter { selectedFilters.contains($0.type) } + } + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/FilterTagBox.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SubComponent/FilterTagBox.swift similarity index 100% rename from WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/FilterTagBox.swift rename to WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SubComponent/FilterTagBox.swift diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/PlaceCell.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SubComponent/PlaceCell.swift similarity index 100% rename from WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/PlaceCell.swift rename to WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SubComponent/PlaceCell.swift diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/PlaceTagCapsule.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SubComponent/PlaceTagCapsule.swift similarity index 100% rename from WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/PlaceTagCapsule.swift rename to WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SubComponent/PlaceTagCapsule.swift diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchBar.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SubComponent/SearchBar.swift similarity index 100% rename from WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchBar.swift rename to WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SubComponent/SearchBar.swift diff --git a/WhereAreYou/WhereAreYou/Presentation/Extension/TransportType+.swift b/WhereAreYou/WhereAreYou/Presentation/Extension/TransportType+.swift index b0b9426..99b7232 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Extension/TransportType+.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Extension/TransportType+.swift @@ -14,7 +14,6 @@ extension TransportType { case .walk: return "걷기" case .car: return "자동차" case .transit: return "대중교통" - case .bicycle: return "자전거" } } @@ -23,16 +22,14 @@ extension TransportType { case .walk: return "figure.walk" case .car: return "car" case .transit: return "bus" - case .bicycle: return "bicycle" } } var color: UIColor { switch self { - case .walk: return .systemBrown + case .walk: return .systemGreen case .car: return .systemIndigo - case .transit: return .systemGreen - case .bicycle: return .systemYellow + case .transit: return .systemYellow } } diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift new file mode 100644 index 0000000..c4143d5 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift @@ -0,0 +1,84 @@ +// +// DepartureTimePickerViewController.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-22. +// + +import UIKit + +final class DepartureTimePickerViewController: UIViewController { + + var onDateSelected: ((Date) -> Void)? + + private let headerLabel: UILabel = { + let label = UILabel() + label.text = "출발 시간 설정" + label.font = .preferredFont(forTextStyle: .headline) + label.textAlignment = .center + return label + }() + + private let datePicker: UIDatePicker = { + let picker = UIDatePicker() + picker.datePickerMode = .dateAndTime + picker.preferredDatePickerStyle = .wheels + picker.locale = Locale(identifier: "ko_KR") + return picker + }() + + private let confirmButton = UIButton.filled( + title: "확인", + background: .blue1, + tint: .white, + font: .body, + edgeInsets: NSDirectionalEdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0) + ) + + private let initialDate: Date + + init(initialDate: Date) { + self.initialDate = initialDate + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + datePicker.minimumDate = Date() + datePicker.date = initialDate + + headerLabel.translatesAutoresizingMaskIntoConstraints = false + datePicker.translatesAutoresizingMaskIntoConstraints = false + confirmButton.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(headerLabel) + view.addSubview(datePicker) + view.addSubview(confirmButton) + + NSLayoutConstraint.activate([ + headerLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 30), + headerLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + headerLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + + datePicker.topAnchor.constraint(equalTo: headerLabel.bottomAnchor, constant: 16), + datePicker.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + datePicker.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + + confirmButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + confirmButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + confirmButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20) + ]) + + confirmButton.addAction(UIAction { [weak self] _ in + guard let self else { return } + self.onDateSelected?(self.datePicker.date) + self.dismiss(animated: true) + }, for: .touchUpInside) + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift new file mode 100644 index 0000000..e06ae1d --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift @@ -0,0 +1,425 @@ +// +// RouteSearchViewController.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-22. +// + +import UIKit +import Combine + +final class RouteSearchViewController: UIViewController { + + // MARK: - Spacing + + private static let horizontalPadding: CGFloat = 20 + private static let sectionSpacing: CGFloat = 24 + private static let sectionHeaderBottomSpacing: CGFloat = 12 + private static let bottomButtonHeight: CGFloat = 50 + private static let bottomButtonBottomPadding: CGFloat = 16 + private static let contentTopPadding: CGFloat = 16 + + // MARK: - Dependencies + + private let viewModel: RouteSearchViewModel + private var cancellables = Set() + + // MARK: - Section 1: Place input + + private let placeInputView = PlaceInputBox() + + // MARK: - Section 2: Transport type + + private let transportSectionLabel: UILabel = { + let label = UILabel() + label.text = "이동 수단 선택" + label.font = .preferredFont(forTextStyle: .callout) + label.textColor = .secondaryLabel + return label + }() + + private let transportSelector = TransportTypeSelectionBox() + + // MARK: - Section 3: Departure time + + private let timeSectionLabel: UILabel = { + let label = UILabel() + label.text = "출발 시간 설정" + label.font = .preferredFont(forTextStyle: .callout) + label.textColor = .secondaryLabel + return label + }() + + private let timeButton: UIButton = { + let button = UIButton.filled( + title: "", + background: .clear, + tint: .label, + font: .footnote, + edgeInsets: NSDirectionalEdgeInsets(top: 10, leading: 15, bottom: 10, trailing: 15) + ) + button.configuration?.background.strokeColor = .separator + button.configuration?.background.strokeWidth = 1 + return button + }() + + // MARK: - Section 4: Routes + + private let routeSectionLabel: UILabel = { + let label = UILabel() + label.text = "추천 경로" + label.font = .preferredFont(forTextStyle: .callout) + label.textColor = .secondaryLabel + return label + }() + + private let emptyStateLabel: UILabel = { + let label = UILabel() + label.text = "출발지와 도착지를 모두 선택해주세요." + label.font = .preferredFont(forTextStyle: .body) + label.textColor = .secondaryLabel + label.textAlignment = .center + return label + }() + + private let loadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.hidesWhenStopped = true + return indicator + }() + + private let routeScrollView: UIScrollView = { + let sv = UIScrollView() + sv.showsVerticalScrollIndicator = false + sv.alwaysBounceVertical = true + return sv + }() + + private let routeCardsStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 16 + return stack + }() + + // MARK: - Bottom button + + private let selectRouteButton = UIButton.filled( + title: "이 경로 선택", + background: .blue1, + tint: .white, + font: .body + ) + + // MARK: - Overlay + + private var placeSearchTarget: PlaceSearchTarget = .departure + + private enum PlaceSearchTarget { + case departure + case arrival + } + + // MARK: - Init + + private let initialDeparture: Place? + private let initialDestination: Place? + + init(viewModel: RouteSearchViewModel, departure: Place? = nil, destination: Place? = nil) { + self.viewModel = viewModel + self.initialDeparture = departure + self.initialDestination = destination + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .pointBackground + title = "경로 검색" + setUpLayout() + setUpActions() + bindViewModel() + setUpInitialPlaces() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.setDepartureTime(Date()) + } + + private func setUpInitialPlaces() { + if let dep = initialDeparture { + viewModel.setDeparture(dep) + } + if let dest = initialDestination { + viewModel.setDestination(dest) + } + } + + // MARK: - Layout + + private func setUpLayout() { + + // 상단 고정 영역: 장소 입력 / 이동 수단 / 출발 시간 / 섹션 헤더들 + + let topStack = UIStackView() + topStack.axis = .vertical + topStack.translatesAutoresizingMaskIntoConstraints = false + + topStack.isLayoutMarginsRelativeArrangement = true + topStack.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: Self.contentTopPadding, + leading: Self.horizontalPadding, + bottom: 0, + trailing: Self.horizontalPadding + ) + + let timeSectionRow = makeTimeSectionRow() + + [placeInputView, transportSectionLabel, transportSelector, + timeSectionRow, routeSectionLabel] + .forEach { topStack.addArrangedSubview($0) } + + topStack.setCustomSpacing(Self.sectionSpacing, after: placeInputView) + topStack.setCustomSpacing(Self.sectionHeaderBottomSpacing, after: transportSectionLabel) + topStack.setCustomSpacing(Self.sectionSpacing, after: transportSelector) + topStack.setCustomSpacing(Self.sectionHeaderBottomSpacing, after: timeSectionRow) + topStack.setCustomSpacing(Self.sectionHeaderBottomSpacing, after: routeSectionLabel) + + // 경로 검색 결과 영역: 빈 상태 / 로딩 / 경로 카드 목록 + + [routeScrollView, routeCardsStack, selectRouteButton, emptyStateLabel, loadingIndicator] + .forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + + routeScrollView.addSubview(routeCardsStack) + + [topStack, emptyStateLabel, loadingIndicator, routeScrollView, selectRouteButton] + .forEach { view.addSubview($0) } + + NSLayoutConstraint.activate([ + + // 상단 고정 영역 + topStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + topStack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + topStack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + // 빈 상태 라벨 (topStack 바로 아래) + emptyStateLabel.topAnchor.constraint(equalTo: topStack.bottomAnchor, constant: Self.sectionSpacing), + emptyStateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Self.horizontalPadding), + emptyStateLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Self.horizontalPadding), + + // 로딩 인디케이터 (topStack 바로 아래) + loadingIndicator.topAnchor.constraint(equalTo: topStack.bottomAnchor, constant: Self.sectionSpacing), + loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + + // 경로 카드 스크롤 영역 (topStack ~ 하단 버튼 사이) + routeScrollView.topAnchor.constraint(equalTo: topStack.bottomAnchor, constant: Self.sectionHeaderBottomSpacing), + routeScrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + routeScrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + routeScrollView.bottomAnchor.constraint(equalTo: selectRouteButton.topAnchor, constant: -8), + + // 스크롤 내부 카드 스택 + routeCardsStack.topAnchor.constraint(equalTo: routeScrollView.contentLayoutGuide.topAnchor), + routeCardsStack.leadingAnchor.constraint(equalTo: routeScrollView.contentLayoutGuide.leadingAnchor, constant: Self.horizontalPadding), + routeCardsStack.trailingAnchor.constraint(equalTo: routeScrollView.contentLayoutGuide.trailingAnchor, constant: -Self.horizontalPadding), + routeCardsStack.bottomAnchor.constraint(equalTo: routeScrollView.contentLayoutGuide.bottomAnchor, constant: -8), + routeCardsStack.widthAnchor.constraint(equalTo: routeScrollView.frameLayoutGuide.widthAnchor, constant: -Self.horizontalPadding * 2), + + // 하단 고정 버튼 + selectRouteButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Self.horizontalPadding), + selectRouteButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Self.horizontalPadding), + selectRouteButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -Self.bottomButtonBottomPadding), + selectRouteButton.heightAnchor.constraint(equalToConstant: Self.bottomButtonHeight), + ]) + + routeScrollView.isHidden = true + loadingIndicator.isHidden = true + } + + private func makeTimeSectionRow() -> UIView { + let container = UIView() + timeSectionLabel.translatesAutoresizingMaskIntoConstraints = false + timeButton.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(timeSectionLabel) + container.addSubview(timeButton) + + NSLayoutConstraint.activate([ + timeSectionLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor), + timeSectionLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor), + + timeButton.trailingAnchor.constraint(equalTo: container.trailingAnchor), + timeButton.centerYAnchor.constraint(equalTo: container.centerYAnchor), + timeButton.topAnchor.constraint(equalTo: container.topAnchor), + timeButton.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + return container + } + + // MARK: - Actions + + private func setUpActions() { + placeInputView.onDepartureTapped = { [weak self] in + self?.presentPlaceSearch(target: .departure) + } + placeInputView.onArrivalTapped = { [weak self] in + self?.presentPlaceSearch(target: .arrival) + } + placeInputView.onCurrentLocationTapped = { [weak self] in + self?.viewModel.fetchCurrentLocation() + } + placeInputView.onDepartureClear = { [weak self] in + self?.viewModel.setDeparture(nil) + } + placeInputView.onArrivalClear = { [weak self] in + self?.viewModel.setDestination(nil) + } + + transportSelector.onTransportTypeSelected = { [weak self] type in + self?.viewModel.setTransportType(type) + } + + timeButton.addAction(UIAction { [weak self] _ in + self?.presentTimePicker() + }, for: .touchUpInside) + + selectRouteButton.addAction(UIAction { _ in + print("경로 선택 tapped") + }, for: .touchUpInside) + } + + // MARK: - ViewModel binding + + private func bindViewModel() { + viewModel.$departure + .receive(on: DispatchQueue.main) + .sink { [weak self] place in + self?.placeInputView.setDeparture(name: place?.name) + } + .store(in: &cancellables) + + viewModel.$destination + .receive(on: DispatchQueue.main) + .sink { [weak self] place in + self?.placeInputView.setArrival(name: place?.name) + } + .store(in: &cancellables) + + viewModel.$departureTime + .receive(on: DispatchQueue.main) + .sink { [weak self] time in + guard let self else { return } + var attr = AttributedString(time.koreanShortDateTimeString) + attr.font = .preferredFont(forTextStyle: .footnote) + self.timeButton.configuration?.attributedTitle = attr + } + .store(in: &cancellables) + + viewModel.$isLoading + .receive(on: DispatchQueue.main) + .sink { [weak self] isLoading in + guard let self else { return } + if isLoading { + self.emptyStateLabel.isHidden = true + self.routeScrollView.isHidden = true + self.loadingIndicator.isHidden = false + self.loadingIndicator.startAnimating() + } + } + .store(in: &cancellables) + + Publishers.CombineLatest3(viewModel.$routes, viewModel.$selectedRouteIndex, viewModel.$isLoading) + .receive(on: DispatchQueue.main) + .sink { [weak self] routes, selectedIndex, isLoading in + guard let self, !isLoading else { return } + self.updateRouteSection(routes: routes, selectedIndex: selectedIndex) + } + .store(in: &cancellables) + } + + // MARK: - Update UI + + private func updateRouteSection(routes: [Route], selectedIndex: Int) { + routeCardsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + + let hasPlaces = viewModel.departure != nil && viewModel.destination != nil + + loadingIndicator.isHidden = true + loadingIndicator.stopAnimating() + + if !hasPlaces { + emptyStateLabel.isHidden = false + routeScrollView.isHidden = true + return + } + + emptyStateLabel.isHidden = true + + if routes.isEmpty { + // TODO: 길찾기 API 확인 후 대체 텍스트 설정 필요 + routeScrollView.isHidden = true + return + } + + routeScrollView.isHidden = false + + for (index, route) in routes.enumerated() { + let card = RouteCard(route: route, isSelected: index == selectedIndex) + card.onTap = { [weak self] in + self?.viewModel.selectRoute(at: index) + } + routeCardsStack.addArrangedSubview(card) + } + } + + // MARK: - Place search + + private func presentPlaceSearch(target: PlaceSearchTarget) { + placeSearchTarget = target + + let buttonTitle = target == .departure ? "출발지로" : "도착지로" + // TODO: DIContainer로 대체 + let searchPlacesUseCase = SearchPlacesUseCase(repository: MockPlaceSearchRepository()) + let searchViewModel = SearchPlaceCardViewModel(searchPlacesUseCase: searchPlacesUseCase) + let searchVC = SearchPlaceCardViewController( + viewModel: searchViewModel, + selectionButtonTitle: buttonTitle, + style: .dimmed(title: "장소 검색") + ) + + searchVC.onPlaceSelected = { [weak self] place in + guard let self else { return } + switch self.placeSearchTarget { + case .departure: + self.viewModel.setDeparture(place) + case .arrival: + self.viewModel.setDestination(place) + } + } + + present(searchVC, animated: true) + } + + // MARK: - Time picker + + private func presentTimePicker() { + let pickerViewController = DepartureTimePickerViewController(initialDate: viewModel.departureTime) + pickerViewController.onDateSelected = { [weak self] date in + self?.viewModel.setDepartureTime(date) + } + + if let sheet = pickerViewController.sheetPresentationController { + sheet.detents = [.medium()] + sheet.prefersGrabberVisible = true + } + + present(pickerViewController, animated: true) + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift new file mode 100644 index 0000000..450e73b --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift @@ -0,0 +1,106 @@ +// +// RouteSearchViewModel.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-22. +// + +import Foundation +import Combine + +final class RouteSearchViewModel { + + @Published private(set) var departure: Place? + @Published private(set) var destination: Place? + @Published private(set) var departureTime: Date = Date() + @Published private(set) var selectedTransportType: TransportType = TransportType.allCases[0] + @Published private(set) var routes: [Route] = [] + @Published private(set) var isLoading = false + @Published private(set) var selectedRouteIndex: Int = 0 + + private let searchRoutesUseCase: SearchRoutesUseCase + private let getCurrentLocationUseCase: GetCurrentLocationUseCase + + init( + searchRoutesUseCase: SearchRoutesUseCase, + getCurrentLocationUseCase: GetCurrentLocationUseCase + ) { + self.searchRoutesUseCase = searchRoutesUseCase + self.getCurrentLocationUseCase = getCurrentLocationUseCase + } + + func setDeparture(_ place: Place?) { + departure = place + searchIfReady() + } + + func setDestination(_ place: Place?) { + destination = place + searchIfReady() + } + + func setDepartureTime(_ time: Date) { + departureTime = time + searchIfReady() + } + + func setTransportType(_ type: TransportType) { + selectedTransportType = type + searchIfReady() + } + + func selectRoute(at index: Int) { + guard index >= 0, index < routes.count else { return } + selectedRouteIndex = index + } + + func fetchCurrentLocation() { + getCurrentLocationUseCase.execute { [weak self] result in + guard let self else { return } + DispatchQueue.main.async { + if case .success(let coordinate) = result { + let place = Place( + id: "current_location", + name: "현재 위치", + address: "", + coordinate: coordinate, + type: .other + ) + self.setDeparture(place) + } + } + } + } + + private func searchIfReady() { + guard let departure, let destination else { + routes = [] + selectedRouteIndex = 0 + return + } + + isLoading = true + + searchRoutesUseCase.execute( + departure: departure, + destination: destination, + departureTime: departureTime, + transportType: selectedTransportType + ) { [weak self] result in + guard let self else { return } + DispatchQueue.main.async { + self.isLoading = false + self.selectedRouteIndex = 0 + + switch result { + case .success(let routes): + self.routes = routes + case .failure: + // TODO: 길찾기 API 확인 후 검색 실패 텍스트 설정 필요 + self.routes = [] + } + } + } + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/PlaceInputBox.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/PlaceInputBox.swift new file mode 100644 index 0000000..9ef73ce --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/PlaceInputBox.swift @@ -0,0 +1,196 @@ +// +// PlaceInputBox.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-22. +// + +import UIKit + +final class PlaceInputBox: UIView { + + var onDepartureTapped: (() -> Void)? + var onArrivalTapped: (() -> Void)? + var onDepartureClear: (() -> Void)? + var onArrivalClear: (() -> Void)? + var onCurrentLocationTapped: (() -> Void)? + + private let departureIcon: UIImageView = { + let imageView = UIImageView(image: UIImage(systemName: "location.fill")) + imageView.tintColor = .secondaryLabel + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private let departureButton: UIButton = { + let button = UIButton.filled( + title: "출발", + background: .clear, + tint: .placeholderText, + font: .body, + edgeInsets: NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 0) + ) + button.contentHorizontalAlignment = .leading + return button + }() + + private let currentLocationButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(UIImage(systemName: "scope"), for: .normal) + button.tintColor = .systemBlue + return button + }() + + private let departureClearButton = UIButton(type: .system) + + private let separator: UIView = { + let view = UIView() + view.backgroundColor = .separator + return view + }() + + private let arrivalIcon: UIImageView = { + let imageView = UIImageView(image: UIImage(systemName: "mappin")) + imageView.tintColor = .secondaryLabel + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private let arrivalButton: UIButton = { + let button = UIButton.filled( + title: "도착", + background: .clear, + tint: .placeholderText, + font: .body, + edgeInsets: NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 0) + ) + button.contentHorizontalAlignment = .leading + return button + }() + + private let arrivalClearButton = UIButton(type: .system) + + init() { + super.init(frame: .zero) + setUp() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented — use init()") + } + + func setDeparture(name: String?) { + if let name { + departureButton.configuration?.title = name + departureButton.configuration?.baseForegroundColor = .label + departureClearButton.isHidden = false + } else { + departureButton.configuration?.title = "출발" + departureButton.configuration?.baseForegroundColor = .placeholderText + departureClearButton.isHidden = true + } + } + + func setArrival(name: String?) { + if let name { + arrivalButton.configuration?.title = name + arrivalButton.configuration?.baseForegroundColor = .label + arrivalClearButton.isHidden = false + } else { + arrivalButton.configuration?.title = "도착" + arrivalButton.configuration?.baseForegroundColor = .placeholderText + arrivalClearButton.isHidden = true + } + } + + private func setUp() { + backgroundColor = .white + layer.cornerRadius = 12 + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.06 + layer.shadowRadius = 16 + layer.shadowOffset = CGSize(width: 0, height: 4) + + let iconSize: CGFloat = 24 + + makeClearButton(departureClearButton) + makeClearButton(arrivalClearButton) + + let departureRow = UIStackView(arrangedSubviews: [departureIcon, departureButton, currentLocationButton, departureClearButton]) + departureRow.axis = .horizontal + departureRow.spacing = 12 + departureRow.alignment = .center + + departureRow.setCustomSpacing(5, after: departureIcon) + + let arrivalRow = UIStackView(arrangedSubviews: [arrivalIcon, arrivalButton, arrivalClearButton]) + arrivalRow.axis = .horizontal + arrivalRow.spacing = 12 + arrivalRow.alignment = .center + + arrivalRow.setCustomSpacing(5, after: arrivalIcon) + + let mainStack = UIStackView(arrangedSubviews: [departureRow, separator, arrivalRow]) + mainStack.axis = .vertical + mainStack.spacing = 0 + + mainStack.translatesAutoresizingMaskIntoConstraints = false + addSubview(mainStack) + + NSLayoutConstraint.activate([ + mainStack.topAnchor.constraint(equalTo: topAnchor, constant: 4), + mainStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + mainStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + mainStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + + departureIcon.widthAnchor.constraint(equalToConstant: iconSize), + departureIcon.heightAnchor.constraint(equalToConstant: iconSize), + currentLocationButton.widthAnchor.constraint(equalToConstant: iconSize), + currentLocationButton.heightAnchor.constraint(equalToConstant: iconSize), + departureClearButton.widthAnchor.constraint(equalToConstant: iconSize), + departureClearButton.heightAnchor.constraint(equalToConstant: iconSize), + + arrivalIcon.widthAnchor.constraint(equalToConstant: iconSize), + arrivalIcon.heightAnchor.constraint(equalToConstant: iconSize), + arrivalClearButton.widthAnchor.constraint(equalToConstant: iconSize), + arrivalClearButton.heightAnchor.constraint(equalToConstant: iconSize), + + separator.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), + departureRow.heightAnchor.constraint(greaterThanOrEqualToConstant: 44), + arrivalRow.heightAnchor.constraint(greaterThanOrEqualToConstant: 44), + ]) + + departureButton.addAction(UIAction { [weak self] _ in + self?.onDepartureTapped?() + }, for: .touchUpInside) + + currentLocationButton.addAction(UIAction { [weak self] _ in + self?.onCurrentLocationTapped?() + }, for: .touchUpInside) + + departureClearButton.addAction(UIAction { [weak self] _ in + self?.onDepartureClear?() + }, for: .touchUpInside) + + arrivalButton.addAction(UIAction { [weak self] _ in + self?.onArrivalTapped?() + }, for: .touchUpInside) + + arrivalClearButton.addAction(UIAction { [weak self] _ in + self?.onArrivalClear?() + }, for: .touchUpInside) + } + +} + +// MARK - Sub view makers + +extension PlaceInputBox { + + private func makeClearButton(_ button: UIButton) { + button.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + button.tintColor = .tertiaryLabel + button.isHidden = true + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/RouteCard.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/RouteCard.swift new file mode 100644 index 0000000..5ee3551 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/RouteCard.swift @@ -0,0 +1,168 @@ +// +// RouteCard.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-22. +// + +import UIKit + +final class RouteCard: UIView { + + var onTap: (() -> Void)? + + private let route: Route + private let isSelected: Bool + + init(route: Route, isSelected: Bool) { + self.route = route + self.isSelected = isSelected + super.init(frame: .zero) + setUp() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUp() { + backgroundColor = .white + layer.cornerRadius = 12 + + let mainStack = UIStackView() + mainStack.axis = .vertical + mainStack.spacing = 12 + + mainStack.addArrangedSubview(makeHeaderRow()) + + for step in route.step { + mainStack.addArrangedSubview(makeStepView(step)) + } + + let radioView = makeRadioView() + + mainStack.translatesAutoresizingMaskIntoConstraints = false + radioView.translatesAutoresizingMaskIntoConstraints = false + addSubview(mainStack) + addSubview(radioView) + + NSLayoutConstraint.activate([ + mainStack.topAnchor.constraint(equalTo: topAnchor, constant: 16), + mainStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + mainStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -40), + mainStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16), + + radioView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + radioView.centerYAnchor.constraint(equalTo: centerYAnchor), + radioView.widthAnchor.constraint(equalToConstant: 24), + radioView.heightAnchor.constraint(equalToConstant: 24), + ]) + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapped)) + addGestureRecognizer(tapGesture) + } + + @objc private func tapped() { onTap?() } + + // MARK: - Header + + private func makeHeaderRow() -> UIView { + let durationLabel = UILabel() + durationLabel.text = formatDuration(route.arrivalTime.timeIntervalSince(route.departureTime) / 60) + durationLabel.font = .boldPreferredFont(forTextStyle: .headline) + + let timeRangeLabel = UILabel() + let depStr = route.departureTime.koreanTimeString + let arrStr = route.arrivalTime.koreanTimeString + timeRangeLabel.text = "\(depStr) - \(arrStr)" + timeRangeLabel.font = .preferredFont(forTextStyle: .footnote) + timeRangeLabel.textColor = .secondaryLabel + + let stack = UIStackView(arrangedSubviews: [durationLabel, timeRangeLabel]) + stack.axis = .vertical + stack.spacing = 2 + return stack + } + + // MARK: - Step + + private func makeStepView(_ step: RouteStep) -> UIView { + let container = UIView() + + let icon = UIImageView(image: UIImage(systemName: step.transportType.icon)) + icon.tintColor = step.transportType.color + icon.contentMode = .scaleAspectFit + + let nameLabel = UILabel() + nameLabel.text = "\(step.departurePoint.name) > \(step.destination.name)" + nameLabel.font = .boldPreferredFont(forTextStyle: .subheadline) + + let durationLabel = UILabel() + durationLabel.text = formatDuration(step.estimatedTime) + durationLabel.font = .preferredFont(forTextStyle: .footnote) + durationLabel.textColor = .secondaryLabel + + let iconSize: CGFloat = 16 + icon.translatesAutoresizingMaskIntoConstraints = false + nameLabel.translatesAutoresizingMaskIntoConstraints = false + durationLabel.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(icon) + container.addSubview(nameLabel) + container.addSubview(durationLabel) + + NSLayoutConstraint.activate([ + icon.leadingAnchor.constraint(equalTo: container.leadingAnchor), + icon.topAnchor.constraint(equalTo: container.topAnchor, constant: 2), + icon.widthAnchor.constraint(equalToConstant: iconSize), + icon.heightAnchor.constraint(equalToConstant: iconSize), + + nameLabel.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6), + nameLabel.topAnchor.constraint(equalTo: container.topAnchor), + nameLabel.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor), + + durationLabel.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor), + durationLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 2), + durationLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + return container + } + + // MARK: - Radio + + private func makeRadioView() -> UIView { + let outer = UIView() + outer.layer.cornerRadius = 12 + outer.layer.borderWidth = 2 + outer.layer.borderColor = isSelected ? UIColor.blue1.cgColor : UIColor.separator.cgColor + + if isSelected { + let inner = UIView() + inner.backgroundColor = .blue1 + inner.layer.cornerRadius = 7 + inner.translatesAutoresizingMaskIntoConstraints = false + outer.addSubview(inner) + NSLayoutConstraint.activate([ + inner.centerXAnchor.constraint(equalTo: outer.centerXAnchor), + inner.centerYAnchor.constraint(equalTo: outer.centerYAnchor), + inner.widthAnchor.constraint(equalToConstant: 14), + inner.heightAnchor.constraint(equalToConstant: 14), + ]) + } + + return outer + } + + // MARK: - Formatting + + private func formatDuration(_ minutes: Double) -> String { + let total = Int(minutes) + let hours = total / 60 + let mins = total % 60 + if hours > 0 { + return mins > 0 ? "\(hours)시간 \(mins)분" : "\(hours)시간" + } + return "\(mins)분" + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/TransportTypeSelectionBox.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/TransportTypeSelectionBox.swift new file mode 100644 index 0000000..641981a --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/TransportTypeSelectionBox.swift @@ -0,0 +1,92 @@ +// +// TransportTypeSelectionBox.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-22. +// + +import UIKit + +final class TransportTypeSelectionBox: UIView { + + var onTransportTypeSelected: ((TransportType) -> Void)? + + private var buttons: [(type: TransportType, button: UIButton)] = [] + private(set) var selectedType: TransportType = TransportType.allCases[0] + + init() { + super.init(frame: .zero) + setUp() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented — use init()") + } + + private func setUp() { + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 10 + stack.distribution = .fillEqually + + for type in TransportType.allCases { + let button = makeButton(for: type) + buttons.append((type: type, button: button)) + stack.addArrangedSubview(button) + + button.addAction(UIAction { [weak self] _ in + self?.selectedType = type + self?.updateButtonStyles() + self?.onTransportTypeSelected?(type) + }, for: .touchUpInside) + } + + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + updateButtonStyles() + } + + private func makeButton(for type: TransportType) -> UIButton { + var config = UIButton.Configuration.filled() + config.image = UIImage(systemName: type.icon) + config.title = type.name + config.imagePlacement = .top + config.imagePadding = 4 + config.cornerStyle = .fixed + config.background.cornerRadius = 12 + config.background.strokeWidth = 1.5 + config.background.strokeColor = type.color + config.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 8, bottom: 12, trailing: 8) + config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var out = incoming + out.font = .preferredFont(forTextStyle: .caption1) + return out + } + config.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration( + font: .preferredFont(forTextStyle: .body) + ) + return UIButton(configuration: config) + } + + private func updateButtonStyles() { + for (type, button) in buttons { + var config = button.configuration ?? .filled() + if type == selectedType { + config.baseBackgroundColor = type.color.withAlphaComponent(0.8) + config.baseForegroundColor = .white + } else { + config.baseBackgroundColor = .white + config.baseForegroundColor = type.color + } + button.configuration = config + } + } + +}