mycode/myApp/HaruDanim/Widgets/QuestRingWidget.swift
songyc macbook 0e6804b531 feat(progress): 주기 몫 채운 주간·월간 다짐의 하루 진행률을 '이번 주/달 달성' 상태로 표시
- 주간/월간 '이상 달성' 다짐이 현재 주기 몫을 채우면 하루 게이지가 0% 대신
  완료 상태(게이지 가득+초록 ✓+문구)로 표시 — 목표 탭 행·모음 카드·위젯 ②(체크)·
  ③(문구)·④(링 가득)·시리 하루 응답 공유
- 목표 하루 평균(combinedSpanRatio)에도 해당 다짐을 1.0으로 반영 —
  위젯 ② 목표 바·워치 목표 게이지·시리 목표 응답·일기 헤더 자동 반영
- 판정은 QuestProgress.isPeriodFulfilled 단일 지점 — 기준일의 논리적 하루 끝
  클리핑으로 일기 과거 날짜 소급 차단. spanProgress 수치·판정(judgment)·연속(streak)
  불변(표시 계층 전용). 하루 다짐·특정 기간·atMost는 비대상, 잠금화면·워치
  컴플리케이션은 공간 제약으로 수치 유지
- 도움말 '진행률 읽는 법' 문구 갱신, l10n en/ja 완료(전 카탈로그 stale 0·missing 0)
- 검증: -progressSelfTest 37→49건 ALL PASS(기존 37건 수치 무변경), 신규
  -seedPeriodFulfilled 시드로 ko/en/ja·위젯 실렌더 확인, 시리 스모크 10건 OK,
  Debug/Store 빌드 성공(워치 타깃은 Shared 미포함·무변경)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVeduv1eNdXjk1ay4tSgBg
2026-08-01 06:45:01 +09:00

258 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.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()
}
}