mycode/myApp/HaruDanim/IOS/Core/DebugSeed.swift
songyc macbook 076dad26d8 feat(widget): adopt LiveActivityIntent on the widget run button too
RunActionIntent(위젯 실행 버튼)도 LiveActivityIntent를 채택 — 시스템이
앱 프로세스에서 실행해, 위젯 탭으로 시간 측정을 시작해도 시리 경로와
동일하게 다이나믹 아일랜드가 바로 뜬다.

과거 '위젯 버튼 먹통' 증상 때문에 확장 프로세스 고정을 유지했었으나,
해당 증상은 위젯 재추가·기기 재부팅(캐시)으로 해소된 것으로 확인되어
제약을 풀었다. 실기기에서 응답성 회귀가 보이면 이 인텐트의
LiveActivityIntent 채택 한 줄만 되돌리면 된다 (코드 주석에도 기록).

검증: -intentSmokeTest에 위젯 실행 토글 시나리오 추가(시작 → 토글 종료),
10개 시나리오 전부 통과. Debug·Store 스킴 빌드 성공.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-13 21:37:42 +09:00

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