- Quest 필드 3종(healthMetricRaw·수면 구간, CloudKit 기본값 규칙)+행동/꼬리표 우선 정규화 - HealthQuestTarget/HealthWorkoutKind(종목 큐레이션)·HealthCache 공유 계층 이전(위젯 읽기) - QuestProgress 값 공급 단일 지점 3곳(value·spanValue·perDayValues)만 분기 — 기존 49건 무변경 그린 - 편집기 대상 3유형째(지표·종목·수면 구간 휠·단위별 목표 입력·프리미엄 게이트·하루 전용) - §8ⓒⓕ: 데이터 없는 기기 모수 제외+'데이터 없음' 표기(목표 행·모음 카드·위젯 ②③④) +자동/수동 판정 보류, 위젯 값 라벨 지표 단위화, HealthStore 백필(400일)·운동 조회·부트스트랩 - 검증: progressSelfTest 61건(H 시리즈 12 신규)·20·27·10 ALL PASS(26.5+18.5), Debug/Store/워치 빌드, 시각 QA(목표 행·편집기·타일). 신규 인자 3종 §14 기록 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
524 lines
18 KiB
Swift
524 lines
18 KiB
Swift
//
|
|
// Models.swift
|
|
// Haru_Danim
|
|
//
|
|
// SwiftData 데이터 모델 정의 (CLAUDE.md §10 참고)
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
import SwiftUI
|
|
|
|
// MARK: - 추적 방식
|
|
|
|
enum TrackingType: String, CaseIterable, Identifiable {
|
|
case time
|
|
case count
|
|
|
|
var id: String { rawValue }
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .time: return String(localized: "시간 측정")
|
|
case .count: return String(localized: "횟수 기록")
|
|
}
|
|
}
|
|
|
|
var symbol: String {
|
|
switch self {
|
|
case .time: return "timer"
|
|
case .count: return "number"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 꼬리표 (Tag)
|
|
|
|
@Model
|
|
final class Tag {
|
|
/// 워치 연동 등에서 꼬리표를 식별하는 안정적인 ID
|
|
var uuid: UUID = UUID()
|
|
var name: String = ""
|
|
var colorHex: String = "#2F6B4F"
|
|
/// 행동 탭 그룹/꼬리표 탭 목록의 표시 순서
|
|
var sortOrder: Int = 0
|
|
var createdAt: Date = Date()
|
|
|
|
// CloudKit 동기화 요건: 모든 관계는 optional이어야 함 → 저장은 optional,
|
|
// 사용처는 아래 non-optional 접근자를 통해 기존 이름 그대로 사용 (originalName으로 기존 데이터 유지)
|
|
@Relationship(originalName: "actions")
|
|
var actionsStorage: [Action]? = []
|
|
|
|
@Relationship(deleteRule: .nullify, originalName: "quests", inverse: \Quest.targetTag)
|
|
var questsStorage: [Quest]? = []
|
|
|
|
var actions: [Action] {
|
|
get { actionsStorage ?? [] }
|
|
set { actionsStorage = newValue }
|
|
}
|
|
|
|
var quests: [Quest] {
|
|
get { questsStorage ?? [] }
|
|
set { questsStorage = newValue }
|
|
}
|
|
|
|
init(name: String, colorHex: String) {
|
|
self.name = name
|
|
self.colorHex = colorHex
|
|
self.createdAt = .now
|
|
}
|
|
}
|
|
|
|
extension Tag {
|
|
var color: Color { Color(hex: colorHex) }
|
|
|
|
var sortedActions: [Action] {
|
|
actions.sorted { $0.sortOrder < $1.sortOrder }
|
|
}
|
|
}
|
|
|
|
// MARK: - 행동 (Action)
|
|
|
|
@Model
|
|
final class Action {
|
|
/// 시리 단축어·위젯에서 행동을 식별하는 안정적인 ID
|
|
var uuid: UUID = UUID()
|
|
var name: String = ""
|
|
var symbolName: String = "star.fill"
|
|
var trackingTypeRaw: String = TrackingType.time.rawValue
|
|
/// [레거시] 배치 순서는 기기별 로컬 설정(LocalPrefs.actionOrder)으로 이전.
|
|
/// CloudKit 스키마 유지를 위해 남겨 두며, 로컬 순서가 없는 기기의 폴백 정렬로만 쓰인다.
|
|
var sortOrder: Int = 0
|
|
/// 행동 탭에서 최상단 "즐겨찾기" 섹션에 표시
|
|
var isFavorite: Bool = false
|
|
/// [레거시] 메모 창 표시 여부는 기기별 로컬 설정(LocalPrefs.notePromptActions)으로 이전.
|
|
/// CloudKit 스키마 유지를 위해 남겨 두며, 최초 1회 이관 시에만 읽는다.
|
|
var promptsForNote: Bool = false
|
|
var createdAt: Date = Date()
|
|
|
|
// CloudKit 요건에 따라 관계는 optional 저장 + non-optional 접근자 (originalName으로 기존 데이터 유지)
|
|
@Relationship(originalName: "tags", inverse: \Tag.actionsStorage)
|
|
var tagsStorage: [Tag]? = []
|
|
|
|
@Relationship(deleteRule: .cascade, originalName: "sessions", inverse: \TimeSession.action)
|
|
var sessionsStorage: [TimeSession]? = []
|
|
|
|
@Relationship(deleteRule: .cascade, originalName: "countEntries", inverse: \CountEntry.action)
|
|
var countEntriesStorage: [CountEntry]? = []
|
|
|
|
@Relationship(deleteRule: .nullify, originalName: "quests", inverse: \Quest.targetAction)
|
|
var questsStorage: [Quest]? = []
|
|
|
|
var tags: [Tag] {
|
|
get { tagsStorage ?? [] }
|
|
set { tagsStorage = newValue }
|
|
}
|
|
|
|
var sessions: [TimeSession] {
|
|
get { sessionsStorage ?? [] }
|
|
set { sessionsStorage = newValue }
|
|
}
|
|
|
|
var countEntries: [CountEntry] {
|
|
get { countEntriesStorage ?? [] }
|
|
set { countEntriesStorage = newValue }
|
|
}
|
|
|
|
var quests: [Quest] {
|
|
get { questsStorage ?? [] }
|
|
set { questsStorage = newValue }
|
|
}
|
|
|
|
init(name: String, symbolName: String, trackingType: TrackingType, sortOrder: Int) {
|
|
self.name = name
|
|
self.symbolName = symbolName
|
|
self.trackingTypeRaw = trackingType.rawValue
|
|
self.sortOrder = sortOrder
|
|
self.createdAt = .now
|
|
}
|
|
}
|
|
|
|
extension Action {
|
|
var trackingType: TrackingType {
|
|
get { TrackingType(rawValue: trackingTypeRaw) ?? .time }
|
|
set { trackingTypeRaw = newValue.rawValue }
|
|
}
|
|
|
|
/// 행동 버튼의 색: 지정된 꼬리표(가장 먼저 만든 것)의 색을 따른다.
|
|
var color: Color {
|
|
tags.sorted { $0.createdAt < $1.createdAt }.first?.color ?? AppTheme.green
|
|
}
|
|
|
|
var sortedTags: [Tag] {
|
|
tags.sorted { $0.createdAt < $1.createdAt }
|
|
}
|
|
|
|
/// 진행 중(종료되지 않은) 세션
|
|
var runningSession: TimeSession? {
|
|
sessions.first { $0.endAt == nil }
|
|
}
|
|
|
|
var isRunning: Bool { runningSession != nil }
|
|
}
|
|
|
|
// MARK: - 시간 기록 (TimeSession)
|
|
|
|
@Model
|
|
final class TimeSession {
|
|
var startAt: Date = Date()
|
|
var endAt: Date?
|
|
/// 측정 종료 시 남기는 메모 (선택)
|
|
var note: String = ""
|
|
|
|
var action: Action?
|
|
|
|
init(action: Action, startAt: Date, endAt: Date? = nil) {
|
|
self.action = action
|
|
self.startAt = startAt
|
|
self.endAt = endAt
|
|
}
|
|
}
|
|
|
|
extension TimeSession {
|
|
func duration(asOf now: Date = .now) -> TimeInterval {
|
|
max(0, (endAt ?? now).timeIntervalSince(startAt))
|
|
}
|
|
}
|
|
|
|
// MARK: - 횟수 기록 (CountEntry)
|
|
|
|
@Model
|
|
final class CountEntry {
|
|
var timestamp: Date = Date()
|
|
var amount: Int = 1
|
|
/// 기록에 남기는 메모 (선택)
|
|
var note: String = ""
|
|
|
|
var action: Action?
|
|
|
|
init(action: Action, timestamp: Date, amount: Int = 1) {
|
|
self.action = action
|
|
self.timestamp = timestamp
|
|
self.amount = amount
|
|
}
|
|
}
|
|
|
|
// MARK: - 목표 (Goal)
|
|
|
|
enum GoalStatus: String, CaseIterable {
|
|
case inProgress
|
|
case achieved
|
|
case notAchieved
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .inProgress: return String(localized: "진행 중")
|
|
case .achieved: return String(localized: "달성 완료")
|
|
case .notAchieved: return String(localized: "미달성 종료")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Model
|
|
final class Goal {
|
|
/// 시리 단축어·위젯에서 목표를 식별하는 안정적인 ID
|
|
var uuid: UUID = UUID()
|
|
var title: String = ""
|
|
var symbolName: String = "flag.fill"
|
|
var colorHex: String = "#2F6B4F"
|
|
var startDate: Date = Date()
|
|
var endDate: Date?
|
|
var statusRaw: String = GoalStatus.inProgress.rawValue
|
|
/// 달성 판정 기준(%). 목표가 끝날 때 다짐들의 평균 달성률이 이 값 이상이면 '달성 완료'.
|
|
/// 100이면 기존과 동일하게 전체 달성이어야 함 (기본값 — CloudKit 요건).
|
|
var achieveThresholdPercent: Int = 100
|
|
/// [레거시] 접힘 상태는 기기별 로컬 설정(LocalPrefs.collapsedGoals)으로 이전.
|
|
/// CloudKit 스키마 유지를 위해 남겨 두며, 최초 1회 이관 시에만 읽는다.
|
|
var isCollapsed: Bool = false
|
|
/// [레거시] 노출 여부는 기기별 로컬 설정(LocalPrefs.pinnedGoals)으로 이전.
|
|
/// CloudKit 스키마 유지를 위해 남겨 두며, 최초 1회 이관 시에만 읽는다.
|
|
var showsOnMain: Bool = false
|
|
/// 목표 탭 리스트 정렬 순서 (드래그로 변경, 값이 같으면 createdAt 순)
|
|
var sortOrder: Int = 0
|
|
var createdAt: Date = Date()
|
|
|
|
// CloudKit 요건에 따라 관계는 optional 저장 + non-optional 접근자 (originalName으로 기존 데이터 유지)
|
|
@Relationship(deleteRule: .cascade, originalName: "quests", inverse: \Quest.goal)
|
|
var questsStorage: [Quest]? = []
|
|
|
|
var quests: [Quest] {
|
|
get { questsStorage ?? [] }
|
|
set { questsStorage = newValue }
|
|
}
|
|
|
|
init(title: String, symbolName: String, colorHex: String, startDate: Date, endDate: Date?) {
|
|
self.title = title
|
|
self.symbolName = symbolName
|
|
self.colorHex = colorHex
|
|
self.startDate = startDate
|
|
self.endDate = endDate
|
|
self.statusRaw = GoalStatus.inProgress.rawValue
|
|
self.createdAt = .now
|
|
}
|
|
}
|
|
|
|
extension Goal {
|
|
var status: GoalStatus {
|
|
get { GoalStatus(rawValue: statusRaw) ?? .inProgress }
|
|
set { statusRaw = newValue.rawValue }
|
|
}
|
|
|
|
var color: Color { Color(hex: colorHex) }
|
|
|
|
var sortedQuests: [Quest] {
|
|
quests.sorted {
|
|
if $0.sortOrder != $1.sortOrder { return $0.sortOrder < $1.sortOrder }
|
|
return $0.createdAt < $1.createdAt
|
|
}
|
|
}
|
|
|
|
/// 종료일이 지났는지 (종료일 당일까지는 진행 중으로 취급)
|
|
func isPastEndDate(asOf now: Date = .now, calendar: Calendar = .current) -> Bool {
|
|
guard let endDate else { return false }
|
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: endDate))!
|
|
return now >= endOfDay
|
|
}
|
|
|
|
/// 다짐이 하나도 없는 채 종료일이 도래 → "확인 필요" 상태 (CLAUDE.md §6.4-4)
|
|
func needsConfirmation(asOf now: Date = .now) -> Bool {
|
|
status == .inProgress && quests.isEmpty && isPastEndDate(asOf: now)
|
|
}
|
|
|
|
/// 종료일이 있는 목표의 기간 경과 진행률 (0...1)
|
|
func dateProgress(asOf now: Date = .now) -> Double? {
|
|
guard let endDate, endDate > startDate else { return nil }
|
|
let total = endDate.timeIntervalSince(startDate)
|
|
let elapsed = now.timeIntervalSince(startDate)
|
|
return min(max(elapsed / total, 0), 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - 다짐 (Quest)
|
|
|
|
enum QuestPeriod: String, CaseIterable, Identifiable {
|
|
case daily
|
|
case weekly
|
|
case monthly
|
|
case custom
|
|
|
|
var id: String { rawValue }
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .daily: return String(localized: "하루 단위")
|
|
case .weekly: return String(localized: "일주일 단위")
|
|
case .monthly: return String(localized: "한 달 단위")
|
|
case .custom: return String(localized: "특정 기간")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 하루 단위 다짐의 적용일 방식
|
|
enum QuestScheduleMode: String, CaseIterable, Identifiable {
|
|
case everyDay
|
|
case weekdays
|
|
case monthDays
|
|
case ordinalWeekday
|
|
|
|
var id: String { rawValue }
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .everyDay: return String(localized: "매일")
|
|
case .weekdays: return String(localized: "요일 지정")
|
|
case .monthDays: return String(localized: "날짜 지정")
|
|
// 구현은 weekdayOrdinal(그 달의 n번째 ◯요일) — '셋째 주'와 다를 수 있어 문구를 정확히 맞춘다
|
|
case .ordinalWeekday: return String(localized: "몇 번째 요일")
|
|
}
|
|
}
|
|
}
|
|
|
|
enum QuestDirection: String, CaseIterable, Identifiable {
|
|
case atLeast
|
|
case atMost
|
|
|
|
var id: String { rawValue }
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .atLeast: return String(localized: "이상 달성")
|
|
case .atMost: return String(localized: "이하 유지")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Model
|
|
final class Quest {
|
|
/// 시리 단축어·위젯에서 다짐을 식별하는 안정적인 ID
|
|
var uuid: UUID = UUID()
|
|
var goal: Goal?
|
|
|
|
/// 대상: 행동 또는 꼬리표 중 하나
|
|
var targetAction: Action?
|
|
var targetTag: Tag?
|
|
|
|
/// 측정 기준 (행동 대상이면 그 행동의 추적 방식, 꼬리표 대상이면 생성 시 선택)
|
|
var measureRaw: String = TrackingType.time.rawValue
|
|
|
|
var periodRaw: String = QuestPeriod.daily.rawValue
|
|
var scheduleModeRaw: String = QuestScheduleMode.everyDay.rawValue
|
|
/// Calendar.weekday 값 (1=일 ... 7=토)
|
|
var weekdays: [Int] = []
|
|
/// 1~31
|
|
var monthDays: [Int] = []
|
|
/// 몇째 주 (1~5)
|
|
var ordinalWeek: Int = 1
|
|
/// Calendar.weekday 값
|
|
var ordinalWeekday: Int = 2
|
|
|
|
var customStart: Date?
|
|
var customEnd: Date?
|
|
|
|
/// 시간 목표(초) — measure == .time 일 때 사용
|
|
var targetSeconds: Double = 3600
|
|
/// 횟수 목표 — measure == .count 일 때 사용
|
|
var targetCount: Int = 1
|
|
var directionRaw: String = QuestDirection.atLeast.rawValue
|
|
/// 하루 단위 다짐의 마감 시각 (자정 기준 분, -1 = 마감 없음 — CloudKit 요건상 기본값).
|
|
/// 지정되면 그날의 집계 창이 [하루 시작, 마감]으로 줄어든다 (QuestProgress.dayMeasurementRange)
|
|
var deadlineMinutes: Int = -1
|
|
|
|
/// 건강 다짐 대상 (1.5) — HealthMetric raw 또는 "workout.<종목>". 빈 값 = 건강 다짐 아님.
|
|
/// 값이 있어도 행동/꼬리표가 지정돼 있으면 무시된다 (구버전 편집이 만든 이중 상태의
|
|
/// 정규화 — 사람의 최신 의도 우선, plan §3.4). 1.4 이하 기기에서는 '대상 없음'으로 안전 강등
|
|
var healthMetricRaw: String = ""
|
|
/// 수면 다짐의 수면 구간 (자정 기준 분) — 기본 전날 21:00 ~ 당일 09:00 (12시간).
|
|
/// 구간 '끝'이 속한 논리적 하루에 수면 전체가 귀속된다 (깨어난 날 규칙 — plan §3.4)
|
|
var sleepWindowStartMinutes: Int = 1260
|
|
var sleepWindowEndMinutes: Int = 540
|
|
|
|
/// 목표 안에서의 표시 순서
|
|
var sortOrder: Int = 0
|
|
var createdAt: Date = Date()
|
|
|
|
init(goal: Goal) {
|
|
self.goal = goal
|
|
self.createdAt = .now
|
|
}
|
|
}
|
|
|
|
extension Quest {
|
|
var measure: TrackingType {
|
|
get { TrackingType(rawValue: measureRaw) ?? .time }
|
|
set { measureRaw = newValue.rawValue }
|
|
}
|
|
|
|
var period: QuestPeriod {
|
|
get { QuestPeriod(rawValue: periodRaw) ?? .daily }
|
|
set { periodRaw = newValue.rawValue }
|
|
}
|
|
|
|
var scheduleMode: QuestScheduleMode {
|
|
get { QuestScheduleMode(rawValue: scheduleModeRaw) ?? .everyDay }
|
|
set { scheduleModeRaw = newValue.rawValue }
|
|
}
|
|
|
|
var direction: QuestDirection {
|
|
get { QuestDirection(rawValue: directionRaw) ?? .atLeast }
|
|
set { directionRaw = newValue.rawValue }
|
|
}
|
|
|
|
/// 건강 다짐 대상 (1.5) — 행동/꼬리표가 없을 때만 유효 (이중 상태 정규화)
|
|
var healthTarget: HealthQuestTarget? {
|
|
guard targetAction == nil, targetTag == nil else { return nil }
|
|
return HealthQuestTarget(raw: healthMetricRaw)
|
|
}
|
|
|
|
var isHealthQuest: Bool { healthTarget != nil }
|
|
|
|
var targetName: String {
|
|
if let targetAction { return targetAction.name }
|
|
if let targetTag { return "#\(targetTag.name)" }
|
|
if let healthTarget { return healthTarget.name }
|
|
return String(localized: "대상 없음")
|
|
}
|
|
|
|
var targetSymbol: String {
|
|
if let targetAction { return targetAction.symbolName }
|
|
if targetTag == nil, let healthTarget { return healthTarget.symbolName }
|
|
return "tag.fill"
|
|
}
|
|
|
|
var targetColor: Color {
|
|
if let targetAction { return targetAction.color }
|
|
if let targetTag { return targetTag.color }
|
|
if healthTarget != nil { return .pink }
|
|
return AppTheme.green
|
|
}
|
|
|
|
/// 다짐이 집계 대상으로 삼는 행동들
|
|
var targetActions: [Action] {
|
|
if let targetAction { return [targetAction] }
|
|
if let targetTag { return targetTag.actions.filter { $0.trackingType == measure } }
|
|
return []
|
|
}
|
|
|
|
/// 목표량 (시간이면 초, 횟수면 개수)
|
|
var targetValue: Double {
|
|
measure == .time ? targetSeconds : Double(targetCount)
|
|
}
|
|
|
|
var targetValueLabel: String {
|
|
// 건강 다짐은 지표 고유 단위(보·kcal·km·L 등)로 표기 — "8000회" 같은 오표기 방지 (1.5)
|
|
if let healthTarget { return healthTarget.valueLabel(targetValue) }
|
|
return measure == .time ? Format.durationShort(targetSeconds) : String(localized: "\(targetCount)회")
|
|
}
|
|
|
|
/// 하루 단위 다짐에 마감 시각이 지정돼 있는지.
|
|
/// 주기를 바꿔 값이 남아 있어도(편집 잔존·동기화) 하루 단위가 아니면 무시하고,
|
|
/// 범위 밖 값(손상 데이터)도 없음으로 취급한다.
|
|
var hasDeadline: Bool {
|
|
period == .daily && (0...1439).contains(deadlineMinutes)
|
|
}
|
|
|
|
/// 마감 시각 표시 문구 ("오전 8:00까지"). 마감이 없으면 nil
|
|
var deadlineLabel: String? {
|
|
guard hasDeadline else { return nil }
|
|
let cal = Calendar.current
|
|
let time = cal.date(byAdding: .minute, value: deadlineMinutes, to: cal.startOfDay(for: .now)) ?? .now
|
|
return String(localized: "\(Format.time(time))까지")
|
|
}
|
|
|
|
/// 주기 요약 문구 (예: "매주 월·수·금", 마감이 있으면 "매일 · 오전 8:00까지")
|
|
var scheduleLabel: String {
|
|
guard let deadlineLabel else { return baseScheduleLabel }
|
|
return "\(baseScheduleLabel) · \(deadlineLabel)"
|
|
}
|
|
|
|
private var baseScheduleLabel: String {
|
|
switch period {
|
|
case .daily:
|
|
switch scheduleMode {
|
|
case .everyDay:
|
|
return String(localized: "매일")
|
|
case .weekdays:
|
|
let names = weekdays.sorted().map { Format.weekdayShort($0) }
|
|
return String(localized: "매주 \(names.joined(separator: "·"))")
|
|
case .monthDays:
|
|
let names = monthDays.sorted().map { String(localized: "\($0)일") }
|
|
return String(localized: "매달 \(names.joined(separator: ", "))")
|
|
case .ordinalWeekday:
|
|
// weekdayOrdinal 기준이므로 "n째 주"가 아니라 "n번째 ◯요일" (예: 매달 3번째 화요일)
|
|
return String(localized: "매달 \(ordinalWeek)번째 \(Format.weekdayShort(ordinalWeekday))요일")
|
|
}
|
|
case .weekly:
|
|
return String(localized: "매주")
|
|
case .monthly:
|
|
return String(localized: "매달")
|
|
case .custom:
|
|
guard let customStart, let customEnd else { return String(localized: "특정 기간") }
|
|
return "\(Format.shortDate(customStart)) ~ \(Format.shortDate(customEnd))"
|
|
}
|
|
}
|
|
}
|