polish(keging): DI 컴팩트·확장 재구성 + 통계 하루 탭 꺾은선 제거 (실기기 6차 피드백)
- 컴팩트 왼쪽: 판독 불가한 미니 타임라인 → 국면 두 색 점(민트·앰버), 오른쪽
남은 시간 카운트다운 유지
- 확장(길게 누름): 잠금화면 실시간 현황과 동일 구성(liveStatusView 공용 —
범례+남은 시간+타임라인 바 14pt+세트 구성), 잠금화면은 그대로
- 통계 하루 탭: 시간대별 꺾은선 카드 제거(타임라인과 중복) — 타임라인+합계+
오늘 기록만. 주간·월간 '일별 횟수' 꺾은선은 유지
- 미사용 코드(dayChart·hourCounts)와 stale 키('시간대별 횟수'·'시') 정리
- 시뮬 검증: 하루/주간 탭·DI 컴팩트(점+4:56 카운트다운), 3종 빌드 그린
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
This commit is contained in:
parent
f43057d481
commit
52a752702e
@ -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 플래그 + 알림 이중 경로로 처리
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user