From fd9fd134d2a876fac6ae922b67a895a5c6bbe0a8 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Wed, 2 Sep 2026 17:00:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(keging):=20Live=20Activity=20=EB=AC=B4?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=C2=B7=EC=99=84=EC=A0=84=20=EC=9E=90=EA=B0=80?= =?UTF-8?q?=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=ED=99=95=EC=A0=95=20=E2=80=94?= =?UTF-8?q?=20=EB=B0=B1=EA=B7=B8=EB=9D=BC=EC=9A=B4=EB=93=9C=20=EC=98=88?= =?UTF-8?q?=EC=82=B0=EC=A0=9C=20=EB=8F=99=EA=B2=B0=20=EC=9B=90=EC=B2=9C=20?= =?UTF-8?q?=ED=9A=8C=ED=94=BC=20(=EC=8B=A4=EA=B8=B0=EA=B8=B0=205=EC=B0=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실기기 판독: 진동은 끝까지 정시 = 앱 생존, 그러나 국면 라벨·횟수는 항상 4번째 전송(수축 2회차)에서 동결 + 진행바만 흐름 → 백그라운드 Activity.update를 시스템이 예산제로 폐기하는 것으로 확정. staleDate 재렌더도 미발화(시뮬 실측) - 설계 전환: ContentState = 세트 일정뿐, 시작 시 1회 등록(ensureStarted) 후 update 호출 0회. 화면은 시스템 렌더(국면 색 타임라인 바+플레이헤드+남은 시간)와 영원히 참인 정적 정보(수축·이완 범례, 세트 구성)만 — 동결할 데이터 자체가 없음 - 컴팩트 = 미니 타임라인+남은 시간, 미니멀 = 국면 두 색 점. 국면 라벨·횟수 텍스트는 LA에서 제거(국면=진동+플레이헤드 색, 횟수=앱) - 통계 타임라인 점별 시각 라벨 제거(겹침), 도움말·번역 갱신, CLAUDE.md 현행화 - 시뮬: 등록 후 갱신 없이 35초간 4:57→4:27 정확 진행·완주 시 DI 해제 확인 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT --- myApp/Keging/CLAUDE.md | 8 +- myApp/Keging/IOS/HelpView.swift | 2 +- myApp/Keging/IOS/LiveActivityManager.swift | 43 ++---- myApp/Keging/IOS/Localizable.xcstrings | 6 +- myApp/Keging/IOS/WorkoutCoordinator.swift | 5 +- .../KegingWidgetsLiveActivity.swift | 139 ++++++++---------- .../KegingWidgets/Localizable.xcstrings | 60 -------- .../Shared/KegingActivityAttributes.swift | 15 +- 8 files changed, 92 insertions(+), 186 deletions(-) diff --git a/myApp/Keging/CLAUDE.md b/myApp/Keging/CLAUDE.md index 5f92844..5ed351e 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) | **실기기 4차 피드백 반영 완료, 재테스트 대기.** 4차(같은 날): 3차에도 실기기 DI·잠금화면이 멈춘다는 보고 → **Live Activity를 자가 렌더링 구조로 전면 재설계**(§3.4 — 상태에 세트 전체 일정을 실어 국면 색 타임라인 바 + 플레이헤드 + 남은 시간을 시스템이 그림. ActivityKit에 미래 상태 예약 API가 없음을 확인한 결과이며, 앱이 정지해도 이 요소들은 계속 움직이고 플레이헤드 위치의 색 = 현재 국면) + 키퍼에 디더 노이즈 샘플(완전 무음 감지 회피)·백그라운드 전환 브리지 태스크(§3.4) + 통계 타임라인 시각 라벨 제거(겹침). 3차: 햅틱 엔진 세션 부착(§3.3)·키퍼 하드닝·2초 하트비트·워치 세팅 pull 동기화(§3.6)·완료 진동(§3.3)·오늘의 타임라인(§3.2)·칼로리 정합(§3.7). 시뮬 검증(4차): DI 백그라운드 80초+ 진행(수축1→…→이완4→10/20), 3종 빌드 그린·경고 0·l10n 0/0/0. **재테스트 항목(사용자, 아이폰)**: ①DI·잠금화면 — 화면 잠그고 수 분 방치 후 타임라인 바·남은 시간이 계속 움직이는지(국면 라벨은 앱 정지 시 흐려짐 = 정상 동작이자 정지 신호) ②백그라운드 진동 지속 여부 ③완료 진동·워치 세팅 즉시 동기화·칼로리(≈28kcal/5분)·타임라인. DI 확장·잠금화면 배너 레이아웃은 시뮬에서 열 수 없어 실기기에서 첫 확인 | +| 현재 상태 (2026-09-02) | **실기기 5차 피드백 반영 완료(LA 완전 자가 렌더링), 재테스트 대기.** 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,8 +127,8 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] - ⚠️**watchOS SDK에는 CoreHaptics가 없다**(프레임워크 부재 실측) — 워치는 강한 시스템 햅틱 3종 매핑(시스템 내장이라 간격 뭉개짐 없음): 기본=.notification, 톡톡=.directionUp, 지이이잉=.retry. 세기는 시스템 고정(더 강하게는 워치 설정 > 사운드 및 햅틱). **워치에서 실행하면 진동은 워치에서만** (완주 시 .retry 0.75s 간격 2방 + .success) ### 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.syncCurrent()`)가 모두 호출. 놓친 국면 경계도 2초 안에 자가 치유. 종료·중단 시 해제(완주는 완료 진동 체인 보호를 위해 4초 지연). 운동 중 `isIdleTimerDisabled`로 화면 꺼짐 방지(종료 시 복원) -- Live Activity — **자가 렌더링 설계(4차)**: ⚠️ActivityKit엔 미래 시점 상태 예약 API가 없다(푸시 서버 없이는 국면 전환 갱신이 앱 런타임 의존 — 애플 공식 확인). 그래서 ContentState에 **세트 전체 일정**(sessionStart·준비/수축/이완/횟수, `sessionEnd` 계산)을 실어 위젯이 **시스템 렌더 요소**로 심장을 그린다: ①국면 색 타임라인 바(준비 회색→[민트·앰버]×횟수 — 하드 스톱 그라데이션 1뷰, 40회 초과 시 단색) ②그 위를 실시간으로 채우는 플레이헤드 `ProgressView(timerInterval: sessionStart...sessionEnd)`(흰색, **줄 끝이 걸친 색 = 지금 국면**) ③전체 남은 시간 `Text(timerInterval:)`. **이 요소들은 앱이 정지해도 계속 움직인다.** 국면 라벨·아이콘·횟수·국면 카운트다운은 앱 갱신(콜백+하트비트, `syncCurrent()` 단일 진입점 — 같은 상태면 no-op, `staleDate = phaseEnd+8초`) — isStale이면 40% 투명도로 흐려져 정지를 드러냄. 종료·중단 시 `.immediate` dismiss, 앱 시작 시 `cleanupStaleActivities()`(유령 DI 정리). 시뮬 실측(4차): 백그라운드 80초+ 진행(10/20까지) 확인. DI 확장·잠금 배너는 시뮬로 못 열어 실기기 확인 필요 +- 운동 시작 시 초저레벨 노이즈 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`로 화면 꺼짐 방지(종료 시 복원) +- Live Activity — **완전 자가 렌더링(5차 확정 설계)**: ⚠️두 가지 실측 제약이 근거 — ①ActivityKit엔 미래 상태 예약 API가 없고(애플 공식), **백그라운드 `Activity.update`는 시스템이 예산제로 폐기**(실기기: 등록+3회 후인 4번째 전송부터 동결 — 진동은 계속 = 앱은 살아 있는데 갱신만 버려짐) ②staleDate가 지나도 재렌더가 보장되지 않음(시뮬 실측 — isStale 분기 UI가 안 뜸). 따라서 **갱신을 아예 안 한다**: ContentState는 세트 일정뿐(sessionStart·준비/수축/이완/횟수·`sessionEnd`), 시작 시 `ensureStarted()` 1회 등록(하트비트에서 등록 실패만 재시도), 종료 시 dismiss. 위젯은 시스템 렌더 요소만: 국면 색 타임라인 바(준비 회색→[민트·앰버]×횟수 하드 스톱 그라데이션 1뷰, 40회 초과 시 단색) + 플레이헤드 `ProgressView(timerInterval:)`(흰색, **줄 끝이 걸친 색 = 지금 국면**) + 남은 시간 `Text(timerInterval:)` + 정적 정보(수축·이완 색 범례, "수축 n초 · 이완 n초 · n회"). 컴팩트 = 미니 타임라인(34pt)+남은 시간, 미니멀 = 국면 두 색 점. **국면 라벨·횟수 텍스트는 LA에 없다**(국면=진동+플레이헤드, 횟수=앱). 앱 시작 시 `cleanupStaleActivities()`(유령 DI 정리). 시뮬 실측(5차): 등록 후 갱신 0회로 35초간 4:57→4:27 정확 진행. ⚠️새 갱신 요소를 추가하고 싶어도 백그라운드 update는 동결된다 — 이 원칙을 깨지 말 것 | ### 3.5 단축어 (`IOS/KegingIntents.swift`) - `StartKegelIntent` 하나 — 앱을 열고(`openAppWhenRun`) 저장된 세팅대로 즉시 시작. 콜드 스타트 경합은 AppLaunchState 플래그 + 알림 이중 경로로 처리 @@ -152,7 +152,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] ## 4. 화면 구성 (탭 없음 — 애플 기본 앱 느낌) - **첫 화면**: 중앙 큰 시작 버튼(그린 그라데이션 원) + 좌상단 설정(gear)·우상단 세팅(slider)·**우하단 도움말(?)** + **하단 중앙 통계 손잡이(위로 스와이프 힌트)** + 버튼 아래 **세팅 요약 카드 3장**(수축·이완·횟수 — 아이콘+큰 숫자, 탭=세팅) -- **세팅/설정/도움말**: 시트(Form/List). 설정에 테마·**언어**(시스템/한국어/English/日本語 — 재실행 시 적용)·**애플 건강**(운동 강도 자동 기록) 섹션. 도움말은 5그룹 14주제(기본 사용법·통계·앱 밖에서·애플워치·설정 기타)로 전 기능 사용법 서술. 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션, **국면 색 전환(준비=회그린·수축=그린·이완=앰버, 카운터 색 연동)** + 원 둘레 **세트 진행 링**(rep 기준, 국면 색, 준비는 0) + 큰 현재 횟수 카운터(numericText 전환). 준비 국면은 작은 원이 천천히 부풀며 첫 수축으로 이어짐. Live Activity는 `LiveActivityManager.sync`(없으면 시작·있으면 갱신) 단일 진입점 +- **세팅/설정/도움말**: 시트(Form/List). 설정에 테마·**언어**(시스템/한국어/English/日本語 — 재실행 시 적용)·**애플 건강**(운동 강도 자동 기록) 섹션. 도움말은 5그룹 14주제(기본 사용법·통계·앱 밖에서·애플워치·설정 기타)로 전 기능 사용법 서술. 운동 진행: fullScreenCover — 수축 때 원이 오므라들고 이완 때 부풀어 오르는 애니메이션, **국면 색 전환(준비=회그린·수축=그린·이완=앰버, 카운터 색 연동)** + 원 둘레 **세트 진행 링**(rep 기준, 국면 색, 준비는 0) + 큰 현재 횟수 카운터(numericText 전환). 준비 국면은 작은 원이 천천히 부풀며 첫 수축으로 이어짐. Live Activity는 시작 시 `LiveActivityManager.ensureStarted()` 1회 등록·이후 무갱신(§3.4 자가 렌더링) - 아이콘 전용 버튼에는 `accessibilityLabel` 필수 (하루 다님 컨벤션 계승) ## 5. 테마·컬러 — 하루 다님 팔레트 계승 (`Shared/Theme.swift`) diff --git a/myApp/Keging/IOS/HelpView.swift b/myApp/Keging/IOS/HelpView.swift index 5dba7bc..fc9e130 100644 --- a/myApp/Keging/IOS/HelpView.swift +++ b/myApp/Keging/IOS/HelpView.swift @@ -62,7 +62,7 @@ struct HelpView: View { helpRow( symbol: "bolt.horizontal.circle", title: Text("다이나믹 아일랜드"), - body: Text("운동 중에 앱을 나가면 다이나믹 아일랜드와 잠금화면에 현재 몇 번째인지와 수축·이완 상태가 떠요. 길게 누르면 세트 전체를 국면 색으로 칠한 타임라인 바가 보이는 큰 화면이 열리는데, 이 바와 남은 시간 숫자는 시스템이 직접 그려서 항상 실시간으로 움직여요 — 밝게 채워진 줄의 끝이 걸친 색이 지금 국면이에요. 진동도 계속 울리니까 다른 앱을 쓰면서도 운동을 이어갈 수 있어요.") + body: Text("운동 중에 앱을 나가면 다이나믹 아일랜드와 잠금화면에 실시간 현황이 떠요. 세트 전체를 국면 색(민트=수축·앰버=이완)으로 칠한 타임라인 바 위로 밝은 줄이 실제 시간에 맞춰 차오르는데, 줄 끝이 걸친 색이 지금 국면이에요. 남은 시간 숫자와 함께 전부 시스템이 직접 그려서 세트가 끝날 때까지 멈추지 않아요. 지금 조여야 할지 풀어야 할지는 진동으로도 늘 알 수 있고, 정확한 횟수는 앱을 열면 보여요.") ) helpRow( symbol: "square.and.arrow.up", diff --git a/myApp/Keging/IOS/LiveActivityManager.swift b/myApp/Keging/IOS/LiveActivityManager.swift index 669461c..99663bf 100644 --- a/myApp/Keging/IOS/LiveActivityManager.swift +++ b/myApp/Keging/IOS/LiveActivityManager.swift @@ -2,10 +2,10 @@ // LiveActivityManager.swift // Keging // -// 다이나믹 아일랜드 Live Activity — 세트 전체 일정을 상태에 실어 위젯이 스스로 그리게 한다 -// (타임라인 바·플레이헤드·남은 시간은 시스템 렌더라 앱이 정지해도 움직임 — §3.4). -// 국면 전환 콜백 + 하트비트(2초)가 모두 syncCurrent를 부르지만 상태가 실제로 바뀐 -// 경우에만 갱신을 보낸다. staleDate(국면 종료+8초)가 지나면 위젯이 국면 정보를 흐린다. +// 다이나믹 아일랜드 Live Activity — 세트 일정을 실어 **시작할 때 한 번만 등록**하고 +// 이후 갱신하지 않는다(종료·중단 시 dismiss만). 위젯이 전부 시스템 렌더 요소로 그리는 +// 구조라(§3.4) 갱신이 필요 없고, 백그라운드 갱신 예산제(실기기 실측: 4번째부터 버려짐)의 +// 영향도 받지 않는다. // import ActivityKit @@ -16,7 +16,6 @@ final class LiveActivityManager { static let shared = LiveActivityManager() private var activity: Activity? - private var lastState: KegingActivityAttributes.ContentState? private init() {} @@ -27,40 +26,30 @@ final class LiveActivityManager { Task { await stale.end(nil, dismissalPolicy: .immediate) } } activity = nil - lastState = nil } - /// 엔진의 현재 상태를 그대로 반영 — 없으면 시작, 있으면 갱신. - /// 같은 상태면 아무것도 하지 않아 하트비트에서 매번 불러도 안전 - func syncCurrent() { + /// 운동 중인데 Live Activity가 없으면 등록 — 시작 직후 1회가 정상 경로, + /// 하트비트에서도 불러 등록 실패(권한 등)를 재시도한다. 이미 있으면 아무것도 안 함 + func ensureStarted() { + guard activity == nil else { return } let engine = KegelEngine.shared - guard case let .running(phase, rep, phaseStart, phaseEnd) = engine.state else { return } + guard case .running = engine.state else { return } + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } let config = engine.config let state = KegingActivityAttributes.ContentState( - phase: phase, rep: rep, totalReps: config.reps, - phaseStart: phaseStart, phaseEnd: phaseEnd, sessionStart: engine.sessionStart, prepSeconds: config.prepSeconds, contractSeconds: config.contractSeconds, - relaxSeconds: config.relaxSeconds + relaxSeconds: config.relaxSeconds, + totalReps: config.reps + ) + activity = try? Activity.request( + attributes: KegingActivityAttributes(startedAt: engine.sessionStart), + content: ActivityContent(state: state, staleDate: nil) ) - let content = ActivityContent(state: state, staleDate: phaseEnd.addingTimeInterval(8)) - if let activity { - guard state != lastState else { return } - lastState = state - Task { await activity.update(content) } - } else { - guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } - lastState = state - activity = try? Activity.request( - attributes: KegingActivityAttributes(startedAt: engine.sessionStart), - content: content - ) - } } func end() { - lastState = nil guard let activity else { return } self.activity = nil Task { await activity.end(nil, dismissalPolicy: .immediate) } diff --git a/myApp/Keging/IOS/Localizable.xcstrings b/myApp/Keging/IOS/Localizable.xcstrings index 96270d9..fa08d3f 100644 --- a/myApp/Keging/IOS/Localizable.xcstrings +++ b/myApp/Keging/IOS/Localizable.xcstrings @@ -1077,18 +1077,18 @@ } } }, - "운동 중에 앱을 나가면 다이나믹 아일랜드와 잠금화면에 현재 몇 번째인지와 수축·이완 상태가 떠요. 길게 누르면 세트 전체를 국면 색으로 칠한 타임라인 바가 보이는 큰 화면이 열리는데, 이 바와 남은 시간 숫자는 시스템이 직접 그려서 항상 실시간으로 움직여요 — 밝게 채워진 줄의 끝이 걸친 색이 지금 국면이에요. 진동도 계속 울리니까 다른 앱을 쓰면서도 운동을 이어갈 수 있어요." : { + "운동 중에 앱을 나가면 다이나믹 아일랜드와 잠금화면에 실시간 현황이 떠요. 세트 전체를 국면 색(민트=수축·앰버=이완)으로 칠한 타임라인 바 위로 밝은 줄이 실제 시간에 맞춰 차오르는데, 줄 끝이 걸친 색이 지금 국면이에요. 남은 시간 숫자와 함께 전부 시스템이 직접 그려서 세트가 끝날 때까지 멈추지 않아요. 지금 조여야 할지 풀어야 할지는 진동으로도 늘 알 수 있고, 정확한 횟수는 앱을 열면 보여요." : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "When you leave the app during a workout, the Dynamic Island and Lock Screen show which rep you're on and whether to squeeze or relax. Press and hold to open a larger view with a timeline bar that paints the whole set in phase colors — the bar and the remaining-time numbers are drawn by the system itself, so they always keep moving in real time, and the color under the tip of the bright fill is the current phase. Vibrations keep playing too, so you can continue your workout while using other apps." + "value" : "When you leave the app during a workout, the Dynamic Island and Lock Screen show a live view. A timeline bar paints the whole set in phase colors (mint = squeeze, amber = relax), and a bright line fills across it in real time — the color under the tip of the line is the current phase. Along with the remaining-time numbers, everything is drawn by the system itself, so it keeps moving until the set ends. Vibrations always tell you whether to squeeze or release, and the exact rep count is shown when you open the app." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ワークアウト中にアプリを離れると、Dynamic Islandとロック画面に今何回目か、締める・ゆるめるの状態が表示されます。長押しすると、セット全体をフェーズの色で塗ったタイムラインバーが見える大きな画面が開きます。このバーと残り時間の数字はシステム自体が描画するため、常にリアルタイムで動き続けます — 明るく満ちていく線の先端が重なる色が今のフェーズです。振動も鳴り続けるので、他のアプリを使いながらでも運動を続けられます。" + "value" : "ワークアウト中にアプリを離れると、Dynamic Islandとロック画面にライブ表示が出ます。セット全体をフェーズの色(ミント=締める・アンバー=ゆるめる)で塗ったタイムラインバーの上を、明るい線が実時間に合わせて満ちていきます — 線の先端が重なる色が今のフェーズです。残り時間の数字とともにすべてシステム自体が描画するため、セットが終わるまで止まりません。締めるかゆるめるかは振動でも常に分かり、正確な回数はアプリを開けば確認できます。" } } } diff --git a/myApp/Keging/IOS/WorkoutCoordinator.swift b/myApp/Keging/IOS/WorkoutCoordinator.swift index c798d55..30cd2d5 100644 --- a/myApp/Keging/IOS/WorkoutCoordinator.swift +++ b/myApp/Keging/IOS/WorkoutCoordinator.swift @@ -35,7 +35,7 @@ final class WorkoutCoordinator { case .contract: Haptics.play(config.contractPattern) case .relax: Haptics.play(config.relaxPattern) } - LiveActivityManager.shared.syncCurrent() + // Live Activity는 갱신하지 않는다 — 시작 시 1회 등록으로 끝 (자가 렌더링, §3.4) } engine.onSessionEnd = { [weak self] completed, record in self?.stopHeartbeat() @@ -66,6 +66,7 @@ final class WorkoutCoordinator { BackgroundAudioKeeper.shared.start() UIApplication.shared.isIdleTimerDisabled = true KegelEngine.shared.start(config: SettingsStore.shared.config) + LiveActivityManager.shared.ensureStarted() startHeartbeat() } @@ -78,7 +79,7 @@ final class WorkoutCoordinator { guard KegelEngine.shared.isRunning else { return } BackgroundAudioKeeper.shared.ensureAlive() KegelEngine.shared.resyncAfterWake() - LiveActivityManager.shared.syncCurrent() + LiveActivityManager.shared.ensureStarted() // 등록 실패 시 재시도 (이미 있으면 no-op) } } } diff --git a/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift b/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift index 3b6c3e0..9361014 100644 --- a/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift +++ b/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift @@ -2,14 +2,16 @@ // KegingWidgetsLiveActivity.swift // KegingWidgets // -// 다이나믹 아일랜드·잠금화면 Live Activity. +// 다이나믹 아일랜드·잠금화면 Live Activity — **100% 자가 렌더링**(실기기 5차 확정 설계). // -// ⚠️설계 원칙(실기기 4차 피드백): 국면 라벨·횟수는 앱 갱신에 의존하지만, iOS가 앱을 정지시키면 -// 갱신이 끊긴다(ActivityKit엔 미래 상태 예약 API가 없음 — 애플 공식). 그래서 화면의 "심장"은 -// 시스템이 직접 그리는 요소로 만든다: 세트 전체를 국면 색으로 칠한 타임라인 바 + 그 위를 -// 실시간으로 채워가는 플레이헤드(ProgressView(timerInterval:)) + 남은 시간(Text(timerInterval:)). -// 이 요소들은 앱이 정지해도 계속 움직이고, 플레이헤드가 걸친 색 = 지금 국면이다. -// 국면 라벨·횟수는 staleDate(국면 종료+8초)가 지나면 흐려져 낡은 정보임을 드러낸다. +// ⚠️백그라운드 Live Activity 갱신은 시스템이 예산제로 버리고(실측: 4번째부터 국면 +// 라벨·횟수가 얼어붙음), staleDate 재렌더도 보장되지 않는다. 그래서 이 화면은 앱 +// 갱신에 의존하는 요소를 아예 두지 않는다 — 애플 시계 앱 타이머와 같은 모델: +// ① 세트 전체를 국면 색으로 칠한 타임라인 바(민트=수축·앰버=이완) 위로 +// ProgressView(timerInterval:) 플레이헤드가 실제 시간에 맞춰 차오르고(줄 끝 색 = 지금 국면), +// ② Text(timerInterval:) 남은 시간이 초 단위로 흐르고, +// ③ 나머지는 영원히 참인 정적 정보(범례·세트 구성)뿐이다. +// 전부 시스템이 직접 그리므로 앱이 갱신을 못 보내도 끝까지 멈추지 않는다. // import ActivityKit @@ -20,97 +22,68 @@ struct KegingLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: KegingActivityAttributes.self) { context in // 잠금화면·배너 - VStack(spacing: 10) { - HStack(spacing: 8) { - phaseIcon(context.state) - Text(context.state.phase.label) - .font(.headline) - .foregroundStyle(phaseColor(context.state)) - Text("\(context.state.rep) / \(context.state.totalReps)") - .font(.subheadline) - .monospacedDigit() - .foregroundStyle(.secondary) - Spacer() - Text(timerInterval: context.state.phaseStart...context.state.phaseEnd, countsDown: true) - .font(.title3.weight(.semibold)) - .monospacedDigit() - .frame(maxWidth: 56) - .multilineTextAlignment(.trailing) - } - .opacity(context.isStale ? 0.4 : 1) - timelineBar(context.state, height: 12) - HStack { - Text("전체") - .font(.caption2) - .foregroundStyle(.secondary) + VStack(spacing: 9) { + HStack(spacing: 12) { + legendItem(color: Self.contractColor, label: Text("수축")) + legendItem(color: Self.relaxColor, label: Text("이완")) Spacer() Text(timerInterval: context.state.sessionStart...context.state.sessionEnd, countsDown: true) - .font(.caption.weight(.medium)) + .font(.title3.weight(.semibold)) .monospacedDigit() - .foregroundStyle(.secondary) - .frame(maxWidth: 60) + .frame(maxWidth: 64) .multilineTextAlignment(.trailing) } + timelineBar(context.state, height: 14) + Text("수축 \(context.state.contractSeconds)초 · 이완 \(context.state.relaxSeconds)초 · \(context.state.totalReps)회") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) } .padding(14) } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { - HStack(spacing: 6) { - phaseIcon(context.state) - Text(context.state.phase.label) - .font(.headline) - .foregroundStyle(phaseColor(context.state)) - } - .opacity(context.isStale ? 0.4 : 1) - .padding(.leading, 4) + legendItem(color: Self.contractColor, label: Text("수축")) + .padding(.leading, 4) } DynamicIslandExpandedRegion(.trailing) { - Text("\(context.state.rep) / \(context.state.totalReps)") - .font(.headline) - .monospacedDigit() - .opacity(context.isStale ? 0.4 : 1) + legendItem(color: Self.relaxColor, label: Text("이완")) .padding(.trailing, 4) } DynamicIslandExpandedRegion(.bottom) { VStack(spacing: 8) { - Text(timerInterval: context.state.phaseStart...context.state.phaseEnd, countsDown: true) + Text(timerInterval: context.state.sessionStart...context.state.sessionEnd, countsDown: true) .font(.title.weight(.semibold)) .monospacedDigit() .multilineTextAlignment(.center) - .opacity(context.isStale ? 0.4 : 1) timelineBar(context.state, height: 10) - HStack { - Text("전체") - .font(.caption2) - .foregroundStyle(.secondary) - Spacer() - Text(timerInterval: context.state.sessionStart...context.state.sessionEnd, countsDown: true) - .font(.caption.weight(.medium)) - .monospacedDigit() - .foregroundStyle(.secondary) - .frame(maxWidth: 60) - .multilineTextAlignment(.trailing) - } + Text("수축 \(context.state.contractSeconds)초 · 이완 \(context.state.relaxSeconds)초 · \(context.state.totalReps)회") + .font(.caption2) + .foregroundStyle(.secondary) } } } compactLeading: { - phaseIcon(context.state) - .opacity(context.isStale ? 0.4 : 1) + // 컴팩트에서도 살아 움직이는 미니 타임라인 — 플레이헤드 색 = 지금 국면 + timelineBar(context.state, height: 7) + .frame(width: 34) } compactTrailing: { - Text("\(context.state.rep)/\(context.state.totalReps)") + Text(timerInterval: context.state.sessionStart...context.state.sessionEnd, countsDown: true) .font(.caption2) .monospacedDigit() - .foregroundStyle(phaseColor(context.state)) - .opacity(context.isStale ? 0.4 : 1) + .foregroundStyle(Self.contractColor) + .frame(maxWidth: 44) + .multilineTextAlignment(.trailing) } minimal: { - phaseIcon(context.state) - .opacity(context.isStale ? 0.4 : 1) + phaseDots } } } - // MARK: 자가 렌더링 타임라인 (앱이 정지해도 시스템이 계속 그린다) + private static let prepColor = Color(hex: "#9AA8A0") + private static let contractColor = Color(hex: "#7FBF9E") + private static let relaxColor = Color(hex: "#E8C558") + + // MARK: 자가 렌더링 타임라인 (앱 갱신 없이 시스템이 계속 그린다) /// 국면 색 타임라인 바 + 실시간 플레이헤드. /// 바탕은 세트 일정 그대로(준비 회색 → [수축 민트·이완 앰버]×횟수)를 흐리게 깔고, @@ -142,7 +115,7 @@ struct KegingLiveActivity: Widget { let reps = max(1, state.totalReps) let total = prep + Double(reps) * (contract + relax) guard reps <= 40, total > 0 else { - return LinearGradient(colors: [Color(hex: "#7FBF9E")], startPoint: .leading, endPoint: .trailing) + return LinearGradient(colors: [Self.contractColor], startPoint: .leading, endPoint: .trailing) } var stops: [Gradient.Stop] = [] var elapsed = 0.0 @@ -152,25 +125,31 @@ struct KegingLiveActivity: Widget { elapsed += duration stops.append(.init(color: color, location: elapsed / total)) } - addSegment(Color(hex: "#9AA8A0"), prep) + addSegment(Self.prepColor, prep) for _ in 0.. Color { - switch state.phase { - case .prepare: Color(hex: "#9AA8A0") - case .contract: Color(hex: "#7FBF9E") - case .relax: Color(hex: "#E8C558") + /// 범례 항목 — 국면 색 점 + 이름 (영원히 참인 정적 정보) + private func legendItem(color: Color, label: Text) -> some View { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + label + .font(.footnote.weight(.medium)) + .foregroundStyle(.secondary) } } - private func phaseIcon(_ state: KegingActivityAttributes.ContentState) -> some View { - Image(systemName: state.phase.symbolName) - .font(.body.weight(.semibold)) - .foregroundStyle(phaseColor(state)) + /// 미니멀(다른 Live Activity와 공존) 표시 — 국면 두 색 점 + private var phaseDots: some View { + VStack(spacing: 2) { + Circle().fill(Self.contractColor).frame(width: 6, height: 6) + Circle().fill(Self.relaxColor).frame(width: 6, height: 6) + } } } diff --git a/myApp/Keging/KegingWidgets/Localizable.xcstrings b/myApp/Keging/KegingWidgets/Localizable.xcstrings index 1890171..64de646 100644 --- a/myApp/Keging/KegingWidgets/Localizable.xcstrings +++ b/myApp/Keging/KegingWidgets/Localizable.xcstrings @@ -1,50 +1,6 @@ { "sourceLanguage" : "ko", "strings" : { - "%lld / %lld" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld / %lld" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld / %lld" - } - }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$lld / %2$lld" - } - } - } - }, - "%lld/%lld" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld/%lld" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld/%lld" - } - }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$lld/%2$lld" - } - } - } - }, "기본" : { "localizations" : { "en" : { @@ -131,22 +87,6 @@ } } }, - "전체" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Total" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "全体" - } - } - } - }, "준비" : { "localizations" : { "en" : { diff --git a/myApp/Keging/Shared/KegingActivityAttributes.swift b/myApp/Keging/Shared/KegingActivityAttributes.swift index 32bd96f..2a08e12 100644 --- a/myApp/Keging/Shared/KegingActivityAttributes.swift +++ b/myApp/Keging/Shared/KegingActivityAttributes.swift @@ -4,10 +4,11 @@ // // Live Activity(다이나믹 아일랜드) 상태 — 앱·위젯 두 타깃에 동일 파일로 컴파일. // -// ⚠️설계 원칙(실기기 4차 피드백): ActivityKit엔 "미래 시점 상태 예약" API가 없어 -// 국면 전환 갱신은 앱 런타임에 의존한다. 그래서 상태에 세트 전체 일정(sessionStart· -// 준비/수축/이완/횟수)을 통째로 실어, 위젯이 타임라인 바·진행 플레이헤드·남은 시간을 -// timerInterval 계열(시스템 렌더)로 그리게 한다 — 앱이 정지해도 이 요소들은 계속 움직인다. +// ⚠️설계 원칙(실기기 5차 확정): 백그라운드 Live Activity 갱신은 시스템이 예산제로 +// 버리고(실측: 4번째부터), staleDate가 지나도 재렌더가 보장되지 않는다(시뮬 실측). +// 그래서 상태는 **세트 일정뿐** — 시작할 때 한 번 등록하면 갱신이 아예 필요 없고, +// 위젯은 전부 시스템 렌더 요소(타임라인 바·플레이헤드·남은 시간)로만 그린다. +// 애플 시계 앱 타이머와 같은 모델: 갱신할 데이터가 없으니 멈출 수도 없다. // #if canImport(ActivityKit) @@ -16,16 +17,12 @@ import Foundation nonisolated struct KegingActivityAttributes: ActivityAttributes { nonisolated struct ContentState: Codable, Hashable { - var phase: KegelPhase - var rep: Int - var totalReps: Int - var phaseStart: Date - var phaseEnd: Date // 자가 렌더링용 전체 일정 — 버튼 누른 시각부터 결정적 var sessionStart: Date var prepSeconds: Int var contractSeconds: Int var relaxSeconds: Int + var totalReps: Int var sessionEnd: Date { sessionStart.addingTimeInterval(