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
This commit is contained in:
songyc macbook 2026-09-05 10:13:48 +09:00
parent 0a25ea5ac7
commit b751247ba7
53 changed files with 2413 additions and 1771 deletions

File diff suppressed because one or more lines are too long

View File

@ -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をオフにしてください。その他の機能はそのまま利用できます。
本アプリは、セット中に画面が消えていても振動ガイドとリアルタイム表示(ライブアクティビティ)が続くよう、バックグラウンドのオーディオセッションを使用します。このセッションは音声を録音せず、マイクの権限も使用しません。

View File

@ -53,11 +53,11 @@ struct AppSettingsView: View {
Text("운동을 끝까지 마치면 그 시간이 위에서 고른 유형으로 애플 건강에 자동 기록되고, 운동 강도(110)도 함께 저장돼요. 기록을 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
}
Section {
Toggle("워치 연결 경고", isOn: $warnNoWatch)
Toggle("워치 연결 실패 경고", isOn: $warnNoWatch)
} header: {
Text("애플워치")
} footer: {
Text("워치가 연결되지 않은 채 시작 버튼을 누르면 확인 창을 띄워요. 워치에서 케깅 앱이 켜져 있을 때만 '연결됨'으로 표시돼요.")
Text("아이폰에서 시작하면 워치 앱을 자동으로 열어 심박수를 재요. 워치 앱을 열지 못하면 확인 창을 띄워요 — 심박 없이 아이폰만으로 기록돼요.")
}
Section {
LabeledContent("버전", value: versionText)

View File

@ -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)

View File

@ -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()

View File

@ -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() }
}
}
}
/// , (/ )

View File

@ -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("운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(110)는 설정에서 고를 수 있어요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 워치에서 하면 워치가 심박수를 실측해 함께 기록하고, 아이폰에서 하면 애플워치의 '기타 운동' 방식(빠르게 걷기 상당)으로 칼로리를 추정해요 — 그래서 둘의 칼로리가 조금 다를 수 있어요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
body: Text("운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(110)는 설정에서 고를 수 있어요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 아이폰에서 시작하든 워치에서 시작하든 워치를 차고 있으면 워치가 심박수와 활성 에너지를 실측해 함께 기록해요. 워치를 못 쓴 세트는 아이폰이 애플워치의 '기타 운동' 방식(빠르게 걷기 상당)으로 칼로리를 추정해 저장해요 — 그래서 세트마다 칼로리가 조금 다를 수 있어요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
)
helpRow(
symbol: "paintbrush",

View File

@ -17,6 +17,7 @@ struct KegingApp: App {
DebugSeed.applyLaunchArguments()
#endif
PhoneSync.shared.activate()
WatchHeartRateBridge.shared.activate() //
WorkoutCoordinator.shared.wire()
}

File diff suppressed because it is too large Load Diff

View File

@ -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([:])
}
}

View 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
}
}
}
}
}

View File

@ -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
}
}

View File

@ -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()
}

View File

@ -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 {

View File

@ -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)

View File

@ -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

View File

@ -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"
}

View 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) {}
}

View 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)
}
}

View 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() }
}
}
}
}

View File

@ -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)
}
}

View File

@ -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() }
}
}
}
}

View File

@ -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

View File

@ -20,6 +20,7 @@ Keging — 振動で導くケーゲル体操タイマー
■ Apple Watch
• ウォッチアプリを開いて開始をタップするだけ — 手首でそのまま進み、振動もウォッチで鳴ります
• iPhoneで開始するとウォッチアプリが自動で開いて心拍数を計測 — 振動は開始したデバイスだけで鳴ります
• 局面・残り時間・今何回目かをひと画面に
• ウォッチ専用の設定(左にスワイプ)— 準備・締める・ゆるめる・回数・振動パターンを手首に合わせて別々に設定
• ウォッチで終えたセットはiPhoneに届いて統計にまとまります。離れていても、あとで自動的に送られます
@ -30,7 +31,7 @@ Keging — 振動で導くケーゲル体操タイマー
• 週間・月間タブは日別の回数グラフとセット・回数の合計。「今週 ↔ 過去7日」のようにカレンダー基準と今日基準を切り替えられます
■ ヘルスケアとショートカット
• 最後まで終えたセットはヘルスケアにワークアウトとして自動記録(ワークアウトの種類・強度を選択可)。ウォッチなら心拍数も一緒に残ります
• 最後まで終えたセットはヘルスケアにワークアウトとして自動記録(ワークアウトの種類・強度を選択可)。ウォッチを着けていれば、どちらから開始してもウォッチが心拍数とアクティブエネルギーを計測して残します
• ショートカットアプリの「ケーゲル運動を開始」アクションで、ホーム画面のアイコン・背面タップ・Siriからすぐ開始
■ そのほか

View File

@ -20,6 +20,7 @@
■ 애플워치
• 워치 앱을 열고 시작 버튼을 누르면 손목에서 바로 시작 — 진동도 워치에서 울려요
• 아이폰에서 시작하면 워치 앱이 자동으로 열려 심박수를 재요 — 진동은 시작한 기기에서만 울려요
• 수축·이완 상태, 남은 시간, 몇 번째인지가 한 화면에
• 워치 전용 세팅(왼쪽으로 쓸어넘기기) — 준비·수축·이완·횟수·진동 패턴을 손목에 맞게 따로 정해요
• 워치에서 마친 세트는 아이폰으로 넘어와 통계에 합쳐져요. 떨어져 있어도 나중에 자동으로 전송돼요
@ -30,7 +31,7 @@
• 주간·월간 탭은 일별 횟수 그래프와 세트·횟수 합계. '이번 주 ↔ 지난 7일'처럼 달력 기준과 오늘 기준을 오갈 수 있어요
■ 애플 건강과 단축어
• 끝까지 마친 세트는 애플 건강에 운동으로 자동 기록돼요(운동 유형·강도 선택 가능). 워치에서 하면 심박수도 함께 남아
• 끝까지 마친 세트는 애플 건강에 운동으로 자동 기록돼요(운동 유형·강도 선택 가능). 워치를 차고 있으면 어느 쪽에서 시작하든 워치가 심박수와 활성 에너지를 실측해 함께 남겨
• 단축어 앱의 '케겔 운동 시작' 동작으로 홈 화면 아이콘, 뒷면 탭, 시리에서 바로 시작할 수 있어요
■ 그 밖에

View File

@ -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.

View File

@ -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日・週間・月間の統計と今日のタイムライン
• ヘルスケアへのワークアウト記録、ショートカット「ケーゲル運動を開始」
• 広告・課金なしの完全無料

View File

@ -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단계 부팅 후 촬영

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

After

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1002 KiB

After

Width:  |  Height:  |  Size: 980 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 KiB

After

Width:  |  Height:  |  Size: 971 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1006 KiB

After

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 939 KiB

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 KiB

After

Width:  |  Height:  |  Size: 966 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 981 KiB

After

Width:  |  Height:  |  Size: 970 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 581 KiB

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 673 KiB

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 KiB

After

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 557 KiB

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 678 KiB

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 971 KiB

After

Width:  |  Height:  |  Size: 965 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 KiB

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 560 KiB

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 KiB

After

Width:  |  Height:  |  Size: 659 KiB

View 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"
}