위젯 동기화 크래시 수정: - DataStore: CloudKit 미러링을 메인 앱 프로세스 전담으로 분리. 위젯 등 앱 확장(.appex)은 CloudKit 엔타이틀먼트가 없고 메모리·수명 제약으로 미러링 설정이 크래시/행을 유발하므로, 확장 프로세스는 항상 같은 App Group 스토어 파일을 로컬 전용(cloudKitDatabase: .none)으로 연다. 확장이 쓴 변경은 메인 앱이 히스토리로 집어 CloudKit에 내보냄 - CloudSyncMonitor 신설: 메인 앱이 NSPersistentStoreRemoteChange (다른 기기의 CloudKit 가져오기·위젯 인텐트의 쓰기)를 디바운스로 받아 라이브 액티비티 → 위젯 타임라인 → 워치 스냅숏 순으로 즉시 갱신 - 인터랙티브 위젯(AppIntent)의 컨테이너 매핑 점검: IntentStore.commit이 저장 직후 reloadAllTimelines를 이미 보장함을 확인·문서화 모음 탭 목표 진행 현황 카드 개선: - '다짐별로 보기'의 상위 3개 제한 제거 — 모든 다짐 확인 가능 - 화면 균형을 위해 접힘/펼침 UX 적용: iPhone은 3개, iPad는 6개까지 접힌 상태로 보여주고 '다짐 N개 더 보기' 버튼으로 카드 안에서 펼침 (카드 탭 이동과 충돌하지 않는 독립 버튼, 스냅 애니메이션) - DebugSeed: 접힘/펼침 검증용 4번째 다짐 추가, -expandGoalCard 인자 추가 검증: iPhone 17 Pro·iPad Pro 11 시뮬레이터에서 접힘/펼침 렌더링과 사이드바 레이아웃 확인, 전체 타깃(iOS·워치·위젯) 빌드 성공 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
244 lines
11 KiB
Swift
244 lines
11 KiB
Swift
//
|
|
// DebugSeed.swift
|
|
// Haru_Danim
|
|
//
|
|
// 개발 검증용 데모 데이터. 런치 인자 `-seedDemo YES`가 있을 때만 동작. (DEBUG 전용)
|
|
//
|
|
|
|
#if DEBUG
|
|
import Foundation
|
|
import SwiftData
|
|
|
|
enum DebugSeed {
|
|
/// `-autoStart <행동이름>` 런치 인자가 있으면 해당 행동의 시간 측정을 시작 (Live Activity 검증용)
|
|
static func autoStartIfRequested(context: ModelContext) {
|
|
guard let name = UserDefaults.standard.string(forKey: "autoStart") else { return }
|
|
let descriptor = FetchDescriptor<Action>(predicate: #Predicate { $0.name == name })
|
|
guard let action = try? context.fetch(descriptor).first else { return }
|
|
if action.runningSession == nil {
|
|
context.insert(TimeSession(action: action, startAt: .now))
|
|
}
|
|
LiveActivityManager.sync(context: context)
|
|
}
|
|
|
|
/// `-pinGoals <N>` 런치 인자: 생성순으로 목표 N개를 모음 탭에 표시 (다중 목표 카드 검증용)
|
|
static func pinGoalsIfRequested(context: ModelContext) {
|
|
let n = UserDefaults.standard.integer(forKey: "pinGoals")
|
|
guard n > 0 else { return }
|
|
let descriptor = FetchDescriptor<Goal>(sortBy: [SortDescriptor(\.createdAt)])
|
|
guard let goals = try? context.fetch(descriptor) else { return }
|
|
// 노출 목표는 기기별 로컬 설정 (LocalPrefs 참고)
|
|
LocalPrefs.defaults.set(
|
|
LocalPrefs.rawValue(goals.prefix(n).map(\.uuid)),
|
|
forKey: LocalPrefsKeys.pinnedGoals
|
|
)
|
|
}
|
|
|
|
static func seedIfRequested(context: ModelContext) {
|
|
guard UserDefaults.standard.bool(forKey: "seedDemo") else { return }
|
|
let existing = (try? context.fetchCount(FetchDescriptor<Action>())) ?? 0
|
|
guard existing == 0 else { return }
|
|
|
|
let study = Tag(name: "공부", colorHex: "#4A7A9D")
|
|
let workout = Tag(name: "운동", colorHex: "#9D5B4A")
|
|
let life = Tag(name: "생활", colorHex: "#2F6B4F")
|
|
context.insert(study)
|
|
context.insert(workout)
|
|
context.insert(life)
|
|
|
|
let reading = Action(name: "독서", symbolName: "book.fill", trackingType: .time, sortOrder: 0)
|
|
reading.tags = [study]
|
|
reading.isFavorite = true
|
|
reading.promptsForNote = true
|
|
let english = Action(name: "영어 공부", symbolName: "graduationcap.fill", trackingType: .time, sortOrder: 1)
|
|
english.tags = [study]
|
|
let running = Action(name: "달리기", symbolName: "figure.run", trackingType: .time, sortOrder: 2)
|
|
running.tags = [workout]
|
|
let pushup = Action(name: "팔굽혀펴기", symbolName: "dumbbell.fill", trackingType: .count, sortOrder: 3)
|
|
pushup.tags = [workout]
|
|
let water = Action(name: "물 마시기", symbolName: "drop.fill", trackingType: .count, sortOrder: 4)
|
|
water.tags = [life]
|
|
water.promptsForNote = true
|
|
// '이하 유지' 다짐 검증용 (하루 1시간 이하 목표, 일부러 초과하는 날 포함)
|
|
let video = Action(name: "영상 시청", symbolName: "play.rectangle.fill", trackingType: .time, sortOrder: 5)
|
|
video.tags = [life]
|
|
for action in [reading, english, running, pushup, water, video] {
|
|
context.insert(action)
|
|
}
|
|
|
|
let now = Date.now
|
|
let cal = Calendar.current
|
|
func at(daysAgo: Int, hour: Int, minute: Int = 0) -> Date {
|
|
let day = cal.date(byAdding: .day, value: -daysAgo, to: now)!
|
|
return cal.date(bySettingHour: hour, minute: minute, second: 0, of: day)!
|
|
}
|
|
|
|
// 시간 기록 (며칠치)
|
|
for daysAgo in 0...6 {
|
|
context.insert(TimeSession(action: english, startAt: at(daysAgo: daysAgo, hour: 8), endAt: at(daysAgo: daysAgo, hour: 9, minute: 10)))
|
|
if daysAgo % 2 == 0 {
|
|
context.insert(TimeSession(action: running, startAt: at(daysAgo: daysAgo, hour: 7), endAt: at(daysAgo: daysAgo, hour: 7, minute: 40)))
|
|
}
|
|
if daysAgo % 3 != 0 {
|
|
context.insert(TimeSession(action: reading, startAt: at(daysAgo: daysAgo, hour: 21), endAt: at(daysAgo: daysAgo, hour: 22, minute: 15)))
|
|
}
|
|
// 영상 시청: 짝수 날은 40분(1시간 이하 유지), 홀수 날은 1시간 30분(초과)
|
|
if daysAgo % 2 == 0 {
|
|
context.insert(TimeSession(action: video, startAt: at(daysAgo: daysAgo, hour: 12, minute: 30), endAt: at(daysAgo: daysAgo, hour: 13, minute: 10)))
|
|
} else {
|
|
context.insert(TimeSession(action: video, startAt: at(daysAgo: daysAgo, hour: 12, minute: 30), endAt: at(daysAgo: daysAgo, hour: 14, minute: 0)))
|
|
}
|
|
}
|
|
// 자정을 걸치는 세션 (하루 경계 분할 확인용) + 메모 표시 확인용
|
|
let crossing = TimeSession(action: reading, startAt: at(daysAgo: 1, hour: 23, minute: 20), endAt: at(daysAgo: 0, hour: 0, minute: 40))
|
|
crossing.note = "자기 전에 소설 읽음. 재밌어서 늦게 잠"
|
|
context.insert(crossing)
|
|
// 진행 중 세션
|
|
context.insert(TimeSession(action: reading, startAt: now.addingTimeInterval(-25 * 60)))
|
|
|
|
// 횟수 기록
|
|
for daysAgo in 0...6 {
|
|
for i in 0..<(3 + daysAgo % 4) {
|
|
context.insert(CountEntry(action: water, timestamp: at(daysAgo: daysAgo, hour: 9 + i * 2)))
|
|
}
|
|
if daysAgo % 2 == 0 {
|
|
context.insert(CountEntry(action: pushup, timestamp: at(daysAgo: daysAgo, hour: 19), amount: 20))
|
|
}
|
|
}
|
|
// 메모가 달린 횟수 기록 (기록 탭 메모 표시 확인용)
|
|
let notedWater = CountEntry(action: water, timestamp: at(daysAgo: 0, hour: 20))
|
|
notedWater.note = "자기 전 물 한 컵"
|
|
context.insert(notedWater)
|
|
|
|
// 목표 + 다짐
|
|
let toeic = Goal(
|
|
title: "토익 700점 이상 받기",
|
|
symbolName: "graduationcap.fill",
|
|
colorHex: "#4A7A9D",
|
|
startDate: cal.date(byAdding: .day, value: -10, to: now)!,
|
|
endDate: cal.date(byAdding: .day, value: 30, to: now)!
|
|
)
|
|
context.insert(toeic)
|
|
let englishQuest = Quest(goal: toeic)
|
|
englishQuest.targetAction = english
|
|
englishQuest.measure = .time
|
|
englishQuest.period = .daily
|
|
englishQuest.targetSeconds = 3600
|
|
englishQuest.direction = .atLeast
|
|
context.insert(englishQuest)
|
|
|
|
let health = Goal(
|
|
title: "건강한 생활 습관 만들기",
|
|
symbolName: "heart.fill",
|
|
colorHex: "#2F6B4F",
|
|
startDate: cal.date(byAdding: .day, value: -20, to: now)!,
|
|
endDate: nil
|
|
)
|
|
// 다짐 3개(이하 유지 포함)라 모음 탭 목표 카드 검증에 적합
|
|
health.showsOnMain = true
|
|
context.insert(health)
|
|
let waterQuest = Quest(goal: health)
|
|
waterQuest.targetAction = water
|
|
waterQuest.measure = .count
|
|
waterQuest.period = .daily
|
|
waterQuest.targetCount = 8
|
|
waterQuest.direction = .atLeast
|
|
context.insert(waterQuest)
|
|
let runQuest = Quest(goal: health)
|
|
runQuest.targetAction = running
|
|
runQuest.measure = .time
|
|
runQuest.period = .daily
|
|
runQuest.scheduleMode = .weekdays
|
|
runQuest.weekdays = [2, 4, 6]
|
|
runQuest.targetSeconds = 30 * 60
|
|
runQuest.direction = .atLeast
|
|
context.insert(runQuest)
|
|
// 다짐 4개째: 모음 탭 목표 카드의 '더 보기' 접힘/펼침 검증용
|
|
let pushupQuest = Quest(goal: health)
|
|
pushupQuest.targetAction = pushup
|
|
pushupQuest.measure = .count
|
|
pushupQuest.period = .daily
|
|
pushupQuest.targetCount = 20
|
|
pushupQuest.direction = .atLeast
|
|
context.insert(pushupQuest)
|
|
// '이하 유지' 다짐: 영상 시청 하루 1시간 이하 (달성률 로직 검증용)
|
|
let videoQuest = Quest(goal: health)
|
|
videoQuest.targetAction = video
|
|
videoQuest.measure = .time
|
|
videoQuest.period = .daily
|
|
videoQuest.targetSeconds = 3600
|
|
videoQuest.direction = .atMost
|
|
context.insert(videoQuest)
|
|
|
|
// 세 번째 목표 (모음 탭 다중 목표 카드 검증용)
|
|
let habit = Goal(
|
|
title: "매일 밤 독서 습관",
|
|
symbolName: "book.fill",
|
|
colorHex: "#9D5B4A",
|
|
startDate: cal.date(byAdding: .day, value: -5, to: now)!,
|
|
endDate: cal.date(byAdding: .day, value: 60, to: now)!
|
|
)
|
|
context.insert(habit)
|
|
let readingQuest = Quest(goal: habit)
|
|
readingQuest.targetAction = reading
|
|
readingQuest.measure = .time
|
|
readingQuest.period = .daily
|
|
readingQuest.targetSeconds = 45 * 60
|
|
readingQuest.direction = .atLeast
|
|
context.insert(readingQuest)
|
|
|
|
// 네 번째 목표 (모음 탭 목표 카드 개수 제한 해제 검증용)
|
|
let run = Goal(
|
|
title: "꾸준한 달리기",
|
|
symbolName: "figure.run",
|
|
colorHex: "#4A7A9D",
|
|
startDate: cal.date(byAdding: .day, value: -14, to: now)!,
|
|
endDate: cal.date(byAdding: .day, value: 45, to: now)!
|
|
)
|
|
context.insert(run)
|
|
let weeklyRunQuest = Quest(goal: run)
|
|
weeklyRunQuest.targetAction = running
|
|
weeklyRunQuest.measure = .time
|
|
weeklyRunQuest.period = .weekly
|
|
weeklyRunQuest.targetSeconds = 2 * 3600
|
|
weeklyRunQuest.direction = .atLeast
|
|
context.insert(weeklyRunQuest)
|
|
|
|
// 목표 탭 정렬 순서 부여
|
|
for (index, goal) in [toeic, health, habit, run].enumerated() {
|
|
goal.sortOrder = index
|
|
}
|
|
|
|
// 완료된 목표 (완료 목표 분리 화면 검증용 — 달성 1개 + 미달성 1개)
|
|
let sleep = Goal(
|
|
title: "일찍 자기 챌린지",
|
|
symbolName: "moon.zzz.fill",
|
|
colorHex: "#2F6B4F",
|
|
startDate: cal.date(byAdding: .day, value: -60, to: now)!,
|
|
endDate: cal.date(byAdding: .day, value: -30, to: now)!
|
|
)
|
|
sleep.status = .achieved
|
|
sleep.sortOrder = 4
|
|
context.insert(sleep)
|
|
let diet = Goal(
|
|
title: "한 달 다이어트",
|
|
symbolName: "fork.knife",
|
|
colorHex: "#9D5B4A",
|
|
startDate: cal.date(byAdding: .day, value: -50, to: now)!,
|
|
endDate: cal.date(byAdding: .day, value: -20, to: now)!
|
|
)
|
|
diet.status = .notAchieved
|
|
diet.sortOrder = 5
|
|
context.insert(diet)
|
|
|
|
// 기기별 로컬 UI 설정 시드: 메모 창 행동, 모음 탭 노출 목표 (LocalPrefs 참고)
|
|
LocalPrefs.setPromptsForNote(reading.uuid, enabled: true)
|
|
LocalPrefs.setPromptsForNote(water.uuid, enabled: true)
|
|
LocalPrefs.defaults.set(
|
|
LocalPrefs.rawValue([health.uuid]),
|
|
forKey: LocalPrefsKeys.pinnedGoals
|
|
)
|
|
}
|
|
}
|
|
#endif
|