diff --git a/myApp/Keging/CLAUDE.md b/myApp/Keging/CLAUDE.md index 4c0cb49..5f92844 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) | **실기기 3차 피드백 6건 반영 완료, 재테스트 대기.** 3차 수정: ①**DI 멈춤 근본 원인 제거** — CHHapticEngine을 기본 생성하면 독자 오디오 정책으로 무음 세션을 끊음 → `CHHapticEngine(audioSession:)` 부착으로 교체(§3.3) + 오디오 키퍼 하드닝(루트 변경·미디어 서버 리셋 대응, §3.4) + 운동 중 2초 하트비트(오디오 생존·엔진 재동기화·LA 갱신, §3.4) + LA staleDate/isStale 흐림 표시 ②워치 세팅 동기화 — 워치 앱이 열릴 때마다 sendMessage로 폰에서 최신 세팅 pull(폰 앱이 꺼져 있어도 깨움) + 폰 reachability 재푸시(§3.6, 동기화 버튼 불필요) ③완료 진동 신설 — 아주 긴 "우우우웅"(§3.3) ④통계 하루 탭 맨 위 '오늘의 타임라인'(24h 점 그래프, §3.2) ⑤칼로리 정합 — 워치 '기타' 운동은 애플 규칙상 "심박 vs 빠르게 걷기 중 큰 쪽"이라 폰 추정을 MET 4.0+강도×0.15(기본 4.3≈빠르게 걷기)로 상향(§3.7) ⑥포그라운드/백그라운드 진동 질감 차이는 iOS 제약(백그라운드 커스텀 햅틱 금지) — 도움말에 명시. 시뮬 검증: DI 백그라운드 3단계 진행(수축1→이완1→수축2/20)·타임라인 카드·완주 플로우·워치 구동, 3종 빌드 그린·경고 0·l10n 0/0/0. **재테스트 항목(사용자, 아이폰)**: DI 백그라운드 갱신(수 분 이상·화면 잠금 포함), 완료 진동, 워치 세팅 즉시 동기화, 칼로리(≈28kcal/5분대로 워치와 근접해야 함), 통계 타임라인 | +| 현재 상태 (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 확장·잠금화면 배너 레이아웃은 시뮬에서 열 수 없어 실기기에서 첫 확인 | ## 2. 빌드·검증 환경 @@ -117,7 +117,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시간 축 위 세션 점, 점 크기=횟수·시각 라벨(5개 이하일 때)·'지금' 점선 룰 상단 라벨, 기록 없으면 카드 숨김) → 합계 카드 → 시간대별 꺾은선 → 오늘 기록 목록. 주간·월간(일별 꺾은선) + 세트·횟수 합계 카드 +- 통계 화면: 첫 화면 **위로 스와이프**(또는 하단 손잡이 탭)로 아래에서 **통통 튀는 스프링**(dampingFraction 0.62)으로 올라오는 오버레이 — 아래로 스와이프·상단 손잡이·X로 닫기(2차 실기기 피드백으로 방향 반전). 하루 탭 구성(위에서부터): **'오늘의 타임라인'**(3차 피드백 — 24시간 축 위 세션 점, 점 크기=횟수·'지금' 점선 룰 상단 라벨, 기록 없으면 카드 숨김. 점별 시각 라벨은 겹침 문제로 제외(4차) — 시각은 아래 목록에서) → 합계 카드 → 시간대별 꺾은선 → 오늘 기록 목록. 주간·월간(일별 꺾은선) + 세트·횟수 합계 카드 - 주간·월간은 **달력 기준(이번 주·이번 달) ↔ 오늘 기준 롤링(지난 7일·30일)** 세그먼트 전환. **주 시작 = 월요일** (`calendar.firstWeekday = 2`) ### 3.3 진동 (`Shared/WorkoutSettings.swift`, `IOS/Haptics.swift`, 워치 `WatchHaptics.swift`) @@ -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초 버퍼) 무한 루프(`UIBackgroundModes=audio`, `.playback + .mixWithOthers`, **volume 0.1**·prepareToPlay — 0.0은 백그라운드 유지가 풀리는 사례 대비) → 앱을 나가도 타이머·진동·Live Activity 갱신 유지. **ensureAlive() 단일 진입점**: 인터럽션 종료·**루트 변경(이어폰 해제 등)**·**미디어 서버 리셋(플레이어 무효 → 재구축)**·백그라운드/활성 전환 옵저버 + **운동 중 2초 하트비트**(WorkoutCoordinator — 오디오 생존 점검·`resyncAfterWake()`·`LiveActivityManager.syncCurrent()`)가 모두 호출. 놓친 국면 경계도 2초 안에 자가 치유. 종료·중단 시 해제(완주는 완료 진동 체인 보호를 위해 4초 지연). 운동 중 `isIdleTimerDisabled`로 화면 꺼짐 방지(종료 시 복원) -- Live Activity: `sync(phase:rep:totalReps:phaseStart:phaseEnd:)` — 실제 phaseStart를 쓰고 **같은 상태면 no-op**(하트비트에서 불러도 안전), `staleDate = phaseEnd+8초`. 다이나믹 아일랜드 컴팩트(국면 아이콘 + n/총), 확장(국면·카운트다운·진행 바), 잠금화면 배너 — **isStale이면 국면 정보를 40% 투명도로**(갱신 끊김 시각화). 종료·중단 시 `.immediate` dismiss. **앱 시작 시 `cleanupStaleActivities()`** — 운동 중 강제 종료로 남은 유령 DI를 정리. 수축=민트 `#7FBF9E`·이완=앰버 `#E8C558` 고정색. 시뮬 실측(3차): 백그라운드에서 수축1→이완1→수축2/20 진행 확인 +- 운동 시작 시 초저레벨 노이즈 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 확장·잠금 배너는 시뮬로 못 열어 실기기 확인 필요 ### 3.5 단축어 (`IOS/KegingIntents.swift`) - `StartKegelIntent` 하나 — 앱을 열고(`openAppWhenRun`) 저장된 세팅대로 즉시 시작. 콜드 스타트 경합은 AppLaunchState 플래그 + 알림 이중 경로로 처리 diff --git a/myApp/Keging/IOS/BackgroundAudioKeeper.swift b/myApp/Keging/IOS/BackgroundAudioKeeper.swift index e2b582a..ccfbc93 100644 --- a/myApp/Keging/IOS/BackgroundAudioKeeper.swift +++ b/myApp/Keging/IOS/BackgroundAudioKeeper.swift @@ -21,6 +21,8 @@ final class BackgroundAudioKeeper { private var observers: [NSObjectProtocol] = [] /// 운동 중 여부 — stop() 후에 늦게 도착한 알림이 재생을 되살리는 것 방지 private var isActive = false + /// 백그라운드 전환 직후 ~30초 유예 태스크 — 전환 순간 오디오가 죽어도 되살릴 시간을 번다 + private var bridgeTask: UIBackgroundTaskIdentifier = .invalid private init() {} @@ -33,6 +35,7 @@ final class BackgroundAudioKeeper { func stop() { isActive = false removeObservers() + endBridgeTask() player?.stop() player = nil try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) @@ -100,12 +103,24 @@ final class BackgroundAudioKeeper { } }) - for name in [UIApplication.didEnterBackgroundNotification, UIApplication.didBecomeActiveNotification] { - observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in - guard let self else { return } - Task { @MainActor in self.ensureAlive() } - }) - } + observers.append(center.addObserver( + forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main + ) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.beginBridgeTask() + self.ensureAlive() + } + }) + observers.append(center.addObserver( + forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.endBridgeTask() + self.ensureAlive() + } + }) } private func removeObservers() { @@ -113,11 +128,27 @@ final class BackgroundAudioKeeper { observers.removeAll() } - /// 20초짜리 무음 WAV (8kHz 16bit mono) — 파일 없이 메모리에서 생성. + private func beginBridgeTask() { + guard isActive, bridgeTask == .invalid else { return } + bridgeTask = UIApplication.shared.beginBackgroundTask(withName: "keging.audio.bridge") { [weak self] in + Task { @MainActor in self?.endBridgeTask() } + } + } + + private func endBridgeTask() { + guard bridgeTask != .invalid else { return } + UIApplication.shared.endBackgroundTask(bridgeTask) + bridgeTask = .invalid + } + + /// 20초짜리 초저레벨 노이즈 WAV (8kHz 16bit mono, ±2 LSB ≈ -84dBFS) — 파일 없이 메모리에서 생성. + /// ⚠️완전한 0 샘플은 시스템이 '실질 무음'으로 보고 백그라운드 앱을 정지시킬 여지가 있어 + /// 들리지 않는 디더 노이즈를 깐다(volume 0.1을 곱하면 -100dB대 — 어떤 기기에서도 무음). /// 루프 경계 이벤트를 줄이기 위해 넉넉한 길이로 만든다 private static let silenceWAV: Data = { let sampleRate = 8000 - let dataSize = sampleRate * 2 * 20 + let sampleCount = sampleRate * 20 + let dataSize = sampleCount * 2 var d = Data() func append(_ s: String) { d.append(s.data(using: .ascii)!) } func appendU32(_ v: UInt32) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } } @@ -126,7 +157,12 @@ final class BackgroundAudioKeeper { append("fmt "); appendU32(16); appendU16(1); appendU16(1) appendU32(UInt32(sampleRate)); appendU32(UInt32(sampleRate * 2)); appendU16(2); appendU16(16) append("data"); appendU32(UInt32(dataSize)) - d.append(Data(count: dataSize)) + var samples = Data(capacity: dataSize) + for _ in 0.. some View { + ZStack { + RoundedRectangle(cornerRadius: height / 2) + .fill(timelineGradient(state)) + .opacity(0.45) + .frame(height: height) + ProgressView( + timerInterval: state.sessionStart...state.sessionEnd, + countsDown: false, + label: { EmptyView() }, + currentValueLabel: { EmptyView() } + ) + .progressViewStyle(.linear) + .tint(.white) + .frame(height: height) + } + } + + /// 세트 일정을 하드 스톱 그라데이션 한 장으로 — 뷰 수를 늘리지 않아 횟수가 많아도 가볍다. + /// 100회처럼 줄무늬가 화소보다 얇아지는 경우는 단색으로 대체 + private func timelineGradient(_ state: KegingActivityAttributes.ContentState) -> LinearGradient { + let prep = Double(max(0, state.prepSeconds)) + let contract = Double(max(1, state.contractSeconds)) + let relax = Double(max(1, state.relaxSeconds)) + 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) + } + var stops: [Gradient.Stop] = [] + var elapsed = 0.0 + func addSegment(_ color: Color, _ duration: Double) { + guard duration > 0 else { return } + stops.append(.init(color: color, location: elapsed / total)) + elapsed += duration + stops.append(.init(color: color, location: elapsed / total)) + } + addSegment(Color(hex: "#9AA8A0"), prep) + for _ in 0.. Color { switch state.phase { case .prepare: Color(hex: "#9AA8A0") diff --git a/myApp/Keging/KegingWidgets/Localizable.xcstrings b/myApp/Keging/KegingWidgets/Localizable.xcstrings index 9d88016..1890171 100644 --- a/myApp/Keging/KegingWidgets/Localizable.xcstrings +++ b/myApp/Keging/KegingWidgets/Localizable.xcstrings @@ -1,200 +1,216 @@ { - "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/%lld": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld/%lld" + "%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" } } } }, - "기본": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Standard" + "기본" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "標準" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "標準" } } } }, - "기타": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Other" + "기타" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Other" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "その他" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "その他" } } } }, - "수축": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Squeeze" + "수축" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Squeeze" } }, - "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" + "수축 %lld초 · 이완 %lld초 · %lld회" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Squeeze %lld s · Relax %lld s · ×%lld" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "締める%lld秒 · ゆるめる%lld秒 · %lld回" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "締める%lld秒 · ゆるめる%lld秒 · %lld回" } }, - "ko": { - "stringUnit": { - "state": "new", - "value": "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회" + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "수축 %1$lld초 · 이완 %2$lld초 · %3$lld회" } } } }, - "이완": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Relax" + "이완" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relax" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ゆるめる" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ゆるめる" } } } }, - "준비": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Ready" + "전체" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "準備" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "全体" } } } }, - "지이이잉": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Long buzz" + "준비" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ready" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ブーッ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "準備" } } } }, - "코어 트레이닝": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Core Training" + "지이이잉" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Long buzz" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "コアトレーニング" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブーッ" } } } }, - "톡톡": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Double tap" + "코어 트레이닝" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Core Training" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "トントン" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コアトレーニング" + } + } + } + }, + "톡톡" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double tap" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "トントン" } } } } }, - "version": "1.0" + "version" : "1.0" } \ No newline at end of file diff --git a/myApp/Keging/Shared/KegelEngine.swift b/myApp/Keging/Shared/KegelEngine.swift index 0047c46..8c4ce54 100644 --- a/myApp/Keging/Shared/KegelEngine.swift +++ b/myApp/Keging/Shared/KegelEngine.swift @@ -44,8 +44,8 @@ final class KegelEngine: ObservableObject { /// 기록·건강의 시작 시각 — 준비를 뺀 첫 수축 시각 (스케줄상 확정값) private(set) var startedAt = Date() /// 시작 버튼을 누른 시각 — 모든 국면은 이 시각 기준 벽시계로 계산한다 - /// (백그라운드 정지 후 복귀해도 재동기화로 정확한 국면에 착지) - private var sessionStart = Date() + /// (백그라운드 정지 후 복귀해도 재동기화로 정확한 국면에 착지, Live Activity 일정의 기준점) + private(set) var sessionStart = Date() private var timer: Timer? /// 국면 진입마다 호출 (phase, rep, phaseStart, phaseEnd) diff --git a/myApp/Keging/Shared/KegingActivityAttributes.swift b/myApp/Keging/Shared/KegingActivityAttributes.swift index f805139..32bd96f 100644 --- a/myApp/Keging/Shared/KegingActivityAttributes.swift +++ b/myApp/Keging/Shared/KegingActivityAttributes.swift @@ -2,7 +2,12 @@ // KegingActivityAttributes.swift // Keging // -// Live Activity(다이나믹 아일랜드) 상태 — 앱·위젯 두 타깃에 동일 파일로 컴파일 +// Live Activity(다이나믹 아일랜드) 상태 — 앱·위젯 두 타깃에 동일 파일로 컴파일. +// +// ⚠️설계 원칙(실기기 4차 피드백): ActivityKit엔 "미래 시점 상태 예약" API가 없어 +// 국면 전환 갱신은 앱 런타임에 의존한다. 그래서 상태에 세트 전체 일정(sessionStart· +// 준비/수축/이완/횟수)을 통째로 실어, 위젯이 타임라인 바·진행 플레이헤드·남은 시간을 +// timerInterval 계열(시스템 렌더)로 그리게 한다 — 앱이 정지해도 이 요소들은 계속 움직인다. // #if canImport(ActivityKit) @@ -16,6 +21,17 @@ nonisolated struct KegingActivityAttributes: ActivityAttributes { var totalReps: Int var phaseStart: Date var phaseEnd: Date + // 자가 렌더링용 전체 일정 — 버튼 누른 시각부터 결정적 + var sessionStart: Date + var prepSeconds: Int + var contractSeconds: Int + var relaxSeconds: Int + + var sessionEnd: Date { + sessionStart.addingTimeInterval( + Double(prepSeconds + totalReps * (contractSeconds + relaxSeconds)) + ) + } } var startedAt: Date = Date()