fix(keging): 실기기 2차 피드백 — 중단 UI 인라인화·통계 위로 스와이프·진동 3종 Core Haptics 재설계

① 중단 확인: 하단 시트(confirmationDialog) → 버튼 자리에서 펼쳐지는 인라인 확인
   (안내 문구 + 계속하기(그린)/중단하기(레드), scale+opacity 전환)
② 통계: 아래→위 스와이프로 반전, 하단 중앙 손잡이(위 화살표+캡슐), 통통 튀는 스프링
   (dampingFraction 0.62), 통계 상단에 닫기 손잡이 추가, 닫기=아래로 스와이프
③ 진동 3종(기본·톡톡·지이이잉)으로 재설계 — 체이닝 간격도 길다는 피드백:
   - iOS 포그라운드: Core Haptics 정밀 재생(Shared/HapticEnginePlayer) — 기본 0.25s,
     톡톡 0.14s×2 시작 간격 0.28s(요구 0.5s 미만), 지이이잉 1.2s 연속, 세기 1.0
   - iOS 백그라운드 폴백: 시스템 바이브 완료 체이닝(1방/2방/3방 즉시 잇기)
   - 워치: watchOS SDK에 CoreHaptics 부재(실측) — 강한 시스템 햅틱 매핑
     (기본=.notification, 톡톡=.directionUp, 지이이잉=.retry — 내장이라 간격 뭉개짐 없음)
   - 구버전 저장 패턴 자동 이관, 기본값 수축=지이이잉·이완=기본
