diff --git a/myApp/Keging/CLAUDE.md b/myApp/Keging/CLAUDE.md index 0f2c79f..c5b02c9 100644 --- a/myApp/Keging/CLAUDE.md +++ b/myApp/Keging/CLAUDE.md @@ -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회로 (완주 경로 검증) | diff --git a/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings b/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings index 1d8fe95..8db5dd4 100644 --- a/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings +++ b/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings @@ -421,6 +421,22 @@ } } }, + "크라운을 돌려 조절" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Turn the crown to adjust" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "クラウンを回して調整" + } + } + } + }, "톡톡" : { "localizations" : { "en" : { diff --git a/myApp/Keging/KegingWatch Watch App/WatchHaptics.swift b/myApp/Keging/KegingWatch Watch App/WatchHaptics.swift index 4b175d4..fafeaa2 100644 --- a/myApp/Keging/KegingWatch Watch App/WatchHaptics.swift +++ b/myApp/Keging/KegingWatch Watch App/WatchHaptics.swift @@ -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)]) diff --git a/myApp/Keging/KegingWatch Watch App/WatchSettingsView.swift b/myApp/Keging/KegingWatch Watch App/WatchSettingsView.swift index 0fb7c90..9ebdc86 100644 --- a/myApp/Keging/KegingWatch Watch App/WatchSettingsView.swift +++ b/myApp/Keging/KegingWatch Watch App/WatchSettingsView.swift @@ -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( + _ 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 + @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 } } }