feat(keging): 도움말 화면 + 영어·일본어 현지화 + 설정 언어 전환

- 첫 화면 우하단 ? 버튼 → 도움말(5그룹 13주제 — 기본 사용법·통계·앱 밖에서·애플워치·설정 기타)
- String Catalog ko(원문)/en/ja 3타깃(iOS 98·워치 15·위젯 9키) 전수 번역, InfoPlist 표시 이름 포함
- 설정 → 언어(시스템/한국어/English/日本語) — AppleLanguages 오버라이드, 재실행 시 적용(하루 다님 방식)
- 검증: Debug·Release·워치 빌드 그린, missing·stale 0, en/ja/ko 화면 QA 7장(도움말·설정·세팅·통계·워치)
- DEBUG 인자 추가: -openHelp/-openSettings/-openTimerSettings/-resetConfig

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 10:33:55 +09:00
parent 43448b35f3
commit 3f91e8889c
17 changed files with 2359 additions and 34 deletions

View File

@ -19,8 +19,8 @@
| 번들 ID | `com.yechan.Keging` / 워치 `.watchkitapp` / 위젯 `.KegingWidgets` / App Group `group.com.yechan.Keging` |
| 최소 OS | iOS 18.0 / watchOS 10.0 |
| 수익 모델 | **완전 무료** — 광고·결제 일절 없음. CloudKit·HealthKit 등 외부 서비스 없음 |
| 언어 | 한국어 하드코딩 (String Catalog 미사용 — 다국어는 필요해지면 도입) |
| 현재 상태 (2026-08-29) | **1.0 전 기능 구현 완료, 시뮬레이터 검증 통과** — Debug·Release·워치 빌드 그린, 화면 QA 10장(라이트/다크/iOS 18.5/워치 46·40mm), 완주·기록 저장 경로 확인. **실기기 테스트 대기(사용자)**: 진동 패턴(포그라운드·백그라운드), 다이나믹 아일랜드, 워치 페어링 동기화(세팅 폰→워치, 기록 워치→폰), 단축어, 워치 손목 내림(확장 런타임)은 시뮬레이터로 검증 불가 |
| 언어 | **한국어(원문)/영어/일본어** — 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 5화면), 전 카탈로그 missing·stale 0, 완주·기록 저장 경로 확인. **실기기 테스트 대기(사용자)**: 진동 패턴(포그라운드·백그라운드), 다이나믹 아일랜드, 워치 페어링 동기화(세팅 폰→워치, 기록 워치→폰), 단축어, 워치 손목 내림(확장 런타임)은 시뮬레이터로 검증 불가 |
## 2. 빌드·검증 환경
@ -35,6 +35,7 @@ IOS/ # iOS 앱 타깃 (Keging)
TimerSettingsView.swift # 세팅 (수축/이완/횟수 Stepper + 진동 패턴 Picker + 미리 느끼기)
AppSettingsView.swift # 설정 (테마 시스템/라이트/다크 — app.theme, 버전)
StatsView.swift # 통계 (하루/주간/월간, 달력↔롤링, Swift Charts 꺾은선)
HelpView.swift # 도움말 (첫 화면 ? 버튼 — 5그룹 13주제 전 기능 사용법)
WorkoutCoordinator.swift # 엔진↔진동·Live Activity·백그라운드 오디오·화면 꺼짐 방지 배선
Haptics.swift # 진동 재생 (AudioServices 바이브 연타 — 백그라운드에서도 동작)
BackgroundAudioKeeper.swift # 운동 중 무음 오디오 루프 (백그라운드 타이머 유지, mixWithOthers)
@ -43,6 +44,8 @@ IOS/ # iOS 앱 타깃 (Keging)
KegingIntents.swift # 단축어 "케겔 운동 시작" (openAppWhenRun + 알림/플래그)
DebugSeed.swift # DEBUG 런치 인자 (§7)
Info.plist # UIBackgroundModes=audio (GENERATE_INFOPLIST_FILE와 병합)
Localizable.xcstrings # ko(원문)/en/ja 문자열 카탈로그 (워치·위젯 폴더에도 각자 있음)
InfoPlist.xcstrings # CFBundleDisplayName 케깅/Keging (워치 폴더에도 있음)
Shared/ # 세 타깃 공용 (pbxproj fileSystemSynchronizedGroups에 3타깃 모두 등록)
Theme.swift # 하루 다님 팔레트 (§5), Color(hex:)·Color(light:dark:)
AppGroup.swift # App Group defaults 단일 인스턴스 + 컨테이너 URL
@ -81,6 +84,23 @@ xcodebuild -project Keging.xcodeproj -scheme "KegingWatch Watch App" \
- 각 타깃은 `GENERATE_INFOPLIST_FILE=YES` + `INFOPLIST_FILE`(추가 키 병합) 조합. 폴더 안 Info.plist는 exception set(`membershipExceptions`)으로 리소스 복사에서 제외돼 있다 — **Info.plist를 옮기거나 새 타깃 폴더에 추가하면 이 제외도 같이 챙길 것**
- 위젯 타깃만 `SWIFT_DEFAULT_ACTOR_ISOLATION` 미설정(=nonisolated 기본) — Shared의 데이터 타입(WorkoutConfig·SessionRecord·KegelPhase·HapticPattern·ActivityAttributes)은 **`nonisolated`로 명시**해 타깃 간 격리 차이·Swift 6 경고를 차단해 뒀다. 새 공용 데이터 타입도 동일하게
### 2.4 다국어(l10n) 루틴 — 하루 다님 방식 계승
새 사용자 문구는 SwiftUI 리터럴(`Text("…")` 등) 또는 `String(localized:)`로만 쓴다(변수 String은 추출 안 됨 — a11y 라벨은 `Text(...)` 오버로드, 커스텀 뷰 파라미터는 `Text`/`LocalizedStringKey` 타입으로). 추가 후:
```zsh
# 1) iOS 빌드 + 워치 스킴 빌드 → 2) 타깃별 stringsdata만 카탈로그에 sync (zsh 배열 ${(f)...} 필수)
DD=~/Library/Developer/Xcode/DerivedData/Keging-<해시> # ⚠️ DD가 2개면 최신(빌드 산출물 있는 쪽) 확인
app_files=(${(f)"$(find $DD -name '*.stringsdata' -path '*Debug-iphonesimulator/Keging.build/Objects-normal/arm64/*')"})
xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]}"
# 위젯: KegingWidgetsExtension.build, 워치: 'Debug-watchsimulator/KegingWatch Watch App.build' 경로로 동일하게
# 3) python으로 en/ja 채움 → 4) sync 재실행(정규화) → 5) 전 카탈로그 missing·stale 0 확인
```
- ⚠️ 카탈로그는 타깃별 — Shared 문자열도 워치·위젯 타깃에서 쓰이면 그 타깃 카탈로그에 각각 추출된다. 세 카탈로그 모두 확인할 것
- 문구 수정 시 옛 키가 stale로 남음 — sync 후 삭제. 복수형은 en에 plural variations("%lld회" 참고)
- 용어 통일: 수축=Squeeze/締める, 이완=Relax/ゆるめる, 세트=Set/セット, 횟수=Reps/回数. 말투는 ko ~해요체 / ja です·ます체
## 3. 도메인·핵심 규칙
### 3.1 세트와 엔진 (`Shared/KegelEngine.swift`)
@ -115,8 +135,8 @@ xcodebuild -project Keging.xcodeproj -scheme "KegingWatch Watch App" \
## 4. 화면 구성 (탭 없음 — 애플 기본 앱 느낌)
- **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(gear)·우상단 세팅(slider) + 상단 중앙 통계 그랩 힌트 + 버튼 아래 현재 세팅 요약(탭=세팅). 버튼은 이 3개가 전부
- **세팅/설정**: 시트(Form). 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션
- **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + 상단 중앙 통계 그랩 힌트 + 버튼 아래 현재 세팅 요약(탭=세팅)
- **세팅/설정/도움말**: 시트(Form/List). 설정에 테마·**언어**(시스템/한국어/English/日本語 — 재실행 시 적용) 섹션. 도움말은 5그룹 13주제(기본 사용법·통계·앱 밖에서·애플워치·설정 기타)로 전 기능 사용법 서술. 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션
- 아이콘 전용 버튼에는 `accessibilityLabel` 필수 (하루 다님 컨벤션 계승)
## 5. 테마·컬러 — 하루 다님 팔레트 계승 (`Shared/Theme.swift`)
@ -146,8 +166,11 @@ xcodebuild -project Keging.xcodeproj -scheme "KegingWatch Watch App" \
| `-openStats` | 시작하자마자 통계 화면 |
| `-statsScope day\|week\|month` | 통계 초기 탭 지정 |
| `-statsRolling` | 주간·월간을 롤링 모드로 |
| `-openHelp` / `-openSettings` / `-openTimerSettings` | 해당 시트를 열고 시작 (화면 QA용) |
| `-autoStart` | 시작하자마자 운동 시작 (아이폰·워치 공통) |
| `-quickConfig` | 세팅을 수축1초·이완1초·2회로 (완주 경로 검증) |
| `-resetConfig` | 세팅을 기본값(10초·5초·20회)으로 복원 |
| `-AppleLanguages "(en)"` | (시스템 표준 인자) 그 실행만 언어 강제 — en/ja 렌더 QA용, 워치도 동일 |
## 8. 남은 작업·보류

