fix(keging): 워치 기본·톡톡 진동 알림급 강화 + 세팅 크라운 사용성 재설계 (8차)

- 기본/톡톡의 .start가 실기기에서 너무 약함 → 가장 강한 알림급 .notification으로
  교체 (기본 1방, 톡톡 2방 0.5초 간격 — 알림 진동이 길어 간격 넉넉히)
- 세팅 화면: 스테퍼가 크라운을 뺏어 스크롤과 값 변경이 충돌 → 목록에서 크라운은
  스크롤 전용으로, 준비/수축/이완/횟수는 행을 눌러 전용 화면(CrownValueEditor —
  큰 민트 값 + digitalCrownRotation)에서 크라운으로 조절. 진동·건강은 눌러서 선택
- DEBUG -openEditor(워치, 화면 QA용) 추가, '크라운을 돌려 조절' en/ja 번역
- 시뮬 검증: 세팅 목록·크라운 편집 화면(46mm), 워치 빌드 그린·경고 0·l10n 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-09-03 03:27:39 +09:00
parent 4d91c48844
commit 42850683c0
4 changed files with 125 additions and 31 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-09-03) | **7차 반영 완료(플랫폼 결정: 현행 iOS+워치 유지), 재테스트 대기.** 7차: ①폰 첫 화면 시작 버튼 위 **워치 연결 배지**(연결됨=그린 전파 아이콘/미연결·미연동=슬래시. ⚠iOS 제약상 '연결됨'은 워치에서 케깅이 켜져 있을 때만 — §4) + **미연결 시작 확인 창**(기록은 동일함을 안내, 설정 > 애플워치 토글로 끔, 단축어·autoStart는 경고 없이 통과) ②**세팅 기기별 독립** — config 동기화 전면 제거(§3.6), 워치는 시작 화면 **왼쪽 스와이프 세팅 페이지**(TabView .page: 준비/수축/이완/횟수 스테퍼 + 진동 2종 피커(선택 즉시 미리 울림) + 운동 유형·강도, 요약 라인 즉시 반영) ③**워치 진동 강화 재설계**(§3.3: .start/.stop급 강한 시퀀스). 6차: DI 컴팩트=두 색 점+남은 시간, 확장=잠금화면과 동일 실시간 현황, 통계 하루 탭 꺾은선 제거. 5차: LA 완전 자가 렌더링(무갱신 — 실기기 정상 확인). "컴팩트에 국면 글자"는 iOS 제약으로 불가 확정(서버 푸시만 가능 — 기각). **재테스트(사용자)**: 워치 세팅 페이지·강한 진동 3종+완료, 폰 배지·경고 창, 워치 기록이 폰 통계 합류 | 5차 실기기 판독: 진동은 끝까지 제때 = 앱은 백그라운드 생존, 그러나 **국면 라벨·횟수는 항상 수축 2회차(4번째 전송)에서 동결 + 진행바만 계속 흐름** → 백그라운드 LA 갱신을 시스템이 예산제로 폐기하는 것으로 확정, staleDate 재렌더도 시뮬 실측 결과 발화 안 함 → **갱신 자체를 없앤 설계로 전환**(§3.4): 시작 시 1회 등록(`ensureStarted`), 이후 update 호출 0회, 화면은 전부 시스템 렌더(타임라인 바+플레이헤드+남은 시간)와 영원히 참인 정적 정보(수축·이완 범례, 세트 구성)만. 국면 라벨·횟수 텍스트는 LA에서 제거(국면=진동+플레이헤드 색, 횟수=앱 열면). 시뮬 검증: 등록 후 갱신 없이 35초간 카운트다운 4:57→4:27 정확·미니 바 전진·완주 시 DI 해제, 3종 빌드 그린·경고 0·l10n 0/0/0. 이전 라운드: 3차(햅틱 엔진 세션 부착·키퍼 하드닝·하트비트·워치 세팅 pull·완료 진동·오늘의 타임라인·칼로리 정합), 4차(디더 노이즈·브리지 태스크·타임라인 시각 라벨 제거). **재테스트 항목(사용자, 아이폰)**: ①DI 컴팩트(미니 타임라인+남은 시간)·확장·잠금화면 배너(범례+큰 남은 시간+바+세트 구성)가 세트 끝까지 흐르는지 — 이제 갱신이 없어 구조적으로 동결 불가 ②완료 진동·백그라운드 진동(이미 정상 확인됨)·워치 세팅 동기화·칼로리 |
| 현재 상태 (2026-09-03) | **8차 반영 완료, 재테스트 대기.** 8차(워치 수정 2건): ①기본·톡톡 진동이 너무 약함 → **.notification(알림급)으로 교체**(§3.3) ②세팅 크라운 충돌 → **목록은 스크롤 전용, 숫자 항목은 눌러서 크라운 편집 화면**(CrownValueEditor, §3.6). 7차(플랫폼 결정: 현행 iOS+워치 유지): ①폰 첫 화면 시작 버튼 위 **워치 연결 배지**(연결됨=그린 전파 아이콘/미연결·미연동=슬래시. ⚠iOS 제약상 '연결됨'은 워치에서 케깅이 켜져 있을 때만 — §4) + **미연결 시작 확인 창**(기록은 동일함을 안내, 설정 > 애플워치 토글로 끔, 단축어·autoStart는 경고 없이 통과) ②**세팅 기기별 독립** — config 동기화 전면 제거(§3.6), 워치는 시작 화면 **왼쪽 스와이프 세팅 페이지**(TabView .page: 준비/수축/이완/횟수 스테퍼 + 진동 2종 피커(선택 즉시 미리 울림) + 운동 유형·강도, 요약 라인 즉시 반영) ③**워치 진동 강화 재설계**(§3.3: .start/.stop급 강한 시퀀스). 6차: DI 컴팩트=두 색 점+남은 시간, 확장=잠금화면과 동일 실시간 현황, 통계 하루 탭 꺾은선 제거. 5차: LA 완전 자가 렌더링(무갱신 — 실기기 정상 확인). "컴팩트에 국면 글자"는 iOS 제약으로 불가 확정(서버 푸시만 가능 — 기각). **재테스트(사용자)**: 워치 세팅 페이지·강한 진동 3종+완료, 폰 배지·경고 창, 워치 기록이 폰 통계 합류 | 5차 실기기 판독: 진동은 끝까지 제때 = 앱은 백그라운드 생존, 그러나 **국면 라벨·횟수는 항상 수축 2회차(4번째 전송)에서 동결 + 진행바만 계속 흐름** → 백그라운드 LA 갱신을 시스템이 예산제로 폐기하는 것으로 확정, staleDate 재렌더도 시뮬 실측 결과 발화 안 함 → **갱신 자체를 없앤 설계로 전환**(§3.4): 시작 시 1회 등록(`ensureStarted`), 이후 update 호출 0회, 화면은 전부 시스템 렌더(타임라인 바+플레이헤드+남은 시간)와 영원히 참인 정적 정보(수축·이완 범례, 세트 구성)만. 국면 라벨·횟수 텍스트는 LA에서 제거(국면=진동+플레이헤드 색, 횟수=앱 열면). 시뮬 검증: 등록 후 갱신 없이 35초간 카운트다운 4:57→4:27 정확·미니 바 전진·완주 시 DI 해제, 3종 빌드 그린·경고 0·l10n 0/0/0. 이전 라운드: 3차(햅틱 엔진 세션 부착·키퍼 하드닝·하트비트·워치 세팅 pull·완료 진동·오늘의 타임라인·칼로리 정합), 4차(디더 노이즈·브리지 태스크·타임라인 시각 라벨 제거). **재테스트 항목(사용자, 아이폰)**: ①DI 컴팩트(미니 타임라인+남은 시간)·확장·잠금화면 배너(범례+큰 남은 시간+바+세트 구성)가 세트 끝까지 흐르는지 — 이제 갱신이 없어 구조적으로 동결 불가 ②완료 진동·백그라운드 진동(이미 정상 확인됨)·워치 세팅 동기화·칼로리 |
## 2. 빌드·검증 환경
@ -127,7 +127,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
- **패턴 3종**(2차 실기기 피드백 '체이닝도 간격 길다'로 재설계): 기본(짧은 1방) / 톡톡(빠른 2방) / 지이이잉(아주 긴 연속 진동). 기본값: **수축=지이이잉, 이완=기본**. 세팅에서 국면별 선택 + "미리 느끼기". 구버전 저장값(1.0 횟수 기반·4종 시절)은 `HapticPattern(migrating:)`으로 이관. **완주 진동(고유·비설정)**: 아주 긴 "우우우웅" — `FinishHaptic.events`(2.4s 연속), 백그라운드는 6방 체인, 워치는 .retry×2+.success 시퀀스
- **iOS 포그라운드 = Core Haptics 정밀 재생**(`Shared/HapticEnginePlayer``hapticEvents` 스펙: 기본 0.25s, 톡톡 0.14s×2 시작 간격 0.28s, 지이이잉 1.2s 연속, 세기 1.0). ⚠️**엔진은 반드시 `CHHapticEngine(audioSession: .sharedInstance())`로 공유 세션에 부착** — 기본 생성 시 독자 오디오 정책이 백그라운드 무음 루프를 끊음(실기기 3차 사고 원인, 애플 공식 패턴). isAutoShutdownEnabled + resetHandler, 실패 시 엔진 1회 재생성, 재생 전 세션 카테고리/활성 보장
- **iOS 백그라운드 폴백 = 시스템 바이브 완료 체이닝**(기본 1방·톡톡 2방·지이이잉 3방·완료 6방 즉시 잇기) — ⚠️재생 중 같은 시스템 사운드 재호출은 무시(실측)라 완료 콜백이 물리적 최소 간격. 세대 토큰으로 새 패턴 시작 시 이전 체인 취소. 무음 스위치 무관, 오디오 세션 살아 있으면 백그라운드에서도 울림. **iOS는 백그라운드 커스텀 햅틱(Core Haptics)을 금지하므로 포그라운드와 질감 차이는 구조적 한계**(도움말에 명시) — 완주 직후엔 체인이 끝나도록 오디오 정지를 4초 지연(WorkoutCoordinator.stopAudioSoon)
- ⚠️**watchOS SDK에는 CoreHaptics가 없다**(프레임워크 부재 실측) — 워치는 강한 시스템 햅틱을 시퀀스로 엮어 셈을 만든다(7차 재설계 — 이전 매핑이 약하고 폰과 셈이 다르던 피드백): **기본=.start 1방(묵직한 노크), 톡톡=.start 2방(0.35s), 지이이잉=.retry 2방 연결(~1.3s), 완주=.retry×3+.success(~2.5s)**. 세대 토큰으로 이전 시퀀스 취소. 세기는 시스템 고정(더 강하게는 워치 설정 > 사운드 및 햅틱). **워치에서 실행하면 진동은 워치에서만.** 패턴 선택은 워치 세팅 페이지에서 — 폰과 독립(§3.6), 고르면 즉시 미리 울림
- ⚠️**watchOS SDK에는 CoreHaptics가 없다**(프레임워크 부재 실측) — 워치는 강한 시스템 햅틱을 시퀀스로 엮어 셈을 만든다(8차: .start가 실기기에서 너무 약함 → 알림급으로 교체): **기본=.notification 1방, 톡톡=.notification 2방(0.5s — 알림 진동이 길어 넉넉히), 지이이잉=.retry 2방 연결(~1.3s), 완주=.retry×3+.success(~2.5s)**. 세대 토큰으로 이전 시퀀스 취소. 세기는 시스템 고정(더 강하게는 워치 설정 > 사운드 및 햅틱). **워치에서 실행하면 진동은 워치에서만.** 패턴 선택은 워치 세팅 페이지에서 — 폰과 독립(§3.6), 고르면 즉시 미리 울림
### 3.4 백그라운드·Live Activity (`IOS/BackgroundAudioKeeper.swift`, `LiveActivityManager.swift`, `KegingWidgets/`)
- 운동 시작 시 초저레벨 노이즈 WAV(20초 버퍼, **±2 LSB 디더 — 완전한 0 샘플은 '실질 무음'으로 감지돼 정지될 여지가 있어 회피**, volume 0.1이라 들리지 않음) 무한 루프(`UIBackgroundModes=audio`, `.playback + .mixWithOthers`) → 앱을 나가도 타이머·진동·Live Activity 갱신 유지. **백그라운드 전환 시 ~30초 브리지 태스크**(`beginBackgroundTask`)로 전환 순간 오디오가 죽어도 되살릴 시간을 확보. **ensureAlive() 단일 진입점**: 인터럽션 종료·**루트 변경(이어폰 해제 등)**·**미디어 서버 리셋(플레이어 무효 → 재구축)**·백그라운드/활성 전환 옵저버 + **운동 중 2초 하트비트**(WorkoutCoordinator — 오디오 생존 점검·`resyncAfterWake()`·`LiveActivityManager.ensureStarted()` 등록 재시도)가 모두 호출. 놓친 국면 경계도 2초 안에 자가 치유. 종료·중단 시 해제(완주는 완료 진동 체인 보호를 위해 4초 지연). 운동 중 `isIdleTimerDisabled`로 화면 꺼짐 방지(종료 시 복원)
@ -149,7 +149,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
### 3.6 워치 (`KegingWatch Watch App/`)
- 실행하면 시작 버튼만(+세팅 요약 한 줄). 진행 중: 국면(수축=민트/이완=앰버)·카운트다운·n/총·중단 — **40mm에서도 스크롤 없이 한 화면** (46·40mm 스크린샷 검증)
- **세팅은 기기별 독립(7차, 2026-09-03)** — 기기마다 진동 질감이 달라 각자 최적화한다는 사용자 결정으로 config 동기화(push/pull) 전면 제거. 워치 세팅은 시작 화면에서 **왼쪽 스와이프**(TabView `.page`, DEBUG `-openSettings`로 바로 열기): 준비/수축/이완/횟수 스테퍼(−·+ 사이 2줄 컴팩트 라벨 — 1줄 긴 라벨은 거대 렌더 사고), 진동 2종 피커(onChange로 즉시 미리 울림), 운동 유형·강도. 시작 화면 요약 라인은 SettingsStore를 EnvironmentObject로 받아 즉시 반영. **동기화는 기록만**: 완주 기록 `transferUserInfo`로 폰에 합류(오프라인 자동 큐), 폰 `PhoneSync`는 기록 수신 + `WatchLinkStatus` 갱신만 담당
- **세팅은 기기별 독립(7차, 2026-09-03)** — 기기마다 진동 질감이 달라 각자 최적화한다는 사용자 결정으로 config 동기화(push/pull) 전면 제거. 워치 세팅은 시작 화면에서 **왼쪽 스와이프**(TabView `.page`, DEBUG `-openSettings`로 바로 열기). **크라운 사용성(8차)**: 목록에서 크라운은 스크롤 전용 — 스테퍼가 크라운을 뺏어 스크롤과 값 변경이 충돌하던 피드백으로, 숫자 4종(준비/수축/이완/횟수)은 **행을 눌러 CrownValueEditor**(큰 민트 값 + `.digitalCrownRotation`, `-openEditor`로 QA 진입)에서 크라운으로 조절. 진동 2종 피커(onChange로 즉시 미리 울림)·운동 유형·강도는 눌러서 목록 선택. 시작 화면 요약 라인은 SettingsStore를 EnvironmentObject로 받아 즉시 반영. **동기화는 기록만**: 완주 기록 `transferUserInfo`로 폰에 합류(오프라인 자동 큐), 폰 `PhoneSync`는 기록 수신 + `WatchLinkStatus` 갱신만 담당
- 운동 시작 시 **HKWorkoutSession**(건강 기록+손목 내려도 유지 — §3.7)을 열고, 실패(권한 거부 등) 시 `WKExtendedRuntimeSession`(physical-therapy) 폴백. 둘 다 실패해도 화면 켜져 있는 동안은 정상 동작
## 4. 화면 구성 (탭 없음 — 애플 기본 앱 느낌)
@ -185,7 +185,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
| `-openStats` | 시작하자마자 통계 화면 |
| `-statsScope day\|week\|month` | 통계 초기 탭 지정 |
| `-statsRolling` | 주간·월간을 롤링 모드로 |
| `-openHelp` / `-openSettings` / `-openTimerSettings` | 해당 시트를 열고 시작 (화면 QA용). `-openSettings`는 워치에서는 세팅 페이지(2쪽)로 시작 |
| `-openHelp` / `-openSettings` / `-openTimerSettings` | 해당 시트를 열고 시작 (화면 QA용). `-openSettings`는 워치에서는 세팅 페이지(2쪽)로 시작, 워치 한정 `-openEditor` 동반 시 준비 크라운 편집 화면까지 진입(시뮬은 행 탭 주입 불가) |
| `-autoStart` | 시작하자마자 운동 시작 (아이폰·워치 공통) |
| `-confirmStop` | 운동 화면의 인라인 중단 확인을 펼친 채 시작 (화면 QA용) |
| `-quickConfig` | 세팅을 수축1초·이완1초·2회로 (완주 경로 검증) |

