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:
parent
da515a8f71
commit
79c16b9038
@ -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용) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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と自動的に同期されます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -275,7 +275,7 @@ struct HelpView: View {
|
||||
HelpTopic(
|
||||
symbol: "applewatch",
|
||||
title: "워치에서 기록하기",
|
||||
body: "워치 앱을 열면 꼬리표 목록이 보여요. 꼬리표를 탭하면 그 안의 행동들이 나오고, 행동을 탭하면 시간형은 측정 시작·종료, 횟수형은 +1이 기록돼요. 아이폰 앱에서 즐겨찾기한 행동은 첫 화면 맨 위 '즐겨찾기'에서 한 탭 만에 열 수 있어요. 기록은 아이폰과 자동으로 동기화돼요."
|
||||
body: "워치 앱을 열면 꼬리표 목록이 보여요. 꼬리표를 탭하면 그 안의 행동들이 나오고, 행동을 탭하면 시간형은 측정 시작·종료, 횟수형은 +1이 기록돼요. 아이폰 앱에서 즐겨찾기한 행동은 첫 화면 맨 위 '즐겨찾기'에서 한 탭 만에 열 수 있어요. 측정 중일 때는 첫 화면 맨 위에 측정 중인 행동과 시간이 보이고, 그 줄을 탭하면 바로 측정이 종료돼요. 기록은 아이폰과 자동으로 동기화돼요."
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "watchface.applewatch.case",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user