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
1 change: 1 addition & 0 deletions Projects/App/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ let appTarget = Target.target(
.project(target: "CoreNetwork", path: "../Core/Network"),
.project(target: "CoreStorage", path: "../Core/Storage"),
.project(target: "CoreAuth", path: "../Core/Auth"),
.project(target: "CoreAlarm", path: "../Core/Alarm"),
.project(target: "CoreCoordinator", path: "../Core/Coordinator"),
.project(target: "DesignSystem", path: "../DesignSystem"),
.external(name: "FirebaseCore"),
Expand Down
28 changes: 28 additions & 0 deletions Projects/App/Sources/Adapters/CoreAlarmSchedulerAdapter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import CoreAlarm
import Domain
import Foundation

/// CoreAlarm(AlarmKit 래퍼) → Domain `AlarmScheduler` 어댑터. CoreAlarm을 보는 곳은 App뿐.
final class CoreAlarmSchedulerAdapter: AlarmScheduler {
private let scheduling: any AlarmKitScheduling

init(scheduling: any AlarmKitScheduling = AlarmKitScheduler()) {
self.scheduling = scheduling
}

func requestAuthorization() async -> Bool {
await scheduling.requestAuthorization()
}

func replaceAlarm(id: String, fireDate: Date, title: String) async throws {
try await scheduling.replaceAlarm(AlarmSpec(id: id, fireDate: fireDate, title: title))
}

func cancelAlarm() async {
await scheduling.cancelAll()
}

func scheduledFireDate() async -> Date? {
await scheduling.scheduledAlarm()?.fireDate
}
}
130 changes: 130 additions & 0 deletions Projects/App/Sources/Adapters/DevDemoFallbacks.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#if DEV
import Domain
import Foundation

// DEV 검수용 임시 우회 (Phase 7 사람 검수 — 사용자 지시로 추가).
//
// 실서버가 미확정 입력 #1(실 base URL)·#2(익명 인증) 상태라 접속 불가인 동안에도
// 전체 플로우(검색 → 경로 선택 → 알람 등록 → 발화 → 해제)를 시연할 수 있게,
// 서버 호출이 "실패했을 때만" 데모 데이터로 대체하거나 실패를 무시한다.
// 서버가 살아나면 실데이터가 그대로 우선한다. Stage/Release에는 컴파일되지 않는다.
// 실서버 스펙 확정 시 이 파일과 AppDIContainer의 #if DEV 주입만 제거하면 된다.

struct DevDemoFallbackPlaceRepository: PlaceRepository {
let base: any PlaceRepository

func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] {
do { return try await base.searchPlaces(keyword: keyword, near: coordinate) }
catch {
print("⚠️ [DEV 우회] 장소 검색 실패 → 데모 장소 반환: \(error)")
return [
Place(
name: "\(keyword) (데모)",
address: "서울 강남구 강남대로 396",
coordinate: Coordinate(latitude: 37.4979, longitude: 127.0276)
),
Place(
name: "구로디지털단지역 (데모)",
address: "서울 구로구 도림천로 486",
coordinate: Coordinate(latitude: 37.4853, longitude: 126.9015)
),
]
}
}

func reverseGeocode(_ coordinate: Coordinate) async throws -> Place {
do { return try await base.reverseGeocode(coordinate) }
catch {
print("⚠️ [DEV 우회] 역지오코딩 실패 → 데모 라벨 반환: \(error)")
return Place(name: "현재 위치 (데모)", address: "", coordinate: coordinate)
}
}
}

struct DevDemoFallbackLastRouteRepository: LastRouteRepository {
let base: any LastRouteRepository

func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] {
do { return try await base.searchLastRoutes(start: start, end: end) }
catch {
print("⚠️ [DEV 우회] 막차 검색 실패 → 데모 경로 반환: \(error)")
return [Self.demoRoute(start: start, end: end)]
}
}

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

/// 발화 검증을 빠르게 하려고 출발 시각을 3분 뒤로 둔다 (알람은 출발 시각에 울린다).
private static func demoRoute(start: Coordinate, end: Coordinate) -> LastRoute {
let departure = Date().addingTimeInterval(3 * 60)
return LastRoute(
id: "dev-demo-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: start),
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: end),
subwayFinalStation: nil,
subwayDirection: nil,
isExpressSubway: false,
isLastSubway: false
),
]
)
}
}

/// 서버 알람 등록/삭제 실패를 무시해 로컬 스케줄(AlarmKit)까지 진행시킨다.
/// refresh는 그대로 실패시킨다 — 홈이 상태를 유지하므로 시연에 지장이 없다.
struct DevDemoTolerantAlarmRepository: AlarmRepository {
let base: any AlarmRepository

func register(lastRouteId: String) async throws {
do { try await base.register(lastRouteId: lastRouteId) }
catch { print("⚠️ [DEV 우회] 서버 알람 등록 실패 무시 → 로컬 스케줄 진행: \(error)") }
}

func cancel(lastRouteId: String) async throws {
do { try await base.cancel(lastRouteId: lastRouteId) }
catch { print("⚠️ [DEV 우회] 서버 알람 삭제 실패 무시 → 로컬 취소 진행: \(error)") }
}

func refresh() async throws -> AlarmInfo {
try await base.refresh()
}
}
#endif
9 changes: 0 additions & 9 deletions Projects/App/Sources/Adapters/NoopAlarmScheduler.swift

