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:
parent
3f91e8889c
commit
893545e7be
@ -18,9 +18,9 @@
|
|||||||
| 플랫폼 | iOS(iPhone 전용, 세로 고정) + watchOS 앱 + Live Activity(다이나믹 아일랜드) + 단축어(App Intents) |
|
| 플랫폼 | iOS(iPhone 전용, 세로 고정) + watchOS 앱 + Live Activity(다이나믹 아일랜드) + 단축어(App Intents) |
|
||||||
| 번들 ID | `com.yechan.Keging` / 워치 `.watchkitapp` / 위젯 `.KegingWidgets` / App Group `group.com.yechan.Keging` |
|
| 번들 ID | `com.yechan.Keging` / 워치 `.watchkitapp` / 위젯 `.KegingWidgets` / App Group `group.com.yechan.Keging` |
|
||||||
| 최소 OS | iOS 18.0 / watchOS 10.0 |
|
| 최소 OS | iOS 18.0 / watchOS 10.0 |
|
||||||
| 수익 모델 | **완전 무료** — 광고·결제 일절 없음. CloudKit·HealthKit 등 외부 서비스 없음 |
|
| 수익 모델 | **완전 무료** — 광고·결제 일절 없음. 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 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. 빌드·검증 환경
|
## 2. 빌드·검증 환경
|
||||||
|
|
||||||
@ -41,6 +41,7 @@ IOS/ # iOS 앱 타깃 (Keging)
|
|||||||
BackgroundAudioKeeper.swift # 운동 중 무음 오디오 루프 (백그라운드 타이머 유지, mixWithOthers)
|
BackgroundAudioKeeper.swift # 운동 중 무음 오디오 루프 (백그라운드 타이머 유지, mixWithOthers)
|
||||||
LiveActivityManager.swift # Live Activity 시작/갱신/종료
|
LiveActivityManager.swift # Live Activity 시작/갱신/종료
|
||||||
PhoneSync.swift # WCSession 폰 측 (세팅 push, 워치 기록 수신·병합)
|
PhoneSync.swift # WCSession 폰 측 (세팅 push, 워치 기록 수신·병합)
|
||||||
|
HealthRecorder.swift # 완주 세트를 애플 건강에 사후 저장 (§3.7)
|
||||||
KegingIntents.swift # 단축어 "케겔 운동 시작" (openAppWhenRun + 알림/플래그)
|
KegingIntents.swift # 단축어 "케겔 운동 시작" (openAppWhenRun + 알림/플래그)
|
||||||
DebugSeed.swift # DEBUG 런치 인자 (§7)
|
DebugSeed.swift # DEBUG 런치 인자 (§7)
|
||||||
Info.plist # UIBackgroundModes=audio (GENERATE_INFOPLIST_FILE와 병합)
|
Info.plist # UIBackgroundModes=audio (GENERATE_INFOPLIST_FILE와 병합)
|
||||||
@ -53,12 +54,15 @@ Shared/ # 세 타깃 공용 (pbxproj fileSystemSynchronize
|
|||||||
SessionRecord.swift # 완주 기록 + RecordStore (App Group records.json, id 중복 병합)
|
SessionRecord.swift # 완주 기록 + RecordStore (App Group records.json, id 중복 병합)
|
||||||
KegelEngine.swift # 타이머 상태 기계 (iOS·워치 공용, §3.1)
|
KegelEngine.swift # 타이머 상태 기계 (iOS·워치 공용, §3.1)
|
||||||
KegingActivityAttributes.swift # Live Activity 상태 (canImport(ActivityKit) 가드 — 워치 제외)
|
KegingActivityAttributes.swift # Live Activity 상태 (canImport(ActivityKit) 가드 — 워치 제외)
|
||||||
|
HealthKitCalls.swift # HealthKit completion API async 래퍼 (iOS 사후 빌더·워치 라이브 빌더 공용)
|
||||||
KegingWidgets/ # Live Activity 전용 위젯 확장 (홈 위젯 없음)
|
KegingWidgets/ # Live Activity 전용 위젯 확장 (홈 위젯 없음)
|
||||||
KegingWidgetsBundle.swift / KegingWidgetsLiveActivity.swift / Info.plist
|
KegingWidgetsBundle.swift / KegingWidgetsLiveActivity.swift / Info.plist
|
||||||
KegingWatch Watch App/ # 워치 앱 타깃
|
KegingWatch Watch App/ # 워치 앱 타깃
|
||||||
KegingWatchApp.swift / ContentView.swift(WatchContentView) / WatchCoordinator.swift
|
KegingWatchApp.swift / ContentView.swift(WatchContentView) / WatchCoordinator.swift
|
||||||
WatchHaptics.swift / WatchSync.swift / RuntimeSessionManager.swift
|
WatchHaptics.swift / WatchSync.swift
|
||||||
Info.plist # WKBackgroundModes=physical-therapy (확장 런타임 세션)
|
WatchWorkoutSession.swift # HKWorkoutSession 라이브 기록(심박 포함)+백그라운드 유지 — 실패 시 폴백:
|
||||||
|
RuntimeSessionManager.swift # 확장 런타임 세션 (physical-therapy)
|
||||||
|
Info.plist # WKBackgroundModes=workout-processing·physical-therapy + 건강 문구
|
||||||
DesignAssets/app-icon.svg # 아이콘 원본 (에셋 PNG는 qlmanage 1024 렌더 → sips 알파 제거)
|
DesignAssets/app-icon.svg # 아이콘 원본 (에셋 PNG는 qlmanage 1024 렌더 → sips 알파 제거)
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -128,15 +132,22 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
|
|||||||
- `StartKegelIntent` 하나 — 앱을 열고(`openAppWhenRun`) 저장된 세팅대로 즉시 시작. 콜드 스타트 경합은 AppLaunchState 플래그 + 알림 이중 경로로 처리
|
- `StartKegelIntent` 하나 — 앱을 열고(`openAppWhenRun`) 저장된 세팅대로 즉시 시작. 콜드 스타트 경합은 AppLaunchState 플래그 + 알림 이중 경로로 처리
|
||||||
- (하루 다님 전례) 인텐트 노출 문구에 'iPhone' 단어 금지 — ITMS-90626
|
- (하루 다님 전례) 인텐트 노출 문구에 '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/`)
|
### 3.6 워치 (`KegingWatch Watch App/`)
|
||||||
- 실행하면 시작 버튼만(+세팅 요약 한 줄). 진행 중: 국면(수축=민트/이완=앰버)·카운트다운·n/총·중단 — **40mm에서도 스크롤 없이 한 화면** (46·40mm 스크린샷 검증)
|
- 실행하면 시작 버튼만(+세팅 요약 한 줄). 진행 중: 국면(수축=민트/이완=앰버)·카운트다운·n/총·중단 — **40mm에서도 스크롤 없이 한 화면** (46·40mm 스크린샷 검증)
|
||||||
- 세팅은 폰에서만 편집 — `updateApplicationContext`로 워치에 도착(`WatchSync`), 완주 기록은 `transferUserInfo`로 폰에 합류(오프라인 자동 큐)
|
- 세팅은 폰에서만 편집 — `updateApplicationContext`로 워치에 도착(`WatchSync`), 완주 기록은 `transferUserInfo`로 폰에 합류(오프라인 자동 큐)
|
||||||
- 운동 시작 시 `WKExtendedRuntimeSession`(Info.plist WKBackgroundModes=physical-therapy) — 손목을 내려도 세션 유지 시도, 실패해도 화면 켜져 있는 동안은 정상 동작
|
- 운동 시작 시 **HKWorkoutSession**(건강 기록+손목 내려도 유지 — §3.7)을 열고, 실패(권한 거부 등) 시 `WKExtendedRuntimeSession`(physical-therapy) 폴백. 둘 다 실패해도 화면 켜져 있는 동안은 정상 동작
|
||||||
|
|
||||||
## 4. 화면 구성 (탭 없음 — 애플 기본 앱 느낌)
|
## 4. 화면 구성 (탭 없음 — 애플 기본 앱 느낌)
|
||||||
|
|
||||||
- **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + 상단 중앙 통계 그랩 힌트 + 버튼 아래 현재 세팅 요약(탭=세팅)
|
- **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + 상단 중앙 통계 그랩 힌트 + 버튼 아래 현재 세팅 요약(탭=세팅)
|
||||||
- **세팅/설정/도움말**: 시트(Form/List). 설정에 테마·**언어**(시스템/한국어/English/日本語 — 재실행 시 적용) 섹션. 도움말은 5그룹 13주제(기본 사용법·통계·앱 밖에서·애플워치·설정 기타)로 전 기능 사용법 서술. 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션
|
- **세팅/설정/도움말**: 시트(Form/List). 설정에 테마·**언어**(시스템/한국어/English/日本語 — 재실행 시 적용)·**애플 건강**(운동 강도 자동 기록) 섹션. 도움말은 5그룹 14주제(기본 사용법·통계·앱 밖에서·애플워치·설정 기타)로 전 기능 사용법 서술. 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션
|
||||||
- 아이콘 전용 버튼에는 `accessibilityLabel` 필수 (하루 다님 컨벤션 계승)
|
- 아이콘 전용 버튼에는 `accessibilityLabel` 필수 (하루 다님 컨벤션 계승)
|
||||||
|
|
||||||
## 5. 테마·컬러 — 하루 다님 팔레트 계승 (`Shared/Theme.swift`)
|
## 5. 테마·컬러 — 하루 다님 팔레트 계승 (`Shared/Theme.swift`)
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AppSettingsView: View {
|
struct AppSettingsView: View {
|
||||||
|
@EnvironmentObject private var settings: SettingsStore
|
||||||
@AppStorage("app.theme", store: AppGroup.defaults) private var themeMode = "system"
|
@AppStorage("app.theme", store: AppGroup.defaults) private var themeMode = "system"
|
||||||
@AppStorage("app.language", store: AppGroup.defaults) private var appLanguage = "system"
|
@AppStorage("app.language", store: AppGroup.defaults) private var appLanguage = "system"
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@ -35,6 +36,18 @@ struct AppSettingsView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("언어를 바꾸면 앱을 완전히 종료했다가 다시 실행했을 때 적용돼요.")
|
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 {
|
Section {
|
||||||
LabeledContent("버전", value: versionText)
|
LabeledContent("버전", value: versionText)
|
||||||
} footer: {
|
} 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 {
|
private var versionText: String {
|
||||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
|
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
|
||||||
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||||
|
|||||||
53
myApp/Keging/IOS/HealthRecorder.swift
Normal file
53
myApp/Keging/IOS/HealthRecorder.swift
Normal 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 {
|
||||||
|
// 권한 없음 등 — 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -89,6 +89,11 @@ struct HelpView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
helpRow(
|
||||||
|
symbol: "heart.circle",
|
||||||
|
title: Text("애플 건강 연동"),
|
||||||
|
body: Text("운동을 끝까지 마치면 그 시간이 애플 건강에 '기타' 운동으로 자동 기록돼요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 설정에서 고른 운동 강도(1~10)도 함께 기록돼요. 워치에서 하면 워치가 심박수와 함께 기록해요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
|
||||||
|
)
|
||||||
helpRow(
|
helpRow(
|
||||||
symbol: "paintbrush",
|
symbol: "paintbrush",
|
||||||
title: Text("테마와 언어"),
|
title: Text("테마와 언어"),
|
||||||
|
|||||||
@ -7,5 +7,10 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
</array>
|
</array>
|
||||||
|
<!-- 완주한 세트를 애플 건강에 운동으로 기록 (읽기는 안 함 — 현지화는 InfoPlist.xcstrings) -->
|
||||||
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
|
<string>케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요.</string>
|
||||||
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
<string>케깅은 건강 데이터를 읽지 않아요.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -1,29 +1,75 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "ko",
|
"sourceLanguage": "ko",
|
||||||
"strings" : {
|
"strings": {
|
||||||
"CFBundleDisplayName" : {
|
"CFBundleDisplayName": {
|
||||||
"extractionState" : "manual",
|
"extractionState": "manual",
|
||||||
"localizations" : {
|
"localizations": {
|
||||||
"ko" : {
|
"ko": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "케깅"
|
"value": "케깅"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Keging"
|
"value": "Keging"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ja" : {
|
"ja": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Keging"
|
"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"
|
||||||
}
|
}
|
||||||
@ -2,6 +2,8 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.developer.healthkit</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.yechan.Keging</string>
|
<string>group.com.yechan.Keging</string>
|
||||||
|
|||||||
@ -3,12 +3,6 @@
|
|||||||
"strings": {
|
"strings": {
|
||||||
"%lld / %lld": {
|
"%lld / %lld": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"ko": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "new",
|
|
||||||
"value": "%1$lld / %2$lld"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
@ -20,6 +14,34 @@
|
|||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "%lld / %lld"
|
"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초": {
|
"%lld분 %lld초": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"ko": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "new",
|
|
||||||
"value": "%1$lld분 %2$lld초"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
@ -58,6 +74,12 @@
|
|||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "%lld分%lld秒"
|
"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": {
|
"localizations": {
|
||||||
"en": {
|
"en": {
|
||||||
@ -491,12 +529,6 @@
|
|||||||
},
|
},
|
||||||
"수축 %lld초 · 이완 %lld초 · %lld회": {
|
"수축 %lld초 · 이완 %lld초 · %lld회": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"ko": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "new",
|
|
||||||
"value": "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
@ -508,17 +540,17 @@
|
|||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "締める%lld秒 · ゆるめる%lld秒 · %lld回"
|
"value": "締める%lld秒 · ゆるめる%lld秒 · %lld回"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "new",
|
||||||
|
"value": "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"수축 → 이완을 %lld회 반복해요. 한 세트 약 %@.": {
|
"수축 → 이완을 %lld회 반복해요. 한 세트 약 %@.": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"ko": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "new",
|
|
||||||
"value": "수축 → 이완을 %1$lld회 반복해요. 한 세트 약 %2$@."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
@ -530,6 +562,12 @@
|
|||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "締める→ゆるめるを%lld回くり返します。1セットは約%@です。"
|
"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": {
|
"localizations": {
|
||||||
"en": {
|
"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": {
|
"localizations": {
|
||||||
"en": {
|
"en": {
|
||||||
@ -853,6 +955,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"운동 강도 자동 기록": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Auto Effort Rating"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "運動強度の自動記録"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"운동 세팅": {
|
"운동 세팅": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"en": {
|
"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": {
|
"localizations": {
|
||||||
"en": {
|
"en": {
|
||||||
@ -1141,6 +1291,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"전력": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "All Out"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "全力"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"주간": {
|
"주간": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"en": {
|
"en": {
|
||||||
@ -1543,12 +1709,6 @@
|
|||||||
},
|
},
|
||||||
"현재 %lld번째, 전체 %lld회": {
|
"현재 %lld번째, 전체 %lld회": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"ko": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "new",
|
|
||||||
"value": "현재 %1$lld번째, 전체 %2$lld회"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
@ -1560,6 +1720,12 @@
|
|||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "%lld回目、全%lld回"
|
"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"
|
"version": "1.0"
|
||||||
|
|||||||
@ -30,15 +30,19 @@ final class WorkoutCoordinator {
|
|||||||
LiveActivityManager.shared.update(phase: phase, rep: rep, totalReps: config.reps, phaseEnd: phaseEnd)
|
LiveActivityManager.shared.update(phase: phase, rep: rep, totalReps: config.reps, phaseEnd: phaseEnd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
engine.onSessionEnd = { _, _ in
|
engine.onSessionEnd = { completed, record in
|
||||||
LiveActivityManager.shared.end()
|
LiveActivityManager.shared.end()
|
||||||
BackgroundAudioKeeper.shared.stop()
|
BackgroundAudioKeeper.shared.stop()
|
||||||
UIApplication.shared.isIdleTimerDisabled = false
|
UIApplication.shared.isIdleTimerDisabled = false
|
||||||
|
if completed, let record {
|
||||||
|
HealthRecorder.shared.save(record: record, effortScore: SettingsStore.shared.config.effortScore)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startWorkout() {
|
func startWorkout() {
|
||||||
guard !KegelEngine.shared.isRunning else { return }
|
guard !KegelEngine.shared.isRunning else { return }
|
||||||
|
HealthRecorder.shared.requestAuthorizationIfNeeded()
|
||||||
BackgroundAudioKeeper.shared.start()
|
BackgroundAudioKeeper.shared.start()
|
||||||
UIApplication.shared.isIdleTimerDisabled = true
|
UIApplication.shared.isIdleTimerDisabled = true
|
||||||
KegelEngine.shared.start(config: SettingsStore.shared.config)
|
KegelEngine.shared.start(config: SettingsStore.shared.config)
|
||||||
|
|||||||
@ -2,10 +2,15 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<!-- 손목을 내려도 세션이 이어지도록 확장 런타임 세션(피지컬 테라피) 허용 -->
|
<!-- 워크아웃 세션(우선)·확장 런타임 세션(폴백) — 손목을 내려도 세션이 이어지게 -->
|
||||||
<key>WKBackgroundModes</key>
|
<key>WKBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
|
<string>workout-processing</string>
|
||||||
<string>physical-therapy</string>
|
<string>physical-therapy</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
|
<string>케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요.</string>
|
||||||
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
<string>케깅은 건강 데이터를 읽지 않아요.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -1,29 +1,75 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "ko",
|
"sourceLanguage": "ko",
|
||||||
"strings" : {
|
"strings": {
|
||||||
"CFBundleDisplayName" : {
|
"CFBundleDisplayName": {
|
||||||
"extractionState" : "manual",
|
"extractionState": "manual",
|
||||||
"localizations" : {
|
"localizations": {
|
||||||
"ko" : {
|
"ko": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "케깅"
|
"value": "케깅"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Keging"
|
"value": "Keging"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ja" : {
|
"ja": {
|
||||||
"stringUnit" : {
|
"stringUnit": {
|
||||||
"state" : "translated",
|
"state": "translated",
|
||||||
"value" : "Keging"
|
"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"
|
||||||
}
|
}
|
||||||
@ -2,6 +2,8 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.developer.healthkit</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.yechan.Keging</string>
|
<string>group.com.yechan.Keging</string>
|
||||||
|
|||||||
@ -26,7 +26,7 @@ final class WatchCoordinator {
|
|||||||
WatchHaptics.play(phase == .contract ? config.contractPattern : config.relaxPattern)
|
WatchHaptics.play(phase == .contract ? config.contractPattern : config.relaxPattern)
|
||||||
}
|
}
|
||||||
engine.onSessionEnd = { completed, record in
|
engine.onSessionEnd = { completed, record in
|
||||||
RuntimeSessionManager.shared.stop()
|
WatchWorkoutSession.shared.end(completed: completed, effortScore: SettingsStore.shared.config.effortScore)
|
||||||
if completed, let record {
|
if completed, let record {
|
||||||
WatchSync.shared.sendRecord(record)
|
WatchSync.shared.sendRecord(record)
|
||||||
WatchHaptics.playSuccess()
|
WatchHaptics.playSuccess()
|
||||||
@ -36,8 +36,8 @@ final class WatchCoordinator {
|
|||||||
|
|
||||||
func startWorkout() {
|
func startWorkout() {
|
||||||
guard !KegelEngine.shared.isRunning else { return }
|
guard !KegelEngine.shared.isRunning else { return }
|
||||||
RuntimeSessionManager.shared.start()
|
|
||||||
KegelEngine.shared.start(config: SettingsStore.shared.config)
|
KegelEngine.shared.start(config: SettingsStore.shared.config)
|
||||||
|
WatchWorkoutSession.shared.begin(startDate: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopWorkout() {
|
func stopWorkout() {
|
||||||
|
|||||||
88
myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift
Normal file
88
myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift
Normal 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
myApp/Keging/Shared/HealthKitCalls.swift
Normal file
50
myApp/Keging/Shared/HealthKitCalls.swift
Normal 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -41,6 +41,35 @@ nonisolated struct WorkoutConfig: Codable, Equatable {
|
|||||||
var reps: Int = 20
|
var reps: Int = 20
|
||||||
var contractPattern: HapticPattern = .twice
|
var contractPattern: HapticPattern = .twice
|
||||||
var relaxPattern: HapticPattern = .once
|
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)) }
|
var totalDuration: TimeInterval { Double(reps * (contractSeconds + relaxSeconds)) }
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user