Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TNT-142] 트레이너/트레이니 선택 화면, 프로필 만들기 화면 작성 #24

Merged
merged 16 commits into from
Jan 23, 2025

Conversation

FpRaArNkK
Copy link
Contributor

@FpRaArNkK FpRaArNkK commented Jan 17, 2025

📌 What is the PR?

  • 온보딩 화면 중 트레이너/트레이니 선택 화면, 프로필 만들기 화면을 작성했습니다.

🪄 Changes

  • 할당 화면 중 트레이너/트레이니 선택 화면, 프로필 만들기 화면을 작성했습니다.
  • 작성에 필요한 Entity와 Policy, Utility를 작성했습니다.

🌐 Common Changes

  • 트레이너 / 트레이니 기본 이미지 에셋을 추가했습니다.
  • Domain에 Entity: UserType, Policy: UserPoilcy, Utility: TextVaildator 를 작성했습니다.

🔥 PR Point

  1. Reducer를 갖는 화면을 작성했습니다.
    각 화면에 해당하는 Store를 가지며, @ViewAction을 연결하여 ViewAction으로 선언된 Action만 send 가능합니다
    위부터 MainView, SubView, Component 순서로 구성했습니다.
    @bindable을 추가하여, 기존 SwiftUI 처럼 화면 작성이 가능합니다. 로직 처리는 Reducer에서 담당합니다.

  2. 각 화면의 Reducer를 작성했습니다.
    State는 비즈니스 로직 수행에 필요한 데이터와 View 화면 표시에 필요한 ViewState를 분리했습니다.
    ViewState는 State 내부에서 Equtable로 포함되며, UI 표시를 반영합니다.

  • Binding 관련, 프로퍼티 감시 관련 문제로 ViewState 사용은 잠정 중단되었습니다. 트러블 슈팅 링크
  • 모든 데이터를 State에서 관리하되, UI에 종속된 값들은 view_ prefix를 붙여 구현합니다.
  • State 중 단순 계산이 필요한 내용은 계산 속성으로 구현했습니다.
  • Action은 비즈니스 로직을 수행하는 Action과 View 인터랙션에서 파생되는 ViewAction를 분리했습니다.
  • ViewAction은 TCA에서 제공하는 ViewAction을 사용하며, 화면의 @ViewAction과 연결됩니다.
  • Action에서 별도의 send를 사용해야 하나, send가 필수적이지 않은 내용은 extension으로 내부 함수로 구현했습니다.
  • BindableAction과 BindingReducer를 추가했습니다. 화면에 연결된 Binding 값을 감시하며 action 호출이 가능합니다.
    전체적인 구조는 아래와 같습니다.
@Reducer
public struct CreateProfileFeature {
    
    @ObservableState
    public struct State: Equatable  {
        // MARK: Data related state
        var userType: UserType
        ...
        // MARK: UI related state
        var textFieldStatus: TTextField.Status
        ...
        var showFooterText: Bool {
            return textFieldStatus == .invalid
        }
    }
    
    public enum Action: Sendable, ViewAction {
        case setNavigating(Bool)
        ...
        case view(ViewAction)
        
        @CasePathable
        public enum View: Sendable, BindableAction {
            case binding(BindingAction<State>)
            case tapWriteButton
            ...
        }
    }
    
    public init() {}
    
    public var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .view(let action):
                switch action {
                case .binding(\.userName):
                    return self.validate(&state)
                  ...
                }
                
            case .imagePicked(let imgData):
                return .none
             ...
            }
        }
    }
}

// MARK: Internal Logic
private extension CreateProfileFeature {
    private func validate(_ state: inout State) -> Effect<Action> { ... }
}

📸 Screenshot

기능 스크린샷
GIF

🙆🏻 To Reviewers

  • 네비게이션 관련해서는 추후 화면 작업 후에 한 번에 진행하려고 합니다..! 현재는 임시로 연결되어있다는 점 양해 부탁드립니다
    • TCA에서 제공하는 NavigationStack - StackState 활용 예정입니다
  • ViewAction과 같이 ViewState로 나눠 관리해보려 하는데 혹여 모르는 리스크가 있을 수 있어 확인 부탁드립니다!
  • 같이 봐주셔서 감사합니다... 🥹🥹

💭 Related Issues

관련 문서

@FpRaArNkK FpRaArNkK added the ✨Feat 새로운 기능 구현 (새로운 로직 추가, UI 구현 등) label Jan 17, 2025
@FpRaArNkK FpRaArNkK self-assigned this Jan 17, 2025
@stealmh
Copy link
Member

