From f6247e53146a123938a5eed1bd93a8f55f278b79 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:04:23 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20Binding=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Login/LoginFeature.swift | 13 +++++++++---- .../Sources/Login/LoginView.swift | 7 ++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift index 4ff8d5c5..9e65828e 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift @@ -20,8 +20,8 @@ struct LoginFeature { var alertMessage = "" } - enum Action { - case setAlert(Bool, AlertType? = nil) + enum Action: BindableAction { + case binding(BindingAction) case tapSignInButton(AuthProvider) case signInSucceeded case signInFailed(AlertType) @@ -36,10 +36,15 @@ struct LoginFeature { @Dependency(SignInUseCaseDependency.self) var signInUseCase var body: some ReducerOf { + BindingReducer() Reduce { state, action in switch action { - case .setAlert(let isPresented, let alertType): - setAlert(&state, isPresented: isPresented, alertType: alertType) + case .binding(\.showAlert): + if !state.showAlert { + setAlert(&state, isPresented: false, alertType: nil) + } + case .binding: // 다른 binding 액션들은 이쪽으로 처리됨 + break // 작성 필수 case .tapSignInButton(let provider): state.isLoading = true return .run { [signInUseCase] send in diff --git a/Application/DevLogPresentation/Sources/Login/LoginView.swift b/Application/DevLogPresentation/Sources/Login/LoginView.swift index 55998367..d8c5c4f2 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginView.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginView.swift @@ -11,7 +11,7 @@ import ComposableArchitecture struct LoginView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.sceneWidth) var sceneWidth - let store: StoreOf + @Bindable var store: StoreOf var body: some View { ZStack { @@ -46,10 +46,7 @@ struct LoginView: View { LoadingView() } } - .alert(store.alertTitle, isPresented: Binding( - get: { store.showAlert }, - set: { store.send(.setAlert($0)) } - )) { + .alert(store.alertTitle, isPresented: $store.showAlert) { Button(String(localized: "common_close"), role: .cancel) { } } message: { Text(store.alertMessage) From 150364fd53ac913e6008ea9065c181a041ce32f1 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:39:03 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=83=81=ED=83=9C=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/DevLogApp/Sources/App/DevLogApp.swift | 1 - .../Sources/Login/LoginFeature.swift | 4 +--- .../Sources/Login/LoginView.swift | 15 ++++++++++++++- .../Sources/Root/RootView.swift | 11 +---------- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/Application/DevLogApp/Sources/App/DevLogApp.swift b/Application/DevLogApp/Sources/App/DevLogApp.swift index 4e8e5c36..3b434735 100644 --- a/Application/DevLogApp/Sources/App/DevLogApp.swift +++ b/Application/DevLogApp/Sources/App/DevLogApp.swift @@ -30,7 +30,6 @@ struct DevLogApp: App { networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self), systemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self), trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), - signInUseCase: container.resolve(SignInUseCase.self), widgetURLTab: { MainTab(widgetURL: $0) }, windowEvent: windowEvent, pushNotificationTodoIdPublisher: PushNotificationRoute.shared.observe(), diff --git a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift index 9e65828e..ec1929bc 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift @@ -23,7 +23,6 @@ struct LoginFeature { enum Action: BindableAction { case binding(BindingAction) case tapSignInButton(AuthProvider) - case signInSucceeded case signInFailed(AlertType) case signInCancelled } @@ -50,7 +49,6 @@ struct LoginFeature { return .run { [signInUseCase] send in do { try await signInUseCase.execute(provider) - await send(.signInSucceeded) } catch { if error.isSocialLoginCancelled { await send(.signInCancelled) @@ -59,7 +57,7 @@ struct LoginFeature { await send(.signInFailed(alertType(for: error))) } } - case .signInSucceeded, .signInCancelled: + case .signInCancelled: state.isLoading = false case .signInFailed(let alertType): state.isLoading = false diff --git a/Application/DevLogPresentation/Sources/Login/LoginView.swift b/Application/DevLogPresentation/Sources/Login/LoginView.swift index d8c5c4f2..53ff26b0 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginView.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginView.swift @@ -7,13 +7,26 @@ import SwiftUI import ComposableArchitecture +import DevLogDomain struct LoginView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.sceneWidth) var sceneWidth - @Bindable var store: StoreOf + @State private var store: StoreOf + + init(signInUseCase: SignInUseCase) { + self._store = State(initialValue: Store( + initialState: LoginFeature.State() + ) { + LoginFeature() + } withDependencies: { + $0.signInUseCase = .live(signInUseCase) + }) + } var body: some View { + @Bindable var store = store + ZStack { VStack { Spacer() diff --git a/Application/DevLogPresentation/Sources/Root/RootView.swift b/Application/DevLogPresentation/Sources/Root/RootView.swift index 530a8ce7..21374dd2 100644 --- a/Application/DevLogPresentation/Sources/Root/RootView.swift +++ b/Application/DevLogPresentation/Sources/Root/RootView.swift @@ -16,7 +16,6 @@ public struct RootView: View { @State var viewModel: RootViewModel @State private var selectedRoute: Route? @State private var selectedMainTab = MainTab.home - private let loginStore: StoreOf private let widgetURLTab: (URL) -> MainTab? private let windowEvent: TodoEditorWindowEvent private let pushNotificationTodoIdPublisher: AnyPublisher @@ -27,7 +26,6 @@ public struct RootView: View { networkConnectivityUseCase: ObserveNetworkConnectivityUseCase, systemThemeUseCase: ObserveSystemThemeUseCase, trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase, - signInUseCase: SignInUseCase, widgetURLTab: @escaping (URL) -> MainTab?, windowEvent: TodoEditorWindowEvent, pushNotificationTodoIdPublisher: AnyPublisher, @@ -39,13 +37,6 @@ public struct RootView: View { systemThemeUseCase: systemThemeUseCase, trackAnalyticsEventUseCase: trackAnalyticsEventUseCase )) - self.loginStore = Store( - initialState: LoginFeature.State() - ) { - LoginFeature() - } withDependencies: { - $0.signInUseCase = .live(signInUseCase) - } self.widgetURLTab = widgetURLTab self.windowEvent = windowEvent self.pushNotificationTodoIdPublisher = pushNotificationTodoIdPublisher @@ -63,7 +54,7 @@ public struct RootView: View { selectedTab: $selectedMainTab ) } else { - LoginView(store: loginStore) + LoginView(signInUseCase: container.resolve(SignInUseCase.self)) } } } From 057ca4149ab4d0b4e1f86e05271e93ee92c1d12b Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:50:50 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20=EB=B3=84=EB=8F=84=20Bindable?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/DevLogPresentation/Sources/Login/LoginView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Login/LoginView.swift b/Application/DevLogPresentation/Sources/Login/LoginView.swift index 53ff26b0..f75b8272 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginView.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginView.swift @@ -25,8 +25,6 @@ struct LoginView: View { } var body: some View { - @Bindable var store = store - ZStack { VStack { Spacer() From 516355f456edd1e405eb1419427ff2014a11878f Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 9 Jun 2026 12:10:26 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EB=B0=94=EC=9D=B8=EB=94=A9=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogPresentation/Tests/Login/LoginFeatureTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift index 324c135f..eb1f43e0 100644 --- a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift @@ -198,6 +198,6 @@ private struct LoginTestDriver { } func setAlert(_ isPresented: Bool) { - feature.send(.setAlert(isPresented)) + feature.send(.binding(.set(\.showAlert, isPresented))) } } From 9d16c0d4e151714ee9cc95e3de514b1519cfadb9 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:31:33 +0900 Subject: [PATCH 5/6] =?UTF-8?q?test:=20=EB=A1=9C=EC=A7=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tests/Login/LoginFeatureTests.swift | 8 ++++---- .../DevLogPresentation/Tests/Support/TestSupport.swift | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift index eb1f43e0..6539df39 100644 --- a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift @@ -27,8 +27,8 @@ struct LoginFeatureTests { #expect(spy.calledProviders == [.github]) } - @Test("로그인 요청 중에는 로딩 상태가 켜지고 요청이 끝나면 꺼진다") - func 로그인_요청_중에는_로딩_상태가_켜지고_요청이_끝나면_꺼진다() async { + @Test("로그인 성공 후에도 메인 화면 전환 전까지 로딩 상태를 유지한다") + func 로그인_성공_후에도_메인_화면_전환_전까지_로딩_상태를_유지한다() async { let spy = SignInUseCaseSpy() spy.shouldSuspend = true let driver = LoginTestDriver(useCase: spy) @@ -44,10 +44,10 @@ struct LoginFeatureTests { spy.resume() await waitUntil { - !driver.isLoading + spy.successfulProviders == [.google] } - #expect(!driver.isLoading) + #expect(driver.isLoading) } @Test("로그인 실패 후에도 로딩 상태가 꺼진다") diff --git a/Application/DevLogPresentation/Tests/Support/TestSupport.swift b/Application/DevLogPresentation/Tests/Support/TestSupport.swift index 4d6bf319..15bf5fc5 100644 --- a/Application/DevLogPresentation/Tests/Support/TestSupport.swift +++ b/Application/DevLogPresentation/Tests/Support/TestSupport.swift @@ -54,6 +54,7 @@ final class SignInUseCaseSpy: SignInUseCase { var error: Error? var shouldSuspend = false private(set) var calledProviders: [AuthProvider] = [] + private(set) var successfulProviders = [AuthProvider]() private var continuation: CheckedContinuation? private var shouldResume = false @@ -74,6 +75,8 @@ final class SignInUseCaseSpy: SignInUseCase { if let error { throw error } + + successfulProviders.append(provider) } func resume() { From ade94faf2942d4e148833d38eff0009b01d2a6ef Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:01:10 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20AlertState=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Login/LoginFeature.swift | 53 ++++++------- .../Sources/Login/LoginView.swift | 6 +- .../Tests/Login/LoginFeatureTests.swift | 77 +++++++++---------- 3 files changed, 63 insertions(+), 73 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift index ec1929bc..710870fe 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift @@ -13,15 +13,12 @@ import Foundation struct LoginFeature { @ObservableState struct State: Equatable { + @Presents var alert: AlertState? var isLoading = false - var showAlert = false - var alertType: AlertType? - var alertTitle = "" - var alertMessage = "" } - enum Action: BindableAction { - case binding(BindingAction) + enum Action { + case alert(PresentationAction) case tapSignInButton(AuthProvider) case signInFailed(AlertType) case signInCancelled @@ -35,15 +32,10 @@ struct LoginFeature { @Dependency(SignInUseCaseDependency.self) var signInUseCase var body: some ReducerOf { - BindingReducer() Reduce { state, action in switch action { - case .binding(\.showAlert): - if !state.showAlert { - setAlert(&state, isPresented: false, alertType: nil) - } - case .binding: // 다른 binding 액션들은 이쪽으로 처리됨 - break // 작성 필수 + case .alert: + break case .tapSignInButton(let provider): state.isLoading = true return .run { [signInUseCase] send in @@ -61,10 +53,11 @@ struct LoginFeature { state.isLoading = false case .signInFailed(let alertType): state.isLoading = false - setAlert(&state, isPresented: true, alertType: alertType) + state.alert = alertState(for: alertType) } return .none } + .ifLet(\.$alert, action: \.alert) } } @@ -98,24 +91,28 @@ extension DependencyValues { } private extension LoginFeature { - func setAlert( - _ state: inout State, - isPresented: Bool, - alertType: AlertType? - ) { + func alertState(for alertType: AlertType) -> AlertState { + let title: String + let message: String + switch alertType { case .emailUnavailable: - state.alertTitle = String(localized: "login_alert_email_unavailable_title") - state.alertMessage = String(localized: "login_alert_email_unavailable_message") + title = String(localized: "login_alert_email_unavailable_title") + message = String(localized: "login_alert_email_unavailable_message") case .error: - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") - case .none: - state.alertTitle = "" - state.alertMessage = "" + title = String(localized: "common_error_title") + message = String(localized: "common_error_message") + } + + return AlertState { + TextState(title) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(message) } - state.showAlert = isPresented - state.alertType = alertType } func alertType(for error: Error) -> AlertType { diff --git a/Application/DevLogPresentation/Sources/Login/LoginView.swift b/Application/DevLogPresentation/Sources/Login/LoginView.swift index f75b8272..a447820d 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginView.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginView.swift @@ -57,10 +57,6 @@ struct LoginView: View { LoadingView() } } - .alert(store.alertTitle, isPresented: $store.showAlert) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(store.alertMessage) - } + .alert($store.scope(state: \.alert, action: \.alert)) } } diff --git a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift index 6539df39..0b10261a 100644 --- a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift @@ -68,7 +68,7 @@ struct LoginFeatureTests { spy.resume() await waitUntil { - !driver.isLoading && driver.showAlert + !driver.isLoading && driver.hasAlert } #expect(!driver.isLoading) @@ -83,12 +83,13 @@ struct LoginFeatureTests { driver.tapSignInButton(.google) await waitUntil { - driver.showAlert + driver.hasAlert } - #expect(driver.alertKind == .emailUnavailable) - #expect(driver.alertTitle == String(localized: "login_alert_email_unavailable_title")) - #expect(driver.alertMessage == String(localized: "login_alert_email_unavailable_message")) + #expect(driver.alert == expectedAlert( + title: String(localized: "login_alert_email_unavailable_title"), + message: String(localized: "login_alert_email_unavailable_message") + )) } @Test("일반 로그인 에러가 발생하면 공통 에러 알림을 표시한다") @@ -100,12 +101,13 @@ struct LoginFeatureTests { driver.tapSignInButton(.apple) await waitUntil { - driver.showAlert + driver.hasAlert } - #expect(driver.alertKind == .error) - #expect(driver.alertTitle == String(localized: "common_error_title")) - #expect(driver.alertMessage == String(localized: "common_error_message")) + #expect(driver.alert == expectedAlert( + title: String(localized: "common_error_title"), + message: String(localized: "common_error_message") + )) } @Test("소셜 로그인 취소 에러가 발생하면 알림을 표시하지 않는다") @@ -121,9 +123,7 @@ struct LoginFeatureTests { } #expect(!driver.showAlert) - #expect(driver.alertKind == nil) - #expect(driver.alertTitle.isEmpty) - #expect(driver.alertMessage.isEmpty) + #expect(driver.alert == nil) } @Test("알림을 닫으면 알림 상태와 문구가 초기화된다") @@ -135,23 +135,16 @@ struct LoginFeatureTests { driver.tapSignInButton(.google) await waitUntil { - driver.showAlert + driver.hasAlert } - driver.setAlert(false) + driver.dismissAlert() #expect(!driver.showAlert) - #expect(driver.alertKind == nil) - #expect(driver.alertTitle.isEmpty) - #expect(driver.alertMessage.isEmpty) + #expect(driver.alert == nil) } } -private enum LoginAlertKind: Equatable { - case emailUnavailable - case error -} - @MainActor private struct LoginTestDriver { private let feature: StoreOf @@ -161,26 +154,15 @@ private struct LoginTestDriver { } var showAlert: Bool { - feature.state.showAlert - } - - var alertKind: LoginAlertKind? { - switch feature.state.alertType { - case .emailUnavailable: - return .emailUnavailable - case .error: - return .error - case .none: - return nil - } + hasAlert } - var alertTitle: String { - feature.state.alertTitle + var hasAlert: Bool { + alert != nil } - var alertMessage: String { - feature.state.alertMessage + var alert: AlertState? { + feature.state.alert } init(useCase: SignInUseCase) { @@ -197,7 +179,22 @@ private struct LoginTestDriver { feature.send(.tapSignInButton(provider)) } - func setAlert(_ isPresented: Bool) { - feature.send(.binding(.set(\.showAlert, isPresented))) + func dismissAlert() { + feature.send(.alert(.dismiss)) + } +} + +private func expectedAlert( + title: String, + message: String +) -> AlertState { + AlertState { + TextState(title) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(message) } }