mycode/myApp/HaruDanim/Widgets/GoalWidgets.swift
songyc macbook 2a176fa8a2 fix(quest): count scheduled days only for daily-quest spans + rest-day state
진행률 검증 보고에서 확정한 결정 반영 (사용자 위임):

- [계산 수정] 하루 단위 다짐의 주/월 집계를 **방향 무관 수행일 기록만** 합산 — 목표량(scaledTarget)이 수행일 수 환산인데 '이하 유지'만 비수행일 기록까지 값에 섞여 분모·분자가 어긋나던 비대칭 해소 (월수금 1시간 이하 다짐이 목요일 시청으로 한도 초과 표시되던 문제). atLeast의 하루 기여 캡·마감 창 클립은 기존 유지
- [표시 수정] 수행일 아닌 날(요일·날짜 다짐의 쉬는 날, 기간 밖 custom)의 '하루' 게이지를 '수행일 아님' 상태로 — 쉬는 날 기록은 주/월 집계에 안 잡히는 값이라 퍼센트(예 133%)로 보여주면 "오늘 채웠다"는 오해 유발. 목표 탭 행·모음 카드(문구+빈 바), 위젯 ③(문구·누적값 숨김)·②(— 대시)·④(링 흐림, 스냅숏 isRestDay), 시리 '오늘' 조회("오늘은 수행일이 아니에요") 공유. 워치·잠금화면 점은 공간 제약으로 수치 유지(문서화)
- [의도 확정·유지] atMost 하루 게이지의 환산 한도 페이스 경고 / 다짐 생성 이전 기록 인정 / 월 경계 주 혼입 / 적용일 없는 달 0% / custom 종료 후 게이지 — CLAUDE.md §4.2에 트레이드오프로 명시
- 검증: -progressSelfTest 37건 ALL PASS(E 비수행일 제외·M 비대칭 수정·N 기간 밖 판정 신규), 목표 탭 스크린샷(달리기 월수금: 하루 '수행일 아님'/주간 0%/월간 7%), 위젯 미리보기 렌더 무결, Debug·Store·워치 3스킴 빌드 성공
- 도움말 '진행률 읽는 법' 전면 보강(수행일 집계·페이스 캡·이하 유지 이분법+하루 경고) en/ja 완비, 신규 키 2종('수행일 아님', 시리 응답) IOS·위젯 카탈로그 클린(763/213키)

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

629 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

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.

