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:
parent
3cee5f08af
commit
eba01b570f
@ -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에서 권한 시트가 캡처를 가리는 것 방지 |
|
||||
|
||||
@ -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,9 +105,25 @@ struct ContentView: View {
|
||||
Spacer()
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
cornerButton(symbol: "questionmark", label: Text("도움말")) { showHelp = true }
|
||||
// 하단: 가운데 = 통계 손잡이(위로 스와이프 힌트), 오른쪽 = 도움말
|
||||
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> {
|
||||
|
||||
@ -2,43 +2,44 @@
|
||||
// Haptics.swift
|
||||
// Keging
|
||||
//
|
||||
// 진동 패턴 재생 (iOS) — 시스템 바이브(1회 ≈0.5초)를 완료 콜백으로 체이닝한다.
|
||||
// ⚠️ 재생 중에 같은 시스템 사운드를 다시 호출하면 무시되므로(실기기 실측 — 겹침·짧은
|
||||
// 간격 오프셋이 전부 먹혀 패턴이 똑같아지던 버그) 반드시 완료 후 다음 진동을 쏜다.
|
||||
// AudioServices 바이브는 무음 스위치 무관, 오디오 세션이 살아 있으면 백그라운드에서도 울린다.
|
||||
// 진동 패턴 재생 (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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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,22 +89,28 @@ struct StatsView: View {
|
||||
// MARK: 헤더·피커
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Text("통계")
|
||||
.font(.largeTitle.bold())
|
||||
Spacer()
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(AppTheme.surface, in: Circle())
|
||||
VStack(spacing: 6) {
|
||||
// 아래로 쓸어내려 닫기 손잡이
|
||||
Capsule()
|
||||
.fill(.secondary.opacity(0.5))
|
||||
.frame(width: 36, height: 5)
|
||||
.padding(.top, 8)
|
||||
HStack {
|
||||
Text("통계")
|
||||
.font(.largeTitle.bold())
|
||||
Spacer()
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(AppTheme.surface, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("통계 닫기"))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("통계 닫기"))
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
|
||||
private var rangeModePicker: some View {
|
||||
|
||||
@ -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,23 +79,50 @@ struct WorkoutView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
confirmStop = true
|
||||
} label: {
|
||||
Text("중단")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: 160)
|
||||
// 중단 확인 — 버튼 자리에서 바로 펼쳐지는 인라인 방식 (하단 시트가 뚝 떨어져 보이던 문제 해소)
|
||||
Group {
|
||||
if confirmStop {
|
||||
VStack(spacing: 12) {
|
||||
Text("중단하면 이 세트는 기록되지 않아요.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
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)
|
||||
.frame(maxWidth: 160)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
.transition(.scale(scale: 0.9).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.confirmationDialog("운동을 중단할까요?", isPresented: $confirmStop, titleVisibility: .visible) {
|
||||
Button("중단하기", role: .destructive) { engine.stop() }
|
||||
Button("계속하기", role: .cancel) {}
|
||||
} message: {
|
||||
Text("중단하면 이 세트는 기록되지 않아요.")
|
||||
}
|
||||
}
|
||||
|
||||
/// 준비=차분한 회그린, 수축=그린, 이완=앰버
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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 // 길고 묵직한 알림
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
61
myApp/Keging/Shared/HapticEnginePlayer.swift
Normal file
61
myApp/Keging/Shared/HapticEnginePlayer.swift
Normal 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
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user