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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Data → Feature

```text
MoziApp → CompositionRoot → RootFeature → AppCoordinator
bootstrapping → main(Placeholder)
bootstrapping → login / onboarding / main
```

## 5. 핵심 규칙 요약
Expand Down
11 changes: 8 additions & 3 deletions Projects/Feature/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
- Root / AppCoordinator / Scene 구성

## 현재 상태
- Root + AppCoordinator + Placeholder scene
- 딥링크: `mozi://home`
- Root + AppCoordinator + Login / OnboardingPlaceholder / Placeholder scene
- restore 기반 로그인 게이트
- 딥링크: `mozi://home` (main 진입 후 처리)

## 의존
- 허용: Domain, SharedUtils, SharedDesignSystem, SharedLogger, ThirdParty, ThirdPartyUI
Expand All @@ -23,10 +24,14 @@
## 주요 진입점
- `RootFeature`, `RootView`
- `AppCoordinatorFeature`, `AppCoordinatorView`
- `Scene/Login`
- `Scene/OnboardingPlaceholder`
- `Scene/Placeholder`

## 테스트 포인트
- 부트스트랩 후 main placeholder 진입
- restore 분기: nil → login / profileCompleted false → onboarding / true → main
- LoginFeature 성공/실패/취소/중복 탭 방지
- 온보딩 placeholder 로그아웃 후 login 복귀
- 딥링크 파싱 (`mozi://home`, https home, unknown)

## 관련 문서
Expand Down
162 changes: 143 additions & 19 deletions Projects/Feature/Sources/AppCoordinator/AppCoordinatorFeature.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Domain
import Foundation
import ThirdParty

