Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down
49 changes: 48 additions & 1 deletion GYMUP/Sources/Features/WorkoutSession/WorkoutSessionModels.swift
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] 날짜·이전 기록 표시와 ViewModel의 성공/기록 없음/실패/재시도 동작을 검증하는 회귀 테스트를 추가해 주세요. Issue #184의 검증 조건인데 현재 PR에는 테스트가 없고, 이번 CI도 SwiftLint 실패 때문에 XCTest를 건너뛰었습니다.

}

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
Expand Down Expand Up @@ -29,6 +70,7 @@ struct WorkoutSessionExercise: Identifiable, Equatable {
var sets: [WorkoutSessionSet]
var imageAssetOverride: String?
var usesSampleHistory = true
var previousRecord: WorkoutSessionPreviousRecord?
var measure = RoutineExerciseMeasure.repetitions

var completedSetCount: Int { sets.filter(\.isCompleted).count }
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] }
Expand All @@ -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
}
Expand Down
138 changes: 138 additions & 0 deletions GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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: "루틴으로 돌아가 운동을 확인해 주세요.")
}
}
}
}
Loading
Loading