feat(keging): 아이폰 시작 세트의 워치 심박 측정 — startWatchApp + 미러링 세션, 배지·사전 경고 폐지 (9차)
- 의도(사용자 TestFlight 피드백): 폰으로 운동해도 워치를 차고 있으면 워치가 심박을 재고, 진동은 시작한 기기에서만. 종전 '워치 연동됨' 배지는 워치 앱 포그라운드 여부(isReachable)만 보여 주던 것이라 폐지 - iOS: WatchHeartRateBridge — 시작 시 HKHealthStore.startWatchApp(폰 설정 운동 유형)으로 워치 앱을 깨우고, 워치가 미러링한 HKWorkoutSession을 workoutSessionMirroringStartHandler로 받아 심박 수신, 완주/중단 명령 전송(finish/discard). 워치를 못 열거나 30초 안에 미러링이 안 붙으면 폰 단독 (+설정에 따라 '워치 앱을 열지 못했어요' 알럿, app.watchWarn 재사용) 및 WatchConnectivity로 워치 세션 폐기 명령. 워치 저장 결과(saved/failed)는 WC로 회신받고 15초 내 미회신이면 폰 사후 저장 폴백. 워치 깨우기는 폰 건강 권한 응답 뒤에(첫 실행 경합 방지). 건강 읽기 권한(심박·활성 에너지) 추가 — 워치 세션 센서 수집용(권한 공유) - 워치: WatchAppDelegate.handle(workoutConfiguration) → MirroredWorkoutSession(라이브 빌더, 무진동·무타이머, 심박을 미러링 채널로 전송, finish 시 저장+강도 relate 후 WC로 결과 통보, discard/끊김 시 폐기, 미러링 30초 타임아웃, 앱 재기동 시 잔류 세션 복구·폐기). 화면 '아이폰에서 진행 중 ♥bpm'(중단 버튼). WatchHealthAuth로 권한 공용화 - UI: 첫 화면 배지·확인 창 제거, 운동 화면 횟수 아래 워치 상태 줄(연결 중/♥bpm/아이폰만), 설정 토글 '워치 연결 실패 경고', 도움말 3건 갱신. l10n 카탈로그 3종 missing/stale 0 - DEBUG: -testConfig(35초 세트), -mockWatchHeartRate <bpm>(상태 줄 QA·스크린샷). watchHR 카테고리 notice 로그 - 시뮬 검증(페어링 심): startWatchApp → 워치 기동·권한·세션·가짜 심박·'아이폰에서 진행 중'까지 동작, 워치 startMirroring 성공 반환 — 그러나 폰 미러링 핸들러는 시뮬에서 불리지 않음(애플 샘플도 실기기 전용) → 30초 타임아웃·알럿·WC 폐기·폰 폴백 저장 경로 확인, 워치 자체 세트 정상. 3타깃 빌드 그린·경고 0. 미러링 수신·완주 저장·회신은 실기기 TestFlight 재테스트 필요 - 문서·마케팅: CLAUDE.md §3.8 신설·§1/§3.7/§4/§6/§7 갱신, 설명 3언어·whats-new·review-notes·PRIVACY.md (아이폰 읽기 권한 설명 — GitHub 게시본 갱신 필요), 스크린샷 01·08(배지 제거)·02·03(♥78 상태 줄) 재촬영·합성 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
@ -1,6 +1,6 @@
|
||||
# 케깅 개인정보처리방침 / Keging Privacy Policy
|
||||
|
||||
- 시행일 / Effective date / 施行日: 2026-09-05
|
||||
- 시행일 / Effective date / 施行日: 2026-09-05 (개정 / Revised / 改定: 2026-09-06)
|
||||
- 문의 / Contact / お問い合わせ: **songyc.eng@gmail.com**
|
||||
|
||||
한국어 · [English](#english) · [日本語](#日本語)
|
||||
@ -27,10 +27,10 @@
|
||||
|
||||
| 권한 | 용도 |
|
||||
|---|---|
|
||||
| 건강(HealthKit) — 쓰기 | 끝까지 마친 케겔 운동 세트를 사용자 본인의 건강 앱에 **운동**(운동 시간·유형, 선택한 경우 운동 강도, 추정 활성 에너지)으로 저장 |
|
||||
| 건강(HealthKit) — 읽기 (애플워치만) | 워치에서 운동하는 동안 **심박수와 활성 에너지**를 측정해 그 운동 기록에 포함. 그 외 건강 데이터는 읽지 않음 |
|
||||
| 건강(HealthKit) — 쓰기 | 끝까지 마친 케겔 운동 세트를 사용자 본인의 건강 앱에 **운동**(운동 시간·유형, 선택한 경우 운동 강도, 활성 에너지)으로 저장 |
|
||||
| 건강(HealthKit) — 읽기 | 애플워치를 차고 운동하는 동안(워치에서 시작했든, 아이폰에서 시작해 워치 앱이 자동으로 열렸든) 워치가 **심박수와 활성 에너지**를 측정해 그 운동 기록에 포함. 아이폰 앱은 운동 중 워치가 보내 주는 현재 심박수를 화면에 보여 줄 뿐 저장하지 않으며, 건강 앱의 데이터를 직접 조회하지 않음. 그 외 건강 데이터는 읽지 않음 |
|
||||
|
||||
건강 데이터는 특히 엄격하게 다룹니다: 앱은 읽어 온 심박수·활성 에너지를 **해당 운동 기록에 담는 용도로만** 사용하며, 이를 **서버로 전송하지 않고 앱 밖으로 내보내지 않습니다.** 건강 데이터를 광고·마케팅 등 다른 목적에 사용하지 않습니다. 건강 기록을 원치 않으면 권한 요청을 거절하거나 건강 앱 → 공유 → 앱에서 케깅의 권한을 끄면 되며, 앱의 다른 기능은 그대로 사용할 수 있습니다.
|
||||
건강 데이터는 특히 엄격하게 다룹니다: 앱은 측정한 심박수·활성 에너지를 **해당 운동 기록에 담고 운동 중 화면에 표시하는 용도로만** 사용하며, 이를 **서버로 전송하지 않고 앱 밖으로 내보내지 않습니다.** 건강 데이터를 광고·마케팅 등 다른 목적에 사용하지 않습니다. 건강 기록을 원치 않으면 권한 요청을 거절하거나 건강 앱 → 공유 → 앱에서 케깅의 권한을 끄면 되며, 앱의 다른 기능은 그대로 사용할 수 있습니다.
|
||||
|
||||
앱은 운동 중 화면이 꺼져 있어도 진동 안내와 실시간 현황(Live Activity)이 이어지도록 백그라운드 오디오 세션을 사용합니다. 이 세션은 소리를 녹음하지 않으며 마이크 권한도 사용하지 않습니다.
|
||||
|
||||
@ -77,10 +77,10 @@ The app uses the permissions below **only if you grant them, and only for the st
|
||||
|
||||
| Permission | Purpose |
|
||||
|---|---|
|
||||
| Health (HealthKit) — write | Saving each fully completed Kegel set to your own Health app as a **workout** (duration and type, the effort level if you chose one, and an estimated active energy value) |
|
||||
| Health (HealthKit) — read (Apple Watch only) | Measuring **heart rate and active energy** during a set on the watch so they can be included in that workout. No other health data is read |
|
||||
| Health (HealthKit) — write | Saving each fully completed Kegel set to your own Health app as a **workout** (duration and type, the effort level if you chose one, and active energy) |
|
||||
| Health (HealthKit) — read | While you exercise wearing an Apple Watch (whether you started on the watch or on iPhone, which opens the watch app automatically), the watch measures **heart rate and active energy** so they can be included in that workout. The iPhone app only displays the current heart rate the watch sends during the set; it does not store it and never queries data in the Health app. No other health data is read |
|
||||
|
||||
Health data is handled with particular care: the app uses the heart rate and active energy it reads **only to include them in that workout record**, and **never sends them to a server or exports them outside the app.** Health data is never used for advertising, marketing, or any other purpose. If you prefer not to record to Health, decline the permission request or turn Keging off under Health app → Sharing → Apps; every other feature keeps working.
|
||||
Health data is handled with particular care: the app uses the measured heart rate and active energy **only to include them in that workout record and to show them on screen during the set**, and **never sends them to a server or exports them outside the app.** Health data is never used for advertising, marketing, or any other purpose. If you prefer not to record to Health, decline the permission request or turn Keging off under Health app → Sharing → Apps; every other feature keeps working.
|
||||
|
||||
The app uses a background audio session so that haptic cues and the live status (Live Activity) continue while the screen is off during a set. This session does not record sound and the app does not use the microphone permission.
|
||||
|
||||
@ -127,10 +127,10 @@ Privacy inquiries: **songyc.eng@gmail.com**
|
||||
|
||||
| 権限 | 用途 |
|
||||
|---|---|
|
||||
| ヘルスケア(HealthKit)— 書き込み | 最後まで終えたケーゲル体操のセットを、ご自身のヘルスケアアプリに**ワークアウト**(時間・種類、選択した場合は運動強度、推定アクティブエネルギー)として保存 |
|
||||
| ヘルスケア(HealthKit)— 読み取り(Apple Watchのみ) | ウォッチでのセット中に**心拍数とアクティブエネルギー**を計測し、そのワークアウト記録に含めるため。それ以外のヘルスケアデータは読み取りません |
|
||||
| ヘルスケア(HealthKit)— 書き込み | 最後まで終えたケーゲル体操のセットを、ご自身のヘルスケアアプリに**ワークアウト**(時間・種類、選択した場合は運動強度、アクティブエネルギー)として保存 |
|
||||
| ヘルスケア(HealthKit)— 読み取り | Apple Watchを着けて運動している間(ウォッチで開始した場合も、iPhoneで開始してウォッチアプリが自動で開いた場合も)、ウォッチが**心拍数とアクティブエネルギー**を計測し、そのワークアウト記録に含めるため。iPhoneアプリはセット中にウォッチから送られる現在の心拍数を表示するだけで、保存せず、ヘルスケアアプリのデータを直接照会することもありません。それ以外のヘルスケアデータは読み取りません |
|
||||
|
||||
ヘルスケアデータはとくに厳格に扱います。本アプリは読み取った心拍数・アクティブエネルギーを**そのワークアウト記録に含める目的にのみ**使用し、**サーバーへ送信したり、アプリの外へ書き出したりすることはありません。** ヘルスケアデータを広告・マーケティングなど他の目的に使用することもありません。ヘルスケアへの記録を望まない場合は、権限の要求を拒否するか、ヘルスケアアプリ → 共有 → AppでKegingをオフにしてください。その他の機能はそのまま利用できます。
|
||||
ヘルスケアデータはとくに厳格に扱います。本アプリは計測した心拍数・アクティブエネルギーを**そのワークアウト記録に含め、セット中に画面に表示する目的にのみ**使用し、**サーバーへ送信したり、アプリの外へ書き出したりすることはありません。** ヘルスケアデータを広告・マーケティングなど他の目的に使用することもありません。ヘルスケアへの記録を望まない場合は、権限の要求を拒否するか、ヘルスケアアプリ → 共有 → AppでKegingをオフにしてください。その他の機能はそのまま利用できます。
|
||||
|
||||
本アプリは、セット中に画面が消えていても振動ガイドとリアルタイム表示(ライブアクティビティ)が続くよう、バックグラウンドのオーディオセッションを使用します。このセッションは音声を録音せず、マイクの権限も使用しません。
|
||||
|
||||
|
||||
@ -53,11 +53,11 @@ struct AppSettingsView: View {
|
||||
Text("운동을 끝까지 마치면 그 시간이 위에서 고른 유형으로 애플 건강에 자동 기록되고, 운동 강도(1~10)도 함께 저장돼요. 기록을 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
|
||||
}
|
||||
Section {
|
||||
Toggle("워치 미연결 경고", isOn: $warnNoWatch)
|
||||
Toggle("워치 연결 실패 경고", isOn: $warnNoWatch)
|
||||
} header: {
|
||||
Text("애플워치")
|
||||
} footer: {
|
||||
Text("워치가 연결되지 않은 채 시작 버튼을 누르면 확인 창을 띄워요. 워치에서 케깅 앱이 켜져 있을 때만 '연결됨'으로 표시돼요.")
|
||||
Text("아이폰에서 시작하면 워치 앱을 자동으로 열어 심박수를 재요. 워치 앱을 열지 못하면 확인 창을 띄워요 — 심박 없이 아이폰만으로 기록돼요.")
|
||||
}
|
||||
Section {
|
||||
LabeledContent("버전", value: versionText)
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
// ContentView.swift
|
||||
// Keging
|
||||
//
|
||||
// 첫 화면 — 큰 시작 버튼 + 구석의 세팅·설정 버튼. 아래로 스와이프하면 통계.
|
||||
// 첫 화면 — 큰 시작 버튼 + 구석의 세팅·설정 버튼. 위로 스와이프하면 통계.
|
||||
// 워치 연결 배지·사전 확인 창은 폐지(2026-09-05) — 시작하면 워치 앱을 자동으로 깨워 심박을 재고,
|
||||
// 못 열면 운동 화면에서 경고(WatchHeartRateBridge, §3.8).
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
@ -10,18 +12,13 @@ import SwiftUI
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject private var engine: KegelEngine
|
||||
@EnvironmentObject private var settings: SettingsStore
|
||||
@ObservedObject private var watchLink = WatchLinkStatus.shared
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
/// 워치 미연결 상태로 시작할 때 확인 창 (설정에서 끌 수 있음)
|
||||
@AppStorage("app.watchWarn", store: AppGroup.defaults) private var warnNoWatch = true
|
||||
|
||||
@State private var showTimerSettings = false
|
||||
@State private var showAppSettings = false
|
||||
@State private var showHelp = false
|
||||
@State private var showStats = false
|
||||
@State private var showWatchWarning = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@ -40,17 +37,10 @@ struct ContentView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .startWorkoutRequested)) { _ in
|
||||
startFromShortcut()
|
||||
}
|
||||
.alert("워치가 연결되어 있지 않아요", isPresented: $showWatchWarning) {
|
||||
Button("취소", role: .cancel) {}
|
||||
Button("시작하기") { WorkoutCoordinator.shared.startWorkout() }
|
||||
} message: {
|
||||
Text("아이폰만으로 진행해도 운동 기록은 똑같이 저장돼요. 진동은 아이폰에서 울리고, 칼로리는 심박 없이 추정돼요.")
|
||||
}
|
||||
.onChange(of: scenePhase) {
|
||||
// 백그라운드에서 정지됐다 돌아와도 벽시계 기준 정확한 국면으로 재정렬
|
||||
if scenePhase == .active {
|
||||
KegelEngine.shared.resyncAfterWake()
|
||||
WatchLinkStatus.shared.refresh()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@ -79,11 +69,8 @@ struct ContentView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
watchLinkBadge
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Button {
|
||||
requestStart()
|
||||
WorkoutCoordinator.shared.startWorkout()
|
||||
} label: {
|
||||
startButtonLabel
|
||||
}
|
||||
@ -208,48 +195,6 @@ struct ContentView: View {
|
||||
.shadow(color: .black.opacity(0.06), radius: 10, x: 0, y: 3)
|
||||
}
|
||||
|
||||
/// 워치 연결 표시 — ⚠️iOS 제약상 '연결됨'은 워치에서 케깅 앱이 켜져 있을 때만
|
||||
/// (설정·도움말에 기준 안내). 미연결이어도 기록은 동일하게 저장된다
|
||||
private var watchLinkBadge: some View {
|
||||
let symbol: String
|
||||
let label: Text
|
||||
let color: Color
|
||||
switch watchLink.state {
|
||||
case .reachable:
|
||||
symbol = "applewatch.radiowaves.left.and.right"
|
||||
label = Text("워치 연결됨")
|
||||
color = AppTheme.green
|
||||
case .unreachable:
|
||||
symbol = "applewatch.slash"
|
||||
label = Text("워치 미연결")
|
||||
color = .secondary
|
||||
case .notLinked:
|
||||
symbol = "applewatch.slash"
|
||||
label = Text("워치 미연동")
|
||||
color = .secondary
|
||||
}
|
||||
return HStack(spacing: 6) {
|
||||
Image(systemName: symbol)
|
||||
.font(.footnote.weight(.semibold))
|
||||
label
|
||||
.font(.footnote.weight(.medium))
|
||||
}
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(AppTheme.surface, in: Capsule())
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
/// 시작 버튼 — 워치 미연결이면(설정에 따라) 확인 후 시작
|
||||
private func requestStart() {
|
||||
if warnNoWatch, watchLink.state != .reachable {
|
||||
showWatchWarning = true
|
||||
} else {
|
||||
WorkoutCoordinator.shared.startWorkout()
|
||||
}
|
||||
}
|
||||
|
||||
private func cornerButton(symbol: String, label: Text, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: symbol)
|
||||
|
||||
@ -17,6 +17,10 @@ enum DebugSeed {
|
||||
if CommandLine.arguments.contains("-quickConfig") {
|
||||
SettingsStore.shared.config = WorkoutConfig(prepSeconds: 1, contractSeconds: 1, relaxSeconds: 1, reps: 2)
|
||||
}
|
||||
// 워치 심박 왕복 검증용 (준비 3초·수축 5초·이완 3초·4회 = 35초 — 워치 앱이 깨어나 붙을 시간이 있는 짧은 세트)
|
||||
if CommandLine.arguments.contains("-testConfig") {
|
||||
SettingsStore.shared.config = WorkoutConfig(prepSeconds: 3, contractSeconds: 5, relaxSeconds: 3, reps: 4)
|
||||
}
|
||||
// 기본 세팅 복원
|
||||
if CommandLine.arguments.contains("-resetConfig") {
|
||||
SettingsStore.shared.config = WorkoutConfig()
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
// Keging
|
||||
//
|
||||
// 완주한 세트를 애플 건강에 '기타' 운동으로 기록 (iOS 측 — 사후 저장).
|
||||
// 워치가 심박 세션을 열어 저장한 세트에는 쓰이지 않고(WatchHeartRateBridge), 워치를 못 쓴 세트의 폴백.
|
||||
// 운동 강도(workout effort score, 1~10)도 설정값대로 함께 저장.
|
||||
// 권한 거부·미응답이면 조용히 건너뛴다 — 앱 자체 기록·통계에는 영향 없음.
|
||||
//
|
||||
@ -15,6 +16,9 @@ final class HealthRecorder {
|
||||
|
||||
private let store = HKHealthStore()
|
||||
private var authRequested = false
|
||||
private var authInFlight = false
|
||||
/// 권한 응답을 기다리는 작업들 (첫 실행: 시트에 답한 뒤 워치 깨우기 — 워치 권한은 폰과 공유되므로)
|
||||
private var pendingAfterAuth: [() -> Void] = []
|
||||
|
||||
private init() {}
|
||||
|
||||
@ -26,6 +30,13 @@ final class HealthRecorder {
|
||||
]
|
||||
}
|
||||
|
||||
/// 심박·활성 에너지 읽기 — 폰이 깨운 워치 심박 세션이 센서 값을 수집하려면 필요.
|
||||
/// 건강 권한은 같은 앱의 아이폰·워치 사이에 공유되므로, 워치 앱을 한 번도 안 열었어도
|
||||
/// 폰에서 허용해 두면 워치 세션이 바로 동작한다. 폰 앱 자체는 건강 앱에서 심박을 조회하지 않는다
|
||||
private static var readTypes: Set<HKObjectType> {
|
||||
[HKQuantityType(.heartRate), HKQuantityType(.activeEnergyBurned)]
|
||||
}
|
||||
|
||||
/// 칼로리 추정 — 애플워치의 '기타' 운동 규칙과 정합시킨다: 워치는 "심박 기반 vs 빠르게 걷기
|
||||
/// 상당 중 큰 쪽"으로 적립하므로(케겔은 심박이 낮아 사실상 빠르게 걷기 ≈ MET 4.3),
|
||||
/// 폰도 MET 4.0 + 강도×0.15(기본 강도 2 → 4.3), 체중 70kg 가정으로 계산 — 워치 실측과의
|
||||
@ -35,15 +46,28 @@ final class HealthRecorder {
|
||||
return met * 3.5 * 70 / 200 * (seconds / 60)
|
||||
}
|
||||
|
||||
/// 운동 시작 시 호출 — 첫 실행에서만 권한 시트가 뜬다 (운동 진행에는 영향 없음)
|
||||
func requestAuthorizationIfNeeded() {
|
||||
/// 운동 시작 시 호출 — 첫 실행에서만 권한 시트가 뜬다 (운동 진행에는 영향 없음).
|
||||
/// `then`은 권한이 이미 정해져 있으면 즉시, 시트가 떠 있으면 사용자가 답한 뒤에 실행된다
|
||||
/// (워치 깨우기를 여기에 걸어 첫 실행의 시트·워치 타임아웃 경합을 없앤다 — §3.8)
|
||||
func requestAuthorizationIfNeeded(then action: @escaping () -> Void) {
|
||||
#if DEBUG
|
||||
// 화면 QA에서 권한 시트가 캡처를 가리는 것 방지
|
||||
if CommandLine.arguments.contains("-noHealth") { return }
|
||||
if CommandLine.arguments.contains("-noHealth") { action(); return }
|
||||
#endif
|
||||
guard HKHealthStore.isHealthDataAvailable(), !authRequested else { return }
|
||||
guard HKHealthStore.isHealthDataAvailable() else { action(); return }
|
||||
if authRequested, !authInFlight { action(); return }
|
||||
pendingAfterAuth.append(action)
|
||||
guard !authRequested else { return }
|
||||
authRequested = true
|
||||
store.requestAuthorization(toShare: Self.shareTypes, read: nil) { _, _ in }
|
||||
authInFlight = true
|
||||
store.requestAuthorization(toShare: Self.shareTypes, read: Self.readTypes) { _, _ in
|
||||
Task { @MainActor in
|
||||
self.authInFlight = false
|
||||
let actions = self.pendingAfterAuth
|
||||
self.pendingAfterAuth = []
|
||||
actions.forEach { $0() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 완주한 세트 저장 — 시작 시각부터 세트 길이만큼, 설정한 유형(기타/코어 트레이닝)으로
|
||||
|
||||
@ -17,7 +17,7 @@ struct HelpView: View {
|
||||
helpRow(
|
||||
symbol: "play.circle.fill",
|
||||
title: Text("바로 시작하기"),
|
||||
body: Text("첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요. 시작 버튼 위에는 워치 연결 상태가 떠요 — 워치 없이 시작해도 기록은 똑같이 저장되고, 미연결 확인 창은 설정에서 끌 수 있어요.")
|
||||
body: Text("첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요. 아이폰에서 시작하면 워치 앱이 자동으로 열려 심박수를 재요 — 워치가 없거나 열지 못하면 심박 없이 아이폰만으로 기록되고, 그때 뜨는 확인 창은 설정에서 끌 수 있어요.")
|
||||
)
|
||||
helpRow(
|
||||
symbol: "slider.horizontal.3",
|
||||
@ -82,7 +82,7 @@ struct HelpView: View {
|
||||
helpRow(
|
||||
symbol: "arrow.triangle.2.circlepath",
|
||||
title: Text("워치와 아이폰 동기화"),
|
||||
body: Text("운동 세팅은 아이폰과 워치가 각자 따로 저장해요 — 기기마다 진동 느낌이 달라서 각자에게 맞게 다르게 세팅할 수 있게요. 워치 세팅은 워치의 시작 화면에서 왼쪽으로 쓸어넘겨 바꿔요. 끝까지 마친 세트 기록은 아이폰으로 넘어와 통계에 합쳐지고, 워치가 아이폰과 떨어져 있어도 기록은 쌓였다가 연결되면 자동으로 전송돼요.")
|
||||
body: Text("운동 세팅은 아이폰과 워치가 각자 따로 저장해요 — 기기마다 진동 느낌이 달라서 각자에게 맞게 다르게 세팅할 수 있게요. 워치 세팅은 워치의 시작 화면에서 왼쪽으로 쓸어넘겨 바꿔요. 아이폰에서 시작하면 워치 앱이 자동으로 열려 심박수만 재 주고 진동은 아이폰에서 울려요. 워치에서 시작하면 워치가 진동하고, 끝까지 마친 기록은 아이폰으로 넘어와 통계에 합쳐져요 — 워치가 아이폰과 떨어져 있어도 기록은 쌓였다가 연결되면 자동으로 전송돼요.")
|
||||
)
|
||||
} header: {
|
||||
Text("애플워치")
|
||||
@ -92,7 +92,7 @@ struct HelpView: View {
|
||||
helpRow(
|
||||
symbol: "heart.circle",
|
||||
title: Text("애플 건강 연동"),
|
||||
body: Text("운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(1~10)는 설정에서 고를 수 있어요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 워치에서 하면 워치가 심박수를 실측해 함께 기록하고, 아이폰에서 하면 애플워치의 '기타 운동' 방식(빠르게 걷기 상당)으로 칼로리를 추정해요 — 그래서 둘의 칼로리가 조금 다를 수 있어요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
|
||||
body: Text("운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(1~10)는 설정에서 고를 수 있어요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 아이폰에서 시작하든 워치에서 시작하든 워치를 차고 있으면 워치가 심박수와 활성 에너지를 실측해 함께 기록해요. 워치를 못 쓴 세트는 아이폰이 애플워치의 '기타 운동' 방식(빠르게 걷기 상당)으로 칼로리를 추정해 저장해요 — 그래서 세트마다 칼로리가 조금 다를 수 있어요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
|
||||
)
|
||||
helpRow(
|
||||
symbol: "paintbrush",
|
||||
|
||||
@ -17,6 +17,7 @@ struct KegingApp: App {
|
||||
DebugSeed.applyLaunchArguments()
|
||||
#endif
|
||||
PhoneSync.shared.activate()
|
||||
WatchHeartRateBridge.shared.activate() // 워치 미러링 세션 수신 핸들러 — 시작 전에 등록돼 있어야 함
|
||||
WorkoutCoordinator.shared.wire()
|
||||
}
|
||||
|
||||
|
||||
@ -2,9 +2,10 @@
|
||||
// PhoneSync.swift
|
||||
// Keging
|
||||
//
|
||||
// 워치 연동 (iOS 측) — 워치에서 완주한 기록을 받아 병합하고, 연결 상태를 알린다.
|
||||
// 세팅은 기기별 독립(2026-09-03, 7차 — 기기마다 진동 질감이 달라 각자 최적화)이라
|
||||
// 더 이상 push/응답하지 않는다.
|
||||
// 워치 연동 (iOS 측, WatchConnectivity) — 워치에서 완주한 기록을 받아 병합하고,
|
||||
// 아이폰이 깨운 심박 세션의 저장 결과(saved/failed)를 WatchHeartRateBridge에 전달한다.
|
||||
// 세팅은 기기별 독립(2026-09-03, 7차 — 기기마다 진동 질감이 달라 각자 최적화)이라 push/응답하지 않는다.
|
||||
// 첫 화면 연결 배지는 폐지(2026-09-05) — 워치 사용 여부는 시작 후 startWatchApp 결과로 판정.
|
||||
//
|
||||
|
||||
import WatchConnectivity
|
||||
@ -21,15 +22,34 @@ final class PhoneSync: NSObject, WCSessionDelegate {
|
||||
session.activate()
|
||||
}
|
||||
|
||||
private nonisolated func refreshLinkStatus() {
|
||||
Task { @MainActor in WatchLinkStatus.shared.refresh() }
|
||||
/// 미러링이 안 붙은 채 폰이 단독으로 넘어갈 때 — 워치가 열어 둔 세션이 남지 않게 폐기 명령
|
||||
/// (sendMessage가 안 닿으면 큐로 — 늦게라도 정리된다)
|
||||
func sendMirrorDiscard() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return }
|
||||
let payload: [String: Any] = [WatchBridgeResult.commandKey: WatchBridgeResult.discard]
|
||||
if session.isReachable {
|
||||
session.sendMessage(payload, replyHandler: nil) { _ in session.transferUserInfo(payload) }
|
||||
} else {
|
||||
session.transferUserInfo(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func handle(_ payload: [String: Any]) {
|
||||
if let data = payload["record"] as? Data,
|
||||
let record = try? JSONDecoder().decode(SessionRecord.self, from: data) {
|
||||
Task { @MainActor in RecordStore.shared.append(record) }
|
||||
}
|
||||
if let result = payload[WatchBridgeResult.key] as? String {
|
||||
let saved = result == WatchBridgeResult.saved
|
||||
Task { @MainActor in WatchHeartRateBridge.shared.handleSaveResult(saved: saved) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: WCSessionDelegate
|
||||
|
||||
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
refreshLinkStatus()
|
||||
}
|
||||
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {}
|
||||
|
||||
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
@ -37,20 +57,16 @@ final class PhoneSync: NSObject, WCSessionDelegate {
|
||||
session.activate()
|
||||
}
|
||||
|
||||
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
refreshLinkStatus()
|
||||
}
|
||||
|
||||
/// 페어링·워치 앱 설치 여부 변화
|
||||
nonisolated func sessionWatchStateDidChange(_ session: WCSession) {
|
||||
refreshLinkStatus()
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
guard let data = userInfo["record"] as? Data,
|
||||
let record = try? JSONDecoder().decode(SessionRecord.self, from: data) else { return }
|
||||
Task { @MainActor in
|
||||
RecordStore.shared.append(record)
|
||||
}
|
||||
handle(userInfo)
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
handle(message)
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
handle(message)
|
||||
replyHandler([:])
|
||||
}
|
||||
}
|
||||
|
||||
236
myApp/Keging/IOS/WatchHeartRateBridge.swift
Normal file
@ -0,0 +1,236 @@
|
||||
//
|
||||
// WatchHeartRateBridge.swift
|
||||
// Keging
|
||||
//
|
||||
// 아이폰에서 시작한 세트의 워치 심박 측정 (iOS 측, §3.8).
|
||||
// 시작 버튼 → HKHealthStore.startWatchApp으로 워치 앱을 깨워 워크아웃 세션을 열게 하고,
|
||||
// 워치가 세션을 폰으로 미러링(watchOS 10+)하면 그 채널로 심박을 받고 완주/중단 명령을 보낸다.
|
||||
// 워치를 못 열면(미착용·미페어링·앱 미설치·멀리 있음) 심박 없이 폰 단독 기록으로 폴백하고,
|
||||
// 설정이 켜져 있으면 운동 화면이 경고를 띄운다. 진동은 폰에서만 울린다(워치는 센서 역할만).
|
||||
// 건강 저장은 워치 라이브 빌더가 맡고(심박·활성 에너지 실측), 결과 회신이 없으면 폰이 사후 저장.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import HealthKit
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class WatchHeartRateBridge: NSObject, ObservableObject {
|
||||
static let shared = WatchHeartRateBridge()
|
||||
|
||||
enum State: Equatable {
|
||||
case idle
|
||||
/// startWatchApp 호출 뒤 미러링 세션 도착 대기
|
||||
case launching
|
||||
/// 워치 세션 연결됨 — 심박 수신 중
|
||||
case measuring
|
||||
/// 워치를 못 열었거나 끊김 — 폰 단독
|
||||
case unavailable
|
||||
/// 완주 명령 전송, 워치 저장 결과 대기
|
||||
case finishing
|
||||
}
|
||||
|
||||
/// 실기기 진단용 (Console.app에서 subsystem com.yechan.Keging, category watchHR)
|
||||
private let log = Logger(subsystem: "com.yechan.Keging", category: "watchHR")
|
||||
|
||||
@Published private(set) var state: State = .idle {
|
||||
didSet { if state != oldValue { log.notice("state \(String(describing: oldValue)) → \(String(describing: self.state))") } }
|
||||
}
|
||||
@Published private(set) var heartRate: Double?
|
||||
/// 워치 앱을 열지 못함 — 운동 화면이 (설정 app.watchWarn에 따라) 경고 알럿으로 소비
|
||||
@Published var launchFailed = false
|
||||
|
||||
private let store = HKHealthStore()
|
||||
private var session: HKWorkoutSession?
|
||||
private var launchTimeout: Task<Void, Never>?
|
||||
private var saveTimeout: Task<Void, Never>?
|
||||
private var fallbackSave: (() -> Void)?
|
||||
/// 세트마다 증가 — 늦게 도착한 콜백이 다음 세트를 건드리지 않게
|
||||
private var generation = 0
|
||||
|
||||
private override init() { super.init() }
|
||||
|
||||
/// 앱 시작 시 1회 — 워치가 미러링을 시작하면 시스템이 이 핸들러로 세션을 넘긴다
|
||||
func activate() {
|
||||
store.workoutSessionMirroringStartHandler = { [weak self] session in
|
||||
Task { @MainActor [weak self] in self?.attach(session) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 운동 시작 직후 — 워치 앱 깨우기 (비동기, 타이머 진행에 영향 없음)
|
||||
func begin(config: WorkoutConfig) {
|
||||
reset()
|
||||
generation += 1
|
||||
let gen = generation
|
||||
// 권한 시트에 오래 답하느라 세트가 이미 끝났으면 깨우지 않는다
|
||||
guard KegelEngine.shared.isRunning else { return }
|
||||
#if DEBUG
|
||||
// 화면 QA·스토어 스크린샷용 — 워치 없이 '심박 측정 중' 상태를 흉내 낸다 (§7 `-mockWatchHeartRate 78`)
|
||||
if let index = CommandLine.arguments.firstIndex(of: "-mockWatchHeartRate"),
|
||||
index + 1 < CommandLine.arguments.count,
|
||||
let bpm = Double(CommandLine.arguments[index + 1]) {
|
||||
state = .measuring
|
||||
heartRate = bpm
|
||||
return
|
||||
}
|
||||
#endif
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
state = .unavailable
|
||||
return
|
||||
}
|
||||
state = .launching
|
||||
let configuration = HKWorkoutConfiguration()
|
||||
configuration.activityType = config.healthWorkoutType.activityType
|
||||
configuration.locationType = .unknown
|
||||
store.startWatchApp(with: configuration) { [weak self] success, error in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.generation == gen, self.state == .launching else { return }
|
||||
self.log.notice("startWatchApp success=\(success) error=\(error.map { String(describing: $0) } ?? "nil")")
|
||||
if success {
|
||||
self.armLaunchTimeout(gen)
|
||||
} else {
|
||||
self.markLaunchFailed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 세트 완주 — 워치가 저장하게 하고, 결과가 안 오면 폴백(폰 사후 저장)
|
||||
func finish(effortScore: Int, fallback: @escaping () -> Void) {
|
||||
guard state == .measuring, let session else {
|
||||
if state == .launching { PhoneSync.shared.sendMirrorDiscard() }
|
||||
reset()
|
||||
fallback()
|
||||
return
|
||||
}
|
||||
state = .finishing
|
||||
fallbackSave = fallback
|
||||
send(.finish(effortScore: effortScore), via: session)
|
||||
let gen = generation
|
||||
saveTimeout = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(15))
|
||||
guard !Task.isCancelled, let self, self.generation == gen else { return }
|
||||
self.runFallback()
|
||||
}
|
||||
}
|
||||
|
||||
/// 세트 중단 — 워치 워크아웃 폐기 (미러링이 아직 안 붙었으면 WatchConnectivity로)
|
||||
func discard() {
|
||||
if let session, state == .measuring {
|
||||
send(.discard, via: session)
|
||||
} else if state == .launching {
|
||||
PhoneSync.shared.sendMirrorDiscard()
|
||||
}
|
||||
reset()
|
||||
}
|
||||
|
||||
/// WatchConnectivity로 온 워치 저장 결과 (PhoneSync)
|
||||
func handleSaveResult(saved: Bool) {
|
||||
log.notice("watch save result saved=\(saved) (state \(String(describing: self.state)))")
|
||||
guard state == .finishing else { return }
|
||||
if saved {
|
||||
fallbackSave = nil
|
||||
reset()
|
||||
} else {
|
||||
runFallback()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 내부
|
||||
|
||||
private func attach(_ session: HKWorkoutSession) {
|
||||
log.notice("mirrored session arrived (state \(String(describing: self.state)), running \(KegelEngine.shared.isRunning))")
|
||||
launchTimeout?.cancel()
|
||||
launchTimeout = nil
|
||||
// 세트가 이미 끝났거나 폰 단독으로 넘어간 뒤 늦게 도착 — 폐기시킨다
|
||||
guard state == .launching, KegelEngine.shared.isRunning else {
|
||||
send(.discard, via: session)
|
||||
return
|
||||
}
|
||||
session.delegate = self
|
||||
self.session = session
|
||||
state = .measuring
|
||||
}
|
||||
|
||||
private func armLaunchTimeout(_ gen: Int) {
|
||||
launchTimeout?.cancel()
|
||||
launchTimeout = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
guard !Task.isCancelled, let self, self.generation == gen, self.state == .launching else { return }
|
||||
self.markLaunchFailed()
|
||||
}
|
||||
}
|
||||
|
||||
private func markLaunchFailed() {
|
||||
state = .unavailable
|
||||
if KegelEngine.shared.isRunning { launchFailed = true }
|
||||
// 워치가 세션을 열었는데 미러링만 안 붙은 경우를 대비해 WatchConnectivity로 정리 명령
|
||||
PhoneSync.shared.sendMirrorDiscard()
|
||||
}
|
||||
|
||||
private func runFallback() {
|
||||
log.notice("fallback: phone saves workout itself")
|
||||
let fallback = fallbackSave
|
||||
fallbackSave = nil
|
||||
reset()
|
||||
fallback?()
|
||||
}
|
||||
|
||||
private func send(_ message: WatchBridgeMessage, via session: HKWorkoutSession) {
|
||||
guard let data = message.encoded() else { return }
|
||||
session.sendToRemoteWorkoutSession(data: data) { _, _ in }
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
launchTimeout?.cancel()
|
||||
launchTimeout = nil
|
||||
saveTimeout?.cancel()
|
||||
saveTimeout = nil
|
||||
session?.delegate = nil
|
||||
session = nil
|
||||
heartRate = nil
|
||||
state = .idle
|
||||
}
|
||||
|
||||
/// 워치 세션이 끝나거나 끊김 — 측정 중이었으면 폰 단독으로 전환(세트는 계속),
|
||||
/// 완주 명령 뒤라면 워치가 세션을 닫는 것이 정상이라 저장 결과(WatchConnectivity)를 계속 기다린다
|
||||
private func remoteEnded() {
|
||||
guard state == .measuring else { return }
|
||||
state = .unavailable
|
||||
heartRate = nil
|
||||
session?.delegate = nil
|
||||
session = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchHeartRateBridge: HKWorkoutSessionDelegate {
|
||||
nonisolated func workoutSession(
|
||||
_ workoutSession: HKWorkoutSession,
|
||||
didChangeTo toState: HKWorkoutSessionState,
|
||||
from fromState: HKWorkoutSessionState,
|
||||
date: Date
|
||||
) {
|
||||
guard toState == .ended || toState == .stopped else { return }
|
||||
Task { @MainActor in self.remoteEnded() }
|
||||
}
|
||||
|
||||
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
|
||||
Task { @MainActor in self.remoteEnded() }
|
||||
}
|
||||
|
||||
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didDisconnectFromRemoteDeviceWithError error: Error?) {
|
||||
Task { @MainActor in self.remoteEnded() }
|
||||
}
|
||||
|
||||
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didReceiveDataFromRemoteWorkoutSession data: [Data]) {
|
||||
let messages = data.compactMap(WatchBridgeMessage.decode)
|
||||
Task { @MainActor in
|
||||
for message in messages {
|
||||
if case let .heartRate(bpm) = message {
|
||||
if self.heartRate == nil { self.log.notice("first heart rate \(bpm)") }
|
||||
self.heartRate = bpm
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
//
|
||||
// WatchLinkStatus.swift
|
||||
// Keging
|
||||
//
|
||||
// 첫 화면의 워치 연결 표시용 상태 — PhoneSync 델리게이트 콜백·씬 활성화 때 refresh().
|
||||
// ⚠️iOS 쪽 isReachable은 워치에서 케깅 앱이 켜져(포그라운드) 있을 때만 true가 되는
|
||||
// 시스템 제약이 있다 — '연결됨' 표시는 "워치의 케깅과 지금 통신 가능"의 의미
|
||||
// (설정·도움말에 이 기준을 안내해 뒀다).
|
||||
//
|
||||
|
||||
import Combine
|
||||
import WatchConnectivity
|
||||
|
||||
@MainActor
|
||||
final class WatchLinkStatus: ObservableObject {
|
||||
static let shared = WatchLinkStatus()
|
||||
|
||||
enum LinkState {
|
||||
/// 워치 미페어링 또는 케깅 워치 앱 미설치
|
||||
case notLinked
|
||||
/// 연동은 되어 있지만 지금은 통신 불가 (워치가 멀거나 워치 앱이 꺼져 있음)
|
||||
case unreachable
|
||||
/// 워치의 케깅 앱과 지금 통신 가능
|
||||
case reachable
|
||||
}
|
||||
|
||||
@Published private(set) var state: LinkState = .notLinked
|
||||
|
||||
private init() {}
|
||||
|
||||
func refresh() {
|
||||
guard WCSession.isSupported() else {
|
||||
state = .notLinked
|
||||
return
|
||||
}
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else {
|
||||
state = .notLinked
|
||||
return
|
||||
}
|
||||
state = session.isReachable ? .reachable : .unreachable
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,11 @@
|
||||
// WorkoutCoordinator.swift
|
||||
// Keging
|
||||
//
|
||||
// 엔진 ↔ 진동·Live Activity·백그라운드 오디오·화면 꺼짐 방지 배선 (iOS).
|
||||
// 엔진 ↔ 진동·Live Activity·백그라운드 오디오·화면 꺼짐 방지·워치 심박 배선 (iOS).
|
||||
// 운동 중에는 2초 하트비트로 오디오 생존·엔진 재동기화·Live Activity를 지속 점검한다
|
||||
// (국면 경계 타이머가 한 번 놓쳐도 2초 안에 자가 치유 — 실기기 DI 멈춤의 3중 방어).
|
||||
// 시작 시 워치 앱을 깨워 심박을 재고(WatchHeartRateBridge, §3.8), 완주 시 워치가 건강에 저장 —
|
||||
// 워치를 못 쓰면 폰이 사후 저장(HealthRecorder)으로 폴백.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
@ -48,12 +50,17 @@ final class WorkoutCoordinator {
|
||||
BackgroundAudioKeeper.shared.stop()
|
||||
}
|
||||
if completed, let record {
|
||||
// 워치가 심박 세션을 열어 뒀으면 워치가 저장(심박·활성 에너지 실측), 아니면 폰 사후 저장
|
||||
let config = SettingsStore.shared.config
|
||||
HealthRecorder.shared.save(
|
||||
record: record,
|
||||
effortScore: config.effortScore,
|
||||
workoutType: config.healthWorkoutType
|
||||
)
|
||||
WatchHeartRateBridge.shared.finish(effortScore: config.effortScore) {
|
||||
HealthRecorder.shared.save(
|
||||
record: record,
|
||||
effortScore: config.effortScore,
|
||||
workoutType: config.healthWorkoutType
|
||||
)
|
||||
}
|
||||
} else {
|
||||
WatchHeartRateBridge.shared.discard()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -62,11 +69,14 @@ final class WorkoutCoordinator {
|
||||
guard !KegelEngine.shared.isRunning else { return }
|
||||
pendingAudioStop?.cancel()
|
||||
pendingAudioStop = nil
|
||||
HealthRecorder.shared.requestAuthorizationIfNeeded()
|
||||
BackgroundAudioKeeper.shared.start()
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
KegelEngine.shared.start(config: SettingsStore.shared.config)
|
||||
LiveActivityManager.shared.ensureStarted()
|
||||
// 워치 깨우기는 폰 건강 권한이 정해진 뒤에 — 권한은 워치와 공유되므로 첫 실행에도 워치 세션이 바로 열린다
|
||||
HealthRecorder.shared.requestAuthorizationIfNeeded {
|
||||
WatchHeartRateBridge.shared.begin(config: SettingsStore.shared.config)
|
||||
}
|
||||
startHeartbeat()
|
||||
}
|
||||
|
||||
|
||||
@ -3,12 +3,16 @@
|
||||
// Keging
|
||||
//
|
||||
// 운동 진행 화면 — 국면 원이 수축 때 줄고 이완 때 커진다. 중단 시 이 세트는 기록 없음.
|
||||
// 횟수 아래에 워치 심박 상태(연결 중/심박/아이폰만) 표시, 워치 앱을 못 열면 설정에 따라 경고 알럿(§3.8).
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct WorkoutView: View {
|
||||
@EnvironmentObject private var engine: KegelEngine
|
||||
@ObservedObject private var bridge = WatchHeartRateBridge.shared
|
||||
/// 워치 앱을 못 열었을 때 확인 창 (설정 > 애플워치에서 끌 수 있음)
|
||||
@AppStorage("app.watchWarn", store: AppGroup.defaults) private var warnNoWatch = true
|
||||
@State private var confirmStop = false
|
||||
|
||||
var body: some View {
|
||||
@ -23,6 +27,11 @@ struct WorkoutView: View {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
.alert("워치 앱을 열지 못했어요", isPresented: launchAlertPresented) {
|
||||
Button("확인") {}
|
||||
} message: {
|
||||
Text("심박 없이 아이폰만으로 기록해요. 워치를 차고 있는지, 케깅 워치 앱이 설치돼 있는지, 워치의 건강 권한이 허용돼 있는지 확인해 주세요.")
|
||||
}
|
||||
.onAppear {
|
||||
#if DEBUG
|
||||
if CommandLine.arguments.contains("-confirmStop") { confirmStop = true }
|
||||
@ -30,6 +39,52 @@ struct WorkoutView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var launchAlertPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { bridge.launchFailed && warnNoWatch },
|
||||
set: { presented in if !presented { bridge.launchFailed = false } }
|
||||
)
|
||||
}
|
||||
|
||||
/// 워치 심박 상태 한 줄 — 연결 중 / 심박 bpm / 아이폰만 (완주 대기·유휴에는 비움)
|
||||
@ViewBuilder
|
||||
private var watchStatus: some View {
|
||||
switch bridge.state {
|
||||
case .launching:
|
||||
statusLine(symbol: "applewatch", label: Text("워치 연결 중"))
|
||||
case .measuring:
|
||||
if let bpm = bridge.heartRate {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text(verbatim: "\(Int(bpm.rounded()))")
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
Text("워치 심박")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.footnote.weight(.medium))
|
||||
.accessibilityElement(children: .combine)
|
||||
} else {
|
||||
statusLine(symbol: "applewatch", label: Text("워치 심박 준비 중"))
|
||||
}
|
||||
case .unavailable:
|
||||
statusLine(symbol: "iphone", label: Text("아이폰만으로 기록"))
|
||||
case .idle, .finishing:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func statusLine(symbol: String, label: Text) -> some View {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: symbol)
|
||||
label
|
||||
}
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private func runningView(phase: KegelPhase, rep: Int, phaseStart: Date, phaseEnd: Date) -> some View {
|
||||
let phaseColor = Self.color(for: phase)
|
||||
let progress = phase == .prepare
|
||||
@ -52,6 +107,9 @@ struct WorkoutView: View {
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Text("현재 \(rep)번째, 전체 \(engine.config.reps)회"))
|
||||
|
||||
watchStatus
|
||||
.padding(.top, 6)
|
||||
|
||||
Spacer()
|
||||
|
||||
ZStack {
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
// 실행하면 바로 시작 버튼 하나 — 누르면 즉시 시작.
|
||||
// 왼쪽으로 스와이프하면 워치 전용 세팅 페이지(7차 — 세팅은 아이폰과 독립).
|
||||
// 진행 중엔 수축/이완·남은 시간·몇 번째인지 한 화면에.
|
||||
// 아이폰이 깨운 심박 세션 중(MirroredWorkoutSession)에는 시작 버튼 대신 '아이폰에서 진행 중' 화면(§3.8).
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
@ -12,6 +13,7 @@ import SwiftUI
|
||||
struct WatchContentView: View {
|
||||
@EnvironmentObject private var engine: KegelEngine
|
||||
@EnvironmentObject private var settings: SettingsStore
|
||||
@ObservedObject private var mirror = MirroredWorkoutSession.shared
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var page = 0
|
||||
|
||||
@ -20,11 +22,15 @@ struct WatchContentView: View {
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
switch engine.state {
|
||||
case .idle:
|
||||
TabView(selection: $page) {
|
||||
startScreen.tag(0)
|
||||
WatchSettingsView().tag(1)
|
||||
if mirror.isActive {
|
||||
mirroredScreen
|
||||
} else {
|
||||
TabView(selection: $page) {
|
||||
startScreen.tag(0)
|
||||
WatchSettingsView().tag(1)
|
||||
}
|
||||
.tabViewStyle(.page)
|
||||
}
|
||||
.tabViewStyle(.page)
|
||||
case let .running(phase, rep, phaseStart, phaseEnd):
|
||||
runningScreen(phase: phase, rep: rep, phaseStart: phaseStart, phaseEnd: phaseEnd)
|
||||
case let .finished(reps):
|
||||
@ -77,6 +83,43 @@ struct WatchContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 아이폰에서 시작한 세트 — 워치는 심박만 잰다 (타이머·진동은 아이폰)
|
||||
private var mirroredScreen: some View {
|
||||
VStack(spacing: 5) {
|
||||
Image(systemName: "iphone.radiowaves.left.and.right")
|
||||
.font(.title3)
|
||||
.foregroundStyle(AppTheme.green)
|
||||
Text("아이폰에서 진행 중")
|
||||
.font(.headline)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundStyle(.red)
|
||||
if let bpm = mirror.heartRate {
|
||||
Text(verbatim: "\(Int(bpm.rounded()))")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Text("심박 측정 중")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
Text("진동은 아이폰에서 울려요")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Button {
|
||||
MirroredWorkoutSession.shared.cancelByUser()
|
||||
} label: {
|
||||
Text("중단")
|
||||
.font(.footnote)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private func runningScreen(phase: KegelPhase, rep: Int, phaseStart: Date, phaseEnd: Date) -> some View {
|
||||
VStack(spacing: 3) {
|
||||
Text(phase.label)
|
||||
|
||||
@ -4,9 +4,12 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WatchKit
|
||||
|
||||
@main
|
||||
struct KegingWatchApp: App {
|
||||
/// 아이폰이 startWatchApp으로 깨울 때 운동 구성을 받는 진입점 (§3.8)
|
||||
@WKApplicationDelegateAdaptor(WatchAppDelegate.self) private var appDelegate
|
||||
@StateObject private var engine = KegelEngine.shared
|
||||
@StateObject private var settings = SettingsStore.shared
|
||||
|
||||
|
||||
@ -1,512 +1,560 @@
|
||||
{
|
||||
"sourceLanguage" : "ko",
|
||||
"strings" : {
|
||||
"%lld / %lld" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%lld / %lld"
|
||||
"sourceLanguage": "ko",
|
||||
"strings": {
|
||||
"%lld / %lld": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld / %lld"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%lld / %lld"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld / %lld"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$lld / %2$lld"
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "%1$lld / %2$lld"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld초" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%lld s"
|
||||
"%lld초": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld s"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%lld秒"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld秒"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld회" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"variations" : {
|
||||
"plural" : {
|
||||
"one" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%lld rep"
|
||||
"%lld회": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"variations": {
|
||||
"plural": {
|
||||
"one": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld rep"
|
||||
}
|
||||
},
|
||||
"other" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%lld reps"
|
||||
"other": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld reps"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%lld回"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld回"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld회 완주" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"variations" : {
|
||||
"plural" : {
|
||||
"one" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%lld rep done"
|
||||
"%lld회 완주": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"variations": {
|
||||
"plural": {
|
||||
"one": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld rep done"
|
||||
}
|
||||
},
|
||||
"other" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%lld reps done"
|
||||
"other": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld reps done"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%lld回完了"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld回完了"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"고르면 바로 진동이 울려요. 세팅은 아이폰과 따로 저장돼요." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Picking one plays the vibration right away. These settings are stored separately from your iPhone."
|
||||
"고르면 바로 진동이 울려요. 세팅은 아이폰과 따로 저장돼요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Picking one plays the vibration right away. These settings are stored separately from your iPhone."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "選ぶとすぐに振動が鳴ります。設定はiPhoneとは別に保存されます。"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "選ぶとすぐに振動が鳴ります。設定はiPhoneとは別に保存されます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"기본" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Standard"
|
||||
"기본": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Standard"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "標準"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "標準"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"기타" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Other"
|
||||
"기타": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Other"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "その他"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "その他"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"세트" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Set"
|
||||
"세트": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Set"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "セット"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "セット"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"세팅" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Setup"
|
||||
"세팅": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Setup"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "設定"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "設定"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"수축" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Squeeze"
|
||||
"수축": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Squeeze"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "締める"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "締める"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"수축 %lld초 · 이완 %lld초 · %lld회" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Squeeze %lld s · Relax %lld s · ×%lld"
|
||||
"수축 %lld초 · 이완 %lld초 · %lld회": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Squeeze %lld s · Relax %lld s · ×%lld"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "締める%lld秒 · ゆるめる%lld秒 · %lld回"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "締める%lld秒 · ゆるめる%lld秒 · %lld回"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회"
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"시작" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Start"
|
||||
"시작": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Start"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "開始"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "開始"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"안 함" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Off"
|
||||
"심박 측정 중": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Measuring heart rate"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "なし"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "心拍数を計測中"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"애플 건강" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Apple Health"
|
||||
"아이폰에서 진행 중": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Running on iPhone"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ヘルスケア"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "iPhoneで実行中"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"완료!" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Done!"
|
||||
"안 함": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Off"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "完了!"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "なし"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"운동 강도" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Effort"
|
||||
"애플 건강": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Apple Health"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "運動強度"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ヘルスケア"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"운동 유형" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Workout Type"
|
||||
"완료!": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Done!"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ワークアウトの種類"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "完了!"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"이완" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Relax"
|
||||
"운동 강도": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Effort"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ゆるめる"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "運動強度"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"준비" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Ready"
|
||||
"운동 유형": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Workout Type"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "準備"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ワークアウトの種類"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"중단" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Stop"
|
||||
"이완": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Relax"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "中断"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ゆるめる"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"지이이잉" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Long buzz"
|
||||
"준비": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Ready"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ブーッ"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "準備"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"진동" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Vibration"
|
||||
"중단": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Stop"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "振動"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "中断"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"케겔 운동 시작" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Start Kegel Workout"
|
||||
"지이이잉": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Long buzz"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ケーゲル運動を開始"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ブーッ"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"코어 트레이닝" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Core Training"
|
||||
"진동": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Vibration"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "コアトレーニング"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "振動"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"크라운을 돌려 조절" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Turn the crown to adjust"
|
||||
"진동은 아이폰에서 울려요": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Haptics play on iPhone"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "クラウンを回して調整"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "振動はiPhoneで鳴ります"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"톡톡" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Double tap"
|
||||
"케겔 운동 시작": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Start Kegel Workout"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "トントン"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ケーゲル運動を開始"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"현재 %lld번째, 전체 %lld회" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Rep %lld of %lld"
|
||||
"코어 트레이닝": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Core Training"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%lld回目、全%lld回"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "현재 %1$lld번째, 전체 %2$lld회"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "コアトレーニング"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"확인" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "OK"
|
||||
"크라운을 돌려 조절": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Turn the crown to adjust"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "OK"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "クラウンを回して調整"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"횟수" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Reps"
|
||||
"톡톡": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Double tap"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "回数"
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "トントン"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"현재 %lld번째, 전체 %lld회": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Rep %lld of %lld"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld回目、全%lld回"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "현재 %1$lld번째, 전체 %2$lld회"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"확인": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "OK"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"횟수": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Reps"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "回数"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
"version": "1.0"
|
||||
}
|
||||
194
myApp/Keging/KegingWatch Watch App/MirroredWorkoutSession.swift
Normal file
@ -0,0 +1,194 @@
|
||||
//
|
||||
// MirroredWorkoutSession.swift
|
||||
// KegingWatch Watch App
|
||||
//
|
||||
// 아이폰에서 시작한 세트의 심박 측정 (워치 측, §3.8).
|
||||
// 폰의 startWatchApp으로 깨어나면(WatchAppDelegate.handle) 워크아웃 세션을 열고 폰으로 미러링한다.
|
||||
// 이 모드에서 워치는 타이머·진동 없이 센서 역할만 — 심박을 미러링 채널로 폰에 보내고,
|
||||
// 폰의 완주(finish)/중단(discard) 명령대로 운동을 저장하거나 폐기한다.
|
||||
// 저장 결과는 세션이 닫힌 뒤라 미러링 대신 WatchConnectivity(WatchSync)로 알린다.
|
||||
// 폰과 끊기면(앱 종료 등) 워크아웃을 폐기한다 — 폰이 자기 폴백으로 저장하므로 중복 방지.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import HealthKit
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class MirroredWorkoutSession: NSObject, ObservableObject {
|
||||
static let shared = MirroredWorkoutSession()
|
||||
|
||||
private let log = Logger(subsystem: "com.yechan.Keging.watchkitapp", category: "watchHR")
|
||||
|
||||
@Published private(set) var isActive = false
|
||||
@Published private(set) var heartRate: Double?
|
||||
|
||||
private let store = HKHealthStore()
|
||||
private var session: HKWorkoutSession?
|
||||
private var builder: HKLiveWorkoutBuilder?
|
||||
|
||||
private override init() { super.init() }
|
||||
|
||||
/// 폰이 넘긴 운동 구성(유형은 폰 설정)으로 세션 시작 → 미러링
|
||||
func start(configuration: HKWorkoutConfiguration) {
|
||||
// 워치 자체 세트 진행 중이거나 이미 세션이 있으면 무시
|
||||
log.notice("phone asked to start (session exists \(self.session != nil), watch set running \(KegelEngine.shared.isRunning))")
|
||||
guard session == nil, !KegelEngine.shared.isRunning else { return }
|
||||
Task { @MainActor in
|
||||
guard HKHealthStore.isHealthDataAvailable() else { return }
|
||||
do {
|
||||
try await WatchHealthAuth.request(store: store)
|
||||
guard session == nil, !KegelEngine.shared.isRunning else { return }
|
||||
let newSession = try HKWorkoutSession(healthStore: store, configuration: configuration)
|
||||
let newBuilder = newSession.associatedWorkoutBuilder()
|
||||
newBuilder.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: configuration)
|
||||
newSession.delegate = self
|
||||
newBuilder.delegate = self
|
||||
let start = Date()
|
||||
newSession.startActivity(with: start)
|
||||
try await HealthKitCalls.beginCollection(newBuilder, at: start)
|
||||
session = newSession
|
||||
builder = newBuilder
|
||||
heartRate = nil
|
||||
isActive = true
|
||||
try await withTimeout(seconds: 30) { try await self.startMirroring(newSession) }
|
||||
log.notice("session started and mirroring to phone")
|
||||
} catch {
|
||||
// 세션·미러링 실패 — 폰은 대기 시간 초과로 폰 단독 전환
|
||||
log.error("session/mirroring failed: \(String(describing: error))")
|
||||
discard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 워치에서 직접 종료 — 폐기 (폰은 끊김을 감지해 폰 단독으로 세트를 이어간다)
|
||||
func cancelByUser() {
|
||||
discard()
|
||||
}
|
||||
|
||||
/// 폰이 WatchConnectivity로 보낸 정리 명령 (미러링이 안 붙어 폰이 단독으로 넘어감)
|
||||
func discardFromPhone() {
|
||||
log.notice("discard command via WatchConnectivity")
|
||||
discard()
|
||||
}
|
||||
|
||||
// MARK: 내부
|
||||
|
||||
/// 앱이 죽었다 다시 뜰 때 시스템이 살려 둔 워크아웃 세션이 있으면 끝내고 폐기한다
|
||||
/// (폰 주도 세트는 이어 갈 수 없고, 중단 규칙상 기록도 남기지 않는다)
|
||||
func recoverAndDiscardIfAny() {
|
||||
store.recoverActiveWorkoutSession { [weak self] session, _ in
|
||||
guard let session else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
self?.log.notice("recovered a leftover session — ending and discarding")
|
||||
session.end()
|
||||
session.associatedWorkoutBuilder().discardWorkout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 미러링 시작이 제한 시간 안에 끝나지 않으면 실패로 본다 (시뮬 등 미러링 불가 환경에서 세션이 영원히 남지 않게)
|
||||
private func withTimeout(seconds: Double, _ operation: @escaping @MainActor () async throws -> Void) async throws {
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask { @MainActor in try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(for: .seconds(seconds))
|
||||
throw CancellationError()
|
||||
}
|
||||
try await group.next()
|
||||
group.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func startMirroring(_ session: HKWorkoutSession) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
session.startMirroringToCompanionDevice { _, error in
|
||||
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ message: WatchBridgeMessage) {
|
||||
log.notice("command from phone: \(String(describing: message))")
|
||||
switch message {
|
||||
case let .finish(effortScore): finish(effortScore: effortScore)
|
||||
case .discard: discard()
|
||||
case .heartRate: break
|
||||
}
|
||||
}
|
||||
|
||||
/// 완주 — 저장(+운동 강도) 후 결과를 WatchConnectivity로 폰에 알린다
|
||||
private func finish(effortScore: Int) {
|
||||
guard let session, let builder else { return }
|
||||
self.session = nil
|
||||
self.builder = nil
|
||||
isActive = false
|
||||
session.end()
|
||||
Task { @MainActor [store] in
|
||||
var saved = false
|
||||
do {
|
||||
try await HealthKitCalls.endCollection(builder, at: Date())
|
||||
let workout = try await HealthKitCalls.finishWorkout(builder)
|
||||
saved = workout != nil
|
||||
if #available(watchOS 11.0, *), effortScore > 0, let workout {
|
||||
try? await HealthKitCalls.relateEffort(store: store, workout: workout, score: effortScore)
|
||||
}
|
||||
} catch {
|
||||
saved = false
|
||||
}
|
||||
log.notice("workout saved=\(saved) — reporting to phone")
|
||||
WatchSync.shared.sendMirrorResult(saved: saved)
|
||||
}
|
||||
}
|
||||
|
||||
private func discard() {
|
||||
isActive = false
|
||||
heartRate = nil
|
||||
guard let session, let builder else { return }
|
||||
self.session = nil
|
||||
self.builder = nil
|
||||
session.end()
|
||||
builder.discardWorkout()
|
||||
}
|
||||
|
||||
private func publish(heartRate bpm: Double) {
|
||||
heartRate = bpm
|
||||
guard let session, let data = WatchBridgeMessage.heartRate(bpm: bpm).encoded() else { return }
|
||||
session.sendToRemoteWorkoutSession(data: data) { _, _ in }
|
||||
}
|
||||
}
|
||||
|
||||
extension MirroredWorkoutSession: HKWorkoutSessionDelegate {
|
||||
nonisolated func workoutSession(
|
||||
_ workoutSession: HKWorkoutSession,
|
||||
didChangeTo toState: HKWorkoutSessionState,
|
||||
from fromState: HKWorkoutSessionState,
|
||||
date: Date
|
||||
) {}
|
||||
|
||||
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
|
||||
Task { @MainActor in self.discard() }
|
||||
}
|
||||
|
||||
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didDisconnectFromRemoteDeviceWithError error: Error?) {
|
||||
Task { @MainActor in self.discard() }
|
||||
}
|
||||
|
||||
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didReceiveDataFromRemoteWorkoutSession data: [Data]) {
|
||||
let messages = data.compactMap(WatchBridgeMessage.decode)
|
||||
Task { @MainActor in messages.forEach { self.handle($0) } }
|
||||
}
|
||||
}
|
||||
|
||||
extension MirroredWorkoutSession: HKLiveWorkoutBuilderDelegate {
|
||||
nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
|
||||
let heartRateType = HKQuantityType(.heartRate)
|
||||
guard collectedTypes.contains(heartRateType),
|
||||
let bpm = workoutBuilder.statistics(for: heartRateType)?
|
||||
.mostRecentQuantity()?
|
||||
.doubleValue(for: .count().unitDivided(by: .minute())) else { return }
|
||||
Task { @MainActor in self.publish(heartRate: bpm) }
|
||||
}
|
||||
|
||||
nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
|
||||
}
|
||||
20
myApp/Keging/KegingWatch Watch App/WatchAppDelegate.swift
Normal file
@ -0,0 +1,20 @@
|
||||
//
|
||||
// WatchAppDelegate.swift
|
||||
// KegingWatch Watch App
|
||||
//
|
||||
// 아이폰의 HKHealthStore.startWatchApp으로 깨어날 때의 진입점 — 폰이 넘긴 운동 구성으로
|
||||
// 심박 미러링 세션을 연다(MirroredWorkoutSession, §3.8). 앱이 꺼져 있어도 시스템이 백그라운드로 띄워 준다.
|
||||
//
|
||||
|
||||
import HealthKit
|
||||
import WatchKit
|
||||
|
||||
final class WatchAppDelegate: NSObject, WKApplicationDelegate {
|
||||
func applicationDidFinishLaunching() {
|
||||
MirroredWorkoutSession.shared.recoverAndDiscardIfAny()
|
||||
}
|
||||
|
||||
func handle(_ workoutConfiguration: HKWorkoutConfiguration) {
|
||||
MirroredWorkoutSession.shared.start(configuration: workoutConfiguration)
|
||||
}
|
||||
}
|
||||
36
myApp/Keging/KegingWatch Watch App/WatchHealthAuth.swift
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// WatchHealthAuth.swift
|
||||
// KegingWatch Watch App
|
||||
//
|
||||
// 워치 건강 권한 — 워치 자체 세트(WatchWorkoutSession)와 아이폰이 깨운 심박 세션(MirroredWorkoutSession) 공용.
|
||||
// 라이브 빌더가 센서에서 심박·활성 에너지를 수집하려면 읽기 권한이 필요하다.
|
||||
//
|
||||
|
||||
import HealthKit
|
||||
|
||||
@MainActor
|
||||
enum WatchHealthAuth {
|
||||
static var shareTypes: Set<HKSampleType> {
|
||||
var types: Set<HKSampleType> = [
|
||||
HKObjectType.workoutType(),
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
HKQuantityType(.heartRate),
|
||||
]
|
||||
if #available(watchOS 11.0, *) {
|
||||
types.insert(HKQuantityType(.workoutEffortScore))
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
static var readTypes: Set<HKObjectType> {
|
||||
[HKQuantityType(.heartRate), HKQuantityType(.activeEnergyBurned)]
|
||||
}
|
||||
|
||||
static func request(store: HKHealthStore) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
store.requestAuthorization(toShare: shareTypes, read: readTypes) { _, error in
|
||||
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
// WatchSync.swift
|
||||
// KegingWatch Watch App
|
||||
//
|
||||
// 폰 연동 (워치 측) — 완주 기록을 폰으로 보낸다
|
||||
// 폰 연동 (워치 측) — 완주 기록과, 아이폰이 깨운 심박 세션의 저장 결과를 폰으로 보낸다
|
||||
// (transferUserInfo는 폰이 안 닿아도 자동으로 큐에 쌓였다 전달됨).
|
||||
// 세팅은 기기별 독립(2026-09-03, 7차 — 기기마다 진동 질감이 달라 각자 최적화)이라 받지 않는다.
|
||||
//
|
||||
@ -27,7 +27,38 @@ final class WatchSync: NSObject, WCSessionDelegate {
|
||||
WCSession.default.transferUserInfo(["record": data])
|
||||
}
|
||||
|
||||
/// 아이폰이 깨운 심박 세션의 저장 결과 — 미러링 채널은 세션 종료로 닫혔으므로 여기로.
|
||||
/// sendMessage는 폰 앱이 꺼져 있어도 백그라운드로 깨워 전달되고, 실패하면 큐(transferUserInfo)로 폴백
|
||||
func sendMirrorResult(saved: Bool) {
|
||||
guard WCSession.isSupported() else { return }
|
||||
let payload: [String: Any] = [WatchBridgeResult.key: saved ? WatchBridgeResult.saved : WatchBridgeResult.failed]
|
||||
let session = WCSession.default
|
||||
if session.isReachable {
|
||||
session.sendMessage(payload, replyHandler: nil) { _ in session.transferUserInfo(payload) }
|
||||
} else {
|
||||
session.transferUserInfo(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func handle(_ payload: [String: Any]) {
|
||||
guard payload[WatchBridgeResult.commandKey] as? String == WatchBridgeResult.discard else { return }
|
||||
Task { @MainActor in MirroredWorkoutSession.shared.discardFromPhone() }
|
||||
}
|
||||
|
||||
// MARK: WCSessionDelegate
|
||||
|
||||
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
handle(message)
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
handle(message)
|
||||
replyHandler([:])
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
handle(userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,23 +19,6 @@ final class WatchWorkoutSession: NSObject {
|
||||
|
||||
private override init() { super.init() }
|
||||
|
||||
private static var shareTypes: Set<HKSampleType> {
|
||||
var types: Set<HKSampleType> = [
|
||||
HKObjectType.workoutType(),
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
HKQuantityType(.heartRate),
|
||||
]
|
||||
if #available(watchOS 11.0, *) {
|
||||
types.insert(HKQuantityType(.workoutEffortScore))
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
/// 라이브 빌더가 센서에서 심박·활성 에너지를 수집하려면 읽기 권한 필요
|
||||
private static var readTypes: Set<HKObjectType> {
|
||||
[HKQuantityType(.heartRate), HKQuantityType(.activeEnergyBurned)]
|
||||
}
|
||||
|
||||
/// 운동 시작 직후 호출 — 권한 요청·세션 시작은 비동기라 타이머 진행에 영향 없음
|
||||
func begin(startDate: Date) {
|
||||
#if DEBUG
|
||||
@ -51,7 +34,7 @@ final class WatchWorkoutSession: NSObject {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await requestAuthorization()
|
||||
try await WatchHealthAuth.request(store: store)
|
||||
// 권한 응답이 늦어 운동이 이미 끝났으면 세션을 열지 않는다
|
||||
guard KegelEngine.shared.isRunning, session == nil else { return }
|
||||
let configuration = HKWorkoutConfiguration()
|
||||
@ -93,12 +76,4 @@ final class WatchWorkoutSession: NSObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestAuthorization() async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
store.requestAuthorization(toShare: Self.shareTypes, read: Self.readTypes) { _, error in
|
||||
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ Squeeze, relax, repeat. Keging runs your squeeze-and-relax cycles for the second
|
||||
|
||||
■ Apple Watch
|
||||
• Open the watch app and tap Start — the workout runs on your wrist, haptics included
|
||||
• Start on iPhone and the watch app opens by itself to measure your heart rate — haptics play only on the device you started from
|
||||
• Phase, remaining time, and current rep on one screen
|
||||
• Watch-only setup (swipe left): ready, squeeze, relax, reps, and haptic patterns tuned separately for the wrist
|
||||
• Sets finished on the watch flow into your iPhone stats — even when apart, they sync automatically later
|
||||
@ -30,7 +31,7 @@ Squeeze, relax, repeat. Keging runs your squeeze-and-relax cycles for the second
|
||||
• Weekly and monthly tabs show reps by day with set and rep totals, switchable between calendar view (this week) and rolling view (last 7 days)
|
||||
|
||||
■ Apple Health and Shortcuts
|
||||
• Completed sets are saved to Apple Health as workouts (choose the workout type and effort). On the watch, heart rate is recorded too
|
||||
• Completed sets are saved to Apple Health as workouts (choose the workout type and effort). Wearing your watch? Then heart rate and active energy are measured by the watch whichever device you start from
|
||||
• Add the "Start Kegel Workout" action in Shortcuts to start from a Home Screen icon, Back Tap, or Siri
|
||||
|
||||
■ And more
|
||||
|
||||
@ -20,6 +20,7 @@ Keging — 振動で導くケーゲル体操タイマー
|
||||
|
||||
■ Apple Watch
|
||||
• ウォッチアプリを開いて開始をタップするだけ — 手首でそのまま進み、振動もウォッチで鳴ります
|
||||
• iPhoneで開始するとウォッチアプリが自動で開いて心拍数を計測 — 振動は開始したデバイスだけで鳴ります
|
||||
• 局面・残り時間・今何回目かをひと画面に
|
||||
• ウォッチ専用の設定(左にスワイプ)— 準備・締める・ゆるめる・回数・振動パターンを手首に合わせて別々に設定
|
||||
• ウォッチで終えたセットはiPhoneに届いて統計にまとまります。離れていても、あとで自動的に送られます
|
||||
@ -30,7 +31,7 @@ Keging — 振動で導くケーゲル体操タイマー
|
||||
• 週間・月間タブは日別の回数グラフとセット・回数の合計。「今週 ↔ 過去7日」のようにカレンダー基準と今日基準を切り替えられます
|
||||
|
||||
■ ヘルスケアとショートカット
|
||||
• 最後まで終えたセットはヘルスケアにワークアウトとして自動記録(ワークアウトの種類・強度を選択可)。ウォッチなら心拍数も一緒に残ります
|
||||
• 最後まで終えたセットはヘルスケアにワークアウトとして自動記録(ワークアウトの種類・強度を選択可)。ウォッチを着けていれば、どちらから開始してもウォッチが心拍数とアクティブエネルギーを計測して残します
|
||||
• ショートカットアプリの「ケーゲル運動を開始」アクションで、ホーム画面のアイコン・背面タップ・Siriからすぐ開始
|
||||
|
||||
■ そのほか
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
|
||||
■ 애플워치
|
||||
• 워치 앱을 열고 시작 버튼을 누르면 손목에서 바로 시작 — 진동도 워치에서 울려요
|
||||
• 아이폰에서 시작하면 워치 앱이 자동으로 열려 심박수를 재요 — 진동은 시작한 기기에서만 울려요
|
||||
• 수축·이완 상태, 남은 시간, 몇 번째인지가 한 화면에
|
||||
• 워치 전용 세팅(왼쪽으로 쓸어넘기기) — 준비·수축·이완·횟수·진동 패턴을 손목에 맞게 따로 정해요
|
||||
• 워치에서 마친 세트는 아이폰으로 넘어와 통계에 합쳐져요. 떨어져 있어도 나중에 자동으로 전송돼요
|
||||
@ -30,7 +31,7 @@
|
||||
• 주간·월간 탭은 일별 횟수 그래프와 세트·횟수 합계. '이번 주 ↔ 지난 7일'처럼 달력 기준과 오늘 기준을 오갈 수 있어요
|
||||
|
||||
■ 애플 건강과 단축어
|
||||
• 끝까지 마친 세트는 애플 건강에 운동으로 자동 기록돼요(운동 유형·강도 선택 가능). 워치에서 하면 심박수도 함께 남아요
|
||||
• 끝까지 마친 세트는 애플 건강에 운동으로 자동 기록돼요(운동 유형·강도 선택 가능). 워치를 차고 있으면 어느 쪽에서 시작하든 워치가 심박수와 활성 에너지를 실측해 함께 남겨요
|
||||
• 단축어 앱의 '케겔 운동 시작' 동작으로 홈 화면 아이콘, 뒷면 탭, 시리에서 바로 시작할 수 있어요
|
||||
|
||||
■ 그 밖에
|
||||
|
||||
@ -26,9 +26,9 @@
|
||||
|
||||
Keging is a free Kegel exercise timer with no account, no ads, and no in-app purchases.
|
||||
- Tap the big Start button on the main screen to run a set (ready countdown → squeeze/relax cycles). The default set takes about 5 minutes; you can shorten it in Workout Setup (top-right slider icon), e.g. 1 s squeeze, 1 s relax, 2 reps.
|
||||
- HealthKit: when a set is completed, the app saves it to Apple Health as a workout (write-only on iPhone). The Apple Watch app runs an HKWorkoutSession during the set so heart rate and active energy are included in that workout. No other health data is read, and nothing leaves the device. Permission is requested at the first start; declining simply skips the Health entry.
|
||||
- HealthKit: when a set is completed, the app saves it to Apple Health as a workout. If the user wears an Apple Watch with the Keging watch app, starting a set on iPhone launches the watch app via HKHealthStore.startWatchApp and the watch runs an HKWorkoutSession mirrored to the iPhone, so heart rate and active energy are measured by the watch and saved with that workout (the iPhone shows the live heart rate during the set only). Without a watch, the iPhone saves the workout with an estimated energy value. The iPhone requests read access to heart rate and active energy solely so the watch session can collect them (HealthKit shares authorization between the paired apps); the iPhone app never queries Health data itself. Nothing leaves the device. Permission is requested at the first start; declining simply skips the Health entry.
|
||||
- Background audio mode: the app's core function is time-based haptic guidance during a set. The audio background mode keeps the workout session alive so phase-change haptics fire on time while the screen is off or another app is in front, and the Live Activity (Dynamic Island / Lock Screen) shows the live set progress. The session ends automatically when the set finishes or is stopped.
|
||||
- Apple Watch: the watch app works standalone (start on the wrist, watch-only setup via left swipe). Completed sets sync to the iPhone statistics.
|
||||
- Apple Watch: the watch app also works standalone (start on the wrist, watch-only setup via left swipe, haptics on the watch). Completed sets sync to the iPhone statistics. If the watch app cannot be opened when starting from iPhone, the app shows a one-time notice and records with iPhone only.
|
||||
- Shortcuts: an App Intent "Start Kegel Workout" opens the app and starts a set.
|
||||
The app provides no medical advice; a note in the description says to consult a professional in case of pain or a medical condition.
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
• 수축·이완을 서로 다른 진동으로 안내하는 케겔 운동 타이머
|
||||
• 준비·수축·이완 시간과 횟수, 진동 패턴 3종 설정
|
||||
• 다이나믹 아일랜드·잠금화면 실시간 현황
|
||||
• 애플워치 앱(손목에서 시작, 워치 전용 세팅)
|
||||
• 애플워치 앱(손목에서 시작, 워치 전용 세팅) — 아이폰에서 시작해도 워치가 심박수를 재요
|
||||
• 하루·주간·월간 통계와 오늘의 타임라인
|
||||
• 애플 건강 운동 기록, 단축어 '케겔 운동 시작'
|
||||
• 광고·결제 없는 완전 무료
|
||||
@ -15,7 +15,7 @@ This is the first release of Keging.
|
||||
• A Kegel timer that cues squeeze and relax with different haptics
|
||||
• Ready, squeeze and relax times, reps, and three haptic patterns
|
||||
• Live status in the Dynamic Island and on the Lock Screen
|
||||
• Apple Watch app (start from your wrist, watch-only setup)
|
||||
• Apple Watch app (start from your wrist, watch-only setup) — even iPhone-started sets get heart rate from the watch
|
||||
• Daily, weekly and monthly stats with Today's Timeline
|
||||
• Apple Health workouts and the "Start Kegel Workout" shortcut
|
||||
• Completely free — no ads, no purchases
|
||||
@ -25,7 +25,7 @@ Kegingの最初のバージョンです。
|
||||
• 締める・ゆるめるを違う振動で知らせるケーゲル体操タイマー
|
||||
• 準備・締める・ゆるめる時間と回数、3種類の振動パターン
|
||||
• Dynamic Island・ロック画面のリアルタイム表示
|
||||
• Apple Watchアプリ(手首から開始、ウォッチ専用設定)
|
||||
• Apple Watchアプリ(手首から開始、ウォッチ専用設定)— iPhoneで開始してもウォッチが心拍数を計測
|
||||
• 1日・週間・月間の統計と今日のタイムライン
|
||||
• ヘルスケアへのワークアウト記録、ショートカット「ケーゲル運動を開始」
|
||||
• 広告・課金なしの完全無料
|
||||
|
||||
@ -26,7 +26,7 @@ tools/
|
||||
|
||||
- 아이패드 슬롯 없음(iPhone 전용 앱, TARGETED_DEVICE_FAMILY=1). ASC는 6.9″ 아이폰 + 워치 한 세트씩 필수, 나머지 크기는 자동 축소
|
||||
- **스크린샷 구성 (1.0, 2026-09-05)**:
|
||||
- **아이폰 9장** = 01 첫 화면(워치 연결됨 배지) / 02 운동 중·수축 / 03 운동 중·이완 / 04 하루 통계(오늘의 타임라인) / 05 주간 통계 / 06 운동 세팅 시트 / 07 다이나믹 아일랜드 확장 뷰(홈 화면에서 길게 누름) / 08 다크 모드 / 09 완료 화면
|
||||
- **아이폰 9장** = 01 첫 화면 / 02 운동 중·수축(워치 심박 줄) / 03 운동 중·이완 / 04 하루 통계(오늘의 타임라인) / 05 주간 통계 / 06 운동 세팅 시트 / 07 다이나믹 아일랜드 확장 뷰(홈 화면에서 길게 누름) / 08 다크 모드 / 09 완료 화면
|
||||
- **워치 3장** = 01 시작 화면 / 02 운동 중 / 03 세팅 페이지
|
||||
- 상태바는 9:41·배터리 100%로 오버라이드된 상태로 촬영 (워치 시뮬레이터는 오버라이드 미지원 — 실제 시각 표시)
|
||||
- 문구 오버레이 규정: Apple은 스크린샷에 마케팅 문구·배경 합성을 허용(실제 앱 화면이 반영되어 있으면 됨) — framed 세트는 이 관행을 따른 것
|
||||
@ -55,10 +55,11 @@ xcrun simctl install $WATCH "$APP/Watch/KegingWatch Watch App.app"
|
||||
|
||||
# 3) 화면별 런치 인자 (매 실행 공통: -noHealth -AppleLanguages "(ko)" -AppleLocale ko_KR / en en_US / ja ja_JP)
|
||||
# ★ 폰 첫 화면의 '워치 연결됨' 배지는 워치 시뮬에서 케깅 워치 앱이 포그라운드일 때만 뜬다 — 01·08 촬영 직전에 워치 앱을 launch
|
||||
# 01-main : -resetConfig (기본 세팅 10·5·20)
|
||||
# 01-main : -resetConfig (기본 세팅 10·5·20 — 9차부터 워치 배지 없음)
|
||||
# 09-finished : -quickConfig -autoStart → 8초 뒤 (완주 화면. 다음 실행에 -resetConfig 필수)
|
||||
# 06-setup : -resetConfig -openTimerSettings
|
||||
# 02-workout : -autoStart → 약 36초 뒤 (3회차 수축, 7초 남음) / 03-relax: 같은 실행 약 45초 뒤 (3회차 이완)
|
||||
# 02-workout : -autoStart -mockWatchHeartRate 78 → 약 36초 뒤 (3회차 수축, 7초 남음) / 03-relax: 같은 실행 약 45초 뒤 (3회차 이완)
|
||||
# (-mockWatchHeartRate = 워치 없이 '♥78 워치 심박' 상태 줄을 흉내 — 실제 미러링은 시뮬 미지원)
|
||||
# 08-dark : simctl ui $PHONE appearance dark → 기본 실행 → 촬영 → appearance light
|
||||
# 05-stats-week : -seedStats -openStats -statsScope week
|
||||
# 04-stats-day : -seedStats -openStats ← ⚠️ '시간대 정합' 2단계 부팅 후 촬영
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 892 KiB After Width: | Height: | Size: 957 KiB |
|
Before Width: | Height: | Size: 1002 KiB After Width: | Height: | Size: 980 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 938 KiB After Width: | Height: | Size: 971 KiB |
|
Before Width: | Height: | Size: 1006 KiB After Width: | Height: | Size: 986 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 939 KiB After Width: | Height: | Size: 954 KiB |
|
Before Width: | Height: | Size: 982 KiB After Width: | Height: | Size: 966 KiB |
|
Before Width: | Height: | Size: 981 KiB After Width: | Height: | Size: 970 KiB |
|
Before Width: | Height: | Size: 581 KiB After Width: | Height: | Size: 579 KiB |
|
Before Width: | Height: | Size: 535 KiB After Width: | Height: | Size: 578 KiB |
|
Before Width: | Height: | Size: 673 KiB After Width: | Height: | Size: 665 KiB |
|
Before Width: | Height: | Size: 986 KiB After Width: | Height: | Size: 974 KiB |
|
Before Width: | Height: | Size: 578 KiB After Width: | Height: | Size: 585 KiB |
|
Before Width: | Height: | Size: 557 KiB After Width: | Height: | Size: 579 KiB |
|
Before Width: | Height: | Size: 678 KiB After Width: | Height: | Size: 669 KiB |
|
Before Width: | Height: | Size: 971 KiB After Width: | Height: | Size: 965 KiB |
|
Before Width: | Height: | Size: 561 KiB After Width: | Height: | Size: 566 KiB |
|
Before Width: | Height: | Size: 560 KiB After Width: | Height: | Size: 571 KiB |
|
Before Width: | Height: | Size: 667 KiB After Width: | Height: | Size: 659 KiB |
37
myApp/Keging/Shared/WatchBridgeMessage.swift
Normal file
@ -0,0 +1,37 @@
|
||||
//
|
||||
// WatchBridgeMessage.swift
|
||||
// Keging
|
||||
//
|
||||
// 아이폰에서 시작한 세트의 워치 심박 측정 — 미러링 세션(HKWorkoutSession, watchOS 10+/iOS 17+)의
|
||||
// 데이터 채널로 오가는 메시지 (iOS·워치 공용).
|
||||
// 폰 → 워치: finish(완주·저장) / discard(중단·폐기). 워치 → 폰: heartRate.
|
||||
// 저장 결과(saved/failed)는 세션이 끝난 뒤라 미러링 채널이 닫히므로
|
||||
// WatchConnectivity(WatchSync → PhoneSync)로 따로 보낸다.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
nonisolated enum WatchBridgeMessage: Codable, Equatable {
|
||||
/// 폰 → 워치: 세트 완주 — 워치가 운동을 저장하고(운동 강도 포함) 결과를 WatchConnectivity로 알린다
|
||||
case finish(effortScore: Int)
|
||||
/// 폰 → 워치: 세트 중단 — 워크아웃 폐기 (중단한 세트는 없던 것, §3.1)
|
||||
case discard
|
||||
/// 워치 → 폰: 최신 심박(bpm)
|
||||
case heartRate(bpm: Double)
|
||||
|
||||
func encoded() -> Data? { try? JSONEncoder().encode(self) }
|
||||
|
||||
static func decode(_ data: Data) -> WatchBridgeMessage? {
|
||||
try? JSONDecoder().decode(WatchBridgeMessage.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// WatchConnectivity로 보내는 저장 결과 (워치 → 폰) / 폐기 명령 (폰 → 워치, 미러링이 안 붙었을 때의 안전장치)
|
||||
nonisolated enum WatchBridgeResult {
|
||||
static let key = "mirrorResult"
|
||||
static let saved = "saved"
|
||||
static let failed = "failed"
|
||||
/// 폰 → 워치: 미러링 세션이 30초 안에 안 붙어 폰이 단독으로 넘어갔을 때 워치 세션을 정리하라는 명령
|
||||
static let commandKey = "mirrorCommand"
|
||||
static let discard = "discard"
|
||||
}
|
||||