feat(keging): 애플 건강 연동 — 완주 세트 운동 기록 + 운동 강도(1~10) 자동 저장

- 완주한 세트만 애플 건강에 '기타' 운동으로 자동 기록 (중단=미기록 규칙 일관)
- 폰=사후 HKWorkoutBuilder, 워치=라이브 HKWorkoutSession(심박 포함·손목 내림 유지 겸용,
  실패 시 확장 런타임 세션 폴백)
- 운동 강도: 설정 → 애플 건강 → 자동 기록(기본 2·쉬움, 안 함 가능) — relateWorkoutEffortSample,
  워치 11+/iOS 18, 워치 10은 운동만 기록
- 쓰기 전용 권한(첫 시작 때 요청·거부 시 조용히 스킵), 건강 문구 3언어, 새 UI 문자열 11키 번역
- WorkoutConfig 전 필드 decodeIfPresent 디코더 (필드 추가에도 기존 설정 보존)
- 검증: Debug·Release·워치 빌드 그린, 권한 시트·설정 ko/ja 화면 QA, 카탈로그 missing·stale 0
  (건강 실제 저장은 권한 시트 조작 불가로 실기기 확인 필요)

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 19:51:49 +09:00
parent 3f91e8889c
commit 893545e7be
16 changed files with 630 additions and 78 deletions

View File