- 신규 QA 인자 -confirmStop, 도움말 문구·번역(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-30 09:39:37 +09:00
parent 3cee5f08af
commit eba01b570f
12 changed files with 285 additions and 240 deletions

View File

@ -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 전 기능 구현 + 실기기 1차 피드백 3건 수정 + 전면 자가 점검 완료, 아침 재빌드 대기.** 1차 수정: ①DI 백그라운드 멈춤 → 오디오 키퍼 하드닝+엔진 벽시계 재설계 ②진동 패턴 동일 → 완료 체이닝 ③칼로리 0 → 에너지 샘플 추가. 자가 점검(심야): **클린룸(새 DerivedData) 3종 빌드 그린·경고 0** — 사용자가 본 "cannot find in scope"는 SourceKit 편집기 오탐으로 확정(§2.2). 추가 수정 2건: 진동 체인 세대 취소, **강제 종료 유령 DI 정리**(cleanupStaleActivities — 시뮬 재현·정리 실측). 시뮬 실측: DI 백그라운드 갱신(수축→이완 색 변화)·포그라운드 복귀 재동기화(5/20 정확 착지)·통계 검산(11세트/220회)·완주·워치 벽시계 동작·18.5 빌드·l10n 0/0/0. **재테스트 항목(사용자, 아이폰)**: DI 백그라운드 갱신, 진동 4종 구분, 칼로리(재권한 시트 '활성 에너지' 허용 필요). **워치 실기기는 TestFlight 1.0에서**(사용자 결정 — 로컬 워치 배포가 네트워크 문제로 실패, 시뮬 검증은 완료) |
| 현재 상태 (2026-08-30) | **1.0 전 기능 구현 + 실기기 2차 피드백 반영 완료, 재테스트 대기.** 2차 수정(2026-08-30): ①중단 확인을 인라인 UI로 교체(§4) ②통계 스와이프 방향 반전(아래→위, 통통 스프링) ③**진동 3종 재설계 — iOS 포그라운드 Core Haptics 정밀 재생**(기본/톡톡 0.28s 간격/지이이잉 1.2s 연속, §3.3. watchOS는 CoreHaptics 부재라 시스템 햅틱 3종 매핑). 이전(1차): DI 백그라운드 멈춤(오디오 하드닝+벽시계 엔진)·칼로리 0(에너지 샘플)·유령 DI 정리, 클린룸 3종 빌드 그린·경고 0("cannot find in scope"는 SourceKit 오탐 확정). **재테스트 항목(사용자, 아이폰)**: DI 백그라운드 갱신, 진동 3종(특히 포그라운드 톡톡 간격·지이이잉 길이 — 백그라운드는 시스템 바이브 체이닝 폴백이라 질감이 다름), 칼로리(재권한 시트 '활성 에너지' 허용), 중단 UI·통계 제스처. **워치 실기기는 TestFlight 1.0에서**(로컬 워치 배포 네트워크 문제 — 시뮬 검증 완료) |
## 2. 빌드·검증 환경
@ -117,13 +117,14 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
### 3.2 기록·통계 (`Shared/SessionRecord.swift`, `IOS/StatsView.swift`)
- SessionRecord: id(UUID)·startedAt(시작 시각)·reps·수축/이완초. 저장은 App Group `records.json`(JSON, atomic). append는 id 중복 병합이라 워치 재전송에도 안전
- 통계 화면: 첫 화면 **아래로 스와이프**(또는 상단 그랩 탭)로 열리고 위로 스와이프/X로 닫는 오버레이. 하루(시간대별 꺾은선 + 오늘 기록 목록) / 주간·월간(일별 꺾은선) + 세트·횟수 합계 카드
- 통계 화면: 첫 화면 **위로 스와이프**(또는 하단 손잡이 탭)로 아래에서 **통통 튀는 스프링**(dampingFraction 0.62)으로 올라오는 오버레이 — 아래로 스와이프·상단 손잡이·X로 닫기(2차 실기기 피드백으로 방향 반전). 하루(시간대별 꺾은선 + 오늘 기록 목록) / 주간·월간(일별 꺾은선) + 세트·횟수 합계 카드
- 주간·월간은 **달력 기준(이번 주·이번 달) ↔ 오늘 기준 롤링(지난 7일·30일)** 세그먼트 전환. **주 시작 = 월요일** (`calendar.firstWeekday = 2`)
### 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는 `AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate)` **완료 체이닝** — ⚠️**재생 중 같은 시스템 사운드 재호출은 무시된다**(실기기 실측: 시간 오프셋 방식은 겹침·짧은 간격이 전부 먹혀 톡=톡톡이 됐음). 완료 후 쉼: 톡=1방, 톡톡=0.25초 쉬고 1방, 드르륵=0.15초 간격 3연타, 지잉=즉시 이어붙인 3방. **세대 토큰으로 새 패턴 시작 시 이전 체인 취소**(짧은 수축·이완에서 이전 체인이 다음 국면 첫 진동을 삼키는 것 방지). 무음 스위치 무관, **무음 오디오 세션이 살아 있으면 백그라운드에서도 울린다**
- 워치는 반복 재생이 뭉개져서 **패턴마다 성격이 다른 시스템 햅틱 1회**로 매핑: 톡=.start, 톡톡=.directionUp, 드르륵=.retry, 지잉=.notification. **워치에서 실행하면 진동은 워치에서만** (완주 시 .success 1회)
- **패턴 3종**(2차 실기기 피드백 '체이닝도 간격 길다'로 재설계): 기본(짧은 1방) / 톡톡(빠른 2방) / 지이이잉(아주 긴 연속 진동). 기본값: **수축=지이이잉, 이완=기본**. 세팅에서 국면별 선택 + "미리 느끼기". 구버전 저장값(1.0 횟수 기반·4종 시절)은 `HapticPattern(migrating:)`으로 이관
- **iOS 포그라운드 = Core Haptics 정밀 재생**(`Shared/HapticEnginePlayer``hapticEvents` 스펙: 기본 0.25s, 톡톡 0.14s×2 시작 간격 0.28s, 지이이잉 1.2s 연속, 세기 1.0). 시스템 바이브로는 불가능한 형태라 CHHapticEngine 사용(isAutoShutdownEnabled, 실패 시 엔진 1회 재생성)
- **iOS 백그라운드 폴백 = 시스템 바이브 완료 체이닝**(기본 1방·톡톡 2방·지이이잉 3방 즉시 잇기) — ⚠️재생 중 같은 시스템 사운드 재호출은 무시(실측)라 완료 콜백이 물리적 최소 간격. 세대 토큰으로 새 패턴 시작 시 이전 체인 취소. 무음 스위치 무관, 오디오 세션 살아 있으면 백그라운드에서도 울림
- ⚠️**watchOS SDK에는 CoreHaptics가 없다**(프레임워크 부재 실측) — 워치는 강한 시스템 햅틱 3종 매핑(시스템 내장이라 간격 뭉개짐 없음): 기본=.notification, 톡톡=.directionUp, 지이이잉=.retry. 세기는 시스템 고정(더 강하게는 워치 설정 > 사운드 및 햅틱). **워치에서 실행하면 진동은 워치에서만** (완주 시 .success 1회)
### 3.4 백그라운드·Live Activity (`IOS/BackgroundAudioKeeper.swift`, `LiveActivityManager.swift`, `KegingWidgets/`)
- 운동 시작 시 무음 WAV 무한 루프(`UIBackgroundModes=audio`, `.playback + .mixWithOthers`, **volume 0.1**·prepareToPlay — 0.0은 백그라운드 유지가 풀리는 사례 대비) → 앱을 나가도 타이머·진동·Live Activity 갱신 유지. **인터럽션 종료·백그라운드 전환·활성화 때 재생 죽었으면 revive()** 옵저버. 그래도 정지되면 엔진 벽시계 재동기화(§3.1)가 복귀 시 교정. 종료·중단 시 즉시 해제. 운동 중 `isIdleTimerDisabled`로 화면 꺼짐 방지(종료 시 복원)
@ -150,7 +151,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
## 4. 화면 구성 (탭 없음 — 애플 기본 앱 느낌)
- **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + 상단 중앙 통계 그랩 힌트 + 버튼 아래 **세팅 요약 카드 3장**(수축·이완·횟수 — 아이콘+큰 숫자, 탭=세팅. 텍스트 한 줄이 성의 없다는 사용자 피드백으로 교체)
- **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + **하단 중앙 통계 손잡이(위로 스와이프 힌트)** + 버튼 아래 **세팅 요약 카드 3장**(수축·이완·횟수 — 아이콘+큰 숫자, 탭=세팅)
- **세팅/설정/도움말**: 시트(Form/List). 설정에 테마·**언어**(시스템/한국어/English/日本語 — 재실행 시 적용)·**애플 건강**(운동 강도 자동 기록) 섹션. 도움말은 5그룹 14주제(기본 사용법·통계·앱 밖에서·애플워치·설정 기타)로 전 기능 사용법 서술. 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션, **국면 색 전환(준비=회그린·수축=그린·이완=앰버, 카운터 색 연동)** + 원 둘레 **세트 진행 링**(rep 기준, 국면 색, 준비는 0) + 큰 현재 횟수 카운터(numericText 전환). 준비 국면은 작은 원이 천천히 부풀며 첫 수축으로 이어짐. Live Activity는 `LiveActivityManager.sync`(없으면 시작·있으면 갱신) 단일 진입점
- 아이콘 전용 버튼에는 `accessibilityLabel` 필수 (하루 다님 컨벤션 계승)
@ -183,6 +184,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
| `-statsRolling` | 주간·월간을 롤링 모드로 |
| `-openHelp` / `-openSettings` / `-openTimerSettings` | 해당 시트를 열고 시작 (화면 QA용) |
| `-autoStart` | 시작하자마자 운동 시작 (아이폰·워치 공통) |
| `-confirmStop` | 운동 화면의 인라인 중단 확인을 펼친 채 시작 (화면 QA용) |
| `-quickConfig` | 세팅을 수축1초·이완1초·2회로 (완주 경로 검증) |
| `-resetConfig` | 세팅을 기본값(10초·5초·20회)으로 복원 |
| `-noHealth` | 건강 권한 요청 생략(아이폰·워치 공통) — 화면 QA에서 권한 시트가 캡처를 가리는 것 방지 |

View File

@ -24,7 +24,7 @@ struct ContentView: View {
mainScreen
if showStats {
StatsView(onClose: closeStats)
.transition(.move(edge: .top))
.transition(.move(edge: .bottom))
.zIndex(2)
}
}
@ -58,20 +58,6 @@ struct ContentView: View {
HStack(alignment: .top) {
cornerButton(symbol: "gearshape.fill", label: Text("설정")) { showAppSettings = true }
Spacer()
Button {
openStats()
} label: {
VStack(spacing: 2) {
Capsule().fill(.secondary.opacity(0.5)).frame(width: 36, height: 5)
Image(systemName: "chevron.compact.down")
.font(.body)
.foregroundStyle(.secondary)
}
.padding(.top, 10)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("통계 보기"))
Spacer()
cornerButton(symbol: "slider.horizontal.3", label: Text("운동 세팅")) { showTimerSettings = true }
}
.padding(.horizontal, 20)
@ -119,10 +105,26 @@ struct ContentView: View {
Spacer()
Spacer()
// : = ( ), =
ZStack {
Button {
openStats()
} label: {
VStack(spacing: 2) {
Image(systemName: "chevron.compact.up")
.font(.body)
.foregroundStyle(.secondary)
Capsule().fill(.secondary.opacity(0.5)).frame(width: 36, height: 5)
}
.padding(.bottom, 6)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("통계 보기"))
HStack {
Spacer()
cornerButton(symbol: "questionmark", label: Text("도움말")) { showHelp = true }
}
}
.padding(.horizontal, 20)
.padding(.bottom, 8)
}
@ -130,7 +132,7 @@ struct ContentView: View {
.gesture(
DragGesture(minimumDistance: 25)
.onEnded { value in
if value.translation.height > 60, abs(value.translation.width) < 100 {
if value.translation.height < -60, abs(value.translation.width) < 100 {
openStats()
}
}
@ -204,11 +206,12 @@ struct ContentView: View {
// MARK: ·
private func openStats() {
withAnimation(.spring(duration: 0.35)) { showStats = true }
// ( )
withAnimation(.spring(response: 0.45, dampingFraction: 0.62)) { showStats = true }
}
private func closeStats() {
withAnimation(.spring(duration: 0.35)) { showStats = false }
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { showStats = false }
}
private var workoutPresented: Binding<Bool> {

View File

@ -2,43 +2,44 @@
// Haptics.swift
// Keging
//
// (iOS) (1 0.5) .
// ( ·
// ) .
// (iOS).
// - : Core Haptics (HapticEnginePlayer) ·
// - : .
// ( ) .
// AudioServices , .
//
import AudioToolbox
import Foundation
import UIKit
@MainActor
enum Haptics {
///
/// (· )
private static var generation = 0
static func play(_ pattern: HapticPattern) {
// ()
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] // :
}
generation += 1
playChain(gaps, generation: generation)
if UIApplication.shared.applicationState == .active,
HapticEnginePlayer.shared.play(pattern) {
return
}
playSystemChain(pattern, generation: generation)
}
private static func playChain(_ gaps: [TimeInterval], generation: Int) {
guard generation == Self.generation, let gap = gaps.first else { return }
let rest = Array(gaps.dropFirst())
Task { @MainActor in
if gap > 0 { try? await Task.sleep(for: .seconds(gap)) }
guard generation == Self.generation else { return }
AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate) { @MainActor @Sendable in
playChain(rest, generation: generation)
///
private static func playSystemChain(_ pattern: HapticPattern, generation: Int) {
let count = switch pattern {
case .basic: 1
case .doubleTap: 2
case .long: 3 //
}
playChain(remaining: count, generation: generation)
}
private static func playChain(remaining: Int, generation: Int) {
guard remaining > 0, generation == Self.generation else { return }
AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate) { @MainActor @Sendable in
playChain(remaining: remaining - 1, generation: generation)
}
}
}

View File

@ -27,7 +27,7 @@ struct HelpView: View {
helpRow(
symbol: "iphone.radiowaves.left.and.right",
title: Text("진동으로 구분하기"),
body: Text("수축이 시작될 때와 이완이 시작될 때 서로 다른 진동이 울려요. 그래서 화면을 보지 않아도 지금 조여야 할지 풀어야 할지 알 수 있어요. 기본은 수축=지잉(길게), 이완=톡(짧게)이라 첫 순간의 질감으로 바로 구분돼요. 패턴은 운동 세팅에서 국면별로 고를 수 있고(톡·톡톡·드르륵·지잉), '미리 느끼기' 버튼으로 실제 진동을 확인할 수 있어요. 진동은 실기기에서만 느껴져요.")
body: Text("수축이 시작될 때와 이완이 시작될 때 서로 다른 진동이 울려요. 그래서 화면을 보지 않아도 지금 조여야 할지 풀어야 할지 알 수 있어요. 기본값은 수축=지이이잉(아주 긴 진동), 이완=기본(짧은 한 번)이라 첫 순간의 질감으로 바로 구분돼요. 패턴은 운동 세팅에서 국면별로 고를 수 있고(기본·톡톡·지이이잉), '미리 느끼기' 버튼으로 실제 진동을 확인할 수 있어요. 워치에서도 같은 패턴으로 울려요. 진동은 실기기에서만 느껴져요.")
)
helpRow(
symbol: "stop.circle",
@ -42,7 +42,7 @@ struct HelpView: View {
helpRow(
symbol: "chart.xyaxis.line",
title: Text("통계 열기"),
body: Text("첫 화면을 아래로 쓸어내리거나 위쪽 가운데의 손잡이를 누르면 통계 화면이 열려요. 닫을 때는 위로 쓸어올리거나 X 버튼을 누르면 돼요.")
body: Text("첫 화면을 위로 쓸어올리거나 아래 가운데의 손잡이를 누르면 통계 화면이 올라와요. 닫을 때는 아래로 쓸어내리거나 X 버튼을 누르면 돼요.")
)
helpRow(
symbol: "calendar",

View File

@ -169,6 +169,22 @@
}
}
},
"기본": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Standard"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "標準"
}
}
}
},
"기본 사용법": {
"localizations": {
"en": {
@ -313,22 +329,6 @@
}
}
},
"드르륵": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Triple burst"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "トトトン"
}
}
}
},
"라이트": {
"localizations": {
"en": {
@ -575,18 +575,18 @@
}
}
},
"수축이 시작될 때와 이완이 시작될 때 서로 다른 진동이 울려요. 그래서 화면을 보지 않아도 지금 조여야 할지 풀어야 할지 알 수 있어요. 기본은 수축=지잉(길게), 이완=톡(짧게)이라 첫 순간의 질감으로 바로 구분돼요. 패턴은 운동 세팅에서 국면별로 고를 수 있고(톡·톡톡·드르륵·지잉), '미리 느끼기' 버튼으로 실제 진동을 확인할 수 있어요. 진동은 실기기에서만 느껴져요.": {
"수축이 시작될 때와 이완이 시작될 때 서로 다른 진동이 울려요. 그래서 화면을 보지 않아도 지금 조여야 할지 풀어야 할지 알 수 있어요. 기본값은 수축=지이이잉(아주 긴 진동), 이완=기본(짧은 한 번)이라 첫 순간의 질감으로 바로 구분돼요. 패턴은 운동 세팅에서 국면별로 고를 수 있고(기본·톡톡·지이이잉), '미리 느끼기' 버튼으로 실제 진동을 확인할 수 있어요. 워치에서도 같은 패턴으로 울려요. 진동은 실기기에서만 느껴져요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "A different vibration plays when a squeeze starts and when a relax starts, so you always know whether to squeeze or release without looking at the screen. By default, squeeze = a long buzz and relax = a short tap, so the very first moment tells them apart. You can choose a pattern for each phase in the workout setup (tap, double tap, triple burst, long buzz) and try them with the preview buttons. Haptics can only be felt on a real device."
"value": "A different vibration plays when a squeeze starts and when a relax starts, so you always know whether to squeeze or release without looking at the screen. By default, squeeze = a very long buzz and relax = a short standard tap, so the very first moment tells them apart. You can choose a pattern for each phase in the workout setup (Standard, Double tap, Long buzz) and try them with the preview buttons. The watch uses the same patterns. Haptics can only be felt on a real device."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "締め始めとゆるめ始めで異なる振動が鳴るので、画面を見なくても今どちらなのか分かります。デフォルトは締める=長いブーッ、ゆるめる=短いトンなので、最初の瞬間の感触ですぐ区別できます。パターンはワークアウト設定でそれぞれ選べて(トン・トントン・トトトン・ブーッ)、「試す」ボタンで実際に確認できます。振動は実機でのみ感じられます。"
"value": "締め始めとゆるめ始めで異なる振動が鳴るので、画面を見なくても今どちらなのか分かります。デフォルトは締める=とても長いブーッ、ゆるめる=短い標準の1回なので、最初の瞬間の感触ですぐ区別できます。パターンはワークアウト設定でそれぞれ選べて(標準・トントン・ブーッ)、「試す」ボタンで実際に確認できます。ウォッチでも同じパターンで振動します。振動は実機でのみ感じられます。"
}
}
}
@ -673,12 +673,6 @@
},
"시작 버튼을 누르면 준비 시간 뒤에 수축 → 이완을 %lld회 반복해요. 한 세트 약 %@.": {
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "시작 버튼을 누르면 준비 시간 뒤에 수축 → 이완을 %1$lld회 반복해요. 한 세트 약 %2$@."
}
},
"en": {
"stringUnit": {
"state": "translated",
@ -690,6 +684,12 @@
"state": "translated",
"value": "開始ボタンを押すと、準備時間のあとに締める→ゆるめるを%lld回くり返します。1セットは約%@です。"
}
},
"ko": {
"stringUnit": {
"state": "new",
"value": "시작 버튼을 누르면 준비 시간 뒤에 수축 → 이완을 %1$lld회 반복해요. 한 세트 약 %2$@."
}
}
}
},
@ -1077,22 +1077,6 @@
}
}
},
"운동을 중단할까요?": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Stop this workout?"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ワークアウトを中断しますか?"
}
}
}
},
"워치 앱을 열면 시작 버튼 하나만 있어요. 누르면 바로 시작되고, 화면에 수축·이완 상태와 남은 시간, 몇 번째인지가 한눈에 보여요. 워치에서 실행하면 진동은 워치에서만 울려요. 중단 버튼을 누르면 그 세트는 기록되지 않아요.": {
"localizations": {
"en": {
@ -1429,7 +1413,7 @@
}
}
},
"지잉 (길게)": {
"지이이잉": {
"localizations": {
"en": {
"stringUnit": {
@ -1440,7 +1424,7 @@
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブーッ(長く)"
"value": "ブーッ"
}
}
}
@ -1477,18 +1461,18 @@
}
}
},
"첫 화면을 아래로 쓸어내리거나 위쪽 가운데의 손잡이를 누르면 통계 화면이 열려요. 닫을 때는 위로 쓸어올리거나 X 버튼을 누르면 돼요.": {
"첫 화면을 위로 쓸어올리거나 아래 가운데의 손잡이를 누르면 통계 화면이 올라와요. 닫을 때는 아래로 쓸어내리거나 X 버튼을 누르면 돼요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Swipe down on the main screen, or tap the handle at the top center, to open statistics. To close, swipe up or tap the X button."
"value": "Swipe up on the main screen, or tap the handle at the bottom center, and the statistics screen pops up. To close, swipe down or tap the X button."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "最初の画面を下にスワイプするか、上部中央のハンドルを押すと統計画面が開きます。閉じるときは上にスワイプするか、Xボタンを押します。"
"value": "最初の画面を上にスワイプするか、下部中央のハンドルを押すと統計画面が上がってきます。閉じるときは下にスワイプするか、Xボタンを押します。"
}
}
}
@ -1605,22 +1589,6 @@
}
}
},
"톡 (짧게)": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Tap (short)"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "トン(短く)"
}
}
}
},
"톡톡": {
"localizations": {
"en": {

View File

@ -68,7 +68,7 @@ struct StatsView: View {
.gesture(
DragGesture(minimumDistance: 40)
.onEnded { value in
if value.translation.height < -70, abs(value.translation.width) < 100 { onClose() }
if value.translation.height > 70, abs(value.translation.width) < 100 { onClose() }
}
)
.onAppear {
@ -89,6 +89,12 @@ struct StatsView: View {
// MARK: ·
private var header: some View {
VStack(spacing: 6) {
//
Capsule()
.fill(.secondary.opacity(0.5))
.frame(width: 36, height: 5)
.padding(.top, 8)
HStack {
Text("통계")
.font(.largeTitle.bold())
@ -104,7 +110,7 @@ struct StatsView: View {
.accessibilityLabel(Text("통계 닫기"))
}
.padding(.horizontal, 20)
.padding(.top, 12)
}
}
private var rangeModePicker: some View {

View File

@ -23,6 +23,11 @@ struct WorkoutView: View {
Color.clear
}
}
.onAppear {
#if DEBUG
if CommandLine.arguments.contains("-confirmStop") { confirmStop = true }
#endif
}
}
private func runningView(phase: KegelPhase, rep: Int, phaseStart: Date, phaseEnd: Date) -> some View {
@ -74,8 +79,38 @@ struct WorkoutView: View {
Spacer()
// ( )
Group {
if confirmStop {
VStack(spacing: 12) {
Text("중단하면 이 세트는 기록되지 않아요.")
.font(.footnote)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Button {
confirmStop = true
withAnimation(.snappy) { confirmStop = false }
} label: {
Text("계속하기")
.font(.headline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button(role: .destructive) {
engine.stop()
} label: {
Text("중단하기")
.font(.headline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.red)
}
}
.padding(.horizontal, 24)
.transition(.scale(scale: 0.9).combined(with: .opacity))
} else {
Button {
withAnimation(.snappy) { confirmStop = true }
} label: {
Text("중단")
.font(.headline)
@ -83,13 +118,10 @@ struct WorkoutView: View {
}
.buttonStyle(.bordered)
.tint(.red)
.padding(.bottom, 30)
.transition(.scale(scale: 0.9).combined(with: .opacity))
}
.confirmationDialog("운동을 중단할까요?", isPresented: $confirmStop, titleVisibility: .visible) {
Button("중단하기", role: .destructive) { engine.stop() }
Button("계속하기", role: .cancel) {}
} message: {
Text("중단하면 이 세트는 기록되지 않아요.")
}
.padding(.bottom, 30)
}
}

View File

@ -51,6 +51,22 @@
}
}
},
"기본": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Standard"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "標準"
}
}
}
},
"기타": {
"localizations": {
"en": {
@ -67,22 +83,6 @@
}
}
},
"드르륵": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Triple burst"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "トトトン"
}
}
}
},
"수축": {
"localizations": {
"en": {
@ -201,7 +201,7 @@
}
}
},
"지잉 (길게)": {
"지이이잉": {
"localizations": {
"en": {
"stringUnit": {
@ -212,7 +212,7 @@
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブーッ(長く)"
"value": "ブーッ"
}
}
}
@ -249,22 +249,6 @@
}
}
},
"톡 (짧게)": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Tap (short)"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "トン(短く)"
}
}
}
},
"톡톡": {
"localizations": {
"en": {

View File

@ -2,8 +2,10 @@
// WatchHaptics.swift
// KegingWatch Watch App
//
// () 1
// ( )
// () watchOS SDK CoreHaptics () .
// 3 ( ):
// =.notification( 1), =.directionUp( 2), =.retry( ).
// > > ' ' .
//
import WatchKit
@ -11,19 +13,15 @@ import WatchKit
@MainActor
enum WatchHaptics {
static func play(_ pattern: HapticPattern) {
WKInterfaceDevice.current().play(hapticType(for: pattern))
let type: WKHapticType = switch pattern {
case .basic: .notification
case .doubleTap: .directionUp
case .long: .retry
}
WKInterfaceDevice.current().play(type)
}
static func playSuccess() {
WKInterfaceDevice.current().play(.success)
}
private static func hapticType(for pattern: HapticPattern) -> WKHapticType {
switch pattern {
case .short: .start //
case .doubleTap: .directionUp //
case .rattle: .retry //
case .long: .notification //
}
}
}

View File

@ -45,6 +45,22 @@
}
}
},
"기본": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Standard"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "標準"
}
}
}
},
"기타": {
"localizations": {
"en": {
@ -61,22 +77,6 @@
}
}
},
"드르륵": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Triple burst"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "トトトン"
}
}
}
},
"수축": {
"localizations": {
"en": {
@ -147,7 +147,7 @@
}
}
},
"지잉 (길게)": {
"지이이잉": {
"localizations": {
"en": {
"stringUnit": {
@ -158,7 +158,7 @@
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブーッ(長く)"
"value": "ブーッ"
}
}
}
@ -179,22 +179,6 @@
}
}
},
"톡 (짧게)": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Tap (short)"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "トン(短く)"
}
}
}
},
"톡톡": {
"localizations": {
"en": {

View File

@ -0,0 +1,61 @@
//
// HapticEnginePlayer.swift
// Keging
//
// Core Haptics · (watchOS SDK CoreHaptics ).
// 1 .
// 1 , false
// ( ).
//
#if canImport(CoreHaptics)
import CoreHaptics
@MainActor
final class HapticEnginePlayer {
static let shared = HapticEnginePlayer()
private var engine: CHHapticEngine?
private init() {}
@discardableResult
func play(_ pattern: HapticPattern) -> Bool {
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return false }
if attempt(pattern) { return true }
engine = nil
return attempt(pattern)
}
private func attempt(_ pattern: HapticPattern) -> Bool {
do {
let engine = try currentEngine()
let events = pattern.hapticEvents.map { spec in
CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: spec.intensity),
CHHapticEventParameter(parameterID: .hapticSharpness, value: spec.sharpness),
],
relativeTime: spec.time,
duration: spec.duration
)
}
let player = try engine.makePlayer(with: try CHHapticPattern(events: events, parameters: []))
try engine.start()
try player.start(atTime: CHHapticTimeImmediate)
return true
} catch {
return false
}
}
private func currentEngine() throws -> CHHapticEngine {
if let engine { return engine }
let newEngine = try CHHapticEngine()
newEngine.isAutoShutdownEnabled = true
engine = newEngine
return newEngine
}
}
#endif

