// // 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) } @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 /// 하루 span인데 오늘이 수행일이 아님(요일·날짜 다짐의 쉬는 날, 기간 밖) — /// 링·게이지는 0으로 비우고 퍼센트 대신 '수행일 아님'을 표기한다 (③ 문구, ②·④는 흐림) let isRestDay: Bool /// 실행 버튼 대상 (단일 행동 대상 다짐만) let runTarget: ActionEntity? let isRunning: Bool /// 연속 달성 문구 ("연속 3일" 등). 1 미만이거나 특정 기간 다짐이면 nil (표시 생략) let streakLabel: String? /// span 구간의 실제 누적값 문구 ("1시간 20분" / "3회") — ③ 다짐 진행률 위젯에서 /// 퍼센트(또는 '한도 지킴') 문구 아래에 표시. 특히 '이하 유지'는 퍼센트가 없어 /// 이 값이 없으면 지금 얼마나 했는지 알 수 없다. 진행률 캡(주간·월간 하루 기여 제한)과 /// 무관한 원본 누적값 (단, 마감 시각 다짐은 그날 집계 창까지의 값 — spanRawValue). let valueLabel: String var color: Color { Color(hex: colorHex) } @MainActor static func make(quest: Quest, span: StatSpan, now: Date = .now) -> QuestCellSnapshot { let progress = QuestProgress(quest: quest) // 하루 span인데 오늘이 수행일이 아니면 수치 대신 상태를 보여 준다 — // 쉬는 날의 기록으로 링이 차 있으면 "오늘 채웠다"는 오해를 유발 (주/월 집계에도 안 잡히는 값) let isRestDay = span == .day && !progress.isScheduled(on: now) let result = isRestDay ? QuestProgressResult(value: 0, target: quest.targetValue, direction: quest.direction) : progress.spanProgress(span, now: now) let streak = progress.streak(now: now) let action = quest.targetAction // 마감 시각 다짐은 집계 창(마감까지) 안의 값만 — 게이지와 라벨이 모순되지 않게 let rawValue = progress.spanRawValue(span, now: now) let valueLabel: String switch quest.measure { case .time: valueLabel = rawValue < 60 ? String(localized: "0분") : Format.durationShort(rawValue) case .count: valueLabel = String(localized: "\(Int(rawValue))회") } 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: isRestDay ? 0 : result.ratio, displayRatio: isRestDay ? 0 : result.displayRatio, isAtMost: quest.direction == .atMost, isAchieved: isRestDay ? false : result.isAchieved, isRestDay: isRestDay, runTarget: action.map(ActionEntity.init), isRunning: action?.isRunning ?? false, streakLabel: (streak?.count ?? 0) > 0 ? streak?.label : nil, valueLabel: valueLabel ) } } /// 목표 진행률 스냅숏 (소형/중형/대형 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, isRestDay: false, runTarget: nil, isRunning: true, streakLabel: QuestStreakInfo(count: 5, unit: .day).label, valueLabel: Format.durationShort(45 * 60)), QuestCellSnapshot(id: UUID(), targetName: String(localized: "달리기"), symbolName: "figure.run", colorHex: "#9D5B4A", ratio: 0.4, displayRatio: 0.4, isAtMost: false, isAchieved: false, isRestDay: false, runTarget: nil, isRunning: false, streakLabel: nil, valueLabel: Format.durationShort(12 * 60)), QuestCellSnapshot(id: UUID(), targetName: String(localized: "물 마시기"), symbolName: "drop.fill", colorHex: "#4A7A9D", ratio: 1, displayRatio: 1.1, isAtMost: false, isAchieved: true, isRestDay: false, runTarget: nil, isRunning: false, streakLabel: QuestStreakInfo(count: 12, unit: .day).label, valueLabel: String(localized: "\(9)회")), QuestCellSnapshot(id: UUID(), targetName: String(localized: "영상 시청"), symbolName: "play.rectangle.fill", colorHex: "#7A5C9D", ratio: 1, displayRatio: 1, isAtMost: true, isAchieved: true, isRestDay: false, runTarget: nil, isRunning: false, streakLabel: nil, valueLabel: Format.durationShort(40 * 60)), ] } 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)) } // MARK: 다중 선택 파라미터 해석 // 설정에서 아무것도 고르지 않았으면(nil/빈 배열) 기본 대상으로 채우고, // 골랐다면 그 선택을 순서 그대로 존중한다 (모자란 슬롯은 뷰가 빈 칸으로 표시). // '선택 안 함' 센티널(NoneEntityID)과 삭제된 모델은 조회 단계에서 자연히 걸러진다. static func selectedActions(_ entities: [ActionEntity]?, defaultCount: Int) -> [Action] { guard let entities, !entities.isEmpty else { return defaultActions(defaultCount) } return entities.compactMap { action($0.id) } } static func selectedGoals(_ entities: [GoalEntity]?, defaultCount: Int) -> [Goal] { guard let entities, !entities.isEmpty else { return defaultGoals(defaultCount) } return entities.compactMap { goal($0.id) } } static func selectedQuests(_ entities: [QuestEntity]?, defaultCount: Int) -> [Quest] { guard let entities, !entities.isEmpty else { // 기본: 진행 중 기본 목표의 다짐부터, 모자라면 나머지 다짐으로 이어서 채움 // (기본 상태에서 빈 칸투성이가 되지 않도록 — 빈 칸은 사용자가 명시적으로 // 슬롯보다 적게 골랐을 때만 나타난다) let fromGoal = defaultGoals(1).first?.sortedQuests ?? [] let rest = IntentStore.quests().filter { quest in !fromGoal.contains { $0.uuid == quest.uuid } } return Array((fromGoal + rest).prefix(defaultCount)) } return entities.compactMap { quest($0.id) } } /// 선택한 모델 목록을 위젯 크기별 슬롯 수에 맞춰 자르거나 nil(빈 칸)로 채운다. /// 사용자가 슬롯 수보다 적게 골랐을 때 "미선택 시 빈칸" 규칙을 구현하는 공용 헬퍼. static func slots(_ items: [T], capacity: Int) -> [T?] { var result: [T?] = items.prefix(capacity).map { $0 } while result.count < capacity { result.append(nil) } return result } }