@ -18,9 +18,9 @@
| 플랫폼 | iOS(iPhone 전용, 세로 고정) + watchOS 앱 + Live Activity(다이나믹 아일랜드) + 단축어(App Intents) |
| 번들 ID | `com.yechan.Keging` / 워치 `.watchkitapp` / 위젯 `.KegingWidgets` / App Group `group.com.yechan.Keging` |
| 최소 OS | iOS 18.0 / watchOS 10.0 |
| 수익 모델 | **완전 무료** — 광고·결제 일절 없음. CloudKit·HealthKit 등 외부 서비스 없음 |
| 수익 모델 | **완전 무료** — 광고·결제 일절 없음. CloudKit 없음. HealthKit은 **쓰기 전용**(운동 기록 저장 — §3.7) |
| 언어 | **한국어(원문)/영어/일본어** — 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 5화면), 전 카탈로그 missing·stale 0, 완주·기록 저장 경로 확인. **실기기 테스트 대기(사용자)**: 진동 패턴(포그라운드·백그라운드), 다이나믹 아일랜드, 워치 페어링 동기화(세팅 폰→워치, 기록 워치→폰), 단축어, 워치 손목 내림(확장 런타임)은 시뮬레이터로 검증 불가 |
| 현재 상태 (2026-08-29) | **1.0 전 기능 구현 완료(도움말·3개 언어·애플 건강 연동 포함), 시뮬레이터 검증 통과** — Debug·Release·워치 빌드 그린, 화면 QA(라이트/다크/iOS 18.5/워치 46·40mm + en/ja + 건강 섹션·권한 시트), 전 카탈로그 missing·stale 0, 완주·기록 저장 경로 확인. **실기기 테스트 대기(사용자)**: 진동 패턴(포그라운드·백그라운드), 다이나믹 아일랜드, 워치 페어링 동기화, 단축어, 워치 손목 내림, **애플 건강 저장·운동 강도**(권한 시트 조작이 필요해 시뮬 E2E 불가 — 권한 시트 표시까지만 확인)는 시뮬레이터로 검증 불가 |
## 2. 빌드·검증 환경
@ -41,6 +41,7 @@ IOS/ # iOS 앱 타깃 (Keging)
BackgroundAudioKeeper.swift # 운동 중 무음 오디오 루프 (백그라운드 타이머 유지, mixWithOthers)
LiveActivityManager.swift # Live Activity 시작/갱신/종료
PhoneSync.swift # WCSession 폰 측 (세팅 push, 워치 기록 수신·병합)
HealthRecorder.swift # 완주 세트를 애플 건강에 사후 저장 (§3.7)
KegingIntents.swift # 단축어 "케겔 운동 시작" (openAppWhenRun + 알림/플래그)
DebugSeed.swift # DEBUG 런치 인자 (§7)
Info.plist # UIBackgroundModes=audio (GENERATE_INFOPLIST_FILE와 병합)
@ -53,12 +54,15 @@ Shared/ # 세 타깃 공용 (pbxproj fileSystemSynchronize
SessionRecord.swift # 완주 기록 + RecordStore (App Group records.json, id 중복 병합)
KegelEngine.swift # 타이머 상태 기계 (iOS·워치 공용, §3.1)
KegingActivityAttributes.swift # Live Activity 상태 (canImport(ActivityKit) 가드 — 워치 제외)
HealthKitCalls.swift # HealthKit completion API async 래퍼 (iOS 사후 빌더·워치 라이브 빌더 공용)
KegingWidgets/ # Live Activity 전용 위젯 확장 (홈 위젯 없음)
KegingWidgetsBundle.swift / KegingWidgetsLiveActivity.swift / Info.plist
KegingWatch Watch App/ # 워치 앱 타깃
KegingWatchApp.swift / ContentView.swift(WatchContentView) / WatchCoordinator.swift
WatchHaptics.swift / WatchSync.swift / RuntimeSessionManager.swift
Info.plist # WKBackgroundModes=physical-therapy (확장 런타임 세션)
WatchHaptics.swift / WatchSync.swift
WatchWorkoutSession.swift # HKWorkoutSession 라이브 기록(심박 포함)+백그라운드 유지 — 실패 시 폴백:
RuntimeSessionManager.swift # 확장 런타임 세션 (physical-therapy)
Info.plist # WKBackgroundModes=workout-processing·physical-therapy + 건강 문구
DesignAssets/app-icon.svg # 아이콘 원본 (에셋 PNG는 qlmanage 1024 렌더 → sips 알파 제거)
```
@ -128,15 +132,22 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
- `StartKegelIntent` 하나 — 앱을 열고(`openAppWhenRun`) 저장된 세팅대로 즉시 시작. 콜드 스타트 경합은 AppLaunchState 플래그 + 알림 이중 경로로 처리
- (하루 다님 전례) 인텐트 노출 문구에 'iPhone' 단어 금지 — ITMS-90626
### 3.7 애플 건강 기록 (`IOS/HealthRecorder.swift`, 워치 `WatchWorkoutSession.swift`, `Shared/HealthKitCalls.swift`)
- **완주한 세트만** 애플 건강에 `.other`('기타') 운동으로 자동 기록 — 중단 세트는 건강에도 없음(§3.1 규칙 일관). 실행한 기기가 기록(폰=사후 HKWorkoutBuilder, 워치=라이브 HKWorkoutSession — 심박 포함, 백그라운드 유지 겸용)
- **운동 강도(workout effort score, 1~10)**: 설정 → 애플 건강 → '운동 강도 자동 기록'(기본 2·쉬움, '안 함' 가능) — 완주 시 `relateWorkoutEffortSample`로 운동에 연결(애플 운동 앱의 강도 기록과 같은 데이터). iOS 18/워치 11+ — 워치 10은 `#available` 가드로 운동만 기록
- 권한은 첫 운동 시작 때 요청(쓰기 전용 — 읽기 없음). 거부·미응답이면 조용히 건너뜀(앱 자체 기록·통계 무영향). 워치는 권한 응답이 늦어 운동이 끝났으면 세션을 열지 않는 가드 있음
- ⚠️ (하루 다님 ITMS-90683 전례) HealthKit 엔타이틀먼트 보유 → `NSHealthUpdateUsageDescription` 필수 — iOS·워치 Info.plist에 3언어(InfoPlist.xcstrings)로 등록됨. 엔타이틀먼트는 iOS·워치 두 타깃(위젯 제외)
- `effortScore`는 WorkoutConfig 필드로 워치에 자동 동기화. **WorkoutConfig는 필드 추가 대비 전 필드 decodeIfPresent 커스텀 디코더** — 새 필드를 추가하면 여기에도 기본값을 넣을 것(안 넣으면 기존 사용자 설정이 통째로 초기화됨)
### 3.6 워치 (`KegingWatch Watch App/`)
- 실행하면 시작 버튼만(+세팅 요약 한 줄). 진행 중: 국면(수축=민트/이완=앰버)·카운트다운·n/총·중단 — **40mm에서도 스크롤 없이 한 화면** (46·40mm 스크린샷 검증)
- 세팅은 폰에서만 편집 — `updateApplicationContext`로 워치에 도착(`WatchSync`), 완주 기록은 `transferUserInfo`로 폰에 합류(오프라인 자동 큐)
- 운동 시작 시 `WKExtendedRuntimeSession`(Info.plist WKBackgroundModes=physical-therapy) — 손목을 내려도 세션 유지 시도, 실패해도 화면 켜져 있는 동안은 정상 동작
- 운동 시작 시 **HKWorkoutSession**(건강 기록+손목 내려도 유지 — §3.7)을 열고, 실패(권한 거부 등) 시 `WKExtendedRuntimeSession`(physical-therapy) 폴백. 둘 다 실패해도 화면 켜져 있는 동안은 정상 동작
## 4. 화면 구성 (탭 없음 — 애플 기본 앱 느낌)
- **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + 상단 중앙 통계 그랩 힌트 + 버튼 아래 현재 세팅 요약(탭=세팅)
- **세팅/설정/도움말**: 시트(Form/List). 설정에 테마·**언어**(시스템/한국어/English/日本語 — 재실행 시 적용) 섹션. 도움말은 5그룹 13주제(기본 사용법·통계·앱 밖에서·애플워치·설정 기타)로 전 기능 사용법 서술. 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션
- **세팅/설정/도움말**: 시트(Form/List). 설정에 테마·**언어**(시스템/한국어/English/日本語 — 재실행 시 적용)·**애플 건강**(운동 강도 자동 기록) 섹션. 도움말은 5그룹 14주제(기본 사용법·통계·앱 밖에서·애플워치·설정 기타)로 전 기능 사용법 서술. 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션
- 아이콘 전용 버튼에는 `accessibilityLabel` 필수 (하루 다님 컨벤션 계승)
## 5. 테마·컬러 — 하루 다님 팔레트 계승 (`Shared/Theme.swift`)

