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:
songyc macbook 2026-07-12 21:50:27 +09:00
parent 32306510c8
commit c7024ea612
7 changed files with 79 additions and 32 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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