diff --git a/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift b/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift index 53a8ced5..eae6b117 100644 --- a/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift +++ b/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift @@ -13,6 +13,7 @@ import Domain /// 트레이너 관련 네트워크 요청을 처리하는 TrainerRepository 구현체 public struct TrainerRepositoryImpl: TrainerRepository { + private let networkService: NetworkService = .shared public init() {} @@ -23,4 +24,25 @@ public struct TrainerRepositoryImpl: TrainerRepository { decodingType: GetVerifyInvitationCodeResDTO.self ) } + + public func getTheFirstInvitationCode() async throws -> GetTheFirstInvitationCodeDTO { + return try await networkService.request( + TrainerTargetType.getFirstInvitationCode, + decodingType: GetTheFirstInvitationCodeDTO.self + ) + } + + public func getReissuanceInvitationCode() async throws -> GetReissuanceInvitationCodeDTO { + return try await networkService.request( + TrainerTargetType.getReissuanceInvitationCode, + decodingType: GetReissuanceInvitationCodeDTO.self + ) + } + + public func getDateSessionList(date: String) async throws -> GetDateSessionListDTO { + return try await networkService.request( + TrainerTargetType.getDateLessionList(date: date), + decodingType: GetDateSessionListDTO.self + ) + } } diff --git a/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerTargetType.swift b/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerTargetType.swift index 8e1cf80d..90b1715c 100644 --- a/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerTargetType.swift +++ b/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerTargetType.swift @@ -14,6 +14,12 @@ import Domain public enum TrainerTargetType { /// 트레이너 초대코드 인증 case getVerifyInvitationCode(code: String) + /// 트레이너 초대코드 불러오기 + case getFirstInvitationCode + /// 트레이너 캘린더, 특정 날짜의 PT 리스트 불러오기 + case getDateLessionList(date: String) + /// 트레이너 초대코드 재발급 + case getReissuanceInvitationCode } extension TrainerTargetType: TargetType { @@ -26,6 +32,12 @@ extension TrainerTargetType: TargetType { switch self { case .getVerifyInvitationCode(let code): return "/invitation-code/verify/\(code)" + case .getFirstInvitationCode: + return "/invitation-code" + case .getDateLessionList(let date): + return "/lessions/\(date)" + case .getReissuanceInvitationCode: + return "/invitation-code/reissue" } } @@ -33,6 +45,12 @@ extension TrainerTargetType: TargetType { switch self { case .getVerifyInvitationCode: return .get + case .getFirstInvitationCode: + return .get + case .getDateLessionList: + return .get + case .getReissuanceInvitationCode: + return .put } } @@ -40,6 +58,12 @@ extension TrainerTargetType: TargetType { switch self { case .getVerifyInvitationCode: return .requestPlain + case .getFirstInvitationCode: + return .requestPlain + case .getDateLessionList: + return .requestPlain + case .getReissuanceInvitationCode: + return .requestPlain } } @@ -47,6 +71,12 @@ extension TrainerTargetType: TargetType { switch self { case .getVerifyInvitationCode: return ["Content-Type": "application/json"] + case .getFirstInvitationCode: + return nil + case .getDateLessionList: + return ["Content-Type": "application/json"] + case .getReissuanceInvitationCode: + return ["Content-Type": "application/json"] } } diff --git a/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_empty.imageset/Contents.json b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_empty.imageset/Contents.json new file mode 100644 index 00000000..75422e92 --- /dev/null +++ b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_empty.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icn_plus_empty.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_empty.imageset/icn_plus_empty.svg b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_empty.imageset/icn_plus_empty.svg new file mode 100644 index 00000000..47010bf9 --- /dev/null +++ b/TnT/Projects/DesignSystem/Resources/Assets.xcassets/Icons/icn_plus_empty.imageset/icn_plus_empty.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/TnT/Projects/DesignSystem/Sources/DesignSystem/Image+DesignSystem.swift b/TnT/Projects/DesignSystem/Sources/DesignSystem/Image+DesignSystem.swift index 97d6c646..ab168ba9 100644 --- a/TnT/Projects/DesignSystem/Sources/DesignSystem/Image+DesignSystem.swift +++ b/TnT/Projects/DesignSystem/Sources/DesignSystem/Image+DesignSystem.swift @@ -48,6 +48,7 @@ public extension ImageResource { static let icnStarSmile: ImageResource = DesignSystemAsset.icnStarSmile.imageResource static let icnWriteBlackFilled: ImageResource = DesignSystemAsset.icnWriteBlackFilled.imageResource static let icnPlus: ImageResource = DesignSystemAsset.icnPlus.imageResource + static let icnPlusEmpty: ImageResource = DesignSystemAsset.icnPlusEmpty.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/GetReissuanceInvitationCodeDTO.swift b/TnT/Projects/Domain/Sources/DTO/Trainer/GetReissuanceInvitationCodeDTO.swift new file mode 100644 index 00000000..56eb6f35 --- /dev/null +++ b/TnT/Projects/Domain/Sources/DTO/Trainer/GetReissuanceInvitationCodeDTO.swift @@ -0,0 +1,22 @@ +// +// GetReissuanceInvitationCodeDTO.swift +// Domain +// +// Created by 박서연 on 2/4/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation + +public struct GetReissuanceInvitationCodeDTO: Decodable { + public let trainerId: String + public let invitationCode: String + + public init( + trainerId: String, + invitationCode: String + ) { + self.trainerId = trainerId + self.invitationCode = invitationCode + } +} diff --git a/TnT/Projects/Domain/Sources/DTO/Trainer/GetTheFirstInvitationCodeDTO.swift b/TnT/Projects/Domain/Sources/DTO/Trainer/GetTheFirstInvitationCodeDTO.swift new file mode 100644 index 00000000..6e9b1cac --- /dev/null +++ b/TnT/Projects/Domain/Sources/DTO/Trainer/GetTheFirstInvitationCodeDTO.swift @@ -0,0 +1,18 @@ +// +// GetTheFirstInvitationCodeDTO.swift +// Domain +// +// Created by 박서연 on 2/4/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation + +/// 트레이너의 최초 연결 코드 +public struct GetTheFirstInvitationCodeDTO: Decodable { + public let invitationCode: String + + public init(invitationCode: String) { + self.invitationCode = invitationCode + } +} diff --git a/TnT/Projects/Domain/Sources/DTO/Trainer/TrainerHomeResponseDTO.swift b/TnT/Projects/Domain/Sources/DTO/Trainer/TrainerHomeResponseDTO.swift new file mode 100644 index 00000000..615efca2 --- /dev/null +++ b/TnT/Projects/Domain/Sources/DTO/Trainer/TrainerHomeResponseDTO.swift @@ -0,0 +1,100 @@ +// +// TrainerHomeResponseDTO.swift +// Domain +// +// Created by 박서연 on 2/4/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation + +/// 특정 날짜의 PT 리스트 불러오기 +public struct GetDateSessionListDTO: Decodable { + public let count: Int + public let date: String + public let lessons: [SessonDTO] + + public init( + count: Int, + date: String, + lessons: [SessonDTO] + ) { + self.count = count + self.date = date + self.lessons = lessons + } +} + +public struct SessonDTO: Decodable { + public let ptLessonId: String + public let traineeId: String + public let traineeName: String + public let session: Int + public let startTime: String + public let endTime: String + public let isCompleted: Bool + + public init( + ptLessonId: String, + traineeId: String, + traineeName: String, + session: Int, + startTime: String, + endTime: String, + isCompleted: Bool + ) { + self.ptLessonId = ptLessonId + self.traineeId = traineeId + self.traineeName = traineeName + self.session = session + self.startTime = startTime + self.endTime = endTime + self.isCompleted = isCompleted + } +} + +public struct GetDateSessionListEntity: Equatable, Encodable { + public let id = UUID().uuidString + public let count: Int + public let date: String + public let lessons: [SessonEntity] + + public init( + count: Int, + date: String, + lessons: [SessonEntity] + ) { + self.count = count + self.date = date + self.lessons = lessons + } +} + +public struct SessonEntity: Equatable, Encodable { + public let id = UUID().uuidString + public let ptLessonId: String + public let traineeId: String + public let traineeName: String + public let session: Int + public let startTime: String + public let endTime: String + public var isCompleted: Bool + + public init( + ptLessonId: String, + traineeId: String, + traineeName: String, + session: Int, + startTime: String, + endTime: String, + isCompleted: Bool + ) { + self.ptLessonId = ptLessonId + self.traineeId = traineeId + self.traineeName = traineeName + self.session = session + self.startTime = startTime + self.endTime = endTime + self.isCompleted = isCompleted + } +} diff --git a/TnT/Projects/Domain/Sources/Policy/TDateFormat.swift b/TnT/Projects/Domain/Sources/Policy/TDateFormat.swift index 1d32e4dd..25242f42 100644 --- a/TnT/Projects/Domain/Sources/Policy/TDateFormat.swift +++ b/TnT/Projects/Domain/Sources/Policy/TDateFormat.swift @@ -26,6 +26,8 @@ public enum TDateFormat: String { case M월_d일 = "M월 d일" /// "01월 10일 화요일" case MM월_dd일_EEEE = "MM월 dd일 EEEE" + /// "1월 10일 화요일" + case M월_d일_EEEE = "M월 d일 EEEE" /// "EE" case EE = "EE" /// "오후 17:00" (시간 포맷) diff --git a/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift b/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift index 7966a335..6c7ae2f1 100644 --- a/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift +++ b/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift @@ -16,4 +16,13 @@ public protocol TrainerRepository { /// - Returns: 검증 성공 시, 초대 코드 정보가 포함된 응답 DTO (`GetVerifyInvitationCodeResDTO`) /// - Throws: 네트워크 오류 또는 유효하지 않은 초대 코드로 인한 서버 오류 발생 가능 func getVerifyInvitationCode(code: String) async throws -> GetVerifyInvitationCodeResDTO + + /// 트레이너 최초 초대 코드 불러오기 + func getTheFirstInvitationCode() async throws -> GetTheFirstInvitationCodeDTO + + /// 트레이너 초대 코드 코드 재발급 + func getReissuanceInvitationCode() async throws -> GetReissuanceInvitationCodeDTO + + /// 트레이너 캘린더에 특정 날짜의 수업 정보 가져오기 + func getDateSessionList(date: String) async throws -> GetDateSessionListDTO } diff --git a/TnT/Projects/Presentation/Sources/Home/Trainer/TrainerHomeFeature.swift b/TnT/Projects/Presentation/Sources/Home/Trainer/TrainerHomeFeature.swift new file mode 100644 index 00000000..f02cc8e8 --- /dev/null +++ b/TnT/Projects/Presentation/Sources/Home/Trainer/TrainerHomeFeature.swift @@ -0,0 +1,124 @@ +// +// TrainerHomeFeature.swift +// Presentation +// +// Created by 박서연 on 2/5/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import SwiftUI +import ComposableArchitecture + +import Domain +import DesignSystem + +@Reducer +public struct TrainerHomeFeature { + + @ObservableState + public struct State: Equatable { + // MARK: Data related state + /// 선택된 날짜 + var selectedDate: Date + /// 캘린더 이벤트 + var events: [Date: Int] + /// 수업 갯수 정보 + var sessionCount: Int + /// 수업 정보 + var sessionInfo: WorkoutListItemEntity? + /// 기록 정보 목록 + var records: [RecordListItemEntity] + /// 특정 날짜의 수업 정보 + var tappedsessionInfo: GetDateSessionListEntity? + + // MARK: UI related state + /// 캘린더 표시 페이지 + 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))" + } + /// 기록 제목 표시 + var view_recordTitleString: String { + return TDateFormatUtility.formatter(for: .M월_d일_EEEE).string(from: selectedDate) + } + /// 선택 바텀 시트 표시 + var view_isBottomSheetPresented: Bool + + public init( + selectedDate: Date = .now, + events: [Date: Int] = [:], + sessionCount: Int = 0, + sessionInfo: WorkoutListItemEntity? = nil, + records: [RecordListItemEntity] = [], + view_currentPage: Date = .now, + view_isBottomSheetPresented: Bool = false, + tappedsessionInfo: GetDateSessionListEntity? = nil + ) { + self.selectedDate = selectedDate + self.events = events + self.sessionCount = sessionCount + self.sessionInfo = sessionInfo + self.records = records + self.view_currentPage = view_currentPage + self.view_isBottomSheetPresented = view_isBottomSheetPresented + self.tappedsessionInfo = tappedsessionInfo + } + } + + @Dependency(\.traineeUseCase) private var traineeUseCase: TraineeUseCase + + public enum Action: Sendable, ViewAction { + /// 뷰에서 발생한 액션을 처리합니다. + case view(View) + /// 네비게이션 여부 설정 + case setNavigating + + @CasePathable + public enum View: Sendable, BindableAction { + /// 바인딩할 액션을 처리 + case binding(BindingAction) + /// 우측 상단 알림 페이지 보기 버튼 탭 + case tapAlarmPageButton + /// 수업 완료 버튼 탭 + case tapSessionCompleted(id: String) + /// 식단 기록 추가 버튼 탭 + case tapAddSessionRecordButton + } + } + + public init() {} + + public var body: some ReducerOf { + BindingReducer(action: \.view) + + Reduce { state, action in + switch action { + + case .view(let action): + switch action { + case .binding(\.selectedDate): + print(state.events[state.selectedDate]) + return .none + case .binding: + return .none + case .tapAlarmPageButton: + // TODO: 네비게이션 연결 시 추가 + print("tapAlarmPageButton") + return .none + case .tapSessionCompleted(let id): + // TODO: 네비게이션 연결 시 추가 + print("tapSessionCompleted otLessionID \(id)") + return .none + case .tapAddSessionRecordButton: + // TODO: 네비게이션 연결 시 추가 + print("tapAddSessionRecordButton") + return .none + } + case .setNavigating: + return .none + } + } + } +} diff --git a/TnT/Projects/Presentation/Sources/Home/Trainer/TrainerHomeView.swift b/TnT/Projects/Presentation/Sources/Home/Trainer/TrainerHomeView.swift new file mode 100644 index 00000000..35c2f89f --- /dev/null +++ b/TnT/Projects/Presentation/Sources/Home/Trainer/TrainerHomeView.swift @@ -0,0 +1,202 @@ +// +// TrainerHomeView.swift +// Presentation +// +// Created by 박서연 on 2/4/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import SwiftUI +import ComposableArchitecture + +import Domain +import DesignSystem + +@ViewAction(for: TrainerHomeFeature.self) +public struct TrainerHomeView: View { + + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView { + VStack(spacing: 0) { + CalendarSection() + .background(Color.common0) + RecordTitle() + RecordList() + } + .background(Color.neutral100) + } + .overlay(alignment: .bottomTrailing) { + SessionAddButton() + } + } + + // MARK: - Sections + @ViewBuilder + private func CalendarSection() -> some View { + VStack(spacing: 16) { + TCalendarHeader( + currentPage: $store.view_currentPage, + formatter: { TDateFormatUtility.formatter(for: .yyyy년_MM월).string(from: $0) }, + rightView: { + Button(action: { + send(.tapAlarmPageButton) + }, label: { + Image(.icnAlarm) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + }) + } + ) + + // Calendar + VStack(spacing: 12) { + TCalendarView( + selectedDate: $store.selectedDate, + currentPage: $store.view_currentPage, + events: store.events, + isWeekMode: false + ) + .padding(.horizontal, 20) + } + } + .padding(.vertical, 12) + + } + + /// 수업 리스트 상단 타이틀 + @ViewBuilder + private func RecordTitle() -> some View { + HStack { + Text(store.view_recordTitleString) + .typographyStyle(.heading3, with: .neutral800) + .padding(.vertical, 20) + + Spacer() + + HStack(spacing: 0) { + Text("🧨") + .typographyStyle(.label1Medium) + Text("\(store.sessionCount)") + .typographyStyle(.label2Bold, with: Color.red500) + Text("개의 수업이 있어요") + .typographyStyle(.label2Medium, with: Color.neutral800) + } + } + .padding(.horizontal, 20) + .background(Color.neutral100) + + } + + /// 수업 리스트 + @ViewBuilder + private func RecordList() -> some View { + VStack { + if let record = store.tappedsessionInfo { + ForEach(record.lessons, id: \.id) { record in + SessionCellView(session: record) { + send(.tapSessionCompleted(id: record.ptLessonId)) + } + } + } else { + RecordEmptyView() + } + } + .padding(.horizontal, 20) + } + + /// 수업 추가 버튼 + @ViewBuilder + private func SessionAddButton() -> some View { + Capsule() + .fill(Color.neutral900) + .frame(width: 126, height: 58) + .overlay { + HStack(spacing: 4) { + Image(.icnPlusEmpty) + .resizable() + .frame(width: 24, height: 24) + Text("수업추가") + .typographyStyle(.body1Medium, with: .neutral50) + } + } + .onTapGesture { + send(.tapAddSessionRecordButton) + } + .padding(.trailing, 22) + .padding(.bottom, 28) + } +} + +extension TrainerHomeView { + + /// 아직 등록된 수업이 없어요 + struct RecordEmptyView: View { + var body: some View { + VStack(spacing: 4) { + Text("아직 등록된 수업이 없어요") + .typographyStyle(.body2Bold, with: .neutral600) + .frame(maxWidth: .infinity) + Text("추가 버튼을 눌러 PT 수업 일정을 추가해 보세요") + .typographyStyle(.label1Medium, with: .neutral400) + .frame(maxWidth: .infinity) + } + .padding(.top, 80) + .padding(.bottom, 100) + } + } + + /// 수업 목록리스트의 셀 + struct SessionCellView: View { + var session: SessonEntity + var onTapComplete: () -> Void + + var body: some View { + HStack(spacing: 20) { + Image(session.isCompleted ? .icnCheckBoxSelected : .icnCheckBoxUnselected) + .resizable() + .frame(width: 32, height: 32) + .onTapGesture { + /// 수업 완료 버튼 탭 + onTapComplete() + } + + VStack(spacing: 12) { + HStack(spacing: 4) { + TChip(leadingEmoji: "💪", title: "\(session.session)회차 수업", style: .blue) + Spacer() + Image(.icnClock) + Text("\(session.startTime) ~ \(session.endTime)") + } + Text(session.traineeName) + + if session.isCompleted { + Button { + // + } label: { + HStack(spacing: 4) { + Image(.icnWriteWhite) + Text("PT 수업 기록 남기기") + .typographyStyle(.label2Medium, with: .neutral400) + } + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + .padding(.init(top: 16, leading: 12, bottom: 16, trailing: 12)) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 20) + .padding(.bottom, 12) + } + } +} diff --git a/TnT/Projects/Presentation/Sources/MyPage/Trainer/TrainerMypageFeature.swift b/TnT/Projects/Presentation/Sources/MyPage/Trainer/TrainerMypageFeature.swift new file mode 100644 index 00000000..aa9895b1 --- /dev/null +++ b/TnT/Projects/Presentation/Sources/MyPage/Trainer/TrainerMypageFeature.swift @@ -0,0 +1,236 @@ +// +// TrainerMypageFeature.swift +// Presentation +// +// Created by 박서연 on 2/4/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import SwiftUI +import ComposableArchitecture + +import Domain +import DesignSystem + +@Reducer +public struct TrainerMypageFeature { + + @ObservableState + public struct State: Equatable { + /// 사용자 이름 + var userName: String + /// 사용자 이미지 URL + var userImageUrl: String? + /// 관리 중인 회원 + var studentCount: Int + /// 함께 했던 회원 + var oldStudentCount: Int + /// 앱 푸시 알림 허용 여부 + var appPushNotificationAllowed: Bool + /// 버전 정보 + var versionInfo: String + /// 팝업 + var view_popUp: PopUp? + /// 팝업 표시 유무 + var view_isPopUpPresented: Bool = false + + public init( + userName: String, + userImageUrl: String? = nil, + studentCount: Int, + oldStudentCount: Int, + appPushNotificationAllowed: Bool, + versionInfo: String, + view_popUp: PopUp? = nil, + view_isPopUpPresented: Bool = false + ) { + self.userName = userName + self.userImageUrl = userImageUrl + self.studentCount = studentCount + self.oldStudentCount = oldStudentCount + self.appPushNotificationAllowed = appPushNotificationAllowed + self.versionInfo = versionInfo + self.view_popUp = view_popUp + self.view_isPopUpPresented = view_isPopUpPresented + } + } + + @Dependency(\.userUseCase) private var userUseCase: UserUseCase + + public enum Action: Sendable, ViewAction { + /// 뷰에서 발생한 액션을 처리합니다. + case view(View) + /// 네비게이션 여부 설정 + case setNavigating + + @CasePathable + public enum View: Sendable, BindableAction { + /// 바인딩할 액션을 처리 (알람) + case binding(BindingAction) + /// 서비스 이용약관 버튼 탭 + case tapTOSButton + /// 개인정보 처리방침 버튼 탭 + case tapPrivacyPolicyButton + /// 오픈소스 라이선스 버튼 탭 + case tapOpenSourceLicenseButton + /// 로그아웃 버튼 탭 + case tapLogoutButton + /// 계정 탈퇴 버튼 탭 + case tapWithdrawButton + /// 팝업 왼쪽 탭 + case tapPupUpSecondaryButton(popUp: PopUp?) + /// 팝옵 오른쪽 탭 + case tapPopUpPrimaryButton(popUp: PopUp?) + } + } + + public init() { } + + public var body: some ReducerOf { + BindingReducer(action: \.view) + + Reduce { state, action in + switch action { + case .view(let action): + switch action { + case .binding(\.appPushNotificationAllowed): + print("푸쉬알림 변경: \(state.appPushNotificationAllowed)") + return .none + case .binding: + return .none + + case .tapTOSButton: + print("tapTOSButton") + return .none + + case .tapPrivacyPolicyButton: + print("tapPrivacyPolicyButton") + return .none + + case .tapOpenSourceLicenseButton: + print("tapOpenSourceLicenseButton") + return .none + + case .tapLogoutButton: + print("tapLogoutButton") + state.view_isPopUpPresented = true + state.view_popUp = .logout + return .none + + case .tapWithdrawButton: + state.view_isPopUpPresented = true + state.view_popUp = .withdraw + print("tapWithdrawButton") + return .none + + case .tapPupUpSecondaryButton(let popUp): + guard let popUp = popUp else { return .none } + switch popUp { + case .logout, .withdraw, .logoutCompleted, .withdrawCompleted: + state.view_popUp = nil + state.view_isPopUpPresented = false + } + return .none + + case .tapPopUpPrimaryButton(let popUp): + guard let popUp = popUp else { return .none } + switch popUp { + case .logout: + state.view_isPopUpPresented = false + state.view_popUp = .logoutCompleted + state.view_isPopUpPresented = true + + case .logoutCompleted: + state.view_isPopUpPresented = false + + case .withdraw: + state.view_isPopUpPresented = false + state.view_popUp = .withdrawCompleted + state.view_isPopUpPresented = true + + case .withdrawCompleted: + state.view_isPopUpPresented = false + } + return .none + } + + case .setNavigating: + return .none + } + } + } +} + +public extension TrainerMypageFeature { + /// 트레이너 마이페이지 팝업 + enum PopUp: Equatable, Sendable { + /// 로그아웃 + case logout + /// 로그아웃 완료 + case logoutCompleted + /// 회원 탈퇴 + case withdraw + /// 회원 탈퇴 완료 + case withdrawCompleted + + var nextPopUp: PopUp? { + switch self { + case .logout: + return .logoutCompleted + case .withdraw: + return .withdrawCompleted + case .logoutCompleted, .withdrawCompleted: + return nil + } + } + + var title: String { + switch self { + case .logout: + return "현재 계정을 로그아웃 할까요?" + case .logoutCompleted: + return "로그아웃이 완료되었어요" + case .withdraw: + return "계정을 탈퇴할까요?" + case .withdrawCompleted: + return "계정 탈퇴가 완료되었어요" + } + } + + var message: String { + switch self { + case .logout: + return "언제든지 다시 로그인 할 수 있어요!" + case .logoutCompleted: + return "언제든지 다시 로그인 할 수 있어요!" + case .withdraw: + return "함께 했던 회원들에 대한 데이터가 사라져요!" + case .withdrawCompleted: + return "다음에 더 폭발적인 케미로 다시 만나요! 💣" + } + } + + var alertIcon: Bool { + switch self { + case .logout, .withdraw: + return true + + case .logoutCompleted, .withdrawCompleted: + return false + } + } + + var secondaryAction: Action.View? { + switch self { + case .logout, .withdraw: + return .tapPupUpSecondaryButton(popUp: self) + case .logoutCompleted, .withdrawCompleted: + return nil + } + } + + var primaryAction: Action.View { + return .tapPopUpPrimaryButton(popUp: self) + } + } +} diff --git a/TnT/Projects/Presentation/Sources/MyPage/Trainer/TrainerMypageView.swift b/TnT/Projects/Presentation/Sources/MyPage/Trainer/TrainerMypageView.swift new file mode 100644 index 00000000..77bca5ea --- /dev/null +++ b/TnT/Projects/Presentation/Sources/MyPage/Trainer/TrainerMypageView.swift @@ -0,0 +1,222 @@ +// +// TrainerMypageView.swift +// Presentation +// +// Created by 박서연 on 2/4/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import SwiftUI +import ComposableArchitecture + +import Domain +import DesignSystem + +@ViewAction(for: TrainerMypageFeature.self) +public struct TrainerMypageView: View { + + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView { + VStack(spacing: 16) { + ProfileView() + StudentInfoView() + + VStack(spacing: 12) { + TopItemSection() + InfoItemSection() + BottomItemSection() + } + .padding(20) + } + } + .background(Color.neutral50) + .navigationBarBackButtonHidden() + .tPopUp(isPresented: $store.view_isPopUpPresented) { + PopUpView() + } + } + + @ViewBuilder + private func PopUpView() -> some View { + if let popUp = store.view_popUp { + let buttons: [TPopupAlertState.ButtonState] = [ + popUp.secondaryAction.map({ action in + TPopupAlertState.ButtonState(title: "취소", style: .secondary, action: .init(action: { send(action) })) + }), + TPopupAlertState.ButtonState(title: "확인", style: .primary, action: .init(action: { send(popUp.primaryAction) })) + ].compactMap { $0 } + + TPopUpAlertView( + alertState: TPopupAlertState( + title: popUp.title, + message: popUp.message, + showAlertIcon: popUp.alertIcon, + buttons: buttons + ) + ) + } else { + EmptyView() + } + } + + @ViewBuilder + private func ProfileView() -> some View { + VStack(spacing: 0) { + ProfileImageView(imageURL: store.userImageUrl) + .padding(.vertical, 12) + Text(store.userName) + .typographyStyle(.heading2, with: .neutral950) + } + } + + @ViewBuilder + private func StudentInfoView() -> some View { + HStack(spacing: 9) { + StudentInfoItem(title: "관리 중인 회원", count: store.studentCount) + StudentInfoItem(title: "함께했던 회원", count: store.oldStudentCount) + } + .padding(.horizontal, 40) + } + + @ViewBuilder + private func TopItemSection() -> some View { + VStack(spacing: 12) { + ProfileItemView(title: "앱 푸시 알림", rightView: { + Toggle("appPushNotification", isOn: $store.appPushNotificationAllowed) + .applyTToggleStyle() + }) + .padding(.vertical, 4) + .background(Color.common0) + .clipShape(.rect(cornerRadius: 12)) + } + } + + @ViewBuilder + private func InfoItemSection() -> some View { + VStack(spacing: 12) { + ProfileItemView(title: "서비스 이용약관", tapAction: { send(.tapTOSButton) }) + ProfileItemView(title: "개인정보 처리방침", tapAction: { send(.tapPrivacyPolicyButton) }) + ProfileItemView(title: "버전 정보", rightView: { + Text(store.versionInfo) + .typographyStyle(.body2Medium, with: .neutral400) + }) + ProfileItemView(title: "오픈소스 라이선스", tapAction: { send(.tapOpenSourceLicenseButton) }) + } + .padding(.vertical, 12) + .background(Color.common0) + .clipShape(.rect(cornerRadius: 12)) + } + + @ViewBuilder + private func BottomItemSection() -> some View { + VStack(spacing: 12) { + ProfileItemView(title: "로그아웃", tapAction: { send(.tapLogoutButton) }) + ProfileItemView(title: "계정 탈퇴", tapAction: { send(.tapWithdrawButton) }) + } + .padding(.vertical, 12) + .background(Color.common0) + .clipShape(.rect(cornerRadius: 12)) + } +} + +extension TrainerMypageView { + struct StudentInfoItem: View { + let title: String + let count: Int + + var body: some View { + VStack { + Text(title) + .typographyStyle(.label1Bold, with: .neutral500) + HStack(spacing: 0) { + Image(.icnBomb) + .resizable() + .frame(width: 28, height: 28) + Text("\(count)") + .typographyStyle(.body1Medium, with: .neutral950) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.neutral100) + .clipShape(RoundedRectangle(cornerRadius: 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: 132, height: 132) + + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 132, height: 132) + .clipShape(Circle()) + + case .failure: + Image(.imgDefaultTrainerImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 132, height: 132) + .clipShape(Circle()) + + @unknown default: + EmptyView() + } + } + } else { + Image(.imgDefaultTrainerImage) + .resizable() + .scaledToFill() + .frame(width: 132, height: 132) + .clipShape(Circle()) + } + } + } + + struct ProfileItemView: View { + let title: String + let rightView: () -> RightView + let tapAction: (() -> Void)? + + init( + title: String, + rightView: @escaping () -> RightView = { EmptyView() }, + tapAction: (() -> Void)? = nil + ) { + self.title = title + self.rightView = rightView + self.tapAction = tapAction + } + + var body: some View { + HStack { + Text(title) + .typographyStyle(.body2Medium, with: .neutral700) + Spacer() + rightView() + } + .onTapGesture { + tapAction?() + } + .padding(.horizontal, 20) + .padding(.vertical, 8) + } + } +} diff --git a/TnT/Projects/TnTApp/Sources/ContentView.swift b/TnT/Projects/TnTApp/Sources/ContentView.swift index 2bf81b08..3e3f4b1a 100644 --- a/TnT/Projects/TnTApp/Sources/ContentView.swift +++ b/TnT/Projects/TnTApp/Sources/ContentView.swift @@ -12,8 +12,7 @@ import ComposableArchitecture struct ContentView: View { var body: some View { - Text("dasdasdf") - Text("Hello, World!") + Text("") } }