mycode/myApp/HaruDanim/Shared/Models.swift
songyc macbook 8b1236d6a9 feat(1.5-p9): 건강 다짐 표시 색 — Quest.healthColorHex(기본 분홍=빈 값), 편집기 색 선택, 빌드 7
- 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
2026-08-23 11:18:57 +09:00

532 lines
18 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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))"
}
}
}