fix(keging): 진동 패턴 4종 재설계 — 횟수 세기 대신 첫 순간의 질감으로 구분

- 톡(짧게)/톡톡/드르륵/지잉(길게, 0.3s 겹침으로 끊기지 않는 긴 진동) — 사용자 피드백
  '한 번·두 번 방식은 아니고 간격도 길다' 반영, 연타 간격 0.5s로 단축
- 기본값: 수축=지잉(조이고 유지), 이완=톡(탁 풀기) — 첫 진동 즉시 구분
- 워치는 반복 재생 대신 패턴별 단일 시스템 햅틱(.start/.directionUp/.retry/.notification)
  — 워치 연타 뭉개짐·긴 간격 문제 자체를 제거
- 구버전 저장값(once/twice/thrice/heartbeat) 자동 이관, 라벨 3언어 번역·stale 정리
- 검증: Debug·Release·워치 빌드 그린, 세팅 화면 QA, 카탈로그 missing·stale 0

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:34:22 +09:00
parent f91968f8ce
commit 0d058c7734
9 changed files with 309 additions and 261 deletions

View File

@ -120,9 +120,9 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
- 주간·월간은 **달력 기준(이번 주·이번 달) ↔ 오늘 기준 롤링(지난 7일·30일)** 세그먼트 전환. **주 시작 = 월요일** (`calendar.firstWeekday = 2`)
### 3.3 진동 (`Shared/WorkoutSettings.swift`, `IOS/Haptics.swift`, 워치 `WatchHaptics.swift`)
- 패턴 4종: 한 번 / 두 번 / 세 번 / 심장 박동 (`beatOffsets` 시퀀스). 기본: 수축=두 번, 이완=한 번. 세팅에서 국면별 선택 + "미리 느끼기"
- iOS는 `AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)` 연타 — 무음 스위치 무관, **무음 오디오 세션이 살아 있으면 백그라운드에서도 울린다**
- 워치는 `WKInterfaceDevice.play(.notification)` 연타(간격 1.5배). **워치에서 실행하면 진동은 워치에서만** (완주 시 .success 1회)
- 패턴 4종**횟수 세기가 아니라 첫 순간의 질감으로 구분**(1.5(사용자 피드백)에서 횟수 기반 4종 교체): 톡(짧게 1방) / 톡톡(0.5s 간격 2방) / 드르륵(0.5s 간격 3연타) / 지잉(0.3s 겹침 3방=끊기지 않는 긴 진동). 기본: **수축=지잉(조이고 유지), 이완=톡(탁 풀기)**. 세팅에서 국면별 선택 + "미리 느끼기". 구버전 저장값(once/twice/thrice/heartbeat)은 `HapticPattern(migrating:)`으로 이관
- iOS는 `AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)`(1회 ≈0.4초) 시퀀스 — 0.35초 이하 간격은 겹쳐 긴 진동이 되고 0.5초 간격은 빠른 연타가 된다. 무음 스위치 무관, **무음 오디오 세션이 살아 있으면 백그라운드에서도 울린다**
- 워치는 반복 재생이 뭉개져서 **패턴마다 성격이 다른 시스템 햅틱 1회**로 매핑: 톡=.start, 톡톡=.directionUp, 드르륵=.retry, 지잉=.notification. **워치에서 실행하면 진동은 워치에서만** (완주 시 .success 1회)
### 3.4 백그라운드·Live Activity (`IOS/BackgroundAudioKeeper.swift`, `LiveActivityManager.swift`, `KegingWidgets/`)
- 운동 시작 시 무음 WAV 무한 루프(`UIBackgroundModes=audio`, `.playback + .mixWithOthers`) → 앱을 나가도 타이머·진동·Live Activity 갱신 유지. 종료·중단 시 즉시 해제. 운동 중 `isIdleTimerDisabled`로 화면 꺼짐 방지(종료 시 복원)

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",

View File

@ -1,75 +1,87 @@
{
"sourceLanguage": "ko",
"strings": {
"CFBundleDisplayName": {
"extractionState": "manual",
"localizations": {
"ko": {
"stringUnit": {
"state": "translated",
"value": "케깅"
"sourceLanguage" : "ko",
"strings" : {
"CFBundleDisplayName" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keging"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Keging"
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keging"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Keging"
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "케깅"
}
}
}
},
"NSHealthUpdateUsageDescription": {
"extractionState": "manual",
"localizations": {
"ko": {
"stringUnit": {
"state": "translated",
"value": "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Used to save your finished Kegel workouts to Apple Health."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。"
"CFBundleName" : {
"comment" : "Bundle name",
"extractionState" : "extracted_with_value",
"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "new",
"value" : "Keging"
}
}
}
},
"NSHealthShareUsageDescription": {
"extractionState": "manual",
"localizations": {
"ko": {
"stringUnit": {
"state": "translated",
"value": "케깅은 건강 데이터를 읽지 않아요."
"NSHealthShareUsageDescription" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keging does not read your health data."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Keging does not read your health data."
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kegingはヘルスケアデータを読み取りません。"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Kegingはヘルスケアデータを読み取りません。"
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "케깅은 건강 데이터를 읽지 않아요."
}
}
}
},
"NSHealthUpdateUsageDescription" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Used to save your finished Kegel workouts to Apple Health."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요."
}
}
}
}
},
"version": "1.0"
"version" : "1.0"
}