View File

@ -9,6 +9,7 @@ import SwiftUI
struct AppSettingsView: View {
@AppStorage("app.theme", store: AppGroup.defaults) private var themeMode = "system"
@AppStorage("app.language", store: AppGroup.defaults) private var appLanguage = "system"
@Environment(\.dismiss) private var dismiss
var body: some View {
@ -22,6 +23,18 @@ struct AppSettingsView: View {
}
.pickerStyle(.segmented)
}
Section {
Picker("언어", selection: $appLanguage) {
Text("시스템").tag("system")
Text(verbatim: "한국어").tag("ko")
Text(verbatim: "English").tag("en")
Text(verbatim: "日本語").tag("ja")
}
} header: {
Text("언어")
} footer: {
Text("언어를 바꾸면 앱을 완전히 종료했다가 다시 실행했을 때 적용돼요.")
}
Section {
LabeledContent("버전", value: versionText)
} footer: {
@ -35,6 +48,14 @@ struct AppSettingsView: View {
Button("완료") { dismiss() }
}
}
.onChange(of: appLanguage) {
// ( )
if appLanguage == "system" {
UserDefaults.standard.removeObject(forKey: "AppleLanguages")
} else {
UserDefaults.standard.set([appLanguage], forKey: "AppleLanguages")
}
}
}
}

