fix(keging): 실기기 1차 피드백 3건 — DI 백그라운드 멈춤·진동 패턴 동일·칼로리 0
① 다이나믹 아일랜드 멈춤(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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
This commit is contained in:
parent
c63618fcc6
commit
f5f8d3bde4
@ -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 커스텀 디코더** — 새 필드를 추가하면 여기에도 기본값을 넣을 것(안 넣으면 기존 사용자 설정이 통째로 초기화됨)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,7 +19,18 @@ final class HealthRecorder {
|
||||
private init() {}
|
||||
|
||||
private static var shareTypes: Set<HKSampleType> {
|
||||
[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 {
|
||||
|
||||
@ -11,6 +11,6 @@
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요.</string>
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>케깅은 건강 데이터를 읽지 않아요.</string>
|
||||
<string>워치에서 운동할 때 심박수와 활성 에너지를 운동 기록에 담기 위해 필요해요. 그 외 건강 데이터는 읽지 않아요.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -11,6 +11,6 @@
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요.</string>
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>케깅은 건강 데이터를 읽지 않아요.</string>
|
||||
<string>워치에서 운동할 때 심박수와 활성 에너지를 운동 기록에 담기 위해 필요해요. 그 외 건강 데이터는 읽지 않아요.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -20,13 +20,22 @@ final class WatchWorkoutSession: NSObject {
|
||||
private override init() { super.init() }
|
||||
|
||||
private static var shareTypes: Set<HKSampleType> {
|
||||
var types: Set<HKSampleType> = [HKObjectType.workoutType()]
|
||||
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) {
|
||||
Task { @MainActor in
|
||||
@ -80,7 +89,7 @@ final class WatchWorkoutSession: NSObject {
|
||||
|
||||
private func requestAuthorization() async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) 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() }
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,6 +33,14 @@ nonisolated enum HealthKitCalls {
|
||||
}
|
||||
}
|
||||
|
||||
static func add(_ samples: [HKSample], to builder: HKWorkoutBuilder) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) 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<HKWorkout?, Error>) in
|
||||
builder.finishWorkout { workout, error in
|
||||
|
||||
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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?) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user