mycode/myApp/HaruDanim/Widgets/WidgetSupport.swift
songyc macbook 24c3004ead feat(widget): show span accumulated value in quest ring widget
③ 다짐 진행률 위젯에서 '이하 유지' 다짐은 시작부터 링이 가득 차 있고
문구도 '오늘 한도 지킴'뿐이라 지금 몇 회/몇 시간 했는지 알 수 없던 문제.
퍼센트(또는 한도 지킴/초과) 문구와 연속 달성 문구 사이에 위젯 옵션의
진행률 기간 기준 실제 누적값 한 줄을 추가한다 ("1시간 20분" / "3회").
'이상 달성' 다짐에도 동일하게 표시. 주간·월간 진행률 캡과 무관한
원본 누적값이며, 다른 위젯(②④)과 나머지 레이아웃은 그대로.

- QuestCellSnapshot.valueLabel 추가 (spanRange 원본 값 포맷)
- QuestProgress.spanRange(_:now:) 헬퍼 추출
- 위젯 미리보기에 '이하 유지(소형)' 행 신설 (앵커 CM)
- l10n: '0분' ko/en/ja (IOS·Widgets 카탈로그)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-14 02:37:41 +09:00

381 lines
16 KiB
Swift

//
// WidgetSupport.swift
// Haru_DanimWidgets
//
// / (CLAUDE.md §8)
// - SwiftData(App Group DB)
// - : ( ·· )
// reloadAllTimelines .
// (WidgetKit ).
// - , / , /
//
import SwiftUI
import SwiftData
import WidgetKit
// MARK: -
enum WidgetRefresh {
/// "15 .after" 6
/// . :
/// - (live): 10 1 .atEnd
/// ( 1 )
/// - : ( 4 )
static func timeline<E: TimelineEntry>(first: E, live: Bool, make: (Date) -> E) -> Timeline<E> {
let now = first.date
if live {
let future = stride(from: 600, through: 3600, by: 600)
.map { make(now.addingTimeInterval(Double($0))) }
return Timeline(entries: [first] + future, policy: .atEnd)
}
let boundary = DayMath().dayRange(containing: now).upperBound
return Timeline(entries: [first], policy: .after(min(boundary, now.addingTimeInterval(4 * 3600))))
}
}
// 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) }
@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
/// (" 3" ). 1 nil ( )
let streakLabel: String?
/// span ("1 20" / "3")
/// ( ' ') . ' '
/// . (· )
/// .
let valueLabel: String
var color: Color { Color(hex: colorHex) }
@MainActor
static func make(quest: Quest, span: StatSpan, now: Date = .now) -> QuestCellSnapshot {
let progress = QuestProgress(quest: quest)
let result = progress.spanProgress(span, now: now)
let streak = progress.streak(now: now)
let action = quest.targetAction
let rawValue = progress.value(in: progress.spanRange(span, now: now), now: now)
let valueLabel: String
switch quest.measure {
case .time:
valueLabel = rawValue < 60 ? String(localized: "0분") : Format.durationShort(rawValue)
case .count:
valueLabel = String(localized: "\(Int(rawValue))")
}
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,
streakLabel: (streak?.count ?? 0) > 0 ? streak?.label : nil,
valueLabel: valueLabel
)
}
}
/// (// 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: -
/// (placeholder/preview)
///
extension ActionCellSnapshot {
static let samples: [ActionCellSnapshot] = [
ActionCellSnapshot(id: UUID(), name: String(localized: "독서"), symbolName: "book.fill",
colorHex: "#2F6B4F", isCount: false, isRunning: true, value: 45 * 60, tickingBase: nil),
ActionCellSnapshot(id: UUID(), name: String(localized: "달리기"), symbolName: "figure.run",
colorHex: "#9D5B4A", isCount: false, isRunning: false, value: 30 * 60, tickingBase: nil),
ActionCellSnapshot(id: UUID(), name: String(localized: "물 마시기"), symbolName: "drop.fill",
colorHex: "#4A7A9D", isCount: true, isRunning: false, value: 5, tickingBase: nil),
ActionCellSnapshot(id: UUID(), name: String(localized: "팔굽혀펴기"), symbolName: "dumbbell.fill",
colorHex: "#7A5C9D", isCount: true, isRunning: false, value: 20, tickingBase: nil),
]
}
extension QuestCellSnapshot {
static let samples: [QuestCellSnapshot] = [
QuestCellSnapshot(id: UUID(), targetName: String(localized: "독서"), symbolName: "book.fill",
colorHex: "#2F6B4F", ratio: 0.75, displayRatio: 0.75,
isAtMost: false, isAchieved: false, runTarget: nil, isRunning: true,
streakLabel: QuestStreakInfo(count: 5, unit: .day).label,
valueLabel: Format.durationShort(45 * 60)),
QuestCellSnapshot(id: UUID(), targetName: String(localized: "달리기"), symbolName: "figure.run",
colorHex: "#9D5B4A", ratio: 0.4, displayRatio: 0.4,
isAtMost: false, isAchieved: false, runTarget: nil, isRunning: false,
streakLabel: nil,
valueLabel: Format.durationShort(12 * 60)),
QuestCellSnapshot(id: UUID(), targetName: String(localized: "물 마시기"), symbolName: "drop.fill",
colorHex: "#4A7A9D", ratio: 1, displayRatio: 1.1,
isAtMost: false, isAchieved: true, runTarget: nil, isRunning: false,
streakLabel: QuestStreakInfo(count: 12, unit: .day).label,
valueLabel: String(localized: "\(9)")),
QuestCellSnapshot(id: UUID(), targetName: String(localized: "영상 시청"), symbolName: "play.rectangle.fill",
colorHex: "#7A5C9D", ratio: 1, displayRatio: 1,
isAtMost: true, isAchieved: true, runTarget: nil, isRunning: false,
streakLabel: nil,
valueLabel: Format.durationShort(40 * 60)),
]
}
extension GoalSnapshot {
static let sample = GoalSnapshot(
id: UUID(), title: String(localized: "건강한 생활 습관"), symbolName: "heart.fill",
colorHex: "#2F6B4F", dayRatio: 0.62, weekRatio: 0.48, monthRatio: 0.7,
quests: QuestCellSnapshot.samples
)
}
// MARK: -
struct WidgetEmptyView: View {
let symbolName: String
let message: String
var body: some View {
VStack(spacing: 6) {
Image(systemName: symbolName)
.font(.title3)
.foregroundStyle(AppTheme.green)
Text(message)
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(12)
}
}
// 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))
}
// MARK:
// (nil/ ) ,
// ( ).
// ' ' (NoneEntityID) .
static func selectedActions(_ entities: [ActionEntity]?, defaultCount: Int) -> [Action] {
guard let entities, !entities.isEmpty else { return defaultActions(defaultCount) }
return entities.compactMap { action($0.id) }
}
static func selectedGoals(_ entities: [GoalEntity]?, defaultCount: Int) -> [Goal] {
guard let entities, !entities.isEmpty else { return defaultGoals(defaultCount) }
return entities.compactMap { goal($0.id) }
}
static func selectedQuests(_ entities: [QuestEntity]?, defaultCount: Int) -> [Quest] {
guard let entities, !entities.isEmpty else {
// : ,
// (
// )
let fromGoal = defaultGoals(1).first?.sortedQuests ?? []
let rest = IntentStore.quests().filter { quest in
!fromGoal.contains { $0.uuid == quest.uuid }
}
return Array((fromGoal + rest).prefix(defaultCount))
}
return entities.compactMap { quest($0.id) }
}
/// nil( ) .
/// " " .
static func slots<T>(_ items: [T], capacity: Int) -> [T?] {
var result: [T?] = items.prefix(capacity).map { $0 }
while result.count < capacity { result.append(nil) }
return result
}
}