From c16b17accca34050c71cd72327cc70b8b99b5dab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Wed, 4 Nov 2026 17:39:59 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20[#184]=20=EC=9D=B4=EC=A0=84=20?= =?UTF-8?q?=EC=9A=B4=EB=8F=99=20=EA=B8=B0=EB=A1=9D=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkoutSessionComponents.swift | 44 ++++++++++++++++ .../WorkoutSession/WorkoutSessionModels.swift | 49 +++++++++++++++++- .../WorkoutSessionRestTimerScreen.swift | 11 ++++ .../WorkoutSession/WorkoutSessionView.swift | 44 ++++++++++++++-- .../WorkoutSessionViewModel.swift | 50 ++++++++++++++++++- .../WorkoutTrainModeScreen.swift | 12 +++++ .../DebugOnboardingNetworking.swift | 12 +++++ 7 files changed, 215 insertions(+), 7 deletions(-) diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionComponents.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionComponents.swift index c0c5f53..4e804b8 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionComponents.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionComponents.swift @@ -1,5 +1,49 @@ import SwiftUI +struct WorkoutSessionRecordRetryBanner: View { + let message: String + let isLoading: Bool + let onRetry: () -> Void + + var body: some View { + HStack(spacing: GymupSpacing.small) { + Image(systemName: "exclamationmark.circle") + .foregroundStyle(GymupColors.brandText) + .accessibilityHidden(true) + + Text(message) + .font(GymupTypography.caption) + .foregroundStyle(GymupColors.secondaryText) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 0) + + Button(action: onRetry) { + Group { + if isLoading { + ProgressView() + .tint(GymupColors.brandText) + } else { + Text("다시 시도") + .font(GymupTypography.uiSemibold) + .foregroundStyle(GymupColors.brandText) + } + } + .frame(minWidth: 68, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isLoading) + .accessibilityLabel("이전 운동 기록 다시 시도") + .accessibilityIdentifier("workout.previous-record.retry") + } + .padding(.horizontal, GymupSpacing.medium) + .padding(.vertical, GymupSpacing.xSmall) + .frame(maxWidth: .infinity, alignment: .leading) + .background(GymupColors.elevatedSurface, in: RoundedRectangle(cornerRadius: GymupRadius.medium)) + } +} + struct WorkoutSessionProgressCard: View { let title: String let completedCount: Int diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionModels.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionModels.swift index e4362fa..c5d929c 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionModels.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionModels.swift @@ -1,5 +1,46 @@ import Foundation +struct WorkoutSessionPreviousRecord: Equatable { + let weightKilograms: Double + let repetitions: Int + let setIndex: Int + let performedAtLabel: String + + init(response: PreviousExerciseRecordResponse) { + weightKilograms = response.weight + repetitions = response.reps + setIndex = response.setIndex + performedAtLabel = Self.dateLabel(from: response.performedAt) + } + + var performanceText: String { + "\(weightKilograms.formatted(.number.precision(.fractionLength(0...1)))) × \(repetitions)" + } + + var title: String { + let weight = weightKilograms.formatted(.number.precision(.fractionLength(0...1))) + return "이전 · \(performedAtLabel) \(weight)kg × \(repetitions)" + } + + private static func dateLabel(from value: String) -> String { + let options: [ISO8601DateFormatter.Options] = [ + [.withInternetDateTime, .withFractionalSeconds], + [.withInternetDateTime] + ] + for formatOptions in options { + let parser = ISO8601DateFormatter() + parser.formatOptions = formatOptions + guard let date = parser.date(from: value) else { continue } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "M월 d일" + return formatter.string(from: date) + } + return String(value.prefix(10)) + } +} + struct WorkoutSessionSet: Identifiable, Equatable { let id: UUID var weightKilograms: Double @@ -29,6 +70,7 @@ struct WorkoutSessionExercise: Identifiable, Equatable { var sets: [WorkoutSessionSet] var imageAssetOverride: String? var usesSampleHistory = true + var previousRecord: WorkoutSessionPreviousRecord? = nil var measure = RoutineExerciseMeasure.repetitions var completedSetCount: Int { sets.filter(\.isCompleted).count } @@ -100,18 +142,23 @@ struct WorkoutSessionExercise: Identifiable, Equatable { } func previousPerformanceTitle(for setIndex: Int?) -> String { + if let previousRecord { return previousRecord.title } guard usesSampleHistory, id == "bench-press" else { return "이전 기록 없음" } let repetitions = setIndex == sets.count - 1 ? 6 : 8 return "이전 · 10월 8일 60kg × \(repetitions)" } func previousPerformance(for setIndex: Int) -> String? { + if let previousRecord { + guard previousRecord.setIndex == setIndex + 1 else { return nil } + return previousRecord.performanceText + } guard usesSampleHistory, id == "bench-press" else { return nil } return setIndex == 3 ? "60 × 6" : "60 × 8" } var hasPreviousPerformance: Bool { - previousPerformance(for: 0) != nil + previousRecord != nil || previousPerformance(for: 0) != nil } var personalRecordKilograms: Double? { usesSampleHistory && id == "bench-press" ? 62.5 : nil } diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionRestTimerScreen.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionRestTimerScreen.swift index 8d5d7f8..2fbed53 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionRestTimerScreen.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionRestTimerScreen.swift @@ -8,6 +8,9 @@ struct WorkoutSessionRestTimerScreen: View { let totalSeconds: Int let onExtend: () -> Void let onSkip: () -> Void + let previousRecordError: String? + let isLoadingPreviousRecord: Bool + let onRetryPreviousRecord: () -> Void @State private var isPlateCalculatorPresented = false private var nextSet: WorkoutSessionSet { exercise.sets[nextSetIndex] } @@ -27,6 +30,14 @@ struct WorkoutSessionRestTimerScreen: View { .padding(.top, 24) nextSetCard .padding(.top, 24) + if let previousRecordError { + WorkoutSessionRecordRetryBanner( + message: previousRecordError, + isLoading: isLoadingPreviousRecord, + onRetry: onRetryPreviousRecord + ) + .padding(.top, 12) + } Spacer(minLength: 64) plateCalculatorHint } diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift index 31ac96b..3fabac7 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift @@ -26,13 +26,25 @@ struct WorkoutSessionView: View { @MainActor init(routineID: String, historyStore: HistoryStore) { - _viewModel = State(initialValue: WorkoutSessionViewModel(routineID: routineID, historyStore: historyStore)) + _viewModel = State( + initialValue: WorkoutSessionViewModel( + routineID: routineID, + historyStore: historyStore, + previousRecordService: Self.makePreviousRecordService() + ) + ) self.historyStore = historyStore } @MainActor init(plan: WorkoutSessionPlan, historyStore: HistoryStore) { - _viewModel = State(initialValue: WorkoutSessionViewModel(plan: plan, historyStore: historyStore)) + _viewModel = State( + initialValue: WorkoutSessionViewModel( + plan: plan, + historyStore: historyStore, + previousRecordService: Self.makePreviousRecordService() + ) + ) self.historyStore = historyStore } @@ -155,6 +167,10 @@ struct WorkoutSessionView: View { Task { await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) } } .task { showFirstExercisePromptIfNeeded() } + .task(id: "\(viewModel.hasStarted)-\(viewModel.currentExercise?.id ?? "")") { + guard viewModel.hasStarted, let exerciseID = viewModel.currentExercise?.id else { return } + await loadPreviousRecord(for: exerciseID) + } } private var preparationContent: some View { @@ -196,7 +212,10 @@ struct WorkoutSessionView: View { viewModel.skipRest() settings.cancelRestNotification(sessionID: liveActivitySessionID) Task { await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) } - } + }, + previousRecordError: viewModel.previousRecordError(for: exercise.id), + isLoadingPreviousRecord: viewModel.isLoadingPreviousRecord(for: exercise.id), + onRetryPreviousRecord: { Task { await loadPreviousRecord(for: exercise.id) } } ) } else if let exercise = viewModel.currentExercise { WorkoutTrainModeScreen( @@ -236,7 +255,10 @@ struct WorkoutSessionView: View { syncRestActivity() }, onNextExercise: { _ = viewModel.moveToNextExercise() }, - onFinish: { isConfirmingFinish = true } + onFinish: { isConfirmingFinish = true }, + previousRecordError: viewModel.previousRecordError(for: exercise.id), + isLoadingPreviousRecord: viewModel.isLoadingPreviousRecord(for: exercise.id), + onRetryPreviousRecord: { Task { await loadPreviousRecord(for: exercise.id) } } ) } else { GymupEmptyState(symbol: "dumbbell", title: "운동을 찾을 수 없어요", message: "루틴으로 돌아가 운동을 확인해 주세요.") @@ -247,6 +269,12 @@ struct WorkoutSessionView: View { } private extension WorkoutSessionView { + func loadPreviousRecord(for exerciseID: String) async { + await viewModel.loadPreviousRecord(for: exerciseID) + guard viewModel.currentExercise?.id == exerciseID else { return } + showFirstExercisePromptIfNeeded() + } + func handleRestTick(at date: Date) async { if let activityState = WorkoutRestActivityController.shared.currentState(sessionID: liveActivitySessionID) { if activityState.isSkipped { @@ -366,6 +394,14 @@ private extension WorkoutSessionView { promptedExerciseIDs.insert(exercise.id) isShowingFirstExercisePrompt = true } + + static func makePreviousRecordService() -> ExerciseReadAPIService? { +#if DEBUG + DebugOnboardingNetworking.makeExerciseReadService() +#else + nil +#endif + } } #Preview { diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift index 53996f1..ed6f064 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift @@ -11,6 +11,9 @@ final class WorkoutSessionViewModel { private(set) var notice: String? private let historyStore: HistoryStore private let goalSettings: OnboardingStoredSettings? + private let previousRecordService: ExerciseReadAPIService? + private(set) var previousRecordErrors: [String: String] = [:] + private(set) var loadingPreviousRecordExerciseIDs: Set = [] private(set) var startedAt: Date? private var pausedAt: Date? @@ -23,13 +26,15 @@ final class WorkoutSessionViewModel { startedAt: Date = Date(), startsImmediately: Bool = true, historyStore: HistoryStore? = nil, - goalSettings: OnboardingStoredSettings? = .load() + goalSettings: OnboardingStoredSettings? = .load(), + previousRecordService: ExerciseReadAPIService? = nil ) { plan = .sample(for: routineID) self.startedAt = startsImmediately ? startedAt : nil now = startedAt self.historyStore = historyStore ?? HistoryStore() self.goalSettings = goalSettings + self.previousRecordService = previousRecordService } init( @@ -37,13 +42,15 @@ final class WorkoutSessionViewModel { startedAt: Date = Date(), startsImmediately: Bool = true, historyStore: HistoryStore? = nil, - goalSettings: OnboardingStoredSettings? = .load() + goalSettings: OnboardingStoredSettings? = .load(), + previousRecordService: ExerciseReadAPIService? = nil ) { self.plan = plan self.startedAt = startsImmediately ? startedAt : nil now = startedAt self.historyStore = historyStore ?? HistoryStore() self.goalSettings = goalSettings + self.previousRecordService = previousRecordService } var currentExercise: WorkoutSessionExercise? { @@ -55,6 +62,45 @@ final class WorkoutSessionViewModel { currentExercise?.sets.firstIndex(where: { !$0.isCompleted }) } + func previousRecordError(for exerciseID: String) -> String? { + previousRecordErrors[exerciseID] + } + + func isLoadingPreviousRecord(for exerciseID: String) -> Bool { + loadingPreviousRecordExerciseIDs.contains(exerciseID) + } + + func loadPreviousRecord(for exerciseID: String) async { + guard let previousRecordService, + plan.exercises.contains(where: { $0.id == exerciseID }), + !loadingPreviousRecordExerciseIDs.contains(exerciseID) else { return } + + loadingPreviousRecordExerciseIDs.insert(exerciseID) + defer { loadingPreviousRecordExerciseIDs.remove(exerciseID) } + + do { + let response = try await previousRecordService.previousRecord(exerciseId: exerciseID) + guard response.exerciseId == exerciseID, + let exerciseIndex = plan.exercises.firstIndex(where: { $0.id == exerciseID }) else { + throw APIClientError.invalidResponse + } + + plan.exercises[exerciseIndex].usesSampleHistory = false + plan.exercises[exerciseIndex].previousRecord = response.hasPreviousRecord + ? WorkoutSessionPreviousRecord(response: response) + : nil + previousRecordErrors.removeValue(forKey: exerciseID) + } catch { + guard !Task.isCancelled else { return } + if let clientError = error as? APIClientError, + case let .server(_, detail) = clientError { + previousRecordErrors[exerciseID] = detail.message + } else { + previousRecordErrors[exerciseID] = "이전 기록을 불러오지 못했어요." + } + } + } + var isPaused: Bool { pausedAt != nil } var hasStarted: Bool { startedAt != nil } var isFinished: Bool { finishedAt != nil } diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift index bd42ae0..77a21bf 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift @@ -21,6 +21,9 @@ struct WorkoutTrainModeScreen: View { let onSkipRest: () -> Void let onNextExercise: () -> Void let onFinish: () -> Void + let previousRecordError: String? + let isLoadingPreviousRecord: Bool + let onRetryPreviousRecord: () -> Void var body: some View { GeometryReader { geometry in @@ -31,6 +34,15 @@ struct WorkoutTrainModeScreen: View { .padding(.top, 6) WorkoutSessionRecordSummary(exercise: exercise, currentSetIndex: currentSetIndex) .padding(.top, 9) + if let previousRecordError { + WorkoutSessionRecordRetryBanner( + message: previousRecordError, + isLoading: isLoadingPreviousRecord, + onRetry: onRetryPreviousRecord + ) + .padding(.horizontal, 20) + .padding(.top, 8) + } WorkoutSessionSetTable( exercise: exercise, currentSetIndex: currentSetIndex, diff --git a/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift b/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift index 0256b95..e8ac5bb 100644 --- a/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift +++ b/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift @@ -143,5 +143,17 @@ enum DebugOnboardingNetworking { let client = APIClient(environment: environment, tokenProvider: tokenProvider) return PlateCalculatorAPIService(client: client) } + + static func makeExerciseReadService() -> ExerciseReadAPIService? { + guard let baseURL = URL(string: "http://localhost:8000"), + let environment = try? APIEnvironment(baseURL: baseURL) + else { + return nil + } + + let tokenProvider = DevelopmentBearerTokenProvider(environment: environment) + let client = APIClient(environment: environment, tokenProvider: tokenProvider) + return ExerciseReadAPIService(client: client) + } } #endif From 9ca9d95727c3d2d4425fc7c477c1dfae25896301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Wed, 4 Nov 2026 19:53:16 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20[#184]=20=EC=9A=B4=EB=8F=99=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkoutSession/WorkoutSessionModels.swift | 2 +- .../WorkoutSessionView+Actions.swift | 138 +++++++++ .../WorkoutSessionView+Content.swift | 96 +++++++ .../WorkoutSession/WorkoutSessionView.swift | 265 ++---------------- .../WorkoutSessionViewModel.swift | 80 +++--- .../WorkoutTrainModeComponents.swift | 161 +++++++++++ .../WorkoutTrainModeScreen.swift | 158 ----------- 7 files changed, 455 insertions(+), 445 deletions(-) create mode 100644 GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift create mode 100644 GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift create mode 100644 GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeComponents.swift diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionModels.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionModels.swift index c5d929c..ea61574 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionModels.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionModels.swift @@ -70,7 +70,7 @@ struct WorkoutSessionExercise: Identifiable, Equatable { var sets: [WorkoutSessionSet] var imageAssetOverride: String? var usesSampleHistory = true - var previousRecord: WorkoutSessionPreviousRecord? = nil + var previousRecord: WorkoutSessionPreviousRecord? var measure = RoutineExerciseMeasure.repetitions var completedSetCount: Int { sets.filter(\.isCompleted).count } diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift new file mode 100644 index 0000000..1523456 --- /dev/null +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift @@ -0,0 +1,138 @@ +import SwiftUI +import UIKit + +extension WorkoutSessionView { + func loadPreviousRecord(for exerciseID: String) async { + await viewModel.loadPreviousRecord(for: exerciseID) + guard viewModel.currentExercise?.id == exerciseID else { return } + showFirstExercisePromptIfNeeded() + } + + func handleRestTick(at date: Date) async { + if let activityState = WorkoutRestActivityController.shared.currentState(sessionID: liveActivitySessionID) { + if activityState.isSkipped { + viewModel.skipRest() + settings.cancelRestNotification(sessionID: liveActivitySessionID) + await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) + return + } + viewModel.synchronizeRestTimer( + endsAt: activityState.restEndsAt, + durationSeconds: activityState.restDurationSeconds, + at: date + ) + } + guard viewModel.refreshClock(at: date) else { return } + isShowingRestFinishedAlert = settings.preferences.restTimerEnabled + if settings.preferences.hapticsEnabled { + UINotificationFeedbackGenerator().notificationOccurred(.success) + } + await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) + } + + func completeCurrentSet(at date: Date) { + guard let currentSetIndex = viewModel.currentSetIndex else { return } + viewModel.toggleSetCompletion( + exerciseIndex: viewModel.currentExerciseIndex, + setIndex: currentSetIndex, + at: date + ) + if settings.preferences.hapticsEnabled { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + syncRestActivity() + if viewModel.currentExerciseIndex == viewModel.plan.exercises.count - 1, + viewModel.currentExercise?.isComplete == true { + showWorkoutProcessing() + } + } + + func formattedDuration(_ seconds: Int) -> String { + String(format: "%02d:%02d", seconds / 60, seconds % 60) + } + + func finishWorkout() { + settings.cancelRestNotification(sessionID: liveActivitySessionID) + Task { await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) } + guard !didSaveHistoryRecord else { + dismiss() + return + } + + if let completedAt = viewModel.finishedAt { + var record = WorkoutSessionCompletionDataFactory.makeHistoryRecord( + plan: viewModel.plan, + elapsedSeconds: viewModel.elapsedSeconds(at: completedAt), + completedAt: completedAt + ) + record.earnedExperience = viewModel.completionData?.earnedXP + if record.setCount > 0 { + historyStore.add(record) + } + didSaveHistoryRecord = true + } + dismiss() + } + + func syncRestActivity() { + guard settings.preferences.restTimerEnabled, viewModel.isResting, + let exercise = viewModel.currentExercise, + let setIndex = viewModel.currentSetIndex, + let restEndsAt = viewModel.activeRestEndsAt else { + settings.cancelRestNotification(sessionID: liveActivitySessionID) + Task { await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) } + return + } + + settings.scheduleRestNotification(sessionID: liveActivitySessionID, endsAt: restEndsAt) + let nextSet = exercise.sets[setIndex] + let duration = viewModel.activeRestDurationSeconds + let state = WorkoutRestActivityAttributes.ContentState( + restStartedAt: restEndsAt.addingTimeInterval(TimeInterval(-duration)), + restEndsAt: restEndsAt, + restDurationSeconds: duration, + routineName: viewModel.plan.title, + exerciseName: exercise.name, + exerciseIndex: viewModel.currentExerciseIndex + 1, + totalExerciseCount: viewModel.plan.exercises.count, + completedSetCount: exercise.completedSetCount, + nextSetNumber: setIndex + 1, + nextSetWeightKilograms: nextSet.weightKilograms, + nextSetRepetitions: nextSet.repetitions, + isSkipped: false, + nextSetIsTimed: exercise.measure == .seconds + ) + Task { + await WorkoutRestActivityController.shared.startOrUpdate(sessionID: liveActivitySessionID, state: state) + } + } + + func showWorkoutProcessing() { + isShowingWorkoutProcessing = true + Task { @MainActor in + do { + try await Task.sleep(for: .milliseconds(1_800)) + } catch { + return + } + isShowingWorkoutProcessing = false + viewModel.finish() + } + } + + func showFirstExercisePromptIfNeeded() { + guard let exercise = viewModel.currentExercise, + !exercise.hasPreviousPerformance, + !promptedExerciseIDs.contains(exercise.id) else { return } + promptedExerciseIDs.insert(exercise.id) + isShowingFirstExercisePrompt = true + } + + static func makePreviousRecordService() -> ExerciseReadAPIService? { +#if DEBUG + DebugOnboardingNetworking.makeExerciseReadService() +#else + nil +#endif + } +} diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift new file mode 100644 index 0000000..c03f2b8 --- /dev/null +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift @@ -0,0 +1,96 @@ +import SwiftUI + +extension WorkoutSessionView { + var preparationContent: some View { + WorkoutSessionPreparationView( + plan: viewModel.plan, + selectedExerciseIndex: $guideIndex, + onBack: dismiss.callAsFunction, + onStart: { viewModel.start(at: Date()) } + ) + } + + var sessionContent: some View { + Group { + if let exercise = viewModel.currentExercise, + exercise.isComplete, + viewModel.plan.exercises.indices.contains(viewModel.currentExerciseIndex + 1) { + WorkoutExerciseCompleteScreen( + exercise: exercise, + nextExercise: viewModel.plan.exercises[viewModel.currentExerciseIndex + 1], + nextExerciseIndex: viewModel.currentExerciseIndex + 1, + totalExerciseCount: viewModel.plan.exercises.count, + onContinue: { _ = viewModel.moveToNextExercise() }, + onFinish: { isConfirmingFinish = true } + ) + } else if viewModel.isResting, + let exercise = viewModel.currentExercise, + let nextSetIndex = viewModel.currentSetIndex { + WorkoutSessionRestTimerScreen( + exercise: exercise, + completedSetCount: exercise.completedSetCount, + nextSetIndex: nextSetIndex, + remainingSeconds: viewModel.restSecondsRemaining, + totalSeconds: viewModel.activeRestDurationSeconds, + onExtend: { + viewModel.extendRest(by: 15) + syncRestActivity() + }, + onSkip: { + viewModel.skipRest() + settings.cancelRestNotification(sessionID: liveActivitySessionID) + Task { await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) } + }, + previousRecordError: viewModel.previousRecordError(for: exercise.id), + isLoadingPreviousRecord: viewModel.isLoadingPreviousRecord(for: exercise.id), + onRetryPreviousRecord: { Task { await loadPreviousRecord(for: exercise.id) } } + ) + } else if let exercise = viewModel.currentExercise { + WorkoutTrainModeScreen( + routineTitle: viewModel.plan.title, + elapsedTime: formattedDuration(viewModel.elapsedSeconds), + exercise: exercise, + exerciseIndex: viewModel.currentExerciseIndex, + totalExerciseCount: viewModel.plan.exercises.count, + currentSetIndex: viewModel.currentSetIndex, + isResting: viewModel.isResting || viewModel.isPaused, + isPaused: viewModel.isPaused, + restSecondsRemaining: viewModel.restSecondsRemaining, + onQuit: { isConfirmingFinish = true }, + onMusic: { isShowingPlaylist = true }, + onToggleGuide: { + withAnimation(.easeInOut(duration: 0.2)) { + isShowingCurrentGuide.toggle() + } + }, + onToggleSet: { setIndex in + viewModel.toggleSetCompletion( + exerciseIndex: viewModel.currentExerciseIndex, + setIndex: setIndex, + at: Date() + ) + syncRestActivity() + }, + onAdjustWeight: viewModel.adjustCurrentWeight, + onInputWeight: { + weightInputSetIndex = viewModel.currentSetIndex + isShowingWeightInput = weightInputSetIndex != nil + }, + onAdjustRepetitions: viewModel.adjustCurrentRepetitions, + onSetDone: { completeCurrentSet(at: Date()) }, + onSkipRest: { + viewModel.skipRest() + syncRestActivity() + }, + onNextExercise: { _ = viewModel.moveToNextExercise() }, + onFinish: { isConfirmingFinish = true }, + previousRecordError: viewModel.previousRecordError(for: exercise.id), + isLoadingPreviousRecord: viewModel.isLoadingPreviousRecord(for: exercise.id), + onRetryPreviousRecord: { Task { await loadPreviousRecord(for: exercise.id) } } + ) + } else { + GymupEmptyState(symbol: "dumbbell", title: "운동을 찾을 수 없어요", message: "루틴으로 돌아가 운동을 확인해 주세요.") + } + } + } +} diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift index 3fabac7..358c43d 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift @@ -3,24 +3,24 @@ import SwiftUI import UIKit struct WorkoutSessionView: View { - @Environment(\.dismiss) private var dismiss - @State private var viewModel: WorkoutSessionViewModel - @State private var settings = WorkoutSettings.shared - @State private var didSaveHistoryRecord = false - private let historyStore: HistoryStore - @State private var isConfirmingFinish = false - @State private var isShowingWorkoutPhoto = false - @State private var photoCompletionData: WorkoutCompletionData? - @State private var isShowingPlaylist = false - @State private var isShowingRestFinishedAlert = false - @State private var guideIndex = 0 - @State private var isShowingCurrentGuide = false - @State private var isShowingFirstExercisePrompt = false - @State private var promptedExerciseIDs: Set = [] - @State private var isShowingWorkoutProcessing = false - @State private var isShowingWeightInput = false - @State private var weightInputSetIndex: Int? - @State private var liveActivitySessionID = UUID().uuidString + @Environment(\.dismiss) var dismiss + @State var viewModel: WorkoutSessionViewModel + @State var settings = WorkoutSettings.shared + @State var didSaveHistoryRecord = false + let historyStore: HistoryStore + @State var isConfirmingFinish = false + @State var isShowingWorkoutPhoto = false + @State var photoCompletionData: WorkoutCompletionData? + @State var isShowingPlaylist = false + @State var isShowingRestFinishedAlert = false + @State var guideIndex = 0 + @State var isShowingCurrentGuide = false + @State var isShowingFirstExercisePrompt = false + @State var promptedExerciseIDs: Set = [] + @State var isShowingWorkoutProcessing = false + @State var isShowingWeightInput = false + @State var weightInputSetIndex: Int? + @State var liveActivitySessionID = UUID().uuidString private let clock = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -173,235 +173,6 @@ struct WorkoutSessionView: View { } } - private var preparationContent: some View { - WorkoutSessionPreparationView( - plan: viewModel.plan, - selectedExerciseIndex: $guideIndex, - onBack: dismiss.callAsFunction, - onStart: { viewModel.start(at: Date()) } - ) - } - - private var sessionContent: some View { - Group { - if let exercise = viewModel.currentExercise, - exercise.isComplete, - viewModel.plan.exercises.indices.contains(viewModel.currentExerciseIndex + 1) { - WorkoutExerciseCompleteScreen( - exercise: exercise, - nextExercise: viewModel.plan.exercises[viewModel.currentExerciseIndex + 1], - nextExerciseIndex: viewModel.currentExerciseIndex + 1, - totalExerciseCount: viewModel.plan.exercises.count, - onContinue: { _ = viewModel.moveToNextExercise() }, - onFinish: { isConfirmingFinish = true } - ) - } else if viewModel.isResting, - let exercise = viewModel.currentExercise, - let nextSetIndex = viewModel.currentSetIndex { - WorkoutSessionRestTimerScreen( - exercise: exercise, - completedSetCount: exercise.completedSetCount, - nextSetIndex: nextSetIndex, - remainingSeconds: viewModel.restSecondsRemaining, - totalSeconds: viewModel.activeRestDurationSeconds, - onExtend: { - viewModel.extendRest(by: 15) - syncRestActivity() - }, - onSkip: { - viewModel.skipRest() - settings.cancelRestNotification(sessionID: liveActivitySessionID) - Task { await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) } - }, - previousRecordError: viewModel.previousRecordError(for: exercise.id), - isLoadingPreviousRecord: viewModel.isLoadingPreviousRecord(for: exercise.id), - onRetryPreviousRecord: { Task { await loadPreviousRecord(for: exercise.id) } } - ) - } else if let exercise = viewModel.currentExercise { - WorkoutTrainModeScreen( - routineTitle: viewModel.plan.title, - elapsedTime: formattedDuration(viewModel.elapsedSeconds), - exercise: exercise, - exerciseIndex: viewModel.currentExerciseIndex, - totalExerciseCount: viewModel.plan.exercises.count, - currentSetIndex: viewModel.currentSetIndex, - isResting: viewModel.isResting || viewModel.isPaused, - isPaused: viewModel.isPaused, - restSecondsRemaining: viewModel.restSecondsRemaining, - onQuit: { isConfirmingFinish = true }, - onMusic: { isShowingPlaylist = true }, - onToggleGuide: { - withAnimation(.easeInOut(duration: 0.2)) { - isShowingCurrentGuide.toggle() - } - }, - onToggleSet: { setIndex in - viewModel.toggleSetCompletion( - exerciseIndex: viewModel.currentExerciseIndex, - setIndex: setIndex, - at: Date() - ) - syncRestActivity() - }, - onAdjustWeight: viewModel.adjustCurrentWeight, - onInputWeight: { - weightInputSetIndex = viewModel.currentSetIndex - isShowingWeightInput = weightInputSetIndex != nil - }, - onAdjustRepetitions: viewModel.adjustCurrentRepetitions, - onSetDone: { completeCurrentSet(at: Date()) }, - onSkipRest: { - viewModel.skipRest() - syncRestActivity() - }, - onNextExercise: { _ = viewModel.moveToNextExercise() }, - onFinish: { isConfirmingFinish = true }, - previousRecordError: viewModel.previousRecordError(for: exercise.id), - isLoadingPreviousRecord: viewModel.isLoadingPreviousRecord(for: exercise.id), - onRetryPreviousRecord: { Task { await loadPreviousRecord(for: exercise.id) } } - ) - } else { - GymupEmptyState(symbol: "dumbbell", title: "운동을 찾을 수 없어요", message: "루틴으로 돌아가 운동을 확인해 주세요.") - } - } - } - -} - -private extension WorkoutSessionView { - func loadPreviousRecord(for exerciseID: String) async { - await viewModel.loadPreviousRecord(for: exerciseID) - guard viewModel.currentExercise?.id == exerciseID else { return } - showFirstExercisePromptIfNeeded() - } - - func handleRestTick(at date: Date) async { - if let activityState = WorkoutRestActivityController.shared.currentState(sessionID: liveActivitySessionID) { - if activityState.isSkipped { - viewModel.skipRest() - settings.cancelRestNotification(sessionID: liveActivitySessionID) - await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) - return - } - viewModel.synchronizeRestTimer( - endsAt: activityState.restEndsAt, - durationSeconds: activityState.restDurationSeconds, - at: date - ) - } - guard viewModel.refreshClock(at: date) else { return } - isShowingRestFinishedAlert = settings.preferences.restTimerEnabled - if settings.preferences.hapticsEnabled { - UINotificationFeedbackGenerator().notificationOccurred(.success) - } - await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) - } - - func completeCurrentSet(at date: Date) { - guard let currentSetIndex = viewModel.currentSetIndex else { return } - viewModel.toggleSetCompletion( - exerciseIndex: viewModel.currentExerciseIndex, - setIndex: currentSetIndex, - at: date - ) - if settings.preferences.hapticsEnabled { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } - syncRestActivity() - if viewModel.currentExerciseIndex == viewModel.plan.exercises.count - 1, - viewModel.currentExercise?.isComplete == true { - showWorkoutProcessing() - } - } - - func formattedDuration(_ seconds: Int) -> String { - String(format: "%02d:%02d", seconds / 60, seconds % 60) - } - - func finishWorkout() { - settings.cancelRestNotification(sessionID: liveActivitySessionID) - Task { await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) } - guard !didSaveHistoryRecord else { - dismiss() - return - } - - if let completedAt = viewModel.finishedAt { - var record = WorkoutSessionCompletionDataFactory.makeHistoryRecord( - plan: viewModel.plan, - elapsedSeconds: viewModel.elapsedSeconds(at: completedAt), - completedAt: completedAt - ) - record.earnedExperience = viewModel.completionData?.earnedXP - if record.setCount > 0 { - historyStore.add(record) - } - didSaveHistoryRecord = true - } - dismiss() - } - - func syncRestActivity() { - guard settings.preferences.restTimerEnabled, viewModel.isResting, - let exercise = viewModel.currentExercise, - let setIndex = viewModel.currentSetIndex, - let restEndsAt = viewModel.activeRestEndsAt else { - settings.cancelRestNotification(sessionID: liveActivitySessionID) - Task { await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) } - return - } - - settings.scheduleRestNotification(sessionID: liveActivitySessionID, endsAt: restEndsAt) - let nextSet = exercise.sets[setIndex] - let duration = viewModel.activeRestDurationSeconds - let state = WorkoutRestActivityAttributes.ContentState( - restStartedAt: restEndsAt.addingTimeInterval(TimeInterval(-duration)), - restEndsAt: restEndsAt, - restDurationSeconds: duration, - routineName: viewModel.plan.title, - exerciseName: exercise.name, - exerciseIndex: viewModel.currentExerciseIndex + 1, - totalExerciseCount: viewModel.plan.exercises.count, - completedSetCount: exercise.completedSetCount, - nextSetNumber: setIndex + 1, - nextSetWeightKilograms: nextSet.weightKilograms, - nextSetRepetitions: nextSet.repetitions, - isSkipped: false, - nextSetIsTimed: exercise.measure == .seconds - ) - Task { - await WorkoutRestActivityController.shared.startOrUpdate(sessionID: liveActivitySessionID, state: state) - } - } - - func showWorkoutProcessing() { - isShowingWorkoutProcessing = true - Task { @MainActor in - do { - try await Task.sleep(for: .milliseconds(1_800)) - } catch { - return - } - isShowingWorkoutProcessing = false - viewModel.finish() - } - } - - func showFirstExercisePromptIfNeeded() { - guard let exercise = viewModel.currentExercise, - !exercise.hasPreviousPerformance, - !promptedExerciseIDs.contains(exercise.id) else { return } - promptedExerciseIDs.insert(exercise.id) - isShowingFirstExercisePrompt = true - } - - static func makePreviousRecordService() -> ExerciseReadAPIService? { -#if DEBUG - DebugOnboardingNetworking.makeExerciseReadService() -#else - nil -#endif - } } #Preview { diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift index ed6f064..1339fbb 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift @@ -62,45 +62,6 @@ final class WorkoutSessionViewModel { currentExercise?.sets.firstIndex(where: { !$0.isCompleted }) } - func previousRecordError(for exerciseID: String) -> String? { - previousRecordErrors[exerciseID] - } - - func isLoadingPreviousRecord(for exerciseID: String) -> Bool { - loadingPreviousRecordExerciseIDs.contains(exerciseID) - } - - func loadPreviousRecord(for exerciseID: String) async { - guard let previousRecordService, - plan.exercises.contains(where: { $0.id == exerciseID }), - !loadingPreviousRecordExerciseIDs.contains(exerciseID) else { return } - - loadingPreviousRecordExerciseIDs.insert(exerciseID) - defer { loadingPreviousRecordExerciseIDs.remove(exerciseID) } - - do { - let response = try await previousRecordService.previousRecord(exerciseId: exerciseID) - guard response.exerciseId == exerciseID, - let exerciseIndex = plan.exercises.firstIndex(where: { $0.id == exerciseID }) else { - throw APIClientError.invalidResponse - } - - plan.exercises[exerciseIndex].usesSampleHistory = false - plan.exercises[exerciseIndex].previousRecord = response.hasPreviousRecord - ? WorkoutSessionPreviousRecord(response: response) - : nil - previousRecordErrors.removeValue(forKey: exerciseID) - } catch { - guard !Task.isCancelled else { return } - if let clientError = error as? APIClientError, - case let .server(_, detail) = clientError { - previousRecordErrors[exerciseID] = detail.message - } else { - previousRecordErrors[exerciseID] = "이전 기록을 불러오지 못했어요." - } - } - } - var isPaused: Bool { pausedAt != nil } var hasStarted: Bool { startedAt != nil } var isFinished: Bool { finishedAt != nil } @@ -292,3 +253,44 @@ final class WorkoutSessionViewModel { notice = nil } } + +extension WorkoutSessionViewModel { + func previousRecordError(for exerciseID: String) -> String? { + previousRecordErrors[exerciseID] + } + + func isLoadingPreviousRecord(for exerciseID: String) -> Bool { + loadingPreviousRecordExerciseIDs.contains(exerciseID) + } + + func loadPreviousRecord(for exerciseID: String) async { + guard let previousRecordService, + plan.exercises.contains(where: { $0.id == exerciseID }), + !loadingPreviousRecordExerciseIDs.contains(exerciseID) else { return } + + loadingPreviousRecordExerciseIDs.insert(exerciseID) + defer { loadingPreviousRecordExerciseIDs.remove(exerciseID) } + + do { + let response = try await previousRecordService.previousRecord(exerciseId: exerciseID) + guard response.exerciseId == exerciseID, + let exerciseIndex = plan.exercises.firstIndex(where: { $0.id == exerciseID }) else { + throw APIClientError.invalidResponse + } + + plan.exercises[exerciseIndex].usesSampleHistory = false + plan.exercises[exerciseIndex].previousRecord = response.hasPreviousRecord + ? WorkoutSessionPreviousRecord(response: response) + : nil + previousRecordErrors.removeValue(forKey: exerciseID) + } catch { + guard !Task.isCancelled else { return } + if let clientError = error as? APIClientError, + case let .server(_, detail) = clientError { + previousRecordErrors[exerciseID] = detail.message + } else { + previousRecordErrors[exerciseID] = "이전 기록을 불러오지 못했어요." + } + } + } +} diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeComponents.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeComponents.swift new file mode 100644 index 0000000..cbafda4 --- /dev/null +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeComponents.swift @@ -0,0 +1,161 @@ +import SwiftUI + +struct WorkoutSessionRecordSummary: View { + let exercise: WorkoutSessionExercise + let currentSetIndex: Int? + + private var displayedSetIndex: Int? { + currentSetIndex ?? (exercise.isComplete ? exercise.sets.indices.last : nil) + } + + private var showsPersonalRecord: Bool { + guard !exercise.isComplete else { return false } + return currentSetIndex != exercise.sets.count - 1 + } + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(exercise.previousPerformanceTitle(for: displayedSetIndex)) + .font(GymupTypography.uiCaption) + .foregroundStyle(GymupColors.secondaryText) + .lineLimit(1) + .minimumScaleFactor(0.8) + Spacer(minLength: 8) + if showsPersonalRecord, let personalRecord = exercise.personalRecordKilograms { + Text("PR 도전 \(personalRecord.formatted(.number.precision(.fractionLength(0...1))))kg") + .font(GymupTypography.uiMedium) + .foregroundStyle(GymupColors.brandText) + .lineLimit(1) + } + } + .padding(.horizontal, 20) + .frame(height: 40, alignment: .center) + .overlay(alignment: .bottom) { + Rectangle().fill(GymupColors.border).frame(height: 1) + .padding(.horizontal, 20) + } + } +} + +struct WorkoutSessionSetTable: View { + let exercise: WorkoutSessionExercise + let currentSetIndex: Int? + let onToggleSet: (Int) -> Void + let onAdjustWeight: (Double) -> Void + let onInputWeight: () -> Void + let onAdjustRepetitions: (Int) -> Void + + var body: some View { + VStack(spacing: 0) { + columnHeaders + ForEach(Array(exercise.sets.enumerated()), id: \.element.id) { index, set in + setRow(set, index: index) + } + if let currentSetIndex, exercise.sets.indices.contains(currentSetIndex) { + HStack(spacing: 10) { + WorkoutSessionValueAdjuster( + title: "SET \(currentSetIndex + 1) · KG", + value: exercise.sets[currentSetIndex].weightKilograms.formatted( + .number.precision(.fractionLength(0...1)) + ), + onDecrease: { onAdjustWeight(-2.5) }, + onIncrease: { onAdjustWeight(2.5) }, + onValueTap: onInputWeight + ) + WorkoutSessionValueAdjuster( + title: "SET \(currentSetIndex + 1) · \(exercise.measure == .seconds ? "SECS" : "REPS")", + value: String(exercise.sets[currentSetIndex].repetitions), + onDecrease: { onAdjustRepetitions(-1) }, + onIncrease: { onAdjustRepetitions(1) } + ) + } + .padding(.top, 14) + } + } + .padding(.horizontal, 20) + } + + private var columnHeaders: some View { + HStack(spacing: 8) { + tableLabel("SET") + .frame(width: 36, alignment: .leading) + tableLabel("이전") + .frame(maxWidth: .infinity, alignment: .leading) + tableLabel("KG") + .frame(width: 72, alignment: .trailing) + Text("×") + .font(GymupTypography.label) + .foregroundStyle(GymupColors.tertiaryText) + tableLabel(exercise.measure == .seconds ? "SECS" : "REPS") + .frame(width: 36, alignment: .leading) + Color.clear.frame(width: 28) + } + .frame(height: 38) + .padding(.horizontal, 4) + } + + private func tableLabel(_ value: String) -> some View { + Text(value) + .font(GymupTypography.label) + .tracking(0.16) + .foregroundStyle(GymupColors.tertiaryText) + } + + private func setRow(_ set: WorkoutSessionSet, index: Int) -> some View { + let isCurrent = currentSetIndex == index + let valueColor = set.isCompleted || isCurrent ? GymupColors.primaryText : GymupColors.tertiaryText + return HStack(spacing: 8) { + Text(String(index + 1)) + .font(GymupTypography.number) + .foregroundStyle(isCurrent ? GymupColors.accent : valueColor) + .frame(width: 36, alignment: .leading) + Text(exercise.previousPerformance(for: index) ?? "—") + .font(GymupTypography.uiCaption) + .foregroundStyle(GymupColors.tertiaryText) + .frame(maxWidth: .infinity, alignment: .leading) + Text(set.weightKilograms.formatted(.number.precision(.fractionLength(0...1)))) + .font(GymupTypography.number) + .foregroundStyle(valueColor) + .frame(width: 72, alignment: .trailing) + Text("×") + .font(GymupTypography.uiCaption) + .foregroundStyle(GymupColors.tertiaryText) + Text(String(set.repetitions)) + .font(GymupTypography.number) + .foregroundStyle(valueColor) + .frame(width: 36, alignment: .leading) + Button { onToggleSet(index) } label: { + statusIcon(set: set, isCurrent: isCurrent) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(set.isCompleted ? "세트 \(index + 1) 완료 취소" : "세트 \(index + 1) 완료") + .accessibilityIdentifier("workout.set.\(index + 1).complete") + } + .padding(.horizontal, 4) + .frame(height: 44) + .background(isCurrent ? GymupColors.elevatedSurface.opacity(0.55) : .clear) + .overlay(alignment: .bottom) { + Rectangle().fill(GymupColors.border.opacity(0.8)).frame(height: 1) + } + } + + @ViewBuilder + private func statusIcon(set: WorkoutSessionSet, isCurrent: Bool) -> some View { + if set.isCompleted { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28, weight: .regular)) + .foregroundStyle(GymupColors.accent) + .overlay { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + } + } else { + Circle() + .strokeBorder(isCurrent ? GymupColors.accent : GymupColors.border, lineWidth: isCurrent ? 1.5 : 1) + .frame(width: 28, height: 28) + } + } +} diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift index 77a21bf..aeedd95 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift @@ -251,161 +251,3 @@ struct WorkoutTrainModeScreen: View { .accessibilityIdentifier("workout.train.primary-action") } } - -private struct WorkoutSessionRecordSummary: View { - let exercise: WorkoutSessionExercise - let currentSetIndex: Int? - - private var displayedSetIndex: Int? { - currentSetIndex ?? (exercise.isComplete ? exercise.sets.indices.last : nil) - } - - private var showsPersonalRecord: Bool { - guard !exercise.isComplete else { return false } - return currentSetIndex != exercise.sets.count - 1 - } - - var body: some View { - HStack(alignment: .firstTextBaseline) { - Text(exercise.previousPerformanceTitle(for: displayedSetIndex)) - .font(GymupTypography.uiCaption) - .foregroundStyle(GymupColors.secondaryText) - .lineLimit(1) - .minimumScaleFactor(0.8) - Spacer(minLength: 8) - if showsPersonalRecord, let personalRecord = exercise.personalRecordKilograms { - Text("PR 도전 \(personalRecord.formatted(.number.precision(.fractionLength(0...1))))kg") - .font(GymupTypography.uiMedium) - .foregroundStyle(GymupColors.brandText) - .lineLimit(1) - } - } - .padding(.horizontal, 20) - .frame(height: 40, alignment: .center) - .overlay(alignment: .bottom) { - Rectangle().fill(GymupColors.border).frame(height: 1) - .padding(.horizontal, 20) - } - } -} - -private struct WorkoutSessionSetTable: View { - let exercise: WorkoutSessionExercise - let currentSetIndex: Int? - let onToggleSet: (Int) -> Void - let onAdjustWeight: (Double) -> Void - let onInputWeight: () -> Void - let onAdjustRepetitions: (Int) -> Void - - var body: some View { - VStack(spacing: 0) { - columnHeaders - ForEach(Array(exercise.sets.enumerated()), id: \.element.id) { index, set in - setRow(set, index: index) - } - if let currentSetIndex, exercise.sets.indices.contains(currentSetIndex) { - HStack(spacing: 10) { - WorkoutSessionValueAdjuster( - title: "SET \(currentSetIndex + 1) · KG", - value: exercise.sets[currentSetIndex].weightKilograms.formatted(.number.precision(.fractionLength(0...1))), - onDecrease: { onAdjustWeight(-2.5) }, - onIncrease: { onAdjustWeight(2.5) }, - onValueTap: onInputWeight - ) - WorkoutSessionValueAdjuster( - title: "SET \(currentSetIndex + 1) · \(exercise.measure == .seconds ? "SECS" : "REPS")", - value: String(exercise.sets[currentSetIndex].repetitions), - onDecrease: { onAdjustRepetitions(-1) }, - onIncrease: { onAdjustRepetitions(1) } - ) - } - .padding(.top, 14) - } - } - .padding(.horizontal, 20) - } - - private var columnHeaders: some View { - HStack(spacing: 8) { - tableLabel("SET") - .frame(width: 36, alignment: .leading) - tableLabel("이전") - .frame(maxWidth: .infinity, alignment: .leading) - tableLabel("KG") - .frame(width: 72, alignment: .trailing) - Text("×") - .font(GymupTypography.label) - .foregroundStyle(GymupColors.tertiaryText) - tableLabel(exercise.measure == .seconds ? "SECS" : "REPS") - .frame(width: 36, alignment: .leading) - Color.clear.frame(width: 28) - } - .frame(height: 38) - .padding(.horizontal, 4) - } - - private func tableLabel(_ value: String) -> some View { - Text(value) - .font(GymupTypography.label) - .tracking(0.16) - .foregroundStyle(GymupColors.tertiaryText) - } - - private func setRow(_ set: WorkoutSessionSet, index: Int) -> some View { - let isCurrent = currentSetIndex == index - let valueColor = set.isCompleted || isCurrent ? GymupColors.primaryText : GymupColors.tertiaryText - return HStack(spacing: 8) { - Text(String(index + 1)) - .font(GymupTypography.number) - .foregroundStyle(isCurrent ? GymupColors.accent : valueColor) - .frame(width: 36, alignment: .leading) - Text(exercise.previousPerformance(for: index) ?? "—") - .font(GymupTypography.uiCaption) - .foregroundStyle(GymupColors.tertiaryText) - .frame(maxWidth: .infinity, alignment: .leading) - Text(set.weightKilograms.formatted(.number.precision(.fractionLength(0...1)))) - .font(GymupTypography.number) - .foregroundStyle(valueColor) - .frame(width: 72, alignment: .trailing) - Text("×") - .font(GymupTypography.uiCaption) - .foregroundStyle(GymupColors.tertiaryText) - Text(String(set.repetitions)) - .font(GymupTypography.number) - .foregroundStyle(valueColor) - .frame(width: 36, alignment: .leading) - Button { onToggleSet(index) } label: { - statusIcon(set: set, isCurrent: isCurrent) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel(set.isCompleted ? "세트 \(index + 1) 완료 취소" : "세트 \(index + 1) 완료") - .accessibilityIdentifier("workout.set.\(index + 1).complete") - } - .padding(.horizontal, 4) - .frame(height: 44) - .background(isCurrent ? GymupColors.elevatedSurface.opacity(0.55) : .clear) - .overlay(alignment: .bottom) { - Rectangle().fill(GymupColors.border.opacity(0.8)).frame(height: 1) - } - } - - @ViewBuilder - private func statusIcon(set: WorkoutSessionSet, isCurrent: Bool) -> some View { - if set.isCompleted { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 28, weight: .regular)) - .foregroundStyle(GymupColors.accent) - .overlay { - Image(systemName: "checkmark") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.white) - } - } else { - Circle() - .strokeBorder(isCurrent ? GymupColors.accent : GymupColors.border, lineWidth: isCurrent ? 1.5 : 1) - .frame(width: 28, height: 28) - } - } -}