diff --git a/CLAUDE.md b/CLAUDE.md index 8c168ec..c36de38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ xcodebuild -workspace Atcha.xcworkspace -scheme AtchaV2 -configuration Debug \ # 모듈 테스트 (Swift Testing 기반) xcodebuild -workspace Atcha.xcworkspace -scheme HomeFeature \ -destination 'platform=iOS Simulator,name=iPhone 17' test -# 단일 테스트: -only-testing:HomeFeatureTests/HomeViewModelTests/viewDidLoad_success_transitionsLoadingToLoaded +# 단일 테스트: -only-testing:HomeFeatureTests/HomeViewModelTests/viewDidLoad_locationSuccess_showsReverseGeocodedName # 의존 그래프 확인 (graph.dot 생성, gitignore됨) tuist graph --format dot --no-open @@ -47,8 +47,10 @@ xcodebuild -workspace Atcha.xcworkspace -scheme Atcha-Dev -configuration Debug \ ## 아키텍처 (AtchaV2 — uFeatures + 클린아키텍처) ``` -AtchaV2(앱, 조합 루트) ─► HomeFeature ─► {HomeFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} - └─► AtchaData ─► {Domain, CoreNetwork} +AtchaV2(앱, 조합 루트: 어댑터·스플래시) ─► HomeFeature ─► {HomeFeatureInterface, SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} + ├─► SearchFeature ─► {SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} + ├─► AtchaData ─► {Domain, CoreNetwork, CoreStorage} + └─► CoreAuth ─► {CoreNetwork, CoreStorage} ``` 의존 규칙(위반 금지, `tuist graph`로 검증 가능): diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 5e4857f..cff8c5c 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -11,6 +11,7 @@ let appTarget = Target.target( "CFBundleDisplayName": "앗차", "UILaunchScreen": [:], "NSAlarmKitUsageDescription": "막차 시간에 맞춰 알람을 울리기 위해 권한이 필요합니다.", + "NSLocationWhenInUseUsageDescription": "현재 위치를 출발지로 사용하기 위해 위치 정보 접근 권한이 필요합니다.", "UIApplicationSceneManifest": [ "UIApplicationSupportsMultipleScenes": false, "UISceneConfigurations": [ @@ -30,6 +31,8 @@ let appTarget = Target.target( dependencies: [ .project(target: "HomeFeature", path: "../Feature/Home"), .project(target: "HomeFeatureInterface", path: "../Feature/Home"), + .project(target: "SearchFeature", path: "../Feature/Search"), + .project(target: "SearchFeatureInterface", path: "../Feature/Search"), .project(target: "Domain", path: "../Domain"), .project(target: "AtchaData", path: "../Data"), .project(target: "CoreNetwork", path: "../Core/Network"), diff --git a/Projects/App/Sources/Adapters/CoreLocationServiceAdapter.swift b/Projects/App/Sources/Adapters/CoreLocationServiceAdapter.swift new file mode 100644 index 0000000..7d86d85 --- /dev/null +++ b/Projects/App/Sources/Adapters/CoreLocationServiceAdapter.swift @@ -0,0 +1,34 @@ +import CoreLocation +import Domain + +/// CoreLocation → Domain `LocationService` 어댑터. CoreLocation을 아는 곳은 여기뿐. +/// +/// `CLLocationUpdate.liveUpdates()`는 권한이 notDetermined이면 WhenInUse 요청을 +/// 스스로 띄우므로(plist 키 필요) delegate/continuation 없이 one-shot 조회가 된다. +/// App 모듈 기본 격리가 MainActor라 이 클래스는 암시적 Sendable — 프로토콜의 +/// nonisolated async 요구사항은 격리 witness로 충족된다. +final class CoreLocationServiceAdapter: LocationService { + 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 location = update.location { + return Coordinate( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ) + } + // 권한 요청 진행 중 / 일시적 위치 불가 → 다음 업데이트를 기다린다. + } + } catch let error as LocationError { + throw error + } catch { + throw LocationError.unavailable + } + throw LocationError.unavailable + } +} diff --git a/Projects/App/Sources/Adapters/NoopAlarmScheduler.swift b/Projects/App/Sources/Adapters/NoopAlarmScheduler.swift new file mode 100644 index 0000000..701c135 --- /dev/null +++ b/Projects/App/Sources/Adapters/NoopAlarmScheduler.swift @@ -0,0 +1,9 @@ +import Domain +import Foundation + +/// Phase 6 임시 어댑터: 서버 알람 등록은 실동작, 로컬 스케줄은 no-op. +/// Phase 7에서 CoreAlarm(AlarmKit) 기반 어댑터로 교체된다. +struct NoopAlarmScheduler: AlarmScheduler { + func replaceAlarm(id: String, fireDate: Date, title: String) async throws {} + func cancelAlarm() async {} +} diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 44e27a4..2e718f9 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -5,6 +5,8 @@ import CoreStorage import Domain import HomeFeature import HomeFeatureInterface +import SearchFeature +import SearchFeatureInterface /// Composition root — the only place that sees concrete Data/Network types. /// Presentation modules depend on Domain protocols only. @@ -33,8 +35,31 @@ final class AppDIContainer { } func makeHomeDIContainer() -> any HomeCoordinatorBuildable { - let repository: any HomeRepository = HomeRepositoryImpl(networkClient: networkClient) - let fetchHome: any FetchHomeUseCase = DefaultFetchHomeUseCase(repository: repository) - return HomeDIContainer(fetchHomeUseCase: fetchHome) + let placeRepository = PlaceRepositoryImpl(networkClient: networkClient) + let lastRouteRepository = LastRouteRepositoryImpl(networkClient: networkClient) + let alarmRepository = AlarmRepositoryImpl(networkClient: networkClient) + let recentSearchRepository = RecentSearchRepositoryImpl(store: UserDefaultsKeyValueStore()) + + // 디바이스 포트 어댑터. AlarmScheduler는 Phase 7에서 CoreAlarm 기반으로 교체. + let locationService = CoreLocationServiceAdapter() + let getCurrentLocation: any GetCurrentLocationUseCase = + DefaultGetCurrentLocationUseCase(locationService: locationService) + + let searchContainer = SearchDIContainer( + searchPlacesUseCase: DefaultSearchPlacesUseCase(repository: placeRepository), + searchLastRoutesUseCase: DefaultSearchLastRoutesUseCase(repository: lastRouteRepository), + recentSearchesUseCase: DefaultRecentSearchesUseCase(repository: recentSearchRepository), + getCurrentLocationUseCase: getCurrentLocation + ) + + return HomeDIContainer( + getCurrentLocationUseCase: getCurrentLocation, + reverseGeocodeUseCase: DefaultReverseGeocodeUseCase(repository: placeRepository), + registerAlarmUseCase: DefaultRegisterAlarmUseCase( + repository: alarmRepository, + scheduler: NoopAlarmScheduler() + ), + searchCoordinatorBuildable: searchContainer + ) } } diff --git a/Projects/App/Sources/SplashViewController.swift b/Projects/App/Sources/SplashViewController.swift index 56339f8..0a1c0f4 100644 --- a/Projects/App/Sources/SplashViewController.swift +++ b/Projects/App/Sources/SplashViewController.swift @@ -28,17 +28,17 @@ final class SplashViewController: UIViewController { } private func configureUI() { - view.backgroundColor = DSColor.background + view.backgroundColor = DSColor.Background.base logoLabel.text = "앗차" - logoLabel.font = DSFont.title(34) - logoLabel.textColor = DSColor.accent + logoLabel.font = DSFont.pretendard(.bold, size: 34) + logoLabel.textColor = DSColor.Accent.default activityIndicator.hidesWhenStopped = true messageLabel.text = "네트워크 연결을 확인해주세요" - messageLabel.font = DSFont.body() - messageLabel.textColor = DSColor.textPrimary + messageLabel.font = DSTypography.body1.font + messageLabel.textColor = DSColor.Text.primary messageLabel.textAlignment = .center retryButton.addAction( diff --git a/Projects/Data/Sources/DTO/HomeSummaryRequestDTO.swift b/Projects/Data/Sources/DTO/HomeSummaryRequestDTO.swift deleted file mode 100644 index 1b7aab4..0000000 --- a/Projects/Data/Sources/DTO/HomeSummaryRequestDTO.swift +++ /dev/null @@ -1,11 +0,0 @@ -public struct HomeSummaryRequestDTO: Encodable, Sendable { - public let userID: String - - public init(userID: String) { - self.userID = userID - } - - enum CodingKeys: String, CodingKey { - case userID = "user_id" - } -} diff --git a/Projects/Data/Sources/DTO/HomeSummaryResponseDTO.swift b/Projects/Data/Sources/DTO/HomeSummaryResponseDTO.swift deleted file mode 100644 index 31d58c6..0000000 --- a/Projects/Data/Sources/DTO/HomeSummaryResponseDTO.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Domain - -public struct HomeSummaryResponseDTO: Decodable, Sendable { - public let id: String - public let title: String - public let subtitle: String? - - public func toEntity() -> HomeSummary { - HomeSummary(id: id, title: title, subtitle: subtitle ?? "") - } -} diff --git a/Projects/Data/Sources/Network/HomeEndpoint.swift b/Projects/Data/Sources/Network/HomeEndpoint.swift deleted file mode 100644 index bcfb1cc..0000000 --- a/Projects/Data/Sources/Network/HomeEndpoint.swift +++ /dev/null @@ -1,25 +0,0 @@ -import CoreNetwork -import Foundation - -enum HomeEndpoint: Endpoint { - case summary(HomeSummaryRequestDTO) - - var path: String { - switch self { - case .summary: "/v2/home/summary" - } - } - - var method: HTTPMethod { - switch self { - case .summary: .get - } - } - - var queryItems: [URLQueryItem] { - switch self { - case let .summary(request): - [URLQueryItem(name: "user_id", value: request.userID)] - } - } -} diff --git a/Projects/Data/Sources/Repositories/HomeRepositoryImpl.swift b/Projects/Data/Sources/Repositories/HomeRepositoryImpl.swift deleted file mode 100644 index 18a0c5b..0000000 --- a/Projects/Data/Sources/Repositories/HomeRepositoryImpl.swift +++ /dev/null @@ -1,17 +0,0 @@ -import CoreNetwork -import Domain - -public struct HomeRepositoryImpl: HomeRepository { - private let networkClient: any NetworkClient - - public init(networkClient: any NetworkClient) { - self.networkClient = networkClient - } - - public func fetchHomeSummary() async throws -> HomeSummary { - let dto: HomeSummaryResponseDTO = try await networkClient.request( - HomeEndpoint.summary(HomeSummaryRequestDTO(userID: "me")) - ) - return dto.toEntity() - } -} diff --git a/Projects/Data/Tests/HomeSummaryResponseDTOTests.swift b/Projects/Data/Tests/HomeSummaryResponseDTOTests.swift deleted file mode 100644 index 7695226..0000000 --- a/Projects/Data/Tests/HomeSummaryResponseDTOTests.swift +++ /dev/null @@ -1,13 +0,0 @@ -@testable import AtchaData -import Domain -import Foundation -import Testing - -struct HomeSummaryResponseDTOTests { - @Test - func toEntity_mapsFieldsAndDefaultsNilSubtitle() throws { - let json = Data(#"{"id":"1","title":"막차까지 42분","subtitle":null}"#.utf8) - let dto = try JSONDecoder().decode(HomeSummaryResponseDTO.self, from: json) - #expect(dto.toEntity() == HomeSummary(id: "1", title: "막차까지 42분", subtitle: "")) - } -} diff --git a/Projects/DesignSystem/Sources/Foundation/DSColor.swift b/Projects/DesignSystem/Sources/Foundation/DSColor.swift index dce16b8..49f68b8 100644 --- a/Projects/DesignSystem/Sources/Foundation/DSColor.swift +++ b/Projects/DesignSystem/Sources/Foundation/DSColor.swift @@ -43,13 +43,4 @@ public enum DSColor { public static var danger: UIColor { DSPalette.red400 } public static var urgent: UIColor { DSPalette.red600 } } - - // Flat aliases kept so HomeFeature compiles untouched until the Phase 6 - // home overhaul migrates it to the semantic tokens. - @available(*, deprecated, renamed: "Accent.default") - public static var accent: UIColor { Accent.default } - @available(*, deprecated, renamed: "Background.base") - public static var background: UIColor { Background.base } - @available(*, deprecated, renamed: "Text.primary") - public static var textPrimary: UIColor { Text.primary } } diff --git a/Projects/DesignSystem/Sources/Foundation/DSFont.swift b/Projects/DesignSystem/Sources/Foundation/DSFont.swift index f4bae20..a163544 100644 --- a/Projects/DesignSystem/Sources/Foundation/DSFont.swift +++ b/Projects/DesignSystem/Sources/Foundation/DSFont.swift @@ -52,19 +52,4 @@ public enum DSFont { private static var didAttemptRegistration = false private static var registrationSucceeded = false - - @available(*, deprecated, message: "Use DSTypography presets") - public static func title(_ size: CGFloat = 22) -> UIFont { - pretendard(.bold, size: size) - } - - @available(*, deprecated, message: "Use DSTypography presets") - public static func body(_ size: CGFloat = 16) -> UIFont { - pretendard(.regular, size: size) - } - - @available(*, deprecated, message: "Use DSTypography presets") - public static func caption(_ size: CGFloat = 12) -> UIFont { - pretendard(.medium, size: size) - } } diff --git a/Projects/DesignSystem/Tests/DSFontTests.swift b/Projects/DesignSystem/Tests/DSFontTests.swift index ce679d1..f689a64 100644 --- a/Projects/DesignSystem/Tests/DSFontTests.swift +++ b/Projects/DesignSystem/Tests/DSFontTests.swift @@ -19,11 +19,4 @@ struct DSFontTests { let resolvable = UIFont(name: "Pretendard-Regular", size: 17) != nil #expect(registered == resolvable) } - - @Test - func deprecatedHelpersKeepDefaultSizes() { - #expect(DSFont.title().pointSize == 22) - #expect(DSFont.body().pointSize == 16) - #expect(DSFont.caption().pointSize == 12) - } } diff --git a/Projects/DesignSystem/Tests/DesignTokenTests.swift b/Projects/DesignSystem/Tests/DesignTokenTests.swift index 11039e8..fe695b7 100644 --- a/Projects/DesignSystem/Tests/DesignTokenTests.swift +++ b/Projects/DesignSystem/Tests/DesignTokenTests.swift @@ -86,11 +86,4 @@ struct DesignTokenTests { #expect(colorsMatch(DSColor.State.danger, DSPalette.red400)) #expect(colorsMatch(DSColor.State.urgent, DSPalette.red600)) } - - @Test - func flatAliasesForwardToSemanticTokens() { - #expect(colorsMatch(DSColor.accent, DSColor.Accent.default)) - #expect(colorsMatch(DSColor.background, DSColor.Background.base)) - #expect(colorsMatch(DSColor.textPrimary, DSColor.Text.primary)) - } } diff --git a/Projects/Domain/Sources/Entities/HomeSummary.swift b/Projects/Domain/Sources/Entities/HomeSummary.swift deleted file mode 100644 index 42fd12c..0000000 --- a/Projects/Domain/Sources/Entities/HomeSummary.swift +++ /dev/null @@ -1,11 +0,0 @@ -public struct HomeSummary: Equatable, Sendable { - public let id: String - public let title: String - public let subtitle: String - - public init(id: String, title: String, subtitle: String) { - self.id = id - self.title = title - self.subtitle = subtitle - } -} diff --git a/Projects/Domain/Sources/Entities/LocationError.swift b/Projects/Domain/Sources/Entities/LocationError.swift new file mode 100644 index 0000000..a3c3033 --- /dev/null +++ b/Projects/Domain/Sources/Entities/LocationError.swift @@ -0,0 +1,5 @@ +/// 위치 조회 실패 사유 — 권한 거부를 구분해야 Feature가 "검색 유도 + 설정 이동" UX로 분기할 수 있다. +public enum LocationError: Error, Equatable, Sendable { + case permissionDenied + case unavailable +} diff --git a/Projects/Domain/Sources/Interfaces/HomeRepository.swift b/Projects/Domain/Sources/Interfaces/HomeRepository.swift deleted file mode 100644 index c342cad..0000000 --- a/Projects/Domain/Sources/Interfaces/HomeRepository.swift +++ /dev/null @@ -1,3 +0,0 @@ -public protocol HomeRepository: Sendable { - func fetchHomeSummary() async throws -> HomeSummary -} diff --git a/Projects/Domain/Sources/UseCases/FetchHomeUseCase.swift b/Projects/Domain/Sources/UseCases/FetchHomeUseCase.swift deleted file mode 100644 index 7772edf..0000000 --- a/Projects/Domain/Sources/UseCases/FetchHomeUseCase.swift +++ /dev/null @@ -1,15 +0,0 @@ -public protocol FetchHomeUseCase: Sendable { - func execute() async throws -> HomeSummary -} - -public struct DefaultFetchHomeUseCase: FetchHomeUseCase { - private let repository: any HomeRepository - - public init(repository: any HomeRepository) { - self.repository = repository - } - - public func execute() async throws -> HomeSummary { - try await repository.fetchHomeSummary() - } -} diff --git a/Projects/Domain/Sources/UseCases/ReverseGeocodeUseCase.swift b/Projects/Domain/Sources/UseCases/ReverseGeocodeUseCase.swift new file mode 100644 index 0000000..6eb73e6 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/ReverseGeocodeUseCase.swift @@ -0,0 +1,15 @@ +public protocol ReverseGeocodeUseCase: Sendable { + func execute(coordinate: Coordinate) async throws -> Place +} + +public struct DefaultReverseGeocodeUseCase: ReverseGeocodeUseCase { + private let repository: any PlaceRepository + + public init(repository: any PlaceRepository) { + self.repository = repository + } + + public func execute(coordinate: Coordinate) async throws -> Place { + try await repository.reverseGeocode(coordinate) + } +} diff --git a/Projects/Domain/Tests/DefaultFetchHomeUseCaseTests.swift b/Projects/Domain/Tests/DefaultFetchHomeUseCaseTests.swift deleted file mode 100644 index bbffb01..0000000 --- a/Projects/Domain/Tests/DefaultFetchHomeUseCaseTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -@testable import Domain -import Testing - -private struct StubHomeRepository: HomeRepository { - let summary: HomeSummary - func fetchHomeSummary() async throws -> HomeSummary { summary } -} - -struct DefaultFetchHomeUseCaseTests { - @Test - func execute_returnsRepositoryEntity() async throws { - let expected = HomeSummary(id: "1", title: "t", subtitle: "s") - let sut = DefaultFetchHomeUseCase(repository: StubHomeRepository(summary: expected)) - let result = try await sut.execute() - #expect(result == expected) - } -} diff --git a/Projects/Domain/Tests/DefaultReverseGeocodeUseCaseTests.swift b/Projects/Domain/Tests/DefaultReverseGeocodeUseCaseTests.swift new file mode 100644 index 0000000..9af6c39 --- /dev/null +++ b/Projects/Domain/Tests/DefaultReverseGeocodeUseCaseTests.swift @@ -0,0 +1,58 @@ +@testable import Domain +import Foundation +import Testing + +private struct StubError: Error {} + +private actor CallLog { + private(set) var coordinates: [Coordinate] = [] + func append(_ coordinate: Coordinate) { coordinates.append(coordinate) } +} + +private struct SpyPlaceRepository: PlaceRepository { + let log: CallLog + var place: Place? = nil + + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + [] + } + + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + await log.append(coordinate) + guard let place else { throw StubError() } + return place + } +} + +struct DefaultReverseGeocodeUseCaseTests { + @Test + func execute_forwardsCoordinateAndReturnsPlace() async throws { + let log = CallLog() + let expected = Place( + name: "강남역", + address: "서울 강남구 강남대로 396", + coordinate: Coordinate(latitude: 37.4979, longitude: 127.0276) + ) + let sut = DefaultReverseGeocodeUseCase( + repository: SpyPlaceRepository(log: log, place: expected) + ) + + let place = try await sut.execute( + coordinate: Coordinate(latitude: 37.4979, longitude: 127.0276) + ) + + #expect(place == expected) + #expect(await log.coordinates == [Coordinate(latitude: 37.4979, longitude: 127.0276)]) + } + + @Test + func execute_propagatesRepositoryError() async { + let sut = DefaultReverseGeocodeUseCase( + repository: SpyPlaceRepository(log: CallLog()) + ) + + await #expect(throws: StubError.self) { + _ = try await sut.execute(coordinate: Coordinate(latitude: 0, longitude: 0)) + } + } +} diff --git a/Projects/Feature/Home/Example/ExampleApp.swift b/Projects/Feature/Home/Example/ExampleApp.swift index a497ffd..c22fc97 100644 --- a/Projects/Feature/Home/Example/ExampleApp.swift +++ b/Projects/Feature/Home/Example/ExampleApp.swift @@ -2,6 +2,7 @@ import CoreCoordinator import Domain import HomeFeature import HomeFeatureInterface +import SearchFeatureInterface import UIKit @main @@ -33,7 +34,12 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) { guard let windowScene = scene as? UIWindowScene else { return } let navigationController = UINavigationController() - let container = HomeDIContainer(fetchHomeUseCase: PreviewFetchHomeUseCase()) + let container = HomeDIContainer( + getCurrentLocationUseCase: PreviewGetCurrentLocationUseCase(), + reverseGeocodeUseCase: PreviewReverseGeocodeUseCase(), + registerAlarmUseCase: PreviewRegisterAlarmUseCase(), + searchCoordinatorBuildable: PreviewSearchCoordinatorBuildable() + ) let coordinator = container.makeHomeCoordinator(navigationController: navigationController) let window = UIWindow(windowScene: windowScene) @@ -46,9 +52,93 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { } // Example apps wire stub use cases — no Data/network dependency. -struct PreviewFetchHomeUseCase: FetchHomeUseCase { - func execute() async throws -> HomeSummary { - try? await Task.sleep(for: .seconds(1)) - return HomeSummary(id: "preview", title: "막차까지 42분", subtitle: "Example 앱의 스텁 데이터입니다") + +struct PreviewGetCurrentLocationUseCase: GetCurrentLocationUseCase { + func execute() async throws -> Coordinate { + try? await Task.sleep(for: .milliseconds(400)) + return Coordinate(latitude: 37.4979, longitude: 127.0276) + } +} + +struct PreviewReverseGeocodeUseCase: ReverseGeocodeUseCase { + func execute(coordinate: Coordinate) async throws -> Place { + try? await Task.sleep(for: .milliseconds(200)) + return Place(name: "강남역", address: "서울 강남구 강남대로 396", coordinate: coordinate) + } +} + +struct PreviewRegisterAlarmUseCase: RegisterAlarmUseCase { + func execute(route: LastRoute) async throws { + try? await Task.sleep(for: .milliseconds(500)) + } +} + +/// 검색 플로우 스텁: 화면 전환 없이 canned 경로를 즉시 반환한다. +/// 실제 검색 UX 시연은 SearchFeatureExample이 담당한다. +struct PreviewSearchCoordinatorBuildable: SearchCoordinatorBuildable { + func makeSearchCoordinator( + navigationController: UINavigationController, + onRouteSelected: @escaping (LastRoute) -> Void + ) -> any Coordinator { + PreviewSearchCoordinator(onRouteSelected: onRouteSelected) + } +} + +final class PreviewSearchCoordinator: Coordinator { + var childCoordinators: [any Coordinator] = [] + weak var finishDelegate: (any CoordinatorFinishDelegate)? + + private let onRouteSelected: (LastRoute) -> Void + + init(onRouteSelected: @escaping (LastRoute) -> Void) { + self.onRouteSelected = onRouteSelected + } + + func start() { + onRouteSelected(Self.makeCannedRoute()) + finish() + } + + private static func makeCannedRoute() -> LastRoute { + let departure = Date().addingTimeInterval(42 * 60) + return LastRoute( + id: "preview-route", + departureTime: departure, + totalTime: 2940, + totalWalkTime: 480, + transferCount: 1, + totalDistance: 14200, + totalWalkDistance: 700, + legs: [ + TransportLeg( + mode: .subway, + sectionTime: 1500, + distance: 9000, + departureTime: departure, + routeName: "2호선", + lineType: "2", + start: RoutePoint(name: "강남역", coordinate: Coordinate(latitude: 37.4979, longitude: 127.0276)), + end: RoutePoint(name: "당산역", coordinate: Coordinate(latitude: 37.5343, longitude: 126.9024)), + subwayFinalStation: "홍대입구행", + subwayDirection: "외선", + isExpressSubway: false, + isLastSubway: true + ), + TransportLeg( + mode: .bus, + sectionTime: 960, + distance: 4500, + departureTime: nil, + routeName: "간선:6411", + lineType: "11", + start: RoutePoint(name: "당산역", coordinate: Coordinate(latitude: 37.5343, longitude: 126.9024)), + end: RoutePoint(name: "구로디지털단지", coordinate: Coordinate(latitude: 37.4853, longitude: 126.9015)), + subwayFinalStation: nil, + subwayDirection: nil, + isExpressSubway: false, + isLastSubway: false + ), + ] + ) } } diff --git a/Projects/Feature/Home/Project.swift b/Projects/Feature/Home/Project.swift index d201285..c6f92df 100644 --- a/Projects/Feature/Home/Project.swift +++ b/Projects/Feature/Home/Project.swift @@ -7,6 +7,8 @@ let project = Project.feature( .project(target: "Domain", path: "../../Domain"), .project(target: "DesignSystem", path: "../../DesignSystem"), .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), + // 검색 플로우 진입점 — 본체(SearchFeature)가 아니라 Interface만 본다. + .project(target: "SearchFeatureInterface", path: "../Search"), .external(name: "SnapKit"), ], interfaceDependencies: [ @@ -19,5 +21,7 @@ let project = Project.feature( exampleDependencies: [ .project(target: "Domain", path: "../../Domain"), .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), + // Example의 스텁 SearchCoordinatorBuildable 구현용. + .project(target: "SearchFeatureInterface", path: "../Search"), ] ) diff --git a/Projects/Feature/Home/Sources/HomeCoordinator.swift b/Projects/Feature/Home/Sources/HomeCoordinator.swift index f6979a1..0f90c97 100644 --- a/Projects/Feature/Home/Sources/HomeCoordinator.swift +++ b/Projects/Feature/Home/Sources/HomeCoordinator.swift @@ -1,7 +1,8 @@ import CoreCoordinator +import Domain import UIKit -final class HomeCoordinator: Coordinator { +final class HomeCoordinator: Coordinator, CoordinatorFinishDelegate { var childCoordinators: [any Coordinator] = [] weak var finishDelegate: (any CoordinatorFinishDelegate)? @@ -15,7 +16,30 @@ final class HomeCoordinator: Coordinator { } func start() { - let viewController = container.makeHomeViewController() + let viewController = container.makeHomeViewController( + onSearchRequested: { [weak self] onRouteSelected in + self?.startSearchFlow(onRouteSelected: onRouteSelected) + } + ) navigationController?.pushViewController(viewController, animated: false) } + + // MARK: - 검색 플로우 + + private func startSearchFlow(onRouteSelected: @escaping (LastRoute) -> Void) { + guard let navigationController else { return } + let child = container.makeSearchCoordinator( + navigationController: navigationController, + onRouteSelected: onRouteSelected + ) + // start() 안에서 동기로 finish()될 수 있으므로(예: Example 스텁) 배선을 먼저 끝낸다. + child.finishDelegate = self + addChild(child) + child.start() + } + + // SearchCoordinator가 스스로 pop 후 finish()하므로 여기서는 제거만 한다. + func coordinatorDidFinish(_ coordinator: any Coordinator) { + removeChild(coordinator) + } } diff --git a/Projects/Feature/Home/Sources/HomeDIContainer.swift b/Projects/Feature/Home/Sources/HomeDIContainer.swift index cbc3773..7b57f17 100644 --- a/Projects/Feature/Home/Sources/HomeDIContainer.swift +++ b/Projects/Feature/Home/Sources/HomeDIContainer.swift @@ -1,23 +1,53 @@ import CoreCoordinator import Domain import HomeFeatureInterface +import SearchFeatureInterface import UIKit /// Assembles the Home feature's screens. Coordinators own flow only; /// screen/ViewModel assembly stays here so adding screens never bloats /// coordinator initializers. public final class HomeDIContainer: HomeCoordinatorBuildable { - private let fetchHomeUseCase: any FetchHomeUseCase + private let getCurrentLocationUseCase: any GetCurrentLocationUseCase + private let reverseGeocodeUseCase: any ReverseGeocodeUseCase + private let registerAlarmUseCase: any RegisterAlarmUseCase + private let searchCoordinatorBuildable: any SearchCoordinatorBuildable - public init(fetchHomeUseCase: any FetchHomeUseCase) { - self.fetchHomeUseCase = fetchHomeUseCase + public init( + getCurrentLocationUseCase: any GetCurrentLocationUseCase, + reverseGeocodeUseCase: any ReverseGeocodeUseCase, + registerAlarmUseCase: any RegisterAlarmUseCase, + searchCoordinatorBuildable: any SearchCoordinatorBuildable + ) { + self.getCurrentLocationUseCase = getCurrentLocationUseCase + self.reverseGeocodeUseCase = reverseGeocodeUseCase + self.registerAlarmUseCase = registerAlarmUseCase + self.searchCoordinatorBuildable = searchCoordinatorBuildable } public func makeHomeCoordinator(navigationController: UINavigationController) -> any Coordinator { HomeCoordinator(navigationController: navigationController, container: self) } - func makeHomeViewController() -> UIViewController { - HomeViewController(viewModel: HomeViewModel(fetchHomeUseCase: fetchHomeUseCase)) + func makeHomeViewController( + onSearchRequested: @escaping (_ onRouteSelected: @escaping (LastRoute) -> Void) -> Void + ) -> UIViewController { + let viewModel = HomeViewModel( + getCurrentLocationUseCase: getCurrentLocationUseCase, + reverseGeocodeUseCase: reverseGeocodeUseCase, + registerAlarmUseCase: registerAlarmUseCase + ) + viewModel.onSearchRequested = onSearchRequested + return HomeViewController(viewModel: viewModel) + } + + func makeSearchCoordinator( + navigationController: UINavigationController, + onRouteSelected: @escaping (LastRoute) -> Void + ) -> any Coordinator { + searchCoordinatorBuildable.makeSearchCoordinator( + navigationController: navigationController, + onRouteSelected: onRouteSelected + ) } } diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift index f0b52ad..f91f575 100644 --- a/Projects/Feature/Home/Sources/HomeViewController.swift +++ b/Projects/Feature/Home/Sources/HomeViewController.swift @@ -8,23 +8,26 @@ final class HomeViewController: UIViewController { private let titleLabel: UILabel = { let label = UILabel() - label.font = DSFont.title() - label.textColor = DSColor.textPrimary - label.textAlignment = .center + label.font = DSTypography.title1.font + label.textColor = DSColor.Accent.default + label.text = "앗차" return label }() - private let subtitleLabel: UILabel = { - let label = UILabel() - label.font = DSFont.body() - label.textColor = DSColor.textPrimary - label.textAlignment = .center - label.numberOfLines = 0 - return label - }() + 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 let routeCard = DSRouteCard() + private let registerButton = DSButton(title: "알람 등록하기") - private let refreshButton = DSButton(title: "새로고침") - private let activityIndicator = UIActivityIndicatorView(style: .medium) + private let contentStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = DSSpacing.md + return stack + }() init(viewModel: HomeViewModel) { self.viewModel = viewModel @@ -43,59 +46,153 @@ final class HomeViewController: UIViewController { viewModel.viewDidLoad() } - private func configureUI() { - view.backgroundColor = DSColor.background - navigationItem.title = "홈" + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + // 홈은 자체 타이틀을 그린다 — 시스템 내비바 숨김(Search와 동일 규약). + navigationController?.setNavigationBarHidden(true, animated: animated) + } - [titleLabel, subtitleLabel, refreshButton, activityIndicator] - .forEach(view.addSubview) + // MARK: - UI - titleLabel.snp.makeConstraints { make in - make.center.equalToSuperview() - make.leading.trailing.equalToSuperview().inset(DSSpacing.md) - } - subtitleLabel.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(DSSpacing.sm) + private func configureUI() { + view.backgroundColor = DSColor.Background.base + + view.addSubview(contentStack) + contentStack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(DSSpacing.sm12) make.leading.trailing.equalToSuperview().inset(DSSpacing.md) } - refreshButton.snp.makeConstraints { make in - make.top.equalTo(subtitleLabel.snp.bottom).offset(DSSpacing.lg) - make.centerX.equalToSuperview() + + [titleLabel, banner, departureRow, arrivalRow, routeCard, registerButton] + .forEach(contentStack.addArrangedSubview) + contentStack.addArrangedSubview(makeCaptionStack()) + contentStack.setCustomSpacing(DSSpacing.lg20, after: titleLabel) + contentStack.setCustomSpacing(DSSpacing.sm, after: departureRow) + contentStack.setCustomSpacing(DSSpacing.lg, after: arrivalRow) + + banner.isHidden = true + routeCard.isHidden = true + registerButton.isHidden = true + + registerButton.addAction( + UIAction { [weak self] _ in self?.viewModel.registerAlarmTapped() }, + for: .touchUpInside + ) + } + + /// 홈의 필드는 편집이 아니라 검색 진입 트리거다. DSTextField에는 편집 시작 훅이 + /// 없으므로 필드 터치를 통째로 죽이고 UIControl 래퍼가 탭을 가져간다. + private func makeFieldRow(icon: UIImage, field: DSTextField) -> UIControl { + let row = UIControl() + let iconView = UIImageView(image: icon) + iconView.tintColor = DSColor.Icon.default + iconView.contentMode = .scaleAspectFit + field.isUserInteractionEnabled = false + + [iconView, field].forEach(row.addSubview) + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview() + make.centerY.equalToSuperview() + make.size.equalTo(DSIconSize.lg) } - activityIndicator.snp.makeConstraints { make in - make.centerX.equalToSuperview() - make.bottom.equalTo(titleLabel.snp.top).offset(-DSSpacing.lg) + field.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(DSSpacing.sm) + make.top.trailing.bottom.equalToSuperview() } - - refreshButton.addAction( - UIAction { [weak self] _ in self?.viewModel.refresh() }, + row.addAction( + UIAction { [weak self] _ in self?.viewModel.searchFieldTapped() }, for: .touchUpInside ) + return row + } + + private func makeCaptionStack() -> UIStackView { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = DSSpacing.xs + [ + "막차 시간과 가까워질수록 정확해져요", + "알람 시간은 막차 환경에 따라 변경될 수 있어요", + ].forEach { stack.addArrangedSubview(makeCaptionRow(text: $0)) } + return stack + } + + private func makeCaptionRow(text: String) -> UIView { + let row = UIView() + let iconView = UIImageView(image: DSIcon.info16) + iconView.tintColor = DSColor.Icon.muted + iconView.contentMode = .scaleAspectFit + let label = UILabel() + label.numberOfLines = 0 + label.attributedText = DSTypography.caption1.attributed(text, color: DSColor.Text.secondary) + + [iconView, label].forEach(row.addSubview) + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview() + make.centerY.equalTo(label.snp.centerY) + make.size.equalTo(DSIconSize.sm) + } + label.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(DSSpacing.xs) + make.top.trailing.bottom.equalToSuperview() + } + return row } + // MARK: - 바인딩 + private func bind() { viewModel.onStateChange = { [weak self] state in self?.render(state) } + viewModel.onToast = { [weak self] event in + self?.showToast(for: event) + } render(viewModel.state) } private func render(_ state: HomeViewModel.State) { - switch state { - case .idle: - break + switch state.departure { case .loading: - activityIndicator.startAnimating() - titleLabel.text = nil - subtitleLabel.text = nil - case let .loaded(viewData): - activityIndicator.stopAnimating() - titleLabel.text = viewData.titleText - subtitleLabel.text = viewData.subtitleText - case let .failed(message): - activityIndicator.stopAnimating() - titleLabel.text = "앗차!" - subtitleLabel.text = message + departureField.setText("현재 위치 확인 중...") + case let .current(name): + departureField.setText(name) + case .needsSearch: + // 빈 값이면 placeholder("출발지를 검색해 주세요")가 유도 문구 역할을 한다. + departureField.setText("") + } + + if let card = state.routeCard { + routeCard.configure(with: card.dsContent) + routeCard.isHidden = false + registerButton.isHidden = false + } else { + routeCard.isHidden = true + registerButton.isHidden = true + } + registerButton.isEnabled = !state.isRegisteringAlarm + + if let bannerData = state.banner { + banner.configure(text: bannerData.text, style: bannerData.isUrgent ? .urgent : .normal) + banner.isHidden = false + } else { + banner.isHidden = true + } + } + + private func showToast(for event: HomeViewModel.ToastEvent) { + switch event { + case .locationPermissionNeeded: + DSToast.show( + "위치 권한이 꺼져 있어요", + in: view, + action: .init(title: "설정으로 이동") { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + ) + case .alarmRegisterFailed: + DSToast.show("알람 등록에 실패했어요. 다시 시도해 주세요.", in: view) } } } diff --git a/Projects/Feature/Home/Sources/HomeViewData.swift b/Projects/Feature/Home/Sources/HomeViewData.swift index cd51a6b..776994f 100644 --- a/Projects/Feature/Home/Sources/HomeViewData.swift +++ b/Projects/Feature/Home/Sources/HomeViewData.swift @@ -1,12 +1,50 @@ +import DesignSystem import Domain +import Foundation -/// Presentation model — views never see the Entity directly. -struct HomeViewData: Equatable { - let titleText: String - let subtitleText: String +// DateFormatter is expensive to create, so cache one at file scope. +private let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + formatter.locale = Locale(identifier: "ko_KR") + return formatter +}() - init(entity: HomeSummary) { - self.titleText = entity.title - self.subtitleText = entity.subtitle +/// 홈에 표출되는 선택 경로 카드. Entity를 뷰에 직접 노출하지 않는다. +struct RouteCardViewData: Equatable { + let badgeText: String? + let departureTimeText: String + let legs: [DSTransportBadge.Kind] + let summaryText: String? + let destinationText: String + + init(entity: LastRoute) { + badgeText = "가장 늦은 차" + departureTimeText = "\(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)회" + } + + var dsContent: DSRouteCard.Content { + .init( + badgeText: badgeText, + departureTimeText: departureTimeText, + legs: legs, + summaryText: summaryText, + destinationText: destinationText + ) + } + + // "탑승지 → 환승지 → 하차지" — 도보 구간은 경유지로 세지 않는다. + private static func summary(from legs: [TransportLeg]) -> String? { + let rideLegs = legs.filter { $0.mode != .walk } + var names = rideLegs.compactMap { $0.start?.name } + if let lastEnd = rideLegs.last?.end?.name { + names.append(lastEnd) + } + return names.isEmpty ? nil : names.joined(separator: " → ") } } diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index 6042fe9..10307d6 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -4,53 +4,165 @@ import Foundation // Convention: every ViewModel in the codebase is @MainActor. @MainActor final class HomeViewModel { - enum State: Equatable { - case idle + enum DepartureState: Equatable { case loading - case loaded(HomeViewData) - case failed(message: String) + /// 역지오코딩된 현재 위치 라벨. + case current(name: String) + /// 위치를 쓸 수 없어 검색으로 출발지를 정해야 하는 상태. + case needsSearch(deniedPermission: Bool) + } + + nonisolated struct BannerViewData: Equatable { + let text: String + let isUrgent: Bool + } + + struct State: Equatable { + var departure: DepartureState = .loading + var routeCard: RouteCardViewData? + var banner: BannerViewData? + var isRegisteringAlarm = false + } + + /// 재방출되면 안 되는 원샷 안내 — 상태와 분리한다. + enum ToastEvent: Equatable { + case locationPermissionNeeded + case alarmRegisterFailed } /// Set by the ViewController; always invoked on the main actor. var onStateChange: ((State) -> Void)? + var onToast: ((ToastEvent) -> Void)? + /// Set by the Coordinator: 검색 플로우를 열고, 선택 경로를 reply 클로저로 돌려받는다. + var onSearchRequested: ((_ onRouteSelected: @escaping (LastRoute) -> Void) -> Void)? - private(set) var state: State = .idle { - didSet { onStateChange?(state) } + private(set) var state = State() { + didSet { if state != oldValue { onStateChange?(state) } } } - private let fetchHomeUseCase: any FetchHomeUseCase - private var loadTask: Task? + private let getCurrentLocationUseCase: any GetCurrentLocationUseCase + private let reverseGeocodeUseCase: any ReverseGeocodeUseCase + private let registerAlarmUseCase: any RegisterAlarmUseCase + private let now: @Sendable () -> Date + private let bannerTickInterval: Duration - init(fetchHomeUseCase: any FetchHomeUseCase) { - self.fetchHomeUseCase = fetchHomeUseCase + private var selectedRoute: LastRoute? + private var locationTask: Task? + private var registerTask: Task? + private var bannerTask: Task? + + init( + getCurrentLocationUseCase: any GetCurrentLocationUseCase, + reverseGeocodeUseCase: any ReverseGeocodeUseCase, + registerAlarmUseCase: any RegisterAlarmUseCase, + now: @escaping @Sendable () -> Date = { Date() }, + bannerTickInterval: Duration = .seconds(60) + ) { + self.getCurrentLocationUseCase = getCurrentLocationUseCase + self.reverseGeocodeUseCase = reverseGeocodeUseCase + self.registerAlarmUseCase = registerAlarmUseCase + self.now = now + self.bannerTickInterval = bannerTickInterval } deinit { - loadTask?.cancel() + locationTask?.cancel() + registerTask?.cancel() + bannerTask?.cancel() } + // MARK: - 입력 + func viewDidLoad() { - load() + loadCurrentLocation() } - func refresh() { - load() + /// 출발지/도착지 어느 필드를 탭해도 동일하게 검색 플로우로 진입한다. + func searchFieldTapped() { + onSearchRequested? { [weak self] route in + self?.routeSelected(route) + } + } + + func routeSelected(_ route: LastRoute) { + selectedRoute = route + var newState = state + newState.routeCard = RouteCardViewData(entity: route) + // 새 경로 선택 = 기존 배너는 더 이상 유효하지 않다 (재등록 전까지 숨김). + newState.banner = nil + state = newState + bannerTask?.cancel() } - private func load() { - loadTask?.cancel() - state = .loading + func registerAlarmTapped() { + guard let route = selectedRoute, !state.isRegisteringAlarm else { return } + registerTask?.cancel() + state.isRegisteringAlarm = true // [weak self]: the in-flight task must not keep the ViewModel alive. - loadTask = Task { [weak self] in - guard let useCase = self?.fetchHomeUseCase else { return } + registerTask = Task { [weak self] in + guard let useCase = self?.registerAlarmUseCase else { return } + do { + try await useCase.execute(route: route) + guard !Task.isCancelled else { return } + self?.state.isRegisteringAlarm = false + self?.startBannerTimer(departure: route.departureTime) + } catch { + guard !Task.isCancelled else { return } + self?.state.isRegisteringAlarm = false + self?.onToast?(.alarmRegisterFailed) + } + } + } + + // MARK: - 내부 전이 + + private func loadCurrentLocation() { + locationTask?.cancel() + state.departure = .loading + locationTask = Task { [weak self] in do { - let summary = try await useCase.execute() + guard let locationUseCase = self?.getCurrentLocationUseCase else { return } + let coordinate = try await locationUseCase.execute() + guard !Task.isCancelled, + let geocodeUseCase = self?.reverseGeocodeUseCase else { return } + let place = try await geocodeUseCase.execute(coordinate: coordinate) + guard !Task.isCancelled else { return } + self?.state.departure = .current(name: place.name) + } catch LocationError.permissionDenied { guard !Task.isCancelled else { return } - self?.state = .loaded(HomeViewData(entity: summary)) + self?.state.departure = .needsSearch(deniedPermission: true) + self?.onToast?(.locationPermissionNeeded) } catch { guard !Task.isCancelled else { return } - self?.state = .failed(message: "홈 정보를 불러오지 못했습니다.") + // 역지오코딩 실패 포함 — 검색으로 출발지를 정하면 된다. + self?.state.departure = .needsSearch(deniedPermission: false) } } } + + private func startBannerTimer(departure: Date) { + bannerTask?.cancel() + // 매 틱 departure 기준으로 재계산 — 누적 드리프트가 없다. + bannerTask = Task { [weak self] in + while !Task.isCancelled { + guard let now = self?.now() else { return } + self?.state.banner = Self.makeBanner(departure: departure, now: now) + // sleep 동안 self를 잡지 않는다 — deinit cancel이 즉시 먹혀야 한다. + guard let interval = self?.bannerTickInterval else { return } + try? await Task.sleep(for: interval) + } + } + } + + // MARK: - 순수 계산 + + nonisolated static func minutesUntil(departure: Date, now: Date) -> Int { + max(0, Int(ceil(departure.timeIntervalSince(now) / 60))) + } + + nonisolated static func makeBanner(departure: Date, now: Date) -> BannerViewData { + let minutes = minutesUntil(departure: departure, now: now) + // 긴박 기준 10분은 디자이너 확정 전 제안값. + return BannerViewData(text: "막차 출발까지 \(minutes)분", isUrgent: minutes <= 10) + } } diff --git a/Projects/Feature/Home/Sources/TransportBadgeMapper.swift b/Projects/Feature/Home/Sources/TransportBadgeMapper.swift new file mode 100644 index 0000000..2c24ba8 --- /dev/null +++ b/Projects/Feature/Home/Sources/TransportBadgeMapper.swift @@ -0,0 +1,81 @@ +import DesignSystem +import Domain + +/// Maps Domain transit legs onto the DesignSystem's badge vocabulary. +/// DSTransportBadge.Kind is deliberately not a Domain type, so this +/// translation lives in the feature. +enum TransportBadgeMapper { + static func kinds(for legs: [TransportLeg]) -> [DSTransportBadge.Kind] { + legs.map(kind(for:)) + } + + static func kind(for leg: TransportLeg) -> DSTransportBadge.Kind { + switch leg.mode { + case .walk: + .walk + case .bus: + busKind(routeName: leg.routeName) + case .subway: + subwayKind(routeName: leg.routeName) + case .unknown: + .bus(.general, text: leg.routeName ?? "이동") + } + } + + // Legacy server format: "간선:472" (type:number). + private static func busKind(routeName: String?) -> DSTransportBadge.Kind { + guard let routeName, let colonIndex = routeName.firstIndex(of: ":") else { + return .bus(.general, text: routeName ?? "버스") + } + let type = String(routeName[.. DSTransportBadge.Kind { + guard let routeName else { return .subway(.line1, text: "지하철") } + for entry in subwayLineTable where routeName.contains(entry.keyword) { + return .subway(entry.line, text: entry.badge) + } + // TODO: 실서버 노선명 실측 후 테이블 확장 — 미매핑 노선은 원문 텍스트 유지, 색상은 best-effort. + return .subway(.line1, text: routeName) + } +} diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift index 42d0cf5..ef9e6f7 100644 --- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -1,34 +1,257 @@ import Domain +import Foundation @testable import HomeFeature import Testing -private struct StubFetchHomeUseCase: FetchHomeUseCase { - let summary: HomeSummary - func execute() async throws -> HomeSummary { summary } +private struct StubError: Error {} + +private struct StubGetCurrentLocationUseCase: GetCurrentLocationUseCase { + let handler: @Sendable () async throws -> Coordinate + func execute() async throws -> Coordinate { try await handler() } +} + +private struct StubReverseGeocodeUseCase: ReverseGeocodeUseCase { + let handler: @Sendable (Coordinate) async throws -> Place + func execute(coordinate: Coordinate) async throws -> Place { try await handler(coordinate) } +} + +private struct StubRegisterAlarmUseCase: RegisterAlarmUseCase { + let handler: @Sendable (LastRoute) async throws -> Void + func execute(route: LastRoute) async throws { try await handler(route) } +} + +/// 테스트 도중 `now()`를 전진시키기 위한 가변 시계. +private nonisolated final class NowBox: @unchecked Sendable { + private let lock = NSLock() + private var value: Date + + init(_ value: Date) { self.value = value } + + func get() -> Date { + lock.lock() + defer { lock.unlock() } + return value + } + + func set(_ newValue: Date) { + lock.lock() + defer { lock.unlock() } + value = newValue + } } +// MARK: - 헬퍼 + +@MainActor +private final class StateRecorder { + private(set) var states: [HomeViewModel.State] = [] + private(set) var toasts: [HomeViewModel.ToastEvent] = [] + + func attach(to sut: HomeViewModel) { + sut.onStateChange = { [weak self] in self?.states.append($0) } + sut.onToast = { [weak self] in self?.toasts.append($0) } + } + + // 스텁이 즉시 resolve하므로 yield 드레인으로 충분하다. + func waitUntilLast(_ predicate: (HomeViewModel.State) -> Bool) async { + while !(states.last.map(predicate) ?? false) { + await Task.yield() + } + } +} + +private nonisolated let fixedNow = Date(timeIntervalSince1970: 1_755_800_000) + +private nonisolated func makeRoute(id: String, departure: Date) -> LastRoute { + LastRoute( + id: id, + departureTime: departure, + totalTime: 3600, + totalWalkTime: 600, + transferCount: 1, + totalDistance: 12000, + totalWalkDistance: 800, + legs: [ + TransportLeg( + mode: .subway, + sectionTime: 1800, + distance: 9000, + departureTime: nil, + routeName: "2호선", + lineType: "2", + start: RoutePoint(name: "강남역", coordinate: Coordinate(latitude: 37.49, longitude: 127.02)), + end: RoutePoint(name: "당산역", coordinate: Coordinate(latitude: 37.53, longitude: 126.90)), + subwayFinalStation: nil, + subwayDirection: nil, + isExpressSubway: false, + isLastSubway: true + ), + ] + ) +} + +@MainActor +private func makeSUT( + location: @escaping @Sendable () async throws -> Coordinate = { + Coordinate(latitude: 37.4979, longitude: 127.0276) + }, + geocode: @escaping @Sendable (Coordinate) async throws -> Place = { + Place(name: "강남역", address: "서울 강남구", coordinate: $0) + }, + register: @escaping @Sendable (LastRoute) async throws -> Void = { _ in }, + now: @escaping @Sendable () -> Date = { fixedNow }, + bannerTickInterval: Duration = .seconds(60) +) -> HomeViewModel { + HomeViewModel( + getCurrentLocationUseCase: StubGetCurrentLocationUseCase(handler: location), + reverseGeocodeUseCase: StubReverseGeocodeUseCase(handler: geocode), + registerAlarmUseCase: StubRegisterAlarmUseCase(handler: register), + now: now, + bannerTickInterval: bannerTickInterval + ) +} + +// MARK: - 테스트 + @MainActor struct HomeViewModelTests { @Test - func viewDidLoad_success_transitionsLoadingToLoaded() async { - let summary = HomeSummary(id: "1", title: "막차까지 42분", subtitle: "지금 출발하면 여유있어요") - let sut = HomeViewModel(fetchHomeUseCase: StubFetchHomeUseCase(summary: summary)) - - var states: [HomeViewModel.State] = [] - await confirmation("reaches .loaded") { loaded in - sut.onStateChange = { state in - states.append(state) - if case .loaded = state { loaded() } - } - sut.viewDidLoad() - - // Drain until the async load lands (stub resolves immediately). - while !states.contains(where: { if case .loaded = $0 { true } else { false } }) { - await Task.yield() - } - } + func viewDidLoad_locationSuccess_showsReverseGeocodedName() async { + let sut = makeSUT() + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + await recorder.waitUntilLast { $0.departure == .current(name: "강남역") } + + // 초기값이 이미 .loading이라 didSet 재방출은 없다 — VC는 bind 시 초기 상태를 직접 렌더한다. + #expect(sut.state.departure == .current(name: "강남역")) + #expect(recorder.toasts.isEmpty) + } + + @Test + func viewDidLoad_permissionDenied_needsSearchAndEmitsSettingsToast() async { + let sut = makeSUT(location: { throw LocationError.permissionDenied }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + await recorder.waitUntilLast { $0.departure == .needsSearch(deniedPermission: true) } + + #expect(recorder.toasts == [.locationPermissionNeeded]) + } + + @Test + func viewDidLoad_otherFailure_needsSearchWithoutToast() async { + let sut = makeSUT(geocode: { _ in throw StubError() }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + await recorder.waitUntilLast { $0.departure == .needsSearch(deniedPermission: false) } + + #expect(recorder.toasts.isEmpty) + } + + @Test + func routeSelected_populatesCard() async { + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let sut = makeSUT() + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.routeSelected(route) + + #expect(sut.state.routeCard == RouteCardViewData(entity: route)) + #expect(sut.state.banner == nil) + } + + @Test + func registerAlarm_success_startsBannerCountdown() async { + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let sut = makeSUT() + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.routeSelected(route) + sut.registerAlarmTapped() + #expect(sut.state.isRegisteringAlarm) + await recorder.waitUntilLast { $0.banner != nil } + + #expect(sut.state.banner == .init(text: "막차 출발까지 42분", isUrgent: false)) + #expect(!sut.state.isRegisteringAlarm) + } + + @Test + func registerAlarm_failure_emitsToastAndKeepsBannerHidden() async { + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let sut = makeSUT(register: { _ in throw StubError() }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.routeSelected(route) + sut.registerAlarmTapped() + while recorder.toasts.isEmpty { await Task.yield() } + + #expect(recorder.toasts == [.alarmRegisterFailed]) + #expect(sut.state.banner == nil) + #expect(!sut.state.isRegisteringAlarm) + } + + @Test + func selectingNewRoute_clearsExistingBanner() async { + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let sut = makeSUT() + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.routeSelected(route) + sut.registerAlarmTapped() + await recorder.waitUntilLast { $0.banner != nil } + + sut.routeSelected(makeRoute(id: "r2", departure: fixedNow.addingTimeInterval(30 * 60))) + + #expect(sut.state.banner == nil) + #expect(sut.state.routeCard?.departureTimeText != nil) + } + + @Test + func minutesUntil_roundsUpAndClampsAtZero() { + #expect(HomeViewModel.minutesUntil( + departure: fixedNow.addingTimeInterval(42 * 60), now: fixedNow + ) == 42) + #expect(HomeViewModel.minutesUntil( + departure: fixedNow.addingTimeInterval(90), now: fixedNow + ) == 2) + #expect(HomeViewModel.minutesUntil( + departure: fixedNow.addingTimeInterval(-30), now: fixedNow + ) == 0) + + #expect(HomeViewModel.makeBanner( + departure: fixedNow.addingTimeInterval(10 * 60), now: fixedNow + ).isUrgent) + #expect(!HomeViewModel.makeBanner( + departure: fixedNow.addingTimeInterval(11 * 60), now: fixedNow + ).isUrgent) + #expect(HomeViewModel.makeBanner( + departure: fixedNow, now: fixedNow + ) == .init(text: "막차 출발까지 0분", isUrgent: true)) + } + + @Test + func bannerTimer_ticksRecomputeMinutes() async { + let clock = NowBox(fixedNow) + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let sut = makeSUT(now: { clock.get() }, bannerTickInterval: .milliseconds(1)) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.routeSelected(route) + sut.registerAlarmTapped() + await recorder.waitUntilLast { $0.banner?.text == "막차 출발까지 42분" } + + clock.set(fixedNow.addingTimeInterval(40 * 60)) + await recorder.waitUntilLast { $0.banner?.text == "막차 출발까지 2분" } - #expect(states.first == .loading) - #expect(states.last == .loaded(HomeViewData(entity: summary))) + #expect(sut.state.banner?.isUrgent == true) } } diff --git a/Projects/Feature/Search/Sources/SearchDIContainer.swift b/Projects/Feature/Search/Sources/SearchDIContainer.swift index 226c362..61f41d8 100644 --- a/Projects/Feature/Search/Sources/SearchDIContainer.swift +++ b/Projects/Feature/Search/Sources/SearchDIContainer.swift @@ -10,15 +10,18 @@ public final class SearchDIContainer: SearchCoordinatorBuildable { private let searchPlacesUseCase: any SearchPlacesUseCase private let searchLastRoutesUseCase: any SearchLastRoutesUseCase private let recentSearchesUseCase: any RecentSearchesUseCase + private let getCurrentLocationUseCase: (any GetCurrentLocationUseCase)? public init( searchPlacesUseCase: any SearchPlacesUseCase, searchLastRoutesUseCase: any SearchLastRoutesUseCase, - recentSearchesUseCase: any RecentSearchesUseCase + recentSearchesUseCase: any RecentSearchesUseCase, + getCurrentLocationUseCase: (any GetCurrentLocationUseCase)? = nil ) { self.searchPlacesUseCase = searchPlacesUseCase self.searchLastRoutesUseCase = searchLastRoutesUseCase self.recentSearchesUseCase = recentSearchesUseCase + self.getCurrentLocationUseCase = getCurrentLocationUseCase } public func makeSearchCoordinator( @@ -39,7 +42,8 @@ public final class SearchDIContainer: SearchCoordinatorBuildable { let viewModel = SearchViewModel( searchPlacesUseCase: searchPlacesUseCase, searchLastRoutesUseCase: searchLastRoutesUseCase, - recentSearchesUseCase: recentSearchesUseCase + recentSearchesUseCase: recentSearchesUseCase, + getCurrentLocationUseCase: getCurrentLocationUseCase ) viewModel.onRouteChosen = onRouteChosen viewModel.onBackRequested = onBack diff --git a/Projects/Feature/Search/Sources/SearchViewModel.swift b/Projects/Feature/Search/Sources/SearchViewModel.swift index 104f337..7e8a8f9 100644 --- a/Projects/Feature/Search/Sources/SearchViewModel.swift +++ b/Projects/Feature/Search/Sources/SearchViewModel.swift @@ -51,6 +51,8 @@ final class SearchViewModel { // 확정된 슬롯. 타이핑이 시작되면 해당 슬롯은 다시 미확정으로 돌아간다. private var departure: Place? private var arrival: Place? + // 확보되면 키워드 검색의 near 바이어스로 쓴다. + private var currentCoordinate: Coordinate? // 현재 리스트(.recent/.places)에 표시 중인 원본 — index 선택 매핑용. private var listedPlaces: [Place] = [] // 0번 = 가장 늦은 차. @@ -60,11 +62,14 @@ final class SearchViewModel { private let searchPlacesUseCase: any SearchPlacesUseCase private let searchLastRoutesUseCase: any SearchLastRoutesUseCase private let recentSearchesUseCase: any RecentSearchesUseCase + // nil이면(예: Example 스텁 구성) 프리필·near 바이어스 없이 동작한다. + private let getCurrentLocationUseCase: (any GetCurrentLocationUseCase)? private let debounceInterval: Duration private var searchTask: Task? private var routeTask: Task? private var recentTask: Task? + private var locationTask: Task? // save는 fetch류와 분리 보관 — 목록 갱신이 저장을 cancel하면 안 된다. private var saveTask: Task? @@ -72,11 +77,13 @@ final class SearchViewModel { searchPlacesUseCase: any SearchPlacesUseCase, searchLastRoutesUseCase: any SearchLastRoutesUseCase, recentSearchesUseCase: any RecentSearchesUseCase, + getCurrentLocationUseCase: (any GetCurrentLocationUseCase)? = nil, debounceInterval: Duration = .milliseconds(300) ) { self.searchPlacesUseCase = searchPlacesUseCase self.searchLastRoutesUseCase = searchLastRoutesUseCase self.recentSearchesUseCase = recentSearchesUseCase + self.getCurrentLocationUseCase = getCurrentLocationUseCase self.debounceInterval = debounceInterval } @@ -84,6 +91,7 @@ final class SearchViewModel { searchTask?.cancel() routeTask?.cancel() recentTask?.cancel() + locationTask?.cancel() saveTask?.cancel() } @@ -91,6 +99,7 @@ final class SearchViewModel { func viewDidLoad() { showRecent() + prefillDepartureWithCurrentLocation() } func fieldDidBeginEditing(_ field: Field) { @@ -134,8 +143,8 @@ final class SearchViewModel { try? await Task.sleep(for: interval) guard !Task.isCancelled else { return } do { - // near: 현재 위치 연동은 Phase 6 스코프 — 그전까지는 nil. - let places = try await useCase.execute(keyword: trimmed, near: nil) + // 현재 위치가 확보된 경우에만 근처 우선 정렬 바이어스를 건다. + let places = try await useCase.execute(keyword: trimmed, near: self?.currentCoordinate) guard !Task.isCancelled else { return } self?.listedPlaces = places self?.state = .places(places.map(PlaceViewData.init(entity:))) @@ -199,6 +208,25 @@ final class SearchViewModel { // MARK: - 내부 전이 + /// 출발지 기본값 = 현재 위치. 실패·권한 거부는 조용히 무시한다 (권한 안내 UX는 홈 담당). + private func prefillDepartureWithCurrentLocation() { + guard let useCase = getCurrentLocationUseCase else { return } + locationTask = Task { [weak self] in + guard let coordinate = try? await useCase.execute() else { return } + guard !Task.isCancelled, let self else { return } + self.currentCoordinate = coordinate + // 사용자가 이미 출발지를 만졌다면 덮어쓰지 않는다. + guard self.departure == nil, self.fields.departureText.isEmpty else { return } + self.departure = Place(name: "현재 위치", address: "", coordinate: coordinate) + var newFields = self.fields + newFields.departureText = "현재 위치" + newFields.activeField = .arrival + self.fields = newFields + // 위치가 늦게 도착해 도착지가 먼저 확정된 경우를 마감한다. + if self.arrival != nil { self.searchRoutes() } + } + } + private func confirm(_ place: Place, in field: Field) { searchTask?.cancel() saveTask = Task { [weak self] in diff --git a/Projects/Feature/Search/Tests/SearchViewModelTests.swift b/Projects/Feature/Search/Tests/SearchViewModelTests.swift index a0d950c..abf112d 100644 --- a/Projects/Feature/Search/Tests/SearchViewModelTests.swift +++ b/Projects/Feature/Search/Tests/SearchViewModelTests.swift @@ -17,6 +17,27 @@ private struct StubSearchLastRoutesUseCase: SearchLastRoutesUseCase { } } +private struct StubGetCurrentLocationUseCase: GetCurrentLocationUseCase { + let handler: @Sendable () async throws -> Coordinate + func execute() async throws -> Coordinate { try await handler() } +} + +// 키워드 검색이 받은 near 좌표를 기록한다. +private actor NearLog { + private(set) var coordinates: [Coordinate?] = [] + func append(_ coordinate: Coordinate?) { coordinates.append(coordinate) } +} + +private struct NearRecordingSearchPlacesUseCase: SearchPlacesUseCase { + let log: NearLog + let places: [Place] + + func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + await log.append(coordinate) + return places + } +} + // save/remove 호출 기록 + fetch 응답을 한곳에서 관리. private actor RecentStore { private(set) var saved: [Place] = [] @@ -110,12 +131,14 @@ private nonisolated func makeRoute(id: String, departureOffset: TimeInterval = 0 private func makeSUT( placesHandler: @escaping @Sendable (String) async throws -> [Place] = { _ in [] }, routesHandler: @escaping @Sendable () async throws -> LastRouteSearchResult = { .available([]) }, - store: RecentStore = RecentStore() + store: RecentStore = RecentStore(), + location: (@Sendable () async throws -> Coordinate)? = nil ) -> SearchViewModel { SearchViewModel( searchPlacesUseCase: StubSearchPlacesUseCase(handler: placesHandler), searchLastRoutesUseCase: StubSearchLastRoutesUseCase(handler: routesHandler), recentSearchesUseCase: StubRecentSearchesUseCase(store: store), + getCurrentLocationUseCase: location.map(StubGetCurrentLocationUseCase.init(handler:)), debounceInterval: .zero ) } @@ -306,6 +329,99 @@ struct SearchViewModelTests { #expect(await store.removed == [first]) } + @Test + func viewDidLoad_withLocation_prefillsDepartureAsCurrentLocation() async { + let coordinate = Coordinate(latitude: 37.49, longitude: 127.02) + let sut = makeSUT(location: { coordinate }) + + sut.viewDidLoad() + while sut.fields.departureText.isEmpty { await Task.yield() } + + #expect(sut.fields.departureText == "현재 위치") + #expect(sut.fields.activeField == .arrival) + } + + @Test + func viewDidLoad_locationFails_keepsDepartureEmpty() async { + let sut = makeSUT(location: { throw StubError() }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + await recorder.waitUntilLast { if case .recent = $0 { true } else { false } } + for _ in 0..<20 { await Task.yield() } + + #expect(sut.fields.departureText.isEmpty) + #expect(sut.fields.activeField == .departure) + } + + @Test + func latePrefill_doesNotOverwriteTypedDeparture() async { + let (stream, continuation) = AsyncStream.makeStream(of: Coordinate.self) + let sut = makeSUT( + placesHandler: { _ in [makePlace("강남역")] }, + location: { + for await coordinate in stream { return coordinate } + throw StubError() + } + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + sut.keywordDidChange("강남", in: .departure) + await recorder.waitUntilLast { if case .places = $0 { true } else { false } } + + continuation.yield(Coordinate(latitude: 37.49, longitude: 127.02)) + continuation.finish() + for _ in 0..<20 { await Task.yield() } + + #expect(sut.fields.departureText == "강남") + } + + @Test + func keywordSearch_afterPrefill_passesNearBias() async { + let coordinate = Coordinate(latitude: 37.49, longitude: 127.02) + let log = NearLog() + let sut = SearchViewModel( + searchPlacesUseCase: NearRecordingSearchPlacesUseCase(log: log, places: [makePlace("회사")]), + searchLastRoutesUseCase: StubSearchLastRoutesUseCase(handler: { .available([]) }), + recentSearchesUseCase: StubRecentSearchesUseCase(store: RecentStore()), + getCurrentLocationUseCase: StubGetCurrentLocationUseCase(handler: { coordinate }), + debounceInterval: .zero + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + while sut.fields.departureText.isEmpty { await Task.yield() } + sut.keywordDidChange("회사", in: .arrival) + await recorder.waitUntilLast { if case .places = $0 { true } else { false } } + + #expect(await log.coordinates == [coordinate]) + } + + @Test + func prefillThenArrivalConfirmed_searchesRoutes() async { + let routes = [makeRoute(id: "r1")] + let sut = makeSUT( + placesHandler: { _ in [makePlace("회사")] }, + routesHandler: { .available(routes) }, + location: { Coordinate(latitude: 37.49, longitude: 127.02) } + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + while sut.fields.departureText.isEmpty { await Task.yield() } + sut.keywordDidChange("회사", in: .arrival) + await recorder.waitUntilLast { if case .places = $0 { true } else { false } } + sut.didSelectListItem(at: 0) + await recorder.waitUntilLast { if case .routes = $0 { true } else { false } } + + #expect(recorder.states.last == .routes(RouteResultsViewData(entities: routes, isExpanded: false))) + } + @Test func selectRoute_forwardsEntityToOnRouteChosen() async { let routes = [makeRoute(id: "r1"), makeRoute(id: "r2")] diff --git a/docs/policy/last-train-change-notification.md b/docs/policy/last-train-change-notification.md new file mode 100644 index 0000000..c827a53 --- /dev/null +++ b/docs/policy/last-train-change-notification.md @@ -0,0 +1,114 @@ +# 막차 시간 변경 알림 정책 + +> 사명: **무조건 막차를 태워 보낸다.** 막차는 저빈도·고위험 이벤트 — 1분 1초가 소중하고, 놓치면 대가가 크다. +> 이 문서는 "서버가 막차 시간 변경값을 보냈을 때 유저에게 어떻게 전달하는가"의 UX·기술 정책을 확정한다. (2026-08-22 논의 확정) + +## 배경 + +레거시는 사일런트 푸시(`type: REFRESH`)로 앱 내부 UI만 갱신한다(`Atcha-iOS/App/AppDelegate.swift`의 `didReceiveRemoteNotification`). 앱이 떠 있지 않으면 유저는 변경을 인지할 수 없고, 사일런트 푸시 자체도 전달 보장이 없다(강제 종료 시 미전달, 저전력 모드·시스템 예산 스로틀링). + +## 핵심 원칙 — 인지는 수단, 안전망은 알람 + +**변경 푸시의 1순위 소비자는 유저의 눈이 아니라 로컬 알람 스케줄러다.** + +- 변경 수신 즉시 로컬 알람(AlarmKit)을 새 시간으로 재스케줄한다. 이것이 불변 조건이다. +- 푸시·라이브 액티비티·alert는 전부 **보조 인지 채널**이다. 전부 실패해도(무음 모드, APNs 유실, 오프라인) 마지막으로 알려진 막차 시간 기준 로컬 알람은 반드시 울린다. 알람은 무음 모드도 뚫는다. +- 마스터 프롬프트 Phase 8(사일런트 푸시 + 폴링 → `RefreshAlarmUseCase` → `AlarmScheduler.replaceAlarm`)이 이 안전망을 구현한다. 아래 인지 계층은 그 위에 얹는 후속 스코프다. + +## 채널 구도 — 에스컬레이션 사다리 + +주의 강도가 낮은 것부터. 각 채널은 대체 관계가 아니라 단계 관계다. + +| 단계 | 채널 | 역할 | +|---|---|---| +| 0 | 사일런트 푸시 + 폴링 | 알람 재스케줄 + 앱 내부 상태 동기화 (유저 비노출, Phase 8 스코프) | +| 1 | Live Activity 조용한 업데이트 | 잠금화면·다이나믹 아일랜드 상시 최신값 (glance 인지) | +| 2 | Live Activity + `alert` | 화면 켜짐 + 워치 알림 (능동 인지) | +| 2′ | 포그라운드 인앱 채널 | 앱 사용 중일 때의 2단계 대체 — DSBanner 갱신 강조 + DSToast("막차가 15분 당겨졌어요") | +| 3 | AlarmKit 알람 | 출발 시점, 못 놓치는 풀스크린 (안전망이자 최종 단계) | + +## 확정 정책 + +### 1. 알림 강도 = 방향 비대칭 + +- 막차 시간 **앞당겨짐** → liveactivity 푸시에 `alert`(title/body/sound) 포함 — 화면 켜짐 + Apple Watch 알림. +- 막차 시간 **늦춰짐** → 조용한 content-state 업데이트만. +- 임계값 승격 정책(N분 이상일 때만 alert)은 도입하지 않는다 — 저빈도 가정에서 불필요한 복잡도(YAGNI). 변경 빈도가 실시간 수준으로 확인되면 재논의. + +### 2. 메시지 프레이밍 = 행동 중심 + +시스템이 뭘 했는지가 아니라 **유저가 뭘 해야 하는지**를 제목으로 말한다. + +``` +🔔 15분 일찍 나가야 해요 +막차가 23:40 → 23:25로 당겨졌어요 +``` + +- 제목: 행동 지시 (출발 시각 변화량 기준). +- 본문: 변경 사실 병기 (변경 전 → 후). +- 전제: 출발 시각 계산(막차 시간 − 이동 시간) — 기존 "출발 알림" 도메인(`AlarmInfo`의 알람 시각 vs 막차 출발 시각)과 일치. + +### 3. 라이브 액티비티 화면 = 긴급도 색 상시 + 일시 배지 + +잠금화면 glance는 0.5초 — 숫자보다 색·상태가 먼저 읽힌다. + +- **평소**: 남은 여유 기반 색상 단계(여유/주의/임박)를 상시 표시. +- **변경 직후**: "⚠ 당겨짐" 배지를 10분 노출 후 제거. 색상은 유지. +- 취소선 등 변경 흔적의 상시 표시는 하지 않는다 (화면 복잡도 > 인지 이득). + +``` +┌────────────────────────────┐ +│ 🚌 5518 막차 [⚠ 당겨짐] │ +│ 출발까지 ⏱ 22분 │ ← 여유도에 따라 색 변경 +│ 23:25 출발 · 정류장 도보 8분 │ +└────────────────────────────┘ +``` + +### 4. 폴백·엣지 케이스 + +- **유저가 액티비티를 스와이프로 지운 경우**: 앱이 `activityStateUpdates`로 dismiss를 감지·기록 → 이후 변경은 **로컬 노티로 폴백** (v1 피기백에선 변경 반영 시점에 앱이 깨어 있으므로 서버 무관여로 가능). push-to-start 재생성은 하지 않는다(유저 의도 무시로 읽힘). +- **이미 못 타게 된 경우** (새 막차 시간 < 현재 시각 + 이동 시간): "일찍 나가세요"는 거짓말이 된다. 실패 상태로 전환하고 **대안을 제시**한다 — 예: "막차가 지나갔어요. 심야버스 N26이 00:10에 있어요". 대안 검색 데이터 필요 — SearchFeature의 경로 검색 도메인 재활용 검토 (데이터 소스 확보 전엔 실패 문구만). +- **알람이 이미 울린 뒤 변경 도착**: 새 알람 시각이 이미 과거면 즉시 최후통첩(임박 상태 + "지금 안 나가면 못 타요"), 미래면 알람 재스케줄로 자동 처리. +- **운행 종료·경로 소멸** (refresh가 경로 없음 반환): 라이브 액티비티를 final state로 종료 + 알람 취소 + 배너 정리. + +### 5. 스누즈·버퍼 통합 정책 — "버퍼 = 스누즈 예산" + +여유는 주되 거짓말은 하지 않는다. 유저가 미룰 수 있는 양 = 안전 마진. + +- **버퍼**: 고정 **+3분**, 설정 없음(v1). 로컬 알람은 `기준 시각 − 3분`에 울린다. 프리셋 조절(여유롭게/보통/딱 맞게)은 후속 확장 여지로만 남긴다. +- **스누즈 = 버퍼 소진**: 미루기는 허용하되 최대 **기준 시각(= 마지노선)**까지만. 스누즈를 다 써도 아직 탈 수 있는 구조. +- **기준 시각 주의**: "서버가 계산한 알람 시각"을 기준으로 설계했으나, **refresh 응답 실측에는 `departureTime`(막차 출발 시각)뿐이고 알람 시각 필드가 없다** (`AlarmInfo` TODO). 서버 필드 추가 요청 여부는 미확정 입력 — 받기 전엔 클라가 `departureTime − 이동 시간`으로 계산한다 (구현 프롬프트 미확정 입력 #7). +- **마지노선 도달 후**: 스누즈 불가. 최종 문구는 행동 중심 — "지금 안 나가면 못 타요". +- 표시 일관성: 라이브 액티비티·배너의 "출발까지 N분"은 버퍼 포함 알람 시각 기준으로 통일 (버퍼를 몰래 숨긴 이중 시각을 만들지 않는다). + +## v1 서버 무관여 (피기백 아키텍처) + +**v1은 서버 변경이 0이다.** 마스터 프롬프트 Phase 8의 기존 설계(사일런트 푸시/폴링은 트리거일 뿐, 값은 `GET /routes/user-routes/refresh` 재조회)를 그대로 쓰고, 그 위에 인지 계층을 얹는다: + +- **diff는 클라 계산**: 이전 값 = 로컬 저장 `AlarmInfo`, 새 값 = refresh 응답. payload 확장 불필요. +- **채널 결정도 클라**: 방향(앞당겨짐/늦춰짐) 판정과 alert 여부는 앱이 깨어난 시점에 판단 — 라이브 액티비티는 로컬 업데이트(`alertConfiguration` 포함 가능)로 갱신한다. +- **버퍼도 서버 무관여**: 클라이언트가 기준 시각 −3분에 로컬 스케줄. 버퍼 정책 변경은 클라 배포만으로 가능. +- **한계 (수용된 리스크)**: 사일런트 푸시가 유실되거나 앱이 강제 종료된 경우 인지 계층 갱신이 지연된다 — Phase 8이 이미 수용한 리스크와 동일하며, 안전망(마지막 값 기준 로컬 알람)은 유지된다. + +### v2 승격 참고 — 서버 주도 LA push (스코프 밖) + +전달 신뢰성을 높이려면 서버가 `apns-push-type: liveactivity` 푸시를 직접 발송하는 승격이 가능하다. 이때 필요한 것: 변경 발생 시마다 push + payload에 diff(변경 전/후 시각) + 액티비티별 push token 등록/해제 API + 방향 판단(alert 여부)의 서버 이관. v1 출시 후 유실 빈도를 보고 결정한다. + +## 마스터 프롬프트와의 관계 + +**이 정책의 구현 프롬프트: [`docs/prompts/atcha-v2-live-activity-prompt.md`](../prompts/atcha-v2-live-activity-prompt.md)** (Phase 9~12, 마스터 Phase 7·8 완료 전제). + +`docs/prompts/atcha-v2-master-prompt.md` 기준: + +- **Phase 8** (FCM 사일런트 푸시 + 폴링 → 알람 자동 갱신)은 이 문서의 "안전망"(핵심 원칙 + 사다리 0단계)을 구현한다. 모순 없음. +- 마스터 프롬프트가 스코프 제외한 **Live Activity 위젯 익스텐션**이 이 문서의 사다리 1~2단계다. AlarmKit 커스텀 알람 UI용 위젯 익스텐션 타겟을 만들 때 같은 타겟에 함께 구현한다. +- **주의**: Phase 8은 알림 권한 프롬프트 없이 동작하도록 설계돼 있다(사일런트 푸시는 권한 불필요). 이 문서의 alert 단계(2)와 일반 푸시 폴백은 **알림 권한이 전제** — 인지 계층 도입 시점에 권한 요청 UX(알람 등록 시점 요청 등)를 함께 설계해야 한다. + +## 구현 시 기술 제약 메모 + +- liveactivity 푸시: `apns-push-type: liveactivity`, payload 4KB 제한 (v2 승격 시에만 해당). +- **라이브 액티비티는 예약 업데이트가 불가** — "배지 10분 후 제거"·"긴급도 색 전환"이 다음 앱 깨움까지 안 일어날 수 있다. 완화: 카운트다운·배지 소멸은 `Text(timerInterval:)` 등 타이머 기반 뷰 조건으로, 갱신 끊긴 LA는 `staleDate`로 오래된 정보 표시 방어, 색 전환은 깨움 시점(푸시·폴링·알람 발화·포그라운드 복귀) 재평가로 근사. +- 라이브 액티비티 활성 8시간 제한 — 막차 추적(저녁~심야) 용도에는 충분. +- 잦은 갱신이 필요해지면 `NSSupportsLiveActivitiesFrequentUpdates` 선언. +- push-to-start(iOS 17.2+)는 최초 시작 용도로만 검토 — dismiss 후 재생성 용도 사용 금지(정책 4). +- **Phase 7 검증 항목**: AlarmKit 기본 UI의 반복(스누즈) 버튼은 정적 설정이라 "마지노선 동적 클램프"(정책 5)가 기본 UI만으로 안 될 수 있다. 불가 판명 시 폴백 — 반복 버튼 제거 + 단발 알람(버퍼 −3분 시점 1회) + 문구로 마지노선 안내. diff --git a/docs/prompts/atcha-v2-live-activity-prompt.md b/docs/prompts/atcha-v2-live-activity-prompt.md new file mode 100644 index 0000000..822ca67 --- /dev/null +++ b/docs/prompts/atcha-v2-live-activity-prompt.md @@ -0,0 +1,212 @@ +# AtchaV2 인지 계층 구현 프롬프트 — 막차 변경 Live Activity + 알림 + +> **사용법**: 이 문서 전체를 Claude Code에 컨텍스트로 전달하고 `"Phase N을 진행해"`라고 지시한다. +> 실행 에이전트는 [마스터 프롬프트](atcha-v2-master-prompt.md)의 **진행 프로토콜·공통 규칙·공통 acceptance를 그대로 상속**하며, 한 번에 한 Phase만 수행한다. +> UX 정본은 [막차 시간 변경 알림 정책](../policy/last-train-change-notification.md), 아키텍처 정본은 마스터 프롬프트 + 레포 `CLAUDE.md`. 충돌 시 **CLAUDE.md > 마스터 프롬프트 > 이 문서** 순. +> 작성일: 2026-08-22. + +--- + +## Goal (최상위) + +**사명: 무조건 막차를 태워 보낸다.** 막차 시간이 변경됐을 때 유저가 확실히 인지하고 제때 출발하게 만드는 "인지 계층"을 Live Activity + 알림으로 구축한다. + +이 문서는 마스터 프롬프트 Goal 표의 `Live Activity 위젯 익스텐션은 스코프 제외` 행을 **해제·초과하는 후속 스코프**다. Phase 번호는 마스터의 1~8에 이어 **9~12**. + +채널 에스컬레이션 사다리 (주의 강도 순, 대체가 아닌 단계 관계): + +``` +0. 사일런트 푸시 + 폴링 → 알람 재스케줄·앱 동기화 (유저 비노출, Phase 8 산출물 — 이 문서 밖) +1. LA 조용한 업데이트 → 잠금화면·다이나믹 아일랜드 상시 최신값 (glance 인지) +2. LA + alert → 화면 켜짐 + 워치 알림 (능동 인지) +2'. 포그라운드 인앱 채널 → DSBanner 갱신 강조 + DSToast (앱 사용 중일 때의 2단계 대체) +3. AlarmKit 알람 → 출발 시점 풀스크린 (안전망이자 최종 단계, Phase 7 산출물) +``` + +확정된 제품 결정사항 (변경하려면 사용자에게 먼저 물을 것 — 근거·상세는 정책 문서): + +| 항목 | 결정 | +|---|---| +| 핵심 원칙 | **인지는 수단, 안전망은 로컬 알람.** 변경 수신 즉시 알람 재스케줄이 1순위, LA·alert는 보조 인지 채널 | +| v1 아키텍처 | **Phase 8 피기백 — 서버 변경 0.** 사일런트 푸시/폴링이 앱을 깨운 시점에 LA를 로컬 업데이트. 서버 주도 LA push는 v2 승격 옵션 | +| diff 계산 | **클라이언트**: 이전 값 = 로컬 저장 `AlarmInfo`, 새 값 = refresh 응답. payload 확장 불필요 | +| 알림 강도 | **방향 비대칭**: 앞당겨짐 → `alertConfiguration` 포함 업데이트(화면 켜짐) / 늦춰짐 → 조용한 업데이트. 임계값 승격 없음(YAGNI) | +| 메시지 | **행동 중심**: 제목 "15분 일찍 나가야 해요" + 본문 "막차가 23:40 → 23:25로 당겨졌어요" | +| LA 화면 | 긴급도 3단계 색(여유/주의/임박) 상시 + 변경 직후 "⚠ 당겨짐" 배지 10분 노출. 취소선·변경 흔적 상시 표시 없음 | +| 버퍼·스누즈 | **버퍼 = 스누즈 예산**: 알람은 기준 시각 −3분(고정, 설정 없음), 스누즈는 마지노선(기준 시각)까지만, 도달 후 "지금 안 나가면 못 타요" | +| 폴백 | LA dismiss 감지 → 이후 변경은 로컬 노티로 (push-to-start 재생성 금지 — 유저 의도 존중). 알림 권한 필요 | +| 알람 발화 후 변경 | 새 알람 시각이 이미 과거 → 즉시 최후통첩(임박 상태) / 미래 → `replaceAlarm` 재스케줄로 자동 처리 | +| 실패 상태 | 못 타게 됨(새 시간 < 현재 + 이동시간)·운행 종료 → LA final state 전환. 대안 제시(심야버스)는 [미확정 입력](#미확정-입력-사용자-제공-대기) | + +--- + +## 이 문서가 다시 정의하지 않는 것 (중복 금지) + +아래는 마스터 프롬프트 Phase 7·8의 산출물이다. **참조만 하고 재정의·재구현·수정하지 않는다.** 이 목록의 코드를 고치고 싶어지면 멈추고 사용자에게 물을 것. + +| 산출물 | 소속 | 이 문서에서의 취급 | +|---|---|---| +| `CoreAlarm` · `AlarmKitScheduling` · AlarmKit 어댑터 | Phase 7 | 알람 재스케줄은 기존 경로 그대로 사용 | +| FCM 사일런트 푸시 수신 · 폴링 폴백 · `UIBackgroundModes` | Phase 8 | 앱을 깨우는 트리거로만 사용 | +| `AlarmSyncService` (앱 시작·포그라운드 복귀·푸시 수신 3경로 일원화) | Phase 8 | **유일한 훅 지점** — 갱신 성공 시점에 인지 계층을 이어 붙인다 | +| `RefreshAlarmUseCase` · `GET /routes/user-routes/refresh` | Phase 1·8 | 새 값의 유일한 출처 | +| 서버 계약 전체 | 마스터 공통 규칙 | v1은 서버 변경 0 — 새 엔드포인트·payload 제안 금지 | + +## 전제 조건 + +1. **Phase 10~12는 마스터 Phase 7(CoreAlarm)·Phase 8(갱신 채널) 완료가 전제.** 예외는 Phase 9뿐 — 산출물이 전부 신규 파일이라 **마스터 Phase 7~8과 병렬 진행 가능** (Phase 9의 병렬 가드 준수). +2. **`GoogleService-Info.plist` 투입 (하드 전제)** — FCM이 없으면 백그라운드 깨움이 없어 피기백이 폴링(포그라운드 전용)으로 퇴화한다. 미투입 상태로도 빌드·구현은 진행하되, 백그라운드 인지 시연이 불가함을 사용자에게 고지할 것 (마스터 미확정 입력 #6). +3. V2에는 현재 entitlements·`NSSupportsLiveActivities`·위젯 익스텐션 인프라가 전무하다 — Phase 9가 이를 만든다. + +--- + +## Phase 9 — 빌드 인프라: 위젯 익스텐션 타겟 + CoreLiveActivity + entitlements *(마스터 Phase 7~8과 병렬 가능)* + +### Goal +Live Activity를 올릴 수 있는 빌드 기반을 만든다 — 위젯 익스텐션 타겟, 공유 Attributes 모듈, entitlements. UI·로직 없이 빈 껍데기까지만. + +### Requirements +- **Tuist DSL 확장**: `Tuist/ProjectDescriptionHelpers/`에 위젯 익스텐션 타겟 헬퍼 신설(또는 `Projects/App/Project.swift`에 인라인 선언). `product: .appExtension`, bundleId `com.atcha.iOS.v2.widget`(앱 접두 필수), infoPlist에 `NSExtension` → `NSExtensionPointIdentifier: com.apple.widgetkit-extension`, **settings는 반드시 `Settings.atchaV2()` 경유** (Stage 3구성 함정 — 이 레포 1순위 함정). +- **앱 타겟 dependencies에 `.target(name:)`으로 익스텐션 추가** → Tuist 자동 임베드. +- **`CoreLiveActivity` 공유 모듈**: `Projects/Core/LiveActivity`에 `Project.layer(name: "CoreLiveActivity", bundleSuffix: "core.liveactivity", isolation: .nonisolated)` + Workspace 등록. `ActivityAttributes` 정의(막차 세션 고정 정보) + `ContentState`(출발 시각, 알람 시각, 긴급도 단계, 변경 배지 만료 시각, 세션 상태) — 앱·익스텐션 양쪽에 링크 (전부 static framework라 중복 심볼 문제 없음). **ActivityKit import는 CoreLiveActivity·위젯 익스텐션·App 어댑터 3곳으로 한정** — Domain·Feature 유출 금지. +- **entitlements**: `Projects/App/Project.swift`에 Tuist `entitlements:` DSL로 `aps-environment` 추가 (마스터 Phase 7 Constraints의 "수동 파일 대신 매니페스트로" 방침 준수). 익스텐션 타겟도 동일 DSL 경유. +- **infoPlist 키**: 앱 타겟에 `NSSupportsLiveActivities: true`. `NSSupportsLiveActivitiesFrequentUpdates`는 추가하지 않는다 (저빈도 정책 — 필요해지면 사용자에게 물을 것). +- **UI 규약 예외 선언**: 위젯 익스텐션은 SwiftUI + WidgetKit — 레포 UIKit 규약의 **유일한 명시적 예외**. DesignSystem 토큰(`DSColor` 등 UIColor 기반)의 SwiftUI 브리지(`Color(uiColor:)`) 헬퍼를 CoreLiveActivity 또는 익스텐션 내부에 둔다 (DesignSystem 모듈 수정 최소화). +- 익스텐션에 플레이스홀더 위젯(빈 잠금화면 뷰)까지만 — 실제 UI는 Phase 10. + +### Constraints +- **Firebase 등 외부 라이브러리를 익스텐션에 링크 금지** (공통 constraints). +- `Tuist/Package.swift`·`Package.resolved` 무변경 (ActivityKit·WidgetKit은 시스템 프레임워크). +- CoreLiveActivity는 Domain을 import하지 않는다 (CoreAlarm과 동일한 무의존 원칙 — 중립 타입만). +- **병렬 가드** (마스터 Phase 7~8과 동시 진행 시): + - **별도 브랜치**에서 진행 (Phase 6 완료 커밋 기준). Phase 7~8 브랜치의 파일을 건드리지 않는다. + - 공유 충돌 지점은 2파일뿐: `Projects/App/Project.swift`(Phase 7도 AlarmKit entitlements를 추가할 수 있음)·`Workspace.swift`(양쪽 다 모듈 등록). **신규 파일 작업(헬퍼·CoreLiveActivity·익스텐션)을 먼저 완성하고, 이 2파일의 수정은 최소 diff로 마지막에** — Phase 7~8 머지 후 리베이스 시 충돌을 몇 줄로 한정한다. + - entitlements 병합 시 **양쪽 항목을 모두 유지** (aps-environment + AlarmKit 관련) — 한쪽을 덮어쓰지 않는다. + - acceptance는 자기 브랜치에서 통과시키고, **Phase 7~8 머지 후 리베이스한 뒤 공통 acceptance를 한 번 더 실행**해야 Phase 10 진입 가능. + +### Acceptance +공통 acceptance (tuist generate + Debug·Stage 빌드 — 익스텐션은 앱 스킴에 임베드되므로 앱 빌드가 커버) + `-scheme CoreLiveActivity test`. + +### 사람 검수 +빌드 산출물에 익스텐션이 임베드됐는지(`.app/PlugIns/`) 확인 보고. 시각 검수는 Phase 10에서. + +--- + +## Phase 10 — LA 라이프사이클 + 화면: 시작/종료 + 긴급도 색 + +### Goal +알람 등록 세션과 Live Activity의 수명을 일치시키고, 잠금화면·다이나믹 아일랜드 화면을 정책 3대로 구현한다. + +### Requirements +- **Domain 포트** (포트+어댑터 패턴 — 마스터 공통 규칙): Domain에 순수 프로토콜 신설: + ```swift + public protocol LastTrainActivityPort: Sendable { + func start(session: AlarmInfo, route: LastRoute) async + func update(state: LastTrainActivityState, alert: Bool) async + func end(final: LastTrainActivityState) async + var isDismissedByUser: Bool { get async } + } + ``` + App의 `Projects/App/Sources/Adapters/`에 ActivityKit 어댑터 구현 (`AppDIContainer` 주입 — `NoopAlarmScheduler` 교체와 동일 패턴). Feature는 이 포트를 직접 보지 않는다 — UseCase 경유. +- **수명 연동**: `RegisterAlarmUseCase` 성공 → LA 시작. `CancelAlarmUseCase` → LA 종료. 알람 발화 후 세션 종료 시점(막차 출발 시각 경과) → final state로 종료. +- **잠금화면 + 다이나믹 아일랜드 UI** (정책 3): + - 잠금화면: 노선명·"출발까지 ⏱ N분" 카운트다운·출발 시각·도보 안내. 다이나믹 아일랜드: compact(카운트다운) / expanded(잠금화면 축약). + - **긴급도 3단계 색** 상시: 여유/주의/임박 — 임계 정의는 Domain에 두고(예: 남은 시간 비율), 색 토큰은 DSColor의 SwiftUI 브리지 경유. 기존 `DSBanner.Style`(normal/urgent 2단계)과 단계 의미가 어긋나지 않게 매핑 정리. + - "⚠ 당겨짐" 배지 슬롯 (표시 조건은 Phase 11에서 연결). +- **dismiss 감지**: 어댑터가 `activityStateUpdates` 관찰 → 유저 스와이프 dismiss를 로컬에 기록 (Phase 12의 폴백 트리거). +- **구현 노트 — LA 예약 업데이트 불가 제약**: LA는 미래 시점 상태 변경을 예약할 수 없다. 카운트다운은 `Text(timerInterval:)` 등 시스템 타이머 뷰로 렌더 고정을 우회하고, **`staleDate`를 설정**해 갱신이 끊긴(강제종료 등) LA가 오래된 정보를 신선한 것처럼 보이지 않게 방어한다. 긴급도 색 전환은 앱 깨움 시점(푸시·폴링·알람 발화·포그라운드 복귀)마다 재평가로 근사 — 이 한계를 코드 주석으로 명시. + +### Constraints +- ActivityKit 심볼이 Domain·Feature로 새어나가면 안 됨 (`tuist graph` + import 검사). +- 익스텐션 UI는 CoreLiveActivity의 `ContentState`만 소비 — 네트워크·저장소 접근 금지. +- 단위 테스트는 ActivityKit 직접 호출 없이 — 수명 정책(등록→시작, 취소→종료)은 Domain UseCase를 스텁 포트로 테스트. 긴급도 임계 계산은 순수 함수로 분리해 테스트. + +### Acceptance +공통 acceptance + `-scheme Domain test` + `-scheme CoreLiveActivity test`. + +### 사람 검수 (블로킹) +**LA 표시는 자동 검증 불가** — iOS 26 시뮬레이터/실기기에서 알람 등록 → 잠금화면·다이나믹 아일랜드 표시 → 취소 시 소멸을 사용자가 직접 확인해야 다음 Phase 진행. + +--- + +## Phase 11 — 변경 반영 피기백: diff 판정 + 방향 비대칭 + 버퍼 + +### Goal +Phase 8의 갱신 성공 지점에 인지 계층을 이어 붙인다 — 변경 판정은 Domain, 표출은 어댑터. 버퍼·마지노선 정책을 알람 스케줄에 반영한다. + +### Requirements +- **Domain 변경 판정 UseCase** (정책의 코드화 — 이 문서의 심장): + ```swift + public enum AlarmChangeVerdict: Sendable, Equatable { + case unchanged + case delayed(by: TimeInterval) // 늦춰짐 → 조용한 업데이트 + case advanced(by: TimeInterval, actionable: Bool) // 앞당겨짐 → alert. actionable=false면 이미 못 탐 + case sessionEnded // 운행 종료·경로 소멸 + } + public protocol EvaluateAlarmChangeUseCase: Sendable { + func execute(previous: AlarmInfo, latest: AlarmInfo, now: Date) -> AlarmChangeVerdict + } + ``` + diff는 **클라 계산**: 이전 = 로컬 저장 `AlarmInfo`, 최신 = refresh 응답. 스텁 없이 순수 로직이므로 경계 케이스(동일 시각·자정 경계·과거 시각) 테스트 필수. +- **`AlarmSyncService` 훅**: 3경로(앱 시작·포그라운드 복귀·푸시 수신) 갱신 성공 → 판정 → ① 알람 재스케줄(기존 경로, 무조건 선행 — 핵심 원칙) ② LA 업데이트: `advanced` → `alert: true` + 행동 중심 문구("N분 일찍 나가야 해요" / "막차가 HH:mm → HH:mm로 당겨졌어요") + 배지 만료 시각 = now + 10분 (배지 소멸은 익스텐션이 타이머 뷰 조건으로 처리 — Phase 10 노트) / `delayed` → 조용한 업데이트. +- **포그라운드 인앱 채널**: 앱이 포그라운드인 상태로 판정이 나오면 LA alert 대신 **DSBanner 갱신 강조 + DSToast**("막차가 15분 당겨졌어요"). 채널 선택(포그라운드/백그라운드)은 훅에서 분기. +- **버퍼·마지노선** (정책 5): `RegisterAlarmUseCase`·재스케줄 경로가 **기준 시각 −3분**에 로컬 알람을 걸도록 수정 (현재 departureTime 그대로 스케줄 중 — `RegisterAlarmUseCase.swift`). 기준 시각은 [미확정 입력 #7](#미확정-입력-사용자-제공-대기) — 받기 전엔 클라 계산. 배너·LA의 "출발까지 N분"도 같은 기준으로 통일 (이중 시각 금지). +- **스누즈 클램프 검증**: AlarmKit 기본 UI의 반복(스누즈) 버튼으로 "마지노선까지만" 클램프가 가능한지 검증 — **불가하면 반복 버튼 제거 + 단발 알람 폴백**을 적용하고 결과를 사용자에게 보고 (정책 문서 Phase 7 검증 항목). +- **알람 발화 후 변경 도착**: 새 알람 시각이 이미 과거 → 즉시 최후통첩(임박 상태 + alert "지금 안 나가면 못 타요") / 미래 → 기존 `replaceAlarm` 재스케줄로 자동 처리 (분기 로직은 판정 UseCase의 `actionable`·now 파라미터로). +- **디버그 변경 시뮬레이터**: 실서버 변경을 기다릴 수 없으므로, DEV 빌드 한정 디버그 메뉴(또는 스텁 refresh)로 "N분 앞당겨짐/늦춰짐/종료" 주입 수단을 만든다 — 사람 검수의 전제. + +### Constraints +- `AlarmSyncService`·`RefreshAlarmUseCase`의 기존 계약을 바꾸지 않는다 — 훅은 갱신 성공 이후에 덧붙이는 방식 (Phase 8 코드 최소 침습). +- 알람 재스케줄이 LA 업데이트보다 **항상 선행** — LA 실패가 알람을 막으면 안 됨 (핵심 원칙의 코드 표현). +- 메시지 문구는 하드코딩 상수로 두되 한 파일에 모을 것 (추후 문구 정책 변경 대비). + +### Acceptance +공통 acceptance + `-scheme Domain test`(판정 UseCase 경계 케이스 포함) + `-scheme HomeFeature test`(배너 갱신). + +### 사람 검수 (블로킹) +디버그 시뮬레이터로 **앞당겨짐(백그라운드 → LA alert 화면 켜짐 / 포그라운드 → 배너+토스트), 늦춰짐(조용한 갱신), 알람 후 변경(즉시 최후통첩)** 3종 시연 후 확인받을 것. 스누즈 클램프 검증 결과(가능/폴백 적용)도 이때 보고. + +--- + +## Phase 12 — 폴백·하드닝: dismiss 로컬 노티 + 실패 상태 + 마감 + +### Goal +인지 계층의 구멍(LA를 지운 유저, 못 타게 된 유저, 세션 소멸)을 막고 전체 시나리오를 마감한다. + +### Requirements +- **알림 권한 요청**: `UNUserNotificationCenter.requestAuthorization`을 [미확정 입력 #8](#미확정-입력-사용자-제공-대기)의 시점에 요청 (임시: 알람 등록 성공 직후). **AlarmKit 권한 요청(Phase 7, 등록 버튼 탭 시점)과 연속 팝업이 되지 않게** 순서·간격을 설계하고 사람 검수에서 확인. 거부 시: LA·알람은 정상 동작(LA는 알림 권한 불필요), 로컬 노티 폴백만 비활성 — 상태를 기록해 두고 재요청 스팸 금지. +- **dismiss 폴백**: Phase 10의 dismiss 기록이 있으면 이후 `advanced` 판정 시 LA alert 대신 **로컬 노티**(같은 행동 중심 문구) 발송 — 피기백 시점엔 앱이 깨어 있으므로 서버 무관여로 가능. push-to-start 재생성 금지. +- **실패·종료 상태**: `advanced(actionable: false)`(못 타게 됨) → LA를 실패 상태로 전환 + "막차가 지나갔어요" (대안 제시는 미확정 입력 #9 — 받기 전엔 문구만). `sessionEnded`(운행 종료·경로 소멸 — refresh가 경로 없음 반환) → LA final state 종료 + 알람 취소 + 배너 정리. +- **하드닝 체크리스트** (전부 점검·수정): 알림 권한 거부, 유저가 설정에서 LA 비활성(`areActivitiesEnabled` false — 알람만으로 동작), LA 8시간 제한 초과 세션, 강제종료 후 stale LA(`staleDate` 동작 확인), 자정 경계 시간 계산(23:40 → 00:10 등 날짜 넘김 diff·카운트다운), plist 부재 시 인지 계층이 폴링 경로에서만이라도 정상 동작, dismiss 기록의 세션 간 초기화(새 알람 등록 시 리셋). + +### Constraints +- 사일런트 푸시 경로에는 여전히 `requestAuthorization` 호출 금지 (Phase 8 Constraints 유지 — 권한 요청은 명시된 시점 한 곳뿐). +- 로컬 노티는 App에서만 (`UNUserNotificationCenter` import가 Feature·Domain에 유입 금지). + +### Acceptance +공통 acceptance + **Release 구성 빌드 1회 추가** + 전 모듈 테스트 스킴 일괄(`Domain`/`AtchaData`/`CoreStorage`/`CoreAuth`/`CoreAlarm`/`CoreLiveActivity`/`DesignSystem`/`SearchFeature`/`HomeFeature`) + `tuist graph`로 의존 규칙 최종 검증(ActivityKit·UNUserNotificationCenter 유출 없음). + +### 사람 검수 (블로킹) +① 권한 팝업 순서(AlarmKit → 알림) UX ② LA dismiss 후 변경 주입 → 로컬 노티 수신 ③ 운행 종료 주입 → LA 정리, 3종 시연 후 전체 스코프 마감 확인. + +--- + +## 진행 프로토콜 + +[마스터 프롬프트의 진행 프로토콜](atcha-v2-master-prompt.md#진행-프로토콜) 1~6을 그대로 상속한다. 추가 규칙: + +1. **Phase 10~12는 마스터 Phase 7·8 완료 전에 시작하지 않는다.** Phase 9만 예외적으로 병렬 가능 (Phase 9의 병렬 가드 준수). +2. ["이 문서가 다시 정의하지 않는 것"](#이-문서가-다시-정의하지-않는-것-중복-금지) 표의 코드를 수정하고 싶어지면 멈추고 사용자에게 물을 것. +3. Phase 9는 빌드 인프라 전용 — UI·로직을 선취하지 않는다. 9 → 10 → 11 → 12 순서 고정 (이 문서 내부의 병렬 없음). +4. v2(서버 주도 LA push 승격)는 이 문서 스코프 밖 — 제안하지 말 것. + +## 미확정 입력 (사용자 제공 대기) + +번호는 마스터 프롬프트의 #1~6에 이어 #7부터. + +| # | 항목 | 필요한 Phase | 받기 전 임시 동작 | +|---|---|---|---| +| 7 | **알람 기준 시각의 서버 필드** — refresh 응답에 `departureTime`뿐이라 서버 계산 알람 시각이 없다 (`AlarmInfo` TODO 실측). 서버 필드 추가 요청? 클라 계산 확정? | 11 | 클라 계산: `departureTime − 첫 도보 구간 시간 − 3분 버퍼`, TODO 주석 | +| 8 | 알림 권한 요청 시점 UX 확정 | 12 | 알람 등록 성공 직후 요청 | +| 9 | 대안 제시(심야버스 등) 데이터 소스 | 12 | 실패 문구만 ("막차가 지나갔어요") | +| 10 | `GoogleService-Info.plist` (마스터 #6과 동일 항목) | 11~12 시연 | plist 가드로 FCM 비활성 — 피기백이 폴링 경로에서만 동작, 백그라운드 인지 시연 불가 고지 |