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:
parent
7799998a2d
commit
8f634c83f4
@ -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. 화면 구성 (탭 없음 — 애플 기본 앱 느낌)
|
||||
|
||||
@ -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) } }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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
@ -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 }
|
||||
|
||||
@ -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("세트"))
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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?) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user