feat(intents): 시리 단축어용 App Intents 구현 (CLAUDE.md §10)

- 엔티티: ActionEntity/GoalEntity/QuestEntity (목록 제안 + 이름 검색)
- 인텐트 6종: 시간 측정 시작/종료, 횟수 추가, 행동 누적값, 목표/다짐 진행률 조회
  — 조회 인텐트는 값을 반환해 단축어에서 변수로 활용 가능
- RunActionIntent: 인터랙티브 위젯 버튼용 토글(시간형 시작/종료, 횟수형 +1)
- ActionRunner: 실행 로직 공용화 (짧은 기록 무시 규칙 포함)
- 모든 인텐트에 프리미엄 게이트, 실행 후 위젯 타임라인 갱신
- IntentHooks: 앱 프로세스에서 Live Activity 갱신 주입
- 시리 대표 문구 4종 (AppShortcutsProvider)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-10 08:59:31 +09:00
parent f468fdf296
commit 18f49bc0db
3 changed files with 557 additions and 8 deletions

View File

@ -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"
)
}
}

View File

@ -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()
}
}
}

View File

@ -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<Action>(sortBy: [SortDescriptor(\.sortOrder)]))) ?? []
}
static func goals() -> [Goal] {
(try? context.fetch(FetchDescriptor<Goal>(
sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
))) ?? []
}
static func quests() -> [Quest] {
(try? context.fetch(FetchDescriptor<Quest>(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<Double> & ProvidesDialog {
try IntentStore.requirePremium()
let model = try IntentStore.action(action.id)
let math = DayMath()
let range: Range<Date>
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<Double> & 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<Double> & 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)"
)
}
}