- 기본/톡톡의 .start가 실기기에서 너무 약함 → 가장 강한 알림급 .notification으로 교체 (기본 1방, 톡톡 2방 0.5초 간격 — 알림 진동이 길어 간격 넉넉히) - 세팅 화면: 스테퍼가 크라운을 뺏어 스크롤과 값 변경이 충돌 → 목록에서 크라운은 스크롤 전용으로, 준비/수축/이완/횟수는 행을 눌러 전용 화면(CrownValueEditor — 큰 민트 값 + digitalCrownRotation)에서 크라운으로 조절. 진동·건강은 눌러서 선택 - DEBUG -openEditor(워치, 화면 QA용) 추가, '크라운을 돌려 조절' en/ja 번역 - 시뮬 검증: 세팅 목록·크라운 편집 화면(46mm), 워치 빌드 그린·경고 0·l10n 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
168 lines
6.5 KiB
Swift
168 lines
6.5 KiB
Swift
//
|
|
// WatchSettingsView.swift
|
|
// KegingWatch Watch App
|
|
//
|
|
// 워치 전용 세팅 — 시작 화면에서 왼쪽으로 스와이프. 세팅값은 아이폰과 독립이고
|
|
// (기기마다 진동 질감이 달라 각자 최적화), 기록만 동기화된다.
|
|
//
|
|
// 사용성(8차 피드백): 목록에서 크라운은 스크롤 전용이다 — 스테퍼가 크라운을 뺏어
|
|
// 스크롤과 값 변경이 충돌하던 문제로, 숫자 항목은 눌러서 전용 화면에 들어간 뒤
|
|
// 크라운을 돌려 조절한다(CrownValueEditor). 진동·건강 항목은 눌러서 목록 선택.
|
|
// 진동 패턴은 고르는 즉시 미리 울려서 손목에서 바로 느껴볼 수 있다.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct WatchSettingsView: View {
|
|
@EnvironmentObject private var settings: SettingsStore
|
|
#if DEBUG
|
|
/// 화면 QA용 — `-openEditor`로 준비 편집 화면을 바로 연다 (시뮬은 행 탭 주입이 안 됨)
|
|
@State private var qaEditorShown = false
|
|
#endif
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section {
|
|
valueRow(Text("준비"), value: Text("\(settings.config.prepSeconds)초")) {
|
|
CrownValueEditor(title: Text("준비"), range: 0...10, value: $settings.config.prepSeconds) {
|
|
Text("\($0)초")
|
|
}
|
|
}
|
|
valueRow(Text("수축"), value: Text("\(settings.config.contractSeconds)초")) {
|
|
CrownValueEditor(title: Text("수축"), range: 1...60, value: $settings.config.contractSeconds) {
|
|
Text("\($0)초")
|
|
}
|
|
}
|
|
valueRow(Text("이완"), value: Text("\(settings.config.relaxSeconds)초")) {
|
|
CrownValueEditor(title: Text("이완"), range: 1...60, value: $settings.config.relaxSeconds) {
|
|
Text("\($0)초")
|
|
}
|
|
}
|
|
valueRow(Text("횟수"), value: Text("\(settings.config.reps)회")) {
|
|
CrownValueEditor(title: Text("횟수"), range: 1...100, value: $settings.config.reps) {
|
|
Text("\($0)회")
|
|
}
|
|
}
|
|
} header: {
|
|
Text("세트")
|
|
}
|
|
Section {
|
|
Picker(selection: $settings.config.contractPattern) {
|
|
ForEach(HapticPattern.allCases) { Text($0.label).tag($0) }
|
|
} label: {
|
|
Text("수축")
|
|
}
|
|
Picker(selection: $settings.config.relaxPattern) {
|
|
ForEach(HapticPattern.allCases) { Text($0.label).tag($0) }
|
|
} label: {
|
|
Text("이완")
|
|
}
|
|
} header: {
|
|
Text("진동")
|
|
} footer: {
|
|
Text("고르면 바로 진동이 울려요. 세팅은 아이폰과 따로 저장돼요.")
|
|
}
|
|
Section {
|
|
Picker(selection: $settings.config.healthWorkoutType) {
|
|
ForEach(HealthWorkoutType.allCases) { Text($0.label).tag($0) }
|
|
} label: {
|
|
Text("운동 유형")
|
|
}
|
|
Picker(selection: $settings.config.effortScore) {
|
|
Text("안 함").tag(0)
|
|
ForEach(1...10, id: \.self) { score in
|
|
Text(verbatim: "\(score)").tag(score)
|
|
}
|
|
} label: {
|
|
Text("운동 강도")
|
|
}
|
|
} header: {
|
|
Text("애플 건강")
|
|
}
|
|
}
|
|
.navigationTitle("세팅")
|
|
.onChange(of: settings.config.contractPattern) { _, newValue in
|
|
WatchHaptics.play(newValue)
|
|
}
|
|
.onChange(of: settings.config.relaxPattern) { _, newValue in
|
|
WatchHaptics.play(newValue)
|
|
}
|
|
#if DEBUG
|
|
.navigationDestination(isPresented: $qaEditorShown) {
|
|
CrownValueEditor(title: Text("준비"), range: 0...10, value: $settings.config.prepSeconds) {
|
|
Text("\($0)초")
|
|
}
|
|
}
|
|
.onAppear {
|
|
if CommandLine.arguments.contains("-openEditor") { qaEditorShown = true }
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// 이름 + 현재 값 행 — 누르면 크라운 조절 화면으로
|
|
private func valueRow<Editor: View>(
|
|
_ title: Text, value: Text, @ViewBuilder editor: @escaping () -> Editor
|
|
) -> some View {
|
|
NavigationLink {
|
|
editor()
|
|
} label: {
|
|
HStack {
|
|
title
|
|
Spacer()
|
|
value
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 값 하나를 크라운으로 조절하는 화면 — 목록의 크라운은 스크롤 전용이라 여기서만 값이 바뀐다
|
|
struct CrownValueEditor: View {
|
|
let title: Text
|
|
let range: ClosedRange<Int>
|
|
@Binding var value: Int
|
|
let format: (Int) -> Text
|
|
|
|
@State private var crownValue = 0.0
|
|
@FocusState private var focused: Bool
|
|
|
|
var body: some View {
|
|
VStack(spacing: 10) {
|
|
title
|
|
.font(.headline)
|
|
.foregroundStyle(.secondary)
|
|
format(value)
|
|
.font(.system(size: 40, weight: .bold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(AppTheme.green)
|
|
.contentTransition(.numericText())
|
|
.animation(.snappy, value: value)
|
|
Text("크라운을 돌려 조절")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.focusable(true)
|
|
.focused($focused)
|
|
.digitalCrownRotation(
|
|
$crownValue,
|
|
from: Double(range.lowerBound),
|
|
through: Double(range.upperBound),
|
|
by: 1,
|
|
sensitivity: .medium,
|
|
isContinuous: false,
|
|
isHapticFeedbackEnabled: true
|
|
)
|
|
.onChange(of: crownValue) { _, newValue in
|
|
let rounded = min(max(Int(newValue.rounded()), range.lowerBound), range.upperBound)
|
|
if rounded != value { value = rounded }
|
|
}
|
|
.onAppear {
|
|
crownValue = Double(value)
|
|
focused = true
|
|
}
|
|
}
|
|
}
|