feat(keging): 준비 시간(0~10초, 기본 3초) — 시작 버튼 후 카운트다운 뒤 첫 수축

- KegelPhase에 prepare 국면 추가(무진동 — 첫 수축 진동이 시작 신호), 준비 0초면 즉시 수축
- 세팅에 '준비' 스테퍼(0~10초), 워치 자동 동기화, 구버전 설정 decodeIfPresent 보존
- 진행 화면: 준비=회그린 원이 작게 시작해 천천히 부풀며 첫 수축으로 연결(기존 그래픽 문법 유지),
  진행 링 0·카운터 색 연동. 워치·Live Activity도 준비 국면 색/표시 대응
- 기록·건강의 시작 시각은 준비를 뺀 첫 수축 기준. Live Activity는 sync 단일 진입점으로 정리
- 도움말·세팅 푸터 문구 갱신, 신규 문자열 번역(en/ja)·stale 정리 — 전 카탈로그 0/0
- 검증: Debug·Release·워치 빌드 그린, 준비→수축 전환·세팅 화면 QA

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
This commit is contained in:
songyc macbook 2026-08-29 20:55:10 +09:00
parent 9c9df03571
commit 06d13b2f3d
15 changed files with 192 additions and 82 deletions

View File

@ -109,8 +109,8 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
## 3. 도메인·핵심 규칙 ## 3. 도메인·핵심 규칙
### 3.1 세트와 엔진 (`Shared/KegelEngine.swift`) ### 3.1 세트와 엔진 (`Shared/KegelEngine.swift`)
- **세트 = (수축 N초 → 이완 M초) × 횟수**, 수축이 먼저. 기본 세팅: 수축 10초·이완 5초·20회 (범위 1~60초·1~100회) - **세트 = 준비 P초 → (수축 N초 → 이완 M초) × 횟수**. 기본 세팅: 준비 3초·수축 10초·이완 5초·20회 (범위 준비 0~10초·수축/이완 1~60초·1~100회). 준비(prepare)는 무진동 — 첫 수축 진동이 시작 신호이고, 준비 0초면 즉시 수축
- 상태 기계: `idle → running(phase, rep, phaseStart, phaseEnd) → finished → (확인) idle`. 국면 경계마다 1회성 Timer, 화면 카운트다운은 `Text(timerInterval:)`이라 틱 타이머 없음 - 상태 기계: `idle → running(phase: prepare|contract|relax, rep, phaseStart, phaseEnd) → finished → (확인) idle`. 국면 경계마다 1회성 Timer, 화면 카운트다운은 `Text(timerInterval:)`이라 틱 타이머 없음. **기록·건강의 startedAt은 준비를 뺀 첫 수축 시각**
- **완주해야만 기록**`complete()`에서 RecordStore에 저장. **중단(`stop()`)하면 이 세트는 아예 없던 것** (기록·통계 미포함). 아이폰은 중단 전 확인 다이얼로그, 워치는 즉시 중단 - **완주해야만 기록**`complete()`에서 RecordStore에 저장. **중단(`stop()`)하면 이 세트는 아예 없던 것** (기록·통계 미포함). 아이폰은 중단 전 확인 다이얼로그, 워치는 즉시 중단
- 진행 중 재시작 불가(`isRunning` 가드). 완료 화면은 "확인"으로 닫는다 - 진행 중 재시작 불가(`isRunning` 가드). 완료 화면은 "확인"으로 닫는다
- 하루 목표 횟수 기능은 **사용자 결정(2026-08-29)으로 제외** — 추후 후보로만, 선제 구현 금지 - 하루 목표 횟수 기능은 **사용자 결정(2026-08-29)으로 제외** — 추후 후보로만, 선제 구현 금지
@ -150,7 +150,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
## 4. 화면 구성 (탭 없음 — 애플 기본 앱 느낌) ## 4. 화면 구성 (탭 없음 — 애플 기본 앱 느낌)
- **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + 상단 중앙 통계 그랩 힌트 + 버튼 아래 **세팅 요약 카드 3장**(수축·이완·횟수 — 아이콘+큰 숫자, 탭=세팅. 텍스트 한 줄이 성의 없다는 사용자 피드백으로 교체) - **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + 상단 중앙 통계 그랩 힌트 + 버튼 아래 **세팅 요약 카드 3장**(수축·이완·횟수 — 아이콘+큰 숫자, 탭=세팅. 텍스트 한 줄이 성의 없다는 사용자 피드백으로 교체)
- **세팅/설정/도움말**: 시트(Form/List). 설정에 테마·**언어**(시스템/한국어/English/日本語 — 재실행 시 적용)·**애플 건강**(운동 강도 자동 기록) 섹션. 도움말은 5그룹 14주제(기본 사용법·통계·앱 밖에서·애플워치·설정 기타)로 전 기능 사용법 서술. 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션, **국면 색 전환(수축=그린·이완=앰버, 카운터 색 연동)** + 원 둘레 **세트 진행 링**(rep 기준, 국면 색) + 큰 현재 횟수 카운터(numericText 전환) - **세팅/설정/도움말**: 시트(Form/List). 설정에 테마·**언어**(시스템/한국어/English/日本語 — 재실행 시 적용)·**애플 건강**(운동 강도 자동 기록) 섹션. 도움말은 5그룹 14주제(기본 사용법·통계·앱 밖에서·애플워치·설정 기타)로 전 기능 사용법 서술. 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션, **국면 색 전환(준비=회그린·수축=그린·이완=앰버, 카운터 색 연동)** + 원 둘레 **세트 진행 링**(rep 기준, 국면 색, 준비는 0) + 큰 현재 횟수 카운터(numericText 전환). 준비 국면은 작은 원이 천천히 부풀며 첫 수축으로 이어짐. Live Activity는 `LiveActivityManager.sync`(없으면 시작·있으면 갱신) 단일 진입점
- 아이콘 전용 버튼에는 `accessibilityLabel` 필수 (하루 다님 컨벤션 계승) - 아이콘 전용 버튼에는 `accessibilityLabel` 필수 (하루 다님 컨벤션 계승)
## 5. 테마·컬러 — 하루 다님 팔레트 계승 (`Shared/Theme.swift`) ## 5. 테마·컬러 — 하루 다님 팔레트 계승 (`Shared/Theme.swift`)

