From 389fcd0b20d6514fd0c9c9b95d5c7cdff932a532 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sat, 22 Aug 2026 22:04:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20AtchaV2=20Phase=209=20=E2=80=94=20LA=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=9D=B8=ED=94=84=EB=9D=BC=20(=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=9D=B5=EC=8A=A4=ED=85=90=EC=85=98=20+=20CoreLive?= =?UTF-8?q?Activity=20+=20entitlements)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CoreLiveActivity 신규 레이어 모듈: LastTrainActivityAttributes + ContentState (출발/알람 시각, 긴급도 3단계, 변경 배지 만료, 세션 상태) — 순수 데이터, 무의존 - AtchaWidget 위젯 익스텐션 타겟 (com.atcha.iOS.v2.widget, PlugIns 임베드): 플레이스홀더 ActivityConfiguration + DSColor SwiftUI 브리지만 — 실제 UI는 Phase 10 - Target.widgetExtension DSL 헬퍼 (Settings.atchaV2 경유 — Stage 3구성 유지) - 앱 NSSupportsLiveActivities + 양 타겟 aps-environment entitlements (Tuist DSL) - 병렬 가드: 공유 2파일(App Project.swift, Workspace.swift)은 최소 diff Co-Authored-By: Claude Fable 5 --- Projects/App/Project.swift | 16 ++++- .../Widget/Sources/AtchaWidgetBundle.swift | 9 +++ .../App/Widget/Sources/DSColor+SwiftUI.swift | 14 +++++ .../Sources/LastTrainLiveActivityWidget.swift | 26 ++++++++ Projects/Core/LiveActivity/Project.swift | 4 ++ .../Sources/LastTrainActivityAttributes.swift | 61 +++++++++++++++++++ .../LastTrainActivityAttributesTests.swift | 56 +++++++++++++++++ .../Target+WidgetExtension.swift | 37 +++++++++++ Workspace.swift | 1 + 9 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 Projects/App/Widget/Sources/AtchaWidgetBundle.swift create mode 100644 Projects/App/Widget/Sources/DSColor+SwiftUI.swift create mode 100644 Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift create mode 100644 Projects/Core/LiveActivity/Project.swift create mode 100644 Projects/Core/LiveActivity/Sources/LastTrainActivityAttributes.swift create mode 100644 Projects/Core/LiveActivity/Tests/LastTrainActivityAttributesTests.swift create mode 100644 Tuist/ProjectDescriptionHelpers/Target+WidgetExtension.swift diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 7c28719..5a1e26b 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -14,6 +14,7 @@ let appTarget = Target.target( "NSLocationWhenInUseUsageDescription": "현재 위치를 출발지로 사용하기 위해 위치 정보 접근 권한이 필요합니다.", // 사일런트 푸시(content-available=1) 수신용 — 사용자 알림 권한과 무관. "UIBackgroundModes": ["remote-notification"], + "NSSupportsLiveActivities": true, "UIApplicationSceneManifest": [ "UIApplicationSupportsMultipleScenes": false, "UISceneConfigurations": [ @@ -36,6 +37,7 @@ let appTarget = Target.target( "aps-environment": "development", ]), dependencies: [ + .target(name: "AtchaWidget"), .project(target: "HomeFeature", path: "../Feature/Home"), .project(target: "HomeFeatureInterface", path: "../Feature/Home"), .project(target: "SearchFeature", path: "../Feature/Search"), @@ -47,6 +49,7 @@ let appTarget = Target.target( .project(target: "CoreAuth", path: "../Core/Auth"), .project(target: "CoreAlarm", path: "../Core/Alarm"), .project(target: "CoreCoordinator", path: "../Core/Coordinator"), + .project(target: "CoreLiveActivity", path: "../Core/LiveActivity"), .project(target: "DesignSystem", path: "../DesignSystem"), .external(name: "FirebaseCore"), .external(name: "FirebaseCrashlytics"), @@ -60,6 +63,17 @@ let appTarget = Target.target( ]) ) +let widgetTarget = Target.widgetExtension( + name: "AtchaWidget", + bundleId: "\(Atcha.v2BundleID).widget", + sources: ["Widget/Sources/**"], + entitlements: .dictionary(["aps-environment": "development"]), + dependencies: [ + .project(target: "CoreLiveActivity", path: "../Core/LiveActivity"), + .project(target: "DesignSystem", path: "../DesignSystem"), + ] +) + let project = Project( name: "AtchaV2", options: .options( @@ -68,7 +82,7 @@ let project = Project( developmentRegion: Atcha.developmentRegion ), settings: .atchaV2(), - targets: [appTarget], + targets: [appTarget, widgetTarget], schemes: [ .scheme( name: "AtchaV2", diff --git a/Projects/App/Widget/Sources/AtchaWidgetBundle.swift b/Projects/App/Widget/Sources/AtchaWidgetBundle.swift new file mode 100644 index 0000000..a83f427 --- /dev/null +++ b/Projects/App/Widget/Sources/AtchaWidgetBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct AtchaWidgetBundle: WidgetBundle { + var body: some Widget { + LastTrainLiveActivityWidget() + } +} diff --git a/Projects/App/Widget/Sources/DSColor+SwiftUI.swift b/Projects/App/Widget/Sources/DSColor+SwiftUI.swift new file mode 100644 index 0000000..2a46226 --- /dev/null +++ b/Projects/App/Widget/Sources/DSColor+SwiftUI.swift @@ -0,0 +1,14 @@ +import DesignSystem +import SwiftUI +import UIKit + +// DesignSystem is UIKit-based (UIColor role tokens). The widget extension is +// the repo's only SwiftUI surface, so the bridge lives here instead of adding +// a SwiftUI dependency to DesignSystem itself. +extension Color { + /// Bridges a DesignSystem `DSColor` token (UIColor) into SwiftUI, + /// e.g. `Color(ds: DSColor.Accent.default)`. + init(ds uiColor: UIColor) { + self.init(uiColor: uiColor) + } +} diff --git a/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift b/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift new file mode 100644 index 0000000..ac619ca --- /dev/null +++ b/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift @@ -0,0 +1,26 @@ +import ActivityKit +import CoreLiveActivity +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. +struct LastTrainLiveActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: LastTrainActivityAttributes.self) { _ in + EmptyView() + } dynamicIsland: { _ in + DynamicIsland { + DynamicIslandExpandedRegion(.center) { + EmptyView() + } + } compactLeading: { + EmptyView() + } compactTrailing: { + EmptyView() + } minimal: { + EmptyView() + } + } + } +} diff --git a/Projects/Core/LiveActivity/Project.swift b/Projects/Core/LiveActivity/Project.swift new file mode 100644 index 0000000..5547c33 --- /dev/null +++ b/Projects/Core/LiveActivity/Project.swift @@ -0,0 +1,4 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.layer(name: "CoreLiveActivity", bundleSuffix: "core.liveactivity", isolation: .nonisolated) diff --git a/Projects/Core/LiveActivity/Sources/LastTrainActivityAttributes.swift b/Projects/Core/LiveActivity/Sources/LastTrainActivityAttributes.swift new file mode 100644 index 0000000..1c805ea --- /dev/null +++ b/Projects/Core/LiveActivity/Sources/LastTrainActivityAttributes.swift @@ -0,0 +1,61 @@ +import ActivityKit +import Foundation + +// Live Activity contract shared by the app process (start/update/end via the +// ActivityKit adapter) and the widget extension (rendering). Pure data only — +// urgency/diff policy lives in Domain, presentation lives in the extension. + +/// Fixed facts of one last-train session, set when the Activity starts. +public struct LastTrainActivityAttributes: ActivityAttributes, Sendable { + public let routeId: String + /// 노선명 (예: "9호선 급행") — 잠금화면·다이나믹 아일랜드 타이틀. + public let routeName: String + + public init(routeId: String, routeName: String) { + self.routeId = routeId + self.routeName = routeName + } + + /// Mutable snapshot pushed on every update. + public struct ContentState: Codable, Hashable, Sendable { + /// 막차 출발 시각. + public let departureTime: Date + /// 로컬 알람 발화 시각 (기준 시각 − 버퍼). + public let alarmTime: Date + /// 긴급도 3단계 — 임계 계산은 Domain, 여기는 결과 값만 나른다. + public let urgency: LastTrainUrgency + /// "⚠ 당겨짐" 배지 노출 만료 시각. nil이면 배지 없음. + public let changeBadgeExpiry: Date? + /// 세션 상태 (final state 전환 포함). + public let status: LastTrainSessionStatus + + public init( + departureTime: Date, + alarmTime: Date, + urgency: LastTrainUrgency, + changeBadgeExpiry: Date?, + status: LastTrainSessionStatus + ) { + self.departureTime = departureTime + self.alarmTime = alarmTime + self.urgency = urgency + self.changeBadgeExpiry = changeBadgeExpiry + self.status = status + } + } +} + +/// 긴급도 3단계 (여유/주의/임박). +public enum LastTrainUrgency: String, Codable, Hashable, Sendable { + case relaxed + case caution + case imminent +} + +/// 세션 상태. `missed` = 못 타게 됨(앞당겨짐이 이미 비행동 가능), +/// `serviceEnded` = 운행 종료·경로 소멸. +public enum LastTrainSessionStatus: String, Codable, Hashable, Sendable { + case active + case missed + case serviceEnded +} diff --git a/Projects/Core/LiveActivity/Tests/LastTrainActivityAttributesTests.swift b/Projects/Core/LiveActivity/Tests/LastTrainActivityAttributesTests.swift new file mode 100644 index 0000000..bed2104 --- /dev/null +++ b/Projects/Core/LiveActivity/Tests/LastTrainActivityAttributesTests.swift @@ -0,0 +1,56 @@ +@testable import CoreLiveActivity +import Foundation +import Testing + +struct LastTrainActivityAttributesTests { + private let state = LastTrainActivityAttributes.ContentState( + departureTime: Date(timeIntervalSince1970: 1_756_000_000), + alarmTime: Date(timeIntervalSince1970: 1_755_999_820), + urgency: .caution, + changeBadgeExpiry: Date(timeIntervalSince1970: 1_755_999_000), + status: .active + ) + + @Test + func contentState_roundTripsCodable() throws { + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(LastTrainActivityAttributes.ContentState.self, from: data) + #expect(decoded == state) + } + + @Test + func contentState_nilBadgeExpiry_roundTripsCodable() throws { + let noBadge = LastTrainActivityAttributes.ContentState( + departureTime: state.departureTime, + alarmTime: state.alarmTime, + urgency: .imminent, + changeBadgeExpiry: nil, + status: .missed + ) + let data = try JSONEncoder().encode(noBadge) + let decoded = try JSONDecoder().decode(LastTrainActivityAttributes.ContentState.self, from: data) + #expect(decoded == noBadge) + #expect(decoded.changeBadgeExpiry == nil) + } + + @Test + func attributes_roundTripCodable_preservesFixedSessionInfo() throws { + let attributes = LastTrainActivityAttributes(routeId: "route-42", routeName: "9호선 급행") + let data = try JSONEncoder().encode(attributes) + let decoded = try JSONDecoder().decode(LastTrainActivityAttributes.self, from: data) + #expect(decoded.routeId == "route-42") + #expect(decoded.routeName == "9호선 급행") + } + + @Test + func urgencyAndStatus_rawValuesAreStable() { + // Raw values ride inside ActivityKit's persisted state — renaming a + // case is a wire-format break, not a refactor. + #expect(LastTrainUrgency.relaxed.rawValue == "relaxed") + #expect(LastTrainUrgency.caution.rawValue == "caution") + #expect(LastTrainUrgency.imminent.rawValue == "imminent") + #expect(LastTrainSessionStatus.active.rawValue == "active") + #expect(LastTrainSessionStatus.missed.rawValue == "missed") + #expect(LastTrainSessionStatus.serviceEnded.rawValue == "serviceEnded") + } +} diff --git a/Tuist/ProjectDescriptionHelpers/Target+WidgetExtension.swift b/Tuist/ProjectDescriptionHelpers/Target+WidgetExtension.swift new file mode 100644 index 0000000..1f4efdc --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/Target+WidgetExtension.swift @@ -0,0 +1,37 @@ +import ProjectDescription + +public extension Target { + /// WidgetKit extension embedded in the host app (`.app/PlugIns/`). + /// SwiftUI + WidgetKit — the repo's single sanctioned exception to the + /// UIKit-only UI convention. Settings go through `Settings.atchaV2()` so + /// the Debug/Stage/Release configuration triple stays intact. + static func widgetExtension( + name: String, + bundleId: String, + sources: SourceFilesList, + entitlements: Entitlements? = nil, + dependencies: [TargetDependency] = [] + ) -> Target { + .target( + name: name, + destinations: Atcha.destinations, + product: .appExtension, + bundleId: bundleId, + deploymentTargets: Atcha.v2Deployment, + infoPlist: .extendingDefault(with: [ + "NSExtension": [ + "NSExtensionPointIdentifier": "com.apple.widgetkit-extension", + ], + ]), + sources: sources, + entitlements: entitlements, + dependencies: dependencies, + settings: .atchaV2( + // Embedded extensions must not install to /Applications on + // archive; the host app carries the .appex. + base: ["SKIP_INSTALL": "YES"], + isolation: .mainActor + ) + ) + } +} diff --git a/Workspace.swift b/Workspace.swift index eb37332..d2bc2de 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -13,6 +13,7 @@ let workspace = Workspace( "Projects/Core/Auth", "Projects/Core/Alarm", "Projects/Core/Coordinator", + "Projects/Core/LiveActivity", "Projects/DesignSystem", "Projects/Legacy", ]