polish(keging): 메인·운동 화면 그래픽 강화 + 도움말 취소선(마크다운 ~) 수정
- 메인: 텍스트 한 줄 요약 → 수축·이완·횟수 카드 3장(아이콘+큰 숫자, 탭=세팅) - 운동 화면: 국면 색 전환(수축=그린·이완=앰버, 카운터 색 연동) + 세트 진행 링 + 큰 현재 횟수 카운터(numericText 전환) - 도움말 취소선 원인 = SwiftUI Text 마크다운의 ~쌍 해석 — 사용자 문구의 ASCII ~를 전각 ~로 전면 교체(4곳), CLAUDE.md에 금지 규칙 기록 - 신규 QA 인자 -noHealth(권한 시트가 캡처 가리는 것 방지) - 검증: Debug·Release 빌드 그린, 화면 QA(메인 카드·수축/이완 국면·도움말), 카탈로그 0/0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
This commit is contained in:
parent
0d058c7734
commit
9c9df03571
@ -104,6 +104,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
|
||||
- ⚠️ 카탈로그는 타깃별 — Shared 문자열도 워치·위젯 타깃에서 쓰이면 그 타깃 카탈로그에 각각 추출된다. 세 카탈로그 모두 확인할 것
|
||||
- 문구 수정 시 옛 키가 stale로 남음 — sync 후 삭제. 복수형은 en에 plural variations("%lld회" 참고)
|
||||
- 용어 통일: 수축=Squeeze/締める, 이완=Relax/ゆるめる, 세트=Set/セット, 횟수=Reps/回数. 말투는 ko ~해요체 / ja です·ます체
|
||||
- ⚠️ **사용자 문구에 ASCII 물결표(~) 금지** — SwiftUI Text가 마크다운으로 해석해 `~쌍~`이 취소선이 된다(도움말 "1~60초" 실사고, 2026-08-29). 범위 표기는 전각 `~` 사용. 그 외 마크다운 특수문자(`*`, `_`, `` ` ``)도 주의
|
||||
|
||||
## 3. 도메인·핵심 규칙
|
||||
|
||||
@ -148,8 +149,8 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
|
||||
|
||||
## 4. 화면 구성 (탭 없음 — 애플 기본 앱 느낌)
|
||||
|
||||
- **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + 상단 중앙 통계 그랩 힌트 + 버튼 아래 현재 세팅 요약(탭=세팅)
|
||||
- **세팅/설정/도움말**: 시트(Form/List). 설정에 테마·**언어**(시스템/한국어/English/日本語 — 재실행 시 적용)·**애플 건강**(운동 강도 자동 기록) 섹션. 도움말은 5그룹 14주제(기본 사용법·통계·앱 밖에서·애플워치·설정 기타)로 전 기능 사용법 서술. 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션
|
||||
- **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + 상단 중앙 통계 그랩 힌트 + 버튼 아래 **세팅 요약 카드 3장**(수축·이완·횟수 — 아이콘+큰 숫자, 탭=세팅. 텍스트 한 줄이 성의 없다는 사용자 피드백으로 교체)
|
||||
- **세팅/설정/도움말**: 시트(Form/List). 설정에 테마·**언어**(시스템/한국어/English/日本語 — 재실행 시 적용)·**애플 건강**(운동 강도 자동 기록) 섹션. 도움말은 5그룹 14주제(기본 사용법·통계·앱 밖에서·애플워치·설정 기타)로 전 기능 사용법 서술. 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션, **국면 색 전환(수축=그린·이완=앰버, 카운터 색 연동)** + 원 둘레 **세트 진행 링**(rep 기준, 국면 색) + 큰 현재 횟수 카운터(numericText 전환)
|
||||
- 아이콘 전용 버튼에는 `accessibilityLabel` 필수 (하루 다님 컨벤션 계승)
|
||||
|
||||
## 5. 테마·컬러 — 하루 다님 팔레트 계승 (`Shared/Theme.swift`)
|
||||
@ -183,6 +184,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
|
||||
| `-autoStart` | 시작하자마자 운동 시작 (아이폰·워치 공통) |
|
||||
| `-quickConfig` | 세팅을 수축1초·이완1초·2회로 (완주 경로 검증) |
|
||||
| `-resetConfig` | 세팅을 기본값(10초·5초·20회)으로 복원 |
|
||||
| `-noHealth` | 건강 권한 요청 생략 — 화면 QA에서 권한 시트가 캡처를 가리는 것 방지 |
|
||||
| `-AppleLanguages "(en)"` | (시스템 표준 인자) 그 실행만 언어 강제 — en/ja 렌더 QA용, 워치도 동일 |
|
||||
|
||||
## 8. 남은 작업·보류
|
||||
|
||||
@ -49,7 +49,7 @@ struct AppSettingsView: View {
|
||||
} header: {
|
||||
Text("애플 건강")
|
||||
} footer: {
|
||||
Text("운동을 끝까지 마치면 그 시간이 위에서 고른 유형으로 애플 건강에 자동 기록되고, 운동 강도(1~10)도 함께 저장돼요. 기록을 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
|
||||
Text("운동을 끝까지 마치면 그 시간이 위에서 고른 유형으로 애플 건강에 자동 기록되고, 운동 강도(1~10)도 함께 저장돼요. 기록을 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
|
||||
}
|
||||
Section {
|
||||
LabeledContent("버전", value: versionText)
|
||||
|
||||
@ -84,12 +84,30 @@ struct ContentView: View {
|
||||
Button {
|
||||
showTimerSettings = true
|
||||
} label: {
|
||||
Text(settings.config.summaryText)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
summaryChip(
|
||||
symbol: KegelPhase.contract.symbolName,
|
||||
value: String(localized: "\(settings.config.contractSeconds)초"),
|
||||
label: Text("수축"),
|
||||
color: AppTheme.green
|
||||
)
|
||||
summaryChip(
|
||||
symbol: KegelPhase.relax.symbolName,
|
||||
value: String(localized: "\(settings.config.relaxSeconds)초"),
|
||||
label: Text("이완"),
|
||||
color: AppTheme.yellow
|
||||
)
|
||||
summaryChip(
|
||||
symbol: "repeat",
|
||||
value: String(localized: "\(settings.config.reps)회"),
|
||||
label: Text("횟수"),
|
||||
color: AppTheme.green
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 30)
|
||||
.padding(.top, 34)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Text("운동 세팅 열기 — 현재 \(settings.config.summaryText)"))
|
||||
|
||||
Spacer()
|
||||
@ -146,6 +164,25 @@ struct ContentView: View {
|
||||
Color(light: .white, dark: Color(hex: "#0F2018"))
|
||||
}
|
||||
|
||||
private func summaryChip(symbol: String, value: String, label: Text, color: Color) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: symbol)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
Text(value)
|
||||
.font(.title3.bold())
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
label
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(width: 92)
|
||||
.padding(.vertical, 14)
|
||||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18))
|
||||
.shadow(color: .black.opacity(0.06), radius: 10, x: 0, y: 3)
|
||||
}
|
||||
|
||||
private func cornerButton(symbol: String, label: Text, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: symbol)
|
||||
|
||||
@ -24,6 +24,10 @@ final class HealthRecorder {
|
||||
|
||||
/// 운동 시작 시 호출 — 첫 실행에서만 권한 시트가 뜬다 (운동 진행에는 영향 없음)
|
||||
func requestAuthorizationIfNeeded() {
|
||||
#if DEBUG
|
||||
// 화면 QA에서 권한 시트가 캡처를 가리는 것 방지
|
||||
if CommandLine.arguments.contains("-noHealth") { return }
|
||||
#endif
|
||||
guard HKHealthStore.isHealthDataAvailable(), !authRequested else { return }
|
||||
authRequested = true
|
||||
store.requestAuthorization(toShare: Self.shareTypes, read: nil) { _, _ in }
|
||||
|
||||
@ -22,7 +22,7 @@ struct HelpView: View {
|
||||
helpRow(
|
||||
symbol: "slider.horizontal.3",
|
||||
title: Text("운동 세팅 바꾸기"),
|
||||
body: Text("오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 수축 시간(1~60초), 이완 시간(1~60초), 반복 횟수(1~100회)를 정할 수 있어요. 예를 들어 수축 10초·이완 5초·20회로 설정하면, 수축과 이완을 20번 반복하는 한 세트가 약 5분이 돼요. 시작 버튼 아래의 요약 문구를 눌러도 이 화면이 열려요.")
|
||||
body: Text("오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 수축 시간(1~60초), 이완 시간(1~60초), 반복 횟수(1~100회)를 정할 수 있어요. 예를 들어 수축 10초·이완 5초·20회로 설정하면, 수축과 이완을 20번 반복하는 한 세트가 약 5분이 돼요. 시작 버튼 아래의 카드를 눌러도 이 화면이 열려요.")
|
||||
)
|
||||
helpRow(
|
||||
symbol: "iphone.radiowaves.left.and.right",
|
||||
@ -52,7 +52,7 @@ struct HelpView: View {
|
||||
helpRow(
|
||||
symbol: "arrow.left.arrow.right",
|
||||
title: Text("이번 주 ↔ 지난 7일 전환"),
|
||||
body: Text("주간·월간 화면 위의 스위치로 두 가지 방식을 오갈 수 있어요. '이번 주 / 이번 달'은 달력 기준(월요일~일요일, 1일~말일)이고, '지난 7일 / 지난 30일'은 오늘을 기준으로 거슬러 세는 방식이에요.")
|
||||
body: Text("주간·월간 화면 위의 스위치로 두 가지 방식을 오갈 수 있어요. '이번 주 / 이번 달'은 달력 기준(월요일~일요일, 1일~말일)이고, '지난 7일 / 지난 30일'은 오늘을 기준으로 거슬러 세는 방식이에요.")
|
||||
)
|
||||
} header: {
|
||||
Text("통계")
|
||||
@ -92,7 +92,7 @@ struct HelpView: View {
|
||||
helpRow(
|
||||
symbol: "heart.circle",
|
||||
title: Text("애플 건강 연동"),
|
||||
body: Text("운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(1~10)는 설정에서 고를 수 있어요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 워치에서 하면 워치가 심박수와 함께 기록해요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
|
||||
body: Text("운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(1~10)는 설정에서 고를 수 있어요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 워치에서 하면 워치가 심박수와 함께 기록해요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.")
|
||||
)
|
||||
helpRow(
|
||||
symbol: "paintbrush",
|
||||
|
||||
@ -1,28 +1,6 @@
|
||||
{
|
||||
"sourceLanguage": "ko",
|
||||
"strings": {
|
||||
"%lld / %lld": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld / %lld"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld / %lld"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "%1$lld / %2$lld"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld · %@": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -875,18 +853,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 수축 시간(1~60초), 이완 시간(1~60초), 반복 횟수(1~100회)를 정할 수 있어요. 예를 들어 수축 10초·이완 5초·20회로 설정하면, 수축과 이완을 20번 반복하는 한 세트가 약 5분이 돼요. 시작 버튼 아래의 요약 문구를 눌러도 이 화면이 열려요.": {
|
||||
"오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 수축 시간(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 summary text under the Start button also opens this screen."
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "右上のスライダーボタンがワークアウト設定です。締める時間(1〜60秒)、ゆるめる時間(1〜60秒)、回数(1〜100回)を設定できます。例えば締める10秒・ゆるめる5秒・20回なら、1セットは約5分です。開始ボタンの下の概要文を押してもこの画面が開きます。"
|
||||
"value": "右上のスライダーボタンがワークアウト設定です。締める時間(1〜60秒)、ゆるめる時間(1〜60秒)、回数(1〜100回)を設定できます。例えば締める10秒・ゆるめる5秒・20回なら、1セットは約5分です。開始ボタンの下のカードを押してもこの画面が開きます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1067,7 +1045,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(1~10)는 설정에서 고를 수 있어요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 워치에서 하면 워치가 심박수와 함께 기록해요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.": {
|
||||
"운동을 끝까지 마치면 그 시간이 애플 건강에 운동으로 자동 기록돼요. 운동 유형(기타·코어 트레이닝)과 운동 강도(1~10)는 설정에서 고를 수 있어요. 처음 시작할 때 건강 권한을 물어보고, 허용하면 그다음부터는 알아서 저장돼요. 워치에서 하면 워치가 심박수와 함께 기록해요. 중단한 세트는 건강에도 기록되지 않고, 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
@ -1083,7 +1061,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"운동을 끝까지 마치면 그 시간이 위에서 고른 유형으로 애플 건강에 자동 기록되고, 운동 강도(1~10)도 함께 저장돼요. 기록을 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.": {
|
||||
"운동을 끝까지 마치면 그 시간이 위에서 고른 유형으로 애플 건강에 자동 기록되고, 운동 강도(1~10)도 함께 저장돼요. 기록을 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
@ -1323,7 +1301,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"주간·월간 화면 위의 스위치로 두 가지 방식을 오갈 수 있어요. '이번 주 / 이번 달'은 달력 기준(월요일~일요일, 1일~말일)이고, '지난 7일 / 지난 30일'은 오늘을 기준으로 거슬러 세는 방식이에요.": {
|
||||
"주간·월간 화면 위의 스위치로 두 가지 방식을 오갈 수 있어요. '이번 주 / 이번 달'은 달력 기준(월요일~일요일, 1일~말일)이고, '지난 7일 / 지난 30일'은 오늘을 기준으로 거슬러 세는 방식이에요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
|
||||
@ -26,17 +26,38 @@ struct WorkoutView: View {
|
||||
}
|
||||
|
||||
private func runningView(phase: KegelPhase, rep: Int, phaseStart: Date, phaseEnd: Date) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
Text("\(rep) / \(engine.config.reps)")
|
||||
.font(.title2.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 24)
|
||||
.accessibilityLabel(Text("현재 \(rep)번째, 전체 \(engine.config.reps)회"))
|
||||
let phaseColor = phase == .contract ? AppTheme.green : AppTheme.yellow
|
||||
let progress = (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)")
|
||||
.font(.system(size: 46, weight: .bold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(phaseColor)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.snappy, value: rep)
|
||||
Text(verbatim: "/ \(engine.config.reps)")
|
||||
.font(.title3.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Text("현재 \(rep)번째, 전체 \(engine.config.reps)회"))
|
||||
|
||||
Spacer()
|
||||
|
||||
ZStack {
|
||||
// 세트 진행 링 — 국면 색으로 채워진다
|
||||
Circle()
|
||||
.stroke(AppTheme.surface, lineWidth: 10)
|
||||
.frame(width: 344, height: 344)
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(phaseColor.opacity(0.85), style: StrokeStyle(lineWidth: 10, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 344, height: 344)
|
||||
.animation(.easeInOut(duration: 0.5), value: progress)
|
||||
PhaseCircle(phase: phase, phaseStart: phaseStart, phaseEnd: phaseEnd)
|
||||
VStack(spacing: 6) {
|
||||
Text(phase.label)
|
||||
@ -46,7 +67,9 @@ struct WorkoutView: View {
|
||||
.monospacedDigit()
|
||||
.opacity(0.85)
|
||||
}
|
||||
.foregroundStyle(Color(light: .white, dark: Color(hex: "#0F2018")))
|
||||
.foregroundStyle(phase == .contract
|
||||
? Color(light: .white, dark: Color(hex: "#0F2018"))
|
||||
: Color(hex: "#33260A"))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@ -93,7 +116,7 @@ struct WorkoutView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 수축이면 오므라들고 이완이면 부풀어 오르는 국면 원
|
||||
/// 수축이면 오므라들고 이완이면 부풀어 오르는 국면 원 — 색도 국면 따라(수축 그린·이완 앰버)
|
||||
private struct PhaseCircle: View {
|
||||
let phase: KegelPhase
|
||||
let phaseStart: Date
|
||||
@ -101,17 +124,20 @@ private struct PhaseCircle: View {
|
||||
|
||||
@State private var scale: CGFloat = 1
|
||||
|
||||
private var color: Color { phase == .contract ? AppTheme.green : AppTheme.yellow }
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [AppTheme.green, AppTheme.green.opacity(0.8)],
|
||||
colors: [color, color.opacity(0.8)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.frame(width: 300, height: 300)
|
||||
.scaleEffect(scale)
|
||||
.shadow(color: AppTheme.green.opacity(0.3), radius: 26, x: 0, y: 10)
|
||||
.shadow(color: color.opacity(0.3), radius: 26, x: 0, y: 10)
|
||||
.animation(.easeInOut(duration: 0.4), value: phase)
|
||||
.onAppear { animate() }
|
||||
.onChange(of: phase) { animate() }
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user