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:
parent
f468fdf296
commit
18f49bc0db
48
myApp/HaruDanim/IOS/Core/AppShortcuts.swift
Normal file
48
myApp/HaruDanim/IOS/Core/AppShortcuts.swift
Normal 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
487
myApp/HaruDanim/Shared/HaruDanimIntents.swift
Normal file
487
myApp/HaruDanim/Shared/HaruDanimIntents.swift
Normal 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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user