- 폰 첫 화면: 시작 버튼 위 워치 연결 배지(연결됨/미연결/미연동 — iOS 제약상 '연결됨'은 워치 앱 포그라운드 기준, 설정·도움말에 안내), 미연결 시작 시 확인 창 (기록은 동일함을 안내), 설정 > 애플워치 > '워치 미연결 경고' 토글. 단축어·autoStart는 경고 없이 통과 - 세팅 동기화 전면 제거(기기마다 진동 질감이 달라 각자 최적화) — 동기화는 기록만. 워치 시작 화면 왼쪽 스와이프로 전용 세팅 페이지(TabView .page): 준비/수축/이완/ 횟수 스테퍼(2줄 컴팩트 라벨), 진동 2종 피커(선택 즉시 미리 울림), 운동 유형·강도. 요약 라인 즉시 반영(SettingsStore EnvironmentObject) - 워치 진동 강화 재설계: 기본=.start 1방, 톡톡=.start 2방(0.35s), 지이이잉=.retry 2방 연결(~1.3s), 완주=.retry×3+.success(~2.5s), 세대 토큰 취소 - 도움말 4건·번역(en/ja) 갱신, CLAUDE.md 현행화 - 시뮬 검증: 배지·경고 창·설정 토글, 워치 세팅 페이지(46mm·40mm)·요약 반영, 3종 빌드 그린·경고 0·l10n 0/0/0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
156 lines
6.0 KiB
Swift
156 lines
6.0 KiB
Swift
//
|
||
// WorkoutSettings.swift
|
||
// Keging
|
||
//
|
||
// 세트 구성(수축·이완·횟수)과 진동 패턴 — 폰에서 설정, 워치로 동기화
|
||
//
|
||
|
||
import Foundation
|
||
import Combine
|
||
|
||
/// 진동 패턴 3종 — 수축/이완 각각 선택. 첫 순간의 질감으로 구분한다
|
||
/// (기본=짧은 한 번 / 톡톡=빠른 두 번 / 지이이잉=아주 긴 연속 진동)
|
||
nonisolated enum HapticPattern: String, CaseIterable, Identifiable, Codable {
|
||
case basic, doubleTap, long
|
||
|
||
var id: String { rawValue }
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .basic: String(localized: "기본")
|
||
case .doubleTap: String(localized: "톡톡")
|
||
case .long: String(localized: "지이이잉")
|
||
}
|
||
}
|
||
|
||
/// Core Haptics 이벤트 스펙 (시작 시각, 길이, 세기, 날카로움) — 아이폰·워치 공용.
|
||
/// 포그라운드 정밀 재생용(HapticEnginePlayer). 톡톡의 시작 간격 0.28초(사용자 요구 0.5초 미만),
|
||
/// 지이이잉은 1.2초 연속 — 시스템 바이브로는 불가능한 형태라 Core Haptics를 쓴다
|
||
var hapticEvents: [(time: Double, duration: Double, intensity: Float, sharpness: Float)] {
|
||
switch self {
|
||
case .basic: [(0, 0.25, 1.0, 0.55)]
|
||
case .doubleTap: [(0, 0.14, 1.0, 0.65), (0.28, 0.14, 1.0, 0.65)]
|
||
case .long: [(0, 1.2, 1.0, 0.45)]
|
||
}
|
||
}
|
||
|
||
/// 구버전 패턴 저장값 이관 (1.0 횟수 기반 · 4종 시절)
|
||
init?(migrating raw: String?) {
|
||
guard let raw else { return nil }
|
||
if let direct = HapticPattern(rawValue: raw) {
|
||
self = direct
|
||
return
|
||
}
|
||
switch raw {
|
||
case "once", "short": self = .basic
|
||
case "twice", "thrice", "heartbeat", "rattle": self = .doubleTap
|
||
default: return nil
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 완주 축하 진동 — 설정과 무관한 고유 패턴. "아주 길게 우우우우웅" 한 번.
|
||
/// (아이폰 포그라운드 Core Haptics 스펙 — 백그라운드·워치의 근사 재생은 각 플랫폼 파일 참고)
|
||
nonisolated enum FinishHaptic {
|
||
static let events: [(time: Double, duration: Double, intensity: Float, sharpness: Float)] = [
|
||
(0, 2.4, 1.0, 0.3)
|
||
]
|
||
}
|
||
|
||
/// 애플 건강에 저장할 운동 유형 — 취향 선택 (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 = .basic
|
||
/// 완주 시 애플 건강에 함께 기록할 운동 강도 (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 = .basic,
|
||
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)) ?? .basic
|
||
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()
|
||
|
||
// 세팅은 기기별 독립(2026-09-03, 7차) — 기기마다 진동 질감이 달라 각자 최적화한다.
|
||
// 폰↔워치는 기록만 동기화하고, 이 스토어는 자기 기기 값만 저장한다
|
||
@Published var config: WorkoutConfig {
|
||
didSet { save() }
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|