- 배포 타깃 26.0→18.0 (앱·위젯. 워치 10.0 불변) - RadialNavigationView: glassEffect 계열을 @available(iOS 26) 선언 격리 + 18 머티리얼 원 폴백 (배치·연출 동일) - DiaryZoomContainer.Coordinator → 비제네릭 톱레벨 DiaryZoomCoordinator + AnyView 소거 (하한 18 Release wholemodule에서 swift-frontend SILPerformanceInliner 무한 재귀 크래시 실측·우회) - Image(safeSymbol:)/SymbolCompat: 카탈로그의 상위 OS 전용 심볼(18 기준 3개)이 교차 기기에서 빈 아이콘이 되지 않게 사용자 심볼 렌더 48곳+워치 7곳 폴백 - -symbolAuditDump 검증 인자 추가, iOS 18.5 시뮬 QA(빌드·radial 폴백·일기 줌·progressSelfTest 49 ALL PASS) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
399 lines
18 KiB
Swift
399 lines
18 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
|
|
/// 하루 span인데 오늘이 수행일이 아님(요일·날짜 다짐의 쉬는 날, 기간 밖) —
|
|
/// 링·게이지는 0으로 비우고 퍼센트 대신 '수행일 아님'을 표기한다 (③ 문구, ②·④는 흐림)
|
|
let isRestDay: Bool
|
|
/// 주기 몫 완료 상태(§4.2) — 주/월 다짐이 이번 주기 몫을 이미 채웠을 때의 표시 문구
|
|
/// ("이번 주 달성" 등). 하루 span 전용, 해당 없으면 nil. 이 상태면 ratio·displayRatio는
|
|
/// 1로 승격돼 링·바가 가득 찬다 (③은 퍼센트 대신 이 문구, ②는 체크 표시).
|
|
var periodFulfilledLabel: String? = nil
|
|
/// 실행 버튼 대상 (단일 행동 대상 다짐만)
|
|
let runTarget: ActionEntity?
|
|
let isRunning: Bool
|
|
/// 연속 달성 문구 ("연속 3일" 등). 1 미만이거나 특정 기간 다짐이면 nil (표시 생략)
|
|
let streakLabel: String?
|
|
/// span 구간의 실제 누적값 문구 ("1시간 20분" / "3회") — ③ 다짐 진행률 위젯에서
|
|
/// 퍼센트(또는 '한도 지킴') 문구 아래에 표시. 특히 '이하 유지'는 퍼센트가 없어
|
|
/// 이 값이 없으면 지금 얼마나 했는지 알 수 없다. 진행률 캡(주간·월간 하루 기여 제한)과
|
|
/// 무관한 원본 누적값 (단, 마감 시각 다짐은 그날 집계 창까지의 값 — spanRawValue).
|
|
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)
|
|
// 하루 span인데 오늘이 수행일이 아니면 수치 대신 상태를 보여 준다 —
|
|
// 쉬는 날의 기록으로 링이 차 있으면 "오늘 채웠다"는 오해를 유발 (주/월 집계에도 안 잡히는 값)
|
|
let isRestDay = span == .day && !progress.isScheduled(on: now)
|
|
// 주기 몫을 이미 채운 주/월 다짐의 하루 셀은 0% 대신 완료 상태 — 링·바를 가득 채우고
|
|
// 퍼센트 자리는 문구/체크로 대체 (§4.2 주기 몫 완료, 목표 탭 행과 동일 규칙)
|
|
let fulfilled = span == .day && !isRestDay && progress.isPeriodFulfilled(asOf: now)
|
|
let result = isRestDay
|
|
? QuestProgressResult(value: 0, target: quest.targetValue, direction: quest.direction)
|
|
: progress.spanProgress(span, now: now)
|
|
let streak = progress.streak(now: now)
|
|
let action = quest.targetAction
|
|
// 마감 시각 다짐은 집계 창(마감까지) 안의 값만 — 게이지와 라벨이 모순되지 않게
|
|
let rawValue = progress.spanRawValue(span, 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: isRestDay ? 0 : (fulfilled ? 1 : result.ratio),
|
|
displayRatio: isRestDay ? 0 : (fulfilled ? 1 : result.displayRatio),
|
|
isAtMost: quest.direction == .atMost,
|
|
isAchieved: isRestDay ? false : (fulfilled || result.isAchieved),
|
|
isRestDay: isRestDay,
|
|
periodFulfilledLabel: fulfilled ? progress.periodFulfilledLabel : nil,
|
|
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, isRestDay: 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, isRestDay: 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, isRestDay: false, 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, isRestDay: false, 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(safeSymbol: 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(safeSymbol: 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
|
|
}
|
|
}
|