polish(keging): 전면 자가 점검 — 클린룸 빌드 검증·유령 DI 정리·진동 체인 취소·경고 0
- 'cannot find in scope' 판정: 새 DerivedData 클린룸 빌드 3종(Debug/Release/워치) 전부 그린 → SourceKit 편집기 오탐 확정 (실제 컴파일 에러 0) - 강제 종료 유령 Live Activity 정리: 운동 중 kill 시 DI가 몇 시간 잔존(시뮬 재현) → 앱 시작 시 cleanupStaleActivities()로 즉시 소멸(실측 확인) - 진동 체인 세대 토큰: 짧은 수축·이완에서 이전 패턴 체인이 다음 국면 첫 진동을 삼키는 엣지 방지, Haptics를 Task 기반으로 재작성해 Swift 6 경고 3건 전부 제거 - 워치에도 -noHealth QA 인자 추가 - 시뮬 실측: DI 백그라운드 갱신(수축 그린→이완 앰버)·포그라운드 복귀 벽시계 재동기화 (5/20 정확 착지)·유령 DI 정리·주간 통계 검산(11세트/220회)·완주 경로·워치 실행· 18.5 빌드 그린·전 카탈로그 missing/stale/ASCII~ 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
This commit is contained in:
parent
f5f8d3bde4
commit
3cee5f08af
@ -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-08-29) | **1.0 전 기능 구현 + 실기기(아이폰) 1차 피드백 3건 수정, 재테스트 대기.** 1차 발견·수정: ①다이나믹 아일랜드 백그라운드 멈춤 → 오디오 키퍼 하드닝 + 엔진 벽시계 재설계(§3.1·§3.4) ②진동 패턴 동일 현상 → 완료 체이닝 재작성(§3.3) ③운동 칼로리 0 → 에너지 샘플 직접 추가(§3.7). Debug·Release·워치 빌드 그린, 카탈로그 0/0, 완주 경로 시뮬 재검증. **재테스트 항목(사용자)**: DI 백그라운드 갱신, 진동 4종 구분, 칼로리 표기(+재권한 시트에 '활성 에너지' 추가됨 — 허용 필요), 워치(권한에 심박·에너지 읽기 추가) |
|
||||
| 현재 상태 (2026-08-29 심야) | **1.0 전 기능 구현 + 실기기 1차 피드백 3건 수정 + 전면 자가 점검 완료, 아침 재빌드 대기.** 1차 수정: ①DI 백그라운드 멈춤 → 오디오 키퍼 하드닝+엔진 벽시계 재설계 ②진동 패턴 동일 → 완료 체이닝 ③칼로리 0 → 에너지 샘플 추가. 자가 점검(심야): **클린룸(새 DerivedData) 3종 빌드 그린·경고 0** — 사용자가 본 "cannot find in scope"는 SourceKit 편집기 오탐으로 확정(§2.2). 추가 수정 2건: 진동 체인 세대 취소, **강제 종료 유령 DI 정리**(cleanupStaleActivities — 시뮬 재현·정리 실측). 시뮬 실측: DI 백그라운드 갱신(수축→이완 색 변화)·포그라운드 복귀 재동기화(5/20 정확 착지)·통계 검산(11세트/220회)·완주·워치 벽시계 동작·18.5 빌드·l10n 0/0/0. **재테스트 항목(사용자, 아이폰)**: DI 백그라운드 갱신, 진동 4종 구분, 칼로리(재권한 시트 '활성 에너지' 허용 필요). **워치 실기기는 TestFlight 1.0에서**(사용자 결정 — 로컬 워치 배포가 네트워크 문제로 실패, 시뮬 검증은 완료) |
|
||||
|
||||
## 2. 빌드·검증 환경
|
||||
|
||||
@ -122,12 +122,12 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
|
||||
|
||||
### 3.3 진동 (`Shared/WorkoutSettings.swift`, `IOS/Haptics.swift`, 워치 `WatchHaptics.swift`)
|
||||
- 패턴 4종 — **횟수 세기가 아니라 첫 순간의 질감으로 구분**(1.5(사용자 피드백)에서 횟수 기반 4종 교체): 톡(짧게 1방) / 톡톡(0.5s 간격 2방) / 드르륵(0.5s 간격 3연타) / 지잉(0.3s 겹침 3방=끊기지 않는 긴 진동). 기본: **수축=지잉(조이고 유지), 이완=톡(탁 풀기)**. 세팅에서 국면별 선택 + "미리 느끼기". 구버전 저장값(once/twice/thrice/heartbeat)은 `HapticPattern(migrating:)`으로 이관
|
||||
- iOS는 `AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate)` **완료 체이닝** — ⚠️**재생 중 같은 시스템 사운드 재호출은 무시된다**(실기기 실측: 시간 오프셋 방식은 겹침·짧은 간격이 전부 먹혀 톡=톡톡이 됐음). 완료 후 쉼: 톡=1방, 톡톡=0.25초 쉬고 1방, 드르륵=0.15초 간격 3연타, 지잉=즉시 이어붙인 3방. 무음 스위치 무관, **무음 오디오 세션이 살아 있으면 백그라운드에서도 울린다**
|
||||
- iOS는 `AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate)` **완료 체이닝** — ⚠️**재생 중 같은 시스템 사운드 재호출은 무시된다**(실기기 실측: 시간 오프셋 방식은 겹침·짧은 간격이 전부 먹혀 톡=톡톡이 됐음). 완료 후 쉼: 톡=1방, 톡톡=0.25초 쉬고 1방, 드르륵=0.15초 간격 3연타, 지잉=즉시 이어붙인 3방. **세대 토큰으로 새 패턴 시작 시 이전 체인 취소**(짧은 수축·이완에서 이전 체인이 다음 국면 첫 진동을 삼키는 것 방지). 무음 스위치 무관, **무음 오디오 세션이 살아 있으면 백그라운드에서도 울린다**
|
||||
- 워치는 반복 재생이 뭉개져서 **패턴마다 성격이 다른 시스템 햅틱 1회**로 매핑: 톡=.start, 톡톡=.directionUp, 드르륵=.retry, 지잉=.notification. **워치에서 실행하면 진동은 워치에서만** (완주 시 .success 1회)
|
||||
|
||||
### 3.4 백그라운드·Live Activity (`IOS/BackgroundAudioKeeper.swift`, `LiveActivityManager.swift`, `KegingWidgets/`)
|
||||
- 운동 시작 시 무음 WAV 무한 루프(`UIBackgroundModes=audio`, `.playback + .mixWithOthers`, **volume 0.1**·prepareToPlay — 0.0은 백그라운드 유지가 풀리는 사례 대비) → 앱을 나가도 타이머·진동·Live Activity 갱신 유지. **인터럽션 종료·백그라운드 전환·활성화 때 재생 죽었으면 revive()** 옵저버. 그래도 정지되면 엔진 벽시계 재동기화(§3.1)가 복귀 시 교정. 종료·중단 시 즉시 해제. 운동 중 `isIdleTimerDisabled`로 화면 꺼짐 방지(종료 시 복원)
|
||||
- Live Activity: 국면 전환마다 update — 다이나믹 아일랜드 컴팩트(국면 아이콘 + n/총), 확장(국면·카운트다운·진행 바), 잠금화면 배너. 종료·중단 시 `.immediate` dismiss. 수축=민트 `#7FBF9E`·이완=앰버 `#E8C558` 고정색
|
||||
- Live Activity: 국면 전환마다 update — 다이나믹 아일랜드 컴팩트(국면 아이콘 + n/총), 확장(국면·카운트다운·진행 바), 잠금화면 배너. 종료·중단 시 `.immediate` dismiss. **앱 시작 시 `cleanupStaleActivities()`** — 운동 중 강제 종료로 남은 유령 DI를 정리(시뮬 실측: kill 후 몇 시간 잔존하던 것 → 재실행 즉시 소멸 확인). 수축=민트 `#7FBF9E`·이완=앰버 `#E8C558` 고정색
|
||||
|
||||
### 3.5 단축어 (`IOS/KegingIntents.swift`)
|
||||
- `StartKegelIntent` 하나 — 앱을 열고(`openAppWhenRun`) 저장된 세팅대로 즉시 시작. 콜드 스타트 경합은 AppLaunchState 플래그 + 알림 이중 경로로 처리
|
||||
@ -185,7 +185,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]
|
||||
| `-autoStart` | 시작하자마자 운동 시작 (아이폰·워치 공통) |
|
||||
| `-quickConfig` | 세팅을 수축1초·이완1초·2회로 (완주 경로 검증) |
|
||||
| `-resetConfig` | 세팅을 기본값(10초·5초·20회)으로 복원 |
|
||||
| `-noHealth` | 건강 권한 요청 생략 — 화면 QA에서 권한 시트가 캡처를 가리는 것 방지 |
|
||||
| `-noHealth` | 건강 권한 요청 생략(아이폰·워치 공통) — 화면 QA에서 권한 시트가 캡처를 가리는 것 방지 |
|
||||
| `-AppleLanguages "(en)"` | (시스템 표준 인자) 그 실행만 언어 강제 — en/ja 렌더 QA용, 워치도 동일 |
|
||||
|
||||
## 8. 남은 작업·보류
|
||||
|
||||
@ -53,14 +53,16 @@ final class BackgroundAudioKeeper {
|
||||
observers.append(center.addObserver(
|
||||
forName: AVAudioSession.interruptionNotification, object: nil, queue: .main
|
||||
) { [weak self] note in
|
||||
guard let info = note.userInfo,
|
||||
guard let self,
|
||||
let info = note.userInfo,
|
||||
let rawType = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
AVAudioSession.InterruptionType(rawValue: rawType) == .ended else { return }
|
||||
Task { @MainActor in self?.revive() }
|
||||
Task { @MainActor in self.revive() }
|
||||
})
|
||||
for name in [UIApplication.didEnterBackgroundNotification, UIApplication.didBecomeActiveNotification] {
|
||||
observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
Task { @MainActor in self?.revive() }
|
||||
guard let self else { return }
|
||||
Task { @MainActor in self.revive() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,12 @@
|
||||
import AudioToolbox
|
||||
import Foundation
|
||||
|
||||
nonisolated enum Haptics {
|
||||
@MainActor
|
||||
enum Haptics {
|
||||
/// 진행 중 체인 취소용 세대 토큰 — 새 패턴이 시작되면 이전 체인의 남은 진동을 버린다
|
||||
/// (수축·이완이 짧을 때 이전 체인이 다음 국면의 첫 진동을 삼키는 것 방지)
|
||||
private static var generation = 0
|
||||
|
||||
static func play(_ pattern: HapticPattern) {
|
||||
// 각 진동 앞의 쉼(초) — 첫 진동은 즉시
|
||||
let gaps: [TimeInterval]
|
||||
@ -21,21 +26,19 @@ nonisolated enum Haptics {
|
||||
case .rattle: gaps = [0, 0.15, 0.15] // 드르륵: 짧은 쉼으로 3연타
|
||||
case .long: gaps = [0, 0, 0] // 지잉: 완료 즉시 이어 붙여 긴 진동감
|
||||
}
|
||||
playChain(gaps)
|
||||
generation += 1
|
||||
playChain(gaps, generation: generation)
|
||||
}
|
||||
|
||||
private static func playChain(_ gaps: [TimeInterval]) {
|
||||
guard let gap = gaps.first else { return }
|
||||
private static func playChain(_ gaps: [TimeInterval], generation: Int) {
|
||||
guard generation == Self.generation, let gap = gaps.first else { return }
|
||||
let rest = Array(gaps.dropFirst())
|
||||
let fire = {
|
||||
AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate) {
|
||||
DispatchQueue.main.async { playChain(rest) }
|
||||
}
|
||||
}
|
||||
if gap > 0 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + gap, execute: fire)
|
||||
} else {
|
||||
fire()
|
||||
Task { @MainActor in
|
||||
if gap > 0 { try? await Task.sleep(for: .seconds(gap)) }
|
||||
guard generation == Self.generation else { return }
|
||||
AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate) { @MainActor @Sendable in
|
||||
playChain(rest, generation: generation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,15 @@ final class LiveActivityManager {
|
||||
|
||||
private init() {}
|
||||
|
||||
/// 앱 시작 시 호출 — 강제 종료 등으로 남은 이전 Live Activity를 정리한다
|
||||
/// (안 하면 운동 중 강제 종료 시 다이나믹 아일랜드가 몇 시간 동안 유령으로 남음)
|
||||
func cleanupStaleActivities() {
|
||||
for stale in Activity<KegingActivityAttributes>.activities {
|
||||
Task { await stale.end(nil, dismissalPolicy: .immediate) }
|
||||
}
|
||||
activity = nil
|
||||
}
|
||||
|
||||
/// 없으면 시작, 있으면 갱신 — 국면 전환마다 이 하나만 호출
|
||||
func sync(phase: KegelPhase, rep: Int, totalReps: Int, phaseEnd: Date) {
|
||||
let state = KegingActivityAttributes.ContentState(
|
||||
|
||||
@ -19,6 +19,8 @@ final class WorkoutCoordinator {
|
||||
guard !wired else { return }
|
||||
wired = true
|
||||
|
||||
LiveActivityManager.shared.cleanupStaleActivities()
|
||||
|
||||
let engine = KegelEngine.shared
|
||||
engine.onPhaseChange = { [weak engine] phase, rep, phaseEnd in
|
||||
guard let engine else { return }
|
||||
|
||||
@ -38,6 +38,13 @@ final class WatchWorkoutSession: NSObject {
|
||||
|
||||
/// 운동 시작 직후 호출 — 권한 요청·세션 시작은 비동기라 타이머 진행에 영향 없음
|
||||
func begin(startDate: Date) {
|
||||
#if DEBUG
|
||||
// 화면 QA에서 권한 시트가 캡처를 가리는 것 방지
|
||||
if CommandLine.arguments.contains("-noHealth") {
|
||||
RuntimeSessionManager.shared.start()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
Task { @MainActor in
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
RuntimeSessionManager.shared.start()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user