stealmh commented Jan 17, 2025

고생많으셨습니다~ 천천히 살펴볼게요!

Comment on lines 2 to 9
// CreateProfileFeature.swift
// Presentation
//
// Created by 박민서 on 1/17/25.
// Copyright © 2025 yapp25thTeamTnT. All rights reserved.
//

import Foundation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

얘 파일이름 똑같은애가 2개가 있는데 컴파일러가 안잡아줬나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엇.. 브랜치를 바꾸면서 작업하다보니 놓쳤던 것 같습니다..! 수정하겠습니다 감사합니다!

Comment on lines +9 to +15
public struct UserPolicy {
/// 사용자 이름 최대 길이 제한 (공백 포함)
public static let maxNameLength: Int = 15

/// 사용자 이름 가능 문자: 한글/영어/공백만 허용 (특수문자 불가)
public static let allowedCharactersRegex = "^[ㄱ-ㅎㅏ-ㅣ가-힣a-zA-Z ]*$"
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

구조체로 만드신 이유가 궁금합니다!

Copy link
Contributor Author

@FpRaArNkK FpRaArNkK Jan 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정책 관련 특정 값들을 저장해놓는 것을 요구 사항으로 생각하고, enun과 struct 중에 고민했었습니다.

  1. enum의 경우애는 enum-case + 연관값으로 각 케이스에 해당하는 함수 결과값을 끌어오거나, 필요한 경우 해당 내용을 가져오는 방향으로 생각했습니다.
  2. struct의 경우에는 본 코드와 같이 static으로 선언한 해당 내용을 바로 끌어오고, 필요한 경우 해당 케이스의 함수 결과값을 가져오는 방향으로 생각했습니다.

추후 추가/수정 시에도 - 정책 내부에 별도의 구조체를 갖거나, 중첩 타입이 있을 경우 enum보다는 struct가 더 자연스럽지 않을까
.. 싶은 이유에서 struct로 작성했습니다!

}

