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:
parent
430ec89341
commit
8818578758
@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user