Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 87 additions & 3 deletions Projects/App/Sources/Adapters/DevDemoFallbacks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,27 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository {
do { return try await base.searchLastRoutes(start: start, end: end) }
catch {
print("⚠️ [DEV 우회] 막차 검색 실패 → 데모 경로 반환: \(error)")
return [Self.demoRoute(start: start, end: end)]
let route = Self.demoRoute(start: start, end: end)
// 변경 시뮬레이터의 기준 출발 시각 — 데모 흐름에선 검색 직후 이 경로가 등록된다.
await DevChangeSimulator.shared.noteKnownSession(
routeId: route.id, departureTime: route.departureTime
)
return [route]
}
}

func lastRoute(id: String) async throws -> LastRoute {
do { return try await base.lastRoute(id: id) }
catch {
print("⚠️ [DEV 우회] 경로 상세 실패 → 데모 경로 반환: \(error)")
return Self.demoRoute(
let route = Self.demoRoute(
start: Coordinate(latitude: 37.4979, longitude: 127.0276),
end: Coordinate(latitude: 37.4853, longitude: 126.9015)
)
await DevChangeSimulator.shared.noteKnownSession(
routeId: route.id, departureTime: route.departureTime
)
return route
}
}

Expand Down Expand Up @@ -110,12 +119,15 @@ struct DevDemoFallbackLastRouteRepository: LastRouteRepository {

/// 서버 알람 등록/삭제 실패를 무시해 로컬 스케줄(AlarmKit)까지 진행시킨다.
/// refresh는 그대로 실패시킨다 — 홈이 상태를 유지하므로 시연에 지장이 없다.
/// 단, Phase 11 변경 시뮬레이터의 주입이 보류 중이면 서버 대신 변형 AlarmInfo를 반환한다.
struct DevDemoTolerantAlarmRepository: AlarmRepository {
let base: any AlarmRepository

func register(lastRouteId: String) async throws {
do { try await base.register(lastRouteId: lastRouteId) }
catch { print("⚠️ [DEV 우회] 서버 알람 등록 실패 무시 → 로컬 스케줄 진행: \(error)") }
// 새 세션 시작 — 변경 시뮬레이터가 이 경로를 기준으로 변형한다.
await DevChangeSimulator.shared.noteKnownSession(routeId: lastRouteId, departureTime: nil)
}

func cancel(lastRouteId: String) async throws {
Expand All @@ -124,7 +136,79 @@ struct DevDemoTolerantAlarmRepository: AlarmRepository {
}

func refresh() async throws -> AlarmInfo {
try await base.refresh()
// Phase 11 검수: 보류 중 변경 주입이 있으면 서버 대신 시뮬레이터가 응답한다(1회 소비).
if let injected = await DevChangeSimulator.shared.consumeInjectedInfo() {
print("⚠️ [DEV 변경 시뮬레이터] 변형 AlarmInfo 반환: departure=\(String(describing: injected.departureTime))")
return injected
}
let info = try await base.refresh()
await DevChangeSimulator.shared.noteKnownSession(
routeId: info.lastRouteId, departureTime: info.departureTime
)
return info
}
}

// MARK: - Phase 11 변경 시뮬레이터 (DEV 한정 — 사람 검수의 전제)

/// 막차 변경 피기백(판정 → 알람 재스케줄 → LA alert/홈 배너)을 검수할 유일한 주입 수단.
/// 실서버가 변경을 내려줄 수 없는 동안 `DevDemoTolerantAlarmRepository.refresh()`가
/// 보류 중 주입을 소비해, 마지막으로 알려진 출발 시각 기준으로 변형된 AlarmInfo를 반환한다.
@MainActor
final class DevChangeSimulator {
static let shared = DevChangeSimulator()

enum Injection {
/// 출발 시각을 N초 앞당긴다 → advanced 판정 유도.
case advance(TimeInterval)
/// 출발 시각을 N초 늦춘다 → delayed 판정 유도.
case delay(TimeInterval)
/// 운행 종료 — departureTime 없는 응답으로 sessionEnded 판정 유도.
case end
}

private var pending: Injection?
/// 마지막으로 알려진 세션 — 데모 경로 생성·등록·refresh 성공·주입 적용 시 갱신된다.
private var knownRouteId: String?
private var knownDepartureTime: Date?

private init() {}

/// 주입 예약 — 다음 refresh() 1회가 소비한다.
func inject(_ injection: Injection) {
pending = injection
print("⚠️ [DEV 변경 시뮬레이터] 주입 보류: \(injection)")
}

/// departureTime이 nil이면 routeId만 갱신한다(등록 경로는 출발 시각을 모른다).
func noteKnownSession(routeId: String, departureTime: Date?) {
knownRouteId = routeId
if let departureTime { knownDepartureTime = departureTime }
}

/// 보류 중 주입을 소비해 변형된 AlarmInfo를 만든다. 주입이 없으면 nil.
/// 적용 결과를 기준 시각으로 다시 캐시하므로 주입을 연달아 합성할 수 있다
/// (예: "10분 늦춤" 뒤 "5분 앞당김" → actionable alert 경로 검수).
func consumeInjectedInfo(now: Date = Date()) -> AlarmInfo? {
guard let injection = pending else { return nil }
pending = nil
let routeId = knownRouteId ?? "dev-demo-route"
// 기준 출발 시각: 캐시가 없으면 데모 경로 기본값(now+3분)과 같은 가정.
let base = knownDepartureTime ?? now.addingTimeInterval(3 * 60)

let departure: Date?
switch injection {
case .advance(let seconds): departure = base.addingTimeInterval(-seconds)
case .delay(let seconds): departure = base.addingTimeInterval(seconds)
case .end: departure = nil
}
knownDepartureTime = departure
return AlarmInfo(
lastRouteId: routeId,
departureTime: departure,
updatedAt: now,
isReal: true
)
}
}
#endif
57 changes: 41 additions & 16 deletions Projects/App/Sources/Adapters/LastTrainLiveActivityAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,27 @@ import CoreLiveActivity
import Domain
import Foundation

/// App 내부 확장 포트 — Phase 11 변경 훅이 alert **문구**를 실어 보내는 경로.
/// Domain 포트(`update(state:alert: Bool)`)는 문서 고정 계약이라 그대로 두고,
/// 변경 유형별 문구가 필요한 호출자(AlarmSyncService)는 이 프로토콜로 어댑터를 본다.
/// nonisolated 명시: App의 기본 MainActor 격리가 요구사항에 스미면 actor 어댑터가
/// 적합성을 만족할 수 없다 — Domain 포트(.nonisolated 모듈)와 같은 조건을 재현한다.
nonisolated protocol LastTrainChangeAlerting: Sendable {
/// alert가 nil이면 조용한 상태 갱신, 값이 있으면 해당 문구의 AlertConfiguration으로 갱신한다.
func update(state: LastTrainActivityState, alert: (title: String, body: String)?) async
}

/// ActivityKit → Domain `LastTrainActivityPort` 어댑터. ActivityKit을 import하는 곳은 App에서 이 파일뿐.
/// 단일 알람 정책과 동일하게 Live Activity도 단일 세션만 유지한다(새 start가 기존 세션을 교체).
/// 포트 계약대로 어떤 실패도 밖으로 던지지 않는다 — LA 실패가 알람 등록·취소를 실패시키면 안 된다.
///
/// actor인 이유: 포트는 nonisolated async 요구사항을 가진 Sendable 프로토콜이라
/// MainActor 클래스의 격리 멤버로는 적합성이 성립하지 않는다(Sendable 경계를 넘는 격리 적합성 불가).
/// ActivityKit의 `Activity`는 Sendable 미표기이나 스레드 안전 설계라 `@preconcurrency`로 완화한다.
actor LastTrainLiveActivityAdapter: LastTrainActivityPort {
actor LastTrainLiveActivityAdapter: LastTrainActivityPort, LastTrainChangeAlerting {
/// 유저 스와이프 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를 먼저 내린다.
Expand Down Expand Up @@ -55,8 +61,8 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort {
let departureTime = session.departureTime ?? route.departureTime
let initialState = LastTrainActivityState(
departureTime: departureTime,
// TODO: Phase 11 — 알람 버퍼(기준 시각 − 버퍼) 도입 전까지 alarmTime = departureTime.
alarmTime: departureTime,
// 로컬 알람 발화 시각 — register/refresh와 동일한 버퍼 반영값(출발 − 3분).
alarmTime: AlarmTiming.alarmFireDate(departureTime: departureTime),
urgency: Domain.LastTrainUrgency.forTimeRemaining(
departureTime.timeIntervalSinceNow
),
Expand Down Expand Up @@ -85,16 +91,15 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort {
}

func update(state: LastTrainActivityState, alert: Bool) async {
guard let activity else { return }
// staleDate도 매 갱신마다 최신 출발 시각으로 재설정한다(앞당겨짐·미뤄짐 반영).
let content = ActivityContent(
state: Self.contentState(from: state),
staleDate: state.departureTime
// 문구 없는 Bool 경로(Domain 포트) — alert=true면 범용 폴백 문구로 위임한다.
// Phase 11 훅은 이 경로 대신 LastTrainChangeAlerting으로 변경 유형별 문구를 싣는다.
await update(
state: state,
alert: alert
? (title: LastTrainChangeMessages.genericChangeTitle,
body: LastTrainChangeMessages.genericChangeBody)
: nil
)
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 {
Expand All @@ -115,6 +120,26 @@ actor LastTrainLiveActivityAdapter: LastTrainActivityPort {

var isDismissedByUser: Bool { dismissedByUser }

// MARK: - LastTrainChangeAlerting

func update(state: LastTrainActivityState, alert: (title: String, body: String)?) async {
guard let activity else { return }
// staleDate도 매 갱신마다 최신 출발 시각으로 재설정한다(앞당겨짐·미뤄짐 반영).
let content = ActivityContent(
state: Self.contentState(from: state),
staleDate: state.departureTime
)
// LocalizedStringResource의 키로 원문을 그대로 쓴다 — 테이블 미등록 키는 원문 표시.
let alertConfiguration = alert.map {
AlertConfiguration(
title: LocalizedStringResource(stringLiteral: $0.title),
body: LocalizedStringResource(stringLiteral: $0.body),
sound: .default
)
}
await activity.update(content, alertConfiguration: alertConfiguration)
}

// MARK: - Dismiss 감지

/// 유저가 잠금화면에서 LA를 스와이프로 지우면 `.dismissed`가 도착한다.
Expand Down
Loading
Loading