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:
parent
f22aea0f95
commit
e4bba280ad
@ -18,6 +18,8 @@ enum DataChange {
|
||||
try? context.save()
|
||||
LiveActivityManager.sync(context: context)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
// 제어 센터 컨트롤의 토글 상태(측정 중 여부)도 함께 갱신
|
||||
ControlCenter.shared.reloadAllControls()
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 시작이
|
||||
|
||||
100
myApp/HaruDanim/Widgets/ActionControlWidget.swift
Normal file
100
myApp/HaruDanim/Widgets/ActionControlWidget.swift
Normal 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. (프리미엄)")
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ struct HaruDanimWidgetsBundle: WidgetBundle {
|
||||
QuestStatusWidget() // ④ 다짐 현황 (표시 전용)
|
||||
StatsChartWidget() // ⑤ 행동 통계
|
||||
LockGoalWidget() // 잠금화면
|
||||
ActionRunControl() // 제어 센터 컨트롤 (행동 실행)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user