Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TNT-227] 트레이니 식단 등록 화면 API 연결 #75

Merged
merged 4 commits into from
Feb 12, 2025
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 @@ -23,4 +23,8 @@ public struct TraineeRepositoryImpl: TraineeRepository {
decodingType: PostConnectTrainerResDTO.self
)
}

public func postTraineeDietRecord(_ reqDTO: PostTraineeDietRecordReqDTO, imgData: Data?) async throws -> PostTraineeDietRecordResDTO {
return try await networkService.request(TraineeTargetType.postTraineeDietRecord(reqDto: reqDTO, imgData: imgData), decodingType: PostTraineeDietRecordResDTO.self)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import Domain
public enum TraineeTargetType {
/// 트레이너 연결 요청
case postConnectTrainer(reqDto: PostConnectTrainerReqDTO)
/// 트레이니 식단 기록 작성
case postTraineeDietRecord(reqDto: PostTraineeDietRecordReqDTO, imgData: Data?)
}

extension TraineeTargetType: TargetType {
Expand All @@ -26,12 +28,14 @@ extension TraineeTargetType: TargetType {
switch self {
case .postConnectTrainer:
return "/connect-trainer"
case .postTraineeDietRecord:
return "/diets"
}
}

var method: HTTPMethod {
switch self {
case .postConnectTrainer:
case .postConnectTrainer, .postTraineeDietRecord:
return .post
}
}
Expand All @@ -40,13 +44,25 @@ extension TraineeTargetType: TargetType {
switch self {
case .postConnectTrainer(let reqDto):
return .requestJSONEncodable(encodable: reqDto)
case let .postTraineeDietRecord(reqDto, imgData):
let files: [MultipartFile] = imgData.map {
[.init(fieldName: "dietImage", fileName: "dietImage.png", mimeType: "image/png", data: $0)]
} ?? []

return .uploadMultipart(
jsons: [.init(jsonName: "request", json: reqDto)],
files: files,
additionalFields: [:]
)
}
}

var headers: [String: String]? {
switch self {
case .postConnectTrainer:
return ["Content-Type": "application/json"]
case .postTraineeDietRecord:
return ["Content-Type": "multipart/form-data"]
}
}

Expand All @@ -56,7 +72,7 @@ extension TraineeTargetType: TargetType {
AuthTokenInterceptor(),
ProgressIndicatorInterceptor(),
ResponseValidatorInterceptor(),
RetryInterceptor(maxRetryCount: 0)
RetryInterceptor(maxRetryCount: 2)
]
}
}
20 changes: 20 additions & 0 deletions TnT/Projects/Domain/Sources/DTO/Trainee/TraineeRequestDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,23 @@ public struct PostConnectTrainerReqDTO: Encodable {
self.finishedPtCount = finishedPtCount
}
}

