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,17 @@ import SwiftUI
struct WorkoutPlateCalculatorSheet: View {
let target: Double
@Environment(\.dismiss) private var dismiss
@State private var viewModel: WorkoutPlateCalculatorViewModel

private var plates: [Double]? { WorkoutPlateCalculator.platesPerSide(for: target) }
init(target: Double) {
self.target = target
#if DEBUG
let service: WorkoutPlateCalculating? = DebugOnboardingNetworking.makePlateCalculatorService()
#else
let service: WorkoutPlateCalculating? = nil
#endif
_viewModel = State(initialValue: WorkoutPlateCalculatorViewModel(target: target, service: service))
}

var body: some View {
ScrollView {
Expand All @@ -26,11 +35,14 @@ struct WorkoutPlateCalculatorSheet: View {
.frame(maxWidth: .infinity).accessibilityHidden(true)
row("바", "20 kg")
row("양쪽 각각", plateDescription)
row("사용 가능 원판", "25 · 20 · 15 · 10 · 5 · 2.5 · 1.25")
if plates == nil {
Text("20kg 이상, 2.5kg 단위의 중량을 입력해 주세요.")
.font(GymupTypography.uiMedium).foregroundStyle(GymupColors.secondaryText)
if let response = viewModel.response {
row("실제 총중량", "\(weight(response.actualWeight)) kg")
if !response.achievable {
row("목표보다 부족", "\(weight(response.remainder)) kg")
}
}
row("사용 가능 원판", "25 · 20 · 15 · 10 · 5 · 2.5 · 1.25")
calculationFeedback
Button("닫기") { dismiss() }
.font(GymupTypography.sectionTitle)
.foregroundStyle(GymupColors.primaryText)
Expand All @@ -45,11 +57,74 @@ struct WorkoutPlateCalculatorSheet: View {
.presentationDetents([.height(555)])
.presentationCornerRadius(28)
.presentationDragIndicator(.visible)
.task { await viewModel.calculateIfAvailable() }
}

private var plateDescription: String {
guard let plates else { return "계산 불가" }
return plates.isEmpty ? "원판 없음" : plates.map { "\(weight($0)) kg" }.joined(separator: " + ")
guard let response = viewModel.response else {
return viewModel.isCalculating ? "계산 중..." : "계산 불가"
}
guard !response.eachSide.isEmpty else { return "원판 없음" }
return response.eachSide
.sorted { $0.weight > $1.weight }
.map { plate in
let count = plate.count > 1 ? " × \(plate.count)" : ""
return "\(weight(plate.weight)) kg\(count)"
}
.joined(separator: " + ")
}

@ViewBuilder
private var calculationFeedback: some View {
if viewModel.isCalculating {
HStack(spacing: 8) {
ProgressView()
.tint(GymupColors.brandText)
.accessibilityHidden(true)
Text("가장 가까운 조합을 확인하고 있어요.")
.font(GymupTypography.uiMedium)
.fixedSize(horizontal: false, vertical: true)
}
.foregroundStyle(GymupColors.secondaryText)
.accessibilityElement(children: .combine)
} else if let errorMessage = viewModel.serverErrorMessage {
VStack(alignment: .leading, spacing: 4) {
Label {
Text(errorMessage)
.font(GymupTypography.uiMedium)
.fixedSize(horizontal: false, vertical: true)
} icon: {
Image(systemName: "exclamationmark.circle.fill")
.accessibilityHidden(true)
}
.foregroundStyle(GymupColors.accent)
.accessibilityIdentifier("workout.plate.serverError")

if viewModel.canRetry {
Button {
Task { await viewModel.retry() }
} label: {
Text("다시 시도")
.font(GymupTypography.uiBold)
.foregroundStyle(GymupColors.brandText)
.frame(minHeight: 44, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("원판 계산 다시 시도")
.accessibilityHint("같은 목표 중량으로 서버에 다시 요청합니다")
.accessibilityIdentifier("workout.plate.retry")
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} else if viewModel.response == nil {
Text(viewModel.isValidTarget
? "정확한 조합을 만들 수 없어요. 2.5kg 단위로 조정해 주세요."
: "20kg 이상, 999kg 이하 중량을 입력해 주세요.")
.font(GymupTypography.uiMedium)
.foregroundStyle(GymupColors.secondaryText)
.fixedSize(horizontal: false, vertical: true)
}
}

private func weight(_ value: Double) -> String {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import Foundation
import Observation

protocol WorkoutPlateCalculating {
func calculate(_ request: PlateCalculatorRequest) async throws -> PlateCalculatorResponse
}

extension PlateCalculatorAPIService: WorkoutPlateCalculating {}

@Observable
@MainActor
final class WorkoutPlateCalculatorViewModel {
let target: Double

private let service: WorkoutPlateCalculating?
private let request: PlateCalculatorRequest?
private var didAttemptCalculation = false

private(set) var response: PlateCalculatorResponse?
private(set) var isCalculating = false
private(set) var serverErrorMessage: String?

init(target: Double, service: WorkoutPlateCalculating?) {
self.target = target
self.service = service
request = Self.makeRequest(target: target)
response = Self.localResponse(target: target)
}

var isValidTarget: Bool { request != nil }
var canRetry: Bool { serverErrorMessage != nil && service != nil && request != nil }
var canRequestServerResult: Bool { service != nil && request != nil }

func calculateIfAvailable() async {
guard !didAttemptCalculation else { return }
await calculate()
}

func retry() async {
guard canRetry else { return }
await calculate()
}

private func calculate() async {
guard !isCalculating,
let service,
let request
else {
return
}

didAttemptCalculation = true
isCalculating = true
serverErrorMessage = nil
defer { isCalculating = false }

do {
response = try await service.calculate(request)
} catch {
serverErrorMessage = Self.errorMessage(for: error, hasLocalResult: response != nil)
}
}

private static func makeRequest(target: Double) -> PlateCalculatorRequest? {
guard target.isFinite,
target >= WorkoutPlateCalculator.barWeight,
target <= 999
else {
return nil
}

return PlateCalculatorRequest(
targetWeight: target,
barWeight: WorkoutPlateCalculator.barWeight,
availablePlates: WorkoutPlateCalculator.plates.map(JSONValue.number)
)
}

private static func localResponse(target: Double) -> PlateCalculatorResponse? {
guard target.isFinite,
target >= WorkoutPlateCalculator.barWeight,
target <= 999
else {
return nil
}

let perSideUnits = (target - WorkoutPlateCalculator.barWeight) / 2 / 1.25
let exactUnits = abs(perSideUnits - perSideUnits.rounded()) < 0.000_001
let localTarget = exactUnits
? target
: WorkoutPlateCalculator.barWeight + 2 * floor(perSideUnits) * 1.25
guard let plates = WorkoutPlateCalculator.platesPerSide(for: localTarget) else { return nil }

let groupedPlates = Dictionary(grouping: plates, by: \.self)
.map { weight, plates in PlateWeightCount(weight: weight, count: plates.count) }
.sorted { $0.weight > $1.weight }
let actualWeight = WorkoutPlateCalculator.barWeight + 2 * plates.reduce(0, +)
let remainder = max(target - actualWeight, 0)

return PlateCalculatorResponse(
achievable: remainder < 0.000_001,
eachSide: groupedPlates,
actualWeight: actualWeight,
remainder: remainder
)
}

private static func errorMessage(for error: Error, hasLocalResult: Bool) -> String {
if case let APIClientError.server(_, detail) = error {
return detail.message
}

return hasLocalResult
? "서버 연결에 실패해 기존 계산 결과를 보여드려요."
: "계산 결과를 불러오지 못했어요. 다시 시도해 주세요."
}
}
12 changes: 12 additions & 0 deletions GYMUP/Sources/Networking/DebugOnboardingNetworking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,5 +131,17 @@ enum DebugOnboardingNetworking {
let client = APIClient(environment: environment, tokenProvider: tokenProvider)
return UserSettingsAPIService(client: client)
}

static func makePlateCalculatorService() -> PlateCalculatorAPIService? {
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 PlateCalculatorAPIService(client: client)
}
}
#endif
39 changes: 39 additions & 0 deletions GYMUP/Tests/Networking/PlateCalculatorAPIServiceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation
import XCTest
@testable import GYMUP

final class PlateCalculatorAPIServiceTests: NetworkingTestCase {
func testCalculatePostsDocumentedRequestAndDecodesSuccessResponse() async throws {
var capturedRequest: URLRequest?
installJSONResponse(
[
"achievable": true,
"eachSide": [["weight": 20.0, "count": 2]],
"actualWeight": 100.0,
"remainder": 0.0
],
recordRequest: { capturedRequest = $0 }
)

let request = PlateCalculatorRequest(
targetWeight: 100,
barWeight: 20,
availablePlates: [25, 20, 15, 10, 5, 2.5, 1.25].map(JSONValue.number)
)
let service = try PlateCalculatorAPIService(client: makeClient(token: "test-token"))
let response = try await service.calculate(request)

XCTAssertEqual(capturedRequest?.httpMethod, "POST")
XCTAssertEqual(capturedRequest?.url?.path, "/api/v1/tools/plate-calculator")
XCTAssertTrue(response.achievable)
XCTAssertEqual(response.eachSide, [PlateWeightCount(weight: 20, count: 2)])
XCTAssertEqual(response.actualWeight, 100)
XCTAssertEqual(response.remainder, 0)

let body = try XCTUnwrap(capturedRequest?.httpBody)
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
XCTAssertEqual(object["targetWeight"] as? Double, 100)
XCTAssertEqual(object["barWeight"] as? Double, 20)
XCTAssertEqual(object["availablePlates"] as? [Double], [25, 20, 15, 10, 5, 2.5, 1.25])
}
}
19 changes: 0 additions & 19 deletions GYMUP/Tests/Networking/WorkoutAPIServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,25 +92,6 @@ final class WorkoutAPIServiceTests: NetworkingTestCase {
let body = try XCTUnwrap(capturedRequest?.httpBody)
XCTAssertEqual(try JSONDecoder().decode(CompleteWorkoutRequest.self, from: body), request)
}
func testPlateCalculatorUsesTypedWeightsAndOpaquePlateItems() async throws {
var capturedRequest: URLRequest?
installJSONResponse(
["achievable": true, "eachSide": [["weight": 20.0, "count": 1]], "actualWeight": 60.0, "remainder": 0.0],
recordRequest: { capturedRequest = $0 }
)

let availablePlate = JSONValue.object(["weight": .number(20), "count": .integer(2)])
let request = PlateCalculatorRequest(targetWeight: 60, barWeight: 20, availablePlates: [availablePlate])
let service = try PlateCalculatorAPIService(client: makeClient(token: "test-token"))
let response = try await service.calculate(request)

XCTAssertEqual(capturedRequest?.url?.path, "/api/v1/tools/plate-calculator")
XCTAssertTrue(response.achievable)
XCTAssertEqual(response.eachSide, [PlateWeightCount(weight: 20, count: 1)])
let body = try XCTUnwrap(capturedRequest?.httpBody)
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
XCTAssertEqual((object["availablePlates"] as? [[String: Any]])?.first?["weight"] as? Double, 20)
}
func testWorkoutListDecodesPagedItemsAndNextSet() async throws {
let paths = [
"/api/v1/workouts": (
Expand Down
Loading
Loading