From c7024ea612b12d228d7dced5618a8f0c9690b1b3 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sun, 12 Jul 2026 21:50:27 +0900 Subject: [PATCH] fix(widget): fix AppIntent button interactions and streamline widget configuration options while preserving real-time sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- myApp/HaruDanim/Shared/HaruDanimIntents.swift | 16 ++++++++---- myApp/HaruDanim/Widgets/ActionRunWidget.swift | 21 ++++++++++----- myApp/HaruDanim/Widgets/GoalWidgets.swift | 26 +++++++++++++++---- .../HaruDanim/Widgets/LockScreenWidgets.swift | 8 +++++- myApp/HaruDanim/Widgets/QuestRingWidget.swift | 21 ++++++++++----- .../HaruDanim/Widgets/StatsChartWidget.swift | 12 +++++++-- myApp/HaruDanim/Widgets/WidgetSupport.swift | 7 ----- 7 files changed, 79 insertions(+), 32 deletions(-) diff --git a/myApp/HaruDanim/Shared/HaruDanimIntents.swift b/myApp/HaruDanim/Shared/HaruDanimIntents.swift index bf920d8..927e5ab 100644 --- a/myApp/HaruDanim/Shared/HaruDanimIntents.swift +++ b/myApp/HaruDanim/Shared/HaruDanimIntents.swift @@ -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() diff --git a/myApp/HaruDanim/Widgets/ActionRunWidget.swift b/myApp/HaruDanim/Widgets/ActionRunWidget.swift index a8d576d..5f768f3 100644 --- a/myApp/HaruDanim/Widgets/ActionRunWidget.swift +++ b/myApp/HaruDanim/Widgets/ActionRunWidget.swift @@ -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) diff --git a/myApp/HaruDanim/Widgets/GoalWidgets.swift b/myApp/HaruDanim/Widgets/GoalWidgets.swift index c7bb7ee..a4ab6c5 100644 --- a/myApp/HaruDanim/Widgets/GoalWidgets.swift +++ b/myApp/HaruDanim/Widgets/GoalWidgets.swift @@ -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 { diff --git a/myApp/HaruDanim/Widgets/LockScreenWidgets.swift b/myApp/HaruDanim/Widgets/LockScreenWidgets.swift index a44653e..a47f684 100644 --- a/myApp/HaruDanim/Widgets/LockScreenWidgets.swift +++ b/myApp/HaruDanim/Widgets/LockScreenWidgets.swift @@ -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: - 타임라인 diff --git a/myApp/HaruDanim/Widgets/QuestRingWidget.swift b/myApp/HaruDanim/Widgets/QuestRingWidget.swift index c325562..f376305 100644 --- a/myApp/HaruDanim/Widgets/QuestRingWidget.swift +++ b/myApp/HaruDanim/Widgets/QuestRingWidget.swift @@ -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) diff --git a/myApp/HaruDanim/Widgets/StatsChartWidget.swift b/myApp/HaruDanim/Widgets/StatsChartWidget.swift index d9ff5ec..919f0f0 100644 --- a/myApp/HaruDanim/Widgets/StatsChartWidget.swift +++ b/myApp/HaruDanim/Widgets/StatsChartWidget.swift @@ -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: - 타임라인 diff --git a/myApp/HaruDanim/Widgets/WidgetSupport.swift b/myApp/HaruDanim/Widgets/WidgetSupport.swift index 90ef9e8..40353a7 100644 --- a/myApp/HaruDanim/Widgets/WidgetSupport.swift +++ b/myApp/HaruDanim/Widgets/WidgetSupport.swift @@ -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()