Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,139 @@ LogMacro: 1.1.1 // 로깅 매크로
- Computed Properties + @ViewBuilder 조합
- 조건부 렌더링 및 Skeleton 패턴

#### 📐 View 분할 — extension + private func 패턴 (필수)

View `body` 는 최상위 레이아웃 (`ZStack` / `VStack`) 만 두고, 모든 하위 영역은 같은 파일의 `extension View {}` 안에 `private func sectionName() -> some View` (또는 `private var sectionName: some View`) 로 분리합니다.

`LoginView` / `OnBoardingView` 가 정착된 레퍼런스입니다.

```swift
// ✅ 올바른 패턴 — LoginView 와 동일하게 extension 분리
public struct OnBoardingView: View {
@Bindable var store: StoreOf<OnBoardingFeature>

public var body: some View {
ZStack {
Color.bgSubtle.edgesIgnoringSafeArea(.all)
VStack(spacing: 0) {
topSection()
.frame(maxHeight: .infinity)
bottomSection()
.padding(.horizontal, 16)
.padding(.bottom, 40)
}
}
}
}

extension OnBoardingView {
private func topSection() -> some View {
TabView(selection: $store.currentIndex) { /* ... */ }
}

private func bottomSection() -> some View {
VStack(spacing: 24) {
OnBoardingPageIndicator(/* ... */)
CustomButton(/* ... */)
}
}
}

// ❌ 금지 — body 안에 모든 레이아웃을 inline 으로 작성하지 말 것
public var body: some View {
VStack {
TabView { /* ... */ }
VStack { /* indicator + button */ }
}
}
```

규칙:
- `body` 는 호출자만, 실제 레이아웃은 `extension` 안으로
- 메서드 이름은 의도가 드러나는 명사형 (`topSection`, `bottomSection`, `loginSNSButtonText`, `logoView`)
- 한 메서드 안에서 다시 큰 블록이 생기면 더 작게 쪼개기 (재귀 적용)
- 공통 컴포넌트는 별도 파일 (`Components/*.swift`) 로 추출

#### 🔤 폰트 — `.font(.system(...))` 금지, Pretendard 토큰 사용

```swift
// ✅ 디자인 시스템 토큰이 있는 경우 (16/14/12 등)
Text("시작하기")
.pretendardCustomFont(textStyle: .headingMedium)

// ✅ 토큰에 없는 임의 크기 (24, 15 등 Figma 스펙 그대로)
Text(page.title)
.pretendardFont(family: .SemiBold, size: 24)

Text(page.subtitle)
.pretendardFont(family: .Medium, size: 15)

// ❌ 금지 — 시스템 폰트 직접 사용
.font(.system(size: 24, weight: .semibold))
```

#### 🎨 컬러 — `.foregroundStyle(.neutral900)` 단축형 사용

```swift
// ✅ 컨텍스트 추론 가능한 위치는 점 단축형
Text(...)
.foregroundStyle(.neutral900)
Color.bgSubtle.edgesIgnoringSafeArea(.all)

// ❌ 금지 — 매번 Color 타입 명시
.foregroundStyle(Color.neutral900)
```

`SwiftUI.Color` 의 정적 멤버로 디자인 토큰 (`neutral50` … `neutral900`, `primary50` … `primary900`, `secondary50` …, `bgSubtle`) 이 등록되어 있어 `.foregroundStyle / .fill / .background / .tint` 등에서 모두 점 단축형 사용 가능.

#### 🖼 이미지 — `Image(asset: .xxx)` + 데이터 모델은 `ImageAsset` 타입

```swift
// ✅ 데이터 모델이 String 이 아닌 ImageAsset 을 보유
public struct Page: Equatable, Identifiable {
public let imageAsset: ImageAsset
}

// View 에서는 단축 init 만 사용
Image(asset: page.imageAsset)
.resizable()
.scaledToFit()

// ❌ 금지 — rawValue 문자열 / bundle 명시
Image(page.imageName, bundle: .module)
Image(ImageAsset.onboarding1.rawValue)
```

이미지 케이스가 추가되면 반드시:
1. `Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/<카테고리>/<name>.imageset/` 폴더 + `Contents.json` 추가
2. `ImageAsset` enum 에 `case <name>` 추가 (raw value = imageset 폴더명과 동일)
3. `tuist generate` 로 리소스 재인덱싱

#### 🧮 텍스트 / 라벨 — `body` 안에 인라인 표현 금지, State computed 로

표시용 파생값은 View 가 아니라 `State` 의 computed property 로 정의해서 View 에서는 그대로 꺼내기만 한다.

```swift
// ✅ State 가 자기 자신을 설명
@ObservableState
public struct State: Equatable {
public var currentIndex: Int
public var isLastPage: Bool { currentIndex >= pageCount - 1 }
public var primaryButtonTitle: String { isLastPage ? "시작하기" : "다음" }
}

// View
CustomButton(
title: store.primaryButtonTitle,
...
)

// ❌ 금지 — View 안에서 store 상태를 다시 가공
private var primaryButtonTitle: String {
store.isLastPage ? "시작하기" : "다음"
}
```

