diff --git a/myApp/Keging/CLAUDE.md b/myApp/Keging/CLAUDE.md index 0ed7d4d..0f2c79f 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-02) | **실기기 6차 피드백 반영 완료, 재테스트 대기.** 6차: 5차 자가 렌더링이 실기기에서 정상 확인됨(잠금화면 OK·컴팩트 카운트다운 OK). 남은 요청 반영 — ①DI 컴팩트 왼쪽을 미니 타임라인(판독 불가) → 국면 두 색 점으로 ②확장(길게 누름)을 잠금화면 실시간 현황과 동일 구성으로(`liveStatusView` 공용) ③통계 하루 탭 시간대별 꺾은선 제거(타임라인과 중복). "컴팩트에 지금 국면 글자" 요청은 iOS 제약으로 불가 확인(서버 푸시가 유일한 정공법 — 무료·무서버 원칙과 충돌로 기각. 미검증 카드로 '진행바 겹쌓기 색 점 해킹'과 'FrequentUpdates 키'가 남아 있으나 사용자가 실용 노선 선택). 이전 요약: 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) | **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 컴팩트(미니 타임라인+남은 시간)·확장·잠금화면 배너(범례+큰 남은 시간+바+세트 구성)가 세트 끝까지 흐르는지 — 이제 갱신이 없어 구조적으로 동결 불가 ②완료 진동·백그라운드 진동(이미 정상 확인됨)·워치 세팅 동기화·칼로리 | ## 2. 빌드·검증 환경 @@ -40,7 +40,8 @@ IOS/ # iOS 앱 타깃 (Keging) Haptics.swift # 진동 재생 (포그라운드 Core Haptics / 백그라운드 바이브 체이닝 + 완료 진동) BackgroundAudioKeeper.swift # 운동 중 무음 오디오 루프 (ensureAlive — 인터럽션·루트 변경·미디어 리셋 대응) LiveActivityManager.swift # Live Activity 시작/갱신/종료 - PhoneSync.swift # WCSession 폰 측 (세팅 push, 워치 기록 수신·병합) + PhoneSync.swift # WCSession 폰 측 (워치 기록 수신·병합 + 연결 상태 갱신 — 세팅 동기화 없음) + WatchLinkStatus.swift # 첫 화면 워치 연결 배지용 상태 (isReachable 기준 주의 — §4) HealthRecorder.swift # 완주 세트를 애플 건강에 사후 저장 (§3.7) KegingIntents.swift # 단축어 "케겔 운동 시작" (openAppWhenRun + 알림/플래그) DebugSeed.swift # DEBUG 런치 인자 (§7) @@ -58,8 +59,9 @@ Shared/ # 세 타깃 공용 (pbxproj fileSystemSynchronize KegingWidgets/ # Live Activity 전용 위젯 확장 (홈 위젯 없음) KegingWidgetsBundle.swift / KegingWidgetsLiveActivity.swift / Info.plist KegingWatch Watch App/ # 워치 앱 타깃 - KegingWatchApp.swift / ContentView.swift(WatchContentView) / WatchCoordinator.swift - WatchHaptics.swift / WatchSync.swift + KegingWatchApp.swift / ContentView.swift(WatchContentView — 좌스와이프 세팅 페이지 포함) / WatchCoordinator.swift + WatchSettingsView.swift # 워치 전용 세팅 (폰과 독립 — §3.6) + WatchHaptics.swift / WatchSync.swift(기록 전송만) WatchWorkoutSession.swift # HKWorkoutSession 라이브 기록(심박 포함)+백그라운드 유지 — 실패 시 폴백: RuntimeSessionManager.swift # 확장 런타임 세션 (physical-therapy) Info.plist # WKBackgroundModes=workout-processing·physical-therapy + 건강 문구 @@ -125,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가 없다**(프레임워크 부재 실측) — 워치는 강한 시스템 햅틱 3종 매핑(시스템 내장이라 간격 뭉개짐 없음): 기본=.notification, 톡톡=.directionUp, 지이이잉=.retry. 세기는 시스템 고정(더 강하게는 워치 설정 > 사운드 및 햅틱). **워치에서 실행하면 진동은 워치에서만** (완주 시 .retry 0.75s 간격 2방 + .success) +- ⚠️**watchOS SDK에는 CoreHaptics가 없다**(프레임워크 부재 실측) — 워치는 강한 시스템 햅틱을 시퀀스로 엮어 셈을 만든다(7차 재설계 — 이전 매핑이 약하고 폰과 셈이 다르던 피드백): **기본=.start 1방(묵직한 노크), 톡톡=.start 2방(0.35s), 지이이잉=.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`로 화면 꺼짐 방지(종료 시 복원) @@ -147,12 +149,12 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] ### 3.6 워치 (`KegingWatch Watch App/`) - 실행하면 시작 버튼만(+세팅 요약 한 줄). 진행 중: 국면(수축=민트/이완=앰버)·카운트다운·n/총·중단 — **40mm에서도 스크롤 없이 한 화면** (46·40mm 스크린샷 검증) -- 세팅은 폰에서만 편집 — 전달은 **3중 경로**(3차 피드백: 폰 앱이 꺼진 상태에서 push가 유실돼 워치가 옛 세팅으로 돌던 사고): ①변경 시 `updateApplicationContext` push ②폰 `sessionReachabilityDidChange`에서 재푸시 ③**워치 앱이 열릴 때마다(onAppear·scenePhase .active) `sendMessage`로 폰에서 pull** — 폰 앱이 꺼져 있어도 시스템이 깨워 응답(`PhoneSync.didReceiveMessage`). 동기화 버튼 불필요. 워치 활성화 때 `receivedApplicationContext`도 적용. 완주 기록은 `transferUserInfo`로 폰에 합류(오프라인 자동 큐) +- **세팅은 기기별 독립(7차, 2026-09-03)** — 기기마다 진동 질감이 달라 각자 최적화한다는 사용자 결정으로 config 동기화(push/pull) 전면 제거. 워치 세팅은 시작 화면에서 **왼쪽 스와이프**(TabView `.page`, DEBUG `-openSettings`로 바로 열기): 준비/수축/이완/횟수 스테퍼(−·+ 사이 2줄 컴팩트 라벨 — 1줄 긴 라벨은 거대 렌더 사고), 진동 2종 피커(onChange로 즉시 미리 울림), 운동 유형·강도. 시작 화면 요약 라인은 SettingsStore를 EnvironmentObject로 받아 즉시 반영. **동기화는 기록만**: 완주 기록 `transferUserInfo`로 폰에 합류(오프라인 자동 큐), 폰 `PhoneSync`는 기록 수신 + `WatchLinkStatus` 갱신만 담당 - 운동 시작 시 **HKWorkoutSession**(건강 기록+손목 내려도 유지 — §3.7)을 열고, 실패(권한 거부 등) 시 `WKExtendedRuntimeSession`(physical-therapy) 폴백. 둘 다 실패해도 화면 켜져 있는 동안은 정상 동작 ## 4. 화면 구성 (탭 없음 — 애플 기본 앱 느낌) -- **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + **하단 중앙 통계 손잡이(위로 스와이프 힌트)** + 버튼 아래 **세팅 요약 카드 3장**(수축·이완·횟수 — 아이콘+큰 숫자, 탭=세팅) +- **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + **버튼 위 워치 연결 배지**(7차 — 연결됨(그린)/미연결/미연동. ⚠️iOS 제약: 폰 쪽 `isReachable`은 워치에서 케깅 앱이 포그라운드일 때만 true라 '연결됨' 기준을 설정 푸터·도움말에 안내) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + **하단 중앙 통계 손잡이(위로 스와이프 힌트)** + 버튼 아래 **세팅 요약 카드 3장**(수축·이완·횟수 — 아이콘+큰 숫자, 탭=세팅). 워치 미연결 상태에서 시작 버튼을 누르면 확인 창(기록 동일함 안내, `app.watchWarn` 토글 — 설정 > 애플워치. 단축어·`-autoStart`는 경고 없이 바로 시작) - **세팅/설정/도움말**: 시트(Form/List). 설정에 테마·**언어**(시스템/한국어/English/日本語 — 재실행 시 적용)·**애플 건강**(운동 강도 자동 기록) 섹션. 도움말은 5그룹 14주제(기본 사용법·통계·앱 밖에서·애플워치·설정 기타)로 전 기능 사용법 서술. 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션, **국면 색 전환(준비=회그린·수축=그린·이완=앰버, 카운터 색 연동)** + 원 둘레 **세트 진행 링**(rep 기준, 국면 색, 준비는 0) + 큰 현재 횟수 카운터(numericText 전환). 준비 국면은 작은 원이 천천히 부풀며 첫 수축으로 이어짐. Live Activity는 시작 시 `LiveActivityManager.ensureStarted()` 1회 등록·이후 무갱신(§3.4 자가 렌더링) - 아이콘 전용 버튼에는 `accessibilityLabel` 필수 (하루 다님 컨벤션 계승) @@ -183,7 +185,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] | `-openStats` | 시작하자마자 통계 화면 | | `-statsScope day\|week\|month` | 통계 초기 탭 지정 | | `-statsRolling` | 주간·월간을 롤링 모드로 | -| `-openHelp` / `-openSettings` / `-openTimerSettings` | 해당 시트를 열고 시작 (화면 QA용) | +| `-openHelp` / `-openSettings` / `-openTimerSettings` | 해당 시트를 열고 시작 (화면 QA용). `-openSettings`는 워치에서는 세팅 페이지(2쪽)로 시작 | | `-autoStart` | 시작하자마자 운동 시작 (아이폰·워치 공통) | | `-confirmStop` | 운동 화면의 인라인 중단 확인을 펼친 채 시작 (화면 QA용) | | `-quickConfig` | 세팅을 수축1초·이완1초·2회로 (완주 경로 검증) | diff --git a/myApp/Keging/IOS/AppSettingsView.swift b/myApp/Keging/IOS/AppSettingsView.swift index 29f68e9..5477679 100644 --- a/myApp/Keging/IOS/AppSettingsView.swift +++ b/myApp/Keging/IOS/AppSettingsView.swift @@ -11,6 +11,7 @@ struct AppSettingsView: View { @EnvironmentObject private var settings: SettingsStore @AppStorage("app.theme", store: AppGroup.defaults) private var themeMode = "system" @AppStorage("app.language", store: AppGroup.defaults) private var appLanguage = "system" + @AppStorage("app.watchWarn", store: AppGroup.defaults) private var warnNoWatch = true @Environment(\.dismiss) private var dismiss var body: some View { @@ -51,6 +52,13 @@ struct AppSettingsView: View { } footer: { Text("운동을 끝까지 마치면 그 시간이 위에서 고른 유형으로 애플 건강에 자동 기록되고, 운동 강도(1~10)도 함께 저장돼요. 기록을 원치 않으면 건강 앱에서 케깅의 권한을 끄면 돼요.") } + Section { + Toggle("워치 미연결 경고", isOn: $warnNoWatch) + } header: { + Text("애플워치") + } footer: { + Text("워치가 연결되지 않은 채 시작 버튼을 누르면 확인 창을 띄워요. 워치에서 케깅 앱이 켜져 있을 때만 '연결됨'으로 표시돼요.") + } Section { LabeledContent("버전", value: versionText) } footer: { diff --git a/myApp/Keging/IOS/ContentView.swift b/myApp/Keging/IOS/ContentView.swift index 36c0145..39ae989 100644 --- a/myApp/Keging/IOS/ContentView.swift +++ b/myApp/Keging/IOS/ContentView.swift @@ -10,13 +10,18 @@ import SwiftUI struct ContentView: View { @EnvironmentObject private var engine: KegelEngine @EnvironmentObject private var settings: SettingsStore + @ObservedObject private var watchLink = WatchLinkStatus.shared @Environment(\.scenePhase) private var scenePhase + /// 워치 미연결 상태로 시작할 때 확인 창 (설정에서 끌 수 있음) + @AppStorage("app.watchWarn", store: AppGroup.defaults) private var warnNoWatch = true + @State private var showTimerSettings = false @State private var showAppSettings = false @State private var showHelp = false @State private var showStats = false + @State private var showWatchWarning = false var body: some View { ZStack { @@ -35,9 +40,18 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .startWorkoutRequested)) { _ in startFromShortcut() } + .alert("워치가 연결되어 있지 않아요", isPresented: $showWatchWarning) { + Button("취소", role: .cancel) {} + Button("시작하기") { WorkoutCoordinator.shared.startWorkout() } + } message: { + Text("아이폰만으로 진행해도 운동 기록은 똑같이 저장돼요. 진동은 아이폰에서 울리고, 칼로리는 심박 없이 추정돼요.") + } .onChange(of: scenePhase) { // 백그라운드에서 정지됐다 돌아와도 벽시계 기준 정확한 국면으로 재정렬 - if scenePhase == .active { KegelEngine.shared.resyncAfterWake() } + if scenePhase == .active { + KegelEngine.shared.resyncAfterWake() + WatchLinkStatus.shared.refresh() + } } .onAppear { if AppLaunchState.shared.startRequested { startFromShortcut() } @@ -65,8 +79,11 @@ struct ContentView: View { Spacer() + watchLinkBadge + .padding(.bottom, 16) + Button { - WorkoutCoordinator.shared.startWorkout() + requestStart() } label: { startButtonLabel } @@ -191,6 +208,48 @@ struct ContentView: View { .shadow(color: .black.opacity(0.06), radius: 10, x: 0, y: 3) } + /// 워치 연결 표시 — ⚠️iOS 제약상 '연결됨'은 워치에서 케깅 앱이 켜져 있을 때만 + /// (설정·도움말에 기준 안내). 미연결이어도 기록은 동일하게 저장된다 + private var watchLinkBadge: some View { + let symbol: String + let label: Text + let color: Color + switch watchLink.state { + case .reachable: + symbol = "applewatch.radiowaves.left.and.right" + label = Text("워치 연결됨") + color = AppTheme.green + case .unreachable: + symbol = "applewatch.slash" + label = Text("워치 미연결") + color = .secondary + case .notLinked: + symbol = "applewatch.slash" + label = Text("워치 미연동") + color = .secondary + } + return HStack(spacing: 6) { + Image(systemName: symbol) + .font(.footnote.weight(.semibold)) + label + .font(.footnote.weight(.medium)) + } + .foregroundStyle(color) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(AppTheme.surface, in: Capsule()) + .accessibilityElement(children: .combine) + } + + /// 시작 버튼 — 워치 미연결이면(설정에 따라) 확인 후 시작 + private func requestStart() { + if warnNoWatch, watchLink.state != .reachable { + showWatchWarning = true + } else { + WorkoutCoordinator.shared.startWorkout() + } + } + private func cornerButton(symbol: String, label: Text, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: symbol) diff --git a/myApp/Keging/IOS/HelpView.swift b/myApp/Keging/IOS/HelpView.swift index fc9e130..73d8ac9 100644 --- a/myApp/Keging/IOS/HelpView.swift +++ b/myApp/Keging/IOS/HelpView.swift @@ -17,7 +17,7 @@ struct HelpView: View { helpRow( symbol: "play.circle.fill", title: Text("바로 시작하기"), - body: Text("첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요.") + body: Text("첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요. 시작 버튼 위에는 워치 연결 상태가 떠요 — 워치 없이 시작해도 기록은 똑같이 저장되고, 미연결 확인 창은 설정에서 끌 수 있어요.") ) helpRow( symbol: "slider.horizontal.3", @@ -27,7 +27,7 @@ struct HelpView: View { helpRow( symbol: "iphone.radiowaves.left.and.right", title: Text("진동으로 구분하기"), - body: Text("수축이 시작될 때와 이완이 시작될 때 서로 다른 진동이 울려요. 그래서 화면을 보지 않아도 지금 조여야 할지 풀어야 할지 알 수 있어요. 기본값은 수축=지이이잉(아주 긴 진동), 이완=기본(짧은 한 번)이라 첫 순간의 질감으로 바로 구분돼요. 패턴은 운동 세팅에서 국면별로 고를 수 있고(기본·톡톡·지이이잉), '미리 느끼기' 버튼으로 실제 진동을 확인할 수 있어요. 세트를 끝까지 마치면 아주 길게 축하 진동이 울려요. 화면이 꺼져 있거나 다른 앱을 쓰는 중에는 시스템 진동으로 대신 울려서 질감이 조금 다를 수 있어요. 워치에서도 같은 패턴으로 울리고, 진동은 실기기에서만 느껴져요.") + body: Text("수축이 시작될 때와 이완이 시작될 때 서로 다른 진동이 울려요. 그래서 화면을 보지 않아도 지금 조여야 할지 풀어야 할지 알 수 있어요. 기본값은 수축=지이이잉(아주 긴 진동), 이완=기본(짧은 한 번)이라 첫 순간의 질감으로 바로 구분돼요. 패턴은 운동 세팅에서 국면별로 고를 수 있고(기본·톡톡·지이이잉), '미리 느끼기' 버튼으로 실제 진동을 확인할 수 있어요. 세트를 끝까지 마치면 아주 길게 축하 진동이 울려요. 화면이 꺼져 있거나 다른 앱을 쓰는 중에는 시스템 진동으로 대신 울려서 질감이 조금 다를 수 있어요. 워치는 손목에 맞춘 더 강한 전용 진동 세트를 쓰고 워치에서 따로 골라요. 진동은 실기기에서만 느껴져요.") ) helpRow( symbol: "stop.circle", @@ -77,12 +77,12 @@ struct HelpView: View { helpRow( symbol: "applewatch", title: Text("애플워치에서 운동하기"), - body: Text("워치 앱을 열면 시작 버튼 하나만 있어요. 누르면 바로 시작되고, 화면에 수축·이완 상태와 남은 시간, 몇 번째인지가 한눈에 보여요. 워치에서 실행하면 진동은 워치에서만 울려요. 중단 버튼을 누르면 그 세트는 기록되지 않아요.") + body: Text("워치 앱을 열면 시작 버튼이 있고, 누르면 바로 시작돼요. 화면에 수축·이완 상태와 남은 시간, 몇 번째인지가 한눈에 보여요. 시작 화면에서 왼쪽으로 쓸어넘기면 워치 전용 세팅(준비·수축·이완·횟수·진동·애플 건강)을 바꿀 수 있어요. 워치에서 실행하면 진동은 워치에서만 울려요. 중단 버튼을 누르면 그 세트는 기록되지 않아요.") ) helpRow( symbol: "arrow.triangle.2.circlepath", title: Text("워치와 아이폰 동기화"), - body: Text("운동 세팅은 아이폰에서만 바꿀 수 있고, 바꾸면 워치에 자동으로 전달돼요. 워치 앱을 열 때마다 아이폰의 최신 세팅을 다시 가져오니까 따로 맞춰줄 필요 없어요. 워치에서 끝까지 마친 세트는 아이폰으로 넘어와 통계에 합쳐져요. 워치가 아이폰과 떨어져 있어도 기록은 쌓였다가 연결되면 자동으로 전송돼요.") + body: Text("운동 세팅은 아이폰과 워치가 각자 따로 저장해요 — 기기마다 진동 느낌이 달라서 각자에게 맞게 다르게 세팅할 수 있게요. 워치 세팅은 워치의 시작 화면에서 왼쪽으로 쓸어넘겨 바꿔요. 끝까지 마친 세트 기록은 아이폰으로 넘어와 통계에 합쳐지고, 워치가 아이폰과 떨어져 있어도 기록은 쌓였다가 연결되면 자동으로 전송돼요.") ) } header: { Text("애플워치") diff --git a/myApp/Keging/IOS/Localizable.xcstrings b/myApp/Keging/IOS/Localizable.xcstrings index 6b8cba5..d247e04 100644 --- a/myApp/Keging/IOS/Localizable.xcstrings +++ b/myApp/Keging/IOS/Localizable.xcstrings @@ -591,18 +591,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 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. When you finish a whole set, an extra-long celebration buzz plays. While the screen is off or you're in another app, system vibrations play instead, so the texture may feel slightly different. The watch uses the same patterns, and 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. When you finish a whole set, an extra-long celebration buzz plays. While the screen is off or you're in another app, system vibrations play instead, so the texture may feel slightly different. The watch uses its own set of stronger wrist-tuned vibrations, chosen separately on the watch. Haptics can only be felt on a real device." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "締め始めとゆるめ始めで異なる振動が鳴るので、画面を見なくても今どちらなのか分かります。デフォルトは締める=とても長いブーッ、ゆるめる=短い標準の1回なので、最初の瞬間の感触ですぐ区別できます。パターンはワークアウト設定でそれぞれ選べて(標準・トントン・ブーッ)、「試す」ボタンで実際に確認できます。セットを最後までやり遂げると、とても長いお祝いの振動が鳴ります。画面がオフのときや他のアプリを使っているときはシステム振動で代わりに鳴るため、感触が少し異なることがあります。ウォッチでも同じパターンで振動し、振動は実機でのみ感じられます。" + "value" : "締め始めとゆるめ始めで異なる振動が鳴るので、画面を見なくても今どちらなのか分かります。デフォルトは締める=とても長いブーッ、ゆるめる=短い標準の1回なので、最初の瞬間の感触ですぐ区別できます。パターンはワークアウト設定でそれぞれ選べて(標準・トントン・ブーッ)、「試す」ボタンで実際に確認できます。セットを最後までやり遂げると、とても長いお祝いの振動が鳴ります。画面がオフのときや他のアプリを使っているときはシステム振動で代わりに鳴るため、感触が少し異なることがあります。ウォッチは手首に合わせたより強い専用の振動セットを使い、ウォッチ側で別に選びます。振動は実機でのみ感じられます。" } } } @@ -693,6 +693,38 @@ } } }, + "시작하기" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Anyway" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "そのまま開始" + } + } + } + }, + "아이폰만으로 진행해도 운동 기록은 똑같이 저장돼요. 진동은 아이폰에서 울리고, 칼로리는 심박 없이 추정돼요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Working out with just your iPhone still saves your records exactly the same. Vibrations play on the iPhone, and calories are estimated without heart rate." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhoneだけで行っても運動記録はまったく同じように保存されます。振動はiPhoneで鳴り、カロリーは心拍数なしで推定されます。" + } + } + } + }, "안 함" : { "localizations" : { "en" : { @@ -997,18 +1029,18 @@ } } }, - "운동 세팅은 아이폰에서만 바꿀 수 있고, 바꾸면 워치에 자동으로 전달돼요. 워치 앱을 열 때마다 아이폰의 최신 세팅을 다시 가져오니까 따로 맞춰줄 필요 없어요. 워치에서 끝까지 마친 세트는 아이폰으로 넘어와 통계에 합쳐져요. 워치가 아이폰과 떨어져 있어도 기록은 쌓였다가 연결되면 자동으로 전송돼요." : { + "운동 세팅은 아이폰과 워치가 각자 따로 저장해요 — 기기마다 진동 느낌이 달라서 각자에게 맞게 다르게 세팅할 수 있게요. 워치 세팅은 워치의 시작 화면에서 왼쪽으로 쓸어넘겨 바꿔요. 끝까지 마친 세트 기록은 아이폰으로 넘어와 통계에 합쳐지고, 워치가 아이폰과 떨어져 있어도 기록은 쌓였다가 연결되면 자동으로 전송돼요." : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Workout settings can only be changed on the iPhone, and changes are sent to the watch automatically. The watch also fetches the latest settings from the iPhone every time you open the watch app, so there's nothing to keep in sync yourself. Sets finished on the watch are sent back to the iPhone and merged into your statistics. Even when the watch is away from the iPhone, records are queued and transferred automatically once connected." + "value" : "Workout settings are stored separately on the iPhone and the watch — each device feels vibrations differently, so you can tune each one to your liking. Change the watch settings by swiping left on the watch's start screen. Sets finished on the watch are sent back to the iPhone and merged into your statistics, and even when the watch is away from the iPhone, records are queued and transferred automatically once connected." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ワークアウト設定はiPhoneでのみ変更でき、変更は自動的にウォッチへ送られます。ウォッチアプリを開くたびにiPhoneの最新設定を取得し直すので、自分で合わせる必要はありません。ウォッチで完了したセットはiPhoneに送られ、統計に合算されます。ウォッチがiPhoneと離れていても、記録はたまって接続時に自動送信されます。" + "value" : "ワークアウト設定はiPhoneとウォッチがそれぞれ別に保存します — デバイスごとに振動の感じ方が違うため、それぞれに合わせて設定できます。ウォッチの設定はウォッチのスタート画面で左にスワイプして変更します。ウォッチで完了したセットの記録はiPhoneに送られて統計に合算され、ウォッチがiPhoneと離れていても、記録はたまって接続時に自動送信されます。" } } } @@ -1093,18 +1125,114 @@ } } }, - "워치 앱을 열면 시작 버튼 하나만 있어요. 누르면 바로 시작되고, 화면에 수축·이완 상태와 남은 시간, 몇 번째인지가 한눈에 보여요. 워치에서 실행하면 진동은 워치에서만 울려요. 중단 버튼을 누르면 그 세트는 기록되지 않아요." : { + "워치 미연결" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Open the watch app and there's just one Start button. Tap it to begin immediately — the screen shows the current phase, the remaining time, and which rep you're on, all at a glance. When you start from the watch, vibrations play only on the watch. Tapping Stop discards that set." + "value" : "Watch not connected" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ウォッチアプリを開くと開始ボタンが1つだけあります。押すとすぐに始まり、画面に締める・ゆるめるの状態、残り時間、何回目かが一目で分かります。ウォッチから実行すると振動はウォッチだけで鳴ります。中断するとそのセットは記録されません。" + "value" : "ウォッチ未接続" + } + } + } + }, + "워치 미연결 경고" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Warn when watch isn't connected" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウォッチ未接続時に警告" + } + } + } + }, + "워치 미연동" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No watch linked" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウォッチ未連携" + } + } + } + }, + "워치 앱을 열면 시작 버튼이 있고, 누르면 바로 시작돼요. 화면에 수축·이완 상태와 남은 시간, 몇 번째인지가 한눈에 보여요. 시작 화면에서 왼쪽으로 쓸어넘기면 워치 전용 세팅(준비·수축·이완·횟수·진동·애플 건강)을 바꿀 수 있어요. 워치에서 실행하면 진동은 워치에서만 울려요. 중단 버튼을 누르면 그 세트는 기록되지 않아요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open the watch app and tap the Start button to begin immediately — the screen shows the current phase, the remaining time, and which rep you're on, all at a glance. Swipe left on the start screen to change watch-only settings (ready, squeeze, relax, reps, vibration, Apple Health). When you start from the watch, vibrations play only on the watch. Tapping Stop discards that set." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウォッチアプリを開くと開始ボタンがあり、押すとすぐに始まります。画面に締める・ゆるめるの状態、残り時間、何回目かが一目で分かります。スタート画面で左にスワイプすると、ウォッチ専用の設定(準備・締める・ゆるめる・回数・振動・ヘルスケア)を変更できます。ウォッチから実行すると振動はウォッチだけで鳴ります。中断するとそのセットは記録されません。" + } + } + } + }, + "워치 연결됨" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Watch connected" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウォッチ接続中" + } + } + } + }, + "워치가 연결되어 있지 않아요" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Watch Isn't Connected" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウォッチが接続されていません" + } + } + } + }, + "워치가 연결되지 않은 채 시작 버튼을 누르면 확인 창을 띄워요. 워치에서 케깅 앱이 켜져 있을 때만 '연결됨'으로 표시돼요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shows a confirmation when you tap Start while the watch isn't connected. 'Connected' appears only while the Keging app is open on your watch." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウォッチが接続されていない状態で開始ボタンを押すと確認を表示します。ウォッチでKegingアプリが開いているときのみ「接続中」と表示されます。" } } } @@ -1509,18 +1637,34 @@ } } }, - "첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요." : { + "첫 화면의 큰 시작 버튼을 누르면 저장된 세팅대로 운동이 시작돼요. 준비 시간을 설정해 두면 그만큼 카운트다운 후에 첫 수축이 시작돼요. 화면의 원이 수축할 때 함께 조이고, 커질 때 힘을 풀면 돼요. 정해진 횟수를 모두 마치면 완료 화면이 뜨고 기록이 저장돼요. 시작 버튼 위에는 워치 연결 상태가 떠요 — 워치 없이 시작해도 기록은 똑같이 저장되고, 미연결 확인 창은 설정에서 끌 수 있어요." : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Tap the big Start button on the main screen to begin with your saved setup. If you set a ready time, the first squeeze starts after that countdown. Squeeze while the circle on screen shrinks, and release as it grows. When you finish all the reps, a completion screen appears and the set is recorded." + "value" : "Tap the big Start button on the main screen to begin with your saved setup. If you set a ready time, the first squeeze starts after that countdown. Squeeze while the circle on screen shrinks, and release as it grows. When you finish all the reps, a completion screen appears and the set is recorded. Above the Start button you'll see your watch connection status — starting without the watch saves your records just the same, and you can turn off the confirmation in Settings." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "最初の画面の大きな開始ボタンを押すと、保存した設定でワークアウトが始まります。準備時間を設定していれば、そのカウントダウンのあとに最初の締めが始まります。画面の円が縮むときに締めて、大きくなるときに力を抜きます。設定した回数をすべて終えると完了画面が表示され、記録が保存されます。" + "value" : "最初の画面の大きな開始ボタンを押すと、保存した設定でワークアウトが始まります。準備時間を設定していれば、そのカウントダウンのあとに最初の締めが始まります。画面の円が縮むときに締めて、大きくなるときに力を抜きます。設定した回数をすべて終えると完了画面が表示され、記録が保存されます。開始ボタンの上にはウォッチの接続状態が表示されます — ウォッチなしで始めても記録はまったく同じように保存され、未接続の確認は設定でオフにできます。" + } + } + } + }, + "취소" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャンセル" } } } diff --git a/myApp/Keging/IOS/PhoneSync.swift b/myApp/Keging/IOS/PhoneSync.swift index 57a9b18..32d9eea 100644 --- a/myApp/Keging/IOS/PhoneSync.swift +++ b/myApp/Keging/IOS/PhoneSync.swift @@ -2,7 +2,9 @@ // PhoneSync.swift // Keging // -// 워치 연동 (iOS 측) — 세트 구성을 워치로 보내고, 워치에서 완주한 기록을 받는다 +// 워치 연동 (iOS 측) — 워치에서 완주한 기록을 받아 병합하고, 연결 상태를 알린다. +// 세팅은 기기별 독립(2026-09-03, 7차 — 기기마다 진동 질감이 달라 각자 최적화)이라 +// 더 이상 push/응답하지 않는다. // import WatchConnectivity @@ -17,24 +19,16 @@ final class PhoneSync: NSObject, WCSessionDelegate { let session = WCSession.default session.delegate = self session.activate() - SettingsStore.shared.onConfigChange = { [weak self] config in - self?.pushConfig(config) - } } - func pushConfig(_ config: WorkoutConfig) { - guard WCSession.isSupported(), WCSession.default.activationState == .activated else { return } - guard let data = try? JSONEncoder().encode(config) else { return } - try? WCSession.default.updateApplicationContext(["config": data]) + private nonisolated func refreshLinkStatus() { + Task { @MainActor in WatchLinkStatus.shared.refresh() } } // MARK: WCSessionDelegate nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - guard activationState == .activated else { return } - Task { @MainActor in - PhoneSync.shared.pushConfig(SettingsStore.shared.config) - } + refreshLinkStatus() } nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} @@ -43,24 +37,13 @@ final class PhoneSync: NSObject, WCSessionDelegate { session.activate() } - /// 워치가 닿게 되면 최신 세팅을 재푸시 — 변경 시점에 전송이 유실됐어도 여기서 복구 nonisolated func sessionReachabilityDidChange(_ session: WCSession) { - guard session.isReachable else { return } - Task { @MainActor in - PhoneSync.shared.pushConfig(SettingsStore.shared.config) - } + refreshLinkStatus() } - /// 워치 앱이 열릴 때 보내는 세팅 요청 — 폰 앱이 꺼져 있어도 시스템이 깨워서 응답한다 - nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { - guard message["request"] as? String == "config" else { - replyHandler([:]) - return - } - Task { @MainActor in - let data = (try? JSONEncoder().encode(SettingsStore.shared.config)) ?? Data() - replyHandler(["config": data]) - } + /// 페어링·워치 앱 설치 여부 변화 + nonisolated func sessionWatchStateDidChange(_ session: WCSession) { + refreshLinkStatus() } nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { diff --git a/myApp/Keging/IOS/WatchLinkStatus.swift b/myApp/Keging/IOS/WatchLinkStatus.swift new file mode 100644 index 0000000..362683d --- /dev/null +++ b/myApp/Keging/IOS/WatchLinkStatus.swift @@ -0,0 +1,43 @@ +// +// WatchLinkStatus.swift +// Keging +// +// 첫 화면의 워치 연결 표시용 상태 — PhoneSync 델리게이트 콜백·씬 활성화 때 refresh(). +// ⚠️iOS 쪽 isReachable은 워치에서 케깅 앱이 켜져(포그라운드) 있을 때만 true가 되는 +// 시스템 제약이 있다 — '연결됨' 표시는 "워치의 케깅과 지금 통신 가능"의 의미 +// (설정·도움말에 이 기준을 안내해 뒀다). +// + +import Combine +import WatchConnectivity + +@MainActor +final class WatchLinkStatus: ObservableObject { + static let shared = WatchLinkStatus() + + enum LinkState { + /// 워치 미페어링 또는 케깅 워치 앱 미설치 + case notLinked + /// 연동은 되어 있지만 지금은 통신 불가 (워치가 멀거나 워치 앱이 꺼져 있음) + case unreachable + /// 워치의 케깅 앱과 지금 통신 가능 + case reachable + } + + @Published private(set) var state: LinkState = .notLinked + + private init() {} + + func refresh() { + guard WCSession.isSupported() else { + state = .notLinked + return + } + let session = WCSession.default + guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { + state = .notLinked + return + } + state = session.isReachable ? .reachable : .unreachable + } +} diff --git a/myApp/Keging/KegingWatch Watch App/ContentView.swift b/myApp/Keging/KegingWatch Watch App/ContentView.swift index 26733cc..7739bb1 100644 --- a/myApp/Keging/KegingWatch Watch App/ContentView.swift +++ b/myApp/Keging/KegingWatch Watch App/ContentView.swift @@ -3,6 +3,7 @@ // KegingWatch Watch App // // 실행하면 바로 시작 버튼 하나 — 누르면 즉시 시작. +// 왼쪽으로 스와이프하면 워치 전용 세팅 페이지(7차 — 세팅은 아이폰과 독립). // 진행 중엔 수축/이완·남은 시간·몇 번째인지 한 화면에. // @@ -10,14 +11,20 @@ import SwiftUI struct WatchContentView: View { @EnvironmentObject private var engine: KegelEngine + @EnvironmentObject private var settings: SettingsStore @Environment(\.scenePhase) private var scenePhase + @State private var page = 0 var body: some View { ZStack { AppTheme.background.ignoresSafeArea() switch engine.state { case .idle: - startScreen + TabView(selection: $page) { + startScreen.tag(0) + WatchSettingsView().tag(1) + } + .tabViewStyle(.page) case let .running(phase, rep, phaseStart, phaseEnd): runningScreen(phase: phase, rep: rep, phaseStart: phaseStart, phaseEnd: phaseEnd) case let .finished(reps): @@ -26,13 +33,12 @@ struct WatchContentView: View { } .onChange(of: scenePhase) { _, newPhase in guard newPhase == .active else { return } - // 손목을 올려 앱이 다시 보일 때 — 국면 재정렬 + 폰에서 최신 세팅 당겨오기 + // 손목을 올려 앱이 다시 보일 때 — 벽시계 기준 국면 재정렬 engine.resyncAfterWake() - WatchSync.shared.requestConfigFromPhone() } .onAppear { - WatchSync.shared.requestConfigFromPhone() #if DEBUG + if CommandLine.arguments.contains("-openSettings") { page = 1 } if CommandLine.arguments.contains("-autoStart") { WatchCoordinator.shared.startWorkout() } #endif } @@ -62,7 +68,8 @@ struct WatchContentView: View { .buttonStyle(.plain) .accessibilityLabel(Text("케겔 운동 시작")) - Text(SettingsStore.shared.config.summaryText) + // 세팅 페이지에서 바꾸면 즉시 반영되는 요약 한 줄 + Text(settings.config.summaryText) .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(1) diff --git a/myApp/Keging/KegingWatch Watch App/KegingWatchApp.swift b/myApp/Keging/KegingWatch Watch App/KegingWatchApp.swift index 799944c..e90e2cf 100644 --- a/myApp/Keging/KegingWatch Watch App/KegingWatchApp.swift +++ b/myApp/Keging/KegingWatch Watch App/KegingWatchApp.swift @@ -8,6 +8,7 @@ import SwiftUI @main struct KegingWatchApp: App { @StateObject private var engine = KegelEngine.shared + @StateObject private var settings = SettingsStore.shared init() { WatchSync.shared.activate() @@ -18,6 +19,7 @@ struct KegingWatchApp: App { WindowGroup { WatchContentView() .environmentObject(engine) + .environmentObject(settings) .tint(AppTheme.green) } } diff --git a/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings b/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings index 5fa81b2..1d8fe95 100644 --- a/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings +++ b/myApp/Keging/KegingWatch Watch App/Localizable.xcstrings @@ -1,308 +1,496 @@ { - "sourceLanguage": "ko", - "strings": { - "%lld / %lld": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld / %lld" + "sourceLanguage" : "ko", + "strings" : { + "%lld / %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld / %lld" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%lld / %lld" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld / %lld" } }, - "ko": { - "stringUnit": { - "state": "new", - "value": "%1$lld / %2$lld" + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld / %2$lld" } } } }, - "%lld회 완주": { - "localizations": { - "en": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "%lld rep done" + "%lld초" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld s" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld秒" + } + } + } + }, + "%lld회" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld rep" } }, - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld reps done" + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld reps" } } } } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%lld回完了" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld回" } } } }, - "기본": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Standard" + "%lld회 완주" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld rep done" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld reps done" + } + } + } } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "標準" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld回完了" } } } }, - "기타": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Other" + "고르면 바로 진동이 울려요. 세팅은 아이폰과 따로 저장돼요." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Picking one plays the vibration right away. These settings are stored separately from your iPhone." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "その他" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "選ぶとすぐに振動が鳴ります。設定はiPhoneとは別に保存されます。" } } } }, - "수축": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Squeeze" + "기본" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "締める" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "標準" } } } }, - "수축 %lld초 · 이완 %lld초 · %lld회": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Squeeze %lld s · Relax %lld s · ×%lld" + "기타" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Other" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "締める%lld秒 · ゆるめる%lld秒 · %lld回" - } - }, - "ko": { - "stringUnit": { - "state": "new", - "value": "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "その他" } } } }, - "시작": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Start" + "세트" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "開始" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セット" } } } }, - "완료!": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Done!" + "세팅" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Setup" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "完了!" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" } } } }, - "이완": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Relax" + "수축" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Squeeze" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ゆるめる" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "締める" } } } }, - "준비": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Ready" + "수축 %lld초 · 이완 %lld초 · %lld회" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Squeeze %lld s · Relax %lld s · ×%lld" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "準備" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "締める%lld秒 · ゆるめる%lld秒 · %lld回" + } + }, + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회" } } } }, - "중단": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Stop" + "시작" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "中断" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "開始" } } } }, - "지이이잉": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Long buzz" + "안 함" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Off" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ブーッ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "なし" } } } }, - "케겔 운동 시작": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Start Kegel Workout" + "애플 건강" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Health" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ケーゲル運動を開始" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ヘルスケア" } } } }, - "코어 트레이닝": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Core Training" + "완료!" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done!" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "コアトレーニング" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "完了!" } } } }, - "톡톡": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Double tap" + "운동 강도" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effort" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "トントン" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "運動強度" } } } }, - "현재 %lld번째, 전체 %lld회": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Rep %lld of %lld" + "운동 유형" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Workout Type" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%lld回目、全%lld回" - } - }, - "ko": { - "stringUnit": { - "state": "new", - "value": "현재 %1$lld번째, 전체 %2$lld회" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ワークアウトの種類" } } } }, - "확인": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "OK" + "이완" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relax" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "OK" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ゆるめる" + } + } + } + }, + "준비" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ready" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "準備" + } + } + } + }, + "중단" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "中断" + } + } + } + }, + "지이이잉" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Long buzz" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブーッ" + } + } + } + }, + "진동" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vibration" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "振動" + } + } + } + }, + "케겔 운동 시작" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Kegel Workout" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ケーゲル運動を開始" + } + } + } + }, + "코어 트레이닝" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Core Training" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コアトレーニング" + } + } + } + }, + "톡톡" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double tap" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "トントン" + } + } + } + }, + "현재 %lld번째, 전체 %lld회" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rep %lld of %lld" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld回目、全%lld回" + } + }, + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "현재 %1$lld번째, 전체 %2$lld회" + } + } + } + }, + "확인" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } + }, + "횟수" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reps" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "回数" } } } } }, - "version": "1.0" + "version" : "1.0" } \ No newline at end of file diff --git a/myApp/Keging/KegingWatch Watch App/WatchHaptics.swift b/myApp/Keging/KegingWatch Watch App/WatchHaptics.swift index 2130eec..4b175d4 100644 --- a/myApp/Keging/KegingWatch Watch App/WatchHaptics.swift +++ b/myApp/Keging/KegingWatch Watch App/WatchHaptics.swift @@ -2,34 +2,53 @@ // WatchHaptics.swift // KegingWatch Watch App // -// 진동 패턴 재생 (워치) — ⚠️watchOS SDK에는 CoreHaptics가 없어(실측) 커스텀 파형 불가. -// 대신 강하고 질감이 다른 시스템 햅틱 3종으로 매핑한다(시스템 내장이라 간격 뭉개짐 없음): -// 기본=.notification(묵직한 1타), 톡톡=.directionUp(타이트한 2연타), 지이이잉=.retry(연속 다중 진동). -// 세기는 시스템 고정 최대 — 더 강하게는 워치 설정 > 사운드 및 햅틱 > '뚜렷한 햅틱'으로 보강. +// 진동 재생 (워치) — ⚠️watchOS SDK에는 CoreHaptics가 없어(실측) 커스텀 파형 불가. +// 강한 시스템 햅틱을 시퀀스로 엮어 '한 번/두 번/길게/아주 길게'를 만든다(7차 재설계 — +// 이전 매핑이 약하고 폰과 셈이 달랐던 피드백): +// 기본 = .start 1방(묵직한 노크), 톡톡 = .start 2방(0.35초 간격), +// 지이이잉 = .retry 2방 연결(~1.3초 우르르), 완주 = .retry×3 + .success(~2.5초). +// 세대 토큰으로 새 패턴 시작 시 이전 시퀀스의 남은 진동을 버린다. +// 세기 자체는 시스템 고정 — 더 강하게는 워치 설정 > 사운드 및 햅틱 > '뚜렷한 햅틱'. // import WatchKit @MainActor enum WatchHaptics { + private static var generation = 0 + static func play(_ pattern: HapticPattern) { - let type: WKHapticType = switch pattern { - case .basic: .notification - case .doubleTap: .directionUp - case .long: .retry + generation += 1 + switch pattern { + case .basic: + WKInterfaceDevice.current().play(.start) + case .doubleTap: + WKInterfaceDevice.current().play(.start) + playLater([(0.35, .start)]) + case .long: + WKInterfaceDevice.current().play(.retry) + playLater([(0.65, .retry)]) } - WKInterfaceDevice.current().play(type) } - /// 완주 축하 진동 — 길게 이어지는 느낌으로 .retry 연타 후 .success 마무리 (~2.3초). - /// 시스템 햅틱은 겹치면 삼켜지므로 0.75초 간격으로 잇는다 + /// 완주 축하 — 길게 이어지는 우르르 + 성공음 마무리 static func playFinish() { + generation += 1 WKInterfaceDevice.current().play(.retry) + playLater([(0.65, .retry), (1.3, .retry), (2.0, .success)]) + } + + /// 시작 시점 기준 지연 목록대로 이어서 재생 — 새 패턴이 시작되면 남은 항목은 버린다 + private static func playLater(_ steps: [(delay: Double, type: WKHapticType)]) { + let gen = generation Task { @MainActor in - try? await Task.sleep(for: .milliseconds(750)) - WKInterfaceDevice.current().play(.retry) - try? await Task.sleep(for: .milliseconds(750)) - WKInterfaceDevice.current().play(.success) + var elapsed = 0.0 + for step in steps { + try? await Task.sleep(for: .seconds(step.delay - elapsed)) + elapsed = step.delay + guard gen == generation else { return } + WKInterfaceDevice.current().play(step.type) + } } } } diff --git a/myApp/Keging/KegingWatch Watch App/WatchSettingsView.swift b/myApp/Keging/KegingWatch Watch App/WatchSettingsView.swift new file mode 100644 index 0000000..0fb7c90 --- /dev/null +++ b/myApp/Keging/KegingWatch Watch App/WatchSettingsView.swift @@ -0,0 +1,89 @@ +// +// WatchSettingsView.swift +// KegingWatch Watch App +// +// 워치 전용 세팅 — 시작 화면에서 왼쪽으로 스와이프(7차). 세팅값은 아이폰과 독립이고 +// (기기마다 진동 질감이 달라 각자 최적화), 기록만 동기화된다. 진동 패턴은 고르는 +// 즉시 미리 울려서 손목에서 바로 느껴볼 수 있다. +// + +import SwiftUI + +struct WatchSettingsView: View { + @EnvironmentObject private var settings: SettingsStore + + var body: some View { + NavigationStack { + Form { + Section { + Stepper(value: $settings.config.prepSeconds, in: 0...10) { + stepperLabel(Text("준비"), value: Text("\(settings.config.prepSeconds)초")) + } + Stepper(value: $settings.config.contractSeconds, in: 1...60) { + stepperLabel(Text("수축"), value: Text("\(settings.config.contractSeconds)초")) + } + Stepper(value: $settings.config.relaxSeconds, in: 1...60) { + stepperLabel(Text("이완"), value: Text("\(settings.config.relaxSeconds)초")) + } + Stepper(value: $settings.config.reps, in: 1...100) { + stepperLabel(Text("횟수"), value: Text("\(settings.config.reps)회")) + } + } header: { + Text("세트") + } + Section { + Picker(selection: $settings.config.contractPattern) { + ForEach(HapticPattern.allCases) { Text($0.label).tag($0) } + } label: { + Text("수축") + } + Picker(selection: $settings.config.relaxPattern) { + ForEach(HapticPattern.allCases) { Text($0.label).tag($0) } + } label: { + Text("이완") + } + } header: { + Text("진동") + } footer: { + Text("고르면 바로 진동이 울려요. 세팅은 아이폰과 따로 저장돼요.") + } + Section { + Picker(selection: $settings.config.healthWorkoutType) { + ForEach(HealthWorkoutType.allCases) { Text($0.label).tag($0) } + } label: { + Text("운동 유형") + } + Picker(selection: $settings.config.effortScore) { + Text("안 함").tag(0) + ForEach(1...10, id: \.self) { score in + Text(verbatim: "\(score)").tag(score) + } + } label: { + Text("운동 강도") + } + } header: { + Text("애플 건강") + } + } + .navigationTitle("세팅") + .onChange(of: settings.config.contractPattern) { _, newValue in + WatchHaptics.play(newValue) + } + .onChange(of: settings.config.relaxPattern) { _, newValue in + WatchHaptics.play(newValue) + } + } + } + + /// 워치 스테퍼의 −·+ 사이에 들어가는 컴팩트 2줄 라벨 (이름 캡션 + 값) + private func stepperLabel(_ title: Text, value: Text) -> some View { + VStack(spacing: 0) { + title + .font(.caption2) + .foregroundStyle(.secondary) + value + .font(.body.weight(.semibold)) + .monospacedDigit() + } + } +} diff --git a/myApp/Keging/KegingWatch Watch App/WatchSync.swift b/myApp/Keging/KegingWatch Watch App/WatchSync.swift index ad41fd7..8e6dfc2 100644 --- a/myApp/Keging/KegingWatch Watch App/WatchSync.swift +++ b/myApp/Keging/KegingWatch Watch App/WatchSync.swift @@ -2,8 +2,9 @@ // WatchSync.swift // KegingWatch Watch App // -// 폰 연동 (워치 측) — 폰에서 세트 구성을 받고, 완주 기록을 폰으로 보낸다 -// (transferUserInfo는 폰이 안 닿아도 자동으로 큐에 쌓였다 전달됨) +// 폰 연동 (워치 측) — 완주 기록을 폰으로 보낸다 +// (transferUserInfo는 폰이 안 닿아도 자동으로 큐에 쌓였다 전달됨). +// 세팅은 기기별 독립(2026-09-03, 7차 — 기기마다 진동 질감이 달라 각자 최적화)이라 받지 않는다. // import WatchConnectivity @@ -26,37 +27,7 @@ final class WatchSync: NSObject, WCSessionDelegate { WCSession.default.transferUserInfo(["record": data]) } - /// 폰에 최신 세팅을 요청 — 워치 앱이 열릴 때마다 호출 (폰 앱이 꺼져 있어도 시스템이 깨움). - /// 실패(폰이 안 닿음 등)는 무시 — applicationContext 경로가 커버한다 - func requestConfigFromPhone() { - guard WCSession.isSupported() else { return } - let session = WCSession.default - guard session.activationState == .activated else { return } - session.sendMessage( - ["request": "config"], - replyHandler: { [weak self] reply in self?.applyConfig(from: reply) }, - errorHandler: { _ in } - ) - } - // MARK: WCSessionDelegate - nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - guard activationState == .activated else { return } - applyConfig(from: session.receivedApplicationContext) - } - - nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { - applyConfig(from: applicationContext) - } - - private nonisolated func applyConfig(from context: [String: Any]) { - guard let data = context["config"] as? Data, - let config = try? JSONDecoder().decode(WorkoutConfig.self, from: data) else { return } - Task { @MainActor in - if SettingsStore.shared.config != config { - SettingsStore.shared.config = config - } - } - } + nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {} } diff --git a/myApp/Keging/Shared/WorkoutSettings.swift b/myApp/Keging/Shared/WorkoutSettings.swift index 2cee9e5..7d038c6 100644 --- a/myApp/Keging/Shared/WorkoutSettings.swift +++ b/myApp/Keging/Shared/WorkoutSettings.swift @@ -130,16 +130,12 @@ nonisolated struct WorkoutConfig: Codable, Equatable { final class SettingsStore: ObservableObject { static let shared = SettingsStore() + // 세팅은 기기별 독립(2026-09-03, 7차) — 기기마다 진동 질감이 달라 각자 최적화한다. + // 폰↔워치는 기록만 동기화하고, 이 스토어는 자기 기기 값만 저장한다 @Published var config: WorkoutConfig { - didSet { - save() - if config != oldValue { onConfigChange?(config) } - } + didSet { save() } } - /// 폰 → 워치 동기화 훅 (iOS에서만 연결) - var onConfigChange: ((WorkoutConfig) -> Void)? - private static let key = "workout.config.v1" private init() {