diff --git a/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift b/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift new file mode 100644 index 0000000..49c05d1 --- /dev/null +++ b/Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift @@ -0,0 +1,180 @@ +@preconcurrency import ActivityKit +import CoreLiveActivity +import Domain +import Foundation + +/// ActivityKit → Domain `LastTrainActivityPort` 어댑터. ActivityKit을 import하는 곳은 App에서 이 파일뿐. +/// 단일 알람 정책과 동일하게 Live Activity도 단일 세션만 유지한다(새 start가 기존 세션을 교체). +/// 포트 계약대로 어떤 실패도 밖으로 던지지 않는다 — LA 실패가 알람 등록·취소를 실패시키면 안 된다. +/// +/// actor인 이유: 포트는 nonisolated async 요구사항을 가진 Sendable 프로토콜이라 +/// MainActor 클래스의 격리 멤버로는 적합성이 성립하지 않는다(Sendable 경계를 넘는 격리 적합성 불가). +/// ActivityKit의 `Activity`는 Sendable 미표기이나 스레드 안전 설계라 `@preconcurrency`로 완화한다. +actor LastTrainLiveActivityAdapter: LastTrainActivityPort { + /// 유저 스와이프 dismiss 기록 키 — 앱 재실행 후에도 남아야 Phase 12 폴백 트리거 재료가 된다. + private static let dismissedDefaultsKey = "la.dismissedByUser" + + // TODO: Phase 11 — 변경 유형별 알림 문구를 UseCase에서 주입한다. 그 전까지는 범용 문구. + private static let alertTitle: LocalizedStringResource = "막차 정보가 변경됐어요" + private static let alertBody: LocalizedStringResource = "잠금화면에서 최신 막차 시간을 확인하세요" + + private let userDefaults: UserDefaults + + /// 단일 세션 — 단일 알람 정책과 동일. 새 start가 이전 activity를 먼저 내린다. + private var activity: Activity? + /// `activityStateUpdates` 관찰 태스크 — 프로그램적 end·새 start·deinit에서 cancel. + private var stateObservationTask: Task? + /// 인메모리 기록 + UserDefaults 미러 — 재실행 후에도 조회 가능해야 한다. + private var dismissedByUser: Bool + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + self.dismissedByUser = userDefaults.bool(forKey: Self.dismissedDefaultsKey) + } + + // deinit 없음: AppDIContainer가 앱 수명 동안 1회 생성·보유하므로 해제 경로가 없고, + // actor deinit에서 격리 상태 접근은 Swift 6에서 제약된다. 관찰 태스크는 end/start에서 정리. + + // MARK: - LastTrainActivityPort + + func start(session: AlarmInfo, route: LastRoute) async { + // 새 알람 세션 = 새 기록. 이전 세션의 dismiss 기록은 여기서 리셋한다. + recordDismissedByUser(false) + + // 정책: LA 비활성(설정 꺼짐 등)이면 조용히 no-op — 알람만으로 동작해야 한다. + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + + // 단일 세션: 기존 activity가 있으면 먼저 내린다. 교체이므로 즉시 제거. + if let existing = activity { + stateObservationTask?.cancel() + stateObservationTask = nil + activity = nil + await existing.end(nil, dismissalPolicy: .immediate) + } + + let departureTime = session.departureTime ?? route.departureTime + let initialState = LastTrainActivityState( + departureTime: departureTime, + // TODO: Phase 11 — 알람 버퍼(기준 시각 − 버퍼) 도입 전까지 alarmTime = departureTime. + alarmTime: departureTime, + urgency: Domain.LastTrainUrgency.forTimeRemaining( + departureTime.timeIntervalSinceNow + ), + changeBadgeExpiry: nil, + phase: .active + ) + + do { + let requested = try Activity.request( + attributes: LastTrainActivityAttributes( + routeId: route.id, + routeName: Self.displayRouteName(for: route) + ), + // staleDate = 출발 시각: 갱신이 끊긴 LA가 출발 시각이 지난 뒤에도 + // 오래된 정보를 신선한 것처럼 보이지 않게 시스템이 stale 처리하도록 방어. + content: ActivityContent( + state: Self.contentState(from: initialState), + staleDate: departureTime + ) + ) + activity = requested + observeActivityState(requested) + } catch { + // LA 시작 실패는 흡수한다(포트 계약) — 알람만으로 동작. + } + } + + func update(state: LastTrainActivityState, alert: Bool) async { + guard let activity else { return } + // staleDate도 매 갱신마다 최신 출발 시각으로 재설정한다(앞당겨짐·미뤄짐 반영). + let content = ActivityContent( + state: Self.contentState(from: state), + staleDate: state.departureTime + ) + let alertConfiguration: AlertConfiguration? = alert + ? AlertConfiguration(title: Self.alertTitle, body: Self.alertBody, sound: .default) + : nil + await activity.update(content, alertConfiguration: alertConfiguration) + } + + func end(final state: LastTrainActivityState) async { + // 프로그램적 end 이후 도착하는 .dismissed는 유저 스와이프가 아니다 — 관찰을 먼저 끊는다. + stateObservationTask?.cancel() + stateObservationTask = nil + guard let activity else { return } + self.activity = nil + // .default: missed/serviceEnded 최종 상태가 잠금화면에 잠깐 남는 것이 정책 취지. + await activity.end( + ActivityContent( + state: Self.contentState(from: state), + staleDate: state.departureTime + ), + dismissalPolicy: .default + ) + } + + var isDismissedByUser: Bool { dismissedByUser } + + // MARK: - Dismiss 감지 + + /// 유저가 잠금화면에서 LA를 스와이프로 지우면 `.dismissed`가 도착한다. + /// 프로그램적 end 경로는 관찰을 먼저 cancel하므로 여기 도달하는 `.dismissed`는 유저 행동뿐이다. + private func observeActivityState(_ activity: Activity) { + stateObservationTask?.cancel() + stateObservationTask = Task { [weak self] in + for await state in activity.activityStateUpdates { + guard !Task.isCancelled else { return } + if state == .dismissed { + await self?.recordDismissedByUser(true) + return + } + } + } + } + + private func recordDismissedByUser(_ value: Bool) { + dismissedByUser = value + userDefaults.set(value, forKey: Self.dismissedDefaultsKey) + } + + // MARK: - Domain → CoreLiveActivity 매핑 + + /// Domain 상태 → 위젯 계약 ContentState. urgency/phase는 양쪽 enum의 rawValue가 동일하다. + /// rawValue 불일치는 계약 위반이므로 방어값은 안전한 쪽(imminent/active)으로 둔다. + private nonisolated static func contentState( + from state: LastTrainActivityState + ) -> LastTrainActivityAttributes.ContentState { + LastTrainActivityAttributes.ContentState( + departureTime: state.departureTime, + alarmTime: state.alarmTime, + urgency: CoreLiveActivity.LastTrainUrgency(rawValue: state.urgency.rawValue) + ?? .imminent, + changeBadgeExpiry: state.changeBadgeExpiry, + status: CoreLiveActivity.LastTrainSessionStatus(rawValue: state.phase.rawValue) + ?? .active + ) + } + + /// 노선 표시명 — 막차 탑승 구간(departureTime이 있는 첫 대중교통 구간, 없으면 첫 대중교통 구간) 기준. + /// 버스 routeName은 "타입:번호"(예: "간선:472") → "472번 버스", 지하철은 노선명 그대로(급행이면 " 급행"). + private nonisolated static func displayRouteName(for route: LastRoute) -> String { + let transitLegs = route.legs.filter { $0.mode == .bus || $0.mode == .subway } + guard let leg = transitLegs.first(where: { $0.departureTime != nil }) ?? transitLegs.first + else { return "막차" } + + switch leg.mode { + case .subway: + guard let name = leg.routeName else { return "지하철" } + return leg.isExpressSubway ? "\(name) 급행" : name + case .bus: + guard let routeName = leg.routeName else { return "버스" } + guard let colonIndex = routeName.firstIndex(of: ":") else { + return "\(routeName)번 버스" + } + let number = String(routeName[routeName.index(after: colonIndex)...]) + return "\(number)번 버스" + case .walk, .unknown: + return "막차" + } + } +} diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 3319dfe..139fd6f 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -22,6 +22,8 @@ final class AppDIContainer { private let placeRepository: any PlaceRepository private let lastRouteRepository: any LastRouteRepository private let alarmScheduler: any AlarmScheduler + // LA도 알람 세션과 수명을 같이하므로 1회 생성해 공유한다 — dismiss 기록이 세션 단위여야 한다. + private let liveActivityPort: any LastTrainActivityPort let alarmSyncService: AlarmSyncService init() { @@ -74,9 +76,10 @@ final class AppDIContainer { self.alarmRepository = AlarmRepositoryImpl(networkClient: networkClient) #endif - // 디바이스 포트 어댑터 — CoreLocation/AlarmKit을 아는 곳은 App의 어댑터뿐. + // 디바이스 포트 어댑터 — CoreLocation/AlarmKit/ActivityKit을 아는 곳은 App의 어댑터뿐. let alarmScheduler = CoreAlarmSchedulerAdapter() self.alarmScheduler = alarmScheduler + self.liveActivityPort = LastTrainLiveActivityAdapter() self.alarmSyncService = AlarmSyncService( refreshAlarmUseCase: DefaultRefreshAlarmUseCase( repository: alarmRepository, @@ -103,11 +106,13 @@ final class AppDIContainer { reverseGeocodeUseCase: DefaultReverseGeocodeUseCase(repository: placeRepository), registerAlarmUseCase: DefaultRegisterAlarmUseCase( repository: alarmRepository, - scheduler: alarmScheduler + scheduler: alarmScheduler, + activityPort: liveActivityPort ), cancelAlarmUseCase: DefaultCancelAlarmUseCase( repository: alarmRepository, - scheduler: alarmScheduler + scheduler: alarmScheduler, + activityPort: liveActivityPort ), observeAlarmUseCase: DefaultObserveAlarmUseCase(events: alarmSyncService), searchCoordinatorBuildable: searchContainer diff --git a/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift b/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift index ac619ca..7ae5c2b 100644 --- a/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift +++ b/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift @@ -1,26 +1,273 @@ import ActivityKit import CoreLiveActivity +import DesignSystem import SwiftUI import WidgetKit -/// Phase 9 placeholder — empty lock-screen view and minimal Dynamic Island -/// regions so the extension builds and embeds. Real UI lands in Phase 10. +// MARK: - Widget + +/// 막차 세션 Live Activity — 잠금화면 + 다이나믹 아일랜드 (Phase 10). +/// +/// 렌더링 한계 (LA는 예약(스케줄) 업데이트가 불가능하다): +/// - 카운트다운은 `Text(timerInterval:)` 시스템 타이머 뷰로 렌더 고정을 +/// 우회한다. 그래서 표기는 목업의 "22분" 같은 정적 문자열이 아니라 +/// 시스템 타이머 형식(예: 21:34)이다 — 정적 "N분"은 다음 갱신까지 +/// 얼어붙기 때문에 의도적으로 쓰지 않는다. +/// - 긴급도 색(여유/주의/임박)과 "⚠ 당겨짐" 배지 노출 여부는 마지막 +/// 콘텐츠 갱신 시점의 스냅샷이다. 여유→주의 같은 색 전환은 앱 깨움 +/// (BG refresh·푸시·재실행) 시점의 재평가로 근사되며, 그 사이에는 +/// 이전 단계의 색·배지가 그대로 남는다. struct LastTrainLiveActivityWidget: Widget { var body: some WidgetConfiguration { - ActivityConfiguration(for: LastTrainActivityAttributes.self) { _ in - EmptyView() - } dynamicIsland: { _ in - DynamicIsland { - DynamicIslandExpandedRegion(.center) { - EmptyView() + ActivityConfiguration(for: LastTrainActivityAttributes.self) { context in + LastTrainLockScreenView( + routeName: context.attributes.routeName, + state: context.state + ) + // 시스템 변형(항상 켜진 화면·밝기 감소·알림 센터 스택)에서도 + // 무난하도록 배경/시스템 액션 색을 DS 토큰으로 고정. + .activityBackgroundTint(Color(ds: DSColor.Background.base)) + .activitySystemActionForegroundColor(Color(ds: DSColor.Text.primary)) + } dynamicIsland: { context in + let state = context.state + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + HStack(spacing: DSSpacing.xs) { + Image(systemName: "bus.fill") + .font(.subheadline) + .foregroundStyle(state.glanceColor) + Text(context.attributes.routeName) + .font(.headline) + .foregroundStyle(Color(ds: DSColor.Text.primary)) + .lineLimit(1) + } + } + DynamicIslandExpandedRegion(.trailing) { + Text("\(LastTrainTimeFormat.hhmm(state.departureTime)) 출발") + .font(.subheadline) + .foregroundStyle(Color(ds: DSColor.Text.secondary)) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.bottom) { + // 잠금화면 2행의 축약판. 도보 안내 축약도 이 영역 몫이지만 + // ContentState에 도보 필드가 없어 생략 — 아래 + // LastTrainLockScreenView.departureRow 주석 참고. + switch state.status { + case .active: + HStack(alignment: .firstTextBaseline, spacing: DSSpacing.sm) { + Text("출발까지") + .font(.subheadline) + .foregroundStyle(Color(ds: DSColor.Text.secondary)) + HStack(spacing: DSSpacing.xs) { + Image(systemName: "timer") + .font(.body.weight(.semibold)) + Text(timerInterval: state.countdownRange, countsDown: true) + .font(.title2.weight(.bold)) + .monospacedDigit() + .multilineTextAlignment(.leading) + } + .foregroundStyle(state.urgencyColor) + } + case .missed, .serviceEnded: + Text(state.finalStatusMessage ?? "") + .font(.headline) + .foregroundStyle(state.glanceColor) + } } } compactLeading: { - EmptyView() + // 노선 수단(버스/지하철) 구분 필드가 계약에 없어 버스 아이콘 고정. + Image(systemName: "bus.fill") + .foregroundStyle(state.glanceColor) } compactTrailing: { - EmptyView() + switch state.status { + case .active: + Text(timerInterval: state.countdownRange, countsDown: true) + .font(.caption2.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(state.urgencyColor) + .multilineTextAlignment(.trailing) + .lineLimit(1) + .minimumScaleFactor(0.7) + // Text(timerInterval:)는 가용 폭을 전부 차지하려 하므로 + // 컴팩트 영역에서는 폭을 제한한다. + .frame(maxWidth: 56) + case .missed: + Text("놓침") + .font(.caption2.weight(.semibold)) + .foregroundStyle(state.glanceColor) + case .serviceEnded: + Text("종료") + .font(.caption2.weight(.semibold)) + .foregroundStyle(state.glanceColor) + } } minimal: { - EmptyView() + Image(systemName: "bus.fill") + .foregroundStyle(state.glanceColor) + } + .keylineTint(Color(ds: DSColor.Accent.default)) + } + } +} + +// MARK: - Lock screen + +/// 잠금화면 뷰 — UX 정본 목업: +/// ``` +/// ┌────────────────────────────┐ +/// │ 🚌 5518 막차 [⚠ 당겨짐] │ +/// │ 출발까지 ⏱ 22분 │ ← 여유도에 따라 색 변경 +/// │ 23:25 출발 · 정류장 도보 8분 │ +/// └────────────────────────────┘ +/// ``` +/// glance는 0.5초 — 숫자보다 색·상태가 먼저 읽히도록 카운트다운 +/// 숫자·아이콘에 긴급도 색을 상시 적용한다. +private struct LastTrainLockScreenView: View { + let routeName: String + let state: LastTrainActivityAttributes.ContentState + + var body: some View { + VStack(alignment: .leading, spacing: DSSpacing.sm) { + headerRow + mainRow + departureRow + } + .padding(DSSpacing.md) + } + + /// 1행: 노선명 + "⚠ 당겨짐" 배지 슬롯. + private var headerRow: some View { + HStack(spacing: DSSpacing.xs) { + Image(systemName: "bus.fill") + .font(.subheadline) + .foregroundStyle(Color(ds: DSColor.Icon.default)) + Text("\(routeName) 막차") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color(ds: DSColor.Text.primary)) + .lineLimit(1) + Spacer(minLength: DSSpacing.sm) + if state.showsChangeBadge { + ChangeBadge() } } } + + /// 2행: glance 핵심. active면 카운트다운(긴급도 색), + /// final state면 카운트다운을 숨기고 상태 문구만 표시. + @ViewBuilder + private var mainRow: some View { + switch state.status { + case .active: + HStack(alignment: .firstTextBaseline, spacing: DSSpacing.sm) { + Text("출발까지") + .font(.body) + .foregroundStyle(Color(ds: DSColor.Text.secondary)) + HStack(spacing: DSSpacing.xs) { + Image(systemName: "timer") + .font(.title3.weight(.semibold)) + // 알람 발화 시각(alarmTime = 출발 기준시각 − 버퍼) 기준 — + // 정책: "출발까지 N분"은 버퍼 포함 알람 시각 기준으로 통일. + Text(timerInterval: state.countdownRange, countsDown: true) + .font(.title.weight(.bold)) + .monospacedDigit() + .multilineTextAlignment(.leading) + } + .foregroundStyle(state.urgencyColor) + } + case .missed, .serviceEnded: + Text(state.finalStatusMessage ?? "") + .font(.title3.weight(.bold)) + .foregroundStyle(state.glanceColor) + } + } + + /// 3행: 출발 시각. 목업의 "정류장 도보 8분" 자리는 비워둔다 — + /// ContentState에 도보 필드가 없고 Phase 9 wire 계약은 고정이라 + /// attributes/state 확장 금지. Phase 11 미확정 #7(도보 소요 데이터) + /// 해소 시 "HH:mm 출발 · 정류장 도보 N분" 형태로 재검토. + private var departureRow: some View { + Text("\(LastTrainTimeFormat.hhmm(state.departureTime)) 출발") + .font(.footnote) + .foregroundStyle(Color(ds: DSColor.Text.secondary)) + .lineLimit(1) + } +} + +/// "⚠ 당겨짐" 배지 — 변경 직후 10분 노출 정책의 표시 슬롯. +/// 취소선 등 변경 흔적의 상시 표시는 하지 않는다(정책). +private struct ChangeBadge: View { + var body: some View { + Text("⚠ 당겨짐") + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color(ds: DSColor.Text.primary)) + .padding(.horizontal, DSSpacing.sm) + .padding(.vertical, DSSpacing.xxs) + .background(Color(ds: DSColor.State.danger), in: Capsule()) + .lineLimit(1) + } +} + +// MARK: - ContentState presentation helpers + +private extension LastTrainActivityAttributes.ContentState { + /// 긴급도 3단계 → DesignSystem 토큰 (하드코딩 금지 — DSColor 브리지만 사용). + /// relaxed=Accent.default(lime) / caution=State.danger(red400) / + /// imminent=State.urgent(red600). + var urgencyColor: Color { + switch urgency { + case .relaxed: Color(ds: DSColor.Accent.default) + case .caution: Color(ds: DSColor.State.danger) + case .imminent: Color(ds: DSColor.State.urgent) + } + } + + /// glance용 대표 색: active면 긴급도 색, missed는 긴급 색(놓침 경고), + /// serviceEnded는 보조 색(더 이상 행동 불가 — 시각적 소음 억제). + var glanceColor: Color { + switch status { + case .active: urgencyColor + case .missed: Color(ds: DSColor.State.urgent) + case .serviceEnded: Color(ds: DSColor.Text.secondary) + } + } + + /// 카운트다운 범위. alarmTime이 이미 지난 스냅샷을 받아도 + /// lowerBound > upperBound 크래시 없이 0:00으로 렌더되도록 클램프. + var countdownRange: ClosedRange { + min(Date.now, alarmTime)...alarmTime + } + + /// "⚠ 당겨짐" 배지 노출 여부 — 렌더(스냅샷 아카이브) 시점 기준 판정. + /// LA 렌더 고정 특성상 만료 시각(changeBadgeExpiry) 경과 후 자동 소멸은 + /// 다음 콘텐츠 갱신 때까지 지연될 수 있다 — 10분 노출 정책은 갱신 시점 + /// 재평가로 근사된다. + var showsChangeBadge: Bool { + guard let changeBadgeExpiry else { return false } + return changeBadgeExpiry > .now + } + + /// final state 문구. active면 nil (카운트다운 표시). + var finalStatusMessage: String? { + switch status { + case .active: nil + case .missed: "막차가 지나갔어요" + case .serviceEnded: "오늘 운행이 끝났어요" + } + } +} + +// MARK: - Formatting + +/// "HH:mm" 출발 시각 포맷. departureTime은 틱하지 않는 값이라 +/// 스냅샷 시점 포맷 고정으로 충분하다 (타이머 뷰 불필요). +private enum LastTrainTimeFormat { + private static let formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "HH:mm" + return formatter + }() + + static func hhmm(_ date: Date) -> String { + formatter.string(from: date) + } } diff --git a/Projects/Domain/Sources/Entities/LastTrainActivityState.swift b/Projects/Domain/Sources/Entities/LastTrainActivityState.swift new file mode 100644 index 0000000..d48c96e --- /dev/null +++ b/Projects/Domain/Sources/Entities/LastTrainActivityState.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Live Activity 표시용 긴급도 — 잠금화면·다이나믹 아일랜드의 색상·강조 단계 키. +public enum LastTrainUrgency: String, Sendable, Equatable { + case relaxed + case caution + case imminent + + /// 남은 시간(초) → 긴급도. 임계는 디자이너 확정 전 제안값: ≤10분 imminent, ≤30분 caution. + /// HomeViewModel.makeBanner의 "10분" 제안값(분 단위 ceil ≤ 10)과 같은 경계다 — 600초 이하면 imminent. + /// 음수(이미 지난 시각)도 imminent로 취급한다. + public static func forTimeRemaining(_ seconds: TimeInterval) -> LastTrainUrgency { + if seconds <= 10 * 60 { return .imminent } + if seconds <= 30 * 60 { return .caution } + return .relaxed + } +} + +/// 알람 세션 단계 — active(진행 중) / missed(막차 놓침) / serviceEnded(운행 종료). +public enum LastTrainSessionPhase: String, Sendable, Equatable { + case active + case missed + case serviceEnded +} + +/// Live Activity 콘텐츠 상태 — LA 어댑터(App)가 이 값만 보고 잠금화면·다이나믹 아일랜드를 그린다. +public struct LastTrainActivityState: Sendable, Equatable { + /// 막차 출발 시각 + public let departureTime: Date + /// 로컬 알람 발화 시각 + public let alarmTime: Date + public let urgency: LastTrainUrgency + /// "⚠ 당겨짐" 배지 만료 시각 (Phase 11 전까진 nil) + public let changeBadgeExpiry: Date? + public let phase: LastTrainSessionPhase + + public init( + departureTime: Date, + alarmTime: Date, + urgency: LastTrainUrgency, + changeBadgeExpiry: Date?, + phase: LastTrainSessionPhase + ) { + self.departureTime = departureTime + self.alarmTime = alarmTime + self.urgency = urgency + self.changeBadgeExpiry = changeBadgeExpiry + self.phase = phase + } +} diff --git a/Projects/Domain/Sources/Interfaces/LastTrainActivityPort.swift b/Projects/Domain/Sources/Interfaces/LastTrainActivityPort.swift new file mode 100644 index 0000000..8d50814 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/LastTrainActivityPort.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Live Activity 포트 — 어댑터 구현(ActivityKit)은 App에 둔다. +/// 수명 정책: 알람 등록 성공 → start, 알람 취소 → end. LA는 알람 세션과 수명을 같이한다. +/// 어떤 메서드도 throws가 아니다 — LA 실패가 알람 등록·취소를 실패시키면 안 된다(어댑터가 흡수). +public protocol LastTrainActivityPort: Sendable { + /// 알람 등록 성공 직후 호출 — 세션 정보와 경로로 LA를 시작한다. 이미 떠 있으면 어댑터가 교체한다. + func start(session: AlarmInfo, route: LastRoute) async + /// 상태 갱신. alert=true면 잠금화면에서 사용자 주의를 끄는 갱신(AlertConfiguration)으로 전달한다. + func update(state: LastTrainActivityState, alert: Bool) async + /// 세션 종료 — 마지막 상태를 남기고 LA를 닫는다. + func end(final: LastTrainActivityState) async + /// 사용자가 잠금화면에서 LA를 직접 지웠는지 — 지운 세션에는 다시 띄우지 않는다(재등록 전까지). + var isDismissedByUser: Bool { get async } +} diff --git a/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift index aec245e..f0a1cc2 100644 --- a/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift @@ -1,3 +1,5 @@ +import Foundation + public protocol CancelAlarmUseCase: Sendable { func execute(lastRouteId: String) async throws } @@ -5,14 +7,35 @@ public protocol CancelAlarmUseCase: Sendable { public struct DefaultCancelAlarmUseCase: CancelAlarmUseCase { private let repository: any AlarmRepository private let scheduler: any AlarmScheduler + /// Live Activity 포트 — nil이면 LA 없이 동작한다(Example·기존 콜사이트 호환). + private let activityPort: (any LastTrainActivityPort)? - public init(repository: any AlarmRepository, scheduler: any AlarmScheduler) { + public init( + repository: any AlarmRepository, + scheduler: any AlarmScheduler, + activityPort: (any LastTrainActivityPort)? = nil + ) { self.repository = repository self.scheduler = scheduler + self.activityPort = activityPort } public func execute(lastRouteId: String) async throws { try await repository.cancel(lastRouteId: lastRouteId) await scheduler.cancelAlarm() + // 수명 정책: 알람 세션이 끝나면 LA도 끝낸다. 유저 취소는 실패 상태가 아니므로 + // phase는 .active로 종료한다. end는 즉시 닫는 경로라 시각 값은 표시에 쓰이지 않는다. + if let activityPort { + let now = Date() + await activityPort.end( + final: LastTrainActivityState( + departureTime: now, + alarmTime: now, + urgency: .relaxed, + changeBadgeExpiry: nil, + phase: .active + ) + ) + } } } diff --git a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift index 9f83b63..a47626c 100644 --- a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift @@ -5,10 +5,17 @@ public protocol RegisterAlarmUseCase: Sendable { public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { private let repository: any AlarmRepository private let scheduler: any AlarmScheduler + /// Live Activity 포트 — nil이면 LA 없이 동작한다(Example·기존 콜사이트 호환). + private let activityPort: (any LastTrainActivityPort)? - public init(repository: any AlarmRepository, scheduler: any AlarmScheduler) { + public init( + repository: any AlarmRepository, + scheduler: any AlarmScheduler, + activityPort: (any LastTrainActivityPort)? = nil + ) { self.repository = repository self.scheduler = scheduler + self.activityPort = activityPort } public func execute(route: LastRoute) async throws { @@ -29,5 +36,16 @@ public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { fireDate: route.departureTime, title: AlarmSchedulingDefaults.title ) + // 수명 정책: 알람 등록(서버+로컬)이 전부 성공한 뒤에만 LA를 시작한다. + // start는 throws가 아니므로 LA 실패가 알람 등록을 실패시킬 수 없다. + if let activityPort { + let session = AlarmInfo( + lastRouteId: route.id, + departureTime: route.departureTime, + updatedAt: nil, // 등록 직후라 서버 재계산 값이 아직 없다 — refresh가 갱신한다. + isReal: true + ) + await activityPort.start(session: session, route: route) + } } } diff --git a/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift b/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift new file mode 100644 index 0000000..e11a6c4 --- /dev/null +++ b/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift @@ -0,0 +1,169 @@ +@testable import Domain +import Foundation +import Testing + +// LA 수명 정책 테스트 — 알람 등록 성공 → start, 알람 취소 → end. +// 기존 Default{Register,Cancel}AlarmUseCaseTests는 activityPort 없이(기본값 nil) 그대로 유효하다. + +private actor CallLog { + private(set) var events: [String] = [] + func append(_ event: String) { events.append(event) } +} + +private actor SessionBox { + private(set) var sessions: [AlarmInfo] = [] + private(set) var finals: [LastTrainActivityState] = [] + func capture(_ session: AlarmInfo) { sessions.append(session) } + func capture(_ final: LastTrainActivityState) { finals.append(final) } +} + +private struct StubError: Error {} + +private struct SpyAlarmRepository: AlarmRepository { + let log: CallLog + var registerError: Error? = nil + + func register(lastRouteId: String) async throws { + await log.append("register:\(lastRouteId)") + if let registerError { throw registerError } + } + + func cancel(lastRouteId: String) async throws { + await log.append("cancel:\(lastRouteId)") + } + + func refresh() async throws -> AlarmInfo { + await log.append("refresh") + throw StubError() + } +} + +private struct SpyAlarmScheduler: AlarmScheduler { + let log: CallLog + var authorizationGranted = true + + func requestAuthorization() async -> Bool { + await log.append("auth:\(authorizationGranted)") + return authorizationGranted + } + + func replaceAlarm(id: String, fireDate: Date, title: String) async throws { + await log.append("replaceAlarm:\(id)") + } + + func cancelAlarm() async { + await log.append("cancelAlarm") + } + + func scheduledFireDate() async -> Date? { nil } +} + +private struct SpyActivityPort: LastTrainActivityPort { + let log: CallLog + let box: SessionBox + + func start(session: AlarmInfo, route: LastRoute) async { + await log.append("startLA:\(route.id)") + await box.capture(session) + } + + func update(state: LastTrainActivityState, alert: Bool) async { + await log.append("updateLA") + } + + func end(final: LastTrainActivityState) async { + await log.append("endLA") + await box.capture(final) + } + + var isDismissedByUser: Bool { false } +} + +private extension LastRoute { + static func fixture(id: String) -> LastRoute { + LastRoute( + id: id, + departureTime: Date(timeIntervalSince1970: 1_000), + totalTime: 0, + totalWalkTime: 0, + transferCount: 0, + totalDistance: 0, + totalWalkDistance: 0, + legs: [] + ) + } +} + +struct LastTrainActivityLifecycleTests { + @Test + func register_success_startsActivityOnceAfterLocalAlarm() async throws { + let log = CallLog() + let box = SessionBox() + let route = LastRoute.fixture(id: "new") + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log), + activityPort: SpyActivityPort(log: log, box: box) + ) + try await sut.execute(route: route) + // 순서 계약: 알람(서버 등록 → 로컬 교체)이 항상 LA 시작에 선행한다. + #expect(await log.events == ["auth:true", "refresh", "register:new", "replaceAlarm:new", "startLA:new"]) + // 세션은 route 기반으로 구성된다. + let sessions = await box.sessions + #expect(sessions.count == 1) + #expect(sessions.first?.lastRouteId == "new") + #expect(sessions.first?.departureTime == route.departureTime) + #expect(sessions.first?.isReal == true) + } + + @Test + func register_serverFails_doesNotStartActivity() 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) + ) + await #expect(throws: StubError.self) { + try await sut.execute(route: .fixture(id: "new")) + } + // 알람 등록이 실패하면 LA는 시작되지 않는다. + #expect(await log.events == ["auth:true", "refresh", "register:new"]) + #expect(await box.sessions.isEmpty) + } + + @Test + func register_permissionDenied_doesNotStartActivity() 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) + ) + await #expect(throws: AlarmError.permissionDenied) { + try await sut.execute(route: .fixture(id: "new")) + } + #expect(await log.events == ["auth:false"]) + #expect(await box.sessions.isEmpty) + } + + @Test + func cancel_success_endsActivityAfterLocalCancel() async throws { + let log = CallLog() + let box = SessionBox() + let sut = DefaultCancelAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log), + activityPort: SpyActivityPort(log: log, box: box) + ) + try await sut.execute(lastRouteId: "route-1") + // 순서 계약: 서버 취소 → 로컬 알람 취소 → LA 종료. + #expect(await log.events == ["cancel:route-1", "cancelAlarm", "endLA"]) + // 유저 취소는 실패 상태가 아니다 — phase .active로 종료한다. + let finals = await box.finals + #expect(finals.count == 1) + #expect(finals.first?.phase == .active) + } +} diff --git a/Projects/Domain/Tests/LastTrainUrgencyTests.swift b/Projects/Domain/Tests/LastTrainUrgencyTests.swift new file mode 100644 index 0000000..3ddc5cf --- /dev/null +++ b/Projects/Domain/Tests/LastTrainUrgencyTests.swift @@ -0,0 +1,41 @@ +@testable import Domain +import Foundation +import Testing + +struct LastTrainUrgencyTests { + // 임계는 디자이너 확정 전 제안값: ≤10분 imminent, ≤30분 caution (경계 포함). + + @Test + func forTimeRemaining_over30Minutes_isRelaxed() { + #expect(LastTrainUrgency.forTimeRemaining(3_600) == .relaxed) + // 30분 경계 바로 위 — caution이 아니다. + #expect(LastTrainUrgency.forTimeRemaining(1_801) == .relaxed) + } + + @Test + func forTimeRemaining_exactly30Minutes_isCaution() { + #expect(LastTrainUrgency.forTimeRemaining(1_800) == .caution) + } + + @Test + func forTimeRemaining_between10And30Minutes_isCaution() { + #expect(LastTrainUrgency.forTimeRemaining(601) == .caution) + } + + @Test + func forTimeRemaining_exactly10Minutes_isImminent() { + // HomeViewModel.makeBanner의 "10분 이하 긴박"과 같은 경계(600초 포함). + #expect(LastTrainUrgency.forTimeRemaining(600) == .imminent) + } + + @Test + func forTimeRemaining_zero_isImminent() { + #expect(LastTrainUrgency.forTimeRemaining(0) == .imminent) + } + + @Test + func forTimeRemaining_negative_isImminent() { + // 이미 지난 시각(막차 출발 후)도 imminent — relaxed로 되돌아가면 안 된다. + #expect(LastTrainUrgency.forTimeRemaining(-60) == .imminent) + } +}