View File

@ -335,18 +335,18 @@
}
}
},
"두 번": {
"드르륵": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Twice"
"value": "Triple burst"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "2回"
"value": "トトトン"
}
}
}
@ -495,22 +495,6 @@
}
}
},
"세 번": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Three Times"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "3回"
}
}
}
},
"세트": {
"localizations": {
"en": {
@ -635,18 +619,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. You can choose a pattern for each phase in the workout setup (Once, Twice, Three Times, Heartbeat) 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 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."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "締め始めとゆるめ始めで異なる振動が鳴るので、画面を見なくても今どちらなのか分かります。振動パターンはワークアウト設定でそれぞれ選べて1回・2回・3回・心拍)、「試す」ボタンで実際に確認できます。振動は実機でのみ感じられます。"
"value": "締め始めとゆるめ始めで異なる振動が鳴るので、画面を見なくても今どちらなのか分かります。デフォルトは締める=長いブーッ、ゆるめる=短いトンなので、最初の瞬間の感触ですぐ区別できます。パターンはワークアウト設定でそれぞれ選べて(トン・トントン・トトトン・ブーッ)、「試す」ボタンで実際に確認できます。振動は実機でのみ感じられます。"
}
}
}
@ -731,22 +715,6 @@
}
}
},
"심장 박동": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Heartbeat"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "心拍"
}
}
}
},
"안 함": {
"localizations": {
"en": {
@ -1467,6 +1435,22 @@
}
}
},
"지잉 (길게)": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Long buzz"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブーッ(長く)"
}
}
}
},
"진동 패턴": {
"localizations": {
"en": {
@ -1627,6 +1611,38 @@
}
}
},
"톡 (짧게)": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Tap (short)"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "トン(短く)"
}
}
}
},
"톡톡": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Double tap"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "トントン"
}
}
}
},
"통계": {
"localizations": {
"en": {
@ -1739,22 +1755,6 @@
}
}
},
"한 번": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Once"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "1回"
}
}
}
},
"현재 %lld번째, 전체 %lld회": {
"localizations": {
"en": {

View File

@ -1,75 +1,87 @@
{
"sourceLanguage": "ko",
"strings": {
"CFBundleDisplayName": {
"extractionState": "manual",
"localizations": {
"ko": {
"stringUnit": {
"state": "translated",
"value": "케깅"
"sourceLanguage" : "ko",
"strings" : {
"CFBundleDisplayName" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keging"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Keging"
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keging"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Keging"
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "케깅"
}
}
}
},
"NSHealthUpdateUsageDescription": {
"extractionState": "manual",
"localizations": {
"ko": {
"stringUnit": {
"state": "translated",
"value": "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Used to save your finished Kegel workouts to Apple Health."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。"
"CFBundleName" : {
"comment" : "Bundle name",
"extractionState" : "extracted_with_value",
"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "new",
"value" : "KegingWatch Watch App"
}
}
}
},
"NSHealthShareUsageDescription": {
"extractionState": "manual",
"localizations": {
"ko": {
"stringUnit": {
"state": "translated",
"value": "케깅은 건강 데이터를 읽지 않아요."
"NSHealthShareUsageDescription" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keging does not read your health data."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Keging does not read your health data."
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kegingはヘルスケアデータを読み取りません。"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Kegingはヘルスケアデータを読み取りません。"
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "케깅은 건강 데이터를 읽지 않아요."
}
}
}
},
"NSHealthUpdateUsageDescription" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Used to save your finished Kegel workouts to Apple Health."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "完了したケーゲル運動をヘルスケアにワークアウトとして記録するために使用します。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "케겔 운동을 마치면 그 시간을 애플 건강에 운동으로 기록하기 위해 필요해요."
}
}
}
}
},
"version": "1.0"
"version" : "1.0"
}

View File

@ -67,34 +67,18 @@
}
}
},
"두 번": {
"드르륵": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Twice"
"value": "Triple burst"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "2回"
}
}
}
},
"세 번": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Three Times"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "3回"
"value": "トトトン"
}
}
}
@ -153,22 +137,6 @@
}
}
},
"심장 박동": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Heartbeat"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "心拍"
}
}
}
},
"완료!": {
"localizations": {
"en": {
@ -217,6 +185,22 @@
}
}
},
"지잉 (길게)": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Long buzz"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブーッ(長く)"
}
}
}
},
"케겔 운동 시작": {
"localizations": {
"en": {
@ -249,18 +233,34 @@
}
}
},
"한 번": {
"톡 (짧게)": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Once"
"value": "Tap (short)"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "1回"
"value": "トン(短く)"
}
}
}
},
"톡톡": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Double tap"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "トントン"
}
}
}

