- 폰 첫 화면: 시작 버튼 위 워치 연결 배지(연결됨/미연결/미연동 — 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
289 lines
11 KiB
Swift
289 lines
11 KiB
Swift
//
|
||
// ContentView.swift
|
||
// Keging
|
||
//
|
||
// 첫 화면 — 큰 시작 버튼 + 구석의 세팅·설정 버튼. 아래로 스와이프하면 통계.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
struct ContentView: View {
|
||
@EnvironmentObject private var engine: KegelEngine
|
||
@EnvironmentObject private var settings: SettingsStore
|
||
@ObservedObject private var watchLink = WatchLinkStatus.shared
|
||
|
||
@Environment(\.scenePhase) private var scenePhase
|
||
|
||
/// 워치 미연결 상태로 시작할 때 확인 창 (설정에서 끌 수 있음)
|
||
@AppStorage("app.watchWarn", store: AppGroup.defaults) private var warnNoWatch = true
|
||
|
||
@State private var showTimerSettings = false
|
||
@State private var showAppSettings = false
|
||
@State private var showHelp = false
|
||
@State private var showStats = false
|
||
@State private var showWatchWarning = false
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
AppTheme.background.ignoresSafeArea()
|
||
mainScreen
|
||
if showStats {
|
||
StatsView(onClose: closeStats)
|
||
.transition(.move(edge: .bottom))
|
||
.zIndex(2)
|
||
}
|
||
}
|
||
.sheet(isPresented: $showTimerSettings) { TimerSettingsView() }
|
||
.sheet(isPresented: $showAppSettings) { AppSettingsView() }
|
||
.sheet(isPresented: $showHelp) { HelpView() }
|
||
.fullScreenCover(isPresented: workoutPresented) { WorkoutView() }
|
||
.onReceive(NotificationCenter.default.publisher(for: .startWorkoutRequested)) { _ in
|
||
startFromShortcut()
|
||
}
|
||
.alert("워치가 연결되어 있지 않아요", isPresented: $showWatchWarning) {
|
||
Button("취소", role: .cancel) {}
|
||
Button("시작하기") { WorkoutCoordinator.shared.startWorkout() }
|
||
} message: {
|
||
Text("아이폰만으로 진행해도 운동 기록은 똑같이 저장돼요. 진동은 아이폰에서 울리고, 칼로리는 심박 없이 추정돼요.")
|
||
}
|
||
.onChange(of: scenePhase) {
|
||
// 백그라운드에서 정지됐다 돌아와도 벽시계 기준 정확한 국면으로 재정렬
|
||
if scenePhase == .active {
|
||
KegelEngine.shared.resyncAfterWake()
|
||
WatchLinkStatus.shared.refresh()
|
||
}
|
||
}
|
||
.onAppear {
|
||
if AppLaunchState.shared.startRequested { startFromShortcut() }
|
||
#if DEBUG
|
||
if CommandLine.arguments.contains("-openStats") { showStats = true }
|
||
if CommandLine.arguments.contains("-openHelp") { showHelp = true }
|
||
if CommandLine.arguments.contains("-openSettings") { showAppSettings = true }
|
||
if CommandLine.arguments.contains("-openTimerSettings") { showTimerSettings = true }
|
||
if CommandLine.arguments.contains("-autoStart") { WorkoutCoordinator.shared.startWorkout() }
|
||
#endif
|
||
}
|
||
}
|
||
|
||
// MARK: 첫 화면
|
||
|
||
private var mainScreen: some View {
|
||
VStack(spacing: 0) {
|
||
HStack(alignment: .top) {
|
||
cornerButton(symbol: "gearshape.fill", label: Text("설정")) { showAppSettings = true }
|
||
Spacer()
|
||
cornerButton(symbol: "slider.horizontal.3", label: Text("운동 세팅")) { showTimerSettings = true }
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.top, 8)
|
||
|
||
Spacer()
|
||
|
||
watchLinkBadge
|
||
.padding(.bottom, 16)
|
||
|
||
Button {
|
||
requestStart()
|
||
} label: {
|
||
startButtonLabel
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(Text("케겔 운동 시작"))
|
||
|
||
Button {
|
||
showTimerSettings = true
|
||
} label: {
|
||
HStack(spacing: 12) {
|
||
summaryChip(
|
||
symbol: KegelPhase.contract.symbolName,
|
||
value: String(localized: "\(settings.config.contractSeconds)초"),
|
||
label: Text("수축"),
|
||
color: AppTheme.green
|
||
)
|
||
summaryChip(
|
||
symbol: KegelPhase.relax.symbolName,
|
||
value: String(localized: "\(settings.config.relaxSeconds)초"),
|
||
label: Text("이완"),
|
||
color: AppTheme.yellow
|
||
)
|
||
summaryChip(
|
||
symbol: "repeat",
|
||
value: String(localized: "\(settings.config.reps)회"),
|
||
label: Text("횟수"),
|
||
color: AppTheme.green
|
||
)
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.top, 34)
|
||
.accessibilityElement(children: .ignore)
|
||
.accessibilityLabel(Text("운동 세팅 열기 — 현재 \(settings.config.summaryText)"))
|
||
|
||
Spacer()
|
||
Spacer()
|
||
|
||
// 하단: 가운데 = 통계 손잡이(위로 스와이프 힌트), 오른쪽 = 도움말
|
||
ZStack {
|
||
Button {
|
||
openStats()
|
||
} label: {
|
||
VStack(spacing: 2) {
|
||
Image(systemName: "chevron.compact.up")
|
||
.font(.body)
|
||
.foregroundStyle(.secondary)
|
||
Capsule().fill(.secondary.opacity(0.5)).frame(width: 36, height: 5)
|
||
}
|
||
.padding(.bottom, 6)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(Text("통계 보기"))
|
||
HStack {
|
||
Spacer()
|
||
cornerButton(symbol: "questionmark", label: Text("도움말")) { showHelp = true }
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 8)
|
||
}
|
||
.contentShape(Rectangle())
|
||
.gesture(
|
||
DragGesture(minimumDistance: 25)
|
||
.onEnded { value in
|
||
if value.translation.height < -60, abs(value.translation.width) < 100 {
|
||
openStats()
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
private var startButtonLabel: some View {
|
||
ZStack {
|
||
Circle()
|
||
.fill(
|
||
RadialGradient(
|
||
colors: [AppTheme.green.opacity(0.22), .clear],
|
||
center: .center, startRadius: 100, endRadius: 165
|
||
)
|
||
)
|
||
.frame(width: 330, height: 330)
|
||
Circle()
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [AppTheme.green, AppTheme.green.opacity(0.82)],
|
||
startPoint: .top, endPoint: .bottom
|
||
)
|
||
)
|
||
.frame(width: 236, height: 236)
|
||
.shadow(color: AppTheme.green.opacity(0.35), radius: 24, x: 0, y: 10)
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "play.fill").font(.system(size: 40, weight: .bold))
|
||
Text("시작").font(.title.bold())
|
||
}
|
||
.foregroundStyle(startButtonForeground)
|
||
}
|
||
.frame(width: 330, height: 330)
|
||
}
|
||
|
||
/// 다크의 세이지 민트 버튼 위에는 흰 글자가 묻혀서 짙은 그린 틴트 글자로
|
||
private var startButtonForeground: Color {
|
||
Color(light: .white, dark: Color(hex: "#0F2018"))
|
||
}
|
||
|
||
private func summaryChip(symbol: String, value: String, label: Text, color: Color) -> some View {
|
||
VStack(spacing: 6) {
|
||
Image(systemName: symbol)
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(color)
|
||
Text(value)
|
||
.font(.title3.bold())
|
||
.monospacedDigit()
|
||
.foregroundStyle(.primary)
|
||
label
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.frame(width: 92)
|
||
.padding(.vertical, 14)
|
||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18))
|
||
.shadow(color: .black.opacity(0.06), radius: 10, x: 0, y: 3)
|
||
}
|
||
|
||
/// 워치 연결 표시 — ⚠️iOS 제약상 '연결됨'은 워치에서 케깅 앱이 켜져 있을 때만
|
||
/// (설정·도움말에 기준 안내). 미연결이어도 기록은 동일하게 저장된다
|
||
private var watchLinkBadge: some View {
|
||
let symbol: String
|
||
let label: Text
|
||
let color: Color
|
||
switch watchLink.state {
|
||
case .reachable:
|
||
symbol = "applewatch.radiowaves.left.and.right"
|
||
label = Text("워치 연결됨")
|
||
color = AppTheme.green
|
||
case .unreachable:
|
||
symbol = "applewatch.slash"
|
||
label = Text("워치 미연결")
|
||
color = .secondary
|
||
case .notLinked:
|
||
symbol = "applewatch.slash"
|
||
label = Text("워치 미연동")
|
||
color = .secondary
|
||
}
|
||
return HStack(spacing: 6) {
|
||
Image(systemName: symbol)
|
||
.font(.footnote.weight(.semibold))
|
||
label
|
||
.font(.footnote.weight(.medium))
|
||
}
|
||
.foregroundStyle(color)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 7)
|
||
.background(AppTheme.surface, in: Capsule())
|
||
.accessibilityElement(children: .combine)
|
||
}
|
||
|
||
/// 시작 버튼 — 워치 미연결이면(설정에 따라) 확인 후 시작
|
||
private func requestStart() {
|
||
if warnNoWatch, watchLink.state != .reachable {
|
||
showWatchWarning = true
|
||
} else {
|
||
WorkoutCoordinator.shared.startWorkout()
|
||
}
|
||
}
|
||
|
||
private func cornerButton(symbol: String, label: Text, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
Image(systemName: symbol)
|
||
.font(.title3)
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 44, height: 44)
|
||
.background(AppTheme.surface, in: Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(label)
|
||
}
|
||
|
||
// MARK: 통계·운동 전환
|
||
|
||
private func openStats() {
|
||
// 통통 튀는 등장 (아래에서 위로)
|
||
withAnimation(.spring(response: 0.45, dampingFraction: 0.62)) { showStats = true }
|
||
}
|
||
|
||
private func closeStats() {
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { showStats = false }
|
||
}
|
||
|
||
private var workoutPresented: Binding<Bool> {
|
||
Binding(
|
||
get: { engine.state != .idle },
|
||
set: { presented in if !presented { engine.acknowledgeFinish() } }
|
||
)
|
||
}
|
||
|
||
private func startFromShortcut() {
|
||
AppLaunchState.shared.startRequested = false
|
||
showStats = false
|
||
WorkoutCoordinator.shared.startWorkout()
|
||
}
|
||
}
|