View File

@ -8,6 +8,7 @@
import SwiftUI
struct AppSettingsView: View {
@EnvironmentObject private var settings: SettingsStore
@AppStorage("app.theme", store: AppGroup.defaults) private var themeMode = "system"
@AppStorage("app.language", store: AppGroup.defaults) private var appLanguage = "system"
@Environment(\.dismiss) private var dismiss
@ -35,6 +36,18 @@ struct AppSettingsView: View {
} footer: {
Text("언어를 바꾸면 앱을 완전히 종료했다가 다시 실행했을 때 적용돼요.")
}
Section {
Picker("운동 강도 자동 기록", selection: $settings.config.effortScore) {
Text("안 함").tag(0)
ForEach(1...10, id: \.self) { score in
Text(effortLabel(score)).tag(score)
}
}
} header: {
Text("애플 건강")
} footer: {
Text("운동을 끝까지 마치면 그 시간이 애플 건강에 '기타' 운동으로 자동 기록되고, 여기서 고른 운동 강도(1~10)도 함께 저장돼요. 기록을 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
}
Section {
LabeledContent("버전", value: versionText)
} footer: {
@ -59,6 +72,17 @@ struct AppSettingsView: View {
}
}
/// : 1~3 · 4~6 · 7~8 · 9~10
private func effortLabel(_ score: Int) -> String {
let word: String = switch score {
case 1...3: String(localized: "쉬움")
case 4...6: String(localized: "보통")
case 7...8: String(localized: "힘듦")
default: String(localized: "전력")
}
return String(localized: "\(score) · \(word)")
}
private var versionText: String {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"

View File

@ -0,0 +1,53 @@
//
// HealthRecorder.swift
// Keging
//
// '' (iOS ).
// (workout effort score, 1~10) .
// · · .
//
import HealthKit
@MainActor
final class HealthRecorder {
static let shared = HealthRecorder()
private let store = HKHealthStore()
private var authRequested = false
private init() {}
private static var shareTypes: Set<HKSampleType> {
[HKObjectType.workoutType(), HKQuantityType(.workoutEffortScore)]
}
/// ( )
func requestAuthorizationIfNeeded() {
guard HKHealthStore.isHealthDataAvailable(), !authRequested else { return }
authRequested = true
store.requestAuthorization(toShare: Self.shareTypes, read: nil) { _, _ in }
}
/// ''
func save(record: SessionRecord, effortScore: Int) {
guard HKHealthStore.isHealthDataAvailable() else { return }
let start = record.startedAt
let end = start.addingTimeInterval(Double(record.reps * (record.contractSeconds + record.relaxSeconds)))
let configuration = HKWorkoutConfiguration()
configuration.activityType = .other
let builder = HKWorkoutBuilder(healthStore: store, configuration: configuration, device: .local())
Task { @MainActor in
do {
try await HealthKitCalls.beginCollection(builder, at: start)
try await HealthKitCalls.endCollection(builder, at: end)
let workout = try await HealthKitCalls.finishWorkout(builder)
if effortScore > 0, let workout {
try? await HealthKitCalls.relateEffort(store: store, workout: workout, score: effortScore)
}
} catch {
//
}
}
}
}

View File

@ -89,6 +89,11 @@ struct HelpView: View {
}
Section {
helpRow(
symbol: "heart.circle",
title: Text("애플 건강 연동"),
body: Text("운동을 끝까지 마치면 그 시간이 애플 건강에 '기타' 운동으로 자동 기록돼요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 설정에서 고른 운동 강도(1~10)도 함께 기록돼요. 워치에서 하면 워치가 심박수와 함께 기록해요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
)
helpRow(
symbol: "paintbrush",
title: Text("테마와 언어"),

View File

@ -7,5 +7,10 @@
<array>
<string>audio</string>
</array>
<!-- 완주한 세트를 애플 건강에 운동으로 기록 (읽기는 안 함 — 현지화는 InfoPlist.xcstrings) -->
<key>NSHealthUpdateUsageDescription</key>
<string>케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요.</string>
<key>NSHealthShareUsageDescription</key>
<string>케깅은 건강 데이터를 읽지 않아요.</string>
</dict>
</plist>

View File

@ -1,29 +1,75 @@
{
"sourceLanguage" : "ko",
"strings" : {
"CFBundleDisplayName" : {
"extractionState" : "manual",
"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "케깅"
"sourceLanguage": "ko",
"strings": {
"CFBundleDisplayName": {
"extractionState": "manual",
"localizations": {
"ko": {
"stringUnit": {
"state": "translated",
"value": "케깅"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keging"
"en": {
"stringUnit": {
"state": "translated",
"value": "Keging"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keging"
"ja": {
"stringUnit": {
"state": "translated",
"value": "Keging"
}
}
}
},
"NSHealthUpdateUsageDescription": {
"extractionState": "manual",
"localizations": {
"ko": {
"stringUnit": {
"state": "translated",
"value": "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Used to save your finished Kegel workouts to Apple Health."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。"
}
}
}
},
"NSHealthShareUsageDescription": {
"extractionState": "manual",
"localizations": {
"ko": {
"stringUnit": {
"state": "translated",
"value": "케깅은 건강 데이터를 읽지 않아요."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Keging does not read your health data."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Kegingはヘルスケアデータを読み取りません。"
}
}
}
}
},
"version" : "1.0"
"version": "1.0"
}

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.yechan.Keging</string>

View File

@ -3,12 +3,6 @@
"strings": {
"%lld / %lld": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "%1$lld / %2$lld"
}
},
"en": {
"stringUnit": {
"state": "translated",
@ -20,6 +14,34 @@
"state": "translated",
"value": "%lld / %lld"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "%1$lld / %2$lld"
}
}
}
},
"%lld · %@": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "%1$lld · %2$@"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld · %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%lld · %@"
}
}
}
},
@ -41,12 +63,6 @@
},
"%lld분 %lld초": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "%1$lld분 %2$lld초"
}
},
"en": {
"stringUnit": {
"state": "translated",
@ -58,6 +74,12 @@
"state": "translated",
"value": "%lld分%lld秒"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "%1$lld분 %2$lld초"
}
}
}
},
@ -377,6 +399,22 @@
}
}
},
"보통": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Moderate"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "普通"
}
}
}
},
"사이클": {
"localizations": {
"en": {
@ -491,12 +529,6 @@
},
"수축 %lld초 · 이완 %lld초 · %lld회": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회"
}
},
"en": {
"stringUnit": {
"state": "translated",
@ -508,17 +540,17 @@
"state": "translated",
"value": "締める%lld秒 · ゆるめる%lld秒 · %lld回"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회"
}
}
}
},
"수축 → 이완을 %lld회 반복해요. 한 세트 약 %@.": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "수축 → 이완을 %1$lld회 반복해요. 한 세트 약 %2$@."
}
},
"en": {
"stringUnit": {
"state": "translated",
@ -530,6 +562,12 @@
"state": "translated",
"value": "締める→ゆるめるを%lld回くり返します。1セットは約%@です。"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "수축 → 이완을 %1$lld회 반복해요. 한 세트 약 %2$@."
}
}
}
},
@ -597,6 +635,22 @@
}
}
},
"쉬움": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Easy"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "楽"
}
}
}
},
"시": {
"localizations": {
"en": {
@ -677,6 +731,54 @@
}
}
},
"안 함": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Off"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "なし"
}
}
}
},
"애플 건강": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Apple Health"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ヘルスケア"
}
}
}
},
"애플 건강 연동": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Apple Health Integration"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ヘルスケア連携"
}
}
}
},
"애플워치": {
"localizations": {
"en": {
@ -853,6 +955,22 @@
}
}
},
"운동 강도 자동 기록": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Auto Effort Rating"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "運動強度の自動記録"
}
}
}
},
"운동 세팅": {
"localizations": {
"en": {
@ -949,6 +1067,38 @@
}
}
},
"운동을 끝까지 마치면 그 시간이 애플 건강에 '기타' 운동으로 자동 기록돼요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 설정에서 고른 운동 강도(1~10)도 함께 기록돼요. 워치에서 하면 워치가 심박수와 함께 기록해요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "When you finish a set, that time is automatically saved to Apple Health as an 'Other' workout. You'll be asked for Health permission the first time you start, and after allowing it everything is saved automatically. The effort rating (110) chosen in Settings is recorded too. Workouts started on the watch are recorded by the watch, including heart rate. Stopped sets are not saved to Health either, and you can turn off Keging's access in the Health app anytime."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "セットを最後まで終えると、その時間がヘルスケアに「その他」のワークアウトとして自動記録されます。初回開始時にヘルスケアの許可を求められ、許可すればその後は自動で保存されます。設定で選んだ運動強度1〜10も一緒に記録されます。ウォッチから実行した場合は、心拍数とともにウォッチが記録します。中断したセットはヘルスケアにも記録されず、記録したくない場合はヘルスケアアプリでKegingのアクセスをいつでもオフにできます。"
}
}
}
},
"운동을 끝까지 마치면 그 시간이 애플 건강에 '기타' 운동으로 자동 기록되고, 여기서 고른 운동 강도(1~10)도 함께 저장돼요. 기록을 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "When you finish a set, that time is automatically saved to Apple Health as an 'Other' workout, along with the effort rating (110) you choose here. If you don't want this, turn off Keging's access in the Health app."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "セットを最後まで終えると、その時間がヘルスケアに「その他」のワークアウトとして自動記録され、ここで選んだ運動強度1〜10も一緒に保存されます。記録したくない場合は、ヘルスケアアプリでKegingのアクセスをオフにしてください。"
}
}
}
},
"운동을 중단할까요?": {
"localizations": {
"en": {
@ -1141,6 +1291,22 @@
}
}
},
"전력": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "All Out"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "全力"
}
}
}
},
"주간": {
"localizations": {
"en": {
@ -1543,12 +1709,6 @@
},
"현재 %lld번째, 전체 %lld회": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "현재 %1$lld번째, 전체 %2$lld회"
}
},
"en": {
"stringUnit": {
"state": "translated",
@ -1560,6 +1720,12 @@
"state": "translated",
"value": "%lld回目、全%lld回"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "현재 %1$lld번째, 전체 %2$lld회"
}
}
}
},
@ -1610,6 +1776,22 @@
}
}
}
},
"힘듦": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Hard"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ハード"
}
}
}
}
},
"version": "1.0"

