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("")
}
}