View File

@ -13,9 +13,9 @@ import Foundation
enum DebugSeed { enum DebugSeed {
static func applyLaunchArguments() { static func applyLaunchArguments() {
if CommandLine.arguments.contains("-seedStats") { seedStats() } if CommandLine.arguments.contains("-seedStats") { seedStats() }
// ( 1· 1·2) // ( 1· 1· 1·2)
if CommandLine.arguments.contains("-quickConfig") { if CommandLine.arguments.contains("-quickConfig") {
SettingsStore.shared.config = WorkoutConfig(contractSeconds: 1, relaxSeconds: 1, reps: 2) SettingsStore.shared.config = WorkoutConfig(prepSeconds: 1, contractSeconds: 1, relaxSeconds: 1, reps: 2)
} }
// //
if CommandLine.arguments.contains("-resetConfig") { if CommandLine.arguments.contains("-resetConfig") {

View File

@ -17,12 +17,12 @@ struct HelpView: View {
helpRow( helpRow(
symbol: "play.circle.fill", symbol: "play.circle.fill",
title: Text("바로 시작하기"), title: Text("바로 시작하기"),
body: Text("첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 바로 운동이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요.") body: Text("첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요.")
) )
helpRow( helpRow(
symbol: "slider.horizontal.3", symbol: "slider.horizontal.3",
title: Text("운동 세팅 바꾸기"), title: Text("운동 세팅 바꾸기"),
body: Text("오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 수축 시간(160초), 이완 시간(160초), 반복 횟수(1100회)를 정할 수 있어요. 예를 들어 수축 10초·이완 5초·20회로 설정하면, 수축과 이완을 20번 반복하는 한 세트가 약 5분이 돼요. 시작 버튼 아래의 카드를 눌러도 이 화면이 열려요.") body: Text("오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 준비 시간(010초), 수축 시간(160초), 이완 시간(160초), 반복 횟수(1100회)를 정할 수 있어요. 예를 들어 수축 10초·이완 5초·20회로 설정하면, 수축과 이완을 20번 반복하는 한 세트가 약 5분이 돼요. 시작 버튼 아래의 카드를 눌러도 이 화면이 열려요.")
) )
helpRow( helpRow(
symbol: "iphone.radiowaves.left.and.right", symbol: "iphone.radiowaves.left.and.right",

View File

@ -16,26 +16,21 @@ final class LiveActivityManager {
private init() {} private init() {}
func start(totalReps: Int, phase: KegelPhase, rep: Int, phaseEnd: Date) { /// ,
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } func sync(phase: KegelPhase, rep: Int, totalReps: Int, phaseEnd: Date) {
end()
let state = KegingActivityAttributes.ContentState( let state = KegingActivityAttributes.ContentState(
phase: phase, rep: rep, totalReps: totalReps, phase: phase, rep: rep, totalReps: totalReps,
phaseStart: Date(), phaseEnd: phaseEnd phaseStart: Date(), phaseEnd: phaseEnd
) )
if let activity {
Task { await activity.update(ActivityContent(state: state, staleDate: nil)) }
} else {
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
activity = try? Activity.request( activity = try? Activity.request(
attributes: KegingActivityAttributes(startedAt: Date()), attributes: KegingActivityAttributes(startedAt: Date()),
content: ActivityContent(state: state, staleDate: nil) content: ActivityContent(state: state, staleDate: nil)
) )
} }
func update(phase: KegelPhase, rep: Int, totalReps: Int, phaseEnd: Date) {
guard let activity else { return }
let state = KegingActivityAttributes.ContentState(
phase: phase, rep: rep, totalReps: totalReps,
phaseStart: Date(), phaseEnd: phaseEnd
)
Task { await activity.update(ActivityContent(state: state, staleDate: nil)) }
} }
func end() { func end() {

View File

@ -527,28 +527,6 @@
} }
} }
}, },
"수축 → 이완을 %lld회 반복해요. 한 세트 약 %@.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Repeats squeeze → relax %lld times. One set takes about %@."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "締める→ゆるめるを%lld回くり返します。1セットは約%@です。"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "수축 → 이완을 %1$lld회 반복해요. 한 세트 약 %2$@."
}
}
}
},
"수축 진동": { "수축 진동": {
"localizations": { "localizations": {
"en": { "en": {
@ -693,6 +671,28 @@
} }
} }
}, },
"시작 버튼을 누르면 준비 시간 뒤에 수축 → 이완을 %lld회 반복해요. 한 세트 약 %@.": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "시작 버튼을 누르면 준비 시간 뒤에 수축 → 이완을 %1$lld회 반복해요. 한 세트 약 %2$@."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "After the ready countdown, squeeze → relax repeats %lld times. One set takes about %@."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "開始ボタンを押すと、準備時間のあとに締める→ゆるめるを%lld回くり返します。1セットは約%@です。"
}
}
}
},
"안 함": { "안 함": {
"localizations": { "localizations": {
"en": { "en": {
@ -853,18 +853,18 @@
} }
} }
}, },
"오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 수축 시간(160초), 이완 시간(160초), 반복 횟수(1100회)를 정할 수 있어요. 예를 들어 수축 10초·이완 5초·20회로 설정하면, 수축과 이완을 20번 반복하는 한 세트가 약 5분이 돼요. 시작 버튼 아래의 카드를 눌러도 이 화면이 열려요.": { "오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 준비 시간(010초), 수축 시간(160초), 이완 시간(160초), 반복 횟수(1100회)를 정할 수 있어요. 예를 들어 수축 10초·이완 5초·20회로 설정하면, 수축과 이완을 20번 반복하는 한 세트가 약 5분이 돼요. 시작 버튼 아래의 카드를 눌러도 이 화면이 열려요.": {
"localizations": { "localizations": {
"en": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "The slider button at the top right opens the workout setup. You can set the squeeze time (160 s), relax time (160 s), and the number of reps (1100). For example, with squeeze 10 s · relax 5 s · 20 reps, one set takes about 5 minutes. Tapping the cards under the Start button also opens this screen." "value": "The slider button at the top right opens the workout setup. You can set the ready time (010 s), squeeze time (160 s), relax time (160 s), and the number of reps (1100). For example, with squeeze 10 s · relax 5 s · 20 reps, one set takes about 5 minutes. Tapping the cards under the Start button also opens this screen."
} }
}, },
"ja": { "ja": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "右上のスライダーボタンがワークアウト設定です。締める時間1〜60秒、ゆるめる時間1〜60秒、回数1〜100回を設定できます。例えば締める10秒・ゆるめる5秒・20回なら、1セットは約5分です。開始ボタンの下のカードを押してもこの画面が開きます。" "value": "右上のスライダーボタンがワークアウト設定です。準備時間0〜10秒締める時間1〜60秒、ゆるめる時間1〜60秒、回数1〜100回を設定できます。例えば締める10秒・ゆるめる5秒・20回なら、1セットは約5分です。開始ボタンの下のカードを押してもこの画面が開きます。"
} }
} }
} }
@ -1317,6 +1317,22 @@
} }
} }
}, },
"준비": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Ready"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "準備"
}
}
}
},
"중간에 중단하면": { "중간에 중단하면": {
"localizations": { "localizations": {
"en": { "en": {
@ -1477,18 +1493,18 @@
} }
} }
}, },
"첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 바로 운동이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요.": { "첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요.": {
"localizations": { "localizations": {
"en": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Tap the big Start button on the main screen to begin right away with your saved setup. Squeeze while the circle on screen shrinks, and release as it grows. When you finish all the reps, a completion screen appears and the set is recorded." "value": "Tap the big Start button on the main screen to begin with your saved setup. If you set a ready time, the first squeeze starts after that countdown. Squeeze while the circle on screen shrinks, and release as it grows. When you finish all the reps, a completion screen appears and the set is recorded."
} }
}, },
"ja": { "ja": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "最初の画面の大きな開始ボタンを押すと、保存した設定ですぐにワークアウトが始まります。画面の円が縮むときに締めて、大きくなるときに力を抜きます。設定した回数をすべて終えると完了画面が表示され、記録が保存されます。" "value": "最初の画面の大きな開始ボタンを押すと、保存した設定でワークアウトが始まります。準備時間を設定していれば、そのカウントダウンのあとに最初の締めが始まります。画面の円が縮むときに締めて、大きくなるときに力を抜きます。設定した回数をすべて終えると完了画面が表示され、記録が保存されます。"
} }
} }
} }

