From 18f49bc0db44be5100e98946bb37ed0621e022fa Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Fri, 10 Jul 2026 08:59:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(intents):=20=EC=8B=9C=EB=A6=AC=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=95=EC=96=B4=EC=9A=A9=20App=20Intents=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(CLAUDE.md=20=C2=A710)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 엔티티: ActionEntity/GoalEntity/QuestEntity (목록 제안 + 이름 검색) - 인텐트 6종: 시간 측정 시작/종료, 횟수 추가, 행동 누적값, 목표/다짐 진행률 조회 — 조회 인텐트는 값을 반환해 단축어에서 변수로 활용 가능 - RunActionIntent: 인터랙티브 위젯 버튼용 토글(시간형 시작/종료, 횟수형 +1) - ActionRunner: 실행 로직 공용화 (짧은 기록 무시 규칙 포함) - 모든 인텐트에 프리미엄 게이트, 실행 후 위젯 타임라인 갱신 - IntentHooks: 앱 프로세스에서 Live Activity 갱신 주입 - 시리 대표 문구 4종 (AppShortcutsProvider) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- myApp/HaruDanim/IOS/Core/AppShortcuts.swift | 48 ++ myApp/HaruDanim/IOS/Haru_DanimApp.swift | 30 +- myApp/HaruDanim/Shared/HaruDanimIntents.swift | 487 ++++++++++++++++++ 3 files changed, 557 insertions(+), 8 deletions(-) create mode 100644 myApp/HaruDanim/IOS/Core/AppShortcuts.swift create mode 100644 myApp/HaruDanim/Shared/HaruDanimIntents.swift diff --git a/myApp/HaruDanim/IOS/Core/AppShortcuts.swift b/myApp/HaruDanim/IOS/Core/AppShortcuts.swift new file mode 100644 index 0000000..3a199ca --- /dev/null +++ b/myApp/HaruDanim/IOS/Core/AppShortcuts.swift @@ -0,0 +1,48 @@ +// +// AppShortcuts.swift +// Haru_Danim +// +// 시리에서 바로 부를 수 있는 대표 단축어 문구 (CLAUDE.md §10) +// + +import AppIntents + +struct HaruDanimShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: StartTimeActionIntent(), + phrases: [ + "\(.applicationName)에서 측정 시작", + "\(.applicationName) 시간 측정 시작해 줘", + ], + shortTitle: "측정 시작", + systemImageName: "play.circle.fill" + ) + AppShortcut( + intent: StopTimeActionIntent(), + phrases: [ + "\(.applicationName)에서 측정 종료", + "\(.applicationName) 시간 측정 끝내 줘", + ], + shortTitle: "측정 종료", + systemImageName: "stop.circle.fill" + ) + AppShortcut( + intent: AddCountIntent(), + phrases: [ + "\(.applicationName)에서 횟수 추가", + "\(.applicationName)에 기록 추가해 줘", + ], + shortTitle: "횟수 추가", + systemImageName: "plus.circle.fill" + ) + AppShortcut( + intent: GoalProgressIntent(), + phrases: [ + "\(.applicationName) 목표 진행률 알려 줘", + ], + shortTitle: "목표 진행률", + systemImageName: "flag.checkered" + ) + } +} diff --git a/myApp/HaruDanim/IOS/Haru_DanimApp.swift b/myApp/HaruDanim/IOS/Haru_DanimApp.swift index 654db88..6bba99c 100644 --- a/myApp/HaruDanim/IOS/Haru_DanimApp.swift +++ b/myApp/HaruDanim/IOS/Haru_DanimApp.swift @@ -10,17 +10,31 @@ import SwiftData @main struct Haru_DanimApp: App { + /// App Group 컨테이너의 공유 DB (위젯·App Intents와 공용, 프리미엄이면 CloudKit 연동) + private let container: ModelContainer + + @Environment(\.scenePhase) private var scenePhase + + init() { + SettingsMigration.runIfNeeded() + container = DataStore.shared + DataStore.ensureUniqueEntityIDs(context: container.mainContext) + // 시리 단축어/위젯 인텐트가 앱 프로세스에서 실행될 때 Live Activity·워치도 갱신되도록 주입 + IntentHooks.afterMutation = { context in + LiveActivityManager.sync(context: context) + WatchSyncManager.shared.pushSnapshot() + } + WatchSyncManager.shared.activate() + } + var body: some Scene { WindowGroup { ContentView() } - .modelContainer(for: [ - Tag.self, - Action.self, - TimeSession.self, - CountEntry.self, - Goal.self, - Quest.self, - ]) + .modelContainer(container) + .onChange(of: scenePhase) { _, _ in + // 앱 사용 중 변경된 데이터를 워치에 반영 (활성/백그라운드 전환 시점) + WatchSyncManager.shared.pushSnapshot() + } } } diff --git a/myApp/HaruDanim/Shared/HaruDanimIntents.swift b/myApp/HaruDanim/Shared/HaruDanimIntents.swift new file mode 100644 index 0000000..a459fef --- /dev/null +++ b/myApp/HaruDanim/Shared/HaruDanimIntents.swift @@ -0,0 +1,487 @@ +// +// AppIntents.swift +// Haru_Danim +// +// 시리 단축어 (CLAUDE.md §10, 프리미엄) +// - 앱 타깃과 위젯 확장 타깃 양쪽에 포함되어, 단축어 앱/시리와 +// 인터랙티브 위젯(Button(intent:))이 같은 인텐트를 사용한다. +// - 데이터는 DataStore.shared(App Group DB)를 사용하므로 어느 프로세스에서 실행돼도 안전. +// + +import AppIntents +import Foundation +import SwiftData +import WidgetKit + +// MARK: - 공통 + +/// 앱 프로세스에서 인텐트 실행 후 부가 동작(Live Activity 갱신)을 주입하는 훅. +/// 위젯 확장 프로세스에서는 기본값(no-op)이 사용된다. +@MainActor +enum IntentHooks { + static var afterMutation: (ModelContext) -> Void = { _ in } +} + +struct PremiumRequiredError: Error, CustomLocalizedStringResourceConvertible { + var localizedStringResource: LocalizedStringResource { + "시리 단축어는 프리미엄 기능이에요. 하루 다님 앱의 설정 → 프리미엄에서 잠금 해제해 주세요." + } +} + +struct IntentTargetError: Error, CustomLocalizedStringResourceConvertible { + let message: LocalizedStringResource + var localizedStringResource: LocalizedStringResource { message } +} + +/// 인텐트가 공용으로 쓰는 데이터 접근 (MainActor: DataStore.shared.mainContext) +@MainActor +enum IntentStore { + static var context: ModelContext { DataStore.shared.mainContext } + + static func requirePremium() throws { + guard PremiumGate.isUnlocked(.siriShortcuts) else { throw PremiumRequiredError() } + } + + static func actions() -> [Action] { + (try? context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.sortOrder)]))) ?? [] + } + + static func goals() -> [Goal] { + (try? context.fetch(FetchDescriptor( + sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)] + ))) ?? [] + } + + static func quests() -> [Quest] { + (try? context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]))) ?? [] + } + + static func action(_ id: UUID) throws -> Action { + guard let found = actions().first(where: { $0.uuid == id }) else { + throw IntentTargetError(message: "행동을 찾을 수 없어요. 앱에서 삭제됐을 수 있어요.") + } + return found + } + + static func goal(_ id: UUID) throws -> Goal { + guard let found = goals().first(where: { $0.uuid == id }) else { + throw IntentTargetError(message: "목표를 찾을 수 없어요. 앱에서 삭제됐을 수 있어요.") + } + return found + } + + static func quest(_ id: UUID) throws -> Quest { + guard let found = quests().first(where: { $0.uuid == id }) else { + throw IntentTargetError(message: "다짐을 찾을 수 없어요. 앱에서 삭제됐을 수 있어요.") + } + return found + } + + /// 데이터 변경 후 저장 + Live Activity 훅 + 위젯 타임라인 갱신 + static func commit() { + try? context.save() + IntentHooks.afterMutation(context) + WidgetCenter.shared.reloadAllTimelines() + } +} + +/// 하루/주간/월간 선택 파라미터 +enum SpanOption: String, AppEnum { + case day, week, month + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "기간") + static let caseDisplayRepresentations: [SpanOption: DisplayRepresentation] = [ + .day: "오늘", + .week: "이번 주", + .month: "이번 달", + ] + + var statSpan: StatSpan { + switch self { + case .day: return .day + case .week: return .week + case .month: return .month + } + } + + var label: String { + switch self { + case .day: return "오늘" + case .week: return "이번 주" + case .month: return "이번 달" + } + } +} + +// MARK: - 엔티티: 행동 + +struct ActionEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "행동") + static let defaultQuery = ActionEntityQuery() + + let id: UUID + let name: String + let symbolName: String + let trackingTypeRaw: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(name)", + subtitle: trackingTypeRaw == TrackingType.count.rawValue ? "횟수 기록" : "시간 측정" + ) + } + + init(_ action: Action) { + id = action.uuid + name = action.name + symbolName = action.symbolName + trackingTypeRaw = action.trackingTypeRaw + } + + init(id: UUID, name: String, symbolName: String, trackingTypeRaw: String) { + self.id = id + self.name = name + self.symbolName = symbolName + self.trackingTypeRaw = trackingTypeRaw + } +} + +struct ActionEntityQuery: EntityStringQuery { + @MainActor + func entities(for identifiers: [UUID]) async throws -> [ActionEntity] { + IntentStore.actions().filter { identifiers.contains($0.uuid) }.map(ActionEntity.init) + } + + @MainActor + func suggestedEntities() async throws -> [ActionEntity] { + IntentStore.actions().map(ActionEntity.init) + } + + @MainActor + func entities(matching string: String) async throws -> [ActionEntity] { + IntentStore.actions() + .filter { $0.name.localizedStandardContains(string) } + .map(ActionEntity.init) + } +} + +// MARK: - 엔티티: 목표 + +struct GoalEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "목표") + static let defaultQuery = GoalEntityQuery() + + let id: UUID + let title: String + let statusLabel: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(title)", subtitle: "\(statusLabel)") + } + + init(_ goal: Goal) { + id = goal.uuid + title = goal.title + statusLabel = goal.status.label + } +} + +struct GoalEntityQuery: EntityStringQuery { + @MainActor + func entities(for identifiers: [UUID]) async throws -> [GoalEntity] { + IntentStore.goals().filter { identifiers.contains($0.uuid) }.map(GoalEntity.init) + } + + @MainActor + func suggestedEntities() async throws -> [GoalEntity] { + // 진행 중인 목표를 먼저 제안 + let goals = IntentStore.goals() + return (goals.filter { $0.status == .inProgress } + goals.filter { $0.status != .inProgress }) + .map(GoalEntity.init) + } + + @MainActor + func entities(matching string: String) async throws -> [GoalEntity] { + IntentStore.goals() + .filter { $0.title.localizedStandardContains(string) } + .map(GoalEntity.init) + } +} + +// MARK: - 엔티티: 다짐 + +struct QuestEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "다짐") + static let defaultQuery = QuestEntityQuery() + + let id: UUID + let targetName: String + let goalTitle: String + let summary: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(goalTitle) · \(targetName)", subtitle: "\(summary)") + } + + init(_ quest: Quest) { + id = quest.uuid + targetName = quest.targetName + goalTitle = quest.goal?.title ?? "목표 없음" + summary = "\(quest.scheduleLabel) · \(quest.targetValueLabel) \(quest.direction.label)" + } +} + +struct QuestEntityQuery: EntityStringQuery { + @MainActor + func entities(for identifiers: [UUID]) async throws -> [QuestEntity] { + IntentStore.quests().filter { identifiers.contains($0.uuid) }.map(QuestEntity.init) + } + + @MainActor + func suggestedEntities() async throws -> [QuestEntity] { + IntentStore.quests().map(QuestEntity.init) + } + + @MainActor + func entities(matching string: String) async throws -> [QuestEntity] { + IntentStore.quests() + .filter { + $0.targetName.localizedStandardContains(string) + || ($0.goal?.title.localizedStandardContains(string) ?? false) + } + .map(QuestEntity.init) + } +} + +// MARK: - 인텐트: 행동 실행 (위젯 버튼용 토글) + +/// 인터랙티브 위젯의 실행 버튼: 시간형은 시작/종료 토글, 횟수형은 +1 +struct RunActionIntent: AppIntent { + static let title: LocalizedStringResource = "행동 실행" + static let description = IntentDescription("시간 측정 행동은 시작/종료를 전환하고, 횟수 기록 행동은 횟수를 1 추가해요.") + + @Parameter(title: "행동") + var action: ActionEntity + + init() {} + + init(action: ActionEntity) { + self.action = action + } + + @MainActor + func perform() async throws -> some IntentResult { + guard PremiumGate.isUnlocked(.homeWidgets) else { throw PremiumRequiredError() } + let model = try IntentStore.action(action.id) + ActionRunner.run(model, context: IntentStore.context) + IntentStore.commit() + return .result() + } +} + +/// 실행 버튼의 공용 동작 (위젯 인텐트·워치 연동이 함께 사용) +@MainActor +enum ActionRunner { + /// 시간형은 시작/종료 토글(짧은 기록 무시 반영), 횟수형은 +1 + static func run(_ model: Action, context: ModelContext) { + switch model.trackingType { + case .time: + if let session = model.runningSession { + let minSeconds = (AppGroup.defaults.object(forKey: SettingsKeys.minSessionSeconds) + ?? UserDefaults.standard.object(forKey: SettingsKeys.minSessionSeconds)) as? Int ?? 0 + if minSeconds > 0, session.duration() < Double(minSeconds) { + context.delete(session) + } else { + session.endAt = .now + } + } else { + context.insert(TimeSession(action: model, startAt: .now)) + } + case .count: + context.insert(CountEntry(action: model, timestamp: .now)) + } + } +} + +// MARK: - 인텐트: 시간 측정 시작 + +struct StartTimeActionIntent: AppIntent { + static let title: LocalizedStringResource = "시간 측정 시작" + static let description = IntentDescription("시간 측정 행동의 측정을 시작해요.") + + @Parameter(title: "행동") + var action: ActionEntity + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + try IntentStore.requirePremium() + let model = try IntentStore.action(action.id) + guard model.trackingType == .time else { + throw IntentTargetError(message: "'\(model.name)'은(는) 횟수 기록 행동이에요. 횟수 추가를 사용해 주세요.") + } + guard model.runningSession == nil else { + return .result(dialog: "\(model.name)은(는) 이미 측정 중이에요.") + } + IntentStore.context.insert(TimeSession(action: model, startAt: .now)) + IntentStore.commit() + return .result(dialog: "\(model.name) 측정을 시작했어요.") + } +} + +// MARK: - 인텐트: 시간 측정 종료 + +struct StopTimeActionIntent: AppIntent { + static let title: LocalizedStringResource = "시간 측정 종료" + static let description = IntentDescription("측정 중인 행동의 시간 측정을 종료해요.") + + @Parameter(title: "행동") + var action: ActionEntity + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + try IntentStore.requirePremium() + let model = try IntentStore.action(action.id) + guard let session = model.runningSession else { + return .result(dialog: "\(model.name)은(는) 측정 중이 아니에요.") + } + // 앱과 동일한 "짧은 기록 무시" 규칙 적용 + let minSeconds = (AppGroup.defaults.object(forKey: SettingsKeys.minSessionSeconds) + ?? UserDefaults.standard.object(forKey: SettingsKeys.minSessionSeconds)) as? Int ?? 0 + let duration = session.duration() + if minSeconds > 0, duration < Double(minSeconds) { + IntentStore.context.delete(session) + IntentStore.commit() + return .result(dialog: "\(model.name) 측정을 종료했어요. 너무 짧은 기록이라 저장하지 않았어요.") + } + session.endAt = .now + IntentStore.commit() + return .result(dialog: "\(model.name) 측정을 종료했어요. \(Format.durationShort(duration)) 기록했어요.") + } +} + +// MARK: - 인텐트: 횟수 추가 + +struct AddCountIntent: AppIntent { + static let title: LocalizedStringResource = "횟수 추가" + static let description = IntentDescription("횟수 기록 행동의 횟수를 바로 추가해요.") + + @Parameter(title: "행동") + var action: ActionEntity + + @Parameter(title: "추가할 횟수", default: 1, inclusiveRange: (1, 999)) + var amount: Int + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + try IntentStore.requirePremium() + let model = try IntentStore.action(action.id) + guard model.trackingType == .count else { + throw IntentTargetError(message: "'\(model.name)'은(는) 시간 측정 행동이에요. 측정 시작/종료를 사용해 주세요.") + } + IntentStore.context.insert(CountEntry(action: model, timestamp: .now, amount: amount)) + IntentStore.commit() + let math = DayMath() + let today = Aggregator(math: math).count(for: model, in: math.dayRange(containing: .now)) + return .result(dialog: "\(model.name) \(amount)회 추가했어요. 오늘 총 \(today)회예요.") + } +} + +// MARK: - 인텐트: 행동 누적값 조회 + +struct ActionTotalIntent: AppIntent { + static let title: LocalizedStringResource = "행동 누적값 가져오기" + static let description = IntentDescription("행동의 하루/주간/월간 누적 시간(초) 또는 횟수를 가져와요.") + + @Parameter(title: "행동") + var action: ActionEntity + + @Parameter(title: "기간", default: .day) + var span: SpanOption + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + try IntentStore.requirePremium() + let model = try IntentStore.action(action.id) + let math = DayMath() + let range: Range + switch span.statSpan { + case .day: range = math.dayRange(containing: .now) + case .week: range = math.weekRange(containing: .now) + case .month: range = math.monthRange(containing: .now) + } + let agg = Aggregator(math: math) + switch model.trackingType { + case .time: + let seconds = agg.seconds(for: model, in: range) + return .result( + value: seconds, + dialog: "\(span.label) \(model.name) 누적 시간은 \(Format.durationShort(seconds))이에요." + ) + case .count: + let count = agg.count(for: model, in: range) + return .result( + value: Double(count), + dialog: "\(span.label) \(model.name) 누적 횟수는 \(count)회예요." + ) + } + } +} + +// MARK: - 인텐트: 목표 진행률 조회 + +struct GoalProgressIntent: AppIntent { + static let title: LocalizedStringResource = "목표 진행률 가져오기" + static let description = IntentDescription("목표의 하루/주간/월간 진행률(%)을 가져와요. 소속 다짐들의 진행률 평균이에요.") + + @Parameter(title: "목표") + var goal: GoalEntity + + @Parameter(title: "기간", default: .day) + var span: SpanOption + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + try IntentStore.requirePremium() + let model = try IntentStore.goal(goal.id) + guard !model.quests.isEmpty else { + throw IntentTargetError(message: "'\(model.title)' 목표에는 아직 다짐이 없어요.") + } + let percent = (model.combinedSpanRatio(span.statSpan) * 100).rounded() + return .result( + value: percent, + dialog: "'\(model.title)' \(span.label) 진행률은 \(Int(percent))%예요." + ) + } +} + +// MARK: - 인텐트: 다짐 진행률 조회 + +struct QuestProgressIntent: AppIntent { + static let title: LocalizedStringResource = "다짐 진행률 가져오기" + static let description = IntentDescription("다짐의 하루/주간/월간 진행률(%)을 가져와요.") + + @Parameter(title: "다짐") + var quest: QuestEntity + + @Parameter(title: "기간", default: .day) + var span: SpanOption + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + try IntentStore.requirePremium() + let model = try IntentStore.quest(quest.id) + let result = QuestProgress(quest: model).spanProgress(span.statSpan) + let percent = (result.displayRatio * 100).rounded() + let detail: String + switch model.direction { + case .atLeast: + detail = "\(Int(percent))%" + case .atMost: + detail = result.isAchieved ? "한도 안에서 잘 지키고 있어요" : "한도를 넘었어요" + } + return .result( + value: percent, + dialog: "'\(quest.goalTitle) · \(model.targetName)' \(span.label) 진행률: \(detail)" + ) + } +}