feat(widget,watch): instant tap feedback + stale complication timer guard

체감 개선 2건 (사용자 리포트 기반):

1) 홈 위젯 버튼의 '안 눌렸나?' 문제 — ①행동 실행·③다짐 진행률
   위젯의 변하는 값(누적·퍼센트·링)에 invalidatableContent() 적용.
   탭 즉시 해당 값이 '갱신 중' 표시로 바뀌어, 새 타임라인 도착
   (1~3초, WidgetKit 구조상 단축 불가) 전에도 탭 접수가 보인다
   → 중복 탭으로 횟수가 여러 번 올라가는 문제 방지.

2) 워치 컴플리케이션 유령 타이머 — 종료 푸시가 유실되면 '측정 중'
   타이머가 무한히 흐르던 문제. 스냅숏 generatedAt 기준 2시간
   (runningTrustInterval)을 넘긴 '측정 중'은 '새로고침 필요' 표시로
   강등. 워치 앱을 열면 즉시 복구되고 진짜 장시간 측정도 다음
   스냅숏에 타이머가 돌아온다 (데이터 무영향).

검증: 위젯 미리보기 레이아웃 무영향 확인, 워치 미리보기에 강등
샘플 행 추가(-complicationScrollTo stale)로 원형/사각형 렌더링 확인.
새 문자열 4개 워치 앱·워치 위젯 카탈로그 ko/en/ja 번역.

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 13:01:05 +09:00
parent 430ec89341
commit 8818578758
7 changed files with 198 additions and 2 deletions

View File

