fix(keging): 실기기 1차 피드백 3건 — DI 백그라운드 멈춤·진동 패턴 동일·칼로리 0

① 다이나믹 아일랜드 멈춤(2중 방어):
- 오디오 키퍼 하드닝 — volume 0.1(0.0은 유지 풀림 사례)·prepareToPlay·인터럽션/씬 전환 revive()
- 엔진을 벽시계 기준으로 재설계 — 전 국면이 sessionStart로부터 결정적(computeTarget),
  타이머는 경계 재장전용, scenePhase .active에서 resyncAfterWake()로 정확한 국면 자가 교정

② 진동 패턴 동일(톡=톡톡): 시스템 바이브는 재생 중 재호출이 무시됨(실측) —
  시간 오프셋 방식 폐기, AudioServicesPlaySystemSoundWithCompletion 완료 체이닝으로 재작성
  (톡 1방 / 톡톡 0.25s 쉬고 1방 / 드르륵 0.15s 3연타 / 지잉 즉시 이어붙인 3방)

③ 운동 칼로리 0: 빌더 운동은 에너지 샘플 직접 추가 필요 —
  iOS는 MET 추정(1.8+강도×0.17, 70kg 가정) activeEnergyBurned 샘플 add,
  워치는 심박·활성 에너지 읽기 권한 추가로 센서 실측 수집, 권한 문구 3언어 갱신

검증: Debug·Release·워치 빌드 그린, 완주 경로 시뮬 재검증(엔진 재설계 후).
DI 갱신·진동 구분·칼로리는 실기기 재확인 필요(재실행 시 '활성 에너지' 권한 시트 뜸)

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-08-29 22:42:00 +09:00
parent c63618fcc6
commit f5f8d3bde4
13 changed files with 291 additions and 173 deletions

View File