View File

@ -2,7 +2,8 @@
// WatchHaptics.swift
// KegingWatch Watch App
//
// () 1.5
// () 1
// ( )
//
import WatchKit
@ -10,14 +11,19 @@ import WatchKit
@MainActor
enum WatchHaptics {
static func play(_ pattern: HapticPattern) {
for offset in pattern.beatOffsets {
DispatchQueue.main.asyncAfter(deadline: .now() + offset * 1.5) {
WKInterfaceDevice.current().play(.notification)
}
}
WKInterfaceDevice.current().play(hapticType(for: pattern))
}
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

@ -61,34 +61,18 @@
}
}
},
"두 번": {
"드르륵": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Twice"
"value": "Triple burst"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "2回"
}
}
}
},
"세 번": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Three Times"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "3回"
"value": "トトトン"
}
}
}
@ -131,22 +115,6 @@
}
}
},
"심장 박동": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Heartbeat"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "心拍"
}
}
}
},
"이완": {
"localizations": {
"en": {
@ -163,6 +131,22 @@
}
}
},
"지잉 (길게)": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Long buzz"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブーッ(長く)"
}
}
}
},
"코어 트레이닝": {
"localizations": {
"en": {
@ -179,18 +163,34 @@
}
}
},
"한 번": {
"톡 (짧게)": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Once"
"value": "Tap (short)"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "1回"
"value": "トン(短く)"
}
}
}
},
"톡톡": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Double tap"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "トントン"
}
}
}

View File

@ -8,28 +8,45 @@
import Foundation
import Combine
/// / . ·
/// / .
/// ( / / / )
nonisolated enum HapticPattern: String, CaseIterable, Identifiable, Codable {
case once, twice, thrice, heartbeat
case short, doubleTap, rattle, long
var id: String { rawValue }
var label: String {
switch self {
case .once: String(localized: "한 번")
case .twice: String(localized: "두 번")
case .thrice: String(localized: "세 번")
case .heartbeat: String(localized: "심장 박동")
case .short: String(localized: "톡 (짧게)")
case .doubleTap: String(localized: "톡톡")
case .rattle: String(localized: "드르륵")
case .long: String(localized: "지잉 (길게)")
}
}
/// () 0
/// () 1 0.4
/// 0.35 , 0.5
var beatOffsets: [TimeInterval] {
switch self {
case .once: [0]
case .twice: [0, 0.45]
case .thrice: [0, 0.45, 0.9]
case .heartbeat: [0, 0.3, 1.0, 1.3]
case .short: [0]
case .doubleTap: [0, 0.5]
case .rattle: [0, 0.5, 1.0]
case .long: [0, 0.3, 0.6]
}
}
/// 1.0 ( )
init?(migrating raw: String?) {
guard let raw else { return nil }
if let direct = HapticPattern(rawValue: raw) {
self = direct
return
}
switch raw {
case "once": self = .short
case "twice": self = .doubleTap
case "thrice", "heartbeat": self = .rattle
default: return nil
}
}
}
@ -53,8 +70,9 @@ nonisolated struct WorkoutConfig: Codable, Equatable {
var contractSeconds: Int = 10
var relaxSeconds: Int = 5
var reps: Int = 20
var contractPattern: HapticPattern = .twice
var relaxPattern: HapticPattern = .once
/// : = ( ), = ( )
var contractPattern: HapticPattern = .long
var relaxPattern: HapticPattern = .short
/// (0 = , 1~10)
var effortScore: Int = 2
///
@ -64,8 +82,8 @@ nonisolated struct WorkoutConfig: Codable, Equatable {
contractSeconds: Int = 10,
relaxSeconds: Int = 5,
reps: Int = 20,
contractPattern: HapticPattern = .twice,
relaxPattern: HapticPattern = .once,
contractPattern: HapticPattern = .long,
relaxPattern: HapticPattern = .short,
effortScore: Int = 2,
healthWorkoutType: HealthWorkoutType = .other
) {
@ -84,8 +102,8 @@ nonisolated struct WorkoutConfig: Codable, Equatable {
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
contractPattern = try container.decodeIfPresent(HapticPattern.self, forKey: .contractPattern) ?? .twice
relaxPattern = try container.decodeIfPresent(HapticPattern.self, forKey: .relaxPattern) ?? .once
contractPattern = HapticPattern(migrating: try container.decodeIfPresent(String.self, forKey: .contractPattern)) ?? .long
relaxPattern = HapticPattern(migrating: try container.decodeIfPresent(String.self, forKey: .relaxPattern)) ?? .short
effortScore = try container.decodeIfPresent(Int.self, forKey: .effortScore) ?? 2
healthWorkoutType = try container.decodeIfPresent(HealthWorkoutType.self, forKey: .healthWorkoutType) ?? .other
}