RunActionIntent(위젯 실행 버튼)도 LiveActivityIntent를 채택 — 시스템이 앱 프로세스에서 실행해, 위젯 탭으로 시간 측정을 시작해도 시리 경로와 동일하게 다이나믹 아일랜드가 바로 뜬다. 과거 '위젯 버튼 먹통' 증상 때문에 확장 프로세스 고정을 유지했었으나, 해당 증상은 위젯 재추가·기기 재부팅(캐시)으로 해소된 것으로 확인되어 제약을 풀었다. 실기기에서 응답성 회귀가 보이면 이 인텐트의 LiveActivityIntent 채택 한 줄만 되돌리면 된다 (코드 주석에도 기록). 검증: -intentSmokeTest에 위젯 실행 토글 시나리오 추가(시작 → 토글 종료), 10개 시나리오 전부 통과. Debug·Store 스킴 빌드 성공. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
388 lines
19 KiB
Swift
388 lines
19 KiB
Swift
//
|
|
// DebugSeed.swift
|
|
// Haru_Danim
|
|
//
|
|
// 개발 검증용 데모 데이터. 런치 인자 `-seedDemo YES`가 있을 때만 동작. (DEBUG 전용)
|
|
//
|
|
|
|
#if DEBUG
|
|
import Foundation
|
|
import SwiftData
|
|
|
|
private extension Error {
|
|
/// 인텐트 오류를 사람이 읽을 문구로 (인텐트 오류 타입의 localizedStringResource 우선)
|
|
var asIntentMessage: LocalizedStringResource {
|
|
if let convertible = self as? any CustomLocalizedStringResourceConvertible {
|
|
return convertible.localizedStringResource
|
|
}
|
|
return "\(localizedDescription)"
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// `-intentSmokeTest YES` 런치 인자: 시리 단축어 인텐트 6종의 perform()을 앱 안에서
|
|
/// 직접 실행해 보는 스모크 테스트. 결과는 [IntentTest] 프리픽스로 콘솔에 출력된다
|
|
/// (`xcrun simctl launch --console`로 확인). 데모 데이터(-seedDemo) 위에서 실행할 것.
|
|
@MainActor
|
|
static func runIntentSmokeTestIfRequested() async {
|
|
guard UserDefaults.standard.bool(forKey: "intentSmokeTest") else { return }
|
|
var lines: [String] = []
|
|
func log(_ name: String, _ result: String) {
|
|
print("[IntentTest] \(name): \(result)")
|
|
lines.append("\(name): \(result)")
|
|
}
|
|
defer {
|
|
// CLI에서 읽을 수 있게 결과를 Documents에도 남긴다
|
|
if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
|
try? lines.joined(separator: "\n")
|
|
.write(to: docs.appendingPathComponent("intent-smoke-test.txt"),
|
|
atomically: true, encoding: .utf8)
|
|
}
|
|
}
|
|
func entity(_ name: String) -> ActionEntity? {
|
|
IntentStore.actions().first { $0.name == name }.map(ActionEntity.init)
|
|
}
|
|
guard let running = entity("달리기"), let water = entity("물 마시기") else {
|
|
log("준비", "실패 — 데모 행동 없음 (-seedDemo 필요)")
|
|
return
|
|
}
|
|
let math = DayMath()
|
|
let agg = Aggregator(math: math)
|
|
|
|
// ① 시간 측정 시작 → 진행 세션 생김
|
|
do {
|
|
let intent = StartTimeActionIntent()
|
|
intent.action = running
|
|
_ = try await intent.perform()
|
|
let model = try IntentStore.action(running.id)
|
|
log("측정 시작", model.isRunning ? "OK — 진행 중 세션 생성" : "실패 — 세션 없음")
|
|
} catch { log("측정 시작", "throw: \(String(localized: error.asIntentMessage))") }
|
|
|
|
// ② 이미 측정 중일 때 다시 시작 → 중복 세션이 생기면 안 됨
|
|
do {
|
|
let intent = StartTimeActionIntent()
|
|
intent.action = running
|
|
_ = try await intent.perform()
|
|
let count = try IntentStore.action(running.id).sessions.filter { $0.endAt == nil }.count
|
|
log("중복 시작 방지", count == 1 ? "OK — 진행 세션 1개 유지" : "실패 — 진행 세션 \(count)개")
|
|
} catch { log("중복 시작 방지", "throw: \(String(localized: error.asIntentMessage))") }
|
|
|
|
// ③ 측정 종료 → 진행 세션 없어짐
|
|
do {
|
|
let intent = StopTimeActionIntent()
|
|
intent.action = running
|
|
_ = try await intent.perform()
|
|
let model = try IntentStore.action(running.id)
|
|
log("측정 종료", model.isRunning ? "실패 — 아직 진행 중" : "OK — 종료됨")
|
|
} catch { log("측정 종료", "throw: \(String(localized: error.asIntentMessage))") }
|
|
|
|
// ④ 횟수 추가 → 오늘 누적 +3
|
|
do {
|
|
let before = agg.count(for: try IntentStore.action(water.id), in: math.dayRange(containing: .now))
|
|
let intent = AddCountIntent()
|
|
intent.action = water
|
|
intent.amount = 3
|
|
_ = try await intent.perform()
|
|
let after = agg.count(for: try IntentStore.action(water.id), in: math.dayRange(containing: .now))
|
|
log("횟수 추가", after == before + 3 ? "OK — \(before) → \(after)" : "실패 — \(before) → \(after)")
|
|
} catch { log("횟수 추가", "throw: \(String(localized: error.asIntentMessage))") }
|
|
|
|
// ⑤ 타입 불일치 방어: 시간형 행동에 횟수 추가 → IntentTargetError
|
|
do {
|
|
let intent = AddCountIntent()
|
|
intent.action = running
|
|
intent.amount = 1
|
|
_ = try await intent.perform()
|
|
log("타입 방어", "실패 — 시간형에 횟수 추가가 통과됨")
|
|
} catch { log("타입 방어", "OK — 거부: \(String(localized: error.asIntentMessage))") }
|
|
|
|
// ⑤-2 위젯 실행 버튼(RunActionIntent): 시간형 시작 → 다시 실행하면 종료 토글
|
|
do {
|
|
_ = try await RunActionIntent(actionID: running.id.uuidString).perform()
|
|
let startedOK = try IntentStore.action(running.id).isRunning
|
|
_ = try await RunActionIntent(actionID: running.id.uuidString).perform()
|
|
let stoppedOK = try !IntentStore.action(running.id).isRunning
|
|
log("위젯 실행 토글", startedOK && stoppedOK ? "OK — 시작 후 토글 종료" : "실패 — 시작=\(startedOK) 종료=\(stoppedOK)")
|
|
} catch { log("위젯 실행 토글", "throw: \(String(localized: error.asIntentMessage))") }
|
|
|
|
// ⑥ 조회 인텐트 3종 — throw 없이 완료되는지
|
|
do {
|
|
let total = ActionTotalIntent()
|
|
total.action = running
|
|
total.span = .day
|
|
_ = try await total.perform()
|
|
log("누적값 조회", "OK — 하루 \(Format.durationShort(agg.seconds(for: try IntentStore.action(running.id), in: math.dayRange(containing: .now))))")
|
|
} catch { log("누적값 조회", "throw: \(String(localized: error.asIntentMessage))") }
|
|
do {
|
|
guard let goal = IntentStore.goals().first(where: { !$0.quests.isEmpty }) else {
|
|
log("목표 진행률", "건너뜀 — 다짐 있는 목표 없음"); return
|
|
}
|
|
let intent = GoalProgressIntent()
|
|
intent.goal = GoalEntity(goal)
|
|
intent.span = .day
|
|
_ = try await intent.perform()
|
|
log("목표 진행률", "OK — '\(goal.title)' 하루 \(Int((goal.combinedSpanRatio(.day) * 100).rounded()))%")
|
|
if let quest = goal.sortedQuests.first {
|
|
let questIntent = QuestProgressIntent()
|
|
questIntent.quest = QuestEntity(quest)
|
|
questIntent.span = .week
|
|
_ = try await questIntent.perform()
|
|
log("다짐 진행률", "OK — '\(quest.targetName)' 주간 \(Format.percent(QuestProgress(quest: quest).spanProgress(.week).displayRatio))")
|
|
}
|
|
} catch { log("목표/다짐 진행률", "throw: \(String(localized: error.asIntentMessage))") }
|
|
|
|
// ⑦ 프리미엄 게이트: 잠금 상태에서 실행 → PremiumRequiredError
|
|
let wasPremium = PremiumGate.isPremium
|
|
PremiumGate.cache(false)
|
|
do {
|
|
let intent = StartTimeActionIntent()
|
|
intent.action = running
|
|
_ = try await intent.perform()
|
|
log("프리미엄 게이트", "실패 — 잠금 상태에서 실행됨")
|
|
} catch { log("프리미엄 게이트", "OK — 거부: \(String(localized: error.asIntentMessage))") }
|
|
PremiumGate.cache(wasPremium)
|
|
log("완료", "스모크 테스트 끝")
|
|
}
|
|
|
|
/// `-endGoalYesterday <목표 제목>` 런치 인자: 해당 목표의 종료일을 어제로 변경
|
|
/// (목표 탭을 열지 않아도 실행 시점에 자동 판정되는지 검증용)
|
|
static func endGoalYesterdayIfRequested(context: ModelContext) {
|
|
guard let title = UserDefaults.standard.string(forKey: "endGoalYesterday") else { return }
|
|
let descriptor = FetchDescriptor<Goal>(predicate: #Predicate { $0.title == title })
|
|
guard let goal = try? context.fetch(descriptor).first else { return }
|
|
goal.endDate = Calendar.current.date(byAdding: .day, value: -1, to: .now)
|
|
try? context.save()
|
|
}
|
|
|
|
/// `-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
|