mycode/myApp/HaruDanim/Widgets/WidgetSupport.swift
songyc macbook ed8b52792a feat(widgets): 홈/잠금화면 위젯 6종 + Interactive Widgets (CLAUDE.md §8)
- 행동 실행(A): 소형1/중형3/대형4칸, Button(intent:)로 앱 없이 즉시 실행,
  측정 중 실시간 타이머 + 노란 테두리, 표시 기간(오늘/이번 주/이번 달) 옵션
- 목표 진행률(B): 하루/주간/월간 가로 진행바, 소형1/중형2/대형4
- 다짐 진행률(C): 원형 링 + 기간 옵션, 단일 행동 다짐은 눌러서 실행,
  '이하 유지'는 한도 지킴/초과로 표현
- 다짐 현황(D): 목표명 + 원형 링 그리드 (소형 2x2 / 중형 최대 8 / 대형 목표 2개)
- 통계: 행동 최대 3개 꺾은선 그래프, 앱 통계 탭과 동일 산식·색,
  소형은 '한 달 일별' 제외 (스펙 §8.3)
- 잠금화면: circular/rectangular/inline, 표시 방식 3종(게이지/숫자/다짐 점)
- 전 위젯 프리미엄 게이트 (미결제 시 잠금 안내)
- DEBUG 미리보기 화면: -widgetPreview YES|lock, -widgetPreviewScroll B|C|D|S

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-10 08:59:47 +09:00

239 lines
7.8 KiB
Swift

//
// 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<Date> = 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))
}
}