diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index ed03686..c1594b5 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -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 ` `-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 ` `-autoRunFirstAction` | --- diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/ComplicationPreviewScreen.swift b/myApp/HaruDanim/Haru_DanimWatch Watch App/ComplicationPreviewScreen.swift index 5ceb72b..30d0976 100644 --- a/myApp/HaruDanim/Haru_DanimWatch Watch App/ComplicationPreviewScreen.swift +++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/ComplicationPreviewScreen.swift @@ -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 → 특정 앵커("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)) diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/Localizable.xcstrings b/myApp/HaruDanim/Haru_DanimWatch Watch App/Localizable.xcstrings index cf771d6..6f786ce 100644 --- a/myApp/HaruDanim/Haru_DanimWatch Watch App/Localizable.xcstrings +++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/Localizable.xcstrings @@ -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": { diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/Localizable.xcstrings b/myApp/HaruDanim/Haru_DanimWatchWidgets/Localizable.xcstrings index ee625ba..f7557a3 100644 --- a/myApp/HaruDanim/Haru_DanimWatchWidgets/Localizable.xcstrings +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/Localizable.xcstrings @@ -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": { diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/StatusComplication.swift b/myApp/HaruDanim/Haru_DanimWatchWidgets/StatusComplication.swift index 18f96f1..9269c9f 100644 --- a/myApp/HaruDanim/Haru_DanimWatchWidgets/StatusComplication.swift +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/StatusComplication.swift @@ -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: diff --git a/myApp/HaruDanim/Widgets/ActionRunWidget.swift b/myApp/HaruDanim/Widgets/ActionRunWidget.swift index 1f6efde..46ade6d 100644 --- a/myApp/HaruDanim/Widgets/ActionRunWidget.swift +++ b/myApp/HaruDanim/Widgets/ActionRunWidget.swift @@ -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 diff --git a/myApp/HaruDanim/Widgets/QuestRingWidget.swift b/myApp/HaruDanim/Widgets/QuestRingWidget.swift index b552a8b..c1f8198 100644 --- a/myApp/HaruDanim/Widgets/QuestRingWidget.swift +++ b/myApp/HaruDanim/Widgets/QuestRingWidget.swift @@ -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")