Expand All @@ -6,24 +7,53 @@ public struct AppCoordinatorFeature {
@ObservableState
public struct State: Equatable {
public var phase: Phase = .bootstrapping
public var isRestoringSession = false
public var pendingDeepLink: DeepLinkRoute?
public var overlay = OverlayFeature.State()

public init(
phase: Phase = .bootstrapping,
isRestoringSession: Bool = false,
pendingDeepLink: DeepLinkRoute? = nil,
overlay: OverlayFeature.State = OverlayFeature.State()
) {
self.phase = phase
self.isRestoringSession = isRestoringSession
self.pendingDeepLink = pendingDeepLink
self.overlay = overlay
}

public enum Phase: Equatable {
case bootstrapping
case login(LoginFeature.State)
case onboarding(OnboardingPlaceholderFeature.State)
case main(PlaceholderFeature.State)
}

public var login: LoginFeature.State? {
get {
guard case let .login(state) = phase else { return nil }
return state
}
set {
if let newValue {
phase = .login(newValue)
}
}
}

public var onboarding: OnboardingPlaceholderFeature.State? {
get {
guard case let .onboarding(state) = phase else { return nil }
return state
}
set {
if let newValue {
phase = .onboarding(newValue)
}
}
}

public var mainPlaceholder: PlaceholderFeature.State? {
get {
guard case let .main(state) = phase else { return nil }
Expand All @@ -39,20 +69,31 @@ public struct AppCoordinatorFeature {

public enum Action: Equatable {
case onAppear
case bootstrapResponse(Result<AuthSession?, AuthError>)
case deepLinkReceived(URL)
case routeDeepLink(DeepLinkRoute)
case flushPendingDeepLink
case login(LoginFeature.Action)
case onboarding(OnboardingPlaceholderFeature.Action)
case main(PlaceholderFeature.Action)
case overlay(OverlayFeature.Action)
}

@Dependency(\.authClient) var authClient

public init() {}

public var body: some ReducerOf<Self> {
Scope(state: \.overlay, action: \.overlay) {
OverlayFeature()
}
Reduce(core)
.ifLet(\.login, action: \.login) {
LoginFeature()
}
.ifLet(\.onboarding, action: \.onboarding) {
OnboardingPlaceholderFeature()
}
.ifLet(\.mainPlaceholder, action: \.main) {
PlaceholderFeature()
}
Expand All @@ -61,11 +102,10 @@ public struct AppCoordinatorFeature {
private func core(state: inout State, action: Action) -> Effect<Action> {
switch action {
case .onAppear:
guard case .bootstrapping = state.phase else {
return .none
}
state.phase = .main(PlaceholderFeature.State())
return .send(.flushPendingDeepLink)
return bootstrapIfNeeded(state: &state)

case let .bootstrapResponse(result):
return handleBootstrapResponse(state: &state, result: result)

case let .deepLinkReceived(url):
guard let route = DeepLinkRouter.parse(url) else {
Expand All @@ -74,25 +114,109 @@ public struct AppCoordinatorFeature {
return .send(.routeDeepLink(route))

case let .routeDeepLink(route):
switch state.phase {
case .bootstrapping:
state.pendingDeepLink = route
return .none
case .main:
// placeholder 골격: home 딥링크는 현재 main scene 유지
_ = route
return .none
}
return handleDeepLinkRoute(state: &state, route: route)

case .flushPendingDeepLink:
guard let route = state.pendingDeepLink else {
return .none
return flushPendingDeepLink(state: &state)

case let .login(.delegate(.loggedIn(session))):
applySession(&state, session: session)
return .send(.flushPendingDeepLink)

case .onboarding(.delegate(.loggedOut)):
state.phase = .login(LoginFeature.State())
return .none

case .login, .onboarding, .main, .overlay:
return .none
}
}

private func bootstrapIfNeeded(state: inout State) -> Effect<Action> {
guard case .bootstrapping = state.phase else {
return .none
}
// 응답 전 중복 onAppear 가 와도 restore 는 한 번만 실행한다.
guard state.isRestoringSession == false else {
return .none
}

state.isRestoringSession = true
return .run { [authClient] send in
do {
let session = try await authClient.restoreSession()
await send(.bootstrapResponse(.success(session)))
} catch let error as AuthError {
await send(.bootstrapResponse(.failure(error)))
} catch {
await send(
.bootstrapResponse(
.failure(.unknown(message: error.localizedDescription))
)
)
}
state.pendingDeepLink = nil
return .send(.routeDeepLink(route))
}
}

case .main, .overlay:
private func handleBootstrapResponse(
state: inout State,
result: Result<AuthSession?, AuthError>
) -> Effect<Action> {
// 진행 중이던 restore 응답만 반영한다.
guard state.isRestoringSession else {
return .none
}
state.isRestoringSession = false

switch result {
case let .success(session):
applySession(&state, session: session)
case .failure:
// restore 실패 시 안전하게 로그인 게이트로 보낸다.
state.phase = .login(LoginFeature.State())
}
return .send(.flushPendingDeepLink)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private func handleDeepLinkRoute(
state: inout State,
route: DeepLinkRoute
) -> Effect<Action> {
switch state.phase {
case .bootstrapping, .login, .onboarding:
state.pendingDeepLink = route
return .none
case .main:
// placeholder 골격: home 딥링크는 현재 main scene 유지
_ = route
return .none
}
}

private func flushPendingDeepLink(state: inout State) -> Effect<Action> {
guard case .main = state.phase else {
return .none
}
guard let route = state.pendingDeepLink else {
return .none
}
state.pendingDeepLink = nil
return .send(.routeDeepLink(route))
}

private func applySession(
_ state: inout State,
session: AuthSession?
) {
guard let session else {
state.phase = .login(LoginFeature.State())
return
}

if session.profileCompleted {
state.phase = .main(PlaceholderFeature.State())
} else {
state.phase = .onboarding(OnboardingPlaceholderFeature.State())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ public struct AppCoordinatorView: View {
case .bootstrapping:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .login:
if let loginStore = store.scope(state: \.login, action: \.login) {
LoginView(store: loginStore)
}
case .onboarding:
if let onboardingStore = store.scope(state: \.onboarding, action: \.onboarding) {
OnboardingPlaceholderView(store: onboardingStore)
}
case .main:
if let mainStore = store.scope(state: \.mainPlaceholder, action: \.main) {
PlaceholderView(store: mainStore)
Expand Down
111 changes: 111 additions & 0 deletions Projects/Feature/Sources/Scene/Login/LoginFeature.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import Domain
import Foundation
import ThirdParty

@Reducer
public struct LoginFeature {
@ObservableState
public struct State: Equatable {
public var isLoading = false
public var errorMessage: String?

public init(
isLoading: Bool = false,
errorMessage: String? = nil
) {
self.isLoading = isLoading
self.errorMessage = errorMessage
}
}

public enum Action: Equatable {
case onAppear
case kakaoLoginTapped
case appleLoginTapped
case loginResponse(Result<AuthSession, AuthError>)
case delegate(Delegate)

public enum Delegate: Equatable {
case loggedIn(AuthSession)
}
}

@Dependency(\.authClient) var authClient

public init() {}

public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
return .none

case .kakaoLoginTapped:
return login(state: &state, provider: .kakao)

case .appleLoginTapped:
return login(state: &state, provider: .apple)

case let .loginResponse(.success(session)):
state.isLoading = false
state.errorMessage = nil
return .send(.delegate(.loggedIn(session)))

case let .loginResponse(.failure(error)):
state.isLoading = false
// 사용자 취소는 에러 메시지 없이 idle 복귀한다.
if case .cancelled = error {
state.errorMessage = nil
} else {
state.errorMessage = Self.errorMessage(for: error)
}
return .none

case .delegate:
return .none
}
}
}

private func login(
state: inout State,
provider: AuthProvider
) -> Effect<Action> {
guard state.isLoading == false else {
return .none
}

state.isLoading = true
state.errorMessage = nil

return .run { [authClient] send in
do {
let session = try await authClient.login(provider)
await send(.loginResponse(.success(session)))
} catch let error as AuthError {
await send(.loginResponse(.failure(error)))
} catch {
await send(.loginResponse(.failure(.unknown(message: error.localizedDescription))))
}
}
}

private static func errorMessage(for error: AuthError) -> String {
switch error {
case .cancelled:
return ""
case .notConfigured:
return "로그인 설정이 완료되지 않았어요."
case .loginFailed:
return "로그인에 실패했어요"
case .network:
return "네트워크 연결을 확인해 주세요"
case .unauthorized:
return "로그인에 실패했어요"
case .storage:
return "로그인 정보를 저장하지 못했어요."
case .unknown:
return "알 수 없는 오류가 발생했어요."
}
}
}
Loading