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
Expand Up @@ -3,8 +3,24 @@ import SwiftUI
struct WorkoutSessionRecordRetryBanner: View {
let message: String
let isLoading: Bool
let retryAccessibilityLabel: String
let retryAccessibilityIdentifier: String
let onRetry: () -> Void

init(
message: String,
isLoading: Bool,
retryAccessibilityLabel: String = "이전 운동 기록 다시 시도",
retryAccessibilityIdentifier: String = "workout.previous-record.retry",
onRetry: @escaping () -> Void
) {
self.message = message
self.isLoading = isLoading
self.retryAccessibilityLabel = retryAccessibilityLabel
self.retryAccessibilityIdentifier = retryAccessibilityIdentifier
self.onRetry = onRetry
}

var body: some View {
HStack(spacing: GymupSpacing.small) {
Image(systemName: "exclamationmark.circle")
Expand Down Expand Up @@ -34,8 +50,8 @@ struct WorkoutSessionRecordRetryBanner: View {
}
.buttonStyle(.plain)
.disabled(isLoading)
.accessibilityLabel("이전 운동 기록 다시 시도")
.accessibilityIdentifier("workout.previous-record.retry")
.accessibilityLabel(retryAccessibilityLabel)
.accessibilityIdentifier(retryAccessibilityIdentifier)
}
.padding(.horizontal, GymupSpacing.medium)
.padding(.vertical, GymupSpacing.xSmall)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ struct WorkoutSessionRestTimerScreen: View {
let previousRecordError: String?
let isLoadingPreviousRecord: Bool
let onRetryPreviousRecord: () -> Void
let nextSetPreview: NextWorkoutSetResponse?
let nextSetPreviewError: String?
let isLoadingNextSetPreview: Bool
let onRetryNextSetPreview: () -> Void
@State private var isPlateCalculatorPresented = false

private var nextSet: WorkoutSessionSet { exercise.sets[nextSetIndex] }
Expand Down Expand Up @@ -38,6 +42,16 @@ struct WorkoutSessionRestTimerScreen: View {
)
.padding(.top, 12)
}
if let nextSetPreviewError {
WorkoutSessionRecordRetryBanner(
message: nextSetPreviewError,
isLoading: isLoadingNextSetPreview,
retryAccessibilityLabel: "다음 세트 정보 다시 시도",
retryAccessibilityIdentifier: "workout.next-set.retry",
onRetry: onRetryNextSetPreview
)
.padding(.top, 12)
}
Spacer(minLength: 64)
plateCalculatorHint
}
Expand Down Expand Up @@ -163,17 +177,17 @@ struct WorkoutSessionRestTimerScreen: View {
.clipShape(RoundedRectangle(cornerRadius: 10))

VStack(alignment: .leading, spacing: 0) {
Text("NEXT · SET \(nextSetIndex + 1) / \(exercise.sets.count)")
Text("NEXT · SET \(nextSetPreview?.setIndex ?? nextSetIndex + 1) / \(exercise.sets.count)")
.font(GymupTypography.label)
.tracking(0.16)
.foregroundStyle(GymupColors.secondaryText)
Text("\(nextSet.weightKilograms.formatted(.number.precision(.fractionLength(0...1)))) KG × "
+ "\(nextSet.repetitions)\(exercise.measure == .seconds ? "초" : "")")
Text("\((nextSetPreview?.weight ?? nextSet.weightKilograms).formatted(.number.precision(.fractionLength(0...1)))) KG × "
+ "\(nextSetPreview?.reps ?? nextSet.repetitions)\(exercise.measure == .seconds ? "초" : "")")
.font(GymupTypography.display)
.foregroundStyle(GymupColors.primaryText)
.lineLimit(1)
.minimumScaleFactor(0.75)
Text("\(exercise.name) · 이전 \(exercise.previousPerformance(for: nextSetIndex) ?? "—")")
Text("\(nextSetPreview?.exerciseName ?? exercise.name) · 이전 \(exercise.previousPerformance(for: nextSetIndex) ?? "—")")
.font(GymupTypography.uiCaption)
.foregroundStyle(GymupColors.secondaryText)
.lineLimit(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ extension WorkoutSessionView {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
syncRestActivity()
refreshNextSetPreviewIfServerResting()
if viewModel.currentExerciseIndex == viewModel.plan.exercises.count - 1,
viewModel.currentExercise?.isComplete == true {
showWorkoutProcessing()
Expand All @@ -78,13 +79,15 @@ extension WorkoutSessionView {
completeSetSideEffects()
} else if let failedAction, case .startRest = failedAction {
syncRestActivity()
refreshNextSetPreviewIfServerResting()
} else if let failedAction, case .skipRest = failedAction {
settings.cancelRestNotification(sessionID: liveActivitySessionID)
await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID)
} else if let failedAction, case .extendRest = failedAction {
syncRestActivity()
} else if let failedAction, case .refreshRest = failedAction {
syncRestActivity()
refreshNextSetPreviewIfServerResting()
}
}
}
Expand Down Expand Up @@ -114,6 +117,11 @@ extension WorkoutSessionView {
}
}

private func refreshNextSetPreviewIfServerResting() {
guard viewModel.isServerManagedRest else { return }
Task { await viewModel.loadNextSetPreview() }
}

func refreshRestOnForeground() {
guard viewModel.isServerManagedRest else { return }
if viewModel.refreshClock(at: Date()) {
Expand All @@ -128,6 +136,7 @@ extension WorkoutSessionView {
return
}
syncRestActivity()
await viewModel.loadNextSetPreview()
}
}

Expand Down Expand Up @@ -237,6 +246,14 @@ extension WorkoutSessionView {
DebugOnboardingNetworking.makeWorkoutSessionService()
#else
nil
#endif
}

static func makeWorkoutReadService() -> WorkoutNextSetReading? {
#if DEBUG
DebugOnboardingNetworking.makeWorkoutReadService()
#else
nil
#endif
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ extension WorkoutSessionView {
onSkip: skipCurrentRest,
previousRecordError: viewModel.previousRecordError(for: exercise.id),
isLoadingPreviousRecord: viewModel.isLoadingPreviousRecord(for: exercise.id),
onRetryPreviousRecord: { Task { await loadPreviousRecord(for: exercise.id) } }
onRetryPreviousRecord: { Task { await loadPreviousRecord(for: exercise.id) } },
nextSetPreview: viewModel.nextSetPreview,
nextSetPreviewError: viewModel.nextSetPreviewError,
isLoadingNextSetPreview: viewModel.isLoadingNextSetPreview,
onRetryNextSetPreview: { Task { await viewModel.loadNextSetPreview() } }
)
} else if let exercise = viewModel.currentExercise {
WorkoutTrainModeScreen(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ struct WorkoutSessionView: View {
historyStore: historyStore,
previousRecordService: Self.makePreviousRecordService(),
serverWorkoutID: serverWorkoutID,
progressService: serverWorkoutID == nil ? nil : Self.makeProgressService()
progressService: serverWorkoutID == nil ? nil : Self.makeProgressService(),
nextSetService: serverWorkoutID == nil ? nil : Self.makeWorkoutReadService()
)
)
self.historyStore = historyStore
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@ final class WorkoutSessionViewModel {
private let historyStore: HistoryStore
private let goalSettings: OnboardingStoredSettings?
private let previousRecordService: ExerciseReadAPIService?
let nextSetService: WorkoutNextSetReading?
let progressService: WorkoutSessionProgressing?
private(set) var previousRecordErrors: [String: String] = [:]
private(set) var loadingPreviousRecordExerciseIDs: Set<String> = []
private(set) var nextSetPreview: NextWorkoutSetResponse?
private(set) var nextSetPreviewError: String?
private(set) var isLoadingNextSetPreview = false
var isSubmittingWorkoutAction = false
var serverActionErrorMessage: String?
var failedServerAction: FailedWorkoutServerAction?
Expand All @@ -40,7 +44,8 @@ final class WorkoutSessionViewModel {
historyStore: HistoryStore? = nil,
goalSettings: OnboardingStoredSettings? = .load(),
previousRecordService: ExerciseReadAPIService? = nil,
progressService: WorkoutSessionProgressing? = nil
progressService: WorkoutSessionProgressing? = nil,
nextSetService: WorkoutNextSetReading? = nil
) {
plan = .sample(for: routineID)
self.startedAt = startsImmediately ? startedAt : nil
Expand All @@ -49,6 +54,7 @@ final class WorkoutSessionViewModel {
self.goalSettings = goalSettings
self.previousRecordService = previousRecordService
self.progressService = progressService
self.nextSetService = nextSetService
serverWorkoutID = nil
}

Expand All @@ -60,7 +66,8 @@ final class WorkoutSessionViewModel {
goalSettings: OnboardingStoredSettings? = .load(),
previousRecordService: ExerciseReadAPIService? = nil,
serverWorkoutID: String? = nil,
progressService: WorkoutSessionProgressing? = nil
progressService: WorkoutSessionProgressing? = nil,
nextSetService: WorkoutNextSetReading? = nil
) {
self.plan = plan
self.startedAt = startsImmediately ? startedAt : nil
Expand All @@ -70,6 +77,7 @@ final class WorkoutSessionViewModel {
self.previousRecordService = previousRecordService
self.serverWorkoutID = serverWorkoutID
self.progressService = progressService
self.nextSetService = nextSetService
}

var isPaused: Bool { pausedAt != nil }
Expand Down Expand Up @@ -305,3 +313,63 @@ extension WorkoutSessionViewModel {
}
}
}

extension WorkoutSessionViewModel {
func loadNextSetPreview() async {
guard !isLoadingNextSetPreview,
isServerManagedRest,
let workoutID = serverWorkoutID,
let nextSetService,
let exercise = currentExercise,
exercise.measure == .repetitions,
let localSetIndex = currentSetIndex else { return }

let expectedSetIndex = localSetIndex + 1
isLoadingNextSetPreview = true
nextSetPreview = nil
nextSetPreviewError = nil
defer { isLoadingNextSetPreview = false }

do {
let response = try await nextSetService.nextSet(workoutId: workoutID)
guard isCurrentNextSetRequest(
workoutID: workoutID,
exerciseID: exercise.id,
setIndex: localSetIndex
) else { return }
guard response.exerciseId == exercise.id,
response.setIndex == expectedSetIndex,
!response.exerciseName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
response.weight.isFinite,
response.weight >= 0,
response.reps >= 0 else {
throw APIClientError.invalidResponse
}

nextSetPreview = response
} catch {
guard !Task.isCancelled,
isCurrentNextSetRequest(
workoutID: workoutID,
exerciseID: exercise.id,
setIndex: localSetIndex
) else { return }
nextSetPreviewError = Self.nextSetPreviewMessage(for: error)
}
}

private func isCurrentNextSetRequest(workoutID: String, exerciseID: String, setIndex: Int) -> Bool {
serverWorkoutID == workoutID &&
isServerManagedRest &&
currentExercise?.id == exerciseID &&
currentSetIndex == setIndex
}

private static func nextSetPreviewMessage(for error: Error) -> String {
if let apiError = error as? APIClientError,
case let .server(_, detail) = apiError {
return detail.message
}
return "다음 세트 정보를 불러오지 못했어요."
}
}
12 changes: 12 additions & 0 deletions GYMUP/Sources/Networking/DebugOnboardingNetworking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,5 +179,17 @@ enum DebugOnboardingNetworking {
let client = APIClient(environment: environment, tokenProvider: tokenProvider)
return WorkoutSessionAPIService(client: client)
}

static func makeWorkoutReadService() -> WorkoutReadAPIService? {
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 WorkoutReadAPIService(client: client)
}
}
#endif
6 changes: 5 additions & 1 deletion GYMUP/Sources/Networking/WorkoutReadAPIService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ struct NextWorkoutSetResponse: Codable, Equatable {
let restEndsAt: String
}

struct WorkoutReadAPIService {
protocol WorkoutNextSetReading {
func nextSet(workoutId: String) async throws -> NextWorkoutSetResponse
}

struct WorkoutReadAPIService: WorkoutNextSetReading {
private let client: APIClient

init(client: APIClient) {
Expand Down
Loading
Loading