① 다이나믹 아일랜드 멈춤(2중 방어): - 오디오 키퍼 하드닝 — volume 0.1(0.0은 유지 풀림 사례)·prepareToPlay·인터럽션/씬 전환 revive() - 엔진을 벽시계 기준으로 재설계 — 전 국면이 sessionStart로부터 결정적(computeTarget), 타이머는 경계 재장전용, scenePhase .active에서 resyncAfterWake()로 정확한 국면 자가 교정 ② 진동 패턴 동일(톡=톡톡): 시스템 바이브는 재생 중 재호출이 무시됨(실측) — 시간 오프셋 방식 폐기, AudioServicesPlaySystemSoundWithCompletion 완료 체이닝으로 재작성 (톡 1방 / 톡톡 0.25s 쉬고 1방 / 드르륵 0.15s 3연타 / 지잉 즉시 이어붙인 3방) ③ 운동 칼로리 0: 빌더 운동은 에너지 샘플 직접 추가 필요 — iOS는 MET 추정(1.8+강도×0.17, 70kg 가정) activeEnergyBurned 샘플 add, 워치는 심박·활성 에너지 읽기 권한 추가로 센서 실측 수집, 권한 문구 3언어 갱신 검증: Debug·Release·워치 빌드 그린, 완주 경로 시뮬 재검증(엔진 재설계 후). DI 갱신·진동 구분·칼로리는 실기기 재확인 필요(재실행 시 '활성 에너지' 권한 시트 뜸) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
146 lines
5.2 KiB
Swift
146 lines
5.2 KiB
Swift
//
|
||
// WorkoutSettings.swift
|
||
// Keging
|
||
//
|
||
// 세트 구성(수축·이완·횟수)과 진동 패턴 — 폰에서 설정, 워치로 동기화
|
||
//
|
||
|
||
import Foundation
|
||
import Combine
|
||
|
||
/// 진동 패턴 — 수축/이완 각각 선택. 횟수 세기가 아니라 첫 순간의 질감으로 구분한다
|
||
/// (짧은 톡 / 빠른 톡톡 / 드르륵 연타 / 끊기지 않는 긴 지잉)
|
||
nonisolated enum HapticPattern: String, CaseIterable, Identifiable, Codable {
|
||
case short, doubleTap, rattle, long
|
||
|
||
var id: String { rawValue }
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .short: String(localized: "톡 (짧게)")
|
||
case .doubleTap: String(localized: "톡톡")
|
||
case .rattle: String(localized: "드르륵")
|
||
case .long: String(localized: "지잉 (길게)")
|
||
}
|
||
}
|
||
|
||
// 재생 방식은 플랫폼별: iOS = 완료 체이닝 시퀀스(IOS/Haptics.swift),
|
||
// 워치 = 패턴별 단일 시스템 햅틱(WatchHaptics.swift)
|
||
|
||
/// 1.0 초기 패턴(횟수 기반) 저장값 이관
|
||
init?(migrating raw: String?) {
|
||
guard let raw else { return nil }
|
||
if let direct = HapticPattern(rawValue: raw) {
|
||
self = direct
|
||
return
|
||
}
|
||
switch raw {
|
||
case "once": self = .short
|
||
case "twice": self = .doubleTap
|
||
case "thrice", "heartbeat": self = .rattle
|
||
default: return nil
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 애플 건강에 저장할 운동 유형 — 취향 선택 (HKWorkoutActivityType 매핑은 HealthKitCalls.swift)
|
||
nonisolated enum HealthWorkoutType: String, CaseIterable, Identifiable, Codable {
|
||
case other, coreTraining
|
||
|
||
var id: String { rawValue }
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .other: String(localized: "기타")
|
||
case .coreTraining: String(localized: "코어 트레이닝")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 세트 구성 — 세트 = (수축 → 이완) × 횟수
|
||
nonisolated struct WorkoutConfig: Codable, Equatable {
|
||
/// 시작 버튼 → 첫 수축 사이의 준비 카운트다운 (0~10초)
|
||
var prepSeconds: Int = 3
|
||
var contractSeconds: Int = 10
|
||
var relaxSeconds: Int = 5
|
||
var reps: Int = 20
|
||
/// 기본: 수축 = 긴 지잉(조이고 유지), 이완 = 짧은 톡(탁 풀기)
|
||
var contractPattern: HapticPattern = .long
|
||
var relaxPattern: HapticPattern = .short
|
||
/// 완주 시 애플 건강에 함께 기록할 운동 강도 (0 = 기록 안 함, 1~10)
|
||
var effortScore: Int = 2
|
||
/// 애플 건강에 저장할 운동 유형
|
||
var healthWorkoutType: HealthWorkoutType = .other
|
||
|
||
init(
|
||
prepSeconds: Int = 3,
|
||
contractSeconds: Int = 10,
|
||
relaxSeconds: Int = 5,
|
||
reps: Int = 20,
|
||
contractPattern: HapticPattern = .long,
|
||
relaxPattern: HapticPattern = .short,
|
||
effortScore: Int = 2,
|
||
healthWorkoutType: HealthWorkoutType = .other
|
||
) {
|
||
self.prepSeconds = prepSeconds
|
||
self.contractSeconds = contractSeconds
|
||
self.relaxSeconds = relaxSeconds
|
||
self.reps = reps
|
||
self.contractPattern = contractPattern
|
||
self.relaxPattern = relaxPattern
|
||
self.effortScore = effortScore
|
||
self.healthWorkoutType = healthWorkoutType
|
||
}
|
||
|
||
// 필드 추가에도 기존 저장 JSON이 깨지지 않게 전 필드 decodeIfPresent
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
prepSeconds = try container.decodeIfPresent(Int.self, forKey: .prepSeconds) ?? 3
|
||
contractSeconds = try container.decodeIfPresent(Int.self, forKey: .contractSeconds) ?? 10
|
||
relaxSeconds = try container.decodeIfPresent(Int.self, forKey: .relaxSeconds) ?? 5
|
||
reps = try container.decodeIfPresent(Int.self, forKey: .reps) ?? 20
|
||
contractPattern = HapticPattern(migrating: try container.decodeIfPresent(String.self, forKey: .contractPattern)) ?? .long
|
||
relaxPattern = HapticPattern(migrating: try container.decodeIfPresent(String.self, forKey: .relaxPattern)) ?? .short
|
||
effortScore = try container.decodeIfPresent(Int.self, forKey: .effortScore) ?? 2
|
||
healthWorkoutType = try container.decodeIfPresent(HealthWorkoutType.self, forKey: .healthWorkoutType) ?? .other
|
||
}
|
||
|
||
var totalDuration: TimeInterval { Double(reps * (contractSeconds + relaxSeconds)) }
|
||
|
||
var summaryText: String {
|
||
String(localized: "수축 \(contractSeconds)초 · 이완 \(relaxSeconds)초 · \(reps)회")
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class SettingsStore: ObservableObject {
|
||
static let shared = SettingsStore()
|
||
|
||
@Published var config: WorkoutConfig {
|
||
didSet {
|
||
save()
|
||
if config != oldValue { onConfigChange?(config) }
|
||
}
|
||
}
|
||
|
||
/// 폰 → 워치 동기화 훅 (iOS에서만 연결)
|
||
var onConfigChange: ((WorkoutConfig) -> Void)?
|
||
|
||
private static let key = "workout.config.v1"
|
||
|
||
private init() {
|
||
if let data = AppGroup.defaults.data(forKey: Self.key),
|
||
let saved = try? JSONDecoder().decode(WorkoutConfig.self, from: data) {
|
||
config = saved
|
||
} else {
|
||
config = WorkoutConfig()
|
||
}
|
||
}
|
||
|
||
private func save() {
|
||
if let data = try? JSONEncoder().encode(config) {
|
||
AppGroup.defaults.set(data, forKey: Self.key)
|
||
}
|
||
}
|
||
}
|