diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 3250fe7..7574ef8 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -151,11 +151,17 @@ final class AppDIContainer { let locationService = CoreLocationServiceAdapter() let getCurrentLocation: any GetCurrentLocationUseCase = DefaultGetCurrentLocationUseCase(locationService: locationService) + // 원탭 칩(Phase 18) — 검색과 홈이 같은 인스턴스를 봐야 검색의 저장·삭제가 + // 칩에 그대로 비친다 (recentSearchRepository 1회 생성과 같은 이유). + let searchLastRoutes: any SearchLastRoutesUseCase = + DefaultSearchLastRoutesUseCase(repository: lastRouteRepository) + let recentSearches: any RecentSearchesUseCase = + DefaultRecentSearchesUseCase(repository: recentSearchRepository) let searchContainer = SearchDIContainer( searchPlacesUseCase: DefaultSearchPlacesUseCase(repository: placeRepository), - searchLastRoutesUseCase: DefaultSearchLastRoutesUseCase(repository: lastRouteRepository), - recentSearchesUseCase: DefaultRecentSearchesUseCase(repository: recentSearchRepository), + searchLastRoutesUseCase: searchLastRoutes, + recentSearchesUseCase: recentSearches, getCurrentLocationUseCase: getCurrentLocation ) @@ -185,6 +191,9 @@ final class AppDIContainer { getLastRouteDetailUseCase: DefaultGetLastRouteDetailUseCase( repository: lastRouteRepository ), + // 원탭 칩(Phase 18) — 검색 화면과 같은 인스턴스 공유(위 주석 참조). + searchLastRoutesUseCase: searchLastRoutes, + recentSearchesUseCase: recentSearches, searchCoordinatorBuildable: searchContainer ) } diff --git a/Projects/DesignSystem/Example/ComponentDemos.swift b/Projects/DesignSystem/Example/ComponentDemos.swift index 622769a..ffc912a 100644 --- a/Projects/DesignSystem/Example/ComponentDemos.swift +++ b/Projects/DesignSystem/Example/ComponentDemos.swift @@ -22,6 +22,21 @@ final class ButtonsDemoViewController: GalleryScreenViewController { let disabled = DSButton(title: "비활성", style: .primary) disabled.isEnabled = false contentStack.addArrangedSubview(disabled) + + addSectionTitle("DSChip") + let chip = DSChip() + chip.setText("→ 신림동") + let disabledChip = DSChip() + disabledChip.setText("→ 구로디지털단지역") + disabledChip.isEnabled = false + [chip, disabledChip].forEach { contentStack.addArrangedSubview(leadingRow($0)) } + } + + /// 칩은 자기 크기 컴포넌트 — 스택 전폭으로 늘리지 않고 leading에 붙인다(홈과 같은 배치). + private func leadingRow(_ view: UIView) -> UIStackView { + let row = UIStackView(arrangedSubviews: [view, UIView()]) + row.axis = .horizontal + return row } } diff --git a/Projects/DesignSystem/Sources/Components/DSChip.swift b/Projects/DesignSystem/Sources/Components/DSChip.swift new file mode 100644 index 0000000..caf63c3 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSChip.swift @@ -0,0 +1,52 @@ +import UIKit + +/// 컴팩트 pill 칩 — 최근 경로 원탭(Phase 18)처럼 내용이 데이터를 따라 바뀌는 표면용. +/// DSButton은 title이 init 고정이라(홈의 등록/해제 버튼 2개 우회가 그 기록) 동적 +/// 텍스트 자리에 부적합하다 — setText 갱신 가능이 이 컴포넌트의 존재 이유다. +public final class DSChip: UIButton { + private static let height: CGFloat = 32 + + public init() { + super.init(frame: .zero) + + var configuration = UIButton.Configuration.filled() + // 높이 절반 = pill. DSRadius.lg(16)가 그 값과 일치한다. + configuration.background.cornerRadius = DSRadius.lg + configuration.cornerStyle = .fixed + configuration.contentInsets = .init( + top: 0, leading: DSSpacing.md, bottom: 0, trailing: DSSpacing.md + ) + // Colors are applied eagerly so the initial configuration is complete + // without waiting for an update pass (which never runs in hostless + // tests); the update handler keeps them in sync with state changes. + Self.applyColors(&configuration, isEnabled: true) + self.configuration = configuration + + configurationUpdateHandler = { button in + guard var configuration = button.configuration else { return } + Self.applyColors(&configuration, isEnabled: button.isEnabled) + button.configuration = configuration + } + } + + public func setText(_ text: String) { + configuration?.attributedTitle = AttributedString( + text, attributes: AttributeContainer([.font: DSTypography.label2.font]) + ) + } + + static func applyColors(_ configuration: inout UIButton.Configuration, isEnabled: Bool) { + // secondary 계열 매핑(DSButton.secondary와 동일 척도) — 강조가 아니라 보조 진입점이다. + configuration.baseBackgroundColor = isEnabled ? DSColor.Fill.elevated : DSColor.Fill.surface + configuration.baseForegroundColor = isEnabled ? DSColor.Text.primary : DSColor.Text.disabled + } + + public override var intrinsicContentSize: CGSize { + CGSize(width: super.intrinsicContentSize.width, height: Self.height) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/DesignSystem/Tests/DSChipTests.swift b/Projects/DesignSystem/Tests/DSChipTests.swift new file mode 100644 index 0000000..c98861f --- /dev/null +++ b/Projects/DesignSystem/Tests/DSChipTests.swift @@ -0,0 +1,46 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSChipTests { + @Test + func setTextUpdatesTitle() { + let chip = DSChip() + chip.setText("→ 신림동") + #expect(chip.configuration?.title == "→ 신림동") + chip.setText("→ 강남역") + #expect(chip.configuration?.title == "→ 강남역") + } + + @Test + func enabledUsesSecondaryColors() { + let chip = DSChip() + let configuration = chip.configuration + #expect(configuration?.baseBackgroundColor.map { + colorsMatch($0, DSColor.Fill.elevated) + } == true) + #expect(configuration?.baseForegroundColor.map { + colorsMatch($0, DSColor.Text.primary) + } == true) + } + + @Test + func disabledAppearanceMutesColors() { + var configuration = UIButton.Configuration.filled() + DSChip.applyColors(&configuration, isEnabled: false) + #expect(configuration.baseBackgroundColor.map { + colorsMatch($0, DSColor.Fill.surface) + } == true) + #expect(configuration.baseForegroundColor.map { + colorsMatch($0, DSColor.Text.disabled) + } == true) + } + + @Test + func pillShapeIsFixedHeight() { + let chip = DSChip() + #expect(chip.intrinsicContentSize.height == 32) + #expect(chip.configuration?.background.cornerRadius == DSRadius.lg) + } +} diff --git a/Projects/Feature/Home/Example/ExampleApp.swift b/Projects/Feature/Home/Example/ExampleApp.swift index fa88555..937acf7 100644 --- a/Projects/Feature/Home/Example/ExampleApp.swift +++ b/Projects/Feature/Home/Example/ExampleApp.swift @@ -43,6 +43,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { observeAlarmChangeUseCase: PreviewObserveAlarmChangeUseCase(), requestAlarmSyncUseCase: PreviewRequestAlarmSyncUseCase(), getLastRouteDetailUseCase: PreviewGetLastRouteDetailUseCase(), + searchLastRoutesUseCase: PreviewSearchLastRoutesUseCase(), + recentSearchesUseCase: PreviewRecentSearchesUseCase(), searchCoordinatorBuildable: PreviewSearchCoordinatorBuildable() ) let coordinator = container.makeHomeCoordinator(navigationController: navigationController) @@ -116,6 +118,24 @@ struct PreviewGetLastRouteDetailUseCase: GetLastRouteDetailUseCase { } } +/// 원탭 칩 재검색 스텁(Phase 18) — canned 경로 1건을 돌려줘 칩 탭 → 카드 시연이 성립한다. +struct PreviewSearchLastRoutesUseCase: SearchLastRoutesUseCase { + func execute(start: Coordinate, end: Coordinate) async throws -> LastRouteSearchResult { + try? await Task.sleep(for: .milliseconds(400)) + return .available([PreviewSearchCoordinator.makeCannedRoute()]) + } +} + +/// 최근 검색 스텁(Phase 18) — canned 1건으로 칩이 즉시 표출된다. save/remove는 no-op. +struct PreviewRecentSearchesUseCase: RecentSearchesUseCase { + func fetch() async throws -> [Place] { + [PreviewSearchCoordinator.makeCannedArrival()] + } + + func save(_ place: Place) async throws {} + func remove(_ place: Place) async throws {} +} + /// 검색 플로우 스텁: 화면 전환 없이 canned 경로를 즉시 반환한다. /// 실제 검색 UX 시연은 SearchFeatureExample이 담당한다. struct PreviewSearchCoordinatorBuildable: SearchCoordinatorBuildable { diff --git a/Projects/Feature/Home/Sources/HomeDIContainer.swift b/Projects/Feature/Home/Sources/HomeDIContainer.swift index 0a13b70..a465a63 100644 --- a/Projects/Feature/Home/Sources/HomeDIContainer.swift +++ b/Projects/Feature/Home/Sources/HomeDIContainer.swift @@ -16,6 +16,8 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { private let observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase private let requestAlarmSyncUseCase: any RequestAlarmSyncUseCase private let getLastRouteDetailUseCase: any GetLastRouteDetailUseCase + private let searchLastRoutesUseCase: any SearchLastRoutesUseCase + private let recentSearchesUseCase: any RecentSearchesUseCase private let searchCoordinatorBuildable: any SearchCoordinatorBuildable public init( @@ -27,6 +29,8 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase, requestAlarmSyncUseCase: any RequestAlarmSyncUseCase, getLastRouteDetailUseCase: any GetLastRouteDetailUseCase, + searchLastRoutesUseCase: any SearchLastRoutesUseCase, + recentSearchesUseCase: any RecentSearchesUseCase, searchCoordinatorBuildable: any SearchCoordinatorBuildable ) { self.getCurrentLocationUseCase = getCurrentLocationUseCase @@ -37,6 +41,8 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { self.observeAlarmChangeUseCase = observeAlarmChangeUseCase self.requestAlarmSyncUseCase = requestAlarmSyncUseCase self.getLastRouteDetailUseCase = getLastRouteDetailUseCase + self.searchLastRoutesUseCase = searchLastRoutesUseCase + self.recentSearchesUseCase = recentSearchesUseCase self.searchCoordinatorBuildable = searchCoordinatorBuildable } @@ -58,7 +64,9 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { observeAlarmUseCase: observeAlarmUseCase, observeAlarmChangeUseCase: observeAlarmChangeUseCase, requestAlarmSyncUseCase: requestAlarmSyncUseCase, - getLastRouteDetailUseCase: getLastRouteDetailUseCase + getLastRouteDetailUseCase: getLastRouteDetailUseCase, + searchLastRoutesUseCase: searchLastRoutesUseCase, + recentSearchesUseCase: recentSearchesUseCase ) viewModel.onSearchRequested = onSearchRequested return HomeViewController(viewModel: viewModel) diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift index 4b14de7..4266d3c 100644 --- a/Projects/Feature/Home/Sources/HomeViewController.swift +++ b/Projects/Feature/Home/Sources/HomeViewController.swift @@ -25,6 +25,18 @@ final class HomeViewController: UIViewController { private lazy var arrivalRow = makeFieldRow( icon: DSIcon.place24, field: arrivalField, entry: .arrival ) + // 최근 경로 원탭 칩(Phase 18) — 자기 크기 컴포넌트라 스택 전폭으로 늘리지 않고 + // 래퍼의 leading에 붙인다. 표시·활성은 render가 State로 반영한다. + private let recentRouteChip = DSChip() + private lazy var chipRow: UIView = { + let row = UIView() + row.addSubview(recentRouteChip) + recentRouteChip.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview() + } + return row + }() private let routeCard = DSRouteCard() private let registerButton = DSButton(title: "알람 등록하기") // DSButton은 title이 init 고정이라 토글은 버튼 2개의 표시 전환으로 구현한다. @@ -79,6 +91,8 @@ final class HomeViewController: UIViewController { super.viewWillAppear(animated) // 홈은 자체 타이틀을 그린다 — 시스템 내비바 숨김(Search와 동일 규약). navigationController?.setNavigationBarHidden(true, animated: animated) + // 검색 화면을 다녀오며 바뀐 최근 검색(삭제 포함)을 칩에 반영한다(Phase 18). + viewModel.viewWillAppear() } // MARK: - UI @@ -106,18 +120,25 @@ final class HomeViewController: UIViewController { make.width.equalTo(scrollView.frameLayoutGuide).offset(-DSSpacing.md * 2) } - [titleLabel, banner, departureRow, arrivalRow, routeCard, registerButton, cancelButton] + [titleLabel, banner, departureRow, arrivalRow, chipRow, routeCard, registerButton, cancelButton] .forEach(contentStack.addArrangedSubview) contentStack.addArrangedSubview(makeCaptionStack()) contentStack.setCustomSpacing(DSSpacing.lg20, after: titleLabel) contentStack.setCustomSpacing(DSSpacing.sm, after: departureRow) + // 칩이 숨겨져도 필드→카드 간격이 기존(lg)과 같도록 앞뒤 모두 lg를 쓴다. contentStack.setCustomSpacing(DSSpacing.lg, after: arrivalRow) + contentStack.setCustomSpacing(DSSpacing.lg, after: chipRow) banner.isHidden = true + chipRow.isHidden = true routeCard.isHidden = true registerButton.isHidden = true cancelButton.isHidden = true + recentRouteChip.addAction( + UIAction { [weak self] _ in self?.viewModel.chipTapped() }, + for: .touchUpInside + ) registerButton.addAction( UIAction { [weak self] _ in self?.viewModel.registerAlarmTapped() }, for: .touchUpInside @@ -221,6 +242,15 @@ final class HomeViewController: UIViewController { // 도착지 필드 = 선택 경로의 도착지명(Phase 17). nil이면 placeholder가 유도한다. arrivalField.setText(state.arrivalText ?? "") + // 최근 경로 원탭 칩(Phase 18) — nil이면 숨김, 재검색 진행 중엔 비활성(더블 탭 방지). + if let chipText = state.recentRouteChipText { + recentRouteChip.setText(chipText) + chipRow.isHidden = false + } else { + chipRow.isHidden = true + } + recentRouteChip.isEnabled = !state.isChipBusy + if let card = state.routeCard { // 신선도 스탬프(Phase 16)는 세션 상태라 State가 따로 나른다 — 표출 시점 합성. routeCard.configure(with: card.dsContent(footnote: state.freshnessText)) @@ -277,6 +307,15 @@ final class HomeViewController: UIViewController { case .locationRestricted: // restricted는 설정으로 못 푸는 제약 — "설정으로 이동"을 안내하지 않는다(Phase 17). DSToast.show("이 기기에선 위치를 사용할 수 없어요. 출발지를 검색해 주세요", in: view) + case .chipLocationUnavailable: + DSToast.show("현재 위치를 확인하지 못했어요. 잠시 후 다시 시도해 주세요", in: view) + case .chipSearchFailed: + DSToast.show("막차를 찾지 못했어요. 다시 시도해 주세요", in: view) + case .chipServiceEnded: + // 검색 화면 빈 상태 제목과 같은 어휘(Phase 18) — 화면 간 표기 통일. + DSToast.show("오늘 막차가 끊겼어요", in: view) + case .chipNoRoute: + DSToast.show("대중교통 경로를 찾지 못했어요", in: view) case .alarmPermissionNeeded: DSToast.show( "알람 권한이 꺼져 있어요", diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index 3f68fc9..756c18b 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -33,6 +33,11 @@ final class HomeViewModel { /// 도착지 필드 표시값(Phase 17) — 선택 경로의 도착지명. nil이면 placeholder. /// 재실행 복원 경로는 도착지 명칭 원천이 없어 채우지 않는다(결정사항 — 수용). var arrivalText: String? + /// 최근 경로 원탭 칩(Phase 18) — 최근 검색 최신 1건의 "→ {도착지명}". nil이면 숨김. + /// 원천은 RecentSearchRepository 하나뿐(최근 검색의 표면 확장 — 즐겨찾기 아님). + var recentRouteChipText: String? + /// 칩 재검색 진행 중(Phase 18) — 칩 비활성(더블 탭 방지). 알람 busy와 독립이다. + var isChipBusy = false var routeCard: RouteCardViewData? var banner: BannerViewData? var isAlarmBusy = false @@ -50,6 +55,14 @@ final class HomeViewModel { case locationServicesDisabled /// 스크린타임·MDM 제약(Phase 17) — 설정으로 못 푼다. "설정으로 이동" 안내 금지. case locationRestricted + /// 칩 원탭 재검색(Phase 18)의 위치 일시 실패 — 명시적 탭에 무음은 없다. + case chipLocationUnavailable + /// 칩 재검색 자체 실패(Phase 18) — 재시도 유도. 카드·필드는 무변경이 원칙. + case chipSearchFailed + /// 칩 재검색 결과 오늘 막차 종료(Phase 18) — 검색 빈 상태 제목과 같은 어휘. + case chipServiceEnded + /// 칩 재검색 결과 경로 없음(Phase 18). + case chipNoRoute case alarmPermissionNeeded case alarmRegisterFailed case alarmCancelFailed @@ -91,10 +104,14 @@ final class HomeViewModel { private let observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase private let requestAlarmSyncUseCase: any RequestAlarmSyncUseCase private let getLastRouteDetailUseCase: any GetLastRouteDetailUseCase + private let searchLastRoutesUseCase: any SearchLastRoutesUseCase + private let recentSearchesUseCase: any RecentSearchesUseCase private let now: @Sendable () -> Date private let bannerTickInterval: Duration private var selectedRoute: LastRoute? + /// 칩의 원본 도착지(Phase 18) — 표시는 문자열(State), 재검색은 이 Place가 한다. + private var chipPlace: Place? /// 서버에 알람이 등록된 경로 id — 해제 버튼·동기화 복원의 기준. private var registeredRouteId: String? /// 세션 값이 마지막으로 서버로 확인된 시각(Phase 16) — 스트림의 checkedAt·등록 @@ -107,6 +124,10 @@ final class HomeViewModel { private var bannerTask: Task? private var restoreCardTask: Task? private var refreshTask: Task? + private var chipTask: Task? + // 승격 저장은 재조회와 분리 보관 — 재조회가 저장을 cancel하면 안 된다(검색 VM saveTask 선례). + private var chipSaveTask: Task? + private var chipSearchTask: Task? init( getCurrentLocationUseCase: any GetCurrentLocationUseCase, @@ -117,6 +138,8 @@ final class HomeViewModel { observeAlarmChangeUseCase: any ObserveAlarmChangeUseCase, requestAlarmSyncUseCase: any RequestAlarmSyncUseCase, getLastRouteDetailUseCase: any GetLastRouteDetailUseCase, + searchLastRoutesUseCase: any SearchLastRoutesUseCase, + recentSearchesUseCase: any RecentSearchesUseCase, now: @escaping @Sendable () -> Date = { Date() }, bannerTickInterval: Duration = .seconds(60) ) { @@ -128,6 +151,8 @@ final class HomeViewModel { self.observeAlarmChangeUseCase = observeAlarmChangeUseCase self.requestAlarmSyncUseCase = requestAlarmSyncUseCase self.getLastRouteDetailUseCase = getLastRouteDetailUseCase + self.searchLastRoutesUseCase = searchLastRoutesUseCase + self.recentSearchesUseCase = recentSearchesUseCase self.now = now self.bannerTickInterval = bannerTickInterval } @@ -140,6 +165,9 @@ final class HomeViewModel { bannerTask?.cancel() restoreCardTask?.cancel() refreshTask?.cancel() + chipTask?.cancel() + chipSaveTask?.cancel() + chipSearchTask?.cancel() } // MARK: - 입력 @@ -148,6 +176,12 @@ final class HomeViewModel { loadCurrentLocation() observeAlarmUpdates() observeAlarmChanges() + refreshChip() + } + + /// 홈 재노출(Phase 18) — 검색 화면을 다녀오며 바뀐 최근 검색(삭제 포함)을 칩에 반영한다. + func viewWillAppear() { + refreshChip() } /// 설정을 다녀온 뒤(didBecomeActive) 위치 권한 회복을 재확인한다(Phase 15). @@ -192,10 +226,13 @@ final class HomeViewModel { func routeSelected(_ route: LastRoute, arrival: Place) { selectedRoute = route + chipPlace = arrival var newState = state newState.routeCard = RouteCardViewData(entity: route, now: now()) // 도착지 필드를 선택 경로의 도착지명과 정합시킨다(Phase 17) — placeholder 공존 해소. newState.arrivalText = arrival.name + // 칩도 즉시 정합(Phase 18) — fetch를 기다리지 않는다. 방금 확정한 도착지가 최신이다. + newState.recentRouteChipText = "→ \(arrival.name)" // 새 경로 선택 = 기존 배너는 더 이상 유효하지 않다 (재등록 전까지 숨김). newState.banner = nil newState.alarmButton = Self.alarmButtonMode( @@ -204,6 +241,64 @@ final class HomeViewModel { ) state = newState bannerTask?.cancel() + promoteChipDestination(arrival) + } + + /// 최근 경로 원탭(Phase 18) — 현재 위치 기준 즉시 재검색, 검색 화면 생략. + /// 성공은 routeSelected로 수렴한다(카드·도착지·배너 무효화·버튼이 검색 복귀와 같은 의미). + /// 알람 자동 등록은 없다 — 등록은 명시적 버튼 탭만(확정 결정). + func chipTapped() { + guard let destination = chipPlace, !state.isChipBusy else { return } + state.isChipBusy = true + chipSearchTask?.cancel() + chipSearchTask = Task { [weak self] in + defer { self?.state.isChipBusy = false } + + let start: Coordinate + do { + guard let locationUseCase = self?.getCurrentLocationUseCase else { return } + start = try await locationUseCase.execute() + } catch let error as LocationError { + guard !Task.isCancelled else { return } + // 사유별 안내는 기존 3분기 이벤트 재사용(Phase 17 문구·액션 그대로). + // 명시적 탭이라 unavailable도 무음일 수 없다 — loadCurrentLocation과의 차이. + switch error { + case .permissionDenied: self?.onToast?(.locationPermissionNeeded) + case .servicesDisabled: self?.onToast?(.locationServicesDisabled) + case .restricted: self?.onToast?(.locationRestricted) + case .unavailable: self?.onToast?(.chipLocationUnavailable) + } + return + } catch { + guard !Task.isCancelled else { return } + self?.onToast?(.chipLocationUnavailable) + return + } + + guard !Task.isCancelled, + let routesUseCase = self?.searchLastRoutesUseCase else { return } + do { + let result = try await routesUseCase.execute( + start: start, end: destination.coordinate + ) + guard !Task.isCancelled, let self else { return } + switch result { + case let .available(routes) where !routes.isEmpty: + // featured(0번 = 가장 늦은 차)만 원탭의 몫 — 대안 표출은 풀 검색 화면 담당. + self.routeSelected(routes[0], arrival: destination) + case .available: + // 정규화가 놓친 빈 목록 방어 (검색 VM 선례). + self.onToast?(.chipNoRoute) + case .serviceEnded: + self.onToast?(.chipServiceEnded) + case .noRoute: + self.onToast?(.chipNoRoute) + } + } catch { + guard !Task.isCancelled else { return } + self?.onToast?(.chipSearchFailed) + } + } } func registerAlarmTapped() { @@ -403,6 +498,36 @@ final class HomeViewModel { } } + /// 칩 재조회(Phase 18) — 최근 검색 최신 1건이 칩의 유일한 원천이다(즐겨찾기 아님). + /// 승격 저장이 진행 중이면 no-op — 저장 전의 낡은 목록으로 되돌리는 경합을 막는다 + /// (저장 완료가 가드를 풀고, 다음 재노출 재조회가 확정 목록을 반영한다). + private func refreshChip() { + guard chipSaveTask == nil else { return } + chipTask?.cancel() + chipTask = Task { [weak self] in + guard let useCase = self?.recentSearchesUseCase else { return } + // 로드 실패는 칩 없음으로 무해화한다 (검색 화면의 최근 목록과 같은 취급). + let latest = ((try? await useCase.fetch()) ?? []).first + guard !Task.isCancelled, let self else { return } + self.chipPlace = latest + self.state.recentRouteChipText = latest.map { "→ \($0.name)" } + } + } + + /// 도착지 승격(Phase 18) — 기존 save의 "중복 시 최신 갱신"을 재사용해 확정 도착지를 + /// 최근 검색 최신으로 올린다(새 저장 의미 없음 — 출발지를 나중에 확정한 경우에도 + /// 칩 = 마지막으로 경로를 확정한 도착지가 되게 하는 최소 수단이다). 칩 탭 경로의 + /// 재저장은 이미 최신 1위라 멱등이다. + private func promoteChipDestination(_ place: Place) { + chipSaveTask?.cancel() + chipSaveTask = Task { [weak self] in + guard let useCase = self?.recentSearchesUseCase else { return } + try? await useCase.save(place) + guard !Task.isCancelled else { return } + self?.chipSaveTask = nil + } + } + private func loadCurrentLocation() { locationTask?.cancel() state.departure = .loading diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift index 2a4c908..c3f8533 100644 --- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -53,6 +53,21 @@ private struct StubGetLastRouteDetailUseCase: GetLastRouteDetailUseCase { func execute(routeId: String) async throws -> LastRoute { try await handler(routeId) } } +private struct StubSearchLastRoutesUseCase: SearchLastRoutesUseCase { + let handler: @Sendable (Coordinate, Coordinate) async throws -> LastRouteSearchResult + func execute(start: Coordinate, end: Coordinate) async throws -> LastRouteSearchResult { + try await handler(start, end) + } +} + +private struct StubRecentSearchesUseCase: RecentSearchesUseCase { + let fetchHandler: @Sendable () async throws -> [Place] + let saveHandler: @Sendable (Place) async throws -> Void + func fetch() async throws -> [Place] { try await fetchHandler() } + func save(_ place: Place) async throws { try await saveHandler(place) } + func remove(_ place: Place) async throws {} +} + /// 테스트 도중 스텁 동작을 바꾸거나 호출 횟수를 세기 위한 가변 박스 (NowBox와 동일 패턴). private nonisolated final class ValueBox: @unchecked Sendable { private let lock = NSLock() @@ -173,6 +188,11 @@ private func makeSUT( routeDetail: @escaping @Sendable (String) async throws -> LastRoute = { _ in throw StubError() }, + searchRoutes: @escaping @Sendable (Coordinate, Coordinate) async throws -> LastRouteSearchResult = { _, _ in + throw StubError() + }, + recentFetch: @escaping @Sendable () async throws -> [Place] = { [] }, + recentSave: @escaping @Sendable (Place) async throws -> Void = { _ in }, now: @escaping @Sendable () -> Date = { fixedNow }, bannerTickInterval: Duration = .seconds(60) ) -> HomeViewModel { @@ -187,6 +207,10 @@ private func makeSUT( observeAlarmChangeUseCase: StubObserveAlarmChangeUseCase(handler: alarmChanges), requestAlarmSyncUseCase: StubRequestAlarmSyncUseCase(handler: requestSync), getLastRouteDetailUseCase: StubGetLastRouteDetailUseCase(handler: routeDetail), + searchLastRoutesUseCase: StubSearchLastRoutesUseCase(handler: searchRoutes), + recentSearchesUseCase: StubRecentSearchesUseCase( + fetchHandler: recentFetch, saveHandler: recentSave + ), now: now, bannerTickInterval: bannerTickInterval ) @@ -1225,6 +1249,249 @@ struct HomeViewModelTests { await recorder.waitUntilLast { $0.banner == nil && $0.freshnessText == nil } #expect(sut.state.routeCard?.tone == .past) } + + // MARK: - 최근 경로 원탭 칩 (Phase 18) + + @Test + func viewDidLoad_recentSearchExists_showsChipForLatest() async { + // 칩은 최근 검색 최신 1건 — 목록의 첫 항목만 표면화한다. + let sut = makeSUT(recentFetch: { [makeArrival("신림동"), makeArrival("강남역")] }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + await recorder.waitUntilLast { $0.recentRouteChipText == "→ 신림동" } + + #expect(sut.state.recentRouteChipText == "→ 신림동") + } + + @Test + func viewDidLoad_noRecentSearch_hidesChip() async { + let sut = makeSUT() + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + await recorder.waitUntilLast { $0.departure == .current(name: "강남역") } + for _ in 0..<20 { await Task.yield() } + + #expect(sut.state.recentRouteChipText == nil) + } + + @Test + func routeSelected_updatesChipImmediatelyAndPromotesArrival() async { + // 칩은 fetch를 기다리지 않고 즉시 정합하고, 도착지는 save로 승격된다(중복 최신 갱신 재사용). + let saved = ValueBox([String]()) + let sut = makeSUT(recentSave: { place in saved.update { $0 + [place.name] } }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.routeSelected( + makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(3600)), + arrival: makeArrival("당산역") + ) + + #expect(sut.state.recentRouteChipText == "→ 당산역") + while saved.get().isEmpty { await Task.yield() } + #expect(saved.get() == ["당산역"]) + } + + @Test + func chipTapped_success_showsFeaturedCardWithoutRegisteringAlarm() async { + // 원탭 성공 = routeSelected 수렴 — 카드는 featured(0번), 알람 등록은 0회여야 한다. + let registerCalls = ValueBox(0) + let searched = ValueBox([Coordinate]()) + let destination = makeArrival("신림동") + let featured = makeRoute(id: "featured", departure: fixedNow.addingTimeInterval(3600)) + let alternative = makeRoute(id: "alt", departure: fixedNow.addingTimeInterval(1800)) + let sut = makeSUT( + register: { _ in registerCalls.update { $0 + 1 } }, + searchRoutes: { start, end in + searched.update { $0 + [start, end] } + return .available([featured, alternative]) + }, + recentFetch: { [destination] } + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.viewDidLoad() + await recorder.waitUntilLast { $0.recentRouteChipText == "→ 신림동" } + + sut.chipTapped() + await recorder.waitUntilLast { $0.routeCard != nil && !$0.isChipBusy } + + // 재검색 좌표: start = 현재 위치(스텁), end = 칩 도착지. + #expect(searched.get() == [ + Coordinate(latitude: 37.4979, longitude: 127.0276), destination.coordinate, + ]) + #expect(sut.state.routeCard == RouteCardViewData(entity: featured, now: fixedNow)) + #expect(sut.state.arrivalText == "신림동") + #expect(sut.state.alarmButton == .register) + #expect(sut.state.banner == nil) + // 알람 자동 등록 금지(확정 결정)의 직접 검증 — 등록은 명시적 버튼 탭만. + #expect(registerCalls.get() == 0) + #expect(recorder.toasts.isEmpty) + } + + @Test + func chipTapped_locationDenied_reusesPermissionToastAndShowsNoCard() async { + // 위치 실패 안내는 기존 3분기 이벤트를 재사용한다 — 칩 전용 문구를 만들지 않는다. + let denied = ValueBox(false) + let sut = makeSUT( + location: { + guard !denied.get() else { throw LocationError.permissionDenied } + return Coordinate(latitude: 37.4979, longitude: 127.0276) + }, + recentFetch: { [makeArrival("신림동")] } + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.viewDidLoad() + await recorder.waitUntilLast { + $0.recentRouteChipText == "→ 신림동" && $0.departure == .current(name: "강남역") + } + + denied.update { _ in true } + sut.chipTapped() + while recorder.toasts.isEmpty { await Task.yield() } + + #expect(recorder.toasts == [.locationPermissionNeeded]) + #expect(sut.state.routeCard == nil) + #expect(!sut.state.isChipBusy) + } + + @Test + func chipTapped_locationUnavailable_emitsChipToast() async { + // 명시적 탭에 무음은 없다 — loadCurrentLocation(무토스트)과 다른 분기. + let unavailable = ValueBox(false) + let sut = makeSUT( + location: { + guard !unavailable.get() else { throw LocationError.unavailable } + return Coordinate(latitude: 37.4979, longitude: 127.0276) + }, + recentFetch: { [makeArrival("신림동")] } + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.viewDidLoad() + await recorder.waitUntilLast { $0.recentRouteChipText == "→ 신림동" } + + unavailable.update { _ in true } + sut.chipTapped() + while recorder.toasts.isEmpty { await Task.yield() } + + #expect(recorder.toasts == [.chipLocationUnavailable]) + #expect(sut.state.routeCard == nil) + } + + @Test + func chipTapped_normalizedAndFailedResults_emitMatchingToasts() async { + // serviceEnded/noRoute/빈 available/실패 전부 토스트만 — 카드·필드는 무변경. + enum Scenario: Sendable { case serviceEnded, noRoute, emptyAvailable, failure } + let scenario = ValueBox(Scenario.serviceEnded) + let sut = makeSUT( + searchRoutes: { _, _ in + switch scenario.get() { + case .serviceEnded: return .serviceEnded + case .noRoute: return .noRoute + case .emptyAvailable: return .available([]) + case .failure: throw StubError() + } + }, + recentFetch: { [makeArrival("신림동")] } + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.viewDidLoad() + await recorder.waitUntilLast { $0.recentRouteChipText == "→ 신림동" } + + for (scenarioCase, expected) in [ + (Scenario.serviceEnded, HomeViewModel.ToastEvent.chipServiceEnded), + (.noRoute, .chipNoRoute), + (.emptyAvailable, .chipNoRoute), + (.failure, .chipSearchFailed), + ] { + scenario.update { _ in scenarioCase } + let toastsBefore = recorder.toasts.count + sut.chipTapped() + while recorder.toasts.count == toastsBefore { await Task.yield() } + #expect(recorder.toasts.last == expected) + await recorder.waitUntilLast { !$0.isChipBusy } + } + #expect(sut.state.routeCard == nil) + #expect(sut.state.arrivalText == nil) + } + + @Test + func chipTapped_whileBusy_ignoresSecondTap() async { + // 더블 탭 = 재검색 1회 — busy 가드가 재진입을 막는다. + let calls = ValueBox(0) + let release = ValueBox(false) + let sut = makeSUT( + searchRoutes: { _, _ in + calls.update { $0 + 1 } + while !release.get() { await Task.yield() } + return .serviceEnded + }, + recentFetch: { [makeArrival("신림동")] } + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.viewDidLoad() + await recorder.waitUntilLast { $0.recentRouteChipText == "→ 신림동" } + + sut.chipTapped() + #expect(sut.state.isChipBusy) + sut.chipTapped() + release.update { _ in true } + while recorder.toasts.isEmpty { await Task.yield() } + + #expect(calls.get() == 1) + #expect(recorder.toasts == [.chipServiceEnded]) + } + + @Test + func viewWillAppear_refetchesChip_reflectingDeletion() async { + // 검색 화면에서 최근을 지우고 돌아오면 칩도 사라져야 한다 — 재노출 재조회. + let recents = ValueBox([makeArrival("신림동")]) + let sut = makeSUT(recentFetch: { recents.get() }) + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.viewDidLoad() + await recorder.waitUntilLast { $0.recentRouteChipText == "→ 신림동" } + + recents.update { _ in [] } + sut.viewWillAppear() + await recorder.waitUntilLast { $0.recentRouteChipText == nil } + + #expect(sut.state.recentRouteChipText == nil) + } + + @Test + func viewWillAppear_whilePromotionInFlight_keepsJustSelectedChip() async { + // 승격 저장이 끝나기 전의 재조회는 no-op — 낡은 목록이 방금의 도착지를 덮지 않는다. + let release = ValueBox(false) + let sut = makeSUT( + recentFetch: { [] }, // 저장 전의 낡은(빈) 목록. + recentSave: { _ in while !release.get() { await Task.yield() } } + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.routeSelected( + makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(3600)), + arrival: makeArrival("당산역") + ) + #expect(sut.state.recentRouteChipText == "→ 당산역") + + sut.viewWillAppear() + for _ in 0..<20 { await Task.yield() } + #expect(sut.state.recentRouteChipText == "→ 당산역") + + release.update { _ in true } + for _ in 0..<20 { await Task.yield() } + #expect(sut.state.recentRouteChipText == "→ 당산역") + } } /// 스탬프 기대값용 포매터 — 프로덕션(HomeViewData)과 같은 구성(HH:mm, ko_KR). diff --git a/docs/prompts/atcha-v2-auto-verification.md b/docs/prompts/atcha-v2-auto-verification.md index 731ace6..95a335b 100644 --- a/docs/prompts/atcha-v2-auto-verification.md +++ b/docs/prompts/atcha-v2-auto-verification.md @@ -25,6 +25,8 @@ - **Mac 화면 잠금 중 대체 경로 (2026-08-24 실측)**: 잠금 중에는 System Events·orca 모두 창 AX 트리가 비어 조작 불가지만, **XCUITest 하니스는 성립한다** — 스크래치 Tuist 프로젝트(더미 host 앱 + `.uiTests` 타겟)에서 `XCUIApplication(bundleIdentifier: "com.atcha.iOS.v2")`로 앱을 구동하면 XCTRunner가 시뮬레이터 디바이스 내부에서 이벤트를 주입하므로 잠금·AX와 무관하다. 증적은 `XCUIScreen.main.screenshot()`을 호스트 경로에 직접 기록(시뮬레이터 프로세스 = 호스트 FS 공유). 시스템 권한 알럿은 `XCUIApplication(bundleIdentifier: "com.apple.springboard")`의 버튼으로 처리하고, **알람 등록 검수는 알림 권한 알럿 응답까지 등록 UseCase가 대기하므로 배너 표출까지 알럿을 반복 소거**해야 한다. 엣지 스와이프 백(좌표 드래그)·셀 스와이프(`swipeLeft`)도 이 경로로 신뢰 가능 — AX 좌표 클릭 불신뢰 제약의 대체다. 한글 `typeText` 입력도 성립. - Simulator.app의 AX 트리가 응답하지 않으면(창 열거 0·entire contents 타임아웃) `killall "System Events"` 후 재시도, 그래도 안 되면 Mac 잠금 상태부터 확인할 것(잠금이 원인인 경우가 많다). +- **위치 권한 알럿 자동화 함정 (2026-08-24 실측)**: 허용 버튼을 `label IN {"앱을 사용하는 동안 허용", "한 번 허용", ...}` 류의 다중 라벨 firstMatch로 누르면 **"한 번 허용(Allow Once)"이 눌린다** — 그 세션은 정상 동작하지만 재실행부터 권한이 소멸하고, 이후의 위치 재조회(`CLLocationUpdate.liveUpdates`)는 **권한 알럿 재표출 없이 nil 업데이트 1건 후 즉시 종료**된다(→ 앱은 unavailable 처리, 증상이 "위치가 조용히 안 잡힘"으로 보여 원인 추적이 어렵다). 허용 검수는 라벨을 **"앱을 사용하는 동안 허용"/"Allow While Using App"으로 한정**할 것. +- **위치 주입 (2026-08-24 실측)**: While-Using 정식 허용 + 검수 전 `xcrun simctl location set ,`(정적 1회)이면 앱 시작 조회뿐 아니라 **재조회 스트림(검색 프리필·홈 재조회 등)에도 픽스가 도달**한다. 반면 Allow Once로 오염된 상태에서는 `location start`(waypoint 연속 시뮬레이션)·`set` 반복 펌프·XCUITest의 `XCUIDevice.shared.location` 어느 것으로도 구제되지 않는다 — 위치가 안 잡히면 주입 수단을 바꾸기 전에 **권한 상태부터 의심**할 것(`simctl privacy grant location `로 확정 부여 가능). - **강제 종료 재현은 `xcrun simctl terminate`** — 앱 스위처 스와이프 자동화 불필요. - **LA dismiss 검수는 DEV dismiss 토글 사용** — 잠금화면 스와이프 삭제 자동화가 불안정해서 그 용도로 만들어진 수단(플로팅 디버그 메뉴의 dismiss 기록 토글, `devToggleDismissedByUser`)을 쓴다. diff --git a/docs/prompts/atcha-v2-one-tap-chip-prompt.md b/docs/prompts/atcha-v2-one-tap-chip-prompt.md new file mode 100644 index 0000000..61d27e4 --- /dev/null +++ b/docs/prompts/atcha-v2-one-tap-chip-prompt.md @@ -0,0 +1,151 @@ +# AtchaV2 최근 경로 원탭 칩 구현 프롬프트 — 홈 원탭 재검색 (3탭 → 1탭) + +> **사용법**: 이 문서 전체를 Claude Code에 컨텍스트로 전달하고 `"Phase 18을 진행해"`라고 지시한다. +> 실행 에이전트는 [마스터 프롬프트](atcha-v2-master-prompt.md)의 **진행 프로토콜·공통 규칙·공통 acceptance를 그대로 상속**하며, 한 번에 한 Phase만 수행한다. +> 갭 분석·우선순위 정본은 [Post-12 로드맵](../planning/atcha-v2-post12-roadmap.md)의 "Phase 18 — 최근 경로 원탭 칩" 절. 충돌 시 **CLAUDE.md > 마스터 프롬프트 > [LA 프롬프트](atcha-v2-live-activity-prompt.md) > [세션 수명주기 프롬프트](atcha-v2-session-lifecycle-prompt.md) > [인지 채널 방어선 프롬프트](atcha-v2-channel-defense-prompt.md) > [갱신 신뢰성 프롬프트](atcha-v2-refresh-reliability-prompt.md) > [마찰 팩 프롬프트](atcha-v2-friction-pack-prompt.md) > 이 문서** 순. +> **검수는 사람 검수가 아니라 [자동 검수 규약](atcha-v2-auto-verification.md)을 따른다** — 실행 에이전트가 computer use로 직접 수행·증적 보고하고, 실기기 잔여 항목만 사용자에게 이관한다. +> 작성일: 2026-08-24. + +--- + +## Goal (최상위) + +**"간편 그 자체"를 반복 사용자에게 체감시킨다 — 어제 검색한 그 경로를 오늘은 한 탭으로.** 3탭(홈 필드 탭 → 최근 검색 행 탭 → 결과 경로 탭)이 1탭(홈 칩 탭)이 된다. + +현재 홈은 매 방문이 백지에서 시작한다 — 최근 검색이 로컬에 저장돼 있는데도(Phase 2) 그 데이터가 닿는 표면은 검색 화면의 목록뿐이라, 매일 같은 경로를 확인하는 핵심 사용자(심야 귀가 반복)도 매번 검색 화면을 왕복해야 한다. 이 문서는 홈 검색 필드 아래에 **마지막 도착지 칩 1개**("→ 신림동")를 놓고, 탭 시 **현재 위치 기준 즉시 재검색 → 결과 카드를 홈에 바로 표출**한다(검색 화면 생략). 이것은 **"최근 검색만, 즐겨찾기 없음" 확정 결정과 충돌하지 않는 표면 확장**이다 — 별도 저장·관리 UI가 없고, 데이터 원천은 기존 `RecentSearchRepository` 하나뿐이다. **알람 자동 등록은 하지 않는다** — 칩의 종착은 카드 표출까지고, 등록은 명시적 버튼 탭만. Phase 번호는 마찰 팩(17)에 이어 **18**. + +원탭 칩의 완성 상태 (이 문서가 만드는 구조): + +``` +홈 + 출발지/도착지 필드 + [→ 신림동] ◄── 최근 검색 최신 1건 (RecentSearchesUseCase.fetch()[0]) + │ 경로 선택 시 도착지를 save로 승격(중복 최신 갱신 재사용) + └─ 탭 ──► 현재 위치 조회 ──► SearchLastRoutesUseCase(start: 현재 위치, end: 칩 도착지) + │ │ + │ 실패 ├─ available → featured(0번) 카드 즉시 표출 + ▼ │ (routeSelected 수렴 — 검색 복귀와 같은 상태 의미) + 기존 위치 토스트 ├─ serviceEnded/noRoute/실패 → 토스트 (카드 무변경) + 3분기 재사용 └─ 알람 등록 없음 — "알람 등록하기" 버튼이 다음 탭 +``` + +확정된 제품 결정사항 (변경하려면 사용자에게 먼저 물을 것 — [로드맵](../planning/atcha-v2-post12-roadmap.md) 2026-08-23 확정 + 이 문서 2026-08-24 구체화): + +| 항목 | 결정 | +|---|---| +| 칩 데이터 원천 | **`RecentSearchesUseCase.fetch()`의 최신 1건**(기존 레포 정책: 최신순·중복 최신 갱신·10개 제한 — 소비만 하고 정책 무변경). 칩 전용 저장 키·스냅샷 확장·별도 영속화 **금지** — "최근 검색의 표면 확장" 정의를 코드로 지킨다 | +| 도착지 승격 | `routeSelected(_:arrival:)`가 도착지를 `save`로 **승격**(기존 "중복 시 최신 갱신" 재사용 — 새 저장 의미 없음). 출발지를 나중에 확정한 경우에도 칩 = "마지막으로 경로를 확정한 도착지"가 되도록 하는 최소 수단이다. 칩 탭 성공도 같은 경로로 수렴하므로 멱등 재저장이다 | +| 칩 라벨·개수 | `"→ {도착지명}"` **1개만**. N개 목록·삭제 액세서리·관리 UI 금지(그건 즐겨찾기다 — 반대 확정). 최근 검색 0건이면 칩 자체가 없다 | +| 칩 위치 | 도착지 필드 행 아래, 경로 카드 위 — leading 정렬(스택 전폭 늘림 금지) | +| 칩 갱신 시점 | ① `viewDidLoad` ② `viewWillAppear`(검색 화면을 다녀온 뒤의 삭제 반영 — 승격 save 진행 중이면 no-op, 승격 파이프라인이 끝나며 최신을 반영한다) ③ `routeSelected` 직접 세팅(fetch 대기 없이 즉시 정합) | +| 탭 플로우 | 현재 위치 조회(`GetCurrentLocationUseCase` — 역지오코딩 불필요, 좌표만) → `SearchLastRoutesUseCase.execute(start: 현재 위치, end: 칩 도착지)` → `.available`의 **featured(0번, 가장 늦은 차)**를 `routeSelected(_:arrival:)`로 수렴 — 카드·arrivalText·배너 무효화·버튼 계산이 검색 복귀와 완전히 같은 의미다. 대안 경로 표출은 풀 검색 화면의 몫(칩은 featured 원탭 전용) | +| 실패 표면 | **전부 토스트, 카드·필드 무변경**(홈에 카드 외 빈 상태 표면을 새로 만들지 않는다). `LocationError` 3분기는 기존 토스트 재사용(denied/전역 OFF/restricted — Phase 17 문구·액션 그대로), `unavailable`·기타 위치 실패 → "현재 위치를 확인하지 못했어요. 잠시 후 다시 시도해 주세요", `serviceEnded` → "오늘 막차가 끊겼어요", `noRoute` → "대중교통 경로를 찾지 못했어요"(검색 DSEmptyState 제목과 동일 표기 — 화면 간 어휘 통일), 경로 검색 throw → "막차를 찾지 못했어요. 다시 시도해 주세요". 신규 4건 전부 액션 버튼 없음 | +| 재진입 가드 | `State.isChipBusy` — 진행 중 칩 비활성(더블 탭 1회 실행). 알람 busy와는 독립(검색 복귀 경로와 같은 노출 수준 유지 — 추가 가드 금지) | +| 알람 자동 등록 금지 | 칩 탭 경로에서 `RegisterAlarmUseCase` 호출 **0회** — 테스트가 직접 검증한다. 카드 표출 후 "알람 등록하기" 버튼이 명시적 다음 탭이다 | +| DSChip 신설 | `DSButton`은 title이 init 고정이라(홈의 버튼 2개 우회가 그 한계의 기록) 동적 도착지명에 부적합 — **`DSChip`(UIButton 서브클래스, `setText(_:)` 갱신 가능)** 신설. height 32 + `DSTypography.label2` + cornerRadius `DSRadius.lg`(= 높이 절반, pill) + secondary 계열 색 매핑(bg `Fill.elevated`/text `Text.primary`, disabled `Fill.surface`/`Text.disabled`) — DSButton의 configuration·eager 컬러 적용 패턴 준수. 갤러리(ComponentDemos) 데모 1행 추가 | +| DEV 검수 훅 | **신규 0건** — 기존 재료로 전 시나리오가 성립한다: 데모 장소 선택 → 최근 저장(칩 생성), "경로없음" 데모 장소(Phase 17 훅) → 칩 serviceEnded 재현, `simctl location set` → 현재 위치 주입. `DevDemoFallbacks` 무변경 | + +--- + +## 이 문서가 다시 정의하지 않는 것 (중복 금지) + +아래는 기존 Phase 산출물이다. **계약을 바꾸지 않고 명시된 지점만 확장한다.** 이 목록의 계약을 깨고 싶어지면 멈추고 사용자에게 물을 것. + +| 산출물 | 소속 | 이 문서에서의 취급 | +|---|---|---| +| 검색 화면 전부 (상태 머신·빈 상태·didShow 정리·스와이프·내일 라벨) | Phase 5·17 | **SearchFeature 소스 변경 0.** 칩은 검색 화면을 생략하는 별도 진입로다 | +| `RecentSearchRepositoryImpl` 정책 (최신순·중복 최신 갱신·10개 제한) | Phase 2 | 불변 — 칩은 소비자일 뿐. 승격 저장도 기존 save 의미(중복 갱신)를 그대로 쓴다 | +| `routeSelected(_:arrival:)`의 상태 의미 (카드·arrivalText·배너 무효화·버튼 계산) | Phase 6~17 | 불변 — 칩 성공 경로가 **이 한 지점으로 수렴**한다(칩 전용 상태 전이 신설 금지). 승격 저장 한 줄이 얹힐 뿐 | +| `DefaultSearchLastRoutesUseCase` 정규화 (빈 목록 = serviceEnded 가정, 미확정 #3 TODO) | Phase 1 | 불변 — 칩은 정규화 결과의 소비자다. responseCode 매핑 추가 금지 | +| 위치 실패 3분기 (`LocationError` 세분화 + ToastEvent 3종 + 문구·액션) | Phase 17 | 불변 — 칩 탭의 위치 실패가 **같은 이벤트를 재사용**한다(문구 분기 중복 신설 금지) | +| `AlarmSyncService` 판정·폴백·만료 / LA·알람·세션 수명주기 / 스탬프·pull-to-refresh | Phase 8~16 | **무변경.** 이 Phase는 알람 경로를 건드리지 않는다 — 자동 등록 금지가 그 경계다 | +| Interface 공개 계약 (`HomeCoordinatorBuildable`·`SearchCoordinatorBuildable`·`SearchEntryField`) | Phase 5~17 | **변경 0** — 칩은 홈 내부 표면이라 Interface에 닿지 않는다 | +| DEV 데모 폴백·변경 시뮬레이터·검수 훅 3건 | Phase 7~17 | **무변경** — 결정사항대로 신규 훅 없이 기존 재료를 조합한다 | +| 서버 계약 전체 | 마스터 공통 규칙 | **서버 변경 0. Domain 변경 0** — 필요한 UseCase(`SearchLastRoutesUseCase`·`RecentSearchesUseCase`)는 Phase 1·2 산출물 그대로다 | + +## 전제 조건 + +1. **Phase 17 완료가 전제** (`feat/v2-phase17-friction-pack` 기준). 이 문서 내부의 병렬 없음 — Phase 18 단일. 작업 브랜치는 Phase 17 완료 커밋 위 `feat/v2-phase18-one-tap-chip`(스택 PR 관례 — 커밋 전 브랜치 확인). +2. 서버 트랙(로드맵 S1~S4)과 **완전 독립** — 미확정 입력을 새로 요구하지 않는다. 검수는 [자동 검수 규약](atcha-v2-auto-verification.md)에 따라 DEV 데모 폴백을 재료로 에이전트가 직접 수행한다. +3. 현재 위치 검수 재료는 **`xcrun simctl location booted set ,`**(헤드리스 좌표 주입)으로 확보한다. Mac 화면 잠금 시에는 규약의 대체 경로(XCUITest 하니스)를 그대로 상속한다 — 칩 탭·타이핑·권한 알럿 전부 그 경로로 성립한다(2026-08-24 실측 승계). + +--- + +## Phase 18 — 최근 경로 원탭 칩 + +### Goal +반복 사용자의 재검색이 홈에서 끝난다 — 마지막 도착지가 칩으로 홈에 상주하고(최근 검색의 표면 확장), 탭 한 번에 현재 위치 기준 막차가 카드로 표출되며(검색 화면 생략), 알람 등록은 여전히 명시적 버튼 탭만이 수행한다(자동 등록 금지). + +### Requirements + +- **DesignSystem — `DSChip` 신설**: + ```swift + /// 컴팩트 pill 칩 — 동적 텍스트(setText) 지원이 DSButton(title init 고정)과의 차이다. + public final class DSChip: UIButton { + public init() + public func setText(_ text: String) + } + ``` + 결정사항의 시각 스펙(height 32·label2·`DSRadius.lg` pill·secondary 계열 색·disabled 상태)과 DSButton의 configuration 패턴(eager 컬러 적용 + `configurationUpdateHandler` — 호스트 없는 테스트에서도 초기 구성이 완전해야 한다)을 따른다. `DSChipTests`(기존 DS 테스트 관례 — 초기 구성·setText 반영·disabled 색) + `ComponentDemos` 갤러리 데모 1행. +- **HomeFeature — 칩 상태·탭 플로우·승격 저장**: + - `HomeViewModel`: `init`에 `searchLastRoutesUseCase: any SearchLastRoutesUseCase`·`recentSearchesUseCase: any RecentSearchesUseCase` 주입 추가. `State`에 `recentRouteChipText: String?`(nil = 칩 숨김)·`isChipBusy: Bool = false` 추가, 원본 `chipPlace: Place?`는 private 보관(표시는 문자열, 검색은 Place — Entity 뷰 노출 금지 관례). Task 3개 신설·보관·deinit cancel: `chipTask`(fetch), `chipSaveTask`(승격 저장 — fetch류가 저장을 cancel하면 안 되므로 분리, 검색 VM `saveTask` 선례), `chipSearchTask`(원탭 재검색). + - `refreshChip()` (private): `chipSaveTask` 진행 중이면 no-op → `recentSearchesUseCase.fetch()` 최신 1건으로 `chipPlace`·`recentRouteChipText`(`"→ {name}"`) 갱신, 0건이면 nil. `viewDidLoad()`와 신설 `viewWillAppear()`가 호출한다. + - `routeSelected(_:arrival:)` 확장: `chipPlace = arrival` + 칩 텍스트 직접 세팅(즉시 정합) + `chipSaveTask`로 `save(arrival)` 승격(완료 시 task nil 리셋 — `refreshChip`의 no-op 가드 해제 지점). 기존 카드·arrivalText·배너·버튼 전이는 무변경. + - `chipTapped()`: `chipPlace` 존재 ∧ `!isChipBusy` 가드 → `isChipBusy = true` → `chipSearchTask`에서 현재 위치 조회 → 재검색 → 결과 분기(결정사항 표). 성공(`.available` 비어 있지 않음)은 `routeSelected(routes[0], arrival: chipPlace)` 수렴, 빈 `available`은 `noRoute` 취급(검색 VM의 방어 선례). 종료 시 `isChipBusy = false`(성공·실패 공통). `[weak self]` + `Task.isCancelled` 가드 관례 준수. + - `ToastEvent`에 4건 추가: `chipLocationUnavailable`·`chipSearchFailed`·`chipServiceEnded`·`chipNoRoute`. 위치 실패의 denied/전역 OFF/restricted는 **기존 3종 이벤트를 그대로 재발화**한다(분기 로직은 `loadCurrentLocation`의 catch와 같은 매핑 — 문구·액션 중복 신설 금지). + - `HomeViewController`: `viewWillAppear`에서 `viewModel.viewWillAppear()` 호출 추가. 도착지 필드 행 아래에 칩 행(래퍼 UIView + `DSChip` leading 고정 — 스택 전폭 늘림 방지) 삽입, `render`가 `recentRouteChipText` nil ↔ 행 숨김·`setText` 반영·`isChipBusy` ↔ `isEnabled` 반영. 칩 액션 → `chipTapped()`. 신규 토스트 4건 매핑(전부 액션 없음, 문구는 결정사항 표). + - `HomeDIContainer`: init에 UseCase 2건 추가(파괴적 변경 — 콜사이트는 `AppDIContainer`·Home Example뿐, 전부 따라간다) + `makeHomeViewController` 주입. +- **App — 조합 루트 공유 배선 (이 Phase의 App 변경은 이것뿐)**: + - `AppDIContainer.makeHomeDIContainer`: `DefaultSearchLastRoutesUseCase`·`DefaultRecentSearchesUseCase`를 지역 상수로 1회 생성해 **Search·Home 컨테이너에 같은 인스턴스를 공유**한다(같은 저장소를 봐야 검색의 저장·삭제가 칩에 그대로 비친다 — 기존 `recentSearchRepository` 1회 생성과 같은 이유). +- **Example — Home 스텁 추종**: `PreviewRecentSearchesUseCase`(canned 1건 — `makeCannedArrival()` 재사용, 칩 즉시 표출)·`PreviewSearchLastRoutesUseCase`(canned 경로 1건 반환 — 칩 탭 시연 성립) 신설·주입. +- **테스트 (Swift Testing — 스텁·주입, 실 Date()·실 UserDefaults 금지)**: + - `HomeFeatureTests`: ① 최근 1건 이상 → `viewDidLoad` 후 칩 `"→ {name}"` / 0건 → nil ② `routeSelected` → 칩 즉시 갱신 + 승격 save 호출 기록 검증 ③ `chipTapped` 성공 → 카드(featured)·arrivalText·`alarmButton == .register`·배너 nil + busy 해제 + **`RegisterAlarmUseCase` 호출 0회(자동 등록 금지 직접 검증)** ④ 위치 실패 4분기(denied·전역 OFF·restricted → 기존 토스트 3종 / unavailable → `chipLocationUnavailable`) + 카드 무변경 ⑤ `serviceEnded` → `chipServiceEnded` / `noRoute`·빈 `available` → `chipNoRoute` / throw → `chipSearchFailed`, 전부 카드 무변경 + busy 해제 ⑥ 더블 탭 → 재검색 1회(busy 가드) ⑦ `viewWillAppear` 재조회 — 삭제 반영(칩 nil 전환)·승격 save 진행 중 no-op ⑧ 기존 배너·스탬프·복원 테스트 무회귀. + - `DesignSystemTests`: `DSChipTests` (위 DS 절). + +### Constraints +- **즐겨찾기 선취 금지**: 칩 데이터의 유일한 원천은 `RecentSearchRepository` — 칩 전용 저장 키·별도 영속화·관리 UI(삭제·고정·다중) 금지. `AlarmSessionSnapshot` 확장 금지(재실행 복원 arrivalText placeholder 수용은 Phase 17 결정 그대로 — 칩은 그것과 별개 표면으로 공존한다). +- **알람 자동 등록 금지**: 칩 탭 경로에서 `RegisterAlarmUseCase`·`AlarmScheduler`에 닿는 코드 금지. 밤 시간대 자동 재검색·제안 카드([제품 결정 트랙](../planning/atcha-v2-post12-roadmap.md) v1.1 후보)·"집" 배지 선취 금지 — 칩은 탭이라는 명시적 의사가 있을 때만 검색한다. +- **SearchFeature·Domain·AtchaData·CoreStorage 소스 변경 0.** Interface 공개 계약 변경 0. `DevDemoFallbacks` 변경 0. 매니페스트(`Project.swift`/`Workspace.swift`)·`Tuist/Package.swift`·`Package.resolved` 무변경 — 신규 타겟 없음. 단 신규 소스 파일(`DSChip.swift` 등)은 생성 시점 글롭에 잡히도록 `tuist generate --no-open` 1회 재생성이 필요하다(2026-08-24 실측 — 매니페스트 무변경과 별개다). +- 알람·LA·sync·스냅샷 경로 무변경. 홈 pull-to-refresh·스탬프·배너·복원 회귀 금지 — 칩 성공 경로는 `routeSelected` 수렴이라 이 표면들과 같은 규칙을 자동 상속한다. +- DesignSystem 변경은 `DSChip`(+테스트·갤러리 데모)에 한정 — 기존 컴포넌트 API 파괴적 변경 금지. +- 미확정 #3(serviceEnded/noRoute의 responseCode 실측) 해소 시도 금지 — 정규화 로직·TODO 불변. + +### Acceptance +공통 acceptance(Debug+Stage 빌드) + `-scheme DesignSystem test` + `-scheme HomeFeature test` + `-scheme AtchaV2 test`(조합 루트 변경 회귀) + `HomeFeatureExample` Debug 빌드(스텁 시그니처 추종 확인) + `git diff --stat`으로 SearchFeature·Domain·AtchaData·매니페스트·`Package.resolved` 무변경 확인. + +### 자동 검수 (블로킹 — [자동 검수 규약](atcha-v2-auto-verification.md)) +iOS 26 시뮬레이터(Debug/DEV)에서 에이전트가 직접 수행하고 단계별 스크린샷 증적으로 보고. 재료: DEV 데모 폴백(기존 그대로) + `simctl location set` 좌표 주입 + Phase 17 검수 훅("경로없음" 데모 장소). Mac 잠금 시 XCUITest 하니스 대체 경로 상속. +① **칩 없음 — 초기 상태**: 앱 데이터 초기화(`simctl uninstall` 후 재설치)·위치 허용으로 실행 → 홈에 칩 미표출 스크린샷 (최근 검색 0건 = 칩 없음의 직접 증명). +② **칩 생성**: 검색 진입 → 데모 장소를 도착지로 선택 → 데모 경로 선택 → 홈 복귀 → **칩 "→ {장소명}" + arrivalField 동시 표출** 스크린샷. +③ **칩 영속**: `simctl terminate` → 재실행 → 홈에 칩 유지 스크린샷 (재실행 복원 경로의 arrivalText는 placeholder 유지 — 칩과의 공존이 정상 상태다). +④ **원탭 재검색 (핵심)**: 칩 탭 → **검색 화면 진입 없이** 홈에 경로 카드 즉시 표출 — 탭 직전·직후 스크린샷 쌍으로 화면 전환 없음을 증명. 카드와 함께 "알람 등록하기" 버튼이 노출되되 **배너 없음 = 알람 미등록**(자동 등록 금지의 화면 증거) 확인. +⑤ **칩 serviceEnded**: 검색에서 "경로없음" 데모 장소를 도착지로 선택(최근 1위 갱신) → serviceEnded 화면에서 백 → 홈 칩 "→ 경로없음 (데모)" → 탭 → **"오늘 막차가 끊겼어요" 토스트 + 카드 무변경** 스크린샷. +⑥ **위치 거부 분기**: 재설치 후 위치 권한 "허용 안 함" → 검색 플로우(출발지·도착지 모두 수동)로 최근을 만든 뒤 → 칩 탭 → "위치 권한이 꺼져 있어요"+설정 이동 토스트 스크린샷 (denied 분기 — 카드 미표출). 전역 위치 OFF·restricted는 Phase 17과 동일 취급(설정 조작 성립 시에만 시도, restricted는 항상 실기기 잔여 — 매핑은 기존 테스트 담보). +⑦ **최근 삭제 정합**: 검색 진입 → 최근 검색 전부 삭제 → 백 → **홈 칩 소멸** 스크린샷 (viewWillAppear 재조회의 직접 증명). +⑧ **명시적 등록 + 회귀**: 칩 탭으로 표출된 카드에서 "알람 등록하기" AXPress(권한 알럿 처리 포함) → **배너+스탬프+카드 동시 표출** 정상(Phase 16·17 표면 회귀 없음) 스크린샷. +경로 검색 throw 폴백(`chipSearchFailed`)은 DEV 데모 폴백이 실패를 삼켜 시뮬레이터 재현이 불가하다 — 단위 테스트 ⑤로 갈음하고 그렇게 기록한다. + +**실기기 잔여**: 실기기 GPS 기반 칩 재검색 실측(시뮬 좌표 주입은 참고값). restricted(스크린타임 제약) 실측(Phase 17 승계). 사일런트 푸시 실수신(기존 항상-잔여 항목 — 이 Phase 무관 승계). + +> **검수 결과 기록 (2026-08-24 실측)**: [규약의 XCUITest 하니스 경로](atcha-v2-auto-verification.md)(Phase 17 하니스 승계)로 ①~⑧ 전 시나리오 자동 검수 **통과**(Run 1: 위치 허용 7건 + Run 2: 위치 거부 1건, 증적 p18-01~p18-10). Run 2는 도착지→출발지 순 확정으로 저장 순서상 최근 1위가 출발지가 되는 상황을 만들어 **승격 저장(칩 = 확정 도착지)을 화면으로 직접 검증**했다. 검수 중 앱 결함 0건 — 초기 2회 실패는 전부 하니스·환경 원인: ① 권한 알럿 자동화의 `label IN` firstMatch가 **"한 번 허용(Allow Once)"을 눌러** 재실행부터 권한이 소멸(→ 위치 재조회가 nil 업데이트 후 즉시 종료 = unavailable), ② `simctl location start`(waypoint)·`simctl location set` 펌프·`XCUIDevice.location` 주입 모두 Allow Once 상태의 재조회 스트림을 구제하지 못함. **While-Using 정식 허용 + 사전 `simctl location set` 정적 좌표**면 재조회 스트림(검색 프리필·칩 탭)까지 픽스가 정상 도달한다 — 규약에 실측 추가. 실기기 잔여는 위 목록 그대로. + +--- + +## 진행 프로토콜 + +[마스터 프롬프트의 진행 프로토콜](atcha-v2-master-prompt.md#진행-프로토콜) 1~6을 그대로 상속한다. 추가 규칙: + +1. 이 문서는 **Phase 18 단일** — 내부 병렬 없음. Phase 17 완료 커밋 위 `feat/v2-phase18-one-tap-chip` 브랜치에서 진행한다(커밋 전 브랜치 확인). +2. ["이 문서가 다시 정의하지 않는 것"](#이-문서가-다시-정의하지-않는-것-중복-금지) 표의 계약을 바꾸고 싶어지면 멈추고 사용자에게 물을 것. +3. **[제품 결정 트랙](../planning/atcha-v2-post12-roadmap.md)의 산출물을 선취하지 않는다** — 밤 시간 자동 재검색·"집" 배지·홈 위치 기반 자동 막차는 별도 논의 후의 몫이다. +4. 검수는 [자동 검수 규약](atcha-v2-auto-verification.md)을 따른다 — "자동 검수 (블로킹)"는 에이전트가 computer use로 수행·증적 보고를 마쳐야 하고, "실기기 잔여"만 사용자에게 이관한다. **acceptance 전부 실행·보고 + 자동 검수 증적 보고 후 정지** — 다음 Phase로 진행하지 않는다. +5. 자동화 불가가 확정된 항목은 결과를 **이 문서 자동 검수 절에 기록**하고 명시된 대체 경로로 검수를 완료한다. + +## 미확정 입력 (참조) + +이 문서는 새 미확정 입력을 만들지 않는다. 관련 기존 항목의 취급: + +| # | 항목 | 이 문서에서의 취급 | +|---|---|---| +| 3 | "막차 종료"/"경로 없음"의 서버 표현 (responseCode 실측값) | **미해소 유지** — 칩은 정규화 결과(`serviceEnded`/`noRoute`)를 토스트로 표면화할 뿐이다. 검수 ⑤는 이 가정(빈 목록 = serviceEnded) 위에서 기존 훅을 재료로 쓴다 | +| 원장 전체 | [Post-12 로드맵](../planning/atcha-v2-post12-roadmap.md)의 "미확정 입력 원장" 절 참조 | |