@ -20,7 +20,7 @@
| 최소 OS | iOS 18.0 / watchOS 10.0 | | 최소 OS | iOS 18.0 / watchOS 10.0 |
| 수익 모델 | **완전 무료** — 광고·결제 일절 없음. CloudKit 없음. HealthKit은 **쓰기 전용**(운동 기록 저장 — §3.7) | | 수익 모델 | **완전 무료** — 광고·결제 일절 없음. CloudKit 없음. HealthKit은 **쓰기 전용**(운동 기록 저장 — §3.7) |
| 언어 | **한국어(원문)/영어/일본어** — String Catalog(타깃별 `Localizable.xcstrings`, InfoPlist 포함). 설정 → 언어(시스템/한국어/English/日本語)에서 앱 내 전환: `AppleLanguages` 오버라이드, **완전 종료 후 재실행 시 적용**(하루 다님 검증 방식). 워치·Live Activity·단축어 표기는 각자의 시스템 언어를 따름 | | 언어 | **한국어(원문)/영어/일본어** — String Catalog(타깃별 `Localizable.xcstrings`, InfoPlist 포함). 설정 → 언어(시스템/한국어/English/日本語)에서 앱 내 전환: `AppleLanguages` 오버라이드, **완전 종료 후 재실행 시 적용**(하루 다님 검증 방식). 워치·Live Activity·단축어 표기는 각자의 시스템 언어를 따름 |
| 현재 상태 (2026-08-29) | **1.0 전 기능 구현 완료(도움말·3개 언어·애플 건강 연동 포함), 시뮬레이터 검증 통과** — Debug·Release·워치 빌드 그린, 화면 QA(라이트/다크/iOS 18.5/워치 46·40mm + en/ja + 건강 섹션·권한 시트), 전 카탈로그 missing·stale 0, 완주·기록 저장 경로 확인. **실기기 테스트 대기(사용자)**: 진동 패턴(포그라운드·백그라운드), 다이나믹 아일랜드, 워치 페어링 동기화, 단축어, 워치 손목 내림, **애플 건강 저장·운동 강도**(권한 시트 조작이 필요해 시뮬 E2E 불가 — 권한 시트 표시까지만 확인)는 시뮬레이터로 검증 불가 | | 현재 상태 (2026-08-29) | **1.0 전 기능 구현 + 실기기(아이폰) 1차 피드백 3건 수정, 재테스트 대기.** 1차 발견·수정: ①다이나믹 아일랜드 백그라운드 멈춤 → 오디오 키퍼 하드닝 + 엔진 벽시계 재설계(§3.1·§3.4) ②진동 패턴 동일 현상 → 완료 체이닝 재작성(§3.3) ③운동 칼로리 0 → 에너지 샘플 직접 추가(§3.7). Debug·Release·워치 빌드 그린, 카탈로그 0/0, 완주 경로 시뮬 재검증. **재테스트 항목(사용자)**: DI 백그라운드 갱신, 진동 4종 구분, 칼로리 표기(+재권한 시트에 '활성 에너지' 추가됨 — 허용 필요), 워치(권한에 심박·에너지 읽기 추가) |
## 2. 빌드·검증 환경 ## 2. 빌드·검증 환경
@ -110,7 +110,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
### 3.1 세트와 엔진 (`Shared/KegelEngine.swift`) ### 3.1 세트와 엔진 (`Shared/KegelEngine.swift`)
- **세트 = 준비 P초 → (수축 N초 → 이완 M초) × 횟수**. 기본 세팅: 준비 3초·수축 10초·이완 5초·20회 (범위 준비 0~10초·수축/이완 1~60초·1~100회). 준비(prepare)는 무진동 — 첫 수축 진동이 시작 신호이고, 준비 0초면 즉시 수축 - **세트 = 준비 P초 → (수축 N초 → 이완 M초) × 횟수**. 기본 세팅: 준비 3초·수축 10초·이완 5초·20회 (범위 준비 0~10초·수축/이완 1~60초·1~100회). 준비(prepare)는 무진동 — 첫 수축 진동이 시작 신호이고, 준비 0초면 즉시 수축
- 상태 기계: `idle → running(phase: prepare|contract|relax, rep, phaseStart, phaseEnd) → finished → (확인) idle`. 국면 경계마다 1회성 Timer, 화면 카운트다운은 `Text(timerInterval:)`이라 틱 타이머 없음. **기록·건강의 startedAt은 준비를 뺀 첫 수축 시각** - 상태 기계: `idle → running(phase: prepare|contract|relax, rep, phaseStart, phaseEnd) → finished → (확인) idle`. **전 국면이 sessionStart(버튼 시각) 기준 벽시계로 결정적 계산**(`computeTarget`) — 타이머는 국면 경계 재장전용일 뿐이라 백그라운드 정지 후에도 다음 tick/`resyncAfterWake()`(scenePhase .active)에서 정확한 국면으로 자가 교정된다(실기기 다이나믹 아일랜드 멈춤 사고의 2중 방어). onPhaseChange는 (phase, rep)가 실제로 바뀔 때만. 화면 카운트다운은 `Text(timerInterval:)`. **기록·건강의 startedAt은 준비를 뺀 첫 수축 시각(스케줄 확정값)**
- **완주해야만 기록**`complete()`에서 RecordStore에 저장. **중단(`stop()`)하면 이 세트는 아예 없던 것** (기록·통계 미포함). 아이폰은 중단 전 확인 다이얼로그, 워치는 즉시 중단 - **완주해야만 기록**`complete()`에서 RecordStore에 저장. **중단(`stop()`)하면 이 세트는 아예 없던 것** (기록·통계 미포함). 아이폰은 중단 전 확인 다이얼로그, 워치는 즉시 중단
- 진행 중 재시작 불가(`isRunning` 가드). 완료 화면은 "확인"으로 닫는다 - 진행 중 재시작 불가(`isRunning` 가드). 완료 화면은 "확인"으로 닫는다
- 하루 목표 횟수 기능은 **사용자 결정(2026-08-29)으로 제외** — 추후 후보로만, 선제 구현 금지 - 하루 목표 횟수 기능은 **사용자 결정(2026-08-29)으로 제외** — 추후 후보로만, 선제 구현 금지
@ -122,11 +122,11 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
### 3.3 진동 (`Shared/WorkoutSettings.swift`, `IOS/Haptics.swift`, 워치 `WatchHaptics.swift`) ### 3.3 진동 (`Shared/WorkoutSettings.swift`, `IOS/Haptics.swift`, 워치 `WatchHaptics.swift`)
- 패턴 4종 — **횟수 세기가 아니라 첫 순간의 질감으로 구분**(1.5(사용자 피드백)에서 횟수 기반 4종 교체): 톡(짧게 1방) / 톡톡(0.5s 간격 2방) / 드르륵(0.5s 간격 3연타) / 지잉(0.3s 겹침 3방=끊기지 않는 긴 진동). 기본: **수축=지잉(조이고 유지), 이완=톡(탁 풀기)**. 세팅에서 국면별 선택 + "미리 느끼기". 구버전 저장값(once/twice/thrice/heartbeat)은 `HapticPattern(migrating:)`으로 이관 - 패턴 4종 — **횟수 세기가 아니라 첫 순간의 질감으로 구분**(1.5(사용자 피드백)에서 횟수 기반 4종 교체): 톡(짧게 1방) / 톡톡(0.5s 간격 2방) / 드르륵(0.5s 간격 3연타) / 지잉(0.3s 겹침 3방=끊기지 않는 긴 진동). 기본: **수축=지잉(조이고 유지), 이완=톡(탁 풀기)**. 세팅에서 국면별 선택 + "미리 느끼기". 구버전 저장값(once/twice/thrice/heartbeat)은 `HapticPattern(migrating:)`으로 이관
- iOS는 `AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)`(1회 ≈0.4초) 시퀀스 — 0.35초 이하 간격은 겹쳐 긴 진동이 되고 0.5초 간격은 빠른 연타가 된다. 무음 스위치 무관, **무음 오디오 세션이 살아 있으면 백그라운드에서도 울린다** - iOS는 `AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate)` **완료 체이닝** — ⚠️**재생 중 같은 시스템 사운드 재호출은 무시된다**(실기기 실측: 시간 오프셋 방식은 겹침·짧은 간격이 전부 먹혀 톡=톡톡이 됐음). 완료 후 쉼: 톡=1방, 톡톡=0.25초 쉬고 1방, 드르륵=0.15초 간격 3연타, 지잉=즉시 이어붙인 3방. 무음 스위치 무관, **무음 오디오 세션이 살아 있으면 백그라운드에서도 울린다**
- 워치는 반복 재생이 뭉개져서 **패턴마다 성격이 다른 시스템 햅틱 1회**로 매핑: 톡=.start, 톡톡=.directionUp, 드르륵=.retry, 지잉=.notification. **워치에서 실행하면 진동은 워치에서만** (완주 시 .success 1회) - 워치는 반복 재생이 뭉개져서 **패턴마다 성격이 다른 시스템 햅틱 1회**로 매핑: 톡=.start, 톡톡=.directionUp, 드르륵=.retry, 지잉=.notification. **워치에서 실행하면 진동은 워치에서만** (완주 시 .success 1회)
### 3.4 백그라운드·Live Activity (`IOS/BackgroundAudioKeeper.swift`, `LiveActivityManager.swift`, `KegingWidgets/`) ### 3.4 백그라운드·Live Activity (`IOS/BackgroundAudioKeeper.swift`, `LiveActivityManager.swift`, `KegingWidgets/`)
- 운동 시작 시 무음 WAV 무한 루프(`UIBackgroundModes=audio`, `.playback + .mixWithOthers`) → 앱을 나가도 타이머·진동·Live Activity 갱신 유지. 종료·중단 시 즉시 해제. 운동 중 `isIdleTimerDisabled`로 화면 꺼짐 방지(종료 시 복원) - 운동 시작 시 무음 WAV 무한 루프(`UIBackgroundModes=audio`, `.playback + .mixWithOthers`, **volume 0.1**·prepareToPlay — 0.0은 백그라운드 유지가 풀리는 사례 대비) → 앱을 나가도 타이머·진동·Live Activity 갱신 유지. **인터럽션 종료·백그라운드 전환·활성화 때 재생 죽었으면 revive()** 옵저버. 그래도 정지되면 엔진 벽시계 재동기화(§3.1)가 복귀 시 교정. 종료·중단 시 즉시 해제. 운동 중 `isIdleTimerDisabled`로 화면 꺼짐 방지(종료 시 복원)
- Live Activity: 국면 전환마다 update — 다이나믹 아일랜드 컴팩트(국면 아이콘 + n/총), 확장(국면·카운트다운·진행 바), 잠금화면 배너. 종료·중단 시 `.immediate` dismiss. 수축=민트 `#7FBF9E`·이완=앰버 `#E8C558` 고정색 - Live Activity: 국면 전환마다 update — 다이나믹 아일랜드 컴팩트(국면 아이콘 + n/총), 확장(국면·카운트다운·진행 바), 잠금화면 배너. 종료·중단 시 `.immediate` dismiss. 수축=민트 `#7FBF9E`·이완=앰버 `#E8C558` 고정색
### 3.5 단축어 (`IOS/KegingIntents.swift`) ### 3.5 단축어 (`IOS/KegingIntents.swift`)
@ -137,7 +137,8 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
- **완주한 세트만** 애플 건강에 운동으로 자동 기록 — 중단 세트는 건강에도 없음(§3.1 규칙 일관). 실행한 기기가 기록(폰=사후 HKWorkoutBuilder, 워치=라이브 HKWorkoutSession — 심박 포함, 백그라운드 유지 겸용). 출처 앱 아이콘은 시스템이 자동 표기 - **완주한 세트만** 애플 건강에 운동으로 자동 기록 — 중단 세트는 건강에도 없음(§3.1 규칙 일관). 실행한 기기가 기록(폰=사후 HKWorkoutBuilder, 워치=라이브 HKWorkoutSession — 심박 포함, 백그라운드 유지 겸용). 출처 앱 아이콘은 시스템이 자동 표기
- **운동 유형 선택(설정 → 애플 건강)**: '기타'(기본) ↔ '코어 트레이닝' — `HealthWorkoutType`(WorkoutConfig 필드, 워치 동기화). 하루 다님의 종목 매핑에서는 둘 다 목록 외라 동일하게 '기타 운동'으로 잡힘(호환 무영향) - **운동 유형 선택(설정 → 애플 건강)**: '기타'(기본) ↔ '코어 트레이닝' — `HealthWorkoutType`(WorkoutConfig 필드, 워치 동기화). 하루 다님의 종목 매핑에서는 둘 다 목록 외라 동일하게 '기타 운동'으로 잡힘(호환 무영향)
- **운동 강도(workout effort score, 1~10)**: 설정 → 애플 건강 → '운동 강도 자동 기록'(기본 2·쉬움, '안 함' 가능) — 완주 시 `relateWorkoutEffortSample`로 운동에 연결(애플 운동 앱의 강도 기록과 같은 데이터). iOS 18/워치 11+ — 워치 10은 `#available` 가드로 운동만 기록 - **운동 강도(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 추가)
- 권한은 첫 운동 시작 때 요청 — iOS는 쓰기 전용(운동·강도·활성 에너지), 워치는 +심박·활성 에너지 읽기(NSHealthShareUsageDescription 문구가 이를 설명). 거부·미응답이면 조용히 건너뜀(앱 자체 기록·통계 무영향). 워치는 권한 응답이 늦어 운동이 끝났으면 세션을 열지 않는 가드 있음
- ⚠️ (하루 다님 ITMS-90683 전례) HealthKit 엔타이틀먼트 보유 → `NSHealthUpdateUsageDescription` 필수 — iOS·워치 Info.plist에 3언어(InfoPlist.xcstrings)로 등록됨. 엔타이틀먼트는 iOS·워치 두 타깃(위젯 제외) - ⚠️ (하루 다님 ITMS-90683 전례) HealthKit 엔타이틀먼트 보유 → `NSHealthUpdateUsageDescription` 필수 — iOS·워치 Info.plist에 3언어(InfoPlist.xcstrings)로 등록됨. 엔타이틀먼트는 iOS·워치 두 타깃(위젯 제외)
- **하루 다님 연계(2026-08-29 확인 — 하루 다님 무수정)**: 하루 다님의 종목별 운동 다짐·타임테이블 운동 블록은 HKWorkout을 출처 무관 조회라 케깅 세트가 '기타 운동'으로 자동 집계됨. 단 '운동 시간' 지표는 `appleExerciseTime`(애플 전용 쓰기 불가) 기반이라 **아이폰 실행분은 미반영, 워치 실행분은 운동 링 적립을 통해 반영**(구조적 한계 — 케깅 측 해결 불가) - **하루 다님 연계(2026-08-29 확인 — 하루 다님 무수정)**: 하루 다님의 종목별 운동 다짐·타임테이블 운동 블록은 HKWorkout을 출처 무관 조회라 케깅 세트가 '기타 운동'으로 자동 집계됨. 단 '운동 시간' 지표는 `appleExerciseTime`(애플 전용 쓰기 불가) 기반이라 **아이폰 실행분은 미반영, 워치 실행분은 운동 링 적립을 통해 반영**(구조적 한계 — 케깅 측 해결 불가)
- `effortScore`는 WorkoutConfig 필드로 워치에 자동 동기화. **WorkoutConfig는 필드 추가 대비 전 필드 decodeIfPresent 커스텀 디코더** — 새 필드를 추가하면 여기에도 기본값을 넣을 것(안 넣으면 기존 사용자 설정이 통째로 초기화됨) - `effortScore`는 WorkoutConfig 필드로 워치에 자동 동기화. **WorkoutConfig는 필드 추가 대비 전 필드 decodeIfPresent 커스텀 디코더** — 새 필드를 추가하면 여기에도 기본값을 넣을 것(안 넣으면 기존 사용자 설정이 통째로 초기화됨)

