419 lines
11 KiB
Swift
419 lines
11 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 {
|
|
var name: String = ""
|
|
var colorHex: String = "#2F6B4F"
|
|
/// 행동 탭 그룹/꼬리표 탭 목록의 표시 순서
|
|
var sortOrder: Int = 0
|
|
var createdAt: Date = Date()
|
|
|
|
var actions: [Action] = []
|
|
|
|
@Relationship(deleteRule: .nullify, inverse: \Quest.targetTag)
|
|
var quests: [Quest] = []
|
|
|
|
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 {
|
|
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()
|
|
|
|
@Relationship(inverse: \Tag.actions)
|
|
var tags: [Tag] = []
|
|
|
|
@Relationship(deleteRule: .cascade, inverse: \TimeSession.action)
|
|
var sessions: [TimeSession] = []
|
|
|
|
@Relationship(deleteRule: .cascade, inverse: \CountEntry.action)
|
|
var countEntries: [CountEntry] = []
|
|
|
|
@Relationship(deleteRule: .nullify, inverse: \Quest.targetAction)
|
|
var quests: [Quest] = []
|
|
|
|
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 {
|
|
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
|
|
/// 모음 탭 상단에 진행 현황을 표시할 목표 (설정에서 선택, 최대 1개)
|
|
var showsOnMain: Bool = false
|
|
var createdAt: Date = Date()
|
|
|
|
@Relationship(deleteRule: .cascade, inverse: \Quest.goal)
|
|
var quests: [Quest] = []
|
|
|
|
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 {
|
|
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))"
|
|
}
|
|
}
|
|
}
|