mycode/myApp/HaruDanim/Shared/HaruDanimIntents.swift
songyc macbook e4bba280ad feat(control): Control Center toggle for running an action (iOS 18 control)
제어 센터/잠금화면 하단/액션 버튼에 배치할 수 있는 '행동 실행' 컨트롤:
- 행동 하나를 골라(미선택 시 배치 첫 행동) 시간형은 시작/종료 토글,
  횟수형은 탭마다 +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
2026-07-14 00:19:36 +09:00

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