- targetColor 건강 분기: 빈 값=시스템 분홍(다크 적응), 지정 시 hex — 단일 지점이라 목표 탭·모음·일기·위젯·워치 자동 반영 - 편집기 건강 섹션 '다짐 색'(프리셋 12+피커), 저장 시 기본색은 ''로·행동/꼬리표 전환 시 '' 정규화 - 타임테이블 블록 색과 별개, 시드 걸음 다짐에 #3E7CB1(대비 렌더 검증+스키마 내보내기 트리거) - QA: 일기 목표 카드에서 걸음=파랑·나머지=분홍 대비 실측, 편집기 렌더, 61건 ALL PASS, 3종 빌드 그린, l10n 0/0 - ⚠️ CloudKit 스키마 추가 — 콘솔 Deploy가 빌드 7 설치보다 선행 필수(CLAUDE.md·plan §12 후속 3) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
532 lines
18 KiB
Swift
532 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
|
||
/// 건강 다짐의 표시 색 "#RRGGBB" (1.5(7)) — 빈 값 = 기본(분홍). 행동·꼬리표 다짐은
|
||
/// 대상의 색을 쓰므로 항상 빈 값(저장 시 정규화). ⚠️ 새 필드 = CloudKit 스키마 추가 —
|
||
/// 배포 절차(개발 내보내기→콘솔 Deploy)가 새 빌드 설치보다 먼저여야 함(§3, plan §12)
|
||
var healthColorHex: String = ""
|
||
|
||
/// 목표 안에서의 표시 순서
|
||
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 }
|
||
// 건강 다짐: 사용자 지정 색(1.5(7)), 없으면 기본 분홍 — 이 접근자가 단일 공급
|
||
// 지점이라 목표 탭·모음 카드·일기·위젯·워치 스냅숏이 전부 함께 따라온다
|
||
if healthTarget != nil {
|
||
return healthColorHex.isEmpty ? .pink : Color(hex: healthColorHex)
|
||
}
|
||
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))"
|
||
}
|
||
}
|
||
}
|