// // WidgetSupport.swift // Haru_DanimWidgets // // 홈/잠금화면 위젯 공용 인프라 (CLAUDE.md §8) // - 타임라인 시점에 SwiftData(App Group DB)에서 읽어 만든 스냅숏 구조체 // - 타임라인 갱신 전략: 값이 실제로 바뀌는 순간(앱 기록·인텐트·원격 동기화)에는 // 앱과 인텐트가 reloadAllTimelines를 호출하므로 주기 폴링을 하지 않는다. // 측정 중일 때만 미리 계산한 미래 엔트리로 진행률을 따라간다 (WidgetKit 예산 절약). // - 프리미엄 잠금 표시, 원형/막대 게이지, 갤러리 미리보기 샘플 등 공용 뷰/데이터 // import SwiftUI import SwiftData import WidgetKit // MARK: - 타임라인 갱신 전략 enum WidgetRefresh { /// 예전의 "15분마다 .after" 정책은 위젯 6종이 하루 종일 리로드 예산을 태워 /// 예산 소진 후 갱신이 멈추는 원인이었다. 지금은: /// - 측정 중(live): 10분 간격 미래 엔트리 1시간치를 미리 계산해 넣고 .atEnd /// (타임라인 1개 안의 엔트리 전환은 예산을 쓰지 않음) /// - 평상시: 다음 하루 경계(최대 4시간 안전망)까지 대기 — 값 변경은 명시적 리로드가 담당 static func timeline(first: E, live: Bool, make: (Date) -> E) -> Timeline { let now = first.date if live { let future = stride(from: 600, through: 3600, by: 600) .map { make(now.addingTimeInterval(Double($0))) } return Timeline(entries: [first] + future, policy: .atEnd) } let boundary = DayMath().dayRange(containing: now).upperBound return Timeline(entries: [first], policy: .after(min(boundary, now.addingTimeInterval(4 * 3600)))) } } // 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: - 갤러리 미리보기 샘플 /// 위젯 갤러리(placeholder/preview)에서 실데이터가 없거나 잠겨 있어도 /// 위젯이 어떤 모습인지 보이도록 하는 샘플 스냅숏 extension ActionCellSnapshot { static let samples: [ActionCellSnapshot] = [ ActionCellSnapshot(id: UUID(), name: String(localized: "독서"), symbolName: "book.fill", colorHex: "#2F6B4F", isCount: false, isRunning: true, value: 45 * 60, tickingBase: nil), ActionCellSnapshot(id: UUID(), name: String(localized: "달리기"), symbolName: "figure.run", colorHex: "#9D5B4A", isCount: false, isRunning: false, value: 30 * 60, tickingBase: nil), ActionCellSnapshot(id: UUID(), name: String(localized: "물 마시기"), symbolName: "drop.fill", colorHex: "#4A7A9D", isCount: true, isRunning: false, value: 5, tickingBase: nil), ActionCellSnapshot(id: UUID(), name: String(localized: "팔굽혀펴기"), symbolName: "dumbbell.fill", colorHex: "#7A5C9D", isCount: true, isRunning: false, value: 20, tickingBase: nil), ] } extension QuestCellSnapshot { static let samples: [QuestCellSnapshot] = [ QuestCellSnapshot(id: UUID(), targetName: String(localized: "독서"), symbolName: "book.fill", colorHex: "#2F6B4F", ratio: 0.75, displayRatio: 0.75, isAtMost: false, isAchieved: false, runTarget: nil, isRunning: true), QuestCellSnapshot(id: UUID(), targetName: String(localized: "달리기"), symbolName: "figure.run", colorHex: "#9D5B4A", ratio: 0.4, displayRatio: 0.4, isAtMost: false, isAchieved: false, runTarget: nil, isRunning: false), QuestCellSnapshot(id: UUID(), targetName: String(localized: "물 마시기"), symbolName: "drop.fill", colorHex: "#4A7A9D", ratio: 1, displayRatio: 1.1, isAtMost: false, isAchieved: true, runTarget: nil, isRunning: false), QuestCellSnapshot(id: UUID(), targetName: String(localized: "영상 시청"), symbolName: "play.rectangle.fill", colorHex: "#7A5C9D", ratio: 1, displayRatio: 1, isAtMost: true, isAchieved: true, runTarget: nil, isRunning: false), ] } extension GoalSnapshot { static let sample = GoalSnapshot( id: UUID(), title: String(localized: "건강한 생활 습관"), symbolName: "heart.fill", colorHex: "#2F6B4F", dayRatio: 0.62, weekRatio: 0.48, monthRatio: 0.7, quests: QuestCellSnapshot.samples ) } // MARK: - 빈 상태 struct WidgetEmptyView: View { let symbolName: String let message: String var body: some View { VStack(spacing: 6) { Image(systemName: symbolName) .font(.title3) .foregroundStyle(AppTheme.green) Text(message) .font(.caption2) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .padding(12) } } // 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)) } }