- 완주한 세트만 애플 건강에 '기타' 운동으로 자동 기록 (중단=미기록 규칙 일관) - 폰=사후 HKWorkoutBuilder, 워치=라이브 HKWorkoutSession(심박 포함·손목 내림 유지 겸용, 실패 시 확장 런타임 세션 폴백) - 운동 강도: 설정 → 애플 건강 → 자동 기록(기본 2·쉬움, 안 함 가능) — relateWorkoutEffortSample, 워치 11+/iOS 18, 워치 10은 운동만 기록 - 쓰기 전용 권한(첫 시작 때 요청·거부 시 조용히 스킵), 건강 문구 3언어, 새 UI 문자열 11키 번역 - WorkoutConfig 전 필드 decodeIfPresent 디코더 (필드 추가에도 기존 설정 보존) - 검증: Debug·Release·워치 빌드 그린, 권한 시트·설정 ko/ja 화면 QA, 카탈로그 missing·stale 0 (건강 실제 저장은 권한 시트 조작 불가로 실기기 확인 필요) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
112 lines
3.6 KiB
Swift
112 lines
3.6 KiB
Swift
//
|
||
// WorkoutSettings.swift
|
||
// Keging
|
||
//
|
||
// 세트 구성(수축·이완·횟수)과 진동 패턴 — 폰에서 설정, 워치로 동기화
|
||
//
|
||
|
||
import Foundation
|
||
import Combine
|
||
|
||
/// 진동 패턴 — 수축/이완 각각 선택. 진동 횟수·리듬으로 구분한다
|
||
nonisolated enum HapticPattern: String, CaseIterable, Identifiable, Codable {
|
||
case once, twice, thrice, heartbeat
|
||
|
||
var id: String { rawValue }
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .once: String(localized: "한 번")
|
||
case .twice: String(localized: "두 번")
|
||
case .thrice: String(localized: "세 번")
|
||
case .heartbeat: String(localized: "심장 박동")
|
||
}
|
||
}
|
||
|
||
/// 각 진동이 울리는 시점(초) — 첫 진동은 0
|
||
var beatOffsets: [TimeInterval] {
|
||
switch self {
|
||
case .once: [0]
|
||
case .twice: [0, 0.45]
|
||
case .thrice: [0, 0.45, 0.9]
|
||
case .heartbeat: [0, 0.3, 1.0, 1.3]
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 세트 구성 — 세트 = (수축 → 이완) × 횟수
|
||
nonisolated struct WorkoutConfig: Codable, Equatable {
|
||
var contractSeconds: Int = 10
|
||
var relaxSeconds: Int = 5
|
||
var reps: Int = 20
|
||
var contractPattern: HapticPattern = .twice
|
||
var relaxPattern: HapticPattern = .once
|
||
/// 완주 시 애플 건강에 함께 기록할 운동 강도 (0 = 기록 안 함, 1~10)
|
||
var effortScore: Int = 2
|
||
|
||
init(
|
||
contractSeconds: Int = 10,
|
||
relaxSeconds: Int = 5,
|
||
reps: Int = 20,
|
||
contractPattern: HapticPattern = .twice,
|
||
relaxPattern: HapticPattern = .once,
|
||
effortScore: Int = 2
|
||
) {
|
||
self.contractSeconds = contractSeconds
|
||
self.relaxSeconds = relaxSeconds
|
||
self.reps = reps
|
||
self.contractPattern = contractPattern
|
||
self.relaxPattern = relaxPattern
|
||
self.effortScore = effortScore
|
||
}
|
||
|
||
// 필드 추가에도 기존 저장 JSON이 깨지지 않게 전 필드 decodeIfPresent
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
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 = try container.decodeIfPresent(HapticPattern.self, forKey: .contractPattern) ?? .twice
|
||
relaxPattern = try container.decodeIfPresent(HapticPattern.self, forKey: .relaxPattern) ?? .once
|
||
effortScore = try container.decodeIfPresent(Int.self, forKey: .effortScore) ?? 2
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|