From 24e4b92e56e2d43c17d3f178331721830dd39c6c Mon Sep 17 00:00:00 2001 From: Minseo Park <125115284+FpRaArNkK@users.noreply.github.com> Date: Mon, 3 Feb 2025 21:26:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[Feat]=20TraineeInvitationCodeInput=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85,=20=EB=84=A4=EB=B9=84=20=EB=B6=84=EA=B8=B0?= =?UTF-8?q?=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 --- .../TraineeInvitationCodeInputFeature.swift | 76 ++++++++-- .../TraineeInvitationCodeInputView.swift | 138 +++++++++++------- 2 files changed, 150 insertions(+), 64 deletions(-) diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputFeature.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputFeature.swift index db80ed3a..90d34bdc 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputFeature.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputFeature.swift @@ -21,6 +21,8 @@ public struct TraineeInvitationCodeInputFeature { // MARK: Data related state /// 입력된 초대코드 var invitationCode: String + /// 표시되는 팝업 + var presentPopUp: PopUp? // MARK: UI related state /// 텍스트 필드 상태 (빈 값 / 입력됨 / 유효하지 않음) @@ -35,25 +37,41 @@ public struct TraineeInvitationCodeInputFeature { var view_isNextButtonEnabled: Bool /// 팝업 표시 여부 var view_isPopupPresented: Bool + /// 상단 네비바 표시 상태 + var view_navigationType: NavigationType + /// `TraineeInvitationCodeInputFeature.State`의 생성자 /// - Parameters: + /// - invitationCode: 사용자가 입력한 초대 코드 (기본값: `""`) + /// - presentPopUp: 현재 표시되는 팝업 (기본값: `nil`) + /// - view_invitationCodeStatus: 텍스트 필드 상태 (`.empty`, `.valid`, `.invalid` 등) + /// - view_textFieldFooterText: 텍스트 필드 하단에 표시될 메시지 (기본값: `""`) + /// - view_isFieldFocused: 현재 텍스트 필드가 포커스를 받고 있는지 여부 (기본값: `false`) + /// - view_isVerityButtonEnabled: "인증하기" 버튼 활성화 여부 (기본값: `false`) + /// - view_isNextButtonEnabled: "다음" 버튼 활성화 여부 (기본값: `false`) + /// - view_isPopupPresented: 팝업이 표시 중인지 여부 (기본값: `false`) + /// - view_navigationType: 현재 화면의 네비게이션 타입 (`.newUser`: "건너뛰기" 버튼 있음, `.existingUser`: "뒤로가기" 버튼 있음) public init( invitationCode: String = "", + presentPopUp: PopUp? = nil, view_invitationCodeStatus: TTextField.Status = .empty, view_textFieldFooterText: String = "", view_isFieldFocused: Bool = false, view_isVerityButtonEnabled: Bool = false, view_isNextButtonEnabled: Bool = false, - view_isPopupPresented: Bool = true + view_isPopupPresented: Bool = false, + view_navigationType: NavigationType = .newUser ) { self.invitationCode = invitationCode + self.presentPopUp = presentPopUp self.view_invitationCodeStatus = view_invitationCodeStatus self.view_textFieldFooterText = view_textFieldFooterText self.view_isFieldFocused = view_isFieldFocused self.view_isVerityButtonEnabled = view_isVerityButtonEnabled self.view_isNextButtonEnabled = view_isNextButtonEnabled self.view_isPopupPresented = view_isPopupPresented + self.view_navigationType = view_navigationType } } @@ -79,10 +97,16 @@ public struct TraineeInvitationCodeInputFeature { case setFocus(Bool) /// Nav바 건너뛰기 버튼이 눌렸을 때 case tapNavBarSkipButton + /// Nav바 back 버튼이 눌렸을 때 + case tapNavBarBackButton /// 팝업 "다음에 할게요" 버튼이 눌렸을 때 - case tapPopupNextButton + case tapInvitePopupNextButton /// 팝업 "확인" 버튼이 눌렸을 때 - case tapPopupConfirmButton + case tapInvitePopupConfirmButton + /// 팝업 "중단하기" 버튼이 눌렸을 때 + case tapDropAlertStopButton + /// 팝업 "계속 진행" 버튼이 눌렸을 때 + case tapDropAlertKeepButton } } @@ -100,7 +124,7 @@ public struct TraineeInvitationCodeInputFeature { case .binding: return .none - + case .tapVerifyButton: return .run { [state] send in let result: Bool = try await traineeUseCase.verifyTrainerInvitationCode(state.invitationCode) @@ -109,20 +133,22 @@ public struct TraineeInvitationCodeInputFeature { case .tapNextButton: return .send(.setNavigating(.trainingInfoInput)) - + case .setFocus(let isFocused): state.view_isFieldFocused = isFocused return .none - - case .tapNavBarSkipButton: - return .send(.setNavigating(.traineeHome)) - case .tapPopupNextButton: + case .tapNavBarSkipButton, .tapNavBarBackButton: + // 인증 후에만 팝업 표시 + return state.view_invitationCodeStatus == .valid + ? self.setPopUpStatus(&state, status: .dropAlert) + : .send(.setNavigating(.traineeHome)) + + case .tapInvitePopupNextButton, .tapDropAlertStopButton: return .send(.setNavigating(.traineeHome)) - case .tapPopupConfirmButton: - state.view_isPopupPresented = false - return .none + case .tapDropAlertKeepButton, .tapInvitePopupConfirmButton: + return self.setPopUpStatus(&state, status: nil) } case .updateVerificationStatus(let isVerified): @@ -130,7 +156,7 @@ public struct TraineeInvitationCodeInputFeature { state.view_invitationCodeStatus = isVerified ? .valid : .invalid state.view_isNextButtonEnabled = isVerified return .none - + case .setNavigating: return .none } @@ -160,11 +186,35 @@ private extension TraineeInvitationCodeInputFeature { return .none } + + /// 팝업 상태, 표시 상태를 업데이트 + func setPopUpStatus(_ state: inout State, status: PopUp?) -> Effect { + state.presentPopUp = status + state.view_isPopupPresented = status != nil + return .none + } } public extension TraineeInvitationCodeInputFeature { + /// 본 화면에서 라우팅(파생)되는 화면 enum RoutingScreen: Sendable { case traineeHome case trainingInfoInput } + + /// 본 화면에 팝업으로 표시되는 목록 + enum PopUp: Sendable { + /// 진입 시 초대 코드를 입력해주세요 + case invitePopUp + /// 연결을 중단하시겠어요? + case dropAlert + } + + /// 본 화면의 네비게이션 타입 + enum NavigationType: Equatable { + /// 신규 유저 (우측 건너뛰기 버튼) + case newUser + /// 기존 유저 (좌측 뒤로가기 버튼) + case existingUser + } } diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputView.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputView.swift index 58bc10fc..91f7c582 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputView.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputView.swift @@ -27,15 +27,7 @@ public struct TraineeInvitationCodeInputView: View { public var body: some View { VStack(spacing: 0) { - TNavigation( - type: .RTextWithTitle( - centerTitle: "연결하기", - rightText: "건너뛰기" - ), - rightAction: { - send(.tapNavBarSkipButton) - } - ) + NavigationBar() .padding(.bottom, 24) Header() @@ -63,18 +55,46 @@ public struct TraineeInvitationCodeInputView: View { } } .tPopUp(isPresented: $store.view_isPopupPresented) { - PopUpView( - secondaryAction: { - send(.tapPopupNextButton) - }, - primaryAction: { - send(.tapPopupConfirmButton) + guard let popUp = store.presentPopUp else { + return TPopUpAlertView(alertState: .init(title: "Error")) + } + switch popUp { + case .invitePopUp: + return TrainerInvitePopup() + + case .dropAlert: + return DropAlertPopup() + } + } + } + + // MARK: - Sections + @ViewBuilder + private func NavigationBar() -> some View { + switch store.view_navigationType { + case .newUser: + TNavigation( + type: .RTextWithTitle( + centerTitle: "연결하기", + rightText: "건너뛰기" + ), + rightAction: { + send(.tapNavBarSkipButton) + } + ) + case .existingUser: + TNavigation( + type: .LButtonWithTitle( + leftImage: .icnArrowLeft, + centerTitle: "연결하기" + ), + leftAction: { + send(.tapNavBarBackButton) } ) } } - // MARK: - Sections @ViewBuilder private func Header() -> some View { TInfoTitleHeader(title: "트레이너에게 받은\n초대 코드를 입력해 주세요") @@ -113,42 +133,58 @@ public struct TraineeInvitationCodeInputView: View { } private extension TraineeInvitationCodeInputView { + @ViewBuilder + /// 진입 시 트레이너 초대 코드 권유 팝업 + private func TrainerInvitePopup() -> TPopUpAlertView { + TPopUpAlertView( + alertState: .init( + title: "트레이너에게 받은\n초대 코드를 입력해보세요!", + message: "트레이너와 연결하지 않을 경우\n일부 기능이 제한될 수 있어요.", + buttons: [ + .init( + title: "다음에 할게요", + style: .secondary, + action: .init(action: { + send(.tapInvitePopupNextButton) + }) + ), + .init( + title: "확인", + style: .primary, + action: .init(action: { + send(.tapInvitePopupConfirmButton) + }) + ) + ] + ) + ) + } - struct PopUpView: View { - let secondaryAction: () -> Void - let primaryAction: () -> Void - - init( - secondaryAction: @escaping () -> Void, - primaryAction: @escaping () -> Void - ) { - self.secondaryAction = secondaryAction - self.primaryAction = primaryAction - } - - var body: some View { - TPopUpAlertView( - alertState: .init( - title: "트레이너에게 받은\n초대 코드를 입력해보세요!", - message: "트레이너와 연결하지 않을 경우\n일부 기능이 제한될 수 있어요.", - buttons: [ - .init( - title: "다음에 할게요", - style: .secondary, - action: .init(action: { - secondaryAction() - }) - ), - .init( - title: "확인", - style: .primary, - action: .init(action: { - primaryAction() - }) - ) - ] - ) + @ViewBuilder + /// 코드 인증 후 화면을 벗어나려는 경우 팝업 + private func DropAlertPopup() -> TPopUpAlertView { + TPopUpAlertView( + alertState: .init( + title: "트레이너 연결을 중단하시겠어요?", + message: "중단 시 연결을 처음부터 다시 설정해야 해요", + showAlertIcon: true, + buttons: [ + .init( + title: "중단하기", + style: .secondary, + action: .init(action: { + send(.tapDropAlertStopButton) + }) + ), + .init( + title: "계속 진행", + style: .primary, + action: .init(action: { + send(.tapDropAlertKeepButton) + }) + ) + ] ) - } + ) } } From 3649e217fd202df800bc26e5d3fb378c5e0bff7c Mon Sep 17 00:00:00 2001 From: Minseo Park <125115284+FpRaArNkK@users.noreply.github.com> Date: Tue, 4 Feb 2025 00:01:00 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[Feat]=20TraineeInvitationCodeInput=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TraineeInvitationCodeInputFeature.swift | 101 +++++++++++++----- .../TraineeInvitationCodeInputView.swift | 89 +++++---------- 2 files changed, 102 insertions(+), 88 deletions(-) diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputFeature.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputFeature.swift index 90d34bdc..087ba647 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputFeature.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputFeature.swift @@ -21,8 +21,6 @@ public struct TraineeInvitationCodeInputFeature { // MARK: Data related state /// 입력된 초대코드 var invitationCode: String - /// 표시되는 팝업 - var presentPopUp: PopUp? // MARK: UI related state /// 텍스트 필드 상태 (빈 값 / 입력됨 / 유효하지 않음) @@ -35,6 +33,8 @@ public struct TraineeInvitationCodeInputFeature { var view_isVerityButtonEnabled: Bool /// 다음 버튼 활성화 여부 var view_isNextButtonEnabled: Bool + /// 표시되는 팝업 + var view_popUp: PopUp? /// 팝업 표시 여부 var view_isPopupPresented: Bool /// 상단 네비바 표시 상태 @@ -44,27 +44,27 @@ public struct TraineeInvitationCodeInputFeature { /// `TraineeInvitationCodeInputFeature.State`의 생성자 /// - Parameters: /// - invitationCode: 사용자가 입력한 초대 코드 (기본값: `""`) - /// - presentPopUp: 현재 표시되는 팝업 (기본값: `nil`) /// - view_invitationCodeStatus: 텍스트 필드 상태 (`.empty`, `.valid`, `.invalid` 등) /// - view_textFieldFooterText: 텍스트 필드 하단에 표시될 메시지 (기본값: `""`) /// - view_isFieldFocused: 현재 텍스트 필드가 포커스를 받고 있는지 여부 (기본값: `false`) /// - view_isVerityButtonEnabled: "인증하기" 버튼 활성화 여부 (기본값: `false`) /// - view_isNextButtonEnabled: "다음" 버튼 활성화 여부 (기본값: `false`) + /// - view_popUp: 현재 표시되는 팝업 (기본값: `nil`) /// - view_isPopupPresented: 팝업이 표시 중인지 여부 (기본값: `false`) /// - view_navigationType: 현재 화면의 네비게이션 타입 (`.newUser`: "건너뛰기" 버튼 있음, `.existingUser`: "뒤로가기" 버튼 있음) public init( invitationCode: String = "", - presentPopUp: PopUp? = nil, view_invitationCodeStatus: TTextField.Status = .empty, view_textFieldFooterText: String = "", view_isFieldFocused: Bool = false, view_isVerityButtonEnabled: Bool = false, view_isNextButtonEnabled: Bool = false, + view_popUp: PopUp? = nil, view_isPopupPresented: Bool = false, view_navigationType: NavigationType = .newUser ) { self.invitationCode = invitationCode - self.presentPopUp = presentPopUp + self.view_popUp = view_popUp self.view_invitationCodeStatus = view_invitationCodeStatus self.view_textFieldFooterText = view_textFieldFooterText self.view_isFieldFocused = view_isFieldFocused @@ -99,14 +99,11 @@ public struct TraineeInvitationCodeInputFeature { case tapNavBarSkipButton /// Nav바 back 버튼이 눌렸을 때 case tapNavBarBackButton - /// 팝업 "다음에 할게요" 버튼이 눌렸을 때 - case tapInvitePopupNextButton - /// 팝업 "확인" 버튼이 눌렸을 때 - case tapInvitePopupConfirmButton - /// 팝업 "중단하기" 버튼이 눌렸을 때 - case tapDropAlertStopButton - /// 팝업 "계속 진행" 버튼이 눌렸을 때 - case tapDropAlertKeepButton + + /// 팝업 좌측 secondary 버튼 탭 + case tapPopUpSecondaryButton(popUp: PopUp?) + /// 팝업 우측 primary 버튼 탭 + case tapPopUpPrimaryButton(popUp: PopUp?) } } @@ -143,11 +140,11 @@ public struct TraineeInvitationCodeInputFeature { return state.view_invitationCodeStatus == .valid ? self.setPopUpStatus(&state, status: .dropAlert) : .send(.setNavigating(.traineeHome)) - - case .tapInvitePopupNextButton, .tapDropAlertStopButton: + + case .tapPopUpSecondaryButton(let popUp): return .send(.setNavigating(.traineeHome)) - case .tapDropAlertKeepButton, .tapInvitePopupConfirmButton: + case .tapPopUpPrimaryButton(let popUp): return self.setPopUpStatus(&state, status: nil) } @@ -188,8 +185,9 @@ private extension TraineeInvitationCodeInputFeature { } /// 팝업 상태, 표시 상태를 업데이트 + /// status nil 입력인 경우 팝업 표시 해제 func setPopUpStatus(_ state: inout State, status: PopUp?) -> Effect { - state.presentPopUp = status + state.view_popUp = status state.view_isPopupPresented = status != nil return .none } @@ -202,14 +200,6 @@ public extension TraineeInvitationCodeInputFeature { case trainingInfoInput } - /// 본 화면에 팝업으로 표시되는 목록 - enum PopUp: Sendable { - /// 진입 시 초대 코드를 입력해주세요 - case invitePopUp - /// 연결을 중단하시겠어요? - case dropAlert - } - /// 본 화면의 네비게이션 타입 enum NavigationType: Equatable { /// 신규 유저 (우측 건너뛰기 버튼) @@ -217,4 +207,65 @@ public extension TraineeInvitationCodeInputFeature { /// 기존 유저 (좌측 뒤로가기 버튼) case existingUser } + + /// 본 화면에 팝업으로 표시되는 목록 + enum PopUp: Equatable, Sendable { + /// 진입 시 초대 코드를 입력해주세요 + case invitePopUp + /// 연결을 중단하시겠어요? + case dropAlert + + var title: String { + switch self { + case .invitePopUp: + return "트레이너에게 받은\n초대 코드를 입력해보세요!" + case .dropAlert: + return "트레이너 연결을 중단하시겠어요?" + } + } + + var message: String { + switch self { + case .invitePopUp: + return "트레이너와 연결하지 않을 경우\n일부 기능이 제한될 수 있어요." + case .dropAlert: + return "중단 시 연결을 처음부터 다시 설정해야 해요" + } + } + + var showAlertIcon: Bool { + switch self { + case .invitePopUp: + return false + case .dropAlert: + return true + } + } + + var secondaryButtonTitle: String { + switch self { + case .invitePopUp: + return "다음에 할게요" + case .dropAlert: + return "중단하기" + } + } + + var primaryButtonTitle: String { + switch self { + case .invitePopUp: + return "확인" + case .dropAlert: + return "계속 진행" + } + } + + var secondaryAction: Action.View { + return .tapPopUpSecondaryButton(popUp: self) + } + + var primaryAction: Action.View { + return .tapPopUpPrimaryButton(popUp: self) + } + } } diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputView.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputView.swift index 91f7c582..1986a047 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputView.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputView.swift @@ -55,16 +55,7 @@ public struct TraineeInvitationCodeInputView: View { } } .tPopUp(isPresented: $store.view_isPopupPresented) { - guard let popUp = store.presentPopUp else { - return TPopUpAlertView(alertState: .init(title: "Error")) - } - switch popUp { - case .invitePopUp: - return TrainerInvitePopup() - - case .dropAlert: - return DropAlertPopup() - } + PopUpView() } } @@ -130,61 +121,33 @@ public struct TraineeInvitationCodeInputView: View { .padding(.horizontal, 20) } } -} - -private extension TraineeInvitationCodeInputView { - @ViewBuilder - /// 진입 시 트레이너 초대 코드 권유 팝업 - private func TrainerInvitePopup() -> TPopUpAlertView { - TPopUpAlertView( - alertState: .init( - title: "트레이너에게 받은\n초대 코드를 입력해보세요!", - message: "트레이너와 연결하지 않을 경우\n일부 기능이 제한될 수 있어요.", - buttons: [ - .init( - title: "다음에 할게요", - style: .secondary, - action: .init(action: { - send(.tapInvitePopupNextButton) - }) - ), - .init( - title: "확인", - style: .primary, - action: .init(action: { - send(.tapInvitePopupConfirmButton) - }) - ) - ] - ) - ) - } @ViewBuilder - /// 코드 인증 후 화면을 벗어나려는 경우 팝업 - private func DropAlertPopup() -> TPopUpAlertView { - TPopUpAlertView( - alertState: .init( - title: "트레이너 연결을 중단하시겠어요?", - message: "중단 시 연결을 처음부터 다시 설정해야 해요", - showAlertIcon: true, - buttons: [ - .init( - title: "중단하기", - style: .secondary, - action: .init(action: { - send(.tapDropAlertStopButton) - }) - ), - .init( - title: "계속 진행", - style: .primary, - action: .init(action: { - send(.tapDropAlertKeepButton) - }) - ) - ] + private func PopUpView() -> some View { + if let popUp = store.view_popUp { + let buttons: [TPopupAlertState.ButtonState] = [ + .init( + title: popUp.secondaryButtonTitle, + style: .secondary, + action: .init(action: { send(popUp.secondaryAction) }) + ), + .init( + title: popUp.primaryButtonTitle, + style: .primary, + action: .init(action: { send(popUp.primaryAction) }) + ) + ] + + TPopUpAlertView( + alertState: .init( + title: popUp.title, + message: popUp.message, + showAlertIcon: popUp.showAlertIcon, + buttons: buttons + ) ) - ) + } else { + EmptyView() + } } } From e80ef9edea9e76607b4ad9826399656c6ba4ac95 Mon Sep 17 00:00:00 2001 From: Minseo Park <125115284+FpRaArNkK@users.noreply.github.com> Date: Tue, 4 Feb 2025 00:01:18 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[Feat]=20TraineeMyPage=20=ED=8C=9D=EC=97=85?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyPage/Trainee/TraineeMyPageFeature.swift | 160 ++++++++++++++++-- .../MyPage/Trainee/TraineeMyPageView.swift | 35 +++- 2 files changed, 177 insertions(+), 18 deletions(-) diff --git a/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageFeature.swift b/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageFeature.swift index 08c4fd21..3e49ad9e 100644 --- a/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageFeature.swift +++ b/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageFeature.swift @@ -28,25 +28,45 @@ public struct TraineeMyPageFeature { var appPushNotificationAllowed: Bool /// 버전 정보 var versionInfo: String - /// 트레이너 연결 여부 - var isTrainerConnected: Bool + /// 트레이너 이름 + var trainerName: String // MARK: UI related state + /// 트레이너 연결 여부 + var view_isTrainerConnected: Bool { + return !self.trainerName.isEmpty + } + /// 표시되는 팝업 + var view_popUp: PopUp? + /// 팝업 표시 여부 + var view_isPopUpPresented: Bool + /// `TraineeMyPageFeature.State`의 생성자 + /// - Parameters: + /// - userName: 사용자 이름 (기본값: `""`) + /// - userImageUrl: 사용자 프로필 이미지 URL (기본값: `nil`) + /// - appPushNotificationAllowed: 앱 푸시 알림 허용 여부 (기본값: `false`) + /// - versionInfo: 현재 앱 버전 정보 (기본값: `""`) + /// - trainerName: 트레이너 이름, 공백이 아닌 경우 연결된 것으로 표시(기본값: `""`) + /// - view_popUp: 현재 표시되는 팝업 (기본값: `nil`) + /// - view_isPopUpPresented: 팝업이 표시 중인지 여부 (기본값: `false`) public init( - userName: String, + userName: String = "", userImageUrl: String? = nil, - appPushNotificationAllowed: Bool, - versionInfo: String, - isTrainerConnected: Bool + appPushNotificationAllowed: Bool = false, + versionInfo: String = "", + trainerName: String = "", + view_popUp: PopUp? = nil, + view_isPopUpPresented: Bool = false ) { self.userName = userName self.userImageUrl = userImageUrl self.appPushNotificationAllowed = appPushNotificationAllowed self.versionInfo = versionInfo - self.isTrainerConnected = isTrainerConnected + self.trainerName = trainerName + self.view_popUp = view_popUp + self.view_isPopUpPresented = view_isPopUpPresented } - } @Dependency(\.userUseCase) private var userUseCase: UserUseCase @@ -77,6 +97,10 @@ public struct TraineeMyPageFeature { case tapLogoutButton /// 계정 탈퇴 버튼 탭 case tapWithdrawButton + /// 팝업 좌측 secondary 버튼 탭 + case tapPopUpSecondaryButton(popUp: PopUp?) + /// 팝업 우측 primary 버튼 탭 + case tapPopUpPrimaryButton(popUp: PopUp?) } } @@ -92,6 +116,7 @@ public struct TraineeMyPageFeature { case .binding(\.appPushNotificationAllowed): print("푸쉬알림 변경: \(state.appPushNotificationAllowed)") return .none + case .binding: return .none case .tapEditProfileButton: @@ -115,16 +140,27 @@ public struct TraineeMyPageFeature { return .none case .tapDisconnectTrainerButton: - print("tapDisconnectTrainerButton") - return .none + return setPopUpStatus(&state, status: .disconnectTrainer(trainerName: state.trainerName)) case .tapLogoutButton: - print("tapLogoutButton") - return .none + return setPopUpStatus(&state, status: .logout) case .tapWithdrawButton: - print("tapWithdrawButton") - return .none + return setPopUpStatus(&state, status: .withdraw) + + case .tapPopUpSecondaryButton(let popUp): + guard popUp != nil else { return .none } + return setPopUpStatus(&state, status: nil) + + case .tapPopUpPrimaryButton(let popUp): + guard let popUp else { return .none } + switch popUp { + case .disconnectTrainer, .logout, .withdraw: + return setPopUpStatus(&state, status: popUp.nextPopUp) + + case .disconnectCompleted, .logoutCompleted, .withdrawCompleted: + return setPopUpStatus(&state, status: nil) + } } case .setNavigating: @@ -133,3 +169,99 @@ public struct TraineeMyPageFeature { } } } + +// MARK: Internal Logic +private extension TraineeMyPageFeature { + /// 팝업 상태, 표시 상태를 업데이트 + /// status nil 입력인 경우 팝업 표시 해제 + func setPopUpStatus(_ state: inout State, status: PopUp?) -> Effect { + state.view_popUp = status + state.view_isPopUpPresented = status != nil + return .none + } +} + +public extension TraineeMyPageFeature { + /// 본 화면에 팝업으로 표시되는 목록 + enum PopUp: Equatable, Sendable { + /// 트레이너와 연결을 해제할까요? + case disconnectTrainer(trainerName: String) + /// 트레이너와 연결이 해제되었어요 + case disconnectCompleted(trainerName: String) + /// 현재 계정을 로그아웃 할까요? + case logout + /// 로그아웃이 완료되었어요 + case logoutCompleted + /// 계정을 탈퇴할까요? + case withdraw + /// 계정 탈퇴가 완료되었어요 + case withdrawCompleted + + var nextPopUp: PopUp? { + switch self { + case .disconnectTrainer(let name): + return .disconnectCompleted(trainerName: name) + case .logout: + return .logoutCompleted + case .withdraw: + return .withdrawCompleted + case .disconnectCompleted, .logoutCompleted, .withdrawCompleted: + return nil + } + } + + var title: String { + switch self { + case .disconnectTrainer(let name): + return "\(name) 트레이너와 연결을 해제할까요?" + case .disconnectCompleted(let name): + return "\(name) 트레이너와 연결이 해제되었어요" + case .logout: + return "현재 계정을 로그아웃 할까요?" + case .logoutCompleted: + return "로그아웃이 완료되었어요" + case .withdraw: + return "계정을 탈퇴할까요?" + case .withdrawCompleted: + return "계정 탈퇴가 완료되었어요" + } + } + + var message: String { + switch self { + case .disconnectTrainer(let name): + return "힘께 나눴던 기록들이 사라져요" + case .disconnectCompleted(let name): + return "더 폭발적인 케미로 다시 만나요!" + case .logout, .logoutCompleted: + return "언제든지 다시 로그인 할 수 있어요!" + case .withdraw: + return "운동 및 식단 기록에 대한 데이터가 사라져요!" + case .withdrawCompleted: + return "다음에 더 폭발적인 케미로 다시 만나요! 💣" + } + } + + var showAlertIcon: Bool { + switch self { + case .disconnectTrainer, .logout, .withdraw: + return true + case .disconnectCompleted, .logoutCompleted, .withdrawCompleted: + return false + } + } + + var secondaryAction: Action.View? { + switch self { + case .disconnectTrainer, .logout, .withdraw: + return .tapPopUpSecondaryButton(popUp: self) + case .disconnectCompleted, .logoutCompleted, .withdrawCompleted: + return nil + } + } + + var primaryAction: Action.View { + return .tapPopUpPrimaryButton(popUp: self) + } + } +} diff --git a/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageView.swift b/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageView.swift index 0d8747f5..6b8f1797 100644 --- a/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageView.swift +++ b/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageView.swift @@ -36,6 +36,9 @@ public struct TraineeMyPageView: View { } .background(Color.neutral50) .navigationBarBackButtonHidden() + .tPopUp(isPresented: $store.view_isPopUpPresented) { + PopUpView() + } } // MARK: - Sections @@ -47,9 +50,9 @@ public struct TraineeMyPageView: View { Text(store.userName) .typographyStyle(.heading2, with: .neutral950) - .padding(.bottom, store.isTrainerConnected ? 8 : 16) + .padding(.bottom, store.view_isTrainerConnected ? 8 : 16) - if store.isTrainerConnected { + if store.view_isTrainerConnected { TButton( title: "개인정보 수정", config: .small, @@ -64,7 +67,7 @@ public struct TraineeMyPageView: View { @ViewBuilder private func TopItemSection() -> some View { VStack(spacing: 12) { - if !store.isTrainerConnected { + if !store.view_isTrainerConnected { ProfileItemView(title: "트레이너 연결하기", tapAction: { send(.tapConnectTrainerButton) }) .padding(.vertical, 4) .background(Color.common0) @@ -100,7 +103,7 @@ public struct TraineeMyPageView: View { @ViewBuilder private func BottomItemSection() -> some View { VStack(spacing: 12) { - if store.isTrainerConnected { + if store.view_isTrainerConnected { ProfileItemView(title: "트레이너와 연결끊기", tapAction: { send(.tapDisconnectTrainerButton) }) .padding(.vertical, 4) .background(Color.common0) @@ -116,6 +119,30 @@ public struct TraineeMyPageView: View { .clipShape(.rect(cornerRadius: 12)) } } + + @ViewBuilder + private func PopUpView() -> some View { + if let popUp = store.view_popUp { + // secondaryAction nil 인 경우 제외하고 버튼 배열 구성 + let buttons: [TPopupAlertState.ButtonState] = [ + popUp.secondaryAction.map { action in + .init(title: "취소", style: .secondary, action: .init(action: { send(action) })) + }, + .init(title: "확인", style: .primary, action: .init(action: { send(popUp.primaryAction) })) + ].compactMap { $0 } + + TPopUpAlertView( + alertState: .init( + title: popUp.title, + message: popUp.message, + showAlertIcon: popUp.showAlertIcon, + buttons: buttons + ) + ) + } else { + EmptyView() + } + } } private extension TraineeMyPageView {