제어 센터/잠금화면 하단/액션 버튼에 배치할 수 있는 '행동 실행' 컨트롤: - 행동 하나를 골라(미선택 시 배치 첫 행동) 시간형은 시작/종료 토글, 횟수형은 탭마다 +1 (토글 표시는 꺼짐으로 복귀) - 전용 ToggleActionControlIntent(SetValueIntent + LiveActivityIntent) — 앱 프로세스 실행이라 제어 센터에서 시작해도 다이나믹 아일랜드가 바로 뜸. 단축어 앱에는 숨김(isDiscoverable=false), 프리미엄 게이트 적용 - DataChange.commit / IntentStore.performWrite에 ControlCenter.reloadAllControls() 추가 — 앱/위젯/시리/워치 어느 경로로 측정 상태가 바뀌어도 컨트롤 토글 표시가 따라온다 시뮬레이터에는 제어 센터에 컨트롤을 추가할 수단이 없어 빌드·인텐트 메타데이터 등록 확인까지 검증 (실기기에서 배치·토글 확인 필요). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
693 lines
28 KiB
Swift
693 lines
28 KiB
Swift
//
|
|
// AppIntents.swift
|
|
// Haru_Danim
|
|
//
|
|
// 시리 단축어 (CLAUDE.md §10, 프리미엄)
|
|
// - 앱 타깃과 위젯 확장 타깃 양쪽에 포함되어, 단축어 앱/시리와
|
|
// 인터랙티브 위젯(Button(intent:))이 같은 인텐트를 사용한다.
|
|
// - 데이터는 DataStore.shared(App Group DB)를 사용하므로 어느 프로세스에서 실행돼도 안전.
|
|
// 위젯 확장 프로세스에서는 같은 스토어 파일을 CloudKit 없이 로컬 전용으로 열고(DataStore 참고),
|
|
// 확장이 쓴 변경은 메인 앱이 원격 변경 알림으로 받아 CloudKit에 내보낸다.
|
|
//
|
|
|
|
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)
|
|
@MainActor
|
|
enum IntentStore {
|
|
/// 현재 작업이 사용하는 컨테이너.
|
|
/// 앱 프로세스: 항상 공유 컨테이너(DataStore.shared) — 앱 화면과 같은 인스턴스.
|
|
/// 확장 프로세스: 오래 살아남으면 캐시된 컨테이너가 메인 앱이 저장한 최신 데이터를
|
|
/// 놓칠 수 있어(낡은 값 표시·이중 시작의 원인), 작업 시작 시 refresh()로 새로 연다.
|
|
private static var container: ModelContainer = DataStore.shared
|
|
|
|
static var context: ModelContext { container.mainContext }
|
|
|
|
/// 위젯 타임라인 생성·인텐트 실행·엔티티 조회 직전에 호출해 디스크의 최신 상태를 보장.
|
|
/// 한 작업(perform/makeEntry) 안에서는 한 번만 호출해야 조회한 모델과 컨텍스트가 일치한다.
|
|
static func refresh() {
|
|
guard DataStore.isExtensionProcess else { return }
|
|
// 스토어 경합 등으로 새 컨테이너 열기에 실패해도 확장이 크래시하지 않도록
|
|
// (인터랙티브 위젯 버튼 먹통/기록 유실의 원인) 직전 컨테이너를 그대로 유지한다.
|
|
if let fresh = try? DataStore.makeContainerThrowing() {
|
|
container = fresh
|
|
}
|
|
}
|
|
|
|
static func requirePremium() throws {
|
|
guard PremiumGate.isUnlocked(.siriShortcuts) else { throw PremiumRequiredError() }
|
|
}
|
|
|
|
// MARK: 조회 (읽기 경로 — 현재 컨테이너 기준)
|
|
|
|
static func actions() -> [Action] { actions(in: context) }
|
|
static func goals() -> [Goal] { goals(in: context) }
|
|
static func quests() -> [Quest] { quests(in: context) }
|
|
static func action(_ id: UUID) throws -> Action { try action(id, in: context) }
|
|
static func goal(_ id: UUID) throws -> Goal { try goal(id, in: context) }
|
|
static func quest(_ id: UUID) throws -> Quest { try quest(id, in: context) }
|
|
|
|
// MARK: 조회 (지정 컨텍스트 기준 — 쓰기 작업에서 컨텍스트 고정용)
|
|
// 쓰기 작업은 조회·수정·저장을 반드시 '같은' 컨텍스트에서 해야 한다. 조회한 모델과
|
|
// 저장 컨텍스트가 갈라지면(컴퓨티드 context가 도중에 다른 컨테이너를 가리키면) 저장이
|
|
// '씹혀' 방금 누른 기록이 유실된다 — 그래서 performWrite가 컨텍스트를 한 번만 캡처해 넘긴다.
|
|
|
|
static func actions(in ctx: ModelContext) -> [Action] {
|
|
// 위젯·단축어의 행동 나열 순서도 이 기기의 모음 탭 배치(로컬 설정)를 따른다
|
|
LocalPrefs.orderedActions(
|
|
(try? ctx.fetch(FetchDescriptor<Action>(sortBy: [SortDescriptor(\.createdAt)]))) ?? []
|
|
)
|
|
}
|
|
|
|
static func goals(in ctx: ModelContext) -> [Goal] {
|
|
(try? ctx.fetch(FetchDescriptor<Goal>(
|
|
sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
|
|
))) ?? []
|
|
}
|
|
|
|
static func quests(in ctx: ModelContext) -> [Quest] {
|
|
(try? ctx.fetch(FetchDescriptor<Quest>(sortBy: [SortDescriptor(\.createdAt)]))) ?? []
|
|
}
|
|
|
|
static func action(_ id: UUID, in ctx: ModelContext) throws -> Action {
|
|
guard let found = actions(in: ctx).first(where: { $0.uuid == id }) else {
|
|
throw IntentTargetError(message: "행동을 찾을 수 없어요. 앱에서 삭제됐을 수 있어요.")
|
|
}
|
|
return found
|
|
}
|
|
|
|
static func goal(_ id: UUID, in ctx: ModelContext) throws -> Goal {
|
|
guard let found = goals(in: ctx).first(where: { $0.uuid == id }) else {
|
|
throw IntentTargetError(message: "목표를 찾을 수 없어요. 앱에서 삭제됐을 수 있어요.")
|
|
}
|
|
return found
|
|
}
|
|
|
|
static func quest(_ id: UUID, in ctx: ModelContext) throws -> Quest {
|
|
guard let found = quests(in: ctx).first(where: { $0.uuid == id }) else {
|
|
throw IntentTargetError(message: "다짐을 찾을 수 없어요. 앱에서 삭제됐을 수 있어요.")
|
|
}
|
|
return found
|
|
}
|
|
|
|
// MARK: 쓰기
|
|
|
|
/// 조회 + 수정 + 저장을 '하나의' 컨텍스트로 원자적으로 처리하고, 끝에서 동기적으로
|
|
/// Live Activity 훅과 위젯 타임라인 갱신을 수행한다.
|
|
/// - 작업 시작 시 컨텍스트를 한 번만 캡처해 끝까지 같은 인스턴스를 쓴다. 확장에서 refresh가
|
|
/// 컨테이너를 교체하더라도 진행 중인 수정이 다른 컨텍스트로 새어 유실되지 않는다.
|
|
/// - 저장 오류를 삼키지 않고 한 번 재시도한다(메인 앱과의 일시적 스토어 경합 대비).
|
|
/// - body가 던지면(대상 없음 등) 수정 자체가 없으므로 저장/리로드를 건너뛴다.
|
|
@discardableResult
|
|
static func performWrite<T>(_ body: (ModelContext) throws -> T) rethrows -> T {
|
|
refresh()
|
|
let ctx = context
|
|
let result = try body(ctx)
|
|
saveWithRetry(ctx)
|
|
IntentHooks.afterMutation(ctx)
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
// 제어 센터 컨트롤의 토글 상태(측정 중 여부)도 함께 갱신
|
|
ControlCenter.shared.reloadAllControls()
|
|
return result
|
|
}
|
|
|
|
/// 변경이 있을 때만 저장하며, 실패 시 한 번 재시도한다.
|
|
private static func saveWithRetry(_ ctx: ModelContext) {
|
|
guard ctx.hasChanges else { return }
|
|
do {
|
|
try ctx.save()
|
|
} catch {
|
|
// 메인 앱과의 일시적 스토어 경합일 수 있어 한 번 더 시도
|
|
do {
|
|
try ctx.save()
|
|
} catch {
|
|
print("[IntentStore] 저장 실패(재시도 후에도): \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 하루/주간/월간 선택 파라미터
|
|
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 String(localized: "오늘")
|
|
case .week: return String(localized: "이번 주")
|
|
case .month: return String(localized: "이번 달")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 엔티티 공통: '선택 안 함'
|
|
|
|
/// 위젯 설정에서 2·3번째 슬롯 등의 선택을 되돌릴 수 있게 하는 '선택 안 함' 항목의 고정 ID.
|
|
/// 어떤 모델의 uuid와도 일치하지 않으므로 위젯 조회(WidgetStore) 단계에서 자연스럽게 걸러진다.
|
|
enum NoneEntityID {
|
|
static let uuid = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
|
}
|
|
|
|
// MARK: - 엔티티: 행동
|
|
|
|
struct ActionEntity: AppEntity {
|
|
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "행동")
|
|
static let defaultQuery = ActionEntityQuery()
|
|
|
|
/// 선택 취소용 항목
|
|
static let none = ActionEntity(
|
|
id: NoneEntityID.uuid, name: "", symbolName: "slash.circle",
|
|
trackingTypeRaw: TrackingType.time.rawValue
|
|
)
|
|
|
|
let id: UUID
|
|
let name: String
|
|
let symbolName: String
|
|
let trackingTypeRaw: String
|
|
|
|
var displayRepresentation: DisplayRepresentation {
|
|
guard id != NoneEntityID.uuid else {
|
|
return DisplayRepresentation(title: "선택 안 함")
|
|
}
|
|
return DisplayRepresentation(
|
|
title: "\(name)",
|
|
subtitle: trackingTypeRaw == TrackingType.count.rawValue
|
|
? LocalizedStringResource("횟수 기록")
|
|
: LocalizedStringResource("시간 측정")
|
|
)
|
|
}
|
|
|
|
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.refresh()
|
|
var result = IntentStore.actions().filter { identifiers.contains($0.uuid) }.map(ActionEntity.init)
|
|
if identifiers.contains(NoneEntityID.uuid) { result.append(.none) }
|
|
return result
|
|
}
|
|
|
|
@MainActor
|
|
func suggestedEntities() async throws -> [ActionEntity] {
|
|
IntentStore.refresh()
|
|
return IntentStore.actions().map(ActionEntity.init)
|
|
}
|
|
|
|
@MainActor
|
|
func entities(matching string: String) async throws -> [ActionEntity] {
|
|
IntentStore.refresh()
|
|
return IntentStore.actions()
|
|
.filter { $0.name.localizedStandardContains(string) }
|
|
.map(ActionEntity.init)
|
|
}
|
|
}
|
|
|
|
// MARK: - 엔티티: 목표
|
|
|
|
struct GoalEntity: AppEntity {
|
|
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "목표")
|
|
static let defaultQuery = GoalEntityQuery()
|
|
|
|
/// 선택 취소용 항목
|
|
static let none = GoalEntity(id: NoneEntityID.uuid, title: "", statusLabel: "")
|
|
|
|
let id: UUID
|
|
let title: String
|
|
let statusLabel: String
|
|
|
|
var displayRepresentation: DisplayRepresentation {
|
|
guard id != NoneEntityID.uuid else {
|
|
return DisplayRepresentation(title: "선택 안 함")
|
|
}
|
|
return DisplayRepresentation(title: "\(title)", subtitle: "\(statusLabel)")
|
|
}
|
|
|
|
init(_ goal: Goal) {
|
|
id = goal.uuid
|
|
title = goal.title
|
|
statusLabel = goal.status.label
|
|
}
|
|
|
|
private init(id: UUID, title: String, statusLabel: String) {
|
|
self.id = id
|
|
self.title = title
|
|
self.statusLabel = statusLabel
|
|
}
|
|
}
|
|
|
|
struct GoalEntityQuery: EntityStringQuery {
|
|
@MainActor
|
|
func entities(for identifiers: [UUID]) async throws -> [GoalEntity] {
|
|
IntentStore.refresh()
|
|
var result = IntentStore.goals().filter { identifiers.contains($0.uuid) }.map(GoalEntity.init)
|
|
if identifiers.contains(NoneEntityID.uuid) { result.append(.none) }
|
|
return result
|
|
}
|
|
|
|
@MainActor
|
|
func suggestedEntities() async throws -> [GoalEntity] {
|
|
IntentStore.refresh()
|
|
// 진행 중인 목표를 먼저 제안. '선택 안 함'은 단일 선택 파라미터(잠금화면 목표)의
|
|
// 선택 해제용으로 유지
|
|
let goals = IntentStore.goals()
|
|
return [.none] + (goals.filter { $0.status == .inProgress } + goals.filter { $0.status != .inProgress })
|
|
.map(GoalEntity.init)
|
|
}
|
|
|
|
@MainActor
|
|
func entities(matching string: String) async throws -> [GoalEntity] {
|
|
IntentStore.refresh()
|
|
return IntentStore.goals()
|
|
.filter { $0.title.localizedStandardContains(string) }
|
|
.map(GoalEntity.init)
|
|
}
|
|
}
|
|
|
|
// MARK: - 엔티티: 다짐
|
|
|
|
struct QuestEntity: AppEntity {
|
|
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "다짐")
|
|
static let defaultQuery = QuestEntityQuery()
|
|
|
|
/// 선택 취소용 항목
|
|
static let none = QuestEntity(id: NoneEntityID.uuid, targetName: "", goalTitle: "", summary: "")
|
|
|
|
let id: UUID
|
|
let targetName: String
|
|
let goalTitle: String
|
|
let summary: String
|
|
|
|
var displayRepresentation: DisplayRepresentation {
|
|
guard id != NoneEntityID.uuid else {
|
|
return DisplayRepresentation(title: "선택 안 함")
|
|
}
|
|
return 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)"
|
|
}
|
|
|
|
private init(id: UUID, targetName: String, goalTitle: String, summary: String) {
|
|
self.id = id
|
|
self.targetName = targetName
|
|
self.goalTitle = goalTitle
|
|
self.summary = summary
|
|
}
|
|
}
|
|
|
|
struct QuestEntityQuery: EntityStringQuery {
|
|
@MainActor
|
|
func entities(for identifiers: [UUID]) async throws -> [QuestEntity] {
|
|
IntentStore.refresh()
|
|
var result = IntentStore.quests().filter { identifiers.contains($0.uuid) }.map(QuestEntity.init)
|
|
if identifiers.contains(NoneEntityID.uuid) { result.append(.none) }
|
|
return result
|
|
}
|
|
|
|
@MainActor
|
|
func suggestedEntities() async throws -> [QuestEntity] {
|
|
IntentStore.refresh()
|
|
return IntentStore.quests().map(QuestEntity.init)
|
|
}
|
|
|
|
@MainActor
|
|
func entities(matching string: String) async throws -> [QuestEntity] {
|
|
IntentStore.refresh()
|
|
return IntentStore.quests()
|
|
.filter {
|
|
$0.targetName.localizedStandardContains(string)
|
|
|| ($0.goal?.title.localizedStandardContains(string) ?? false)
|
|
}
|
|
.map(QuestEntity.init)
|
|
}
|
|
}
|
|
|
|
// MARK: - 인텐트: 행동 실행 (위젯 버튼용 토글)
|
|
|
|
/// 인터랙티브 위젯의 실행 버튼: 시간형은 시작/종료 토글, 횟수형은 +1
|
|
/// LiveActivityIntent 채택: 시스템이 앱 프로세스에서 실행해 위젯 탭으로 측정을 시작해도
|
|
/// 다이나믹 아일랜드가 바로 뜬다. (과거 위젯 버튼 먹통 증상은 위젯 캐시/재부팅으로 해소된
|
|
/// 것으로 확인되어 확장 프로세스 고정 제약을 풀었다 — 실기기에서 응답성 회귀 시 이 채택만 되돌릴 것)
|
|
struct RunActionIntent: LiveActivityIntent {
|
|
static let title: LocalizedStringResource = "행동 실행"
|
|
static let description = IntentDescription("시간 측정 행동은 시작/종료를 전환하고, 횟수 기록 행동은 횟수를 1 추가해요.")
|
|
|
|
/// 위젯 버튼 전용이므로 단축어 앱·스포트라이트에는 숨긴다 — 파라미터가 생 UUID 문자열이라
|
|
/// 사용자가 직접 조합할 수 없는 동작이다 (isDiscoverable false여도 위젯 Button(intent:)은 동작).
|
|
static let isDiscoverable = false
|
|
|
|
/// 위젯 버튼 전용으로 AppEntity 대신 UUID 문자열을 직접 받는다.
|
|
/// AppEntity 파라미터는 버튼을 누를 때마다 EntityQuery로 재조회(그 안에서 ModelContainer를
|
|
/// 통째로 다시 여는 무거운 작업)를 거쳐야 perform이 실행돼, "눌러도 반응이 없고 여러 번 연타해야
|
|
/// 겨우 동작"하는 지연·유실의 핵심 원인이었다. 문자열 ID는 조회 없이 즉시 역직렬화되므로
|
|
/// 한 번의 탭으로 곧바로 perform이 실행된다.
|
|
@Parameter(title: "행동 ID")
|
|
var actionID: String
|
|
|
|
init() {}
|
|
|
|
init(actionID: String) {
|
|
self.actionID = actionID
|
|
}
|
|
|
|
@MainActor
|
|
func perform() async throws -> some IntentResult {
|
|
guard PremiumGate.isUnlocked(.homeWidgets) else { throw PremiumRequiredError() }
|
|
guard let uuid = UUID(uuidString: actionID) else { return .result() }
|
|
// 조회·수정·저장을 한 컨텍스트로 묶어 저장 유실을 막는다.
|
|
try IntentStore.performWrite { ctx in
|
|
let model = try IntentStore.action(uuid, in: ctx)
|
|
ActionRunner.run(model, context: ctx)
|
|
}
|
|
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: - 인텐트: 제어 센터 토글 (컨트롤 위젯 전용)
|
|
|
|
/// 제어 센터 컨트롤의 토글 인텐트. SetValueIntent 요건(value)에 맞춰
|
|
/// 목표 상태를 받아 시간형은 시작/종료, 횟수형은 켤 때마다 +1로 처리한다.
|
|
/// LiveActivityIntent 채택으로 제어 센터에서 시작해도 다이나믹 아일랜드가 바로 뜬다.
|
|
struct ToggleActionControlIntent: SetValueIntent, LiveActivityIntent {
|
|
static let title: LocalizedStringResource = "행동 실행"
|
|
static let isDiscoverable = false
|
|
|
|
@Parameter(title: "행동 ID")
|
|
var actionID: String
|
|
|
|
@Parameter(title: "실행 상태")
|
|
var value: Bool
|
|
|
|
init() {}
|
|
|
|
init(actionID: String) {
|
|
self.actionID = actionID
|
|
}
|
|
|
|
@MainActor
|
|
func perform() async throws -> some IntentResult {
|
|
guard PremiumGate.isUnlocked(.homeWidgets) else { throw PremiumRequiredError() }
|
|
guard let uuid = UUID(uuidString: actionID) else { return .result() }
|
|
try IntentStore.performWrite { ctx in
|
|
let model = try IntentStore.action(uuid, in: ctx)
|
|
switch model.trackingType {
|
|
case .time:
|
|
if value {
|
|
if model.runningSession == nil {
|
|
ctx.insert(TimeSession(action: model, startAt: .now))
|
|
}
|
|
} else 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) {
|
|
ctx.delete(session)
|
|
} else {
|
|
session.endAt = .now
|
|
}
|
|
}
|
|
case .count:
|
|
// 횟수형은 상태가 없어 '켬'을 +1로 취급 (토글은 곧 꺼짐 표시로 복귀)
|
|
if value {
|
|
ctx.insert(CountEntry(action: model, timestamp: .now))
|
|
}
|
|
}
|
|
}
|
|
return .result()
|
|
}
|
|
}
|
|
|
|
// MARK: - 인텐트: 시간 측정 시작
|
|
|
|
/// LiveActivityIntent 채택: 시리/단축어의 백그라운드 실행에서도 Live Activity 시작이
|
|
/// 허용되어, 앱을 열지 않아도 다이나믹 아일랜드에 측정이 표시된다. (앱 프로세스에서 실행됨)
|
|
struct StartTimeActionIntent: LiveActivityIntent {
|
|
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 dialog: IntentDialog = try IntentStore.performWrite { ctx in
|
|
let model = try IntentStore.action(action.id, in: ctx)
|
|
guard model.trackingType == .time else {
|
|
throw IntentTargetError(message: "'\(model.name)'은(는) 횟수 기록 행동이에요. 횟수 추가를 사용해 주세요.")
|
|
}
|
|
guard model.runningSession == nil else {
|
|
return "\(model.name)은(는) 이미 측정 중이에요."
|
|
}
|
|
ctx.insert(TimeSession(action: model, startAt: .now))
|
|
return "\(model.name) 측정을 시작했어요."
|
|
}
|
|
return .result(dialog: dialog)
|
|
}
|
|
}
|
|
|
|
// MARK: - 인텐트: 시간 측정 종료
|
|
|
|
/// LiveActivityIntent 채택 이유는 StartTimeActionIntent 참고 (종료 시 Live Activity 갱신/제거)
|
|
struct StopTimeActionIntent: LiveActivityIntent {
|
|
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 dialog: IntentDialog = try IntentStore.performWrite { ctx in
|
|
let model = try IntentStore.action(action.id, in: ctx)
|
|
guard let session = model.runningSession else {
|
|
return "\(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) {
|
|
ctx.delete(session)
|
|
return "\(model.name) 측정을 종료했어요. 너무 짧은 기록이라 저장하지 않았어요."
|
|
}
|
|
session.endAt = .now
|
|
return "\(model.name) 측정을 종료했어요. \(Format.durationShort(duration)) 기록했어요."
|
|
}
|
|
return .result(dialog: dialog)
|
|
}
|
|
}
|
|
|
|
// 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 dialog: IntentDialog = try IntentStore.performWrite { ctx in
|
|
let model = try IntentStore.action(action.id, in: ctx)
|
|
guard model.trackingType == .count else {
|
|
throw IntentTargetError(message: "'\(model.name)'은(는) 시간 측정 행동이에요. 측정 시작/종료를 사용해 주세요.")
|
|
}
|
|
ctx.insert(CountEntry(action: model, timestamp: .now, amount: amount))
|
|
// 방금 삽입한 기록까지 반영된 오늘 누적(같은 컨텍스트의 역관계로 계산)
|
|
let math = DayMath()
|
|
let today = Aggregator(math: math).count(for: model, in: math.dayRange(containing: .now))
|
|
return "\(model.name) \(amount)회 추가했어요. 오늘 총 \(today)회예요."
|
|
}
|
|
return .result(dialog: dialog)
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
IntentStore.refresh()
|
|
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()
|
|
IntentStore.refresh()
|
|
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()
|
|
IntentStore.refresh()
|
|
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 ? String(localized: "한도 안에서 잘 지키고 있어요") : String(localized: "한도를 넘었어요")
|
|
}
|
|
return .result(
|
|
value: percent,
|
|
dialog: "'\(quest.goalTitle) · \(model.targetName)' \(span.label) 진행률: \(detail)"
|
|
)
|
|
}
|
|
}
|