/// UI 관련 상태를 관리하는 구조체입니다.
public struct ViewState: Equatable {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ViewState를 만드셨군요! State에 있는 값들과 어떠한 차이가 있나요? 애매모호한 중간선에 있는 애들이 분명 있어보여서요

Copy link
Contributor Author

@FpRaArNkK FpRaArNkK Jan 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

본 PR 코드에서는 두드러지지는 않지만,
1차 스프린트 분량의 전체적인 화면 분석을 진행하며 리듀서에서 관리해야하는 State의 내용이 많았습니다.

State를 유지보수해야하는 관점에 보았을 때 - 컴포넌트 visible값, UI 컬러 등 비즈니스 로직 데이터가 섞여있는 경우 작업에 차질이 생길 것 같다 판단했습니다.

따라서 State를 크게 (비즈니스 로직 관련 데이터 / UI 관련 데이터) 으로 나누었는데,
ViewState로 분류한 선제적인 기준은
<본 화면 상에서만 존재하나 Testable한 값이어야 하는 경우>로 생각했습니다.

언제까지나 선제적인 조치여서 예외적인 케이스가 생길 수 있을 것 같은데.. 작업해가며 개선해보려 합니다..!

우려되는 부분이 있으시다면 편하게 말씀 부탁드립니다!

/// 텍스트 필드 상태 (빈 값 / 입력됨 / 유효하지 않음)
var textFieldStatus: TTextField.Status
/// 포토 피커 표시 여부
var showPhotoPicker: Bool
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

하단 변수들이 is라는 prefix를 가지고 있으니 isPhotoPickerPresented 라는 이름이라던가 등등 어떨까요??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 너무 좋습니다 감사합니다!

/// 뷰에서 발생한 액션을 처리합니다.
case view(ViewAction)

public enum ViewAction {
Copy link
Member

@stealmh stealmh Jan 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TCA의 ViewAction protocol 과 이름이 동일해요

Copy link
Contributor Author

@FpRaArNkK FpRaArNkK Jan 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엇 TCA에서 제공하는 ViewAction 프로토콜을 Action애서 준수한 내용입니다!
해당 Action을 정의하라고 나와있어 저렇게 정의했는데, 다른 방법이 있을까요?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

@FpRaArNkK FpRaArNkK Jan 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아..! 좋네요.. Action.View로 접근하는 내용이 좋은 것 같습니다!
State도 같은 형식으로 State.View로 접근하는 방향으로 변경해보겠습니다.

ViewState의 경우 View에서 접근할 때 store.viewState.isPhotoPickerPresented 와 같은 방식으로 접근해서,
외부의 관점에서 store.view 로 보게되어 자칫 뷰 객체로 볼 수 있겠다는 판단이 들어 ViewState로 유지하려 합니다!

Comment on lines 158 to 160
private extension CreateProfileFeature {
/// 사용자 입력값을 검증하고 상태를 업데이트합니다.
private func validate(_ state: inout State) -> Effect<Action> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate는 이미 private해요

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그 외 로직을 함수로 분리하는건 정말 좋은 습관입니다! 😃
액션이 생각보다 많은 비용이 드는 작업이에요! 메서드를 실행하는 것처럼 간단하지 않습니다.
잘하셨어요👍👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

감사합니다! TCA Performance 문서를 참고하면서 최대한 반영해보려고 하고 있습니다.
private*2는 호딱 수정하겠습니다!

Comment on lines 9 to 14
import SwiftUI
import ComposableArchitecture
import PhotosUI

import Domain
import DesignSystem
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기두 컨벤션 있을까여? 없으면 정해주시고 있다면 수정해주세요

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

빠르게 정해보겠습니다 감사합니다!

Comment on lines +22 to +26
/// `CreateProfileView`의 생성자
/// - Parameter store: `CreateProfileFeature`의 상태를 관리하는 `Store`
public init(store: StoreOf<CreateProfileFeature>) {
self.store = store
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

store만 주입받으실거면 없애셔도 됩니다

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

감사합니다!!

}

public var body: some View {
VStack {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

얘도 의도한건지는 모르겠는데 spacing 보셨는지 확인해주세욥

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 부분이 디자인 파트 쪽에서 제대로 반영되지 않아서.. 임시용입니다!
추후 오토레이아웃 디자인 확정 시 수정 예정입니다!

Comment on lines 83 to 87
selection: Binding(get: {
store.viewState.photoPickerItem
}, set: {
send(.tapImageInPicker($0))
}),
Copy link
Member

@stealmh stealmh Jan 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BindingReducer로 적용해보세요. 민서님이 작성한 코드 Binding(get: set:) 모두!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 감사합니다! 관련 로직들 BindingReducer로 리팩토링해보겠습니다 :)


public enum Action: ViewAction {
/// 네비게이션 여부 설정
case setNavigating(Bool)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isEnableNavigate 같은 이름 어떠신가요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redcuer에서 수행하는 Action이라서, 최대한 동사로 사용하려고 했습니다!
view에 연결되는 State의 경우 추천해주신 네이밍을 사용하고자 하는데 어떨까요..?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗그러네요! 지금 네이밍 하셔도될듯 :)

Comment on lines 75 to 81
if store.userType == .trainer {
Image(.imgOnboardingTrainer)
.resizable()
} else {
Image(.imgOnboardingTrainee)
.resizable()
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let userType = store.userType

Image(
    userType == .trainer 
    ? .imgOnboardingTrainer 
    : .imgOnboardingTrainee
)

저라면 이렇게 사용할겁니다! 타입빼곤 모두 동일하니까요

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 그렇네요..! 짚어주셔서 감사합니다! :)

Comment on lines +89 to +98
TempButton(
isSelected: store.userType == UserType.trainer,
title: UserType.trainer.koreanName,
action: { send(.tapUserTypeButton(.trainer), animation: .easeInOut(duration: 0.5)) }
)
TempButton(
isSelected: store.userType == UserType.trainee,
title: UserType.trainee.koreanName,
action: { send(.tapUserTypeButton(.trainee), animation: .easeInOut(duration: 0.5)) }
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

얘도 위에 수정한거처럼 바꿀 수 있어보이죠??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 맞습니다!

struct TempButton: View {
var isSelected: Bool = false
var title: String
let action: (() -> Void)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

() -> Void
어디는 var action인데가 있더라구요 모두 동일하게 let action: () -> Void로 바꿔주세요

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞네요 감사합니다! 의도한 경우가 아니라면 let 작성으로 통일하겠습니다!

Copy link
Member

@stealmh stealmh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

궁금하거나 수정이 필요한부분들 남겨뒀습니다~!
항상 approve로 해둘 예정이지만 수정해주세요 :)

Copy link
Member

@syss220211 syss220211 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TempButton 모두 수정해주셔도 될것 같습니다!
고생하셨습니다.

@FpRaArNkK FpRaArNkK merged commit fe4c1f0 into develop Jan 23, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
✨Feat 새로운 기능 구현 (새로운 로직 추가, UI 구현 등)
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants