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
16 changes: 15 additions & 1 deletion Projects/App/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ let appTarget = Target.target(
"NSLocationWhenInUseUsageDescription": "현재 위치를 출발지로 사용하기 위해 위치 정보 접근 권한이 필요합니다.",
// 사일런트 푸시(content-available=1) 수신용 — 사용자 알림 권한과 무관.
"UIBackgroundModes": ["remote-notification"],
"NSSupportsLiveActivities": true,
"UIApplicationSceneManifest": [
"UIApplicationSupportsMultipleScenes": false,
"UISceneConfigurations": [
Expand All @@ -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"),
Expand All @@ -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"),
Expand All @@ -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(
Expand All @@ -68,7 +82,7 @@ let project = Project(
developmentRegion: Atcha.developmentRegion
),
settings: .atchaV2(),
targets: [appTarget],
targets: [appTarget, widgetTarget],
schemes: [
.scheme(
name: "AtchaV2",
Expand Down
9 changes: 9 additions & 0 deletions Projects/App/Widget/Sources/AtchaWidgetBundle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import SwiftUI
import WidgetKit

@main
struct AtchaWidgetBundle: WidgetBundle {
var body: some Widget {
LastTrainLiveActivityWidget()
}
}
14 changes: 14 additions & 0 deletions Projects/App/Widget/Sources/DSColor+SwiftUI.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
26 changes: 26 additions & 0 deletions Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
4 changes: 4 additions & 0 deletions Projects/Core/LiveActivity/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: "CoreLiveActivity", bundleSuffix: "core.liveactivity", isolation: .nonisolated)
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
37 changes: 37 additions & 0 deletions Tuist/ProjectDescriptionHelpers/Target+WidgetExtension.swift
Original file line number Diff line number Diff line change
@@ -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
)
)
}
}
1 change: 1 addition & 0 deletions Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ let workspace = Workspace(
"Projects/Core/Auth",
"Projects/Core/Alarm",
"Projects/Core/Coordinator",
"Projects/Core/LiveActivity",
"Projects/DesignSystem",
"Projects/Legacy",
]
Expand Down
Loading