fix(keging): 실기기 3차 피드백 6건 — DI 멈춤 근본 원인(햅틱 엔진 세션 충돌) 제거·워치 세팅 pull 동기화·완료 진동·하루 타임라인·칼로리 정합

- CHHapticEngine을 공유 AVAudioSession에 부착(기본 생성 시 독자 오디오 정책이
  백그라운드 무음 루프를 끊던 것이 DI 멈춤 원인) + 오디오 키퍼 하드닝(루트 변경·
  미디어 서버 리셋·ensureAlive) + 운동 중 2초 하트비트(오디오·엔진·LA 자가 치유)
- Live Activity: 실제 phaseStart 사용·동일 상태 no-op·staleDate 8초·isStale 흐림
- 워치 세팅 동기화 3중화: 변경 push + reachability 재푸시 + 워치 앱 열릴 때 sendMessage pull
- 완주 진동 신설: 폰 2.4초 연속(백그라운드 6방 체인, 오디오 정지 4초 지연), 워치 retry×2+success
- 통계 하루 탭 맨 위 '오늘의 타임라인'(24시간 축 점 그래프, 시각 라벨·지금 룰)
- 폰 칼로리 공식을 애플워치 '기타' 운동 규칙(빠르게 걷기 상당)에 정합: MET 4.0+강도×0.15
- 도움말 4건 갱신·en/ja 번역·CFBundleName 보충, CLAUDE.md 현행화

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
This commit is contained in:
songyc macbook 2026-09-02 14:30:08 +09:00
parent 7799998a2d
commit 8f634c83f4
20 changed files with 1676 additions and 1319 deletions

View File

@ -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. 화면 구성 (탭 없음 — 애플 기본 앱 느낌)

View File

@ -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) } }

View File

@ -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

View File

@ -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)
}

View File

@ -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("운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(110)는 설정에서 고를 수 있어요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 워치에서 하면 워치가 심박수와 함께 기록해요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
body: Text("운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(110)는 설정에서 고를 수 있어요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 워치에서 하면 워치가 심박수를 실측해 함께 기록하고, 아이폰에서 하면 애플워치의 '기타 운동' 방식(빠르게 걷기 상당)으로 칼로리를 추정해요 — 그래서 둘의 칼로리가 조금 다를 수 있어요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
)
helpRow(
symbol: "paintbrush",

View File

@ -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"
}

View File

@ -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<KegingActivityAttributes>?
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) }

File diff suppressed because it is too large Load Diff

View File

@ -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 }

View File

@ -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("세트"))

View File

@ -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<Void, Never>?
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()
}
}
}

View File

@ -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

View File

@ -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"
}

View File

@ -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()
}
}
}

View File

@ -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)
}
}
}

View File

@ -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?) {

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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