View File

@ -13,6 +13,7 @@ struct ContentView: View {
@State private var showTimerSettings = false
@State private var showAppSettings = false
@State private var showHelp = false
@State private var showStats = false
var body: some View {
@ -27,6 +28,7 @@ struct ContentView: View {
}
.sheet(isPresented: $showTimerSettings) { TimerSettingsView() }
.sheet(isPresented: $showAppSettings) { AppSettingsView() }
.sheet(isPresented: $showHelp) { HelpView() }
.fullScreenCover(isPresented: workoutPresented) { WorkoutView() }
.onReceive(NotificationCenter.default.publisher(for: .startWorkoutRequested)) { _ in
startFromShortcut()
@ -35,6 +37,9 @@ struct ContentView: View {
if AppLaunchState.shared.startRequested { startFromShortcut() }
#if DEBUG
if CommandLine.arguments.contains("-openStats") { showStats = true }
if CommandLine.arguments.contains("-openHelp") { showHelp = true }
if CommandLine.arguments.contains("-openSettings") { showAppSettings = true }
if CommandLine.arguments.contains("-openTimerSettings") { showTimerSettings = true }
if CommandLine.arguments.contains("-autoStart") { WorkoutCoordinator.shared.startWorkout() }
#endif
}
@ -45,7 +50,7 @@ struct ContentView: View {
private var mainScreen: some View {
VStack(spacing: 0) {
HStack(alignment: .top) {
cornerButton(symbol: "gearshape.fill", label: "설정") { showAppSettings = true }
cornerButton(symbol: "gearshape.fill", label: Text("설정")) { showAppSettings = true }
Spacer()
Button {
openStats()
@ -59,9 +64,9 @@ struct ContentView: View {
.padding(.top, 10)
}
.buttonStyle(.plain)
.accessibilityLabel("통계 보기")
.accessibilityLabel(Text("통계 보기"))
Spacer()
cornerButton(symbol: "slider.horizontal.3", label: "운동 세팅") { showTimerSettings = true }
cornerButton(symbol: "slider.horizontal.3", label: Text("운동 세팅")) { showTimerSettings = true }
}
.padding(.horizontal, 20)
.padding(.top, 8)
@ -74,7 +79,7 @@ struct ContentView: View {
startButtonLabel
}
.buttonStyle(.plain)
.accessibilityLabel("케겔 운동 시작")
.accessibilityLabel(Text("케겔 운동 시작"))
Button {
showTimerSettings = true
@ -85,10 +90,17 @@ struct ContentView: View {
}
.buttonStyle(.plain)
.padding(.top, 30)
.accessibilityLabel("운동 세팅 열기 — 현재 \(settings.config.summaryText)")
.accessibilityLabel(Text("운동 세팅 열기 — 현재 \(settings.config.summaryText)"))
Spacer()
Spacer()
HStack {
Spacer()
cornerButton(symbol: "questionmark", label: Text("도움말")) { showHelp = true }
}
.padding(.horizontal, 20)
.padding(.bottom, 8)
}
.contentShape(Rectangle())
.gesture(
@ -134,7 +146,7 @@ struct ContentView: View {
Color(light: .white, dark: Color(hex: "#0F2018"))
}
private func cornerButton(symbol: String, label: String, action: @escaping () -> Void) -> some View {
private func cornerButton(symbol: String, label: Text, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: symbol)
.font(.title3)

View File

@ -17,6 +17,10 @@ enum DebugSeed {
if CommandLine.arguments.contains("-quickConfig") {
SettingsStore.shared.config = WorkoutConfig(contractSeconds: 1, relaxSeconds: 1, reps: 2)
}
//
if CommandLine.arguments.contains("-resetConfig") {
SettingsStore.shared.config = WorkoutConfig()
}
}
private static func seedStats() {

View File

@ -0,0 +1,133 @@
//
// HelpView.swift
// Keging
//
// . .
//
import SwiftUI
struct HelpView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
helpRow(
symbol: "play.circle.fill",
title: Text("바로 시작하기"),
body: Text("첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 바로 운동이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요.")
)
helpRow(
symbol: "slider.horizontal.3",
title: Text("운동 세팅 바꾸기"),
body: Text("오른쪽 위 슬라이더 버튼이 운동 세팅이에요. 수축 시간(1~60초), 이완 시간(1~60초), 반복 횟수(1~100회)를 정할 수 있어요. 예를 들어 수축 10초·이완 5초·20회로 설정하면, 수축과 이완을 20번 반복하는 한 세트가 약 5분이 돼요. 시작 버튼 아래의 요약 문구를 눌러도 이 화면이 열려요.")
)
helpRow(
symbol: "iphone.radiowaves.left.and.right",
title: Text("진동으로 구분하기"),
body: Text("수축이 시작될 때와 이완이 시작될 때 서로 다른 진동이 울려요. 그래서 화면을 보지 않아도 지금 조여야 할지 풀어야 할지 알 수 있어요. 진동 패턴은 운동 세팅에서 국면별로 고를 수 있고(한 번·두 번·세 번·심장 박동), '미리 느끼기' 버튼으로 실제 진동을 확인할 수 있어요. 진동은 실기기에서만 느껴져요.")
)
helpRow(
symbol: "stop.circle",
title: Text("중간에 중단하면"),
body: Text("운동 중 아래의 중단 버튼을 누르면 확인 후 세트가 끝나요. 중단한 세트는 아예 하지 않은 것으로 처리돼서 기록과 통계에 남지 않아요. 끝까지 마친 세트만 기록돼요.")
)
} header: {
Text("기본 사용법")
}
Section {
helpRow(
symbol: "chart.xyaxis.line",
title: Text("통계 열기"),
body: Text("첫 화면을 아래로 쓸어내리거나 위쪽 가운데의 손잡이를 누르면 통계 화면이 열려요. 닫을 때는 위로 쓸어올리거나 X 버튼을 누르면 돼요.")
)
helpRow(
symbol: "calendar",
title: Text("하루 · 주간 · 월간"),
body: Text("하루 탭에서는 오늘 몇 시에 몇 회를 했는지 목록과 시간대별 꺾은선 그래프로 보여줘요. 주간·월간 탭에서는 일별 횟수를 꺾은선 그래프와 합계(세트·횟수)로 보여줘요. 주는 월요일에 시작해요.")
)
helpRow(
symbol: "arrow.left.arrow.right",
title: Text("이번 주 ↔ 지난 7일 전환"),
body: Text("주간·월간 화면 위의 스위치로 두 가지 방식을 오갈 수 있어요. '이번 주 / 이번 달'은 달력 기준(월요일~일요일, 1일~말일)이고, '지난 7일 / 지난 30일'은 오늘을 기준으로 거슬러 세는 방식이에요.")
)
} header: {
Text("통계")
}
Section {
helpRow(
symbol: "bolt.horizontal.circle",
title: Text("다이나믹 아일랜드"),
body: Text("운동 중에 앱을 나가면 다이나믹 아일랜드에 현재 몇 번째인지와 수축·이완 상태가 떠요. 길게 누르면 남은 시간과 진행 바가 보이는 큰 화면이 열려요. 진동도 계속 울리니까 다른 앱을 쓰면서도 운동을 이어갈 수 있어요.")
)
helpRow(
symbol: "square.and.arrow.up",
title: Text("단축어와 시리"),
body: Text("단축어 앱에서 '케겔 운동 시작' 동작을 추가할 수 있어요. 실행하면 케깅이 열리면서 저장된 세팅대로 바로 시작돼요. 홈 화면 아이콘·잠금화면 위젯·뒷면 탭 등 단축어를 부를 수 있는 곳이라면 어디서든 쓸 수 있어요.")
)
} header: {
Text("앱 밖에서")
}
Section {
helpRow(
symbol: "applewatch",
title: Text("애플워치에서 운동하기"),
body: Text("워치 앱을 열면 시작 버튼 하나만 있어요. 누르면 바로 시작되고, 화면에 수축·이완 상태와 남은 시간, 몇 번째인지가 한눈에 보여요. 워치에서 실행하면 진동은 워치에서만 울려요. 중단 버튼을 누르면 그 세트는 기록되지 않아요.")
)
helpRow(
symbol: "arrow.triangle.2.circlepath",
title: Text("워치와 아이폰 동기화"),
body: Text("운동 세팅은 아이폰에서만 바꿀 수 있고, 바꾸면 워치에 자동으로 전달돼요. 워치에서 끝까지 마친 세트는 아이폰으로 넘어와 통계에 합쳐져요. 워치가 아이폰과 떨어져 있어도 기록은 쌓였다가 연결되면 자동으로 전송돼요.")
)
} header: {
Text("애플워치")
}
Section {
helpRow(
symbol: "paintbrush",
title: Text("테마와 언어"),
body: Text("왼쪽 위 톱니바퀴 버튼이 설정이에요. 화면 모드(시스템·라이트·다크)와 언어(시스템·한국어·English·日本語)를 고를 수 있어요. 언어는 앱을 완전히 종료했다가 다시 실행하면 적용돼요. 워치는 워치의 시스템 언어를 따라요.")
)
helpRow(
symbol: "lock.shield",
title: Text("데이터와 개인정보"),
body: Text("케깅은 광고와 결제가 없는 완전 무료 앱이에요. 모든 기록은 이 기기(그리고 워치)에만 저장되고, 외부 서버로 전송되지 않아요.")
)
} header: {
Text("설정·기타")
}
}
.navigationTitle("도움말")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("완료") { dismiss() }
}
}
}
}
private func helpRow(symbol: String, title: Text, body: Text) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: symbol)
.font(.body)
.foregroundStyle(AppTheme.green)
.frame(width: 24)
title
.font(.headline)
}
body
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
}
}

