From f5f8d3bde478be2d648a49bf25de8f6ba1293379 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sat, 29 Aug 2026 22:42:00 +0900 Subject: [PATCH] =?UTF-8?q?fix(keging):=20=EC=8B=A4=EA=B8=B0=EA=B8=B0=201?= =?UTF-8?q?=EC=B0=A8=20=ED=94=BC=EB=93=9C=EB=B0=B1=203=EA=B1=B4=20?= =?UTF-8?q?=E2=80=94=20DI=20=EB=B0=B1=EA=B7=B8=EB=9D=BC=EC=9A=B4=EB=93=9C?= =?UTF-8?q?=20=EB=A9=88=EC=B6=A4=C2=B7=EC=A7=84=EB=8F=99=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EB=8F=99=EC=9D=BC=C2=B7=EC=B9=BC=EB=A1=9C=EB=A6=AC?= =?UTF-8?q?=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 다이나믹 아일랜드 멈춤(2중 방어): - 오디오 키퍼 하드닝 — volume 0.1(0.0은 유지 풀림 사례)·prepareToPlay·인터럽션/씬 전환 revive() - 엔진을 벽시계 기준으로 재설계 — 전 국면이 sessionStart로부터 결정적(computeTarget), 타이머는 경계 재장전용, scenePhase .active에서 resyncAfterWake()로 정확한 국면 자가 교정 ② 진동 패턴 동일(톡=톡톡): 시스템 바이브는 재생 중 재호출이 무시됨(실측) — 시간 오프셋 방식 폐기, AudioServicesPlaySystemSoundWithCompletion 완료 체이닝으로 재작성 (톡 1방 / 톡톡 0.25s 쉬고 1방 / 드르륵 0.15s 3연타 / 지잉 즉시 이어붙인 3방) ③ 운동 칼로리 0: 빌더 운동은 에너지 샘플 직접 추가 필요 — iOS는 MET 추정(1.8+강도×0.17, 70kg 가정) activeEnergyBurned 샘플 add, 워치는 심박·활성 에너지 읽기 권한 추가로 센서 실측 수집, 권한 문구 3언어 갱신 검증: Debug·Release·워치 빌드 그린, 완주 경로 시뮬 재검증(엔진 재설계 후). DI 갱신·진동 구분·칼로리는 실기기 재확인 필요(재실행 시 '활성 에너지' 권한 시트 뜸) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT --- myApp/Keging/CLAUDE.md | 11 +- myApp/Keging/IOS/BackgroundAudioKeeper.swift | 39 +++++- myApp/Keging/IOS/ContentView.swift | 6 + myApp/Keging/IOS/Haptics.swift | 35 ++++-- myApp/Keging/IOS/HealthRecorder.swift | 21 +++- myApp/Keging/IOS/Info.plist | 2 +- myApp/Keging/IOS/InfoPlist.xcstrings | 112 +++++++++--------- myApp/Keging/KegingWatch Watch App/Info.plist | 2 +- .../KegingWatch Watch App/InfoPlist.xcstrings | 112 +++++++++--------- .../WatchWorkoutSession.swift | 13 +- myApp/Keging/Shared/HealthKitCalls.swift | 8 ++ myApp/Keging/Shared/KegelEngine.swift | 91 ++++++++------ myApp/Keging/Shared/WorkoutSettings.swift | 12 +- 13 files changed, 291 insertions(+), 173 deletions(-) diff --git a/myApp/Keging/CLAUDE.md b/myApp/Keging/CLAUDE.md index 6ffcb07..da7000a 100644 --- a/myApp/Keging/CLAUDE.md +++ b/myApp/Keging/CLAUDE.md @@ -20,7 +20,7 @@ | 최소 OS | iOS 18.0 / watchOS 10.0 | | 수익 모델 | **완전 무료** — 광고·결제 일절 없음. CloudKit 없음. HealthKit은 **쓰기 전용**(운동 기록 저장 — §3.7) | | 언어 | **한국어(원문)/영어/일본어** — String Catalog(타깃별 `Localizable.xcstrings`, InfoPlist 포함). 설정 → 언어(시스템/한국어/English/日本語)에서 앱 내 전환: `AppleLanguages` 오버라이드, **완전 종료 후 재실행 시 적용**(하루 다님 검증 방식). 워치·Live Activity·단축어 표기는 각자의 시스템 언어를 따름 | -| 현재 상태 (2026-08-29) | **1.0 전 기능 구현 완료(도움말·3개 언어·애플 건강 연동 포함), 시뮬레이터 검증 통과** — Debug·Release·워치 빌드 그린, 화면 QA(라이트/다크/iOS 18.5/워치 46·40mm + en/ja + 건강 섹션·권한 시트), 전 카탈로그 missing·stale 0, 완주·기록 저장 경로 확인. **실기기 테스트 대기(사용자)**: 진동 패턴(포그라운드·백그라운드), 다이나믹 아일랜드, 워치 페어링 동기화, 단축어, 워치 손목 내림, **애플 건강 저장·운동 강도**(권한 시트 조작이 필요해 시뮬 E2E 불가 — 권한 시트 표시까지만 확인)는 시뮬레이터로 검증 불가 | +| 현재 상태 (2026-08-29) | **1.0 전 기능 구현 + 실기기(아이폰) 1차 피드백 3건 수정, 재테스트 대기.** 1차 발견·수정: ①다이나믹 아일랜드 백그라운드 멈춤 → 오디오 키퍼 하드닝 + 엔진 벽시계 재설계(§3.1·§3.4) ②진동 패턴 동일 현상 → 완료 체이닝 재작성(§3.3) ③운동 칼로리 0 → 에너지 샘플 직접 추가(§3.7). Debug·Release·워치 빌드 그린, 카탈로그 0/0, 완주 경로 시뮬 재검증. **재테스트 항목(사용자)**: DI 백그라운드 갱신, 진동 4종 구분, 칼로리 표기(+재권한 시트에 '활성 에너지' 추가됨 — 허용 필요), 워치(권한에 심박·에너지 읽기 추가) | ## 2. 빌드·검증 환경 @@ -110,7 +110,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] ### 3.1 세트와 엔진 (`Shared/KegelEngine.swift`) - **세트 = 준비 P초 → (수축 N초 → 이완 M초) × 횟수**. 기본 세팅: 준비 3초·수축 10초·이완 5초·20회 (범위 준비 0~10초·수축/이완 1~60초·1~100회). 준비(prepare)는 무진동 — 첫 수축 진동이 시작 신호이고, 준비 0초면 즉시 수축 -- 상태 기계: `idle → running(phase: prepare|contract|relax, rep, phaseStart, phaseEnd) → finished → (확인) idle`. 국면 경계마다 1회성 Timer, 화면 카운트다운은 `Text(timerInterval:)`이라 틱 타이머 없음. **기록·건강의 startedAt은 준비를 뺀 첫 수축 시각** +- 상태 기계: `idle → running(phase: prepare|contract|relax, rep, phaseStart, phaseEnd) → finished → (확인) idle`. **전 국면이 sessionStart(버튼 시각) 기준 벽시계로 결정적 계산**(`computeTarget`) — 타이머는 국면 경계 재장전용일 뿐이라 백그라운드 정지 후에도 다음 tick/`resyncAfterWake()`(scenePhase .active)에서 정확한 국면으로 자가 교정된다(실기기 다이나믹 아일랜드 멈춤 사고의 2중 방어). onPhaseChange는 (phase, rep)가 실제로 바뀔 때만. 화면 카운트다운은 `Text(timerInterval:)`. **기록·건강의 startedAt은 준비를 뺀 첫 수축 시각(스케줄 확정값)** - **완주해야만 기록** — `complete()`에서 RecordStore에 저장. **중단(`stop()`)하면 이 세트는 아예 없던 것** (기록·통계 미포함). 아이폰은 중단 전 확인 다이얼로그, 워치는 즉시 중단 - 진행 중 재시작 불가(`isRunning` 가드). 완료 화면은 "확인"으로 닫는다 - 하루 목표 횟수 기능은 **사용자 결정(2026-08-29)으로 제외** — 추후 후보로만, 선제 구현 금지 @@ -122,11 +122,11 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] ### 3.3 진동 (`Shared/WorkoutSettings.swift`, `IOS/Haptics.swift`, 워치 `WatchHaptics.swift`) - 패턴 4종 — **횟수 세기가 아니라 첫 순간의 질감으로 구분**(1.5(사용자 피드백)에서 횟수 기반 4종 교체): 톡(짧게 1방) / 톡톡(0.5s 간격 2방) / 드르륵(0.5s 간격 3연타) / 지잉(0.3s 겹침 3방=끊기지 않는 긴 진동). 기본: **수축=지잉(조이고 유지), 이완=톡(탁 풀기)**. 세팅에서 국면별 선택 + "미리 느끼기". 구버전 저장값(once/twice/thrice/heartbeat)은 `HapticPattern(migrating:)`으로 이관 -- iOS는 `AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)`(1회 ≈0.4초) 시퀀스 — 0.35초 이하 간격은 겹쳐 긴 진동이 되고 0.5초 간격은 빠른 연타가 된다. 무음 스위치 무관, **무음 오디오 세션이 살아 있으면 백그라운드에서도 울린다** +- iOS는 `AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate)` **완료 체이닝** — ⚠️**재생 중 같은 시스템 사운드 재호출은 무시된다**(실기기 실측: 시간 오프셋 방식은 겹침·짧은 간격이 전부 먹혀 톡=톡톡이 됐음). 완료 후 쉼: 톡=1방, 톡톡=0.25초 쉬고 1방, 드르륵=0.15초 간격 3연타, 지잉=즉시 이어붙인 3방. 무음 스위치 무관, **무음 오디오 세션이 살아 있으면 백그라운드에서도 울린다** - 워치는 반복 재생이 뭉개져서 **패턴마다 성격이 다른 시스템 햅틱 1회**로 매핑: 톡=.start, 톡톡=.directionUp, 드르륵=.retry, 지잉=.notification. **워치에서 실행하면 진동은 워치에서만** (완주 시 .success 1회) ### 3.4 백그라운드·Live Activity (`IOS/BackgroundAudioKeeper.swift`, `LiveActivityManager.swift`, `KegingWidgets/`) -- 운동 시작 시 무음 WAV 무한 루프(`UIBackgroundModes=audio`, `.playback + .mixWithOthers`) → 앱을 나가도 타이머·진동·Live Activity 갱신 유지. 종료·중단 시 즉시 해제. 운동 중 `isIdleTimerDisabled`로 화면 꺼짐 방지(종료 시 복원) +- 운동 시작 시 무음 WAV 무한 루프(`UIBackgroundModes=audio`, `.playback + .mixWithOthers`, **volume 0.1**·prepareToPlay — 0.0은 백그라운드 유지가 풀리는 사례 대비) → 앱을 나가도 타이머·진동·Live Activity 갱신 유지. **인터럽션 종료·백그라운드 전환·활성화 때 재생 죽었으면 revive()** 옵저버. 그래도 정지되면 엔진 벽시계 재동기화(§3.1)가 복귀 시 교정. 종료·중단 시 즉시 해제. 운동 중 `isIdleTimerDisabled`로 화면 꺼짐 방지(종료 시 복원) - Live Activity: 국면 전환마다 update — 다이나믹 아일랜드 컴팩트(국면 아이콘 + n/총), 확장(국면·카운트다운·진행 바), 잠금화면 배너. 종료·중단 시 `.immediate` dismiss. 수축=민트 `#7FBF9E`·이완=앰버 `#E8C558` 고정색 ### 3.5 단축어 (`IOS/KegingIntents.swift`) @@ -137,7 +137,8 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] - **완주한 세트만** 애플 건강에 운동으로 자동 기록 — 중단 세트는 건강에도 없음(§3.1 규칙 일관). 실행한 기기가 기록(폰=사후 HKWorkoutBuilder, 워치=라이브 HKWorkoutSession — 심박 포함, 백그라운드 유지 겸용). 출처 앱 아이콘은 시스템이 자동 표기 - **운동 유형 선택(설정 → 애플 건강)**: '기타'(기본) ↔ '코어 트레이닝' — `HealthWorkoutType`(WorkoutConfig 필드, 워치 동기화). 하루 다님의 종목 매핑에서는 둘 다 목록 외라 동일하게 '기타 운동'으로 잡힘(호환 무영향) - **운동 강도(workout effort score, 1~10)**: 설정 → 애플 건강 → '운동 강도 자동 기록'(기본 2·쉬움, '안 함' 가능) — 완주 시 `relateWorkoutEffortSample`로 운동에 연결(애플 운동 앱의 강도 기록과 같은 데이터). iOS 18/워치 11+ — 워치 10은 `#available` 가드로 운동만 기록 -- 권한은 첫 운동 시작 때 요청(쓰기 전용 — 읽기 없음). 거부·미응답이면 조용히 건너뜀(앱 자체 기록·통계 무영향). 워치는 권한 응답이 늦어 운동이 끝났으면 세션을 열지 않는 가드 있음 +- **칼로리**: 빌더로 저장한 운동은 에너지 샘플을 직접 넣지 않으면 0kcal(실기기 실측) — iOS는 `estimatedKcal`(MET 1.8+강도×0.17, 체중 70kg 가정)로 activeEnergyBurned 샘플을 add. 워치는 라이브 빌더가 센서로 심박·활성 에너지를 실측 수집(read: 심박·활성 에너지, share에 activeEnergyBurned 추가) +- 권한은 첫 운동 시작 때 요청 — iOS는 쓰기 전용(운동·강도·활성 에너지), 워치는 +심박·활성 에너지 읽기(NSHealthShareUsageDescription 문구가 이를 설명). 거부·미응답이면 조용히 건너뜀(앱 자체 기록·통계 무영향). 워치는 권한 응답이 늦어 운동이 끝났으면 세션을 열지 않는 가드 있음 - ⚠️ (하루 다님 ITMS-90683 전례) HealthKit 엔타이틀먼트 보유 → `NSHealthUpdateUsageDescription` 필수 — iOS·워치 Info.plist에 3언어(InfoPlist.xcstrings)로 등록됨. 엔타이틀먼트는 iOS·워치 두 타깃(위젯 제외) - **하루 다님 연계(2026-08-29 확인 — 하루 다님 무수정)**: 하루 다님의 종목별 운동 다짐·타임테이블 운동 블록은 HKWorkout을 출처 무관 조회라 케깅 세트가 '기타 운동'으로 자동 집계됨. 단 '운동 시간' 지표는 `appleExerciseTime`(애플 전용 쓰기 불가) 기반이라 **아이폰 실행분은 미반영, 워치 실행분은 운동 링 적립을 통해 반영**(구조적 한계 — 케깅 측 해결 불가) - `effortScore`는 WorkoutConfig 필드로 워치에 자동 동기화. **WorkoutConfig는 필드 추가 대비 전 필드 decodeIfPresent 커스텀 디코더** — 새 필드를 추가하면 여기에도 기본값을 넣을 것(안 넣으면 기존 사용자 설정이 통째로 초기화됨) diff --git a/myApp/Keging/IOS/BackgroundAudioKeeper.swift b/myApp/Keging/IOS/BackgroundAudioKeeper.swift index f006684..124399a 100644 --- a/myApp/Keging/IOS/BackgroundAudioKeeper.swift +++ b/myApp/Keging/IOS/BackgroundAudioKeeper.swift @@ -4,15 +4,18 @@ // // 운동 중 무음 오디오 루프 재생 — 앱을 나가도 타이머·진동·Live Activity 갱신이 유지되게 한다. // (UIBackgroundModes=audio, .mixWithOthers라 음악 재생을 방해하지 않음) +// 인터럽션(전화 등)·백그라운드 전환에서 재생이 끊기면 다시 살린다. // import AVFoundation +import UIKit @MainActor final class BackgroundAudioKeeper { static let shared = BackgroundAudioKeeper() private var player: AVAudioPlayer? + private var observers: [NSObjectProtocol] = [] private init() {} @@ -23,16 +26,50 @@ final class BackgroundAudioKeeper { try? session.setActive(true) player = try? AVAudioPlayer(data: Self.silenceWAV) player?.numberOfLoops = -1 - player?.volume = 0 + // 0.0은 일부 환경에서 백그라운드 유지가 풀리는 사례가 있어 소량으로 — 샘플이 무음이라 들리지 않는다 + player?.volume = 0.1 + player?.prepareToPlay() player?.play() + installObservers() } func stop() { + removeObservers() player?.stop() player = nil try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) } + /// 재생이 죽었으면 되살린다 (인터럽션 종료·씬 전환 시) + private func revive() { + guard let player, !player.isPlaying else { return } + try? AVAudioSession.sharedInstance().setActive(true) + player.play() + } + + private func installObservers() { + guard observers.isEmpty else { return } + let center = NotificationCenter.default + observers.append(center.addObserver( + forName: AVAudioSession.interruptionNotification, object: nil, queue: .main + ) { [weak self] note in + guard let info = note.userInfo, + let rawType = info[AVAudioSessionInterruptionTypeKey] as? UInt, + AVAudioSession.InterruptionType(rawValue: rawType) == .ended else { return } + Task { @MainActor in self?.revive() } + }) + for name in [UIApplication.didEnterBackgroundNotification, UIApplication.didBecomeActiveNotification] { + observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor in self?.revive() } + }) + } + } + + private func removeObservers() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + } + /// 1초짜리 무음 WAV (8kHz 16bit mono) — 파일 없이 메모리에서 생성 private static let silenceWAV: Data = { let sampleRate = 8000 diff --git a/myApp/Keging/IOS/ContentView.swift b/myApp/Keging/IOS/ContentView.swift index 58662eb..dcb441e 100644 --- a/myApp/Keging/IOS/ContentView.swift +++ b/myApp/Keging/IOS/ContentView.swift @@ -11,6 +11,8 @@ 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 @@ -33,6 +35,10 @@ struct ContentView: View { .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 diff --git a/myApp/Keging/IOS/Haptics.swift b/myApp/Keging/IOS/Haptics.swift index 98f643d..5a9f8d2 100644 --- a/myApp/Keging/IOS/Haptics.swift +++ b/myApp/Keging/IOS/Haptics.swift @@ -2,19 +2,40 @@ // Haptics.swift // Keging // -// 진동 패턴 재생 (iOS) — 시스템 바이브를 패턴 오프셋대로 연타. -// AudioServices 바이브는 무음 스위치와 무관하고, 오디오 세션이 살아 있으면 백그라운드에서도 울린다. +// 진동 패턴 재생 (iOS) — 시스템 바이브(1회 ≈0.5초)를 완료 콜백으로 체이닝한다. +// ⚠️ 재생 중에 같은 시스템 사운드를 다시 호출하면 무시되므로(실기기 실측 — 겹침·짧은 +// 간격 오프셋이 전부 먹혀 패턴이 똑같아지던 버그) 반드시 완료 후 다음 진동을 쏜다. +// AudioServices 바이브는 무음 스위치 무관, 오디오 세션이 살아 있으면 백그라운드에서도 울린다. // import AudioToolbox +import Foundation -@MainActor -enum Haptics { +nonisolated enum Haptics { static func play(_ pattern: HapticPattern) { - for offset in pattern.beatOffsets { - DispatchQueue.main.asyncAfter(deadline: .now() + offset) { - AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + // 각 진동 앞의 쉼(초) — 첫 진동은 즉시 + let gaps: [TimeInterval] + switch pattern { + case .short: gaps = [0] // 톡: 1방 + case .doubleTap: gaps = [0, 0.25] // 톡톡: 완료 후 0.25초 쉬고 1방 더 + case .rattle: gaps = [0, 0.15, 0.15] // 드르륵: 짧은 쉼으로 3연타 + case .long: gaps = [0, 0, 0] // 지잉: 완료 즉시 이어 붙여 긴 진동감 + } + playChain(gaps) + } + + private static func playChain(_ gaps: [TimeInterval]) { + guard let gap = gaps.first else { return } + let rest = Array(gaps.dropFirst()) + let fire = { + AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate) { + DispatchQueue.main.async { playChain(rest) } } } + if gap > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + gap, execute: fire) + } else { + fire() + } } } diff --git a/myApp/Keging/IOS/HealthRecorder.swift b/myApp/Keging/IOS/HealthRecorder.swift index 700e943..6ddb3ec 100644 --- a/myApp/Keging/IOS/HealthRecorder.swift +++ b/myApp/Keging/IOS/HealthRecorder.swift @@ -19,7 +19,18 @@ final class HealthRecorder { private init() {} private static var shareTypes: Set { - [HKObjectType.workoutType(), HKQuantityType(.workoutEffortScore)] + [ + HKObjectType.workoutType(), + HKQuantityType(.workoutEffortScore), + HKQuantityType(.activeEnergyBurned), + ] + } + + /// 칼로리 추정 — 케겔은 저강도라 MET 1.8(강도 0)~3.5(강도 10), 체중 70kg 가정. + /// 빌더로 저장한 운동은 에너지 샘플을 직접 넣지 않으면 0kcal로 남는다(실기기 실측) + static func estimatedKcal(seconds: TimeInterval, effortScore: Int) -> Double { + let met = 1.8 + Double(min(max(effortScore, 0), 10)) * 0.17 + return met * 3.5 * 70 / 200 * (seconds / 60) } /// 운동 시작 시 호출 — 첫 실행에서만 권한 시트가 뜬다 (운동 진행에는 영향 없음) @@ -44,6 +55,14 @@ final class HealthRecorder { Task { @MainActor in do { try await HealthKitCalls.beginCollection(builder, at: start) + let kcal = Self.estimatedKcal(seconds: end.timeIntervalSince(start), effortScore: effortScore) + let energySample = HKQuantitySample( + type: HKQuantityType(.activeEnergyBurned), + quantity: HKQuantity(unit: .kilocalorie(), doubleValue: kcal), + start: start, + end: end + ) + try await HealthKitCalls.add([energySample], to: builder) try await HealthKitCalls.endCollection(builder, at: end) let workout = try await HealthKitCalls.finishWorkout(builder) if effortScore > 0, let workout { diff --git a/myApp/Keging/IOS/Info.plist b/myApp/Keging/IOS/Info.plist index 36f9dcb..b9392ce 100644 --- a/myApp/Keging/IOS/Info.plist +++ b/myApp/Keging/IOS/Info.plist @@ -11,6 +11,6 @@ NSHealthUpdateUsageDescription 케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요. NSHealthShareUsageDescription - 케깅은 건강 데이터를 읽지 않아요. + 워치에서 운동할 때 심박수와 활성 에너지를 운동 기록에 담기 위해 필요해요. 그 외 건강 데이터는 읽지 않아요. diff --git a/myApp/Keging/IOS/InfoPlist.xcstrings b/myApp/Keging/IOS/InfoPlist.xcstrings index 3665428..ed6a17a 100644 --- a/myApp/Keging/IOS/InfoPlist.xcstrings +++ b/myApp/Keging/IOS/InfoPlist.xcstrings @@ -1,87 +1,87 @@ { - "sourceLanguage" : "ko", - "strings" : { - "CFBundleDisplayName" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keging" + "sourceLanguage": "ko", + "strings": { + "CFBundleDisplayName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keging" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keging" + "ja": { + "stringUnit": { + "state": "translated", + "value": "Keging" } }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "케깅" + "ko": { + "stringUnit": { + "state": "translated", + "value": "케깅" } } } }, - "CFBundleName" : { - "comment" : "Bundle name", - "extractionState" : "extracted_with_value", - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "Keging" + "CFBundleName": { + "comment": "Bundle name", + "extractionState": "extracted_with_value", + "localizations": { + "ko": { + "stringUnit": { + "state": "new", + "value": "Keging" } } } }, - "NSHealthShareUsageDescription" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keging does not read your health data." + "NSHealthShareUsageDescription": { + "extractionState": "manual", + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "워치에서 운동할 때 심박수와 활성 에너지를 운동 기록에 담기 위해 필요해요. 그 외 건강 데이터는 읽지 않아요." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kegingはヘルスケアデータを読み取りません。" + "en": { + "stringUnit": { + "state": "translated", + "value": "Needed to include heart rate and active energy in workouts recorded on the watch. No other health data is read." } }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "케깅은 건강 데이터를 읽지 않아요." + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウォッチでのワークアウト時に心拍数とアクティブエネルギーを記録に含めるために必要です。それ以外のヘルスケアデータは読み取りません。" } } } }, - "NSHealthUpdateUsageDescription" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Used to save your finished Kegel workouts to Apple Health." + "NSHealthUpdateUsageDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Used to save your finished Kegel workouts to Apple Health." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。" } }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요." + "ko": { + "stringUnit": { + "state": "translated", + "value": "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요." } } } } }, - "version" : "1.0" + "version": "1.0" } \ No newline at end of file diff --git a/myApp/Keging/KegingWatch Watch App/Info.plist b/myApp/Keging/KegingWatch Watch App/Info.plist index 89a1aa3..1f24555 100644 --- a/myApp/Keging/KegingWatch Watch App/Info.plist +++ b/myApp/Keging/KegingWatch Watch App/Info.plist @@ -11,6 +11,6 @@ NSHealthUpdateUsageDescription 케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요. NSHealthShareUsageDescription - 케깅은 건강 데이터를 읽지 않아요. + 워치에서 운동할 때 심박수와 활성 에너지를 운동 기록에 담기 위해 필요해요. 그 외 건강 데이터는 읽지 않아요. diff --git a/myApp/Keging/KegingWatch Watch App/InfoPlist.xcstrings b/myApp/Keging/KegingWatch Watch App/InfoPlist.xcstrings index 06104e0..d094961 100644 --- a/myApp/Keging/KegingWatch Watch App/InfoPlist.xcstrings +++ b/myApp/Keging/KegingWatch Watch App/InfoPlist.xcstrings @@ -1,87 +1,87 @@ { - "sourceLanguage" : "ko", - "strings" : { - "CFBundleDisplayName" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keging" + "sourceLanguage": "ko", + "strings": { + "CFBundleDisplayName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keging" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keging" + "ja": { + "stringUnit": { + "state": "translated", + "value": "Keging" } }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "케깅" + "ko": { + "stringUnit": { + "state": "translated", + "value": "케깅" } } } }, - "CFBundleName" : { - "comment" : "Bundle name", - "extractionState" : "extracted_with_value", - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "KegingWatch Watch App" + "CFBundleName": { + "comment": "Bundle name", + "extractionState": "extracted_with_value", + "localizations": { + "ko": { + "stringUnit": { + "state": "new", + "value": "KegingWatch Watch App" } } } }, - "NSHealthShareUsageDescription" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keging does not read your health data." + "NSHealthShareUsageDescription": { + "extractionState": "manual", + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "워치에서 운동할 때 심박수와 활성 에너지를 운동 기록에 담기 위해 필요해요. 그 외 건강 데이터는 읽지 않아요." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kegingはヘルスケアデータを読み取りません。" + "en": { + "stringUnit": { + "state": "translated", + "value": "Needed to include heart rate and active energy in workouts recorded on the watch. No other health data is read." } }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "케깅은 건강 데이터를 읽지 않아요." + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウォッチでのワークアウト時に心拍数とアクティブエネルギーを記録に含めるために必要です。それ以外のヘルスケアデータは読み取りません。" } } } }, - "NSHealthUpdateUsageDescription" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Used to save your finished Kegel workouts to Apple Health." + "NSHealthUpdateUsageDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Used to save your finished Kegel workouts to Apple Health." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。" } }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요." + "ko": { + "stringUnit": { + "state": "translated", + "value": "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요." } } } } }, - "version" : "1.0" + "version": "1.0" } \ No newline at end of file diff --git a/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift b/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift index 76b6a46..6f541bb 100644 --- a/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift +++ b/myApp/Keging/KegingWatch Watch App/WatchWorkoutSession.swift @@ -20,13 +20,22 @@ final class WatchWorkoutSession: NSObject { private override init() { super.init() } private static var shareTypes: Set { - var types: Set = [HKObjectType.workoutType()] + var types: Set = [ + HKObjectType.workoutType(), + HKQuantityType(.activeEnergyBurned), + HKQuantityType(.heartRate), + ] if #available(watchOS 11.0, *) { types.insert(HKQuantityType(.workoutEffortScore)) } return types } + /// 라이브 빌더가 센서에서 심박·활성 에너지를 수집하려면 읽기 권한 필요 + private static var readTypes: Set { + [HKQuantityType(.heartRate), HKQuantityType(.activeEnergyBurned)] + } + /// 운동 시작 직후 호출 — 권한 요청·세션 시작은 비동기라 타이머 진행에 영향 없음 func begin(startDate: Date) { Task { @MainActor in @@ -80,7 +89,7 @@ final class WatchWorkoutSession: NSObject { private func requestAuthorization() async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - store.requestAuthorization(toShare: Self.shareTypes, read: nil) { _, error in + store.requestAuthorization(toShare: Self.shareTypes, read: Self.readTypes) { _, error in if let error { continuation.resume(throwing: error) } else { continuation.resume() } } } diff --git a/myApp/Keging/Shared/HealthKitCalls.swift b/myApp/Keging/Shared/HealthKitCalls.swift index a6cea04..51fc5b3 100644 --- a/myApp/Keging/Shared/HealthKitCalls.swift +++ b/myApp/Keging/Shared/HealthKitCalls.swift @@ -33,6 +33,14 @@ nonisolated enum HealthKitCalls { } } + static func add(_ samples: [HKSample], to builder: HKWorkoutBuilder) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + builder.add(samples) { _, error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } + } + static func finishWorkout(_ builder: HKWorkoutBuilder) async throws -> HKWorkout? { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in builder.finishWorkout { workout, error in diff --git a/myApp/Keging/Shared/KegelEngine.swift b/myApp/Keging/Shared/KegelEngine.swift index 28191ba..6c35c9a 100644 --- a/myApp/Keging/Shared/KegelEngine.swift +++ b/myApp/Keging/Shared/KegelEngine.swift @@ -41,7 +41,11 @@ final class KegelEngine: ObservableObject { @Published private(set) var state: State = .idle private(set) var config = WorkoutConfig() + /// 기록·건강의 시작 시각 — 준비를 뺀 첫 수축 시각 (스케줄상 확정값) private(set) var startedAt = Date() + /// 시작 버튼을 누른 시각 — 모든 국면은 이 시각 기준 벽시계로 계산한다 + /// (백그라운드 정지 후 복귀해도 재동기화로 정확한 국면에 착지) + private var sessionStart = Date() private var timer: Timer? /// 국면 진입마다 호출 (phase, rep, phaseEnd) @@ -56,12 +60,16 @@ final class KegelEngine: ObservableObject { func start(config: WorkoutConfig) { guard !isRunning else { return } self.config = config - startedAt = Date() - if config.prepSeconds > 0 { - enterPhase(.prepare, rep: 1) - } else { - enterPhase(.contract, rep: 1) - } + sessionStart = Date() + startedAt = sessionStart.addingTimeInterval(Double(config.prepSeconds)) + applyTarget(computeTarget(at: Date())) + } + + /// 포그라운드 복귀 등에서 호출 — 벽시계 기준 현재 국면으로 재정렬. + /// 국면이 실제로 바뀌었을 때만 onPhaseChange(진동·Live Activity)가 나간다 + func resyncAfterWake() { + guard case .running = state else { return } + applyTarget(computeTarget(at: Date())) } /// 중단 — 이 세트는 아예 안 한 것으로 (기록 없음) @@ -78,37 +86,54 @@ final class KegelEngine: ObservableObject { if case .finished = state { state = .idle } } - private func enterPhase(_ phase: KegelPhase, rep: Int) { - let seconds = switch phase { - case .prepare: config.prepSeconds - case .contract: config.contractSeconds - case .relax: config.relaxSeconds + private enum Target { + case finished + case running(KegelPhase, Int, Date, Date) + } + + /// 벽시계 기준 지금 있어야 할 국면 — 세션 전체가 sessionStart로부터 결정적 + private func computeTarget(at now: Date) -> Target { + let prep = Double(config.prepSeconds) + let contract = Double(max(1, config.contractSeconds)) + let relax = Double(max(1, config.relaxSeconds)) + let cycle = contract + relax + let elapsed = now.timeIntervalSince(sessionStart) + if elapsed < prep { + return .running(.prepare, 1, sessionStart, sessionStart.addingTimeInterval(prep)) } - // 기록·건강의 시작 시각은 준비를 뺀 첫 수축 기준 - if phase == .contract && rep == 1 { startedAt = Date() } - let now = Date() - let end = now.addingTimeInterval(Double(max(1, seconds))) - state = .running(phase: phase, rep: rep, phaseStart: now, phaseEnd: end) - onPhaseChange?(phase, rep, end) - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval: Double(max(1, seconds)), repeats: false) { [weak self] _ in - guard let self else { return } - Task { @MainActor in self.advance(from: phase, rep: rep) } + let exercised = elapsed - prep + let repIndex = Int(exercised / cycle) // 0부터 + guard repIndex < config.reps else { return .finished } + let repStart = sessionStart.addingTimeInterval(prep + Double(repIndex) * cycle) + if exercised - Double(repIndex) * cycle < contract { + return .running(.contract, repIndex + 1, repStart, repStart.addingTimeInterval(contract)) + } + return .running(.relax, repIndex + 1, repStart.addingTimeInterval(contract), repStart.addingTimeInterval(cycle)) + } + + private func applyTarget(_ target: Target) { + switch target { + case .finished: + complete() + case let .running(phase, rep, phaseStart, phaseEnd): + let changed: Bool = { + guard case let .running(currentPhase, currentRep, _, _) = state else { return true } + return currentPhase != phase || currentRep != rep + }() + state = .running(phase: phase, rep: rep, phaseStart: phaseStart, phaseEnd: phaseEnd) + if changed { onPhaseChange?(phase, rep, phaseEnd) } + scheduleTick(at: phaseEnd) } } - private func advance(from phase: KegelPhase, rep: Int) { - guard case .running = state else { return } - switch phase { - case .prepare: - enterPhase(.contract, rep: 1) - case .contract: - enterPhase(.relax, rep: rep) - case .relax: - if rep < config.reps { - enterPhase(.contract, rep: rep + 1) - } else { - complete() + private func scheduleTick(at date: Date) { + timer?.invalidate() + let interval = max(0.05, date.timeIntervalSinceNow + 0.02) + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + guard case .running = self.state else { return } + self.applyTarget(self.computeTarget(at: Date())) } } } diff --git a/myApp/Keging/Shared/WorkoutSettings.swift b/myApp/Keging/Shared/WorkoutSettings.swift index aeeaa11..c38d14c 100644 --- a/myApp/Keging/Shared/WorkoutSettings.swift +++ b/myApp/Keging/Shared/WorkoutSettings.swift @@ -24,16 +24,8 @@ nonisolated enum HapticPattern: String, CaseIterable, Identifiable, Codable { } } - /// 각 진동이 울리는 시점(초) — 시스템 바이브 1회가 약 0.4초라 - /// 0.35초 간격은 겹쳐서 하나의 긴 진동, 0.5초 간격은 빠른 연타가 된다 - var beatOffsets: [TimeInterval] { - switch self { - case .short: [0] - case .doubleTap: [0, 0.5] - case .rattle: [0, 0.5, 1.0] - case .long: [0, 0.3, 0.6] - } - } + // 재생 방식은 플랫폼별: iOS = 완료 체이닝 시퀀스(IOS/Haptics.swift), + // 워치 = 패턴별 단일 시스템 햅틱(WatchHaptics.swift) /// 1.0 초기 패턴(횟수 기반) 저장값 이관 init?(migrating raw: String?) {