//
// GoalWidgets.swift
// Haru_DanimWidgets
//
// + (CLAUDE.md §8.2)
//
import SwiftUI
import WidgetKit
import AppIntents
// MARK: - (Goal Progress)
// // .
// - : 1
// - : [ 2] [ 1 + 2~3]
// - : [ 4 (2×2)] / [+ 2] / [ 1 + ]
///
enum GoalMediumStyle: String, AppEnum {
/// 2
case twoGoals
/// 1 +
case goalWithQuests
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "중형 위젯 구성")
static let caseDisplayRepresentations: [GoalMediumStyle: DisplayRepresentation] = [
.twoGoals: "목표 2개",
.goalWithQuests: "목표 1개 + 다짐 진행률",
]
}
///
enum GoalLargeStyle: String, AppEnum {
/// 4 (2×2)
case fourGoals
/// [ 1 + ] 2
case twoGoalsWithQuests
/// 1 +
case oneGoalAllQuests
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "대형 위젯 구성")
static let caseDisplayRepresentations: [GoalLargeStyle: DisplayRepresentation] = [
.fourGoals: "목표 4개 (2×2)",
.twoGoalsWithQuests: "목표+다짐 2개 (위아래)",
.oneGoalAllQuests: "목표 1개 + 모든 다짐",
]
}
struct GoalProgressConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "목표 진행률 위젯"
static let description = IntentDescription("진행률을 표시할 목표들과 위젯 구성, 테마를 선택하세요.")
@Parameter(title: "목표")
var goals: [GoalEntity]?
@Parameter(title: "중형 위젯 구성", default: .twoGoals)
var mediumStyle: GoalMediumStyle
@Parameter(title: "대형 위젯 구성", default: .fourGoals)
var largeStyle: GoalLargeStyle
@Parameter(title: "테마", default: .matchApp)
var theme: WidgetThemeOption
}
struct GoalProgressEntry: TimelineEntry {
let date: Date
let locked: Bool
let theme: WidgetThemeOption
let mediumStyle: GoalMediumStyle
let largeStyle: GoalLargeStyle
/// · (nil = )
let goals: [GoalSnapshot?]
///
var isLive: Bool { goals.contains { $0?.quests.contains { $0.isRunning } == true } }
}
struct GoalProgressProvider: AppIntentTimelineProvider {
static func capacity(_ family: WidgetFamily, configuration: GoalProgressConfigIntent) -> Int {
switch family {
case .systemMedium: return configuration.mediumStyle == .twoGoals ? 2 : 1
case .systemLarge:
switch configuration.largeStyle {
case .fourGoals: return 4
case .twoGoalsWithQuests: return 2
case .oneGoalAllQuests: return 1
}
default: return 1
}
}
@MainActor
static func makeEntry(_ configuration: GoalProgressConfigIntent, family: WidgetFamily,
now: Date = .now) -> GoalProgressEntry {
IntentStore.refresh()
guard WidgetStore.isUnlocked else {
return GoalProgressEntry(date: now, locked: true, theme: configuration.theme,
mediumStyle: configuration.mediumStyle,
largeStyle: configuration.largeStyle, goals: [])
}
let capacity = Self.capacity(family, configuration: configuration)
let goals = WidgetStore.selectedGoals(configuration.goals, defaultCount: capacity)
return GoalProgressEntry(
date: now,
locked: false,
theme: configuration.theme,
mediumStyle: configuration.mediumStyle,
largeStyle: configuration.largeStyle,
goals: WidgetStore.slots(goals.map { GoalSnapshot.make(goal: $0, now: now) }, capacity: capacity)
)
}
func placeholder(in context: Context) -> GoalProgressEntry {
GoalProgressEntry(date: .now, locked: false, theme: .matchApp,
mediumStyle: .twoGoals, largeStyle: .fourGoals, goals: [.sample])
}
@MainActor
func snapshot(for configuration: GoalProgressConfigIntent, in context: Context) async -> GoalProgressEntry {
Self.makeEntry(configuration, family: context.family)
}
@MainActor
func timeline(for configuration: GoalProgressConfigIntent, in context: Context) async -> Timeline<GoalProgressEntry> {
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)
}
}
}
/// 1: + + // 3
struct GoalBarsCellView: View {
let goal: GoalSnapshot
/// ()
var fullBleed = false
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 5) {
Image(systemName: goal.symbolName)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(goal.color)
Text(goal.title)
.font(.caption.weight(.semibold))
.lineLimit(1)
}
SpanBarRow(label: String(localized: "하루"), ratio: goal.dayRatio, color: goal.color)
SpanBarRow(label: String(localized: "주간"), ratio: goal.weekRatio, color: goal.color)
SpanBarRow(label: String(localized: "월간"), ratio: goal.monthRatio, color: goal.color)
}
.padding(fullBleed ? 14 : 10)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(WidgetCellBackground(fullBleed: fullBleed))
}
}
/// 1 ( + + + %).
/// % ( · ).
struct QuestBarRow: View {
let quest: QuestCellSnapshot
var body: some View {
HStack(spacing: 6) {
Image(systemName: quest.symbolName)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(quest.color)
.frame(width: 14)
Text(quest.targetName)
.font(.caption2)
.lineLimit(1)
.frame(width: 52, alignment: .leading)
ProgressView(value: min(max(quest.ratio, 0), 1))
.progressViewStyle(.linear)
.tint(quest.isAtMost && !quest.isAchieved && !quest.isRestDay ? Color.red : quest.color)
if quest.isRestDay {
// ( )
Text(verbatim: "")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.tertiary)
.frame(width: 34, alignment: .trailing)
} else if let streak = quest.streakLabel {
VStack(alignment: .trailing, spacing: 0) {
Text(Format.percent(quest.displayRatio))
.font(.system(size: 9, weight: .semibold).monospacedDigit())
.foregroundStyle(.secondary)
Text(streak)
.font(.system(size: 6.5, weight: .semibold))
.foregroundStyle(AppTheme.yellow)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(width: 34, alignment: .trailing)
} else {
Text(Format.percent(quest.displayRatio))
.font(.system(size: 9, weight: .semibold).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 34, alignment: .trailing)
}
}
}
}
/// .
/// "+N" . (/ )
struct QuestBarsColumn: View {
let quests: [QuestCellSnapshot]
/// ( 14
/// (fitCount) )
private var rowHeight: CGFloat {
quests.contains { $0.streakLabel != nil } ? 19 : 14
}
private let rowSpacing: CGFloat = 6
private let headerHeight: CGFloat = 17
var body: some View {
GeometryReader { geo in
let fitCount = max(Int((geo.size.height - headerHeight + rowSpacing) / (rowHeight + rowSpacing)), 1)
let shown = Array(quests.prefix(fitCount))
VStack(alignment: .leading, spacing: rowSpacing) {
HStack {
Text("다짐 (하루)")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
if quests.count > shown.count {
Text(verbatim: "+\(quests.count - shown.count)")
.font(.system(size: 9, weight: .semibold).monospacedDigit())
.foregroundStyle(.tertiary)
}
}
.frame(height: headerHeight - rowSpacing, alignment: .top)
if quests.isEmpty {
Spacer()
Text("다짐이 없어요")
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
Spacer()
} else {
ForEach(shown) { quest in
QuestBarRow(quest: quest)
.frame(height: rowHeight)
}
Spacer(minLength: 0)
}
}
}
.padding(10)
.background(WidgetCellBackground())
}
}
/// 1 + ( 2 / 2)
struct GoalWithQuestsCellView: View {
let goal: GoalSnapshot
var body: some View {
HStack(alignment: .top, spacing: 10) {
GoalBarsCellView(goal: goal)
.frame(maxWidth: .infinity)
QuestBarsColumn(quests: goal.quests)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
struct GoalProgressWidgetView: View {
@Environment(\.widgetFamily) private var envFamily
/// DEBUG
var previewFamily: WidgetFamily? = nil
private var family: WidgetFamily { previewFamily ?? envFamily }
let entry: GoalProgressEntry
var body: some View {
Group {
if entry.locked {
WidgetLockedView()
.padding(12)
} else if !entry.goals.contains(where: { $0 != nil }) {
WidgetEmptyView(symbolName: "flag.fill", message: "목표 탭에서 목표를 만들면\n진행률이 표시돼요")
} else {
switch family {
case .systemMedium:
mediumLayout
.padding(10)
case .systemLarge:
largeLayout
.padding(10)
default:
// : ()
if let goal = entry.goals.first ?? nil {
GoalBarsCellView(goal: goal, fullBleed: true)
}
}
}
}
.widgetAppTheme(entry.theme)
}
@ViewBuilder
private var mediumLayout: some View {
switch entry.mediumStyle {
case .twoGoals:
HStack(spacing: 8) {
ForEach(0..<2, id: \.self) { index in
slotCell(index)
}
}
case .goalWithQuests:
if let goal = entry.goals.first ?? nil {
GoalWithQuestsCellView(goal: goal)
}
}
}
@ViewBuilder
private var largeLayout: some View {
switch entry.largeStyle {
case .fourGoals:
VStack(spacing: 8) {
HStack(spacing: 8) {
slotCell(0)
slotCell(1)
}
HStack(spacing: 8) {
slotCell(2)
slotCell(3)
}
}
case .twoGoalsWithQuests:
VStack(spacing: 8) {
ForEach(0..<2, id: \.self) { index in
if index < entry.goals.count, let goal = entry.goals[index] {
GoalWithQuestsCellView(goal: goal)
} else {
WidgetBlankCellView()
}
}
}
case .oneGoalAllQuests:
if let goal = entry.goals.first ?? nil {
VStack(spacing: 8) {
GoalBarsCellView(goal: goal)
.frame(maxHeight: 130)
QuestBarsColumn(quests: goal.quests)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
}
@ViewBuilder
private func slotCell(_ index: Int) -> some View {
if index < entry.goals.count, let goal = entry.goals[index] {
GoalBarsCellView(goal: goal)
} else {
WidgetBlankCellView()
}
}
}
struct GoalProgressWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "HaruGoalBarsWidget",
intent: GoalProgressConfigIntent.self,
provider: GoalProgressProvider()
) { entry in
GoalProgressWidgetView(entry: entry)
}
.configurationDisplayName("목표 진행률")
.description("목표의 하루·주간·월간 진행률을 진행바로 확인해요. (프리미엄)")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
.contentMarginsDisabled()
}
}
// MARK: - (Quest Status, )
// . (% ),
// +.
// - : 4 (2×2)
// - : 8 (4×2)
// - : [ 16 (4×4)] / [ 4 (2×2)] / [ 2 ()]
///
enum QuestStatusLargeStyle: String, AppEnum {
/// 1 16 (4×4)
case manyQuests
/// 4 2×2 ( 4)
case fourGoals
/// 2 ( 8)
case twoGoals
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "대형 위젯 구성")
static let caseDisplayRepresentations: [QuestStatusLargeStyle: DisplayRepresentation] = [
.manyQuests: "목표 1개 · 다짐 16개",
.fourGoals: "목표 4개 (2×2)",
.twoGoals: "목표 2개 (위아래)",
]
}
struct QuestStatusConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "다짐 현황 위젯"
static let description = IntentDescription("다짐들의 진행률 링을 보여줄 목표와 기간, 테마를 선택하세요.")
@Parameter(title: "목표")
var goals: [GoalEntity]?
@Parameter(title: "진행률 기간", default: .day)
var span: SpanOption
@Parameter(title: "대형 위젯 구성", default: .manyQuests)
var largeStyle: QuestStatusLargeStyle
@Parameter(title: "테마", default: .matchApp)
var theme: WidgetThemeOption
}
struct QuestStatusEntry: TimelineEntry {
let date: Date
let locked: Bool
let theme: WidgetThemeOption
let spanLabel: String
let largeStyle: QuestStatusLargeStyle
let goals: [GoalSnapshot?]
var isLive: Bool { goals.contains { $0?.quests.contains { $0.isRunning } == true } }
}
struct QuestStatusProvider: AppIntentTimelineProvider {
static func capacity(_ family: WidgetFamily, configuration: QuestStatusConfigIntent) -> Int {
guard family == .systemLarge else { return 1 }
switch configuration.largeStyle {
case .manyQuests: return 1
case .fourGoals: return 4
case .twoGoals: return 2
}
}
@MainActor
static func makeEntry(_ configuration: QuestStatusConfigIntent, family: WidgetFamily,
now: Date = .now) -> QuestStatusEntry {
IntentStore.refresh()
guard WidgetStore.isUnlocked else {
return QuestStatusEntry(date: now, locked: true, theme: configuration.theme,
spanLabel: "", largeStyle: configuration.largeStyle, goals: [])
}
let capacity = Self.capacity(family, configuration: configuration)
let goals = WidgetStore.selectedGoals(configuration.goals, defaultCount: capacity)
let span = configuration.span.statSpan
return QuestStatusEntry(
date: now,
locked: false,
theme: configuration.theme,
spanLabel: configuration.span.label,
largeStyle: configuration.largeStyle,
goals: WidgetStore.slots(goals.map { GoalSnapshot.make(goal: $0, span: span, now: now) },
capacity: capacity)
)
}
func placeholder(in context: Context) -> QuestStatusEntry {
QuestStatusEntry(date: .now, locked: false, theme: .matchApp, spanLabel: String(localized: "오늘"),
largeStyle: .manyQuests, goals: [.sample])
}
@MainActor
func snapshot(for configuration: QuestStatusConfigIntent, in context: Context) async -> QuestStatusEntry {
Self.makeEntry(configuration, family: context.family)
}
@MainActor
func timeline(for configuration: QuestStatusConfigIntent, in context: Context) async -> Timeline<QuestStatusEntry> {
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)
}
}
}
/// + ( % ). .
struct QuestRingCellView: View {
let quest: QuestCellSnapshot
var ringSize: CGFloat = 34
var body: some View {
VStack(spacing: 5) {
QuestRingView(snapshot: quest, lineWidth: 4, iconSize: ringSize * 0.36)
.frame(width: ringSize, height: ringSize)
// ( )
.opacity(quest.isRestDay ? 0.4 : 1)
VStack(spacing: 1) {
Text(quest.targetName)
.font(.system(size: 8, weight: .medium))
.lineLimit(1)
.foregroundStyle(.secondary)
if let streak = quest.streakLabel {
Text(streak)
.font(.system(size: 7, weight: .semibold))
.foregroundStyle(AppTheme.yellow)
.lineLimit(1)
}
}
}
}
}
struct QuestStatusWidgetView: View {
@Environment(\.widgetFamily) private var envFamily
/// DEBUG
var previewFamily: WidgetFamily? = nil
private var family: WidgetFamily { previewFamily ?? envFamily }
let entry: QuestStatusEntry
var body: some View {
Group {
if entry.locked {
WidgetLockedView()
} else if !entry.goals.contains(where: { $0 != nil }) {
WidgetEmptyView(symbolName: "square.grid.2x2", message: "목표 탭에서 목표와\n다짐을 만들어 보세요")
} else {
switch family {
case .systemMedium:
// : 8 (4×2)
if let goal = entry.goals.first ?? nil {
goalSection(goal, maxCount: 8, columns: 4, ringSize: goal.quests.count > 4 ? 30 : 38)
}
case .systemLarge:
largeLayout
default:
// : 4 (2×2)
if let goal = entry.goals.first ?? nil {
goalSection(goal, maxCount: 4, columns: 2, ringSize: 32)
}
}
}
}
.padding(13)
.widgetAppTheme(entry.theme)
}
@ViewBuilder
private var largeLayout: some View {
switch entry.largeStyle {
case .manyQuests:
if let goal = entry.goals.first ?? nil {
goalSection(goal, maxCount: 16, columns: 4, ringSize: 44)
}
case .fourGoals:
VStack(spacing: 10) {
ForEach(0..<2, id: \.self) { row in
HStack(spacing: 10) {
ForEach(0..<2, id: \.self) { col in
goalSlot(row * 2 + col, maxCount: 4, columns: 2, ringSize: 30)
}
}
}
}
case .twoGoals:
VStack(spacing: 10) {
ForEach(0..<2, id: \.self) { index in
goalSlot(index, maxCount: 8, columns: 4, ringSize: 34)
}
}
}
}
@ViewBuilder
private func goalSlot(_ index: Int, maxCount: Int, columns: Int, ringSize: CGFloat) -> some View {
if index < entry.goals.count, let goal = entry.goals[index] {
goalSection(goal, maxCount: maxCount, columns: columns, ringSize: ringSize)
} else {
WidgetBlankCellView()
}
}
private func goalSection(_ goal: GoalSnapshot, maxCount: Int, columns: Int, ringSize: CGFloat) -> some View {
VStack(spacing: 10) {
HStack(spacing: 4) {
Image(systemName: goal.symbolName)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(goal.color)
Text(goal.title)
.font(.caption2.weight(.semibold))
.lineLimit(1)
Spacer()
Text(entry.spanLabel)
.font(.system(size: 8))
.foregroundStyle(.tertiary)
}
if goal.quests.isEmpty {
Spacer()
Text("다짐이 없어요")
.font(.caption2)
.foregroundStyle(.secondary)
Spacer()
} else {
let quests = Array(goal.quests.prefix(maxCount))
let grid = [GridItem](repeating: GridItem(.flexible(), spacing: 8), count: columns)
LazyVGrid(columns: grid, spacing: 12) {
ForEach(quests) { quest in
QuestRingCellView(quest: quest, ringSize: ringSize)
}
}
Spacer(minLength: 0)
}
}
}
}
struct QuestStatusWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "HaruGoalQuestGridWidget",
intent: QuestStatusConfigIntent.self,
provider: QuestStatusProvider()
) { entry in
QuestStatusWidgetView(entry: entry)
}
.configurationDisplayName("다짐 현황")
.description("목표에 속한 다짐들의 진행률을 원형 링으로 한눈에 봐요. (프리미엄)")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
.contentMarginsDisabled()
}
}