From e4bba280adbcdcbbf3c28abab7768c304ea552e6 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Tue, 14 Jul 2026 00:19:36 +0900 Subject: [PATCH] feat(control): Control Center toggle for running an action (iOS 18 control) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제어 센터/잠금화면 하단/액션 버튼에 배치할 수 있는 '행동 실행' 컨트롤: - 행동 하나를 골라(미선택 시 배치 첫 행동) 시간형은 시작/종료 토글, 횟수형은 탭마다 +1 (토글 표시는 꺼짐으로 복귀) - 전용 ToggleActionControlIntent(SetValueIntent + LiveActivityIntent) — 앱 프로세스 실행이라 제어 센터에서 시작해도 다이나믹 아일랜드가 바로 뜸. 단축어 앱에는 숨김(isDiscoverable=false), 프리미엄 게이트 적용 - DataChange.commit / IntentStore.performWrite에 ControlCenter.reloadAllControls() 추가 — 앱/위젯/시리/워치 어느 경로로 측정 상태가 바뀌어도 컨트롤 토글 표시가 따라온다 시뮬레이터에는 제어 센터에 컨트롤을 추가할 수단이 없어 빌드·인텐트 메타데이터 등록 확인까지 검증 (실기기에서 배치·토글 확인 필요). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- myApp/HaruDanim/IOS/Core/DataChange.swift | 2 + myApp/HaruDanim/Shared/HaruDanimIntents.swift | 55 ++++++++++ .../Widgets/ActionControlWidget.swift | 100 ++++++++++++++++++ .../HaruDanim/Widgets/HaruDanimWidgets.swift | 1 + 4 files changed, 158 insertions(+) create mode 100644 myApp/HaruDanim/Widgets/ActionControlWidget.swift diff --git a/myApp/HaruDanim/IOS/Core/DataChange.swift b/myApp/HaruDanim/IOS/Core/DataChange.swift index 18f8366..521c6a3 100644 --- a/myApp/HaruDanim/IOS/Core/DataChange.swift +++ b/myApp/HaruDanim/IOS/Core/DataChange.swift @@ -18,6 +18,8 @@ enum DataChange { try? context.save() LiveActivityManager.sync(context: context) WidgetCenter.shared.reloadAllTimelines() + // 제어 센터 컨트롤의 토글 상태(측정 중 여부)도 함께 갱신 + ControlCenter.shared.reloadAllControls() WatchSyncManager.shared.pushSnapshot() } } diff --git a/myApp/HaruDanim/Shared/HaruDanimIntents.swift b/myApp/HaruDanim/Shared/HaruDanimIntents.swift index 5bd40d8..3571570 100644 --- a/myApp/HaruDanim/Shared/HaruDanimIntents.swift +++ b/myApp/HaruDanim/Shared/HaruDanimIntents.swift @@ -129,6 +129,8 @@ enum IntentStore { saveWithRetry(ctx) IntentHooks.afterMutation(ctx) WidgetCenter.shared.reloadAllTimelines() + // 제어 센터 컨트롤의 토글 상태(측정 중 여부)도 함께 갱신 + ControlCenter.shared.reloadAllControls() return result } @@ -441,6 +443,59 @@ enum ActionRunner { } } +// 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 시작이 diff --git a/myApp/HaruDanim/Widgets/ActionControlWidget.swift b/myApp/HaruDanim/Widgets/ActionControlWidget.swift new file mode 100644 index 0000000..86c8c0d --- /dev/null +++ b/myApp/HaruDanim/Widgets/ActionControlWidget.swift @@ -0,0 +1,100 @@ +// +// ActionControlWidget.swift +// Haru_DanimWidgets +// +// 제어 센터 컨트롤 (iOS 18+): 화면을 켜자마자 제어 센터/잠금화면 하단/액션 버튼에서 +// 행동을 실행한다. 시간형 = 시작/종료 토글, 횟수형 = 켤 때마다 +1. +// 실행은 ToggleActionControlIntent(LiveActivityIntent) — 앱 프로세스에서 돌아 +// 제어 센터에서 시작해도 다이나믹 아일랜드가 바로 뜬다. +// + +import AppIntents +import SwiftUI +import WidgetKit + +// MARK: - 설정 (어떤 행동을 담을지) + +struct ActionControlConfigIntent: ControlConfigurationIntent { + static let title: LocalizedStringResource = "행동 선택" + static let description = IntentDescription("제어 센터 버튼으로 실행할 행동을 선택하세요.") + + @Parameter(title: "행동") + var action: ActionEntity? +} + +// MARK: - 값 공급자 + +/// 컨트롤에 그릴 상태 스냅숏 +struct ActionControlValue { + var actionID: String + var name: String + var symbolName: String + var isCount: Bool + var isRunning: Bool + var locked: Bool +} + +struct ActionControlProvider: AppIntentControlValueProvider { + func previewValue(configuration: ActionControlConfigIntent) -> ActionControlValue { + ActionControlValue( + actionID: "", name: String(localized: "독서"), symbolName: "book.fill", + isCount: false, isRunning: false, locked: false + ) + } + + func currentValue(configuration: ActionControlConfigIntent) async throws -> ActionControlValue { + await MainActor.run { + IntentStore.refresh() + guard WidgetStore.isUnlocked else { + return ActionControlValue( + actionID: "", name: String(localized: "프리미엄 기능"), symbolName: "crown.fill", + isCount: false, isRunning: false, locked: true + ) + } + // 미선택이면 이 기기 배치 순서의 첫 행동 + guard let model = WidgetStore.action(configuration.action?.id) + ?? WidgetStore.defaultActions(1).first else { + return ActionControlValue( + actionID: "", name: String(localized: "행동 없음"), symbolName: "square.grid.2x2", + isCount: false, isRunning: false, locked: false + ) + } + return ActionControlValue( + actionID: model.uuid.uuidString, + name: model.name, + symbolName: model.symbolName, + isCount: model.trackingType == .count, + isRunning: model.isRunning, + locked: false + ) + } + } +} + +// MARK: - 컨트롤 정의 + +struct ActionRunControl: ControlWidget { + var body: some ControlWidgetConfiguration { + AppIntentControlConfiguration( + kind: "HaruActionRunControl", + provider: ActionControlProvider() + ) { value in + ControlWidgetToggle( + value.name, + isOn: value.isRunning, + action: ToggleActionControlIntent(actionID: value.actionID) + ) { isOn in + Label( + value.locked + ? String(localized: "프리미엄 필요") + : value.isCount + ? String(localized: "탭해서 +1") + : isOn ? String(localized: "측정 중") : String(localized: "시작"), + systemImage: value.symbolName + ) + } + } + .displayName("행동 실행") + .description("행동 하나를 골라 제어 센터에서 바로 실행해요. 시간 측정은 시작/종료 토글, 횟수 기록은 탭마다 +1. (프리미엄)") + } +} diff --git a/myApp/HaruDanim/Widgets/HaruDanimWidgets.swift b/myApp/HaruDanim/Widgets/HaruDanimWidgets.swift index d222741..0848ea4 100644 --- a/myApp/HaruDanim/Widgets/HaruDanimWidgets.swift +++ b/myApp/HaruDanim/Widgets/HaruDanimWidgets.swift @@ -20,6 +20,7 @@ struct HaruDanimWidgetsBundle: WidgetBundle { QuestStatusWidget() // ④ 다짐 현황 (표시 전용) StatsChartWidget() // ⑤ 행동 통계 LockGoalWidget() // 잠금화면 + ActionRunControl() // 제어 센터 컨트롤 (행동 실행) } }