diff --git a/myApp/Keging/CLAUDE.md b/myApp/Keging/CLAUDE.md index f7a959f..4c0cb49 100644 --- a/myApp/Keging/CLAUDE.md +++ b/myApp/Keging/CLAUDE.md @@ -20,7 +20,7 @@ | 최소 OS | iOS 18.0 / watchOS 10.0 | | 수익 모델 | **완전 무료** — 광고·결제 일절 없음. CloudKit 없음. HealthKit은 **쓰기 전용**(운동 기록 저장 — §3.7) | | 언어 | **한국어(원문)/영어/일본어** — String Catalog(타깃별 `Localizable.xcstrings`, InfoPlist 포함). 설정 → 언어(시스템/한국어/English/日本語)에서 앱 내 전환: `AppleLanguages` 오버라이드, **완전 종료 후 재실행 시 적용**(하루 다님 검증 방식). 워치·Live Activity·단축어 표기는 각자의 시스템 언어를 따름 | -| 현재 상태 (2026-08-30) | **1.0 전 기능 구현 + 실기기 2차 피드백 반영 완료, 재테스트 대기.** 2차 수정(2026-08-30): ①중단 확인을 인라인 UI로 교체(§4) ②통계 스와이프 방향 반전(아래→위, 통통 스프링) ③**진동 3종 재설계 — iOS 포그라운드 Core Haptics 정밀 재생**(기본/톡톡 0.28s 간격/지이이잉 1.2s 연속, §3.3. watchOS는 CoreHaptics 부재라 시스템 햅틱 3종 매핑). 이전(1차): DI 백그라운드 멈춤(오디오 하드닝+벽시계 엔진)·칼로리 0(에너지 샘플)·유령 DI 정리, 클린룸 3종 빌드 그린·경고 0("cannot find in scope"는 SourceKit 오탐 확정). **재테스트 항목(사용자, 아이폰)**: DI 백그라운드 갱신, 진동 3종(특히 포그라운드 톡톡 간격·지이이잉 길이 — 백그라운드는 시스템 바이브 체이닝 폴백이라 질감이 다름), 칼로리(재권한 시트 '활성 에너지' 허용), 중단 UI·통계 제스처. **워치 실기기는 TestFlight 1.0에서**(로컬 워치 배포 네트워크 문제 — 시뮬 검증 완료) | +| 현재 상태 (2026-09-02) | **실기기 3차 피드백 6건 반영 완료, 재테스트 대기.** 3차 수정: ①**DI 멈춤 근본 원인 제거** — CHHapticEngine을 기본 생성하면 독자 오디오 정책으로 무음 세션을 끊음 → `CHHapticEngine(audioSession:)` 부착으로 교체(§3.3) + 오디오 키퍼 하드닝(루트 변경·미디어 서버 리셋 대응, §3.4) + 운동 중 2초 하트비트(오디오 생존·엔진 재동기화·LA 갱신, §3.4) + LA staleDate/isStale 흐림 표시 ②워치 세팅 동기화 — 워치 앱이 열릴 때마다 sendMessage로 폰에서 최신 세팅 pull(폰 앱이 꺼져 있어도 깨움) + 폰 reachability 재푸시(§3.6, 동기화 버튼 불필요) ③완료 진동 신설 — 아주 긴 "우우우웅"(§3.3) ④통계 하루 탭 맨 위 '오늘의 타임라인'(24h 점 그래프, §3.2) ⑤칼로리 정합 — 워치 '기타' 운동은 애플 규칙상 "심박 vs 빠르게 걷기 중 큰 쪽"이라 폰 추정을 MET 4.0+강도×0.15(기본 4.3≈빠르게 걷기)로 상향(§3.7) ⑥포그라운드/백그라운드 진동 질감 차이는 iOS 제약(백그라운드 커스텀 햅틱 금지) — 도움말에 명시. 시뮬 검증: DI 백그라운드 3단계 진행(수축1→이완1→수축2/20)·타임라인 카드·완주 플로우·워치 구동, 3종 빌드 그린·경고 0·l10n 0/0/0. **재테스트 항목(사용자, 아이폰)**: DI 백그라운드 갱신(수 분 이상·화면 잠금 포함), 완료 진동, 워치 세팅 즉시 동기화, 칼로리(≈28kcal/5분대로 워치와 근접해야 함), 통계 타임라인 | ## 2. 빌드·검증 환경 @@ -34,11 +34,11 @@ IOS/ # iOS 앱 타깃 (Keging) WorkoutView.swift # 운동 진행 (국면 원 애니메이션·중단 확인 다이얼로그·완료 화면) TimerSettingsView.swift # 세팅 (수축/이완/횟수 Stepper + 진동 패턴 Picker + 미리 느끼기) AppSettingsView.swift # 설정 (테마 시스템/라이트/다크 — app.theme, 버전) - StatsView.swift # 통계 (하루/주간/월간, 달력↔롤링, Swift Charts 꺾은선) + StatsView.swift # 통계 (하루/주간/월간, 달력↔롤링, 꺾은선 + 하루 '오늘의 타임라인') HelpView.swift # 도움말 (첫 화면 ? 버튼 — 5그룹 13주제 전 기능 사용법) - WorkoutCoordinator.swift # 엔진↔진동·Live Activity·백그라운드 오디오·화면 꺼짐 방지 배선 - Haptics.swift # 진동 재생 (AudioServices 바이브 연타 — 백그라운드에서도 동작) - BackgroundAudioKeeper.swift # 운동 중 무음 오디오 루프 (백그라운드 타이머 유지, mixWithOthers) + WorkoutCoordinator.swift # 엔진↔진동·Live Activity·오디오·화면 유지 배선 + 2초 하트비트·완료 진동 + Haptics.swift # 진동 재생 (포그라운드 Core Haptics / 백그라운드 바이브 체이닝 + 완료 진동) + BackgroundAudioKeeper.swift # 운동 중 무음 오디오 루프 (ensureAlive — 인터럽션·루트 변경·미디어 리셋 대응) LiveActivityManager.swift # Live Activity 시작/갱신/종료 PhoneSync.swift # WCSession 폰 측 (세팅 push, 워치 기록 수신·병합) HealthRecorder.swift # 완주 세트를 애플 건강에 사후 저장 (§3.7) @@ -117,18 +117,18 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] ### 3.2 기록·통계 (`Shared/SessionRecord.swift`, `IOS/StatsView.swift`) - SessionRecord: id(UUID)·startedAt(시작 시각)·reps·수축/이완초. 저장은 App Group `records.json`(JSON, atomic). append는 id 중복 병합이라 워치 재전송에도 안전 -- 통계 화면: 첫 화면 **위로 스와이프**(또는 하단 손잡이 탭)로 아래에서 **통통 튀는 스프링**(dampingFraction 0.62)으로 올라오는 오버레이 — 아래로 스와이프·상단 손잡이·X로 닫기(2차 실기기 피드백으로 방향 반전). 하루(시간대별 꺾은선 + 오늘 기록 목록) / 주간·월간(일별 꺾은선) + 세트·횟수 합계 카드 +- 통계 화면: 첫 화면 **위로 스와이프**(또는 하단 손잡이 탭)로 아래에서 **통통 튀는 스프링**(dampingFraction 0.62)으로 올라오는 오버레이 — 아래로 스와이프·상단 손잡이·X로 닫기(2차 실기기 피드백으로 방향 반전). 하루 탭 구성(위에서부터): **'오늘의 타임라인'**(3차 피드백 — 24시간 축 위 세션 점, 점 크기=횟수·시각 라벨(5개 이하일 때)·'지금' 점선 룰 상단 라벨, 기록 없으면 카드 숨김) → 합계 카드 → 시간대별 꺾은선 → 오늘 기록 목록. 주간·월간(일별 꺾은선) + 세트·횟수 합계 카드 - 주간·월간은 **달력 기준(이번 주·이번 달) ↔ 오늘 기준 롤링(지난 7일·30일)** 세그먼트 전환. **주 시작 = 월요일** (`calendar.firstWeekday = 2`) ### 3.3 진동 (`Shared/WorkoutSettings.swift`, `IOS/Haptics.swift`, 워치 `WatchHaptics.swift`) -- **패턴 3종**(2차 실기기 피드백 '체이닝도 간격 길다'로 재설계): 기본(짧은 1방) / 톡톡(빠른 2방) / 지이이잉(아주 긴 연속 진동). 기본값: **수축=지이이잉, 이완=기본**. 세팅에서 국면별 선택 + "미리 느끼기". 구버전 저장값(1.0 횟수 기반·4종 시절)은 `HapticPattern(migrating:)`으로 이관 -- **iOS 포그라운드 = Core Haptics 정밀 재생**(`Shared/HapticEnginePlayer` — `hapticEvents` 스펙: 기본 0.25s, 톡톡 0.14s×2 시작 간격 0.28s, 지이이잉 1.2s 연속, 세기 1.0). 시스템 바이브로는 불가능한 형태라 CHHapticEngine 사용(isAutoShutdownEnabled, 실패 시 엔진 1회 재생성) -- **iOS 백그라운드 폴백 = 시스템 바이브 완료 체이닝**(기본 1방·톡톡 2방·지이이잉 3방 즉시 잇기) — ⚠️재생 중 같은 시스템 사운드 재호출은 무시(실측)라 완료 콜백이 물리적 최소 간격. 세대 토큰으로 새 패턴 시작 시 이전 체인 취소. 무음 스위치 무관, 오디오 세션 살아 있으면 백그라운드에서도 울림 -- ⚠️**watchOS SDK에는 CoreHaptics가 없다**(프레임워크 부재 실측) — 워치는 강한 시스템 햅틱 3종 매핑(시스템 내장이라 간격 뭉개짐 없음): 기본=.notification, 톡톡=.directionUp, 지이이잉=.retry. 세기는 시스템 고정(더 강하게는 워치 설정 > 사운드 및 햅틱). **워치에서 실행하면 진동은 워치에서만** (완주 시 .success 1회) +- **패턴 3종**(2차 실기기 피드백 '체이닝도 간격 길다'로 재설계): 기본(짧은 1방) / 톡톡(빠른 2방) / 지이이잉(아주 긴 연속 진동). 기본값: **수축=지이이잉, 이완=기본**. 세팅에서 국면별 선택 + "미리 느끼기". 구버전 저장값(1.0 횟수 기반·4종 시절)은 `HapticPattern(migrating:)`으로 이관. **완주 진동(고유·비설정)**: 아주 긴 "우우우웅" — `FinishHaptic.events`(2.4s 연속), 백그라운드는 6방 체인, 워치는 .retry×2+.success 시퀀스 +- **iOS 포그라운드 = Core Haptics 정밀 재생**(`Shared/HapticEnginePlayer` — `hapticEvents` 스펙: 기본 0.25s, 톡톡 0.14s×2 시작 간격 0.28s, 지이이잉 1.2s 연속, 세기 1.0). ⚠️**엔진은 반드시 `CHHapticEngine(audioSession: .sharedInstance())`로 공유 세션에 부착** — 기본 생성 시 독자 오디오 정책이 백그라운드 무음 루프를 끊음(실기기 3차 사고 원인, 애플 공식 패턴). isAutoShutdownEnabled + resetHandler, 실패 시 엔진 1회 재생성, 재생 전 세션 카테고리/활성 보장 +- **iOS 백그라운드 폴백 = 시스템 바이브 완료 체이닝**(기본 1방·톡톡 2방·지이이잉 3방·완료 6방 즉시 잇기) — ⚠️재생 중 같은 시스템 사운드 재호출은 무시(실측)라 완료 콜백이 물리적 최소 간격. 세대 토큰으로 새 패턴 시작 시 이전 체인 취소. 무음 스위치 무관, 오디오 세션 살아 있으면 백그라운드에서도 울림. **iOS는 백그라운드 커스텀 햅틱(Core Haptics)을 금지하므로 포그라운드와 질감 차이는 구조적 한계**(도움말에 명시) — 완주 직후엔 체인이 끝나도록 오디오 정지를 4초 지연(WorkoutCoordinator.stopAudioSoon) +- ⚠️**watchOS SDK에는 CoreHaptics가 없다**(프레임워크 부재 실측) — 워치는 강한 시스템 햅틱 3종 매핑(시스템 내장이라 간격 뭉개짐 없음): 기본=.notification, 톡톡=.directionUp, 지이이잉=.retry. 세기는 시스템 고정(더 강하게는 워치 설정 > 사운드 및 햅틱). **워치에서 실행하면 진동은 워치에서만** (완주 시 .retry 0.75s 간격 2방 + .success) ### 3.4 백그라운드·Live Activity (`IOS/BackgroundAudioKeeper.swift`, `LiveActivityManager.swift`, `KegingWidgets/`) -- 운동 시작 시 무음 WAV 무한 루프(`UIBackgroundModes=audio`, `.playback + .mixWithOthers`, **volume 0.1**·prepareToPlay — 0.0은 백그라운드 유지가 풀리는 사례 대비) → 앱을 나가도 타이머·진동·Live Activity 갱신 유지. **인터럽션 종료·백그라운드 전환·활성화 때 재생 죽었으면 revive()** 옵저버. 그래도 정지되면 엔진 벽시계 재동기화(§3.1)가 복귀 시 교정. 종료·중단 시 즉시 해제. 운동 중 `isIdleTimerDisabled`로 화면 꺼짐 방지(종료 시 복원) -- Live Activity: 국면 전환마다 update — 다이나믹 아일랜드 컴팩트(국면 아이콘 + n/총), 확장(국면·카운트다운·진행 바), 잠금화면 배너. 종료·중단 시 `.immediate` dismiss. **앱 시작 시 `cleanupStaleActivities()`** — 운동 중 강제 종료로 남은 유령 DI를 정리(시뮬 실측: kill 후 몇 시간 잔존하던 것 → 재실행 즉시 소멸 확인). 수축=민트 `#7FBF9E`·이완=앰버 `#E8C558` 고정색 +- 운동 시작 시 무음 WAV(20초 버퍼) 무한 루프(`UIBackgroundModes=audio`, `.playback + .mixWithOthers`, **volume 0.1**·prepareToPlay — 0.0은 백그라운드 유지가 풀리는 사례 대비) → 앱을 나가도 타이머·진동·Live Activity 갱신 유지. **ensureAlive() 단일 진입점**: 인터럽션 종료·**루트 변경(이어폰 해제 등)**·**미디어 서버 리셋(플레이어 무효 → 재구축)**·백그라운드/활성 전환 옵저버 + **운동 중 2초 하트비트**(WorkoutCoordinator — 오디오 생존 점검·`resyncAfterWake()`·`LiveActivityManager.syncCurrent()`)가 모두 호출. 놓친 국면 경계도 2초 안에 자가 치유. 종료·중단 시 해제(완주는 완료 진동 체인 보호를 위해 4초 지연). 운동 중 `isIdleTimerDisabled`로 화면 꺼짐 방지(종료 시 복원) +- Live Activity: `sync(phase:rep:totalReps:phaseStart:phaseEnd:)` — 실제 phaseStart를 쓰고 **같은 상태면 no-op**(하트비트에서 불러도 안전), `staleDate = phaseEnd+8초`. 다이나믹 아일랜드 컴팩트(국면 아이콘 + n/총), 확장(국면·카운트다운·진행 바), 잠금화면 배너 — **isStale이면 국면 정보를 40% 투명도로**(갱신 끊김 시각화). 종료·중단 시 `.immediate` dismiss. **앱 시작 시 `cleanupStaleActivities()`** — 운동 중 강제 종료로 남은 유령 DI를 정리. 수축=민트 `#7FBF9E`·이완=앰버 `#E8C558` 고정색. 시뮬 실측(3차): 백그라운드에서 수축1→이완1→수축2/20 진행 확인 ### 3.5 단축어 (`IOS/KegingIntents.swift`) - `StartKegelIntent` 하나 — 앱을 열고(`openAppWhenRun`) 저장된 세팅대로 즉시 시작. 콜드 스타트 경합은 AppLaunchState 플래그 + 알림 이중 경로로 처리 @@ -138,7 +138,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] - **완주한 세트만** 애플 건강에 운동으로 자동 기록 — 중단 세트는 건강에도 없음(§3.1 규칙 일관). 실행한 기기가 기록(폰=사후 HKWorkoutBuilder, 워치=라이브 HKWorkoutSession — 심박 포함, 백그라운드 유지 겸용). 출처 앱 아이콘은 시스템이 자동 표기 - **운동 유형 선택(설정 → 애플 건강)**: '기타'(기본) ↔ '코어 트레이닝' — `HealthWorkoutType`(WorkoutConfig 필드, 워치 동기화). 하루 다님의 종목 매핑에서는 둘 다 목록 외라 동일하게 '기타 운동'으로 잡힘(호환 무영향) - **운동 강도(workout effort score, 1~10)**: 설정 → 애플 건강 → '운동 강도 자동 기록'(기본 2·쉬움, '안 함' 가능) — 완주 시 `relateWorkoutEffortSample`로 운동에 연결(애플 운동 앱의 강도 기록과 같은 데이터). iOS 18/워치 11+ — 워치 10은 `#available` 가드로 운동만 기록 -- **칼로리**: 빌더로 저장한 운동은 에너지 샘플을 직접 넣지 않으면 0kcal(실기기 실측) — iOS는 `estimatedKcal`(MET 1.8+강도×0.17, 체중 70kg 가정)로 activeEnergyBurned 샘플을 add. 워치는 라이브 빌더가 센서로 심박·활성 에너지를 실측 수집(read: 심박·활성 에너지, share에 activeEnergyBurned 추가) +- **칼로리**: 빌더로 저장한 운동은 에너지 샘플을 직접 넣지 않으면 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 문구가 이를 설명). 거부·미응답이면 조용히 건너뜀(앱 자체 기록·통계 무영향). 워치는 권한 응답이 늦어 운동이 끝났으면 세션을 열지 않는 가드 있음 - ⚠️ (하루 다님 ITMS-90683 전례) HealthKit 엔타이틀먼트 보유 → `NSHealthUpdateUsageDescription` 필수 — iOS·워치 Info.plist에 3언어(InfoPlist.xcstrings)로 등록됨. 엔타이틀먼트는 iOS·워치 두 타깃(위젯 제외) - **하루 다님 연계(2026-08-29 확인 — 하루 다님 무수정)**: 하루 다님의 종목별 운동 다짐·타임테이블 운동 블록은 HKWorkout을 출처 무관 조회라 케깅 세트가 '기타 운동'으로 자동 집계됨. 단 '운동 시간' 지표는 `appleExerciseTime`(애플 전용 쓰기 불가) 기반이라 **아이폰 실행분은 미반영, 워치 실행분은 운동 링 적립을 통해 반영**(구조적 한계 — 케깅 측 해결 불가) @@ -146,7 +146,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] ### 3.6 워치 (`KegingWatch Watch App/`) - 실행하면 시작 버튼만(+세팅 요약 한 줄). 진행 중: 국면(수축=민트/이완=앰버)·카운트다운·n/총·중단 — **40mm에서도 스크롤 없이 한 화면** (46·40mm 스크린샷 검증) -- 세팅은 폰에서만 편집 — `updateApplicationContext`로 워치에 도착(`WatchSync`), 완주 기록은 `transferUserInfo`로 폰에 합류(오프라인 자동 큐) +- 세팅은 폰에서만 편집 — 전달은 **3중 경로**(3차 피드백: 폰 앱이 꺼진 상태에서 push가 유실돼 워치가 옛 세팅으로 돌던 사고): ①변경 시 `updateApplicationContext` push ②폰 `sessionReachabilityDidChange`에서 재푸시 ③**워치 앱이 열릴 때마다(onAppear·scenePhase .active) `sendMessage`로 폰에서 pull** — 폰 앱이 꺼져 있어도 시스템이 깨워 응답(`PhoneSync.didReceiveMessage`). 동기화 버튼 불필요. 워치 활성화 때 `receivedApplicationContext`도 적용. 완주 기록은 `transferUserInfo`로 폰에 합류(오프라인 자동 큐) - 운동 시작 시 **HKWorkoutSession**(건강 기록+손목 내려도 유지 — §3.7)을 열고, 실패(권한 거부 등) 시 `WKExtendedRuntimeSession`(physical-therapy) 폴백. 둘 다 실패해도 화면 켜져 있는 동안은 정상 동작 ## 4. 화면 구성 (탭 없음 — 애플 기본 앱 느낌) diff --git a/myApp/Keging/IOS/BackgroundAudioKeeper.swift b/myApp/Keging/IOS/BackgroundAudioKeeper.swift index 467e99c..e2b582a 100644 --- a/myApp/Keging/IOS/BackgroundAudioKeeper.swift +++ b/myApp/Keging/IOS/BackgroundAudioKeeper.swift @@ -4,7 +4,10 @@ // // 운동 중 무음 오디오 루프 재생 — 앱을 나가도 타이머·진동·Live Activity 갱신이 유지되게 한다. // (UIBackgroundModes=audio, .mixWithOthers라 음악 재생을 방해하지 않음) -// 인터럽션(전화 등)·백그라운드 전환에서 재생이 끊기면 다시 살린다. +// +// 실기기에서 재생이 죽는 경로가 여럿이라(전화 인터럽션, 이어폰 연결 해제 같은 루트 변경, +// 미디어 서버 리셋 — 이 경우 플레이어 객체 자체가 무효) 전부 감시해 되살리고, +// 운동 중에는 WorkoutCoordinator의 하트비트가 ensureAlive()를 주기 호출해 이중 방어한다. // import AVFoundation @@ -16,40 +19,58 @@ final class BackgroundAudioKeeper { private var player: AVAudioPlayer? private var observers: [NSObjectProtocol] = [] + /// 운동 중 여부 — stop() 후에 늦게 도착한 알림이 재생을 되살리는 것 방지 + private var isActive = false private init() {} func start() { - guard player == nil else { return } - let session = AVAudioSession.sharedInstance() - try? session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) - try? session.setActive(true) - player = try? AVAudioPlayer(data: Self.silenceWAV) - player?.numberOfLoops = -1 - // 0.0은 일부 환경에서 백그라운드 유지가 풀리는 사례가 있어 소량으로 — 샘플이 무음이라 들리지 않는다 - player?.volume = 0.1 - player?.prepareToPlay() - player?.play() + isActive = true + activateSessionAndPlay() installObservers() } func stop() { + isActive = false removeObservers() player?.stop() player = nil try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) } - /// 재생이 죽었으면 되살린다 (인터럽션 종료·씬 전환 시) - private func revive() { - guard let player, !player.isPlaying else { return } - try? AVAudioSession.sharedInstance().setActive(true) - player.play() + /// 어떤 이유로든 재생이 죽었으면 되살린다 — 알림 옵저버·하트비트 공용 진입점 + func ensureAlive() { + guard isActive else { return } + if let player, player.isPlaying { return } + activateSessionAndPlay() + } + + private func activateSessionAndPlay() { + let session = AVAudioSession.sharedInstance() + try? session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) + try? session.setActive(true) + if player == nil { player = Self.makePlayer() } + if player?.play() != true { + // 미디어 서버 리셋 등으로 플레이어가 무효화됐으면 새로 만들어 재시도 + player = Self.makePlayer() + player?.play() + } + } + + private static func makePlayer() -> AVAudioPlayer? { + let player = try? AVAudioPlayer(data: silenceWAV) + player?.numberOfLoops = -1 + // 0.0은 일부 환경에서 백그라운드 유지가 풀리는 사례가 있어 소량으로 — 샘플이 무음이라 들리지 않는다 + player?.volume = 0.1 + player?.prepareToPlay() + return player } private func installObservers() { guard observers.isEmpty else { return } let center = NotificationCenter.default + + // 인터럽션(전화 등) 종료 → 재개 observers.append(center.addObserver( forName: AVAudioSession.interruptionNotification, object: nil, queue: .main ) { [weak self] note in @@ -57,12 +78,32 @@ final class BackgroundAudioKeeper { let info = note.userInfo, let rawType = info[AVAudioSessionInterruptionTypeKey] as? UInt, AVAudioSession.InterruptionType(rawValue: rawType) == .ended else { return } - Task { @MainActor in self.revive() } + Task { @MainActor in self.ensureAlive() } }) + + // 이어폰 연결 해제 등 루트 변경 — 시스템이 재생을 멈출 수 있다 + observers.append(center.addObserver( + forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main + ) { [weak self] _ in + guard let self else { return } + Task { @MainActor in self.ensureAlive() } + }) + + // 미디어 서버 리셋 — 플레이어·세션이 통째로 무효화되므로 전부 재구축 + observers.append(center.addObserver( + forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main + ) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.player = nil + self.ensureAlive() + } + }) + for name in [UIApplication.didEnterBackgroundNotification, UIApplication.didBecomeActiveNotification] { observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in guard let self else { return } - Task { @MainActor in self.revive() } + Task { @MainActor in self.ensureAlive() } }) } } @@ -72,10 +113,11 @@ final class BackgroundAudioKeeper { observers.removeAll() } - /// 1초짜리 무음 WAV (8kHz 16bit mono) — 파일 없이 메모리에서 생성 + /// 20초짜리 무음 WAV (8kHz 16bit mono) — 파일 없이 메모리에서 생성. + /// 루프 경계 이벤트를 줄이기 위해 넉넉한 길이로 만든다 private static let silenceWAV: Data = { let sampleRate = 8000 - let dataSize = sampleRate * 2 + let dataSize = sampleRate * 2 * 20 var d = Data() func append(_ s: String) { d.append(s.data(using: .ascii)!) } func appendU32(_ v: UInt32) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } } diff --git a/myApp/Keging/IOS/Haptics.swift b/myApp/Keging/IOS/Haptics.swift index 8a04b59..3cbf782 100644 --- a/myApp/Keging/IOS/Haptics.swift +++ b/myApp/Keging/IOS/Haptics.swift @@ -4,8 +4,9 @@ // // 진동 패턴 재생 (iOS). // - 포그라운드: Core Haptics 정밀 패턴(HapticEnginePlayer) — 짧은 간격 연타·긴 연속 진동 -// - 백그라운드: 시스템 바이브 완료 체이닝 폴백. ⚠️재생 중 같은 시스템 사운드 재호출은 -// 무시되므로(실기기 실측) 완료 콜백으로만 잇는다 — 물리적 최소 간격. +// - 백그라운드: 시스템 바이브 완료 체이닝 폴백. iOS는 백그라운드 커스텀 햅틱을 막아두어 +// (Core Haptics 포그라운드 전용) 시스템 진동 연타가 공개 API의 한계 — 질감이 조금 다르다. +// ⚠️재생 중 같은 시스템 사운드 재호출은 무시되므로(실기기 실측) 완료 콜백으로만 잇는다. // AudioServices 바이브는 무음 스위치 무관, 오디오 세션이 살아 있으면 백그라운드에서도 울린다. // @@ -23,11 +24,6 @@ enum Haptics { HapticEnginePlayer.shared.play(pattern) { return } - playSystemChain(pattern, generation: generation) - } - - /// 백그라운드 폴백 — 완료 즉시 잇는 것이 시스템 바이브의 최소 간격 - private static func playSystemChain(_ pattern: HapticPattern, generation: Int) { let count = switch pattern { case .basic: 1 case .doubleTap: 2 @@ -36,6 +32,17 @@ enum Haptics { playChain(remaining: count, generation: generation) } + /// 완주 축하 진동 — 아주 긴 "우우우우웅" (백그라운드는 6방 연속 체인으로 근사) + static func playFinish() { + generation += 1 + if UIApplication.shared.applicationState == .active, + HapticEnginePlayer.shared.playFinish() { + return + } + playChain(remaining: 6, generation: generation) + } + + /// 백그라운드 폴백 — 완료 즉시 잇는 것이 시스템 바이브의 최소 간격 private static func playChain(remaining: Int, generation: Int) { guard remaining > 0, generation == Self.generation else { return } AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate) { @MainActor @Sendable in diff --git a/myApp/Keging/IOS/HealthRecorder.swift b/myApp/Keging/IOS/HealthRecorder.swift index 6ddb3ec..8784084 100644 --- a/myApp/Keging/IOS/HealthRecorder.swift +++ b/myApp/Keging/IOS/HealthRecorder.swift @@ -26,10 +26,12 @@ final class HealthRecorder { ] } - /// 칼로리 추정 — 케겔은 저강도라 MET 1.8(강도 0)~3.5(강도 10), 체중 70kg 가정. - /// 빌더로 저장한 운동은 에너지 샘플을 직접 넣지 않으면 0kcal로 남는다(실기기 실측) + /// 칼로리 추정 — 애플워치의 '기타' 운동 규칙과 정합시킨다: 워치는 "심박 기반 vs 빠르게 걷기 + /// 상당 중 큰 쪽"으로 적립하므로(케겔은 심박이 낮아 사실상 빠르게 걷기 ≈ MET 4.3), + /// 폰도 MET 4.0 + 강도×0.15(기본 강도 2 → 4.3), 체중 70kg 가정으로 계산 — 워치 실측과의 + /// 잔차는 체중·심박 개인차. 빌더 저장 운동은 에너지 샘플을 직접 넣지 않으면 0kcal(실기기 실측) static func estimatedKcal(seconds: TimeInterval, effortScore: Int) -> Double { - let met = 1.8 + Double(min(max(effortScore, 0), 10)) * 0.17 + let met = 4.0 + Double(min(max(effortScore, 0), 10)) * 0.15 return met * 3.5 * 70 / 200 * (seconds / 60) } diff --git a/myApp/Keging/IOS/HelpView.swift b/myApp/Keging/IOS/HelpView.swift index 543e8dd..04afdee 100644 --- a/myApp/Keging/IOS/HelpView.swift +++ b/myApp/Keging/IOS/HelpView.swift @@ -27,7 +27,7 @@ struct HelpView: View { helpRow( symbol: "iphone.radiowaves.left.and.right", title: Text("진동으로 구분하기"), - body: Text("수축이 시작될 때와 이완이 시작될 때 서로 다른 진동이 울려요. 그래서 화면을 보지 않아도 지금 조여야 할지 풀어야 할지 알 수 있어요. 기본값은 수축=지이이잉(아주 긴 진동), 이완=기본(짧은 한 번)이라 첫 순간의 질감으로 바로 구분돼요. 패턴은 운동 세팅에서 국면별로 고를 수 있고(기본·톡톡·지이이잉), '미리 느끼기' 버튼으로 실제 진동을 확인할 수 있어요. 워치에서도 같은 패턴으로 울려요. 진동은 실기기에서만 느껴져요.") + body: Text("수축이 시작될 때와 이완이 시작될 때 서로 다른 진동이 울려요. 그래서 화면을 보지 않아도 지금 조여야 할지 풀어야 할지 알 수 있어요. 기본값은 수축=지이이잉(아주 긴 진동), 이완=기본(짧은 한 번)이라 첫 순간의 질감으로 바로 구분돼요. 패턴은 운동 세팅에서 국면별로 고를 수 있고(기본·톡톡·지이이잉), '미리 느끼기' 버튼으로 실제 진동을 확인할 수 있어요. 세트를 끝까지 마치면 아주 길게 축하 진동이 울려요. 화면이 꺼져 있거나 다른 앱을 쓰는 중에는 시스템 진동으로 대신 울려서 질감이 조금 다를 수 있어요. 워치에서도 같은 패턴으로 울리고, 진동은 실기기에서만 느껴져요.") ) helpRow( symbol: "stop.circle", @@ -47,7 +47,7 @@ struct HelpView: View { helpRow( symbol: "calendar", title: Text("하루 · 주간 · 월간"), - body: Text("하루 탭에서는 오늘 몇 시에 몇 회를 했는지 목록과 시간대별 꺾은선 그래프로 보여줘요. 주간·월간 탭에서는 일별 횟수를 꺾은선 그래프와 합계(세트·횟수)로 보여줘요. 주는 월요일에 시작해요.") + body: Text("하루 탭 맨 위의 타임라인에서 오늘 언제 운동했는지 24시간 축 위의 점으로 한눈에 볼 수 있어요(점이 클수록 횟수가 많은 세트). 아래로는 시간대별 꺾은선 그래프와 오늘 기록 목록이 이어져요. 주간·월간 탭에서는 일별 횟수를 꺾은선 그래프와 합계(세트·횟수)로 보여줘요. 주는 월요일에 시작해요.") ) helpRow( symbol: "arrow.left.arrow.right", @@ -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/InfoPlist.xcstrings b/myApp/Keging/IOS/InfoPlist.xcstrings index ed6a17a..29ee56c 100644 --- a/myApp/Keging/IOS/InfoPlist.xcstrings +++ b/myApp/Keging/IOS/InfoPlist.xcstrings @@ -1,87 +1,99 @@ { - "sourceLanguage": "ko", - "strings": { - "CFBundleDisplayName": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Keging" + "sourceLanguage" : "ko", + "strings" : { + "CFBundleDisplayName" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keging" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Keging" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keging" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "케깅" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "케깅" } } } }, - "CFBundleName": { - "comment": "Bundle name", - "extractionState": "extracted_with_value", - "localizations": { - "ko": { - "stringUnit": { - "state": "new", - "value": "Keging" + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "Keging" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keging" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keging" } } } }, - "NSHealthShareUsageDescription": { - "extractionState": "manual", - "localizations": { - "ko": { - "stringUnit": { - "state": "translated", - "value": "워치에서 운동할 때 심박수와 활성 에너지를 운동 기록에 담기 위해 필요해요. 그 외 건강 데이터는 읽지 않아요." + "NSHealthShareUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "워치에서 운동할 때 심박수와 활성 에너지를 운동 기록에 담기 위해 필요해요. 그 외 건강 데이터는 읽지 않아요." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Needed to include heart rate and active energy in workouts recorded on the watch. No other health data is read." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Needed to include heart rate and active energy in workouts recorded on the watch. No other health data is read." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ウォッチでのワークアウト時に心拍数とアクティブエネルギーを記録に含めるために必要です。それ以外のヘルスケアデータは読み取りません。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウォッチでのワークアウト時に心拍数とアクティブエネルギーを記録に含めるために必要です。それ以外のヘルスケアデータは読み取りません。" } } } }, - "NSHealthUpdateUsageDescription": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Used to save your finished Kegel workouts to Apple Health." + "NSHealthUpdateUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Used to save your finished Kegel workouts to Apple Health." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요." + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요." } } } } }, - "version": "1.0" + "version" : "1.0" } \ No newline at end of file diff --git a/myApp/Keging/IOS/LiveActivityManager.swift b/myApp/Keging/IOS/LiveActivityManager.swift index 6477e92..65e5166 100644 --- a/myApp/Keging/IOS/LiveActivityManager.swift +++ b/myApp/Keging/IOS/LiveActivityManager.swift @@ -2,7 +2,10 @@ // LiveActivityManager.swift // Keging // -// 다이나믹 아일랜드 Live Activity — 현재 횟수·수축/이완 국면 표시 +// 다이나믹 아일랜드 Live Activity — 현재 횟수·수축/이완 국면 표시. +// 국면 전환 콜백 + 하트비트(2초)가 모두 sync를 부르지만 상태가 실제로 바뀐 경우에만 +// 갱신을 보낸다. staleDate를 국면 종료+8초로 걸어, 앱이 정지해 갱신이 끊기면 +// 시스템이 stale로 표시(위젯 쪽에서 흐리게 처리)하게 한다. // import ActivityKit @@ -13,6 +16,7 @@ final class LiveActivityManager { static let shared = LiveActivityManager() private var activity: Activity? + private var lastState: KegingActivityAttributes.ContentState? private init() {} @@ -23,26 +27,41 @@ final class LiveActivityManager { Task { await stale.end(nil, dismissalPolicy: .immediate) } } activity = nil + lastState = nil } - /// 없으면 시작, 있으면 갱신 — 국면 전환마다 이 하나만 호출 - func sync(phase: KegelPhase, rep: Int, totalReps: Int, phaseEnd: Date) { + /// 없으면 시작, 있으면 갱신 — 같은 상태면 아무것도 하지 않아 하트비트에서 불러도 안전 + func sync(phase: KegelPhase, rep: Int, totalReps: Int, phaseStart: Date, phaseEnd: Date) { let state = KegingActivityAttributes.ContentState( phase: phase, rep: rep, totalReps: totalReps, - phaseStart: Date(), phaseEnd: phaseEnd + phaseStart: phaseStart, phaseEnd: phaseEnd ) + let content = ActivityContent(state: state, staleDate: phaseEnd.addingTimeInterval(8)) if let activity { - Task { await activity.update(ActivityContent(state: state, staleDate: nil)) } + guard state != lastState else { return } + lastState = state + Task { await activity.update(content) } } else { guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + lastState = state activity = try? Activity.request( attributes: KegingActivityAttributes(startedAt: Date()), - content: ActivityContent(state: state, staleDate: nil) + content: content ) } } + /// 하트비트용 — 엔진의 현재 상태를 그대로 반영 (놓친 국면 전환 자가 치유) + func syncCurrent() { + guard case let .running(phase, rep, phaseStart, phaseEnd) = KegelEngine.shared.state else { return } + sync( + phase: phase, rep: rep, totalReps: KegelEngine.shared.config.reps, + phaseStart: phaseStart, phaseEnd: phaseEnd + ) + } + func end() { + lastState = nil guard let activity else { return } self.activity = nil Task { await activity.end(nil, dismissalPolicy: .immediate) } diff --git a/myApp/Keging/IOS/Localizable.xcstrings b/myApp/Keging/IOS/Localizable.xcstrings index e462987..1d65949 100644 --- a/myApp/Keging/IOS/Localizable.xcstrings +++ b/myApp/Keging/IOS/Localizable.xcstrings @@ -1,1808 +1,1872 @@ { - "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": "Other" + "기준선" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baseline" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "その他" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "基準線" } } } }, - "날짜": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Date" + "기타" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Other" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "日付" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "その他" } } } }, - "다이나믹 아일랜드": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Dynamic Island" + "날짜" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Dynamic Island" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日付" } } } }, - "다크": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Dark" + "다이나믹 아일랜드" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ダーク" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dynamic Island" } } } }, - "단축어 앱에서 '케겔 운동 시작' 동작을 추가할 수 있어요. 실행하면 케깅이 열리면서 저장된 세팅대로 바로 시작돼요. 홈 화면 아이콘·잠금화면 위젯·뒷면 탭 등 단축어를 부를 수 있는 곳이라면 어디서든 쓸 수 있어요.": { - "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" : "Dark" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ショートカットアプリで「ケーゲル運動を開始」アクションを追加できます。実行するとKegingが開き、保存した設定ですぐに始まります。ホーム画面アイコン・ロック画面ウィジェット・背面タップなど、ショートカットを呼べる場所ならどこでも使えます。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダーク" } } } }, - "단축어와 시리": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Shortcuts & Siri" + "단축어 앱에서 '케겔 운동 시작' 동작을 추가할 수 있어요. 실행하면 케깅이 열리면서 저장된 세팅대로 바로 시작돼요. 홈 화면 아이콘·잠금화면 위젯·뒷면 탭 등 단축어를 부를 수 있는 곳이라면 어디서든 쓸 수 있어요." : { + "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": "ショートカットとSiri" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ショートカットアプリで「ケーゲル運動を開始」アクションを追加できます。実行するとKegingが開き、保存した設定ですぐに始まります。ホーム画面アイコン・ロック画面ウィジェット・背面タップなど、ショートカットを呼べる場所ならどこでも使えます。" } } } }, - "데이터와 개인정보": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Data & Privacy" + "단축어와 시리" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shortcuts & Siri" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "データとプライバシー" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ショートカットとSiri" } } } }, - "도움말": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Help" + "데이터와 개인정보" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data & Privacy" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ヘルプ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "データとプライバシー" } } } }, - "라이트": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Light" + "도움말" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Help" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ライト" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ヘルプ" } } } }, - "바로 시작하기": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Getting Started" + "라이트" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Light" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "すぐに始める" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ライト" } } } }, - "버전": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Version" + "바로 시작하기" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Getting Started" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "バージョン" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すぐに始める" } } } }, - "범위": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Range" + "버전" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "範囲" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "バージョン" } } } }, - "보통": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Moderate" + "범위" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Range" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "普通" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "範囲" } } } }, - "사이클": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cycle" + "보통" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moderate" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "サイクル" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "普通" } } } }, - "설정": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Settings" + "사이클" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cycle" } }, - "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" } }, - "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" : "Settings & More" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "保存した締める・ゆるめるサイクルで、ケーゲル運動をすぐに開始します。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定・その他" } } } }, - "세트": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Sets" + "설정해 둔 수축·이완 사이클로 케겔 운동을 바로 시작해요." : { + "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": "Squeeze" + "세트" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sets" } }, - "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" + "수축" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Squeeze" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "締める%lld秒 · ゆるめる%lld秒 · %lld回" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "締める" + } + } + } + }, + "수축 %lld초 · 이완 %lld초 · %lld회" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Squeeze %lld s · Relax %lld s · ×%lld" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "締める%lld秒 · ゆるめる%lld秒 · %lld回" + } + }, + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회" + } + } + } + }, + "수축 진동" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Squeeze Haptic" } }, - "ko": { - "stringUnit": { - "state": "new", - "value": "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "締めるときの振動" } } } }, - "수축 진동": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "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": "Preview Squeeze Haptic" + "수축과 이완의 진동이 달라 화면을 보지 않아도 구분할 수 있어요. 진동은 실기기에서만 느껴져요." : { + "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": "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" : "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 the same patterns, and haptics can only be felt on a real device." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "締めるときとゆるめるときで振動が異なるので、画面を見なくても区別できます。振動は実機でのみ感じられます。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "締め始めとゆるめ始めで異なる振動が鳴るので、画面を見なくても今どちらなのか分かります。デフォルトは締める=とても長いブーッ、ゆるめる=短い標準の1回なので、最初の瞬間の感触ですぐ区別できます。パターンはワークアウト設定でそれぞれ選べて(標準・トントン・ブーッ)、「試す」ボタンで実際に確認できます。セットを最後までやり遂げると、とても長いお祝いの振動が鳴ります。画面がオフのときや他のアプリを使っているときはシステム振動で代わりに鳴るため、感触が少し異なることがあります。ウォッチでも同じパターンで振動し、振動は実機でのみ感じられます。" } } } }, - "수축이 시작될 때와 이완이 시작될 때 서로 다른 진동이 울려요. 그래서 화면을 보지 않아도 지금 조여야 할지 풀어야 할지 알 수 있어요. 기본값은 수축=지이이잉(아주 긴 진동), 이완=기본(짧은 한 번)이라 첫 순간의 질감으로 바로 구분돼요. 패턴은 운동 세팅에서 국면별로 고를 수 있고(기본·톡톡·지이이잉), '미리 느끼기' 버튼으로 실제 진동을 확인할 수 있어요. 워치에서도 같은 패턴으로 울려요. 진동은 실기기에서만 느껴져요.": { - "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. The watch uses the same patterns. Haptics can only be felt on a real device." + "쉬움" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Easy" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "締め始めとゆるめ始めで異なる振動が鳴るので、画面を見なくても今どちらなのか分かります。デフォルトは締める=とても長いブーッ、ゆるめる=短い標準の1回なので、最初の瞬間の感触ですぐ区別できます。パターンはワークアウト設定でそれぞれ選べて(標準・トントン・ブーッ)、「試す」ボタンで実際に確認できます。ウォッチでも同じパターンで振動します。振動は実機でのみ感じられます。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "楽" } } } }, - "쉬움": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Easy" + "시" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hour" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "楽" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "時" } } } }, - "시": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Hour" + "시각" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "時" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "時刻" } } } }, - "시간대별 횟수": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reps by Hour" + "시간대별 횟수" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reps by Hour" } }, - "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$@." + } + } + } + }, + "안 함" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Off" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "なし" + } + } + } + }, + "애플 건강" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Health" } }, - "ko": { - "stringUnit": { - "state": "new", - "value": "시작 버튼을 누르면 준비 시간 뒤에 수축 → 이완을 %1$lld회 반복해요. 한 세트 약 %2$@." + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ヘルスケア" } } } }, - "안 함": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Off" + "애플 건강 연동" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Health Integration" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "なし" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ヘルスケア連携" } } } }, - "애플 건강": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Apple Health" + "애플워치" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Watch" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ヘルスケア" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Watch" } } } }, - "애플 건강 연동": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Apple Health Integration" + "애플워치에서 운동하기" : { + "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": "Apple Watch" + "앱 밖에서" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Outside the App" } }, - "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" : "Language" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Apple Watchでワークアウト" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "言語" } } } }, - "앱 밖에서": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Outside the App" + "언어를 바꾸면 앱을 완전히 종료했다가 다시 실행했을 때 적용돼요." : { + "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": "Language" + "오늘 기록" : { + "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": "The new language takes effect after you fully quit and relaunch the app." + "오늘은 아직 기록이 없어요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No sessions yet today." } }, - "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" : "Today's Timeline" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "今日の記録" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日のタイムライン" } } } }, - "오늘은 아직 기록이 없어요.": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No sessions yet today." + "오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 준비 시간(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分です。開始ボタンの下のカードを押してもこの画面が開きます。" } } } }, - "오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 준비 시간(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" : "Done" } }, - "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" + "완료!" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done!" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "完了" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "完了!" } } } }, - "완료!": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Done!" + "왼쪽 위 톱니바퀴 버튼이 설정이에요. 화면 모드(시스템·라이트·다크)와 언어(시스템·한국어·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・日本語)を選べます。言語はアプリを完全に終了して再起動すると適用されます。ウォッチはウォッチのシステム言語に従います。" } } } }, - "왼쪽 위 톱니바퀴 버튼이 설정이에요. 화면 모드(시스템·라이트·다크)와 언어(시스템·한국어·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" : "Auto Effort Rating" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "左上の歯車ボタンが設定です。画面モード(システム・ライト・ダーク)と言語(システム・한국어・English・日本語)を選べます。言語はアプリを完全に終了して再起動すると適用されます。ウォッチはウォッチのシステム言語に従います。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "運動強度の自動記録" } } } }, - "운동 강도 자동 기록": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Auto Effort Rating" + "운동 세팅" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Workout Setup" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "運動強度の自動記録" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ワークアウト設定" } } } }, - "운동 세팅": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Workout Setup" + "운동 세팅 바꾸기" : { + "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": "Changing Your Setup" + "운동 세팅 열기 — 현재 %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open workout setup — currently %@" } }, - "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" : "Workout settings can only be changed on the iPhone, and changes are sent to the watch automatically. The watch also fetches the latest settings from the iPhone every time you open the watch app, so there's nothing to keep in sync yourself. Sets finished on the watch 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と離れていても、記録はたまって接続時に自動送信されます。" } } } }, - "운동 세팅은 아이폰에서만 바꿀 수 있고, 바꾸면 워치에 자동으로 전달돼요. 워치에서 끝까지 마친 세트는 아이폰으로 넘어와 통계에 합쳐져요. 워치가 아이폰과 떨어져 있어도 기록은 쌓였다가 연결되면 자동으로 전송돼요.": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Workout settings can only be changed on the iPhone, and changes are sent to the watch automatically. Sets finished on the watch 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." + "운동 유형" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Workout Type" } }, - "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" : "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": "ワークアウトの種類" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ワークアウト中に下の中断ボタンを押して確認すると、セットが終了します。中断したセットは最初からなかったものとして扱われ、記録や統計には残りません。最後までやり切ったセットだけが記録されます。" } } } }, - "운동 중 아래의 중단 버튼을 누르면 확인 후 세트가 끝나요. 중단한 세트는 아예 하지 않은 것으로 처리돼서 기록과 통계에 남지 않아요. 끝까지 마친 세트만 기록돼요.": { - "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" : "If you leave the app during a workout, the Dynamic Island shows your current rep and whether to squeeze or relax. Press and hold it for a larger view with the remaining time and a progress bar. Vibrations keep playing, so you can continue while using other apps." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ワークアウト中に下の中断ボタンを押して確認すると、セットが終了します。中断したセットは最初からなかったものとして扱われ、記録や統計には残りません。最後までやり切ったセットだけが記録されます。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ワークアウト中にアプリを離れると、Dynamic Islandに現在の回数と締める・ゆるめるの状態が表示されます。長押しすると残り時間と進行バーが見える大きな画面が開きます。振動も鳴り続けるので、他のアプリを使いながら続けられます。" } } } }, - "운동 중에 앱을 나가면 다이나믹 아일랜드에 현재 몇 번째인지와 수축·이완 상태가 떠요. 길게 누르면 남은 시간과 진행 바가 보이는 큰 화면이 열려요. 진동도 계속 울리니까 다른 앱을 쓰면서도 운동을 이어갈 수 있어요.": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "If you leave the app during a workout, the Dynamic Island shows your current rep and whether to squeeze or relax. Press and hold it for a larger view with the remaining time and a progress bar. Vibrations keep playing, so you can continue while using other apps." + "운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(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." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ワークアウト中にアプリを離れると、Dynamic Islandに現在の回数と締める・ゆるめるの状態が表示されます。長押しすると残り時間と進行バーが見える大きな画面が開きます。振動も鳴り続けるので、他のアプリを使いながら続けられます。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セットを最後まで終えると、その時間がヘルスケアにワークアウトとして自動記録されます。ワークアウトの種類(その他・コアトレーニング)と運動強度(1〜10)は設定で選べます。初回開始時にヘルスケアの許可を求められ、許可すればその後は自動で保存されます。ウォッチで行うと心拍数を実測して一緒に記録し、iPhoneで行うとApple Watchの「その他」ワークアウトと同じ方式(早歩き相当)でカロリーを推定します — そのため両者のカロリーは少し異なることがあります。中断したセットはヘルスケアにも記録されず、記録したくない場合はヘルスケアアプリでKegingのアクセスをいつでもオフにできます。" } } } }, - "운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(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 started on the watch are recorded by the watch, including heart rate. Stopped sets are not saved to Health either, and you can turn off Keging's access in the Health app anytime." + "운동을 끝까지 마치면 그 시간이 위에서 고른 유형으로 애플 건강에 자동 기록되고, 운동 강도(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": "セットを最後まで終えると、その時間がヘルスケアにワークアウトとして自動記録されます。ワークアウトの種類(その他・コアトレーニング)と運動強度(1〜10)は設定で選べます。初回開始時にヘルスケアの許可を求められ、許可すればその後は自動で保存されます。ウォッチから実行した場合は、心拍数とともにウォッチが記録します。中断したセットはヘルスケアにも記録されず、記録したくない場合はヘルスケアアプリでKegingのアクセスをいつでもオフにできます。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セットを最後まで終えると、その時間が上で選んだ種類のワークアウトとしてヘルスケアに自動記録され、運動強度(1〜10)も一緒に保存されます。記録したくない場合は、ヘルスケアアプリでKegingのアクセスをオフにしてください。" } } } }, - "운동을 끝까지 마치면 그 시간이 위에서 고른 유형으로 애플 건강에 자동 기록되고, 운동 강도(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." + "워치 앱을 열면 시작 버튼 하나만 있어요. 누르면 바로 시작되고, 화면에 수축·이완 상태와 남은 시간, 몇 번째인지가 한눈에 보여요. 워치에서 실행하면 진동은 워치에서만 울려요. 중단 버튼을 누르면 그 세트는 기록되지 않아요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open the watch app and there's just one Start button. Tap it to begin immediately — the screen shows the current phase, the remaining time, and which rep you're on, all at a glance. When you start from the watch, vibrations play only on the watch. Tapping Stop discards that set." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "セットを最後まで終えると、その時間が上で選んだ種類のワークアウトとしてヘルスケアに自動記録され、運動強度(1〜10)も一緒に保存されます。記録したくない場合は、ヘルスケアアプリでKegingのアクセスをオフにしてください。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウォッチアプリを開くと開始ボタンが1つだけあります。押すとすぐに始まり、画面に締める・ゆるめるの状態、残り時間、何回目かが一目で分かります。ウォッチから実行すると振動はウォッチだけで鳴ります。中断するとそのセットは記録されません。" } } } }, - "워치 앱을 열면 시작 버튼 하나만 있어요. 누르면 바로 시작되고, 화면에 수축·이완 상태와 남은 시간, 몇 번째인지가 한눈에 보여요. 워치에서 실행하면 진동은 워치에서만 울려요. 중단 버튼을 누르면 그 세트는 기록되지 않아요.": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open the watch app and there's just one Start button. Tap it to begin immediately — the screen shows the current phase, the remaining time, and which rep you're on, all at a glance. When you start from the watch, vibrations play only on the watch. Tapping Stop discards that set." + "워치와 아이폰 동기화" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Watch & iPhone Sync" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ウォッチアプリを開くと開始ボタンが1つだけあります。押すとすぐに始まり、画面に締める・ゆるめるの状態、残り時間、何回目かが一目で分かります。ウォッチから実行すると振動はウォッチだけで鳴ります。中断するとそのセットは記録されません。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウォッチとiPhoneの同期" } } } }, - "워치와 아이폰 동기화": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Watch & iPhone Sync" + "월간" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monthly" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ウォッチとiPhoneの同期" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "月間" } } } }, - "월간": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Monthly" + "이 기간에는 아직 기록이 없어요." : { + "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": "No sessions in this period yet." + "이번 달" : { + "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 Month" + "이번 주" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This Week" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "今月" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今週" } } } }, - "이번 주": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "This Week" + "이번 주 ↔ 지난 7일 전환" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This Week ↔ Last 7 Days" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "今週" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今週 ↔ 過去7日の切り替え" } } } }, - "이번 주 ↔ 지난 7일 전환": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "This Week ↔ Last 7 Days" + "이완" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relax" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "今週 ↔ 過去7日の切り替え" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ゆるめる" } } } }, - "이완": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Relax" + "이완 진동" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relax Haptic" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ゆるめる" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ゆるめるときの振動" } } } }, - "이완 진동": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "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": "Preview Relax Haptic" + "일별 횟수" : { + "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": "Reps by Day" + "전력" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All Out" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "日別の回数" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "全力" } } } }, - "전력": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "All Out" + "주간" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weekly" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "全力" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "週間" } } } }, - "주간": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Weekly" + "주간·월간 화면 위의 스위치로 두 가지 방식을 오갈 수 있어요. '이번 주 / 이번 달'은 달력 기준(월요일~일요일, 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": "週間" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "週間・月間画面の上のスイッチで2つの方式を切り替えられます。「今週/今月」はカレンダー基準(月〜日、1日〜末日)、「過去7日/過去30日」は今日からさかのぼって数える方式です。" } } } }, - "주간·월간 화면 위의 스위치로 두 가지 방식을 오갈 수 있어요. '이번 주 / 이번 달'은 달력 기준(월요일~일요일, 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." + "준비" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ready" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "週間・月間画面の上のスイッチで2つの方式を切り替えられます。「今週/今月」はカレンダー基準(月〜日、1日〜末日)、「過去7日/過去30日」は今日からさかのぼって数える方式です。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "準備" } } } }, - "준비": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Ready" + "중간에 중단하면" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stopping Midway" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "準備" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "途中で中断すると" } } } }, - "중간에 중단하면": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Stopping Midway" + "중단" : { + "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": "Stop" + "중단하면 이 세트는 기록되지 않아요." : { + "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": "If you stop, this set won't be recorded." + "지금" : { + "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." + "첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요." : { + "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." } }, - "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" : "Start Kegel Workout" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ケーゲル運動を開始" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ケーゲル運動を開始" } } } }, - "케깅은 광고와 결제가 없는 완전 무료 앱이에요.": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Keging is completely free — no ads, no purchases." + "케깅은 광고와 결제가 없는 완전 무료 앱이에요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keging is completely free — no ads, no purchases." } }, - "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" : "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": "Core Training" + "코어 트레이닝" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Core Training" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "コアトレーニング" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コアトレーニング" } } } }, - "테마": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Theme" + "테마" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Theme" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "テーマ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "テーマ" } } } }, - "테마와 언어": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Theme & Language" + "테마와 언어" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Theme & Language" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "テーマと言語" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "テーマと言語" } } } }, - "톡톡": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Double tap" + "톡톡" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double tap" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "トントン" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "トントン" } } } }, - "통계": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Statistics" + "통계" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "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" : "Close 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" : "Show 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" : "Opening Statistics" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "統計を開く" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "統計を開く" } } } }, - "하루": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Day" + "하루" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Day" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "1日" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "1日" } } } }, - "하루 · 주간 · 월간": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Day · Weekly · Monthly" + "하루 · 주간 · 월간" : { + "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": "The Day tab shows what time you worked out today and how many reps, as a list and an hourly line chart. The Weekly and Monthly tabs show daily reps as a line chart with totals (sets and reps). Weeks start on Monday." + "하루 탭 맨 위의 타임라인에서 오늘 언제 운동했는지 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時間の軸上の点でひと目で確認できます(点が大きいほど回数の多いセット)。その下に時間帯別の折れ線グラフと今日の記録リストが続きます。週間・月間タブでは日別の回数を折れ線グラフと合計(セット・回数)で表示します。週は月曜日から始まります。" } } } }, - "현재 %lld번째, 전체 %lld회": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Rep %lld of %lld" + "현재 %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 bf9c41a..57a9b18 100644 --- a/myApp/Keging/IOS/PhoneSync.swift +++ b/myApp/Keging/IOS/PhoneSync.swift @@ -43,6 +43,26 @@ final class PhoneSync: NSObject, WCSessionDelegate { session.activate() } + /// 워치가 닿게 되면 최신 세팅을 재푸시 — 변경 시점에 전송이 유실됐어도 여기서 복구 + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + guard session.isReachable else { return } + Task { @MainActor in + PhoneSync.shared.pushConfig(SettingsStore.shared.config) + } + } + + /// 워치 앱이 열릴 때 보내는 세팅 요청 — 폰 앱이 꺼져 있어도 시스템이 깨워서 응답한다 + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + guard message["request"] as? String == "config" else { + replyHandler([:]) + return + } + Task { @MainActor in + let data = (try? JSONEncoder().encode(SettingsStore.shared.config)) ?? Data() + replyHandler(["config": data]) + } + } + 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 } diff --git a/myApp/Keging/IOS/StatsView.swift b/myApp/Keging/IOS/StatsView.swift index f942737..0456513 100644 --- a/myApp/Keging/IOS/StatsView.swift +++ b/myApp/Keging/IOS/StatsView.swift @@ -3,7 +3,8 @@ // Keging // // 통계 — 하루·주간·월간, 꺾은선 그래프. 주간·월간은 달력 기준 ↔ 오늘 기준 롤링 전환. -// 첫 화면에서 아래로 스와이프해 열리고, 위로 스와이프하거나 X로 닫는다. +// 하루 탭 맨 위에는 '오늘 언제 했는지' 한눈에 보이는 타임라인(24시간 점 그래프). +// 첫 화면에서 위로 스와이프해 올라오고, 아래로 스와이프하거나 X로 닫는다. // import SwiftUI @@ -56,6 +57,7 @@ struct StatsView: View { ScrollView { VStack(spacing: 14) { + if scope == .day, !todaySessions.isEmpty { timelineCard } summaryCard chartCard if scope == .day { sessionListCard } @@ -185,6 +187,68 @@ struct StatsView: View { // MARK: 카드 + private var todaySessions: [SessionRecord] { + records(on: calendar.startOfDay(for: Date())).sorted { $0.startedAt < $1.startedAt } + } + + /// 오늘 언제 운동했는지 한눈에 — 24시간 축 위의 점 타임라인 (점 크기 = 횟수) + private var timelineCard: some View { + let sessions = todaySessions + let dayStart = calendar.startOfDay(for: Date()) + let dayEnd = dayStart.addingTimeInterval(24 * 3600) + let showTimeLabels = sessions.count <= 5 + return VStack(alignment: .leading, spacing: 10) { + Text("오늘의 타임라인") + .font(.headline) + Chart { + RuleMark(y: .value("기준선", 0)) + .foregroundStyle(AppTheme.green.opacity(0.18)) + .lineStyle(StrokeStyle(lineWidth: 4, lineCap: .round)) + RuleMark(x: .value("지금", Date())) + .foregroundStyle(.secondary.opacity(0.45)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + .annotation(position: .top, spacing: 2) { + Text("지금") + .font(.caption2) + .foregroundStyle(.secondary) + } + ForEach(sessions) { record in + PointMark( + x: .value("시각", record.startedAt), + y: .value("기준선", 0) + ) + .symbolSize(140 + Double(min(record.reps, 60)) * 3) + .foregroundStyle(AppTheme.green) + .annotation(position: .top, spacing: 5) { + if showTimeLabels { + Text(record.startedAt, format: .dateTime.hour().minute()) + .font(.caption2.weight(.medium)) + .foregroundStyle(AppTheme.green) + } + } + } + } + .chartXScale(domain: dayStart.addingTimeInterval(-1200)...dayEnd.addingTimeInterval(1200)) + .chartYScale(domain: -1...1) + .chartYAxis(.hidden) + .chartXAxis { + AxisMarks(values: [0, 6, 12, 18, 24].map { dayStart.addingTimeInterval(Double($0) * 3600) }) { value in + AxisGridLine() + AxisValueLabel { + if let date = value.as(Date.self) { + let hour = Int((date.timeIntervalSince(dayStart) / 3600).rounded()) + Text("\(hour)시") + } + } + } + } + .frame(height: 96) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16)) + } + private var summaryCard: some View { HStack(spacing: 0) { summaryItem(value: "\(totalSets)", label: Text("세트")) diff --git a/myApp/Keging/IOS/WorkoutCoordinator.swift b/myApp/Keging/IOS/WorkoutCoordinator.swift index ee01726..ac41120 100644 --- a/myApp/Keging/IOS/WorkoutCoordinator.swift +++ b/myApp/Keging/IOS/WorkoutCoordinator.swift @@ -2,7 +2,9 @@ // WorkoutCoordinator.swift // Keging // -// 엔진 ↔ 진동·Live Activity·백그라운드 오디오·화면 꺼짐 방지 배선 (iOS) +// 엔진 ↔ 진동·Live Activity·백그라운드 오디오·화면 꺼짐 방지 배선 (iOS). +// 운동 중에는 2초 하트비트로 오디오 생존·엔진 재동기화·Live Activity를 지속 점검한다 +// (국면 경계 타이머가 한 번 놓쳐도 2초 안에 자가 치유 — 실기기 DI 멈춤의 3중 방어). // import UIKit @@ -12,6 +14,9 @@ final class WorkoutCoordinator { static let shared = WorkoutCoordinator() private var wired = false + private var heartbeat: Timer? + /// 완주 진동(백그라운드 체인 ~2.7초)이 끝나기 전에 오디오 세션을 닫지 않기 위한 지연 정지 + private var pendingAudioStop: Task? private init() {} @@ -22,7 +27,7 @@ final class WorkoutCoordinator { LiveActivityManager.shared.cleanupStaleActivities() let engine = KegelEngine.shared - engine.onPhaseChange = { [weak engine] phase, rep, phaseEnd in + engine.onPhaseChange = { [weak engine] phase, rep, phaseStart, phaseEnd in guard let engine else { return } let config = engine.config switch phase { @@ -30,12 +35,21 @@ final class WorkoutCoordinator { case .contract: Haptics.play(config.contractPattern) case .relax: Haptics.play(config.relaxPattern) } - LiveActivityManager.shared.sync(phase: phase, rep: rep, totalReps: config.reps, phaseEnd: phaseEnd) + LiveActivityManager.shared.sync( + phase: phase, rep: rep, totalReps: config.reps, + phaseStart: phaseStart, phaseEnd: phaseEnd + ) } - engine.onSessionEnd = { completed, record in + engine.onSessionEnd = { [weak self] completed, record in + self?.stopHeartbeat() LiveActivityManager.shared.end() - BackgroundAudioKeeper.shared.stop() UIApplication.shared.isIdleTimerDisabled = false + if completed { + Haptics.playFinish() + self?.stopAudioSoon() + } else { + BackgroundAudioKeeper.shared.stop() + } if completed, let record { let config = SettingsStore.shared.config HealthRecorder.shared.save( @@ -49,9 +63,40 @@ final class WorkoutCoordinator { func startWorkout() { 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) + startHeartbeat() + } + + // MARK: 하트비트 + + private func startHeartbeat() { + heartbeat?.invalidate() + heartbeat = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in + Task { @MainActor in + guard KegelEngine.shared.isRunning else { return } + BackgroundAudioKeeper.shared.ensureAlive() + KegelEngine.shared.resyncAfterWake() + LiveActivityManager.shared.syncCurrent() + } + } + } + + private func stopHeartbeat() { + heartbeat?.invalidate() + heartbeat = nil + } + + private func stopAudioSoon() { + pendingAudioStop?.cancel() + pendingAudioStop = Task { @MainActor in + try? await Task.sleep(for: .seconds(4)) + guard !Task.isCancelled, !KegelEngine.shared.isRunning else { return } + BackgroundAudioKeeper.shared.stop() + } } } diff --git a/myApp/Keging/KegingWatch Watch App/ContentView.swift b/myApp/Keging/KegingWatch Watch App/ContentView.swift index 0848e07..26733cc 100644 --- a/myApp/Keging/KegingWatch Watch App/ContentView.swift +++ b/myApp/Keging/KegingWatch Watch App/ContentView.swift @@ -10,6 +10,7 @@ import SwiftUI struct WatchContentView: View { @EnvironmentObject private var engine: KegelEngine + @Environment(\.scenePhase) private var scenePhase var body: some View { ZStack { @@ -23,7 +24,14 @@ struct WatchContentView: View { finishedScreen(reps: reps) } } + .onChange(of: scenePhase) { _, newPhase in + guard newPhase == .active else { return } + // 손목을 올려 앱이 다시 보일 때 — 국면 재정렬 + 폰에서 최신 세팅 당겨오기 + engine.resyncAfterWake() + WatchSync.shared.requestConfigFromPhone() + } .onAppear { + WatchSync.shared.requestConfigFromPhone() #if DEBUG if CommandLine.arguments.contains("-autoStart") { WatchCoordinator.shared.startWorkout() } #endif diff --git a/myApp/Keging/KegingWatch Watch App/InfoPlist.xcstrings b/myApp/Keging/KegingWatch Watch App/InfoPlist.xcstrings index d094961..8c1d14a 100644 --- a/myApp/Keging/KegingWatch Watch App/InfoPlist.xcstrings +++ b/myApp/Keging/KegingWatch Watch App/InfoPlist.xcstrings @@ -1,87 +1,99 @@ { - "sourceLanguage": "ko", - "strings": { - "CFBundleDisplayName": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Keging" + "sourceLanguage" : "ko", + "strings" : { + "CFBundleDisplayName" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keging" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Keging" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keging" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "케깅" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "케깅" } } } }, - "CFBundleName": { - "comment": "Bundle name", - "extractionState": "extracted_with_value", - "localizations": { - "ko": { - "stringUnit": { - "state": "new", - "value": "KegingWatch Watch App" + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "KegingWatch Watch App" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "KegingWatch Watch App" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "KegingWatch Watch App" } } } }, - "NSHealthShareUsageDescription": { - "extractionState": "manual", - "localizations": { - "ko": { - "stringUnit": { - "state": "translated", - "value": "워치에서 운동할 때 심박수와 활성 에너지를 운동 기록에 담기 위해 필요해요. 그 외 건강 데이터는 읽지 않아요." + "NSHealthShareUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "워치에서 운동할 때 심박수와 활성 에너지를 운동 기록에 담기 위해 필요해요. 그 외 건강 데이터는 읽지 않아요." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Needed to include heart rate and active energy in workouts recorded on the watch. No other health data is read." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Needed to include heart rate and active energy in workouts recorded on the watch. No other health data is read." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ウォッチでのワークアウト時に心拍数とアクティブエネルギーを記録に含めるために必要です。それ以外のヘルスケアデータは読み取りません。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウォッチでのワークアウト時に心拍数とアクティブエネルギーを記録に含めるために必要です。それ以外のヘルスケアデータは読み取りません。" } } } }, - "NSHealthUpdateUsageDescription": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Used to save your finished Kegel workouts to Apple Health." + "NSHealthUpdateUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Used to save your finished Kegel workouts to Apple Health." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요." + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요." } } } } }, - "version": "1.0" + "version" : "1.0" } \ No newline at end of file diff --git a/myApp/Keging/KegingWatch Watch App/WatchCoordinator.swift b/myApp/Keging/KegingWatch Watch App/WatchCoordinator.swift index 18cf9a2..4b8ae9b 100644 --- a/myApp/Keging/KegingWatch Watch App/WatchCoordinator.swift +++ b/myApp/Keging/KegingWatch Watch App/WatchCoordinator.swift @@ -20,7 +20,7 @@ final class WatchCoordinator { wired = true let engine = KegelEngine.shared - engine.onPhaseChange = { [weak engine] phase, _, _ in + engine.onPhaseChange = { [weak engine] phase, _, _, _ in guard let engine else { return } let config = engine.config switch phase { @@ -33,7 +33,7 @@ final class WatchCoordinator { WatchWorkoutSession.shared.end(completed: completed, effortScore: SettingsStore.shared.config.effortScore) if completed, let record { WatchSync.shared.sendRecord(record) - WatchHaptics.playSuccess() + WatchHaptics.playFinish() } } } diff --git a/myApp/Keging/KegingWatch Watch App/WatchHaptics.swift b/myApp/Keging/KegingWatch Watch App/WatchHaptics.swift index 0ced28a..2130eec 100644 --- a/myApp/Keging/KegingWatch Watch App/WatchHaptics.swift +++ b/myApp/Keging/KegingWatch Watch App/WatchHaptics.swift @@ -21,7 +21,15 @@ enum WatchHaptics { WKInterfaceDevice.current().play(type) } - static func playSuccess() { - WKInterfaceDevice.current().play(.success) + /// 완주 축하 진동 — 길게 이어지는 느낌으로 .retry 연타 후 .success 마무리 (~2.3초). + /// 시스템 햅틱은 겹치면 삼켜지므로 0.75초 간격으로 잇는다 + static func playFinish() { + WKInterfaceDevice.current().play(.retry) + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(750)) + WKInterfaceDevice.current().play(.retry) + try? await Task.sleep(for: .milliseconds(750)) + WKInterfaceDevice.current().play(.success) + } } } diff --git a/myApp/Keging/KegingWatch Watch App/WatchSync.swift b/myApp/Keging/KegingWatch Watch App/WatchSync.swift index c834792..ad41fd7 100644 --- a/myApp/Keging/KegingWatch Watch App/WatchSync.swift +++ b/myApp/Keging/KegingWatch Watch App/WatchSync.swift @@ -26,6 +26,19 @@ final class WatchSync: NSObject, WCSessionDelegate { WCSession.default.transferUserInfo(["record": data]) } + /// 폰에 최신 세팅을 요청 — 워치 앱이 열릴 때마다 호출 (폰 앱이 꺼져 있어도 시스템이 깨움). + /// 실패(폰이 안 닿음 등)는 무시 — applicationContext 경로가 커버한다 + func requestConfigFromPhone() { + guard WCSession.isSupported() else { return } + let session = WCSession.default + guard session.activationState == .activated else { return } + session.sendMessage( + ["request": "config"], + replyHandler: { [weak self] reply in self?.applyConfig(from: reply) }, + errorHandler: { _ in } + ) + } + // MARK: WCSessionDelegate nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { diff --git a/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift b/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift index 318676b..73facea 100644 --- a/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift +++ b/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift @@ -12,7 +12,7 @@ import SwiftUI struct KegingLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: KegingActivityAttributes.self) { context in - // 잠금화면·배너 + // 잠금화면·배너 — isStale이면(앱 정지로 갱신 끊김) 국면 정보를 흐리게 HStack(spacing: 12) { phaseIcon(context.state) VStack(alignment: .leading, spacing: 2) { @@ -24,6 +24,7 @@ struct KegingLiveActivity: Widget { .monospacedDigit() .foregroundStyle(.secondary) } + .opacity(context.isStale ? 0.4 : 1) Spacer() Text(timerInterval: context.state.phaseStart...context.state.phaseEnd, countsDown: true) .font(.title2.weight(.semibold)) @@ -41,6 +42,7 @@ struct KegingLiveActivity: Widget { .font(.headline) .foregroundStyle(phaseColor(context.state)) } + .opacity(context.isStale ? 0.4 : 1) .padding(.leading, 4) } DynamicIslandExpandedRegion(.trailing) { @@ -61,13 +63,16 @@ struct KegingLiveActivity: Widget { } } compactLeading: { phaseIcon(context.state) + .opacity(context.isStale ? 0.4 : 1) } compactTrailing: { Text("\(context.state.rep)/\(context.state.totalReps)") .font(.caption2) .monospacedDigit() .foregroundStyle(phaseColor(context.state)) + .opacity(context.isStale ? 0.4 : 1) } minimal: { phaseIcon(context.state) + .opacity(context.isStale ? 0.4 : 1) } } } diff --git a/myApp/Keging/Shared/HapticEnginePlayer.swift b/myApp/Keging/Shared/HapticEnginePlayer.swift index ddadd3f..95589c7 100644 --- a/myApp/Keging/Shared/HapticEnginePlayer.swift +++ b/myApp/Keging/Shared/HapticEnginePlayer.swift @@ -4,11 +4,15 @@ // // Core Haptics 정밀 패턴 재생 — 아이폰 전용·포그라운드 전용 (watchOS SDK엔 CoreHaptics 없음). // 시스템 바이브로는 불가능한 짧은 간격의 연타와 1초 이상의 연속 진동을 만든다. -// 엔진이 백그라운드 등에서 죽어 있으면 1회 재생성 후 재시도, 그래도 실패면 false -// (호출 측이 시스템 진동으로 폴백). +// +// ⚠️엔진은 반드시 공유 AVAudioSession에 부착해 만든다(CHHapticEngine(audioSession:)). +// 기본 생성하면 햅틱 엔진이 독자 오디오 정책으로 세션을 건드려, 운동 중 백그라운드 유지용 +// 무음 루프가 끊길 수 있다(실기기 3차 사고 — 포그라운드에서 진동 몇 번 후 잠그면 DI 멈춤). +// 엔진이 죽어 있으면 1회 재생성 후 재시도, 그래도 실패면 false(호출 측이 시스템 진동으로 폴백). // #if canImport(CoreHaptics) +import AVFoundation import CoreHaptics @MainActor @@ -21,16 +25,37 @@ final class HapticEnginePlayer { @discardableResult func play(_ pattern: HapticPattern) -> Bool { - guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return false } - if attempt(pattern) { return true } - engine = nil - return attempt(pattern) + play(events: pattern.hapticEvents) } - private func attempt(_ pattern: HapticPattern) -> Bool { + /// 완주 축하 진동 — 아주 긴 "우우우우웅" + @discardableResult + func playFinish() -> Bool { + play(events: FinishHaptic.events) + } + + private func play(events: [(time: Double, duration: Double, intensity: Float, sharpness: Float)]) -> Bool { + guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return false } + ensureSession() + if attempt(events) { return true } + engine = nil + return attempt(events) + } + + /// 공유 세션을 재생 카테고리로 유지 — 운동 중엔 BackgroundAudioKeeper와 같은 설정이라 무해하고, + /// 세팅의 '미리 느끼기'처럼 키퍼가 없는 상황에서도 엔진이 도는 기반이 된다 + private func ensureSession() { + let session = AVAudioSession.sharedInstance() + if session.category != .playback { + try? session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) + } + try? session.setActive(true) + } + + private func attempt(_ specs: [(time: Double, duration: Double, intensity: Float, sharpness: Float)]) -> Bool { do { let engine = try currentEngine() - let events = pattern.hapticEvents.map { spec in + let events = specs.map { spec in CHHapticEvent( eventType: .hapticContinuous, parameters: [ @@ -52,8 +77,11 @@ final class HapticEnginePlayer { private func currentEngine() throws -> CHHapticEngine { if let engine { return engine } - let newEngine = try CHHapticEngine() + let newEngine = try CHHapticEngine(audioSession: AVAudioSession.sharedInstance()) newEngine.isAutoShutdownEnabled = true + newEngine.resetHandler = { [weak self] in + Task { @MainActor in self?.engine = nil } + } engine = newEngine return newEngine } diff --git a/myApp/Keging/Shared/KegelEngine.swift b/myApp/Keging/Shared/KegelEngine.swift index 6c35c9a..0047c46 100644 --- a/myApp/Keging/Shared/KegelEngine.swift +++ b/myApp/Keging/Shared/KegelEngine.swift @@ -48,8 +48,8 @@ final class KegelEngine: ObservableObject { private var sessionStart = Date() private var timer: Timer? - /// 국면 진입마다 호출 (phase, rep, phaseEnd) - var onPhaseChange: ((KegelPhase, Int, Date) -> Void)? + /// 국면 진입마다 호출 (phase, rep, phaseStart, phaseEnd) + var onPhaseChange: ((KegelPhase, Int, Date, Date) -> Void)? /// 세션 종료 (completed, 완주 시 저장된 기록) var onSessionEnd: ((Bool, SessionRecord?) -> Void)? @@ -121,7 +121,7 @@ final class KegelEngine: ObservableObject { return currentPhase != phase || currentRep != rep }() state = .running(phase: phase, rep: rep, phaseStart: phaseStart, phaseEnd: phaseEnd) - if changed { onPhaseChange?(phase, rep, phaseEnd) } + if changed { onPhaseChange?(phase, rep, phaseStart, phaseEnd) } scheduleTick(at: phaseEnd) } } diff --git a/myApp/Keging/Shared/WorkoutSettings.swift b/myApp/Keging/Shared/WorkoutSettings.swift index 5d21d53..2cee9e5 100644 --- a/myApp/Keging/Shared/WorkoutSettings.swift +++ b/myApp/Keging/Shared/WorkoutSettings.swift @@ -49,6 +49,14 @@ nonisolated enum HapticPattern: String, CaseIterable, Identifiable, Codable { } } +/// 완주 축하 진동 — 설정과 무관한 고유 패턴. "아주 길게 우우우우웅" 한 번. +/// (아이폰 포그라운드 Core Haptics 스펙 — 백그라운드·워치의 근사 재생은 각 플랫폼 파일 참고) +nonisolated enum FinishHaptic { + static let events: [(time: Double, duration: Double, intensity: Float, sharpness: Float)] = [ + (0, 2.4, 1.0, 0.3) + ] +} + /// 애플 건강에 저장할 운동 유형 — 취향 선택 (HKWorkoutActivityType 매핑은 HealthKitCalls.swift) nonisolated enum HealthWorkoutType: String, CaseIterable, Identifiable, Codable { case other, coreTraining