mycode/myApp/Keging/Shared/WorkoutSettings.swift
songyc macbook eba01b570f fix(keging): 실기기 2차 피드백 — 중단 UI 인라인화·통계 위로 스와이프·진동 3종 Core Haptics 재설계
① 중단 확인: 하단 시트(confirmationDialog) → 버튼 자리에서 펼쳐지는 인라인 확인
   (안내 문구 + 계속하기(그린)/중단하기(레드), scale+opacity 전환)
② 통계: 아래→위 스와이프로 반전, 하단 중앙 손잡이(위 화살표+캡슐), 통통 튀는 스프링
   (dampingFraction 0.62), 통계 상단에 닫기 손잡이 추가, 닫기=아래로 스와이프
③ 진동 3종(기본·톡톡·지이이잉)으로 재설계 — 체이닝 간격도 길다는 피드백:
   - iOS 포그라운드: Core Haptics 정밀 재생(Shared/HapticEnginePlayer) — 기본 0.25s,
     톡톡 0.14s×2 시작 간격 0.28s(요구 0.5s 미만), 지이이잉 1.2s 연속, 세기 1.0
   - iOS 백그라운드 폴백: 시스템 바이브 완료 체이닝(1방/2방/3방 즉시 잇기)
   - 워치: watchOS SDK에 CoreHaptics 부재(실측) — 강한 시스템 햅틱 매핑
     (기본=.notification, 톡톡=.directionUp, 지이이잉=.retry — 내장이라 간격 뭉개짐 없음)
   - 구버전 저장 패턴 자동 이관, 기본값 수축=지이이잉·이완=기본
- 신규 QA 인자 -confirmStop, 도움말 문구·번역(en/ja) 갱신, stale 정리(카탈로그 0/0)
- 검증: Debug·Release·워치 빌드 그린, 화면 QA(메인 하단 손잡이·통계 시트·중단 인라인 레드)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
2026-08-30 09:39:37 +09:00

152 lines
5.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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