mycode/myApp/Keging/IOS/ContentView.swift
songyc macbook 4d91c48844 feat(keging): 워치 연결 배지·미연결 시작 경고 + 세팅 기기별 독립(워치 좌스와이프 세팅) + 워치 진동 강화 (7차)
- 폰 첫 화면: 시작 버튼 위 워치 연결 배지(연결됨/미연결/미연동 — 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
2026-09-03 03:05:28 +09:00

289 lines
11 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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