fix(widget): fix AppIntent button interactions and streamline widget configuration options while preserving real-time sync
Two independent widget issues, both confined to the widget/intent layer.
The app→widget real-time sync path (IntentStore.refresh + performWrite +
reloadAllTimelines) is intentionally left untouched.
1. Interactive button taps were unreliable ("touch feedback but nothing
changes; works only after several taps"). The run buttons passed a full
AppEntity parameter — RunActionIntent(action: cell.entity). On every tap
WidgetKit re-resolves that AppEntity through ActionEntityQuery.entities(for:),
which opens a brand-new ModelContainer from disk each time. That heavy
entity-resolution round-trip on the tap hot path is the classic cause of
dropped/laggy interactive-widget taps — the write logic itself was already
proven reliable last session. Replaced RunActionIntent's AppEntity parameter
with a plain UUID String (actionID) that deserializes instantly with no
query; perform() looks the action up inside the existing single-context
performWrite path. One tap now fires immediately. RunActionIntent is
widget-only (not a Siri AppShortcut), so this touches no shortcut. Both call
sites (ActionRunCellView, QuestRingActionCellView) updated; the now-unused
ActionCellSnapshot.entity helper removed.
2. Configuration screens were cluttered — every widget exposed action/goal/
quest slots 1–4 regardless of family, so a Small widget's long-press config
listed slots it never renders. Added a parameterSummary to all six
WidgetConfigurationIntents so the config reads as one clean sentence, and
labeled secondary slots with the size that uses them (e.g. "행동 2 (중형·대형)",
"목표 3 (대형)"). WidgetKit can't hide parameters by family, so this makes the
optional slots self-explanatory instead of noise.
Verified: BUILD SUCCEEDED (app + embedded Haru_DanimWidgets.appex); cold launch
with the widget preview renders without crashing. Physical home/lock-screen
button taps still warrant a real-device pass (the simulator can't tap widget
overlays).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
32306510c8
commit
c7024ea612
@ -383,21 +383,27 @@ struct RunActionIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "행동 실행"
|
||||
static let description = IntentDescription("시간 측정 행동은 시작/종료를 전환하고, 횟수 기록 행동은 횟수를 1 추가해요.")
|
||||
|
||||
@Parameter(title: "행동")
|
||||
var action: ActionEntity
|
||||
/// 위젯 버튼 전용으로 AppEntity 대신 UUID 문자열을 직접 받는다.
|
||||
/// AppEntity 파라미터는 버튼을 누를 때마다 EntityQuery로 재조회(그 안에서 ModelContainer를
|
||||
/// 통째로 다시 여는 무거운 작업)를 거쳐야 perform이 실행돼, "눌러도 반응이 없고 여러 번 연타해야
|
||||
/// 겨우 동작"하는 지연·유실의 핵심 원인이었다. 문자열 ID는 조회 없이 즉시 역직렬화되므로
|
||||
/// 한 번의 탭으로 곧바로 perform이 실행된다.
|
||||
@Parameter(title: "행동 ID")
|
||||
var actionID: String
|
||||
|
||||
init() {}
|
||||
|
||||
init(action: ActionEntity) {
|
||||
self.action = action
|
||||
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(action.id, in: ctx)
|
||||
let model = try IntentStore.action(uuid, in: ctx)
|
||||
ActionRunner.run(model, context: ctx)
|
||||
}
|
||||
return .result()
|
||||
|
||||
@ -14,22 +14,31 @@ import AppIntents
|
||||
|
||||
struct ActionRunConfigIntent: WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "행동 실행 위젯"
|
||||
static let description = IntentDescription("표시할 행동과 누적 기간을 선택하세요.")
|
||||
static let description = IntentDescription("표시할 행동과 누적 기간을 선택하세요. 비워 두면 최근 행동이 채워져요.")
|
||||
|
||||
@Parameter(title: "표시 기간", default: .day)
|
||||
var period: SpanOption
|
||||
|
||||
@Parameter(title: "행동 1")
|
||||
@Parameter(title: "행동")
|
||||
var action1: ActionEntity?
|
||||
|
||||
@Parameter(title: "행동 2")
|
||||
@Parameter(title: "행동 2 (중형·대형)")
|
||||
var action2: ActionEntity?
|
||||
|
||||
@Parameter(title: "행동 3")
|
||||
@Parameter(title: "행동 3 (중형·대형)")
|
||||
var action3: ActionEntity?
|
||||
|
||||
@Parameter(title: "행동 4")
|
||||
@Parameter(title: "행동 4 (대형)")
|
||||
var action4: ActionEntity?
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("행동을 \(\.$period) 기준으로 표시") {
|
||||
\.$action1
|
||||
\.$action2
|
||||
\.$action3
|
||||
\.$action4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 타임라인
|
||||
@ -90,7 +99,7 @@ struct ActionRunCellView: View {
|
||||
var fullBleed = false
|
||||
|
||||
var body: some View {
|
||||
Button(intent: RunActionIntent(action: cell.entity)) {
|
||||
Button(intent: RunActionIntent(actionID: cell.id.uuidString)) {
|
||||
VStack(alignment: .leading, spacing: compact ? 2 : 4) {
|
||||
HStack {
|
||||
Image(systemName: cell.symbolName)
|
||||
|
||||
@ -13,19 +13,28 @@ import AppIntents
|
||||
|
||||
struct GoalBarsConfigIntent: WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "목표 진행률 위젯"
|
||||
static let description = IntentDescription("하루·주간·월간 진행률을 표시할 목표를 선택하세요.")
|
||||
static let description = IntentDescription("하루·주간·월간 진행률을 표시할 목표를 선택하세요. 비워 두면 진행 중인 목표가 채워져요.")
|
||||
|
||||
@Parameter(title: "목표 1")
|
||||
@Parameter(title: "목표")
|
||||
var goal1: GoalEntity?
|
||||
|
||||
@Parameter(title: "목표 2")
|
||||
@Parameter(title: "목표 2 (중형·대형)")
|
||||
var goal2: GoalEntity?
|
||||
|
||||
@Parameter(title: "목표 3")
|
||||
@Parameter(title: "목표 3 (대형)")
|
||||
var goal3: GoalEntity?
|
||||
|
||||
@Parameter(title: "목표 4")
|
||||
@Parameter(title: "목표 4 (대형)")
|
||||
var goal4: GoalEntity?
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("목표의 하루·주간·월간 진행률을 표시") {
|
||||
\.$goal1
|
||||
\.$goal2
|
||||
\.$goal3
|
||||
\.$goal4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GoalBarsEntry: TimelineEntry {
|
||||
@ -178,6 +187,13 @@ struct GoalQuestGridConfigIntent: WidgetConfigurationIntent {
|
||||
|
||||
@Parameter(title: "진행률 기간", default: .day)
|
||||
var span: SpanOption
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("목표의 다짐들을 \(\.$span) 기준으로 표시") {
|
||||
\.$goal
|
||||
\.$secondGoal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GoalQuestGridEntry: TimelineEntry {
|
||||
|
||||
@ -35,8 +35,14 @@ struct LockGoalConfigIntent: WidgetConfigurationIntent {
|
||||
@Parameter(title: "달성률 기간", default: .day)
|
||||
var span: SpanOption
|
||||
|
||||
@Parameter(title: "표시 방식 (원형 크기)", default: .gauge)
|
||||
@Parameter(title: "표시 방식", default: .gauge)
|
||||
var style: LockStyleOption
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("목표 달성률을 \(\.$span) 기준 \(\.$style)(으)로 표시") {
|
||||
\.$goal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 타임라인
|
||||
|
||||
@ -14,22 +14,31 @@ import AppIntents
|
||||
|
||||
struct QuestRingConfigIntent: WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "다짐 진행률 위젯"
|
||||
static let description = IntentDescription("원형 진행률로 표시할 다짐과 기간을 선택하세요.")
|
||||
static let description = IntentDescription("원형 진행률로 표시할 다짐과 기간을 선택하세요. 비워 두면 최근 목표의 다짐이 채워져요.")
|
||||
|
||||
@Parameter(title: "진행률 기간", default: .day)
|
||||
var span: SpanOption
|
||||
|
||||
@Parameter(title: "다짐 1")
|
||||
@Parameter(title: "다짐")
|
||||
var quest1: QuestEntity?
|
||||
|
||||
@Parameter(title: "다짐 2")
|
||||
@Parameter(title: "다짐 2 (중형·대형)")
|
||||
var quest2: QuestEntity?
|
||||
|
||||
@Parameter(title: "다짐 3")
|
||||
@Parameter(title: "다짐 3 (대형)")
|
||||
var quest3: QuestEntity?
|
||||
|
||||
@Parameter(title: "다짐 4")
|
||||
@Parameter(title: "다짐 4 (대형)")
|
||||
var quest4: QuestEntity?
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("다짐의 진행률을 \(\.$span) 기준으로 표시") {
|
||||
\.$quest1
|
||||
\.$quest2
|
||||
\.$quest3
|
||||
\.$quest4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 타임라인
|
||||
@ -93,7 +102,7 @@ struct QuestRingActionCellView: View {
|
||||
|
||||
var body: some View {
|
||||
if let target = cell.runTarget {
|
||||
Button(intent: RunActionIntent(action: target)) {
|
||||
Button(intent: RunActionIntent(actionID: target.id.uuidString)) {
|
||||
content
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@ -36,12 +36,12 @@ enum StatsChartSpan: String, AppEnum {
|
||||
|
||||
struct StatsChartConfigIntent: WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "통계 위젯"
|
||||
static let description = IntentDescription("그래프로 보여줄 행동과 기간을 선택하세요.")
|
||||
static let description = IntentDescription("그래프로 보여줄 행동과 기간을 선택하세요. 비워 두면 최근 행동이 채워져요.")
|
||||
|
||||
@Parameter(title: "통계 기간", default: .week)
|
||||
var span: StatsChartSpan
|
||||
|
||||
@Parameter(title: "행동 1")
|
||||
@Parameter(title: "행동")
|
||||
var action1: ActionEntity?
|
||||
|
||||
@Parameter(title: "행동 2")
|
||||
@ -49,6 +49,14 @@ struct StatsChartConfigIntent: WidgetConfigurationIntent {
|
||||
|
||||
@Parameter(title: "행동 3")
|
||||
var action3: ActionEntity?
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("행동 통계를 \(\.$span)(으)로 표시") {
|
||||
\.$action1
|
||||
\.$action2
|
||||
\.$action3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 타임라인
|
||||
|
||||
@ -51,13 +51,6 @@ struct ActionCellSnapshot: Identifiable {
|
||||
|
||||
var color: Color { Color(hex: colorHex) }
|
||||
|
||||
var entity: ActionEntity {
|
||||
ActionEntity(
|
||||
id: id, name: name, symbolName: symbolName,
|
||||
trackingTypeRaw: isCount ? TrackingType.count.rawValue : TrackingType.time.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func make(action: Action, span: StatSpan, now: Date = .now) -> ActionCellSnapshot {
|
||||
let math = DayMath()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user