From 6398b46692df29030cf5dfb511e53aca05cbc246 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sat, 22 Aug 2026 22:09:40 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20AtchaV2=20Phase=207=20=E2=80=94=20C?= =?UTF-8?q?oreAlarm:=20AlarmKit=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=A7=81=20E?= =?UTF-8?q?2E=20(=EB=8B=A8=EC=9D=BC=20=EC=95=8C=EB=9E=8C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=20=EC=A0=95=EC=B1=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CoreAlarm 신설: 중립 타입 API(AlarmSpec/AlarmKitScheduling) + AlarmKitScheduler(actor, 교체 정책) + AlarmKitEngine(AlarmKit 유일 import 지점) + UserDefaults 레코드(제목 복원) - Domain 포트 확장: requestAuthorization/scheduledFireDate 추가, AlarmError.permissionDenied 신설 - 등록: 권한 요청 → (denied면 서버 등록 보류) → 서버 등록 → 로컬 교체 - refresh: 시각 변경(로컬 소실 포함) 시 재스케줄 - App: NoopAlarmScheduler → CoreAlarmSchedulerAdapter 교체, Cancel/Refresh UseCase 조립 - Home: 등록/해제 버튼 토글, 권한·해제 토스트, 포그라운드 복귀 시 refresh(배너 갱신·복원) - 스누즈/반복 버튼 없는 단발 알람 (마지노선 클램프 검증은 Phase 11 — 보수 기본값) Co-Authored-By: Claude Fable 5 --- Projects/App/Project.swift | 1 + .../Adapters/CoreAlarmSchedulerAdapter.swift | 28 ++++ .../Sources/Adapters/NoopAlarmScheduler.swift | 9 -- Projects/App/Sources/AppDIContainer.swift | 14 +- Projects/Core/Alarm/Project.swift | 4 + .../Core/Alarm/Sources/AlarmKitEngine.swift | 52 +++++++ .../Alarm/Sources/AlarmKitScheduler.swift | 60 +++++++ .../Alarm/Sources/AlarmKitScheduling.swift | 9 ++ Projects/Core/Alarm/Sources/AlarmSpec.swift | 14 ++ .../Alarm/Sources/ScheduledAlarmRecord.swift | 50 ++++++ .../Alarm/Tests/AlarmKitSchedulerTests.swift | 147 ++++++++++++++++++ .../Domain/Sources/Entities/AlarmError.swift | 5 + .../Sources/Interfaces/AlarmScheduler.swift | 11 +- .../UseCases/RefreshAlarmUseCase.swift | 18 ++- .../UseCases/RegisterAlarmUseCase.swift | 10 +- .../DefaultCancelAlarmUseCaseTests.swift | 4 + .../DefaultRefreshAlarmUseCaseTests.swift | 115 ++++++++++++++ .../DefaultRegisterAlarmUseCaseTests.swift | 28 +++- .../Feature/Home/Example/ExampleApp.swift | 16 ++ .../Home/Sources/HomeDIContainer.swift | 10 +- .../Home/Sources/HomeViewController.swift | 39 ++++- .../Feature/Home/Sources/HomeViewModel.swift | 118 ++++++++++++-- .../Home/Tests/HomeViewModelTests.swift | 134 +++++++++++++++- Workspace.swift | 1 + 24 files changed, 862 insertions(+), 35 deletions(-) create mode 100644 Projects/App/Sources/Adapters/CoreAlarmSchedulerAdapter.swift delete mode 100644 Projects/App/Sources/Adapters/NoopAlarmScheduler.swift create mode 100644 Projects/Core/Alarm/Project.swift create mode 100644 Projects/Core/Alarm/Sources/AlarmKitEngine.swift create mode 100644 Projects/Core/Alarm/Sources/AlarmKitScheduler.swift create mode 100644 Projects/Core/Alarm/Sources/AlarmKitScheduling.swift create mode 100644 Projects/Core/Alarm/Sources/AlarmSpec.swift create mode 100644 Projects/Core/Alarm/Sources/ScheduledAlarmRecord.swift create mode 100644 Projects/Core/Alarm/Tests/AlarmKitSchedulerTests.swift create mode 100644 Projects/Domain/Sources/Entities/AlarmError.swift create mode 100644 Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index cff8c5c..d74f566 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -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"), diff --git a/Projects/App/Sources/Adapters/CoreAlarmSchedulerAdapter.swift b/Projects/App/Sources/Adapters/CoreAlarmSchedulerAdapter.swift new file mode 100644 index 0000000..b38511b --- /dev/null +++ b/Projects/App/Sources/Adapters/CoreAlarmSchedulerAdapter.swift @@ -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 + } +} diff --git a/Projects/App/Sources/Adapters/NoopAlarmScheduler.swift b/Projects/App/Sources/Adapters/NoopAlarmScheduler.swift deleted file mode 100644 index 701c135..0000000 --- a/Projects/App/Sources/Adapters/NoopAlarmScheduler.swift +++ /dev/null @@ -1,9 +0,0 @@ -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 2e718f9..c4683f7 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -1,4 +1,5 @@ import AtchaData +import CoreAlarm import CoreAuth import CoreNetwork import CoreStorage @@ -40,10 +41,11 @@ final class AppDIContainer { let alarmRepository = AlarmRepositoryImpl(networkClient: networkClient) 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), @@ -57,7 +59,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 ) diff --git a/Projects/Core/Alarm/Project.swift b/Projects/Core/Alarm/Project.swift new file mode 100644 index 0000000..b4c7300 --- /dev/null +++ b/Projects/Core/Alarm/Project.swift @@ -0,0 +1,4 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.layer(name: "CoreAlarm", bundleSuffix: "core.alarm", isolation: .nonisolated) diff --git a/Projects/Core/Alarm/Sources/AlarmKitEngine.swift b/Projects/Core/Alarm/Sources/AlarmKitEngine.swift new file mode 100644 index 0000000..791184e --- /dev/null +++ b/Projects/Core/Alarm/Sources/AlarmKitEngine.swift @@ -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( + 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)) ?? [] + } +} diff --git a/Projects/Core/Alarm/Sources/AlarmKitScheduler.swift b/Projects/Core/Alarm/Sources/AlarmKitScheduler.swift new file mode 100644 index 0000000..7c02eb5 --- /dev/null +++ b/Projects/Core/Alarm/Sources/AlarmKitScheduler.swift @@ -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] +} diff --git a/Projects/Core/Alarm/Sources/AlarmKitScheduling.swift b/Projects/Core/Alarm/Sources/AlarmKitScheduling.swift new file mode 100644 index 0000000..8b33ab1 --- /dev/null +++ b/Projects/Core/Alarm/Sources/AlarmKitScheduling.swift @@ -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? +} diff --git a/Projects/Core/Alarm/Sources/AlarmSpec.swift b/Projects/Core/Alarm/Sources/AlarmSpec.swift new file mode 100644 index 0000000..d701f6b --- /dev/null +++ b/Projects/Core/Alarm/Sources/AlarmSpec.swift @@ -0,0 +1,14 @@ +import Foundation + +/// 디바이스 알람 한 건의 중립 표현 — AlarmKit 타입을 모듈 밖으로 내보내지 않는다. +public struct AlarmSpec: Sendable, Equatable { + public let id: String + public let fireDate: Date + public let title: String + + public init(id: String, fireDate: Date, title: String) { + self.id = id + self.fireDate = fireDate + self.title = title + } +} diff --git a/Projects/Core/Alarm/Sources/ScheduledAlarmRecord.swift b/Projects/Core/Alarm/Sources/ScheduledAlarmRecord.swift new file mode 100644 index 0000000..ce5e0a7 --- /dev/null +++ b/Projects/Core/Alarm/Sources/ScheduledAlarmRecord.swift @@ -0,0 +1,50 @@ +import Foundation + +/// 마지막으로 등록한 알람의 로컬 레코드 — `scheduledAlarm()` 복원용. +struct ScheduledAlarmRecord: Codable, Equatable, Sendable { + let uuid: UUID + let id: String + let fireDate: Date + let title: String + + init(uuid: UUID, spec: AlarmSpec) { + self.uuid = uuid + self.id = spec.id + self.fireDate = spec.fireDate + self.title = spec.title + } + + var spec: AlarmSpec { + AlarmSpec(id: id, fireDate: fireDate, title: title) + } +} + +protocol AlarmRecordStoring: Sendable { + func load() -> ScheduledAlarmRecord? + func save(_ record: ScheduledAlarmRecord) + func clear() +} + +// UserDefaults는 문서화된 thread-safe지만 SDK가 Sendable로 표기하지 않아 @unchecked가 필요하다. +final class UserDefaultsAlarmRecordStore: AlarmRecordStoring, @unchecked Sendable { + private let defaults: UserDefaults + private let key = "core.alarm.scheduled-record" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func load() -> ScheduledAlarmRecord? { + guard let data = defaults.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(ScheduledAlarmRecord.self, from: data) + } + + func save(_ record: ScheduledAlarmRecord) { + guard let data = try? JSONEncoder().encode(record) else { return } + defaults.set(data, forKey: key) + } + + func clear() { + defaults.removeObject(forKey: key) + } +} diff --git a/Projects/Core/Alarm/Tests/AlarmKitSchedulerTests.swift b/Projects/Core/Alarm/Tests/AlarmKitSchedulerTests.swift new file mode 100644 index 0000000..f71c46e --- /dev/null +++ b/Projects/Core/Alarm/Tests/AlarmKitSchedulerTests.swift @@ -0,0 +1,147 @@ +@testable import CoreAlarm +import Foundation +import Synchronization +import Testing + +private struct StubError: Error {} + +/// AlarmKit 대역 — 호출 순서와 시스템에 남아 있는 알람 ID 집합을 흉내 낸다. +private actor SpyEngine: AlarmEngine { + private(set) var events: [String] = [] + private var alarmIDsInSystem: [UUID] + private let authorizationResult: Result + private let scheduleError: (any Error)? + + init( + existingIDs: [UUID] = [], + authorization: Result = .success(true), + scheduleError: (any Error)? = nil + ) { + self.alarmIDsInSystem = existingIDs + self.authorizationResult = authorization + self.scheduleError = scheduleError + } + + func requestAuthorization() async throws -> Bool { + events.append("auth") + return try authorizationResult.get() + } + + func schedule(id: UUID, fireDate: Date, title: String) async throws { + events.append("schedule:\(title)") + if let scheduleError { throw scheduleError } + alarmIDsInSystem.append(id) + } + + func cancel(id: UUID) async { + events.append("cancel") + alarmIDsInSystem.removeAll { $0 == id } + } + + func alarmIDs() async -> [UUID] { + alarmIDsInSystem + } + + /// 발화·삭제 등으로 시스템에서 알람이 사라진 상황을 흉내 낸다. + func removeAllFromSystem() { + alarmIDsInSystem.removeAll() + } +} + +private final class InMemoryRecordStore: AlarmRecordStoring, Sendable { + private let record = Mutex(nil) + + func load() -> ScheduledAlarmRecord? { record.withLock { $0 } } + func save(_ new: ScheduledAlarmRecord) { record.withLock { $0 = new } } + func clear() { record.withLock { $0 = nil } } +} + +private func makeSpec(id: String = "route-1") -> AlarmSpec { + AlarmSpec(id: id, fireDate: Date(timeIntervalSince1970: 1_000), title: "막차 출발 알림") +} + +struct AlarmKitSchedulerTests { + @Test + func replaceAlarm_existingAlarms_cancelsAllBeforeScheduling() async throws { + let engine = SpyEngine(existingIDs: [UUID(), UUID()]) + let sut = AlarmKitScheduler(engine: engine, recordStore: InMemoryRecordStore()) + + try await sut.replaceAlarm(makeSpec()) + + #expect(await engine.events == ["cancel", "cancel", "schedule:막차 출발 알림"]) + } + + @Test + func replaceAlarm_scheduleFails_throwsAndKeepsNoRecord() async { + let engine = SpyEngine(scheduleError: StubError()) + let store = InMemoryRecordStore() + let sut = AlarmKitScheduler(engine: engine, recordStore: store) + + await #expect(throws: StubError.self) { + try await sut.replaceAlarm(makeSpec()) + } + #expect(store.load() == nil) + #expect(await sut.scheduledAlarm() == nil) + } + + @Test + func scheduledAlarm_afterReplace_returnsSpec() async throws { + let sut = AlarmKitScheduler(engine: SpyEngine(), recordStore: InMemoryRecordStore()) + + try await sut.replaceAlarm(makeSpec(id: "route-9")) + + #expect(await sut.scheduledAlarm() == makeSpec(id: "route-9")) + } + + @Test + func scheduledAlarm_afterCancelAll_returnsNil() async throws { + let store = InMemoryRecordStore() + let sut = AlarmKitScheduler(engine: SpyEngine(), recordStore: store) + + try await sut.replaceAlarm(makeSpec()) + await sut.cancelAll() + + #expect(await sut.scheduledAlarm() == nil) + #expect(store.load() == nil) + } + + @Test + func scheduledAlarm_alarmGoneFromSystem_returnsNilAndClearsRecord() async throws { + let engine = SpyEngine() + let store = InMemoryRecordStore() + let sut = AlarmKitScheduler(engine: engine, recordStore: store) + + try await sut.replaceAlarm(makeSpec()) + await engine.removeAllFromSystem() + + #expect(await sut.scheduledAlarm() == nil) + #expect(store.load() == nil) + } + + @Test + func requestAuthorization_granted_returnsTrue() async { + let sut = AlarmKitScheduler( + engine: SpyEngine(authorization: .success(true)), + recordStore: InMemoryRecordStore() + ) + #expect(await sut.requestAuthorization()) + } + + @Test + func requestAuthorization_denied_returnsFalse() async { + let sut = AlarmKitScheduler( + engine: SpyEngine(authorization: .success(false)), + recordStore: InMemoryRecordStore() + ) + #expect(await sut.requestAuthorization() == false) + } + + @Test + func requestAuthorization_engineThrows_returnsFalse() async { + let sut = AlarmKitScheduler( + engine: SpyEngine(authorization: .failure(StubError())), + recordStore: InMemoryRecordStore() + ) + #expect(await sut.requestAuthorization() == false) + } +} diff --git a/Projects/Domain/Sources/Entities/AlarmError.swift b/Projects/Domain/Sources/Entities/AlarmError.swift new file mode 100644 index 0000000..aca3ae3 --- /dev/null +++ b/Projects/Domain/Sources/Entities/AlarmError.swift @@ -0,0 +1,5 @@ +/// 알람 등록 플로우의 도메인 에러. +public enum AlarmError: Error, Equatable, Sendable { + /// AlarmKit 권한 거부 — 서버 등록을 보류하고 설정 이동을 안내한다. + case permissionDenied +} diff --git a/Projects/Domain/Sources/Interfaces/AlarmScheduler.swift b/Projects/Domain/Sources/Interfaces/AlarmScheduler.swift index 5e48ec1..624a9f6 100644 --- a/Projects/Domain/Sources/Interfaces/AlarmScheduler.swift +++ b/Projects/Domain/Sources/Interfaces/AlarmScheduler.swift @@ -1,8 +1,17 @@ import Foundation -/// 디바이스 알람 포트 — 어댑터 구현은 Phase 7(CoreAlarm)에서 App에 둔다. +/// 디바이스 알람 포트 — 어댑터 구현(CoreAlarm ↔ AlarmKit)은 App에 둔다. public protocol AlarmScheduler: Sendable { + /// 권한이 미결정이면 시스템 다이얼로그를 띄운다. 거부 상태면 false. + func requestAuthorization() async -> Bool /// 기존 알람을 전부 취소하고 새로 등록한다 (단일 알람 정책). func replaceAlarm(id: String, fireDate: Date, title: String) async throws func cancelAlarm() async + /// 현재 스케줄된 로컬 알람의 발화 시각 — 없으면 nil (refresh의 변경 비교용). + func scheduledFireDate() async -> Date? +} + +/// 로컬 알람 제목 — 등록·재스케줄 경로가 같은 문구를 쓴다. +enum AlarmSchedulingDefaults { + static let title = "막차 출발 알림" } diff --git a/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift index 73afeed..b80ee54 100644 --- a/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift @@ -1,15 +1,29 @@ +import Foundation + public protocol RefreshAlarmUseCase: Sendable { func execute() async throws -> AlarmInfo } public struct DefaultRefreshAlarmUseCase: RefreshAlarmUseCase { private let repository: any AlarmRepository + private let scheduler: any AlarmScheduler - public init(repository: any AlarmRepository) { + public init(repository: any AlarmRepository, scheduler: any AlarmScheduler) { self.repository = repository + self.scheduler = scheduler } public func execute() async throws -> AlarmInfo { - try await repository.refresh() + let info = try await repository.refresh() + // 시각 변경 시(로컬 알람이 사라진 경우 포함) 재스케줄한다. + if let departure = info.departureTime, + await scheduler.scheduledFireDate() != departure { + try await scheduler.replaceAlarm( + id: info.lastRouteId, + fireDate: departure, + title: AlarmSchedulingDefaults.title + ) + } + return info } } diff --git a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift index a0c136a..9f83b63 100644 --- a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift @@ -12,6 +12,10 @@ public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { } public func execute(route: LastRoute) async throws { + // 권한 요청이 최초 진입점 — 거부면 서버 등록까지 전부 보류한다. + guard await scheduler.requestAuthorization() else { + throw AlarmError.permissionDenied + } // TODO: [미확정 #4] 단일 알람 규약(서버 교체 여부) 확정 전까지 클라이언트가 삭제 후 등록한다. // 기존 알람 확인 실패(= 등록된 알람 없음)와 삭제 실패는 등록을 막지 않는다. if let existing = try? await repository.refresh() { @@ -20,6 +24,10 @@ public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { try await repository.register(lastRouteId: route.id) // 단일 알람 정책: 서버 등록이 성공한 뒤에만 로컬 알람을 교체한다. // TODO: [미확정] 서버 계산 알람 시각 스펙 확정 전까지 막차 출발 시각으로 스케줄한다. - try await scheduler.replaceAlarm(id: route.id, fireDate: route.departureTime, title: "막차 출발 알림") + try await scheduler.replaceAlarm( + id: route.id, + fireDate: route.departureTime, + title: AlarmSchedulingDefaults.title + ) } } diff --git a/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift index 12eb8d0..2b23a32 100644 --- a/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift @@ -31,6 +31,8 @@ private struct SpyAlarmRepository: AlarmRepository { private struct SpyAlarmScheduler: AlarmScheduler { let log: CallLog + func requestAuthorization() async -> Bool { true } + func replaceAlarm(id: String, fireDate: Date, title: String) async throws { await log.append("replaceAlarm:\(id)") } @@ -38,6 +40,8 @@ private struct SpyAlarmScheduler: AlarmScheduler { func cancelAlarm() async { await log.append("cancelAlarm") } + + func scheduledFireDate() async -> Date? { nil } } struct DefaultCancelAlarmUseCaseTests { diff --git a/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift new file mode 100644 index 0000000..6c8db6b --- /dev/null +++ b/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift @@ -0,0 +1,115 @@ +@testable import Domain +import Foundation +import Testing + +private actor CallLog { + private(set) var events: [String] = [] + func append(_ event: String) { events.append(event) } +} + +private struct StubError: Error {} + +private struct StubAlarmRepository: AlarmRepository { + let log: CallLog + var refreshResult: Result + + func register(lastRouteId: String) async throws { + await log.append("register:\(lastRouteId)") + } + + func cancel(lastRouteId: String) async throws { + await log.append("cancel:\(lastRouteId)") + } + + func refresh() async throws -> AlarmInfo { + await log.append("refresh") + return try refreshResult.get() + } +} + +private struct SpyAlarmScheduler: AlarmScheduler { + let log: CallLog + var scheduledDate: Date? = nil + + func requestAuthorization() async -> Bool { true } + + func replaceAlarm(id: String, fireDate: Date, title: String) async throws { + await log.append("replaceAlarm:\(id)@\(Int(fireDate.timeIntervalSince1970))") + } + + func cancelAlarm() async { + await log.append("cancelAlarm") + } + + func scheduledFireDate() async -> Date? { + await log.append("scheduledFireDate") + return scheduledDate + } +} + +private func makeInfo(departureTime: Date?) -> AlarmInfo { + AlarmInfo(lastRouteId: "route-1", departureTime: departureTime, updatedAt: nil, isReal: true) +} + +struct DefaultRefreshAlarmUseCaseTests { + @Test + func execute_departureChanged_reschedules() async throws { + let log = CallLog() + let newDeparture = Date(timeIntervalSince1970: 2_000) + let sut = DefaultRefreshAlarmUseCase( + repository: StubAlarmRepository(log: log, refreshResult: .success(makeInfo(departureTime: newDeparture))), + scheduler: SpyAlarmScheduler(log: log, scheduledDate: Date(timeIntervalSince1970: 1_000)) + ) + let info = try await sut.execute() + #expect(info.departureTime == newDeparture) + #expect(await log.events == ["refresh", "scheduledFireDate", "replaceAlarm:route-1@2000"]) + } + + @Test + func execute_departureUnchanged_doesNotReschedule() async throws { + let log = CallLog() + let departure = Date(timeIntervalSince1970: 1_000) + let sut = DefaultRefreshAlarmUseCase( + repository: StubAlarmRepository(log: log, refreshResult: .success(makeInfo(departureTime: departure))), + scheduler: SpyAlarmScheduler(log: log, scheduledDate: departure) + ) + _ = try await sut.execute() + #expect(await log.events == ["refresh", "scheduledFireDate"]) + } + + @Test + func execute_localAlarmMissing_reschedules() async throws { + let log = CallLog() + let departure = Date(timeIntervalSince1970: 3_000) + let sut = DefaultRefreshAlarmUseCase( + repository: StubAlarmRepository(log: log, refreshResult: .success(makeInfo(departureTime: departure))), + scheduler: SpyAlarmScheduler(log: log, scheduledDate: nil) + ) + _ = try await sut.execute() + #expect(await log.events == ["refresh", "scheduledFireDate", "replaceAlarm:route-1@3000"]) + } + + @Test + func execute_noDepartureTime_doesNotTouchScheduler() async throws { + let log = CallLog() + let sut = DefaultRefreshAlarmUseCase( + repository: StubAlarmRepository(log: log, refreshResult: .success(makeInfo(departureTime: nil))), + scheduler: SpyAlarmScheduler(log: log) + ) + _ = try await sut.execute() + #expect(await log.events == ["refresh"]) + } + + @Test + func execute_refreshFails_throwsWithoutScheduling() async { + let log = CallLog() + let sut = DefaultRefreshAlarmUseCase( + repository: StubAlarmRepository(log: log, refreshResult: .failure(StubError())), + scheduler: SpyAlarmScheduler(log: log) + ) + await #expect(throws: StubError.self) { + _ = try await sut.execute() + } + #expect(await log.events == ["refresh"]) + } +} diff --git a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift index c71078f..17407d7 100644 --- a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift @@ -32,6 +32,12 @@ private struct SpyAlarmRepository: AlarmRepository { 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)") @@ -40,6 +46,8 @@ private struct SpyAlarmScheduler: AlarmScheduler { func cancelAlarm() async { await log.append("cancelAlarm") } + + func scheduledFireDate() async -> Date? { nil } } private extension LastRoute { @@ -67,7 +75,7 @@ struct DefaultRegisterAlarmUseCaseTests { scheduler: SpyAlarmScheduler(log: log) ) try await sut.execute(route: .fixture(id: "new")) - #expect(await log.events == ["refresh", "cancel:old", "register:new", "replaceAlarm:new"]) + #expect(await log.events == ["auth:true", "refresh", "cancel:old", "register:new", "replaceAlarm:new"]) } @Test @@ -78,7 +86,7 @@ struct DefaultRegisterAlarmUseCaseTests { scheduler: SpyAlarmScheduler(log: log) ) try await sut.execute(route: .fixture(id: "new")) - #expect(await log.events == ["refresh", "register:new", "replaceAlarm:new"]) + #expect(await log.events == ["auth:true", "refresh", "register:new", "replaceAlarm:new"]) } @Test @@ -91,6 +99,20 @@ struct DefaultRegisterAlarmUseCaseTests { await #expect(throws: StubError.self) { try await sut.execute(route: .fixture(id: "new")) } - #expect(await log.events == ["refresh", "register:new"]) + #expect(await log.events == ["auth:true", "refresh", "register:new"]) + } + + @Test + func execute_permissionDenied_throwsAndHoldsServerRegistration() async { + let log = CallLog() + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log, authorizationGranted: false) + ) + await #expect(throws: AlarmError.permissionDenied) { + try await sut.execute(route: .fixture(id: "new")) + } + // 권한 거부 시 서버 등록·삭제·로컬 스케줄 어디에도 도달하면 안 된다. + #expect(await log.events == ["auth:false"]) } } diff --git a/Projects/Feature/Home/Example/ExampleApp.swift b/Projects/Feature/Home/Example/ExampleApp.swift index c22fc97..ab40aa4 100644 --- a/Projects/Feature/Home/Example/ExampleApp.swift +++ b/Projects/Feature/Home/Example/ExampleApp.swift @@ -38,6 +38,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { getCurrentLocationUseCase: PreviewGetCurrentLocationUseCase(), reverseGeocodeUseCase: PreviewReverseGeocodeUseCase(), registerAlarmUseCase: PreviewRegisterAlarmUseCase(), + cancelAlarmUseCase: PreviewCancelAlarmUseCase(), + refreshAlarmUseCase: PreviewRefreshAlarmUseCase(), searchCoordinatorBuildable: PreviewSearchCoordinatorBuildable() ) let coordinator = container.makeHomeCoordinator(navigationController: navigationController) @@ -73,6 +75,20 @@ struct PreviewRegisterAlarmUseCase: RegisterAlarmUseCase { } } +struct PreviewCancelAlarmUseCase: CancelAlarmUseCase { + func execute(lastRouteId: String) async throws { + try? await Task.sleep(for: .milliseconds(300)) + } +} + +/// 등록된 알람이 없는 서버 상태를 흉내 낸다 — 포그라운드 복귀가 화면을 건드리지 않는다. +struct PreviewRefreshAlarmUseCase: RefreshAlarmUseCase { + struct NoRegisteredAlarm: Error {} + func execute() async throws -> AlarmInfo { + throw NoRegisteredAlarm() + } +} + /// 검색 플로우 스텁: 화면 전환 없이 canned 경로를 즉시 반환한다. /// 실제 검색 UX 시연은 SearchFeatureExample이 담당한다. struct PreviewSearchCoordinatorBuildable: SearchCoordinatorBuildable { diff --git a/Projects/Feature/Home/Sources/HomeDIContainer.swift b/Projects/Feature/Home/Sources/HomeDIContainer.swift index 7b57f17..6509ff8 100644 --- a/Projects/Feature/Home/Sources/HomeDIContainer.swift +++ b/Projects/Feature/Home/Sources/HomeDIContainer.swift @@ -11,17 +11,23 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { private let getCurrentLocationUseCase: any GetCurrentLocationUseCase private let reverseGeocodeUseCase: any ReverseGeocodeUseCase private let registerAlarmUseCase: any RegisterAlarmUseCase + private let cancelAlarmUseCase: any CancelAlarmUseCase + private let refreshAlarmUseCase: any RefreshAlarmUseCase private let searchCoordinatorBuildable: any SearchCoordinatorBuildable public init( getCurrentLocationUseCase: any GetCurrentLocationUseCase, reverseGeocodeUseCase: any ReverseGeocodeUseCase, registerAlarmUseCase: any RegisterAlarmUseCase, + cancelAlarmUseCase: any CancelAlarmUseCase, + refreshAlarmUseCase: any RefreshAlarmUseCase, searchCoordinatorBuildable: any SearchCoordinatorBuildable ) { self.getCurrentLocationUseCase = getCurrentLocationUseCase self.reverseGeocodeUseCase = reverseGeocodeUseCase self.registerAlarmUseCase = registerAlarmUseCase + self.cancelAlarmUseCase = cancelAlarmUseCase + self.refreshAlarmUseCase = refreshAlarmUseCase self.searchCoordinatorBuildable = searchCoordinatorBuildable } @@ -35,7 +41,9 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { let viewModel = HomeViewModel( getCurrentLocationUseCase: getCurrentLocationUseCase, reverseGeocodeUseCase: reverseGeocodeUseCase, - registerAlarmUseCase: registerAlarmUseCase + registerAlarmUseCase: registerAlarmUseCase, + cancelAlarmUseCase: cancelAlarmUseCase, + refreshAlarmUseCase: refreshAlarmUseCase ) viewModel.onSearchRequested = onSearchRequested return HomeViewController(viewModel: viewModel) diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift index f91f575..94b8b67 100644 --- a/Projects/Feature/Home/Sources/HomeViewController.swift +++ b/Projects/Feature/Home/Sources/HomeViewController.swift @@ -21,6 +21,8 @@ final class HomeViewController: UIViewController { private lazy var arrivalRow = makeFieldRow(icon: DSIcon.place24, field: arrivalField) private let routeCard = DSRouteCard() private let registerButton = DSButton(title: "알람 등록하기") + // DSButton은 title이 init 고정이라 토글은 버튼 2개의 표시 전환으로 구현한다. + private let cancelButton = DSButton(title: "알람 해제하기", style: .secondary) private let contentStack: UIStackView = { let stack = UIStackView() @@ -44,6 +46,18 @@ final class HomeViewController: UIViewController { configureUI() bind() viewModel.viewDidLoad() + // "SceneDelegate → 알림 경유": scene 포그라운드 전환마다 시스템이 게시하는 + // 노티를 관찰한다 (최초 진입 포함 — 알람 상태 복원을 겸한다). + NotificationCenter.default.addObserver( + self, + selector: #selector(sceneWillEnterForeground), + name: UIScene.willEnterForegroundNotification, + object: nil + ) + } + + @objc private func sceneWillEnterForeground() { + viewModel.appWillEnterForeground() } override func viewWillAppear(_ animated: Bool) { @@ -63,7 +77,7 @@ final class HomeViewController: UIViewController { make.leading.trailing.equalToSuperview().inset(DSSpacing.md) } - [titleLabel, banner, departureRow, arrivalRow, routeCard, registerButton] + [titleLabel, banner, departureRow, arrivalRow, routeCard, registerButton, cancelButton] .forEach(contentStack.addArrangedSubview) contentStack.addArrangedSubview(makeCaptionStack()) contentStack.setCustomSpacing(DSSpacing.lg20, after: titleLabel) @@ -73,11 +87,16 @@ final class HomeViewController: UIViewController { banner.isHidden = true routeCard.isHidden = true registerButton.isHidden = true + cancelButton.isHidden = true registerButton.addAction( UIAction { [weak self] _ in self?.viewModel.registerAlarmTapped() }, for: .touchUpInside ) + cancelButton.addAction( + UIAction { [weak self] _ in self?.viewModel.cancelAlarmTapped() }, + for: .touchUpInside + ) } /// 홈의 필드는 편집이 아니라 검색 진입 트리거다. DSTextField에는 편집 시작 훅이 @@ -165,12 +184,13 @@ final class HomeViewController: UIViewController { 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 + registerButton.isHidden = state.alarmButton != .register + cancelButton.isHidden = state.alarmButton != .cancel + registerButton.isEnabled = !state.isAlarmBusy + cancelButton.isEnabled = !state.isAlarmBusy if let bannerData = state.banner { banner.configure(text: bannerData.text, style: bannerData.isUrgent ? .urgent : .normal) @@ -191,8 +211,19 @@ final class HomeViewController: UIViewController { UIApplication.shared.open(url) } ) + case .alarmPermissionNeeded: + 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) + case .alarmCancelFailed: + DSToast.show("알람 해제에 실패했어요. 다시 시도해 주세요.", in: view) } } } diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index 10307d6..147b2e3 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -17,17 +17,27 @@ final class HomeViewModel { let isUrgent: Bool } + /// 알람 버튼 슬롯의 표출 모드 — 선택 경로·등록 상태의 순수 함수로 계산한다. + enum AlarmButtonMode: Equatable { + case hidden + case register + case cancel + } + struct State: Equatable { var departure: DepartureState = .loading var routeCard: RouteCardViewData? var banner: BannerViewData? - var isRegisteringAlarm = false + var isAlarmBusy = false + var alarmButton: AlarmButtonMode = .hidden } /// 재방출되면 안 되는 원샷 안내 — 상태와 분리한다. enum ToastEvent: Equatable { case locationPermissionNeeded + case alarmPermissionNeeded case alarmRegisterFailed + case alarmCancelFailed } /// Set by the ViewController; always invoked on the main actor. @@ -43,31 +53,41 @@ final class HomeViewModel { private let getCurrentLocationUseCase: any GetCurrentLocationUseCase private let reverseGeocodeUseCase: any ReverseGeocodeUseCase private let registerAlarmUseCase: any RegisterAlarmUseCase + private let cancelAlarmUseCase: any CancelAlarmUseCase + private let refreshAlarmUseCase: any RefreshAlarmUseCase private let now: @Sendable () -> Date private let bannerTickInterval: Duration private var selectedRoute: LastRoute? + /// 서버에 알람이 등록된 경로 id — 해제 버튼·포그라운드 복원의 기준. + private var registeredRouteId: String? private var locationTask: Task? - private var registerTask: Task? + private var alarmTask: Task? + private var refreshTask: Task? private var bannerTask: Task? init( getCurrentLocationUseCase: any GetCurrentLocationUseCase, reverseGeocodeUseCase: any ReverseGeocodeUseCase, registerAlarmUseCase: any RegisterAlarmUseCase, + cancelAlarmUseCase: any CancelAlarmUseCase, + refreshAlarmUseCase: any RefreshAlarmUseCase, now: @escaping @Sendable () -> Date = { Date() }, bannerTickInterval: Duration = .seconds(60) ) { self.getCurrentLocationUseCase = getCurrentLocationUseCase self.reverseGeocodeUseCase = reverseGeocodeUseCase self.registerAlarmUseCase = registerAlarmUseCase + self.cancelAlarmUseCase = cancelAlarmUseCase + self.refreshAlarmUseCase = refreshAlarmUseCase self.now = now self.bannerTickInterval = bannerTickInterval } deinit { locationTask?.cancel() - registerTask?.cancel() + alarmTask?.cancel() + refreshTask?.cancel() bannerTask?.cancel() } @@ -90,30 +110,91 @@ final class HomeViewModel { newState.routeCard = RouteCardViewData(entity: route) // 새 경로 선택 = 기존 배너는 더 이상 유효하지 않다 (재등록 전까지 숨김). newState.banner = nil + newState.alarmButton = Self.alarmButtonMode( + selectedRouteId: route.id, + registeredRouteId: registeredRouteId + ) state = newState bannerTask?.cancel() } func registerAlarmTapped() { - guard let route = selectedRoute, !state.isRegisteringAlarm else { return } - registerTask?.cancel() - state.isRegisteringAlarm = true + guard let route = selectedRoute, !state.isAlarmBusy else { return } + alarmTask?.cancel() + state.isAlarmBusy = true // [weak self]: the in-flight task must not keep the ViewModel alive. - registerTask = Task { [weak self] in + alarmTask = 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?.registeredRouteId = route.id + self?.state.isAlarmBusy = false + self?.refreshAlarmButton() self?.startBannerTimer(departure: route.departureTime) + } catch AlarmError.permissionDenied { + guard !Task.isCancelled else { return } + self?.state.isAlarmBusy = false + self?.onToast?(.alarmPermissionNeeded) } catch { guard !Task.isCancelled else { return } - self?.state.isRegisteringAlarm = false + self?.state.isAlarmBusy = false self?.onToast?(.alarmRegisterFailed) } } } + func cancelAlarmTapped() { + guard let routeId = registeredRouteId, !state.isAlarmBusy else { return } + alarmTask?.cancel() + state.isAlarmBusy = true + alarmTask = Task { [weak self] in + guard let useCase = self?.cancelAlarmUseCase else { return } + do { + try await useCase.execute(lastRouteId: routeId) + guard !Task.isCancelled, let self else { return } + self.bannerTask?.cancel() + self.registeredRouteId = nil + var newState = self.state + newState.banner = nil + newState.isAlarmBusy = false + newState.alarmButton = Self.alarmButtonMode( + selectedRouteId: self.selectedRoute?.id, + registeredRouteId: nil + ) + self.state = newState + } catch { + guard !Task.isCancelled else { return } + self?.state.isAlarmBusy = false + self?.onToast?(.alarmCancelFailed) + } + } + } + + /// 포그라운드 복귀(최초 진입 포함) 시 서버 알람을 재조회한다. 시각 변경 재스케줄은 + /// RefreshAlarmUseCase 내부 정책이고, 여기서는 배너·버튼 상태만 갱신한다. + /// Phase 8에서 앱 시작·푸시 수신 경로와 함께 AlarmSyncService로 일원화될 예정이라 + /// App이 아닌 ViewModel에 최소 구현으로 둔다. + func appWillEnterForeground() { + refreshTask?.cancel() + refreshTask = Task { [weak self] in + guard let useCase = self?.refreshAlarmUseCase else { return } + do { + let info = try await useCase.execute() + guard !Task.isCancelled else { return } + self?.registeredRouteId = info.lastRouteId + self?.refreshAlarmButton() + if let departure = info.departureTime { + self?.startBannerTimer(departure: departure) + } + } catch { + // TODO: [미확정] 서버의 "등록된 알람 없음" 표현이 확정되면 그 경우에만 + // 배너·버튼을 정리한다. 지금은 네트워크 오류와 구분할 수 없어 + // 상태를 유지한다 (Phase 8 하드닝에서 세분화). + } + } + } + // MARK: - 내부 전이 private func loadCurrentLocation() { @@ -140,6 +221,13 @@ final class HomeViewModel { } } + private func refreshAlarmButton() { + state.alarmButton = Self.alarmButtonMode( + selectedRouteId: selectedRoute?.id, + registeredRouteId: registeredRouteId + ) + } + private func startBannerTimer(departure: Date) { bannerTask?.cancel() // 매 틱 departure 기준으로 재계산 — 누적 드리프트가 없다. @@ -156,6 +244,18 @@ final class HomeViewModel { // MARK: - 순수 계산 + nonisolated static func alarmButtonMode( + selectedRouteId: String?, + registeredRouteId: String? + ) -> AlarmButtonMode { + if let selectedRouteId { + // 선택한 카드가 등록된 경로면 해제, 아니면 (재)등록. + return selectedRouteId == registeredRouteId ? .cancel : .register + } + // 카드 없이 등록만 남은 상태(재실행 복원) — 해제만 가능하다. + return registeredRouteId == nil ? .hidden : .cancel + } + nonisolated static func minutesUntil(departure: Date, now: Date) -> Int { max(0, Int(ceil(departure.timeIntervalSince(now) / 60))) } diff --git a/Projects/Feature/Home/Tests/HomeViewModelTests.swift b/Projects/Feature/Home/Tests/HomeViewModelTests.swift index ef9e6f7..8a738c6 100644 --- a/Projects/Feature/Home/Tests/HomeViewModelTests.swift +++ b/Projects/Feature/Home/Tests/HomeViewModelTests.swift @@ -20,6 +20,16 @@ private struct StubRegisterAlarmUseCase: RegisterAlarmUseCase { func execute(route: LastRoute) async throws { try await handler(route) } } +private struct StubCancelAlarmUseCase: CancelAlarmUseCase { + let handler: @Sendable (String) async throws -> Void + func execute(lastRouteId: String) async throws { try await handler(lastRouteId) } +} + +private struct StubRefreshAlarmUseCase: RefreshAlarmUseCase { + let handler: @Sendable () async throws -> AlarmInfo + func execute() async throws -> AlarmInfo { try await handler() } +} + /// 테스트 도중 `now()`를 전진시키기 위한 가변 시계. private nonisolated final class NowBox: @unchecked Sendable { private let lock = NSLock() @@ -99,6 +109,8 @@ private func makeSUT( Place(name: "강남역", address: "서울 강남구", coordinate: $0) }, register: @escaping @Sendable (LastRoute) async throws -> Void = { _ in }, + cancel: @escaping @Sendable (String) async throws -> Void = { _ in }, + refresh: @escaping @Sendable () async throws -> AlarmInfo = { throw StubError() }, now: @escaping @Sendable () -> Date = { fixedNow }, bannerTickInterval: Duration = .seconds(60) ) -> HomeViewModel { @@ -106,6 +118,8 @@ private func makeSUT( getCurrentLocationUseCase: StubGetCurrentLocationUseCase(handler: location), reverseGeocodeUseCase: StubReverseGeocodeUseCase(handler: geocode), registerAlarmUseCase: StubRegisterAlarmUseCase(handler: register), + cancelAlarmUseCase: StubCancelAlarmUseCase(handler: cancel), + refreshAlarmUseCase: StubRefreshAlarmUseCase(handler: refresh), now: now, bannerTickInterval: bannerTickInterval ) @@ -175,11 +189,13 @@ struct HomeViewModelTests { sut.routeSelected(route) sut.registerAlarmTapped() - #expect(sut.state.isRegisteringAlarm) + #expect(sut.state.isAlarmBusy) await recorder.waitUntilLast { $0.banner != nil } #expect(sut.state.banner == .init(text: "막차 출발까지 42분", isUrgent: false)) - #expect(!sut.state.isRegisteringAlarm) + #expect(!sut.state.isAlarmBusy) + // 등록 성공 후 같은 자리 버튼이 해제로 토글된다. + #expect(sut.state.alarmButton == .cancel) } @Test @@ -195,7 +211,8 @@ struct HomeViewModelTests { #expect(recorder.toasts == [.alarmRegisterFailed]) #expect(sut.state.banner == nil) - #expect(!sut.state.isRegisteringAlarm) + #expect(!sut.state.isAlarmBusy) + #expect(sut.state.alarmButton == .register) } @Test @@ -254,4 +271,115 @@ struct HomeViewModelTests { #expect(sut.state.banner?.isUrgent == true) } + + @Test + func registerAlarm_permissionDenied_emitsSettingsToast() async { + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let sut = makeSUT(register: { _ in throw AlarmError.permissionDenied }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.routeSelected(route) + sut.registerAlarmTapped() + while recorder.toasts.isEmpty { await Task.yield() } + + #expect(recorder.toasts == [.alarmPermissionNeeded]) + #expect(sut.state.banner == nil) + #expect(!sut.state.isAlarmBusy) + #expect(sut.state.alarmButton == .register) + } + + @Test + func cancelAlarm_success_hidesBannerAndTogglesButton() 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.cancelAlarmTapped() + await recorder.waitUntilLast { $0.banner == nil && !$0.isAlarmBusy } + + #expect(recorder.toasts.isEmpty) + // 카드는 남아 있으므로 다시 등록할 수 있다. + #expect(sut.state.alarmButton == .register) + #expect(sut.state.routeCard != nil) + } + + @Test + func cancelAlarm_failure_emitsToastAndKeepsBanner() async { + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + let sut = makeSUT(cancel: { _ in throw StubError() }) + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.routeSelected(route) + sut.registerAlarmTapped() + await recorder.waitUntilLast { $0.banner != nil } + + sut.cancelAlarmTapped() + while recorder.toasts.isEmpty { await Task.yield() } + + #expect(recorder.toasts == [.alarmCancelFailed]) + #expect(sut.state.banner != nil) + #expect(sut.state.alarmButton == .cancel) + } + + @Test + func foregroundRefresh_restoresBannerAndCancelButtonWithoutCard() async { + // 앱 재실행 복원 시나리오: 카드 없이 서버 알람만 있는 상태. + let departure = fixedNow.addingTimeInterval(30 * 60) + let sut = makeSUT(refresh: { + AlarmInfo(lastRouteId: "r1", departureTime: departure, updatedAt: nil, isReal: true) + }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.appWillEnterForeground() + await recorder.waitUntilLast { $0.banner != nil } + + #expect(sut.state.banner == .init(text: "막차 출발까지 30분", isUrgent: false)) + #expect(sut.state.alarmButton == .cancel) + #expect(sut.state.routeCard == nil) + } + + @Test + func foregroundRefresh_updatesBannerToNewDeparture() async { + let route = makeRoute(id: "r1", departure: fixedNow.addingTimeInterval(42 * 60)) + // 서버가 15분 당긴 시각을 돌려준다. + let sut = makeSUT(refresh: { + AlarmInfo( + lastRouteId: "r1", + departureTime: fixedNow.addingTimeInterval(27 * 60), + updatedAt: nil, + isReal: true + ) + }) + let recorder = StateRecorder() + recorder.attach(to: sut) + sut.routeSelected(route) + sut.registerAlarmTapped() + await recorder.waitUntilLast { $0.banner?.text == "막차 출발까지 42분" } + + sut.appWillEnterForeground() + await recorder.waitUntilLast { $0.banner?.text == "막차 출발까지 27분" } + + #expect(sut.state.alarmButton == .cancel) + } + + @Test + func foregroundRefresh_failure_keepsState() async { + let sut = makeSUT(refresh: { throw StubError() }) + let recorder = StateRecorder() + recorder.attach(to: sut) + + sut.appWillEnterForeground() + // 실패는 상태를 건드리지 않는다 — 드레인만 하고 확인한다. + for _ in 0..<20 { await Task.yield() } + + #expect(sut.state.banner == nil) + #expect(sut.state.alarmButton == .hidden) + #expect(recorder.toasts.isEmpty) + } } diff --git a/Workspace.swift b/Workspace.swift index 1503e41..eb37332 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -11,6 +11,7 @@ let workspace = Workspace( "Projects/Core/Network", "Projects/Core/Storage", "Projects/Core/Auth", + "Projects/Core/Alarm", "Projects/Core/Coordinator", "Projects/DesignSystem", "Projects/Legacy", From 29306e0104d82af5ceb7e7c4c89dc2691f24d1e1 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sat, 22 Aug 2026 22:51:23 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20DEV=20=ED=95=9C=EC=A0=95=20?= =?UTF-8?q?=EA=B2=80=EC=88=98=EC=9A=A9=20=EC=9A=B0=ED=9A=8C=20=E2=80=94=20?= =?UTF-8?q?=EC=8B=A4=EC=84=9C=EB=B2=84=20=EB=B6=80=EC=9E=AC=20=EC=8B=9C=20?= =?UTF-8?q?=EB=8D=B0=EB=AA=A8=20=ED=8F=B4=EB=B0=B1=20+=203=EC=B4=88=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실서버(미확정 #1·#2)가 접속 불가라 Phase 7 사람 검수(권한 다이얼로그·알람 발화)가 막혀, 사용자 지시로 DEV 빌드에만 임시 우회를 넣는다: - 장소/막차 검색·역지오코딩: 서버 실패 시에만 데모 데이터 폴백 (서버 살아나면 실데이터 우선) - 알람 등록/삭제: 서버 실패 무시하고 로컬 스케줄 진행 (refresh는 그대로 실패) - URLSession 타임아웃 3초 (기본 60초 대기로 시연 불가 방지) 전부 #if DEV — Stage/Release에는 컴파일되지 않음. 실서버 스펙 확정 시 이 커밋을 리버트한다. Co-Authored-By: Claude Fable 5 --- .../Sources/Adapters/DevDemoFallbacks.swift | 130 ++++++++++++++++++ Projects/App/Sources/AppDIContainer.swift | 33 ++++- 2 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 Projects/App/Sources/Adapters/DevDemoFallbacks.swift diff --git a/Projects/App/Sources/Adapters/DevDemoFallbacks.swift b/Projects/App/Sources/Adapters/DevDemoFallbacks.swift new file mode 100644 index 0000000..d7d104c --- /dev/null +++ b/Projects/App/Sources/Adapters/DevDemoFallbacks.swift @@ -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 diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index c4683f7..33deca3 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -4,6 +4,7 @@ import CoreAuth import CoreNetwork import CoreStorage import Domain +import Foundation import HomeFeature import HomeFeatureInterface import SearchFeature @@ -16,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 @@ -36,9 +49,23 @@ 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()) // 디바이스 포트 어댑터 — CoreLocation/AlarmKit을 아는 곳은 App의 어댑터뿐.