View File

@ -4,15 +4,18 @@
// //
// ··Live Activity . // ··Live Activity .
// (UIBackgroundModes=audio, .mixWithOthers ) // (UIBackgroundModes=audio, .mixWithOthers )
// ( )· .
// //
import AVFoundation import AVFoundation
import UIKit
@MainActor @MainActor
final class BackgroundAudioKeeper { final class BackgroundAudioKeeper {
static let shared = BackgroundAudioKeeper() static let shared = BackgroundAudioKeeper()
private var player: AVAudioPlayer? private var player: AVAudioPlayer?
private var observers: [NSObjectProtocol] = []
private init() {} private init() {}
@ -23,16 +26,50 @@ final class BackgroundAudioKeeper {
try? session.setActive(true) try? session.setActive(true)
player = try? AVAudioPlayer(data: Self.silenceWAV) player = try? AVAudioPlayer(data: Self.silenceWAV)
player?.numberOfLoops = -1 player?.numberOfLoops = -1
player?.volume = 0 // 0.0
player?.volume = 0.1
player?.prepareToPlay()
player?.play() player?.play()
installObservers()
} }
func stop() { func stop() {
removeObservers()
player?.stop() player?.stop()
player = nil player = nil
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
} }
/// ( · )
private func revive() {
guard let player, !player.isPlaying else { return }
try? AVAudioSession.sharedInstance().setActive(true)
player.play()
}
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
guard let info = note.userInfo,
let rawType = info[AVAudioSessionInterruptionTypeKey] as? UInt,
AVAudioSession.InterruptionType(rawValue: rawType) == .ended else { return }
Task { @MainActor in self?.revive() }
})
for name in [UIApplication.didEnterBackgroundNotification, UIApplication.didBecomeActiveNotification] {
observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in self?.revive() }
})
}
}
private func removeObservers() {
observers.forEach { NotificationCenter.default.removeObserver($0) }
observers.removeAll()
}
/// 1 WAV (8kHz 16bit mono) /// 1 WAV (8kHz 16bit mono)
private static let silenceWAV: Data = { private static let silenceWAV: Data = {
let sampleRate = 8000 let sampleRate = 8000

View File

@ -11,6 +11,8 @@ struct ContentView: View {
@EnvironmentObject private var engine: KegelEngine @EnvironmentObject private var engine: KegelEngine
@EnvironmentObject private var settings: SettingsStore @EnvironmentObject private var settings: SettingsStore
@Environment(\.scenePhase) private var scenePhase
@State private var showTimerSettings = false @State private var showTimerSettings = false
@State private var showAppSettings = false @State private var showAppSettings = false
@State private var showHelp = false @State private var showHelp = false
@ -33,6 +35,10 @@ struct ContentView: View {
.onReceive(NotificationCenter.default.publisher(for: .startWorkoutRequested)) { _ in .onReceive(NotificationCenter.default.publisher(for: .startWorkoutRequested)) { _ in
startFromShortcut() startFromShortcut()
} }
.onChange(of: scenePhase) {
//
if scenePhase == .active { KegelEngine.shared.resyncAfterWake() }
}
.onAppear { .onAppear {
if AppLaunchState.shared.startRequested { startFromShortcut() } if AppLaunchState.shared.startRequested { startFromShortcut() }
#if DEBUG #if DEBUG

View File

@ -2,19 +2,40 @@
// Haptics.swift // Haptics.swift
// Keging // Keging
// //
// (iOS) . // (iOS) (1 0.5) .
// AudioServices , . // ( ·
// ) .
// AudioServices , .
// //
import AudioToolbox import AudioToolbox
import Foundation
@MainActor nonisolated enum Haptics {
enum Haptics {
static func play(_ pattern: HapticPattern) { static func play(_ pattern: HapticPattern) {
for offset in pattern.beatOffsets { // ()
DispatchQueue.main.asyncAfter(deadline: .now() + offset) { let gaps: [TimeInterval]
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) switch pattern {
case .short: gaps = [0] // : 1
case .doubleTap: gaps = [0, 0.25] // : 0.25 1
case .rattle: gaps = [0, 0.15, 0.15] // : 3
case .long: gaps = [0, 0, 0] // :
}
playChain(gaps)
}
private static func playChain(_ gaps: [TimeInterval]) {
guard let gap = gaps.first else { return }
let rest = Array(gaps.dropFirst())
let fire = {
AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate) {
DispatchQueue.main.async { playChain(rest) }
} }
} }
if gap > 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + gap, execute: fire)
} else {
fire()
}
} }
} }

View File

@ -19,7 +19,18 @@ final class HealthRecorder {
private init() {} private init() {}
private static var shareTypes: Set<HKSampleType> { private static var shareTypes: Set<HKSampleType> {
[HKObjectType.workoutType(), HKQuantityType(.workoutEffortScore)] [
HKObjectType.workoutType(),
HKQuantityType(.workoutEffortScore),
HKQuantityType(.activeEnergyBurned),
]
}
/// MET 1.8( 0)3.5( 10), 70kg .
/// 0kcal ( )
static func estimatedKcal(seconds: TimeInterval, effortScore: Int) -> Double {
let met = 1.8 + Double(min(max(effortScore, 0), 10)) * 0.17
return met * 3.5 * 70 / 200 * (seconds / 60)
} }
/// ( ) /// ( )
@ -44,6 +55,14 @@ final class HealthRecorder {
Task { @MainActor in Task { @MainActor in
do { do {
try await HealthKitCalls.beginCollection(builder, at: start) try await HealthKitCalls.beginCollection(builder, at: start)
let kcal = Self.estimatedKcal(seconds: end.timeIntervalSince(start), effortScore: effortScore)
let energySample = HKQuantitySample(
type: HKQuantityType(.activeEnergyBurned),
quantity: HKQuantity(unit: .kilocalorie(), doubleValue: kcal),
start: start,
end: end
)
try await HealthKitCalls.add([energySample], to: builder)
try await HealthKitCalls.endCollection(builder, at: end) try await HealthKitCalls.endCollection(builder, at: end)
let workout = try await HealthKitCalls.finishWorkout(builder) let workout = try await HealthKitCalls.finishWorkout(builder)
if effortScore > 0, let workout { if effortScore > 0, let workout {

View File

@ -11,6 +11,6 @@
<key>NSHealthUpdateUsageDescription</key> <key>NSHealthUpdateUsageDescription</key>
<string>케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요.</string> <string>케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요.</string>
<key>NSHealthShareUsageDescription</key> <key>NSHealthShareUsageDescription</key>
<string>케깅은 건강 데이터를 읽지 않아요.</string> <string>워치에서 운동할 때 심박수와 활성 에너지를 운동 기록에 담기 위해 필요해요. 그 외 건강 데이터는 읽지 않아요.</string>
</dict> </dict>
</plist> </plist>

View File

@ -1,87 +1,87 @@
{ {
"sourceLanguage" : "ko", "sourceLanguage": "ko",
"strings" : { "strings": {
"CFBundleDisplayName" : { "CFBundleDisplayName": {
"extractionState" : "manual", "extractionState": "manual",
"localizations" : { "localizations": {
"en" : { "en": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Keging" "value": "Keging"
} }
}, },
"ja" : { "ja": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Keging" "value": "Keging"
} }
}, },
"ko" : { "ko": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "케깅" "value": "케깅"
} }
} }
} }
}, },
"CFBundleName" : { "CFBundleName": {
"comment" : "Bundle name", "comment": "Bundle name",
"extractionState" : "extracted_with_value", "extractionState": "extracted_with_value",
"localizations" : { "localizations": {
"ko" : { "ko": {
"stringUnit" : { "stringUnit": {
"state" : "new", "state": "new",
"value" : "Keging" "value": "Keging"
} }
} }
} }
}, },
"NSHealthShareUsageDescription" : { "NSHealthShareUsageDescription": {
"extractionState" : "manual", "extractionState": "manual",
"localizations" : { "localizations": {
"en" : { "ko": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Keging does not read your health data." "value": "워치에서 운동할 때 심박수와 활성 에너지를 운동 기록에 담기 위해 필요해요. 그 외 건강 데이터는 읽지 않아요."
} }
}, },
"ja" : { "en": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Kegingはヘルスケアデータを読み取りません。" "value": "Needed to include heart rate and active energy in workouts recorded on the watch. No other health data is read."
} }
}, },
"ko" : { "ja": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "케깅은 건강 데이터를 읽지 않아요." "value": "ウォッチでのワークアウト時に心拍数とアクティブエネルギーを記録に含めるために必要です。それ以外のヘルスケアデータは読み取りません。"
} }
} }
} }
}, },
"NSHealthUpdateUsageDescription" : { "NSHealthUpdateUsageDescription": {
"extractionState" : "manual", "extractionState": "manual",
"localizations" : { "localizations": {
"en" : { "en": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Used to save your finished Kegel workouts to Apple Health." "value": "Used to save your finished Kegel workouts to Apple Health."
} }
}, },
"ja" : { "ja": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。" "value": "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。"
} }
}, },
"ko" : { "ko": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요." "value": "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요."
} }
} }
} }
} }
}, },
"version" : "1.0" "version": "1.0"
} }

