diff --git a/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift b/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift index 493f409a..e6729693 100644 --- a/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift +++ b/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift @@ -50,13 +50,6 @@ public struct TrainerRepositoryImpl: TrainerRepository { return try await networkService.request(TrainerTargetType.getMonthlyLessonList(year: year, month: month), decodingType: GetMonthlyLessonListResDTO.self) } - public func getMembersList() async throws -> GetActiveTraineesListResDTO { - return try await networkService.request( - TrainerTargetType.getMemebersList, - decodingType: GetActiveTraineesListResDTO.self - ) - } - public func getConnectedTraineeInfo(trainerId: Int, traineeId: Int) async throws -> GetConnectedTraineeInfoResponseDTO { return try await networkService.request(TrainerTargetType.getConnectedTraineeInfo(trainerId: trainerId, traineeId: traineeId), decodingType: GetConnectedTraineeInfoResponseDTO.self) } diff --git a/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_check_mark_green.imageset/Contents.json b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_check_mark_green.imageset/Contents.json new file mode 100644 index 00000000..6c472576 --- /dev/null +++ b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_check_mark_green.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icn_check_mark_green.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_check_mark_green.imageset/icn_check_mark_green.svg b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_check_mark_green.imageset/icn_check_mark_green.svg new file mode 100644 index 00000000..69a51a56 --- /dev/null +++ b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_check_mark_green.imageset/icn_check_mark_green.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_gray.imageset/Contents.json b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_gray.imageset/Contents.json new file mode 100644 index 00000000..853622df --- /dev/null +++ b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_gray.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icn_plus_gray.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icn_plus_gray@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icn_plus_gray@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_gray.imageset/icn_plus_gray.png b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_gray.imageset/icn_plus_gray.png new file mode 100644 index 00000000..8f07edcf Binary files /dev/null and b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_gray.imageset/icn_plus_gray.png differ diff --git a/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_gray.imageset/icn_plus_gray@2x.png b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_gray.imageset/icn_plus_gray@2x.png new file mode 100644 index 00000000..9b1633f5 Binary files /dev/null and b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_gray.imageset/icn_plus_gray@2x.png differ diff --git a/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_gray.imageset/icn_plus_gray@3x.png b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_gray.imageset/icn_plus_gray@3x.png new file mode 100644 index 00000000..59e4e830 Binary files /dev/null and b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_gray.imageset/icn_plus_gray@3x.png differ diff --git a/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_white.imageset/Contents.json b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_white.imageset/Contents.json new file mode 100644 index 00000000..b65b482b --- /dev/null +++ b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_white.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icn_plus_white.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_white.imageset/icn_plus_white.svg b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_white.imageset/icn_plus_white.svg new file mode 100644 index 00000000..5019f950 --- /dev/null +++ b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_white.imageset/icn_plus_white.svg @@ -0,0 +1,4 @@ + + + + diff --git a/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_write_gray.imageset/Contents.json b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_write_gray.imageset/Contents.json new file mode 100644 index 00000000..e5c5eaee --- /dev/null +++ b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_write_gray.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icn_write_gray.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_write_gray.imageset/icn_write_gray.svg b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_write_gray.imageset/icn_write_gray.svg new file mode 100644 index 00000000..cb2f7be1 --- /dev/null +++ b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_write_gray.imageset/icn_write_gray.svg @@ -0,0 +1,5 @@ + + + + + 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/DesignSystem/Image+DesignSystem.swift b/TnT/Projects/DesignSystem/Sources/DesignSystem/Image+DesignSystem.swift index 239b95b1..6e7f95d6 100644 --- a/TnT/Projects/DesignSystem/Sources/DesignSystem/Image+DesignSystem.swift +++ b/TnT/Projects/DesignSystem/Sources/DesignSystem/Image+DesignSystem.swift @@ -29,9 +29,11 @@ public extension ImageResource { static let icnMypageFilled: ImageResource = DesignSystemAsset.icnMypageFilled.imageResource static let icnWriteWhite: ImageResource = DesignSystemAsset.icnWriteWhite.imageResource static let icnWriteBlack: ImageResource = DesignSystemAsset.icnWriteBlack.imageResource + static let icnWriteGray: ImageResource = DesignSystemAsset.icnWriteGray.imageResource static let icnRadioButtonUnselected: ImageResource = DesignSystemAsset.icnRadioButtonUnselected.imageResource static let icnRadioButtonSelected: ImageResource = DesignSystemAsset.icnRadioButtonSelected.imageResource static let icnCheckMarkEmpty: ImageResource = DesignSystemAsset.icnCheckMarkEmpty.imageResource + static let icnCheckMarkGreen: ImageResource = DesignSystemAsset.icnCheckMarkGreen.imageResource static let icnCheckMarkFilled: ImageResource = DesignSystemAsset.icnCheckMarkFilled.imageResource static let icnCheckButtonUnselected: ImageResource = DesignSystemAsset.icnCheckButtonUnselected.imageResource static let icnCheckButtonSelected: ImageResource = DesignSystemAsset.icnCheckButtonSelected.imageResource @@ -50,6 +52,7 @@ public extension ImageResource { static let icnWriteBlackFilled: ImageResource = DesignSystemAsset.icnWriteBlackFilled.imageResource static let icnPlus: ImageResource = DesignSystemAsset.icnPlus.imageResource static let icnPlusEmpty: ImageResource = DesignSystemAsset.icnPlusEmpty.imageResource + static let icnPlusGray: ImageResource = DesignSystemAsset.icnPlusGray.imageResource static let icnAlarm: ImageResource = DesignSystemAsset.icnAlarm.imageResource static let icnCalendar: ImageResource = DesignSystemAsset.icnCalendar.imageResource static let icnDelete24px: ImageResource = DesignSystemAsset.icnDelete24.imageResource diff --git a/TnT/Projects/Domain/Sources/DTO/Trainer/TrainerHomeResponseDTO.swift b/TnT/Projects/Domain/Sources/DTO/Trainer/TrainerHomeResponseDTO.swift index dcf33eb1..62962f2c 100644 --- a/TnT/Projects/Domain/Sources/DTO/Trainer/TrainerHomeResponseDTO.swift +++ b/TnT/Projects/Domain/Sources/DTO/Trainer/TrainerHomeResponseDTO.swift @@ -29,6 +29,7 @@ public struct SessonDTO: Decodable { public let ptLessonId: String public let traineeId: String public let traineeName: String + public let traineeProfileImageUrl: String public let session: Int public let startTime: String public let endTime: String @@ -38,6 +39,7 @@ public struct SessonDTO: Decodable { ptLessonId: String, traineeId: String, traineeName: String, + traineeProfileImageUrl: String, session: Int, startTime: String, endTime: String, @@ -46,6 +48,7 @@ public struct SessonDTO: Decodable { self.ptLessonId = ptLessonId self.traineeId = traineeId self.traineeName = traineeName + self.traineeProfileImageUrl = traineeProfileImageUrl self.session = session self.startTime = startTime self.endTime = endTime @@ -75,6 +78,7 @@ public struct SessonEntity: Equatable, Encodable { public let ptLessonId: String public let traineeId: String public let traineeName: String + public let traineeProfileImageUrl: String public let session: Int public let startTime: String public let endTime: String @@ -84,6 +88,7 @@ public struct SessonEntity: Equatable, Encodable { ptLessonId: String, traineeId: String, traineeName: String, + traineeProfileImageUrl: String, session: Int, startTime: String, endTime: String, @@ -92,6 +97,7 @@ public struct SessonEntity: Equatable, Encodable { self.ptLessonId = ptLessonId self.traineeId = traineeId self.traineeName = traineeName + self.traineeProfileImageUrl = traineeProfileImageUrl self.session = session self.startTime = startTime self.endTime = endTime @@ -122,14 +128,15 @@ public extension GetDateSessionListDTO { // MARK: - SessonDTO -> SessonEntity public extension SessonDTO { - public func toEntity() -> SessonEntity { + func toEntity() -> SessonEntity { return SessonEntity( ptLessonId: self.ptLessonId, traineeId: self.traineeId, traineeName: self.traineeName, + traineeProfileImageUrl: self.traineeProfileImageUrl, session: self.session, - startTime: self.startTime, - endTime: self.endTime, + startTime: self.startTime.toDate(format: .ISO8601)?.toString(format: .a_HHmm) ?? "", + endTime: self.endTime.toDate(format: .ISO8601)?.toString(format: .a_HHmm) ?? "", isCompleted: self.isCompleted ) } @@ -140,6 +147,7 @@ public extension SessonDTO { ptLessonId: self.ptLessonId, traineeId: self.traineeId, traineeName: self.traineeName, + traineeProfileImageUrl: self.traineeProfileImageUrl, session: self.session, startTime: self.startTime, endTime: self.endTime, @@ -166,6 +174,7 @@ public extension SessonEntity { ptLessonId: self.ptLessonId, traineeId: self.traineeId, traineeName: self.traineeName, + traineeProfileImageUrl: self.traineeProfileImageUrl, session: self.session, startTime: self.startTime, endTime: self.endTime, diff --git a/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift b/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift index 796c98e0..7e86967d 100644 --- a/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift +++ b/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift @@ -29,9 +29,6 @@ public protocol TrainerRepository { /// 달력 스케줄 카운트 표시에 필요한 PT 리스트 불러오기 func getMonthlyLessonList(year: Int, month: Int) async throws -> GetMonthlyLessonListResDTO - /// 회원 조희 - func getMembersList() async throws -> GetActiveTraineesListResDTO - /// 연결 완료된 트레이니 정보 불러오기 func getConnectedTraineeInfo(trainerId: Int, traineeId: Int) async throws -> GetConnectedTraineeInfoResponseDTO diff --git a/TnT/Projects/Domain/Sources/UseCase/TrainerUseCase.swift b/TnT/Projects/Domain/Sources/UseCase/TrainerUseCase.swift index 997999ec..7401b58e 100644 --- a/TnT/Projects/Domain/Sources/UseCase/TrainerUseCase.swift +++ b/TnT/Projects/Domain/Sources/UseCase/TrainerUseCase.swift @@ -33,10 +33,6 @@ public struct DefaultTrainerUseCase: TrainerRepository { return try await trainerRepository.getDateSessionList(date: date) } - public func getMembersList() async throws -> GetActiveTraineesListResDTO { - return try await trainerRepository.getMembersList() - } - public func getConnectedTraineeInfo(trainerId: Int, traineeId: Int) async throws -> GetConnectedTraineeInfoResponseDTO { return try await trainerRepository.getConnectedTraineeInfo(trainerId: trainerId, traineeId: traineeId) } diff --git a/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowFeature.swift b/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowFeature.swift index 6f49f08c..12493df9 100644 --- a/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowFeature.swift +++ b/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowFeature.swift @@ -55,9 +55,12 @@ public struct TrainerMainFlowFeature { } /// 트레이너 회원목록 - case .trainerTraineeList: - state.path.append(.trainerManagment(.init())) - return .none + case .trainerTraineeList(let screen): + switch screen { + case .addTrainee: + state.path.append(.addTrainee(.init())) + return .none + } /// 트레이너 마이페이지 case .trainerMyPage(let screen): @@ -119,8 +122,10 @@ extension TrainerMainFlowFeature { case connectionComplete(ConnectionCompleteFeature) /// 연결된 트레이니 프로필 case connectedTraineeProfile(ConnectedTraineeProfileFeature) - /// 트레이너 회원 관리 페이지 - case trainerManagment(TrainerManagementFeature) + + // MARK: - 회원 목록 + /// 회원 추가 + case addTrainee(AddTraineeFeature) // MARK: MyPage /// 초대코드 발급 diff --git a/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowView.swift b/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowView.swift index 0abe1a27..c8887b40 100644 --- a/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowView.swift +++ b/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowView.swift @@ -36,12 +36,14 @@ public struct TrainerMainFlowView: View { ConnectionCompleteView(store: store) case .connectedTraineeProfile(let store): ConnectedTraineeProfileView(store: store) + + // MARK: - TraineeList + case .addTrainee(let store): + AddTraineeView(store: store) // MARK: MyPage case .trainerMakeInvitationCodePage(let store): MakeInvitationCodeView(store: store) - case .trainerManagment(let store): - TrainerManagementView(store: store) } } } diff --git a/TnT/Projects/Presentation/Sources/Home/Trainer/TrainerHomeFeature.swift b/TnT/Projects/Presentation/Sources/Home/Trainer/TrainerHomeFeature.swift index 43a7fd70..2055d32d 100644 --- a/TnT/Projects/Presentation/Sources/Home/Trainer/TrainerHomeFeature.swift +++ b/TnT/Projects/Presentation/Sources/Home/Trainer/TrainerHomeFeature.swift @@ -112,6 +112,8 @@ public struct TrainerHomeFeature { case calendarDateTap /// 탭한 일자 api 형태에 맞춰 변환하기(yyyy-mm-dd) case settingSessionList(sessions: GetDateSessionListEntity) + /// 수업 완료 후 토스트 메시지 + case completeToastMessage } } @@ -126,7 +128,7 @@ public struct TrainerHomeFeature { case .view(let action): switch action { case .binding(\.selectedDate): - print("state.events[state.selectedDate] \(state.events[state.selectedDate])") + // print("state.events[state.selectedDate] \(state.events[state.selectedDate])") return .none case .binding: @@ -136,8 +138,15 @@ public struct TrainerHomeFeature { return .send(.setNavigating(.alarmPage)) case .tapSessionCompleted(let id): - // TODO: 네비게이션 연결 시 추가 - print("tapSessionCompleted otLessionID \(id)") + guard let id = Int(id) else { return .none } + return .run { send in + let result: PutCompleteLessonResDTO = try await trainerRepoUseCase.putCompleteLesson(lessonId: id) + await send(.view(.completeToastMessage)) + await send(.view(.calendarDateTap)) + } + + case .completeToastMessage: + NotificationCenter.default.post(toast: .init(presentType: .image(.icnCheckMarkGreen), message: "PT 수업을 완료했어요")) return .none case .tapAddSessionButton: @@ -175,7 +184,12 @@ public struct TrainerHomeFeature { state.view_isPopUpPresented = true } - return .send(.view(.fetchMonthlyLessons(year: year, month: month))) + return .concatenate( + .send(.view(.fetchMonthlyLessons(year: month == 1 ? year-1 : year, month: month == 1 ? 12 : month-1))), + .send(.view(.fetchMonthlyLessons(year: year, month: month))), + .send(.view(.fetchMonthlyLessons(year: year, month: month+1))), + .send(.view(.calendarDateTap)) + ) case .fetchMonthlyLessons(year: let year, month: let month): return .run { send in @@ -206,7 +220,7 @@ public struct TrainerHomeFeature { return .none case .calendarDateTap: - let formattedDate = TDateFormatUtility.formatter(for: .yyyyMMdd).string(from: state.selectedDate) + let formattedDate: String = TDateFormatUtility.formatter(for: .yyyyMMdd).string(from: state.selectedDate) return .run { send in do { diff --git a/TnT/Projects/Presentation/Sources/Home/Trainer/TrainerHomeView.swift b/TnT/Projects/Presentation/Sources/Home/Trainer/TrainerHomeView.swift index 70232296..6ff563f1 100644 --- a/TnT/Projects/Presentation/Sources/Home/Trainer/TrainerHomeView.swift +++ b/TnT/Projects/Presentation/Sources/Home/Trainer/TrainerHomeView.swift @@ -76,9 +76,9 @@ public struct TrainerHomeView: View { events: store.events ) .onChange(of: store.state.selectedDate, { oldValue, newValue in - let startOfDay = Calendar.current.startOfDay(for: newValue) + let startOfDay: Date = Calendar.current.startOfDay(for: newValue) store.selectedDate = startOfDay - store.send(.view(.calendarDateTap)) + send(.calendarDateTap) }) .padding(.horizontal, 20) } @@ -99,7 +99,7 @@ public struct TrainerHomeView: View { HStack(spacing: 0) { Text("🧨") .typographyStyle(.label1Medium) - Text("\(store.sessionCount)") + Text("\(store.tappedsessionInfo?.lessons.count ?? 0)") .typographyStyle(.label2Bold, with: Color.red500) Text("개의 수업이 있어요") .typographyStyle(.label2Medium, with: Color.neutral800) @@ -137,10 +137,9 @@ public struct TrainerHomeView: View { .frame(width: 126, height: 58) .overlay { HStack(spacing: 4) { - Image(.icnPlus) + Image(.icnPlusGray) .resizable() .frame(width: 24, height: 24) - .tint(Color.common0) Text("수업추가") .typographyStyle(.body1Medium, with: .neutral50) } @@ -240,19 +239,29 @@ extension TrainerHomeView { Spacer() Image(.icnClock) Text("\(session.startTime) ~ \(session.endTime)") + .typographyStyle(.label2Medium, with: .neutral500) + .frame(maxWidth: .infinity) + } + + HStack(spacing: 6) { + ProfileImageView(imageURL: session.traineeProfileImageUrl) + Text(session.traineeName) + .typographyStyle(.body1Bold, with: .neutral800) + .frame(maxWidth: .infinity, alignment: .leading) } - Text(session.traineeName) if session.isCompleted { Button { onTap?() } label: { HStack(spacing: 4) { - Image(.icnWriteWhite) + Image(.icnWriteGray) Text("PT 수업 기록 남기기") .typographyStyle(.label2Medium, with: .neutral400) } .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.neutral100) .clipShape(RoundedRectangle(cornerRadius: 8)) } } @@ -262,8 +271,47 @@ extension TrainerHomeView { .clipShape(RoundedRectangle(cornerRadius: 12)) .frame(maxWidth: .infinity) } - .padding(.horizontal, 20) .padding(.bottom, 12) } } + + struct ProfileImageView: View { + let imageURL: String? + + var body: some View { + if let urlString = imageURL, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + ProgressView() + .tint(.red500) + .frame(width: 24, height: 24) + + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 24, height: 24) + .clipShape(Circle()) + + case .failure: + Image(.imgDefaultTrainerImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 24, height: 24) + .clipShape(Circle()) + + @unknown default: + EmptyView() + } + } + } else { + Image(.imgDefaultTrainerImage) + .resizable() + .scaledToFill() + .frame(width: 132, height: 132) + .clipShape(Circle()) + } + } + } } diff --git a/TnT/Projects/Presentation/Sources/MainTab/Trainer/TrainerMainTabFeature.swift b/TnT/Projects/Presentation/Sources/MainTab/Trainer/TrainerMainTabFeature.swift index b7a8cdb8..1b8d82c5 100644 --- a/TnT/Projects/Presentation/Sources/MainTab/Trainer/TrainerMainTabFeature.swift +++ b/TnT/Projects/Presentation/Sources/MainTab/Trainer/TrainerMainTabFeature.swift @@ -105,6 +105,8 @@ public struct TrainerMainTabFeature { switch internalAction { case .homeAction(.setNavigating(let screen)): return .send(.setNavigating(.trainerHome(screen))) + case .traineeListAction(.setNavigating(let screen)): + return .send(.setNavigating(.trainerTraineeList(screen))) case .myPageAction(.setNavigating(let screen)): return .send(.setNavigating(.trainerMyPage(screen))) default: @@ -118,6 +120,9 @@ public struct TrainerMainTabFeature { .ifCaseLet(\.home, action: \.subFeature.homeAction) { TrainerHomeFeature() } + .ifCaseLet(\.traineeList, action: \.subFeature.traineeListAction, then: { + TrainerManagementFeature() + }) .ifCaseLet(\.myPage, action: \.subFeature.myPageAction) { TrainerMypageFeature() } diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Common/LoginFeature.swift b/TnT/Projects/Presentation/Sources/Onboarding/Common/LoginFeature.swift index 685b6b7a..6ac03a6a 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/Common/LoginFeature.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/Common/LoginFeature.swift @@ -129,7 +129,7 @@ public struct LoginFeature { return .run { send in do { - let result = try await userUseCaseRepo.postSocialLogin(post) + let result: PostSocialLoginResDTO = try await userUseCaseRepo.postSocialLogin(post) saveSessionId(result.sessionId) switch result.memberType { diff --git a/TnT/Projects/Presentation/Sources/TrainerManagement/AddTraineeFeature.swift b/TnT/Projects/Presentation/Sources/TrainerManagement/AddTraineeFeature.swift new file mode 100644 index 00000000..32353eba --- /dev/null +++ b/TnT/Projects/Presentation/Sources/TrainerManagement/AddTraineeFeature.swift @@ -0,0 +1,106 @@ +// +// AddTraineeFeature.swift +// Presentation +// +// Created by 박서연 on 2/14/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import SwiftUI +import ComposableArchitecture + +import DesignSystem +import DIContainer + +@Reducer +public struct AddTraineeFeature { + + @ObservableState + public struct State: Equatable { + var invitationCode: String = "" + + public init() { } + } + + @Dependency(\.trainerRepoUseCase) var trainerRepoUseCase + + public enum Action: ViewAction { + /// 화면 내 발생 액션 처리 + case view(View) + /// api 콜 액션 처리 + case api(APIAction) + /// 초대 코드 설정 + case setInvitationCode(String) + /// 네비게이션 처리 + case setNavigation + + @CasePathable + public enum View: Sendable, BindableAction { + /// 바인딩 액션 처리 + case binding(BindingAction) + /// 코드 재발급 버튼 탭 + case tappedReissuanceButton + /// 코드 카피 영역 탭 + case tapCodeToCopy + /// 화면 표시될 때 + case onAppear + } + + @CasePathable + public enum APIAction: Sendable { + /// 초대 코드 불러오기 API + case getInvitationCode + /// 초대 코드 재발급하기 API + case reissueInvitationCode + } + } + + public init() { } + + public var body: some ReducerOf { + BindingReducer(action: \.view) + + Reduce { state, action in + switch action { + case .view(let view): + switch view { + case .binding: + return .none + + case .tapCodeToCopy: + UIPasteboard.general.string = state.invitationCode + NotificationCenter.default.post(toast: .init(presentType: .text("⚠"), message: "코드가 복사되었어요!")) + return .none + + case .tappedReissuanceButton: + return .send(.api(.reissueInvitationCode)) + + case .onAppear: + return .send(.api(.getInvitationCode)) + } + + case .api(let action): + switch action { + case .getInvitationCode: + return .run { send in + let result = try await trainerRepoUseCase.getTheFirstInvitationCode() + await send(.setInvitationCode(result.invitationCode)) + } + + case .reissueInvitationCode: + return .run { send in + let result = try await trainerRepoUseCase.getReissuanceInvitationCode() + await send(.setInvitationCode(result.invitationCode)) + } + } + + case .setInvitationCode(let code): + state.invitationCode = code + return .none + + case .setNavigation: + return .none + } + } + } +} diff --git a/TnT/Projects/Presentation/Sources/TrainerManagement/AddTraineeView.swift b/TnT/Projects/Presentation/Sources/TrainerManagement/AddTraineeView.swift new file mode 100644 index 00000000..6316baf8 --- /dev/null +++ b/TnT/Projects/Presentation/Sources/TrainerManagement/AddTraineeView.swift @@ -0,0 +1,90 @@ +// +// AddTraineeView.swift +// Presentation +// +// Created by 박서연 on 2/14/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import SwiftUI +import ComposableArchitecture + +import DesignSystem + +@ViewAction(for: AddTraineeFeature.self) +public struct AddTraineeView: View { + + @Environment(\.dismiss) var dismiss + public let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + Header() + InvitationCode() + } + .navigationBarBackButtonHidden() + .onAppear { send(.onAppear) } + } + + @ViewBuilder + private func Header() -> some View { + TNavigation( + type: .LButtonWithTitle( + leftImage: .icnArrowLeft, + centerTitle: "회원추가") + ) + .leftTap { + dismiss() + } + } + + @ViewBuilder + private func InvitationCode() -> some View { + VStack(alignment: .leading, spacing: 0) { + Text("생성된 초대코드로\n트레이니가 로그인할 수 있어요") + .typographyStyle(.heading2, with: .neutral950) + + Spacer().frame(height: 48) + + VStack(spacing: 15) { + HStack(spacing: 0) { + Text("내 초대 코드") + .typographyStyle(.body1Bold, with: .neutral900) + Text("*") + .typographyStyle(.body1Bold, with: .red500) + .frame(maxWidth: .infinity, alignment: .leading) + } + + HStack(spacing: 0) { + ZStack(alignment: .bottom) { + Text("\(store.invitationCode)") + .typographyStyle(.body1Medium, with: .neutral600) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .onTapGesture { + send(.tapCodeToCopy) + } + + TDivider(height: 1, color: .neutral300) + } + + TButton( + title: "코드 재발급", + config: .small, + state: .default(.gray(isEnabled: true)) + ) { + send(.tappedReissuanceButton) + } + .frame(width: 82) + } + } + + Spacer() + } + .padding(.horizontal, 24) + } +} diff --git a/TnT/Projects/Presentation/Sources/TrainerManagement/TrainerManagementView.swift b/TnT/Projects/Presentation/Sources/TrainerManagement/TrainerManagementView.swift index 6c5eb9b2..dd224e25 100644 --- a/TnT/Projects/Presentation/Sources/TrainerManagement/TrainerManagementView.swift +++ b/TnT/Projects/Presentation/Sources/TrainerManagement/TrainerManagementView.swift @@ -12,6 +12,7 @@ import ComposableArchitecture import DesignSystem import Domain +@ViewAction(for: TrainerManagementFeature.self) struct TrainerManagementView: View { public var store: StoreOf @@ -21,11 +22,11 @@ struct TrainerManagementView: View { } var body: some View { - ScrollView { + ScrollView(showsIndicators: false) { VStack(spacing: 12) { - Header() - if let trainees = store.traineeList { + + if let trainees = store.traineeList, !trainees.isEmpty { TraineeListView(trainees: trainees) } else { EmptyListView() @@ -33,7 +34,7 @@ struct TrainerManagementView: View { } } .onAppear { - store.send(.view(.onappear)) + send(.onappear) } .navigationBarBackButtonHidden() } @@ -42,14 +43,25 @@ struct TrainerManagementView: View { @ViewBuilder func Header() -> some View { - TNavigation(type: .LTextRButtonTitle( - leftTitle: "내 회원", - pointText: "\(store.traineeList?.count ?? 0)", - rightButton: "회원 초대하기") - ) - .rightTap { - store.send(.view(.goTraineeInvitation)) + HStack(spacing: 6) { + Text("내 회원") + .typographyStyle(.heading2, with: .neutral900) + Text("\(store.traineeList?.count ?? 0)") + .typographyStyle(.heading2, with: .red500) + + Spacer() + + Button { + send(.tapTraineeInvitation) + } label: { + Text("회원 초대하기") + .typographyStyle(.label2Medium, with: Color.neutral600) + .padding(.init(top: 7, leading: 12, bottom: 7, trailing: 12)) + .background(Color.neutral200) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } } + .padding(20) } /// 연결된 회원이 있는 경우 @@ -61,6 +73,7 @@ struct TrainerManagementView: View { .padding(.bottom, 16) } } + .padding(.horizontal, 16) } /// 연결된 회원이 없는 경우 @@ -79,27 +92,36 @@ struct TrainerManagementView: View { @ViewBuilder func ListCellView(trainee: ActiveTraineeInfoResEntity) -> some View { VStack(spacing: 12) { - HStack { - HStack { + HStack(spacing: 0) { + HStack(spacing: 12) { ProfileImageView(imageURL: trainee.profileImageUrl) - VStack(spacing: 12) { + VStack(spacing: 0) { Text(trainee.name) .typographyStyle(.body1Bold, with: Color.neutral900) + .frame(maxWidth: .infinity, alignment: .leading) Text(trainee.ptGoals.joined(separator: ", ")) .typographyStyle(.label2Medium, with: Color.neutral500) + .frame(maxWidth: .infinity, alignment: .leading) } + .padding(.vertical, 9) } Spacer() - TChip(leadingEmoji: "💪", title: "\(trainee.finishedPtCount)", style: .blue) + VStack { + TChip(leadingEmoji: "💪", title: "\(trainee.finishedPtCount)/\(trainee.totalPtCount)회", style: .blue) + } } - VStack(spacing: 5) { - Text("메모") - .typographyStyle(.label2Bold, with: Color.neutral600) - Text(trainee.memo) - .typographyStyle(.label2Medium, with: Color.neutral500) + if !trainee.memo.isEmpty { + VStack(spacing: 5) { + Text("메모") + .typographyStyle(.label2Bold, with: Color.neutral600) + .frame(maxWidth: .infinity, alignment: .leading) + Text(trainee.memo) + .typographyStyle(.label2Medium, with: Color.neutral500) + .frame(maxWidth: .infinity, alignment: .leading) + } } } .padding(12) diff --git a/TnT/Projects/Presentation/Sources/TrainerManagement/TrainerManagmentFeature.swift b/TnT/Projects/Presentation/Sources/TrainerManagement/TrainerManagmentFeature.swift index 4c4c3b4b..68961787 100644 --- a/TnT/Projects/Presentation/Sources/TrainerManagement/TrainerManagmentFeature.swift +++ b/TnT/Projects/Presentation/Sources/TrainerManagement/TrainerManagmentFeature.swift @@ -23,7 +23,7 @@ public struct TrainerManagementFeature { /// 뷰에서 발생한 에러를 처리합니다. case view(View) /// 네비게이션 여부 설정 - case setNavigating + case setNavigating(RoutingScreen) @CasePathable public enum View: Sendable { @@ -34,7 +34,7 @@ public struct TrainerManagementFeature { /// 화면 진입시 case onappear /// 회원 초대하기로 이동 - case goTraineeInvitation + case tapTraineeInvitation } } @@ -48,21 +48,21 @@ public struct TrainerManagementFeature { await send(.view(.getTraineeList)) } - case .setNavigating: - return .none case .view(.getTraineeList): return .run { send in - let result: GetActiveTraineesListResDTO = try await trainerRepoUseCase.getMembersList() + let result: GetActiveTraineesListResDTO = try await trainerRepoUseCase.getActiveTraineesList() let trainee: [ActiveTraineeInfoResEntity] = result.trainees.map { $0.dtoToEntity() } await send(.view(.setTraineeList(trainee))) - } + case .view(.setTraineeList(let trainees)): state.traineeList = trainees return .none - case .view(.goTraineeInvitation): - print("트레이너 내 회원 > 회원 초대하기로 이동") + case .view(.tapTraineeInvitation): + return .send(.setNavigating(.addTrainee)) + + case .setNavigating: return .none } }