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:
songyc macbook 2026-09-02 16:26:29 +09:00
parent 8f634c83f4
commit 32737d74fd
11 changed files with 330 additions and 190 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) | **실기기 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 플래그 + 알림 이중 경로로 처리

View File

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

View File

@ -62,7 +62,7 @@ struct HelpView: View {
helpRow(
symbol: "bolt.horizontal.circle",
title: Text("다이나믹 아일랜드"),
body: Text("운동 중에 앱을 나가면 다이나믹 아일랜드에 현재 몇 번째인지와 수축·이완 상태가 떠요. 길게 누르면 남은 시간과 진행 바가 보이는 큰 화면이 열려요. 진동도 계속 울리니까 다른 앱을 쓰면서도 운동을 이어갈 수 있어요.")
body: Text("운동 중에 앱을 나가면 다이나믹 아일랜드와 잠금화면에 현재 몇 번째인지와 수축·이완 상태가 떠요. 길게 누르면 세트 전체를 국면 색으로 칠한 타임라인 바가 보이는 큰 화면이 열리는데, 이 바와 남은 시간 숫자는 시스템이 직접 그려서 항상 실시간으로 움직여요 — 밝게 채워진 줄의 끝이 걸친 색이 지금 국면이에요. 진동도 계속 울리니까 다른 앱을 쓰면서도 운동을 이어갈 수 있어요.")
)
helpRow(
symbol: "square.and.arrow.up",

View File

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

View File

@ -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とロック画面に今何回目か、締める・ゆるめるの状態が表示されます。長押しすると、セット全体をフェーズの色で塗ったタイムラインバーが見える大きな画面が開きます。このバーと残り時間の数字はシステム自体が描画するため、常にリアルタイムで動き続けます — 明るく満ちていく線の先端が重なる色が今のフェーズです。振動も鳴り続けるので、他のアプリを使いながらでも運動を続けられます。"
}
}
}

View File

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

View File

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

View File

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

View File

@ -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"
}

View File

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

View File

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