### 📏 Swift 코딩 규칙 (`docs/agent/swift-coding-rules.md`)
- Swift 스타일 가이드
- 에러 처리 패턴
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ public final class AuthRepositoryImpl: AuthInterface, @unchecked Sendable {
// MARK: - 회원 탈퇴

public func withDraw(token: String) async throws -> WithdrawEntity {
let response = try await provider.requestResponse(.withdraw(token: token))
let response = try await authProvider.requestResponse(.withdraw(token: token))
let decoder = JSONDecoder()

if (200 ... 299).contains(response.statusCode) {
Expand Down
4 changes: 3 additions & 1 deletion Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ public struct AuthUseCaseImpl: AuthInterface {

public func withDraw(token: String) async throws -> WithdrawEntity {
let result = try await authRepository.withDraw(token: token)
keychainManager.clear()
if result.withdrawn {
keychainManager.clear()
}
return result
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@ import Entity
@FlowCoordinator(screen: "AuthScreen", navigation: true)
public struct AuthCoordinator {
public init() {}

@ObservableState
public struct State: Equatable {
var routes: [Route<AuthScreen.State>]

public init() {
@Shared(.inMemory("UserSession")) var userSession: UserSession = .empty
self.routes = [.root(.login(.init(userSession: userSession)), embedInNavigationView: true)]
routes = [.root(.login(.init(userSession: userSession)), embedInNavigationView: true)]
}
}

@CasePathable
public enum Action {
case router(IndexedRouterActionOf<AuthScreen>)
Expand All @@ -34,54 +34,49 @@ public struct AuthCoordinator {
case inner(InnerAction)
case navigation(NavigationAction)
}

// MARK: - ViewAction

@CasePathable
public enum View {
case backAction
case backToRootAction
}

// MARK: - AsyncAction 비동기 처리 액션

public enum AsyncAction: Equatable {

}


public enum AsyncAction: Equatable {}

// MARK: - 앱내에서 사용하는 액션

public enum InnerAction: Equatable {

}


public enum InnerAction: Equatable {}

// MARK: - NavigationAction

public enum NavigationAction: Equatable {

}


public enum NavigationAction: Equatable {}

func handleRoute(state: inout State, action: Action) -> Effect<Action> {
switch action {
case .router(let routeAction):
return routerAction(state: &state, action: routeAction)
case .view(let viewAction):
return handleViewAction(state: &state, action: viewAction)
case .async(let asyncAction):
return handleAsyncAction(state: &state, action: asyncAction)
case .inner(let innerAction):
return handleInnerAction(state: &state, action: innerAction)
case .navigation(let navigationAction):
return handleNavigationAction(state: &state, action: navigationAction)
case let .router(routeAction):
routerAction(state: &state, action: routeAction)

case let .view(viewAction):
handleViewAction(state: &state, action: viewAction)

case let .async(asyncAction):
handleAsyncAction(state: &state, action: asyncAction)

case let .inner(innerAction):
handleInnerAction(state: &state, action: innerAction)

case let .navigation(navigationAction):
handleNavigationAction(state: &state, action: navigationAction)
}
}
}

// MARK: - Effect Cancellation IDs

nonisolated enum AuthCancelID: Hashable {
case loginEffects
}
Expand All @@ -92,15 +87,23 @@ extension AuthCoordinator {
action: IndexedRouterActionOf<AuthScreen>
) -> Effect<Action> {
switch action {

// MARK: - 초대코드 입력


// MARK: - 로그인 성공 → 온보딩 화면 푸시

case .routeAction(_, action: .login(.delegate(.presentOnboarding))):
state.routes.push(.onboarding(.init()))
return .none

// MARK: - 온보딩 완료 → 루트로 (다음 플로우 연결 지점)

case .routeAction(_, action: .onboarding(.delegate(.finished))):
// TODO: 메인 탭으로 전환하는 NavigationAction 발송
return .none
Comment on lines +98 to +100
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Route onboarding completion to next flow

When the onboarding feature emits .delegate(.finished), the coordinator currently returns .none, so tapping the last-page CTA ("시작하기") has no effect. In the current auth flow this leaves users stuck on onboarding with no forward transition, even though the UI communicates completion; this should dispatch a concrete navigation action (or root transition) instead of a no-op.

Useful? React with 👍 / 👎.


default:
return .none
}
}

private func handleViewAction(
state: inout State,
action: View
Expand All @@ -109,43 +112,40 @@ extension AuthCoordinator {
case .backAction:
state.routes.goBack()
return .none

case .backToRootAction:
state.routes.goBackToRoot()
return .none
}
}

private func handleNavigationAction(
state: inout State,
state _: inout State,
action: NavigationAction
) -> Effect<Action> {
switch action {



}
switch action {}
}

private func handleAsyncAction(
state: inout State,
action: AsyncAction
state _: inout State,
action _: AsyncAction
) -> Effect<Action> {
return .none
.none
}

private func handleInnerAction(
state: inout State,
action: InnerAction
state _: inout State,
action _: InnerAction
) -> Effect<Action> {
return .none
.none
}
}

extension AuthCoordinator {
@Reducer
public enum AuthScreen {
case login(LoginFeature)
case onboarding(OnBoardingFeature)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,28 @@ import Foundation

import SwiftUI

import TCAFlow
import ComposableArchitecture

import TCAFlow

public struct AuthCoordinatorView: View {
@Bindable private var store: StoreOf<AuthCoordinator>

public init(
store: StoreOf<AuthCoordinator>
) {
self.store = store
}

public var body: some View {
TCAFlowRouter(store.scope(state: \.routes, action: \.router)) { screen in
switch screen.case {
case .login(let loginStore):
case let .login(loginStore):
LoginView(store: loginStore)
.navigationBarBackButtonHidden()


case let .onboarding(onboardingStore):
OnBoardingView(store: onboardingStore)
.navigationBarBackButtonHidden()
}
}
}
Expand Down
Loading
Loading