@ -248,6 +248,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}"
- 슬롯 규칙: 미선택이면 기본 대상 채움, 선택했으면 순서 존중 + 모자란 슬롯은 점선 빈 칸. '선택 안 함' 센티널 `NoneEntityID`
- **타임라인 예산 전략**(`WidgetRefresh`): 주기 폴링 없음. 값 변경은 `DataChange.commit`/인텐트의 reloadAllTimelines가 담당. 측정 중일 때만 10분 간격 미래 엔트리 1시간치+.atEnd, 평상시엔 하루 경계(최대 4h) 안전망
- **버튼 탭 즉시 피드백**: ①③의 변하는 값(누적·퍼센트·링)에 `invalidatableContent()` — 탭 즉시 '갱신 중' 표시로 바뀌어 새 타임라인 도착(1~3초, WidgetKit 구조상 단축 불가) 전에도 탭 접수를 보여 줌(중복 탭 방지)
- 확장 프로세스는 `IntentStore.refresh()`로 작업마다 컨테이너를 새로 열어 최신 데이터 보장 (실패 시 직전 컨테이너 유지 — 크래시 방지)
- `RunActionIntent`는 AppEntity 대신 **UUID 문자열 파라미터** — 버튼 탭 지연·유실 방지의 핵심
@ -261,6 +262,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}"
- 워치→iPhone: `sendMessage`(응답=최신 스냅숏), 실패/미활성 시 `transferUserInfo` 큐 폴백 (5분 지난 큐 명령은 무시)
- 스냅숏은 워치 App Group defaults에 캐시(오래된 스냅숏 역행 방지: generatedAt 비교) → 컴플리케이션이 읽음
- **컴플리케이션** 3종 (15분 .after 타임라인): 현재 현황(대표 행동+타이머+N) / 목표 달성률(기간 선택, 게이지) / 다짐 달성률(rectangular는 3기간 모두, **atMost는 게이지 대신 한도 안/초과 상태 표현**)
- **유령 타이머 안전장치**(현재 현황): '측정 중' 스냅숏이 `generatedAt` 기준 2시간을 넘기면 무한 타이머 대신 "새로고침 필요" 표시로 강등(`StatusProvider.runningTrustInterval`) — 종료 푸시가 유실돼도 유령 타이머가 몇 시간씩 남지 않음. 워치 앱을 열면 즉시 복구, 진짜 장시간 측정도 다음 스냅숏에 타이머 복귀(데이터 무영향)
- ⚠️ `recommendations()`의 description에 포맷 텍스트 금지 — `Text(verbatim:)`만 (WidgetKit assertion으로 익스텐션 즉사)
---
@ -301,7 +303,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}"
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사) `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) |
| 버블 | `-radialExpanded` `-radialDoubleTapTest`(2초 뒤 FAB 더블탭 재현 — `-settings.radialDoubleTapTab <탭>`과 함께) |
| 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage <N>` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` |
| 워치 | `-complicationPreview` `-complicationScroll` `-autoRunFirstAction` |
| 워치 | `-complicationPreview` `-complicationScroll` `-complicationScrollTo <stale\|quest>` `-autoRunFirstAction` |
---

View File

@ -26,6 +26,13 @@ struct ComplicationPreviewScreen: View {
circularBox { StatusComplicationView(previewFamily: .accessoryCircular, entry: status) }
rectBox { StatusComplicationView(previewFamily: .accessoryRectangular, entry: status) }
}
// ' ' ( )
label("현재 현황 · 오래된 측정 강등")
HStack(spacing: 8) {
circularBox { StatusComplicationView(previewFamily: .accessoryCircular, entry: staleSample) }
rectBox { StatusComplicationView(previewFamily: .accessoryRectangular, entry: staleSample) }
}
.id("stale")
}
if let goalRate {
label("목표 달성률")
@ -54,10 +61,21 @@ struct ComplicationPreviewScreen: View {
withAnimation { proxy.scrollTo("quest", anchor: .bottom) }
}
}
// -complicationScrollTo <id> ("stale"/"quest")
if let target = UserDefaults.standard.string(forKey: "complicationScrollTo") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { proxy.scrollTo(target, anchor: .center) }
}
}
}
}
}
private var staleSample: StatusEntry {
StatusEntry(date: .now, locked: false, runningName: "독서", runningSymbol: "book.fill",
runningStartAt: .now.addingTimeInterval(-3 * 3600), extraCount: 0, stale: true)
}
private func label(_ text: String) -> some View {
Text(text)
.font(.system(size: 11, weight: .semibold))

View File

@ -345,6 +345,22 @@
}
}
},
"새로고침": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Refresh"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "更新"
}
}
}
},
"선택한 다짐의 달성률을 표시해요. 한도형 다짐은 상태로 표시돼요. (프리미엄)": {
"localizations": {
"en": {
@ -393,6 +409,22 @@
}
}
},
"앱을 열면 새로고침돼요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Open the app to refresh"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "アプリを開くと更新されます"
}
}
}
},
"없음": {
"localizations": {
"en": {
@ -537,6 +569,38 @@
}
}
},
"측정 표시 새로고침 필요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Timer display needs a refresh"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "計測表示の更新が必要"
}
}
}
},
"측정 표시가 오래됐어요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Timer display is out of date"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "計測表示が古くなっています"
}
}
}
},
"표시할 다짐과 기간을 선택하세요.": {
"localizations": {
"en": {

View File

@ -211,6 +211,22 @@
}
}
},
"새로고침": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Refresh"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "更新"
}
}
}
},
"선택한 다짐의 달성률을 표시해요. 한도형 다짐은 상태로 표시돼요. (프리미엄)": {
"localizations": {
"en": {
@ -259,6 +275,22 @@
}
}
},
"앱을 열면 새로고침돼요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Open the app to refresh"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "アプリを開くと更新されます"
}
}
}
},
"없음": {
"localizations": {
"en": {
@ -355,6 +387,38 @@
}
}
},
"측정 표시 새로고침 필요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Timer display needs a refresh"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "計測表示の更新が必要"
}
}
}
},
"측정 표시가 오래됐어요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Timer display is out of date"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "計測表示が古くなっています"
}
}
}
},
"표시할 다짐과 기간을 선택하세요.": {
"localizations": {
"en": {

View File

@ -17,9 +17,17 @@ struct StatusEntry: TimelineEntry {
let runningSymbol: String?
let runningStartAt: Date?
let extraCount: Int
/// ' '
var stale: Bool = false
}
struct StatusProvider: TimelineProvider {
/// ' ' .
/// .
/// ' ' ,
/// ( )
static let runningTrustInterval: TimeInterval = 2 * 3600
static func makeEntry() -> StatusEntry {
guard let snapshot = ComplicationStore.snapshot() else {
return StatusEntry(date: .now, locked: false, runningName: nil, runningSymbol: nil,
@ -29,13 +37,16 @@ struct StatusProvider: TimelineProvider {
return StatusEntry(date: .now, locked: true, runningName: nil, runningSymbol: nil,
runningStartAt: nil, extraCount: 0)
}
let stale = snapshot.runningStartAt != nil
&& Date.now.timeIntervalSince(snapshot.generatedAt) > Self.runningTrustInterval
return StatusEntry(
date: .now,
locked: false,
runningName: snapshot.runningName,
runningSymbol: snapshot.runningSymbol,
runningStartAt: snapshot.runningStartAt,
extraCount: snapshot.extraRunningCount
extraCount: snapshot.extraRunningCount,
stale: stale
)
}
@ -64,6 +75,34 @@ struct StatusComplicationView: View {
Group {
if entry.locked {
ComplicationLockedView()
} else if entry.stale {
// ' '
switch family {
case .accessoryInline:
Text("측정 표시 새로고침 필요")
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 11))
Text("하루 다님")
.font(.headline)
}
Text("측정 표시가 오래됐어요")
.font(.caption2)
Text("앱을 열면 새로고침돼요")
.font(.caption2)
.foregroundStyle(.secondary)
}
default:
VStack(spacing: 2) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 14, weight: .semibold))
Text("새로고침")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
}
} else if let name = entry.runningName {
switch family {
case .accessoryInline:

View File

@ -206,6 +206,9 @@ struct ActionRunCellView: View {
.font(.caption.monospacedDigit())
.opacity(0.85)
.lineLimit(1)
// ' ' (1~3)
//
.invalidatableContent()
}
.padding(fullBleed ? 14 : 12)
}
@ -223,6 +226,7 @@ struct ActionRunCellView: View {
.font(.caption2.monospacedDigit())
.opacity(0.85)
.lineLimit(1)
.invalidatableContent()
}
Spacer(minLength: 0)
runningDot

View File

@ -127,17 +127,22 @@ struct QuestRingActionCellView: View {
.offset(x: ringSize * 0.38, y: -ringSize * 0.38)
}
}
// · ' '
// ( )
.invalidatableContent()
Text(cell.targetName)
.font(.caption2.weight(.semibold))
.lineLimit(1)
Text(percentText)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.invalidatableContent()
// span ' '
Text(cell.valueLabel)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.invalidatableContent()
if let streak = cell.streakLabel {
HStack(spacing: 2) {
Image(systemName: "flame.fill")