View File

@ -30,15 +30,19 @@ final class WorkoutCoordinator {
LiveActivityManager.shared.update(phase: phase, rep: rep, totalReps: config.reps, phaseEnd: phaseEnd)
}
}
engine.onSessionEnd = { _, _ in
engine.onSessionEnd = { completed, record in
LiveActivityManager.shared.end()
BackgroundAudioKeeper.shared.stop()
UIApplication.shared.isIdleTimerDisabled = false
if completed, let record {
HealthRecorder.shared.save(record: record, effortScore: SettingsStore.shared.config.effortScore)
}
}
}
func startWorkout() {
guard !KegelEngine.shared.isRunning else { return }
HealthRecorder.shared.requestAuthorizationIfNeeded()
BackgroundAudioKeeper.shared.start()
UIApplication.shared.isIdleTimerDisabled = true
KegelEngine.shared.start(config: SettingsStore.shared.config)

View File

@ -2,10 +2,15 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 손목을 내려도 세션이 이어지도록 확장 런타임 세션(피지컬 테라피) 허용 -->
<!-- 워크아웃 세션(우선)·확장 런타임 세션(폴백) — 손목을 내려도 세션이 이어지게 -->
<key>WKBackgroundModes</key>
<array>
<string>workout-processing</string>
<string>physical-therapy</string>
</array>
<key>NSHealthUpdateUsageDescription</key>
<string>케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요.</string>
<key>NSHealthShareUsageDescription</key>
<string>케깅은 건강 데이터를 읽지 않아요.</string>
</dict>
</plist>

View File

@ -1,29 +1,75 @@
{
"sourceLanguage" : "ko",
"strings" : {
"CFBundleDisplayName" : {
"extractionState" : "manual",
"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "케깅"
"sourceLanguage": "ko",
"strings": {
"CFBundleDisplayName": {
"extractionState": "manual",
"localizations": {
"ko": {
"stringUnit": {
"state": "translated",
"value": "케깅"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keging"
"en": {
"stringUnit": {
"state": "translated",
"value": "Keging"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keging"
"ja": {
"stringUnit": {
"state": "translated",
"value": "Keging"
}
}
}
},
"NSHealthUpdateUsageDescription": {
"extractionState": "manual",
"localizations": {
"ko": {
"stringUnit": {
"state": "translated",
"value": "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Used to save your finished Kegel workouts to Apple Health."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。"
}
}
}
},
"NSHealthShareUsageDescription": {
"extractionState": "manual",
"localizations": {
"ko": {
"stringUnit": {
"state": "translated",
"value": "케깅은 건강 데이터를 읽지 않아요."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Keging does not read your health data."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Kegingはヘルスケアデータを読み取りません。"
}
}
}
}
},
"version" : "1.0"
"version": "1.0"
}

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.yechan.Keging</string>

View File

@ -26,7 +26,7 @@ final class WatchCoordinator {
WatchHaptics.play(phase == .contract ? config.contractPattern : config.relaxPattern)
}
engine.onSessionEnd = { completed, record in
RuntimeSessionManager.shared.stop()
WatchWorkoutSession.shared.end(completed: completed, effortScore: SettingsStore.shared.config.effortScore)
if completed, let record {
WatchSync.shared.sendRecord(record)
WatchHaptics.playSuccess()
@ -36,8 +36,8 @@ final class WatchCoordinator {
func startWorkout() {
guard !KegelEngine.shared.isRunning else { return }
RuntimeSessionManager.shared.start()
KegelEngine.shared.start(config: SettingsStore.shared.config)
WatchWorkoutSession.shared.begin(startDate: Date())
}
func stopWorkout() {

View File

@ -0,0 +1,88 @@
//
// WatchWorkoutSession.swift
// KegingWatch Watch App
//
// '' ( )
// . ( ) .
// ( ) .
//
import HealthKit
@MainActor
final class WatchWorkoutSession: NSObject {
static let shared = WatchWorkoutSession()
private let store = HKHealthStore()
private var session: HKWorkoutSession?
private var builder: HKLiveWorkoutBuilder?
private override init() { super.init() }
private static var shareTypes: Set<HKSampleType> {
var types: Set<HKSampleType> = [HKObjectType.workoutType()]
if #available(watchOS 11.0, *) {
types.insert(HKQuantityType(.workoutEffortScore))
}
return types
}
/// ·
func begin(startDate: Date) {
Task { @MainActor in
guard HKHealthStore.isHealthDataAvailable() else {
RuntimeSessionManager.shared.start()
return
}
do {
try await requestAuthorization()
//
guard KegelEngine.shared.isRunning, session == nil else { return }
let configuration = HKWorkoutConfiguration()
configuration.activityType = .other
configuration.locationType = .unknown
let newSession = try HKWorkoutSession(healthStore: store, configuration: configuration)
let newBuilder = newSession.associatedWorkoutBuilder()
newBuilder.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: configuration)
newSession.startActivity(with: startDate)
try await HealthKitCalls.beginCollection(newBuilder, at: startDate)
session = newSession
builder = newBuilder
} catch {
RuntimeSessionManager.shared.start()
}
}
}
/// (+ ),
func end(completed: Bool, effortScore: Int) {
RuntimeSessionManager.shared.stop()
guard let session, let builder else { return }
self.session = nil
self.builder = nil
session.end()
Task { @MainActor [store] in
do {
if completed {
try await HealthKitCalls.endCollection(builder, at: Date())
let workout = try await HealthKitCalls.finishWorkout(builder)
if #available(watchOS 11.0, *), effortScore > 0, let workout {
try? await HealthKitCalls.relateEffort(store: store, workout: workout, score: effortScore)
}
} else {
builder.discardWorkout()
}
} catch {
//
}
}
}
private func requestAuthorization() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
store.requestAuthorization(toShare: Self.shareTypes, read: nil) { _, error in
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
}
}
}
}

View File

@ -0,0 +1,50 @@
//
// HealthKitCalls.swift
// Keging
//
// HealthKit completion API async iOS( )·( )
//
import HealthKit
nonisolated enum HealthKitCalls {
static func beginCollection(_ builder: HKWorkoutBuilder, at date: Date) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
builder.beginCollection(withStart: date) { _, error in
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
}
}
}
static func endCollection(_ builder: HKWorkoutBuilder, at date: Date) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
builder.endCollection(withEnd: date) { _, error in
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
}
}
}
static func finishWorkout(_ builder: HKWorkoutBuilder) async throws -> HKWorkout? {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<HKWorkout?, Error>) in
builder.finishWorkout { workout, error in
if let error { continuation.resume(throwing: error) } else { continuation.resume(returning: workout) }
}
}
}
/// (1~10)
@available(iOS 18.0, watchOS 11.0, *)
static func relateEffort(store: HKHealthStore, workout: HKWorkout, score: Int) async throws {
let sample = HKQuantitySample(
type: HKQuantityType(.workoutEffortScore),
quantity: HKQuantity(unit: .appleEffortScore(), doubleValue: Double(min(max(score, 1), 10))),
start: workout.startDate,
end: workout.endDate
)
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
store.relateWorkoutEffortSample(sample, with: workout, activity: nil) { _, error in
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
}
}
}
}

