- Add Shared/LocalPrefs.swift: device-local UI preferences stored in App Group UserDefaults (never synced by iCloud), read identically by app, widgets, watch snapshot, and App Intents on the same device - Move to local storage: main-tab action button order (UUID array), pinned goal-progress cards on the main tab, goal quest-list collapse state, and per-action memo-prompt popup — reordering or repinning on iPhone no longer touches the iPad and vice versa - Defense logic: actions not in the local order list (e.g. newly received via CloudKit from another device) are automatically appended to the end, sorted by legacy sortOrder then creation date - One-time migration adopts existing @Model values (sortOrder, showsOnMain, isCollapsed, promptsForNote) into local prefs at launch; model properties stay in place so the CloudKit schema is untouched and records keep syncing - Data remains fully synced: actions, goals, quests, sessions, count entries, statistics, and goal/tag/quest list order are untouched by this change - Verified in simulator: legacy data migrates losslessly, grid renders from local order, unknown action lands at the end; iOS+watch+widgets build clean Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
474 lines
14 KiB
Swift
474 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 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
|
|
/// [레거시] 접힘 상태는 기기별 로컬 설정(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: "날짜 지정")
|
|
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
|
|
|
|
/// 목표 안에서의 표시 순서
|
|
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 String(localized: "대상 없음")
|
|
}
|
|
|
|
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) : String(localized: "\(targetCount)회")
|
|
}
|
|
|
|
/// 주기 요약 문구 (예: "매주 월·수·금")
|
|
var scheduleLabel: 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:
|
|
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))"
|
|
}
|
|
}
|
|
}
|