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:
parent
4d91c48844
commit
42850683c0
@ -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회로 (완주 경로 검증) |
|
||||
|
||||
@ -421,6 +421,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"크라운을 돌려 조절" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Turn the crown to adjust"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "クラウンを回して調整"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"톡톡" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
|
||||
@ -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)])
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user