diff --git a/myApp/Keging/CLAUDE.md b/myApp/Keging/CLAUDE.md index e9c7301..eb95116 100644 --- a/myApp/Keging/CLAUDE.md +++ b/myApp/Keging/CLAUDE.md @@ -18,9 +18,9 @@ | 플랫폼 | iOS(iPhone 전용, 세로 고정) + watchOS 앱 + Live Activity(다이나믹 아일랜드) + 단축어(App Intents) | | 번들 ID | `com.yechan.Keging` / 워치 `.watchkitapp` / 위젯 `.KegingWidgets` / App Group `group.com.yechan.Keging` | | 최소 OS | iOS 18.0 / watchOS 10.0 | -| 수익 모델 | **완전 무료** — 광고·결제 일절 없음. CloudKit 없음. HealthKit은 **쓰기 전용**(운동 기록 저장 — §3.7) | +| 수익 모델 | **완전 무료** — 광고·결제 일절 없음. CloudKit 없음. HealthKit은 운동·강도·활성 에너지 **쓰기** + 심박·활성 에너지 **읽기**(워치 심박 세션의 센서 수집용 — 폰 앱 자체는 조회 안 함, §3.7·§3.8) | | 언어 | **한국어(원문)/영어/일본어** — String Catalog(타깃별 `Localizable.xcstrings`, InfoPlist 포함). 설정 → 언어(시스템/한국어/English/日本語)에서 앱 내 전환: `AppleLanguages` 오버라이드, **완전 종료 후 재실행 시 적용**(하루 다님 검증 방식). 워치·Live Activity·단축어 표기는 각자의 시스템 언어를 따름 | -| 현재 상태 (2026-09-05) | **App Store 배포 자료 작성 완료(2026-09-05) — 사용자 등록 단계 대기.** `Marketing/`(하루 다님 구성 이식: 이름·부제목/설명/키워드/프로모션/새 기능/리뷰 메모 3언어 + 스크린샷 아이폰 9장·워치 3장 × ko/en/ja 원본+합성, 합성 도구·재촬영 요령·출시 체크리스트는 `Marketing/README.md`) + `Docs/PRIVACY.md`(개인정보처리방침 3언어) + `PrivacyInfo.xcprivacy` 3타깃(ITMS-91053 대응, 코드 무변경). **사용자 다음 단계**: ①GitHub 공개 저장소 `notalentprogrammer/Keging`에 PRIVACY.md 게시(URL이 description·review-notes에 박혀 있음) ②ASC 앱 생성·자료 입력(review-notes.txt) ③Archive → 업로드 → TestFlight → 제출. 심사 예상 쟁점: 백그라운드 오디오 모드(2.5.4) — 리뷰 메모에 용도 소명 문구 준비됨. 코드·버전(1.0/빌드 1) 무변경. 이전: **8차 반영 완료, 재테스트 대기.** 8차(워치 수정 2건): ①기본·톡톡 진동이 너무 약함 → **.notification(알림급)으로 교체**(§3.3) ②세팅 크라운 충돌 → **목록은 스크롤 전용, 숫자 항목은 눌러서 크라운 편집 화면**(CrownValueEditor, §3.6). 7차(플랫폼 결정: 현행 iOS+워치 유지): ①폰 첫 화면 시작 버튼 위 **워치 연결 배지**(연결됨=그린 전파 아이콘/미연결·미연동=슬래시. ⚠️iOS 제약상 '연결됨'은 워치에서 케깅이 켜져 있을 때만 — §4) + **미연결 시작 확인 창**(기록은 동일함을 안내, 설정 > 애플워치 토글로 끔, 단축어·autoStart는 경고 없이 통과) ②**세팅 기기별 독립** — config 동기화 전면 제거(§3.6), 워치는 시작 화면 **왼쪽 스와이프 세팅 페이지**(TabView .page: 준비/수축/이완/횟수 스테퍼 + 진동 2종 피커(선택 즉시 미리 울림) + 운동 유형·강도, 요약 라인 즉시 반영) ③**워치 진동 강화 재설계**(§3.3: .start/.stop급 강한 시퀀스). 6차: DI 컴팩트=두 색 점+남은 시간, 확장=잠금화면과 동일 실시간 현황, 통계 하루 탭 꺾은선 제거. 5차: LA 완전 자가 렌더링(무갱신 — 실기기 정상 확인). "컴팩트에 국면 글자"는 iOS 제약으로 불가 확정(서버 푸시만 가능 — 기각). **재테스트(사용자)**: 워치 세팅 페이지·강한 진동 3종+완료, 폰 배지·경고 창, 워치 기록이 폰 통계 합류 | 5차 실기기 판독: 진동은 끝까지 제때 = 앱은 백그라운드 생존, 그러나 **국면 라벨·횟수는 항상 수축 2회차(4번째 전송)에서 동결 + 진행바만 계속 흐름** → 백그라운드 LA 갱신을 시스템이 예산제로 폐기하는 것으로 확정, staleDate 재렌더도 시뮬 실측 결과 발화 안 함 → **갱신 자체를 없앤 설계로 전환**(§3.4): 시작 시 1회 등록(`ensureStarted`), 이후 update 호출 0회, 화면은 전부 시스템 렌더(타임라인 바+플레이헤드+남은 시간)와 영원히 참인 정적 정보(수축·이완 범례, 세트 구성)만. 국면 라벨·횟수 텍스트는 LA에서 제거(국면=진동+플레이헤드 색, 횟수=앱 열면). 시뮬 검증: 등록 후 갱신 없이 35초간 카운트다운 4:57→4:27 정확·미니 바 전진·완주 시 DI 해제, 3종 빌드 그린·경고 0·l10n 0/0/0. 이전 라운드: 3차(햅틱 엔진 세션 부착·키퍼 하드닝·하트비트·워치 세팅 pull·완료 진동·오늘의 타임라인·칼로리 정합), 4차(디더 노이즈·브리지 태스크·타임라인 시각 라벨 제거). **재테스트 항목(사용자, 아이폰)**: ①DI 컴팩트(미니 타임라인+남은 시간)·확장·잠금화면 배너(범례+큰 남은 시간+바+세트 구성)가 세트 끝까지 흐르는지 — 이제 갱신이 없어 구조적으로 동결 불가 ②완료 진동·백그라운드 진동(이미 정상 확인됨)·워치 세팅 동기화·칼로리 | +| 현재 상태 (2026-09-05) | **9차 반영 완료(2026-09-05) — TestFlight 재테스트 대기: 아이폰 시작 세트의 워치 심박 측정(§3.8).** 사용자 TestFlight 피드백('워치 연동됨' 배지가 워치 앱 포그라운드에서만 켜짐 — 원래 의도는 폰으로 할 때 워치가 심박을 재는 것)으로 재설계: ①폰 시작 → `HKHealthStore.startWatchApp`으로 워치 앱을 깨워 HKWorkoutSession을 열고 폰으로 **미러링**(watchOS 10+/iOS 17+ 공식 API) — 워치는 센서 역할만(무진동), 심박은 미러링 채널로 폰 운동 화면에 표시, 완주 시 워치 라이브 빌더가 심박·활성 에너지 실측으로 건강 저장(폰 사후 저장은 폴백) ②첫 화면 **워치 배지·사전 확인 창 폐지**, 대신 운동 화면 횟수 아래 상태 줄(연결 중/♥bpm/아이폰만) + 워치 앱을 못 열면 경고 알럿(설정 > 애플워치 '워치 연결 실패 경고' 토글 — 기존 `app.watchWarn` 키 재사용) ③워치 시작은 종전대로 워치 진동·워치 저장. 시뮬로는 startWatchApp·미러링 검증 불가 — **실기기 재테스트 항목**: 폰 시작 → 워치 앱 자동 기동·'아이폰에서 진행 중' 화면·폰에 ♥bpm 표시, 완주 후 건강 앱에 심박 포함 운동 1건(중복 없음), 워치 미착용/멀리 → 30초 안에 경고 알럿 + 폰 사후 저장, 중단 → 건강 기록 없음. 이전: **App Store 배포 자료 작성 완료(2026-09-05) — 사용자 등록 단계 대기.** `Marketing/`(하루 다님 구성 이식: 이름·부제목/설명/키워드/프로모션/새 기능/리뷰 메모 3언어 + 스크린샷 아이폰 9장·워치 3장 × ko/en/ja 원본+합성, 합성 도구·재촬영 요령·출시 체크리스트는 `Marketing/README.md`) + `Docs/PRIVACY.md`(개인정보처리방침 3언어) + `PrivacyInfo.xcprivacy` 3타깃(ITMS-91053 대응, 코드 무변경). **사용자 다음 단계**: ①GitHub 공개 저장소 `notalentprogrammer/Keging`에 PRIVACY.md 게시(URL이 description·review-notes에 박혀 있음) ②ASC 앱 생성·자료 입력(review-notes.txt) ③Archive → 업로드 → TestFlight → 제출. 심사 예상 쟁점: 백그라운드 오디오 모드(2.5.4) — 리뷰 메모에 용도 소명 문구 준비됨. 코드·버전(1.0/빌드 1) 무변경. 이전: **8차 반영 완료, 재테스트 대기.** 8차(워치 수정 2건): ①기본·톡톡 진동이 너무 약함 → **.notification(알림급)으로 교체**(§3.3) ②세팅 크라운 충돌 → **목록은 스크롤 전용, 숫자 항목은 눌러서 크라운 편집 화면**(CrownValueEditor, §3.6). 7차(플랫폼 결정: 현행 iOS+워치 유지): ①폰 첫 화면 시작 버튼 위 **워치 연결 배지**(연결됨=그린 전파 아이콘/미연결·미연동=슬래시. ⚠️iOS 제약상 '연결됨'은 워치에서 케깅이 켜져 있을 때만 — §4) + **미연결 시작 확인 창**(기록은 동일함을 안내, 설정 > 애플워치 토글로 끔, 단축어·autoStart는 경고 없이 통과) ②**세팅 기기별 독립** — config 동기화 전면 제거(§3.6), 워치는 시작 화면 **왼쪽 스와이프 세팅 페이지**(TabView .page: 준비/수축/이완/횟수 스테퍼 + 진동 2종 피커(선택 즉시 미리 울림) + 운동 유형·강도, 요약 라인 즉시 반영) ③**워치 진동 강화 재설계**(§3.3: .start/.stop급 강한 시퀀스). 6차: DI 컴팩트=두 색 점+남은 시간, 확장=잠금화면과 동일 실시간 현황, 통계 하루 탭 꺾은선 제거. 5차: LA 완전 자가 렌더링(무갱신 — 실기기 정상 확인). "컴팩트에 국면 글자"는 iOS 제약으로 불가 확정(서버 푸시만 가능 — 기각). **재테스트(사용자)**: 워치 세팅 페이지·강한 진동 3종+완료, 폰 배지·경고 창, 워치 기록이 폰 통계 합류 | 5차 실기기 판독: 진동은 끝까지 제때 = 앱은 백그라운드 생존, 그러나 **국면 라벨·횟수는 항상 수축 2회차(4번째 전송)에서 동결 + 진행바만 계속 흐름** → 백그라운드 LA 갱신을 시스템이 예산제로 폐기하는 것으로 확정, staleDate 재렌더도 시뮬 실측 결과 발화 안 함 → **갱신 자체를 없앤 설계로 전환**(§3.4): 시작 시 1회 등록(`ensureStarted`), 이후 update 호출 0회, 화면은 전부 시스템 렌더(타임라인 바+플레이헤드+남은 시간)와 영원히 참인 정적 정보(수축·이완 범례, 세트 구성)만. 국면 라벨·횟수 텍스트는 LA에서 제거(국면=진동+플레이헤드 색, 횟수=앱 열면). 시뮬 검증: 등록 후 갱신 없이 35초간 카운트다운 4:57→4:27 정확·미니 바 전진·완주 시 DI 해제, 3종 빌드 그린·경고 0·l10n 0/0/0. 이전 라운드: 3차(햅틱 엔진 세션 부착·키퍼 하드닝·하트비트·워치 세팅 pull·완료 진동·오늘의 타임라인·칼로리 정합), 4차(디더 노이즈·브리지 태스크·타임라인 시각 라벨 제거). **재테스트 항목(사용자, 아이폰)**: ①DI 컴팩트(미니 타임라인+남은 시간)·확장·잠금화면 배너(범례+큰 남은 시간+바+세트 구성)가 세트 끝까지 흐르는지 — 이제 갱신이 없어 구조적으로 동결 불가 ②완료 진동·백그라운드 진동(이미 정상 확인됨)·워치 세팅 동기화·칼로리 | ## 2. 빌드·검증 환경 @@ -40,9 +40,9 @@ IOS/ # iOS 앱 타깃 (Keging) Haptics.swift # 진동 재생 (포그라운드 Core Haptics / 백그라운드 바이브 체이닝 + 완료 진동) BackgroundAudioKeeper.swift # 운동 중 무음 오디오 루프 (ensureAlive — 인터럽션·루트 변경·미디어 리셋 대응) LiveActivityManager.swift # Live Activity 시작/갱신/종료 - PhoneSync.swift # WCSession 폰 측 (워치 기록 수신·병합 + 연결 상태 갱신 — 세팅 동기화 없음) - WatchLinkStatus.swift # 첫 화면 워치 연결 배지용 상태 (isReachable 기준 주의 — §4) - HealthRecorder.swift # 완주 세트를 애플 건강에 사후 저장 (§3.7) + PhoneSync.swift # WCSession 폰 측 (워치 기록 수신·병합 + 워치 심박 세션 저장 결과 수신 — 세팅 동기화 없음) + WatchHeartRateBridge.swift # 폰 시작 세트의 워치 심박 측정 — startWatchApp·미러링 세션·완주/중단 명령·폴백 (§3.8) + HealthRecorder.swift # 완주 세트를 애플 건강에 사후 저장 (§3.7 — 워치 세션이 저장 못 했을 때의 폴백) KegingIntents.swift # 단축어 "케겔 운동 시작" (openAppWhenRun + 알림/플래그) DebugSeed.swift # DEBUG 런치 인자 (§7) Info.plist # UIBackgroundModes=audio (GENERATE_INFOPLIST_FILE와 병합) @@ -56,14 +56,18 @@ Shared/ # 세 타깃 공용 (pbxproj fileSystemSynchronize SessionRecord.swift # 완주 기록 + RecordStore (App Group records.json, id 중복 병합) KegelEngine.swift # 타이머 상태 기계 (iOS·워치 공용, §3.1) KegingActivityAttributes.swift # Live Activity 상태 (canImport(ActivityKit) 가드 — 워치 제외) + WatchBridgeMessage.swift # 폰↔워치 미러링 채널 메시지(finish/discard/heartRate) + WC 저장 결과 키 (§3.8) HealthKitCalls.swift # HealthKit completion API async 래퍼 (iOS 사후 빌더·워치 라이브 빌더 공용) KegingWidgets/ # Live Activity 전용 위젯 확장 (홈 위젯 없음) KegingWidgetsBundle.swift / KegingWidgetsLiveActivity.swift / Info.plist KegingWatch Watch App/ # 워치 앱 타깃 KegingWatchApp.swift / ContentView.swift(WatchContentView — 좌스와이프 세팅 페이지 포함) / WatchCoordinator.swift WatchSettingsView.swift # 워치 전용 세팅 (폰과 독립 — §3.6) - WatchHaptics.swift / WatchSync.swift(기록 전송만) - WatchWorkoutSession.swift # HKWorkoutSession 라이브 기록(심박 포함)+백그라운드 유지 — 실패 시 폴백: + WatchHaptics.swift / WatchSync.swift(기록 전송 + 심박 세션 저장 결과 전송) + WatchAppDelegate.swift # 폰 startWatchApp 진입점 handle(workoutConfiguration) → MirroredWorkoutSession (§3.8) + MirroredWorkoutSession.swift # 폰 시작 세트의 심박 세션(미러링, 무진동·무타이머) — finish/discard 명령 처리 (§3.8) + WatchHealthAuth.swift # 워치 건강 권한 공용(share/read 타입·요청) + WatchWorkoutSession.swift # 워치 시작 세트의 HKWorkoutSession 라이브 기록(심박 포함)+백그라운드 유지 — 실패 시 폴백: RuntimeSessionManager.swift # 확장 런타임 세션 (physical-therapy) Info.plist # WKBackgroundModes=workout-processing·physical-therapy + 건강 문구 DesignAssets/app-icon.svg # 아이콘 원본 (에셋 PNG는 qlmanage 1024 렌더 → sips 알파 제거) @@ -148,15 +152,24 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] - (하루 다님 전례) 인텐트 노출 문구에 'iPhone' 단어 금지 — ITMS-90626 ### 3.7 애플 건강 기록 (`IOS/HealthRecorder.swift`, 워치 `WatchWorkoutSession.swift`, `Shared/HealthKitCalls.swift`) -- **완주한 세트만** 애플 건강에 운동으로 자동 기록 — 중단 세트는 건강에도 없음(§3.1 규칙 일관). 실행한 기기가 기록(폰=사후 HKWorkoutBuilder, 워치=라이브 HKWorkoutSession — 심박 포함, 백그라운드 유지 겸용). 출처 앱 아이콘은 시스템이 자동 표기 +- **완주한 세트만** 애플 건강에 운동으로 자동 기록 — 중단 세트는 건강에도 없음(§3.1 규칙 일관). **워치를 차고 있으면 어느 기기에서 시작하든 워치가 기록**(워치 시작=WatchWorkoutSession, 폰 시작=폰이 깨운 MirroredWorkoutSession — 둘 다 라이브 HKWorkoutSession, 심박·활성 에너지 실측). 워치를 못 쓴 폰 세트만 폰 사후 HKWorkoutBuilder(HealthRecorder)로 저장(§3.8). 출처 앱 아이콘은 시스템이 자동 표기 - **운동 유형 선택(설정 → 애플 건강)**: '기타'(기본) ↔ '코어 트레이닝' — `HealthWorkoutType`(WorkoutConfig 필드, 워치 동기화). 하루 다님의 종목 매핑에서는 둘 다 목록 외라 동일하게 '기타 운동'으로 잡힘(호환 무영향) - **운동 강도(workout effort score, 1~10)**: 설정 → 애플 건강 → '운동 강도 자동 기록'(기본 2·쉬움, '안 함' 가능) — 완주 시 `relateWorkoutEffortSample`로 운동에 연결(애플 운동 앱의 강도 기록과 같은 데이터). iOS 18/워치 11+ — 워치 10은 `#available` 가드로 운동만 기록 - **칼로리**: 빌더로 저장한 운동은 에너지 샘플을 직접 넣지 않으면 0kcal(실기기 실측) — iOS는 `estimatedKcal`(**MET 4.0+강도×0.15, 기본 강도 2 → 4.3**, 체중 70kg 가정)로 activeEnergyBurned 샘플을 add. 워치는 라이브 빌더가 센서로 심박·활성 에너지를 실측 수집(read: 심박·활성 에너지, share에 activeEnergyBurned 추가). ⚠️**워치 '기타' 운동은 애플 규칙상 "심박 기반 vs 빠르게 걷기 상당 중 큰 쪽"으로 적립**(애플 공식 문서) — 케겔은 심박이 낮아 사실상 빠르게 걷기(≈MET 4.3)라, 폰 공식을 여기에 정합(3차 피드백 '워치 33 vs 폰 16kcal' 원인). 잔차는 체중 가정·심박 개인차 -- 권한은 첫 운동 시작 때 요청 — iOS는 쓰기 전용(운동·강도·활성 에너지), 워치는 +심박·활성 에너지 읽기(NSHealthShareUsageDescription 문구가 이를 설명). 거부·미응답이면 조용히 건너뜀(앱 자체 기록·통계 무영향). 워치는 권한 응답이 늦어 운동이 끝났으면 세션을 열지 않는 가드 있음 +- 권한은 첫 운동 시작 때 요청 — iOS는 쓰기(운동·강도·활성 에너지) + 읽기(심박·활성 에너지 — 같은 앱의 아이폰·워치 간 권한이 공유되므로 워치 앱을 한 번도 안 열었어도 폰이 깨운 워치 세션이 센서 수집 가능. 폰 앱 자체는 심박을 조회하지 않음), 워치는 동일 타입(NSHealthShareUsageDescription 문구가 이를 설명). 거부·미응답이면 조용히 건너뜀(앱 자체 기록·통계 무영향). 워치는 권한 응답이 늦어 운동이 끝났으면 세션을 열지 않는 가드 있음 - ⚠️ (하루 다님 ITMS-90683 전례) HealthKit 엔타이틀먼트 보유 → `NSHealthUpdateUsageDescription` 필수 — iOS·워치 Info.plist에 3언어(InfoPlist.xcstrings)로 등록됨. 엔타이틀먼트는 iOS·워치 두 타깃(위젯 제외) - **하루 다님 연계(2026-08-29 확인 — 하루 다님 무수정)**: 하루 다님의 종목별 운동 다짐·타임테이블 운동 블록은 HKWorkout을 출처 무관 조회라 케깅 세트가 '기타 운동'으로 자동 집계됨. 단 '운동 시간' 지표는 `appleExerciseTime`(애플 전용 쓰기 불가) 기반이라 **아이폰 실행분은 미반영, 워치 실행분은 운동 링 적립을 통해 반영**(구조적 한계 — 케깅 측 해결 불가) - `effortScore`는 WorkoutConfig 필드로 워치에 자동 동기화. **WorkoutConfig는 필드 추가 대비 전 필드 decodeIfPresent 커스텀 디코더** — 새 필드를 추가하면 여기에도 기본값을 넣을 것(안 넣으면 기존 사용자 설정이 통째로 초기화됨) +### 3.8 아이폰 시작 세트의 워치 심박 측정 (`IOS/WatchHeartRateBridge.swift`, 워치 `MirroredWorkoutSession.swift`·`WatchAppDelegate.swift`, `Shared/WatchBridgeMessage.swift`) — 9차(2026-09-05) +- **의도(사용자)**: 폰으로 운동해도 워치를 차고 있으면 워치가 심박을 재 준다. 진동은 **시작한 기기에서만**(폰 시작=폰 진동·워치 무진동, 워치 시작=워치 진동). 첫 화면 배지·사전 확인 창은 폐지 — 시작 후 워치 앱을 못 열었을 때만 경고 +- **흐름**: 폰 `startWorkout` → 엔진·진동·LA 시작과 함께 `WatchHeartRateBridge.begin(config)` → `HKHealthStore.startWatchApp(toHandle: HKWorkoutConfiguration(폰 설정의 운동 유형))` → 워치 앱이 (꺼져 있어도) 백그라운드로 깨어나 `WatchAppDelegate.handle(configuration)` → `MirroredWorkoutSession.start`: 권한 → HKWorkoutSession+라이브 빌더 → `startMirroringToCompanionDevice` → 폰의 `workoutSessionMirroringStartHandler`(앱 init에서 등록)가 세션을 받아 `attach` → 상태 `.measuring`. 워치 빌더가 심박을 모을 때마다 `WatchBridgeMessage.heartRate`를 미러링 채널로 전송 → 폰 운동 화면 횟수 아래 "♥ 78 워치 심박". 워치 화면은 '아이폰에서 진행 중'(심박·'진동은 아이폰에서'·중단 버튼) +- **완주**: 폰 → `.finish(effortScore: 폰 설정)` → 워치가 `session.end` → endCollection → finishWorkout(+강도 relate, 워치 11+) → 결과를 **WatchConnectivity**(`WatchSync.sendMirrorResult` — sendMessage, 실패 시 transferUserInfo)로 폰에 통보(미러링 채널은 세션 종료로 닫히므로). 폰은 15초 안에 saved가 안 오면 `HealthRecorder.save`로 **폴백 사후 저장**. **중단**: 폰 → `.discard` → 워치 폐기. 폰 앱 종료·통신 두절(`didDisconnectFromRemoteDevice`) 시 워치는 폐기하고 폰은 `.unavailable`로 세트를 이어 가며 완주 시 폴백 저장 — 양쪽 규칙이 맞물려 **중복 저장 없음** +- **첫 실행 경합 방지**: 워치 깨우기는 `HealthRecorder.requestAuthorizationIfNeeded(then:)`의 콜백에서 — 폰 권한 시트에 답한 뒤(이미 정해져 있으면 즉시) startWatchApp. 권한은 같은 앱의 워치와 공유되므로 워치 쪽 프롬프트 없이 세션이 열린다(시뮬 실측: 폰 시트가 미응답인 채로 워치가 깨어나면 워치에도 건강 프롬프트가 떠 타임아웃) +- **실패 경로**: `startWatchApp` 실패(미페어링·앱 미설치·워치 멀리·미착용 등) 또는 30초 안에 미러링 세션 미도착 → `.unavailable` + `launchFailed` → 운동 화면 알럿 "워치 앱을 열지 못했어요"(설정 > 애플워치 '워치 연결 실패 경고' = `app.watchWarn`, 기본 켬). 늦게 도착한 세션은 `.discard`로 정리. 세트마다 `generation`으로 늦은 콜백 차단 +- **안전장치**: 폰이 30초 타임아웃·중단·완주로 워치 없이 넘어갈 때 WatchConnectivity로 `mirrorCommand=discard`(PhoneSync.sendMirrorDiscard → WatchSync → MirroredWorkoutSession.discardFromPhone)를 보내 워치 세션이 남지 않게 한다. 워치는 앱 재기동 시 `recoverActiveWorkoutSession`으로 잔류 세션을 끝내고 폐기, 미러링 시작이 30초 안에 안 끝나면 폐기. 진단 로그: `Logger(subsystem: com.yechan.Keging[.watchkitapp], category: watchHR)` notice 레벨 — 시뮬은 `xcrun simctl spawn log show --predicate 'process == "Keging"'`(호스트 `log show`엔 안 잡힘), 실기기는 Console.app +- ⚠️ **시뮬레이터 실측(2026-09-05, 페어링 심)**: startWatchApp → 워치 앱 기동·`handle` 호출·권한 프롬프트·세션 시작·가짜 심박(♥60대)·'아이폰에서 진행 중' 화면까지 전부 동작하고 워치 쪽 `startMirroringToCompanionDevice`도 성공을 돌려주지만, **폰의 `workoutSessionMirroringStartHandler`는 끝내 불리지 않는다**(애플 미러링 샘플도 실기기 전용) → 폰은 30초 뒤 '워치 앱을 열지 못했어요' 알럿 + 폰 사후 저장, 워치는 WC 폐기 명령으로 정리. 미러링 수신·완주 저장·결과 회신은 **실기기 TestFlight로만 검증 가능**. 워치 자체 세트가 진행 중이면 폰의 깨우기는 무시(`KegelEngine.isRunning` 가드) + ### 3.6 워치 (`KegingWatch Watch App/`) - 실행하면 시작 버튼만(+세팅 요약 한 줄). 진행 중: 국면(수축=민트/이완=앰버)·카운트다운·n/총·중단 — **40mm에서도 스크롤 없이 한 화면** (46·40mm 스크린샷 검증) - **세팅은 기기별 독립(7차, 2026-09-03)** — 기기마다 진동 질감이 달라 각자 최적화한다는 사용자 결정으로 config 동기화(push/pull) 전면 제거. 워치 세팅은 시작 화면에서 **왼쪽 스와이프**(TabView `.page`, DEBUG `-openSettings`로 바로 열기). **크라운 사용성(8차)**: 목록에서 크라운은 스크롤 전용 — 스테퍼가 크라운을 뺏어 스크롤과 값 변경이 충돌하던 피드백으로, 숫자 4종(준비/수축/이완/횟수)은 **행을 눌러 CrownValueEditor**(큰 민트 값 + `.digitalCrownRotation`, `-openEditor`로 QA 진입)에서 크라운으로 조절. 진동 2종 피커(onChange로 즉시 미리 울림)·운동 유형·강도는 눌러서 목록 선택. 시작 화면 요약 라인은 SettingsStore를 EnvironmentObject로 받아 즉시 반영. **동기화는 기록만**: 완주 기록 `transferUserInfo`로 폰에 합류(오프라인 자동 큐), 폰 `PhoneSync`는 기록 수신 + `WatchLinkStatus` 갱신만 담당 @@ -164,7 +177,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] ## 4. 화면 구성 (탭 없음 — 애플 기본 앱 느낌) -- **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + **버튼 위 워치 연결 배지**(7차 — 연결됨(그린)/미연결/미연동. ⚠️iOS 제약: 폰 쪽 `isReachable`은 워치에서 케깅 앱이 포그라운드일 때만 true라 '연결됨' 기준을 설정 푸터·도움말에 안내) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + **하단 중앙 통계 손잡이(위로 스와이프 힌트)** + 버튼 아래 **세팅 요약 카드 3장**(수축·이완·횟수 — 아이콘+큰 숫자, 탭=세팅). 워치 미연결 상태에서 시작 버튼을 누르면 확인 창(기록 동일함 안내, `app.watchWarn` 토글 — 설정 > 애플워치. 단축어·`-autoStart`는 경고 없이 바로 시작) +- **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + **하단 중앙 통계 손잡이(위로 스와이프 힌트)** + 버튼 아래 **세팅 요약 카드 3장**(수축·이완·횟수 — 아이콘+큰 숫자, 탭=세팅). 시작 버튼은 즉시 시작(9차 — 워치 배지·사전 확인 창 폐지). 운동 화면 횟수 아래 **워치 상태 줄**(워치 연결 중 / ♥bpm 워치 심박 / 아이폰만으로 기록) + 워치 앱을 못 열면 알럿(`app.watchWarn` 토글 — 설정 > 애플워치 '워치 연결 실패 경고', §3.8) - **세팅/설정/도움말**: 시트(Form/List). 설정에 테마·**언어**(시스템/한국어/English/日本語 — 재실행 시 적용)·**애플 건강**(운동 강도 자동 기록) 섹션. 도움말은 5그룹 14주제(기본 사용법·통계·앱 밖에서·애플워치·설정 기타)로 전 기능 사용법 서술. 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션, **국면 색 전환(준비=회그린·수축=그린·이완=앰버, 카운터 색 연동)** + 원 둘레 **세트 진행 링**(rep 기준, 국면 색, 준비는 0) + 큰 현재 횟수 카운터(numericText 전환). 준비 국면은 작은 원이 천천히 부풀며 첫 수축으로 이어짐. Live Activity는 시작 시 `LiveActivityManager.ensureStarted()` 1회 등록·이후 무갱신(§3.4 자가 렌더링) - 아이콘 전용 버튼에는 `accessibilityLabel` 필수 (하루 다님 컨벤션 계승) @@ -183,8 +196,9 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] ## 6. 데이터 흐름 요약 -- 세팅: SettingsStore(@Published WorkoutConfig, App Group defaults 저장) — didSet에서 폰→워치 push +- 세팅: SettingsStore(@Published WorkoutConfig, App Group defaults 저장) — 기기별 독립, 동기화 없음(§3.6) - 기록: 완주 시 엔진이 RecordStore.append → 폰은 자기 컨테이너에 저장, 워치는 자기 저장 + 폰으로 전송(폰이 병합) → 통계는 RecordStore.records를 그대로 집계 +- 심박(폰 시작 세트): 폰 startWatchApp → 워치 미러링 세션 → 심박은 미러링 채널로 폰 화면에, 건강 저장은 워치, 저장 결과는 WC로 폰에 → 미회신 시 폰 폴백 저장 (§3.8) - 위젯 확장은 데이터 접근 없음(Live Activity 상태는 앱이 push) ## 7. DEBUG 런치 인자 (검증용, 모두 DEBUG 빌드 전용 — 콜드 스타트 필수) @@ -199,8 +213,10 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] | `-autoStart` | 시작하자마자 운동 시작 (아이폰·워치 공통) | | `-confirmStop` | 운동 화면의 인라인 중단 확인을 펼친 채 시작 (화면 QA용) | | `-quickConfig` | 세팅을 수축1초·이완1초·2회로 (완주 경로 검증) | +| `-testConfig` | (아이폰) 세팅을 준비3·수축5·이완3·4회(35초)로 — 워치 심박 왕복(깨우기→미러링→완주 저장) 검증용 | | `-resetConfig` | 세팅을 기본값(10초·5초·20회)으로 복원 | | `-noHealth` | 건강 권한 요청 생략(아이폰·워치 공통) — 화면 QA에서 권한 시트가 캡처를 가리는 것 방지 | +| `-mockWatchHeartRate ` | (아이폰) 워치 없이 운동 화면의 '♥bpm 워치 심박' 상태를 흉내 — 상태 줄 QA·스토어 스크린샷용. startWatchApp은 호출 안 함, 완주 시 폰 사후 저장 폴백 | | `-AppleLanguages "(en)"` | (시스템 표준 인자) 그 실행만 언어 강제 — en/ja 렌더 QA용, 워치도 동일 | ## 8. 남은 작업·보류 diff --git a/myApp/Keging/Docs/PRIVACY.md b/myApp/Keging/Docs/PRIVACY.md index 12c403d..1e53c73 100644 --- a/myApp/Keging/Docs/PRIVACY.md +++ b/myApp/Keging/Docs/PRIVACY.md @@ -1,6 +1,6 @@ # 케깅 개인정보처리방침 / Keging Privacy Policy -- 시행일 / Effective date / 施行日: 2026-09-05 +- 시행일 / Effective date / 施行日: 2026-09-05 (개정 / Revised / 改定: 2026-09-06) - 문의 / Contact / お問い合わせ: **songyc.eng@gmail.com** 한국어 · [English](#english) · [日本語](#日本語) @@ -27,10 +27,10 @@ | 권한 | 용도 | |---|---| -| 건강(HealthKit) — 쓰기 | 끝까지 마친 케겔 운동 세트를 사용자 본인의 건강 앱에 **운동**(운동 시간·유형, 선택한 경우 운동 강도, 추정 활성 에너지)으로 저장 | -| 건강(HealthKit) — 읽기 (애플워치만) | 워치에서 운동하는 동안 **심박수와 활성 에너지**를 측정해 그 운동 기록에 포함. 그 외 건강 데이터는 읽지 않음 | +| 건강(HealthKit) — 쓰기 | 끝까지 마친 케겔 운동 세트를 사용자 본인의 건강 앱에 **운동**(운동 시간·유형, 선택한 경우 운동 강도, 활성 에너지)으로 저장 | +| 건강(HealthKit) — 읽기 | 애플워치를 차고 운동하는 동안(워치에서 시작했든, 아이폰에서 시작해 워치 앱이 자동으로 열렸든) 워치가 **심박수와 활성 에너지**를 측정해 그 운동 기록에 포함. 아이폰 앱은 운동 중 워치가 보내 주는 현재 심박수를 화면에 보여 줄 뿐 저장하지 않으며, 건강 앱의 데이터를 직접 조회하지 않음. 그 외 건강 데이터는 읽지 않음 | -건강 데이터는 특히 엄격하게 다룹니다: 앱은 읽어 온 심박수·활성 에너지를 **해당 운동 기록에 담는 용도로만** 사용하며, 이를 **서버로 전송하지 않고 앱 밖으로 내보내지 않습니다.** 건강 데이터를 광고·마케팅 등 다른 목적에 사용하지 않습니다. 건강 기록을 원치 않으면 권한 요청을 거절하거나 건강 앱 → 공유 → 앱에서 케깅의 권한을 끄면 되며, 앱의 다른 기능은 그대로 사용할 수 있습니다. +건강 데이터는 특히 엄격하게 다룹니다: 앱은 측정한 심박수·활성 에너지를 **해당 운동 기록에 담고 운동 중 화면에 표시하는 용도로만** 사용하며, 이를 **서버로 전송하지 않고 앱 밖으로 내보내지 않습니다.** 건강 데이터를 광고·마케팅 등 다른 목적에 사용하지 않습니다. 건강 기록을 원치 않으면 권한 요청을 거절하거나 건강 앱 → 공유 → 앱에서 케깅의 권한을 끄면 되며, 앱의 다른 기능은 그대로 사용할 수 있습니다. 앱은 운동 중 화면이 꺼져 있어도 진동 안내와 실시간 현황(Live Activity)이 이어지도록 백그라운드 오디오 세션을 사용합니다. 이 세션은 소리를 녹음하지 않으며 마이크 권한도 사용하지 않습니다. @@ -77,10 +77,10 @@ The app uses the permissions below **only if you grant them, and only for the st | Permission | Purpose | |---|---| -| Health (HealthKit) — write | Saving each fully completed Kegel set to your own Health app as a **workout** (duration and type, the effort level if you chose one, and an estimated active energy value) | -| Health (HealthKit) — read (Apple Watch only) | Measuring **heart rate and active energy** during a set on the watch so they can be included in that workout. No other health data is read | +| Health (HealthKit) — write | Saving each fully completed Kegel set to your own Health app as a **workout** (duration and type, the effort level if you chose one, and active energy) | +| Health (HealthKit) — read | While you exercise wearing an Apple Watch (whether you started on the watch or on iPhone, which opens the watch app automatically), the watch measures **heart rate and active energy** so they can be included in that workout. The iPhone app only displays the current heart rate the watch sends during the set; it does not store it and never queries data in the Health app. No other health data is read | -Health data is handled with particular care: the app uses the heart rate and active energy it reads **only to include them in that workout record**, and **never sends them to a server or exports them outside the app.** Health data is never used for advertising, marketing, or any other purpose. If you prefer not to record to Health, decline the permission request or turn Keging off under Health app → Sharing → Apps; every other feature keeps working. +Health data is handled with particular care: the app uses the measured heart rate and active energy **only to include them in that workout record and to show them on screen during the set**, and **never sends them to a server or exports them outside the app.** Health data is never used for advertising, marketing, or any other purpose. If you prefer not to record to Health, decline the permission request or turn Keging off under Health app → Sharing → Apps; every other feature keeps working. The app uses a background audio session so that haptic cues and the live status (Live Activity) continue while the screen is off during a set. This session does not record sound and the app does not use the microphone permission. @@ -127,10 +127,10 @@ Privacy inquiries: **songyc.eng@gmail.com** | 権限 | 用途 | |---|---| -| ヘルスケア(HealthKit)— 書き込み | 最後まで終えたケーゲル体操のセットを、ご自身のヘルスケアアプリに**ワークアウト**(時間・種類、選択した場合は運動強度、推定アクティブエネルギー)として保存 | -| ヘルスケア(HealthKit)— 読み取り(Apple Watchのみ) | ウォッチでのセット中に**心拍数とアクティブエネルギー**を計測し、そのワークアウト記録に含めるため。それ以外のヘルスケアデータは読み取りません | +| ヘルスケア(HealthKit)— 書き込み | 最後まで終えたケーゲル体操のセットを、ご自身のヘルスケアアプリに**ワークアウト**(時間・種類、選択した場合は運動強度、アクティブエネルギー)として保存 | +| ヘルスケア(HealthKit)— 読み取り | Apple Watchを着けて運動している間(ウォッチで開始した場合も、iPhoneで開始してウォッチアプリが自動で開いた場合も)、ウォッチが**心拍数とアクティブエネルギー**を計測し、そのワークアウト記録に含めるため。iPhoneアプリはセット中にウォッチから送られる現在の心拍数を表示するだけで、保存せず、ヘルスケアアプリのデータを直接照会することもありません。それ以外のヘルスケアデータは読み取りません | -ヘルスケアデータはとくに厳格に扱います。本アプリは読み取った心拍数・アクティブエネルギーを**そのワークアウト記録に含める目的にのみ**使用し、**サーバーへ送信したり、アプリの外へ書き出したりすることはありません。** ヘルスケアデータを広告・マーケティングなど他の目的に使用することもありません。ヘルスケアへの記録を望まない場合は、権限の要求を拒否するか、ヘルスケアアプリ → 共有 → AppでKegingをオフにしてください。その他の機能はそのまま利用できます。 +ヘルスケアデータはとくに厳格に扱います。本アプリは計測した心拍数・アクティブエネルギーを**そのワークアウト記録に含め、セット中に画面に表示する目的にのみ**使用し、**サーバーへ送信したり、アプリの外へ書き出したりすることはありません。** ヘルスケアデータを広告・マーケティングなど他の目的に使用することもありません。ヘルスケアへの記録を望まない場合は、権限の要求を拒否するか、ヘルスケアアプリ → 共有 → AppでKegingをオフにしてください。その他の機能はそのまま利用できます。 本アプリは、セット中に画面が消えていても振動ガイドとリアルタイム表示(ライブアクティビティ)が続くよう、バックグラウンドのオーディオセッションを使用します。このセッションは音声を録音せず、マイクの権限も使用しません。 diff --git a/myApp/Keging/IOS/AppSettingsView.swift b/myApp/Keging/IOS/AppSettingsView.swift index 5477679..9087716 100644 --- a/myApp/Keging/IOS/AppSettingsView.swift +++ b/myApp/Keging/IOS/AppSettingsView.swift @@ -53,11 +53,11 @@ struct AppSettingsView: View { Text("운동을 끝까지 마치면 그 시간이 위에서 고른 유형으로 애플 건강에 자동 기록되고, 운동 강도(1~10)도 함께 저장돼요. 기록을 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.") } Section { - Toggle("워치 미연결 경고", isOn: $warnNoWatch) + Toggle("워치 연결 실패 경고", isOn: $warnNoWatch) } header: { Text("애플워치") } footer: { - Text("워치가 연결되지 않은 채 시작 버튼을 누르면 확인 창을 띄워요. 워치에서 케깅 앱이 켜져 있을 때만 '연결됨'으로 표시돼요.") + Text("아이폰에서 시작하면 워치 앱을 자동으로 열어 심박수를 재요. 워치 앱을 열지 못하면 확인 창을 띄워요 — 심박 없이 아이폰만으로 기록돼요.") } Section { LabeledContent("버전", value: versionText) diff --git a/myApp/Keging/IOS/ContentView.swift b/myApp/Keging/IOS/ContentView.swift index 39ae989..f2e6e18 100644 --- a/myApp/Keging/IOS/ContentView.swift +++ b/myApp/Keging/IOS/ContentView.swift @@ -2,7 +2,9 @@ // ContentView.swift // Keging // -// 첫 화면 — 큰 시작 버튼 + 구석의 세팅·설정 버튼. 아래로 스와이프하면 통계. +// 첫 화면 — 큰 시작 버튼 + 구석의 세팅·설정 버튼. 위로 스와이프하면 통계. +// 워치 연결 배지·사전 확인 창은 폐지(2026-09-05) — 시작하면 워치 앱을 자동으로 깨워 심박을 재고, +// 못 열면 운동 화면에서 경고(WatchHeartRateBridge, §3.8). // import SwiftUI @@ -10,18 +12,13 @@ import SwiftUI struct ContentView: View { @EnvironmentObject private var engine: KegelEngine @EnvironmentObject private var settings: SettingsStore - @ObservedObject private var watchLink = WatchLinkStatus.shared @Environment(\.scenePhase) private var scenePhase - /// 워치 미연결 상태로 시작할 때 확인 창 (설정에서 끌 수 있음) - @AppStorage("app.watchWarn", store: AppGroup.defaults) private var warnNoWatch = true - @State private var showTimerSettings = false @State private var showAppSettings = false @State private var showHelp = false @State private var showStats = false - @State private var showWatchWarning = false var body: some View { ZStack { @@ -40,17 +37,10 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .startWorkoutRequested)) { _ in startFromShortcut() } - .alert("워치가 연결되어 있지 않아요", isPresented: $showWatchWarning) { - Button("취소", role: .cancel) {} - Button("시작하기") { WorkoutCoordinator.shared.startWorkout() } - } message: { - Text("아이폰만으로 진행해도 운동 기록은 똑같이 저장돼요. 진동은 아이폰에서 울리고, 칼로리는 심박 없이 추정돼요.") - } .onChange(of: scenePhase) { // 백그라운드에서 정지됐다 돌아와도 벽시계 기준 정확한 국면으로 재정렬 if scenePhase == .active { KegelEngine.shared.resyncAfterWake() - WatchLinkStatus.shared.refresh() } } .onAppear { @@ -79,11 +69,8 @@ struct ContentView: View { Spacer() - watchLinkBadge - .padding(.bottom, 16) - Button { - requestStart() + WorkoutCoordinator.shared.startWorkout() } label: { startButtonLabel } @@ -208,48 +195,6 @@ struct ContentView: View { .shadow(color: .black.opacity(0.06), radius: 10, x: 0, y: 3) } - /// 워치 연결 표시 — ⚠️iOS 제약상 '연결됨'은 워치에서 케깅 앱이 켜져 있을 때만 - /// (설정·도움말에 기준 안내). 미연결이어도 기록은 동일하게 저장된다 - private var watchLinkBadge: some View { - let symbol: String - let label: Text - let color: Color - switch watchLink.state { - case .reachable: - symbol = "applewatch.radiowaves.left.and.right" - label = Text("워치 연결됨") - color = AppTheme.green - case .unreachable: - symbol = "applewatch.slash" - label = Text("워치 미연결") - color = .secondary - case .notLinked: - symbol = "applewatch.slash" - label = Text("워치 미연동") - color = .secondary - } - return HStack(spacing: 6) { - Image(systemName: symbol) - .font(.footnote.weight(.semibold)) - label - .font(.footnote.weight(.medium)) - } - .foregroundStyle(color) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(AppTheme.surface, in: Capsule()) - .accessibilityElement(children: .combine) - } - - /// 시작 버튼 — 워치 미연결이면(설정에 따라) 확인 후 시작 - private func requestStart() { - if warnNoWatch, watchLink.state != .reachable { - showWatchWarning = true - } else { - WorkoutCoordinator.shared.startWorkout() - } - } - private func cornerButton(symbol: String, label: Text, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: symbol) diff --git a/myApp/Keging/IOS/DebugSeed.swift b/myApp/Keging/IOS/DebugSeed.swift index 65d65ff..a930f83 100644 --- a/myApp/Keging/IOS/DebugSeed.swift +++ b/myApp/Keging/IOS/DebugSeed.swift @@ -17,6 +17,10 @@ enum DebugSeed { if CommandLine.arguments.contains("-quickConfig") { SettingsStore.shared.config = WorkoutConfig(prepSeconds: 1, contractSeconds: 1, relaxSeconds: 1, reps: 2) } + // 워치 심박 왕복 검증용 (준비 3초·수축 5초·이완 3초·4회 = 35초 — 워치 앱이 깨어나 붙을 시간이 있는 짧은 세트) + if CommandLine.arguments.contains("-testConfig") { + SettingsStore.shared.config = WorkoutConfig(prepSeconds: 3, contractSeconds: 5, relaxSeconds: 3, reps: 4) + } // 기본 세팅 복원 if CommandLine.arguments.contains("-resetConfig") { SettingsStore.shared.config = WorkoutConfig() diff --git a/myApp/Keging/IOS/HealthRecorder.swift b/myApp/Keging/IOS/HealthRecorder.swift index 8784084..85e1913 100644 --- a/myApp/Keging/IOS/HealthRecorder.swift +++ b/myApp/Keging/IOS/HealthRecorder.swift @@ -3,6 +3,7 @@ // Keging // // 완주한 세트를 애플 건강에 '기타' 운동으로 기록 (iOS 측 — 사후 저장). +// 워치가 심박 세션을 열어 저장한 세트에는 쓰이지 않고(WatchHeartRateBridge), 워치를 못 쓴 세트의 폴백. // 운동 강도(workout effort score, 1~10)도 설정값대로 함께 저장. // 권한 거부·미응답이면 조용히 건너뛴다 — 앱 자체 기록·통계에는 영향 없음. // @@ -15,6 +16,9 @@ final class HealthRecorder { private let store = HKHealthStore() private var authRequested = false + private var authInFlight = false + /// 권한 응답을 기다리는 작업들 (첫 실행: 시트에 답한 뒤 워치 깨우기 — 워치 권한은 폰과 공유되므로) + private var pendingAfterAuth: [() -> Void] = [] private init() {} @@ -26,6 +30,13 @@ final class HealthRecorder { ] } + /// 심박·활성 에너지 읽기 — 폰이 깨운 워치 심박 세션이 센서 값을 수집하려면 필요. + /// 건강 권한은 같은 앱의 아이폰·워치 사이에 공유되므로, 워치 앱을 한 번도 안 열었어도 + /// 폰에서 허용해 두면 워치 세션이 바로 동작한다. 폰 앱 자체는 건강 앱에서 심박을 조회하지 않는다 + private static var readTypes: Set { + [HKQuantityType(.heartRate), HKQuantityType(.activeEnergyBurned)] + } + /// 칼로리 추정 — 애플워치의 '기타' 운동 규칙과 정합시킨다: 워치는 "심박 기반 vs 빠르게 걷기 /// 상당 중 큰 쪽"으로 적립하므로(케겔은 심박이 낮아 사실상 빠르게 걷기 ≈ MET 4.3), /// 폰도 MET 4.0 + 강도×0.15(기본 강도 2 → 4.3), 체중 70kg 가정으로 계산 — 워치 실측과의 @@ -35,15 +46,28 @@ final class HealthRecorder { return met * 3.5 * 70 / 200 * (seconds / 60) } - /// 운동 시작 시 호출 — 첫 실행에서만 권한 시트가 뜬다 (운동 진행에는 영향 없음) - func requestAuthorizationIfNeeded() { + /// 운동 시작 시 호출 — 첫 실행에서만 권한 시트가 뜬다 (운동 진행에는 영향 없음). + /// `then`은 권한이 이미 정해져 있으면 즉시, 시트가 떠 있으면 사용자가 답한 뒤에 실행된다 + /// (워치 깨우기를 여기에 걸어 첫 실행의 시트·워치 타임아웃 경합을 없앤다 — §3.8) + func requestAuthorizationIfNeeded(then action: @escaping () -> Void) { #if DEBUG // 화면 QA에서 권한 시트가 캡처를 가리는 것 방지 - if CommandLine.arguments.contains("-noHealth") { return } + if CommandLine.arguments.contains("-noHealth") { action(); return } #endif - guard HKHealthStore.isHealthDataAvailable(), !authRequested else { return } + guard HKHealthStore.isHealthDataAvailable() else { action(); return } + if authRequested, !authInFlight { action(); return } + pendingAfterAuth.append(action) + guard !authRequested else { return } authRequested = true - store.requestAuthorization(toShare: Self.shareTypes, read: nil) { _, _ in } + authInFlight = true + store.requestAuthorization(toShare: Self.shareTypes, read: Self.readTypes) { _, _ in + Task { @MainActor in + self.authInFlight = false + let actions = self.pendingAfterAuth + self.pendingAfterAuth = [] + actions.forEach { $0() } + } + } } /// 완주한 세트 저장 — 시작 시각부터 세트 길이만큼, 설정한 유형(기타/코어 트레이닝)으로 diff --git a/myApp/Keging/IOS/HelpView.swift b/myApp/Keging/IOS/HelpView.swift index 73d8ac9..863ab77 100644 --- a/myApp/Keging/IOS/HelpView.swift +++ b/myApp/Keging/IOS/HelpView.swift @@ -17,7 +17,7 @@ struct HelpView: View { helpRow( symbol: "play.circle.fill", title: Text("바로 시작하기"), - body: Text("첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요. 시작 버튼 위에는 워치 연결 상태가 떠요 — 워치 없이 시작해도 기록은 똑같이 저장되고, 미연결 확인 창은 설정에서 끌 수 있어요.") + body: Text("첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요. 아이폰에서 시작하면 워치 앱이 자동으로 열려 심박수를 재요 — 워치가 없거나 열지 못하면 심박 없이 아이폰만으로 기록되고, 그때 뜨는 확인 창은 설정에서 끌 수 있어요.") ) helpRow( symbol: "slider.horizontal.3", @@ -82,7 +82,7 @@ struct HelpView: View { helpRow( symbol: "arrow.triangle.2.circlepath", title: Text("워치와 아이폰 동기화"), - body: Text("운동 세팅은 아이폰과 워치가 각자 따로 저장해요 — 기기마다 진동 느낌이 달라서 각자에게 맞게 다르게 세팅할 수 있게요. 워치 세팅은 워치의 시작 화면에서 왼쪽으로 쓸어넘겨 바꿔요. 끝까지 마친 세트 기록은 아이폰으로 넘어와 통계에 합쳐지고, 워치가 아이폰과 떨어져 있어도 기록은 쌓였다가 연결되면 자동으로 전송돼요.") + body: Text("운동 세팅은 아이폰과 워치가 각자 따로 저장해요 — 기기마다 진동 느낌이 달라서 각자에게 맞게 다르게 세팅할 수 있게요. 워치 세팅은 워치의 시작 화면에서 왼쪽으로 쓸어넘겨 바꿔요. 아이폰에서 시작하면 워치 앱이 자동으로 열려 심박수만 재 주고 진동은 아이폰에서 울려요. 워치에서 시작하면 워치가 진동하고, 끝까지 마친 기록은 아이폰으로 넘어와 통계에 합쳐져요 — 워치가 아이폰과 떨어져 있어도 기록은 쌓였다가 연결되면 자동으로 전송돼요.") ) } header: { Text("애플워치") @@ -92,7 +92,7 @@ struct HelpView: View { helpRow( symbol: "heart.circle", title: Text("애플 건강 연동"), - body: Text("운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(1~10)는 설정에서 고를 수 있어요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 워치에서 하면 워치가 심박수를 실측해 함께 기록하고, 아이폰에서 하면 애플워치의 '기타 운동' 방식(빠르게 걷기 상당)으로 칼로리를 추정해요 — 그래서 둘의 칼로리가 조금 다를 수 있어요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.") + body: Text("운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(1~10)는 설정에서 고를 수 있어요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 아이폰에서 시작하든 워치에서 시작하든 워치를 차고 있으면 워치가 심박수와 활성 에너지를 실측해 함께 기록해요. 워치를 못 쓴 세트는 아이폰이 애플워치의 '기타 운동' 방식(빠르게 걷기 상당)으로 칼로리를 추정해 저장해요 — 그래서 세트마다 칼로리가 조금 다를 수 있어요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.") ) helpRow( symbol: "paintbrush", diff --git a/myApp/Keging/IOS/KegingApp.swift b/myApp/Keging/IOS/KegingApp.swift index dce7d1e..7c0caaf 100644 --- a/myApp/Keging/IOS/KegingApp.swift +++ b/myApp/Keging/IOS/KegingApp.swift @@ -17,6 +17,7 @@ struct KegingApp: App { DebugSeed.applyLaunchArguments() #endif PhoneSync.shared.activate() + WatchHeartRateBridge.shared.activate() // 워치 미러링 세션 수신 핸들러 — 시작 전에 등록돼 있어야 함 WorkoutCoordinator.shared.wire() } diff --git a/myApp/Keging/IOS/Localizable.xcstrings b/myApp/Keging/IOS/Localizable.xcstrings index d247e04..9e744b5 100644 --- a/myApp/Keging/IOS/Localizable.xcstrings +++ b/myApp/Keging/IOS/Localizable.xcstrings @@ -1,1984 +1,1968 @@ { - "sourceLanguage" : "ko", - "strings" : { - "%lld · %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld · %@" + "sourceLanguage": "ko", + "strings": { + "%lld · %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld · %@" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld · %@" + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld · %@" } }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$lld · %2$@" + "ko": { + "stringUnit": { + "state": "new", + "value": "%1$lld · %2$@" } } } }, - "%lld분" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld min" + "%lld분": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld min" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld分" + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld分" } } } }, - "%lld분 %lld초" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld min %lld s" + "%lld분 %lld초": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld min %lld s" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld分%lld秒" + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld分%lld秒" } }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$lld분 %2$lld초" + "ko": { + "stringUnit": { + "state": "new", + "value": "%1$lld분 %2$lld초" } } } }, - "%lld시" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld:00" + "%lld시": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld:00" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld時" + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld時" } } } }, - "%lld초" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld s" + "%lld초": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld s" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld秒" + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld秒" } } } }, - "%lld회" : { - "localizations" : { - "en" : { - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld rep" + "%lld회": { + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld rep" } }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld reps" + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld reps" } } } } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld回" + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld回" } } } }, - "%lld회를 모두 마쳤어요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "You completed all %lld reps." + "%lld회를 모두 마쳤어요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You completed all %lld reps." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld回すべて完了しました。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld回すべて完了しました。" } } } }, - "계속하기" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keep Going" + "계속하기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep Going" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "続ける" + "ja": { + "stringUnit": { + "state": "translated", + "value": "続ける" } } } }, - "기간" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Period" + "기간": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Period" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "期間" + "ja": { + "stringUnit": { + "state": "translated", + "value": "期間" } } } }, - "기본" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Standard" + "기본": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Standard" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "標準" + "ja": { + "stringUnit": { + "state": "translated", + "value": "標準" } } } }, - "기본 사용법" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Basics" + "기본 사용법": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Basics" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "基本の使い方" + "ja": { + "stringUnit": { + "state": "translated", + "value": "基本の使い方" } } } }, - "기준선" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Baseline" + "기준선": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Baseline" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "基準線" + "ja": { + "stringUnit": { + "state": "translated", + "value": "基準線" } } } }, - "기타" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Other" + "기타": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "その他" + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" } } } }, - "날짜" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Date" + "날짜": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Date" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "日付" + "ja": { + "stringUnit": { + "state": "translated", + "value": "日付" } } } }, - "다이나믹 아일랜드" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dynamic Island" + "다이나믹 아일랜드": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dynamic Island" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dynamic Island" + "ja": { + "stringUnit": { + "state": "translated", + "value": "Dynamic Island" } } } }, - "다크" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dark" + "다크": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ダーク" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダーク" } } } }, - "단축어 앱에서 '케겔 운동 시작' 동작을 추가할 수 있어요. 실행하면 케깅이 열리면서 저장된 세팅대로 바로 시작돼요. 홈 화면 아이콘·잠금화면 위젯·뒷면 탭 등 단축어를 부를 수 있는 곳이라면 어디서든 쓸 수 있어요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "In the Shortcuts app, you can add the 'Start Kegel Workout' action. Running it opens Keging and starts right away with your saved setup. Use it anywhere shortcuts can be triggered — a Home Screen icon, a Lock Screen widget, Back Tap, and more." + "단축어 앱에서 '케겔 운동 시작' 동작을 추가할 수 있어요. 실행하면 케깅이 열리면서 저장된 세팅대로 바로 시작돼요. 홈 화면 아이콘·잠금화면 위젯·뒷면 탭 등 단축어를 부를 수 있는 곳이라면 어디서든 쓸 수 있어요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In the Shortcuts app, you can add the 'Start Kegel Workout' action. Running it opens Keging and starts right away with your saved setup. Use it anywhere shortcuts can be triggered — a Home Screen icon, a Lock Screen widget, Back Tap, and more." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ショートカットアプリで「ケーゲル運動を開始」アクションを追加できます。実行するとKegingが開き、保存した設定ですぐに始まります。ホーム画面アイコン・ロック画面ウィジェット・背面タップなど、ショートカットを呼べる場所ならどこでも使えます。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ショートカットアプリで「ケーゲル運動を開始」アクションを追加できます。実行するとKegingが開き、保存した設定ですぐに始まります。ホーム画面アイコン・ロック画面ウィジェット・背面タップなど、ショートカットを呼べる場所ならどこでも使えます。" } } } }, - "단축어와 시리" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Shortcuts & Siri" + "단축어와 시리": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shortcuts & Siri" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ショートカットとSiri" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ショートカットとSiri" } } } }, - "데이터와 개인정보" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Data & Privacy" + "데이터와 개인정보": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Data & Privacy" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "データとプライバシー" + "ja": { + "stringUnit": { + "state": "translated", + "value": "データとプライバシー" } } } }, - "도움말" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Help" + "도움말": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Help" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ヘルプ" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヘルプ" } } } }, - "라이트" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Light" + "라이트": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ライト" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライト" } } } }, - "바로 시작하기" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Getting Started" + "바로 시작하기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Getting Started" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "すぐに始める" + "ja": { + "stringUnit": { + "state": "translated", + "value": "すぐに始める" } } } }, - "버전" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Version" + "버전": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "バージョン" + "ja": { + "stringUnit": { + "state": "translated", + "value": "バージョン" } } } }, - "범위" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Range" + "범위": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Range" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "範囲" + "ja": { + "stringUnit": { + "state": "translated", + "value": "範囲" } } } }, - "보통" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Moderate" + "보통": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Moderate" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "普通" + "ja": { + "stringUnit": { + "state": "translated", + "value": "普通" } } } }, - "사이클" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cycle" + "사이클": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cycle" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "サイクル" + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイクル" } } } }, - "설정" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Settings" + "설정": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "設定" + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定" } } } }, - "설정·기타" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Settings & More" + "설정·기타": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings & More" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "設定・その他" + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定・その他" } } } }, - "설정해 둔 수축·이완 사이클로 케겔 운동을 바로 시작해요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Starts a Kegel workout right away with your saved squeeze–relax cycle." + "설정해 둔 수축·이완 사이클로 케겔 운동을 바로 시작해요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Starts a Kegel workout right away with your saved squeeze–relax cycle." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "保存した締める・ゆるめるサイクルで、ケーゲル運動をすぐに開始します。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存した締める・ゆるめるサイクルで、ケーゲル運動をすぐに開始します。" } } } }, - "세트" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sets" + "세트": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sets" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "セット" + "ja": { + "stringUnit": { + "state": "translated", + "value": "セット" } } } }, - "수축" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Squeeze" + "수축": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Squeeze" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "締める" + "ja": { + "stringUnit": { + "state": "translated", + "value": "締める" } } } }, - "수축 %lld초 · 이완 %lld초 · %lld회" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Squeeze %lld s · Relax %lld s · ×%lld" + "수축 %lld초 · 이완 %lld초 · %lld회": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Squeeze %lld s · Relax %lld s · ×%lld" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "締める%lld秒 · ゆるめる%lld秒 · %lld回" + "ja": { + "stringUnit": { + "state": "translated", + "value": "締める%lld秒 · ゆるめる%lld秒 · %lld回" } }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회" + "ko": { + "stringUnit": { + "state": "new", + "value": "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회" } } } }, - "수축 진동" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Squeeze Haptic" + "수축 진동": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Squeeze Haptic" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "締めるときの振動" + "ja": { + "stringUnit": { + "state": "translated", + "value": "締めるときの振動" } } } }, - "수축 진동 미리 느끼기" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Preview Squeeze Haptic" + "수축 진동 미리 느끼기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview Squeeze Haptic" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "締める振動を試す" + "ja": { + "stringUnit": { + "state": "translated", + "value": "締める振動を試す" } } } }, - "수축과 이완의 진동이 달라 화면을 보지 않아도 구분할 수 있어요. 진동은 실기기에서만 느껴져요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Squeeze and relax use different vibrations, so you can follow along without looking at the screen. Haptics can only be felt on a real device." + "수축과 이완의 진동이 달라 화면을 보지 않아도 구분할 수 있어요. 진동은 실기기에서만 느껴져요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Squeeze and relax use different vibrations, so you can follow along without looking at the screen. Haptics can only be felt on a real device." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "締めるときとゆるめるときで振動が異なるので、画面を見なくても区別できます。振動は実機でのみ感じられます。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "締めるときとゆるめるときで振動が異なるので、画面を見なくても区別できます。振動は実機でのみ感じられます。" } } } }, - "수축이 시작될 때와 이완이 시작될 때 서로 다른 진동이 울려요. 그래서 화면을 보지 않아도 지금 조여야 할지 풀어야 할지 알 수 있어요. 기본값은 수축=지이이잉(아주 긴 진동), 이완=기본(짧은 한 번)이라 첫 순간의 질감으로 바로 구분돼요. 패턴은 운동 세팅에서 국면별로 고를 수 있고(기본·톡톡·지이이잉), '미리 느끼기' 버튼으로 실제 진동을 확인할 수 있어요. 세트를 끝까지 마치면 아주 길게 축하 진동이 울려요. 화면이 꺼져 있거나 다른 앱을 쓰는 중에는 시스템 진동으로 대신 울려서 질감이 조금 다를 수 있어요. 워치는 손목에 맞춘 더 강한 전용 진동 세트를 쓰고 워치에서 따로 골라요. 진동은 실기기에서만 느껴져요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "A different vibration plays when a squeeze starts and when a relax starts, so you always know whether to squeeze or release without looking at the screen. By default, squeeze = a very long buzz and relax = a short standard tap, so the very first moment tells them apart. You can choose a pattern for each phase in the workout setup (Standard, Double tap, Long buzz) and try them with the preview buttons. When you finish a whole set, an extra-long celebration buzz plays. While the screen is off or you're in another app, system vibrations play instead, so the texture may feel slightly different. The watch uses its own set of stronger wrist-tuned vibrations, chosen separately on the watch. Haptics can only be felt on a real device." + "수축이 시작될 때와 이완이 시작될 때 서로 다른 진동이 울려요. 그래서 화면을 보지 않아도 지금 조여야 할지 풀어야 할지 알 수 있어요. 기본값은 수축=지이이잉(아주 긴 진동), 이완=기본(짧은 한 번)이라 첫 순간의 질감으로 바로 구분돼요. 패턴은 운동 세팅에서 국면별로 고를 수 있고(기본·톡톡·지이이잉), '미리 느끼기' 버튼으로 실제 진동을 확인할 수 있어요. 세트를 끝까지 마치면 아주 길게 축하 진동이 울려요. 화면이 꺼져 있거나 다른 앱을 쓰는 중에는 시스템 진동으로 대신 울려서 질감이 조금 다를 수 있어요. 워치는 손목에 맞춘 더 강한 전용 진동 세트를 쓰고 워치에서 따로 골라요. 진동은 실기기에서만 느껴져요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A different vibration plays when a squeeze starts and when a relax starts, so you always know whether to squeeze or release without looking at the screen. By default, squeeze = a very long buzz and relax = a short standard tap, so the very first moment tells them apart. You can choose a pattern for each phase in the workout setup (Standard, Double tap, Long buzz) and try them with the preview buttons. When you finish a whole set, an extra-long celebration buzz plays. While the screen is off or you're in another app, system vibrations play instead, so the texture may feel slightly different. The watch uses its own set of stronger wrist-tuned vibrations, chosen separately on the watch. Haptics can only be felt on a real device." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "締め始めとゆるめ始めで異なる振動が鳴るので、画面を見なくても今どちらなのか分かります。デフォルトは締める=とても長いブーッ、ゆるめる=短い標準の1回なので、最初の瞬間の感触ですぐ区別できます。パターンはワークアウト設定でそれぞれ選べて(標準・トントン・ブーッ)、「試す」ボタンで実際に確認できます。セットを最後までやり遂げると、とても長いお祝いの振動が鳴ります。画面がオフのときや他のアプリを使っているときはシステム振動で代わりに鳴るため、感触が少し異なることがあります。ウォッチは手首に合わせたより強い専用の振動セットを使い、ウォッチ側で別に選びます。振動は実機でのみ感じられます。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "締め始めとゆるめ始めで異なる振動が鳴るので、画面を見なくても今どちらなのか分かります。デフォルトは締める=とても長いブーッ、ゆるめる=短い標準の1回なので、最初の瞬間の感触ですぐ区別できます。パターンはワークアウト設定でそれぞれ選べて(標準・トントン・ブーッ)、「試す」ボタンで実際に確認できます。セットを最後までやり遂げると、とても長いお祝いの振動が鳴ります。画面がオフのときや他のアプリを使っているときはシステム振動で代わりに鳴るため、感触が少し異なることがあります。ウォッチは手首に合わせたより強い専用の振動セットを使い、ウォッチ側で別に選びます。振動は実機でのみ感じられます。" } } } }, - "쉬움" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Easy" + "쉬움": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Easy" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "楽" + "ja": { + "stringUnit": { + "state": "translated", + "value": "楽" } } } }, - "시각" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Time" + "시각": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Time" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "時刻" + "ja": { + "stringUnit": { + "state": "translated", + "value": "時刻" } } } }, - "시스템" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "System" + "시스템": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "システム" + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム" } } } }, - "시작" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start" + "시작": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "開始" + "ja": { + "stringUnit": { + "state": "translated", + "value": "開始" } } } }, - "시작 버튼을 누르면 준비 시간 뒤에 수축 → 이완을 %lld회 반복해요. 한 세트 약 %@." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "After the ready countdown, squeeze → relax repeats %lld times. One set takes about %@." + "시작 버튼을 누르면 준비 시간 뒤에 수축 → 이완을 %lld회 반복해요. 한 세트 약 %@.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "After the ready countdown, squeeze → relax repeats %lld times. One set takes about %@." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "開始ボタンを押すと、準備時間のあとに締める→ゆるめるを%lld回くり返します。1セットは約%@です。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "開始ボタンを押すと、準備時間のあとに締める→ゆるめるを%lld回くり返します。1セットは約%@です。" } }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "시작 버튼을 누르면 준비 시간 뒤에 수축 → 이완을 %1$lld회 반복해요. 한 세트 약 %2$@." + "ko": { + "stringUnit": { + "state": "new", + "value": "시작 버튼을 누르면 준비 시간 뒤에 수축 → 이완을 %1$lld회 반복해요. 한 세트 약 %2$@." } } } }, - "시작하기" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start Anyway" + "심박 없이 아이폰만으로 기록해요. 워치를 차고 있는지, 케깅 워치 앱이 설치돼 있는지, 워치의 건강 권한이 허용돼 있는지 확인해 주세요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recording with iPhone only, without heart rate. Check that your watch is on your wrist, the Keging watch app is installed, and Health access is allowed on the watch." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "そのまま開始" + "ja": { + "stringUnit": { + "state": "translated", + "value": "心拍数なしでiPhoneだけで記録します。ウォッチを着けているか、Kegingウォッチアプリがインストールされているか、ウォッチでヘルスケアへのアクセスが許可されているかを確認してください。" } } } }, - "아이폰만으로 진행해도 운동 기록은 똑같이 저장돼요. 진동은 아이폰에서 울리고, 칼로리는 심박 없이 추정돼요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Working out with just your iPhone still saves your records exactly the same. Vibrations play on the iPhone, and calories are estimated without heart rate." + "아이폰만으로 기록": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "iPhone only" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "iPhoneだけで行っても運動記録はまったく同じように保存されます。振動はiPhoneで鳴り、カロリーは心拍数なしで推定されます。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "iPhoneのみで記録" } } } }, - "안 함" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Off" + "아이폰에서 시작하면 워치 앱을 자동으로 열어 심박수를 재요. 워치 앱을 열지 못하면 확인 창을 띄워요 — 심박 없이 아이폰만으로 기록돼요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When you start on iPhone, the watch app opens automatically to measure your heart rate. If the watch app can't be opened, a notice appears — the set is recorded with iPhone only, without heart rate." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "なし" + "ja": { + "stringUnit": { + "state": "translated", + "value": "iPhoneで開始すると、ウォッチアプリが自動的に開いて心拍数を計測します。ウォッチアプリを開けないときは確認を表示します — 心拍数なしでiPhoneだけで記録されます。" } } } }, - "애플 건강" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apple Health" + "안 함": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ヘルスケア" + "ja": { + "stringUnit": { + "state": "translated", + "value": "なし" } } } }, - "애플 건강 연동" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apple Health Integration" + "애플 건강": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apple Health" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ヘルスケア連携" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヘルスケア" } } } }, - "애플워치" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apple Watch" + "애플 건강 연동": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apple Health Integration" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apple Watch" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヘルスケア連携" } } } }, - "애플워치에서 운동하기" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Working Out on Apple Watch" + "애플워치": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apple Watch" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apple Watchでワークアウト" + "ja": { + "stringUnit": { + "state": "translated", + "value": "Apple Watch" } } } }, - "앱 밖에서" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Outside the App" + "애플워치에서 운동하기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Working Out on Apple Watch" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "アプリの外で" + "ja": { + "stringUnit": { + "state": "translated", + "value": "Apple Watchでワークアウト" } } } }, - "언어" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Language" + "앱 밖에서": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Outside the App" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "言語" + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリの外で" } } } }, - "언어를 바꾸면 앱을 완전히 종료했다가 다시 실행했을 때 적용돼요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The new language takes effect after you fully quit and relaunch the app." + "언어": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "言語を変更すると、アプリを完全に終了して再起動したときに適用されます。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "言語" } } } }, - "오늘 기록" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Today's Sessions" + "언어를 바꾸면 앱을 완전히 종료했다가 다시 실행했을 때 적용돼요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The new language takes effect after you fully quit and relaunch the app." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "今日の記録" + "ja": { + "stringUnit": { + "state": "translated", + "value": "言語を変更すると、アプリを完全に終了して再起動したときに適用されます。" } } } }, - "오늘은 아직 기록이 없어요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No sessions yet today." + "오늘 기록": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Today's Sessions" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "今日はまだ記録がありません。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日の記録" } } } }, - "오늘의 타임라인" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Today's Timeline" + "오늘은 아직 기록이 없어요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No sessions yet today." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "今日のタイムライン" + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日はまだ記録がありません。" } } } }, - "오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 준비 시간(0~10초), 수축 시간(1~60초), 이완 시간(1~60초), 반복 횟수(1~100회)를 정할 수 있어요. 예를 들어 수축 10초·이완 5초·20회로 설정하면, 수축과 이완을 20번 반복하는 한 세트가 약 5분이 돼요. 시작 버튼 아래의 카드를 눌러도 이 화면이 열려요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The slider button at the top right opens the workout setup. You can set the ready time (0–10 s), squeeze time (1–60 s), relax time (1–60 s), and the number of reps (1–100). For example, with squeeze 10 s · relax 5 s · 20 reps, one set takes about 5 minutes. Tapping the cards under the Start button also opens this screen." + "오늘의 타임라인": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Today's Timeline" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "右上のスライダーボタンがワークアウト設定です。準備時間(0〜10秒)、締める時間(1〜60秒)、ゆるめる時間(1〜60秒)、回数(1〜100回)を設定できます。例えば締める10秒・ゆるめる5秒・20回なら、1セットは約5分です。開始ボタンの下のカードを押してもこの画面が開きます。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日のタイムライン" } } } }, - "완료" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Done" + "오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 준비 시간(0~10초), 수축 시간(1~60초), 이완 시간(1~60초), 반복 횟수(1~100회)를 정할 수 있어요. 예를 들어 수축 10초·이완 5초·20회로 설정하면, 수축과 이완을 20번 반복하는 한 세트가 약 5분이 돼요. 시작 버튼 아래의 카드를 눌러도 이 화면이 열려요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The slider button at the top right opens the workout setup. You can set the ready time (0–10 s), squeeze time (1–60 s), relax time (1–60 s), and the number of reps (1–100). For example, with squeeze 10 s · relax 5 s · 20 reps, one set takes about 5 minutes. Tapping the cards under the Start button also opens this screen." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "完了" + "ja": { + "stringUnit": { + "state": "translated", + "value": "右上のスライダーボタンがワークアウト設定です。準備時間(0〜10秒)、締める時間(1〜60秒)、ゆるめる時間(1〜60秒)、回数(1〜100回)を設定できます。例えば締める10秒・ゆるめる5秒・20回なら、1セットは約5分です。開始ボタンの下のカードを押してもこの画面が開きます。" } } } }, - "완료!" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Done!" + "완료": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "完了!" + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" } } } }, - "왼쪽 위 톱니바퀴 버튼이 설정이에요. 화면 모드(시스템·라이트·다크)와 언어(시스템·한국어·English·日本語)를 고를 수 있어요. 언어는 앱을 완전히 종료했다가 다시 실행하면 적용돼요. 워치는 워치의 시스템 언어를 따라요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The gear button at the top left opens Settings. You can choose the appearance (System, Light, Dark) and the language (System, 한국어, English, 日本語). Language changes take effect after fully quitting and relaunching the app. The watch follows its own system language." + "완료!": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done!" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "左上の歯車ボタンが設定です。画面モード(システム・ライト・ダーク)と言語(システム・한국어・English・日本語)を選べます。言語はアプリを完全に終了して再起動すると適用されます。ウォッチはウォッチのシステム言語に従います。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了!" } } } }, - "운동 강도 자동 기록" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auto Effort Rating" + "왼쪽 위 톱니바퀴 버튼이 설정이에요. 화면 모드(시스템·라이트·다크)와 언어(시스템·한국어·English·日本語)를 고를 수 있어요. 언어는 앱을 완전히 종료했다가 다시 실행하면 적용돼요. 워치는 워치의 시스템 언어를 따라요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The gear button at the top left opens Settings. You can choose the appearance (System, Light, Dark) and the language (System, 한국어, English, 日本語). Language changes take effect after fully quitting and relaunching the app. The watch follows its own system language." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "運動強度の自動記録" + "ja": { + "stringUnit": { + "state": "translated", + "value": "左上の歯車ボタンが設定です。画面モード(システム・ライト・ダーク)と言語(システム・한국어・English・日本語)を選べます。言語はアプリを完全に終了して再起動すると適用されます。ウォッチはウォッチのシステム言語に従います。" } } } }, - "운동 세팅" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Workout Setup" + "운동 강도 자동 기록": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto Effort Rating" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ワークアウト設定" + "ja": { + "stringUnit": { + "state": "translated", + "value": "運動強度の自動記録" } } } }, - "운동 세팅 바꾸기" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Changing Your Setup" + "운동 세팅": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workout Setup" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ワークアウト設定を変える" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークアウト設定" } } } }, - "운동 세팅 열기 — 현재 %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open workout setup — currently %@" + "운동 세팅 바꾸기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Changing Your Setup" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ワークアウト設定を開く — 現在 %@" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークアウト設定を変える" } } } }, - "운동 세팅은 아이폰과 워치가 각자 따로 저장해요 — 기기마다 진동 느낌이 달라서 각자에게 맞게 다르게 세팅할 수 있게요. 워치 세팅은 워치의 시작 화면에서 왼쪽으로 쓸어넘겨 바꿔요. 끝까지 마친 세트 기록은 아이폰으로 넘어와 통계에 합쳐지고, 워치가 아이폰과 떨어져 있어도 기록은 쌓였다가 연결되면 자동으로 전송돼요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Workout settings are stored separately on the iPhone and the watch — each device feels vibrations differently, so you can tune each one to your liking. Change the watch settings by swiping left on the watch's start screen. Sets finished on the watch are sent back to the iPhone and merged into your statistics, and even when the watch is away from the iPhone, records are queued and transferred automatically once connected." + "운동 세팅 열기 — 현재 %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open workout setup — currently %@" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ワークアウト設定はiPhoneとウォッチがそれぞれ別に保存します — デバイスごとに振動の感じ方が違うため、それぞれに合わせて設定できます。ウォッチの設定はウォッチのスタート画面で左にスワイプして変更します。ウォッチで完了したセットの記録はiPhoneに送られて統計に合算され、ウォッチがiPhoneと離れていても、記録はたまって接続時に自動送信されます。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークアウト設定を開く — 現在 %@" } } } }, - "운동 유형" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Workout Type" + "운동 세팅은 아이폰과 워치가 각자 따로 저장해요 — 기기마다 진동 느낌이 달라서 각자에게 맞게 다르게 세팅할 수 있게요. 워치 세팅은 워치의 시작 화면에서 왼쪽으로 쓸어넘겨 바꿔요. 아이폰에서 시작하면 워치 앱이 자동으로 열려 심박수만 재 주고 진동은 아이폰에서 울려요. 워치에서 시작하면 워치가 진동하고, 끝까지 마친 기록은 아이폰으로 넘어와 통계에 합쳐져요 — 워치가 아이폰과 떨어져 있어도 기록은 쌓였다가 연결되면 자동으로 전송돼요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workout settings are stored separately on the iPhone and the watch — each device feels vibrations differently, so you can tune each one to your liking. Change the watch settings by swiping left on the watch's start screen. When you start on iPhone, the watch app opens automatically just to measure your heart rate, and haptics play on the iPhone. When you start on the watch, the watch vibrates, and finished sets are sent back to the iPhone and merged into your statistics — even when the watch is away from the iPhone, records are queued and transferred automatically once connected." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ワークアウトの種類" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークアウト設定はiPhoneとウォッチがそれぞれ別に保存します — デバイスごとに振動の感じ方が違うため、それぞれに合わせて設定できます。ウォッチの設定はウォッチのスタート画面で左にスワイプして変更します。iPhoneで開始すると、ウォッチアプリが自動的に開いて心拍数だけを計測し、振動はiPhoneで鳴ります。ウォッチで開始するとウォッチが振動し、完了したセットの記録はiPhoneに送られて統計に合算されます — ウォッチがiPhoneと離れていても、記録はたまって接続時に自動送信されます。" } } } }, - "운동 중 아래의 중단 버튼을 누르면 확인 후 세트가 끝나요. 중단한 세트는 아예 하지 않은 것으로 처리돼서 기록과 통계에 남지 않아요. 끝까지 마친 세트만 기록돼요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "During a workout, tap the Stop button below and confirm to end the set. A stopped set is treated as if it never happened — it won't appear in your history or statistics. Only sets you finish are recorded." + "운동 유형": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workout Type" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ワークアウト中に下の中断ボタンを押して確認すると、セットが終了します。中断したセットは最初からなかったものとして扱われ、記録や統計には残りません。最後までやり切ったセットだけが記録されます。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークアウトの種類" } } } }, - "운동 중에 앱을 나가면 다이나믹 아일랜드와 잠금화면에 실시간 현황이 떠요. 세트 전체를 국면 색(민트=수축·앰버=이완)으로 칠한 타임라인 바 위로 밝은 줄이 실제 시간에 맞춰 차오르는데, 줄 끝이 걸친 색이 지금 국면이에요. 남은 시간 숫자와 함께 전부 시스템이 직접 그려서 세트가 끝날 때까지 멈추지 않아요. 지금 조여야 할지 풀어야 할지는 진동으로도 늘 알 수 있고, 정확한 횟수는 앱을 열면 보여요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "When you leave the app during a workout, the Dynamic Island and Lock Screen show a live view. A timeline bar paints the whole set in phase colors (mint = squeeze, amber = relax), and a bright line fills across it in real time — the color under the tip of the line is the current phase. Along with the remaining-time numbers, everything is drawn by the system itself, so it keeps moving until the set ends. Vibrations always tell you whether to squeeze or release, and the exact rep count is shown when you open the app." + "운동 중 아래의 중단 버튼을 누르면 확인 후 세트가 끝나요. 중단한 세트는 아예 하지 않은 것으로 처리돼서 기록과 통계에 남지 않아요. 끝까지 마친 세트만 기록돼요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "During a workout, tap the Stop button below and confirm to end the set. A stopped set is treated as if it never happened — it won't appear in your history or statistics. Only sets you finish are recorded." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ワークアウト中にアプリを離れると、Dynamic Islandとロック画面にライブ表示が出ます。セット全体をフェーズの色(ミント=締める・アンバー=ゆるめる)で塗ったタイムラインバーの上を、明るい線が実時間に合わせて満ちていきます — 線の先端が重なる色が今のフェーズです。残り時間の数字とともにすべてシステム自体が描画するため、セットが終わるまで止まりません。締めるかゆるめるかは振動でも常に分かり、正確な回数はアプリを開けば確認できます。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークアウト中に下の中断ボタンを押して確認すると、セットが終了します。中断したセットは最初からなかったものとして扱われ、記録や統計には残りません。最後までやり切ったセットだけが記録されます。" } } } }, - "운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(1~10)는 설정에서 고를 수 있어요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 워치에서 하면 워치가 심박수를 실측해 함께 기록하고, 아이폰에서 하면 애플워치의 '기타 운동' 방식(빠르게 걷기 상당)으로 칼로리를 추정해요 — 그래서 둘의 칼로리가 조금 다를 수 있어요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "When you finish a set, that time is automatically saved to Apple Health as a workout. You can choose the workout type (Other or Core Training) and the effort rating (1–10) in Settings. You'll be asked for Health permission the first time you start, and after allowing it everything is saved automatically. Workouts on the watch record your actual heart rate as well, while iPhone workouts estimate calories the same way Apple Watch treats 'Other' workouts (equivalent to a brisk walk) — so the two may show slightly different calories. Stopped sets are not saved to Health either, and you can turn off Keging's access in the Health app anytime." + "운동 중에 앱을 나가면 다이나믹 아일랜드와 잠금화면에 실시간 현황이 떠요. 세트 전체를 국면 색(민트=수축·앰버=이완)으로 칠한 타임라인 바 위로 밝은 줄이 실제 시간에 맞춰 차오르는데, 줄 끝이 걸친 색이 지금 국면이에요. 남은 시간 숫자와 함께 전부 시스템이 직접 그려서 세트가 끝날 때까지 멈추지 않아요. 지금 조여야 할지 풀어야 할지는 진동으로도 늘 알 수 있고, 정확한 횟수는 앱을 열면 보여요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When you leave the app during a workout, the Dynamic Island and Lock Screen show a live view. A timeline bar paints the whole set in phase colors (mint = squeeze, amber = relax), and a bright line fills across it in real time — the color under the tip of the line is the current phase. Along with the remaining-time numbers, everything is drawn by the system itself, so it keeps moving until the set ends. Vibrations always tell you whether to squeeze or release, and the exact rep count is shown when you open the app." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "セットを最後まで終えると、その時間がヘルスケアにワークアウトとして自動記録されます。ワークアウトの種類(その他・コアトレーニング)と運動強度(1〜10)は設定で選べます。初回開始時にヘルスケアの許可を求められ、許可すればその後は自動で保存されます。ウォッチで行うと心拍数を実測して一緒に記録し、iPhoneで行うとApple Watchの「その他」ワークアウトと同じ方式(早歩き相当)でカロリーを推定します — そのため両者のカロリーは少し異なることがあります。中断したセットはヘルスケアにも記録されず、記録したくない場合はヘルスケアアプリでKegingのアクセスをいつでもオフにできます。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークアウト中にアプリを離れると、Dynamic Islandとロック画面にライブ表示が出ます。セット全体をフェーズの色(ミント=締める・アンバー=ゆるめる)で塗ったタイムラインバーの上を、明るい線が実時間に合わせて満ちていきます — 線の先端が重なる色が今のフェーズです。残り時間の数字とともにすべてシステム自体が描画するため、セットが終わるまで止まりません。締めるかゆるめるかは振動でも常に分かり、正確な回数はアプリを開けば確認できます。" } } } }, - "운동을 끝까지 마치면 그 시간이 위에서 고른 유형으로 애플 건강에 자동 기록되고, 운동 강도(1~10)도 함께 저장돼요. 기록을 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "When you finish a set, that time is automatically saved to Apple Health as the workout type chosen above, along with the effort rating (1–10). If you don't want this, turn off Keging's access in the Health app." + "운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(1~10)는 설정에서 고를 수 있어요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 아이폰에서 시작하든 워치에서 시작하든 워치를 차고 있으면 워치가 심박수와 활성 에너지를 실측해 함께 기록해요. 워치를 못 쓴 세트는 아이폰이 애플워치의 '기타 운동' 방식(빠르게 걷기 상당)으로 칼로리를 추정해 저장해요 — 그래서 세트마다 칼로리가 조금 다를 수 있어요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When you finish a set, that time is automatically saved to Apple Health as a workout. You can choose the workout type (Other or Core Training) and the effort rating (1–10) in Settings. You'll be asked for Health permission the first time you start, and after allowing it everything is saved automatically. Whether you start on iPhone or on the watch, if you're wearing your watch it measures your actual heart rate and active energy and records them with the set. Sets where the watch couldn't be used are saved by the iPhone with calories estimated the same way Apple Watch treats 'Other' workouts (equivalent to a brisk walk) — so calories may differ slightly from set to set. Stopped sets are not saved to Health either, and you can turn off Keging's access in the Health app anytime." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "セットを最後まで終えると、その時間が上で選んだ種類のワークアウトとしてヘルスケアに自動記録され、運動強度(1〜10)も一緒に保存されます。記録したくない場合は、ヘルスケアアプリでKegingのアクセスをオフにしてください。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "セットを最後まで終えると、その時間がヘルスケアにワークアウトとして自動記録されます。ワークアウトの種類(その他・コアトレーニング)と運動強度(1〜10)は設定で選べます。初回開始時にヘルスケアの許可を求められ、許可すればその後は自動で保存されます。iPhoneから開始してもウォッチから開始しても、ウォッチを着けていればウォッチが心拍数とアクティブエネルギーを実測して一緒に記録します。ウォッチを使えなかったセットは、iPhoneがApple Watchの「その他」ワークアウトと同じ方式(早歩き相当)でカロリーを推定して保存します — そのためセットごとにカロリーが少し異なることがあります。中断したセットはヘルスケアにも記録されず、記録したくない場合はヘルスケアアプリでKegingのアクセスをいつでもオフにできます。" } } } }, - "워치 미연결" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Watch not connected" + "운동을 끝까지 마치면 그 시간이 위에서 고른 유형으로 애플 건강에 자동 기록되고, 운동 강도(1~10)도 함께 저장돼요. 기록을 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When you finish a set, that time is automatically saved to Apple Health as the workout type chosen above, along with the effort rating (1–10). If you don't want this, turn off Keging's access in the Health app." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ウォッチ未接続" + "ja": { + "stringUnit": { + "state": "translated", + "value": "セットを最後まで終えると、その時間が上で選んだ種類のワークアウトとしてヘルスケアに自動記録され、運動強度(1〜10)も一緒に保存されます。記録したくない場合は、ヘルスケアアプリでKegingのアクセスをオフにしてください。" } } } }, - "워치 미연결 경고" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Warn when watch isn't connected" + "워치 심박": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Watch heart rate" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ウォッチ未接続時に警告" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウォッチの心拍" } } } }, - "워치 미연동" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No watch linked" + "워치 심박 준비 중": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for watch heart rate" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ウォッチ未連携" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウォッチの心拍を待機中" } } } }, - "워치 앱을 열면 시작 버튼이 있고, 누르면 바로 시작돼요. 화면에 수축·이완 상태와 남은 시간, 몇 번째인지가 한눈에 보여요. 시작 화면에서 왼쪽으로 쓸어넘기면 워치 전용 세팅(준비·수축·이완·횟수·진동·애플 건강)을 바꿀 수 있어요. 워치에서 실행하면 진동은 워치에서만 울려요. 중단 버튼을 누르면 그 세트는 기록되지 않아요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open the watch app and tap the Start button to begin immediately — the screen shows the current phase, the remaining time, and which rep you're on, all at a glance. Swipe left on the start screen to change watch-only settings (ready, squeeze, relax, reps, vibration, Apple Health). When you start from the watch, vibrations play only on the watch. Tapping Stop discards that set." + "워치 앱을 열면 시작 버튼이 있고, 누르면 바로 시작돼요. 화면에 수축·이완 상태와 남은 시간, 몇 번째인지가 한눈에 보여요. 시작 화면에서 왼쪽으로 쓸어넘기면 워치 전용 세팅(준비·수축·이완·횟수·진동·애플 건강)을 바꿀 수 있어요. 워치에서 실행하면 진동은 워치에서만 울려요. 중단 버튼을 누르면 그 세트는 기록되지 않아요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open the watch app and tap the Start button to begin immediately — the screen shows the current phase, the remaining time, and which rep you're on, all at a glance. Swipe left on the start screen to change watch-only settings (ready, squeeze, relax, reps, vibration, Apple Health). When you start from the watch, vibrations play only on the watch. Tapping Stop discards that set." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ウォッチアプリを開くと開始ボタンがあり、押すとすぐに始まります。画面に締める・ゆるめるの状態、残り時間、何回目かが一目で分かります。スタート画面で左にスワイプすると、ウォッチ専用の設定(準備・締める・ゆるめる・回数・振動・ヘルスケア)を変更できます。ウォッチから実行すると振動はウォッチだけで鳴ります。中断するとそのセットは記録されません。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウォッチアプリを開くと開始ボタンがあり、押すとすぐに始まります。画面に締める・ゆるめるの状態、残り時間、何回目かが一目で分かります。スタート画面で左にスワイプすると、ウォッチ専用の設定(準備・締める・ゆるめる・回数・振動・ヘルスケア)を変更できます。ウォッチから実行すると振動はウォッチだけで鳴ります。中断するとそのセットは記録されません。" } } } }, - "워치 연결됨" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Watch connected" + "워치 앱을 열지 못했어요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't Open the Watch App" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ウォッチ接続中" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウォッチアプリを開けませんでした" } } } }, - "워치가 연결되어 있지 않아요" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Watch Isn't Connected" + "워치 연결 실패 경고": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warn when watch can't be opened" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ウォッチが接続されていません" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウォッチを開けないときに警告" } } } }, - "워치가 연결되지 않은 채 시작 버튼을 누르면 확인 창을 띄워요. 워치에서 케깅 앱이 켜져 있을 때만 '연결됨'으로 표시돼요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Shows a confirmation when you tap Start while the watch isn't connected. 'Connected' appears only while the Keging app is open on your watch." + "워치 연결 중": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting to watch" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ウォッチが接続されていない状態で開始ボタンを押すと確認を表示します。ウォッチでKegingアプリが開いているときのみ「接続中」と表示されます。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウォッチに接続中" } } } }, - "워치와 아이폰 동기화" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Watch & iPhone Sync" + "워치와 아이폰 동기화": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Watch & iPhone Sync" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ウォッチとiPhoneの同期" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウォッチとiPhoneの同期" } } } }, - "월간" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Monthly" + "월간": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Monthly" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "月間" + "ja": { + "stringUnit": { + "state": "translated", + "value": "月間" } } } }, - "이 기간에는 아직 기록이 없어요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No sessions in this period yet." + "이 기간에는 아직 기록이 없어요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No sessions in this period yet." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "この期間の記録はまだありません。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "この期間の記録はまだありません。" } } } }, - "이번 달" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "This Month" + "이번 달": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This Month" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "今月" + "ja": { + "stringUnit": { + "state": "translated", + "value": "今月" } } } }, - "이번 주" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "This Week" + "이번 주": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This Week" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "今週" + "ja": { + "stringUnit": { + "state": "translated", + "value": "今週" } } } }, - "이번 주 ↔ 지난 7일 전환" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "This Week ↔ Last 7 Days" + "이번 주 ↔ 지난 7일 전환": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This Week ↔ Last 7 Days" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "今週 ↔ 過去7日の切り替え" + "ja": { + "stringUnit": { + "state": "translated", + "value": "今週 ↔ 過去7日の切り替え" } } } }, - "이완" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Relax" + "이완": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Relax" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ゆるめる" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゆるめる" } } } }, - "이완 진동" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Relax Haptic" + "이완 진동": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Relax Haptic" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ゆるめるときの振動" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゆるめるときの振動" } } } }, - "이완 진동 미리 느끼기" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Preview Relax Haptic" + "이완 진동 미리 느끼기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview Relax Haptic" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ゆるめる振動を試す" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゆるめる振動を試す" } } } }, - "일별 횟수" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reps by Day" + "일별 횟수": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reps by Day" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "日別の回数" + "ja": { + "stringUnit": { + "state": "translated", + "value": "日別の回数" } } } }, - "전력" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "All Out" + "전력": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All Out" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "全力" + "ja": { + "stringUnit": { + "state": "translated", + "value": "全力" } } } }, - "주간" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Weekly" + "주간": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Weekly" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "週間" + "ja": { + "stringUnit": { + "state": "translated", + "value": "週間" } } } }, - "주간·월간 화면 위의 스위치로 두 가지 방식을 오갈 수 있어요. '이번 주 / 이번 달'은 달력 기준(월요일~일요일, 1일~말일)이고, '지난 7일 / 지난 30일'은 오늘을 기준으로 거슬러 세는 방식이에요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Use the switch above the weekly and monthly views to toggle between two modes: 'This Week / This Month' follows the calendar (Monday–Sunday, 1st to last day), while 'Last 7 Days / Last 30 Days' counts back from today." + "주간·월간 화면 위의 스위치로 두 가지 방식을 오갈 수 있어요. '이번 주 / 이번 달'은 달력 기준(월요일~일요일, 1일~말일)이고, '지난 7일 / 지난 30일'은 오늘을 기준으로 거슬러 세는 방식이에요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use the switch above the weekly and monthly views to toggle between two modes: 'This Week / This Month' follows the calendar (Monday–Sunday, 1st to last day), while 'Last 7 Days / Last 30 Days' counts back from today." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "週間・月間画面の上のスイッチで2つの方式を切り替えられます。「今週/今月」はカレンダー基準(月〜日、1日〜末日)、「過去7日/過去30日」は今日からさかのぼって数える方式です。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "週間・月間画面の上のスイッチで2つの方式を切り替えられます。「今週/今月」はカレンダー基準(月〜日、1日〜末日)、「過去7日/過去30日」は今日からさかのぼって数える方式です。" } } } }, - "준비" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ready" + "준비": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ready" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "準備" + "ja": { + "stringUnit": { + "state": "translated", + "value": "準備" } } } }, - "중간에 중단하면" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stopping Midway" + "중간에 중단하면": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stopping Midway" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "途中で中断すると" + "ja": { + "stringUnit": { + "state": "translated", + "value": "途中で中断すると" } } } }, - "중단" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stop" + "중단": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "中断" + "ja": { + "stringUnit": { + "state": "translated", + "value": "中断" } } } }, - "중단하기" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stop" + "중단하기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "中断する" + "ja": { + "stringUnit": { + "state": "translated", + "value": "中断する" } } } }, - "중단하면 이 세트는 기록되지 않아요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "If you stop, this set won't be recorded." + "중단하면 이 세트는 기록되지 않아요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "If you stop, this set won't be recorded." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "中断すると、このセットは記録されません。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "中断すると、このセットは記録されません。" } } } }, - "지금" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Now" + "지금": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Now" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "今" + "ja": { + "stringUnit": { + "state": "translated", + "value": "今" } } } }, - "지난 30일" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Last 30 Days" + "지난 30일": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last 30 Days" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "過去30日" + "ja": { + "stringUnit": { + "state": "translated", + "value": "過去30日" } } } }, - "지난 7일" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Last 7 Days" + "지난 7일": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last 7 Days" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "過去7日" + "ja": { + "stringUnit": { + "state": "translated", + "value": "過去7日" } } } }, - "지이이잉" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Long buzz" + "지이이잉": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Long buzz" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ブーッ" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブーッ" } } } }, - "진동 패턴" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Haptic Patterns" + "진동 패턴": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Haptic Patterns" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "振動パターン" + "ja": { + "stringUnit": { + "state": "translated", + "value": "振動パターン" } } } }, - "진동으로 구분하기" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Follow by Vibration" + "진동으로 구분하기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Follow by Vibration" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "振動で区別する" + "ja": { + "stringUnit": { + "state": "translated", + "value": "振動で区別する" } } } }, - "첫 화면을 위로 쓸어올리거나 아래 가운데의 손잡이를 누르면 통계 화면이 올라와요. 닫을 때는 아래로 쓸어내리거나 X 버튼을 누르면 돼요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Swipe up on the main screen, or tap the handle at the bottom center, and the statistics screen pops up. To close, swipe down or tap the X button." + "첫 화면을 위로 쓸어올리거나 아래 가운데의 손잡이를 누르면 통계 화면이 올라와요. 닫을 때는 아래로 쓸어내리거나 X 버튼을 누르면 돼요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Swipe up on the main screen, or tap the handle at the bottom center, and the statistics screen pops up. To close, swipe down or tap the X button." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "最初の画面を上にスワイプするか、下部中央のハンドルを押すと統計画面が上がってきます。閉じるときは下にスワイプするか、Xボタンを押します。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "最初の画面を上にスワイプするか、下部中央のハンドルを押すと統計画面が上がってきます。閉じるときは下にスワイプするか、Xボタンを押します。" } } } }, - "첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요. 시작 버튼 위에는 워치 연결 상태가 떠요 — 워치 없이 시작해도 기록은 똑같이 저장되고, 미연결 확인 창은 설정에서 끌 수 있어요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tap the big Start button on the main screen to begin with your saved setup. If you set a ready time, the first squeeze starts after that countdown. Squeeze while the circle on screen shrinks, and release as it grows. When you finish all the reps, a completion screen appears and the set is recorded. Above the Start button you'll see your watch connection status — starting without the watch saves your records just the same, and you can turn off the confirmation in Settings." + "첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요. 아이폰에서 시작하면 워치 앱이 자동으로 열려 심박수를 재요 — 워치가 없거나 열지 못하면 심박 없이 아이폰만으로 기록되고, 그때 뜨는 확인 창은 설정에서 끌 수 있어요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tap the big Start button on the main screen to begin with your saved setup. If you set a ready time, the first squeeze starts after that countdown. Squeeze while the circle on screen shrinks, and release as it grows. When you finish all the reps, a completion screen appears and the set is recorded. When you start on iPhone, the watch app opens automatically to measure your heart rate — if there's no watch or it can't be opened, the set is recorded with iPhone only, without heart rate, and you can turn off the notice that appears in Settings." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "最初の画面の大きな開始ボタンを押すと、保存した設定でワークアウトが始まります。準備時間を設定していれば、そのカウントダウンのあとに最初の締めが始まります。画面の円が縮むときに締めて、大きくなるときに力を抜きます。設定した回数をすべて終えると完了画面が表示され、記録が保存されます。開始ボタンの上にはウォッチの接続状態が表示されます — ウォッチなしで始めても記録はまったく同じように保存され、未接続の確認は設定でオフにできます。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "最初の画面の大きな開始ボタンを押すと、保存した設定でワークアウトが始まります。準備時間を設定していれば、そのカウントダウンのあとに最初の締めが始まります。画面の円が縮むときに締めて、大きくなるときに力を抜きます。設定した回数をすべて終えると完了画面が表示され、記録が保存されます。iPhoneで開始すると、ウォッチアプリが自動的に開いて心拍数を計測します — ウォッチがない、または開けない場合は心拍数なしでiPhoneだけで記録され、そのとき表示される確認は設定でオフにできます。" } } } }, - "취소" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancel" + "케겔 운동 시작": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start Kegel Workout" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "キャンセル" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ケーゲル運動を開始" } } } }, - "케겔 운동 시작" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start Kegel Workout" + "케깅은 광고와 결제가 없는 완전 무료 앱이에요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keging is completely free — no ads, no purchases." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ケーゲル運動を開始" + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kegingは広告も課金もない完全無料アプリです。" } } } }, - "케깅은 광고와 결제가 없는 완전 무료 앱이에요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keging is completely free — no ads, no purchases." + "케깅은 광고와 결제가 없는 완전 무료 앱이에요. 모든 기록은 이 기기(그리고 워치)에만 저장되고, 외부 서버로 전송되지 않아요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keging is completely free, with no ads and no purchases. All records are stored only on this device (and your watch) and are never sent to any server." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kegingは広告も課金もない完全無料アプリです。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kegingは広告も課金もない完全無料アプリです。すべての記録はこの端末(とウォッチ)にのみ保存され、外部サーバーへ送信されることはありません。" } } } }, - "케깅은 광고와 결제가 없는 완전 무료 앱이에요. 모든 기록은 이 기기(그리고 워치)에만 저장되고, 외부 서버로 전송되지 않아요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keging is completely free, with no ads and no purchases. All records are stored only on this device (and your watch) and are never sent to any server." + "코어 트레이닝": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Core Training" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kegingは広告も課金もない完全無料アプリです。すべての記録はこの端末(とウォッチ)にのみ保存され、外部サーバーへ送信されることはありません。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "コアトレーニング" } } } }, - "코어 트레이닝" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Core Training" + "테마": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Theme" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "コアトレーニング" + "ja": { + "stringUnit": { + "state": "translated", + "value": "テーマ" } } } }, - "테마" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Theme" + "테마와 언어": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Theme & Language" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "テーマ" + "ja": { + "stringUnit": { + "state": "translated", + "value": "テーマと言語" } } } }, - "테마와 언어" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Theme & Language" + "톡톡": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Double tap" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "テーマと言語" + "ja": { + "stringUnit": { + "state": "translated", + "value": "トントン" } } } }, - "톡톡" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Double tap" + "통계": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Statistics" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "トントン" + "ja": { + "stringUnit": { + "state": "translated", + "value": "統計" } } } }, - "통계" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Statistics" + "통계 닫기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close statistics" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "統計" + "ja": { + "stringUnit": { + "state": "translated", + "value": "統計を閉じる" } } } }, - "통계 닫기" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Close statistics" + "통계 보기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show statistics" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "統計を閉じる" + "ja": { + "stringUnit": { + "state": "translated", + "value": "統計を見る" } } } }, - "통계 보기" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show statistics" + "통계 열기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opening Statistics" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "統計を見る" + "ja": { + "stringUnit": { + "state": "translated", + "value": "統計を開く" } } } }, - "통계 열기" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Opening Statistics" + "하루": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Day" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "統計を開く" + "ja": { + "stringUnit": { + "state": "translated", + "value": "1日" } } } }, - "하루" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Day" + "하루 · 주간 · 월간": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Day · Weekly · Monthly" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "1日" + "ja": { + "stringUnit": { + "state": "translated", + "value": "1日・週間・月間" } } } }, - "하루 · 주간 · 월간" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Day · Weekly · Monthly" + "하루 탭 맨 위의 타임라인에서 오늘 언제 운동했는지 24시간 축 위의 점으로 한눈에 볼 수 있어요(점이 클수록 횟수가 많은 세트). 아래로는 시간대별 꺾은선 그래프와 오늘 기록 목록이 이어져요. 주간·월간 탭에서는 일별 횟수를 꺾은선 그래프와 합계(세트·횟수)로 보여줘요. 주는 월요일에 시작해요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The timeline at the top of the Day tab shows at a glance when you worked out today, as dots on a 24-hour axis (bigger dots mean sets with more reps). Below it come an hourly line chart and today's session list. The Weekly and Monthly tabs show daily reps as a line chart with totals (sets and reps). Weeks start on Monday." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "1日・週間・月間" + "ja": { + "stringUnit": { + "state": "translated", + "value": "1日タブの一番上のタイムラインでは、今日いつ運動したかを24時間の軸上の点でひと目で確認できます(点が大きいほど回数の多いセット)。その下に時間帯別の折れ線グラフと今日の記録リストが続きます。週間・月間タブでは日別の回数を折れ線グラフと合計(セット・回数)で表示します。週は月曜日から始まります。" } } } }, - "하루 탭 맨 위의 타임라인에서 오늘 언제 운동했는지 24시간 축 위의 점으로 한눈에 볼 수 있어요(점이 클수록 횟수가 많은 세트). 아래로는 시간대별 꺾은선 그래프와 오늘 기록 목록이 이어져요. 주간·월간 탭에서는 일별 횟수를 꺾은선 그래프와 합계(세트·횟수)로 보여줘요. 주는 월요일에 시작해요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The timeline at the top of the Day tab shows at a glance when you worked out today, as dots on a 24-hour axis (bigger dots mean sets with more reps). Below it come an hourly line chart and today's session list. The Weekly and Monthly tabs show daily reps as a line chart with totals (sets and reps). Weeks start on Monday." + "현재 %lld번째, 전체 %lld회": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rep %lld of %lld" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "1日タブの一番上のタイムラインでは、今日いつ運動したかを24時間の軸上の点でひと目で確認できます(点が大きいほど回数の多いセット)。その下に時間帯別の折れ線グラフと今日の記録リストが続きます。週間・月間タブでは日別の回数を折れ線グラフと合計(セット・回数)で表示します。週は月曜日から始まります。" - } - } - } - }, - "현재 %lld번째, 전체 %lld회" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rep %lld of %lld" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld回目、全%lld回" + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld回目、全%lld回" } }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "현재 %1$lld번째, 전체 %2$lld회" + "ko": { + "stringUnit": { + "state": "new", + "value": "현재 %1$lld번째, 전체 %2$lld회" } } } }, - "화면 모드" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Appearance" + "화면 모드": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "画面モード" + "ja": { + "stringUnit": { + "state": "translated", + "value": "画面モード" } } } }, - "확인" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "OK" + "확인": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "OK" + "ja": { + "stringUnit": { + "state": "translated", + "value": "OK" } } } }, - "횟수" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reps" + "횟수": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reps" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "回数" + "ja": { + "stringUnit": { + "state": "translated", + "value": "回数" } } } }, - "힘듦" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hard" + "힘듦": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hard" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ハード" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ハード" } } } } }, - "version" : "1.0" + "version": "1.0" } \ No newline at end of file diff --git a/myApp/Keging/IOS/PhoneSync.swift b/myApp/Keging/IOS/PhoneSync.swift index 32d9eea..914b54c 100644 --- a/myApp/Keging/IOS/PhoneSync.swift +++ b/myApp/Keging/IOS/PhoneSync.swift @@ -2,9 +2,10 @@ // PhoneSync.swift // Keging // -// 워치 연동 (iOS 측) — 워치에서 완주한 기록을 받아 병합하고, 연결 상태를 알린다. -// 세팅은 기기별 독립(2026-09-03, 7차 — 기기마다 진동 질감이 달라 각자 최적화)이라 -// 더 이상 push/응답하지 않는다. +// 워치 연동 (iOS 측, WatchConnectivity) — 워치에서 완주한 기록을 받아 병합하고, +// 아이폰이 깨운 심박 세션의 저장 결과(saved/failed)를 WatchHeartRateBridge에 전달한다. +// 세팅은 기기별 독립(2026-09-03, 7차 — 기기마다 진동 질감이 달라 각자 최적화)이라 push/응답하지 않는다. +// 첫 화면 연결 배지는 폐지(2026-09-05) — 워치 사용 여부는 시작 후 startWatchApp 결과로 판정. // import WatchConnectivity @@ -21,15 +22,34 @@ final class PhoneSync: NSObject, WCSessionDelegate { session.activate() } - private nonisolated func refreshLinkStatus() { - Task { @MainActor in WatchLinkStatus.shared.refresh() } + /// 미러링이 안 붙은 채 폰이 단독으로 넘어갈 때 — 워치가 열어 둔 세션이 남지 않게 폐기 명령 + /// (sendMessage가 안 닿으면 큐로 — 늦게라도 정리된다) + func sendMirrorDiscard() { + guard WCSession.isSupported() else { return } + let session = WCSession.default + guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return } + let payload: [String: Any] = [WatchBridgeResult.commandKey: WatchBridgeResult.discard] + if session.isReachable { + session.sendMessage(payload, replyHandler: nil) { _ in session.transferUserInfo(payload) } + } else { + session.transferUserInfo(payload) + } + } + + private nonisolated func handle(_ payload: [String: Any]) { + if let data = payload["record"] as? Data, + let record = try? JSONDecoder().decode(SessionRecord.self, from: data) { + Task { @MainActor in RecordStore.shared.append(record) } + } + if let result = payload[WatchBridgeResult.key] as? String { + let saved = result == WatchBridgeResult.saved + Task { @MainActor in WatchHeartRateBridge.shared.handleSaveResult(saved: saved) } + } } // MARK: WCSessionDelegate - nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - refreshLinkStatus() - } + nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {} nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} @@ -37,20 +57,16 @@ final class PhoneSync: NSObject, WCSessionDelegate { session.activate() } - nonisolated func sessionReachabilityDidChange(_ session: WCSession) { - refreshLinkStatus() - } - - /// 페어링·워치 앱 설치 여부 변화 - nonisolated func sessionWatchStateDidChange(_ session: WCSession) { - refreshLinkStatus() - } - nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { - guard let data = userInfo["record"] as? Data, - let record = try? JSONDecoder().decode(SessionRecord.self, from: data) else { return } - Task { @MainActor in - RecordStore.shared.append(record) - } + handle(userInfo) + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + handle(message) + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + handle(message) + replyHandler([:]) } } diff --git a/myApp/Keging/IOS/WatchHeartRateBridge.swift b/myApp/Keging/IOS/WatchHeartRateBridge.swift new file mode 100644 index 0000000..cb97b74 --- /dev/null +++ b/myApp/Keging/IOS/WatchHeartRateBridge.swift @@ -0,0 +1,236 @@ +// +// WatchHeartRateBridge.swift +// Keging +// +// 아이폰에서 시작한 세트의 워치 심박 측정 (iOS 측, §3.8). +// 시작 버튼 → HKHealthStore.startWatchApp으로 워치 앱을 깨워 워크아웃 세션을 열게 하고, +// 워치가 세션을 폰으로 미러링(watchOS 10+)하면 그 채널로 심박을 받고 완주/중단 명령을 보낸다. +// 워치를 못 열면(미착용·미페어링·앱 미설치·멀리 있음) 심박 없이 폰 단독 기록으로 폴백하고, +// 설정이 켜져 있으면 운동 화면이 경고를 띄운다. 진동은 폰에서만 울린다(워치는 센서 역할만). +// 건강 저장은 워치 라이브 빌더가 맡고(심박·활성 에너지 실측), 결과 회신이 없으면 폰이 사후 저장. +// + +import Combine +import HealthKit +import OSLog + +@MainActor +final class WatchHeartRateBridge: NSObject, ObservableObject { + static let shared = WatchHeartRateBridge() + + enum State: Equatable { + case idle + /// startWatchApp 호출 뒤 미러링 세션 도착 대기 + case launching + /// 워치 세션 연결됨 — 심박 수신 중 + case measuring + /// 워치를 못 열었거나 끊김 — 폰 단독 + case unavailable + /// 완주 명령 전송, 워치 저장 결과 대기 + case finishing + } + + /// 실기기 진단용 (Console.app에서 subsystem com.yechan.Keging, category watchHR) + private let log = Logger(subsystem: "com.yechan.Keging", category: "watchHR") + + @Published private(set) var state: State = .idle { + didSet { if state != oldValue { log.notice("state \(String(describing: oldValue)) → \(String(describing: self.state))") } } + } + @Published private(set) var heartRate: Double? + /// 워치 앱을 열지 못함 — 운동 화면이 (설정 app.watchWarn에 따라) 경고 알럿으로 소비 + @Published var launchFailed = false + + private let store = HKHealthStore() + private var session: HKWorkoutSession? + private var launchTimeout: Task? + private var saveTimeout: Task? + private var fallbackSave: (() -> Void)? + /// 세트마다 증가 — 늦게 도착한 콜백이 다음 세트를 건드리지 않게 + private var generation = 0 + + private override init() { super.init() } + + /// 앱 시작 시 1회 — 워치가 미러링을 시작하면 시스템이 이 핸들러로 세션을 넘긴다 + func activate() { + store.workoutSessionMirroringStartHandler = { [weak self] session in + Task { @MainActor [weak self] in self?.attach(session) } + } + } + + /// 운동 시작 직후 — 워치 앱 깨우기 (비동기, 타이머 진행에 영향 없음) + func begin(config: WorkoutConfig) { + reset() + generation += 1 + let gen = generation + // 권한 시트에 오래 답하느라 세트가 이미 끝났으면 깨우지 않는다 + guard KegelEngine.shared.isRunning else { return } + #if DEBUG + // 화면 QA·스토어 스크린샷용 — 워치 없이 '심박 측정 중' 상태를 흉내 낸다 (§7 `-mockWatchHeartRate 78`) + if let index = CommandLine.arguments.firstIndex(of: "-mockWatchHeartRate"), + index + 1 < CommandLine.arguments.count, + let bpm = Double(CommandLine.arguments[index + 1]) { + state = .measuring + heartRate = bpm + return + } + #endif + guard HKHealthStore.isHealthDataAvailable() else { + state = .unavailable + return + } + state = .launching + let configuration = HKWorkoutConfiguration() + configuration.activityType = config.healthWorkoutType.activityType + configuration.locationType = .unknown + store.startWatchApp(with: configuration) { [weak self] success, error in + Task { @MainActor [weak self] in + guard let self, self.generation == gen, self.state == .launching else { return } + self.log.notice("startWatchApp success=\(success) error=\(error.map { String(describing: $0) } ?? "nil")") + if success { + self.armLaunchTimeout(gen) + } else { + self.markLaunchFailed() + } + } + } + } + + /// 세트 완주 — 워치가 저장하게 하고, 결과가 안 오면 폴백(폰 사후 저장) + func finish(effortScore: Int, fallback: @escaping () -> Void) { + guard state == .measuring, let session else { + if state == .launching { PhoneSync.shared.sendMirrorDiscard() } + reset() + fallback() + return + } + state = .finishing + fallbackSave = fallback + send(.finish(effortScore: effortScore), via: session) + let gen = generation + saveTimeout = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(15)) + guard !Task.isCancelled, let self, self.generation == gen else { return } + self.runFallback() + } + } + + /// 세트 중단 — 워치 워크아웃 폐기 (미러링이 아직 안 붙었으면 WatchConnectivity로) + func discard() { + if let session, state == .measuring { + send(.discard, via: session) + } else if state == .launching { + PhoneSync.shared.sendMirrorDiscard() + } + reset() + } + + /// WatchConnectivity로 온 워치 저장 결과 (PhoneSync) + func handleSaveResult(saved: Bool) { + log.notice("watch save result saved=\(saved) (state \(String(describing: self.state)))") + guard state == .finishing else { return } + if saved { + fallbackSave = nil + reset() + } else { + runFallback() + } + } + + // MARK: 내부 + + private func attach(_ session: HKWorkoutSession) { + log.notice("mirrored session arrived (state \(String(describing: self.state)), running \(KegelEngine.shared.isRunning))") + launchTimeout?.cancel() + launchTimeout = nil + // 세트가 이미 끝났거나 폰 단독으로 넘어간 뒤 늦게 도착 — 폐기시킨다 + guard state == .launching, KegelEngine.shared.isRunning else { + send(.discard, via: session) + return + } + session.delegate = self + self.session = session + state = .measuring + } + + private func armLaunchTimeout(_ gen: Int) { + launchTimeout?.cancel() + launchTimeout = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(30)) + guard !Task.isCancelled, let self, self.generation == gen, self.state == .launching else { return } + self.markLaunchFailed() + } + } + + private func markLaunchFailed() { + state = .unavailable + if KegelEngine.shared.isRunning { launchFailed = true } + // 워치가 세션을 열었는데 미러링만 안 붙은 경우를 대비해 WatchConnectivity로 정리 명령 + PhoneSync.shared.sendMirrorDiscard() + } + + private func runFallback() { + log.notice("fallback: phone saves workout itself") + let fallback = fallbackSave + fallbackSave = nil + reset() + fallback?() + } + + private func send(_ message: WatchBridgeMessage, via session: HKWorkoutSession) { + guard let data = message.encoded() else { return } + session.sendToRemoteWorkoutSession(data: data) { _, _ in } + } + + private func reset() { + launchTimeout?.cancel() + launchTimeout = nil + saveTimeout?.cancel() + saveTimeout = nil + session?.delegate = nil + session = nil + heartRate = nil + state = .idle + } + + /// 워치 세션이 끝나거나 끊김 — 측정 중이었으면 폰 단독으로 전환(세트는 계속), + /// 완주 명령 뒤라면 워치가 세션을 닫는 것이 정상이라 저장 결과(WatchConnectivity)를 계속 기다린다 + private func remoteEnded() { + guard state == .measuring else { return } + state = .unavailable + heartRate = nil + session?.delegate = nil + session = nil + } +} + +extension WatchHeartRateBridge: HKWorkoutSessionDelegate { + nonisolated func workoutSession( + _ workoutSession: HKWorkoutSession, + didChangeTo toState: HKWorkoutSessionState, + from fromState: HKWorkoutSessionState, + date: Date + ) { + guard toState == .ended || toState == .stopped else { return } + Task { @MainActor in self.remoteEnded() } + } + + nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { + Task { @MainActor in self.remoteEnded() } + } + + nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didDisconnectFromRemoteDeviceWithError error: Error?) { + Task { @MainActor in self.remoteEnded() } + } + + nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didReceiveDataFromRemoteWorkoutSession data: [Data]) { + let messages = data.compactMap(WatchBridgeMessage.decode) + Task { @MainActor in + for message in messages { + if case let .heartRate(bpm) = message { + if self.heartRate == nil { self.log.notice("first heart rate \(bpm)") } + self.heartRate = bpm + } + } + } + } +} diff --git a/myApp/Keging/IOS/WatchLinkStatus.swift b/myApp/Keging/IOS/WatchLinkStatus.swift deleted file mode 100644 index 362683d..0000000 --- a/myApp/Keging/IOS/WatchLinkStatus.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// WatchLinkStatus.swift -// Keging -// -// 첫 화면의 워치 연결 표시용 상태 — PhoneSync 델리게이트 콜백·씬 활성화 때 refresh(). -// ⚠️iOS 쪽 isReachable은 워치에서 케깅 앱이 켜져(포그라운드) 있을 때만 true가 되는 -// 시스템 제약이 있다 — '연결됨' 표시는 "워치의 케깅과 지금 통신 가능"의 의미 -// (설정·도움말에 이 기준을 안내해 뒀다). -// - -import Combine -import WatchConnectivity - -@MainActor -final class WatchLinkStatus: ObservableObject { - static let shared = WatchLinkStatus() - - enum LinkState { - /// 워치 미페어링 또는 케깅 워치 앱 미설치 - case notLinked - /// 연동은 되어 있지만 지금은 통신 불가 (워치가 멀거나 워치 앱이 꺼져 있음) - case unreachable - /// 워치의 케깅 앱과 지금 통신 가능 - case reachable - } - - @Published private(set) var state: LinkState = .notLinked - - private init() {} - - func refresh() { - guard WCSession.isSupported() else { - state = .notLinked - return - } - let session = WCSession.default - guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { - state = .notLinked - return - } - state = session.isReachable ? .reachable : .unreachable - } -} diff --git a/myApp/Keging/IOS/WorkoutCoordinator.swift b/myApp/Keging/IOS/WorkoutCoordinator.swift index 30cd2d5..80ef38f 100644 --- a/myApp/Keging/IOS/WorkoutCoordinator.swift +++ b/myApp/Keging/IOS/WorkoutCoordinator.swift @@ -2,9 +2,11 @@ // WorkoutCoordinator.swift // Keging // -// 엔진 ↔ 진동·Live Activity·백그라운드 오디오·화면 꺼짐 방지 배선 (iOS). +// 엔진 ↔ 진동·Live Activity·백그라운드 오디오·화면 꺼짐 방지·워치 심박 배선 (iOS). // 운동 중에는 2초 하트비트로 오디오 생존·엔진 재동기화·Live Activity를 지속 점검한다 // (국면 경계 타이머가 한 번 놓쳐도 2초 안에 자가 치유 — 실기기 DI 멈춤의 3중 방어). +// 시작 시 워치 앱을 깨워 심박을 재고(WatchHeartRateBridge, §3.8), 완주 시 워치가 건강에 저장 — +// 워치를 못 쓰면 폰이 사후 저장(HealthRecorder)으로 폴백. // import UIKit @@ -48,12 +50,17 @@ final class WorkoutCoordinator { BackgroundAudioKeeper.shared.stop() } if completed, let record { + // 워치가 심박 세션을 열어 뒀으면 워치가 저장(심박·활성 에너지 실측), 아니면 폰 사후 저장 let config = SettingsStore.shared.config - HealthRecorder.shared.save( - record: record, - effortScore: config.effortScore, - workoutType: config.healthWorkoutType - ) + WatchHeartRateBridge.shared.finish(effortScore: config.effortScore) { + HealthRecorder.shared.save( + record: record, + effortScore: config.effortScore, + workoutType: config.healthWorkoutType + ) + } + } else { + WatchHeartRateBridge.shared.discard() } } } @@ -62,11 +69,14 @@ final class WorkoutCoordinator { guard !KegelEngine.shared.isRunning else { return } pendingAudioStop?.cancel() pendingAudioStop = nil - HealthRecorder.shared.requestAuthorizationIfNeeded() BackgroundAudioKeeper.shared.start() UIApplication.shared.isIdleTimerDisabled = true KegelEngine.shared.start(config: SettingsStore.shared.config) LiveActivityManager.shared.ensureStarted() + // 워치 깨우기는 폰 건강 권한이 정해진 뒤에 — 권한은 워치와 공유되므로 첫 실행에도 워치 세션이 바로 열린다 + HealthRecorder.shared.requestAuthorizationIfNeeded { + WatchHeartRateBridge.shared.begin(config: SettingsStore.shared.config) + } startHeartbeat() } diff --git a/myApp/Keging/IOS/WorkoutView.swift b/myApp/Keging/IOS/WorkoutView.swift index d7f77a2..9867318 100644 --- a/myApp/Keging/IOS/WorkoutView.swift +++ b/myApp/Keging/IOS/WorkoutView.swift @@ -3,12 +3,16 @@ // Keging // // 운동 진행 화면 — 국면 원이 수축 때 줄고 이완 때 커진다. 중단 시 이 세트는 기록 없음. +// 횟수 아래에 워치 심박 상태(연결 중/심박/아이폰만) 표시, 워치 앱을 못 열면 설정에 따라 경고 알럿(§3.8). // import SwiftUI struct WorkoutView: View { @EnvironmentObject private var engine: KegelEngine + @ObservedObject private var bridge = WatchHeartRateBridge.shared + /// 워치 앱을 못 열었을 때 확인 창 (설정 > 애플워치에서 끌 수 있음) + @AppStorage("app.watchWarn", store: AppGroup.defaults) private var warnNoWatch = true @State private var confirmStop = false var body: some View { @@ -23,6 +27,11 @@ struct WorkoutView: View { Color.clear } } + .alert("워치 앱을 열지 못했어요", isPresented: launchAlertPresented) { + Button("확인") {} + } message: { + Text("심박 없이 아이폰만으로 기록해요. 워치를 차고 있는지, 케깅 워치 앱이 설치돼 있는지, 워치의 건강 권한이 허용돼 있는지 확인해 주세요.") + } .onAppear { #if DEBUG if CommandLine.arguments.contains("-confirmStop") { confirmStop = true } @@ -30,6 +39,52 @@ struct WorkoutView: View { } } + private var launchAlertPresented: Binding { + Binding( + get: { bridge.launchFailed && warnNoWatch }, + set: { presented in if !presented { bridge.launchFailed = false } } + ) + } + + /// 워치 심박 상태 한 줄 — 연결 중 / 심박 bpm / 아이폰만 (완주 대기·유휴에는 비움) + @ViewBuilder + private var watchStatus: some View { + switch bridge.state { + case .launching: + statusLine(symbol: "applewatch", label: Text("워치 연결 중")) + case .measuring: + if let bpm = bridge.heartRate { + HStack(spacing: 5) { + Image(systemName: "heart.fill") + .foregroundStyle(.red) + Text(verbatim: "\(Int(bpm.rounded()))") + .monospacedDigit() + .foregroundStyle(.primary) + Text("워치 심박") + .foregroundStyle(.secondary) + } + .font(.footnote.weight(.medium)) + .accessibilityElement(children: .combine) + } else { + statusLine(symbol: "applewatch", label: Text("워치 심박 준비 중")) + } + case .unavailable: + statusLine(symbol: "iphone", label: Text("아이폰만으로 기록")) + case .idle, .finishing: + EmptyView() + } + } + + private func statusLine(symbol: String, label: Text) -> some View { + HStack(spacing: 5) { + Image(systemName: symbol) + label + } + .font(.footnote.weight(.medium)) + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + } + private func runningView(phase: KegelPhase, rep: Int, phaseStart: Date, phaseEnd: Date) -> some View { let phaseColor = Self.color(for: phase) let progress = phase == .prepare @@ -52,6 +107,9 @@ struct WorkoutView: View { .accessibilityElement(children: .ignore) .accessibilityLabel(Text("현재 \(rep)번째, 전체 \(engine.config.reps)회")) + watchStatus + .padding(.top, 6) + Spacer() ZStack { diff --git a/myApp/Keging/KegingWatch Watch App/ContentView.swift b/myApp/Keging/KegingWatch Watch App/ContentView.swift index 7739bb1..69ede11 100644 --- a/myApp/Keging/KegingWatch Watch App/ContentView.swift +++ b/myApp/Keging/KegingWatch Watch App/ContentView.swift @@ -5,6 +5,7 @@ // 실행하면 바로 시작 버튼 하나 — 누르면 즉시 시작. // 왼쪽으로 스와이프하면 워치 전용 세팅 페이지(7차 — 세팅은 아이폰과 독립). // 진행 중엔 수축/이완·남은 시간·몇 번째인지 한 화면에. +// 아이폰이 깨운 심박 세션 중(MirroredWorkoutSession)에는 시작 버튼 대신 '아이폰에서 진행 중' 화면(§3.8). // import SwiftUI @@ -12,6 +13,7 @@ import SwiftUI struct WatchContentView: View { @EnvironmentObject private var engine: KegelEngine @EnvironmentObject private var settings: SettingsStore + @ObservedObject private var mirror = MirroredWorkoutSession.shared @Environment(\.scenePhase) private var scenePhase @State private var page = 0 @@ -20,11 +22,15 @@ struct WatchContentView: View { AppTheme.background.ignoresSafeArea() switch engine.state { case .idle: - TabView(selection: $page) { - startScreen.tag(0) - WatchSettingsView().tag(1) + if mirror.isActive { + mirroredScreen + } else { + TabView(selection: $page) { + startScreen.tag(0) + WatchSettingsView().tag(1) + } + .tabViewStyle(.page) } - .tabViewStyle(.page) case let .running(phase, rep, phaseStart, phaseEnd): runningScreen(phase: phase, rep: rep, phaseStart: phaseStart, phaseEnd: phaseEnd) case let .finished(reps): @@ -77,6 +83,43 @@ struct WatchContentView: View { } } + /// 아이폰에서 시작한 세트 — 워치는 심박만 잰다 (타이머·진동은 아이폰) + private var mirroredScreen: some View { + VStack(spacing: 5) { + Image(systemName: "iphone.radiowaves.left.and.right") + .font(.title3) + .foregroundStyle(AppTheme.green) + Text("아이폰에서 진행 중") + .font(.headline) + HStack(spacing: 4) { + Image(systemName: "heart.fill") + .foregroundStyle(.red) + if let bpm = mirror.heartRate { + Text(verbatim: "\(Int(bpm.rounded()))") + .font(.system(size: 28, weight: .semibold)) + .monospacedDigit() + } else { + Text("심박 측정 중") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + Text("진동은 아이폰에서 울려요") + .font(.caption2) + .foregroundStyle(.secondary) + Button { + MirroredWorkoutSession.shared.cancelByUser() + } label: { + Text("중단") + .font(.footnote) + } + .buttonStyle(.bordered) + .tint(.red) + .padding(.top, 2) + } + } + private func runningScreen(phase: KegelPhase, rep: Int, phaseStart: Date, phaseEnd: Date) -> some View { VStack(spacing: 3) { Text(phase.label) diff --git a/myApp/Keging/KegingWatch Watch App/KegingWatchApp.swift b/myApp/Keging/KegingWatch Watch App/KegingWatchApp.swift index e90e2cf..5798892 100644 --- a/myApp/Keging/KegingWatch Watch App/KegingWatchApp.swift +++ b/myApp/Keging/KegingWatch Watch App/KegingWatchApp.swift @@ -4,9 +4,12 @@ // import SwiftUI +import WatchKit @main struct KegingWatchApp: App { + /// 아이폰이 startWatchApp으로 깨울 때 운동 구성을 받는 진입점 (§3.8) + @WKApplicationDelegateAdaptor(WatchAppDelegate.self) private var appDelegate @StateObject private var engine = KegelEngine.shared @StateObject private var settings = SettingsStore.shared diff --git a/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings b/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings index 8db5dd4..ce8a39c 100644 --- a/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings +++ b/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings @@ -1,512 +1,560 @@ { - "sourceLanguage" : "ko", - "strings" : { - "%lld / %lld" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld / %lld" + "sourceLanguage": "ko", + "strings": { + "%lld / %lld": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld / %lld" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld / %lld" + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld / %lld" } }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$lld / %2$lld" + "ko": { + "stringUnit": { + "state": "new", + "value": "%1$lld / %2$lld" } } } }, - "%lld초" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld s" + "%lld초": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld s" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld秒" + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld秒" } } } }, - "%lld회" : { - "localizations" : { - "en" : { - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld rep" + "%lld회": { + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld rep" } }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld reps" + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld reps" } } } } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld回" + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld回" } } } }, - "%lld회 완주" : { - "localizations" : { - "en" : { - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld rep done" + "%lld회 완주": { + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld rep done" } }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld reps done" + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld reps done" } } } } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld回完了" + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld回完了" } } } }, - "고르면 바로 진동이 울려요. 세팅은 아이폰과 따로 저장돼요." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Picking one plays the vibration right away. These settings are stored separately from your iPhone." + "고르면 바로 진동이 울려요. 세팅은 아이폰과 따로 저장돼요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Picking one plays the vibration right away. These settings are stored separately from your iPhone." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "選ぶとすぐに振動が鳴ります。設定はiPhoneとは別に保存されます。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "選ぶとすぐに振動が鳴ります。設定はiPhoneとは別に保存されます。" } } } }, - "기본" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Standard" + "기본": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Standard" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "標準" + "ja": { + "stringUnit": { + "state": "translated", + "value": "標準" } } } }, - "기타" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Other" + "기타": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "その他" + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" } } } }, - "세트" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Set" + "세트": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "セット" + "ja": { + "stringUnit": { + "state": "translated", + "value": "セット" } } } }, - "세팅" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Setup" + "세팅": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Setup" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "設定" + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定" } } } }, - "수축" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Squeeze" + "수축": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Squeeze" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "締める" + "ja": { + "stringUnit": { + "state": "translated", + "value": "締める" } } } }, - "수축 %lld초 · 이완 %lld초 · %lld회" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Squeeze %lld s · Relax %lld s · ×%lld" + "수축 %lld초 · 이완 %lld초 · %lld회": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Squeeze %lld s · Relax %lld s · ×%lld" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "締める%lld秒 · ゆるめる%lld秒 · %lld回" + "ja": { + "stringUnit": { + "state": "translated", + "value": "締める%lld秒 · ゆるめる%lld秒 · %lld回" } }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회" + "ko": { + "stringUnit": { + "state": "new", + "value": "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회" } } } }, - "시작" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start" + "시작": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "開始" + "ja": { + "stringUnit": { + "state": "translated", + "value": "開始" } } } }, - "안 함" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Off" + "심박 측정 중": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Measuring heart rate" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "なし" + "ja": { + "stringUnit": { + "state": "translated", + "value": "心拍数を計測中" } } } }, - "애플 건강" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apple Health" + "아이폰에서 진행 중": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running on iPhone" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ヘルスケア" + "ja": { + "stringUnit": { + "state": "translated", + "value": "iPhoneで実行中" } } } }, - "완료!" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Done!" + "안 함": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "完了!" + "ja": { + "stringUnit": { + "state": "translated", + "value": "なし" } } } }, - "운동 강도" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Effort" + "애플 건강": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apple Health" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "運動強度" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヘルスケア" } } } }, - "운동 유형" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Workout Type" + "완료!": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done!" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ワークアウトの種類" + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了!" } } } }, - "이완" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Relax" + "운동 강도": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Effort" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ゆるめる" + "ja": { + "stringUnit": { + "state": "translated", + "value": "運動強度" } } } }, - "준비" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ready" + "운동 유형": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workout Type" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "準備" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークアウトの種類" } } } }, - "중단" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stop" + "이완": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Relax" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "中断" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゆるめる" } } } }, - "지이이잉" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Long buzz" + "준비": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ready" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ブーッ" + "ja": { + "stringUnit": { + "state": "translated", + "value": "準備" } } } }, - "진동" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vibration" + "중단": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "振動" + "ja": { + "stringUnit": { + "state": "translated", + "value": "中断" } } } }, - "케겔 운동 시작" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start Kegel Workout" + "지이이잉": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Long buzz" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ケーゲル運動を開始" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブーッ" } } } }, - "코어 트레이닝" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Core Training" + "진동": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Vibration" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "コアトレーニング" + "ja": { + "stringUnit": { + "state": "translated", + "value": "振動" } } } }, - "크라운을 돌려 조절" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Turn the crown to adjust" + "진동은 아이폰에서 울려요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Haptics play on iPhone" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "クラウンを回して調整" + "ja": { + "stringUnit": { + "state": "translated", + "value": "振動はiPhoneで鳴ります" } } } }, - "톡톡" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Double tap" + "케겔 운동 시작": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start Kegel Workout" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "トントン" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ケーゲル運動を開始" } } } }, - "현재 %lld번째, 전체 %lld회" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rep %lld of %lld" + "코어 트레이닝": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Core Training" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld回目、全%lld回" - } - }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "현재 %1$lld번째, 전체 %2$lld회" + "ja": { + "stringUnit": { + "state": "translated", + "value": "コアトレーニング" } } } }, - "확인" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "OK" + "크라운을 돌려 조절": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn the crown to adjust" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "OK" + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラウンを回して調整" } } } }, - "횟수" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reps" + "톡톡": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Double tap" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "回数" + "ja": { + "stringUnit": { + "state": "translated", + "value": "トントン" + } + } + } + }, + "현재 %lld번째, 전체 %lld회": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rep %lld of %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld回目、全%lld回" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "현재 %1$lld번째, 전체 %2$lld회" + } + } + } + }, + "확인": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + } + } + }, + "횟수": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reps" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "回数" } } } } }, - "version" : "1.0" + "version": "1.0" } \ No newline at end of file diff --git a/myApp/Keging/KegingWatch Watch App/MirroredWorkoutSession.swift b/myApp/Keging/KegingWatch Watch App/MirroredWorkoutSession.swift new file mode 100644 index 0000000..1f6c2f7 --- /dev/null +++ b/myApp/Keging/KegingWatch Watch App/MirroredWorkoutSession.swift @@ -0,0 +1,194 @@ +// +// MirroredWorkoutSession.swift +// KegingWatch Watch App +// +// 아이폰에서 시작한 세트의 심박 측정 (워치 측, §3.8). +// 폰의 startWatchApp으로 깨어나면(WatchAppDelegate.handle) 워크아웃 세션을 열고 폰으로 미러링한다. +// 이 모드에서 워치는 타이머·진동 없이 센서 역할만 — 심박을 미러링 채널로 폰에 보내고, +// 폰의 완주(finish)/중단(discard) 명령대로 운동을 저장하거나 폐기한다. +// 저장 결과는 세션이 닫힌 뒤라 미러링 대신 WatchConnectivity(WatchSync)로 알린다. +// 폰과 끊기면(앱 종료 등) 워크아웃을 폐기한다 — 폰이 자기 폴백으로 저장하므로 중복 방지. +// + +import Combine +import HealthKit +import OSLog + +@MainActor +final class MirroredWorkoutSession: NSObject, ObservableObject { + static let shared = MirroredWorkoutSession() + + private let log = Logger(subsystem: "com.yechan.Keging.watchkitapp", category: "watchHR") + + @Published private(set) var isActive = false + @Published private(set) var heartRate: Double? + + private let store = HKHealthStore() + private var session: HKWorkoutSession? + private var builder: HKLiveWorkoutBuilder? + + private override init() { super.init() } + + /// 폰이 넘긴 운동 구성(유형은 폰 설정)으로 세션 시작 → 미러링 + func start(configuration: HKWorkoutConfiguration) { + // 워치 자체 세트 진행 중이거나 이미 세션이 있으면 무시 + log.notice("phone asked to start (session exists \(self.session != nil), watch set running \(KegelEngine.shared.isRunning))") + guard session == nil, !KegelEngine.shared.isRunning else { return } + Task { @MainActor in + guard HKHealthStore.isHealthDataAvailable() else { return } + do { + try await WatchHealthAuth.request(store: store) + guard session == nil, !KegelEngine.shared.isRunning else { return } + let newSession = try HKWorkoutSession(healthStore: store, configuration: configuration) + let newBuilder = newSession.associatedWorkoutBuilder() + newBuilder.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: configuration) + newSession.delegate = self + newBuilder.delegate = self + let start = Date() + newSession.startActivity(with: start) + try await HealthKitCalls.beginCollection(newBuilder, at: start) + session = newSession + builder = newBuilder + heartRate = nil + isActive = true + try await withTimeout(seconds: 30) { try await self.startMirroring(newSession) } + log.notice("session started and mirroring to phone") + } catch { + // 세션·미러링 실패 — 폰은 대기 시간 초과로 폰 단독 전환 + log.error("session/mirroring failed: \(String(describing: error))") + discard() + } + } + } + + /// 워치에서 직접 종료 — 폐기 (폰은 끊김을 감지해 폰 단독으로 세트를 이어간다) + func cancelByUser() { + discard() + } + + /// 폰이 WatchConnectivity로 보낸 정리 명령 (미러링이 안 붙어 폰이 단독으로 넘어감) + func discardFromPhone() { + log.notice("discard command via WatchConnectivity") + discard() + } + + // MARK: 내부 + + /// 앱이 죽었다 다시 뜰 때 시스템이 살려 둔 워크아웃 세션이 있으면 끝내고 폐기한다 + /// (폰 주도 세트는 이어 갈 수 없고, 중단 규칙상 기록도 남기지 않는다) + func recoverAndDiscardIfAny() { + store.recoverActiveWorkoutSession { [weak self] session, _ in + guard let session else { return } + Task { @MainActor [weak self] in + self?.log.notice("recovered a leftover session — ending and discarding") + session.end() + session.associatedWorkoutBuilder().discardWorkout() + } + } + } + + /// 미러링 시작이 제한 시간 안에 끝나지 않으면 실패로 본다 (시뮬 등 미러링 불가 환경에서 세션이 영원히 남지 않게) + private func withTimeout(seconds: Double, _ operation: @escaping @MainActor () async throws -> Void) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { @MainActor in try await operation() } + group.addTask { + try await Task.sleep(for: .seconds(seconds)) + throw CancellationError() + } + try await group.next() + group.cancelAll() + } + } + + private func startMirroring(_ session: HKWorkoutSession) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + session.startMirroringToCompanionDevice { _, error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } + } + + private func handle(_ message: WatchBridgeMessage) { + log.notice("command from phone: \(String(describing: message))") + switch message { + case let .finish(effortScore): finish(effortScore: effortScore) + case .discard: discard() + case .heartRate: break + } + } + + /// 완주 — 저장(+운동 강도) 후 결과를 WatchConnectivity로 폰에 알린다 + private func finish(effortScore: Int) { + guard let session, let builder else { return } + self.session = nil + self.builder = nil + isActive = false + session.end() + Task { @MainActor [store] in + var saved = false + do { + try await HealthKitCalls.endCollection(builder, at: Date()) + let workout = try await HealthKitCalls.finishWorkout(builder) + saved = workout != nil + if #available(watchOS 11.0, *), effortScore > 0, let workout { + try? await HealthKitCalls.relateEffort(store: store, workout: workout, score: effortScore) + } + } catch { + saved = false + } + log.notice("workout saved=\(saved) — reporting to phone") + WatchSync.shared.sendMirrorResult(saved: saved) + } + } + + private func discard() { + isActive = false + heartRate = nil + guard let session, let builder else { return } + self.session = nil + self.builder = nil + session.end() + builder.discardWorkout() + } + + private func publish(heartRate bpm: Double) { + heartRate = bpm + guard let session, let data = WatchBridgeMessage.heartRate(bpm: bpm).encoded() else { return } + session.sendToRemoteWorkoutSession(data: data) { _, _ in } + } +} + +extension MirroredWorkoutSession: HKWorkoutSessionDelegate { + nonisolated func workoutSession( + _ workoutSession: HKWorkoutSession, + didChangeTo toState: HKWorkoutSessionState, + from fromState: HKWorkoutSessionState, + date: Date + ) {} + + nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { + Task { @MainActor in self.discard() } + } + + nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didDisconnectFromRemoteDeviceWithError error: Error?) { + Task { @MainActor in self.discard() } + } + + nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didReceiveDataFromRemoteWorkoutSession data: [Data]) { + let messages = data.compactMap(WatchBridgeMessage.decode) + Task { @MainActor in messages.forEach { self.handle($0) } } + } +} + +extension MirroredWorkoutSession: HKLiveWorkoutBuilderDelegate { + nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set) { + let heartRateType = HKQuantityType(.heartRate) + guard collectedTypes.contains(heartRateType), + let bpm = workoutBuilder.statistics(for: heartRateType)? + .mostRecentQuantity()? + .doubleValue(for: .count().unitDivided(by: .minute())) else { return } + Task { @MainActor in self.publish(heartRate: bpm) } + } + + nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {} +} diff --git a/myApp/Keging/KegingWatch Watch App/WatchAppDelegate.swift b/myApp/Keging/KegingWatch Watch App/WatchAppDelegate.swift new file mode 100644 index 0000000..1f8f4fd --- /dev/null +++ b/myApp/Keging/KegingWatch Watch App/WatchAppDelegate.swift @@ -0,0 +1,20 @@ +// +// WatchAppDelegate.swift +// KegingWatch Watch App +// +// 아이폰의 HKHealthStore.startWatchApp으로 깨어날 때의 진입점 — 폰이 넘긴 운동 구성으로 +// 심박 미러링 세션을 연다(MirroredWorkoutSession, §3.8). 앱이 꺼져 있어도 시스템이 백그라운드로 띄워 준다. +// + +import HealthKit +import WatchKit + +final class WatchAppDelegate: NSObject, WKApplicationDelegate { + func applicationDidFinishLaunching() { + MirroredWorkoutSession.shared.recoverAndDiscardIfAny() + } + + func handle(_ workoutConfiguration: HKWorkoutConfiguration) { + MirroredWorkoutSession.shared.start(configuration: workoutConfiguration) + } +} diff --git a/myApp/Keging/KegingWatch Watch App/WatchHealthAuth.swift b/myApp/Keging/KegingWatch Watch App/WatchHealthAuth.swift new file mode 100644 index 0000000..2168914 --- /dev/null +++ b/myApp/Keging/KegingWatch Watch App/WatchHealthAuth.swift @@ -0,0 +1,36 @@ +// +// WatchHealthAuth.swift +// KegingWatch Watch App +// +// 워치 건강 권한 — 워치 자체 세트(WatchWorkoutSession)와 아이폰이 깨운 심박 세션(MirroredWorkoutSession) 공용. +// 라이브 빌더가 센서에서 심박·활성 에너지를 수집하려면 읽기 권한이 필요하다. +// + +import HealthKit + +@MainActor +enum WatchHealthAuth { + static var shareTypes: Set { + var types: Set = [ + HKObjectType.workoutType(), + HKQuantityType(.activeEnergyBurned), + HKQuantityType(.heartRate), + ] + if #available(watchOS 11.0, *) { + types.insert(HKQuantityType(.workoutEffortScore)) + } + return types + } + + static var readTypes: Set { + [HKQuantityType(.heartRate), HKQuantityType(.activeEnergyBurned)] + } + + static func request(store: HKHealthStore) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + store.requestAuthorization(toShare: shareTypes, read: readTypes) { _, error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } + } +} diff --git a/myApp/Keging/KegingWatch Watch App/WatchSync.swift b/myApp/Keging/KegingWatch Watch App/WatchSync.swift index 8e6dfc2..bc71893 100644 --- a/myApp/Keging/KegingWatch Watch App/WatchSync.swift +++ b/myApp/Keging/KegingWatch Watch App/WatchSync.swift @@ -2,7 +2,7 @@ // WatchSync.swift // KegingWatch Watch App // -// 폰 연동 (워치 측) — 완주 기록을 폰으로 보낸다 +// 폰 연동 (워치 측) — 완주 기록과, 아이폰이 깨운 심박 세션의 저장 결과를 폰으로 보낸다 // (transferUserInfo는 폰이 안 닿아도 자동으로 큐에 쌓였다 전달됨). // 세팅은 기기별 독립(2026-09-03, 7차 — 기기마다 진동 질감이 달라 각자 최적화)이라 받지 않는다. // @@ -27,7 +27,38 @@ final class WatchSync: NSObject, WCSessionDelegate { WCSession.default.transferUserInfo(["record": data]) } + /// 아이폰이 깨운 심박 세션의 저장 결과 — 미러링 채널은 세션 종료로 닫혔으므로 여기로. + /// sendMessage는 폰 앱이 꺼져 있어도 백그라운드로 깨워 전달되고, 실패하면 큐(transferUserInfo)로 폴백 + func sendMirrorResult(saved: Bool) { + guard WCSession.isSupported() else { return } + let payload: [String: Any] = [WatchBridgeResult.key: saved ? WatchBridgeResult.saved : WatchBridgeResult.failed] + let session = WCSession.default + if session.isReachable { + session.sendMessage(payload, replyHandler: nil) { _ in session.transferUserInfo(payload) } + } else { + session.transferUserInfo(payload) + } + } + + private nonisolated func handle(_ payload: [String: Any]) { + guard payload[WatchBridgeResult.commandKey] as? String == WatchBridgeResult.discard else { return } + Task { @MainActor in MirroredWorkoutSession.shared.discardFromPhone() } + } + // MARK: WCSessionDelegate nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {} + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + handle(message) + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + handle(message) + replyHandler([:]) + } + + nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + handle(userInfo) + } } diff --git a/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift b/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift index 3293fc6..d88eab0 100644 --- a/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift +++ b/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift @@ -19,23 +19,6 @@ final class WatchWorkoutSession: NSObject { private override init() { super.init() } - private static var shareTypes: Set { - var types: Set = [ - HKObjectType.workoutType(), - HKQuantityType(.activeEnergyBurned), - HKQuantityType(.heartRate), - ] - if #available(watchOS 11.0, *) { - types.insert(HKQuantityType(.workoutEffortScore)) - } - return types - } - - /// 라이브 빌더가 센서에서 심박·활성 에너지를 수집하려면 읽기 권한 필요 - private static var readTypes: Set { - [HKQuantityType(.heartRate), HKQuantityType(.activeEnergyBurned)] - } - /// 운동 시작 직후 호출 — 권한 요청·세션 시작은 비동기라 타이머 진행에 영향 없음 func begin(startDate: Date) { #if DEBUG @@ -51,7 +34,7 @@ final class WatchWorkoutSession: NSObject { return } do { - try await requestAuthorization() + try await WatchHealthAuth.request(store: store) // 권한 응답이 늦어 운동이 이미 끝났으면 세션을 열지 않는다 guard KegelEngine.shared.isRunning, session == nil else { return } let configuration = HKWorkoutConfiguration() @@ -93,12 +76,4 @@ final class WatchWorkoutSession: NSObject { } } } - - private func requestAuthorization() async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - store.requestAuthorization(toShare: Self.shareTypes, read: Self.readTypes) { _, error in - if let error { continuation.resume(throwing: error) } else { continuation.resume() } - } - } - } } diff --git a/myApp/Keging/Marketing/AppStore/description-en.txt b/myApp/Keging/Marketing/AppStore/description-en.txt index c8305a9..5a0e055 100644 --- a/myApp/Keging/Marketing/AppStore/description-en.txt +++ b/myApp/Keging/Marketing/AppStore/description-en.txt @@ -20,6 +20,7 @@ Squeeze, relax, repeat. Keging runs your squeeze-and-relax cycles for the second ■ Apple Watch • Open the watch app and tap Start — the workout runs on your wrist, haptics included +• Start on iPhone and the watch app opens by itself to measure your heart rate — haptics play only on the device you started from • Phase, remaining time, and current rep on one screen • Watch-only setup (swipe left): ready, squeeze, relax, reps, and haptic patterns tuned separately for the wrist • Sets finished on the watch flow into your iPhone stats — even when apart, they sync automatically later @@ -30,7 +31,7 @@ Squeeze, relax, repeat. Keging runs your squeeze-and-relax cycles for the second • Weekly and monthly tabs show reps by day with set and rep totals, switchable between calendar view (this week) and rolling view (last 7 days) ■ Apple Health and Shortcuts -• Completed sets are saved to Apple Health as workouts (choose the workout type and effort). On the watch, heart rate is recorded too +• Completed sets are saved to Apple Health as workouts (choose the workout type and effort). Wearing your watch? Then heart rate and active energy are measured by the watch whichever device you start from • Add the "Start Kegel Workout" action in Shortcuts to start from a Home Screen icon, Back Tap, or Siri ■ And more diff --git a/myApp/Keging/Marketing/AppStore/description-ja.txt b/myApp/Keging/Marketing/AppStore/description-ja.txt index 0c01ac0..fdf8107 100644 --- a/myApp/Keging/Marketing/AppStore/description-ja.txt +++ b/myApp/Keging/Marketing/AppStore/description-ja.txt @@ -20,6 +20,7 @@ Keging — 振動で導くケーゲル体操タイマー ■ Apple Watch • ウォッチアプリを開いて開始をタップするだけ — 手首でそのまま進み、振動もウォッチで鳴ります +• iPhoneで開始するとウォッチアプリが自動で開いて心拍数を計測 — 振動は開始したデバイスだけで鳴ります • 局面・残り時間・今何回目かをひと画面に • ウォッチ専用の設定(左にスワイプ)— 準備・締める・ゆるめる・回数・振動パターンを手首に合わせて別々に設定 • ウォッチで終えたセットはiPhoneに届いて統計にまとまります。離れていても、あとで自動的に送られます @@ -30,7 +31,7 @@ Keging — 振動で導くケーゲル体操タイマー • 週間・月間タブは日別の回数グラフとセット・回数の合計。「今週 ↔ 過去7日」のようにカレンダー基準と今日基準を切り替えられます ■ ヘルスケアとショートカット -• 最後まで終えたセットはヘルスケアにワークアウトとして自動記録(ワークアウトの種類・強度を選択可)。ウォッチなら心拍数も一緒に残ります +• 最後まで終えたセットはヘルスケアにワークアウトとして自動記録(ワークアウトの種類・強度を選択可)。ウォッチを着けていれば、どちらから開始してもウォッチが心拍数とアクティブエネルギーを計測して残します • ショートカットアプリの「ケーゲル運動を開始」アクションで、ホーム画面のアイコン・背面タップ・Siriからすぐ開始 ■ そのほか diff --git a/myApp/Keging/Marketing/AppStore/description-ko.txt b/myApp/Keging/Marketing/AppStore/description-ko.txt index a1add3d..7be745d 100644 --- a/myApp/Keging/Marketing/AppStore/description-ko.txt +++ b/myApp/Keging/Marketing/AppStore/description-ko.txt @@ -20,6 +20,7 @@ ■ 애플워치 • 워치 앱을 열고 시작 버튼을 누르면 손목에서 바로 시작 — 진동도 워치에서 울려요 +• 아이폰에서 시작하면 워치 앱이 자동으로 열려 심박수를 재요 — 진동은 시작한 기기에서만 울려요 • 수축·이완 상태, 남은 시간, 몇 번째인지가 한 화면에 • 워치 전용 세팅(왼쪽으로 쓸어넘기기) — 준비·수축·이완·횟수·진동 패턴을 손목에 맞게 따로 정해요 • 워치에서 마친 세트는 아이폰으로 넘어와 통계에 합쳐져요. 떨어져 있어도 나중에 자동으로 전송돼요 @@ -30,7 +31,7 @@ • 주간·월간 탭은 일별 횟수 그래프와 세트·횟수 합계. '이번 주 ↔ 지난 7일'처럼 달력 기준과 오늘 기준을 오갈 수 있어요 ■ 애플 건강과 단축어 -• 끝까지 마친 세트는 애플 건강에 운동으로 자동 기록돼요(운동 유형·강도 선택 가능). 워치에서 하면 심박수도 함께 남아요 +• 끝까지 마친 세트는 애플 건강에 운동으로 자동 기록돼요(운동 유형·강도 선택 가능). 워치를 차고 있으면 어느 쪽에서 시작하든 워치가 심박수와 활성 에너지를 실측해 함께 남겨요 • 단축어 앱의 '케겔 운동 시작' 동작으로 홈 화면 아이콘, 뒷면 탭, 시리에서 바로 시작할 수 있어요 ■ 그 밖에 diff --git a/myApp/Keging/Marketing/AppStore/review-notes.txt b/myApp/Keging/Marketing/AppStore/review-notes.txt index d240dc5..0b7ab62 100644 --- a/myApp/Keging/Marketing/AppStore/review-notes.txt +++ b/myApp/Keging/Marketing/AppStore/review-notes.txt @@ -26,9 +26,9 @@ Keging is a free Kegel exercise timer with no account, no ads, and no in-app purchases. - Tap the big Start button on the main screen to run a set (ready countdown → squeeze/relax cycles). The default set takes about 5 minutes; you can shorten it in Workout Setup (top-right slider icon), e.g. 1 s squeeze, 1 s relax, 2 reps. -- HealthKit: when a set is completed, the app saves it to Apple Health as a workout (write-only on iPhone). The Apple Watch app runs an HKWorkoutSession during the set so heart rate and active energy are included in that workout. No other health data is read, and nothing leaves the device. Permission is requested at the first start; declining simply skips the Health entry. +- HealthKit: when a set is completed, the app saves it to Apple Health as a workout. If the user wears an Apple Watch with the Keging watch app, starting a set on iPhone launches the watch app via HKHealthStore.startWatchApp and the watch runs an HKWorkoutSession mirrored to the iPhone, so heart rate and active energy are measured by the watch and saved with that workout (the iPhone shows the live heart rate during the set only). Without a watch, the iPhone saves the workout with an estimated energy value. The iPhone requests read access to heart rate and active energy solely so the watch session can collect them (HealthKit shares authorization between the paired apps); the iPhone app never queries Health data itself. Nothing leaves the device. Permission is requested at the first start; declining simply skips the Health entry. - Background audio mode: the app's core function is time-based haptic guidance during a set. The audio background mode keeps the workout session alive so phase-change haptics fire on time while the screen is off or another app is in front, and the Live Activity (Dynamic Island / Lock Screen) shows the live set progress. The session ends automatically when the set finishes or is stopped. -- Apple Watch: the watch app works standalone (start on the wrist, watch-only setup via left swipe). Completed sets sync to the iPhone statistics. +- Apple Watch: the watch app also works standalone (start on the wrist, watch-only setup via left swipe, haptics on the watch). Completed sets sync to the iPhone statistics. If the watch app cannot be opened when starting from iPhone, the app shows a one-time notice and records with iPhone only. - Shortcuts: an App Intent "Start Kegel Workout" opens the app and starts a set. The app provides no medical advice; a note in the description says to consult a professional in case of pain or a medical condition. diff --git a/myApp/Keging/Marketing/AppStore/whats-new-1.0.txt b/myApp/Keging/Marketing/AppStore/whats-new-1.0.txt index e7b13db..5140ef6 100644 --- a/myApp/Keging/Marketing/AppStore/whats-new-1.0.txt +++ b/myApp/Keging/Marketing/AppStore/whats-new-1.0.txt @@ -5,7 +5,7 @@ • 수축·이완을 서로 다른 진동으로 안내하는 케겔 운동 타이머 • 준비·수축·이완 시간과 횟수, 진동 패턴 3종 설정 • 다이나믹 아일랜드·잠금화면 실시간 현황 -• 애플워치 앱(손목에서 시작, 워치 전용 세팅) +• 애플워치 앱(손목에서 시작, 워치 전용 세팅) — 아이폰에서 시작해도 워치가 심박수를 재요 • 하루·주간·월간 통계와 오늘의 타임라인 • 애플 건강 운동 기록, 단축어 '케겔 운동 시작' • 광고·결제 없는 완전 무료 @@ -15,7 +15,7 @@ This is the first release of Keging. • A Kegel timer that cues squeeze and relax with different haptics • Ready, squeeze and relax times, reps, and three haptic patterns • Live status in the Dynamic Island and on the Lock Screen -• Apple Watch app (start from your wrist, watch-only setup) +• Apple Watch app (start from your wrist, watch-only setup) — even iPhone-started sets get heart rate from the watch • Daily, weekly and monthly stats with Today's Timeline • Apple Health workouts and the "Start Kegel Workout" shortcut • Completely free — no ads, no purchases @@ -25,7 +25,7 @@ Kegingの最初のバージョンです。 • 締める・ゆるめるを違う振動で知らせるケーゲル体操タイマー • 準備・締める・ゆるめる時間と回数、3種類の振動パターン • Dynamic Island・ロック画面のリアルタイム表示 -• Apple Watchアプリ(手首から開始、ウォッチ専用設定) +• Apple Watchアプリ(手首から開始、ウォッチ専用設定)— iPhoneで開始してもウォッチが心拍数を計測 • 1日・週間・月間の統計と今日のタイムライン • ヘルスケアへのワークアウト記録、ショートカット「ケーゲル運動を開始」 • 広告・課金なしの完全無料 diff --git a/myApp/Keging/Marketing/README.md b/myApp/Keging/Marketing/README.md index 56db850..1218c82 100644 --- a/myApp/Keging/Marketing/README.md +++ b/myApp/Keging/Marketing/README.md @@ -26,7 +26,7 @@ tools/ - 아이패드 슬롯 없음(iPhone 전용 앱, TARGETED_DEVICE_FAMILY=1). ASC는 6.9″ 아이폰 + 워치 한 세트씩 필수, 나머지 크기는 자동 축소 - **스크린샷 구성 (1.0, 2026-09-05)**: - - **아이폰 9장** = 01 첫 화면(워치 연결됨 배지) / 02 운동 중·수축 / 03 운동 중·이완 / 04 하루 통계(오늘의 타임라인) / 05 주간 통계 / 06 운동 세팅 시트 / 07 다이나믹 아일랜드 확장 뷰(홈 화면에서 길게 누름) / 08 다크 모드 / 09 완료 화면 + - **아이폰 9장** = 01 첫 화면 / 02 운동 중·수축(워치 심박 줄) / 03 운동 중·이완 / 04 하루 통계(오늘의 타임라인) / 05 주간 통계 / 06 운동 세팅 시트 / 07 다이나믹 아일랜드 확장 뷰(홈 화면에서 길게 누름) / 08 다크 모드 / 09 완료 화면 - **워치 3장** = 01 시작 화면 / 02 운동 중 / 03 세팅 페이지 - 상태바는 9:41·배터리 100%로 오버라이드된 상태로 촬영 (워치 시뮬레이터는 오버라이드 미지원 — 실제 시각 표시) - 문구 오버레이 규정: Apple은 스크린샷에 마케팅 문구·배경 합성을 허용(실제 앱 화면이 반영되어 있으면 됨) — framed 세트는 이 관행을 따른 것 @@ -55,10 +55,11 @@ xcrun simctl install $WATCH "$APP/Watch/KegingWatch Watch App.app" # 3) 화면별 런치 인자 (매 실행 공통: -noHealth -AppleLanguages "(ko)" -AppleLocale ko_KR / en en_US / ja ja_JP) # ★ 폰 첫 화면의 '워치 연결됨' 배지는 워치 시뮬에서 케깅 워치 앱이 포그라운드일 때만 뜬다 — 01·08 촬영 직전에 워치 앱을 launch -# 01-main : -resetConfig (기본 세팅 10·5·20) +# 01-main : -resetConfig (기본 세팅 10·5·20 — 9차부터 워치 배지 없음) # 09-finished : -quickConfig -autoStart → 8초 뒤 (완주 화면. 다음 실행에 -resetConfig 필수) # 06-setup : -resetConfig -openTimerSettings -# 02-workout : -autoStart → 약 36초 뒤 (3회차 수축, 7초 남음) / 03-relax: 같은 실행 약 45초 뒤 (3회차 이완) +# 02-workout : -autoStart -mockWatchHeartRate 78 → 약 36초 뒤 (3회차 수축, 7초 남음) / 03-relax: 같은 실행 약 45초 뒤 (3회차 이완) +# (-mockWatchHeartRate = 워치 없이 '♥78 워치 심박' 상태 줄을 흉내 — 실제 미러링은 시뮬 미지원) # 08-dark : simctl ui $PHONE appearance dark → 기본 실행 → 촬영 → appearance light # 05-stats-week : -seedStats -openStats -statsScope week # 04-stats-day : -seedStats -openStats ← ⚠️ '시간대 정합' 2단계 부팅 후 촬영 diff --git a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/01-main.png b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/01-main.png index 4ded411..6899443 100644 Binary files a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/01-main.png and b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/01-main.png differ diff --git a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/02-workout.png b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/02-workout.png index 8b5546b..0d8dfbb 100644 Binary files a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/02-workout.png and b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/02-workout.png differ diff --git a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/03-relax.png b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/03-relax.png index 3296e0c..7c8cabc 100644 Binary files a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/03-relax.png and b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/03-relax.png differ diff --git a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/08-dark.png b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/08-dark.png index ea7abad..b1b5871 100644 Binary files a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/08-dark.png and b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/en/08-dark.png differ diff --git a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/01-main.png b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/01-main.png index 226d919..7d6d633 100644 Binary files a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/01-main.png and b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/01-main.png differ diff --git a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/02-workout.png b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/02-workout.png index e554d17..9bc07ea 100644 Binary files a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/02-workout.png and b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/02-workout.png differ diff --git a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/03-relax.png b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/03-relax.png index cab5efd..3f762e8 100644 Binary files a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/03-relax.png and b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/03-relax.png differ diff --git a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/08-dark.png b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/08-dark.png index 97182cd..1877f1c 100644 Binary files a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/08-dark.png and b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ja/08-dark.png differ diff --git a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/01-main.png b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/01-main.png index 5d608da..3809459 100644 Binary files a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/01-main.png and b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/01-main.png differ diff --git a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/02-workout.png b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/02-workout.png index 2bc078f..95d8871 100644 Binary files a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/02-workout.png and b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/02-workout.png differ diff --git a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/03-relax.png b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/03-relax.png index cb170cd..642ded5 100644 Binary files a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/03-relax.png and b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/03-relax.png differ diff --git a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/08-dark.png b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/08-dark.png index 555ff17..4fb11fb 100644 Binary files a/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/08-dark.png and b/myApp/Keging/Marketing/Screenshots/framed-iphone-6.9/ko/08-dark.png differ diff --git a/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/01-main.png b/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/01-main.png index 3e8def6..5f8d73d 100644 Binary files a/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/01-main.png and b/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/01-main.png differ diff --git a/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/02-workout.png b/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/02-workout.png index 9917d66..e268463 100644 Binary files a/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/02-workout.png and b/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/02-workout.png differ diff --git a/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/03-relax.png b/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/03-relax.png index 8dec0c9..7ae8cab 100644 Binary files a/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/03-relax.png and b/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/03-relax.png differ diff --git a/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/08-dark.png b/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/08-dark.png index c6a02fc..cea8205 100644 Binary files a/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/08-dark.png and b/myApp/Keging/Marketing/Screenshots/iphone-6.9/en/08-dark.png differ diff --git a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/01-main.png b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/01-main.png index f5583bc..60763ee 100644 Binary files a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/01-main.png and b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/01-main.png differ diff --git a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/02-workout.png b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/02-workout.png index a6d86f3..abd925b 100644 Binary files a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/02-workout.png and b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/02-workout.png differ diff --git a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/03-relax.png b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/03-relax.png index 7a34cb9..c3b6fba 100644 Binary files a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/03-relax.png and b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/03-relax.png differ diff --git a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/08-dark.png b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/08-dark.png index cb0863a..483a5d5 100644 Binary files a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/08-dark.png and b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ja/08-dark.png differ diff --git a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/01-main.png b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/01-main.png index b649ffd..0bb8ce4 100644 Binary files a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/01-main.png and b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/01-main.png differ diff --git a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/02-workout.png b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/02-workout.png index ee4bb08..b701329 100644 Binary files a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/02-workout.png and b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/02-workout.png differ diff --git a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/03-relax.png b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/03-relax.png index acce2f1..a2695cb 100644 Binary files a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/03-relax.png and b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/03-relax.png differ diff --git a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/08-dark.png b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/08-dark.png index 693423d..89f36d7 100644 Binary files a/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/08-dark.png and b/myApp/Keging/Marketing/Screenshots/iphone-6.9/ko/08-dark.png differ diff --git a/myApp/Keging/Shared/WatchBridgeMessage.swift b/myApp/Keging/Shared/WatchBridgeMessage.swift new file mode 100644 index 0000000..1092c28 --- /dev/null +++ b/myApp/Keging/Shared/WatchBridgeMessage.swift @@ -0,0 +1,37 @@ +// +// WatchBridgeMessage.swift +// Keging +// +// 아이폰에서 시작한 세트의 워치 심박 측정 — 미러링 세션(HKWorkoutSession, watchOS 10+/iOS 17+)의 +// 데이터 채널로 오가는 메시지 (iOS·워치 공용). +// 폰 → 워치: finish(완주·저장) / discard(중단·폐기). 워치 → 폰: heartRate. +// 저장 결과(saved/failed)는 세션이 끝난 뒤라 미러링 채널이 닫히므로 +// WatchConnectivity(WatchSync → PhoneSync)로 따로 보낸다. +// + +import Foundation + +nonisolated enum WatchBridgeMessage: Codable, Equatable { + /// 폰 → 워치: 세트 완주 — 워치가 운동을 저장하고(운동 강도 포함) 결과를 WatchConnectivity로 알린다 + case finish(effortScore: Int) + /// 폰 → 워치: 세트 중단 — 워크아웃 폐기 (중단한 세트는 없던 것, §3.1) + case discard + /// 워치 → 폰: 최신 심박(bpm) + case heartRate(bpm: Double) + + func encoded() -> Data? { try? JSONEncoder().encode(self) } + + static func decode(_ data: Data) -> WatchBridgeMessage? { + try? JSONDecoder().decode(WatchBridgeMessage.self, from: data) + } +} + +/// WatchConnectivity로 보내는 저장 결과 (워치 → 폰) / 폐기 명령 (폰 → 워치, 미러링이 안 붙었을 때의 안전장치) +nonisolated enum WatchBridgeResult { + static let key = "mirrorResult" + static let saved = "saved" + static let failed = "failed" + /// 폰 → 워치: 미러링 세션이 30초 안에 안 붙어 폰이 단독으로 넘어갔을 때 워치 세션을 정리하라는 명령 + static let commandKey = "mirrorCommand" + static let discard = "discard" +}