feat(watch): tap the running row on watch home to stop tracking

- 워치 첫 화면 '측정 중' 행이 표시 전용이라 종료하려면 꼬리표/즐겨찾기로 행동을 찾아가야 했던 불편 수정 — 행을 탭하면 대표 측정이 바로 종료됨
- 종료는 행동 목록 탭과 완전히 같은 run 명령 경로 재사용 — 아이폰 쪽 처리(짧은 기록 무시·위젯·라이브 액티비티·동기화)는 기존 그대로라 동기화가 엉킬 여지 없음
- WatchSnapshot에 runningActionID(optional) 추가 — isFavorite와 같은 구버전 캐시 호환 패턴, 구캐시는 이름 매칭 폴백(runningAction 헬퍼)
- UI: 오른쪽 노란 정지 아이콘 + "누르면 측정이 종료돼요" 푸터 + 접근성 라벨. 대표를 못 찾는 이례적 경우엔 기존 표시 전용 유지
- 검증: 페어링 심 E2E — 워치 첫 화면에 종료 버튼 렌더 → -autoStopRunning(신설 인자, 탭과 동일 경로) → 아이폰 세션 종료·모음 탭 반영 스크린샷 확인
- 도움말 워치 항목 문장 추가(ko/en/ja), 워치 카탈로그 2키 번역, 전 카탈로그 클린. CLAUDE.md §11·§14 갱신. iOS Debug/Store + 워치(최소 OS 26.2·11.5 둘 다) 빌드 성공

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
This commit is contained in:
songyc macbook 2026-07-18 05:32:10 +09:00
parent da515a8f71
commit 79c16b9038
8 changed files with 110 additions and 24 deletions

View File

@ -286,7 +286,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
## 11. 애플워치 (프리미엄)
- **워치 앱**: 첫 화면 = [측정 중 섹션] + **즐겨찾기**(앱에서 즐겨찾기한 행동 직행, WatchActionInfo.isFavorite는 구버전 캐시 호환 위해 optional) + 꼬리표 목록 → 행동 목록 → 탭으로 실행. iPhone과 실시간 연동
- **워치 앱**: 첫 화면 = [측정 중 섹션**행을 탭하면 대표 측정 즉시 종료**(행동 목록과 같은 run 명령 재사용, 오른쪽 정지 아이콘+'누르면 측정이 종료돼요' 푸터. 대표 식별은 `WatchSnapshot.runningActionID`(optional, 구버전 캐시는 이름 매칭 폴백 — `runningAction` 헬퍼)] + **즐겨찾기**(앱에서 즐겨찾기한 행동 직행, WatchActionInfo.isFavorite는 구버전 캐시 호환 위해 optional) + 꼬리표 목록 → 행동 목록 → 탭으로 실행. iPhone과 실시간 연동
- **통신** (`WatchSyncManager`(iOS) ↔ `WatchStore`(워치), 페이로드 `WatchShared/WatchPayload.swift`):
- iPhone→워치: `updateApplicationContext`(기본) + `transferCurrentComplicationUserInfo`(측정 상태 변경 또는 30분 경과 시만 — 일일 예산 절약)
- 워치→iPhone: `sendMessage`(응답=최신 스냅숏), 실패/미활성 시 `transferUserInfo` 큐 폴백 (5분 지난 큐 명령은 무시)
@ -335,7 +335,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사) `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `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` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diarySeedPhoto`(오늘=이모지+사진·어제=사진만 기분 사진 시드 — 달력 백레이어 검증) `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` |
| 워치 | `-complicationPreview` `-complicationScroll` `-complicationScrollTo <stale\|quest>` `-autoRunFirstAction` |
| 워치 | `-complicationPreview` `-complicationScroll` `-complicationScrollTo <stale\|quest>` `-autoRunFirstAction` `-autoStopRunning`(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용) |
---

View File

