From 52a752702e6cfba193bfe85080963625587eab49 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Wed, 2 Sep 2026 20:24:23 +0900 Subject: [PATCH] =?UTF-8?q?polish(keging):=20DI=20=EC=BB=B4=ED=8C=A9?= =?UTF-8?q?=ED=8A=B8=C2=B7=ED=99=95=EC=9E=A5=20=EC=9E=AC=EA=B5=AC=EC=84=B1?= =?UTF-8?q?=20+=20=ED=86=B5=EA=B3=84=20=ED=95=98=EB=A3=A8=20=ED=83=AD=20?= =?UTF-8?q?=EA=BA=BE=EC=9D=80=EC=84=A0=20=EC=A0=9C=EA=B1=B0=20(=EC=8B=A4?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0=206=EC=B0=A8=20=ED=94=BC=EB=93=9C=EB=B0=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 컴팩트 왼쪽: 판독 불가한 미니 타임라인 → 국면 두 색 점(민트·앰버), 오른쪽 남은 시간 카운트다운 유지 - 확장(길게 누름): 잠금화면 실시간 현황과 동일 구성(liveStatusView 공용 — 범례+남은 시간+타임라인 바 14pt+세트 구성), 잠금화면은 그대로 - 통계 하루 탭: 시간대별 꺾은선 카드 제거(타임라인과 중복) — 타임라인+합계+ 오늘 기록만. 주간·월간 '일별 횟수' 꺾은선은 유지 - 미사용 코드(dayChart·hourCounts)와 stale 키('시간대별 횟수'·'시') 정리 - 시뮬 검증: 하루/주간 탭·DI 컴팩트(점+4:56 카운트다운), 3종 빌드 그린 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT --- myApp/Keging/CLAUDE.md | 6 +- myApp/Keging/IOS/Localizable.xcstrings | 32 -------- myApp/Keging/IOS/StatsView.swift | 55 ++----------- .../KegingWidgetsLiveActivity.swift | 77 +++++++++---------- 4 files changed, 44 insertions(+), 126 deletions(-) diff --git a/myApp/Keging/CLAUDE.md b/myApp/Keging/CLAUDE.md index 4465a0b..0c6d2da 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) | **실기기 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 컴팩트(미니 타임라인+남은 시간)·확장·잠금화면 배너(범례+큰 남은 시간+바+세트 구성)가 세트 끝까지 흐르는지 — 이제 갱신이 없어 구조적으로 동결 불가 ②완료 진동·백그라운드 진동(이미 정상 확인됨)·워치 세팅 동기화·칼로리 | +| 현재 상태 (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 컴팩트(미니 타임라인+남은 시간)·확장·잠금화면 배너(범례+큰 남은 시간+바+세트 구성)가 세트 끝까지 흐르는지 — 이제 갱신이 없어 구조적으로 동결 불가 ②완료 진동·백그라운드 진동(이미 정상 확인됨)·워치 세팅 동기화·칼로리 | ## 2. 빌드·검증 환경 @@ -118,7 +118,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] ### 3.2 기록·통계 (`Shared/SessionRecord.swift`, `IOS/StatsView.swift`) - SessionRecord: id(UUID)·startedAt(시작 시각)·reps·수축/이완초. 저장은 App Group `records.json`(JSON, atomic). append는 id 중복 병합이라 워치 재전송에도 안전 -- 통계 화면: 첫 화면 **위로 스와이프**(또는 하단 손잡이 탭)로 아래에서 **통통 튀는 스프링**(dampingFraction 0.62)으로 올라오는 오버레이 — 아래로 스와이프·상단 손잡이·X로 닫기(2차 실기기 피드백으로 방향 반전). 하루 탭 구성(위에서부터): **'오늘의 타임라인'**(3차 피드백 — 24시간 축 위 세션 점, 점 크기=횟수·'지금' 점선 룰 상단 라벨, 기록 없으면 카드 숨김. 점별 시각 라벨은 겹침 문제로 제외(4차) — 시각은 아래 목록에서) → 합계 카드 → 시간대별 꺾은선 → 오늘 기록 목록. 주간·월간(일별 꺾은선) + 세트·횟수 합계 카드 +- 통계 화면: 첫 화면 **위로 스와이프**(또는 하단 손잡이 탭)로 아래에서 **통통 튀는 스프링**(dampingFraction 0.62)으로 올라오는 오버레이 — 아래로 스와이프·상단 손잡이·X로 닫기(2차 실기기 피드백으로 방향 반전). 하루 탭 구성(위에서부터): **'오늘의 타임라인'**(3차 피드백 — 24시간 축 위 세션 점, 점 크기=횟수·'지금' 점선 룰 상단 라벨, 기록 없으면 카드 숨김. 점별 시각 라벨은 겹침 문제로 제외(4차) — 시각은 아래 목록에서) → 합계 카드 → 오늘 기록 목록(**시간대별 꺾은선은 타임라인과 중복이라 제거 — 6차**). 주간·월간(일별 꺾은선 '일별 횟수') + 세트·횟수 합계 카드 - 주간·월간은 **달력 기준(이번 주·이번 달) ↔ 오늘 기준 롤링(지난 7일·30일)** 세그먼트 전환. **주 시작 = 월요일** (`calendar.firstWeekday = 2`) ### 3.3 진동 (`Shared/WorkoutSettings.swift`, `IOS/Haptics.swift`, 워치 `WatchHaptics.swift`) @@ -129,7 +129,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@] ### 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`로 화면 꺼짐 방지(종료 시 복원) -- 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는 동결된다 — 이 원칙을 깨지 말 것 | +- 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회"). **잠금화면·배너 = 확장(길게 누름) = 같은 실시간 현황**(`liveStatusView` 공용 — 범례+남은 시간+바 14pt+세트 구성. 6차: 확장을 잠금화면과 동일 구성으로), 컴팩트 = 국면 두 색 점+남은 시간(6차: 미니 타임라인은 컴팩트 크기에서 판독 불가라 제거), 미니멀 = 국면 두 색 점. **국면 라벨·횟수 텍스트는 LA에 없다**(국면=진동+플레이헤드, 횟수=앱). 앱 시작 시 `cleanupStaleActivities()`(유령 DI 정리). 시뮬 실측(5차): 등록 후 갱신 0회로 35초간 4:57→4:27 정확 진행. ⚠️새 갱신 요소를 추가하고 싶어도 백그라운드 update는 동결된다 — 이 원칙을 깨지 말 것 | ### 3.5 단축어 (`IOS/KegingIntents.swift`) - `StartKegelIntent` 하나 — 앱을 열고(`openAppWhenRun`) 저장된 세팅대로 즉시 시작. 콜드 스타트 경합은 AppLaunchState 플래그 + 알림 이중 경로로 처리 diff --git a/myApp/Keging/IOS/Localizable.xcstrings b/myApp/Keging/IOS/Localizable.xcstrings index fa08d3f..6b8cba5 100644 --- a/myApp/Keging/IOS/Localizable.xcstrings +++ b/myApp/Keging/IOS/Localizable.xcstrings @@ -623,22 +623,6 @@ } } }, - "시" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hour" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "時" - } - } - } - }, "시각" : { "localizations" : { "en" : { @@ -655,22 +639,6 @@ } } }, - "시간대별 횟수" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reps by Hour" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "時間帯別の回数" - } - } - } - }, "시스템" : { "localizations" : { "en" : { diff --git a/myApp/Keging/IOS/StatsView.swift b/myApp/Keging/IOS/StatsView.swift index 109867d..ed57747 100644 --- a/myApp/Keging/IOS/StatsView.swift +++ b/myApp/Keging/IOS/StatsView.swift @@ -2,8 +2,9 @@ // StatsView.swift // Keging // -// 통계 — 하루·주간·월간, 꺾은선 그래프. 주간·월간은 달력 기준 ↔ 오늘 기준 롤링 전환. -// 하루 탭 맨 위에는 '오늘 언제 했는지' 한눈에 보이는 타임라인(24시간 점 그래프). +// 통계 — 하루(타임라인+합계+오늘 기록)·주간·월간(일별 꺾은선, 달력 기준 ↔ 롤링 전환). +// 하루 탭 맨 위는 '오늘 언제 했는지' 한눈에 보이는 타임라인(24시간 점 그래프) — +// 시간대별 꺾은선은 타임라인과 중복이라 제거(실기기 6차 피드백). // 첫 화면에서 위로 스와이프해 올라오고, 아래로 스와이프하거나 X로 닫는다. // @@ -59,7 +60,7 @@ struct StatsView: View { VStack(spacing: 14) { if scope == .day, !todaySessions.isEmpty { timelineCard } summaryCard - chartCard + if scope != .day { chartCard } if scope == .day { sessionListCard } } .padding(.horizontal, 20) @@ -133,12 +134,6 @@ struct StatsView: View { var id: Date { date } } - private struct HourCount: Identifiable { - let hour: Int - let reps: Int - var id: Int { hour } - } - /// 현재 범위의 날짜(자정) 목록 private var rangeDates: [Date] { let today = calendar.startOfDay(for: Date()) @@ -172,16 +167,6 @@ struct StatsView: View { } } - private var hourCounts: [HourCount] { - let todayRecords = records(on: calendar.startOfDay(for: Date())) - var byHour: [Int: Int] = [:] - for record in todayRecords { - let hour = calendar.component(.hour, from: record.startedAt) - byHour[hour, default: 0] += record.reps - } - return (0..<24).map { HourCount(hour: $0, reps: byHour[$0] ?? 0) } - } - private var totalSets: Int { dayCounts.reduce(0) { $0 + $1.sets } } private var totalReps: Int { dayCounts.reduce(0) { $0 + $1.reps } } @@ -266,17 +251,16 @@ struct StatsView: View { .frame(maxWidth: .infinity) } + /// 주간·월간 전용 — 하루 탭은 타임라인 카드가 대신한다 private var chartCard: some View { VStack(alignment: .leading, spacing: 12) { - Text(scope == .day ? "시간대별 횟수" : "일별 횟수") + Text("일별 횟수") .font(.headline) if totalSets == 0 { Text("이 기간에는 아직 기록이 없어요.") .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, minHeight: 120) - } else if scope == .day { - dayChart } else { rangeChart } @@ -286,33 +270,6 @@ struct StatsView: View { .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16)) } - private var dayChart: some View { - Chart(hourCounts) { item in - LineMark( - x: .value("시", item.hour), - y: .value("횟수", item.reps) - ) - .foregroundStyle(AppTheme.green) - if item.reps > 0 { - PointMark( - x: .value("시", item.hour), - y: .value("횟수", item.reps) - ) - .foregroundStyle(AppTheme.green) - } - } - .chartXScale(domain: 0...23) - .chartXAxis { - AxisMarks(values: [0, 6, 12, 18, 23]) { value in - AxisGridLine() - AxisValueLabel { - if let hour = value.as(Int.self) { Text("\(hour)시") } - } - } - } - .frame(height: 200) - } - private var rangeChart: some View { Chart(dayCounts) { item in LineMark( diff --git a/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift b/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift index 9361014..8bbcfed 100644 --- a/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift +++ b/myApp/Keging/KegingWidgets/KegingWidgetsLiveActivity.swift @@ -13,6 +13,10 @@ // ③ 나머지는 영원히 참인 정적 정보(범례·세트 구성)뿐이다. // 전부 시스템이 직접 그리므로 앱이 갱신을 못 보내도 끝까지 멈추지 않는다. // +// 구성(실기기 6차 피드백): 잠금화면 = 실시간 현황(사용자 확인 완료 형태 — 유지), +// 확장(길게 누름) = 잠금화면과 같은 실시간 현황, 컴팩트 = 국면 두 색 점 + 남은 시간 +// (미니 타임라인 바는 컴팩트 크기에선 판독 불가라 제거). +// import ActivityKit import WidgetKit @@ -21,51 +25,19 @@ import SwiftUI struct KegingLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: KegingActivityAttributes.self) { context in - // 잠금화면·배너 - 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(.title3.weight(.semibold)) - .monospacedDigit() - .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) + // 잠금화면·배너 — 실시간 현황 + liveStatusView(context.state, barHeight: 14) + .padding(14) } dynamicIsland: { context in DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - legendItem(color: Self.contractColor, label: Text("수축")) - .padding(.leading, 4) - } - DynamicIslandExpandedRegion(.trailing) { - legendItem(color: Self.relaxColor, label: Text("이완")) - .padding(.trailing, 4) - } + // 확장(길게 누름) — 잠금화면 실시간 현황과 같은 구성 DynamicIslandExpandedRegion(.bottom) { - VStack(spacing: 8) { - Text(timerInterval: context.state.sessionStart...context.state.sessionEnd, countsDown: true) - .font(.title.weight(.semibold)) - .monospacedDigit() - .multilineTextAlignment(.center) - timelineBar(context.state, height: 10) - Text("수축 \(context.state.contractSeconds)초 · 이완 \(context.state.relaxSeconds)초 · \(context.state.totalReps)회") - .font(.caption2) - .foregroundStyle(.secondary) - } + liveStatusView(context.state, barHeight: 14) + .padding(.horizontal, 4) + .padding(.top, 6) } } compactLeading: { - // 컴팩트에서도 살아 움직이는 미니 타임라인 — 플레이헤드 색 = 지금 국면 - timelineBar(context.state, height: 7) - .frame(width: 34) + phaseDots } compactTrailing: { Text(timerInterval: context.state.sessionStart...context.state.sessionEnd, countsDown: true) .font(.caption2) @@ -83,7 +55,28 @@ struct KegingLiveActivity: Widget { private static let contractColor = Color(hex: "#7FBF9E") private static let relaxColor = Color(hex: "#E8C558") - // MARK: 자가 렌더링 타임라인 (앱 갱신 없이 시스템이 계속 그린다) + // MARK: 실시간 현황 (잠금화면·확장 공용 — 전부 시스템 렌더 요소) + + /// 범례 + 남은 시간 + 국면 색 타임라인 바 + 세트 구성 — 앱 갱신 없이 끝까지 움직인다 + private func liveStatusView(_ state: KegingActivityAttributes.ContentState, barHeight: CGFloat) -> some View { + VStack(spacing: 9) { + HStack(spacing: 12) { + legendItem(color: Self.contractColor, label: Text("수축")) + legendItem(color: Self.relaxColor, label: Text("이완")) + Spacer() + Text(timerInterval: state.sessionStart...state.sessionEnd, countsDown: true) + .font(.title3.weight(.semibold)) + .monospacedDigit() + .frame(maxWidth: 64) + .multilineTextAlignment(.trailing) + } + timelineBar(state, height: barHeight) + Text("수축 \(state.contractSeconds)초 · 이완 \(state.relaxSeconds)초 · \(state.totalReps)회") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } /// 국면 색 타임라인 바 + 실시간 플레이헤드. /// 바탕은 세트 일정 그대로(준비 회색 → [수축 민트·이완 앰버]×횟수)를 흐리게 깔고, @@ -145,7 +138,7 @@ struct KegingLiveActivity: Widget { } } - /// 미니멀(다른 Live Activity와 공존) 표시 — 국면 두 색 점 + /// 컴팩트·미니멀 표시 — 국면 두 색 점 (케깅 아이덴티티) private var phaseDots: some View { VStack(spacing: 2) { Circle().fill(Self.contractColor).frame(width: 6, height: 6)