mycode/myApp/HaruDanim/Widgets/QuestRingWidget.swift
songyc macbook 78bad15852 feat(1.5-p3): 건강 다짐 — 8지표+운동 종목 20종, 값 공급 이음새로 §4.2 수학 무변경
- Quest 필드 3종(healthMetricRaw·수면 구간, CloudKit 기본값 규칙)+행동/꼬리표 우선 정규화
- HealthQuestTarget/HealthWorkoutKind(종목 큐레이션)·HealthCache 공유 계층 이전(위젯 읽기)
- QuestProgress 값 공급 단일 지점 3곳(value·spanValue·perDayValues)만 분기 — 기존 49건 무변경 그린
- 편집기 대상 3유형째(지표·종목·수면 구간 휠·단위별 목표 입력·프리미엄 게이트·하루 전용)
- §8ⓒⓕ: 데이터 없는 기기 모수 제외+'데이터 없음' 표기(목표 행·모음 카드·위젯 ②③④)
  +자동/수동 판정 보류, 위젯 값 라벨 지표 단위화, HealthStore 백필(400일)·운동 조회·부트스트랩
- 검증: progressSelfTest 61건(H 시리즈 12 신규)·20·27·10 ALL PASS(26.5+18.5),
  Debug/Store/워치 빌드, 시각 QA(목표 행·편집기·타일). 신규 인자 3종 §14 기록

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-08-22 06:07:25 +09:00

259 lines
10 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// QuestRingWidget.swift
// Haru_DanimWidgets
//
// (CLAUDE.md §8.2)
// ( ) + (Interactive).
// , " 0%" .
// - : 1 ()
// - : 2 ()
// - : 4 (2×2)
//
import SwiftUI
import WidgetKit
import AppIntents
// MARK: -
struct QuestRingConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "다짐 진행률 위젯"
static let description = IntentDescription("원형 진행률로 표시할 다짐들과 기간, 테마를 선택하세요. 선택하지 않은 칸은 비워 둬요.")
@Parameter(title: "다짐")
var quests: [QuestEntity]?
@Parameter(title: "진행률 기간", default: .day)
var span: SpanOption
@Parameter(title: "테마", default: .matchApp)
var theme: WidgetThemeOption
}
// MARK: -
struct QuestRingEntry: TimelineEntry {
let date: Date
let locked: Bool
let theme: WidgetThemeOption
let spanLabel: String
/// (nil = )
let cells: [QuestCellSnapshot?]
///
var isLive: Bool { cells.contains { $0?.isRunning == true } }
}
struct QuestRingProvider: AppIntentTimelineProvider {
/// ( 1 / 2 / 4)
static func capacity(_ family: WidgetFamily) -> Int {
switch family {
case .systemMedium: return 2
case .systemLarge: return 4
default: return 1
}
}
@MainActor
static func makeEntry(_ configuration: QuestRingConfigIntent, family: WidgetFamily,
now: Date = .now) -> QuestRingEntry {
IntentStore.refresh()
guard WidgetStore.isUnlocked else {
return QuestRingEntry(date: now, locked: true, theme: configuration.theme, spanLabel: "", cells: [])
}
let capacity = Self.capacity(family)
let quests = WidgetStore.selectedQuests(configuration.quests, defaultCount: capacity)
let span = configuration.span.statSpan
return QuestRingEntry(
date: now,
locked: false,
theme: configuration.theme,
spanLabel: configuration.span.label,
cells: WidgetStore.slots(quests.map { QuestCellSnapshot.make(quest: $0, span: span, now: now) },
capacity: capacity)
)
}
func placeholder(in context: Context) -> QuestRingEntry {
QuestRingEntry(date: .now, locked: false, theme: .matchApp, spanLabel: String(localized: "오늘"),
cells: Array(QuestCellSnapshot.samples.prefix(Self.capacity(context.family))))
}
@MainActor
func snapshot(for configuration: QuestRingConfigIntent, in context: Context) async -> QuestRingEntry {
Self.makeEntry(configuration, family: context.family)
}
@MainActor
func timeline(for configuration: QuestRingConfigIntent, in context: Context) async -> Timeline<QuestRingEntry> {
let first = Self.makeEntry(configuration, family: context.family)
return WidgetRefresh.timeline(first: first, live: first.isLive) { date in
Self.makeEntry(configuration, family: context.family, now: date)
}
}
}
// MARK: -
struct QuestRingActionCellView: View {
let cell: QuestCellSnapshot
let spanLabel: String
var ringSize: CGFloat = 52
/// ()
var fullBleed = false
var body: some View {
// ( )
if let target = cell.runTarget {
Button(intent: RunActionIntent(actionID: target.id.uuidString)) {
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)
}
}
// ZStack invalidatableContent ·
// ,
// . .
Text(cell.targetName)
.font(.caption2.weight(.semibold))
.lineLimit(1)
Text(percentText)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
// ' ' ( )
.invalidatableContent()
// span ' ' .
// ( )
if !cell.isRestDay {
Text(cell.valueLabel)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.invalidatableContent()
}
if let streak = cell.streakLabel {
HStack(spacing: 2) {
Image(systemName: "flame.fill")
.font(.system(size: 7, weight: .bold))
Text(streak)
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(AppTheme.yellow)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(fullBleed ? 10 : 6)
.background(WidgetCellBackground(fullBleed: fullBleed))
// plain ( ·)
// , ( )
.contentShape(.rect)
}
private var percentText: String {
// ( span )
if cell.isHealthUnavailable { return String(localized: "데이터 없음") }
if cell.isRestDay { return String(localized: "수행일 아님") }
// (§4.2) " " .
if let fulfilled = cell.periodFulfilledLabel { return fulfilled }
if cell.isAtMost {
return cell.isAchieved
? String(localized: "\(spanLabel) 한도 지킴")
: String(localized: "\(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
var body: some View {
Group {
if entry.locked {
WidgetLockedView()
.padding(12)
} else if !entry.cells.contains(where: { $0 != nil }) {
WidgetEmptyView(symbolName: "checkmark.circle", message: "목표 탭에서 다짐을 만들면\n진행률이 표시돼요")
} else {
switch family {
case .systemMedium:
HStack(spacing: 8) {
ForEach(0..<2, id: \.self) { index in
slotCell(index, ringSize: 52)
}
}
.padding(10)
case .systemLarge:
VStack(spacing: 8) {
ForEach(0..<2, id: \.self) { row in
HStack(spacing: 8) {
ForEach(0..<2, id: \.self) { col in
slotCell(row * 2 + col, ringSize: 60)
}
}
}
}
.padding(10)
default:
// : ()
if let cell = entry.cells.first ?? nil {
QuestRingActionCellView(cell: cell, spanLabel: entry.spanLabel,
ringSize: 58, fullBleed: true)
} else {
WidgetBlankCellView(fullBleed: true)
}
}
}
}
.widgetAppTheme(entry.theme)
}
@ViewBuilder
private func slotCell(_ index: Int, ringSize: CGFloat) -> some View {
if index < entry.cells.count, let cell = entry.cells[index] {
QuestRingActionCellView(cell: cell, spanLabel: entry.spanLabel, ringSize: ringSize)
} else {
WidgetBlankCellView()
}
}
}
// 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])
.contentMarginsDisabled()
}
}