Skip to content

Commit cc8d747

Browse files
committed
feat: AccountFeature 구현
1 parent 3872fdb commit cc8d747

2 files changed

Lines changed: 618 additions & 0 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
//
2+
// AccountFeature.swift
3+
// DevLogPresentation
4+
//
5+
// Created by opfic on 6/11/26.
6+
//
7+
8+
import ComposableArchitecture
9+
import DevLogDomain
10+
import Foundation
11+
12+
@Reducer
13+
struct AccountFeature {
14+
@ObservableState
15+
struct State: Equatable {
16+
@Presents var alert: AlertState<Never>?
17+
var currentProvider: AuthProvider?
18+
var connectedProviders: [AuthProvider] = []
19+
var disconnectedProviders: [AuthProvider] = []
20+
var loading = LoadingFeature.State()
21+
22+
var isLoading: Bool {
23+
loading.isLoading
24+
}
25+
}
26+
27+
enum Action {
28+
case alert(PresentationAction<Never>)
29+
case onAppear
30+
case linkWithProvider(AuthProvider)
31+
case unlinkFromProvider(AuthProvider)
32+
case setAlert(AlertType)
33+
case setProviders(currentProvider: AuthProvider?, allProviders: [AuthProvider])
34+
case loading(LoadingFeature.Action)
35+
}
36+
37+
enum AlertType: Equatable {
38+
case linkEmailNotFound
39+
case linkEmailMismatch
40+
case linkCredentialAlreadyInUse
41+
case error
42+
}
43+
44+
@Dependency(\.fetchAuthProvidersUseCase) var fetchProvidersUseCase
45+
@Dependency(\.linkAuthProviderUseCase) var linkProviderUseCase
46+
@Dependency(\.unlinkAuthProviderUseCase) var unlinkProviderUseCase
47+
48+
var body: some ReducerOf<Self> {
49+
Scope(state: \.loading, action: \.loading) {
50+
LoadingFeature()
51+
}
52+
Reduce { state, action in
53+
switch action {
54+
case .alert:
55+
break
56+
case .onAppear:
57+
return fetchProvidersEffect()
58+
case .linkWithProvider(let provider):
59+
return linkProviderEffect(provider)
60+
case .unlinkFromProvider(let provider):
61+
return unlinkProviderEffect(provider)
62+
case .setAlert(let type):
63+
state.alert = alertState(for: type)
64+
case .setProviders(let currentProvider, let allProviders):
65+
state.currentProvider = currentProvider
66+
state.connectedProviders = allProviders.filter { $0 != currentProvider }
67+
state.disconnectedProviders = AuthProvider.allCases
68+
.filter { !allProviders.contains($0) }
69+
case .loading:
70+
break
71+
}
72+
return .none
73+
}
74+
.ifLet(\.$alert, action: \.alert)
75+
}
76+
}
77+
78+
extension DependencyValues {
79+
var fetchAuthProvidersUseCase: FetchAuthProvidersUseCase {
80+
get { self[FetchAuthProvidersUseCaseKey.self] }
81+
set { self[FetchAuthProvidersUseCaseKey.self] = newValue }
82+
}
83+
84+
var linkAuthProviderUseCase: LinkAuthProviderUseCase {
85+
get { self[LinkAuthProviderUseCaseKey.self] }
86+
set { self[LinkAuthProviderUseCaseKey.self] = newValue }
87+
}
88+
89+
var unlinkAuthProviderUseCase: UnlinkAuthProviderUseCase {
90+
get { self[UnlinkAuthProviderUseCaseKey.self] }
91+
set { self[UnlinkAuthProviderUseCaseKey.self] = newValue }
92+
}
93+
}
94+
95+
private enum FetchAuthProvidersUseCaseKey: DependencyKey {
96+
static var liveValue: FetchAuthProvidersUseCase {
97+
preconditionFailure("FetchAuthProvidersUseCase must be provided.")
98+
}
99+
100+
static var testValue: FetchAuthProvidersUseCase {
101+
liveValue
102+
}
103+
}
104+
105+
private enum LinkAuthProviderUseCaseKey: DependencyKey {
106+
static var liveValue: LinkAuthProviderUseCase {
107+
preconditionFailure("LinkAuthProviderUseCase must be provided.")
108+
}
109+
110+
static var testValue: LinkAuthProviderUseCase {
111+
liveValue
112+
}
113+
}
114+
115+
private enum UnlinkAuthProviderUseCaseKey: DependencyKey {
116+
static var liveValue: UnlinkAuthProviderUseCase {
117+
preconditionFailure("UnlinkAuthProviderUseCase must be provided.")
118+
}
119+
120+
static var testValue: UnlinkAuthProviderUseCase {
121+
liveValue
122+
}
123+
}
124+
125+
private extension AccountFeature {
126+
func fetchProvidersEffect() -> Effect<Action> {
127+
.run { [fetchProvidersUseCase] send in
128+
do {
129+
let providers = try await fetchProvidersUseCase.execute()
130+
await send(.setProviders(
131+
currentProvider: providers.currentProvider,
132+
allProviders: providers.allProviders
133+
))
134+
} catch {
135+
await send(.setAlert(.error))
136+
}
137+
}
138+
}
139+
140+
func linkProviderEffect(_ provider: AuthProvider) -> Effect<Action> {
141+
.run { [fetchProvidersUseCase, linkProviderUseCase] send in
142+
await send(.loading(.begin(target: .default, mode: .delayed)))
143+
do {
144+
try await linkProviderUseCase.execute(provider)
145+
await ToastPresenter.present(message: String(localized: "account_toast_link_success"))
146+
let providers = try await fetchProvidersUseCase.execute()
147+
await send(.setProviders(
148+
currentProvider: providers.currentProvider,
149+
allProviders: providers.allProviders
150+
))
151+
await send(.loading(.end(target: .default, mode: .delayed)))
152+
} catch {
153+
await send(.loading(.end(target: .default, mode: .delayed)))
154+
155+
if error.isSocialLoginCancelled { return }
156+
157+
await send(.setAlert(linkAlertType(for: error)))
158+
}
159+
}
160+
}
161+
162+
func unlinkProviderEffect(_ provider: AuthProvider) -> Effect<Action> {
163+
.run { [fetchProvidersUseCase, unlinkProviderUseCase] send in
164+
await send(.loading(.begin(target: .default, mode: .delayed)))
165+
do {
166+
try await unlinkProviderUseCase.execute(provider)
167+
await ToastPresenter.present(message: String(localized: "account_toast_unlink_success"))
168+
let providers = try await fetchProvidersUseCase.execute()
169+
await send(.setProviders(
170+
currentProvider: providers.currentProvider,
171+
allProviders: providers.allProviders
172+
))
173+
await send(.loading(.end(target: .default, mode: .delayed)))
174+
} catch {
175+
await send(.loading(.end(target: .default, mode: .delayed)))
176+
await send(.setAlert(.error))
177+
}
178+
}
179+
}
180+
}
181+
182+
private func linkAlertType(for error: Error) -> AccountFeature.AlertType {
183+
guard let authError = error as? AuthError else {
184+
return .error
185+
}
186+
187+
switch authError {
188+
case .linkEmailNotFound:
189+
return .linkEmailNotFound
190+
case .linkEmailMismatch:
191+
return .linkEmailMismatch
192+
case .linkCredentialAlreadyInUse:
193+
return .linkCredentialAlreadyInUse
194+
case .notAuthenticated, .failedToUnlinkLastProvider, .emailNotFound, .unsupportedProvider:
195+
return .error
196+
}
197+
}
198+
199+
private func alertState(for type: AccountFeature.AlertType) -> AlertState<Never> {
200+
let title: String
201+
let message: String
202+
203+
switch type {
204+
case .linkEmailNotFound:
205+
title = String(localized: "account_alert_email_unavailable_title")
206+
message = String(localized: "account_alert_email_unavailable_message")
207+
case .linkEmailMismatch:
208+
title = String(localized: "account_alert_cannot_link_title")
209+
message = String(localized: "account_alert_cannot_link_message")
210+
case .linkCredentialAlreadyInUse:
211+
title = String(localized: "account_alert_already_linked_title")
212+
message = String(localized: "account_alert_already_linked_message")
213+
case .error:
214+
title = String(localized: "common_error_title")
215+
message = String(localized: "common_error_message")
216+
}
217+
218+
return AlertState {
219+
TextState(title)
220+
} actions: {
221+
ButtonState(role: .cancel) {
222+
TextState(String(localized: "common_close"))
223+
}
224+
} message: {
225+
TextState(message)
226+
}
227+
}

0 commit comments

Comments
 (0)