@ -60,34 +60,59 @@ struct ContentView: View {
}
}
/// ' ' stoppable
private func runningRow(_ snapshot: WatchSnapshot, name: String, stoppable: Bool) -> some View {
HStack(spacing: 6) {
Image(systemName: snapshot.runningSymbol ?? "record.circle")
.foregroundStyle(.yellow)
VStack(alignment: .leading, spacing: 0) {
Text(name)
.font(.footnote.weight(.semibold))
.lineLimit(1)
if let start = snapshot.runningStartAt {
Text(start, style: .timer)
.font(.footnote.monospacedDigit())
.foregroundStyle(.secondary)
}
}
Spacer()
if snapshot.extraRunningCount > 0 {
Text("+\(snapshot.extraRunningCount)")
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
}
if stoppable {
Image(systemName: "stop.circle.fill")
.font(.title3)
.foregroundStyle(.yellow)
}
}
}
// MARK:
private func tagList(_ snapshot: WatchSnapshot) -> some View {
List {
if let name = snapshot.runningName {
Section {
HStack(spacing: 6) {
Image(systemName: snapshot.runningSymbol ?? "record.circle")
.foregroundStyle(.yellow)
VStack(alignment: .leading, spacing: 0) {
Text(name)
.font(.footnote.weight(.semibold))
.lineLimit(1)
if let start = snapshot.runningStartAt {
Text(start, style: .timer)
.font(.footnote.monospacedDigit())
.foregroundStyle(.secondary)
}
}
Spacer()
if snapshot.extraRunningCount > 0 {
Text("+\(snapshot.extraRunningCount)")
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
// run .
// () .
if let running = snapshot.runningAction {
Button {
store.run(running)
} label: {
runningRow(snapshot, name: name, stoppable: true)
}
.accessibilityLabel(Text("\(name) 측정 종료"))
} else {
runningRow(snapshot, name: name, stoppable: false)
}
} header: {
Text("측정 중")
} footer: {
Text("누르면 측정이 종료돼요")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section {

View File

@ -83,6 +83,22 @@
}
}
},
"%@ 측정 종료": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Stop tracking %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%@の計測を終了"
}
}
}
},
"%lld": {
"localizations": {
"en": {
@ -281,6 +297,22 @@
}
}
},
"누르면 측정이 종료돼요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Tap to stop tracking"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "タップすると計測が終了します"
}
}
}
},
"다짐": {
"localizations": {
"en": {

View File

@ -105,6 +105,7 @@ final class WatchStore: NSObject {
WidgetCenter.shared.invalidateConfigurationRecommendations()
#if DEBUG
autoRunIfNeeded()
autoStopIfNeeded()
#endif
}
@ -125,6 +126,21 @@ final class WatchStore: NSObject {
watchLog.notice("autoRun firing: \(action.name, privacy: .public)")
run(action)
}
/// -autoStopRunning YES: ' '
/// ( CLI autoRun )
private var didAutoStop = false
private func autoStopIfNeeded() {
guard !didAutoStop,
UserDefaults.standard.bool(forKey: "autoStopRunning"),
WCSession.default.activationState == .activated,
WCSession.default.isReachable,
let snapshot,
let action = snapshot.runningAction else { return }
didAutoStop = true
watchLog.notice("autoStop firing: \(action.name, privacy: .public)")
run(action)
}
#endif
}

View File

@ -121,6 +121,7 @@ final class WatchSyncManager: NSObject {
snapshot.runningName = action.name
snapshot.runningSymbol = action.symbolName
snapshot.runningStartAt = representative.startAt
snapshot.runningActionID = action.uuid
snapshot.extraRunningCount = max(0, running.count - 1)
}

View File

@ -7669,18 +7669,18 @@
}
}
},
"워치 앱을 열면 꼬리표 목록이 보여요. 꼬리표를 탭하면 그 안의 행동들이 나오고, 행동을 탭하면 시간형은 측정 시작·종료, 횟수형은 +1이 기록돼요. 아이폰 앱에서 즐겨찾기한 행동은 첫 화면 맨 위 '즐겨찾기'에서 한 탭 만에 열 수 있어요. 기록은 아이폰과 자동으로 동기화돼요.": {
"워치 앱을 열면 꼬리표 목록이 보여요. 꼬리표를 탭하면 그 안의 행동들이 나오고, 행동을 탭하면 시간형은 측정 시작·종료, 횟수형은 +1이 기록돼요. 아이폰 앱에서 즐겨찾기한 행동은 첫 화면 맨 위 '즐겨찾기'에서 한 탭 만에 열 수 있어요. 측정 중일 때는 첫 화면 맨 위에 측정 중인 행동과 시간이 보이고, 그 줄을 탭하면 바로 측정이 종료돼요. 기록은 아이폰과 자동으로 동기화돼요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Open the watch app to see your tag list. Tap a tag to see its actions; tapping an action starts/stops time tracking or adds +1 for counts. Actions you favorited on iPhone appear under 'Favorites' at the top of the first screen, one tap away. Records sync with your iPhone automatically."
"value": "Open the watch app to see your tag list. Tap a tag to see its actions, then tap an action — time actions start or stop tracking, count actions add +1. Actions you've favorited in the iPhone app are one tap away under 'Favorites' at the top of the first screen. While tracking, the first screen shows the running action and its time at the top — tap that row to stop tracking right away. Records sync with your iPhone automatically."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ウォッチアプリを開くとタグ一覧が表示されます。タグをタップするとその中のアクションが表示され、アクションをタップすると時間タイプは計測の開始・終了、回数タイプは+1が記録されます。iPhoneでお気に入りにしたアクションは最初の画面の一番上「お気に入り」からワンタップで開けます。記録はiPhoneと自動的に同期されます。"
"value": "ウォッチアプリを開くとタグ一覧が表示されます。タグをタップするとその中のアクションが表示され、アクションをタップすると、時間計測は開始・終了、回数は+1が記録されます。iPhoneアプリでお気に入りにしたアクションは、最初の画面の一番上の「お気に入り」からワンタップで開けます。計測中は最初の画面の一番上に計測中のアクションと時間が表示され、その行をタップするとすぐに計測が終了します。記録はiPhoneと自動的に同期されます。"
}
}
}

