mycode/myApp/Keging/IOS/ContentView.swift
songyc macbook b751247ba7 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
2026-09-05 10:13:48 +09:00

234 lines
8.6 KiB
Swift

//
// ContentView.swift
// Keging
//
// + · . .
// · (2026-09-05) ,
// (WatchHeartRateBridge, §3.8).
//
import SwiftUI
struct ContentView: View {
@EnvironmentObject private var engine: KegelEngine
@EnvironmentObject private var settings: SettingsStore
@Environment(\.scenePhase) private var scenePhase
@State private var showTimerSettings = false
@State private var showAppSettings = false
@State private var showHelp = false
@State private var showStats = false
var body: some View {
ZStack {
AppTheme.background.ignoresSafeArea()
mainScreen
if showStats {
StatsView(onClose: closeStats)
.transition(.move(edge: .bottom))
.zIndex(2)
}
}
.sheet(isPresented: $showTimerSettings) { TimerSettingsView() }
.sheet(isPresented: $showAppSettings) { AppSettingsView() }
.sheet(isPresented: $showHelp) { HelpView() }
.fullScreenCover(isPresented: workoutPresented) { WorkoutView() }
.onReceive(NotificationCenter.default.publisher(for: .startWorkoutRequested)) { _ in
startFromShortcut()
}
.onChange(of: scenePhase) {
//
if scenePhase == .active {
KegelEngine.shared.resyncAfterWake()
}
}
.onAppear {
if AppLaunchState.shared.startRequested { startFromShortcut() }
#if DEBUG
if CommandLine.arguments.contains("-openStats") { showStats = true }
if CommandLine.arguments.contains("-openHelp") { showHelp = true }
if CommandLine.arguments.contains("-openSettings") { showAppSettings = true }
if CommandLine.arguments.contains("-openTimerSettings") { showTimerSettings = true }
if CommandLine.arguments.contains("-autoStart") { WorkoutCoordinator.shared.startWorkout() }
#endif
}
}
// MARK:
private var mainScreen: some View {
VStack(spacing: 0) {
HStack(alignment: .top) {
cornerButton(symbol: "gearshape.fill", label: Text("설정")) { showAppSettings = true }
Spacer()
cornerButton(symbol: "slider.horizontal.3", label: Text("운동 세팅")) { showTimerSettings = true }
}
.padding(.horizontal, 20)
.padding(.top, 8)
Spacer()
Button {
WorkoutCoordinator.shared.startWorkout()
} label: {
startButtonLabel
}
.buttonStyle(.plain)
.accessibilityLabel(Text("케겔 운동 시작"))
Button {
showTimerSettings = true
} label: {
HStack(spacing: 12) {
summaryChip(
symbol: KegelPhase.contract.symbolName,
value: String(localized: "\(settings.config.contractSeconds)"),
label: Text("수축"),
color: AppTheme.green
)
summaryChip(
symbol: KegelPhase.relax.symbolName,
value: String(localized: "\(settings.config.relaxSeconds)"),
label: Text("이완"),
color: AppTheme.yellow
)
summaryChip(
symbol: "repeat",
value: String(localized: "\(settings.config.reps)"),
label: Text("횟수"),
color: AppTheme.green
)
}
}
.buttonStyle(.plain)
.padding(.top, 34)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text("운동 세팅 열기 — 현재 \(settings.config.summaryText)"))
Spacer()
Spacer()
// : = ( ), =
ZStack {
Button {
openStats()
} label: {
VStack(spacing: 2) {
Image(systemName: "chevron.compact.up")
.font(.body)
.foregroundStyle(.secondary)
Capsule().fill(.secondary.opacity(0.5)).frame(width: 36, height: 5)
}
.padding(.bottom, 6)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("통계 보기"))
HStack {
Spacer()
cornerButton(symbol: "questionmark", label: Text("도움말")) { showHelp = true }
}
}
.padding(.horizontal, 20)
.padding(.bottom, 8)
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 25)
.onEnded { value in
if value.translation.height < -60, abs(value.translation.width) < 100 {
openStats()
}
}
)
}
private var startButtonLabel: some View {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [AppTheme.green.opacity(0.22), .clear],
center: .center, startRadius: 100, endRadius: 165
)
)
.frame(width: 330, height: 330)
Circle()
.fill(
LinearGradient(
colors: [AppTheme.green, AppTheme.green.opacity(0.82)],
startPoint: .top, endPoint: .bottom
)
)
.frame(width: 236, height: 236)
.shadow(color: AppTheme.green.opacity(0.35), radius: 24, x: 0, y: 10)
VStack(spacing: 8) {
Image(systemName: "play.fill").font(.system(size: 40, weight: .bold))
Text("시작").font(.title.bold())
}
.foregroundStyle(startButtonForeground)
}
.frame(width: 330, height: 330)
}
///
private var startButtonForeground: Color {
Color(light: .white, dark: Color(hex: "#0F2018"))
}
private func summaryChip(symbol: String, value: String, label: Text, color: Color) -> some View {
VStack(spacing: 6) {
Image(systemName: symbol)
.font(.subheadline.weight(.semibold))
.foregroundStyle(color)
Text(value)
.font(.title3.bold())
.monospacedDigit()
.foregroundStyle(.primary)
label
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(width: 92)
.padding(.vertical, 14)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18))
.shadow(color: .black.opacity(0.06), radius: 10, x: 0, y: 3)
}
private func cornerButton(symbol: String, label: Text, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: symbol)
.font(.title3)
.foregroundStyle(.secondary)
.frame(width: 44, height: 44)
.background(AppTheme.surface, in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(label)
}
// MARK: ·
private func openStats() {
// ( )
withAnimation(.spring(response: 0.45, dampingFraction: 0.62)) { showStats = true }
}
private func closeStats() {
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { showStats = false }
}
private var workoutPresented: Binding<Bool> {
Binding(
get: { engine.state != .idle },
set: { presented in if !presented { engine.acknowledgeFinish() } }
)
}
private func startFromShortcut() {
AppLaunchState.shared.startRequested = false
showStats = false
WorkoutCoordinator.shared.startWorkout()
}
}