From 86875a0404d9f212c701be392aec1fbbea8a5933 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 19 Feb 2026 14:32:45 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20ProfileView=EC=97=90=20=EC=96=BC?= =?UTF-8?q?=EB=9F=BF=20=EB=9D=84=EC=9A=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/ProfileViewModel.swift | 47 +++++++++++++------ DevLog/UI/Profile/ProfileView.swift | 13 +++-- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index c934bd63..1da72e63 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -13,10 +13,10 @@ final class ProfileViewModel: Store { var email: String = "" var statusMessage: String = "" var avatarURL: URL? - var showDoneButton: Bool = false - var showToast: Bool = false - var toastMessage: String = "" + var showAlert: Bool = false + var alertTitle: String = "" + var alertMessage: String = "" var resetButtonEnabled: Bool { !statusMessage.isEmpty && showDoneButton } @@ -24,13 +24,12 @@ final class ProfileViewModel: Store { enum Action { case onAppear - case tapConfirmButton + case setAlert(Bool) case tapResetStatusMessageButton case willUpdateStatusMessage case fetchUserData(UserProfile) case updateStatusMessage(String) case updateStatusTextFieldFocus(Bool) - case tapCloseToast } enum SideEffect { @@ -52,11 +51,12 @@ final class ProfileViewModel: Store { func reduce(with action: Action) -> [SideEffect] { var state = self.state + var effects: [SideEffect] = [] switch action { case .onAppear: - return [.fetchUserData] - case .tapConfirmButton: - state.showToast = false + effects = [.fetchUserData] + case .setAlert(let isPresented): + setAlert(&state, isPresented: isPresented) case .tapResetStatusMessageButton: state.statusMessage = "" case .fetchUserData(let profile): @@ -66,29 +66,46 @@ final class ProfileViewModel: Store { state.avatarURL = profile.avatarURL case .willUpdateStatusMessage: let message = self.state.statusMessage - return [.updateStatusMessage(message)] + effects = [.updateStatusMessage(message)] case .updateStatusMessage(let message): state.statusMessage = message case .updateStatusTextFieldFocus(let focused): state.showDoneButton = focused - case .tapCloseToast: - state.showToast = false } self.state = state - return [] + return effects } func run(_ effect: SideEffect) { switch effect { case .fetchUserData: Task { - let profile = try await fetchUserDataUseCase.execute() - send(.fetchUserData(profile)) + do { + let profile = try await fetchUserDataUseCase.execute() + send(.fetchUserData(profile)) + } catch { + send(.setAlert(true)) + } } case .updateStatusMessage(let message): Task { - try await upsertStatusMessageUseCase.execute(message) + do { + try await upsertStatusMessageUseCase.execute(message) + } catch { + send(.setAlert(true)) + } } } } } + +private extension ProfileViewModel { + func setAlert( + _ state: inout State, + isPresented: Bool + ) { + state.alertTitle = "오류" + state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요." + state.showAlert = isPresented + } +} diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 0db61dea..5f10592f 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -108,15 +108,14 @@ struct ProfileView: View { viewModel.send(.updateStatusTextFieldFocus(newValue)) } } - .alert("", isPresented: Binding( - get: { viewModel.state.showToast }, - set: { _, _ in } + .alert( + "", isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert($0)) } )) { - Button("확인") { - viewModel.send(.tapCloseToast) - } + Button("확인", role: .cancel) { } } message: { - Text(viewModel.state.toastMessage) + Text(viewModel.state.alertMessage) } } } From b42ef152d0a43f4a904329425164dc98e40ac593 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 19 Feb 2026 15:53:17 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20PushNotificationSettingsView?= =?UTF-8?q?=EC=97=90=20=EC=96=BC=EB=9F=BF=20=EB=9D=84=EC=9A=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationSettingsViewModel.swift | 67 ++++++++++++++----- .../PushNotificationSettingsView.swift | 5 ++ 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift index 0bd73c41..a2942b88 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift @@ -9,10 +9,15 @@ import Foundation final class PushNotificationSettingsViewModel: Store { struct State { - var pushNotificationEnable = false - var pushNotificationTime = Date() - var showTimePicker = false - var sheetHeight = CGFloat.pi + var pushNotificationEnable: Bool = false + var pushNotificationTime: Date = .init() + var showTimePicker: Bool = false + var isLoading: Bool = false + var sheetHeight: CGFloat = .pi + var showSheet: Bool = false + var showAlert: Bool = false + var alertTitle: String = "" + var alertMessage: String = "" var pushNotificationHour: Int { Calendar.current.component(.hour, from: pushNotificationTime) } @@ -23,6 +28,8 @@ final class PushNotificationSettingsViewModel: Store { enum Action { case onAppear + case setAlert(Bool) + case setLoading(Bool) case setPushNotificationEnable(Bool) case setPushNotificationHour(Int) case setPushNotificationTime(Date) @@ -53,6 +60,10 @@ final class PushNotificationSettingsViewModel: Store { switch action { case .onAppear: return [.fetchPushNotificationSettings] + case .setAlert(let isPresented): + setAlert(&state, isPresented: isPresented) + case .setLoading(let value): + state.isLoading = value case .setPushNotificationEnable(let value): self.state.pushNotificationEnable = value return [.updatePushNotificationSettings] @@ -82,24 +93,46 @@ final class PushNotificationSettingsViewModel: Store { switch effect { case .fetchPushNotificationSettings: Task { - let settings = try await fetchPushSettingsUseCase.execute() - self.send(.setPushNotificationEnable(settings.isEnabled)) - if let hour = settings.scheduledTime.hour, - let minute = settings.scheduledTime.minute, - let date = calendar.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) { - self.send(.setPushNotificationTime(date)) + do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) + let settings = try await fetchPushSettingsUseCase.execute() + self.send(.setPushNotificationEnable(settings.isEnabled)) + if let hour = settings.scheduledTime.hour, + let minute = settings.scheduledTime.minute, + let date = calendar.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) { + self.send(.setPushNotificationTime(date)) + } + } catch { + send(.setAlert(true)) } } case .updatePushNotificationSettings: Task { - let dateComponents = calendar.dateComponents([.hour, .minute], from: state.pushNotificationTime) - let settings = PushNotificationSettings( - isEnabled: state.pushNotificationEnable, - scheduledTime: dateComponents - ) - - try await updatePushSettingsUseCase.execute(settings) + do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) + let dateComponents = calendar.dateComponents([.hour, .minute], from: state.pushNotificationTime) + let settings = PushNotificationSettings( + isEnabled: state.pushNotificationEnable, + scheduledTime: dateComponents + ) + try await updatePushSettingsUseCase.execute(settings) + } catch { + send(.setAlert(true)) + } } } } } + +extension PushNotificationSettingsViewModel { + func setAlert( + _ state: inout State, + isPresented: Bool + ) { + state.alertTitle = "오류" + state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요." + state.showAlert = isPresented + } +} diff --git a/DevLog/UI/Setting/PushNotificationSettingsView.swift b/DevLog/UI/Setting/PushNotificationSettingsView.swift index 3cc05b9c..9231a645 100644 --- a/DevLog/UI/Setting/PushNotificationSettingsView.swift +++ b/DevLog/UI/Setting/PushNotificationSettingsView.swift @@ -61,6 +61,11 @@ struct PushNotificationSettingsView: View { } .listStyle(.insetGrouped) .navigationTitle("알람") + .overlay { + if viewModel.state.isLoading { + LoadingView() + } + } .onAppear { viewModel.send(.onAppear) } From 752c44143ef0b3d5642909ecb7f8a473a2db3310 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 19 Feb 2026 16:04:01 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20SearchView=EC=97=90=20=EC=96=BC?= =?UTF-8?q?=EB=9F=BF=20=EB=9D=84=EC=9A=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/SearchViewModel.swift | 19 ++++++++++++++++++- DevLog/UI/Search/SearchView.swift | 8 ++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift index e2cf7e2c..3b4c38ad 100644 --- a/DevLog/Presentation/ViewModel/SearchViewModel.swift +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -21,12 +21,16 @@ final class SearchViewModel: Store { $0.displayURL.localizedCaseInsensitiveContains(searchQuery) } } + var showAlert: Bool = false + var alertTitle: String = "" + var alertMessage: String = "" } enum Action { case onAppear case fetchWebPage([WebPageItem]? = nil) case selectWebPage(WebPageItem) + case setAlert(Bool) case setLoading(Bool) case setSearching(Bool) case setSearchQuery(String) @@ -59,6 +63,8 @@ final class SearchViewModel: Store { state.webPages = OrderedSet(items) case .selectWebPage(let item): state.selectedWebPage = item + case .setAlert(let isPresented): + setAlert(&state, isPresented: isPresented) case .setLoading(let isLoading): state.isLoading = isLoading case .setSearching(let isSearching): @@ -81,9 +87,20 @@ final class SearchViewModel: Store { let items = try await self.fetchWebPagesUseCase.execute().map { WebPageItem(from: $0) } send(.fetchWebPage(items)) } catch { - + send(.setAlert(true)) } } } } } + +private extension SearchViewModel { + func setAlert( + _ state: inout State, + isPresented: Bool + ) { + state.alertTitle = "오류" + state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요." + state.showAlert = isPresented + } +} diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index f492405d..d729f1f8 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -36,6 +36,14 @@ struct SearchView: View { dismiss() } } + .alert(viewModel.state.alertTitle, isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert($0)) } + )) { + Button("확인", role: .cancel) { } + } message: { + Text(viewModel.state.alertMessage) + } // TODO: iOS 16에서 introspect 모듈을 사용하여 .searchable의 isPresented를 관리한다 // .introspect(.searchField, on: iOS(.v16)) { searchBar in } From d6132a6f18c823c8c7c52f97ea974274d8f8c1fe Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 19 Feb 2026 18:10:59 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20SettingView=EC=97=90=20?= =?UTF-8?q?=EC=96=BC=EB=9F=BF=20=EB=9D=84=EC=9A=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/SettingViewModel.swift | 64 +++++++++------ DevLog/Resource/Localizable.xcstrings | 9 --- DevLog/UI/Setting/SettingView.swift | 79 ++++++++----------- 3 files changed, 76 insertions(+), 76 deletions(-) diff --git a/DevLog/Presentation/ViewModel/SettingViewModel.swift b/DevLog/Presentation/ViewModel/SettingViewModel.swift index bf28394c..38169df8 100644 --- a/DevLog/Presentation/ViewModel/SettingViewModel.swift +++ b/DevLog/Presentation/ViewModel/SettingViewModel.swift @@ -10,24 +10,21 @@ import Foundation final class SettingViewModel: Store { struct State { var theme = "" - var showDeleteUserAlert = false - var showSignOutAlert = false - var toastMessage = "" - var showToast = false var isLoading = false + var showAlert: Bool = false + var alertTitle: String = "" + var alertType: AlertType? + var alertMessage: String = "" let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String let policyURL = Bundle.main.object(forInfoDictionaryKey: "PRIVACY_POLICY_URL") as? String } enum Action { - case toggleToast(Bool) + case setAlert(isPresented: Bool, type: AlertType? = nil) case setLoading(Bool) case setTheme(String) - case setToastMessage(String) case tapDeleteAuthButton case tapSignOutButton - case toggleDeleteUserAlert(Bool) - case toggleSignOutAlert(Bool) } enum SideEffect { @@ -35,6 +32,10 @@ final class SettingViewModel: Store { case signOut } + enum AlertType { + case signOut, delete, error + } + private let deleteAuthuseCase: DeleteAuthUseCase private let signOutUseCase: SignOutUseCase private let sessionUseCase: AuthSessionUseCase @@ -53,22 +54,16 @@ final class SettingViewModel: Store { func reduce(with action: Action) -> [SideEffect] { switch action { - case .toggleToast(let value): - state.showToast = value + case .setAlert(let isPresented, let type): + setAlert(&state, isPresented: isPresented, type: type) case .setLoading(let value): state.isLoading = value case .setTheme(let value): state.theme = value - case .setToastMessage(let message): - state.toastMessage = message case .tapDeleteAuthButton: - break + return [.deleteAuth] case .tapSignOutButton: return [.signOut] - case .toggleDeleteUserAlert(let value): - state.showDeleteUserAlert = value - case .toggleSignOutAlert(let value): - state.showSignOutAlert = value } return [] } @@ -79,27 +74,50 @@ final class SettingViewModel: Store { Task { do { defer { send(.setLoading(false)) } - send(.toggleDeleteUserAlert(false)) + send(.setAlert(isPresented: false)) send(.setLoading(true)) try await deleteAuthuseCase.execute() } catch { - send(.toggleToast(true)) - send(.setToastMessage(error.localizedDescription)) + send(.setAlert(isPresented: true, type: .error)) } } case .signOut: Task { do { defer { send(.setLoading(false)) } - send(.toggleSignOutAlert(false)) + send(.setAlert(isPresented: false)) send(.setLoading(true)) try await signOutUseCase.execute() sessionUseCase.execute(false) } catch { - send(.toggleToast(true)) - send(.setToastMessage(error.localizedDescription)) + send(.setAlert(isPresented: true, type: .error)) } } } } } + +private extension SettingViewModel { + func setAlert( + _ state: inout State, + isPresented: Bool, + type: AlertType? + ) { + switch type { + case .signOut: + state.alertTitle = "로그아웃" + state.alertMessage = "로그아웃 하시겠습니까?" + case .delete: + state.alertTitle = "정말 탈퇴하시겠습니까?" + state.alertMessage = "회원 탈퇴가 진행되면 모든 데이터가 지워지고 복구할 수 없습니다." + case .error: + state.alertTitle = "오류" + state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요." + case .none: + state.alertTitle = "" + state.alertMessage = "" + } + state.showAlert = isPresented + state.alertType = type + } +} diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index da35c223..9d491204 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -277,9 +277,6 @@ }, "로그아웃" : { - }, - "로그아웃하시겠습니까?" : { - }, "로그인하면 사용 약관 및 개인 정보 취급 방침에 동의하게 됩니다." : { @@ -361,9 +358,6 @@ }, "정렬: %@" : { - }, - "정말 탈퇴하시겠습니까?" : { - }, "제목" : { @@ -421,9 +415,6 @@ }, "회원 탈퇴" : { - }, - "회원 탈퇴가 진행되면 모든 데이터가 지워지고 복구할 수 없습니다." : { - } }, "version" : "1.0" diff --git a/DevLog/UI/Setting/SettingView.swift b/DevLog/UI/Setting/SettingView.swift index 36f89319..ebd72518 100644 --- a/DevLog/UI/Setting/SettingView.swift +++ b/DevLog/UI/Setting/SettingView.swift @@ -84,7 +84,7 @@ struct SettingView: View { Text("계정 연동") } Button(role: .destructive, action: { - viewModel.send(.toggleSignOutAlert(true)) + viewModel.send(.setAlert(isPresented: true, type: .signOut)) }) { Text("로그아웃") } @@ -93,7 +93,7 @@ struct SettingView: View { HStack { Spacer() Button(role: .destructive, action: { - viewModel.send(.toggleDeleteUserAlert(true)) + viewModel.send(.setAlert(isPresented: true, type: .delete)) }) { Text("회원 탈퇴") .font(.headline) @@ -124,49 +124,16 @@ struct SettingView: View { ) } } - .alert("로그아웃", isPresented: Binding( - get: { viewModel.state.showSignOutAlert }, set: { _, _ in } - )) { - Button(role: .cancel, action: { - viewModel.send(.toggleSignOutAlert(false)) - }) { - Text("취소") + .alert( + viewModel.state.alertTitle, + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert(isPresented: $0)) } + )) { + alertButtons + } message: { + Text(viewModel.state.alertMessage) } - Button(role: .destructive, action: { - viewModel.send(.tapSignOutButton) - }) { - Text("확인") - } - } message: { - Text("로그아웃하시겠습니까?") - } - .alert("정말 탈퇴하시겠습니까?", isPresented: Binding( - get: { viewModel.state.showDeleteUserAlert }, set: { _, _ in } - )) { - Button(role: .cancel, action: { - viewModel.send(.toggleDeleteUserAlert(false)) - }) { - Text("취소") - } - Button(role: .destructive, action: { - viewModel.send(.tapDeleteAuthButton) - }) { - Text("탈퇴") - } - } message: { - Text("회원 탈퇴가 진행되면 모든 데이터가 지워지고 복구할 수 없습니다.") - } - .alert("", isPresented: Binding( - get: { viewModel.state.showToast }, set: { _, _ in } - )) { - Button(role: .cancel, action: { - viewModel.send(.toggleToast(false)) - }) { - Text("확인") - } - } message: { - Text(viewModel.state.toastMessage) - } .overlay { if viewModel.state.isLoading { LoadingView() @@ -177,4 +144,28 @@ struct SettingView: View { private enum Path: Hashable { case theme, pushNotification, account } + + @ViewBuilder + private var alertButtons: some View { + switch viewModel.state.alertType { + case .signOut: + Button("취소", role: .cancel) { + viewModel.send(.setAlert(isPresented: false)) + } + Button("확인", role: .destructive) { + viewModel.send(.tapSignOutButton) + } + case .delete: + Button("취소", role: .cancel) { + viewModel.send(.setAlert(isPresented: false)) + } + Button("탈퇴", role: .destructive) { + viewModel.send(.tapDeleteAuthButton) + } + case .error, .none: + Button("확인", role: .cancel) { + viewModel.send(.setAlert(isPresented: false)) + } + } + } } From cbccb3c20541ad01e600ec79ab434a5eb2bce0d8 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 19 Feb 2026 18:14:55 +0900 Subject: [PATCH 5/5] =?UTF-8?q?style:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Presentation/ViewModel/TodoEditorViewModel.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift index c59eb77e..8942b765 100644 --- a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift @@ -104,8 +104,6 @@ final class TodoEditorViewModel: Store { } return [] } - - func run(_ effect: SideEffect) { } } extension TodoEditorViewModel {