View File

@ -15,6 +15,9 @@ struct TimerSettingsView: View {
NavigationStack { NavigationStack {
Form { Form {
Section { Section {
Stepper(value: $settings.config.prepSeconds, in: 0...10) {
LabeledContent("준비", value: String(localized: "\(settings.config.prepSeconds)"))
}
Stepper(value: $settings.config.contractSeconds, in: 1...60) { Stepper(value: $settings.config.contractSeconds, in: 1...60) {
LabeledContent("수축", value: String(localized: "\(settings.config.contractSeconds)")) LabeledContent("수축", value: String(localized: "\(settings.config.contractSeconds)"))
} }
@ -27,7 +30,7 @@ struct TimerSettingsView: View {
} header: { } header: {
Text("사이클") Text("사이클")
} footer: { } footer: {
Text("수축 → 이완을 \(settings.config.reps)회 반복해요. 한 세트 약 \(durationText).") Text("시작 버튼을 누르면 준비 시간 뒤에 수축 → 이완을 \(settings.config.reps)회 반복해요. 한 세트 약 \(durationText).")
} }
Section { Section {

View File

@ -23,12 +23,12 @@ final class WorkoutCoordinator {
engine.onPhaseChange = { [weak engine] phase, rep, phaseEnd in engine.onPhaseChange = { [weak engine] phase, rep, phaseEnd in
guard let engine else { return } guard let engine else { return }
let config = engine.config let config = engine.config
Haptics.play(phase == .contract ? config.contractPattern : config.relaxPattern) switch phase {
if phase == .contract && rep == 1 { case .prepare: break //
LiveActivityManager.shared.start(totalReps: config.reps, phase: phase, rep: rep, phaseEnd: phaseEnd) case .contract: Haptics.play(config.contractPattern)
} else { case .relax: Haptics.play(config.relaxPattern)
LiveActivityManager.shared.update(phase: phase, rep: rep, totalReps: config.reps, phaseEnd: phaseEnd)
} }
LiveActivityManager.shared.sync(phase: phase, rep: rep, totalReps: config.reps, phaseEnd: phaseEnd)
} }
engine.onSessionEnd = { completed, record in engine.onSessionEnd = { completed, record in
LiveActivityManager.shared.end() LiveActivityManager.shared.end()

View File

@ -26,8 +26,10 @@ struct WorkoutView: View {
} }
private func runningView(phase: KegelPhase, rep: Int, phaseStart: Date, phaseEnd: Date) -> some View { private func runningView(phase: KegelPhase, rep: Int, phaseStart: Date, phaseEnd: Date) -> some View {
let phaseColor = phase == .contract ? AppTheme.green : AppTheme.yellow let phaseColor = Self.color(for: phase)
let progress = (Double(rep - 1) + (phase == .relax ? 0.5 : 0)) / Double(max(1, engine.config.reps)) let progress = phase == .prepare
? 0
: (Double(rep - 1) + (phase == .relax ? 0.5 : 0)) / Double(max(1, engine.config.reps))
return VStack(spacing: 0) { return VStack(spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 5) { HStack(alignment: .firstTextBaseline, spacing: 5) {
Text(verbatim: "\(rep)") Text(verbatim: "\(rep)")
@ -67,9 +69,7 @@ struct WorkoutView: View {
.monospacedDigit() .monospacedDigit()
.opacity(0.85) .opacity(0.85)
} }
.foregroundStyle(phase == .contract .foregroundStyle(Self.textColor(for: phase))
? Color(light: .white, dark: Color(hex: "#0F2018"))
: Color(hex: "#33260A"))
} }
Spacer() Spacer()
@ -93,6 +93,23 @@ struct WorkoutView: View {
} }
} }
/// = , =, =
static func color(for phase: KegelPhase) -> Color {
switch phase {
case .prepare: Color(light: Color(hex: "#7C8A81"), dark: Color(hex: "#5E6B64"))
case .contract: AppTheme.green
case .relax: AppTheme.yellow
}
}
static func textColor(for phase: KegelPhase) -> Color {
switch phase {
case .prepare: Color(light: .white, dark: Color(hex: "#10140F"))
case .contract: Color(light: .white, dark: Color(hex: "#0F2018"))
case .relax: Color(hex: "#33260A")
}
}
private func finishedView(reps: Int) -> some View { private func finishedView(reps: Int) -> some View {
VStack(spacing: 18) { VStack(spacing: 18) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
@ -124,7 +141,7 @@ private struct PhaseCircle: View {
@State private var scale: CGFloat = 1 @State private var scale: CGFloat = 1
private var color: Color { phase == .contract ? AppTheme.green : AppTheme.yellow } private var color: Color { WorkoutView.color(for: phase) }
var body: some View { var body: some View {
Circle() Circle()
@ -144,13 +161,15 @@ private struct PhaseCircle: View {
private func animate() { private func animate() {
let duration = max(0.3, phaseEnd.timeIntervalSince(phaseStart)) let duration = max(0.3, phaseEnd.timeIntervalSince(phaseStart))
// : ( )
let (from, to): (CGFloat, CGFloat) = switch phase {
case .prepare: (0.62, 1.0)
case .contract: (1.0, 0.62)
case .relax: (0.62, 1.0)
}
var transaction = Transaction() var transaction = Transaction()
transaction.disablesAnimations = true transaction.disablesAnimations = true
withTransaction(transaction) { withTransaction(transaction) { scale = from }
scale = phase == .contract ? 1.0 : 0.62 withAnimation(.easeInOut(duration: duration)) { scale = to }
}
withAnimation(.easeInOut(duration: duration)) {
scale = phase == .contract ? 0.62 : 1.0
}
} }
} }

View File

@ -66,7 +66,7 @@ struct WatchContentView: View {
VStack(spacing: 3) { VStack(spacing: 3) {
Text(phase.label) Text(phase.label)
.font(.title3.bold()) .font(.title3.bold())
.foregroundStyle(phase == .contract ? AppTheme.green : AppTheme.yellow) .foregroundStyle(phaseColor(phase))
Text(timerInterval: phaseStart...phaseEnd, countsDown: true) Text(timerInterval: phaseStart...phaseEnd, countsDown: true)
.font(.system(size: 30, weight: .semibold)) .font(.system(size: 30, weight: .semibold))
.monospacedDigit() .monospacedDigit()
@ -88,6 +88,14 @@ struct WatchContentView: View {
} }
} }
private func phaseColor(_ phase: KegelPhase) -> Color {
switch phase {
case .prepare: .gray
case .contract: AppTheme.green
case .relax: AppTheme.yellow
}
}
private func finishedScreen(reps: Int) -> some View { private func finishedScreen(reps: Int) -> some View {
VStack(spacing: 6) { VStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")

View File

@ -169,6 +169,22 @@
} }
} }
}, },
"준비": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Ready"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "準備"
}
}
}
},
"중단": { "중단": {
"localizations": { "localizations": {
"en": { "en": {

View File

@ -23,7 +23,11 @@ final class WatchCoordinator {
engine.onPhaseChange = { [weak engine] phase, _, _ in engine.onPhaseChange = { [weak engine] phase, _, _ in
guard let engine else { return } guard let engine else { return }
let config = engine.config let config = engine.config
WatchHaptics.play(phase == .contract ? config.contractPattern : config.relaxPattern) switch phase {
case .prepare: break //
case .contract: WatchHaptics.play(config.contractPattern)
case .relax: WatchHaptics.play(config.relaxPattern)
}
} }
engine.onSessionEnd = { completed, record in engine.onSessionEnd = { completed, record in
WatchWorkoutSession.shared.end(completed: completed, effortScore: SettingsStore.shared.config.effortScore) WatchWorkoutSession.shared.end(completed: completed, effortScore: SettingsStore.shared.config.effortScore)

View File

@ -73,7 +73,11 @@ struct KegingLiveActivity: Widget {
} }
private func phaseColor(_ state: KegingActivityAttributes.ContentState) -> Color { private func phaseColor(_ state: KegingActivityAttributes.ContentState) -> Color {
state.phase == .contract ? Color(hex: "#7FBF9E") : Color(hex: "#E8C558") switch state.phase {
case .prepare: Color(hex: "#9AA8A0")
case .contract: Color(hex: "#7FBF9E")
case .relax: Color(hex: "#E8C558")
}
} }
private func phaseIcon(_ state: KegingActivityAttributes.ContentState) -> some View { private func phaseIcon(_ state: KegingActivityAttributes.ContentState) -> some View {

View File

@ -131,6 +131,22 @@
} }
} }
}, },
"준비": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Ready"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "準備"
}
}
}
},
"지잉 (길게)": { "지잉 (길게)": {
"localizations": { "localizations": {
"en": { "en": {

View File

@ -10,13 +10,22 @@ import Foundation
import Combine import Combine
nonisolated enum KegelPhase: String, Codable, Hashable { nonisolated enum KegelPhase: String, Codable, Hashable {
case contract, relax case prepare, contract, relax
var label: String { var label: String {
self == .contract ? String(localized: "수축") : String(localized: "이완") switch self {
case .prepare: String(localized: "준비")
case .contract: String(localized: "수축")
case .relax: String(localized: "이완")
} }
}
var symbolName: String { var symbolName: String {
self == .contract ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right" switch self {
case .prepare: "timer"
case .contract: "arrow.down.right.and.arrow.up.left"
case .relax: "arrow.up.left.and.arrow.down.right"
}
} }
} }
@ -48,8 +57,12 @@ final class KegelEngine: ObservableObject {
guard !isRunning else { return } guard !isRunning else { return }
self.config = config self.config = config
startedAt = Date() startedAt = Date()
if config.prepSeconds > 0 {
enterPhase(.prepare, rep: 1)
} else {
enterPhase(.contract, rep: 1) enterPhase(.contract, rep: 1)
} }
}
/// ( ) /// ( )
func stop() { func stop() {
@ -66,7 +79,13 @@ final class KegelEngine: ObservableObject {
} }
private func enterPhase(_ phase: KegelPhase, rep: Int) { private func enterPhase(_ phase: KegelPhase, rep: Int) {
let seconds = phase == .contract ? config.contractSeconds : config.relaxSeconds let seconds = switch phase {
case .prepare: config.prepSeconds
case .contract: config.contractSeconds
case .relax: config.relaxSeconds
}
// ·
if phase == .contract && rep == 1 { startedAt = Date() }
let now = Date() let now = Date()
let end = now.addingTimeInterval(Double(max(1, seconds))) let end = now.addingTimeInterval(Double(max(1, seconds)))
state = .running(phase: phase, rep: rep, phaseStart: now, phaseEnd: end) state = .running(phase: phase, rep: rep, phaseStart: now, phaseEnd: end)
@ -80,14 +99,19 @@ final class KegelEngine: ObservableObject {
private func advance(from phase: KegelPhase, rep: Int) { private func advance(from phase: KegelPhase, rep: Int) {
guard case .running = state else { return } guard case .running = state else { return }
if phase == .contract { switch phase {
case .prepare:
enterPhase(.contract, rep: 1)
case .contract:
enterPhase(.relax, rep: rep) enterPhase(.relax, rep: rep)
} else if rep < config.reps { case .relax:
if rep < config.reps {
enterPhase(.contract, rep: rep + 1) enterPhase(.contract, rep: rep + 1)
} else { } else {
complete() complete()
} }
} }
}
private func complete() { private func complete() {
timer?.invalidate() timer?.invalidate()

View File

@ -67,6 +67,8 @@ nonisolated enum HealthWorkoutType: String, CaseIterable, Identifiable, Codable
/// = ( ) × /// = ( ) ×
nonisolated struct WorkoutConfig: Codable, Equatable { nonisolated struct WorkoutConfig: Codable, Equatable {
/// (010)
var prepSeconds: Int = 3
var contractSeconds: Int = 10 var contractSeconds: Int = 10
var relaxSeconds: Int = 5 var relaxSeconds: Int = 5
var reps: Int = 20 var reps: Int = 20
@ -79,6 +81,7 @@ nonisolated struct WorkoutConfig: Codable, Equatable {
var healthWorkoutType: HealthWorkoutType = .other var healthWorkoutType: HealthWorkoutType = .other
init( init(
prepSeconds: Int = 3,
contractSeconds: Int = 10, contractSeconds: Int = 10,
relaxSeconds: Int = 5, relaxSeconds: Int = 5,
reps: Int = 20, reps: Int = 20,
@ -87,6 +90,7 @@ nonisolated struct WorkoutConfig: Codable, Equatable {
effortScore: Int = 2, effortScore: Int = 2,
healthWorkoutType: HealthWorkoutType = .other healthWorkoutType: HealthWorkoutType = .other
) { ) {
self.prepSeconds = prepSeconds
self.contractSeconds = contractSeconds self.contractSeconds = contractSeconds
self.relaxSeconds = relaxSeconds self.relaxSeconds = relaxSeconds
self.reps = reps self.reps = reps
@ -99,6 +103,7 @@ nonisolated struct WorkoutConfig: Codable, Equatable {
// JSON decodeIfPresent // JSON decodeIfPresent
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
prepSeconds = try container.decodeIfPresent(Int.self, forKey: .prepSeconds) ?? 3
contractSeconds = try container.decodeIfPresent(Int.self, forKey: .contractSeconds) ?? 10 contractSeconds = try container.decodeIfPresent(Int.self, forKey: .contractSeconds) ?? 10
relaxSeconds = try container.decodeIfPresent(Int.self, forKey: .relaxSeconds) ?? 5 relaxSeconds = try container.decodeIfPresent(Int.self, forKey: .relaxSeconds) ?? 5
reps = try container.decodeIfPresent(Int.self, forKey: .reps) ?? 20 reps = try container.decodeIfPresent(Int.self, forKey: .reps) ?? 20