Skip to content
20 changes: 16 additions & 4 deletions Projects/App/Sources/Adapters/CoreLocationServiceAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,25 @@ import Domain
/// App 모듈 기본 격리가 MainActor라 이 클래스는 암시적 Sendable — 프로토콜의
/// nonisolated async 요구사항은 격리 witness로 충족된다.
final class CoreLocationServiceAdapter: LocationService {
/// CLLocationUpdate의 거부 플래그 3종 → LocationError 매핑(Phase 17). nil = 진행 중.
/// 우선순위: restricted > 전역 OFF > 앱 권한 거부 — 더 좁은 회복 경로가 이긴다
/// (restricted는 설정으로 못 풀고, 전역 OFF는 앱 권한 상태를 무의미하게 만든다).
static func classify(denied: Bool, deniedGlobally: Bool, restricted: Bool) -> LocationError? {
if restricted { return .restricted }
if deniedGlobally { return .servicesDisabled }
if denied { return .permissionDenied }
return nil
}

func currentLocation() async throws -> Coordinate {
do {
for try await update in CLLocationUpdate.liveUpdates() {
if update.authorizationDenied
|| update.authorizationDeniedGlobally
|| update.authorizationRestricted {
throw LocationError.permissionDenied
if let error = Self.classify(
denied: update.authorizationDenied,
deniedGlobally: update.authorizationDeniedGlobally,
restricted: update.authorizationRestricted
) {
throw error
}
if let location = update.location {
return Coordinate(
Expand Down
48 changes: 45 additions & 3 deletions Projects/App/Sources/Adapters/DevDemoFallbacks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,27 @@ import Foundation
struct DevDemoFallbackPlaceRepository: PlaceRepository {
let base: any PlaceRepository

/// Phase 17 검수 훅 — 이 좌표의 장소를 도착지로 고르면 경로 검색이 빈 목록을 반환한다
/// (빈 목록 정규화 = serviceEnded 표면의 유일한 DEV 재현 수단).
static let emptyRouteCoordinate = Coordinate(latitude: 0, longitude: 0)

func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] {
// Phase 17 검수 훅 — 예약 키워드는 base보다 먼저 판정한다("실패 시에만" 원칙의
// 명시적 예외, 검수 결정론 확보). 빈 상태·serviceEnded 화면의 재현 재료.
if keyword == "결과없음" {
print("⚠️ [DEV 검수 훅] 예약 키워드 → 장소 0건 반환")
return []
}
if keyword == "경로없음" {
print("⚠️ [DEV 검수 훅] 예약 키워드 → 빈 경로 유도 데모 장소 반환")
return [
Place(
name: "경로없음 (데모)",
address: "도착지로 선택하면 빈 경로 응답을 시연해요",
coordinate: Self.emptyRouteCoordinate
),
]
}
do { return try await base.searchPlaces(keyword: keyword, near: coordinate) }
catch {
print("⚠️ [DEV 우회] 장소 검색 실패 → 데모 장소 반환: \(error)")
Expand Down Expand Up @@ -45,15 +65,22 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository {
let base: any LastRouteRepository

func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] {
// Phase 17 검수 훅 — 예약 도착지(경로없음 데모 장소)는 base보다 먼저 판정한다.
// 빈 목록은 정규화 가정(미확정 #3)에 따라 serviceEnded로 표면화된다.
if end == DevDemoFallbackPlaceRepository.emptyRouteCoordinate {
print("⚠️ [DEV 검수 훅] 예약 도착지 → 빈 경로 목록 반환 (serviceEnded 정규화 재료)")
return []
}
do { return try await base.searchLastRoutes(start: start, end: end) }
catch {
print("⚠️ [DEV 우회] 막차 검색 실패 → 데모 경로 반환: \(error)")
let route = Self.demoRoute(start: start, end: end)
// 변경 시뮬레이터의 기준 출발 시각 — 데모 흐름에선 검색 직후 이 경로가 등록된다.
// 대안(내일 출발)은 기준 시각에 관여하지 않는다 — index 0 정합 불변.
await DevChangeSimulator.shared.noteKnownSession(
routeId: route.id, departureTime: route.departureTime
)
return [route]
return [route, Self.tomorrowDemoRoute(start: start, end: end)]
}
}

Expand All @@ -76,15 +103,30 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository {
}
}

/// 오늘/내일 라벨(Phase 17) 검수 재료 — 더보기의 대안 경로로 항상 "내일" 출발이
/// 표시되게 다음 자정+10분을 출발 시각으로 잡는다(검수 시각 무관 결정론).
/// 알람 등록 검수 플로우는 featured(index 0)를 쓰므로 이 경로와 무관하다.
private static func tomorrowDemoRoute(start: Coordinate, end: Coordinate) -> LastRoute {
let startOfToday = Calendar.current.startOfDay(for: Date())
let nextMidnight = Calendar.current.date(byAdding: .day, value: 1, to: startOfToday)
?? startOfToday.addingTimeInterval(24 * 60 * 60)
return demoRoute(
start: start,
end: end,
departure: nextMidnight.addingTimeInterval(10 * 60),
id: "dev-demo-route-tomorrow"
)
}

/// 출발 시각은 8분 뒤 — 알람이 도보(첫 walk leg 120초)+버퍼(180초) 반영으로 +3분
/// 시점에 걸린다(Phase 14 검수 ③: 배너·LA·발화가 전부 "출발 − 도보 − 3분" 기준).
/// 더 이르면 등록 탭 시점에 이미 과거가 되어 tooLate 가드·AlarmKit 거부에 걸린다.
private static func demoRoute(
start: Coordinate, end: Coordinate, departure: Date? = nil
start: Coordinate, end: Coordinate, departure: Date? = nil, id: String = "dev-demo-route"
) -> LastRoute {
let departure = departure ?? Date().addingTimeInterval(8 * 60)
return LastRoute(
id: "dev-demo-route",
id: id,
departureTime: departure,
totalTime: 2940,
totalWalkTime: 480,
Expand Down
5 changes: 2 additions & 3 deletions Projects/App/Sources/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,8 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate {
/// 스플래시 단계의 탭이면 popToRoot가 스플래시에 머무를 뿐 — 부트스트랩 후 홈 자연 랜딩.
func returnToHome() {
navigationController.presentedViewController?.dismiss(animated: false)
// 프로그램적 pop은 SearchCoordinator.closeFlow()를 타지 않아 자식 코디네이터가
// 잔존할 수 있다 — 스와이프 백 누수와 같은 계열이라 Phase 17
// (UINavigationControllerDelegate 정리)이 일괄 해소한다. 여기서 선취하지 않는다.
// 프로그램적 pop도 SearchCoordinator의 didShow 정리 경로를 탄다(Phase 17) —
// 백 버튼·스와이프 백과 같은 단일 지점에서 자식 코디네이터가 finish된다.
navigationController.popToRootViewController(animated: false)
}

Expand Down
38 changes: 38 additions & 0 deletions Projects/App/Tests/LocationErrorClassifyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
@testable import AtchaV2
import Domain
import Testing

/// CLLocationUpdate 거부 플래그 → LocationError 매핑(Phase 17) — 사유별 안내 분기의 원천.
@MainActor
struct LocationErrorClassifyTests {
@Test
func eachFlag_mapsToItsOwnCase() {
#expect(CoreLocationServiceAdapter.classify(
denied: true, deniedGlobally: false, restricted: false
) == .permissionDenied)
#expect(CoreLocationServiceAdapter.classify(
denied: false, deniedGlobally: true, restricted: false
) == .servicesDisabled)
#expect(CoreLocationServiceAdapter.classify(
denied: false, deniedGlobally: false, restricted: true
) == .restricted)
}

@Test
func noFlags_meansStillInProgress() {
#expect(CoreLocationServiceAdapter.classify(
denied: false, deniedGlobally: false, restricted: false
) == nil)
}

@Test
func priority_narrowerRecoveryPathWins() {
// restricted > 전역 OFF > 앱 권한 거부 — 복합 플래그에선 더 좁은 회복 경로가 이긴다.
#expect(CoreLocationServiceAdapter.classify(
denied: true, deniedGlobally: true, restricted: true
) == .restricted)
#expect(CoreLocationServiceAdapter.classify(
denied: true, deniedGlobally: true, restricted: false
) == .servicesDisabled)
}
}
7 changes: 6 additions & 1 deletion Projects/Domain/Sources/Entities/LocationError.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
/// 위치 조회 실패 사유 — 권한 거부를 구분해야 Feature가 "검색 유도 + 설정 이동" UX로 분기할 수 있다.
/// 위치 조회 실패 사유 — 사유별로 회복 경로가 다르므로 Feature가 안내를 분기한다(Phase 17).
public enum LocationError: Error, Equatable, Sendable {
/// 이 앱의 권한 거부 — "설정으로 이동"이 유효한 회복 경로다.
case permissionDenied
/// 스크린타임·MDM 제약 — 사용자가 설정으로 못 푼다. "설정으로 이동" 안내 금지.
case restricted
/// 기기 전역 위치 서비스 OFF — 앱 권한이 아니라 시스템 설정의 문제.
case servicesDisabled
case unavailable
}
18 changes: 14 additions & 4 deletions Projects/Feature/Home/Example/ExampleApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ struct PreviewGetLastRouteDetailUseCase: GetLastRouteDetailUseCase {
struct PreviewSearchCoordinatorBuildable: SearchCoordinatorBuildable {
func makeSearchCoordinator(
navigationController: UINavigationController,
onRouteSelected: @escaping (LastRoute) -> Void
initialField: SearchEntryField,
onRouteSelected: @escaping (LastRoute, Place) -> Void
) -> any Coordinator {
PreviewSearchCoordinator(onRouteSelected: onRouteSelected)
}
Expand All @@ -131,17 +132,26 @@ final class PreviewSearchCoordinator: Coordinator {
var childCoordinators: [any Coordinator] = []
weak var finishDelegate: (any CoordinatorFinishDelegate)?

private let onRouteSelected: (LastRoute) -> Void
private let onRouteSelected: (LastRoute, Place) -> Void

init(onRouteSelected: @escaping (LastRoute) -> Void) {
init(onRouteSelected: @escaping (LastRoute, Place) -> Void) {
self.onRouteSelected = onRouteSelected
}

func start() {
onRouteSelected(Self.makeCannedRoute())
onRouteSelected(Self.makeCannedRoute(), Self.makeCannedArrival())
finish()
}

// 도착지 필드 바인딩(Phase 17) 시연용 — canned 경로의 하차지와 같은 동네.
nonisolated static func makeCannedArrival() -> Place {
Place(
name: "구로디지털단지역",
address: "서울 구로구 도림천로 486",
coordinate: Coordinate(latitude: 37.4853, longitude: 126.9015)
)
}

// nonisolated: 상세 재조회 스텁(nonisolated async)에서도 공유한다.
nonisolated static func makeCannedRoute() -> LastRoute {
let departure = Date().addingTimeInterval(42 * 60)
Expand Down
11 changes: 8 additions & 3 deletions Projects/Feature/Home/Sources/HomeCoordinator.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CoreCoordinator
import Domain
import SearchFeatureInterface
import UIKit

final class HomeCoordinator: Coordinator, CoordinatorFinishDelegate {
Expand All @@ -17,19 +18,23 @@ final class HomeCoordinator: Coordinator, CoordinatorFinishDelegate {

func start() {
let viewController = container.makeHomeViewController(
onSearchRequested: { [weak self] onRouteSelected in
self?.startSearchFlow(onRouteSelected: onRouteSelected)
onSearchRequested: { [weak self] initialField, onRouteSelected in
self?.startSearchFlow(initialField: initialField, onRouteSelected: onRouteSelected)
}
)
navigationController?.pushViewController(viewController, animated: false)
}

// MARK: - 검색 플로우

private func startSearchFlow(onRouteSelected: @escaping (LastRoute) -> Void) {
private func startSearchFlow(
initialField: SearchEntryField,
onRouteSelected: @escaping (LastRoute, Place) -> Void
) {
guard let navigationController else { return }
let child = container.makeSearchCoordinator(
navigationController: navigationController,
initialField: initialField,
onRouteSelected: onRouteSelected
)
// start() 안에서 동기로 finish()될 수 있으므로(예: Example 스텁) 배선을 먼저 끝낸다.
Expand Down
9 changes: 7 additions & 2 deletions Projects/Feature/Home/Sources/HomeDIContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ public final class HomeDIContainer: HomeCoordinatorBuildable {
}

func makeHomeViewController(
onSearchRequested: @escaping (_ onRouteSelected: @escaping (LastRoute) -> Void) -> Void
onSearchRequested: @escaping (
_ initialField: SearchEntryField,
_ onRouteSelected: @escaping (LastRoute, Place) -> Void
) -> Void
) -> UIViewController {
let viewModel = HomeViewModel(
getCurrentLocationUseCase: getCurrentLocationUseCase,
Expand All @@ -63,10 +66,12 @@ public final class HomeDIContainer: HomeCoordinatorBuildable {

func makeSearchCoordinator(
navigationController: UINavigationController,
onRouteSelected: @escaping (LastRoute) -> Void
initialField: SearchEntryField,
onRouteSelected: @escaping (LastRoute, Place) -> Void
) -> any Coordinator {
searchCoordinatorBuildable.makeSearchCoordinator(
navigationController: navigationController,
initialField: initialField,
onRouteSelected: onRouteSelected
)
}
Expand Down
31 changes: 27 additions & 4 deletions Projects/Feature/Home/Sources/HomeViewController.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import DesignSystem
import Domain
import SearchFeatureInterface
import SnapKit
import UIKit

Expand All @@ -18,8 +19,12 @@ final class HomeViewController: UIViewController {
private let banner = DSBanner()
private let departureField = DSTextField(placeholder: "출발지를 검색해 주세요", showsAccentDot: true)
private let arrivalField = DSTextField(placeholder: "도착지를 검색해 주세요")
private lazy var departureRow = makeFieldRow(icon: DSIcon.myLocation24, field: departureField)
private lazy var arrivalRow = makeFieldRow(icon: DSIcon.place24, field: arrivalField)
private lazy var departureRow = makeFieldRow(
icon: DSIcon.myLocation24, field: departureField, entry: .departure
)
private lazy var arrivalRow = makeFieldRow(
icon: DSIcon.place24, field: arrivalField, entry: .arrival
)
private let routeCard = DSRouteCard()
private let registerButton = DSButton(title: "알람 등록하기")
// DSButton은 title이 init 고정이라 토글은 버튼 2개의 표시 전환으로 구현한다.
Expand Down Expand Up @@ -125,7 +130,10 @@ final class HomeViewController: UIViewController {

/// 홈의 필드는 편집이 아니라 검색 진입 트리거다. DSTextField에는 편집 시작 훅이
/// 없으므로 필드 터치를 통째로 죽이고 UIControl 래퍼가 탭을 가져간다.
private func makeFieldRow(icon: UIImage, field: DSTextField) -> UIControl {
/// 탭한 필드가 검색 진입 슬롯이 된다(Phase 17) — entry가 그대로 넘어간다.
private func makeFieldRow(
icon: UIImage, field: DSTextField, entry: SearchEntryField
) -> UIControl {
let row = UIControl()
let iconView = UIImageView(image: icon)
iconView.tintColor = DSColor.Icon.default
Expand All @@ -143,7 +151,7 @@ final class HomeViewController: UIViewController {
make.top.trailing.bottom.equalToSuperview()
}
row.addAction(
UIAction { [weak self] _ in self?.viewModel.searchFieldTapped() },
UIAction { [weak self] _ in self?.viewModel.searchFieldTapped(entry) },
for: .touchUpInside
)
return row
Expand Down Expand Up @@ -210,6 +218,9 @@ final class HomeViewController: UIViewController {
departureField.setText("")
}

// 도착지 필드 = 선택 경로의 도착지명(Phase 17). nil이면 placeholder가 유도한다.
arrivalField.setText(state.arrivalText ?? "")

if let card = state.routeCard {
// 신선도 스탬프(Phase 16)는 세션 상태라 State가 따로 나른다 — 표출 시점 합성.
routeCard.configure(with: card.dsContent(footnote: state.freshnessText))
Expand Down Expand Up @@ -254,6 +265,18 @@ final class HomeViewController: UIViewController {
UIApplication.shared.open(url)
}
)
case .locationServicesDisabled:
DSToast.show(
"기기의 위치 서비스가 꺼져 있어요",
in: view,
action: .init(title: "설정으로 이동") {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
)
case .locationRestricted:
// restricted는 설정으로 못 푸는 제약 — "설정으로 이동"을 안내하지 않는다(Phase 17).
DSToast.show("이 기기에선 위치를 사용할 수 없어요. 출발지를 검색해 주세요", in: view)
case .alarmPermissionNeeded:
DSToast.show(
"알람 권한이 꺼져 있어요",
Expand Down
16 changes: 13 additions & 3 deletions Projects/Feature/Home/Sources/HomeViewData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ extension HomeViewModel {
}
}

/// 자정 넘김 표기(Phase 17) — 내일이면 "내일 " 접두, 오늘·그 외는 무라벨(무라벨 = 오늘).
/// 이틀+ 미래·과거는 막차 도메인상 비발생 — 방어적 무라벨. 피처 간 공유 모듈을 만들지
/// 않으므로 SearchFeature와 중복이다(TransportBadgeMapper 선례).
func dayPrefix(for date: Date, now: Date, calendar: Calendar = .current) -> String {
guard let tomorrow = calendar.date(byAdding: .day, value: 1, to: now) else { return "" }
return calendar.isDate(date, inSameDayAs: tomorrow) ? "내일 " : ""
}

/// 홈에 표출되는 선택 경로 카드. Entity를 뷰에 직접 노출하지 않는다.
struct RouteCardViewData: Equatable {
/// 카드 톤 — past는 유예 경과 후의 "지난 막차" 상태(비활성 시각, Phase 13).
Expand All @@ -34,14 +42,16 @@ struct RouteCardViewData: Equatable {
let destinationText: String
let tone: Tone

init(entity: LastRoute) {
init(entity: LastRoute, now: Date) {
badgeText = "가장 늦은 차"
departureTimeText = "\(timeFormatter.string(from: entity.departureTime)) 출발"
let departurePrefix = dayPrefix(for: entity.departureTime, now: now)
departureTimeText = "\(departurePrefix)\(timeFormatter.string(from: entity.departureTime)) 출발"
legs = TransportBadgeMapper.kinds(for: entity.legs)
summaryText = Self.summary(from: entity.legs)

let arrival = entity.departureTime.addingTimeInterval(TimeInterval(entity.totalTime))
destinationText = "도착 \(timeFormatter.string(from: arrival)) · 환승 \(entity.transferCount)회"
let arrivalPrefix = dayPrefix(for: arrival, now: now)
destinationText = "도착 \(arrivalPrefix)\(timeFormatter.string(from: arrival)) · 환승 \(entity.transferCount)회"
tone = .normal
}

Expand Down
Loading