View File

@ -11,6 +11,6 @@
<key>NSHealthUpdateUsageDescription</key> <key>NSHealthUpdateUsageDescription</key>
<string>케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요.</string> <string>케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요.</string>
<key>NSHealthShareUsageDescription</key> <key>NSHealthShareUsageDescription</key>
<string>케깅은 건강 데이터를 읽지 않아요.</string> <string>워치에서 운동할 때 심박수와 활성 에너지를 운동 기록에 담기 위해 필요해요. 그 외 건강 데이터는 읽지 않아요.</string>
</dict> </dict>
</plist> </plist>

View File

@ -1,87 +1,87 @@
{ {
"sourceLanguage" : "ko", "sourceLanguage": "ko",
"strings" : { "strings": {
"CFBundleDisplayName" : { "CFBundleDisplayName": {
"extractionState" : "manual", "extractionState": "manual",
"localizations" : { "localizations": {
"en" : { "en": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Keging" "value": "Keging"
} }
}, },
"ja" : { "ja": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Keging" "value": "Keging"
} }
}, },
"ko" : { "ko": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "케깅" "value": "케깅"
} }
} }
} }
}, },
"CFBundleName" : { "CFBundleName": {
"comment" : "Bundle name", "comment": "Bundle name",
"extractionState" : "extracted_with_value", "extractionState": "extracted_with_value",
"localizations" : { "localizations": {
"ko" : { "ko": {
"stringUnit" : { "stringUnit": {
"state" : "new", "state": "new",
"value" : "KegingWatch Watch App" "value": "KegingWatch Watch App"
} }
} }
} }
}, },
"NSHealthShareUsageDescription" : { "NSHealthShareUsageDescription": {
"extractionState" : "manual", "extractionState": "manual",
"localizations" : { "localizations": {
"en" : { "ko": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Keging does not read your health data." "value": "워치에서 운동할 때 심박수와 활성 에너지를 운동 기록에 담기 위해 필요해요. 그 외 건강 데이터는 읽지 않아요."
} }
}, },
"ja" : { "en": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Kegingはヘルスケアデータを読み取りません。" "value": "Needed to include heart rate and active energy in workouts recorded on the watch. No other health data is read."
} }
}, },
"ko" : { "ja": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "케깅은 건강 데이터를 읽지 않아요." "value": "ウォッチでのワークアウト時に心拍数とアクティブエネルギーを記録に含めるために必要です。それ以外のヘルスケアデータは読み取りません。"
} }
} }
} }
}, },
"NSHealthUpdateUsageDescription" : { "NSHealthUpdateUsageDescription": {
"extractionState" : "manual", "extractionState": "manual",
"localizations" : { "localizations": {
"en" : { "en": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "Used to save your finished Kegel workouts to Apple Health." "value": "Used to save your finished Kegel workouts to Apple Health."
} }
}, },
"ja" : { "ja": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。" "value": "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。"
} }
}, },
"ko" : { "ko": {
"stringUnit" : { "stringUnit": {
"state" : "translated", "state": "translated",
"value" : "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요." "value": "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요."
} }
} }
} }
} }
}, },
"version" : "1.0" "version": "1.0"
} }

