From bd0fd3d303fdc18a40f68ac79c6d6bf2a548312f Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:18:50 +0900 Subject: [PATCH 01/13] =?UTF-8?q?ui:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=EC=97=90=20ProgressView=EB=A5=BC=20=EB=9D=84?= =?UTF-8?q?=EC=9A=B0=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/Component/LoginButton.swift | 41 ++++++---- .../Sources/Login/LoginFeature.swift | 7 +- .../Sources/Login/LoginView.swift | 77 +++++++++++-------- .../Tests/Login/LoginFeatureTests.swift | 7 ++ 4 files changed, 85 insertions(+), 47 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Common/Component/LoginButton.swift b/Application/DevLogPresentation/Sources/Common/Component/LoginButton.swift index d5e6a153..4b6e57fb 100644 --- a/Application/DevLogPresentation/Sources/Common/Component/LoginButton.swift +++ b/Application/DevLogPresentation/Sources/Common/Component/LoginButton.swift @@ -12,15 +12,18 @@ struct LoginButton: View { @State private var logo: Image? @State private var text = "" @ScaledMetric(relativeTo: .body) private var height = CGFloat(22) + private let showsProgressView: Bool private let action: () -> Void init( logo: Image? = nil, text: String = "", + showsProgressView: Bool = false, action: @escaping () -> Void = {} ) { self._logo = State(initialValue: logo) self._text = State(initialValue: text) + self.showsProgressView = showsProgressView self.action = action } @@ -28,24 +31,30 @@ struct LoginButton: View { Button { action() } label: { - Text(text) - .foregroundStyle(Color.primary) - .font(.system(.body)) - .contentShape(.capsule) - .frame(width: 300, height: height + 16) - .overlay { - ZStack(alignment: .leading) { - Capsule() - .stroke(Color.gray, lineWidth: 1) - if let logo = logo { - logo - .resizable() - .scaledToFit() - .frame(width: height, height: height) - .padding(.leading) - } + ZStack { + Text(text) + .opacity(showsProgressView ? 0 : 1) + if showsProgressView { + ProgressView() + } + } + .foregroundStyle(Color.primary) + .font(.system(.body)) + .contentShape(.capsule) + .frame(width: 300, height: height + 16) + .overlay { + ZStack(alignment: .leading) { + Capsule() + .stroke(Color.gray, lineWidth: 1) + if let logo, !showsProgressView { + logo + .resizable() + .scaledToFit() + .frame(width: height, height: height) + .padding(.leading) } } + } } } } diff --git a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift index 196e6da3..b7c9a53e 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift @@ -14,6 +14,7 @@ struct LoginFeature { @ObservableState struct State: Equatable { @Presents var alert: AlertState? + var activeSignInProvider: AuthProvider? var loading = LoadingFeature.State() var isLoading: Bool { @@ -44,11 +45,15 @@ struct LoginFeature { case .alert: break case .tapSignInButton(let provider): + guard !state.isLoading else { return .none } + state.activeSignInProvider = provider return signInEffect(provider) case .signInFailed(let alertType): state.alert = Self.alertState(for: alertType) case .loading: - break + if !state.isLoading { + state.activeSignInProvider = nil + } } return .none } diff --git a/Application/DevLogPresentation/Sources/Login/LoginView.swift b/Application/DevLogPresentation/Sources/Login/LoginView.swift index 17e453dc..d75142df 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginView.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginView.swift @@ -25,38 +25,55 @@ struct LoginView: View { } var body: some View { - ZStack { - VStack { - Spacer() - Image("Primary") - .resizable() - .scaledToFit() - .frame(width: sceneWidth / 5) - Spacer() - VStack(spacing: 20) { - LoginButton(logo: Image("Google"), text: String(localized: "login_google_sign_in")) { - store.send(.tapSignInButton(.google)) - } - - LoginButton(logo: Image("Github"), text: String(localized: "login_github_sign_in")) { - store.send(.tapSignInButton(.github)) - } - - LoginButton(logo: Image("Apple"), text: String(localized: "login_apple_sign_in")) { - store.send(.tapSignInButton(.apple)) - } - } - .padding(.bottom, 30) - Text(String(localized: "login_terms_notice")) - .font(.caption2) - .foregroundStyle(Color.gray) - .multilineTextAlignment(.center) - .padding(.vertical) - } - if store.isLoading { - LoadingView() + VStack { + Spacer() + Image("Primary") + .resizable() + .scaledToFit() + .frame(width: sceneWidth / 5) + Spacer() + VStack(spacing: 20) { + signInButton( + provider: .google, + logo: Image("Google"), + text: String(localized: "login_google_sign_in") + ) + + signInButton( + provider: .github, + logo: Image("Github"), + text: String(localized: "login_github_sign_in") + ) + + signInButton( + provider: .apple, + logo: Image("Apple"), + text: String(localized: "login_apple_sign_in") + ) } + .padding(.bottom, 30) + Text(String(localized: "login_terms_notice")) + .font(.caption2) + .foregroundStyle(Color.gray) + .multilineTextAlignment(.center) + .padding(.vertical) } .alert($store.scope(state: \.alert, action: \.alert)) } + + private func signInButton( + provider: AuthProvider, + logo: Image, + text: String + ) -> some View { + LoginButton( + logo: logo, + text: text, + showsProgressView: store.activeSignInProvider == provider + ) { + store.send(.tapSignInButton(provider)) + } + .disabled(store.isLoading) + .opacity(store.isLoading ? 0.5 : 1) + } } diff --git a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift index d9657866..c221979e 100644 --- a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift @@ -40,6 +40,7 @@ struct LoginFeatureTests { } #expect(driver.isLoading) + #expect(driver.activeSignInProvider == .google) spy.resume() @@ -48,6 +49,7 @@ struct LoginFeatureTests { } #expect(driver.isLoading) + #expect(driver.activeSignInProvider == .google) } @Test("로그인 실패 후에도 로딩 상태가 꺼진다") @@ -72,6 +74,7 @@ struct LoginFeatureTests { } #expect(!driver.isLoading) + #expect(driver.activeSignInProvider == nil) } @Test("이메일을 가져오지 못하면 이메일 없음 알림을 표시한다") @@ -153,6 +156,10 @@ private struct LoginTestDriver { feature.state.isLoading } + var activeSignInProvider: AuthProvider? { + feature.state.activeSignInProvider + } + var showAlert: Bool { hasAlert } From 68bde4979023b148cde2fec6dfb780f285deec91 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:08:28 +0900 Subject: [PATCH 02/13] =?UTF-8?q?ui:=20SettingsView=EC=97=90=20=EA=B0=81?= =?UTF-8?q?=20=EB=B2=84=ED=8A=B8=EC=97=90=20ProgressView=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Settings/SettingsFeature.swift | 21 +++++++-- .../Sources/Settings/SettingsView.swift | 47 ++++++++++++------- .../Tests/Settings/SettingsFeatureTests.swift | 12 +++-- 3 files changed, 57 insertions(+), 23 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift b/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift index bf46c4db..9efe099e 100644 --- a/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift @@ -18,12 +18,19 @@ struct SettingsFeature { case systemTheme } + enum ActiveLoadingRow: Equatable { + case removeCache + case signOut + case deleteAuth + } + @ObservableState struct State: Equatable { @Presents var alert: AlertState? var theme: SystemTheme = .automatic var dirSize: Int64 = 0 var isNetworkConnected = true + var activeLoadingRow: ActiveLoadingRow? var loading = LoadingFeature.State() var alertType: Action.AlertType? var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String @@ -78,14 +85,17 @@ struct SettingsFeature { case .alert(.presented(.tapDeleteAuthButton)): state.alert = nil state.alertType = nil + state.activeLoadingRow = .deleteAuth return deleteAuthEffect() case .alert(.presented(.tapSignOutButton)): state.alert = nil state.alertType = nil + state.activeLoadingRow = .signOut return signOutEffect() case .alert(.presented(.confirmRemoveCache)): state.alert = nil state.alertType = nil + state.activeLoadingRow = .removeCache return clearWebPageImageDirectoryEffect() case .alert(.dismiss): state.alert = nil @@ -114,7 +124,9 @@ struct SettingsFeature { state.alert = Self.alertState(for: .removeCache) state.alertType = .removeCache case .loading: - break + if !state.isLoading { +// state.activeLoadingRow = nil + } } return .none @@ -263,11 +275,14 @@ private extension SettingsFeature { func clearWebPageImageDirectoryEffect() -> Effect { .run { [clearWebPageImageDirectoryUseCase, fetchWebPageImageDirSizeUseCase] send in + await send(.loading(.begin(target: .default, mode: .delayed))) do { try await clearWebPageImageDirectoryUseCase.execute() let dirSize = await fetchWebPageImageDirSizeUseCase.execute() await send(.setDirSize(dirSize)) + await send(.loading(.end(target: .default, mode: .delayed))) } catch { + await send(.loading(.end(target: .default, mode: .delayed))) await send(.setAlert(.error)) } } @@ -278,7 +293,7 @@ private extension SettingsFeature { await send(.loading(.begin(target: .default, mode: .delayed))) do { try await deleteAuthUseCase.execute() - await send(.loading(.end(target: .default, mode: .delayed))) + // 유스케이스 완료가 LoginView 전환 완료를 의미하지 않으므로 화면이 교체될 때까지 로딩을 유지한다. } catch { await send(.loading(.end(target: .default, mode: .delayed))) await send(.setAlert(.error)) @@ -291,7 +306,7 @@ private extension SettingsFeature { await send(.loading(.begin(target: .default, mode: .delayed))) do { try await signOutUseCase.execute() - await send(.loading(.end(target: .default, mode: .delayed))) + // 유스케이스 완료가 LoginView 전환 완료를 의미하지 않으므로 화면이 교체될 때까지 로딩을 유지한다. } catch { await send(.loading(.end(target: .default, mode: .delayed))) await send(.setAlert(.error)) diff --git a/Application/DevLogPresentation/Sources/Settings/SettingsView.swift b/Application/DevLogPresentation/Sources/Settings/SettingsView.swift index 9a60fcdd..f217e659 100644 --- a/Application/DevLogPresentation/Sources/Settings/SettingsView.swift +++ b/Application/DevLogPresentation/Sources/Settings/SettingsView.swift @@ -44,11 +44,16 @@ struct SettingsView: View { Text(String(localized: "settings_clear_temp_data")) .foregroundStyle(dirSize == 0 ? Color.secondary : .primary) Spacer() - Text(formatFileSize(bytes: dirSize)) - .foregroundStyle(Color.secondary.opacity(dirSize == 0 ? 0 : 1)) + if store.activeLoadingRow == .removeCache { + ProgressView() + .tint(.secondary) + } else { + Text(formatFileSize(bytes: dirSize)) + .foregroundStyle(Color.secondary.opacity(dirSize == 0 ? 0 : 1)) + } } } - .disabled(dirSize == 0) + .disabled(dirSize == 0 || store.isLoading) } Section { @@ -88,34 +93,42 @@ struct SettingsView: View { Text(String(localized: "settings_account")) } .disabled(!connected) - Button(role: .destructive, action: { + Button { store.send(.setAlert(.signOut)) - }) { - Text(String(localized: "settings_sign_out")) + } label: { + HStack { + Text(String(localized: "settings_sign_out")) + .foregroundStyle(.red) + Spacer() + if store.activeLoadingRow == .signOut { + ProgressView() + } + } } - .disabled(!connected) + .disabled(!connected || store.isLoading) } HStack { Spacer() - Button(role: .destructive, action: { + Button { store.send(.setAlert(.deleteAuth)) - }) { - Text(String(localized: "settings_delete_account")) - .font(.headline) + } label: { + if store.activeLoadingRow == .deleteAuth { + ProgressView() + .tint(.red) + } else { + Text(String(localized: "settings_delete_account")) + .foregroundStyle(.red) + .font(.headline) + } } - .disabled(!connected) + .disabled(!connected || store.isLoading) Spacer() } } .navigationTitle(String(localized: "nav_settings")) .navigationBarTitleDisplayMode(.inline) .alert($store.scope(state: \.alert, action: \.alert)) - .overlay { - if store.isLoading { - LoadingView() - } - } .onAppear { store.send(.updateDirSize) } diff --git a/Application/DevLogPresentation/Tests/Settings/SettingsFeatureTests.swift b/Application/DevLogPresentation/Tests/Settings/SettingsFeatureTests.swift index 07fabd7c..f74f8dd2 100644 --- a/Application/DevLogPresentation/Tests/Settings/SettingsFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Settings/SettingsFeatureTests.swift @@ -106,8 +106,8 @@ struct SettingsFeatureTests { #expect(adapter.alertMessage == String(localized: "common_error_message")) } - @Test("로그아웃 작업이 지연되면 로딩 상태를 표시하고 완료되면 해제한다") - func 로그아웃_작업이_지연되면_로딩_상태를_표시하고_완료되면_해제한다() async { + @Test("로그아웃 성공 후에도 LoginView 전환 전까지 로딩 상태를 유지한다") + func 로그아웃_성공_후에도_LoginView_전환_전까지_로딩_상태를_유지한다() async { let signOutSpy = SignOutUseCaseSpy() signOutSpy.shouldSuspend = true let adapter = SettingsStoreTestAdapter(signOutUseCase: signOutSpy) @@ -119,11 +119,13 @@ struct SettingsFeatureTests { await adapter.advanceDelayedLoading() #expect(adapter.isLoading) + #expect(adapter.activeLoadingRow == .signOut) signOutSpy.resume() await adapter.drainReceivedActions() - #expect(!adapter.isLoading) + #expect(adapter.isLoading) + #expect(adapter.activeLoadingRow == .signOut) } @Test("회원 탈퇴 실패 시 공통 에러 알림을 표시한다") @@ -156,6 +158,7 @@ private struct SettingsStoreTestAdapter { var dirSize: Int64 { store.state.dirSize } var isNetworkConnected: Bool { store.state.isNetworkConnected } var isLoading: Bool { store.state.isLoading } + var activeLoadingRow: SettingsFeature.ActiveLoadingRow? { store.state.activeLoadingRow } var showAlert: Bool { store.state.alert != nil } var alertTitle: String { guard let alert = store.state.alert else { return "" } @@ -221,6 +224,7 @@ private struct SettingsStoreTestAdapter { await store.send(.alert(.presented(.confirmRemoveCache))) { $0.alert = nil $0.alertType = nil + $0.activeLoadingRow = .removeCache } await drainReceivedActions() } @@ -233,6 +237,7 @@ private struct SettingsStoreTestAdapter { await store.send(.alert(.presented(.tapSignOutButton))) { $0.alert = nil $0.alertType = nil + $0.activeLoadingRow = .signOut } await drainReceivedActions() } @@ -245,6 +250,7 @@ private struct SettingsStoreTestAdapter { await store.send(.alert(.presented(.tapDeleteAuthButton))) { $0.alert = nil $0.alertType = nil + $0.activeLoadingRow = .deleteAuth } await drainReceivedActions() } From 8ac830a5dc196945498d984951fdc3d763ed41db Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:35:03 +0900 Subject: [PATCH 03/13] =?UTF-8?q?fix:=20AccountView=20=EB=A1=9C=EB=94=A9?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Settings/AccountFeature.swift | 9 +++++- .../Sources/Settings/AccountView.swift | 32 +++++++++---------- .../Tests/Settings/AccountFeatureTests.swift | 5 +++ 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift b/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift index fbc492b1..9f55dec5 100644 --- a/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift @@ -17,6 +17,7 @@ struct AccountFeature { var currentProvider: AuthProvider? var connectedProviders: [AuthProvider] = [] var disconnectedProviders: [AuthProvider] = [] + var activeLoadingProvider: AuthProvider? var loading = LoadingFeature.State() var isLoading: Bool { @@ -56,8 +57,12 @@ struct AccountFeature { case .onAppear: return fetchProvidersEffect() case .linkWithProvider(let provider): + guard !state.isLoading else { return .none } + state.activeLoadingProvider = provider return linkProviderEffect(provider) case .unlinkFromProvider(let provider): + guard !state.isLoading else { return .none } + state.activeLoadingProvider = provider return unlinkProviderEffect(provider) case .setAlert(let type): state.alert = Self.alertState(for: type) @@ -67,7 +72,9 @@ struct AccountFeature { state.disconnectedProviders = AuthProvider.allCases .filter { !allProviders.contains($0) } case .loading: - break + if !state.isLoading { + state.activeLoadingProvider = nil + } } return .none } diff --git a/Application/DevLogPresentation/Sources/Settings/AccountView.swift b/Application/DevLogPresentation/Sources/Settings/AccountView.swift index 28c664de..f6833338 100644 --- a/Application/DevLogPresentation/Sources/Settings/AccountView.swift +++ b/Application/DevLogPresentation/Sources/Settings/AccountView.swift @@ -28,24 +28,29 @@ struct AccountView: View { HStack { providerContent(provider) Spacer() - Button { - if isConnected { - store.send(.unlinkFromProvider(provider)) - } else { - store.send(.linkWithProvider(provider)) - } - } label: { - Text(isConnected - ? String(localized: "account_disconnect") - : String(localized: "account_connect")) + if store.isLoading && store.activeLoadingProvider == provider { + ProgressView() + .id(UUID()) + } else { + Button { + if isConnected { + store.send(.unlinkFromProvider(provider)) + } else { + store.send(.linkWithProvider(provider)) + } + } label: { + Text(isConnected + ? String(localized: "account_disconnect") + : String(localized: "account_connect")) .font(.caption.weight(.semibold)) .foregroundStyle(.white) .padding(.horizontal, 12) .padding(.vertical, 6) .background(isConnected ? Color.red : .blue) .clipShape(.capsule) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } } } @@ -55,11 +60,6 @@ struct AccountView: View { .navigationTitle(String(localized: "nav_account")) .onAppear { store.send(.onAppear) } .alert($store.scope(state: \.alert, action: \.alert)) - .overlay { - if store.isLoading { - LoadingView() - } - } } private func formattedProviderName(_ provider: AuthProvider) -> String { diff --git a/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift b/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift index 42108b12..1ecf0e46 100644 --- a/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift @@ -110,6 +110,7 @@ struct AccountFeatureTests { } #expect(driver.isLoading) + #expect(driver.activeLoadingProvider == .github) linkSpy.resume() @@ -252,6 +253,10 @@ private struct AccountTestDriver { feature.state.isLoading } + var activeLoadingProvider: AuthProvider? { + feature.state.activeLoadingProvider + } + var alert: AlertState? { feature.state.alert } From 91b223a5675cc93e69958ed1fd848bfb81d066f9 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:19:22 +0900 Subject: [PATCH 04/13] =?UTF-8?q?PushNotifiactionSettingsView=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=ED=91=9C=EC=8B=9C=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationSettingsFeature.swift | 27 ++++++++ .../PushNotificationSettingsView.swift | 40 ++++++++---- ...PushNotificationSettingsFeatureTests.swift | 62 +++++++++++++++++++ 3 files changed, 118 insertions(+), 11 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift index bd45e1f0..b1a98385 100644 --- a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift @@ -12,12 +12,19 @@ import SwiftUI @Reducer struct PushNotificationSettingsFeature { + enum ActiveLoadingRow: Equatable { + case enable + case presetTime(hour: Int, minute: Int) + case customTime + } + @ObservableState struct State: Equatable { @Presents var alert: AlertState? @Presents var timePicker: TimePickerState? var pushNotificationEnable = false var viewPushNotificationTime = Date() + var activeLoadingRow: ActiveLoadingRow? var loading = LoadingFeature.State() var isLoading: Bool { @@ -46,6 +53,7 @@ struct PushNotificationSettingsFeature { case setAlert case tapCustomTime case selectPresetTime(Date) + case clearActiveLoadingRow case loading(LoadingFeature.Action) enum TimePicker: BindableAction, Equatable { @@ -68,6 +76,7 @@ struct PushNotificationSettingsFeature { case .alert: break case .binding(\.pushNotificationEnable): + state.activeLoadingRow = .enable return updatePushNotificationSettingsEffect(settings: Self.settings(from: state)) case .binding(\.viewPushNotificationTime): let time = state.viewPushNotificationTime @@ -82,10 +91,12 @@ struct PushNotificationSettingsFeature { guard let time = state.timePicker?.time else { break } state.timePicker = nil state.viewPushNotificationTime = time + state.activeLoadingRow = .customTime return updatePushNotificationSettingsEffect(settings: Self.settings(from: state)) case .timePicker: break case .fetchSettings: + state.activeLoadingRow = .enable return fetchPushNotificationSettingsEffect() case .applyFetchedSettings(let settings): state.pushNotificationEnable = settings.isEnabled @@ -101,7 +112,10 @@ struct PushNotificationSettingsFeature { case .selectPresetTime(let date): state.viewPushNotificationTime = date state.timePicker?.time = date + state.activeLoadingRow = Self.activeLoadingRow(for: date) return updatePushNotificationSettingsEffect(settings: Self.settings(from: state)) + case .clearActiveLoadingRow: + state.activeLoadingRow = nil case .loading: break } @@ -156,6 +170,15 @@ private enum UpdatePushSettingsUseCaseKey: DependencyKey { } } +extension PushNotificationSettingsFeature { + static func activeLoadingRow(for date: Date) -> ActiveLoadingRow? { + let components = Calendar.current.dateComponents([.hour, .minute], from: date) + guard let hour = components.hour, + let minute = components.minute else { return nil } + return .presetTime(hour: hour, minute: minute) + } +} + private extension PushNotificationSettingsFeature { func fetchPushNotificationSettingsEffect() -> Effect { .run { [fetchPushSettingsUseCase] send in @@ -164,8 +187,10 @@ private extension PushNotificationSettingsFeature { let settings = try await fetchPushSettingsUseCase.execute() await send(.applyFetchedSettings(settings)) await send(.loading(.end(target: .default, mode: .delayed))) + await send(.clearActiveLoadingRow) } catch { await send(.loading(.end(target: .default, mode: .delayed))) + await send(.clearActiveLoadingRow) await send(.setAlert) } } @@ -177,8 +202,10 @@ private extension PushNotificationSettingsFeature { do { try await updatePushSettingsUseCase.execute(settings) await send(.loading(.end(target: .default, mode: .delayed))) + await send(.clearActiveLoadingRow) } catch { await send(.loading(.end(target: .default, mode: .delayed))) + await send(.clearActiveLoadingRow) await send(.setAlert) await send(.fetchSettings) } diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift index 88d7a481..fa93b19d 100644 --- a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift +++ b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift @@ -14,21 +14,35 @@ struct PushNotificationSettingsView: View { var body: some View { List { Section(content: { - Toggle(isOn: $store.pushNotificationEnable) { + HStack { Text(String(localized: "push_settings_enable")) + Spacer() + if store.isLoading && store.activeLoadingRow == .enable { + ProgressView() + .id(UUID()) + } else { + Toggle("", isOn: $store.pushNotificationEnable) + .labelsHidden() + .tint(.blue) + .disabled(store.activeLoadingRow != nil) + } } - .tint(.blue) }, footer: { Text(String(localized: "push_settings_footer")) }) Section { ForEach([9, 15, 18, 21], id: \.self) { hour in if let date = Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: Date()) { + let loadingRow = PushNotificationSettingsFeature.activeLoadingRow(for: date) HStack { Text(formattedTimeString(date)) Spacer() - if store.pushNotificationHour == hour && - store.pushNotificationMinute == 0 { + if let loadingRow, + store.isLoading && store.activeLoadingRow == loadingRow { + ProgressView() + .id(UUID()) + } else if store.pushNotificationHour == hour && + store.pushNotificationMinute == 0 { Image(systemName: "checkmark") .foregroundStyle(Color.blue) } @@ -40,22 +54,26 @@ struct PushNotificationSettingsView: View { HStack { Text(String(localized: "push_settings_custom")) Spacer() - Text(formattedTimeString(store.viewPushNotificationTime)) - .foregroundStyle(.secondary) - if store.pushNotificationMinute != 0 { - Image(systemName: "checkmark") - .foregroundStyle(Color.blue) + if store.isLoading && store.activeLoadingRow == .customTime { + ProgressView() + .id(UUID()) + } else { + Text(formattedTimeString(store.viewPushNotificationTime)) + .foregroundStyle(.secondary) + if store.pushNotificationMinute != 0 { + Image(systemName: "checkmark") + .foregroundStyle(Color.blue) + } } } .contentShape(Rectangle()) .onTapGesture { store.send(.tapCustomTime) } } - .disabled(!store.pushNotificationEnable) + .disabled(!store.pushNotificationEnable || store.activeLoadingRow != nil) .opacity(store.pushNotificationEnable ? 1.0 : 0.2) } .listStyle(.insetGrouped) .navigationTitle(String(localized: "nav_push_settings")) - .overlay { if store.isLoading { LoadingView() } } .onAppear { store.send(.fetchSettings) } .alert($store.scope(state: \.alert, action: \.alert)) .sheet(item: $store.scope(state: \.timePicker, action: \.timePicker)) { store in diff --git a/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift b/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift index 63e43082..b8f58491 100644 --- a/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift @@ -5,6 +5,8 @@ // Created by opfic on 6/12/26. // +// swiftlint:disable file_length + import Testing import ComposableArchitecture import Foundation @@ -143,14 +145,48 @@ struct PushNotificationSettingsFeatureTests { await adapter.receiveDelayedLoading() #expect(adapter.isLoading) + #expect(adapter.activeLoadingRow == .enable) fetchSpy.resume() await adapter.drainReceivedActions() #expect(!adapter.isLoading) + #expect(adapter.activeLoadingRow == nil) #expect(adapter.pushNotificationHour == 9) } + @Test("프리셋 시간 업데이트가 지연되면 해당 시간 row에 로딩 상태를 표시한다") + func 프리셋_시간_업데이트가_지연되면_해당_시간_row에_로딩_상태를_표시한다() async { + let clock = TestClock() + let updateSpy = UpdatePushSettingsUseCaseSpy() + updateSpy.shouldSuspend = true + let adapter = PushNotificationSettingsStoreTestAdapter( + updateUseCase: updateSpy, + configureDependencies: { + $0.continuousClock = clock + } + ) + let date = makeDate(hour: 15, minute: 0) + + await adapter.selectPresetTime(date) + + #expect(updateSpy.executeCallCount == 1) + #expect(adapter.activeLoadingRow == .presetTime(hour: 15, minute: 0)) + #expect(!adapter.isLoading) + + await clock.advance(by: .milliseconds(300)) + await adapter.receiveDelayedLoading() + + #expect(adapter.isLoading) + #expect(adapter.activeLoadingRow == .presetTime(hour: 15, minute: 0)) + + updateSpy.resume() + await adapter.drainReceivedActions() + + #expect(!adapter.isLoading) + #expect(adapter.activeLoadingRow == nil) + } + @Test("푸시 설정 조회에 실패하면 공통 에러 알림을 표시한다") func 푸시_설정_조회에_실패하면_공통_에러_알림을_표시한다() async { let fetchSpy = FetchPushSettingsUseCaseSpy() @@ -193,6 +229,7 @@ private struct PushNotificationSettingsStoreTestAdapter { var sheetPushNotificationTime: Date { store.state.timePicker?.time ?? store.state.viewPushNotificationTime } var showTimePicker: Bool { store.state.timePicker != nil } var isLoading: Bool { store.state.isLoading } + var activeLoadingRow: PushNotificationSettingsFeature.ActiveLoadingRow? { store.state.activeLoadingRow } var sheetHeight: CGFloat { store.state.timePicker?.height ?? .pi } var alert: AlertState? { store.state.alert } var pushNotificationHour: Int { store.state.pushNotificationHour } @@ -349,15 +386,40 @@ private final class FetchPushSettingsUseCaseSpy: FetchPushSettingsUseCase { private final class UpdatePushSettingsUseCaseSpy: UpdatePushSettingsUseCase { var error: Error? + var shouldSuspend = false private(set) var executeCallCount = 0 + private var continuation: CheckedContinuation? + private var shouldResume = false func execute(_: PushNotificationSettings) async throws { executeCallCount += 1 + + if shouldSuspend { + await withCheckedContinuation { continuation in + if shouldResume { + shouldResume = false + continuation.resume() + } else { + self.continuation = continuation + } + } + } + if let error { self.error = nil throw error } } + + func resume() { + guard let continuation else { + shouldResume = true + return + } + + self.continuation = nil + continuation.resume() + } } private enum PushNotificationSettingsTestError: Error { From 0266929aa1130b3abd93ed3030e45e69033d49f9 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:11:22 +0900 Subject: [PATCH 05/13] =?UTF-8?q?refactor:=20=EB=82=99=EA=B4=80=EC=A0=81?= =?UTF-8?q?=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=20=EC=B2=B4=ED=81=AC=EB=A7=88=ED=81=AC=20=EC=84=A0=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=ED=9B=84=20ProgressView=EA=B0=80=20?= =?UTF-8?q?=EB=9C=A8=EB=8A=94=20=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/PushNotificationSettingsView.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift index fa93b19d..e557b169 100644 --- a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift +++ b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift @@ -19,7 +19,6 @@ struct PushNotificationSettingsView: View { Spacer() if store.isLoading && store.activeLoadingRow == .enable { ProgressView() - .id(UUID()) } else { Toggle("", isOn: $store.pushNotificationEnable) .labelsHidden() @@ -40,9 +39,9 @@ struct PushNotificationSettingsView: View { if let loadingRow, store.isLoading && store.activeLoadingRow == loadingRow { ProgressView() - .id(UUID()) - } else if store.pushNotificationHour == hour && - store.pushNotificationMinute == 0 { + } else if store.activeLoadingRow != loadingRow + && store.pushNotificationHour == hour + && store.pushNotificationMinute == 0 { Image(systemName: "checkmark") .foregroundStyle(Color.blue) } @@ -56,11 +55,11 @@ struct PushNotificationSettingsView: View { Spacer() if store.isLoading && store.activeLoadingRow == .customTime { ProgressView() - .id(UUID()) } else { Text(formattedTimeString(store.viewPushNotificationTime)) .foregroundStyle(.secondary) - if store.pushNotificationMinute != 0 { + if store.activeLoadingRow != .customTime && + store.pushNotificationMinute != 0 { Image(systemName: "checkmark") .foregroundStyle(Color.blue) } From 7839504c7bf5a431775cd4a4928e4eb76bf26a0a Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:42:29 +0900 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20AccountViewd=EC=97=90=EC=84=9C=20l?= =?UTF-8?q?oading=20=EC=95=A1=EC=85=98=20=EC=8B=9C=20=EC=83=81=EC=8B=9C=20?= =?UTF-8?q?Provider=EA=B0=80=20nil=EC=9D=B4=20=EB=90=98=EC=96=B4=20Progres?= =?UTF-8?q?sView=EA=B0=80=20=EB=B3=B4=EC=9D=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogPresentation/Sources/Settings/AccountFeature.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift b/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift index 9f55dec5..941eaa9a 100644 --- a/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift @@ -71,10 +71,12 @@ struct AccountFeature { state.connectedProviders = allProviders.filter { $0 != currentProvider } state.disconnectedProviders = AuthProvider.allCases .filter { !allProviders.contains($0) } - case .loading: + case .loading(.end): if !state.isLoading { state.activeLoadingProvider = nil } + case .loading: + break } return .none } From 7082990148a7546162c115695a57b46fb554b78f Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:02:15 +0900 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20ProgressView=EC=99=80=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=81=AC=EA=B8=B0=EA=B0=80=20=EB=8B=AC=EB=9D=BC=20?= =?UTF-8?q?row=EA=B0=80=20=EC=99=94=EB=8B=A4=EA=B0=94=EB=8B=A4=20=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Settings/AccountView.swift | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Settings/AccountView.swift b/Application/DevLogPresentation/Sources/Settings/AccountView.swift index f6833338..c392d957 100644 --- a/Application/DevLogPresentation/Sources/Settings/AccountView.swift +++ b/Application/DevLogPresentation/Sources/Settings/AccountView.swift @@ -25,31 +25,34 @@ struct AccountView: View { let providers = AuthProvider.allCases.filter { $0 != store.currentProvider } ForEach(providers, id: \.self) { provider in let isConnected = store.connectedProviders.contains(provider) + let showProgressView = store.isLoading && store.activeLoadingProvider == provider HStack { providerContent(provider) Spacer() - if store.isLoading && store.activeLoadingProvider == provider { - ProgressView() - .id(UUID()) - } else { - Button { - if isConnected { - store.send(.unlinkFromProvider(provider)) - } else { - store.send(.linkWithProvider(provider)) - } - } label: { - Text(isConnected - ? String(localized: "account_disconnect") - : String(localized: "account_connect")) - .font(.caption.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(isConnected ? Color.red : .blue) - .clipShape(.capsule) + Button { + if isConnected { + store.send(.unlinkFromProvider(provider)) + } else { + store.send(.linkWithProvider(provider)) + } + } label: { + Text(isConnected + ? String(localized: "account_disconnect") + : String(localized: "account_connect")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isConnected ? Color.red : .blue) + .clipShape(.capsule) + } + .buttonStyle(.plain) + .disabled(store.isLoading) + .opacity(showProgressView ? 0 : 1) + .overlay { + if showProgressView { + ProgressView() } - .buttonStyle(.plain) } } } From be0239e50192b9eed6c8dccc70d211687574d8e2 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:21:47 +0900 Subject: [PATCH 08/13] =?UTF-8?q?ui:=20TodoEditorView=EC=97=90=20Todo=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=ED=95=98=EB=A9=B4=20=ED=88=B4=EB=B0=94=20=EC=9A=B0?= =?UTF-8?q?=EC=B8=A1=20=EB=B2=84=ED=8A=BC=20=EC=9C=84=EC=B9=98=EC=97=90=20?= =?UTF-8?q?ProgressView=EA=B0=80=20=EB=B3=B4=EC=9D=B4=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Editor/TodoEditorView.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift index c6e26ab7..b565b1f6 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift @@ -67,10 +67,19 @@ struct TodoEditorView: View { Image(systemName: "info.circle") } } - ToolbarTrailingButton { - submit() + if store.isLoading { + if #available(iOS 26.0, *) { + ToolbarSpacer(.fixed, placement: .topBarTrailing) + } + ToolbarItem(placement: .topBarTrailing) { + ProgressView() + } + } else { + ToolbarTrailingButton { + submit() + } + .disabled(!store.isReadyToSubmit) } - .disabled(!store.isReadyToSubmit || store.isLoading) } .alert($store.scope(state: \.alert, action: \.alert)) } From c71aa3a9f5f0e36c2c316fcd36bf6ea43a17bd8c Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:46:35 +0900 Subject: [PATCH 09/13] =?UTF-8?q?ui:=20HomeView=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9B=B9=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EC=8B=9C=20=ED=88=B4=EB=B0=94=20=EC=9C=84=EC=B9=98=EC=97=90=20?= =?UTF-8?q?LoadingView=EA=B0=80=20=EB=9C=A8=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Home/HomeFeature+Effects.swift | 1 + .../Sources/Home/Home/HomeFeature.swift | 1 - .../Sources/Home/Home/HomeView.swift | 20 +++++++++++-------- .../Home/HomeFeatureTestAssertions.swift | 20 +++++++++++++++++++ .../Tests/Home/HomeFeatureTests.swift | 20 +++++++++++++++++++ .../Tests/Support/TestSupport.swift | 4 ++++ 6 files changed, 57 insertions(+), 9 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift index 8a56eca5..32ffbbd8 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift @@ -76,6 +76,7 @@ extension HomeFeature { trackAnalyticsEventUseCase.execute(.webPageCreate) let pages = try await fetchWebPagesUseCase.execute("") await send(.store(.updateWebPages(pages.map(WebPageItem.init(from:))))) + await send(.store(.setSheet(nil))) } catch { await send(.store(.setAlert(isPresented: true, type: .error))) } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift index 04c6b94a..2edde907 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature.swift @@ -253,7 +253,6 @@ private extension HomeFeature { Self.setAlert(&state, isPresented: true, type: .invalidURL) return .none } - state.sheet = nil Self.setAlert(&state, isPresented: false, type: nil) return addWebPageEffect(normalizedURL) case .deleteWebPage(let page): diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 40b122b1..6b516a53 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -38,11 +38,6 @@ struct HomeView: View { .alert($store.scope(state: \.alert, action: \.alert)) .sheet(item: $store.scope(state: \.sheet, action: \.sheet), content: sheetContent) .fullScreenCover(item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover), content: coverContent) - .overlay { - if store.isAppending { - LoadingView() - } - } } private var todoSection: some View { @@ -234,9 +229,18 @@ struct HomeView: View { .navigationTitle(Text(String(localized: "home_webpage_input_title"))) .navigationBarTitleDisplayMode(.inline) // 설정 안하면 섹션 위에 내비게이션 large 만큼 영역 먹음 .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(String(localized: "home_add")) { - store.send(.view(.addWebPage)) + if store.isAppending { + if #available(iOS 26.0, *) { + ToolbarSpacer(.fixed, placement: .topBarTrailing) + } + ToolbarItem(placement: .topBarTrailing) { + ProgressView() + } + } else { + ToolbarItem(placement: .topBarTrailing) { + Button(String(localized: "home_add")) { + store.send(.view(.addWebPage)) + } } } } diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift index 96d21f2b..c95a509d 100644 --- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestAssertions.swift @@ -101,6 +101,7 @@ func verifyHomeAddWebPage( fetchWebPagesUseCaseSpy: FetchWebPagesUseCaseSpy, trackAnalyticsEventUseCaseSpy: HomeTrackAnalyticsEventUseCaseSpy ) async throws { + await adapter.setPresentation(.contentPicker, true) await adapter.updateWebPageURLInput("openai.com") await adapter.addWebPage() @@ -116,9 +117,28 @@ func verifyHomeAddWebPage( "https://openai.com", "https://developer.apple.com" ]) + #expect(!adapter.showContentPicker) #expect(!adapter.showAlert) } +@MainActor +func verifyHomeAddWebPageFailureKeepsSheet( + adapter: HomeStoreTestAdapter, + addWebPageUseCaseSpy: AddWebPageUseCaseSpy +) async throws { + await adapter.setPresentation(.contentPicker, true) + await adapter.updateWebPageURLInput("openai.com") + await adapter.addWebPage() + + await waitUntil { + addWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] + && adapter.showAlert + } + + #expect(adapter.showContentPicker) + #expect(adapter.alertType == .error) +} + struct HomeFetchDataContext { let fetchPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCaseSpy let fetchTodosUseCaseSpy: FetchTodosUseCaseSpy diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift index 383269f0..a7da6f02 100644 --- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTests.swift @@ -74,6 +74,22 @@ struct HomeFeatureTests { ) } + @Test("HomeFeature addWebPage 실패는 입력 시트를 유지한다") + func HomeFeature_addWebPage_실패는_입력_시트를_유지한다() async throws { + let context = makeHomeAddWebPageContext() + context.addWebPageUseCaseSpy.error = HomeTestError.failure + let adapter = HomeStoreTestAdapter( + addWebPageUseCase: context.addWebPageUseCaseSpy, + fetchWebPagesUseCase: context.fetchWebPagesUseCaseSpy, + trackAnalyticsEventUseCase: context.trackAnalyticsEventUseCaseSpy + ) + + try await verifyHomeAddWebPageFailureKeepsSheet( + adapter: adapter, + addWebPageUseCaseSpy: context.addWebPageUseCaseSpy + ) + } + @Test("웹페이지를 삭제하면 항목이 즉시 숨겨지고 삭제 유스케이스가 호출된다") func 웹페이지를_삭제하면_항목이_즉시_숨겨지고_삭제_유스케이스가_호출된다() async throws { let context = makeHomeDeleteContext() @@ -143,3 +159,7 @@ struct HomeFeatureTests { #expect(!adapter.isNetworkConnected) } } + +private enum HomeTestError: Error { + case failure +} diff --git a/Application/DevLogPresentation/Tests/Support/TestSupport.swift b/Application/DevLogPresentation/Tests/Support/TestSupport.swift index 2172c7ac..42b23d07 100644 --- a/Application/DevLogPresentation/Tests/Support/TestSupport.swift +++ b/Application/DevLogPresentation/Tests/Support/TestSupport.swift @@ -155,10 +155,14 @@ final class UpdateTodoCategoryPreferencesUseCaseSpy: UpdateTodoCategoryPreferenc } final class AddWebPageUseCaseSpy: AddWebPageUseCase { + var error: Error? private(set) var calledUrlStrings: [String] = [] func execute(_ urlString: String) async throws { calledUrlStrings.append(urlString) + if let error { + throw error + } } } From 42a9f9501256ab73b19bce66e29545ef559326b9 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:09:34 +0900 Subject: [PATCH 10/13] =?UTF-8?q?ui:=20PushNotficationSettingsView?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=94=BC=EC=BB=A4=EC=9A=A9=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20=ED=88=B4=EB=B0=94=EC=97=90=20ProgressView=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationSettingsFeature.swift | 2 +- .../PushNotificationSettingsView.swift | 36 +++++++++++------- ...PushNotificationSettingsFeatureTests.swift | 38 ++++++++++++++++++- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift index b1a98385..7f91eda8 100644 --- a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift @@ -89,7 +89,6 @@ struct PushNotificationSettingsFeature { state.timePicker = nil case .timePicker(.presented(.tapDoneButton)): guard let time = state.timePicker?.time else { break } - state.timePicker = nil state.viewPushNotificationTime = time state.activeLoadingRow = .customTime return updatePushNotificationSettingsEffect(settings: Self.settings(from: state)) @@ -202,6 +201,7 @@ private extension PushNotificationSettingsFeature { do { try await updatePushSettingsUseCase.execute(settings) await send(.loading(.end(target: .default, mode: .delayed))) + await send(.timePicker(.dismiss)) await send(.clearActiveLoadingRow) } catch { await send(.loading(.end(target: .default, mode: .delayed))) diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift index e557b169..043a9d85 100644 --- a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift +++ b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsView.swift @@ -53,16 +53,11 @@ struct PushNotificationSettingsView: View { HStack { Text(String(localized: "push_settings_custom")) Spacer() - if store.isLoading && store.activeLoadingRow == .customTime { - ProgressView() - } else { - Text(formattedTimeString(store.viewPushNotificationTime)) - .foregroundStyle(.secondary) - if store.activeLoadingRow != .customTime && - store.pushNotificationMinute != 0 { - Image(systemName: "checkmark") - .foregroundStyle(Color.blue) - } + Text(formattedTimeString(store.viewPushNotificationTime)) + .foregroundStyle(.secondary) + if store.pushNotificationMinute != 0 { + Image(systemName: "checkmark") + .foregroundStyle(Color.blue) } } .contentShape(Rectangle()) @@ -75,8 +70,11 @@ struct PushNotificationSettingsView: View { .navigationTitle(String(localized: "nav_push_settings")) .onAppear { store.send(.fetchSettings) } .alert($store.scope(state: \.alert, action: \.alert)) - .sheet(item: $store.scope(state: \.timePicker, action: \.timePicker)) { store in - TimePickerView(store: store) + .sheet(item: $store.scope(state: \.timePicker, action: \.timePicker)) { timePickerStore in + TimePickerView( + store: timePickerStore, + showsProgressView: store.isLoading && store.activeLoadingRow == .customTime + ) } } @@ -90,6 +88,7 @@ private struct TimePickerView: View { PushNotificationSettingsFeature.TimePickerState, PushNotificationSettingsFeature.Action.TimePicker > + let showsProgressView: Bool var body: some View { NavigationStack { @@ -106,8 +105,17 @@ private struct TimePickerView: View { ToolbarLeadingButton { store.send(.tapCloseButton) } - ToolbarTrailingButton { - store.send(.tapDoneButton) + if showsProgressView { + if #available(iOS 26.0, *) { + ToolbarSpacer(.fixed, placement: .topBarTrailing) + } + ToolbarItem(placement: .topBarTrailing) { + ProgressView() + } + } else { + ToolbarTrailingButton { + store.send(.tapDoneButton) + } } } .background( diff --git a/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift b/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift index b8f58491..8f091504 100644 --- a/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Settings/PushNotificationSettingsFeatureTests.swift @@ -187,6 +187,42 @@ struct PushNotificationSettingsFeatureTests { #expect(adapter.activeLoadingRow == nil) } + @Test("커스텀 시간 업데이트가 지연되면 시트를 유지하고 Done 버튼 로딩 상태를 표시한다") + func 커스텀_시간_업데이트가_지연되면_시트를_유지하고_Done_버튼_로딩_상태를_표시한다() async { + let clock = TestClock() + let updateSpy = UpdatePushSettingsUseCaseSpy() + updateSpy.shouldSuspend = true + let adapter = PushNotificationSettingsStoreTestAdapter( + updateUseCase: updateSpy, + configureDependencies: { + $0.continuousClock = clock + } + ) + let date = makeDate(hour: 10, minute: 35) + + await adapter.setShowTimePicker(true) + await adapter.setPushNotificationTime(sheet: date) + await adapter.confirmUpdate() + + #expect(adapter.showTimePicker) + #expect(adapter.activeLoadingRow == .customTime) + #expect(!adapter.isLoading) + + await clock.advance(by: .milliseconds(300)) + await adapter.receiveDelayedLoading() + + #expect(adapter.showTimePicker) + #expect(adapter.isLoading) + #expect(adapter.activeLoadingRow == .customTime) + + updateSpy.resume() + await adapter.drainReceivedActions() + + #expect(!adapter.showTimePicker) + #expect(!adapter.isLoading) + #expect(adapter.activeLoadingRow == nil) + } + @Test("푸시 설정 조회에 실패하면 공통 에러 알림을 표시한다") func 푸시_설정_조회에_실패하면_공통_에러_알림을_표시한다() async { let fetchSpy = FetchPushSettingsUseCaseSpy() @@ -309,10 +345,10 @@ private struct PushNotificationSettingsStoreTestAdapter { func confirmUpdate() async { let time = store.state.timePicker?.time await store.send(.timePicker(.presented(.tapDoneButton))) { - $0.timePicker = nil if let time { $0.viewPushNotificationTime = time } + $0.activeLoadingRow = .customTime } await drainReceivedActions() } From 3281983da1af06e971c32fb163cb913fa32f50b1 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:19:58 +0900 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20SettingsView=20=EB=A1=9C=EB=94=A9?= =?UTF-8?q?=20row=20=EC=83=81=ED=83=9C=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Settings/SettingsFeature.swift | 2 +- .../Tests/Settings/SettingsFeatureTests.swift | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift b/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift index 9efe099e..03e1ed4d 100644 --- a/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift @@ -125,7 +125,7 @@ struct SettingsFeature { state.alertType = .removeCache case .loading: if !state.isLoading { -// state.activeLoadingRow = nil + state.activeLoadingRow = nil } } diff --git a/Application/DevLogPresentation/Tests/Settings/SettingsFeatureTests.swift b/Application/DevLogPresentation/Tests/Settings/SettingsFeatureTests.swift index f74f8dd2..301a09c0 100644 --- a/Application/DevLogPresentation/Tests/Settings/SettingsFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Settings/SettingsFeatureTests.swift @@ -90,6 +90,7 @@ struct SettingsFeatureTests { #expect(!adapter.showAlert) #expect(adapter.dirSize == 0) + #expect(adapter.activeLoadingRow == nil) } @Test("캐시 삭제에 실패하면 공통 에러 알림을 표시한다") @@ -104,6 +105,7 @@ struct SettingsFeatureTests { #expect(adapter.showAlert) #expect(adapter.alertTitle == String(localized: "common_error_title")) #expect(adapter.alertMessage == String(localized: "common_error_message")) + #expect(adapter.activeLoadingRow == nil) } @Test("로그아웃 성공 후에도 LoginView 전환 전까지 로딩 상태를 유지한다") @@ -128,6 +130,18 @@ struct SettingsFeatureTests { #expect(adapter.activeLoadingRow == .signOut) } + @Test("로그아웃 실패 시 로딩 row 상태를 해제한다") + func 로그아웃_실패_시_로딩_row_상태를_해제한다() async { + let signOutSpy = SignOutUseCaseSpy() + signOutSpy.error = SettingsTestError.failure + let adapter = SettingsStoreTestAdapter(signOutUseCase: signOutSpy) + + await adapter.tapSignOutButton() + + #expect(adapter.showAlert) + #expect(adapter.activeLoadingRow == nil) + } + @Test("회원 탈퇴 실패 시 공통 에러 알림을 표시한다") func 회원_탈퇴_실패_시_공통_에러_알림을_표시한다() async { let deleteSpy = DeleteAuthUseCaseSpy() @@ -139,6 +153,7 @@ struct SettingsFeatureTests { #expect(deleteSpy.executeCallCount == 1) #expect(adapter.showAlert) #expect(adapter.alertTitle == String(localized: "common_error_title")) + #expect(adapter.activeLoadingRow == nil) } } From 854e83424f1d90a74736112f431605e8b9e86eb3 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:07:29 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20Settings=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=95=EB=A6=AC=20=EC=8B=9C=EC=A0=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/PushNotificationSettingsFeature.swift | 14 +++++++++++--- .../Sources/Settings/SettingsFeature.swift | 4 +++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift index 7f91eda8..220dbfb9 100644 --- a/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/PushNotificationSettingsFeature.swift @@ -91,7 +91,10 @@ struct PushNotificationSettingsFeature { guard let time = state.timePicker?.time else { break } state.viewPushNotificationTime = time state.activeLoadingRow = .customTime - return updatePushNotificationSettingsEffect(settings: Self.settings(from: state)) + return updatePushNotificationSettingsEffect( + settings: Self.settings(from: state), + dismissesTimePickerOnSuccess: true + ) case .timePicker: break case .fetchSettings: @@ -195,13 +198,18 @@ private extension PushNotificationSettingsFeature { } } - func updatePushNotificationSettingsEffect(settings: PushNotificationSettings) -> Effect { + func updatePushNotificationSettingsEffect( + settings: PushNotificationSettings, + dismissesTimePickerOnSuccess: Bool = false + ) -> Effect { .run { [updatePushSettingsUseCase] send in await send(.loading(.begin(target: .default, mode: .delayed))) do { try await updatePushSettingsUseCase.execute(settings) await send(.loading(.end(target: .default, mode: .delayed))) - await send(.timePicker(.dismiss)) + if dismissesTimePickerOnSuccess { + await send(.timePicker(.dismiss)) + } await send(.clearActiveLoadingRow) } catch { await send(.loading(.end(target: .default, mode: .delayed))) diff --git a/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift b/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift index 03e1ed4d..25baa805 100644 --- a/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift @@ -123,10 +123,12 @@ struct SettingsFeature { case .tapRemoveCacheButton: state.alert = Self.alertState(for: .removeCache) state.alertType = .removeCache - case .loading: + case .loading(.end): if !state.isLoading { state.activeLoadingRow = nil } + case .loading: + break } return .none From 18d8c5fd79518fe6568684c20d4399bc3f20f109 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:38:16 +0900 Subject: [PATCH 13/13] =?UTF-8?q?chore:=20CI=20=EC=9E=AC=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit