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:
songyc macbook 2026-09-02 20:24:23 +09:00
parent f43057d481
commit 52a752702e
4 changed files with 44 additions and 126 deletions

View File

@ -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 플래그 + 알림 이중 경로로 처리

View File

@ -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" : {

View File

@ -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(

View File

@ -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)