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
This commit is contained in:
songyc macbook 2026-07-14 00:19:36 +09:00
parent f22aea0f95
commit e4bba280ad
4 changed files with 158 additions and 0 deletions

View File

@ -18,6 +18,8 @@ enum DataChange {
try? context.save()
LiveActivityManager.sync(context: context)
WidgetCenter.shared.reloadAllTimelines()
// ( )
ControlCenter.shared.reloadAllControls()
WatchSyncManager.shared.pushSnapshot()
}
}

View File

@ -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

View File

@ -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. (프리미엄)")
}
}

View File

@ -20,6 +20,7 @@ struct HaruDanimWidgetsBundle: WidgetBundle {
QuestStatusWidget() // ( )
StatsChartWidget() //
LockGoalWidget() //
ActionRunControl() // ( )
}
}