View File

@ -421,6 +421,22 @@
}
}
},
"크라운을 돌려 조절" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Turn the crown to adjust"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "クラウンを回して調整"
}
}
}
},
"톡톡" : {
"localizations" : {
"en" : {

View File

@ -3,9 +3,9 @@
// KegingWatch Watch App
//
// () watchOS SDK CoreHaptics () .
// 퀀 ' / // ' (7
// ):
// = .start 1( ), = .start 2(0.35 ),
// 퀀 ' / // ' .
// 8: .start .notification .
// = .notification 1, = .notification 2(0.5 ),
// = .retry 2 (~1.3 ), = .retry×3 + .success(~2.5).
// 퀀 .
// > > ' '.
@ -21,10 +21,10 @@ enum WatchHaptics {
generation += 1
switch pattern {
case .basic:
WKInterfaceDevice.current().play(.start)
WKInterfaceDevice.current().play(.notification)
case .doubleTap:
WKInterfaceDevice.current().play(.start)
playLater([(0.35, .start)])
WKInterfaceDevice.current().play(.notification)
playLater([(0.5, .notification)])
case .long:
WKInterfaceDevice.current().play(.retry)
playLater([(0.65, .retry)])

View File

@ -2,31 +2,47 @@
// WatchSettingsView.swift
// KegingWatch Watch App
//
// (7).
// ( ), .
// .
// .
// ( ), .
//
// (8 ):
// ,
// (CrownValueEditor). · .
// .
//
import SwiftUI
struct WatchSettingsView: View {
@EnvironmentObject private var settings: SettingsStore
#if DEBUG
/// QA `-openEditor` ( )
@State private var qaEditorShown = false
#endif
var body: some View {
NavigationStack {
Form {
List {
Section {
Stepper(value: $settings.config.prepSeconds, in: 0...10) {
stepperLabel(Text("준비"), value: Text("\(settings.config.prepSeconds)"))
valueRow(Text("준비"), value: Text("\(settings.config.prepSeconds)")) {
CrownValueEditor(title: Text("준비"), range: 0...10, value: $settings.config.prepSeconds) {
Text("\($0)")
}
}
Stepper(value: $settings.config.contractSeconds, in: 1...60) {
stepperLabel(Text("수축"), value: Text("\(settings.config.contractSeconds)"))
valueRow(Text("수축"), value: Text("\(settings.config.contractSeconds)")) {
CrownValueEditor(title: Text("수축"), range: 1...60, value: $settings.config.contractSeconds) {
Text("\($0)")
}
}
Stepper(value: $settings.config.relaxSeconds, in: 1...60) {
stepperLabel(Text("이완"), value: Text("\(settings.config.relaxSeconds)"))
valueRow(Text("이완"), value: Text("\(settings.config.relaxSeconds)")) {
CrownValueEditor(title: Text("이완"), range: 1...60, value: $settings.config.relaxSeconds) {
Text("\($0)")
}
}
Stepper(value: $settings.config.reps, in: 1...100) {
stepperLabel(Text("횟수"), value: Text("\(settings.config.reps)"))
valueRow(Text("횟수"), value: Text("\(settings.config.reps)")) {
CrownValueEditor(title: Text("횟수"), range: 1...100, value: $settings.config.reps) {
Text("\($0)")
}
}
} header: {
Text("세트")
@ -72,18 +88,80 @@ struct WatchSettingsView: View {
.onChange(of: settings.config.relaxPattern) { _, newValue in
WatchHaptics.play(newValue)
}
#if DEBUG
.navigationDestination(isPresented: $qaEditorShown) {
CrownValueEditor(title: Text("준비"), range: 0...10, value: $settings.config.prepSeconds) {
Text("\($0)")
}
}
.onAppear {
if CommandLine.arguments.contains("-openEditor") { qaEditorShown = true }
}
#endif
}
}
/// ·+ 2 ( + )
private func stepperLabel(_ title: Text, value: Text) -> some View {
VStack(spacing: 0) {
title
.font(.caption2)
.foregroundStyle(.secondary)
value
.font(.body.weight(.semibold))
.monospacedDigit()
/// +
private func valueRow<Editor: View>(
_ title: Text, value: Text, @ViewBuilder editor: @escaping () -> Editor
) -> some View {
NavigationLink {
editor()
} label: {
HStack {
title
Spacer()
value
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
}
}
///
struct CrownValueEditor: View {
let title: Text
let range: ClosedRange<Int>
@Binding var value: Int
let format: (Int) -> Text
@State private var crownValue = 0.0
@FocusState private var focused: Bool
var body: some View {
VStack(spacing: 10) {
title
.font(.headline)
.foregroundStyle(.secondary)
format(value)
.font(.system(size: 40, weight: .bold))
.monospacedDigit()
.foregroundStyle(AppTheme.green)
.contentTransition(.numericText())
.animation(.snappy, value: value)
Text("크라운을 돌려 조절")
.font(.caption2)
.foregroundStyle(.secondary)
}
.focusable(true)
.focused($focused)
.digitalCrownRotation(
$crownValue,
from: Double(range.lowerBound),
through: Double(range.upperBound),
by: 1,
sensitivity: .medium,
isContinuous: false,
isHapticFeedbackEnabled: true
)
.onChange(of: crownValue) { _, newValue in
let rounded = min(max(Int(newValue.rounded()), range.lowerBound), range.upperBound)
if rounded != value { value = rounded }
}
.onAppear {
crownValue = Double(value)
focused = true
}
}
}