diff --git a/myApp/HaruDanim/Haru_DanimWidgets.entitlements b/myApp/HaruDanim/Haru_DanimWidgets.entitlements
new file mode 100644
index 0000000..64b9130
--- /dev/null
+++ b/myApp/HaruDanim/Haru_DanimWidgets.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.yechan.HaruDanim
+
+
+
diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift
index 90345b2..8101d43 100644
--- a/myApp/HaruDanim/IOS/ContentView.swift
+++ b/myApp/HaruDanim/IOS/ContentView.swift
@@ -15,7 +15,16 @@ struct ContentView: View {
var body: some View {
ZStack {
+ #if DEBUG
+ // 검증용: -widgetPreview YES(홈 위젯) / lock(잠금화면 위젯) → 위젯 미리보기 화면
+ if let mode = UserDefaults.standard.string(forKey: "widgetPreview") {
+ WidgetPreviewScreen(showLock: mode == "lock")
+ } else {
+ MainTabView()
+ }
+ #else
MainTabView()
+ #endif
if showSplash {
SplashView()
.transition(.opacity)
diff --git a/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift b/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift
new file mode 100644
index 0000000..7c0b258
--- /dev/null
+++ b/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift
@@ -0,0 +1,182 @@
+//
+// WidgetPreviewScreen.swift
+// Haru_Danim
+//
+// DEBUG 전용: 위젯 뷰들을 앱 안에서 실제 크기로 렌더링해 검증하는 화면.
+// 런치 인자 `-widgetPreview YES`(홈 위젯) / `-widgetPreview lock`(잠금화면)으로 진입.
+// (시뮬레이터 홈 화면에 위젯을 자동으로 추가할 방법이 없어 만든 검증 도구)
+//
+
+#if DEBUG
+import SwiftUI
+import WidgetKit
+
+struct WidgetPreviewScreen: View {
+ let showLock: Bool
+
+ @State private var actionEntry: ActionRunEntry?
+ @State private var goalBarsEntry: GoalBarsEntry?
+ @State private var questRingEntry: QuestRingEntry?
+ @State private var gridEntry: GoalQuestGridEntry?
+ @State private var statsEntrySmall: StatsChartEntry?
+ @State private var statsEntryMedium: StatsChartEntry?
+ @State private var lockEntries: [(String, LockGoalEntry)] = []
+
+ var body: some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ if showLock {
+ lockSection
+ } else {
+ homeSection
+ }
+ }
+ .padding()
+ }
+ .background(Color(white: 0.9))
+ .onAppear {
+ load()
+ // -widgetPreviewScroll B|C|D|S → 해당 섹션으로 자동 스크롤
+ if let anchor = UserDefaults.standard.string(forKey: "widgetPreviewScroll") {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
+ withAnimation { proxy.scrollTo(anchor, anchor: .top) }
+ }
+ }
+ }
+ }
+ }
+
+ private func load() {
+ // -premium 런치 인자가 PremiumGate 캐시에 반영되도록 매니저를 먼저 초기화
+ _ = PremiumManager.shared
+ if showLock {
+ var gauge = LockGoalConfigIntent()
+ gauge.style = .gauge
+ var number = LockGoalConfigIntent()
+ number.style = .number
+ var dots = LockGoalConfigIntent()
+ dots.style = .dots
+ lockEntries = [
+ ("gauge", LockGoalProvider.makeEntry(gauge)),
+ ("number", LockGoalProvider.makeEntry(number)),
+ ("dots", LockGoalProvider.makeEntry(dots)),
+ ]
+ } else {
+ actionEntry = ActionRunProvider.makeEntry(ActionRunConfigIntent())
+ goalBarsEntry = GoalBarsProvider.makeEntry(GoalBarsConfigIntent())
+ questRingEntry = QuestRingProvider.makeEntry(QuestRingConfigIntent())
+ gridEntry = GoalQuestGridProvider.makeEntry(GoalQuestGridConfigIntent())
+ statsEntrySmall = StatsChartProvider.makeEntry(StatsChartConfigIntent(), family: .systemSmall)
+ statsEntryMedium = StatsChartProvider.makeEntry(StatsChartConfigIntent(), family: .systemMedium)
+ }
+ }
+
+ // MARK: 홈 위젯
+
+ @ViewBuilder
+ private var homeSection: some View {
+ if let entry = actionEntry {
+ row("A 행동 실행 · 소형/중형") {
+ widgetBox(.systemSmall) { ActionRunWidgetView(previewFamily: .systemSmall, entry: entry) }
+ widgetBox(.systemMedium) { ActionRunWidgetView(previewFamily: .systemMedium, entry: entry) }
+ }
+ row("A 행동 실행 · 대형") {
+ widgetBox(.systemLarge) { ActionRunWidgetView(previewFamily: .systemLarge, entry: entry) }
+ }
+ }
+ if let entry = goalBarsEntry {
+ row("B 목표 진행률 · 소형/중형") {
+ widgetBox(.systemSmall) { GoalBarsWidgetView(previewFamily: .systemSmall, entry: entry) }
+ widgetBox(.systemMedium) { GoalBarsWidgetView(previewFamily: .systemMedium, entry: entry) }
+ }
+ .id("B")
+ }
+ if let entry = questRingEntry {
+ row("C 다짐 진행률 · 소형/중형") {
+ widgetBox(.systemSmall) { QuestRingWidgetView(previewFamily: .systemSmall, entry: entry) }
+ widgetBox(.systemMedium) { QuestRingWidgetView(previewFamily: .systemMedium, entry: entry) }
+ }
+ .id("C")
+ }
+ if let entry = gridEntry {
+ row("D 다짐 현황 · 소형/중형") {
+ widgetBox(.systemSmall) { GoalQuestGridWidgetView(previewFamily: .systemSmall, entry: entry) }
+ widgetBox(.systemMedium) { GoalQuestGridWidgetView(previewFamily: .systemMedium, entry: entry) }
+ }
+ .id("D")
+ row("D 다짐 현황 · 대형(목표 2개)") {
+ widgetBox(.systemLarge) { GoalQuestGridWidgetView(previewFamily: .systemLarge, entry: entry) }
+ }
+ }
+ if let small = statsEntrySmall, let medium = statsEntryMedium {
+ row("통계 · 소형/중형") {
+ widgetBox(.systemSmall) { StatsChartWidgetView(previewFamily: .systemSmall, entry: small) }
+ widgetBox(.systemMedium) { StatsChartWidgetView(previewFamily: .systemMedium, entry: medium) }
+ }
+ .id("S")
+ }
+ }
+
+ // MARK: 잠금화면 위젯
+
+ @ViewBuilder
+ private var lockSection: some View {
+ ForEach(lockEntries, id: \.0) { name, entry in
+ row("잠금화면 · \(name)") {
+ ZStack {
+ Color.black
+ LockGoalWidgetView(previewFamily: .accessoryCircular, entry: entry)
+ .foregroundStyle(.white)
+ }
+ .frame(width: 72, height: 72)
+ .clipShape(Circle())
+ ZStack {
+ Color.black
+ LockGoalWidgetView(previewFamily: .accessoryRectangular, entry: entry)
+ .foregroundStyle(.white)
+ .padding(6)
+ }
+ .frame(width: 172, height: 76)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ }
+ }
+ if let entry = lockEntries.first?.1 {
+ row("잠금화면 · inline") {
+ ZStack {
+ Color.black
+ LockGoalWidgetView(previewFamily: .accessoryInline, entry: entry)
+ .foregroundStyle(.white)
+ }
+ .frame(width: 230, height: 32)
+ .clipShape(Capsule())
+ }
+ }
+ }
+
+ // MARK: 레이아웃 헬퍼
+
+ private func row(_ title: String, @ViewBuilder content: () -> some View) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(title)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 12, content: content)
+ }
+ }
+
+ private func widgetBox(_ family: WidgetFamily, @ViewBuilder content: () -> some View) -> some View {
+ let size: CGSize = switch family {
+ case .systemMedium: CGSize(width: 338, height: 158)
+ case .systemLarge: CGSize(width: 338, height: 354)
+ default: CGSize(width: 158, height: 158)
+ }
+ return content()
+ .padding(12)
+ .frame(width: size.width, height: size.height)
+ .background(AppTheme.background)
+ .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+ .shadow(color: .black.opacity(0.08), radius: 6, y: 2)
+ }
+}
+#endif
diff --git a/myApp/HaruDanim/Widgets/ActionRunWidget.swift b/myApp/HaruDanim/Widgets/ActionRunWidget.swift
new file mode 100644
index 0000000..f1d5f08
--- /dev/null
+++ b/myApp/HaruDanim/Widgets/ActionRunWidget.swift
@@ -0,0 +1,202 @@
+//
+// ActionRunWidget.swift
+// Haru_DanimWidgets
+//
+// 행동 실행 및 표기 위젯 (CLAUDE.md §8.2 소형 A / 중형 A / 대형 A)
+// Interactive Widgets: 셀을 누르면 앱을 켜지 않고 즉시 실행 (시간형 토글 / 횟수형 +1)
+//
+
+import SwiftUI
+import WidgetKit
+import AppIntents
+
+// MARK: - 설정
+
+struct ActionRunConfigIntent: WidgetConfigurationIntent {
+ static let title: LocalizedStringResource = "행동 실행 위젯"
+ static let description = IntentDescription("표시할 행동과 누적 기간을 선택하세요.")
+
+ @Parameter(title: "표시 기간", default: .day)
+ var period: SpanOption
+
+ @Parameter(title: "행동 1")
+ var action1: ActionEntity?
+
+ @Parameter(title: "행동 2")
+ var action2: ActionEntity?
+
+ @Parameter(title: "행동 3")
+ var action3: ActionEntity?
+
+ @Parameter(title: "행동 4")
+ var action4: ActionEntity?
+}
+
+// MARK: - 타임라인
+
+struct ActionRunEntry: TimelineEntry {
+ let date: Date
+ let locked: Bool
+ let periodLabel: String
+ let cells: [ActionCellSnapshot]
+}
+
+struct ActionRunProvider: AppIntentTimelineProvider {
+ @MainActor
+ static func makeEntry(_ configuration: ActionRunConfigIntent) -> ActionRunEntry {
+ guard WidgetStore.isUnlocked else {
+ return ActionRunEntry(date: .now, locked: true, periodLabel: "", cells: [])
+ }
+ let span = configuration.period.statSpan
+ let chosen = [configuration.action1, configuration.action2, configuration.action3, configuration.action4]
+ .compactMap { $0 }
+ .compactMap { WidgetStore.action($0.id) }
+ let actions = chosen.isEmpty ? WidgetStore.defaultActions(4) : chosen
+ return ActionRunEntry(
+ date: .now,
+ locked: false,
+ periodLabel: configuration.period.label,
+ cells: actions.map { ActionCellSnapshot.make(action: $0, span: span) }
+ )
+ }
+
+ func placeholder(in context: Context) -> ActionRunEntry {
+ ActionRunEntry(date: .now, locked: false, periodLabel: "오늘", cells: [])
+ }
+
+ @MainActor
+ func snapshot(for configuration: ActionRunConfigIntent, in context: Context) async -> ActionRunEntry {
+ Self.makeEntry(configuration)
+ }
+
+ @MainActor
+ func timeline(for configuration: ActionRunConfigIntent, in context: Context) async -> Timeline {
+ // 값 자체는 인텐트 실행/앱 사용 시 reloadAllTimelines로 갱신되고,
+ // 날짜 경계를 넘길 때를 대비해 15분 주기로도 새로 고침
+ Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
+ }
+}
+
+// MARK: - 뷰
+
+struct ActionRunCellView: View {
+ let cell: ActionCellSnapshot
+ let periodLabel: String
+ var compact = false
+
+ var body: some View {
+ Button(intent: RunActionIntent(action: cell.entity)) {
+ VStack(alignment: .leading, spacing: compact ? 2 : 4) {
+ HStack {
+ Image(systemName: cell.symbolName)
+ .font(.system(size: compact ? 14 : 18, weight: .semibold))
+ Spacer()
+ if cell.isRunning {
+ Image(systemName: "record.circle")
+ .font(.system(size: compact ? 10 : 12, weight: .bold))
+ .foregroundStyle(AppTheme.yellow)
+ }
+ }
+ Spacer(minLength: 0)
+ Text(cell.name)
+ .font(compact ? .caption2.weight(.semibold) : .caption.weight(.semibold))
+ .lineLimit(1)
+ Group {
+ if cell.isCount {
+ Text("\(periodLabel) \(Int(cell.value))회")
+ } else if let base = cell.tickingBase {
+ // 측정 중: 누적 시간이 실시간으로 흐름
+ Text(base, style: .timer)
+ } else {
+ Text("\(periodLabel) \(Format.durationShort(cell.value))")
+ }
+ }
+ .font(compact ? .caption2.monospacedDigit() : .caption.monospacedDigit())
+ .opacity(0.85)
+ .lineLimit(1)
+ }
+ .foregroundStyle(.white)
+ .padding(compact ? 8 : 12)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .fill(cell.color)
+ )
+ .overlay {
+ if cell.isRunning {
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .strokeBorder(AppTheme.yellow, lineWidth: 2)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+struct ActionRunWidgetView: View {
+ @Environment(\.widgetFamily) private var envFamily
+ /// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
+ var previewFamily: WidgetFamily? = nil
+ private var family: WidgetFamily { previewFamily ?? envFamily }
+ let entry: ActionRunEntry
+
+ var body: some View {
+ Group {
+ if entry.locked {
+ WidgetLockedView()
+ } else if entry.cells.isEmpty {
+ Text("앱에서 행동을 만들면 여기서 실행할 수 있어요.")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ } else {
+ switch family {
+ case .systemMedium:
+ HStack(spacing: 8) {
+ ForEach(entry.cells.prefix(3)) { cell in
+ ActionRunCellView(cell: cell, periodLabel: entry.periodLabel, compact: true)
+ }
+ }
+ case .systemLarge:
+ let cells = Array(entry.cells.prefix(4))
+ VStack(spacing: 8) {
+ HStack(spacing: 8) {
+ ForEach(cells.prefix(2)) { cell in
+ ActionRunCellView(cell: cell, periodLabel: entry.periodLabel)
+ }
+ }
+ if cells.count > 2 {
+ HStack(spacing: 8) {
+ ForEach(cells.dropFirst(2)) { cell in
+ ActionRunCellView(cell: cell, periodLabel: entry.periodLabel)
+ }
+ }
+ }
+ }
+ default:
+ if let cell = entry.cells.first {
+ ActionRunCellView(cell: cell, periodLabel: entry.periodLabel)
+ }
+ }
+ }
+ }
+ .containerBackground(AppTheme.background, for: .widget)
+ }
+}
+
+// MARK: - 위젯 정의
+
+struct ActionRunWidget: Widget {
+ var body: some WidgetConfiguration {
+ AppIntentConfiguration(
+ kind: "HaruActionRunWidget",
+ intent: ActionRunConfigIntent.self,
+ provider: ActionRunProvider()
+ ) { entry in
+ ActionRunWidgetView(entry: entry)
+ }
+ .configurationDisplayName("행동 실행")
+ .description("행동을 위젯에서 바로 실행하고 누적값을 확인해요. (프리미엄)")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+ }
+}
diff --git a/myApp/HaruDanim/Widgets/GoalWidgets.swift b/myApp/HaruDanim/Widgets/GoalWidgets.swift
new file mode 100644
index 0000000..c336ee4
--- /dev/null
+++ b/myApp/HaruDanim/Widgets/GoalWidgets.swift
@@ -0,0 +1,324 @@
+//
+// GoalWidgets.swift
+// Haru_DanimWidgets
+//
+// 목표 진행률 위젯 (CLAUDE.md §8.2 소형/중형/대형 B, 소형/중형/대형 D)
+//
+
+import SwiftUI
+import WidgetKit
+import AppIntents
+
+// MARK: - B: 목표 진행바 위젯
+
+struct GoalBarsConfigIntent: WidgetConfigurationIntent {
+ static let title: LocalizedStringResource = "목표 진행률 위젯"
+ static let description = IntentDescription("하루·주간·월간 진행률을 표시할 목표를 선택하세요.")
+
+ @Parameter(title: "목표 1")
+ var goal1: GoalEntity?
+
+ @Parameter(title: "목표 2")
+ var goal2: GoalEntity?
+
+ @Parameter(title: "목표 3")
+ var goal3: GoalEntity?
+
+ @Parameter(title: "목표 4")
+ var goal4: GoalEntity?
+}
+
+struct GoalBarsEntry: TimelineEntry {
+ let date: Date
+ let locked: Bool
+ let goals: [GoalSnapshot]
+}
+
+struct GoalBarsProvider: AppIntentTimelineProvider {
+ @MainActor
+ static func makeEntry(_ configuration: GoalBarsConfigIntent) -> GoalBarsEntry {
+ guard WidgetStore.isUnlocked else {
+ return GoalBarsEntry(date: .now, locked: true, goals: [])
+ }
+ let chosen = [configuration.goal1, configuration.goal2, configuration.goal3, configuration.goal4]
+ .compactMap { $0 }
+ .compactMap { WidgetStore.goal($0.id) }
+ let goals = chosen.isEmpty ? WidgetStore.defaultGoals(4) : chosen
+ return GoalBarsEntry(date: .now, locked: false, goals: goals.map { GoalSnapshot.make(goal: $0) })
+ }
+
+ func placeholder(in context: Context) -> GoalBarsEntry {
+ GoalBarsEntry(date: .now, locked: false, goals: [])
+ }
+
+ @MainActor
+ func snapshot(for configuration: GoalBarsConfigIntent, in context: Context) async -> GoalBarsEntry {
+ Self.makeEntry(configuration)
+ }
+
+ @MainActor
+ func timeline(for configuration: GoalBarsConfigIntent, in context: Context) async -> Timeline {
+ Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
+ }
+}
+
+struct GoalBarsCellView: View {
+ let goal: GoalSnapshot
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 5) {
+ HStack(spacing: 5) {
+ Image(systemName: goal.symbolName)
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(goal.color)
+ Text(goal.title)
+ .font(.caption.weight(.semibold))
+ .lineLimit(1)
+ }
+ SpanBarRow(label: "하루", ratio: goal.dayRatio, color: goal.color)
+ SpanBarRow(label: "주간", ratio: goal.weekRatio, color: goal.color)
+ SpanBarRow(label: "월간", ratio: goal.monthRatio, color: goal.color)
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .background(
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .fill(AppTheme.surface)
+ )
+ }
+}
+
+struct GoalBarsWidgetView: View {
+ @Environment(\.widgetFamily) private var envFamily
+ /// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
+ var previewFamily: WidgetFamily? = nil
+ private var family: WidgetFamily { previewFamily ?? envFamily }
+ let entry: GoalBarsEntry
+
+ private var visibleCount: Int {
+ switch family {
+ case .systemMedium: return 2
+ case .systemLarge: return 4
+ default: return 1
+ }
+ }
+
+ var body: some View {
+ Group {
+ if entry.locked {
+ WidgetLockedView()
+ } else if entry.goals.isEmpty {
+ Text("앱의 목표 탭에서 목표를 만들면 진행률이 표시돼요.")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ } else {
+ let goals = Array(entry.goals.prefix(visibleCount))
+ switch family {
+ case .systemMedium:
+ HStack(spacing: 8) {
+ ForEach(goals) { GoalBarsCellView(goal: $0) }
+ }
+ case .systemLarge:
+ VStack(spacing: 8) {
+ HStack(spacing: 8) {
+ ForEach(goals.prefix(2)) { GoalBarsCellView(goal: $0) }
+ }
+ if goals.count > 2 {
+ HStack(spacing: 8) {
+ ForEach(goals.dropFirst(2)) { GoalBarsCellView(goal: $0) }
+ }
+ }
+ }
+ default:
+ if let goal = goals.first {
+ GoalBarsCellView(goal: goal)
+ }
+ }
+ }
+ }
+ .containerBackground(AppTheme.background, for: .widget)
+ }
+}
+
+struct GoalBarsWidget: Widget {
+ var body: some WidgetConfiguration {
+ AppIntentConfiguration(
+ kind: "HaruGoalBarsWidget",
+ intent: GoalBarsConfigIntent.self,
+ provider: GoalBarsProvider()
+ ) { entry in
+ GoalBarsWidgetView(entry: entry)
+ }
+ .configurationDisplayName("목표 진행률")
+ .description("목표의 하루·주간·월간 진행률을 진행바로 확인해요. (프리미엄)")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+ }
+}
+
+// MARK: - D: 목표의 다짐 링 그리드 위젯
+
+struct GoalQuestGridConfigIntent: WidgetConfigurationIntent {
+ static let title: LocalizedStringResource = "다짐 현황 위젯"
+ static let description = IntentDescription("다짐들의 진행률 링을 표시할 목표와 기간을 선택하세요.")
+
+ @Parameter(title: "목표")
+ var goal: GoalEntity?
+
+ @Parameter(title: "목표 2 (대형 전용)")
+ var secondGoal: GoalEntity?
+
+ @Parameter(title: "진행률 기간", default: .day)
+ var span: SpanOption
+}
+
+struct GoalQuestGridEntry: TimelineEntry {
+ let date: Date
+ let locked: Bool
+ let spanLabel: String
+ let goals: [GoalSnapshot]
+}
+
+struct GoalQuestGridProvider: AppIntentTimelineProvider {
+ @MainActor
+ static func makeEntry(_ configuration: GoalQuestGridConfigIntent) -> GoalQuestGridEntry {
+ guard WidgetStore.isUnlocked else {
+ return GoalQuestGridEntry(date: .now, locked: true, spanLabel: "", goals: [])
+ }
+ let span = configuration.span.statSpan
+ var goals: [Goal] = []
+ if let first = WidgetStore.goal(configuration.goal?.id) { goals.append(first) }
+ if let second = WidgetStore.goal(configuration.secondGoal?.id) { goals.append(second) }
+ if goals.isEmpty { goals = WidgetStore.defaultGoals(2) }
+ return GoalQuestGridEntry(
+ date: .now,
+ locked: false,
+ spanLabel: configuration.span.label,
+ goals: goals.map { GoalSnapshot.make(goal: $0, span: span) }
+ )
+ }
+
+ func placeholder(in context: Context) -> GoalQuestGridEntry {
+ GoalQuestGridEntry(date: .now, locked: false, spanLabel: "오늘", goals: [])
+ }
+
+ @MainActor
+ func snapshot(for configuration: GoalQuestGridConfigIntent, in context: Context) async -> GoalQuestGridEntry {
+ Self.makeEntry(configuration)
+ }
+
+ @MainActor
+ func timeline(for configuration: GoalQuestGridConfigIntent, in context: Context) async -> Timeline {
+ Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
+ }
+}
+
+struct QuestRingCellView: View {
+ let quest: QuestCellSnapshot
+ var ringSize: CGFloat = 34
+
+ var body: some View {
+ VStack(spacing: 3) {
+ QuestRingView(snapshot: quest, lineWidth: 4, iconSize: ringSize * 0.36)
+ .frame(width: ringSize, height: ringSize)
+ Text(quest.targetName)
+ .font(.system(size: 8, weight: .medium))
+ .lineLimit(1)
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+struct GoalQuestGridWidgetView: View {
+ @Environment(\.widgetFamily) private var envFamily
+ /// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
+ var previewFamily: WidgetFamily? = nil
+ private var family: WidgetFamily { previewFamily ?? envFamily }
+ let entry: GoalQuestGridEntry
+
+ var body: some View {
+ Group {
+ if entry.locked {
+ WidgetLockedView()
+ } else if entry.goals.isEmpty {
+ Text("앱의 목표 탭에서 목표와 다짐을 만들어 보세요.")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ } else {
+ switch family {
+ case .systemMedium:
+ // 중형 D: 1 x 4 나열, 4개 초과면 2줄로 최대 8개
+ if let goal = entry.goals.first {
+ goalSection(goal, maxCount: 8, columns: 4, ringSize: goal.quests.count > 4 ? 30 : 38)
+ }
+ case .systemLarge:
+ // 대형 D: 목표 2개를 2줄로, 혹은 1개 목표의 다짐 8개 크게
+ if entry.goals.count > 1 {
+ VStack(spacing: 10) {
+ ForEach(entry.goals.prefix(2)) { goal in
+ goalSection(goal, maxCount: 4, columns: 4, ringSize: 40)
+ }
+ }
+ } else if let goal = entry.goals.first {
+ goalSection(goal, maxCount: 8, columns: 4, ringSize: 44)
+ }
+ default:
+ // 소형 D: 2 x 2 링 + 최상단 목표 이름
+ if let goal = entry.goals.first {
+ goalSection(goal, maxCount: 4, columns: 2, ringSize: 32)
+ }
+ }
+ }
+ }
+ .containerBackground(AppTheme.background, for: .widget)
+ }
+
+ private func goalSection(_ goal: GoalSnapshot, maxCount: Int, columns: Int, ringSize: CGFloat) -> some View {
+ VStack(spacing: 6) {
+ HStack(spacing: 4) {
+ Image(systemName: goal.symbolName)
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(goal.color)
+ Text(goal.title)
+ .font(.caption2.weight(.semibold))
+ .lineLimit(1)
+ Spacer()
+ Text(entry.spanLabel)
+ .font(.system(size: 8))
+ .foregroundStyle(.tertiary)
+ }
+ if goal.quests.isEmpty {
+ Spacer()
+ Text("다짐이 없어요")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ Spacer()
+ } else {
+ let quests = Array(goal.quests.prefix(maxCount))
+ let grid = [GridItem](repeating: GridItem(.flexible(), spacing: 4), count: columns)
+ LazyVGrid(columns: grid, spacing: 6) {
+ ForEach(quests) { quest in
+ QuestRingCellView(quest: quest, ringSize: ringSize)
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ }
+ }
+}
+
+struct GoalQuestGridWidget: Widget {
+ var body: some WidgetConfiguration {
+ AppIntentConfiguration(
+ kind: "HaruGoalQuestGridWidget",
+ intent: GoalQuestGridConfigIntent.self,
+ provider: GoalQuestGridProvider()
+ ) { entry in
+ GoalQuestGridWidgetView(entry: entry)
+ }
+ .configurationDisplayName("다짐 현황")
+ .description("목표에 속한 다짐들의 진행률을 원형 링으로 한눈에 봐요. (프리미엄)")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+ }
+}
diff --git a/myApp/HaruDanim/Widgets/HaruDanimWidgets.swift b/myApp/HaruDanim/Widgets/HaruDanimWidgets.swift
index 3661f72..40ba8ec 100644
--- a/myApp/HaruDanim/Widgets/HaruDanimWidgets.swift
+++ b/myApp/HaruDanim/Widgets/HaruDanimWidgets.swift
@@ -13,6 +13,12 @@ import ActivityKit
struct HaruDanimWidgetsBundle: WidgetBundle {
var body: some Widget {
TrackingLiveActivity()
+ ActionRunWidget()
+ GoalBarsWidget()
+ QuestRingWidget()
+ GoalQuestGridWidget()
+ StatsChartWidget()
+ LockGoalWidget()
}
}
diff --git a/myApp/HaruDanim/Widgets/LockScreenWidgets.swift b/myApp/HaruDanim/Widgets/LockScreenWidgets.swift
new file mode 100644
index 0000000..43b1643
--- /dev/null
+++ b/myApp/HaruDanim/Widgets/LockScreenWidgets.swift
@@ -0,0 +1,215 @@
+//
+// LockScreenWidgets.swift
+// Haru_DanimWidgets
+//
+// 잠금화면 위젯 (CLAUDE.md §8.4) — 목표 1개의 달성률/다짐 현황을 간단하게
+// 표시 방식 옵션: 원형 게이지 / 숫자만 / 다짐 점 표시
+//
+
+import SwiftUI
+import WidgetKit
+import AppIntents
+
+// MARK: - 설정
+
+enum LockStyleOption: String, AppEnum {
+ case gauge
+ case number
+ case dots
+
+ static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "표시 방식")
+ static let caseDisplayRepresentations: [LockStyleOption: DisplayRepresentation] = [
+ .gauge: "원형 게이지",
+ .number: "숫자만",
+ .dots: "다짐 점 표시",
+ ]
+}
+
+struct LockGoalConfigIntent: WidgetConfigurationIntent {
+ static let title: LocalizedStringResource = "잠금화면 목표 위젯"
+ static let description = IntentDescription("잠금화면에 표시할 목표와 기간, 표시 방식을 선택하세요.")
+
+ @Parameter(title: "목표")
+ var goal: GoalEntity?
+
+ @Parameter(title: "달성률 기간", default: .day)
+ var span: SpanOption
+
+ @Parameter(title: "표시 방식 (원형 크기)", default: .gauge)
+ var style: LockStyleOption
+}
+
+// MARK: - 타임라인
+
+struct LockGoalEntry: TimelineEntry {
+ let date: Date
+ let locked: Bool
+ let spanLabel: String
+ let style: LockStyleOption
+ let goal: GoalSnapshot?
+ let ratio: Double
+}
+
+struct LockGoalProvider: AppIntentTimelineProvider {
+ @MainActor
+ static func makeEntry(_ configuration: LockGoalConfigIntent) -> LockGoalEntry {
+ guard PremiumGate.isUnlocked(.lockScreenWidgets) else {
+ return LockGoalEntry(date: .now, locked: true, spanLabel: "", style: .gauge, goal: nil, ratio: 0)
+ }
+ let span = configuration.span.statSpan
+ let model = WidgetStore.goal(configuration.goal?.id) ?? WidgetStore.defaultGoals(1).first
+ let snapshot = model.map { GoalSnapshot.make(goal: $0, span: span) }
+ return LockGoalEntry(
+ date: .now,
+ locked: false,
+ spanLabel: configuration.span.label,
+ style: configuration.style,
+ goal: snapshot,
+ ratio: snapshot?.ratio(for: span) ?? 0
+ )
+ }
+
+ func placeholder(in context: Context) -> LockGoalEntry {
+ LockGoalEntry(date: .now, locked: false, spanLabel: "오늘", style: .gauge, goal: nil, ratio: 0)
+ }
+
+ @MainActor
+ func snapshot(for configuration: LockGoalConfigIntent, in context: Context) async -> LockGoalEntry {
+ Self.makeEntry(configuration)
+ }
+
+ @MainActor
+ func timeline(for configuration: LockGoalConfigIntent, in context: Context) async -> Timeline {
+ Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
+ }
+}
+
+// MARK: - 뷰
+
+struct LockGoalWidgetView: View {
+ @Environment(\.widgetFamily) private var envFamily
+ /// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
+ var previewFamily: WidgetFamily? = nil
+ private var family: WidgetFamily { previewFamily ?? envFamily }
+ let entry: LockGoalEntry
+
+ var body: some View {
+ Group {
+ if entry.locked {
+ switch family {
+ case .accessoryInline:
+ Text("하루 다님 · 프리미엄 기능")
+ default:
+ VStack(spacing: 2) {
+ Image(systemName: "crown.fill")
+ Text("프리미엄")
+ .font(.caption2)
+ }
+ }
+ } else if let goal = entry.goal {
+ switch family {
+ case .accessoryInline:
+ // 한 줄: 아이콘 + 목표명 + %
+ Text("\(goal.title) \(Format.percent(entry.ratio))")
+ case .accessoryRectangular:
+ rectangularView(goal)
+ default:
+ circularView(goal)
+ }
+ } else {
+ switch family {
+ case .accessoryInline:
+ Text("하루 다님 · 목표 없음")
+ default:
+ VStack(spacing: 2) {
+ Image(systemName: "flag.checkered")
+ Text("목표 없음")
+ .font(.caption2)
+ }
+ }
+ }
+ }
+ .containerBackground(.clear, for: .widget)
+ }
+
+ @ViewBuilder
+ private func circularView(_ goal: GoalSnapshot) -> some View {
+ switch entry.style {
+ case .gauge:
+ Gauge(value: min(max(entry.ratio, 0), 1)) {
+ Image(systemName: goal.symbolName)
+ } currentValueLabel: {
+ Text("\(Int((entry.ratio * 100).rounded()))")
+ .font(.system(size: 14, weight: .bold).monospacedDigit())
+ }
+ .gaugeStyle(.accessoryCircular)
+ case .number:
+ VStack(spacing: 0) {
+ Text("\(Int((entry.ratio * 100).rounded()))%")
+ .font(.system(size: 16, weight: .bold).monospacedDigit())
+ Text(entry.spanLabel)
+ .font(.system(size: 9))
+ .foregroundStyle(.secondary)
+ }
+ case .dots:
+ // 다짐 달성 여부를 점으로 (최대 6개)
+ VStack(spacing: 3) {
+ Image(systemName: goal.symbolName)
+ .font(.system(size: 12, weight: .semibold))
+ questDots(goal, size: 6)
+ }
+ }
+ }
+
+ private func rectangularView(_ goal: GoalSnapshot) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 4) {
+ Image(systemName: goal.symbolName)
+ .font(.system(size: 11, weight: .semibold))
+ Text(goal.title)
+ .font(.caption.weight(.semibold))
+ .lineLimit(1)
+ }
+ Gauge(value: min(max(entry.ratio, 0), 1)) { EmptyView() }
+ .gaugeStyle(.accessoryLinearCapacity)
+ HStack {
+ Text("\(entry.spanLabel) \(Format.percent(entry.ratio))")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ Spacer()
+ if !goal.quests.isEmpty {
+ questDots(goal, size: 5)
+ }
+ }
+ }
+ }
+
+ /// 다짐 달성 여부 점 표시 (달성 = 채움)
+ private func questDots(_ goal: GoalSnapshot, size: CGFloat) -> some View {
+ HStack(spacing: 3) {
+ ForEach(goal.quests.prefix(6)) { quest in
+ Circle()
+ .strokeBorder(.primary, lineWidth: 1)
+ .background(Circle().fill(quest.isAchieved ? AnyShapeStyle(.primary) : AnyShapeStyle(.clear)))
+ .frame(width: size, height: size)
+ }
+ }
+ }
+}
+
+// MARK: - 위젯 정의
+
+struct LockGoalWidget: Widget {
+ var body: some WidgetConfiguration {
+ AppIntentConfiguration(
+ kind: "HaruLockGoalWidget",
+ intent: LockGoalConfigIntent.self,
+ provider: LockGoalProvider()
+ ) { entry in
+ LockGoalWidgetView(entry: entry)
+ }
+ .configurationDisplayName("목표 달성률 (잠금화면)")
+ .description("잠금화면에서 목표 달성률과 다짐 현황을 확인해요. (프리미엄)")
+ .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
+ }
+}
diff --git a/myApp/HaruDanim/Widgets/QuestRingWidget.swift b/myApp/HaruDanim/Widgets/QuestRingWidget.swift
new file mode 100644
index 0000000..0e30382
--- /dev/null
+++ b/myApp/HaruDanim/Widgets/QuestRingWidget.swift
@@ -0,0 +1,205 @@
+//
+// QuestRingWidget.swift
+// Haru_DanimWidgets
+//
+// 다짐 원형 진행률 위젯 (CLAUDE.md §8.2 소형 C / 중형 C / 대형 C)
+// 다짐 대상이 단일 행동이면 셀을 눌러 바로 실행 (Interactive)
+//
+
+import SwiftUI
+import WidgetKit
+import AppIntents
+
+// MARK: - 설정
+
+struct QuestRingConfigIntent: WidgetConfigurationIntent {
+ static let title: LocalizedStringResource = "다짐 진행률 위젯"
+ static let description = IntentDescription("원형 진행률로 표시할 다짐과 기간을 선택하세요.")
+
+ @Parameter(title: "진행률 기간", default: .day)
+ var span: SpanOption
+
+ @Parameter(title: "다짐 1")
+ var quest1: QuestEntity?
+
+ @Parameter(title: "다짐 2")
+ var quest2: QuestEntity?
+
+ @Parameter(title: "다짐 3")
+ var quest3: QuestEntity?
+
+ @Parameter(title: "다짐 4")
+ var quest4: QuestEntity?
+}
+
+// MARK: - 타임라인
+
+struct QuestRingEntry: TimelineEntry {
+ let date: Date
+ let locked: Bool
+ let spanLabel: String
+ let cells: [QuestCellSnapshot]
+}
+
+struct QuestRingProvider: AppIntentTimelineProvider {
+ @MainActor
+ static func makeEntry(_ configuration: QuestRingConfigIntent) -> QuestRingEntry {
+ guard WidgetStore.isUnlocked else {
+ return QuestRingEntry(date: .now, locked: true, spanLabel: "", cells: [])
+ }
+ let span = configuration.span.statSpan
+ var quests = [configuration.quest1, configuration.quest2, configuration.quest3, configuration.quest4]
+ .compactMap { $0 }
+ .compactMap { WidgetStore.quest($0.id) }
+ if quests.isEmpty {
+ quests = Array(WidgetStore.defaultGoals(1).flatMap(\.sortedQuests).prefix(4))
+ }
+ return QuestRingEntry(
+ date: .now,
+ locked: false,
+ spanLabel: configuration.span.label,
+ cells: quests.map { QuestCellSnapshot.make(quest: $0, span: span) }
+ )
+ }
+
+ func placeholder(in context: Context) -> QuestRingEntry {
+ QuestRingEntry(date: .now, locked: false, spanLabel: "오늘", cells: [])
+ }
+
+ @MainActor
+ func snapshot(for configuration: QuestRingConfigIntent, in context: Context) async -> QuestRingEntry {
+ Self.makeEntry(configuration)
+ }
+
+ @MainActor
+ func timeline(for configuration: QuestRingConfigIntent, in context: Context) async -> Timeline {
+ Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
+ }
+}
+
+// MARK: - 뷰
+
+struct QuestRingActionCellView: View {
+ let cell: QuestCellSnapshot
+ let spanLabel: String
+ var ringSize: CGFloat = 52
+
+ var body: some View {
+ if let target = cell.runTarget {
+ Button(intent: RunActionIntent(action: target)) {
+ content
+ }
+ .buttonStyle(.plain)
+ } else {
+ content
+ }
+ }
+
+ private var content: some View {
+ VStack(spacing: 4) {
+ ZStack {
+ QuestRingView(snapshot: cell, lineWidth: 5, iconSize: ringSize * 0.34)
+ .frame(width: ringSize, height: ringSize)
+ if cell.isRunning {
+ Circle()
+ .fill(AppTheme.yellow)
+ .frame(width: 9, height: 9)
+ .offset(x: ringSize * 0.38, y: -ringSize * 0.38)
+ }
+ }
+ Text(cell.targetName)
+ .font(.caption2.weight(.semibold))
+ .lineLimit(1)
+ Text(percentText)
+ .font(.caption2.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(6)
+ .background(
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .fill(AppTheme.surface)
+ )
+ }
+
+ private var percentText: String {
+ if cell.isAtMost {
+ return cell.isAchieved ? "\(spanLabel) 한도 지킴" : "\(spanLabel) 한도 초과"
+ }
+ return "\(spanLabel) \(Format.percent(cell.displayRatio))"
+ }
+}
+
+struct QuestRingWidgetView: View {
+ @Environment(\.widgetFamily) private var envFamily
+ /// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
+ var previewFamily: WidgetFamily? = nil
+ private var family: WidgetFamily { previewFamily ?? envFamily }
+ let entry: QuestRingEntry
+
+ private var visibleCount: Int {
+ switch family {
+ case .systemMedium: return 2
+ case .systemLarge: return 4
+ default: return 1
+ }
+ }
+
+ var body: some View {
+ Group {
+ if entry.locked {
+ WidgetLockedView()
+ } else if entry.cells.isEmpty {
+ Text("앱의 목표 탭에서 다짐을 만들면 진행률이 표시돼요.")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ } else {
+ let cells = Array(entry.cells.prefix(visibleCount))
+ switch family {
+ case .systemMedium:
+ HStack(spacing: 8) {
+ ForEach(cells) { QuestRingActionCellView(cell: $0, spanLabel: entry.spanLabel) }
+ }
+ case .systemLarge:
+ VStack(spacing: 8) {
+ HStack(spacing: 8) {
+ ForEach(cells.prefix(2)) {
+ QuestRingActionCellView(cell: $0, spanLabel: entry.spanLabel, ringSize: 60)
+ }
+ }
+ if cells.count > 2 {
+ HStack(spacing: 8) {
+ ForEach(cells.dropFirst(2)) {
+ QuestRingActionCellView(cell: $0, spanLabel: entry.spanLabel, ringSize: 60)
+ }
+ }
+ }
+ }
+ default:
+ if let cell = cells.first {
+ QuestRingActionCellView(cell: cell, spanLabel: entry.spanLabel, ringSize: 58)
+ }
+ }
+ }
+ }
+ .containerBackground(AppTheme.background, for: .widget)
+ }
+}
+
+// MARK: - 위젯 정의
+
+struct QuestRingWidget: Widget {
+ var body: some WidgetConfiguration {
+ AppIntentConfiguration(
+ kind: "HaruQuestRingWidget",
+ intent: QuestRingConfigIntent.self,
+ provider: QuestRingProvider()
+ ) { entry in
+ QuestRingWidgetView(entry: entry)
+ }
+ .configurationDisplayName("다짐 진행률")
+ .description("다짐의 진행률을 원형 링으로 보고, 눌러서 바로 실행해요. (프리미엄)")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+ }
+}
diff --git a/myApp/HaruDanim/Widgets/StatsChartWidget.swift b/myApp/HaruDanim/Widgets/StatsChartWidget.swift
new file mode 100644
index 0000000..60c47b2
--- /dev/null
+++ b/myApp/HaruDanim/Widgets/StatsChartWidget.swift
@@ -0,0 +1,248 @@
+//
+// StatsChartWidget.swift
+// Haru_DanimWidgets
+//
+// 통계 위젯 (CLAUDE.md §8.3) — 선택한 행동들의 꺾은선 그래프
+// 앱 통계 탭과 동일: 선 색 = 행동 색, 시간형은 h 단위, 횟수형은 회
+//
+
+import SwiftUI
+import WidgetKit
+import AppIntents
+import Charts
+
+// MARK: - 설정
+
+enum StatsChartSpan: String, AppEnum {
+ case week
+ case monthByDay
+ case monthByWeek
+
+ static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "통계 기간")
+ static let caseDisplayRepresentations: [StatsChartSpan: DisplayRepresentation] = [
+ .week: "일주일 (하루 단위)",
+ .monthByDay: "한 달 (하루 단위, 중형 이상)",
+ .monthByWeek: "한 달 (주 단위)",
+ ]
+
+ var title: String {
+ switch self {
+ case .week: return "일주일 통계"
+ case .monthByDay: return "한 달 통계 (일별)"
+ case .monthByWeek: return "한 달 통계 (주별)"
+ }
+ }
+}
+
+struct StatsChartConfigIntent: WidgetConfigurationIntent {
+ static let title: LocalizedStringResource = "통계 위젯"
+ static let description = IntentDescription("그래프로 보여줄 행동과 기간을 선택하세요.")
+
+ @Parameter(title: "통계 기간", default: .week)
+ var span: StatsChartSpan
+
+ @Parameter(title: "행동 1")
+ var action1: ActionEntity?
+
+ @Parameter(title: "행동 2")
+ var action2: ActionEntity?
+
+ @Parameter(title: "행동 3")
+ var action3: ActionEntity?
+}
+
+// MARK: - 타임라인
+
+struct StatsPoint: Identifiable {
+ let id = UUID()
+ /// 하루 단위 그래프의 x축 (일별)
+ let day: Date?
+ /// 주 단위 그래프의 x축 ("1주차" 등)
+ let weekLabel: String?
+ let actionName: String
+ let value: Double
+}
+
+struct StatsChartEntry: TimelineEntry {
+ let date: Date
+ let locked: Bool
+ let title: String
+ let isWeekAxis: Bool
+ let isTimeType: Bool
+ let names: [String]
+ let colorHexes: [String]
+ let points: [StatsPoint]
+}
+
+struct StatsChartProvider: AppIntentTimelineProvider {
+ @MainActor
+ static func makeEntry(_ configuration: StatsChartConfigIntent, family: WidgetFamily) -> StatsChartEntry {
+ guard WidgetStore.isUnlocked else {
+ return StatsChartEntry(date: .now, locked: true, title: "", isWeekAxis: false,
+ isTimeType: true, names: [], colorHexes: [], points: [])
+ }
+ // 소형은 "한 달 (하루 단위)" 제외 (CLAUDE.md §8.3) → 주 단위로 대체
+ var span = configuration.span
+ if family == .systemSmall && span == .monthByDay { span = .monthByWeek }
+
+ let chosen = [configuration.action1, configuration.action2, configuration.action3]
+ .compactMap { $0 }
+ .compactMap { WidgetStore.action($0.id) }
+ let actions = chosen.isEmpty ? WidgetStore.defaultActions(3) : chosen
+ let isTimeType = !actions.contains { $0.trackingType == .count }
+
+ let math = DayMath()
+ let agg = Aggregator(math: math)
+ let now = Date.now
+ var points: [StatsPoint] = []
+
+ func value(_ action: Action, in range: Range) -> Double {
+ switch action.trackingType {
+ case .time: return agg.seconds(for: action, in: range, now: now) / 3600
+ case .count: return Double(agg.count(for: action, in: range))
+ }
+ }
+
+ switch span {
+ case .week, .monthByDay:
+ let range = span == .week ? math.weekRange(containing: now) : math.monthRange(containing: now)
+ for key in math.dayKeys(in: range) {
+ let dayRange = math.dayRange(forKey: key)
+ for action in actions {
+ points.append(StatsPoint(day: key, weekLabel: nil, actionName: action.name,
+ value: value(action, in: dayRange)))
+ }
+ }
+ case .monthByWeek:
+ let month = math.monthRange(containing: now)
+ var cursor = month.lowerBound
+ var index = 1
+ while cursor < month.upperBound {
+ let week = math.weekRange(containing: cursor)
+ let clipped = max(week.lowerBound, month.lowerBound).. StatsChartEntry {
+ StatsChartEntry(date: .now, locked: false, title: "일주일 통계", isWeekAxis: false,
+ isTimeType: true, names: [], colorHexes: [], points: [])
+ }
+
+ @MainActor
+ func snapshot(for configuration: StatsChartConfigIntent, in context: Context) async -> StatsChartEntry {
+ Self.makeEntry(configuration, family: context.family)
+ }
+
+ @MainActor
+ func timeline(for configuration: StatsChartConfigIntent, in context: Context) async -> Timeline {
+ Timeline(entries: [Self.makeEntry(configuration, family: context.family)],
+ policy: .after(.now.addingTimeInterval(30 * 60)))
+ }
+}
+
+// MARK: - 뷰
+
+struct StatsChartWidgetView: View {
+ @Environment(\.widgetFamily) private var envFamily
+ /// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
+ var previewFamily: WidgetFamily? = nil
+ private var family: WidgetFamily { previewFamily ?? envFamily }
+ let entry: StatsChartEntry
+
+ var body: some View {
+ Group {
+ if entry.locked {
+ WidgetLockedView()
+ } else if entry.points.isEmpty {
+ Text("앱에서 행동을 만들고 기록하면 그래프가 표시돼요.")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ } else {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(entry.title)
+ .font(.caption2.weight(.semibold))
+ .foregroundStyle(.secondary)
+ chart
+ }
+ }
+ }
+ .containerBackground(AppTheme.background, for: .widget)
+ }
+
+ private var colors: [Color] {
+ entry.colorHexes.map { Color(hex: $0) }
+ }
+
+ @ViewBuilder
+ private var chart: some View {
+ Chart(entry.points) { point in
+ if let day = point.day {
+ LineMark(
+ x: .value("날짜", day, unit: .day),
+ y: .value(entry.isTimeType ? "시간" : "값", point.value)
+ )
+ .foregroundStyle(by: .value("행동", point.actionName))
+ .interpolationMethod(.monotone)
+ } else if let weekLabel = point.weekLabel {
+ LineMark(
+ x: .value("주차", weekLabel),
+ y: .value(entry.isTimeType ? "시간" : "값", point.value)
+ )
+ .foregroundStyle(by: .value("행동", point.actionName))
+ .interpolationMethod(.monotone)
+ }
+ }
+ .chartForegroundStyleScale(domain: entry.names, range: colors)
+ .chartLegend(family == .systemSmall ? .hidden : .visible)
+ .chartYAxisLabel(family == .systemSmall ? "" : (entry.isTimeType ? "시간(h)" : "횟수"))
+ .chartXAxis {
+ if entry.isWeekAxis {
+ AxisMarks { _ in
+ AxisGridLine()
+ AxisValueLabel()
+ }
+ } else {
+ AxisMarks(values: .stride(by: .day, count: family == .systemSmall ? 2 : 1)) { value in
+ AxisGridLine()
+ AxisValueLabel(format: .dateTime.day(), centered: false)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - 위젯 정의
+
+struct StatsChartWidget: Widget {
+ var body: some WidgetConfiguration {
+ AppIntentConfiguration(
+ kind: "HaruStatsChartWidget",
+ intent: StatsChartConfigIntent.self,
+ provider: StatsChartProvider()
+ ) { entry in
+ StatsChartWidgetView(entry: entry)
+ }
+ .configurationDisplayName("행동 통계")
+ .description("선택한 행동들의 통계 꺾은선 그래프를 확인해요. (프리미엄)")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+ }
+}
diff --git a/myApp/HaruDanim/Widgets/WidgetSupport.swift b/myApp/HaruDanim/Widgets/WidgetSupport.swift
new file mode 100644
index 0000000..3e7b903
--- /dev/null
+++ b/myApp/HaruDanim/Widgets/WidgetSupport.swift
@@ -0,0 +1,238 @@
+//
+// WidgetSupport.swift
+// Haru_DanimWidgets
+//
+// 홈/잠금화면 위젯 공용 인프라 (CLAUDE.md §8)
+// - 타임라인 시점에 SwiftData(App Group DB)에서 읽어 만든 스냅숏 구조체
+// - 프리미엄 잠금 표시, 원형/막대 게이지 등 공용 뷰
+//
+
+import SwiftUI
+import SwiftData
+import WidgetKit
+
+// MARK: - 스냅숏 모델
+
+/// 행동 실행 셀 스냅숏 (소형/중형/대형 A)
+struct ActionCellSnapshot: Identifiable {
+ let id: UUID
+ let name: String
+ let symbolName: String
+ let colorHex: String
+ let isCount: Bool
+ let isRunning: Bool
+ /// 표시 기간 내 누적값 (시간=초, 횟수=회). 진행 중 세션 포함(스냅숏 시점 기준).
+ let value: Double
+ /// 진행 중일 때 누적 시간이 실시간으로 흐르도록 하는 기준 시각 (now - 누적초)
+ let tickingBase: Date?
+
+ var color: Color { Color(hex: colorHex) }
+
+ var entity: ActionEntity {
+ ActionEntity(
+ id: id, name: name, symbolName: symbolName,
+ trackingTypeRaw: isCount ? TrackingType.count.rawValue : TrackingType.time.rawValue
+ )
+ }
+
+ @MainActor
+ static func make(action: Action, span: StatSpan, now: Date = .now) -> ActionCellSnapshot {
+ let math = DayMath()
+ let range: Range = switch span {
+ case .day: math.dayRange(containing: now)
+ case .week: math.weekRange(containing: now)
+ case .month: math.monthRange(containing: now)
+ }
+ let agg = Aggregator(math: math)
+ let isCount = action.trackingType == .count
+ let value: Double = isCount
+ ? Double(agg.count(for: action, in: range))
+ : agg.seconds(for: action, in: range, now: now)
+ let running = action.isRunning
+ return ActionCellSnapshot(
+ id: action.uuid,
+ name: action.name,
+ symbolName: action.symbolName,
+ colorHex: action.tags.sorted { $0.createdAt < $1.createdAt }.first?.colorHex ?? "#2F6B4F",
+ isCount: isCount,
+ isRunning: running,
+ value: value,
+ tickingBase: running && !isCount ? now.addingTimeInterval(-value) : nil
+ )
+ }
+}
+
+/// 다짐 진행률 셀 스냅숏 (소형/중형/대형 C·D, 컴플리케이션)
+struct QuestCellSnapshot: Identifiable {
+ let id: UUID
+ let targetName: String
+ let symbolName: String
+ let colorHex: String
+ /// 게이지 비율 0...1 ('이하 유지'는 100% 또는 0%)
+ let ratio: Double
+ /// 퍼센트 문구용 (이상 달성은 100% 초과 가능)
+ let displayRatio: Double
+ let isAtMost: Bool
+ let isAchieved: Bool
+ /// 실행 버튼 대상 (단일 행동 대상 다짐만)
+ let runTarget: ActionEntity?
+ let isRunning: Bool
+
+ var color: Color { Color(hex: colorHex) }
+
+ @MainActor
+ static func make(quest: Quest, span: StatSpan, now: Date = .now) -> QuestCellSnapshot {
+ let result = QuestProgress(quest: quest).spanProgress(span, now: now)
+ let action = quest.targetAction
+ return QuestCellSnapshot(
+ id: quest.uuid,
+ targetName: quest.targetName,
+ symbolName: quest.targetSymbol,
+ colorHex: action?.tags.sorted { $0.createdAt < $1.createdAt }.first?.colorHex
+ ?? quest.targetTag?.colorHex ?? "#2F6B4F",
+ ratio: result.ratio,
+ displayRatio: result.displayRatio,
+ isAtMost: quest.direction == .atMost,
+ isAchieved: result.isAchieved,
+ runTarget: action.map(ActionEntity.init),
+ isRunning: action?.isRunning ?? false
+ )
+ }
+}
+
+/// 목표 진행률 스냅숏 (소형/중형/대형 B, 잠금화면, 컴플리케이션)
+struct GoalSnapshot: Identifiable {
+ let id: UUID
+ let title: String
+ let symbolName: String
+ let colorHex: String
+ /// 하루/주간/월간 진행률 (다짐 평균, 0...1)
+ let dayRatio: Double
+ let weekRatio: Double
+ let monthRatio: Double
+ let quests: [QuestCellSnapshot]
+
+ var color: Color { Color(hex: colorHex) }
+
+ func ratio(for span: StatSpan) -> Double {
+ switch span {
+ case .day: return dayRatio
+ case .week: return weekRatio
+ case .month: return monthRatio
+ }
+ }
+
+ @MainActor
+ static func make(goal: Goal, span: StatSpan = .day, now: Date = .now) -> GoalSnapshot {
+ GoalSnapshot(
+ id: goal.uuid,
+ title: goal.title,
+ symbolName: goal.symbolName,
+ colorHex: goal.colorHex,
+ dayRatio: goal.combinedSpanRatio(.day, now: now),
+ weekRatio: goal.combinedSpanRatio(.week, now: now),
+ monthRatio: goal.combinedSpanRatio(.month, now: now),
+ quests: goal.sortedQuests.map { QuestCellSnapshot.make(quest: $0, span: span, now: now) }
+ )
+ }
+}
+
+// MARK: - 프리미엄 잠금
+
+struct WidgetLockedView: View {
+ var body: some View {
+ VStack(spacing: 6) {
+ Image(systemName: "crown.fill")
+ .font(.title3)
+ .foregroundStyle(AppTheme.yellow)
+ Text("프리미엄 기능")
+ .font(.caption.weight(.semibold))
+ Text("앱의 설정 → 프리미엄에서\n잠금 해제해 주세요")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ }
+}
+
+// MARK: - 공용 게이지
+
+/// 원형 진행바 + 가운데 아이콘 (소형 C·D)
+struct QuestRingView: View {
+ let snapshot: QuestCellSnapshot
+ var lineWidth: CGFloat = 5
+ var iconSize: CGFloat = 15
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .stroke(snapshot.color.opacity(0.2), lineWidth: lineWidth)
+ Circle()
+ .trim(from: 0, to: min(max(snapshot.ratio, 0), 1))
+ .stroke(
+ snapshot.isAtMost && !snapshot.isAchieved ? Color.red : snapshot.color,
+ style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
+ )
+ .rotationEffect(.degrees(-90))
+ Image(systemName: snapshot.symbolName)
+ .font(.system(size: iconSize, weight: .semibold))
+ .foregroundStyle(snapshot.color)
+ }
+ }
+}
+
+/// 가로 진행바 한 줄 (소형 B)
+struct SpanBarRow: View {
+ let label: String
+ let ratio: Double
+ let color: Color
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack {
+ Text(label)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text(Format.percent(ratio))
+ .font(.caption2.weight(.semibold).monospacedDigit())
+ }
+ ProgressView(value: min(max(ratio, 0), 1))
+ .progressViewStyle(.linear)
+ .tint(color)
+ }
+ }
+}
+
+// MARK: - 데이터 로드 헬퍼
+
+@MainActor
+enum WidgetStore {
+ static var isUnlocked: Bool { PremiumGate.isUnlocked(.homeWidgets) }
+
+ static func action(_ id: UUID?) -> Action? {
+ guard let id else { return nil }
+ return IntentStore.actions().first { $0.uuid == id }
+ }
+
+ static func goal(_ id: UUID?) -> Goal? {
+ guard let id else { return nil }
+ return IntentStore.goals().first { $0.uuid == id }
+ }
+
+ static func quest(_ id: UUID?) -> Quest? {
+ guard let id else { return nil }
+ return IntentStore.quests().first { $0.uuid == id }
+ }
+
+ /// 설정에서 아직 선택하지 않았을 때의 기본 대상
+ static func defaultActions(_ count: Int) -> [Action] {
+ Array(IntentStore.actions().prefix(count))
+ }
+
+ static func defaultGoals(_ count: Int) -> [Goal] {
+ let goals = IntentStore.goals()
+ let active = goals.filter { $0.status == .inProgress }
+ return Array((active.isEmpty ? goals : active).prefix(count))
+ }
+}