feat(keging): Live Activity 자가 렌더링 재설계 — 앱이 정지해도 움직이는 DI/잠금화면 (실기기 4차 피드백)
- ActivityKit엔 미래 상태 예약 API가 없음을 확인(푸시 서버 없이는 국면 갱신이 앱 런타임 의존) → 상태에 세트 전체 일정을 실어 시스템 렌더 요소로 심장을 재구성: 국면 색 타임라인 바(하드 스톱 그라데이션) + 실시간 플레이헤드 ProgressView(timerInterval:) + 전체 남은 시간. 플레이헤드가 걸친 색 = 지금 국면 - 국면 라벨·횟수는 앱 갱신 유지(syncCurrent 단일 진입점), stale 시 흐림 - 오디오 키퍼: 완전 무음 대신 ±2 LSB 디더 노이즈(무음 감지 정지 회피) + 백그라운드 전환 ~30초 브리지 태스크 - 통계 타임라인 점별 시각 라벨 제거(겹침 — 시각은 오늘 기록 목록에서) - 도움말·번역 갱신, CLAUDE.md 현행화. 시뮬: DI 백그라운드 80초+(10/20) 진행 확인 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
This commit is contained in:
parent
8f634c83f4
commit
32737d74fd
@ -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 플래그 + 알림 이중 경로로 처리
|
||||
|
||||
@ -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..<sampleCount {
|
||||
let v = Int16.random(in: -2...2)
|
||||
withUnsafeBytes(of: v.littleEndian) { samples.append(contentsOf: $0) }
|
||||
}
|
||||
d.append(samples)
|
||||
return d
|
||||
}()
|
||||
}
|
||||
|
||||
@ -62,7 +62,7 @@ struct HelpView: View {
|
||||
helpRow(
|
||||
symbol: "bolt.horizontal.circle",
|
||||
title: Text("다이나믹 아일랜드"),
|
||||
body: Text("운동 중에 앱을 나가면 다이나믹 아일랜드에 현재 몇 번째인지와 수축·이완 상태가 떠요. 길게 누르면 남은 시간과 진행 바가 보이는 큰 화면이 열려요. 진동도 계속 울리니까 다른 앱을 쓰면서도 운동을 이어갈 수 있어요.")
|
||||
body: Text("운동 중에 앱을 나가면 다이나믹 아일랜드와 잠금화면에 현재 몇 번째인지와 수축·이완 상태가 떠요. 길게 누르면 세트 전체를 국면 색으로 칠한 타임라인 바가 보이는 큰 화면이 열리는데, 이 바와 남은 시간 숫자는 시스템이 직접 그려서 항상 실시간으로 움직여요 — 밝게 채워진 줄의 끝이 걸친 색이 지금 국면이에요. 진동도 계속 울리니까 다른 앱을 쓰면서도 운동을 이어갈 수 있어요.")
|
||||
)
|
||||
helpRow(
|
||||
symbol: "square.and.arrow.up",
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
// LiveActivityManager.swift
|
||||
// Keging
|
||||
//
|
||||
// 다이나믹 아일랜드 Live Activity — 현재 횟수·수축/이완 국면 표시.
|
||||
// 국면 전환 콜백 + 하트비트(2초)가 모두 sync를 부르지만 상태가 실제로 바뀐 경우에만
|
||||
// 갱신을 보낸다. staleDate를 국면 종료+8초로 걸어, 앱이 정지해 갱신이 끊기면
|
||||
// 시스템이 stale로 표시(위젯 쪽에서 흐리게 처리)하게 한다.
|
||||
// 다이나믹 아일랜드 Live Activity — 세트 전체 일정을 상태에 실어 위젯이 스스로 그리게 한다
|
||||
// (타임라인 바·플레이헤드·남은 시간은 시스템 렌더라 앱이 정지해도 움직임 — §3.4).
|
||||
// 국면 전환 콜백 + 하트비트(2초)가 모두 syncCurrent를 부르지만 상태가 실제로 바뀐
|
||||
// 경우에만 갱신을 보낸다. staleDate(국면 종료+8초)가 지나면 위젯이 국면 정보를 흐린다.
|
||||
//
|
||||
|
||||
import ActivityKit
|
||||
@ -30,11 +30,19 @@ final class LiveActivityManager {
|
||||
lastState = nil
|
||||
}
|
||||
|
||||
/// 없으면 시작, 있으면 갱신 — 같은 상태면 아무것도 하지 않아 하트비트에서 불러도 안전
|
||||
func sync(phase: KegelPhase, rep: Int, totalReps: Int, phaseStart: Date, phaseEnd: Date) {
|
||||
/// 엔진의 현재 상태를 그대로 반영 — 없으면 시작, 있으면 갱신.
|
||||
/// 같은 상태면 아무것도 하지 않아 하트비트에서 매번 불러도 안전
|
||||
func syncCurrent() {
|
||||
let engine = KegelEngine.shared
|
||||
guard case let .running(phase, rep, phaseStart, phaseEnd) = engine.state else { return }
|
||||
let config = engine.config
|
||||
let state = KegingActivityAttributes.ContentState(
|
||||
phase: phase, rep: rep, totalReps: totalReps,
|
||||
phaseStart: phaseStart, phaseEnd: phaseEnd
|
||||
phase: phase, rep: rep, totalReps: config.reps,
|
||||
phaseStart: phaseStart, phaseEnd: phaseEnd,
|
||||
sessionStart: engine.sessionStart,
|
||||
prepSeconds: config.prepSeconds,
|
||||
contractSeconds: config.contractSeconds,
|
||||
relaxSeconds: config.relaxSeconds
|
||||
)
|
||||
let content = ActivityContent(state: state, staleDate: phaseEnd.addingTimeInterval(8))
|
||||
if let activity {
|
||||
@ -45,21 +53,12 @@ final class LiveActivityManager {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||
lastState = state
|
||||
activity = try? Activity.request(
|
||||
attributes: KegingActivityAttributes(startedAt: Date()),
|
||||
attributes: KegingActivityAttributes(startedAt: engine.sessionStart),
|
||||
content: content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 하트비트용 — 엔진의 현재 상태를 그대로 반영 (놓친 국면 전환 자가 치유)
|
||||
func syncCurrent() {
|
||||
guard case let .running(phase, rep, phaseStart, phaseEnd) = KegelEngine.shared.state else { return }
|
||||
sync(
|
||||
phase: phase, rep: rep, totalReps: KegelEngine.shared.config.reps,
|
||||
phaseStart: phaseStart, phaseEnd: phaseEnd
|
||||
)
|
||||
}
|
||||
|
||||
func end() {
|
||||
lastState = nil
|
||||
guard let activity else { return }
|
||||
|
||||
@ -1077,18 +1077,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"운동 중에 앱을 나가면 다이나믹 아일랜드에 현재 몇 번째인지와 수축·이완 상태가 떠요. 길게 누르면 남은 시간과 진행 바가 보이는 큰 화면이 열려요. 진동도 계속 울리니까 다른 앱을 쓰면서도 운동을 이어갈 수 있어요." : {
|
||||
"운동 중에 앱을 나가면 다이나믹 아일랜드와 잠금화면에 현재 몇 번째인지와 수축·이완 상태가 떠요. 길게 누르면 세트 전체를 국면 색으로 칠한 타임라인 바가 보이는 큰 화면이 열리는데, 이 바와 남은 시간 숫자는 시스템이 직접 그려서 항상 실시간으로 움직여요 — 밝게 채워진 줄의 끝이 걸친 색이 지금 국면이에요. 진동도 계속 울리니까 다른 앱을 쓰면서도 운동을 이어갈 수 있어요." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "If you leave the app during a workout, the Dynamic Island shows your current rep and whether to squeeze or relax. Press and hold it for a larger view with the remaining time and a progress bar. Vibrations keep playing, so you can continue while using other apps."
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ワークアウト中にアプリを離れると、Dynamic Islandに現在の回数と締める・ゆるめるの状態が表示されます。長押しすると残り時間と進行バーが見える大きな画面が開きます。振動も鳴り続けるので、他のアプリを使いながら続けられます。"
|
||||
"value" : "ワークアウト中にアプリを離れると、Dynamic Islandとロック画面に今何回目か、締める・ゆるめるの状態が表示されます。長押しすると、セット全体をフェーズの色で塗ったタイムラインバーが見える大きな画面が開きます。このバーと残り時間の数字はシステム自体が描画するため、常にリアルタイムで動き続けます — 明るく満ちていく線の先端が重なる色が今のフェーズです。振動も鳴り続けるので、他のアプリを使いながらでも運動を続けられます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -196,7 +196,6 @@ struct StatsView: View {
|
||||
let sessions = todaySessions
|
||||
let dayStart = calendar.startOfDay(for: Date())
|
||||
let dayEnd = dayStart.addingTimeInterval(24 * 3600)
|
||||
let showTimeLabels = sessions.count <= 5
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
Text("오늘의 타임라인")
|
||||
.font(.headline)
|
||||
@ -212,6 +211,7 @@ struct StatsView: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
// 시각 라벨은 겹침 문제로 뺐다 — 정확한 시각은 아래 '오늘 기록' 목록에서
|
||||
ForEach(sessions) { record in
|
||||
PointMark(
|
||||
x: .value("시각", record.startedAt),
|
||||
@ -219,13 +219,6 @@ struct StatsView: View {
|
||||
)
|
||||
.symbolSize(140 + Double(min(record.reps, 60)) * 3)
|
||||
.foregroundStyle(AppTheme.green)
|
||||
.annotation(position: .top, spacing: 5) {
|
||||
if showTimeLabels {
|
||||
Text(record.startedAt, format: .dateTime.hour().minute())
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXScale(domain: dayStart.addingTimeInterval(-1200)...dayEnd.addingTimeInterval(1200))
|
||||
|
||||
@ -27,7 +27,7 @@ final class WorkoutCoordinator {
|
||||
LiveActivityManager.shared.cleanupStaleActivities()
|
||||
|
||||
let engine = KegelEngine.shared
|
||||
engine.onPhaseChange = { [weak engine] phase, rep, phaseStart, phaseEnd in
|
||||
engine.onPhaseChange = { [weak engine] phase, _, _, _ in
|
||||
guard let engine else { return }
|
||||
let config = engine.config
|
||||
switch phase {
|
||||
@ -35,10 +35,7 @@ final class WorkoutCoordinator {
|
||||
case .contract: Haptics.play(config.contractPattern)
|
||||
case .relax: Haptics.play(config.relaxPattern)
|
||||
}
|
||||
LiveActivityManager.shared.sync(
|
||||
phase: phase, rep: rep, totalReps: config.reps,
|
||||
phaseStart: phaseStart, phaseEnd: phaseEnd
|
||||
)
|
||||
LiveActivityManager.shared.syncCurrent()
|
||||
}
|
||||
engine.onSessionEnd = { [weak self] completed, record in
|
||||
self?.stopHeartbeat()
|
||||
|
||||
@ -2,7 +2,14 @@
|
||||
// KegingWidgetsLiveActivity.swift
|
||||
// KegingWidgets
|
||||
//
|
||||
// 다이나믹 아일랜드·잠금화면 Live Activity — 현재 횟수와 수축/이완 국면 표시
|
||||
// 다이나믹 아일랜드·잠금화면 Live Activity.
|
||||
//
|
||||
// ⚠️설계 원칙(실기기 4차 피드백): 국면 라벨·횟수는 앱 갱신에 의존하지만, iOS가 앱을 정지시키면
|
||||
// 갱신이 끊긴다(ActivityKit엔 미래 상태 예약 API가 없음 — 애플 공식). 그래서 화면의 "심장"은
|
||||
// 시스템이 직접 그리는 요소로 만든다: 세트 전체를 국면 색으로 칠한 타임라인 바 + 그 위를
|
||||
// 실시간으로 채워가는 플레이헤드(ProgressView(timerInterval:)) + 남은 시간(Text(timerInterval:)).
|
||||
// 이 요소들은 앱이 정지해도 계속 움직이고, 플레이헤드가 걸친 색 = 지금 국면이다.
|
||||
// 국면 라벨·횟수는 staleDate(국면 종료+8초)가 지나면 흐려져 낡은 정보임을 드러낸다.
|
||||
//
|
||||
|
||||
import ActivityKit
|
||||
@ -12,10 +19,10 @@ import SwiftUI
|
||||
struct KegingLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: KegingActivityAttributes.self) { context in
|
||||
// 잠금화면·배너 — isStale이면(앱 정지로 갱신 끊김) 국면 정보를 흐리게
|
||||
HStack(spacing: 12) {
|
||||
phaseIcon(context.state)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
// 잠금화면·배너
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
phaseIcon(context.state)
|
||||
Text(context.state.phase.label)
|
||||
.font(.headline)
|
||||
.foregroundStyle(phaseColor(context.state))
|
||||
@ -23,14 +30,27 @@ struct KegingLiveActivity: Widget {
|
||||
.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)
|
||||
Spacer()
|
||||
Text(timerInterval: context.state.phaseStart...context.state.phaseEnd, countsDown: true)
|
||||
.font(.title2.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.frame(maxWidth: 64)
|
||||
.multilineTextAlignment(.trailing)
|
||||
timelineBar(context.state, height: 12)
|
||||
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)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
} dynamicIsland: { context in
|
||||
@ -49,16 +69,29 @@ struct KegingLiveActivity: Widget {
|
||||
Text("\(context.state.rep) / \(context.state.totalReps)")
|
||||
.font(.headline)
|
||||
.monospacedDigit()
|
||||
.opacity(context.isStale ? 0.4 : 1)
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(spacing: 6) {
|
||||
VStack(spacing: 8) {
|
||||
Text(timerInterval: context.state.phaseStart...context.state.phaseEnd, countsDown: true)
|
||||
.font(.title.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.multilineTextAlignment(.center)
|
||||
ProgressView(value: Double(context.state.rep), total: Double(max(1, context.state.totalReps)))
|
||||
.tint(phaseColor(context.state))
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
@ -77,6 +110,56 @@ struct KegingLiveActivity: Widget {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 자가 렌더링 타임라인 (앱이 정지해도 시스템이 계속 그린다)
|
||||
|
||||
/// 국면 색 타임라인 바 + 실시간 플레이헤드.
|
||||
/// 바탕은 세트 일정 그대로(준비 회색 → [수축 민트·이완 앰버]×횟수)를 흐리게 깔고,
|
||||
/// ProgressView(timerInterval:)가 지나간 만큼 밝은 줄로 채운다 — 줄 끝이 걸친 색 = 지금 국면
|
||||
private func timelineBar(_ state: KegingActivityAttributes.ContentState, height: CGFloat) -> 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..<reps {
|
||||
addSegment(Color(hex: "#7FBF9E"), contract)
|
||||
addSegment(Color(hex: "#E8C558"), relax)
|
||||
}
|
||||
return LinearGradient(stops: stops, startPoint: .leading, endPoint: .trailing)
|
||||
}
|
||||
|
||||
private func phaseColor(_ state: KegingActivityAttributes.ContentState) -> Color {
|
||||
switch state.phase {
|
||||
case .prepare: Color(hex: "#9AA8A0")
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user