mycode/myApp/HaruDanim/Shared/Models.swift
songyc macbook f468fdf296 feat(data): SwiftData 저장소를 App Group으로 이관하고 CloudKit 동기화 지원
- DataStore: App Group 컨테이너의 HaruDanim.store 사용, 기존 default.store 1회 자동 이관
- 프리미엄 + 동기화 토글 시 CloudKit(.private) 구성으로 컨테이너 생성, 실패하면 로컬 폴백
- CloudKit 요건(모든 관계 optional)에 맞춰 to-many 관계를 optional 저장 + 기존 이름
  접근자로 리팩터링 (originalName 매핑으로 기존 데이터 유지)
- Tag/Action/Goal/Quest에 uuid 추가 (시리 단축어·위젯·워치 식별자) + 중복 보정
- 모델 레이어(Models/Settings/DayMath/QuestProgress/Formatters/Theme)를 Shared/로 이동
  — 위젯 확장과 공유
- 집계 설정(주 시작 요일·하루 시작 시간·대표 시간·짧은 기록 무시)을 App Group defaults로
  이관해 위젯·워치와 공유, 설정의 '기기 간 동기화' 토글 활성화
- Goal.combinedSpanRatio 공용화 (모음 탭 카드·위젯·시리가 동일 산식 사용)
- DEBUG 검증 인자: -cloudSync YES, -settingsScrollPremium YES

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-10 08:59:15 +09:00

469 lines
14 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 "시간 측정"
case .count: return "횟수 기록"
}
}
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
var sortOrder: Int = 0
/// ""
var isFavorite: Bool = false
/// ( / )
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 "진행 중"
case .achieved: return "달성 완료"
case .notAchieved: return "미달성 종료"
}
}
}
@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
///
var isCollapsed: Bool = false
/// ( )
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 "하루 단위"
case .weekly: return "일주일 단위"
case .monthly: return "한 달 단위"
case .custom: return "특정 기간"
}
}
}
///
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 "매일"
case .weekdays: return "요일 지정"
case .monthDays: return "날짜 지정"
case .ordinalWeekday: return "몇째 주 요일"
}
}
}
enum QuestDirection: String, CaseIterable, Identifiable {
case atLeast
case atMost
var id: String { rawValue }
var label: String {
switch self {
case .atLeast: return "이상 달성"
case .atMost: return "이하 유지"
}
}
}
@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
///
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 }
}
var targetName: String {
if let targetAction { return targetAction.name }
if let targetTag { return "#\(targetTag.name)" }
return "대상 없음"
}
var targetSymbol: String {
if let targetAction { return targetAction.symbolName }
return "tag.fill"
}
var targetColor: Color {
if let targetAction { return targetAction.color }
if let targetTag { return targetTag.color }
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 {
measure == .time ? Format.durationShort(targetSeconds) : "\(targetCount)"
}
/// (: " ··")
var scheduleLabel: String {
switch period {
case .daily:
switch scheduleMode {
case .everyDay:
return "매일"
case .weekdays:
let names = weekdays.sorted().map { Format.weekdayShort($0) }
return "매주 " + names.joined(separator: "·")
case .monthDays:
let names = monthDays.sorted().map { "\($0)" }
return "매달 " + names.joined(separator: ", ")
case .ordinalWeekday:
return "매달 \(ordinalWeek)째 주 \(Format.weekdayShort(ordinalWeekday))요일"
}
case .weekly:
return "매주"
case .monthly:
return "매달"
case .custom:
guard let customStart, let customEnd else { return "특정 기간" }
return "\(Format.shortDate(customStart)) ~ \(Format.shortDate(customEnd))"
}
}
}