/// 트레이니 식단 기록 요청 DTO
public struct PostTraineeDietRecordReqDTO: Encodable {
/// 식단 dateTime
public let date: String
/// 식단 타입
public let dietType: String
/// 식단 메모
public let memo: String

public init(
date: String,
dietType: String,
memo: String
) {
self.date = date
self.dietType = dietType
self.memo = memo
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ public struct PostConnectTrainerResDTO: Decodable {
/// 트레이니 프로필 이미지 URL
public let traineeProfileImageUrl: String
}

/// 트레이니 식단 기록 응답 DTO
public typealias PostTraineeDietRecordResDTO = EmptyResponse
6 changes: 3 additions & 3 deletions TnT/Projects/Domain/Sources/Entity/DietType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@
import Foundation

/// 앱에서 존재하는 식단 유형을 정의한 열거형
public enum DietType: Sendable, CaseIterable {
case morning
public enum DietType: String, Sendable, CaseIterable {
case breakfast
case lunch
case dinner
case snack

/// 식사 유형을 한글로 변환하여 반환
public var koreanName: String {
switch self {
case .morning: return "아침"
case .breakfast: return "아침"
case .lunch: return "점심"
case .dinner: return "저녁"
case .snack: return "간식"
Expand Down
2 changes: 2 additions & 0 deletions TnT/Projects/Domain/Sources/Policy/TDateFormat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ public enum TDateFormat: String {
case a_HHmm = "a HH:mm"
/// "17:00"
case HHmm = "HH:mm"
/// "2024-02-12T15:30:00Z"
case ISO8601 = "yyyy-MM-dd'T'HH:mm:ss'Z'"
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,12 @@ public protocol TraineeRepository {
/// - Returns: 연결 성공 시, 연결된 트레이너 정보가 포함된 응답 DTO (`PostConnectTrainerResDTO`)
/// - Throws: 네트워크 오류 또는 잘못된 요청 데이터로 인한 서버 오류 발생 가능
func postConnectTrainer(_ reqDTO: PostConnectTrainerReqDTO) async throws -> PostConnectTrainerResDTO

/// 회원가입 요청
/// - Parameters:
/// - reqDTO: 식단 등록 요청에 필요한 데이터
/// - imgData: 사용자가 업로드한 식단 이미지 (옵션)
/// - Returns: 등록 성공 시, 응답 DTO (empty) (`PostTraineeDietRecordResDTO`)
/// - Throws: 네트워크 오류 또는 서버에서 반환한 오류를 발생시킬 수 있음
func postTraineeDietRecord(_ reqDTO: PostTraineeDietRecordReqDTO, imgData: Data?) async throws -> PostTraineeDietRecordResDTO
}
6 changes: 6 additions & 0 deletions TnT/Projects/Domain/Sources/UseCase/TraineeUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
// Copyright © 2025 yapp25thTeamTnT. All rights reserved.
//

import Foundation

// MARK: - TraineeUseCase 프로토콜
public protocol TraineeUseCase {
/// 입력 초대 코드 검증
Expand Down Expand Up @@ -66,4 +68,8 @@ extension DefaultTraineeUseCase: TraineeRepository {
public func postConnectTrainer(_ reqDTO: PostConnectTrainerReqDTO) async throws -> PostConnectTrainerResDTO {
return try await traineeRepository.postConnectTrainer(reqDTO)
}

public func postTraineeDietRecord(_ reqDTO: PostTraineeDietRecordReqDTO, imgData: Data?) async throws -> PostTraineeDietRecordResDTO {
return try await traineeRepository.postTraineeDietRecord(reqDTO, imgData: imgData)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,14 @@ public struct TraineeAddDietRecordFeature {
}
}

@Dependency(\.traineeRepoUseCase) private var traineeRepoUseCase
@Dependency(\.dismiss) private var dismiss

public enum Action: Sendable, ViewAction {
/// 뷰에서 발생한 액션을 처리합니다.
case view(View)
/// api 콜 액션을 처리합니다
case api(APIAction)
/// 선택된 이미지 데이터 저장
case imagePicked(Data?)
/// 네비게이션 여부 설정
Expand Down Expand Up @@ -118,6 +121,12 @@ public struct TraineeAddDietRecordFeature {
/// 포커스 상태 변경
case setFocus(FocusField?, FocusField?)
}

@CasePathable
public enum APIAction: Sendable {
/// 식단 등록 API
case registerDietRecord
}
}

public init() {}
Expand All @@ -129,7 +138,9 @@ public struct TraineeAddDietRecordFeature {
switch action {
case .view(let action):
switch action {
case .binding(\.dietDate), .binding(\.dietTime), .binding(\.dietType):
case .binding(\.dietDate),
.binding(\.dietTime),
.binding(\.dietType):
return self.validateAllFields(&state)

case .binding(\.dietInfo):
Expand All @@ -139,14 +150,15 @@ public struct TraineeAddDietRecordFeature {
case .binding(\.view_photoPickerItem):
let item: PhotosPickerItem? = state.view_photoPickerItem
return .run { [item] send in
if let item, let data = try? await item.loadTransferable(type: Data.self) {
if let item,
let data = try? await item.loadTransferable(type: Data.self) {
await send(.imagePicked(data))
}
}

case .binding:
return .none

case .tapNavBackButton:
if state.view_isSubmitButtonEnabled {
return self.setPopUpStatus(&state, status: .cancelDietAdd)
Expand All @@ -164,11 +176,11 @@ public struct TraineeAddDietRecordFeature {
case .tapDietDateDropDown:
state.view_bottomSheetItem = .datePicker(.dietDate)
return .send(.view(.setFocus(state.view_focusField, .dietDate)))

case .tapDietTimeDropDown:
state.view_bottomSheetItem = .timePicker(.dietTime)
return .send(.view(.setFocus(state.view_focusField, .dietTime)))

case let .tapBottomSheetSubmitButton(field, date):
state.view_bottomSheetItem = nil

Expand All @@ -193,7 +205,7 @@ public struct TraineeAddDietRecordFeature {
return self.validateAllFields(&state)

case .tapSubmitButton:
return .send(.setNavigating)
return .send(.api(.registerDietRecord))

case .tapPopUpSecondaryButton(let popUp):
guard popUp != nil else { return .none }
Expand All @@ -202,13 +214,31 @@ public struct TraineeAddDietRecordFeature {
case .tapPopUpPrimaryButton(let popUp):
guard popUp != nil else { return .none }
return setPopUpStatus(&state, status: nil)

case let .setFocus(oldFocus, newFocus):
guard oldFocus != newFocus else { return .none }
state.view_focusField = newFocus
return .none
}

case .api(let action):
switch action {
case .registerDietRecord:
guard let date = combinedDietDateTime(date: state.dietDate, time: state.dietTime)?.toString(format: .ISO8601),
let dietType = state.dietType?.rawValue else { return .none }
return .run { [state] send in
let result = try await traineeRepoUseCase.postTraineeDietRecord(
.init(
date: date,
dietType: dietType,
memo: state.dietInfo
),
imgData: state.dietImageData
)
await send(.setNavigating)
}
}

case .imagePicked(let imgData):
state.dietImageData = imgData
return self.validateAllFields(&state)
Expand All @@ -235,7 +265,8 @@ private extension TraineeAddDietRecordFeature {
guard state.dietDate != nil else { return .none }
guard state.dietTime != nil else { return .none }
guard state.dietType != nil else { return .none }

guard state.dietInfo != nil else { return .none }

state.view_isSubmitButtonEnabled = true
return .none
}
Expand All @@ -247,6 +278,24 @@ private extension TraineeAddDietRecordFeature {
state.view_isPopUpPresented = status != nil
return .none
}

/// dietDate와 dietTime을 결합하여 최종 `Date`를 생성
func combinedDietDateTime(date: Date?, time: Date?) -> Date? {
guard let date = date, let time = time else { return nil }

let calendar = Calendar.current
let dateComponents = calendar.dateComponents([.year, .month, .day], from: date)
let timeComponents = calendar.dateComponents([.hour, .minute, .second], from: time)

return calendar.date(from: DateComponents(
year: dateComponents.year,
month: dateComponents.month,
day: dateComponents.day,
hour: timeComponents.hour,
minute: timeComponents.minute,
second: timeComponents.second
))
}
}

// MARK: BottomSheet
Expand Down Expand Up @@ -317,7 +366,6 @@ public extension TraineeAddDietRecordFeature {
return nil
case .cancelDietAdd:
return .tapPopUpSecondaryButton(popUp: self)
return nil
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,20 +254,24 @@ public struct TraineeAddDietRecordView: View {

@ViewBuilder
private func DietInfoSection() -> some View {
TTextEditor(
placeholder: "식단에 대한 정보를 입력해주세요!",
text: $store.dietInfo,
textEditorStatus: $store.view_dietInfoStatus,
footer: {
.init(
textLimit: 100,
status: $store.view_dietInfoStatus,
textCount: store.dietInfo.count,
warningText: "100자 미만으로 입력해주세요"
)
}
)
.focused($focusedField, equals: .dietInfo)
VStack(alignment: .leading, spacing: 8) {
TTextField.Header(isRequired: true, title: "메모하기", limitCount: nil, textCount: nil)

TTextEditor(
placeholder: "식단에 대한 정보를 입력해주세요!",
text: $store.dietInfo,
textEditorStatus: $store.view_dietInfoStatus,
footer: {
.init(
textLimit: 100,
status: $store.view_dietInfoStatus,
textCount: store.dietInfo.count,
warningText: "100자 미만으로 입력해주세요"
)
}
)
.focused($focusedField, equals: .dietInfo)
}
}

@ViewBuilder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ public struct TraineeMainFlowFeature {
// 특정 화면 append
return .none

/// 식단 기록 화면 등록 -> 홈화면으로 이동
case .element(id: _, action: .addDietRecordPage(.setNavigating)):
state.path.removeLast()
return .none

/// 마이페이지 초대코드 입력화면 다음 버튼 탭 - > PT 정보 입력 화면 or 홈 이동
case .element(_, action: .traineeInvitationCodeInput(.setNavigating(let screen))):
switch screen {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ extension DietType {
/// 해당 식단을 표시하는 이모지 입니다
var emoji: String {
switch self {
case .morning:
case .breakfast:
"🌞"
case .lunch:
"⛅"
Expand Down