From e1a428aa8f292d5518a66ec50439671cfc0a67dd Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 7 Apr 2026 09:43:51 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=200.3=EC=B4=88=EB=A1=9C=20?= =?UTF-8?q?=EB=94=9C=EB=A0=88=EC=9D=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Presentation/Common/LoadingState.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DevLog/Presentation/Common/LoadingState.swift b/DevLog/Presentation/Common/LoadingState.swift index 7a730653..42c35c73 100644 --- a/DevLog/Presentation/Common/LoadingState.swift +++ b/DevLog/Presentation/Common/LoadingState.swift @@ -25,7 +25,7 @@ final class LoadingState { private var visibleDelayedTargets = Set() private var visibleTargets = Set() - init(delay: Duration = .milliseconds(500)) { + init(delay: Duration = .seconds(0.3)) { self.delay = delay } From 8d3af59f05102536874f0d2d3051d110538a2b22 Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 7 Apr 2026 10:03:56 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20.immediate=20=EB=8C=80=EC=8B=A0?= =?UTF-8?q?=20.delay=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Presentation/ViewModel/HomeViewModel.swift | 12 ++++++------ DevLog/Presentation/ViewModel/ProfileViewModel.swift | 4 ++-- .../ViewModel/PushNotificationListViewModel.swift | 6 +++--- .../PushNotificationSettingsViewModel.swift | 4 ++-- DevLog/Presentation/ViewModel/TodayViewModel.swift | 4 ++-- .../Presentation/ViewModel/TodoDetailViewModel.swift | 4 ++-- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index efc26925..e3a5f39d 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -157,10 +157,10 @@ final class HomeViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetchTodoCategoryPreferences: - beginLoading(for: .preferences, mode: .immediate) + beginLoading(for: .preferences, mode: .delayed) Task { do { - defer { endLoading(for: .preferences, mode: .immediate) } + defer { endLoading(for: .preferences, mode: .delayed) } let preferences = try await fetchPreferencesUseCase.execute() send(.setTodoCategory(preferences.map(TodoCategoryItem.init(from:)))) } catch { @@ -192,10 +192,10 @@ final class HomeViewModel: Store { } } case .fetchRecentTodos: - beginLoading(for: .recentTodos, mode: .immediate) + beginLoading(for: .recentTodos, mode: .delayed) Task { do { - defer { endLoading(for: .recentTodos, mode: .immediate) } + defer { endLoading(for: .recentTodos, mode: .delayed) } let page = try await fetchRecentTodos() let items = page.items .filter { $0.createdAt != $0.updatedAt } @@ -254,10 +254,10 @@ final class HomeViewModel: Store { } } case .fetchWebPages: - beginLoading(for: .webPage, mode: .immediate) + beginLoading(for: .webPage, mode: .delayed) Task { do { - defer { endLoading(for: .webPage, mode: .immediate) } + defer { endLoading(for: .webPage, mode: .delayed) } let pages = try await fetchWebPagesUseCase.execute("") send(.updateWebPages(pages.map { WebPageItem(from: $0) })) } catch { diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 3d95a557..16ee4695 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -201,10 +201,10 @@ final class ProfileViewModel: Store { } } case .fetchActivityQuarter(let quarterStart): - beginLoading(mode: .immediate) + beginLoading(mode: .delayed) Task { do { - defer { endLoading(mode: .immediate) } + defer { endLoading(mode: .delayed) } let quarterActivityData = try await fetchQuarterActivityData(from: quarterStart) send( .setActivityQuarter( diff --git a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift index f6386d4f..7738db5b 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift @@ -118,10 +118,10 @@ final class PushNotificationListViewModel: Store { if cursor == nil { stopObservingNotifications() } - beginLoading(.immediate) + beginLoading(.delayed) Task { do { - defer { endLoading(.immediate) } + defer { endLoading(.delayed) } let existingCount = cursor == nil ? 0 : self.state.notifications.count let page = try await fetchUseCase.execute(query, cursor: cursor) @@ -160,7 +160,7 @@ final class PushNotificationListViewModel: Store { beginLoading(.delayed) Task { // endLoading(.delayed)를 defer로 두지 않는 이유 - // send(.fetchNotifications)가 같은 턴에서 beginLoading(.immediate)를 먼저 올린 뒤 + // send(.fetchNotifications)가 같은 턴에서 beginLoading(.delayed)를 먼저 올린 뒤 // delayed 로딩을 내려야 같은 isLoading이 끊기지 않기 때문 do { try await undoDeleteUseCase.execute(notificationId) diff --git a/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift index f40e0cb3..6bbaf64f 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationSettingsViewModel.swift @@ -107,10 +107,10 @@ final class PushNotificationSettingsViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetchPushNotificationSettings: - beginLoading(.immediate) + beginLoading(.delayed) Task { do { - defer { endLoading(.immediate) } + defer { endLoading(.delayed) } let settings = try await fetchPushSettingsUseCase.execute() self.send(.setPushNotificationEnable(settings.isEnabled)) if let hour = settings.scheduledTime.hour, diff --git a/DevLog/Presentation/ViewModel/TodayViewModel.swift b/DevLog/Presentation/ViewModel/TodayViewModel.swift index d14bff96..a4e7b113 100644 --- a/DevLog/Presentation/ViewModel/TodayViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodayViewModel.swift @@ -189,10 +189,10 @@ final class TodayViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetchTodos: - beginLoading(.immediate) + beginLoading(.delayed) Task { do { - defer { endLoading(.immediate) } + defer { endLoading(.delayed) } async let todosWithDueDatePage = fetchTodosUseCase.execute( TodoQuery( completionFilter: .incomplete, diff --git a/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift b/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift index 3f5e705b..6ceb674f 100644 --- a/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoDetailViewModel.swift @@ -95,10 +95,10 @@ final class TodoDetailViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetchTodo: - beginLoading(.immediate) + beginLoading(.delayed) Task { do { - defer { endLoading(.immediate) } + defer { endLoading(.delayed) } let todo = try await fetchTodoUseCase.execute(todoId) send(.setTodo(todo)) } catch { From 50d39077086c559b9227ac2be01c4a6246fbde67 Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 7 Apr 2026 10:04:21 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20LoadingState=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 --- .../Presentation/ViewModel/LoginViewModel.swift | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/DevLog/Presentation/ViewModel/LoginViewModel.swift b/DevLog/Presentation/ViewModel/LoginViewModel.swift index 4ed50ec2..98470d49 100644 --- a/DevLog/Presentation/ViewModel/LoginViewModel.swift +++ b/DevLog/Presentation/ViewModel/LoginViewModel.swift @@ -27,6 +27,7 @@ final class LoginViewModel: Store { } private let signInUseCase: SignInUseCase + private let loadingState = LoadingState() private(set) var state = State() @@ -54,12 +55,12 @@ final class LoginViewModel: Store { } func run(_ effect: SideEffect) { - send(.setLoading(true)) switch effect { case .signIn(let authProvider): + beginLoading(.immediate) Task { do { - defer { send(.setLoading(false)) } + defer { endLoading(.immediate) } try await self.signInUseCase.execute(authProvider) } catch { if error.isSocialLoginCancelled { return } @@ -79,4 +80,16 @@ private extension LoginViewModel { state.alertMessage = String(localized: "common_error_message") state.showAlert = isPresented } + + func beginLoading(_ mode: LoadingState.Mode) { + loadingState.begin(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } + + func endLoading(_ mode: LoadingState.Mode) { + loadingState.end(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } } From 30544024021de5ef8133b4b07ea80f65c30d0ccf Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 7 Apr 2026 12:30:11 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20.immediate=EC=97=90=EC=84=9C=20?= =?UTF-8?q?.delay=EB=A1=9C=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=94=94?= =?UTF-8?q?=EB=B0=94=EC=9A=B4=EC=8A=A4=20=EC=9E=91=EC=97=85=EB=8F=84=20?= =?UTF-8?q?=EA=B0=99=EC=9D=B4=20=EB=81=9D=EB=82=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/SearchViewModel.swift | 64 +++++++++++------ .../ViewModel/TodoListViewModel.swift | 72 +++++++++++-------- 2 files changed, 84 insertions(+), 52 deletions(-) diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift index 6bfc606b..6b0592f4 100644 --- a/DevLog/Presentation/ViewModel/SearchViewModel.swift +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -43,16 +43,22 @@ final class SearchViewModel: Store { case fetch(String) } + private enum SearchTaskKind: Hashable { + case debounce + case request + } + private(set) var state: State = .init() private let fetchWebPagesUseCase: FetchWebPagesUseCase private let fetchTodosUseCase: FetchTodosUseCase private let fetchRecentSearchQueriesUseCase: FetchRecentSearchQueriesUseCase private let updateRecentSearchQueriesUseCase: UpdateRecentSearchQueriesUseCase + private let loadingState = LoadingState() let contentsLimit: Int = 5 private let maxRecentQueries = 20 private let searchDebounceDelay: Double = 0.4 - private var searchDebounceTask: Task? + private var searchTasks: [SearchTaskKind: Task] = [:] init( fetchWebPagesUseCase: FetchWebPagesUseCase, @@ -104,22 +110,16 @@ final class SearchViewModel: Store { state.showAllWebPages = false let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { - cancelDebounce() + cancelSearch() state.webPages = [] state.todos = [] - state.isLoading = false } else { - state.isLoading = true - scheduleDebouncedQuery(query) + cancelSearch() + beginLoading(.immediate) + scheduleDebouncedFetch(trimmed) } case .applySearchQuery(let query): - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - state.webPages = [] - state.todos = [] - } else { - effects = [.fetch(trimmed)] - } + effects = [.fetch(query)] case .setShowAllTodos(let shouldShowAll): state.showAllTodos = shouldShowAll case .setShowAllWebPages(let shouldShowAll): @@ -133,10 +133,14 @@ final class SearchViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetch(let query): - Task { + searchTasks[.request]?.cancel() + let requestTask = Task { [weak self] in + guard let self else { return } do { - send(.setLoading(true)) - defer { send(.setLoading(false)) } + defer { + self.searchTasks[.request] = nil + self.endLoading(.immediate) + } let searchesTodoOnly = searchesTodoOnly(query) async let todos = fetchTodosUseCase.execute(TodoQuery(keyword: query), cursor: nil) async let webPageItems = fetchWebPageItems( @@ -145,12 +149,15 @@ final class SearchViewModel: Store { ) let todoItems = try await todos.items.compactMap { TodoListItem(from: $0) } let resolvedWebPageItems = try await webPageItems + if Task.isCancelled { return } send(.fetchTodos(todoItems)) send(.fetchWebPage(resolvedWebPageItems)) } catch { + if error is CancellationError { return } send(.setAlert(true)) } } + searchTasks[.request] = requestTask } } } @@ -165,21 +172,36 @@ private extension SearchViewModel { state.showAlert = isPresented } - func scheduleDebouncedQuery(_ query: String) { - searchDebounceTask?.cancel() - searchDebounceTask = Task { [weak self] in + func scheduleDebouncedFetch(_ query: String) { + searchTasks[.debounce]?.cancel() + let debounceTask = Task { [weak self] in guard let self else { return } try? await Task.sleep(for: .seconds(searchDebounceDelay)) if Task.isCancelled { return } await MainActor.run { + self.searchTasks[.debounce] = nil self.send(.applySearchQuery(query)) } } + searchTasks[.debounce] = debounceTask + } + + func cancelSearch() { + searchTasks.values.forEach { $0.cancel() } + searchTasks = [:] + endLoading(.immediate) } - func cancelDebounce() { - searchDebounceTask?.cancel() - searchDebounceTask = nil + func beginLoading(_ mode: LoadingState.Mode) { + loadingState.begin(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } + } + + func endLoading(_ mode: LoadingState.Mode) { + loadingState.end(mode: mode) { [weak self] isLoading in + self?.send(.setLoading(isLoading)) + } } func searchesTodoOnly(_ query: String) -> Bool { diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index 50a7cd9e..16679161 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -52,7 +52,7 @@ final class TodoListViewModel: Store { case upsertTodo(Todo) // Run - case setSearchQuery(String) + case triggerSearch(String) case fetchSearchResults([TodoListItem]) case didToggleCompleted(TodoListItem) case didTogglePinned(TodoListItem) @@ -74,9 +74,12 @@ final class TodoListViewModel: Store { case togglePinned(TodoListItem) } + private enum SearchTaskKind: Hashable { + case debounce + case request + } + private(set) var state: State - private let searchDebounceDelay: Double = 0.4 - private var searchDebounceTask: Task? private let fetchTodosUseCase: FetchTodosUseCase private let fetchTodoByIdUseCase: FetchTodoByIdUseCase private let upsertTodoUseCase: UpsertTodoUseCase @@ -85,6 +88,8 @@ final class TodoListViewModel: Store { private let loadingState = LoadingState() private var undoDeleteTodoId: String? private var nextCursor: TodoCursor? + private var searchTasks: [SearchTaskKind: Task] = [:] + private let searchDebounceDelay: Double = 0.4 init( fetchTodosUseCase: FetchTodosUseCase, @@ -129,7 +134,7 @@ final class TodoListViewModel: Store { case .onAppear, .loadNextPage, .setSearchText, .setToast, .upsertTodo: effects = reduceByView(action, state: &state) - case .setSearchQuery, .fetchSearchResults, .didToggleCompleted, .didTogglePinned, + case .triggerSearch, .fetchSearchResults, .didToggleCompleted, .didTogglePinned, .restoreTodo, .setLoading, .appendTodos, .resetPagination, .setHasMore: effects = reduceByRun(action, state: &state) } @@ -142,10 +147,10 @@ final class TodoListViewModel: Store { func run(_ effect: SideEffect) { switch effect { case .fetch: - beginLoading(.immediate) + beginLoading(.delayed) Task { do { - defer { endLoading(.immediate) } + defer { endLoading(.delayed) } let page = try await fetchTodosUseCase.execute(state.query, cursor: nil) send(.resetPagination) send(.appendTodos(page.items.compactMap { TodoListItem(from: $0) }, nextCursor: page.nextCursor)) @@ -156,10 +161,10 @@ final class TodoListViewModel: Store { } } case .loadNextPage: - beginLoading(.immediate) + beginLoading(.delayed) Task { do { - defer { endLoading(.immediate) } + defer { endLoading(.delayed) } let page = try await fetchTodosUseCase.execute(state.query, cursor: nextCursor) send(.appendTodos(page.items.compactMap { TodoListItem(from: $0) }, nextCursor: page.nextCursor)) let hasMore = page.items.count == state.query.pageSize && page.nextCursor != nil @@ -169,17 +174,24 @@ final class TodoListViewModel: Store { } } case .search(let keyword): - beginLoading(.immediate) - Task { + searchTasks[.request]?.cancel() + let requestTask = Task { [weak self] in + guard let self else { return } do { - defer { endLoading(.immediate) } + defer { + self.searchTasks[.request] = nil + self.endLoading(.immediate) + } let query = TodoQuery(category: state.category, keyword: keyword) let page = try await fetchTodosUseCase.execute(query, cursor: nil) + if Task.isCancelled { return } send(.fetchSearchResults(page.items.compactMap { TodoListItem(from: $0) })) } catch { + if error is CancellationError { return } send(.setAlert(true)) } } + searchTasks[.request] = requestTask case .upsert(let item): beginLoading(.delayed) Task { @@ -242,7 +254,7 @@ final class TodoListViewModel: Store { beginLoading(.delayed) Task { // endLoading(.delayed)를 defer로 두지 않는 이유 - // send(.refresh)가 같은 턴에서 beginLoading(.immediate)를 먼저 올린 뒤 + // send(.refresh)가 같은 턴에서 beginLoading(.delayed)를 먼저 올린 뒤 // delayed 로딩을 내려야 같은 isLoading이 끊기지 않기 때문 do { try await undoDeleteTodoUseCase.execute(todoId) @@ -298,7 +310,7 @@ private extension TodoListViewModel { case .setIsSearching(let value): state.isSearching = value if !value { - cancelDebounce() + cancelSearch() state.searchText = "" state.searchResults = [] state.showAllSearchResults = false @@ -332,12 +344,12 @@ private extension TodoListViewModel { state.showAllSearchResults = false let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { - cancelDebounce() + cancelSearch() state.searchResults = [] - state.isLoading = false } else { - state.isLoading = true - scheduleDebouncedQuery(text) + cancelSearch() + beginLoading(.immediate) + scheduleDebouncedSearch(trimmed) } case .setToast(let isPresented): setToast(&state, isPresented: isPresented) @@ -352,13 +364,8 @@ private extension TodoListViewModel { func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { switch action { - case .setSearchQuery(let query): - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - state.searchResults = [] - } else { - return [.search(trimmed)] - } + case .triggerSearch(let query): + return [.search(query)] case .fetchSearchResults(let items): state.searchResults = items case .didToggleCompleted(let todo): @@ -418,21 +425,24 @@ private extension TodoListViewModel { state.showToast = isPresented } - func scheduleDebouncedQuery(_ query: String) { - searchDebounceTask?.cancel() - searchDebounceTask = Task { [weak self] in + func scheduleDebouncedSearch(_ query: String) { + searchTasks[.debounce]?.cancel() + let debounceTask = Task { [weak self] in guard let self else { return } try? await Task.sleep(for: .seconds(searchDebounceDelay)) if Task.isCancelled { return } await MainActor.run { - self.send(.setSearchQuery(query)) + self.searchTasks[.debounce] = nil + self.send(.triggerSearch(query)) } } + searchTasks[.debounce] = debounceTask } - func cancelDebounce() { - searchDebounceTask?.cancel() - searchDebounceTask = nil + func cancelSearch() { + searchTasks.values.forEach { $0.cancel() } + searchTasks = [:] + endLoading(.immediate) } private func beginLoading(_ mode: LoadingState.Mode) { From 62b618b32b1cd6009aba3a44a9a9c462e008a00e Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 7 Apr 2026 12:39:20 +0900 Subject: [PATCH 5/9] =?UTF-8?q?chore:=20PR=20=ED=85=9C=ED=94=8C=EB=A6=BF?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..fec87575 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +## 🔗 연관된 이슈 +> 이슈 번호를 입력해주세요. (예: #12) +> 이슈가 완전히 해결되었다면 아래에 예약어를 남겨주세요. +- closed #이슈번호 + +## 📝 작업 내용 + +### 📌 요약 + +### 🔍 상세 + +## 📸 영상 / 이미지 (Optional) \ No newline at end of file From d30b015c03b55411aaf0f94ec54ad465e09fa5cc Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 7 Apr 2026 15:04:48 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20Task=EA=B0=80=20=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EC=95=98=EC=9D=84=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EB=A1=9C=EB=94=A9=EC=9D=84=20=EB=81=9D=EB=82=B4?= =?UTF-8?q?=EB=B2=84=EB=A6=B4=20=EC=88=98=20=EC=9E=88=EB=8A=94=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Presentation/ViewModel/SearchViewModel.swift | 4 +++- DevLog/Presentation/ViewModel/TodoListViewModel.swift | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift index 6b0592f4..42b91ae2 100644 --- a/DevLog/Presentation/ViewModel/SearchViewModel.swift +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -139,7 +139,9 @@ final class SearchViewModel: Store { do { defer { self.searchTasks[.request] = nil - self.endLoading(.immediate) + if !Task.isCancelled { + self.endLoading(.immediate) + } } let searchesTodoOnly = searchesTodoOnly(query) async let todos = fetchTodosUseCase.execute(TodoQuery(keyword: query), cursor: nil) diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index 16679161..a303f1f6 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -180,7 +180,9 @@ final class TodoListViewModel: Store { do { defer { self.searchTasks[.request] = nil - self.endLoading(.immediate) + if !Task.isCancelled { + self.endLoading(.immediate) + } } let query = TodoQuery(category: state.category, keyword: keyword) let page = try await fetchTodosUseCase.execute(query, cursor: nil) From 3160f01a3f91f58ecb9fb26f3d7bf62517ccec1b Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 7 Apr 2026 15:55:27 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=EA=B2=80=EC=83=89=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EC=83=81=ED=83=9C=20=EB=B0=98=EC=98=81=EC=9D=B4=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=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 --- .../Presentation/ViewModel/SearchViewModel.swift | 16 ++++++++++++---- .../ViewModel/TodoListViewModel.swift | 15 ++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift index 42b91ae2..179fa8ec 100644 --- a/DevLog/Presentation/ViewModel/SearchViewModel.swift +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -40,6 +40,8 @@ final class SearchViewModel: Store { } enum SideEffect { + case cancelSearch + case debounceFetch(String) case fetch(String) } @@ -103,6 +105,9 @@ final class SearchViewModel: Store { state.isLoading = isLoading case .setSearching(let isSearching): state.isSearching = isSearching + if !isSearching { + effects = [.cancelSearch] + } case .setSearchQuery(let query): guard state.searchQuery != query else { return [] } state.searchQuery = query @@ -110,13 +115,11 @@ final class SearchViewModel: Store { state.showAllWebPages = false let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { - cancelSearch() state.webPages = [] state.todos = [] + effects = [.cancelSearch] } else { - cancelSearch() - beginLoading(.immediate) - scheduleDebouncedFetch(trimmed) + effects = [.cancelSearch, .debounceFetch(trimmed)] } case .applySearchQuery(let query): effects = [.fetch(query)] @@ -132,6 +135,11 @@ final class SearchViewModel: Store { func run(_ effect: SideEffect) { switch effect { + case .cancelSearch: + cancelSearch() + case .debounceFetch(let query): + beginLoading(.immediate) + scheduleDebouncedFetch(query) case .fetch(let query): searchTasks[.request]?.cancel() let requestTask = Task { [weak self] in diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index a303f1f6..8dc81a15 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -64,6 +64,8 @@ final class TodoListViewModel: Store { } enum SideEffect { + case cancelSearch + case debounceSearch(String) case fetch case loadNextPage case search(String) @@ -146,6 +148,11 @@ final class TodoListViewModel: Store { // swiftlint:disable function_body_length func run(_ effect: SideEffect) { switch effect { + case .cancelSearch: + cancelSearch() + case .debounceSearch(let keyword): + beginLoading(.immediate) + scheduleDebouncedSearch(keyword) case .fetch: beginLoading(.delayed) Task { @@ -312,10 +319,10 @@ private extension TodoListViewModel { case .setIsSearching(let value): state.isSearching = value if !value { - cancelSearch() state.searchText = "" state.searchResults = [] state.showAllSearchResults = false + return [.cancelSearch] } case .setShowAllSearchResults(let value): state.showAllSearchResults = value @@ -346,12 +353,10 @@ private extension TodoListViewModel { state.showAllSearchResults = false let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { - cancelSearch() state.searchResults = [] + return [.cancelSearch] } else { - cancelSearch() - beginLoading(.immediate) - scheduleDebouncedSearch(trimmed) + return [.cancelSearch, .debounceSearch(trimmed)] } case .setToast(let isPresented): setToast(&state, isPresented: isPresented) From f3f3ca9d90f968334c751901a3b1b4bafcb6c37e Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 7 Apr 2026 16:12:21 +0900 Subject: [PATCH 8/9] =?UTF-8?q?style:=20SearchViewModel=EA=B3=BC=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=ED=95=9C=20=EC=97=AD=ED=95=A0=20=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=A9=94=EC=84=9C=EB=93=9C=EC=99=80=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=9D=BC=EC=9B=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Presentation/ViewModel/TodoListViewModel.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index 8dc81a15..29ac313c 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -52,7 +52,7 @@ final class TodoListViewModel: Store { case upsertTodo(Todo) // Run - case triggerSearch(String) + case applySearchQuery(String) case fetchSearchResults([TodoListItem]) case didToggleCompleted(TodoListItem) case didTogglePinned(TodoListItem) @@ -136,7 +136,7 @@ final class TodoListViewModel: Store { case .onAppear, .loadNextPage, .setSearchText, .setToast, .upsertTodo: effects = reduceByView(action, state: &state) - case .triggerSearch, .fetchSearchResults, .didToggleCompleted, .didTogglePinned, + case .applySearchQuery, .fetchSearchResults, .didToggleCompleted, .didTogglePinned, .restoreTodo, .setLoading, .appendTodos, .resetPagination, .setHasMore: effects = reduceByRun(action, state: &state) } @@ -371,7 +371,7 @@ private extension TodoListViewModel { func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { switch action { - case .triggerSearch(let query): + case .applySearchQuery(let query): return [.search(query)] case .fetchSearchResults(let items): state.searchResults = items @@ -440,7 +440,7 @@ private extension TodoListViewModel { if Task.isCancelled { return } await MainActor.run { self.searchTasks[.debounce] = nil - self.send(.triggerSearch(query)) + self.send(.applySearchQuery(query)) } } searchTasks[.debounce] = debounceTask From 864886e40e753a6b77bbebcffae902324503f3d9 Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 7 Apr 2026 16:21:16 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EC=95=A1?= =?UTF-8?q?=EC=85=98=EC=9D=98=20=EB=B9=88=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Presentation/ViewModel/SearchViewModel.swift | 9 ++++++++- DevLog/Presentation/ViewModel/TodoListViewModel.swift | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift index 179fa8ec..742ec15f 100644 --- a/DevLog/Presentation/ViewModel/SearchViewModel.swift +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -122,7 +122,14 @@ final class SearchViewModel: Store { effects = [.cancelSearch, .debounceFetch(trimmed)] } case .applySearchQuery(let query): - effects = [.fetch(query)] + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + state.webPages = [] + state.todos = [] + effects = [.cancelSearch] + } else { + effects = [.fetch(trimmed)] + } case .setShowAllTodos(let shouldShowAll): state.showAllTodos = shouldShowAll case .setShowAllWebPages(let shouldShowAll): diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index 29ac313c..c0138270 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -372,7 +372,13 @@ private extension TodoListViewModel { func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { switch action { case .applySearchQuery(let query): - return [.search(query)] + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + state.searchResults = [] + return [.cancelSearch] + } else { + return [.search(trimmed)] + } case .fetchSearchResults(let items): state.searchResults = items case .didToggleCompleted(let todo):