소킹 중 '1.4 대비 버벅임 증가' 보고의 원인 수정 (동작·수치 완전 불변): - HealthCache/HealthIntervals 인메모리 캐시(세대 카운터 무효화) + workoutKeys 이중 load 제거 - DayMath.calendar 저장 프로퍼티화 (접근마다 Calendar 사본 생성 제거) - 수면 백필 O(400일×샘플수) → 정렬+포인터 스위프 (퍼즈 3,000회 등가 증명) - refreshRecent 캐시 기록 배칭 (일수×2회 → 총 2회) - QuestProgress.spanValue/spanRawValue 하루 버킷화 (게이지의 미래 기록 즉시 반영 스펙 보존) - 위젯 라이브 타임라인 makeEntry refresh 파라미터 (엔트리 7개×컨테이너 오픈 → 1회) - 일기 건강 카드 칩 value() 이중 호출 제거 검증: 옛/새 빌드 A/B — 덤프 3종 값 동일·4화면 픽셀 동일, 자가 검증 62/20/27/10 ALL PASS(26.5+18.5), 벌크 1만 건 렌더 정상, 3종 빌드 그린, 카탈로그 무변경. whats-new-1.5 3언어에 성능 개선 줄 추가. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
260 lines
11 KiB
Swift
260 lines
11 KiB
Swift
//
|
||
// 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, refresh: Bool = true) -> QuestRingEntry {
|
||
// 미래 엔트리는 컨테이너 재오픈 없이 같은 상태에서 계산 (GoalProgressProvider와 동일)
|
||
if refresh { 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, refresh: false)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
}
|