View File

@ -275,7 +275,7 @@ struct HelpView: View {
HelpTopic(
symbol: "applewatch",
title: "워치에서 기록하기",
body: "워치 앱을 열면 꼬리표 목록이 보여요. 꼬리표를 탭하면 그 안의 행동들이 나오고, 행동을 탭하면 시간형은 측정 시작·종료, 횟수형은 +1이 기록돼요. 아이폰 앱에서 즐겨찾기한 행동은 첫 화면 맨 위 '즐겨찾기'에서 한 탭 만에 열 수 있어요. 기록은 아이폰과 자동으로 동기화돼요."
body: "워치 앱을 열면 꼬리표 목록이 보여요. 꼬리표를 탭하면 그 안의 행동들이 나오고, 행동을 탭하면 시간형은 측정 시작·종료, 횟수형은 +1이 기록돼요. 아이폰 앱에서 즐겨찾기한 행동은 첫 화면 맨 위 '즐겨찾기'에서 한 탭 만에 열 수 있어요. 측정 중일 때는 첫 화면 맨 위에 측정 중인 행동과 시간이 보이고, 그 줄을 탭하면 바로 측정이 종료돼요. 기록은 아이폰과 자동으로 동기화돼요."
),
HelpTopic(
symbol: "watchface.applewatch.case",

View File

@ -38,9 +38,21 @@ nonisolated struct WatchSnapshot: Codable {
var runningName: String?
var runningSymbol: String?
var runningStartAt: Date?
/// id ' ' .
/// optional : ( ) (isFavorite )
var runningActionID: UUID? = nil
///
var extraRunningCount: Int = 0
/// ' ' id , ( )
var runningAction: WatchActionInfo? {
if let runningActionID, let match = actions.first(where: { $0.id == runningActionID }) {
return match
}
guard let runningName else { return nil }
return actions.first { $0.isRunning && $0.name == runningName }
}
func encoded() -> Data? {
try? JSONEncoder().encode(self)
}