From 0122b75a4c0db2464c67598063afa13cfd0dcb51 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 02:38:55 +0900 Subject: [PATCH 01/31] =?UTF-8?q?RENAME:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=ED=8F=B4=EB=8D=94=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WhereAreYou/WhereAreYou/Domain/{ => Entity}/Appointment.swift | 0 .../WhereAreYou/Domain/{ => Entity}/LocationSharingScope.swift | 0 WhereAreYou/WhereAreYou/Domain/{ => Entity}/Place.swift | 0 WhereAreYou/WhereAreYou/Domain/{ => Entity}/PlaceType.swift | 0 WhereAreYou/WhereAreYou/Domain/{ => Entity}/Route.swift | 2 +- WhereAreYou/WhereAreYou/Domain/{ => Entity}/RouteStep.swift | 0 .../WhereAreYou/Domain/{ => Entity}/TransportType.swift | 3 +-- WhereAreYou/WhereAreYou/Domain/{ => Entity}/User.swift | 0 8 files changed, 2 insertions(+), 3 deletions(-) rename WhereAreYou/WhereAreYou/Domain/{ => Entity}/Appointment.swift (100%) rename WhereAreYou/WhereAreYou/Domain/{ => Entity}/LocationSharingScope.swift (100%) rename WhereAreYou/WhereAreYou/Domain/{ => Entity}/Place.swift (100%) rename WhereAreYou/WhereAreYou/Domain/{ => Entity}/PlaceType.swift (100%) rename WhereAreYou/WhereAreYou/Domain/{ => Entity}/Route.swift (97%) rename WhereAreYou/WhereAreYou/Domain/{ => Entity}/RouteStep.swift (100%) rename WhereAreYou/WhereAreYou/Domain/{ => Entity}/TransportType.swift (78%) rename WhereAreYou/WhereAreYou/Domain/{ => Entity}/User.swift (100%) 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 From d4984b6997b902e2511323b9c698936a3f446529 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 02:40:06 +0900 Subject: [PATCH 02/31] =?UTF-8?q?FEAT:=20=EC=9D=B4=EB=8F=99=EC=88=98?= =?UTF-8?q?=EB=8B=A8=EC=97=90=EC=84=9C=20=EC=9E=90=EC=A0=84=EA=B1=B0=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Extension/TransportType+.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 } } From d1bc918e4a802d3b07a61c77af1603cd9564a6ee Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 02:45:05 +0900 Subject: [PATCH 03/31] =?UTF-8?q?DESIGN:=20=EC=B6=9C=EB=B0=9C/=EB=8F=84?= =?UTF-8?q?=EC=B0=A9=20=EC=9E=A5=EC=86=8C=20=EC=9E=85=EB=A0=A5=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SubComponent/PlaceInputBox.swift | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/PlaceInputBox.swift diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/PlaceInputBox.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/PlaceInputBox.swift new file mode 100644 index 0000000..88e71fb --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/PlaceInputBox.swift @@ -0,0 +1,190 @@ +// +// 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 + ) + 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 + ) + 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 + + let arrivalRow = UIStackView(arrangedSubviews: [arrivalIcon, arrivalButton, arrivalClearButton]) + arrivalRow.axis = .horizontal + arrivalRow.spacing = 12 + arrivalRow.alignment = .center + + 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 + } + +} From fcec97446fc3255ab98473a7f9cdc762dc8f9488 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 02:45:29 +0900 Subject: [PATCH 04/31] =?UTF-8?q?DESIGN:=20=EC=9D=B4=EB=8F=99=EC=88=98?= =?UTF-8?q?=EB=8B=A8=20=EC=84=A0=ED=83=9D=20=EC=98=81=EC=97=AD=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TransportTypeSelectionBox.swift | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/TransportTypeSelectionBox.swift 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 + } + } + +} From 9c6387150ff5ac887607e00b44267d1c51c74d40 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 02:45:46 +0900 Subject: [PATCH 05/31] =?UTF-8?q?DESIGN:=20=EC=9D=B4=EB=8F=99=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=B9=B4=EB=93=9C=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteSearch/SubComponent/RouteCard.swift | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/RouteCard.swift diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/RouteCard.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/RouteCard.swift new file mode 100644 index 0000000..b423a23 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/RouteCard.swift @@ -0,0 +1,175 @@ +// +// 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 + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "ko_KR") + f.dateFormat = "HH:mm" + return f + }() + + 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 = Self.timeFormatter.string(from: route.departureTime) + let arrStr = Self.timeFormatter.string(from: route.arrivalTime) + 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)분" + } + +} From d635e4f9f386de9b84e106b2a70fecb3ff6805ad Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 02:46:20 +0900 Subject: [PATCH 06/31] =?UTF-8?q?DESIGN:=20=EB=82=A0=EC=A7=9C=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20view=20controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DepartureTimePickerViewController.swift | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift new file mode 100644 index 0000000..805fa25 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift @@ -0,0 +1,65 @@ +// +// DepartureTimePickerViewController.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-22. +// + +import UIKit + +final class DepartureTimePickerViewController: UIViewController { + + var onDateSelected: ((Date) -> Void)? + + 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 + ) + + 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.date = initialDate + + let stack = UIStackView(arrangedSubviews: [datePicker, confirmButton]) + stack.axis = .vertical + stack.spacing = 20 + + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + ]) + + confirmButton.addAction(UIAction { [weak self] _ in + guard let self else { return } + self.onDateSelected?(self.datePicker.date) + self.dismiss(animated: true) + }, for: .touchUpInside) + } + +} From 79a571647aa27b31c8be9141585c52fcec4dc485 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 02:46:43 +0900 Subject: [PATCH 07/31] =?UTF-8?q?FEAT:=20=EA=B2=BD=EB=A1=9C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20repository=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/RouteSearchRepository.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Domain/Repository/RouteSearchRepository.swift 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 + ) + +} From 89f2d7ebdf7961b75019a5b439775f49156f6cc5 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 02:47:01 +0900 Subject: [PATCH 08/31] =?UTF-8?q?FEAT:=20Mock=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/MockRouteSearchRepository.swift | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Data/MockRouteSearchRepository.swift diff --git a/WhereAreYou/WhereAreYou/Data/MockRouteSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/MockRouteSearchRepository.swift new file mode 100644 index 0000000..f49db1d --- /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: destination, destination: parking, 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: destination, destination: parking, 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 + ) + ] + } + } + +} From 76d6c090e09e9197e52f739d5219454225a35fea Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 02:47:18 +0900 Subject: [PATCH 09/31] =?UTF-8?q?FEAT:=20=EA=B2=BD=EB=A1=9C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20use=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/UseCase/SearchRoutesUseCase.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Domain/UseCase/SearchRoutesUseCase.swift 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 + ) + } + +} From ae98579c445d910d4bece93562ab06711bce7e55 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 02:55:29 +0900 Subject: [PATCH 10/31] =?UTF-8?q?DESIGN:=20=EA=B2=BD=EB=A1=9C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20view=20controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteSearchViewController.swift | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift new file mode 100644 index 0000000..8ed147b --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift @@ -0,0 +1,319 @@ +// +// RouteSearchViewController.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-22. +// + +import UIKit + +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 + private static let contentBottomPadding: CGFloat = 80 + + // MARK: - Scroll + + private let scrollView: UIScrollView = { + let sv = UIScrollView() + sv.showsVerticalScrollIndicator = false + sv.alwaysBounceVertical = true + return sv + }() + + private let contentStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + return stack + }() + + // 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 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 placeSearchOverlay: UIView? + + // MARK: - Date formatting + + private static let departureTimeFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "ko_KR") + f.dateFormat = "M월 d일 (E) a h:mm" + return f + }() + + // MARK: - Init + + private let initialDeparture: Place? + private let initialDestination: Place? + + init(departure: Place? = nil, destination: Place? = nil) { + 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() + } + + private func setUpInitialPlaces() { + if let dep = initialDeparture { + placeInputView.setDeparture(name: dep.name) + } + if let dest = initialDestination { + placeInputView.setArrival(name: dest.name) + } + } + + // MARK: - Layout + + private func setUpLayout() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + contentStack.translatesAutoresizingMaskIntoConstraints = false + selectRouteButton.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(scrollView) + view.addSubview(selectRouteButton) + scrollView.addSubview(contentStack) + + contentStack.isLayoutMarginsRelativeArrangement = true + contentStack.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: Self.contentTopPadding, + leading: Self.horizontalPadding, + bottom: Self.contentBottomPadding, + trailing: Self.horizontalPadding + ) + + let timeSectionRow = makeTimeSectionRow() + + [placeInputView, transportSectionLabel, transportSelector, + timeSectionRow, routeSectionLabel, emptyStateLabel, + loadingIndicator, routeCardsStack] + .forEach { contentStack.addArrangedSubview($0) } + + contentStack.setCustomSpacing(Self.sectionSpacing, after: placeInputView) + contentStack.setCustomSpacing(Self.sectionHeaderBottomSpacing, after: transportSectionLabel) + contentStack.setCustomSpacing(Self.sectionSpacing, after: transportSelector) + contentStack.setCustomSpacing(Self.sectionSpacing, after: timeSectionRow) + contentStack.setCustomSpacing(Self.sectionHeaderBottomSpacing, after: routeSectionLabel) + contentStack.setCustomSpacing(Self.sectionHeaderBottomSpacing, after: emptyStateLabel) + contentStack.setCustomSpacing(Self.sectionHeaderBottomSpacing, after: loadingIndicator) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + + 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), + ]) + + routeCardsStack.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 = { } + placeInputView.onArrivalTapped = { } + placeInputView.onCurrentLocationTapped = { } + placeInputView.onDepartureClear = { } + placeInputView.onArrivalClear = { } + + transportSelector.onTransportTypeSelected = { _ in } + + timeButton.addAction(UIAction { [weak self] _ in + self?.presentTimePicker() + }, for: .touchUpInside) + + selectRouteButton.addAction(UIAction { _ in + print("경로 선택 tapped") + }, for: .touchUpInside) + } + + // MARK: - ViewModel binding + + private func bindViewModel() { } + + // MARK: - Place search overlay + + private func presentPlaceSearchOverlay(title: String) { + let overlay = UIView() + overlay.backgroundColor = .black.withAlphaComponent(0.4) + overlay.translatesAutoresizingMaskIntoConstraints = false + + // TODO: 임시 카드 + let card = CardContainerView(headerStyle: .titleWithCloseButton(title)) + card.translatesAutoresizingMaskIntoConstraints = false + card.onClose = { [weak self] in + self?.dismissPlaceSearchOverlay() + } + + overlay.addSubview(card) + view.addSubview(overlay) + placeSearchOverlay = overlay + + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: view.topAnchor), + overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor), + overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + card.leadingAnchor.constraint(equalTo: overlay.leadingAnchor, constant: 24), + card.trailingAnchor.constraint(equalTo: overlay.trailingAnchor, constant: -24), + card.centerYAnchor.constraint(equalTo: overlay.centerYAnchor), + ]) + } + + private func dismissPlaceSearchOverlay() { + placeSearchOverlay?.removeFromSuperview() + placeSearchOverlay = nil + } + + // MARK: - Current location + + private func setCurrentLocationAsDeparture() { } + + // MARK: - Time picker + + private func presentTimePicker() { + let pickerViewController = DepartureTimePickerViewController(initialDate: .now) + pickerViewController.onDateSelected = { _ in } + + if let sheet = pickerViewController.sheetPresentationController { + sheet.detents = [.medium()] + sheet.prefersGrabberVisible = true + } + + present(pickerViewController, animated: true) + } + +} From 04ab0a350cf5b7605de46a12c83d0fd88cc30c49 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 03:42:08 +0900 Subject: [PATCH 11/31] =?UTF-8?q?FEAT:=20=EB=82=A0=EC=A7=9C=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=20=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WhereAreYou/Core/Date+Format.swift | 22 +++++++++++++++++++ .../RouteSearchViewController.swift | 7 ------ .../RouteSearch/SubComponent/RouteCard.swift | 11 ++-------- 3 files changed, 24 insertions(+), 16 deletions(-) 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/Presentation/RouteSearch/RouteSearchViewController.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift index 8ed147b..e6c0d2f 100644 --- a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift @@ -118,14 +118,7 @@ final class RouteSearchViewController: UIViewController { private var placeSearchOverlay: UIView? - // MARK: - Date formatting - private static let departureTimeFormatter: DateFormatter = { - let f = DateFormatter() - f.locale = Locale(identifier: "ko_KR") - f.dateFormat = "M월 d일 (E) a h:mm" - return f - }() // MARK: - Init diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/RouteCard.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/RouteCard.swift index b423a23..5ee3551 100644 --- a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/RouteCard.swift +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/RouteCard.swift @@ -14,13 +14,6 @@ final class RouteCard: UIView { private let route: Route private let isSelected: Bool - private static let timeFormatter: DateFormatter = { - let f = DateFormatter() - f.locale = Locale(identifier: "ko_KR") - f.dateFormat = "HH:mm" - return f - }() - init(route: Route, isSelected: Bool) { self.route = route self.isSelected = isSelected @@ -79,8 +72,8 @@ final class RouteCard: UIView { durationLabel.font = .boldPreferredFont(forTextStyle: .headline) let timeRangeLabel = UILabel() - let depStr = Self.timeFormatter.string(from: route.departureTime) - let arrStr = Self.timeFormatter.string(from: route.arrivalTime) + let depStr = route.departureTime.koreanTimeString + let arrStr = route.arrivalTime.koreanTimeString timeRangeLabel.text = "\(depStr) - \(arrStr)" timeRangeLabel.font = .preferredFont(forTextStyle: .footnote) timeRangeLabel.textColor = .secondaryLabel From c59e8f3afacf9321d3dc902a741616de9248c4c8 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 04:43:00 +0900 Subject: [PATCH 12/31] =?UTF-8?q?FEAT:=20=ED=98=84=EC=9E=AC=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=ED=83=90=EC=83=89=20=ED=9D=90=EB=A6=84=20(use=20ca?= =?UTF-8?q?se,=20repository,=20mock=20data)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/MockLocationRepository.swift | 18 +++++++++++++++++ .../Repository/LocationRepository.swift | 12 +++++++++++ .../UseCase/GetCurrentLocationUseCase.swift | 20 +++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Data/MockLocationRepository.swift create mode 100644 WhereAreYou/WhereAreYou/Domain/Repository/LocationRepository.swift create mode 100644 WhereAreYou/WhereAreYou/Domain/UseCase/GetCurrentLocationUseCase.swift 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/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/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) + } + +} From 9c55de3fdf728e8bcddec207f9df0f2ca090c026 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 04:43:29 +0900 Subject: [PATCH 13/31] =?UTF-8?q?FEAT:=20=EC=9E=A5=EC=86=8C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=ED=9D=90=EB=A6=84=20(use=20case,=20repository,=20m?= =?UTF-8?q?ock=20data)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/MockPlaceSearchRepository.swift | 52 +++++++++++++++++++ .../Repository/PlaceSearchRepository.swift | 15 ++++++ .../Domain/UseCase/SearchPlacesUseCase.swift | 23 ++++++++ 3 files changed, 90 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift create mode 100644 WhereAreYou/WhereAreYou/Domain/Repository/PlaceSearchRepository.swift create mode 100644 WhereAreYou/WhereAreYou/Domain/UseCase/SearchPlacesUseCase.swift diff --git a/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift new file mode 100644 index 0000000..3cddedd --- /dev/null +++ b/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift @@ -0,0 +1,52 @@ +// +// 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 = Self.mockPlaces + } else { + results = Self.mockPlaces.filter { + $0.name.localizedCaseInsensitiveContains(trimmed) || + $0.address.localizedCaseInsensitiveContains(trimmed) + } + } + + 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/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/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) + } + +} From 80c20c88a66cd515f695e953c6cc80fbd5b84b27 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 04:57:42 +0900 Subject: [PATCH 14/31] =?UTF-8?q?RENAME:=20=EC=9E=A5=EC=86=8C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=B9=B4=EB=93=9C=20=ED=8F=B4=EB=8D=94=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SearchPlaceCard/{ => SubComponent}/FilterTagBox.swift | 0 .../Component/SearchPlaceCard/{ => SubComponent}/PlaceCell.swift | 0 .../SearchPlaceCard/{ => SubComponent}/PlaceTagCapsule.swift | 0 .../Component/SearchPlaceCard/{ => SubComponent}/SearchBar.swift | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/{ => SubComponent}/FilterTagBox.swift (100%) rename WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/{ => SubComponent}/PlaceCell.swift (100%) rename WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/{ => SubComponent}/PlaceTagCapsule.swift (100%) rename WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/{ => SubComponent}/SearchBar.swift (100%) 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 From 748531d7300444de1c66f8e88a5687ae9a2ee689 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 04:58:49 +0900 Subject: [PATCH 15/31] =?UTF-8?q?FEAT:=20=EC=9E=A5=EC=86=8C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20view=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SearchPlaceCardViewModel.swift | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift new file mode 100644 index 0000000..17cc1ed --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift @@ -0,0 +1,59 @@ +// +// 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] = [] + + private var allPlaces: [Place] = [] + private let searchPlacesUseCase: SearchPlacesUseCase + + init(searchPlacesUseCase: SearchPlacesUseCase) { + self.searchPlacesUseCase = searchPlacesUseCase + } + + func search(keyword: String) { + searchPlacesUseCase.execute(keyword: keyword) { [weak self] result in + guard let self else { return } + 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) } + } + } + +} From f9eb930512ca123e80d6db6d5a2506f03819f56e Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 04:59:56 +0900 Subject: [PATCH 16/31] =?UTF-8?q?FEAT:=20=EC=9E=A5=EC=86=8C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=B9=B4=EB=93=9C=20=EC=8B=A4=EC=A0=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=A9=ED=96=A5=EC=97=90=20=EB=A7=9E=EA=B2=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SearchPlaceCard/SearchPlaceCard.swift | 84 +++++++++++++------ 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift index b406b2c..8ced4a3 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift @@ -10,7 +10,7 @@ import UIKit final class SearchPlaceCard: UIView { var onSearchBarTap: (() -> Void)? - var onSearchTap: (() -> Void)? + var onSearchTap: ((String) -> Void)? var onResetFilterTap: (() -> Void)? var onFilterTap: ((PlaceType) -> Void)? @@ -35,12 +35,18 @@ final class SearchPlaceCard: UIView { return stack }() - private let places: [PlaceInfo] + 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) { self.selectionButtonTitle = selectionButtonTitle super.init(frame: .zero) setUpLayout() @@ -48,7 +54,7 @@ final class SearchPlaceCard: UIView { } 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 +73,28 @@ 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 + resultScrollView.isHidden = true } - private func setUpResultScrollView() { + private func setUpResultScrollViewConstraints() { resultScrollView.translatesAutoresizingMaskIntoConstraints = false resultStack.translatesAutoresizingMaskIntoConstraints = false resultScrollView.addSubview(resultStack) 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) ]) - - 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 - } } private static func makeDivider() -> UIView { @@ -111,14 +107,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 +124,45 @@ final class SearchPlaceCard: UIView { // MARK: - Public Configuration + func updatePlaces(_ newPlaces: [PlaceInfo]) { + places = newPlaces + rebuildResultCells() + } + func configureSelectedFilters(_ selected: [PlaceType]) { filterSection.configure(selected: selected) } + // MARK: - Result Cells + + private func rebuildResultCells() { + resultStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + cells = [] + + guard !places.isEmpty else { + // TODO: 장소 검색 API 확인 후 대체 텍스트 설정 필요 + resultDivider.isHidden = true + resultScrollView.isHidden = true + return + } + + resultDivider.isHidden = false + resultScrollView.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 { From 97a7e0d5ec03db70094224fd31280f76ea2e5f71 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 05:00:18 +0900 Subject: [PATCH 17/31] =?UTF-8?q?DESIGN:=20=EC=9E=A5=EC=86=8C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20view=20controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SearchPlaceCardViewController.swift | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift new file mode 100644 index 0000000..f0023a4 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift @@ -0,0 +1,116 @@ +// +// SearchPlaceCardViewController.swift +// WhereAreYou +// +// Created by 이상유 on 2026-07-24. +// + +import UIKit +import Combine + +final class SearchPlaceCardViewController: UIViewController { + + var onPlaceSelected: ((Place) -> Void)? + + private let viewModel: SearchPlaceCardViewModel + private var cancellables = Set() + + private var searchCard: SearchPlaceCard + private var dimView = UIView() + + init(viewModel: SearchPlaceCardViewModel, selectionButtonTitle: String) { + self.viewModel = viewModel + self.searchCard = SearchPlaceCard(selectionButtonTitle: selectionButtonTitle) + super.init(nibName: nil, bundle: nil) + 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() { + dimView.backgroundColor = .black.withAlphaComponent(0.4) + dimView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(dimView) + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dimTapped(_:))) + dimView.addGestureRecognizer(tapGesture) + + searchCard.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(searchCard) + + NSLayoutConstraint.activate([ + dimView.topAnchor.constraint(equalTo: view.topAnchor), + dimView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + dimView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + dimView.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), + ]) + } + + // 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) + self.dismiss(animated: true) + } + + searchCard.onFilterTap = { [weak self] placeType in + self?.viewModel.toggleFilter(placeType) + } + + searchCard.onResetFilterTap = { [weak self] in + self?.viewModel.resetFilters() + } + } + + @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) + } + +} From 44aa18c15fa1e12dc8cb1a03ec5a966811db6175 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 05:11:38 +0900 Subject: [PATCH 18/31] =?UTF-8?q?FEAT:=20=EA=B2=BD=EB=A1=9C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20view=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteSearch/RouteSearchViewModel.swift | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift new file mode 100644 index 0000000..127f45c --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift @@ -0,0 +1,102 @@ +// +// 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 } + 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 } + self.isLoading = false + self.selectedRouteIndex = 0 + + switch result { + case .success(let routes): + self.routes = routes + case .failure: + // TODO: 길찾기 API 확인 후 검색 실패 텍스트 설정 필요 + self.routes = [] + } + } + } + +} From 92daaa6e388fdbe20623c63e194c809f8f49c46f Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 05:19:14 +0900 Subject: [PATCH 19/31] =?UTF-8?q?DESIGN:=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteSearchViewController.swift | 94 ++++++++++--------- 1 file changed, 49 insertions(+), 45 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift index e6c0d2f..0f9e353 100644 --- a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift @@ -17,22 +17,8 @@ final class RouteSearchViewController: UIViewController { private static let bottomButtonHeight: CGFloat = 50 private static let bottomButtonBottomPadding: CGFloat = 16 private static let contentTopPadding: CGFloat = 16 - private static let contentBottomPadding: CGFloat = 80 - // MARK: - Scroll - private let scrollView: UIScrollView = { - let sv = UIScrollView() - sv.showsVerticalScrollIndicator = false - sv.alwaysBounceVertical = true - return sv - }() - - private let contentStack: UIStackView = { - let stack = UIStackView() - stack.axis = .vertical - return stack - }() // MARK: - Section 1: Place input @@ -98,6 +84,13 @@ final class RouteSearchViewController: UIViewController { 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 @@ -159,48 +152,59 @@ final class RouteSearchViewController: UIViewController { // MARK: - Layout private func setUpLayout() { - scrollView.translatesAutoresizingMaskIntoConstraints = false - contentStack.translatesAutoresizingMaskIntoConstraints = false - selectRouteButton.translatesAutoresizingMaskIntoConstraints = false - - view.addSubview(scrollView) - view.addSubview(selectRouteButton) - scrollView.addSubview(contentStack) + let topStack = UIStackView() + topStack.axis = .vertical + topStack.translatesAutoresizingMaskIntoConstraints = false - contentStack.isLayoutMarginsRelativeArrangement = true - contentStack.directionalLayoutMargins = NSDirectionalEdgeInsets( + topStack.isLayoutMarginsRelativeArrangement = true + topStack.directionalLayoutMargins = NSDirectionalEdgeInsets( top: Self.contentTopPadding, leading: Self.horizontalPadding, - bottom: Self.contentBottomPadding, + bottom: 0, trailing: Self.horizontalPadding ) let timeSectionRow = makeTimeSectionRow() [placeInputView, transportSectionLabel, transportSelector, - timeSectionRow, routeSectionLabel, emptyStateLabel, - loadingIndicator, routeCardsStack] - .forEach { contentStack.addArrangedSubview($0) } - - contentStack.setCustomSpacing(Self.sectionSpacing, after: placeInputView) - contentStack.setCustomSpacing(Self.sectionHeaderBottomSpacing, after: transportSectionLabel) - contentStack.setCustomSpacing(Self.sectionSpacing, after: transportSelector) - contentStack.setCustomSpacing(Self.sectionSpacing, after: timeSectionRow) - contentStack.setCustomSpacing(Self.sectionHeaderBottomSpacing, after: routeSectionLabel) - contentStack.setCustomSpacing(Self.sectionHeaderBottomSpacing, after: emptyStateLabel) - contentStack.setCustomSpacing(Self.sectionHeaderBottomSpacing, after: loadingIndicator) + 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: routeSectionLabel) + + [routeScrollView, routeCardsStack, selectRouteButton, emptyStateLabel, loadingIndicator] + .forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + + routeScrollView.addSubview(routeCardsStack) + + [topStack, emptyStateLabel, loadingIndicator, routeScrollView, selectRouteButton] + .forEach { view.addSubview($0) } NSLayoutConstraint.activate([ - scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + topStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + topStack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + topStack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + 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), + + loadingIndicator.topAnchor.constraint(equalTo: topStack.bottomAnchor, constant: Self.sectionSpacing), + loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + + 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), - contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - contentStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - contentStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), - contentStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), - contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + 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), @@ -208,7 +212,7 @@ final class RouteSearchViewController: UIViewController { selectRouteButton.heightAnchor.constraint(equalToConstant: Self.bottomButtonHeight), ]) - routeCardsStack.isHidden = true + routeScrollView.isHidden = true loadingIndicator.isHidden = true } From ae9c2463d9526017a26f762617b2029fab67644c Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 05:25:28 +0900 Subject: [PATCH 20/31] =?UTF-8?q?FEAT:=20=EA=B2=BD=EB=A1=9C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20view=20model=20=EB=B0=94=EC=9D=B8=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteSearchViewController.swift | 134 +++++++++++++----- 1 file changed, 97 insertions(+), 37 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift index 0f9e353..3bc5fcf 100644 --- a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift @@ -6,6 +6,7 @@ // import UIKit +import Combine final class RouteSearchViewController: UIViewController { @@ -18,7 +19,10 @@ final class RouteSearchViewController: UIViewController { 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 @@ -118,7 +122,8 @@ final class RouteSearchViewController: UIViewController { private let initialDeparture: Place? private let initialDestination: Place? - init(departure: Place? = nil, destination: Place? = nil) { + init(viewModel: RouteSearchViewModel, departure: Place? = nil, destination: Place? = nil) { + self.viewModel = viewModel self.initialDeparture = departure self.initialDestination = destination super.init(nibName: nil, bundle: nil) @@ -142,10 +147,10 @@ final class RouteSearchViewController: UIViewController { private func setUpInitialPlaces() { if let dep = initialDeparture { - placeInputView.setDeparture(name: dep.name) + viewModel.setDeparture(dep) } if let dest = initialDestination { - placeInputView.setArrival(name: dest.name) + viewModel.setDestination(dest) } } @@ -241,11 +246,19 @@ final class RouteSearchViewController: UIViewController { private func setUpActions() { placeInputView.onDepartureTapped = { } placeInputView.onArrivalTapped = { } - placeInputView.onCurrentLocationTapped = { } - placeInputView.onDepartureClear = { } - placeInputView.onArrivalClear = { } + 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 = { _ in } + transportSelector.onTransportTypeSelected = { [weak self] type in + self?.viewModel.setTransportType(type) + } timeButton.addAction(UIAction { [weak self] _ in self?.presentTimePicker() @@ -258,41 +271,86 @@ final class RouteSearchViewController: UIViewController { // MARK: - ViewModel binding - private func bindViewModel() { } + 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 - // MARK: - Place search overlay + private func updateRouteSection(routes: [Route], selectedIndex: Int) { + routeCardsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } - private func presentPlaceSearchOverlay(title: String) { - let overlay = UIView() - overlay.backgroundColor = .black.withAlphaComponent(0.4) - overlay.translatesAutoresizingMaskIntoConstraints = false + let hasPlaces = viewModel.departure != nil && viewModel.destination != nil + + loadingIndicator.isHidden = true + loadingIndicator.stopAnimating() - // TODO: 임시 카드 - let card = CardContainerView(headerStyle: .titleWithCloseButton(title)) - card.translatesAutoresizingMaskIntoConstraints = false - card.onClose = { [weak self] in - self?.dismissPlaceSearchOverlay() + if !hasPlaces { + emptyStateLabel.isHidden = false + routeScrollView.isHidden = true + return } - overlay.addSubview(card) - view.addSubview(overlay) - placeSearchOverlay = overlay + emptyStateLabel.isHidden = true - NSLayoutConstraint.activate([ - overlay.topAnchor.constraint(equalTo: view.topAnchor), - overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor), - overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor), - overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - card.leadingAnchor.constraint(equalTo: overlay.leadingAnchor, constant: 24), - card.trailingAnchor.constraint(equalTo: overlay.trailingAnchor, constant: -24), - card.centerYAnchor.constraint(equalTo: overlay.centerYAnchor), - ]) - } + if routes.isEmpty { + // TODO: 길찾기 API 확인 후 대체 텍스트 설정 필요 + routeScrollView.isHidden = true + return + } - private func dismissPlaceSearchOverlay() { - placeSearchOverlay?.removeFromSuperview() - placeSearchOverlay = nil + 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: - Current location @@ -302,8 +360,10 @@ final class RouteSearchViewController: UIViewController { // MARK: - Time picker private func presentTimePicker() { - let pickerViewController = DepartureTimePickerViewController(initialDate: .now) - pickerViewController.onDateSelected = { _ in } + let pickerViewController = DepartureTimePickerViewController(initialDate: viewModel.departureTime) + pickerViewController.onDateSelected = { [weak self] date in + self?.viewModel.setDepartureTime(date) + } if let sheet = pickerViewController.sheetPresentationController { sheet.detents = [.medium()] From f8b6f035dbba5786364bbd3e344b5b5f2d0c70cb Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 05:27:11 +0900 Subject: [PATCH 21/31] =?UTF-8?q?FEAT:=20=EA=B2=BD=EB=A1=9C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=ED=99=94=EB=A9=B4=EC=97=90=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=B9=B4=EB=93=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteSearchViewController.swift | 47 ++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift index 3bc5fcf..fdb742c 100644 --- a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift @@ -113,9 +113,12 @@ final class RouteSearchViewController: UIViewController { // MARK: - Overlay - private var placeSearchOverlay: UIView? - + private var placeSearchTarget: PlaceSearchTarget = .departure + private enum PlaceSearchTarget { + case departure + case arrival + } // MARK: - Init @@ -145,6 +148,11 @@ final class RouteSearchViewController: UIViewController { setUpInitialPlaces() } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.setDepartureTime(Date()) + } + private func setUpInitialPlaces() { if let dep = initialDeparture { viewModel.setDeparture(dep) @@ -244,8 +252,12 @@ final class RouteSearchViewController: UIViewController { // MARK: - Actions private func setUpActions() { - placeInputView.onDepartureTapped = { } - placeInputView.onArrivalTapped = { } + 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() } @@ -353,9 +365,32 @@ final class RouteSearchViewController: UIViewController { } } - // MARK: - Current location + // MARK: - Place search + + private func presentPlaceSearch(target: PlaceSearchTarget) { + placeSearchTarget = target - private func setCurrentLocationAsDeparture() { } + let buttonTitle = target == .departure ? "출발지로" : "도착지로" + // TODO: DIContainer로 대체 + let searchPlacesUseCase = SearchPlacesUseCase(repository: MockPlaceSearchRepository()) + let searchViewModel = SearchPlaceCardViewModel(searchPlacesUseCase: searchPlacesUseCase) + let searchVC = SearchPlaceCardViewController( + viewModel: searchViewModel, + selectionButtonTitle: buttonTitle + ) + + 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 From 77e3167777bd7175208ef1d33a5cfbe9afc7da0a Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 05:48:59 +0900 Subject: [PATCH 22/31] =?UTF-8?q?DESIGN:=20=EC=9E=A5=EC=86=8C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=B9=B4=EB=93=9C=20=ED=99=9C=EC=9A=A9=20=EB=B0=A9?= =?UTF-8?q?=EB=B2=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SearchPlaceCard/SearchPlaceCard.swift | 7 +- .../SearchPlaceCardViewController.swift | 85 +++++++++++++------ .../RouteSearchViewController.swift | 3 +- 3 files changed, 68 insertions(+), 27 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift index 8ced4a3..c118574 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift @@ -16,8 +16,9 @@ final class SearchPlaceCard: UIView { 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() @@ -46,11 +47,13 @@ final class SearchPlaceCard: UIView { private let selectionButtonTitle: String private var cells: [PlaceCell] = [] - init(selectionButtonTitle: String) { + 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) { diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift index f0023a4..c478e9d 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift @@ -10,20 +10,40 @@ 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() + private var dimView: UIView? - init(viewModel: SearchPlaceCardViewModel, selectionButtonTitle: String) { + init(viewModel: SearchPlaceCardViewModel, selectionButtonTitle: String, style: Style = .plain) { + self.style = style self.viewModel = viewModel - self.searchCard = SearchPlaceCard(selectionButtonTitle: selectionButtonTitle) + + 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) - modalPresentationStyle = .overFullScreen - modalTransitionStyle = .crossDissolve + + if case .dimmed = style { + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } } required init?(coder: NSCoder) { @@ -40,27 +60,40 @@ final class SearchPlaceCardViewController: UIViewController { // MARK: - Layout private func setUpLayout() { - dimView.backgroundColor = .black.withAlphaComponent(0.4) - dimView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(dimView) - - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dimTapped(_:))) - dimView.addGestureRecognizer(tapGesture) - searchCard.translatesAutoresizingMaskIntoConstraints = false view.addSubview(searchCard) - NSLayoutConstraint.activate([ - dimView.topAnchor.constraint(equalTo: view.topAnchor), - dimView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - dimView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - dimView.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), - ]) + 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 @@ -73,7 +106,7 @@ final class SearchPlaceCardViewController: UIViewController { searchCard.onPlaceTap = { [weak self] placeInfo in guard let self, let place = self.viewModel.place(for: placeInfo) else { return } self.onPlaceSelected?(place) - self.dismiss(animated: true) + if case .dimmed = self.style { self.dismiss(animated: true) } } searchCard.onFilterTap = { [weak self] placeType in @@ -83,6 +116,10 @@ final class SearchPlaceCardViewController: UIViewController { searchCard.onResetFilterTap = { [weak self] in self?.viewModel.resetFilters() } + + searchCard.onClose = { [weak self] in + self?.dismiss(animated: true) + } } @objc private func dimTapped(_ gesture: UITapGestureRecognizer) { diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift index fdb742c..5dc9837 100644 --- a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift @@ -376,7 +376,8 @@ final class RouteSearchViewController: UIViewController { let searchViewModel = SearchPlaceCardViewModel(searchPlacesUseCase: searchPlacesUseCase) let searchVC = SearchPlaceCardViewController( viewModel: searchViewModel, - selectionButtonTitle: buttonTitle + selectionButtonTitle: buttonTitle, + style: .dimmed(title: "장소 검색") ) searchVC.onPlaceSelected = { [weak self] place in From d90208d11fc0dc89caf467c7be72e9bf9127017d Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 05:56:12 +0900 Subject: [PATCH 23/31] =?UTF-8?q?DESIGN:=20=EC=84=B9=EC=85=98=20=EA=B0=84?= =?UTF-8?q?=EA=B2=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/RouteSearch/RouteSearchViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift index 5dc9837..f333f89 100644 --- a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift @@ -186,6 +186,7 @@ final class RouteSearchViewController: UIViewController { 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] From 26bf55cf3ad94c822246b23b0311c9aff01a10bb Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 06:05:59 +0900 Subject: [PATCH 24/31] =?UTF-8?q?DOCS:=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=BD=94=EB=93=9C=20=EC=A3=BC=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteSearch/RouteSearchViewController.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift index f333f89..e06ae1d 100644 --- a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift @@ -165,6 +165,9 @@ final class RouteSearchViewController: UIViewController { // MARK: - Layout private func setUpLayout() { + + // 상단 고정 영역: 장소 입력 / 이동 수단 / 출발 시간 / 섹션 헤더들 + let topStack = UIStackView() topStack.axis = .vertical topStack.translatesAutoresizingMaskIntoConstraints = false @@ -189,6 +192,8 @@ final class RouteSearchViewController: UIViewController { topStack.setCustomSpacing(Self.sectionHeaderBottomSpacing, after: timeSectionRow) topStack.setCustomSpacing(Self.sectionHeaderBottomSpacing, after: routeSectionLabel) + // 경로 검색 결과 영역: 빈 상태 / 로딩 / 경로 카드 목록 + [routeScrollView, routeCardsStack, selectRouteButton, emptyStateLabel, loadingIndicator] .forEach { $0.translatesAutoresizingMaskIntoConstraints = false } @@ -198,28 +203,35 @@ final class RouteSearchViewController: UIViewController { .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), From b49556cfbc6fe6293cddfefff1d5948b4dff061d Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 06:16:50 +0900 Subject: [PATCH 25/31] =?UTF-8?q?FEAT:=20=EC=B6=9C=EB=B0=9C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EC=9D=84=20=EA=B3=BC=EA=B1=B0=EB=A1=9C=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=ED=95=98=EC=A7=80=20=EB=AA=BB=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteSearch/DepartureTimePickerViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift index 805fa25..f244376 100644 --- a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift @@ -40,6 +40,7 @@ final class DepartureTimePickerViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground + datePicker.minimumDate = Date() datePicker.date = initialDate let stack = UIStackView(arrangedSubviews: [datePicker, confirmButton]) From ecb9a7ef2c9a6670f79125bc21a387e04f1d68bd Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 17:25:28 +0900 Subject: [PATCH 26/31] =?UTF-8?q?DESIGN:=20=EC=9E=A5=EC=86=8C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B2=B0=EA=B3=BC=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EB=8C=80=EC=B2=B4=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SearchPlaceCard/SearchPlaceCard.swift | 29 +++++++++++++++---- .../SearchPlaceCardViewController.swift | 7 +++++ .../SearchPlaceCardViewModel.swift | 10 +++++-- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift index c118574..20e6ff0 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift @@ -36,6 +36,15 @@ final class SearchPlaceCard: UIView { return stack }() + private let emptyResultLabel: UILabel = { + let label = UILabel() + label.text = "장소를 검색해주세요." + label.font = .preferredFont(forTextStyle: .body) + label.textColor = .secondaryLabel + label.textAlignment = .center + return label + }() + private var resultDivider = { let divider = UIView() divider.backgroundColor = .separator @@ -82,21 +91,28 @@ final class SearchPlaceCard: UIView { setUpResultScrollViewConstraints() resultDivider.isHidden = true - resultScrollView.isHidden = true + emptyResultLabel.isHidden = false } 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), + + emptyResultLabel.centerXAnchor.constraint(equalTo: resultScrollView.frameLayoutGuide.centerXAnchor), + emptyResultLabel.centerYAnchor.constraint(equalTo: resultScrollView.frameLayoutGuide.centerYAnchor), ]) } @@ -136,6 +152,10 @@ final class SearchPlaceCard: UIView { filterSection.configure(selected: selected) } + func updateEmptyResultMessage(hasSearched: Bool) { + emptyResultLabel.text = hasSearched ? "검색 결과가 존재하지 않습니다." : "장소를 검색해주세요." + } + // MARK: - Result Cells private func rebuildResultCells() { @@ -143,14 +163,13 @@ final class SearchPlaceCard: UIView { cells = [] guard !places.isEmpty else { - // TODO: 장소 검색 API 확인 후 대체 텍스트 설정 필요 + emptyResultLabel.isHidden = false resultDivider.isHidden = true - resultScrollView.isHidden = true return } + emptyResultLabel.isHidden = true resultDivider.isHidden = false - resultScrollView.isHidden = false cells = places.map { place in let cell = PlaceCell(place, buttonText: selectionButtonTitle) diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift index c478e9d..eee23b2 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewController.swift @@ -148,6 +148,13 @@ final class SearchPlaceCardViewController: UIViewController { 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 index 17cc1ed..55ffeea 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCardViewModel.swift @@ -12,6 +12,7 @@ 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 @@ -21,11 +22,14 @@ final class SearchPlaceCardViewModel { } func search(keyword: String) { + hasSearched = true searchPlacesUseCase.execute(keyword: keyword) { [weak self] result in guard let self else { return } - if case .success(let places) = result { - self.allPlaces = places - self.applyFilter() + DispatchQueue.main.async { + if case .success(let places) = result { + self.allPlaces = places + self.applyFilter() + } } } } From c45c1afee234a020493ca89547c85e2a14374a79 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 17:30:25 +0900 Subject: [PATCH 27/31] =?UTF-8?q?FIX:=20mock=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EC=9D=98=EB=8F=84=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WhereAreYou/Data/MockPlaceSearchRepository.swift | 7 ++----- .../WhereAreYou/Data/MockRouteSearchRepository.swift | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift index 3cddedd..cd4f755 100644 --- a/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/MockPlaceSearchRepository.swift @@ -17,12 +17,9 @@ final class MockPlaceSearchRepository: PlaceSearchRepository { let results: [Place] if trimmed.isEmpty { - results = Self.mockPlaces + results = [] } else { - results = Self.mockPlaces.filter { - $0.name.localizedCaseInsensitiveContains(trimmed) || - $0.address.localizedCaseInsensitiveContains(trimmed) - } + results = Self.mockPlaces } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { diff --git a/WhereAreYou/WhereAreYou/Data/MockRouteSearchRepository.swift b/WhereAreYou/WhereAreYou/Data/MockRouteSearchRepository.swift index f49db1d..08de48f 100644 --- a/WhereAreYou/WhereAreYou/Data/MockRouteSearchRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/MockRouteSearchRepository.swift @@ -50,7 +50,7 @@ final class MockRouteSearchRepository: RouteSearchRepository { arrivalTime: departureTime.addingTimeInterval(285 * 60), step: [ RouteStep(departurePoint: departure, destination: parking, estimatedTime: 10, distance: 0.5, transportType: .walk), - RouteStep(departurePoint: destination, destination: parking, estimatedTime: 260, distance: 280, transportType: .car), + 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 @@ -60,7 +60,7 @@ final class MockRouteSearchRepository: RouteSearchRepository { arrivalTime: departureTime.addingTimeInterval(285 * 60), step: [ RouteStep(departurePoint: departure, destination: parking, estimatedTime: 10, distance: 0.5, transportType: .walk), - RouteStep(departurePoint: destination, destination: parking, estimatedTime: 260, distance: 300, transportType: .car), + 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 From 23b838aa847af8375ba6a7feef2d401336188e61 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 17:31:14 +0900 Subject: [PATCH 28/31] =?UTF-8?q?FIX:=20UI=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EC=9D=B8=20=EC=8A=A4=EB=A0=88=EB=93=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteSearch/RouteSearchViewModel.swift | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift index 127f45c..450e73b 100644 --- a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewModel.swift @@ -57,15 +57,17 @@ final class RouteSearchViewModel { func fetchCurrentLocation() { getCurrentLocationUseCase.execute { [weak self] result in guard let self else { return } - if case .success(let coordinate) = result { - let place = Place( - id: "current_location", - name: "현재 위치", - address: "", - coordinate: coordinate, - type: .other - ) - self.setDeparture(place) + DispatchQueue.main.async { + if case .success(let coordinate) = result { + let place = Place( + id: "current_location", + name: "현재 위치", + address: "", + coordinate: coordinate, + type: .other + ) + self.setDeparture(place) + } } } } @@ -86,15 +88,17 @@ final class RouteSearchViewModel { transportType: selectedTransportType ) { [weak self] result in guard let self else { return } - self.isLoading = false - self.selectedRouteIndex = 0 - - switch result { - case .success(let routes): - self.routes = routes - case .failure: - // TODO: 길찾기 API 확인 후 검색 실패 텍스트 설정 필요 - self.routes = [] + DispatchQueue.main.async { + self.isLoading = false + self.selectedRouteIndex = 0 + + switch result { + case .success(let routes): + self.routes = routes + case .failure: + // TODO: 길찾기 API 확인 후 검색 실패 텍스트 설정 필요 + self.routes = [] + } } } } From c1afc08301c98e378c04c8217d309097b063db0c Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 17:45:17 +0900 Subject: [PATCH 29/31] =?UTF-8?q?DESIGN:=20=EC=B6=9C=EB=B0=9C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=84=A4=EC=A0=95=20=EC=8B=9C=ED=8A=B8=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DepartureTimePickerViewController.swift | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift index f244376..c4143d5 100644 --- a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/DepartureTimePickerViewController.swift @@ -11,6 +11,14 @@ 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 @@ -23,7 +31,8 @@ final class DepartureTimePickerViewController: UIViewController { title: "확인", background: .blue1, tint: .white, - font: .body + font: .body, + edgeInsets: NSDirectionalEdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0) ) private let initialDate: Date @@ -43,17 +52,26 @@ final class DepartureTimePickerViewController: UIViewController { datePicker.minimumDate = Date() datePicker.date = initialDate - let stack = UIStackView(arrangedSubviews: [datePicker, confirmButton]) - stack.axis = .vertical - stack.spacing = 20 + headerLabel.translatesAutoresizingMaskIntoConstraints = false + datePicker.translatesAutoresizingMaskIntoConstraints = false + confirmButton.translatesAutoresizingMaskIntoConstraints = false - stack.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(stack) + view.addSubview(headerLabel) + view.addSubview(datePicker) + view.addSubview(confirmButton) NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), - stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), - stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + 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 From ee6b4ccc4281a0e7ffc852918ed6265259b586fd Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 17:47:27 +0900 Subject: [PATCH 30/31] =?UTF-8?q?DESIGN:=20=EC=9E=A5=EC=86=8C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B2=B0=EA=B3=BC=EC=9D=98=20emptyResultLabel=20?= =?UTF-8?q?=ED=8F=B0=ED=8A=B8=20=ED=81=AC=EA=B8=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Component/SearchPlaceCard/SearchPlaceCard.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift index 20e6ff0..ee313d3 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Component/SearchPlaceCard/SearchPlaceCard.swift @@ -39,7 +39,7 @@ final class SearchPlaceCard: UIView { private let emptyResultLabel: UILabel = { let label = UILabel() label.text = "장소를 검색해주세요." - label.font = .preferredFont(forTextStyle: .body) + label.font = .preferredFont(forTextStyle: .subheadline) label.textColor = .secondaryLabel label.textAlignment = .center return label From bf8b0eed5d42ebc8d1966a4973813412782b5052 Mon Sep 17 00:00:00 2001 From: Sang Yu Lee Date: Fri, 24 Jul 2026 17:53:38 +0900 Subject: [PATCH 31/31] =?UTF-8?q?DESIGN:=20=EC=B6=9C=EB=B0=9C/=EB=8F=84?= =?UTF-8?q?=EC=B0=A9=20=EC=9E=A5=EC=86=8C=20=EC=9E=85=EB=A0=A5=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=98=81=EC=97=AD=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RouteSearch/SubComponent/PlaceInputBox.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/PlaceInputBox.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/PlaceInputBox.swift index 88e71fb..9ef73ce 100644 --- a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/PlaceInputBox.swift +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/SubComponent/PlaceInputBox.swift @@ -27,7 +27,8 @@ final class PlaceInputBox: UIView { title: "출발", background: .clear, tint: .placeholderText, - font: .body + font: .body, + edgeInsets: NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 0) ) button.contentHorizontalAlignment = .leading return button @@ -60,7 +61,8 @@ final class PlaceInputBox: UIView { title: "도착", background: .clear, tint: .placeholderText, - font: .body + font: .body, + edgeInsets: NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 0) ) button.contentHorizontalAlignment = .leading return button @@ -119,11 +121,15 @@ final class PlaceInputBox: UIView { 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