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:
parent
f91968f8ce
commit
0d058c7734
@ -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`로 화면 꺼짐 방지(종료 시 복원)
|
||||
|
||||
@ -27,7 +27,7 @@ struct HelpView: View {
|
||||
helpRow(
|
||||
symbol: "iphone.radiowaves.left.and.right",
|
||||
title: Text("진동으로 구분하기"),
|
||||
body: Text("수축이 시작될 때와 이완이 시작될 때 서로 다른 진동이 울려요. 그래서 화면을 보지 않아도 지금 조여야 할지 풀어야 할지 알 수 있어요. 진동 패턴은 운동 세팅에서 국면별로 고를 수 있고(한 번·두 번·세 번·심장 박동), '미리 느끼기' 버튼으로 실제 진동을 확인할 수 있어요. 진동은 실기기에서만 느껴져요.")
|
||||
body: Text("수축이 시작될 때와 이완이 시작될 때 서로 다른 진동이 울려요. 그래서 화면을 보지 않아도 지금 조여야 할지 풀어야 할지 알 수 있어요. 기본은 수축=지잉(길게), 이완=톡(짧게)이라 첫 순간의 질감으로 바로 구분돼요. 패턴은 운동 세팅에서 국면별로 고를 수 있고(톡·톡톡·드르륵·지잉), '미리 느끼기' 버튼으로 실제 진동을 확인할 수 있어요. 진동은 실기기에서만 느껴져요.")
|
||||
)
|
||||
helpRow(
|
||||
symbol: "stop.circle",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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": "トントン"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 // 길고 묵직한 알림
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": "トントン"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user