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)) + } +}