View File

@ -20,13 +20,22 @@ final class WatchWorkoutSession: NSObject {
private override init() { super.init() } private override init() { super.init() }
private static var shareTypes: Set<HKSampleType> { private static var shareTypes: Set<HKSampleType> {
var types: Set<HKSampleType> = [HKObjectType.workoutType()] var types: Set<HKSampleType> = [
HKObjectType.workoutType(),
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.heartRate),
]
if #available(watchOS 11.0, *) { if #available(watchOS 11.0, *) {
types.insert(HKQuantityType(.workoutEffortScore)) types.insert(HKQuantityType(.workoutEffortScore))
} }
return types return types
} }
/// ·
private static var readTypes: Set<HKObjectType> {
[HKQuantityType(.heartRate), HKQuantityType(.activeEnergyBurned)]
}
/// · /// ·
func begin(startDate: Date) { func begin(startDate: Date) {
Task { @MainActor in Task { @MainActor in
@ -80,7 +89,7 @@ final class WatchWorkoutSession: NSObject {
private func requestAuthorization() async throws { private func requestAuthorization() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
store.requestAuthorization(toShare: Self.shareTypes, read: nil) { _, error in store.requestAuthorization(toShare: Self.shareTypes, read: Self.readTypes) { _, error in
if let error { continuation.resume(throwing: error) } else { continuation.resume() } if let error { continuation.resume(throwing: error) } else { continuation.resume() }
} }
} }

View File

@ -33,6 +33,14 @@ nonisolated enum HealthKitCalls {
} }
} }
static func add(_ samples: [HKSample], to builder: HKWorkoutBuilder) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
builder.add(samples) { _, error in
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
}
}
}
static func finishWorkout(_ builder: HKWorkoutBuilder) async throws -> HKWorkout? { static func finishWorkout(_ builder: HKWorkoutBuilder) async throws -> HKWorkout? {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<HKWorkout?, Error>) in try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<HKWorkout?, Error>) in
builder.finishWorkout { workout, error in builder.finishWorkout { workout, error in

View File

@ -41,7 +41,11 @@ final class KegelEngine: ObservableObject {
@Published private(set) var state: State = .idle @Published private(set) var state: State = .idle
private(set) var config = WorkoutConfig() private(set) var config = WorkoutConfig()
/// · ( )
private(set) var startedAt = Date() private(set) var startedAt = Date()
///
/// ( )
private var sessionStart = Date()
private var timer: Timer? private var timer: Timer?
/// (phase, rep, phaseEnd) /// (phase, rep, phaseEnd)
@ -56,12 +60,16 @@ final class KegelEngine: ObservableObject {
func start(config: WorkoutConfig) { func start(config: WorkoutConfig) {
guard !isRunning else { return } guard !isRunning else { return }
self.config = config self.config = config
startedAt = Date() sessionStart = Date()
if config.prepSeconds > 0 { startedAt = sessionStart.addingTimeInterval(Double(config.prepSeconds))
enterPhase(.prepare, rep: 1) applyTarget(computeTarget(at: Date()))
} else { }
enterPhase(.contract, rep: 1)
} /// .
/// onPhaseChange(·Live Activity)
func resyncAfterWake() {
guard case .running = state else { return }
applyTarget(computeTarget(at: Date()))
} }
/// ( ) /// ( )
@ -78,37 +86,54 @@ final class KegelEngine: ObservableObject {
if case .finished = state { state = .idle } if case .finished = state { state = .idle }
} }
private func enterPhase(_ phase: KegelPhase, rep: Int) { private enum Target {
let seconds = switch phase { case finished
case .prepare: config.prepSeconds case running(KegelPhase, Int, Date, Date)
case .contract: config.contractSeconds }
case .relax: config.relaxSeconds
/// sessionStart
private func computeTarget(at now: Date) -> Target {
let prep = Double(config.prepSeconds)
let contract = Double(max(1, config.contractSeconds))
let relax = Double(max(1, config.relaxSeconds))
let cycle = contract + relax
let elapsed = now.timeIntervalSince(sessionStart)
if elapsed < prep {
return .running(.prepare, 1, sessionStart, sessionStart.addingTimeInterval(prep))
} }
// · let exercised = elapsed - prep
if phase == .contract && rep == 1 { startedAt = Date() } let repIndex = Int(exercised / cycle) // 0
let now = Date() guard repIndex < config.reps else { return .finished }
let end = now.addingTimeInterval(Double(max(1, seconds))) let repStart = sessionStart.addingTimeInterval(prep + Double(repIndex) * cycle)
state = .running(phase: phase, rep: rep, phaseStart: now, phaseEnd: end) if exercised - Double(repIndex) * cycle < contract {
onPhaseChange?(phase, rep, end) return .running(.contract, repIndex + 1, repStart, repStart.addingTimeInterval(contract))
timer?.invalidate() }
timer = Timer.scheduledTimer(withTimeInterval: Double(max(1, seconds)), repeats: false) { [weak self] _ in return .running(.relax, repIndex + 1, repStart.addingTimeInterval(contract), repStart.addingTimeInterval(cycle))
guard let self else { return } }
Task { @MainActor in self.advance(from: phase, rep: rep) }
private func applyTarget(_ target: Target) {
switch target {
case .finished:
complete()
case let .running(phase, rep, phaseStart, phaseEnd):
let changed: Bool = {
guard case let .running(currentPhase, currentRep, _, _) = state else { return true }
return currentPhase != phase || currentRep != rep
}()
state = .running(phase: phase, rep: rep, phaseStart: phaseStart, phaseEnd: phaseEnd)
if changed { onPhaseChange?(phase, rep, phaseEnd) }
scheduleTick(at: phaseEnd)
} }
} }
private func advance(from phase: KegelPhase, rep: Int) { private func scheduleTick(at date: Date) {
guard case .running = state else { return } timer?.invalidate()
switch phase { let interval = max(0.05, date.timeIntervalSinceNow + 0.02)
case .prepare: timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
enterPhase(.contract, rep: 1) guard let self else { return }
case .contract: Task { @MainActor in
enterPhase(.relax, rep: rep) guard case .running = self.state else { return }
case .relax: self.applyTarget(self.computeTarget(at: Date()))
if rep < config.reps {
enterPhase(.contract, rep: rep + 1)
} else {
complete()
} }
} }
} }

View File

@ -24,16 +24,8 @@ nonisolated enum HapticPattern: String, CaseIterable, Identifiable, Codable {
} }
} }
/// () 1 0.4 // : iOS = 퀀(IOS/Haptics.swift),
/// 0.35 , 0.5 // = (WatchHaptics.swift)
var beatOffsets: [TimeInterval] {
switch self {
case .short: [0]
case .doubleTap: [0, 0.5]
case .rattle: [0, 0.5, 1.0]
case .long: [0, 0.3, 0.6]
}
}
/// 1.0 ( ) /// 1.0 ( )
init?(migrating raw: String?) { init?(migrating raw: String?) {