- 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
469 lines
14 KiB
Swift
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))"
|
|
}
|
|
}
|
|
}
|