diff --git a/Projects/App/Sources/Adapters/DevDemoFallbacks.swift b/Projects/App/Sources/Adapters/DevDemoFallbacks.swift index 2e8108b..918744b 100644 --- a/Projects/App/Sources/Adapters/DevDemoFallbacks.swift +++ b/Projects/App/Sources/Adapters/DevDemoFallbacks.swift @@ -72,9 +72,10 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository { } } - /// 발화 검증을 빠르게 하려고 출발 시각을 3분 뒤로 둔다 (알람은 출발 시각에 울린다). + /// 출발 시각은 8분 뒤 — 알람이 버퍼(−3분) 반영으로 +5분 시점에 걸린다. + /// (3분이면 알람 시각 ≈ now라 등록 탭 시점에 이미 과거가 되어 AlarmKit이 거부한다.) private static func demoRoute(start: Coordinate, end: Coordinate) -> LastRoute { - let departure = Date().addingTimeInterval(3 * 60) + let departure = Date().addingTimeInterval(8 * 60) return LastRoute( id: "dev-demo-route", departureTime: departure, diff --git a/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift b/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift index 5b067b8..a51d043 100644 --- a/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift +++ b/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift @@ -11,6 +11,11 @@ import Foundation nonisolated protocol LastTrainChangeAlerting: Sendable { /// alert가 nil이면 조용한 상태 갱신, 값이 있으면 해당 문구의 AlertConfiguration으로 갱신한다. func update(state: LastTrainActivityState, alert: (title: String, body: String)?) async + /// Phase 12 dismiss 폴백 트리거 — 유저가 잠금화면에서 LA를 스와이프로 지운 기록. + /// true면 LA alert는 도달 불가(update가 no-op)라 호출자가 로컬 노티로 갈아탄다. + var isDismissedByUser: Bool { get async } + /// Phase 12 종료 표출 — missed/serviceEnded 최종 상태로 LA를 내린다(Domain 포트와 동일 구현). + func end(final state: LastTrainActivityState) async } /// ActivityKit → Domain `LastTrainActivityPort` 어댑터. ActivityKit을 import하는 곳은 App에서 이 파일뿐. @@ -27,6 +32,10 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti private let userDefaults: UserDefaults /// 단일 세션 — 단일 알람 정책과 동일. 새 start가 이전 activity를 먼저 내린다. + /// + /// 알려진 한계(LA 활성 8시간 제한): 출발이 8시간 이상 남은 세션은 시스템이 LA를 먼저 + /// 종료할 수 있다(다이나믹 아일랜드는 더 짧음). 막차 추적은 저녁~심야 용도라 통상 무해하고, + /// 안전망(로컬 알람)은 LA와 무관하게 유지된다 — 재시작 경로는 두지 않는다(정책: 저빈도, YAGNI). private var activity: Activity? /// `activityStateUpdates` 관찰 태스크 — 프로그램적 end·새 start·deinit에서 cancel. private var stateObservationTask: Task? @@ -162,6 +171,22 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerti userDefaults.set(value, forKey: Self.dismissedDefaultsKey) } + #if DEV + // MARK: - DEV 검수용 (Phase 12 dismiss 폴백) + + /// 플로팅 디버그 메뉴의 현재값 표시용 — 인메모리 캐시와 UserDefaults는 항상 함께 갱신되므로 + /// MainActor(UI)에서 UserDefaults만 동기로 읽어도 어긋나지 않는다. + nonisolated static var devDismissedDefaultsKey: String { dismissedDefaultsKey } + + /// 잠금화면 스와이프 자동화가 불안정할 때 dismiss 기록을 강제로 뒤집는 검수 수단. + /// recordDismissedByUser를 그대로 타므로 인메모리 캐시와 UserDefaults가 함께 갱신된다 — + /// UserDefaults만 바깥에서 직접 만지면 살아 있는 액터가 낡은 캐시 값을 계속 답하게 된다. + func devToggleDismissedByUser() -> Bool { + recordDismissedByUser(!dismissedByUser) + return dismissedByUser + } + #endif + // MARK: - Domain → CoreLiveActivity 매핑 /// Domain 상태 → 위젯 계약 ContentState. urgency/phase는 양쪽 enum의 rawValue가 동일하다. diff --git a/Projects/App/Sources/Adapters/LocalNotificationAdapter.swift b/Projects/App/Sources/Adapters/LocalNotificationAdapter.swift new file mode 100644 index 0000000..034b28f --- /dev/null +++ b/Projects/App/Sources/Adapters/LocalNotificationAdapter.swift @@ -0,0 +1,65 @@ +import Domain +import Foundation +@preconcurrency import UserNotifications + +/// UNUserNotificationCenter → Domain `LocalNotificationPort` 어댑터. +/// UserNotifications를 import하는 곳은 App에서 이 파일뿐(디바이스 프레임워크 App 한정 규칙). +/// +/// 역할은 Phase 12의 두 가지뿐: +/// ① 권한 요청 — 명시된 한 시점(알람 등록 성공 직후, `DefaultRegisterAlarmUseCase` 훅)에서만 +/// 불린다. 사일런트 푸시 경로에는 requestAuthorization이 절대 없다 — `post()`는 권한을 +/// 묻지 않고 현재 상태만 조회해 authorized가 아니면 조용히 no-op한다. +/// ② dismiss 폴백 발송 — 유저가 LA를 스와이프로 지운 뒤의 변경 표출을 로컬 노티로 대신한다. +/// +/// actor인 이유: LastTrainLiveActivityAdapter와 동일 — 포트가 nonisolated async 요구사항을 +/// 가진 Sendable 프로토콜이라 MainActor 클래스의 격리 멤버로는 적합성이 성립하지 않는다. +/// UserNotifications 타입 일부가 Sendable 미표기라 `@preconcurrency`로 완화한다. +actor LocalNotificationAdapter: LocalNotificationPort { + /// 권한 요청 이력 키 — 시스템 다이얼로그는 어차피 최초 1회만 뜨지만, "재요청 금지"를 + /// 명시 계약으로 어댑터가 보장한다(이력이 있으면 시스템 호출 자체를 다시 하지 않는다). + private static let authRequestedDefaultsKey = "noti.authRequested" + + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + // MARK: - LocalNotificationPort + + func requestAuthorizationIfNeeded() async { + guard !userDefaults.bool(forKey: Self.authRequestedDefaultsKey) else { return } + do { + _ = try await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound]) + } catch { + // 실패 흡수(포트 계약) — 권한 요청 실패가 알람 등록 결과에 영향을 줄 수 없다. + } + // 허용·거부·에러 무관하게 "요청했음"만 기록한다 — 발송 가능 여부는 post()가 매번 + // notificationSettings()로 실시간 조회하므로 결과까지 저장할 필요가 없다. + userDefaults.set(true, forKey: Self.authRequestedDefaultsKey) + } + + func post(title: String, body: String) async { + let center = UNUserNotificationCenter.current() + // 권한 "요청"이 아니라 상태 조회다 — 사일런트 푸시로 깨어난 백그라운드에서도 안전. + let settings = await center.notificationSettings() + guard settings.authorizationStatus == .authorized else { return } + + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + let request = UNNotificationRequest( + // 폴백 노티는 변경 이벤트당 1건으로 드물다 — 교체(고정 id) 없이 개별 발송. + identifier: UUID().uuidString, + content: content, + trigger: nil // 즉시 발송 + ) + do { + try await center.add(request) + } catch { + // 실패 흡수(포트 계약) — 표출 실패가 알람·동기화에 영향을 줄 수 없다. + } + } +} diff --git a/Projects/App/Sources/AlarmSyncService.swift b/Projects/App/Sources/AlarmSyncService.swift index 35dd133..8174dfb 100644 --- a/Projects/App/Sources/AlarmSyncService.swift +++ b/Projects/App/Sources/AlarmSyncService.swift @@ -12,6 +12,12 @@ import os /// ① LA 채널(당겨짐 alert / 늦춰짐 조용한 갱신)과 ② 인앱 채널(`AlarmChangeEvents` → /// 홈 배너 강조·토스트)을 덧붙인다. 알람 재스케줄은 `RefreshAlarmUseCase.execute()` 안에서 /// 이미 끝난 뒤라(반환 = 재스케줄 완료) LA·인앱 표출 실패가 알람을 막을 구조 자체가 없다. +/// +/// Phase 12 폴백·종료: ③ 유저가 LA를 스와이프로 지운 기록(`isDismissedByUser`)이 있으면 +/// 백그라운드 alert 채널을 로컬 노티(같은 문구)로 갈아탄다 — 피기백 시점엔 앱이 깨어 있으므로 +/// 서버 무관여로 가능하고, push-to-start 재생성은 하지 않는다(지운 의사 존중). +/// ④ advanced(actionable: false)는 LA를 missed 상태로, sessionEnded는 serviceEnded 최종 +/// 상태로 내리고 로컬 알람을 취소한다. 배너 정리는 changes 스트림을 받은 홈의 몫. // Sendable 프로토콜(AlarmSyncEvents 등) 채택이 기본 MainActor 격리를 nonisolated로 // 추론시키므로 명시한다 — 상태(subscribers 등)는 전부 메인 액터에서만 만진다. @MainActor @@ -20,6 +26,11 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { private let evaluateChangeUseCase: any EvaluateAlarmChangeUseCase /// LA 표출 경로 — non-throwing 계약(어댑터가 실패 흡수)이라 이 훅의 어떤 실패도 무해하다. private let liveActivity: any LastTrainChangeAlerting + /// dismiss 폴백 채널(Phase 12) — 유저가 LA를 지운 뒤의 변경 alert를 로컬 노티로 대신한다. + /// 발송도 non-throwing(권한 없으면 조용히 no-op) — 여기서 권한을 요청하는 일은 절대 없다. + private let localNotification: any LocalNotificationPort + /// sessionEnded 시 로컬 알람 취소용(Phase 12) — 등록/갱신 UseCase와 같은 스케줄러를 공유한다. + private let alarmScheduler: any AlarmScheduler private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "AlarmSync") /// "⚠ 당겨짐" 배지 유지 시간 — 정책: 표출 시점 + 10분. @@ -39,11 +50,15 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { init( refreshAlarmUseCase: any RefreshAlarmUseCase, evaluateChangeUseCase: any EvaluateAlarmChangeUseCase, - liveActivity: any LastTrainChangeAlerting + liveActivity: any LastTrainChangeAlerting, + localNotification: any LocalNotificationPort, + alarmScheduler: any AlarmScheduler ) { self.refreshAlarmUseCase = refreshAlarmUseCase self.evaluateChangeUseCase = evaluateChangeUseCase self.liveActivity = liveActivity + self.localNotification = localNotification + self.alarmScheduler = alarmScheduler } /// 인증 부트스트랩 완료 후 1회 호출: 즉시 동기화(앱 시작 경로) + 포그라운드 @@ -99,7 +114,7 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { return info } - // MARK: - Phase 11 변경 표출 (판정 → LA/인앱 채널) + // MARK: - Phase 11·12 변경 표출 (판정 → LA/로컬 노티/인앱 채널) /// 판정 → 채널 분기. LA 호출은 전부 실패 무해(포트가 non-throwing) — 알람에 영향 없음. private func propagateChange(previous: AlarmInfo?, latest: AlarmInfo) async { @@ -127,13 +142,15 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { await presentAdvanced(previous: previous, latest: latest, delta: delta, now: now) yieldChange(verdict) - case .advanced(by: _, actionable: false), .sessionEnded: - // TODO(Phase 12): 못 탐/운행 종료 표출(missed·serviceEnded 전환, 로컬 노티 폴백)은 - // 다음 페이즈 산출물 — 그 전까지 조용한 LA 갱신만 한다 - // (sessionEnded는 departureTime이 없어 사실상 no-op). - if let state = activityState(for: latest, now: now) { - await liveActivity.update(state: state, alert: nil) - } + case .advanced(by: _, actionable: false): + // 못 타게 됨 — LA를 실패 상태(missed)로 전환하고 '막차가 지나갔어요'를 알린다. + await presentMissed(latest: latest, now: now) + yieldChange(verdict) + + case .sessionEnded: + // 운행 종료·경로 소멸 — LA 최종 상태 종료 + 로컬 알람 취소. + // 배너 정리는 changes yield를 받은 홈의 몫. + await presentSessionEnded(previous: previous, now: now) yieldChange(verdict) } } @@ -152,17 +169,24 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { guard alarmTime > now else { // 새 알람 시각이 이미 과거(출발은 미래) — 마지노선 침범. 원래 울렸어야 할 알람 // 시점이 지나 있으므로 조용한 채널로는 늦다: 포그라운드 여부와 무관하게 즉시 최후통첩. - let state = LastTrainActivityState( - departureTime: departure, - alarmTime: alarmTime, - urgency: .imminent, - changeBadgeExpiry: badgeExpiry, - phase: .active - ) - await liveActivity.update(state: state, alert: ( + let alert = ( title: LastTrainChangeMessages.ultimatumTitle, body: LastTrainChangeMessages.ultimatumBody(latestDeparture: departure) - )) + ) + if await liveActivity.isDismissedByUser { + // dismiss 폴백(Phase 12) — LA는 유저가 지웠다: alert를 실을 update는 no-op이고 + // push-to-start 재생성은 하지 않는다(어차피 세션도 없고, 지운 의사 존중이 정책). + // 피기백 시점엔 앱이 깨어 있으므로 서버 무관여 로컬 노티로 같은 문구를 보낸다. + await localNotification.post(title: alert.title, body: alert.body) + } else { + await liveActivity.update(state: LastTrainActivityState( + departureTime: departure, + alarmTime: alarmTime, + urgency: .imminent, + changeBadgeExpiry: badgeExpiry, + phase: .active + ), alert: alert) + } return } @@ -177,18 +201,79 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents { // 포그라운드 — LA alert 생략(조용한 업데이트 + 배지). 사용자 주의는 인앱 채널 // (changes 스트림 → 홈 배너 강조 + 토스트)이 맡는다. 이중 알림 방지. await liveActivity.update(state: state, alert: nil) + return + } + + // 백그라운드 — 행동 중심 문구를 잠금화면에 싣는다. + let minutesEarlier = max(1, Int((delta / 60).rounded(.up))) + // advanced(by:)의 delta = 이전 출발 − 새 출발. 이전 값이 비어 있으면 새 시각 + delta로 복원. + let previousDeparture = previous?.departureTime ?? departure.addingTimeInterval(delta) + let alert = ( + title: LastTrainChangeMessages.advancedAlertTitle(minutesEarlier: minutesEarlier), + body: LastTrainChangeMessages.advancedAlertBody(from: previousDeparture, to: departure) + ) + if await liveActivity.isDismissedByUser { + // dismiss 폴백(Phase 12) — LA alert 대신 같은 행동 중심 문구의 로컬 노티. + // push-to-start 재생성 금지(정책) — 지워진 LA를 되살리지 않는다. + await localNotification.post(title: alert.title, body: alert.body) + } else { + // 잠금화면 alert + "당겨짐" 배지(만료 now+10분). + await liveActivity.update(state: state, alert: alert) + } + } + + /// 못 탐(advanced, actionable: false) 표출 — LA를 실패 상태(missed)로 전환한다. + /// 알람은 손대지 않는다: 과거 fireDate 재스케줄은 RefreshAlarmUseCase가 이미 걸렀고, + /// 이미 울렸거나 임박한 알람을 지우는 것은 인지 기회만 줄인다. + private func presentMissed(latest: AlarmInfo, now: Date) async { + // actionable=false 판정은 departureTime이 있을 때만 나온다(없으면 sessionEnded). + guard let departure = latest.departureTime else { return } + let state = LastTrainActivityState( + departureTime: departure, + alarmTime: AlarmTiming.alarmFireDate(departureTime: departure), + urgency: .imminent, + changeBadgeExpiry: nil, + phase: .missed + ) + // TODO(#9 임시 — 문구만): 대안 제시 데이터(심야버스·첫차 등) 확보 시 본문에 대안 안내를 싣는다. + let alert = ( + title: LastTrainChangeMessages.missedTitle, + body: LastTrainChangeMessages.missedBody(latestDeparture: departure) + ) + if UIApplication.shared.applicationState == .active { + // 포그라운드 — 상태 전환만 조용히. 사용자 주의는 인앱 채널이 맡는다(이중 알림 방지). + await liveActivity.update(state: state, alert: nil) + } else if await liveActivity.isDismissedByUser { + // dismiss 폴백(Phase 12) — push-to-start 재생성 금지, 같은 문구의 로컬 노티로 대신한다. + await localNotification.post(title: alert.title, body: alert.body) } else { - // 백그라운드 — 잠금화면 alert + 행동 중심 문구 + "당겨짐" 배지(만료 now+10분). - let minutesEarlier = max(1, Int((delta / 60).rounded(.up))) - // advanced(by:)의 delta = 이전 출발 − 새 출발. 이전 값이 비어 있으면 새 시각 + delta로 복원. - let previousDeparture = previous?.departureTime ?? departure.addingTimeInterval(delta) - await liveActivity.update(state: state, alert: ( - title: LastTrainChangeMessages.advancedAlertTitle(minutesEarlier: minutesEarlier), - body: LastTrainChangeMessages.advancedAlertBody(from: previousDeparture, to: departure) - )) + await liveActivity.update(state: state, alert: alert) } } + /// 운행 종료·경로 소멸(sessionEnded) 표출 — LA를 최종 상태(serviceEnded)로 내리고 + /// 로컬 알람을 취소한다. 서버 측 알람 취소는 부르지 않는다 — 경로 소멸은 서버 재계산 + /// 결과 그 자체라 이미 반영돼 있다. + /// TODO: [미확정] 서버가 종료 후에도 세션을 남겨 두는 스펙으로 확정되면 취소 API 연동을 재검토한다. + private func presentSessionEnded(previous: AlarmInfo?, now: Date) async { + // 더는 울리면 안 되는 것이 정책의 핵심 — 표출(LA 종료)보다 알람 취소를 먼저 한다. + await alarmScheduler.cancelAlarm() + + // sessionEnded 응답에는 departureTime이 없다 — 종료 시각 정보용으로 직전 스냅샷 + // (previous = 갱신 전 lastInfo)의 마지막 출발 시각을 쓰고, 그것도 없으면 now. + let departure = previous?.departureTime ?? now + // 유저가 이미 LA를 지웠으면 end는 no-op — 종료는 행동을 요구하지 않으므로 + // 로컬 노티 폴백도 없다(배너 정리는 changes 스트림을 받은 홈이 한다). + await liveActivity.end(final: LastTrainActivityState( + departureTime: departure, + alarmTime: AlarmTiming.alarmFireDate(departureTime: departure), + // 위젯은 serviceEnded phase 키로 그린다 — urgency는 종료 화면에선 의미 없는 방어값. + urgency: .imminent, + changeBadgeExpiry: nil, + phase: .serviceEnded + )) + } + /// 갱신된 AlarmInfo → LA 상태. departureTime이 없으면(세션 종료 등) 만들 수 없다. private func activityState(for info: AlarmInfo, now: Date) -> LastTrainActivityState? { guard let departure = info.departureTime else { return nil } diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 486a58c..1925b27 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -24,7 +24,14 @@ final class AppDIContainer { private let alarmScheduler: any AlarmScheduler // LA도 알람 세션과 수명을 같이하므로 1회 생성해 공유한다 — dismiss 기록이 세션 단위여야 한다. private let liveActivityPort: any LastTrainActivityPort + // 로컬 노티도 1회 생성 공유 — 권한 요청 훅(등록 UseCase)과 dismiss 폴백 발송(AlarmSyncService)이 + // 같은 요청 이력을 봐야 한다. UNUserNotificationCenter를 아는 곳은 이 어댑터뿐. + private let localNotificationPort: any LocalNotificationPort let alarmSyncService: AlarmSyncService + #if DEV + /// DEV 플로팅 디버그 메뉴가 dismiss 기록 강제 토글에 접근하는 유일한 통로 (Phase 12 검수). + let devLiveActivityAdapter: LastTrainLiveActivityAdapter + #endif init() { #if DEV @@ -83,13 +90,20 @@ final class AppDIContainer { // App 내부 변경 표출 경로(LastTrainChangeAlerting, Phase 11 훅). let liveActivityAdapter = LastTrainLiveActivityAdapter() self.liveActivityPort = liveActivityAdapter + #if DEV + self.devLiveActivityAdapter = liveActivityAdapter + #endif + let localNotificationAdapter = LocalNotificationAdapter() + self.localNotificationPort = localNotificationAdapter self.alarmSyncService = AlarmSyncService( refreshAlarmUseCase: DefaultRefreshAlarmUseCase( repository: alarmRepository, scheduler: alarmScheduler ), evaluateChangeUseCase: DefaultEvaluateAlarmChangeUseCase(), - liveActivity: liveActivityAdapter + liveActivity: liveActivityAdapter, + localNotification: localNotificationAdapter, + alarmScheduler: alarmScheduler ) } @@ -112,7 +126,9 @@ final class AppDIContainer { registerAlarmUseCase: DefaultRegisterAlarmUseCase( repository: alarmRepository, scheduler: alarmScheduler, - activityPort: liveActivityPort + activityPort: liveActivityPort, + // 알림 권한 요청의 유일한 시점(등록 성공 직후) — UseCase 내부 훅이 호출한다. + notificationPort: localNotificationPort ), cancelAlarmUseCase: DefaultCancelAlarmUseCase( repository: alarmRepository, diff --git a/Projects/App/Sources/LastTrainChangeMessages.swift b/Projects/App/Sources/LastTrainChangeMessages.swift index f01fc79..b5417a5 100644 --- a/Projects/App/Sources/LastTrainChangeMessages.swift +++ b/Projects/App/Sources/LastTrainChangeMessages.swift @@ -1,7 +1,7 @@ import Foundation /// Phase 11 — 막차 변경 인지 계층의 사용자 대면 문구 단일 소스(정책: 하드코딩 상수, 한 파일). -/// Live Activity alert(잠금화면)와 추후 Phase 12 로컬 노티 폴백이 같은 문구를 공유한다. +/// Live Activity alert(잠금화면)와 Phase 12 로컬 노티 폴백(LA dismiss 시)이 같은 문구를 공유한다. /// nonisolated: 발신처가 액터 경계를 넘나든다(@MainActor AlarmSyncService·actor LA 어댑터· /// Phase 12 노티 빌더) — 어디서든 동기 접근 가능해야 한다. nonisolated enum LastTrainChangeMessages { @@ -26,6 +26,16 @@ nonisolated enum LastTrainChangeMessages { "막차가 \(timeText(latestDeparture)) 출발로 당겨졌어요. 바로 출발하세요" } + // MARK: - 못 탐 (advanced, actionable: false) — 당겨진 출발 시각이 이미 과거 (Phase 12) + + /// LA 실패 상태(missed) 전환 alert와 dismiss 폴백 로컬 노티가 공유하는 제목. + static let missedTitle = "막차가 지나갔어요" + + /// TODO(#9 임시 — 문구만): 대안 제시 데이터(심야버스·첫차 등) 확보 시 본문에 대안 안내를 싣는다. + static func missedBody(latestDeparture: Date) -> String { + "막차가 \(timeText(latestDeparture))에 이미 출발했어요" + } + // MARK: - 범용 폴백 — 문구를 싣지 않는 Domain 포트 경로(update(state:alert: true)) 전용 static let genericChangeTitle = "막차 정보가 변경됐어요" diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index 5091955..5e1db59 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -91,6 +91,21 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { add("10분 늦춤 (3초 후)", injection: .delay(10 * 60), delay: 3) add("운행 종료 (3초 후)", injection: .end, delay: 3, style: .destructive) add("즉시 refresh", injection: nil, delay: 0) + // Phase 12 dismiss 폴백 검수용 — 잠금화면 스와이프 자동화가 불안정할 때 기록을 강제한다. + // 켠 뒤 앞당김/운행 종료를 주입하면 LA alert 대신 로컬 노티 경로를 탄다. + // 표시용 현재값은 UserDefaults 동기 읽기 — 어댑터가 캐시와 함께 갱신하므로 어긋나지 않는다. + let dismissed = UserDefaults.standard.bool( + forKey: LastTrainLiveActivityAdapter.devDismissedDefaultsKey + ) + sheet.addAction(UIAlertAction( + title: "LA dismiss 기록 토글 (현재 \(dismissed ? "ON" : "OFF"))", + style: .default + ) { _ in + Task { @MainActor in + let value = await container.devLiveActivityAdapter.devToggleDismissedByUser() + print("⚠️ [DEV] la.dismissedByUser 강제 세팅 → \(value)") + } + }) sheet.addAction(UIAlertAction(title: "취소", style: .cancel)) if let popover = sheet.popoverPresentationController { popover.sourceView = sourceView ?? window diff --git a/Projects/Domain/Sources/Interfaces/LocalNotificationPort.swift b/Projects/Domain/Sources/Interfaces/LocalNotificationPort.swift new file mode 100644 index 0000000..8602701 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/LocalNotificationPort.swift @@ -0,0 +1,8 @@ +/// 로컬 노티 포트 — 구현은 App 어댑터(UNUserNotificationCenter는 App 한정 규칙). +/// 권한 요청은 명시된 한 시점(알람 등록 성공 직후)에서만 호출된다. +/// 거부 상태 기록·재요청 금지는 구현(어댑터) 책임. 실패는 밖으로 던지지 않는다. +public protocol LocalNotificationPort: Sendable { + func requestAuthorizationIfNeeded() async + /// LA dismiss 폴백용 즉시 발송. 권한 없으면 조용히 no-op. + func post(title: String, body: String) async +} diff --git a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift index 0f1b1eb..8f2e7f7 100644 --- a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift @@ -7,15 +7,19 @@ public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { private let scheduler: any AlarmScheduler /// Live Activity 포트 — nil이면 LA 없이 동작한다(Example·기존 콜사이트 호환). private let activityPort: (any LastTrainActivityPort)? + /// 로컬 노티 포트 — nil이면 알림 권한 요청 없이 동작한다(Example·기존 콜사이트 호환). + private let notificationPort: (any LocalNotificationPort)? public init( repository: any AlarmRepository, scheduler: any AlarmScheduler, - activityPort: (any LastTrainActivityPort)? = nil + activityPort: (any LastTrainActivityPort)? = nil, + notificationPort: (any LocalNotificationPort)? = nil ) { self.repository = repository self.scheduler = scheduler self.activityPort = activityPort + self.notificationPort = notificationPort } public func execute(route: LastRoute) async throws { @@ -47,5 +51,11 @@ public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { ) await activityPort.start(session: session, route: route) } + // 알림 권한 요청 — [미확정 #8 임시] 알람 등록 성공 직후가 유일한 요청 시점. + // 순서 근거: AlarmKit 권한 팝업(흐름 시작, requestAuthorization)과 알림 권한 팝업(여기, 흐름 끝) + // 사이에 서버 등록·로컬 스케줄·LA 시작이 끼어 있어 연속 팝업이 완화된다. + // 등록 실패·권한 거부 경로에서는 위에서 throw로 빠져나가므로 여기까지 오지 않는다. + // 거부 기록·재요청 스팸 방지는 어댑터 책임이고, 실패는 던지지 않는다(등록 결과에 영향 없음). + await notificationPort?.requestAuthorizationIfNeeded() } } diff --git a/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift b/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift index e11a6c4..deb808c 100644 --- a/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift +++ b/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift @@ -79,6 +79,18 @@ private struct SpyActivityPort: LastTrainActivityPort { var isDismissedByUser: Bool { false } } +private struct SpyLocalNotificationPort: LocalNotificationPort { + let log: CallLog + + func requestAuthorizationIfNeeded() async { + await log.append("requestNotiAuth") + } + + func post(title: String, body: String) async { + await log.append("postNoti:\(title)") + } +} + private extension LastRoute { static func fixture(id: String) -> LastRoute { LastRoute( @@ -149,6 +161,57 @@ struct LastTrainActivityLifecycleTests { #expect(await box.sessions.isEmpty) } + @Test + func register_success_requestsNotificationAuthOnceAfterActivityStart() async throws { + let log = CallLog() + let box = SessionBox() + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log), + activityPort: SpyActivityPort(log: log, box: box), + notificationPort: SpyLocalNotificationPort(log: log) + ) + try await sut.execute(route: .fixture(id: "new")) + // 순서 계약: 알림 권한 요청은 흐름의 맨 끝 — LA 시작 뒤에 정확히 1회. + #expect(await log.events == [ + "auth:true", "refresh", "register:new", "replaceAlarm:new", "startLA:new", "requestNotiAuth", + ]) + } + + @Test + func register_serverFails_doesNotRequestNotificationAuth() async { + let log = CallLog() + let box = SessionBox() + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log, registerError: StubError()), + scheduler: SpyAlarmScheduler(log: log), + activityPort: SpyActivityPort(log: log, box: box), + notificationPort: SpyLocalNotificationPort(log: log) + ) + await #expect(throws: StubError.self) { + try await sut.execute(route: .fixture(id: "new")) + } + // 서버 등록 실패 경로에서는 알림 권한을 요청하지 않는다. + #expect(await log.events == ["auth:true", "refresh", "register:new"]) + } + + @Test + func register_alarmKitPermissionDenied_doesNotRequestNotificationAuth() async { + let log = CallLog() + let box = SessionBox() + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log, authorizationGranted: false), + activityPort: SpyActivityPort(log: log, box: box), + notificationPort: SpyLocalNotificationPort(log: log) + ) + await #expect(throws: AlarmError.permissionDenied) { + try await sut.execute(route: .fixture(id: "new")) + } + // AlarmKit 권한 거부 경로에서는 알림 권한을 요청하지 않는다(연속 팝업·스팸 금지). + #expect(await log.events == ["auth:false"]) + } + @Test func cancel_success_endsActivityAfterLocalCancel() async throws { let log = CallLog() diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift index 1c2d1d7..9e1e005 100644 --- a/Projects/Feature/Home/Sources/HomeViewController.swift +++ b/Projects/Feature/Home/Sources/HomeViewController.swift @@ -227,6 +227,11 @@ final class HomeViewController: UIViewController { // 배너의 시각·긴급도 값 자체는 info 스트림이 State로 이미 갱신한다. DSToast.show("막차가 \(minutes)분 당겨졌어요", in: view) banner.emphasize() + case .lastTrainMissed: + // TODO: [미확정 #9] 대안 제시(심야버스 등) 데이터 소스 확정 전까지 실패 문구만 표출한다. + DSToast.show("막차가 지나갔어요", in: view) + case .lastTrainServiceEnded: + DSToast.show("오늘 운행이 끝나 알람을 정리했어요", in: view) } } } diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index f2bdac6..8e6b007 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -42,6 +42,10 @@ final class HomeViewModel { case alarmCancelFailed /// 포그라운드 인앱 채널: 막차가 당겨졌다는 판정 — VC가 토스트 + 배너 강조로 표출한다. case lastTrainAdvanced(minutes: Int) + /// 이미 못 타는 앞당김(actionable=false) — 배너는 실패 문구로 고정되고 원샷 안내만 나간다. + case lastTrainMissed + /// 운행 종료·경로 소멸(sessionEnded) — 배너·버튼 정리를 마쳤다는 원샷 안내. + case lastTrainServiceEnded } /// Set by the ViewController; always invoked on the main actor. @@ -203,14 +207,17 @@ final class HomeViewModel { private func alarmSynced(_ info: AlarmInfo) { registeredRouteId = info.lastRouteId refreshAlarmButton() - if let departure = info.departureTime { + // 이미 출발한 시각으로는 카운트다운을 (재)시작하지 않는다 — "출발까지 0분" 복원은 + // 오정보이고, 못 탐(actionable=false) 판정이 고정한 실패 배너를 후속 동기화가 + // 되살아난 카운트다운으로 덮어쓰는 일도 이 가드가 막는다. + if let departure = info.departureTime, departure > now() { startBannerTimer(departure: departure) } } /// 포그라운드 인앱 채널: 앱이 떠 있을 때의 변경 판정은 LA alert 대신 여기서 소비한다. - /// 배너의 시각·긴급도 자체는 info 스트림(alarmSynced)이 이미 갱신하므로, - /// 이 스트림은 "당겨짐" 원샷 안내만 담당한다. + /// 배너의 시각·긴급도 자체는 info 스트림(alarmSynced)이 이미 갱신하므로, 이 스트림은 + /// 원샷 안내(당겨짐·못 탐·운행 종료)와 실패·종료 시의 배너 정리만 담당한다. private func observeAlarmChanges() { changeTask?.cancel() changeTask = Task { [weak self] in @@ -229,11 +236,26 @@ final class HomeViewModel { let minutes = max(1, Int((interval / 60).rounded())) onToast?(.lastTrainAdvanced(minutes: minutes)) case .advanced(by: _, actionable: false): - // TODO(Phase 12): 이미 못 타는 앞당김 — 막차 놓침(세션 종료) UX로 처리한다. - break + // 이미 못 타는 앞당김 — 카운트다운을 멈추고 배너를 실패 문구로 고정한다. + // 알람·LA·서버 정리는 App(AlarmSyncService)·Domain 몫이고, 홈은 표출만 바꾼다. + // 긴급 스타일(imminent)은 유지 — 텍스트만 실패 문구로 교체된 같은 배너다. + bannerTask?.cancel() + state.banner = BannerViewData(text: "막차가 지나갔어요", urgency: .imminent) + onToast?(.lastTrainMissed) case .sessionEnded: - // TODO(Phase 12): 운행 종료·경로 소멸 UX. - break + // 운행 종료·경로 소멸 — 알람 세션이 사라졌으므로 배너·버튼·등록 기록을 전부 + // 정리한다. 직전 info 이벤트(alarmSynced)가 남긴 죽은 registeredRouteId도 + // 여기서 지워진다. LA final state 종료·알람 취소는 App/Domain 경로의 몫. + bannerTask?.cancel() + registeredRouteId = nil + var newState = state + newState.banner = nil + newState.alarmButton = Self.alarmButtonMode( + selectedRouteId: selectedRoute?.id, + registeredRouteId: nil + ) + state = newState + onToast?(.lastTrainServiceEnded) case .delayed, .unchanged: // 정책: 늦춰짐은 조용한 업데이트 — 배너는 info 스트림이 갱신하고 토스트는 없다. break diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift index ff9f7af..59b7e2c 100644 --- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -444,7 +444,7 @@ struct HomeViewModelTests { @Test func changeStream_quietVerdicts_emitNoToast() async { - // 정책: 늦춰짐은 조용한 업데이트. actionable=false·sessionEnded는 Phase 12 산출물. + // 정책: 늦춰짐·변경 없음은 조용한 업데이트 — 배너는 info 스트림 몫, 토스트 없음. let (stream, continuation) = AsyncStream.makeStream() let sut = makeSUT(alarmChanges: { stream }) let recorder = StateRecorder() @@ -453,11 +453,107 @@ struct HomeViewModelTests { sut.viewDidLoad() continuation.yield(.delayed(by: 10 * 60)) continuation.yield(.unchanged) - continuation.yield(.advanced(by: 5 * 60, actionable: false)) - continuation.yield(.sessionEnded) for _ in 0..<20 { await Task.yield() } #expect(recorder.toasts.isEmpty) #expect(sut.state.banner == nil) } + + @Test + func changeStream_sessionEnded_clearsBannerResetsButtonAndToasts() async { + // 운행 종료·경로 소멸: 배너 제거 + 등록 기록 삭제 + 버튼 리셋 + 원샷 안내. + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let (stream, continuation) = AsyncStream.makeStream() + let sut = makeSUT(alarmChanges: { stream }, bannerTickInterval: .milliseconds(1)) + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.viewDidLoad() + sut.routeSelected(route) + sut.registerAlarmTapped() + await recorder.waitUntilLast { $0.banner != nil } + + continuation.yield(.sessionEnded) + await recorder.waitUntilLast { $0.banner == nil } + + #expect(recorder.toasts == [.lastTrainServiceEnded]) + // 카드는 남고 등록 기록만 사라졌다 — 재등록 버튼으로 돌아간다. + #expect(sut.state.alarmButton == .register) + #expect(sut.state.routeCard != nil) + + // 배너 타이머도 멈췄다 — 살아 있다면 1ms 틱이 카운트다운을 곧장 되살린다. + try? await Task.sleep(for: .milliseconds(30)) + #expect(sut.state.banner == nil) + } + + @Test + func changeStream_advancedNotActionable_freezesBannerAsMissedAndToasts() async { + // 이미 못 타는 앞당김: 카운트다운 중지 + 배너 텍스트를 실패 문구로 교체(imminent 유지). + let clock = NowBox(fixedNow) + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let (stream, continuation) = AsyncStream.makeStream() + let sut = makeSUT( + alarmChanges: { stream }, + now: { clock.get() }, + bannerTickInterval: .milliseconds(1) + ) + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.viewDidLoad() + sut.routeSelected(route) + sut.registerAlarmTapped() + await recorder.waitUntilLast { $0.banner?.text == "출발까지 39분" } + + continuation.yield(.advanced(by: 5 * 60, actionable: false)) + await recorder.waitUntilLast { $0.banner?.text == "막차가 지나갔어요" } + + #expect(recorder.toasts == [.lastTrainMissed]) + #expect(sut.state.banner == .init(text: "막차가 지나갔어요", urgency: .imminent)) + // 등록 기록·버튼은 그대로 — 알람·서버 정리는 홈 밖(App·Domain) 몫이다. + #expect(sut.state.alarmButton == .cancel) + + // 타이머가 살아 있다면 시계가 흐른 뒤 "출발까지 N분"으로 되돌린다 — 멈췄음을 확인. + clock.set(fixedNow.addingTimeInterval(10 * 60)) + try? await Task.sleep(for: .milliseconds(30)) + #expect(sut.state.banner?.text == "막차가 지나갔어요") + } + + @Test + func alarmSync_pastDeparture_doesNotStartCountdown() async { + // 이미 출발한 시각의 동기화 복원 — "출발까지 0분" 카운트다운을 되살리지 않는다. + // 못 탐 판정이 고정한 실패 배너를 후속 동기화가 덮어쓰는 것도 같은 가드가 막는다. + let (stream, continuation) = AsyncStream.makeStream() + let sut = makeSUT(alarmUpdates: { stream }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.viewDidLoad() + continuation.yield( + AlarmInfo( + lastRouteId: "r1", + departureTime: fixedNow.addingTimeInterval(-60), + updatedAt: nil, + isReal: true + ) + ) + await recorder.waitUntilLast { $0.alarmButton == .cancel } + + #expect(sut.state.banner == nil) + } + + @Test + func makeBanner_acrossMidnight_usesAbsoluteDateArithmetic() { + // 자정 경계: 23:40 → 익일 00:10 출발은 벽시계로는 "이른 시각"이지만 절대 시간으로는 + // 30분 뒤다 — 알람 발화 시각(00:07)까지 27분 카운트다운, caution. + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Seoul")! + let now = calendar.date( + from: DateComponents(year: 2026, month: 8, day: 22, hour: 23, minute: 40) + )! + let departure = calendar.date( + from: DateComponents(year: 2026, month: 8, day: 23, hour: 0, minute: 10) + )! + + #expect(HomeViewModel.makeBanner(departure: departure, now: now) + == .init(text: "출발까지 27분", urgency: .caution)) + } }