View File

@ -0,0 +1,29 @@
{
"sourceLanguage" : "ko",
"strings" : {
"CFBundleDisplayName" : {
"extractionState" : "manual",
"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "케깅"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keging"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keging"
}
}
}
}
},
"version" : "1.0"
}

File diff suppressed because it is too large Load Diff

View File

@ -10,8 +10,16 @@ import SwiftUI
import Charts
enum StatScope: String, CaseIterable, Identifiable {
case day = "하루", week = "주간", month = "월간"
case day, week, month
var id: String { rawValue }
var title: LocalizedStringKey {
switch self {
case .day: "하루"
case .week: "주간"
case .month: "월간"
}
}
}
enum StatRangeMode: String {
@ -39,7 +47,7 @@ struct StatsView: View {
VStack(spacing: 12) {
header
Picker("기간", selection: $scope) {
ForEach(StatScope.allCases) { Text($0.rawValue).tag($0) }
ForEach(StatScope.allCases) { Text($0.title).tag($0) }
}
.pickerStyle(.segmented)
.padding(.horizontal, 20)
@ -93,7 +101,7 @@ struct StatsView: View {
.background(AppTheme.surface, in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel("통계 닫기")
.accessibilityLabel(Text("통계 닫기"))
}
.padding(.horizontal, 20)
.padding(.top, 12)
@ -173,22 +181,22 @@ struct StatsView: View {
private var summaryCard: some View {
HStack(spacing: 0) {
summaryItem(value: "\(totalSets)", label: "세트")
summaryItem(value: "\(totalSets)", label: Text("세트"))
Divider().frame(height: 36)
summaryItem(value: "\(totalReps)", label: "횟수")
summaryItem(value: "\(totalReps)", label: Text("횟수"))
}
.padding(.vertical, 16)
.frame(maxWidth: .infinity)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16))
}
private func summaryItem(value: String, label: String) -> some View {
private func summaryItem(value: String, label: Text) -> some View {
VStack(spacing: 4) {
Text(value)
.font(.title.bold())
.monospacedDigit()
.foregroundStyle(AppTheme.green)
Text(label)
label
.font(.caption)
.foregroundStyle(.secondary)
}
@ -283,7 +291,7 @@ struct StatsView: View {
} else {
ForEach(todayRecords.sorted { $0.startedAt > $1.startedAt }) { record in
HStack {
Text(record.startedAt.formatted(date: .omitted, time: .shortened))
Text(record.startedAt, format: .dateTime.hour().minute())
.font(.body)
Spacer()
Text("\(record.reps)")

View File

@ -16,13 +16,13 @@ struct TimerSettingsView: View {
Form {
Section {
Stepper(value: $settings.config.contractSeconds, in: 1...60) {
LabeledContent("수축", value: "\(settings.config.contractSeconds)")
LabeledContent("수축", value: String(localized: "\(settings.config.contractSeconds)"))
}
Stepper(value: $settings.config.relaxSeconds, in: 1...60) {
LabeledContent("이완", value: "\(settings.config.relaxSeconds)")
LabeledContent("이완", value: String(localized: "\(settings.config.relaxSeconds)"))
}
Stepper(value: $settings.config.reps, in: 1...100) {
LabeledContent("횟수", value: "\(settings.config.reps)")
LabeledContent("횟수", value: String(localized: "\(settings.config.reps)"))
}
} header: {
Text("사이클")
@ -59,8 +59,8 @@ struct TimerSettingsView: View {
let total = Int(settings.config.totalDuration)
let minutes = total / 60
let seconds = total % 60
if minutes == 0 { return "\(seconds)" }
if seconds == 0 { return "\(minutes)" }
return "\(minutes)\(seconds)"
if minutes == 0 { return String(localized: "\(seconds)") }
if seconds == 0 { return String(localized: "\(minutes)") }
return String(localized: "\(minutes)\(seconds)")
}
}

View File

@ -32,7 +32,7 @@ struct WorkoutView: View {
.monospacedDigit()
.foregroundStyle(.secondary)
.padding(.top, 24)
.accessibilityLabel("현재 \(rep)번째, 전체 \(engine.config.reps)")
.accessibilityLabel(Text("현재 \(rep)번째, 전체 \(engine.config.reps)"))
Spacer()

View File

@ -277,10 +277,12 @@
};
};
buildConfigurationList = 7217F81030425E4B001577AB /* Build configuration list for PBXProject "Keging" */;
developmentRegion = en;
developmentRegion = ko;
hasScannedForEncodings = 0;
knownRegions = (
ko,
en,
ja,
Base,
);
mainGroup = 7217F80C30425E4B001577AB;

View File

@ -52,7 +52,7 @@ struct WatchContentView: View {
.frame(width: 108, height: 108)
}
.buttonStyle(.plain)
.accessibilityLabel("케겔 운동 시작")
.accessibilityLabel(Text("케겔 운동 시작"))
Text(SettingsStore.shared.config.summaryText)
.font(.system(size: 11))
@ -75,7 +75,7 @@ struct WatchContentView: View {
.font(.footnote)
.monospacedDigit()
.foregroundStyle(.secondary)
.accessibilityLabel("현재 \(rep)번째, 전체 \(engine.config.reps)")
.accessibilityLabel(Text("현재 \(rep)번째, 전체 \(engine.config.reps)"))
Button {
WatchCoordinator.shared.stopWorkout()
} label: {

View File

@ -0,0 +1,29 @@
{
"sourceLanguage" : "ko",
"strings" : {
"CFBundleDisplayName" : {
"extractionState" : "manual",
"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "케깅"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keging"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keging"
}
}
}
}
},
"version" : "1.0"
}

View File

@ -0,0 +1,276 @@
{
"sourceLanguage": "ko",
"strings": {
"%lld / %lld": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "%1$lld / %2$lld"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld / %lld"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%lld / %lld"
}
}
}
},
"%lld회 완주": {
"localizations": {
"en": {
"variations": {
"plural": {
"one": {
"stringUnit": {
"state": "translated",
"value": "%lld rep done"
}
},
"other": {
"stringUnit": {
"state": "translated",
"value": "%lld reps done"
}
}
}
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%lld回完了"
}
}
}
},
"두 번": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Twice"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "2回"
}
}
}
},
"세 번": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Three Times"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "3回"
}
}
}
},
"수축": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Squeeze"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "締める"
}
}
}
},
"수축 %lld초 · 이완 %lld초 · %lld회": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Squeeze %lld s · Relax %lld s · ×%lld"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "締める%lld秒 · ゆるめる%lld秒 · %lld回"
}
}
}
},
"시작": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Start"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "開始"
}
}
}
},
"심장 박동": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Heartbeat"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "心拍"
}
}
}
},
"완료!": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Done!"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "完了!"
}
}
}
},
"이완": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Relax"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ゆるめる"
}
}
}
},
"중단": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Stop"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "中断"
}
}
}
},
"케겔 운동 시작": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Start Kegel Workout"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ケーゲル運動を開始"
}
}
}
},
"한 번": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Once"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "1回"
}
}
}
},
"현재 %lld번째, 전체 %lld회": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "현재 %1$lld번째, 전체 %2$lld회"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Rep %lld of %lld"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%lld回目、全%lld回"
}
}
}
},
"확인": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "OK"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "OK"
}
}
}
}
},
"version": "1.0"
}

