From a41900ca11b0dbefcba1e1d321d04245bbd140e7 Mon Sep 17 00:00:00 2001 From: Minseo Park <125115284+FpRaArNkK@users.noreply.github.com> Date: Fri, 14 Feb 2025 00:38:20 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[Feat]=20Trainee=20=EA=B4=80=EB=A0=A8=20API?= =?UTF-8?q?=20=EC=B5=9C=EC=8B=A0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Trainee/TraineeRepositoryImpl.swift | 12 ++++ .../Service/Trainee/TraineeTargetType.swift | 27 ++++++++- .../DTO/Trainee/TraineeResponseDTO.swift | 56 +++++++++++++++++++ .../Repository/TraineeRepository.swift | 22 ++++++++ .../Sources/UseCase/TraineeUseCase.swift | 12 ++++ 5 files changed, 128 insertions(+), 1 deletion(-) diff --git a/TnT/Projects/Data/Sources/Network/Service/Trainee/TraineeRepositoryImpl.swift b/TnT/Projects/Data/Sources/Network/Service/Trainee/TraineeRepositoryImpl.swift index b8ba4074..4e2ccaf7 100644 --- a/TnT/Projects/Data/Sources/Network/Service/Trainee/TraineeRepositoryImpl.swift +++ b/TnT/Projects/Data/Sources/Network/Service/Trainee/TraineeRepositoryImpl.swift @@ -27,4 +27,16 @@ public struct TraineeRepositoryImpl: TraineeRepository { public func postTraineeDietRecord(_ reqDTO: PostTraineeDietRecordReqDTO, imgData: Data?) async throws -> PostTraineeDietRecordResDTO { return try await networkService.request(TraineeTargetType.postTraineeDietRecord(reqDto: reqDTO, imgData: imgData), decodingType: PostTraineeDietRecordResDTO.self) } + + public func getActiveDateList(startDate: String, endDate: String) async throws -> GetActiveDateListResDTO { + return try await networkService.request(TraineeTargetType.getActiveDateList(startDate: startDate, endDate: endDate), decodingType: GetActiveDateListResDTO.self) + } + + public func getActiveDateDetail(date: String) async throws -> GetActiveDateDetailResDTO { + return try await networkService.request(TraineeTargetType.getActiveDateDetail(date: date), decodingType: GetActiveDateDetailResDTO.self) + } + + public func getDietRecordDetail(dietId: Int) async throws -> GetDietRecordDetailResDTO { + return try await networkService.request(TraineeTargetType.getDietRecordDetail(dietId: dietId), decodingType: GetDietRecordDetailResDTO.self) + } } diff --git a/TnT/Projects/Data/Sources/Network/Service/Trainee/TraineeTargetType.swift b/TnT/Projects/Data/Sources/Network/Service/Trainee/TraineeTargetType.swift index 490b6a08..42a232f2 100644 --- a/TnT/Projects/Data/Sources/Network/Service/Trainee/TraineeTargetType.swift +++ b/TnT/Projects/Data/Sources/Network/Service/Trainee/TraineeTargetType.swift @@ -16,6 +16,12 @@ public enum TraineeTargetType { case postConnectTrainer(reqDto: PostConnectTrainerReqDTO) /// 트레이니 식단 기록 작성 case postTraineeDietRecord(reqDto: PostTraineeDietRecordReqDTO, imgData: Data?) + /// 캘린더 수업, 기록 존재하는 날짜 조회 + case getActiveDateList(startDate: String, endDate: String) + /// 특정 날짜 수업, 기록 조회 + case getActiveDateDetail(date: String) + /// 특정 식단 조회 + case getDietRecordDetail(dietId: Int) } extension TraineeTargetType: TargetType { @@ -30,11 +36,20 @@ extension TraineeTargetType: TargetType { return "/connect-trainer" case .postTraineeDietRecord: return "/diets" + case .getActiveDateList: + return "/lessons/calendar" + case .getActiveDateDetail(date: let date): + return "/calendar/\(date)" + case .getDietRecordDetail(dietId: let dietId): + return "/diets/\(dietId)" } } var method: HTTPMethod { switch self { + case .getActiveDateList, .getActiveDateDetail, .getDietRecordDetail: + return .get + case .postConnectTrainer, .postTraineeDietRecord: return .post } @@ -42,8 +57,18 @@ extension TraineeTargetType: TargetType { var task: RequestTask { switch self { + case .getActiveDateDetail, .getDietRecordDetail: + return .requestPlain + + case let .getActiveDateList(startDate, endDate): + return .requestParameters(parameters: [ + "startDate": startDate, + "endDate": endDate + ], encoding: .url) + 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)] @@ -59,7 +84,7 @@ extension TraineeTargetType: TargetType { var headers: [String: String]? { switch self { - case .postConnectTrainer: + case .postConnectTrainer, .getActiveDateDetail, .getActiveDateList, .getDietRecordDetail: return ["Content-Type": "application/json"] case .postTraineeDietRecord: return ["Content-Type": "multipart/form-data"] diff --git a/TnT/Projects/Domain/Sources/DTO/Trainee/TraineeResponseDTO.swift b/TnT/Projects/Domain/Sources/DTO/Trainee/TraineeResponseDTO.swift index 7870355d..3f246971 100644 --- a/TnT/Projects/Domain/Sources/DTO/Trainee/TraineeResponseDTO.swift +++ b/TnT/Projects/Domain/Sources/DTO/Trainee/TraineeResponseDTO.swift @@ -22,3 +22,59 @@ public struct PostConnectTrainerResDTO: Decodable { /// 트레이니 식단 기록 응답 DTO public typealias PostTraineeDietRecordResDTO = EmptyResponse + +/// 트레이니 캘린더 수업/기록 존재 날짜 조회 응답 DTO +public struct GetActiveDateListResDTO: Decodable { + public let ptLessonDates: [String] +} + +/// 특정 날짜 수업/기록 조회 응답 DTO +public struct GetActiveDateDetailResDTO: Decodable { + public let date: String + public let ptInfo: PTInfoResDTO? + public let diets: [DietResDTO] +} + +/// PT 정보에 사용되는 PTInfoResDTO +public struct PTInfoResDTO: Decodable { + /// 트레이너 이름 + public let trainerName: String + /// 세션 회차 + public let session: Int + /// 수업 시작 시간 + public let lessonStart: String + /// 수업 종료 시간 + public let lessonEnd: String +} + +/// 식단 정보에 사용되는 DietResDTO +public struct DietResDTO: Decodable { + /// 식단 ID + public let dietId: Int + /// 식단 시간 + public let date: String + /// 식단 이미지 URL + public let dietImageUrl: String? + /// 식단 타입 + public let dietType: DietTypeResDTO + /// 식단 메모 + public let memo: String +} + +/// Breakfast, lunch, dinner, snack으로 구분되는 DietTypeResDTO +public enum DietTypeResDTO: String, Decodable { + case breakfast = "BREAKFAST" + case lunch = "LUNCH" + case dinner = "DINNER" + case snack = "SNACK" + case unknown = "" + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = DietTypeResDTO(rawValue: rawValue) ?? .unknown + } +} + +/// 특정 식단 조회 응답 DTO +public typealias GetDietRecordDetailResDTO = DietResDTO diff --git a/TnT/Projects/Domain/Sources/Repository/TraineeRepository.swift b/TnT/Projects/Domain/Sources/Repository/TraineeRepository.swift index 32adff7f..d41c6064 100644 --- a/TnT/Projects/Domain/Sources/Repository/TraineeRepository.swift +++ b/TnT/Projects/Domain/Sources/Repository/TraineeRepository.swift @@ -24,4 +24,26 @@ public protocol TraineeRepository { /// - Returns: 등록 성공 시, 응답 DTO (empty) (`PostTraineeDietRecordResDTO`) /// - Throws: 네트워크 오류 또는 서버에서 반환한 오류를 발생시킬 수 있음 func postTraineeDietRecord(_ reqDTO: PostTraineeDietRecordReqDTO, imgData: Data?) async throws -> PostTraineeDietRecordResDTO + + /// 캘린더 수업, 기록 존재하는 날짜 조회 요청 + /// - Parameters: + /// - startDate: 조회 시작 날짜 + /// - endDate: 조회 종료 날짜 + /// - Returns: 등록 성공 시, 응답 DTO (`GetActiveDateListResDTO`) + /// - Throws: 네트워크 오류 또는 서버에서 반환한 오류를 발생시킬 수 있음 + func getActiveDateList(startDate: String, endDate: String) async throws -> GetActiveDateListResDTO + + /// 특정 날짜 수업, 기록 조회 + /// - Parameters: + /// - date: 조회 특정 날짜 + /// - Returns: 등록 성공 시, 응답 DTO (`GetActiveDateDetailResDTO`) + /// - Throws: 네트워크 오류 또는 서버에서 반환한 오류를 발생시킬 수 있음 + func getActiveDateDetail(date: String) async throws -> GetActiveDateDetailResDTO + + /// 특정 식단 조회 요청 + /// - Parameters: + /// - dietId: 조회 특정 식단 ID + /// - Returns: 등록 성공 시, 응답 DTO (`GetDietRecordDetailResDTO`) + /// - Throws: 네트워크 오류 또는 서버에서 반환한 오류를 발생시킬 수 있음 + func getDietRecordDetail(dietId: Int) async throws -> GetDietRecordDetailResDTO } diff --git a/TnT/Projects/Domain/Sources/UseCase/TraineeUseCase.swift b/TnT/Projects/Domain/Sources/UseCase/TraineeUseCase.swift index 1b68d0c8..ba5ec99d 100644 --- a/TnT/Projects/Domain/Sources/UseCase/TraineeUseCase.swift +++ b/TnT/Projects/Domain/Sources/UseCase/TraineeUseCase.swift @@ -72,4 +72,16 @@ extension DefaultTraineeUseCase: TraineeRepository { public func postTraineeDietRecord(_ reqDTO: PostTraineeDietRecordReqDTO, imgData: Data?) async throws -> PostTraineeDietRecordResDTO { return try await traineeRepository.postTraineeDietRecord(reqDTO, imgData: imgData) } + + public func getActiveDateList(startDate: String, endDate: String) async throws -> GetActiveDateListResDTO { + return try await traineeRepository.getActiveDateList(startDate: startDate, endDate: endDate) + } + + public func getActiveDateDetail(date: String) async throws -> GetActiveDateDetailResDTO { + return try await traineeRepository.getActiveDateDetail(date: date) + } + + public func getDietRecordDetail(dietId: Int) async throws -> GetDietRecordDetailResDTO { + return try await traineeRepository.getDietRecordDetail(dietId: dietId) + } } From 17ab47ccda524ffdcae5ab34fab7e36722c12c40 Mon Sep 17 00:00:00 2001 From: Minseo Park <125115284+FpRaArNkK@users.noreply.github.com> Date: Fri, 14 Feb 2025 03:04:19 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[Feat]=20TCalendarView=20Binding=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20distinguish=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Calendar/TCalendarRepresentable.swift | 1 - .../Sources/Components/Calendar/TCalendarView.swift | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/TnT/Projects/DesignSystem/Sources/Components/Calendar/TCalendarRepresentable.swift b/TnT/Projects/DesignSystem/Sources/Components/Calendar/TCalendarRepresentable.swift index f67d5cb3..e8872dda 100644 --- a/TnT/Projects/DesignSystem/Sources/Components/Calendar/TCalendarRepresentable.swift +++ b/TnT/Projects/DesignSystem/Sources/Components/Calendar/TCalendarRepresentable.swift @@ -64,7 +64,6 @@ public struct TCalendarRepresentable: UIViewRepresentable { calendar.appearance.titleDefaultColor = .clear calendar.calendarWeekdayView.weekdayLabels[0].textColor = UIColor(.red500) - return calendar } diff --git a/TnT/Projects/DesignSystem/Sources/Components/Calendar/TCalendarView.swift b/TnT/Projects/DesignSystem/Sources/Components/Calendar/TCalendarView.swift index 435992be..266bea3d 100644 --- a/TnT/Projects/DesignSystem/Sources/Components/Calendar/TCalendarView.swift +++ b/TnT/Projects/DesignSystem/Sources/Components/Calendar/TCalendarView.swift @@ -43,7 +43,11 @@ public struct TCalendarView: View { GeometryReader { proxy in TCalendarRepresentable( selectedDate: $selectedDate, - currentPage: $currentPage, + currentPage: Binding(get: { + currentPage + }, set: { + if $0 != currentPage { currentPage = $0 } + }), calendarHeight: $calendarHeight, mode: mode, events: events From 924162c9593c35d35c9ac0c98f6188cbe4a9fb73 Mon Sep 17 00:00:00 2001 From: Minseo Park <125115284+FpRaArNkK@users.noreply.github.com> Date: Fri, 14 Feb 2025 03:07:06 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[Feat]=20TrainerHome=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EB=8B=AC=EB=A0=A5=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Trainee/TraineeHomeFeature.swift | 84 ++++++++++++++++++- .../Home/Trainee/TraineeHomeView.swift | 2 +- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift index 19fb1e39..ff9c18e1 100644 --- a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift +++ b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift @@ -31,6 +31,8 @@ public struct TraineeHomeFeature { var records: [RecordListItemEntity] /// 3일 동안 보지 않기 선택되었는지 여부 var isHideUntilSelected: Bool + /// API 로드된 년/달 집합 + var loadedMonths: Set = [] // MARK: UI related state /// 캘린더 표시 페이지 @@ -71,10 +73,17 @@ public struct TraineeHomeFeature { } @Dependency(\.traineeUseCase) private var traineeUseCase: TraineeUseCase + @Dependency(\.traineeRepoUseCase) private var traineeRepoUseCase public enum Action: Equatable, Sendable, ViewAction { /// 뷰에서 발생한 액션을 처리합니다. case view(View) + /// api 콜 액션 처리 + case api(APIAction) + /// 새로운 이벤트 추가 + case updateEvents([Date: Int]) + /// 팝업 표시 처리 + case showPopUp /// 네비게이션 여부 설정 case setNavigating(RoutingScreen) @@ -103,6 +112,14 @@ public struct TraineeHomeFeature { /// 화면이 표시될 때 case onAppear } + + @CasePathable + public enum APIAction: Equatable, Sendable { + /// 캘린더 수업 기록 존재하는 날짜 조회 + case getActiveDateList(startDate: Date, endDate: Date) + /// 캘린더 특정 날짜 수업/기록 조회 + case getActiveDateDetail + } } public init() {} @@ -118,6 +135,9 @@ public struct TraineeHomeFeature { case .binding(\.selectedDate): return .none + case .binding(\.view_currentPage): + return self.currentPageUpdated(state: &state) + case .binding: return .none @@ -170,12 +190,44 @@ public struct TraineeHomeFeature { return .send(.setNavigating(.traineeInvitationCodeInput)) case .onAppear: - let hideUntil = state.hidePopupUntil ?? Date() - let hidePopUp = state.isConnected || hideUntil > Date() - state.view_isPopUpPresented = !hidePopUp + return .concatenate( + .send(.showPopUp), + currentPageUpdated(state: &state) + ) + } + + case .api(let action): + switch action { + case let .getActiveDateList(startDate, endDate): + let startDate = startDate.toString(format: .yyyyMMdd) + let endDate = endDate.toString(format: .yyyyMMdd) + + return .run { send in + let result = try await traineeRepoUseCase.getActiveDateList(startDate: startDate, endDate: endDate) + + let newEvents: [Date: Int] = result.ptLessonDates.reduce(into: [:]) { events, dateString in + if let date = dateString.toDate(format: .yyyyMMdd) { + events[date] = 1 + } + } + + await send(.updateEvents(newEvents)) + } + + case .getActiveDateDetail: return .none } + case .updateEvents(let newEvents): + state.events.merge(newEvents) { _, new in new } + return .none + + case .showPopUp: + let hideUntil = state.hidePopupUntil ?? Date() + let hidePopUp = state.isConnected || hideUntil > Date() + state.view_isPopUpPresented = !hidePopUp + return .none + case .setNavigating: return .none } @@ -183,6 +235,32 @@ public struct TraineeHomeFeature { } } +extension TraineeHomeFeature { + /// view\_currentPage가 업데이트 되었을 때 호출됩니다 + /// 달이 변경되는 경우 새로운 달력 데이터를 불러오기 위한 API를 호출합니다 + func currentPageUpdated(state: inout State) -> Effect { + let newPage = state.view_currentPage + let newMonth = newPage.toString(format: .yyyyMM) + + // 이전에 불러온 년/달과 같은 경우 API 호출 생략 + guard !state.loadedMonths.contains(newMonth) else { return .none } + state.loadedMonths.insert(newMonth) + + + // API 호출할 범위 설정 + let calendar = Calendar.current + let components = calendar.dateComponents([.year, .month], from: newPage) + guard let firstDayOfMonth = calendar.date(from: components), + let startDate = calendar.date(byAdding: DateComponents(month: -1, day: 20), to: firstDayOfMonth), + let endDate = calendar.date(byAdding: DateComponents(month: 1, day: 7), to: firstDayOfMonth) + else { + return .none + } + + return .send(.api(.getActiveDateList(startDate: startDate, endDate: endDate))) + } +} + extension TraineeHomeFeature { public enum RoutingScreen: Sendable { /// 알림 페이지 diff --git a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeView.swift b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeView.swift index 469a7360..8c26773f 100644 --- a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeView.swift +++ b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeView.swift @@ -60,7 +60,7 @@ public struct TraineeHomeView: View { .navigationBarBackButtonHidden() .sheet(isPresented: $store.view_isBottomSheetPresented) { TraineeRecordStartView(itemContents: [ - ("🏋🏻‍♀️", "개인 운동", { send(.tapAddWorkoutRecordButton) }), +// ("🏋🏻‍♀️", "개인 운동", { send(.tapAddWorkoutRecordButton) }), ("🥗", "식단", { send(.tapAddDietRecordButton) }) ]) .padding(.top, 10) From 019fdbb4757e694ab1fc3acdbf0f1bd3cf19f7c5 Mon Sep 17 00:00:00 2001 From: Minseo Park <125115284+FpRaArNkK@users.noreply.github.com> Date: Fri, 14 Feb 2025 04:35:32 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[Feat]=20DTO,=20Entity=20=EC=98=B5=EC=85=94?= =?UTF-8?q?=EB=84=90=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Components/Card/TRecordCard.swift | 8 ++- .../DTO/Trainee/TraineeResponseDTO.swift | 32 ++++++++-- .../Sources/Entity/RecordListItemEntity.swift | 10 ++-- .../Entity/WorkoutListItemEntity.swift | 10 ++-- .../Domain/Sources/Mapper/TraineeMapper.swift | 59 +++++++++++++++++++ 5 files changed, 102 insertions(+), 17 deletions(-) create mode 100644 TnT/Projects/Domain/Sources/Mapper/TraineeMapper.swift diff --git a/TnT/Projects/DesignSystem/Sources/Components/Card/TRecordCard.swift b/TnT/Projects/DesignSystem/Sources/Components/Card/TRecordCard.swift index 597f7fc9..4b95740b 100644 --- a/TnT/Projects/DesignSystem/Sources/Components/Card/TRecordCard.swift +++ b/TnT/Projects/DesignSystem/Sources/Components/Card/TRecordCard.swift @@ -10,7 +10,7 @@ import SwiftUI /// 트레이니 - 운동/식단 카드 public struct TRecordCard: View { - private let chipUIInfo: TChip.UIInfo + private let chipUIInfo: TChip.UIInfo? private let timeText: String private let title: String private let imgURL: URL? @@ -18,7 +18,7 @@ public struct TRecordCard: View { private let footerTapAction: (() -> Void)? public init( - chipUIInfo: TChip.UIInfo, + chipUIInfo: TChip.UIInfo?, timeText: String, title: String, imgURL: URL?, @@ -94,7 +94,9 @@ public struct TRecordCard: View { @ViewBuilder public func Header() -> some View { HStack { - TChip(uiInfo: chipUIInfo) + if let chipUIInfo { + TChip(uiInfo: chipUIInfo) + } Spacer() TimeIndicator(timeText: timeText) } diff --git a/TnT/Projects/Domain/Sources/DTO/Trainee/TraineeResponseDTO.swift b/TnT/Projects/Domain/Sources/DTO/Trainee/TraineeResponseDTO.swift index 3f246971..25be1147 100644 --- a/TnT/Projects/Domain/Sources/DTO/Trainee/TraineeResponseDTO.swift +++ b/TnT/Projects/Domain/Sources/DTO/Trainee/TraineeResponseDTO.swift @@ -33,18 +33,42 @@ public struct GetActiveDateDetailResDTO: Decodable { public let date: String public let ptInfo: PTInfoResDTO? public let diets: [DietResDTO] + + enum CodingKeys: String, CodingKey { + case date, ptInfo, diets + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + date = try container.decode(String.self, forKey: .date) + diets = try container.decode([DietResDTO].self, forKey: .diets) + + let ptInfoDecoded = try container.decodeIfPresent(PTInfoResDTO.self, forKey: .ptInfo) + ptInfo = ptInfoDecoded?.isEmpty == true ? nil : ptInfoDecoded + } } /// PT 정보에 사용되는 PTInfoResDTO public struct PTInfoResDTO: Decodable { /// 트레이너 이름 - public let trainerName: String + public let trainerName: String? + /// 트레이니 이미지 URL + public let trainerProfileImage: String? /// 세션 회차 - public let session: Int + public let session: Int? /// 수업 시작 시간 - public let lessonStart: String + public let lessonStart: String? /// 수업 종료 시간 - public let lessonEnd: String + public let lessonEnd: String? + + /// 모든 프로퍼티가 nil인지 확인하는 computed property + public var isEmpty: Bool { + return trainerName == nil && + trainerProfileImage == nil && + session == nil && + lessonStart == nil && + lessonEnd == nil + } } /// 식단 정보에 사용되는 DietResDTO diff --git a/TnT/Projects/Domain/Sources/Entity/RecordListItemEntity.swift b/TnT/Projects/Domain/Sources/Entity/RecordListItemEntity.swift index e3a76870..52f96d70 100644 --- a/TnT/Projects/Domain/Sources/Entity/RecordListItemEntity.swift +++ b/TnT/Projects/Domain/Sources/Entity/RecordListItemEntity.swift @@ -9,13 +9,13 @@ import Foundation /// 트레이니 기록 목록 아이템 모델 -public struct RecordListItemEntity: Equatable { +public struct RecordListItemEntity: Equatable, Sendable { /// 기록 id public let id: Int /// 기록 타입 - public let type: RecordType + public let type: RecordType? /// 기록 시간 - public let date: Date + public let date: Date? /// 기록 제목 public let title: String /// 피드백 여부 @@ -25,8 +25,8 @@ public struct RecordListItemEntity: Equatable { public init( id: Int, - type: RecordType, - date: Date, + type: RecordType?, + date: Date?, title: String, hasFeedBack: Bool, imageUrl: String? diff --git a/TnT/Projects/Domain/Sources/Entity/WorkoutListItemEntity.swift b/TnT/Projects/Domain/Sources/Entity/WorkoutListItemEntity.swift index d9d93d76..523ae779 100644 --- a/TnT/Projects/Domain/Sources/Entity/WorkoutListItemEntity.swift +++ b/TnT/Projects/Domain/Sources/Entity/WorkoutListItemEntity.swift @@ -9,15 +9,15 @@ import Foundation /// 트레이니 PT 운동 목록 아이템 모델 -public struct WorkoutListItemEntity: Equatable { +public struct WorkoutListItemEntity: Equatable, Sendable { /// 수업 Id public let id: Int /// 현재 수업 차수 public let currentCount: Int /// 수업 시작 시간 - public let startDate: Date + public let startDate: Date? /// 수업 종료 시간 - public let endDate: Date + public let endDate: Date? /// 트레이너 프로필 사진 URL public let trainerProfileImageUrl: String? /// 트레이너 이름 @@ -28,8 +28,8 @@ public struct WorkoutListItemEntity: Equatable { public init( id: Int, currentCount: Int, - startDate: Date, - endDate: Date, + startDate: Date?, + endDate: Date?, trainerProfileImageUrl: String?, trainerName: String, hasRecord: Bool diff --git a/TnT/Projects/Domain/Sources/Mapper/TraineeMapper.swift b/TnT/Projects/Domain/Sources/Mapper/TraineeMapper.swift new file mode 100644 index 00000000..3e61fbae --- /dev/null +++ b/TnT/Projects/Domain/Sources/Mapper/TraineeMapper.swift @@ -0,0 +1,59 @@ +// +// TraineeMapper.swift +// Domain +// +// Created by 박민서 on 2/14/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation + +public extension PTInfoResDTO { + func toEntity() -> WorkoutListItemEntity? { + guard !self.isEmpty else { return nil } + return .init( + id: Int.random(in: 1...10000), + currentCount: self.session ?? 0, + startDate: self.lessonStart?.toDate(format: .ISO8601), + endDate: self.lessonEnd?.toDate(format: .ISO8601), + trainerProfileImageUrl: self.trainerProfileImage, + trainerName: self.trainerName ?? "", + hasRecord: false + ) + } +} + +public extension DietResDTO { + func toEntity() -> RecordListItemEntity { + return .init( + id: self.dietId, + type: self.dietType.toEntity(), + date: self.date.toDate(format: .ISO8601), + title: self.memo, + hasFeedBack: false, + imageUrl: self.dietImageUrl + ) + } +} + +public extension DietTypeResDTO { + func toEntity() -> RecordType? { + let dietType: DietType? = { + switch self { + case .breakfast: + return .breakfast + case .lunch: + return .lunch + case .dinner: + return .dinner + case .snack: + return .snack + case .unknown: + return nil + } + }() + + guard let dietType else { return nil } + return .diet(type: dietType) + } +} From 75ea918bc83e9d052684336d19af096db3c11306 Mon Sep 17 00:00:00 2001 From: Minseo Park <125115284+FpRaArNkK@users.noreply.github.com> Date: Fri, 14 Feb 2025 04:35:56 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[Feat]=20TraineeHome=20=ED=8A=B9=EC=A0=95?= =?UTF-8?q?=20=EB=82=A0=EC=A7=9C=20=EA=B8=B0=EB=A1=9D=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Trainee/TraineeHomeFeature.swift | 30 +++++++++++++++---- .../Home/Trainee/TraineeHomeView.swift | 4 +-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift index ff9c18e1..be987936 100644 --- a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift +++ b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift @@ -39,8 +39,12 @@ public struct TraineeHomeFeature { var view_currentPage: Date /// 수업 카드 시간 표시 var view_sessionCardTimeString: String { - guard let sessionInfo else { return "" } - return "\(TDateFormatUtility.formatter(for: .a_HHmm).string(from: sessionInfo.startDate)) ~ \(TDateFormatUtility.formatter(for: .a_HHmm).string(from: sessionInfo.endDate))" + guard let sessionInfo, + let startDate = sessionInfo.startDate?.toString(format: .a_HHmm), + let endDate = sessionInfo.endDate?.toString(format: .a_HHmm) + else { return "" } + + return "\(startDate) ~ \(endDate)" } /// 기록 제목 표시 var view_recordTitleString: String { @@ -82,6 +86,8 @@ public struct TraineeHomeFeature { case api(APIAction) /// 새로운 이벤트 추가 case updateEvents([Date: Int]) + /// 해당 날짜 수업/기록 표시 + case setContent(session: WorkoutListItemEntity?, records: [RecordListItemEntity]) /// 팝업 표시 처리 case showPopUp /// 네비게이션 여부 설정 @@ -118,7 +124,7 @@ public struct TraineeHomeFeature { /// 캘린더 수업 기록 존재하는 날짜 조회 case getActiveDateList(startDate: Date, endDate: Date) /// 캘린더 특정 날짜 수업/기록 조회 - case getActiveDateDetail + case getActiveDateDetail(date: Date) } } @@ -133,7 +139,7 @@ public struct TraineeHomeFeature { case .view(let action): switch action { case .binding(\.selectedDate): - return .none + return .send(.api(.getActiveDateDetail(date: state.selectedDate))) case .binding(\.view_currentPage): return self.currentPageUpdated(state: &state) @@ -214,14 +220,26 @@ public struct TraineeHomeFeature { await send(.updateEvents(newEvents)) } - case .getActiveDateDetail: - return .none + case .getActiveDateDetail(let date): + let date = date.toString(format: .yyyyMMdd) + return .run { send in + let result = try await traineeRepoUseCase.getActiveDateDetail(date: date) + let sessionInfo = result.ptInfo?.toEntity() + let recordsInfo = result.diets.map { $0.toEntity() } + + await send(.setContent(session: sessionInfo, records: recordsInfo)) + } } case .updateEvents(let newEvents): state.events.merge(newEvents) { _, new in new } return .none + case let .setContent(sessionInfo, records): + state.sessionInfo = sessionInfo + state.records = records + return .none + case .showPopUp: let hideUntil = state.hidePopupUntil ?? Date() let hidePopUp = state.isConnected || hideUntil > Date() diff --git a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeView.swift b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeView.swift index 8c26773f..da89cf3d 100644 --- a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeView.swift +++ b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeView.swift @@ -143,8 +143,8 @@ public struct TraineeHomeView: View { if !store.records.isEmpty { ForEach(store.records, id: \.id) { item in TRecordCard( - chipUIInfo: item.type.chipInfo, - timeText: TDateFormatUtility.formatter(for: .a_HHmm).string(from: item.date), + chipUIInfo: item.type?.chipInfo, + timeText: item.date?.toString(format: .a_HHmm) ?? "", title: item.title, imgURL: URL(string: item.imageUrl ?? ""), hasFeedback: item.hasFeedBack, From 235cb3b751de14e44c84b6459943ed86a717cfea Mon Sep 17 00:00:00 2001 From: Minseo Park <125115284+FpRaArNkK@users.noreply.github.com> Date: Fri, 14 Feb 2025 04:49:09 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[Feat]=20TraineeHome=20->=20DietRecordDetai?= =?UTF-8?q?l=20=ED=99=94=EB=A9=B4=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TraineeMainFlow/TraineeMainFlowFeature.swift | 5 +++++ .../TraineeMainFlow/TraineeMainFlowView.swift | 2 ++ .../Sources/Home/Trainee/TraineeHomeFeature.swift | 14 +++++++++++++- .../Sources/Home/Trainee/TraineeHomeView.swift | 3 +++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowFeature.swift b/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowFeature.swift index 3cf575d7..4accf090 100644 --- a/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowFeature.swift +++ b/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowFeature.swift @@ -61,6 +61,9 @@ public struct TraineeMainFlowFeature { case .traineeInvitationCodeInput: state.path.append(.traineeInvitationCodeInput(.init(view_navigationType: .existingUser))) return .none + case .dietDetailPage(let id): + state.path.append(.dietRecordDetail(.init(dietId: id))) + return .none } /// 트레이니 마이페이지 case .traineeMyPage(let screen): @@ -147,6 +150,8 @@ extension TraineeMainFlowFeature { case alarmCheck(AlarmCheckFeature) /// 식단 기록 추가 case addDietRecordPage(TraineeAddDietRecordFeature) + /// 식단 상세 화면 + case dietRecordDetail(TraineeDietRecordDetailFeature) // MARK: MyPage /// 트레이니 초대 코드입력 diff --git a/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowView.swift b/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowView.swift index bb0771c8..b9e25420 100644 --- a/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowView.swift +++ b/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowView.swift @@ -32,6 +32,8 @@ public struct TraineeMainFlowView: View { AlarmCheckView(store: store) case .addDietRecordPage(let store): TraineeAddDietRecordView(store: store) + case .dietRecordDetail(let store): + TraineeDietRecordDetailView(store: store) // MARK: MyPage case .traineeInvitationCodeInput(let store): diff --git a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift index be987936..08e72a7b 100644 --- a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift +++ b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift @@ -103,6 +103,8 @@ public struct TraineeHomeFeature { case tapShowSessionRecordButton(id: Int) /// 기록 목록 피드백 보기 버튼 탭 case tapShowRecordFeedbackButton(id: Int) + /// 기록 아이템 탭 + case tapRecordItem(type: RecordType?, id: Int) /// 우측 하단 기록 추가 버튼 탭 case tapAddRecordButton /// 개인 운동 기록 추가 버튼 탭 @@ -160,6 +162,14 @@ public struct TraineeHomeFeature { print("tapShowRecordFeedbackButton \(id)") return .none + case let .tapRecordItem(recordType, id): + switch recordType { + case .diet: + return .send(.setNavigating(.dietDetailPage(id: id))) + default: + return .none + } + case .tapAddRecordButton: state.view_isBottomSheetPresented = true return .none @@ -280,13 +290,15 @@ extension TraineeHomeFeature { } extension TraineeHomeFeature { - public enum RoutingScreen: Sendable { + public enum RoutingScreen: Equatable, Sendable { /// 알림 페이지 case alarmPage /// 수업 기록 상세 페이지 case sessionRecordPage /// 기록 피드백 페이지 case recordFeedbackPage + /// 식단 상세 페이지 + case dietDetailPage(id: Int) /// 운동 기록 추가 페이지 case addWorkoutRecordPage /// 식단 기록 추가 페이지 diff --git a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeView.swift b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeView.swift index da89cf3d..47366475 100644 --- a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeView.swift +++ b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeView.swift @@ -152,6 +152,9 @@ public struct TraineeHomeView: View { send(.tapShowRecordFeedbackButton(id: item.id)) } ) + .onTapGesture { + send(.tapRecordItem(type: item.type, id: item.id)) + } } } else { RecordEmptyView() From dc796ea9194b6f6dafbca319bc4f4f4a0fbc4b6d Mon Sep 17 00:00:00 2001 From: Minseo Park <125115284+FpRaArNkK@users.noreply.github.com> Date: Fri, 14 Feb 2025 05:26:47 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[Feat]=20TraineeDietRecordDetail=20API=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Sources/Entity/DietType.swift | 5 ++++ .../TraineeDietRecordDetailFeature.swift | 23 +++++++++++++++++-- .../TraineeDietRecordDetailView.swift | 5 +++- .../Home/Trainee/TraineeHomeFeature.swift | 3 ++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/TnT/Projects/Domain/Sources/Entity/DietType.swift b/TnT/Projects/Domain/Sources/Entity/DietType.swift index b494d40f..acd0aea8 100644 --- a/TnT/Projects/Domain/Sources/Entity/DietType.swift +++ b/TnT/Projects/Domain/Sources/Entity/DietType.swift @@ -24,4 +24,9 @@ public enum DietType: String, Sendable, CaseIterable { case .snack: return "간식" } } + + public init?(from recordType: RecordType) { + guard case .diet(let type) = recordType else { return nil } + self = type + } } diff --git a/TnT/Projects/Presentation/Sources/DietRecordDetail/TraineeDietRecordDetailFeature.swift b/TnT/Projects/Presentation/Sources/DietRecordDetail/TraineeDietRecordDetailFeature.swift index 90b44852..59c21c88 100644 --- a/TnT/Projects/Presentation/Sources/DietRecordDetail/TraineeDietRecordDetailFeature.swift +++ b/TnT/Projects/Presentation/Sources/DietRecordDetail/TraineeDietRecordDetailFeature.swift @@ -43,11 +43,15 @@ public struct TraineeDietRecordDetailFeature { } } + @Dependency(\.traineeRepoUseCase) private var traineeRepoUseCase + public enum Action: Sendable, ViewAction { /// 뷰에서 발생한 액션을 처리합니다. case view(View) /// api 콜 액션을 처리합니다 case api(APIAction) + /// 식단 정보 반영 + case setDietRecordDetail(RecordListItemEntity) /// 네비게이션 여부 설정 case setNavigating @@ -57,6 +61,8 @@ public struct TraineeDietRecordDetailFeature { case binding(BindingAction) /// 우측 상단 ellipsis 버튼 탭 case tapEllipsisButton + /// 화면에 표시될 때 + case onAppear } @CasePathable @@ -81,15 +87,28 @@ public struct TraineeDietRecordDetailFeature { case .tapEllipsisButton: return .none + + case .onAppear: + return .send(.api(.getDietRecordDetail)) } case .api(let action): switch action { case .getDietRecordDetail: - // TODO: API 나오면 추후 연결 - return .none + let id = state.dietId + return .run { send in + let result = try await traineeRepoUseCase.getDietRecordDetail(dietId: id).toEntity() + await send(.setDietRecordDetail(result)) + } } + case .setDietRecordDetail(let info): + state.dietImageURL = URL(string: info.imageUrl ?? "") + state.dietType = .init(from: info.type ?? RecordType.diet(type: .breakfast)) + state.dietDate = info.date + state.dietInfo = info.title + return .none + case .setNavigating: return .none } diff --git a/TnT/Projects/Presentation/Sources/DietRecordDetail/TraineeDietRecordDetailView.swift b/TnT/Projects/Presentation/Sources/DietRecordDetail/TraineeDietRecordDetailView.swift index 8a2d1655..bd6c9b58 100644 --- a/TnT/Projects/Presentation/Sources/DietRecordDetail/TraineeDietRecordDetailView.swift +++ b/TnT/Projects/Presentation/Sources/DietRecordDetail/TraineeDietRecordDetailView.swift @@ -52,6 +52,9 @@ public struct TraineeDietRecordDetailView: View { } .navigationBarBackButtonHidden() .keyboardDismissOnTap() + .onAppear { + send(.onAppear) + } } // MARK: - Sections @@ -90,7 +93,7 @@ public struct TraineeDietRecordDetailView: View { @ViewBuilder private func ContentSection() -> some View { - VStack(spacing: 8) { + VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 0) { if let chipInfo = store.dietType?.chipInfo { TChip(uiInfo: chipInfo) diff --git a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift index 08e72a7b..18ef3378 100644 --- a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift +++ b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift @@ -208,7 +208,8 @@ public struct TraineeHomeFeature { case .onAppear: return .concatenate( .send(.showPopUp), - currentPageUpdated(state: &state) + currentPageUpdated(state: &state), + .send(.api(.getActiveDateDetail(date: state.selectedDate))) ) }