- 메인: 텍스트 한 줄 요약 → 수축·이완·횟수 카드 3장(아이콘+큰 숫자, 탭=세팅) - 운동 화면: 국면 색 전환(수축=그린·이완=앰버, 카운터 색 연동) + 세트 진행 링 + 큰 현재 횟수 카운터(numericText 전환) - 도움말 취소선 원인 = SwiftUI Text 마크다운의 ~쌍 해석 — 사용자 문구의 ASCII ~를 전각 ~로 전면 교체(4곳), CLAUDE.md에 금지 규칙 기록 - 신규 QA 인자 -noHealth(권한 시트가 캡처 가리는 것 방지) - 검증: Debug·Release 빌드 그린, 화면 QA(메인 카드·수축/이완 국면·도움말), 카탈로그 0/0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPmp29DQHY6Ahfss5SRopT
221 lines
7.8 KiB
Swift
221 lines
7.8 KiB
Swift
//
|
|
// ContentView.swift
|
|
// Keging
|
|
//
|
|
// 첫 화면 — 큰 시작 버튼 + 구석의 세팅·설정 버튼. 아래로 스와이프하면 통계.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ContentView: View {
|
|
@EnvironmentObject private var engine: KegelEngine
|
|
@EnvironmentObject private var settings: SettingsStore
|
|
|
|
@State private var showTimerSettings = false
|
|
@State private var showAppSettings = false
|
|
@State private var showHelp = false
|
|
@State private var showStats = false
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
AppTheme.background.ignoresSafeArea()
|
|
mainScreen
|
|
if showStats {
|
|
StatsView(onClose: closeStats)
|
|
.transition(.move(edge: .top))
|
|
.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()
|
|
}
|
|
.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()
|
|
Button {
|
|
openStats()
|
|
} label: {
|
|
VStack(spacing: 2) {
|
|
Capsule().fill(.secondary.opacity(0.5)).frame(width: 36, height: 5)
|
|
Image(systemName: "chevron.compact.down")
|
|
.font(.body)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.top, 10)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text("통계 보기"))
|
|
Spacer()
|
|
cornerButton(symbol: "slider.horizontal.3", label: Text("운동 세팅")) { showTimerSettings = true }
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 8)
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
WorkoutCoordinator.shared.startWorkout()
|
|
} 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()
|
|
|
|
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)
|
|
}
|
|
|
|
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(duration: 0.35)) { showStats = true }
|
|
}
|
|
|
|
private func closeStats() {
|
|
withAnimation(.spring(duration: 0.35)) { 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()
|
|
}
|
|
}
|