View File

@ -0,0 +1,168 @@
{
"sourceLanguage": "ko",
"strings": {
"%lld / %lld": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "%1$lld / %2$lld"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld / %lld"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%lld / %lld"
}
}
}
},
"%lld/%lld": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "%1$lld/%2$lld"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld/%lld"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%lld/%lld"
}
}
}
},
"두 번": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Twice"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "2回"
}
}
}
},
"세 번": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Three Times"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "3回"
}
}
}
},
"수축": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Squeeze"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "締める"
}
}
}
},
"수축 %lld초 · 이완 %lld초 · %lld회": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Squeeze %lld s · Relax %lld s · ×%lld"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "締める%lld秒 · ゆるめる%lld秒 · %lld回"
}
}
}
},
"심장 박동": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Heartbeat"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "心拍"
}
}
}
},
"이완": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Relax"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ゆるめる"
}
}
}
},
"한 번": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Once"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "1回"
}
}
}
}
},
"version": "1.0"
}

View File

@ -12,7 +12,9 @@ import Combine
nonisolated enum KegelPhase: String, Codable, Hashable {
case contract, relax
var label: String { self == .contract ? "수축" : "이완" }
var label: String {
self == .contract ? String(localized: "수축") : String(localized: "이완")
}
var symbolName: String {
self == .contract ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right"
}

View File

@ -16,10 +16,10 @@ nonisolated enum HapticPattern: String, CaseIterable, Identifiable, Codable {
var label: String {
switch self {
case .once: "한 번"
case .twice: "두 번"
case .thrice: "세 번"
case .heartbeat: "심장 박동"
case .once: String(localized: "한 번")
case .twice: String(localized: "두 번")
case .thrice: String(localized: "세 번")
case .heartbeat: String(localized: "심장 박동")
}
}
@ -44,7 +44,9 @@ nonisolated struct WorkoutConfig: Codable, Equatable {
var totalDuration: TimeInterval { Double(reps * (contractSeconds + relaxSeconds)) }
var summaryText: String { "수축 \(contractSeconds)초 · 이완 \(relaxSeconds)초 · \(reps)" }
var summaryText: String {
String(localized: "수축 \(contractSeconds)초 · 이완 \(relaxSeconds)초 · \(reps)")
}
}
@MainActor