View File

@ -8,26 +8,33 @@
import Foundation
import Combine
/// / .
/// ( / / / )
/// 3 / .
/// (= / = / = )
nonisolated enum HapticPattern: String, CaseIterable, Identifiable, Codable {
case short, doubleTap, rattle, long
case basic, doubleTap, long
var id: String { rawValue }
var label: String {
switch self {
case .short: String(localized: "톡 (짧게)")
case .basic: String(localized: "기본")
case .doubleTap: String(localized: "톡톡")
case .rattle: String(localized: "드르륵")
case .long: String(localized: "지잉 (길게)")
case .long: String(localized: "지이이잉")
}
}
// : iOS = 퀀(IOS/Haptics.swift),
// = (WatchHaptics.swift)
/// Core Haptics ( , , , ) · .
/// (HapticEnginePlayer). 0.28( 0.5 ),
/// 1.2 Core Haptics
var hapticEvents: [(time: Double, duration: Double, intensity: Float, sharpness: Float)] {
switch self {
case .basic: [(0, 0.25, 1.0, 0.55)]
case .doubleTap: [(0, 0.14, 1.0, 0.65), (0.28, 0.14, 1.0, 0.65)]
case .long: [(0, 1.2, 1.0, 0.45)]
}
}
/// 1.0 ( )
/// (1.0 · 4 )
init?(migrating raw: String?) {
guard let raw else { return nil }
if let direct = HapticPattern(rawValue: raw) {
@ -35,9 +42,8 @@ nonisolated enum HapticPattern: String, CaseIterable, Identifiable, Codable {
return
}
switch raw {
case "once": self = .short
case "twice": self = .doubleTap
case "thrice", "heartbeat": self = .rattle
case "once", "short": self = .basic
case "twice", "thrice", "heartbeat", "rattle": self = .doubleTap
default: return nil
}
}
@ -64,9 +70,9 @@ nonisolated struct WorkoutConfig: Codable, Equatable {
var contractSeconds: Int = 10
var relaxSeconds: Int = 5
var reps: Int = 20
/// : = ( ), = ( )
/// : = ( ), = ( )
var contractPattern: HapticPattern = .long
var relaxPattern: HapticPattern = .short
var relaxPattern: HapticPattern = .basic
/// (0 = , 1~10)
var effortScore: Int = 2
///
@ -78,7 +84,7 @@ nonisolated struct WorkoutConfig: Codable, Equatable {
relaxSeconds: Int = 5,
reps: Int = 20,
contractPattern: HapticPattern = .long,
relaxPattern: HapticPattern = .short,
relaxPattern: HapticPattern = .basic,
effortScore: Int = 2,
healthWorkoutType: HealthWorkoutType = .other
) {
@ -100,7 +106,7 @@ nonisolated struct WorkoutConfig: Codable, Equatable {
relaxSeconds = try container.decodeIfPresent(Int.self, forKey: .relaxSeconds) ?? 5
reps = try container.decodeIfPresent(Int.self, forKey: .reps) ?? 20
contractPattern = HapticPattern(migrating: try container.decodeIfPresent(String.self, forKey: .contractPattern)) ?? .long
relaxPattern = HapticPattern(migrating: try container.decodeIfPresent(String.self, forKey: .relaxPattern)) ?? .short
relaxPattern = HapticPattern(migrating: try container.decodeIfPresent(String.self, forKey: .relaxPattern)) ?? .basic
effortScore = try container.decodeIfPresent(Int.self, forKey: .effortScore) ?? 2
healthWorkoutType = try container.decodeIfPresent(HealthWorkoutType.self, forKey: .healthWorkoutType) ?? .other
}