View File

@ -41,6 +41,35 @@ nonisolated struct WorkoutConfig: Codable, Equatable {
var reps: Int = 20
var contractPattern: HapticPattern = .twice
var relaxPattern: HapticPattern = .once
/// (0 = , 1~10)
var effortScore: Int = 2
init(
contractSeconds: Int = 10,
relaxSeconds: Int = 5,
reps: Int = 20,
contractPattern: HapticPattern = .twice,
relaxPattern: HapticPattern = .once,
effortScore: Int = 2
) {
self.contractSeconds = contractSeconds
self.relaxSeconds = relaxSeconds
self.reps = reps
self.contractPattern = contractPattern
self.relaxPattern = relaxPattern
self.effortScore = effortScore
}
// JSON decodeIfPresent
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
contractSeconds = try container.decodeIfPresent(Int.self, forKey: .contractSeconds) ?? 10
relaxSeconds = try container.decodeIfPresent(Int.self, forKey: .relaxSeconds) ?? 5
reps = try container.decodeIfPresent(Int.self, forKey: .reps) ?? 20
contractPattern = try container.decodeIfPresent(HapticPattern.self, forKey: .contractPattern) ?? .twice
relaxPattern = try container.decodeIfPresent(HapticPattern.self, forKey: .relaxPattern) ?? .once
effortScore = try container.decodeIfPresent(Int.self, forKey: .effortScore) ?? 2
}
var totalDuration: TimeInterval { Double(reps * (contractSeconds + relaxSeconds)) }