This file was deleted.

47 changes: 42 additions & 5 deletions Projects/App/Sources/AppDIContainer.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import AtchaData
import CoreAlarm
import CoreAuth
import CoreNetwork
import CoreStorage
import Domain
import Foundation
import HomeFeature
import HomeFeatureInterface
import SearchFeature
Expand All @@ -15,9 +17,21 @@ final class AppDIContainer {
let authSessionManager: AuthSessionManager

init() {
#if DEV
// 검수용 임시 우회: 실서버(미확정 #1·#2)가 죽어 있어도 기본 60초 타임아웃 대기로
// 시연이 멈추지 않게 짧은 타임아웃을 쓴다. DevDemoFallbacks와 함께 제거한다.
let sessionConfiguration = URLSessionConfiguration.ephemeral
sessionConfiguration.timeoutIntervalForRequest = 3
sessionConfiguration.timeoutIntervalForResource = 5
let baseClient = URLSessionNetworkClient(
baseURL: AppEnvironment.current.apiBaseURL,
session: URLSession(configuration: sessionConfiguration)
)
#else
let baseClient = URLSessionNetworkClient(
baseURL: AppEnvironment.current.apiBaseURL
)
#endif
let sessionManager = AuthSessionManager(
tokenStore: TokenStore(store: KeychainStore()),
// The plain client, not the decorator — reissue must never recurse
Expand All @@ -35,15 +49,30 @@ final class AppDIContainer {
}

func makeHomeDIContainer() -> any HomeCoordinatorBuildable {
let placeRepository = PlaceRepositoryImpl(networkClient: networkClient)
let lastRouteRepository = LastRouteRepositoryImpl(networkClient: networkClient)
let alarmRepository = AlarmRepositoryImpl(networkClient: networkClient)
#if DEV
// 검수용 임시 우회 — 실서버(미확정 #1·#2) 부재 시에만 데모 데이터로 폴백.
// 실서버 확정 시 이 블록과 DevDemoFallbacks.swift를 제거한다.
let placeRepository: any PlaceRepository = DevDemoFallbackPlaceRepository(
base: PlaceRepositoryImpl(networkClient: networkClient)
)
let lastRouteRepository: any LastRouteRepository = DevDemoFallbackLastRouteRepository(
base: LastRouteRepositoryImpl(networkClient: networkClient)
)
let alarmRepository: any AlarmRepository = DevDemoTolerantAlarmRepository(
base: AlarmRepositoryImpl(networkClient: networkClient)
)
#else
let placeRepository: any PlaceRepository = PlaceRepositoryImpl(networkClient: networkClient)
let lastRouteRepository: any LastRouteRepository = LastRouteRepositoryImpl(networkClient: networkClient)
let alarmRepository: any AlarmRepository = AlarmRepositoryImpl(networkClient: networkClient)
#endif
let recentSearchRepository = RecentSearchRepositoryImpl(store: UserDefaultsKeyValueStore())

// 디바이스 포트 어댑터. AlarmScheduler는 Phase 7에서 CoreAlarm 기반으로 교체.
// 디바이스 포트 어댑터 — CoreLocation/AlarmKit을 아는 곳은 App의 어댑터뿐.
let locationService = CoreLocationServiceAdapter()
let getCurrentLocation: any GetCurrentLocationUseCase =
DefaultGetCurrentLocationUseCase(locationService: locationService)
let alarmScheduler = CoreAlarmSchedulerAdapter()

let searchContainer = SearchDIContainer(
searchPlacesUseCase: DefaultSearchPlacesUseCase(repository: placeRepository),
Expand All @@ -57,7 +86,15 @@ final class AppDIContainer {
reverseGeocodeUseCase: DefaultReverseGeocodeUseCase(repository: placeRepository),
registerAlarmUseCase: DefaultRegisterAlarmUseCase(
repository: alarmRepository,
scheduler: NoopAlarmScheduler()
scheduler: alarmScheduler
),
cancelAlarmUseCase: DefaultCancelAlarmUseCase(
repository: alarmRepository,
scheduler: alarmScheduler
),
refreshAlarmUseCase: DefaultRefreshAlarmUseCase(
repository: alarmRepository,
scheduler: alarmScheduler
),
searchCoordinatorBuildable: searchContainer
)
Expand Down
4 changes: 4 additions & 0 deletions Projects/Core/Alarm/Project.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import ProjectDescription
import ProjectDescriptionHelpers

let project = Project.layer(name: "CoreAlarm", bundleSuffix: "core.alarm", isolation: .nonisolated)
52 changes: 52 additions & 0 deletions Projects/Core/Alarm/Sources/AlarmKitEngine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import AlarmKit
import Foundation
import SwiftUI

/// AlarmKit을 직접 만지는 유일한 타입 — 레포 규칙(AlarmKit import는 CoreAlarm 한정)을
/// 모듈 안에서도 이 파일 한 곳으로 좁힌다. 테스트는 `AlarmEngine` 스텁으로 대체.
struct AlarmKitEngine: AlarmEngine {
/// 커스텀 Live Activity 없이 기본 알람 UI만 쓰므로 메타데이터는 비어 있다.
private struct EmptyMetadata: AlarmMetadata {}

func requestAuthorization() async throws -> Bool {
switch AlarmManager.shared.authorizationState {
case .authorized:
return true
case .denied:
return false
case .notDetermined:
return try await AlarmManager.shared.requestAuthorization() == .authorized
@unknown default:
return false
}
}

func schedule(id: UUID, fireDate: Date, title: String) async throws {
// Alert의 non-deprecated init은 iOS 26.1+라 배포 타겟 26.0에서는 stopButton
// 버전을 쓴다 (26.1 미만 타겟에서는 deprecation 경고가 나지 않는다).
// 반복(스누즈) 버튼은 넣지 않는다 — "마지노선까지만 미루기" 클램프 검증(Phase 11)
// 전까지는 단발 알람이 보수 기본값이다.
let alert = AlarmPresentation.Alert(
title: "\(title)",
stopButton: AlarmButton(text: "확인", textColor: .white, systemImageName: "checkmark")
)
// CoreAlarm은 무의존 모듈이라 DesignSystem 토큰을 볼 수 없다 — 시스템 기본 틴트 사용.
let attributes = AlarmAttributes<EmptyMetadata>(
presentation: AlarmPresentation(alert: alert),
tintColor: .accentColor
)
_ = try await AlarmManager.shared.schedule(
id: id,
configuration: .alarm(schedule: .fixed(fireDate), attributes: attributes)
)
}

func cancel(id: UUID) async {
// 이미 사라진 알람의 취소 실패는 무시한다 (교체·정리 경로를 막지 않는다).
try? AlarmManager.shared.cancel(id: id)
}

func alarmIDs() async -> [UUID] {
(try? AlarmManager.shared.alarms.map(\.id)) ?? []
}
}
60 changes: 60 additions & 0 deletions Projects/Core/Alarm/Sources/AlarmKitScheduler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation

/// AlarmKit 위에서 단일 알람 교체 정책을 구현하는 기본 스케줄러.
///
/// AlarmKit의 `Alarm`은 제목을 되돌려주지 않으므로, 마지막으로 등록한 스펙을
/// 로컬 레코드로 보관했다가 `scheduledAlarm()`에서 시스템 알람 존재 여부와
/// 대조해 복원한다 (발화·삭제로 사라진 알람은 레코드도 함께 정리).
public actor AlarmKitScheduler: AlarmKitScheduling {
private let engine: any AlarmEngine
private let recordStore: any AlarmRecordStoring

public init() {
self.init(engine: AlarmKitEngine(), recordStore: UserDefaultsAlarmRecordStore())
}

init(engine: any AlarmEngine, recordStore: any AlarmRecordStoring) {
self.engine = engine
self.recordStore = recordStore
}

public func requestAuthorization() async -> Bool {
(try? await engine.requestAuthorization()) ?? false
}

public func replaceAlarm(_ spec: AlarmSpec) async throws {
await cancelEngineAlarms()
recordStore.clear()
let uuid = UUID()
try await engine.schedule(id: uuid, fireDate: spec.fireDate, title: spec.title)
recordStore.save(ScheduledAlarmRecord(uuid: uuid, spec: spec))
}

public func cancelAll() async {
await cancelEngineAlarms()
recordStore.clear()
}

public func scheduledAlarm() async -> AlarmSpec? {
guard let record = recordStore.load() else { return nil }
guard await engine.alarmIDs().contains(record.uuid) else {
recordStore.clear()
return nil
}
return record.spec
}

private func cancelEngineAlarms() async {
for id in await engine.alarmIDs() {
await engine.cancel(id: id)
}
}
}

/// AlarmKit 호출 시임 — 테스트는 스텁 엔진으로 대체한다 (AlarmKit 직접 호출 금지 규약).
protocol AlarmEngine: Sendable {
func requestAuthorization() async throws -> Bool
func schedule(id: UUID, fireDate: Date, title: String) async throws
func cancel(id: UUID) async
func alarmIDs() async -> [UUID]
}
9 changes: 9 additions & 0 deletions Projects/Core/Alarm/Sources/AlarmKitScheduling.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/// AlarmKit 래퍼의 공개 계약. 구현은 `AlarmKitScheduler`, 소비자는 App의 어댑터뿐.
public protocol AlarmKitScheduling: Sendable {
/// 권한이 미결정이면 시스템 다이얼로그를 띄운다. 거부 상태면 false.
func requestAuthorization() async -> Bool
/// 전부 취소 후 등록 (단일 알람 정책).
func replaceAlarm(_ spec: AlarmSpec) async throws
func cancelAll() async
func scheduledAlarm() async -> AlarmSpec?
}
Loading
Loading