From 893545e7bec6dc4f94cdbf11f210ebee9552d49e Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sat, 29 Aug 2026 19:51:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(keging):=20=EC=95=A0=ED=94=8C=20=EA=B1=B4?= =?UTF-8?q?=EA=B0=95=20=EC=97=B0=EB=8F=99=20=E2=80=94=20=EC=99=84=EC=A3=BC?= =?UTF-8?q?=20=EC=84=B8=ED=8A=B8=20=EC=9A=B4=EB=8F=99=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=20+=20=EC=9A=B4=EB=8F=99=20=EA=B0=95=EB=8F=84(1~10)=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 완주한 세트만 애플 건강에 '기타' 운동으로 자동 기록 (중단=미기록 규칙 일관) - 폰=사후 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 Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT --- myApp/Keging/CLAUDE.md | 23 +- myApp/Keging/IOS/AppSettingsView.swift | 24 ++ myApp/Keging/IOS/HealthRecorder.swift | 53 ++++ myApp/Keging/IOS/HelpView.swift | 5 + myApp/Keging/IOS/Info.plist | 5 + myApp/Keging/IOS/InfoPlist.xcstrings | 84 ++++-- myApp/Keging/IOS/Keging.entitlements | 2 + myApp/Keging/IOS/Localizable.xcstrings | 242 +++++++++++++++--- myApp/Keging/IOS/WorkoutCoordinator.swift | 6 +- myApp/Keging/KegingWatch Watch App/Info.plist | 7 +- .../KegingWatch Watch App/InfoPlist.xcstrings | 84 ++++-- .../KegingWatch Watch App.entitlements | 2 + .../WatchCoordinator.swift | 4 +- .../WatchWorkoutSession.swift | 88 +++++++ myApp/Keging/Shared/HealthKitCalls.swift | 50 ++++ myApp/Keging/Shared/WorkoutSettings.swift | 29 +++ 16 files changed, 630 insertions(+), 78 deletions(-) create mode 100644 myApp/Keging/IOS/HealthRecorder.swift create mode 100644 myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift create mode 100644 myApp/Keging/Shared/HealthKitCalls.swift diff --git a/myApp/Keging/CLAUDE.md b/myApp/Keging/CLAUDE.md index 9ec5de2..43d83ca 100644 --- a/myApp/Keging/CLAUDE.md +++ b/myApp/Keging/CLAUDE.md @@ -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`) diff --git a/myApp/Keging/IOS/AppSettingsView.swift b/myApp/Keging/IOS/AppSettingsView.swift index 047bc4e..4a61799 100644 --- a/myApp/Keging/IOS/AppSettingsView.swift +++ b/myApp/Keging/IOS/AppSettingsView.swift @@ -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" diff --git a/myApp/Keging/IOS/HealthRecorder.swift b/myApp/Keging/IOS/HealthRecorder.swift new file mode 100644 index 0000000..a37de11 --- /dev/null +++ b/myApp/Keging/IOS/HealthRecorder.swift @@ -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 { + [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 { + // 권한 없음 등 — 무시 + } + } + } +} diff --git a/myApp/Keging/IOS/HelpView.swift b/myApp/Keging/IOS/HelpView.swift index 3abefaa..0d81292 100644 --- a/myApp/Keging/IOS/HelpView.swift +++ b/myApp/Keging/IOS/HelpView.swift @@ -89,6 +89,11 @@ struct HelpView: View { } Section { + helpRow( + symbol: "heart.circle", + title: Text("애플 건강 연동"), + body: Text("운동을 끝까지 마치면 그 시간이 애플 건강에 '기타' 운동으로 자동 기록돼요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 설정에서 고른 운동 강도(1~10)도 함께 기록돼요. 워치에서 하면 워치가 심박수와 함께 기록해요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.") + ) helpRow( symbol: "paintbrush", title: Text("테마와 언어"), diff --git a/myApp/Keging/IOS/Info.plist b/myApp/Keging/IOS/Info.plist index f188152..36f9dcb 100644 --- a/myApp/Keging/IOS/Info.plist +++ b/myApp/Keging/IOS/Info.plist @@ -7,5 +7,10 @@ audio + + NSHealthUpdateUsageDescription + 케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요. + NSHealthShareUsageDescription + 케깅은 건강 데이터를 읽지 않아요. diff --git a/myApp/Keging/IOS/InfoPlist.xcstrings b/myApp/Keging/IOS/InfoPlist.xcstrings index 06afc2b..c4bd58b 100644 --- a/myApp/Keging/IOS/InfoPlist.xcstrings +++ b/myApp/Keging/IOS/InfoPlist.xcstrings @@ -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" +} \ No newline at end of file diff --git a/myApp/Keging/IOS/Keging.entitlements b/myApp/Keging/IOS/Keging.entitlements index c54dbc5..60aa2ba 100644 --- a/myApp/Keging/IOS/Keging.entitlements +++ b/myApp/Keging/IOS/Keging.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.healthkit + com.apple.security.application-groups group.com.yechan.Keging diff --git a/myApp/Keging/IOS/Localizable.xcstrings b/myApp/Keging/IOS/Localizable.xcstrings index 997bab2..b939609 100644 --- a/myApp/Keging/IOS/Localizable.xcstrings +++ b/myApp/Keging/IOS/Localizable.xcstrings @@ -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 (1–10) 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 (1–10) 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" diff --git a/myApp/Keging/IOS/WorkoutCoordinator.swift b/myApp/Keging/IOS/WorkoutCoordinator.swift index d3e7ed8..b0e8e70 100644 --- a/myApp/Keging/IOS/WorkoutCoordinator.swift +++ b/myApp/Keging/IOS/WorkoutCoordinator.swift @@ -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) diff --git a/myApp/Keging/KegingWatch Watch App/Info.plist b/myApp/Keging/KegingWatch Watch App/Info.plist index 55dcf3f..89a1aa3 100644 --- a/myApp/Keging/KegingWatch Watch App/Info.plist +++ b/myApp/Keging/KegingWatch Watch App/Info.plist @@ -2,10 +2,15 @@ - + WKBackgroundModes + workout-processing physical-therapy + NSHealthUpdateUsageDescription + 케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요. + NSHealthShareUsageDescription + 케깅은 건강 데이터를 읽지 않아요. diff --git a/myApp/Keging/KegingWatch Watch App/InfoPlist.xcstrings b/myApp/Keging/KegingWatch Watch App/InfoPlist.xcstrings index 06afc2b..c4bd58b 100644 --- a/myApp/Keging/KegingWatch Watch App/InfoPlist.xcstrings +++ b/myApp/Keging/KegingWatch Watch App/InfoPlist.xcstrings @@ -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" +} \ No newline at end of file diff --git a/myApp/Keging/KegingWatch Watch App/KegingWatch Watch App.entitlements b/myApp/Keging/KegingWatch Watch App/KegingWatch Watch App.entitlements index c54dbc5..60aa2ba 100644 --- a/myApp/Keging/KegingWatch Watch App/KegingWatch Watch App.entitlements +++ b/myApp/Keging/KegingWatch Watch App/KegingWatch Watch App.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.healthkit + com.apple.security.application-groups group.com.yechan.Keging diff --git a/myApp/Keging/KegingWatch Watch App/WatchCoordinator.swift b/myApp/Keging/KegingWatch Watch App/WatchCoordinator.swift index 16e021f..4bc5625 100644 --- a/myApp/Keging/KegingWatch Watch App/WatchCoordinator.swift +++ b/myApp/Keging/KegingWatch Watch App/WatchCoordinator.swift @@ -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() { diff --git a/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift b/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift new file mode 100644 index 0000000..f924904 --- /dev/null +++ b/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift @@ -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 { + var types: Set = [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) in + store.requestAuthorization(toShare: Self.shareTypes, read: nil) { _, error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } + } +} diff --git a/myApp/Keging/Shared/HealthKitCalls.swift b/myApp/Keging/Shared/HealthKitCalls.swift new file mode 100644 index 0000000..a87f7f4 --- /dev/null +++ b/myApp/Keging/Shared/HealthKitCalls.swift @@ -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) 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) 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) 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) in + store.relateWorkoutEffortSample(sample, with: workout, activity: nil) { _, error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } + } +} diff --git a/myApp/Keging/Shared/WorkoutSettings.swift b/myApp/Keging/Shared/WorkoutSettings.swift index 957447c..914969b 100644 --- a/myApp/Keging/Shared/WorkoutSettings.swift +++ b/myApp/Keging/Shared/WorkoutSettings.swift @@ -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)) }