From 06d13b2f3d9d222d3f0d4650457d97593218121e Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sat, 29 Aug 2026 20:55:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(keging):=20=EC=A4=80=EB=B9=84=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84(0=EF=BD=9E10=EC=B4=88,=20=EA=B8=B0=EB=B3=B8=203?= =?UTF-8?q?=EC=B4=88)=20=E2=80=94=20=EC=8B=9C=EC=9E=91=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=9B=84=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EB=92=A4=20=EC=B2=AB=20=EC=88=98=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT --- myApp/Keging/CLAUDE.md | 6 +- myApp/Keging/IOS/DebugSeed.swift | 4 +- myApp/Keging/IOS/HelpView.swift | 4 +- myApp/Keging/IOS/LiveActivityManager.swift | 27 +++---- myApp/Keging/IOS/Localizable.xcstrings | 72 +++++++++++-------- myApp/Keging/IOS/TimerSettingsView.swift | 5 +- myApp/Keging/IOS/WorkoutCoordinator.swift | 10 +-- myApp/Keging/IOS/WorkoutView.swift | 43 +++++++---- .../KegingWatch Watch App/ContentView.swift | 10 ++- .../Localizable.xcstrings | 16 +++++ .../WatchCoordinator.swift | 6 +- .../KegingWidgetsLiveActivity.swift | 6 +- .../KegingWidgets/Localizable.xcstrings | 16 +++++ myApp/Keging/Shared/KegelEngine.swift | 44 +++++++++--- myApp/Keging/Shared/WorkoutSettings.swift | 5 ++ 15 files changed, 192 insertions(+), 82 deletions(-) diff --git a/myApp/Keging/CLAUDE.md b/myApp/Keging/CLAUDE.md index 9ac29d1..6ffcb07 100644 --- a/myApp/Keging/CLAUDE.md +++ b/myApp/Keging/CLAUDE.md @@ -109,8 +109,8 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] ## 3. 도메인·핵심 규칙 ### 3.1 세트와 엔진 (`Shared/KegelEngine.swift`) -- **세트 = (수축 N초 → 이완 M초) × 횟수**, 수축이 먼저. 기본 세팅: 수축 10초·이완 5초·20회 (범위 1~60초·1~100회) -- 상태 기계: `idle → running(phase, rep, phaseStart, phaseEnd) → finished → (확인) idle`. 국면 경계마다 1회성 Timer, 화면 카운트다운은 `Text(timerInterval:)`이라 틱 타이머 없음 +- **세트 = 준비 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은 준비를 뺀 첫 수축 시각** - **완주해야만 기록** — `complete()`에서 RecordStore에 저장. **중단(`stop()`)하면 이 세트는 아예 없던 것** (기록·통계 미포함). 아이폰은 중단 전 확인 다이얼로그, 워치는 즉시 중단 - 진행 중 재시작 불가(`isRunning` 가드). 완료 화면은 "확인"으로 닫는다 - 하루 목표 횟수 기능은 **사용자 결정(2026-08-29)으로 제외** — 추후 후보로만, 선제 구현 금지 @@ -150,7 +150,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] ## 4. 화면 구성 (탭 없음 — 애플 기본 앱 느낌) - **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(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` 필수 (하루 다님 컨벤션 계승) ## 5. 테마·컬러 — 하루 다님 팔레트 계승 (`Shared/Theme.swift`) diff --git a/myApp/Keging/IOS/DebugSeed.swift b/myApp/Keging/IOS/DebugSeed.swift index dfa1580..65d65ff 100644 --- a/myApp/Keging/IOS/DebugSeed.swift +++ b/myApp/Keging/IOS/DebugSeed.swift @@ -13,9 +13,9 @@ import Foundation enum DebugSeed { static func applyLaunchArguments() { if CommandLine.arguments.contains("-seedStats") { seedStats() } - // 완주 경로 검증용 초단축 세팅 (수축 1초·이완 1초·2회) + // 완주 경로 검증용 초단축 세팅 (준비 1초·수축 1초·이완 1초·2회) 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") { diff --git a/myApp/Keging/IOS/HelpView.swift b/myApp/Keging/IOS/HelpView.swift index 155dadf..7af4e52 100644 --- a/myApp/Keging/IOS/HelpView.swift +++ b/myApp/Keging/IOS/HelpView.swift @@ -17,12 +17,12 @@ struct HelpView: View { helpRow( symbol: "play.circle.fill", title: Text("바로 시작하기"), - body: Text("첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 바로 운동이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요.") + body: Text("첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요.") ) helpRow( symbol: "slider.horizontal.3", title: Text("운동 세팅 바꾸기"), - body: Text("오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 수축 시간(1~60초), 이완 시간(1~60초), 반복 횟수(1~100회)를 정할 수 있어요. 예를 들어 수축 10초·이완 5초·20회로 설정하면, 수축과 이완을 20번 반복하는 한 세트가 약 5분이 돼요. 시작 버튼 아래의 카드를 눌러도 이 화면이 열려요.") + body: Text("오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 준비 시간(0~10초), 수축 시간(1~60초), 이완 시간(1~60초), 반복 횟수(1~100회)를 정할 수 있어요. 예를 들어 수축 10초·이완 5초·20회로 설정하면, 수축과 이완을 20번 반복하는 한 세트가 약 5분이 돼요. 시작 버튼 아래의 카드를 눌러도 이 화면이 열려요.") ) helpRow( symbol: "iphone.radiowaves.left.and.right", diff --git a/myApp/Keging/IOS/LiveActivityManager.swift b/myApp/Keging/IOS/LiveActivityManager.swift index 3abc88a..df641fb 100644 --- a/myApp/Keging/IOS/LiveActivityManager.swift +++ b/myApp/Keging/IOS/LiveActivityManager.swift @@ -16,26 +16,21 @@ final class LiveActivityManager { private init() {} - func start(totalReps: Int, phase: KegelPhase, rep: Int, phaseEnd: Date) { - guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } - end() + /// 없으면 시작, 있으면 갱신 — 국면 전환마다 이 하나만 호출 + func sync(phase: KegelPhase, rep: Int, totalReps: Int, phaseEnd: Date) { let state = KegingActivityAttributes.ContentState( phase: phase, rep: rep, totalReps: totalReps, phaseStart: Date(), phaseEnd: phaseEnd ) - activity = try? Activity.request( - attributes: KegingActivityAttributes(startedAt: Date()), - 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)) } + if let activity { + Task { await activity.update(ActivityContent(state: state, staleDate: nil)) } + } else { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + activity = try? Activity.request( + attributes: KegingActivityAttributes(startedAt: Date()), + content: ActivityContent(state: state, staleDate: nil) + ) + } } func end() { diff --git a/myApp/Keging/IOS/Localizable.xcstrings b/myApp/Keging/IOS/Localizable.xcstrings index 02d46b0..9ad5d81 100644 --- a/myApp/Keging/IOS/Localizable.xcstrings +++ b/myApp/Keging/IOS/Localizable.xcstrings @@ -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": { "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": { "en": { @@ -853,18 +853,18 @@ } } }, - "오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 수축 시간(1~60초), 이완 시간(1~60초), 반복 횟수(1~100회)를 정할 수 있어요. 예를 들어 수축 10초·이완 5초·20회로 설정하면, 수축과 이완을 20번 반복하는 한 세트가 약 5분이 돼요. 시작 버튼 아래의 카드를 눌러도 이 화면이 열려요.": { + "오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 준비 시간(0~10초), 수축 시간(1~60초), 이완 시간(1~60초), 반복 횟수(1~100회)를 정할 수 있어요. 예를 들어 수축 10초·이완 5초·20회로 설정하면, 수축과 이완을 20번 반복하는 한 세트가 약 5분이 돼요. 시작 버튼 아래의 카드를 눌러도 이 화면이 열려요.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The slider button at the top right opens the workout setup. You can set the squeeze time (1–60 s), relax time (1–60 s), and the number of reps (1–100). 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 (0–10 s), squeeze time (1–60 s), relax time (1–60 s), and the number of reps (1–100). 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": { "stringUnit": { "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": { "en": { @@ -1477,18 +1493,18 @@ } } }, - "첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 바로 운동이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요.": { + "첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요.": { "localizations": { "en": { "stringUnit": { "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": { "stringUnit": { "state": "translated", - "value": "最初の画面の大きな開始ボタンを押すと、保存した設定ですぐにワークアウトが始まります。画面の円が縮むときに締めて、大きくなるときに力を抜きます。設定した回数をすべて終えると完了画面が表示され、記録が保存されます。" + "value": "最初の画面の大きな開始ボタンを押すと、保存した設定でワークアウトが始まります。準備時間を設定していれば、そのカウントダウンのあとに最初の締めが始まります。画面の円が縮むときに締めて、大きくなるときに力を抜きます。設定した回数をすべて終えると完了画面が表示され、記録が保存されます。" } } } diff --git a/myApp/Keging/IOS/TimerSettingsView.swift b/myApp/Keging/IOS/TimerSettingsView.swift index 58ce47c..137bb1f 100644 --- a/myApp/Keging/IOS/TimerSettingsView.swift +++ b/myApp/Keging/IOS/TimerSettingsView.swift @@ -15,6 +15,9 @@ struct TimerSettingsView: View { NavigationStack { Form { Section { + Stepper(value: $settings.config.prepSeconds, in: 0...10) { + LabeledContent("준비", value: String(localized: "\(settings.config.prepSeconds)초")) + } Stepper(value: $settings.config.contractSeconds, in: 1...60) { LabeledContent("수축", value: String(localized: "\(settings.config.contractSeconds)초")) } @@ -27,7 +30,7 @@ struct TimerSettingsView: View { } header: { Text("사이클") } footer: { - Text("수축 → 이완을 \(settings.config.reps)회 반복해요. 한 세트 약 \(durationText).") + Text("시작 버튼을 누르면 준비 시간 뒤에 수축 → 이완을 \(settings.config.reps)회 반복해요. 한 세트 약 \(durationText).") } Section { diff --git a/myApp/Keging/IOS/WorkoutCoordinator.swift b/myApp/Keging/IOS/WorkoutCoordinator.swift index bd391b6..01972e5 100644 --- a/myApp/Keging/IOS/WorkoutCoordinator.swift +++ b/myApp/Keging/IOS/WorkoutCoordinator.swift @@ -23,12 +23,12 @@ final class WorkoutCoordinator { engine.onPhaseChange = { [weak engine] phase, rep, phaseEnd in guard let engine else { return } let config = engine.config - Haptics.play(phase == .contract ? config.contractPattern : config.relaxPattern) - if phase == .contract && rep == 1 { - LiveActivityManager.shared.start(totalReps: config.reps, phase: phase, rep: rep, phaseEnd: phaseEnd) - } else { - LiveActivityManager.shared.update(phase: phase, rep: rep, totalReps: config.reps, phaseEnd: phaseEnd) + switch phase { + case .prepare: break // 준비는 무진동 — 첫 수축 진동이 시작 신호 + case .contract: Haptics.play(config.contractPattern) + case .relax: Haptics.play(config.relaxPattern) } + LiveActivityManager.shared.sync(phase: phase, rep: rep, totalReps: config.reps, phaseEnd: phaseEnd) } engine.onSessionEnd = { completed, record in LiveActivityManager.shared.end() diff --git a/myApp/Keging/IOS/WorkoutView.swift b/myApp/Keging/IOS/WorkoutView.swift index 427f36e..b17a980 100644 --- a/myApp/Keging/IOS/WorkoutView.swift +++ b/myApp/Keging/IOS/WorkoutView.swift @@ -26,8 +26,10 @@ struct WorkoutView: View { } private func runningView(phase: KegelPhase, rep: Int, phaseStart: Date, phaseEnd: Date) -> some View { - let phaseColor = phase == .contract ? AppTheme.green : AppTheme.yellow - let progress = (Double(rep - 1) + (phase == .relax ? 0.5 : 0)) / Double(max(1, engine.config.reps)) + let phaseColor = Self.color(for: phase) + let progress = phase == .prepare + ? 0 + : (Double(rep - 1) + (phase == .relax ? 0.5 : 0)) / Double(max(1, engine.config.reps)) return VStack(spacing: 0) { HStack(alignment: .firstTextBaseline, spacing: 5) { Text(verbatim: "\(rep)") @@ -67,9 +69,7 @@ struct WorkoutView: View { .monospacedDigit() .opacity(0.85) } - .foregroundStyle(phase == .contract - ? Color(light: .white, dark: Color(hex: "#0F2018")) - : Color(hex: "#33260A")) + .foregroundStyle(Self.textColor(for: phase)) } 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 { VStack(spacing: 18) { Image(systemName: "checkmark.circle.fill") @@ -124,7 +141,7 @@ private struct PhaseCircle: View { @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 { Circle() @@ -144,13 +161,15 @@ private struct PhaseCircle: View { private func animate() { 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() transaction.disablesAnimations = true - withTransaction(transaction) { - scale = phase == .contract ? 1.0 : 0.62 - } - withAnimation(.easeInOut(duration: duration)) { - scale = phase == .contract ? 0.62 : 1.0 - } + withTransaction(transaction) { scale = from } + withAnimation(.easeInOut(duration: duration)) { scale = to } } } diff --git a/myApp/Keging/KegingWatch Watch App/ContentView.swift b/myApp/Keging/KegingWatch Watch App/ContentView.swift index 151ff9d..0848e07 100644 --- a/myApp/Keging/KegingWatch Watch App/ContentView.swift +++ b/myApp/Keging/KegingWatch Watch App/ContentView.swift @@ -66,7 +66,7 @@ struct WatchContentView: View { VStack(spacing: 3) { Text(phase.label) .font(.title3.bold()) - .foregroundStyle(phase == .contract ? AppTheme.green : AppTheme.yellow) + .foregroundStyle(phaseColor(phase)) Text(timerInterval: phaseStart...phaseEnd, countsDown: true) .font(.system(size: 30, weight: .semibold)) .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 { VStack(spacing: 6) { Image(systemName: "checkmark.circle.fill") diff --git a/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings b/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings index 3e60be7..e62e68f 100644 --- a/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings +++ b/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings @@ -169,6 +169,22 @@ } } }, + "준비": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ready" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "準備" + } + } + } + }, "중단": { "localizations": { "en": { diff --git a/myApp/Keging/KegingWatch Watch App/WatchCoordinator.swift b/myApp/Keging/KegingWatch Watch App/WatchCoordinator.swift index 4bc5625..18cf9a2 100644 --- a/myApp/Keging/KegingWatch Watch App/WatchCoordinator.swift +++ b/myApp/Keging/KegingWatch Watch App/WatchCoordinator.swift @@ -23,7 +23,11 @@ final class WatchCoordinator { engine.onPhaseChange = { [weak engine] phase, _, _ in guard let engine else { return } 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 WatchWorkoutSession.shared.end(completed: completed, effortScore: SettingsStore.shared.config.effortScore) diff --git a/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift b/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift index 2b434c7..318676b 100644 --- a/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift +++ b/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift @@ -73,7 +73,11 @@ struct KegingLiveActivity: Widget { } 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 { diff --git a/myApp/Keging/KegingWidgets/Localizable.xcstrings b/myApp/Keging/KegingWidgets/Localizable.xcstrings index 0f2c24a..18d9077 100644 --- a/myApp/Keging/KegingWidgets/Localizable.xcstrings +++ b/myApp/Keging/KegingWidgets/Localizable.xcstrings @@ -131,6 +131,22 @@ } } }, + "준비": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ready" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "準備" + } + } + } + }, "지잉 (길게)": { "localizations": { "en": { diff --git a/myApp/Keging/Shared/KegelEngine.swift b/myApp/Keging/Shared/KegelEngine.swift index d7fac22..28191ba 100644 --- a/myApp/Keging/Shared/KegelEngine.swift +++ b/myApp/Keging/Shared/KegelEngine.swift @@ -10,13 +10,22 @@ import Foundation import Combine nonisolated enum KegelPhase: String, Codable, Hashable { - case contract, relax + case prepare, contract, relax 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 { - 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,7 +57,11 @@ final class KegelEngine: ObservableObject { guard !isRunning else { return } self.config = config startedAt = Date() - enterPhase(.contract, rep: 1) + if config.prepSeconds > 0 { + enterPhase(.prepare, rep: 1) + } else { + enterPhase(.contract, rep: 1) + } } /// 중단 — 이 세트는 아예 안 한 것으로 (기록 없음) @@ -66,7 +79,13 @@ final class KegelEngine: ObservableObject { } 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 end = now.addingTimeInterval(Double(max(1, seconds))) state = .running(phase: phase, rep: rep, phaseStart: now, phaseEnd: end) @@ -80,12 +99,17 @@ final class KegelEngine: ObservableObject { private func advance(from phase: KegelPhase, rep: Int) { guard case .running = state else { return } - if phase == .contract { + switch phase { + case .prepare: + enterPhase(.contract, rep: 1) + case .contract: enterPhase(.relax, rep: rep) - } else if rep < config.reps { - enterPhase(.contract, rep: rep + 1) - } else { - complete() + case .relax: + if rep < config.reps { + enterPhase(.contract, rep: rep + 1) + } else { + complete() + } } } diff --git a/myApp/Keging/Shared/WorkoutSettings.swift b/myApp/Keging/Shared/WorkoutSettings.swift index d9ee003..aeeaa11 100644 --- a/myApp/Keging/Shared/WorkoutSettings.swift +++ b/myApp/Keging/Shared/WorkoutSettings.swift @@ -67,6 +67,8 @@ nonisolated enum HealthWorkoutType: String, CaseIterable, Identifiable, Codable /// 세트 구성 — 세트 = (수축 → 이완) × 횟수 nonisolated struct WorkoutConfig: Codable, Equatable { + /// 시작 버튼 → 첫 수축 사이의 준비 카운트다운 (0~10초) + var prepSeconds: Int = 3 var contractSeconds: Int = 10 var relaxSeconds: Int = 5 var reps: Int = 20 @@ -79,6 +81,7 @@ nonisolated struct WorkoutConfig: Codable, Equatable { var healthWorkoutType: HealthWorkoutType = .other init( + prepSeconds: Int = 3, contractSeconds: Int = 10, relaxSeconds: Int = 5, reps: Int = 20, @@ -87,6 +90,7 @@ nonisolated struct WorkoutConfig: Codable, Equatable { effortScore: Int = 2, healthWorkoutType: HealthWorkoutType = .other ) { + self.prepSeconds = prepSeconds self.contractSeconds = contractSeconds self.relaxSeconds = relaxSeconds self.reps = reps @@ -99,6 +103,7 @@ nonisolated struct WorkoutConfig: Codable, Equatable { // 필드 추가에도 기존 저장 JSON이 깨지지 않게 전 필드 decodeIfPresent init(from decoder: Decoder) throws { 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 relaxSeconds = try container.decodeIfPresent(Int.self, forKey: .relaxSeconds) ?? 5 reps = try container.decodeIfPresent(Int.self, forKey: .reps) ?? 20