From 56ff87b4885588a166d22fb400fe1a63c9a20912 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 17 Apr 2026 22:42:42 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor:=20=EC=9B=B9=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=A0=9C=EA=B1=B0=20=EC=8B=9C=20LoadingView=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Presentation/ViewModel/HomeViewModel.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index e3a5f39d..3347dd5b 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -219,10 +219,8 @@ final class HomeViewModel: Store { } } case .deleteWebPage(let page, let index): - beginLoading(for: .webPage, mode: .delayed) Task { do { - defer { endLoading(for: .webPage, mode: .delayed) } try await deleteWebPageUseCase.execute(page.url.absoluteString) } catch { send(.restoreWebPage(page, index)) From e3b87ae567155fc8b72443f2d962dc1ad96a2122 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 18 Apr 2026 09:41:14 +0900 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20=EC=9B=B9=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=82=AD=EC=A0=9C=20=EB=B3=B5=EA=B5=AC=20=ED=9B=84?= =?UTF-8?q?=20=ED=83=AD=20=EC=A0=84=ED=99=98=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 웹페이지 삭제 항목 숨김 처리 적용 - 토스트 종료 시 숨김 항목 제거 처리 - 복구 직후 메타데이터 재동기화 적용 - Home 섹션 로딩 표시 조건 정리 --- .../Presentation/Structure/WebPageItem.swift | 1 + .../ViewModel/HomeViewModel.swift | 61 ++++++++----------- DevLog/UI/Home/HomeView.swift | 5 +- 3 files changed, 31 insertions(+), 36 deletions(-) diff --git a/DevLog/Presentation/Structure/WebPageItem.swift b/DevLog/Presentation/Structure/WebPageItem.swift index 9cb403d5..9f19f654 100644 --- a/DevLog/Presentation/Structure/WebPageItem.swift +++ b/DevLog/Presentation/Structure/WebPageItem.swift @@ -9,6 +9,7 @@ import SwiftUI struct WebPageItem: Identifiable, Hashable { private let metadata: WebPage + var isHidden = false init(from metadata: WebPage) { self.metadata = metadata diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index 3347dd5b..0d0151ae 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -41,6 +41,7 @@ final class HomeViewModel: Store { case setAlert(isPresented: Bool, type: AlertType? = nil) case setToast(isPresented: Bool, type: ToastType? = nil) case setLoading(LoadingTarget, Bool) + case setWebPageHidden(URL, Bool) case tapTodoCategory(TodoCategory) case orderTodoCategory([TodoCategoryItem]) case setTodoCategory([TodoCategoryItem]) @@ -51,13 +52,12 @@ final class HomeViewModel: Store { case deleteWebPage(WebPageItem) case undoDeleteWebPage case updateWebPages([WebPageItem]) - case restoreWebPage(WebPageItem, Int) } enum SideEffect { case addTodo(Todo) case addWebPage(String) - case deleteWebPage(WebPageItem, Int) + case deleteWebPage(WebPageItem) case undoDeleteWebPage(String) case fetchTodoCategoryPreferences case updateTodoCategoryPreferences([TodoCategoryItem]) @@ -145,8 +145,8 @@ final class HomeViewModel: Store { .addWebPage, .deleteWebPage, .undoDeleteWebPage: effects = reduceByView(action, state: &state) - case .setLoading, .setTodoCategory, .updateRecentTodos, - .updateWebPages, .restoreWebPage: + case .setLoading, .setWebPageHidden, .setTodoCategory, .updateRecentTodos, + .updateWebPages: effects = reduceByRun(action, state: &state) } @@ -218,36 +218,24 @@ final class HomeViewModel: Store { send(.setAlert(isPresented: true, type: .error)) } } - case .deleteWebPage(let page, let index): + case .deleteWebPage(let page): Task { do { try await deleteWebPageUseCase.execute(page.url.absoluteString) } catch { - send(.restoreWebPage(page, index)) + send(.setWebPageHidden(page.id, false)) send(.setAlert(isPresented: true, type: .error)) } } case .undoDeleteWebPage(let urlString): - beginLoading(for: .webPage, mode: .delayed) Task { - defer { endLoading(for: .webPage, mode: .delayed) } - - var shouldPresentError = false - do { try await undoDeleteWebPageUseCase.execute(urlString) + try await addWebPageUseCase.execute(urlString) } catch { - shouldPresentError = true - } - - do { - let pages = try await fetchWebPagesUseCase.execute("") - send(.updateWebPages(pages.map { WebPageItem(from: $0) })) - } catch { - shouldPresentError = true - } - - if shouldPresentError { + if let webPageURL = URL(string: urlString) { + send(.setWebPageHidden(webPageURL, true)) + } send(.setAlert(isPresented: true, type: .error)) } } @@ -294,6 +282,12 @@ private extension HomeViewModel { case .setToast(let isPresented, let type): setToast(&state, isPresented: isPresented, for: type) if !isPresented { + if let deletedWebPageURLString, + let index = state.webPages.firstIndex(where: { + $0.url.absoluteString == deletedWebPageURLString && $0.isHidden + }) { + state.webPages.remove(at: index) + } deletedWebPageURLString = nil } case .tapTodoCategory(let category): @@ -318,12 +312,17 @@ private extension HomeViewModel { case .deleteWebPage(let page): if let index = state.webPages.firstIndex(where: { $0.id == page.id }) { deletedWebPageURLString = page.url.absoluteString - state.webPages.remove(at: index) + state.webPages[index].isHidden = true setToast(&state, isPresented: true, for: .deleteWebPage) - return [.deleteWebPage(page, index)] + return [.deleteWebPage(page)] } case .undoDeleteWebPage: guard let deletedWebPageURLString else { return [] } + if let index = state.webPages.firstIndex(where: { + $0.url.absoluteString == deletedWebPageURLString + }) { + state.webPages[index].isHidden = false + } self.deletedWebPageURLString = nil return [.undoDeleteWebPage(deletedWebPageURLString)] default: @@ -337,6 +336,10 @@ private extension HomeViewModel { switch action { case .setLoading(let loadingTarget, let isLoading): setLoading(&state, loadingTarget: loadingTarget, isLoading: isLoading) + case .setWebPageHidden(let webPageURL, let isHidden): + if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { + state.webPages[index].isHidden = isHidden + } case .setTodoCategory(let preferences): state.preferences = preferences state.recentTodos = syncRecentTodos(state.recentTodos, preferences: preferences) @@ -344,16 +347,6 @@ private extension HomeViewModel { state.recentTodos = todos case .updateWebPages(let pages): state.webPages = pages - case .restoreWebPage(let page, let index): - if state.webPages.contains(where: { $0.id == page.id }) { break } - if index <= state.webPages.count { - state.webPages.insert(page, at: index) - } else { - state.webPages.append(page) - } - if deletedWebPageURLString == page.url.absoluteString { - deletedWebPageURLString = nil - } default: break } diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index f428413c..1f21413a 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -227,10 +227,11 @@ struct HomeView: View { private var webPageSection: some View { Section { + let webPages = viewModel.state.webPages.filter { !$0.isHidden } if viewModel.state.isWebPageLoading { LoadingView() .id(UUID()) // id 부여를 통해 렌더링 강제 - } else if viewModel.state.webPages.isEmpty { + } else if webPages.isEmpty { HStack { Spacer() Text(String(localized: "home_web_empty")) @@ -238,7 +239,7 @@ struct HomeView: View { Spacer() } } else { - ForEach(viewModel.state.webPages, id: \.id) { page in + ForEach(webPages, id: \.id) { page in webResultRow(page) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) From cb34bdb1bb73a268e2c15cbb37646fcee76f3a12 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 18 Apr 2026 13:37:12 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20Todo=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EC=8B=9C=20LoadingView=EA=B0=80=20?= =?UTF-8?q?=EB=9C=A8=EA=B3=A0=20=EA=B3=BC=EB=8F=84=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=EC=97=90=20=EC=98=A4=EB=9E=98=20=EA=B1=B8?= =?UTF-8?q?=EB=A0=A4=EB=B3=B4=EC=9D=B4=EB=8A=94=20=ED=98=84=EC=83=81=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Structure/Todo/TodoListItem.swift | 1 + .../ViewModel/TodoListViewModel.swift | 68 +++++++++++-------- DevLog/UI/Home/TodoListView.swift | 15 ++-- 3 files changed, 49 insertions(+), 35 deletions(-) diff --git a/DevLog/Presentation/Structure/Todo/TodoListItem.swift b/DevLog/Presentation/Structure/Todo/TodoListItem.swift index ee8717d1..dc958b0c 100644 --- a/DevLog/Presentation/Structure/Todo/TodoListItem.swift +++ b/DevLog/Presentation/Structure/Todo/TodoListItem.swift @@ -9,6 +9,7 @@ import Foundation struct TodoListItem: Identifiable, Hashable { let id: String + var isHidden = false let number: Int let title: String let tags: [String] diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index c0138270..44b545ce 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -56,7 +56,7 @@ final class TodoListViewModel: Store { case fetchSearchResults([TodoListItem]) case didToggleCompleted(TodoListItem) case didTogglePinned(TodoListItem) - case restoreTodo(TodoListItem, Int) + case setTodoHidden(String, Bool) case setLoading(Bool) case appendTodos([TodoListItem], nextCursor: TodoCursor?) case resetPagination @@ -70,7 +70,7 @@ final class TodoListViewModel: Store { case loadNextPage case search(String) case upsert(Todo) - case delete(TodoListItem, Int) + case delete(TodoListItem) case undoDelete(String) case toggleCompleted(TodoListItem) case togglePinned(TodoListItem) @@ -137,7 +137,7 @@ final class TodoListViewModel: Store { effects = reduceByView(action, state: &state) case .applySearchQuery, .fetchSearchResults, .didToggleCompleted, .didTogglePinned, - .restoreTodo, .setLoading, .appendTodos, .resetPagination, .setHasMore: + .setTodoHidden, .setLoading, .appendTodos, .resetPagination, .setHasMore: effects = reduceByRun(action, state: &state) } @@ -250,29 +250,23 @@ final class TodoListViewModel: Store { send(.setAlert(true)) } } - case .delete(let item, let index): + case .delete(let item): Task { do { try await deleteTodoUseCase.execute(item.id) } catch { - send(.restoreTodo(item, index)) + send(.setTodoHidden(item.id, false)) send(.setAlert(true)) } } case .undoDelete(let todoId): - beginLoading(.delayed) Task { - // endLoading(.delayed)를 defer로 두지 않는 이유 - // send(.refresh)가 같은 턴에서 beginLoading(.delayed)를 먼저 올린 뒤 - // delayed 로딩을 내려야 같은 isLoading이 끊기지 않기 때문 do { try await undoDeleteTodoUseCase.execute(todoId) } catch { + send(.setTodoHidden(todoId, true)) send(.setAlert(true)) } - - send(.refresh) - endLoading(.delayed) } } } @@ -290,11 +284,11 @@ private extension TodoListViewModel { case .setShowEditor(let value): state.showEditor = value case .swipeTodo(let todo): - if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { + if state.todos.contains(where: { $0.id == todo.id }) { undoDeleteTodoId = todo.id - state.todos.remove(at: index) + setTodoHidden(&state, todoId: todo.id, isHidden: true) setToast(&state, isPresented: true) - return [.delete(todo, index)] + return [.delete(todo)] } case .setSortTarget(let target): state.query.sortTarget = target @@ -332,6 +326,7 @@ private extension TodoListViewModel { return [.togglePinned(todo)] case .undoDelete: guard let undoDeleteTodoId else { return [] } + setTodoHidden(&state, todoId: undoDeleteTodoId, isHidden: false) self.undoDeleteTodoId = nil return [.undoDelete(undoDeleteTodoId)] default: @@ -360,7 +355,12 @@ private extension TodoListViewModel { } case .setToast(let isPresented): setToast(&state, isPresented: isPresented) - if !isPresented { undoDeleteTodoId = nil } + if !isPresented { + if let undoDeleteTodoId { + removeHiddenTodo(&state, todoId: undoDeleteTodoId) + } + self.undoDeleteTodoId = nil + } case .upsertTodo(let todo): return [.upsert(todo)] default: @@ -389,18 +389,8 @@ private extension TodoListViewModel { if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { state.todos[index] = todo } - case .restoreTodo(let todo, let index): - if state.todos.contains(where: { $0.id == todo.id }) { break } - - if index <= state.todos.count { - state.todos.insert(todo, at: index) - } else { - state.todos.append(todo) - } - - if undoDeleteTodoId == todo.id { - undoDeleteTodoId = nil - } + case .setTodoHidden(let todoId, let isHidden): + setTodoHidden(&state, todoId: todoId, isHidden: isHidden) case .setLoading(let value): state.isLoading = value case .appendTodos(let todos, let nextCursor): @@ -438,6 +428,28 @@ private extension TodoListViewModel { state.showToast = isPresented } + func setTodoHidden( + _ state: inout State, + todoId: String, + isHidden: Bool + ) { + if let todoIndex = state.todos.firstIndex(where: { $0.id == todoId }) { + state.todos[todoIndex].isHidden = isHidden + } + + if let searchResultIndex = state.searchResults.firstIndex(where: { $0.id == todoId }) { + state.searchResults[searchResultIndex].isHidden = isHidden + } + } + + func removeHiddenTodo( + _ state: inout State, + todoId: String + ) { + state.todos.removeAll { $0.id == todoId && $0.isHidden } + state.searchResults.removeAll { $0.id == todoId && $0.isHidden } + } + func scheduleDebouncedSearch(_ query: String) { searchTasks[.debounce]?.cancel() let debounceTask = Task { [weak self] in diff --git a/DevLog/UI/Home/TodoListView.swift b/DevLog/UI/Home/TodoListView.swift index 0b194299..2b2d59c8 100644 --- a/DevLog/UI/Home/TodoListView.swift +++ b/DevLog/UI/Home/TodoListView.swift @@ -122,10 +122,12 @@ struct TodoListView: View { } private var todoListContent: some View { - ZStack { + let visibleTodos = viewModel.state.todos.filter { !$0.isHidden } + + return ZStack { List { Group { - if viewModel.state.todos.isEmpty, !viewModel.state.isLoading { + if visibleTodos.isEmpty, !viewModel.state.isLoading { HStack { Spacer() Text(String(localized: "todo_list_empty")) @@ -134,8 +136,7 @@ struct TodoListView: View { } .listRowSeparator(.hidden) } else { - let todos = viewModel.state.todos - ForEach(Array(zip(todos.indices, todos)), id: \.1.id) { idx, todo in + ForEach(Array(zip(visibleTodos.indices, visibleTodos)), id: \.1.id) { idx, todo in Button { router.push(Path.detail(todo.id)) } label: { @@ -152,7 +153,7 @@ struct TodoListView: View { } } .onAppear { - let lastID = viewModel.state.todos.last?.id + let lastID = visibleTodos.last?.id if todo.id == lastID, viewModel.state.hasMore { viewModel.send(.loadNextPage) } @@ -207,7 +208,7 @@ struct TodoListView: View { .offset(y: headerOffset) } .refreshable { viewModel.send(.refresh) } - .scrollDisabled(viewModel.state.todos.isEmpty || viewModel.state.isLoading) + .scrollDisabled(visibleTodos.isEmpty || viewModel.state.isLoading) if viewModel.state.isLoading { LoadingView() @@ -239,7 +240,7 @@ struct TodoListView: View { @ViewBuilder private var searchResultsContent: some View { - let searchResults = viewModel.state.searchResults + let searchResults = viewModel.state.searchResults.filter { !$0.isHidden } let limit = viewModel.searchResultsLimit let displayedTodos = viewModel.state.showAllSearchResults ? searchResults From 7466b9d9001624691ae4d838f504e005996efc0e Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 18 Apr 2026 22:35:44 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20PushNotification=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B3=B5=EA=B5=AC=20=EC=8B=9C=20LoadingView?= =?UTF-8?q?=EA=B0=80=20=EB=9C=A8=EA=B3=A0=20=EA=B3=BC=EB=8F=84=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EB=A1=9C=EB=94=A9=EC=9D=B4=20=EC=98=A4=EB=9E=98=20?= =?UTF-8?q?=EA=B1=B8=EB=A0=A4=EB=B3=B4=EC=9D=B4=EB=8A=94=20=ED=98=84?= =?UTF-8?q?=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 --- .../Structure/PushNotificationItem.swift | 1 + .../PushNotificationListViewModel.swift | 92 ++++++++++++------- .../PushNotificationListView.swift | 18 ++-- 3 files changed, 72 insertions(+), 39 deletions(-) diff --git a/DevLog/Presentation/Structure/PushNotificationItem.swift b/DevLog/Presentation/Structure/PushNotificationItem.swift index dd8273fb..5857f99c 100644 --- a/DevLog/Presentation/Structure/PushNotificationItem.swift +++ b/DevLog/Presentation/Structure/PushNotificationItem.swift @@ -9,6 +9,7 @@ import Foundation struct PushNotificationItem: Identifiable, Hashable { let id: String + var isHidden = false let title: String let body: String let receivedAt: Date diff --git a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift index 7738db5b..681a69af 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift @@ -37,7 +37,7 @@ final class PushNotificationListViewModel: Store { case resetPagination case setHasMore(Bool) case syncNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?, hasMore: Bool) - case restoreNotification(PushNotificationItem, Int) + case setNotificationHidden(String, Bool) case toggleSortOption case setTimeFilter(PushNotificationQuery.TimeFilter) case toggleUnreadOnly @@ -48,7 +48,7 @@ final class PushNotificationListViewModel: Store { enum SideEffect { case fetchNotifications(PushNotificationQuery, cursor: PushNotificationCursor?) - case delete(PushNotificationItem, Int) + case delete(PushNotificationItem) case undoDelete(String) case toggleRead(String) } @@ -104,7 +104,7 @@ final class PushNotificationListViewModel: Store { effects = reduceByView(action, state: &state) case .setLoading, .appendNotifications, .resetPagination, .setHasMore, - .syncNotifications, .restoreNotification: + .syncNotifications, .setNotificationHidden: effects = reduceByRun(action, state: &state) } @@ -145,31 +145,23 @@ final class PushNotificationListViewModel: Store { } } - case .delete(let item, let index): - beginLoading(.delayed) + case .delete(let item): Task { do { - defer { endLoading(.delayed) } try await deleteUseCase.execute(item.id) } catch { - send(.restoreNotification(item, index)) + send(.setNotificationHidden(item.id, false)) send(.setAlert(isPresented: true)) } } case .undoDelete(let notificationId): - beginLoading(.delayed) Task { - // endLoading(.delayed)를 defer로 두지 않는 이유 - // send(.fetchNotifications)가 같은 턴에서 beginLoading(.delayed)를 먼저 올린 뒤 - // delayed 로딩을 내려야 같은 isLoading이 끊기지 않기 때문 do { try await undoDeleteUseCase.execute(notificationId) } catch { + send(.setNotificationHidden(notificationId, true)) send(.setAlert(isPresented: true)) } - - send(.fetchNotifications) - endLoading(.delayed) } case .toggleRead(let todoId): beginLoading(.delayed) @@ -190,11 +182,11 @@ private extension PushNotificationListViewModel { func reduceByUser(_ action: Action, state: inout State) -> [SideEffect] { switch action { case .deleteNotification(let item): - if let index = state.notifications.firstIndex(where: { $0.id == item.id }) { + if state.notifications.contains(where: { $0.id == item.id }) { undoDeleteNotificationId = item.id - state.notifications.remove(at: index) + setNotificationHidden(&state, notificationId: item.id, isHidden: true) setToast(&state, isPresented: true) - return [.delete(item, index)] + return [.delete(item)] } return [] case .toggleRead(let item): @@ -204,6 +196,7 @@ private extension PushNotificationListViewModel { } case .undoDelete: guard let undoDeleteNotificationId else { return [] } + setNotificationHidden(&state, notificationId: undoDeleteNotificationId, isHidden: false) self.undoDeleteNotificationId = nil return [.undoDelete(undoDeleteNotificationId)] case .setAlert(let isPresented): @@ -251,7 +244,10 @@ private extension PushNotificationListViewModel { case .setToast(let isPresented): setToast(&state, isPresented: isPresented) if !isPresented { - undoDeleteNotificationId = nil + if let undoDeleteNotificationId { + removeHiddenNotification(&state, notificationId: undoDeleteNotificationId) + } + self.undoDeleteNotificationId = nil } case .setSelectedTodoId(let todoId): state.selectedTodoId = todoId @@ -271,24 +267,20 @@ private extension PushNotificationListViewModel { state.notifications = [] state.nextCursor = nil case .appendNotifications(let notifications, let nextCursor): - state.notifications.append(contentsOf: notifications) + state.notifications.append(contentsOf: mergedHiddenNotifications( + currentNotifications: state.notifications, + incomingNotifications: notifications + )) state.nextCursor = nextCursor case .syncNotifications(let notifications, let nextCursor, let hasMore): - state.notifications = notifications + state.notifications = mergedHiddenNotifications( + currentNotifications: state.notifications, + incomingNotifications: notifications + ) state.nextCursor = nextCursor state.hasMore = hasMore - case .restoreNotification(let notification, let index): - if state.notifications.contains(where: { $0.id == notification.id }) { break } - - if index <= state.notifications.count { - state.notifications.insert(notification, at: index) - } else { - state.notifications.append(notification) - } - - if undoDeleteNotificationId == notification.id { - undoDeleteNotificationId = nil - } + case .setNotificationHidden(let notificationId, let isHidden): + setNotificationHidden(&state, notificationId: notificationId, isHidden: isHidden) default: break } @@ -314,6 +306,42 @@ private extension PushNotificationListViewModel { state.showToast = isPresented } + func setNotificationHidden( + _ state: inout State, + notificationId: String, + isHidden: Bool + ) { + if let notificationIndex = state.notifications.firstIndex(where: { + $0.id == notificationId + }) { + state.notifications[notificationIndex].isHidden = isHidden + } + } + + func removeHiddenNotification( + _ state: inout State, + notificationId: String + ) { + state.notifications.removeAll { $0.id == notificationId && $0.isHidden } + } + + func mergedHiddenNotifications( + currentNotifications: [PushNotificationItem], + incomingNotifications: [PushNotificationItem] + ) -> [PushNotificationItem] { + incomingNotifications.map { incomingNotification in + guard let currentNotification = currentNotifications.first(where: { + $0.id == incomingNotification.id + }), currentNotification.isHidden else { + return incomingNotification + } + + var hiddenNotification = incomingNotification + hiddenNotification.isHidden = true + return hiddenNotification + } + } + func startObservingNotifications( query: PushNotificationQuery, limit: Int diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index f266e18f..9e05f41a 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -83,9 +83,11 @@ struct PushNotificationListView: View { } private var notificationList: some View { - List { + let visibleNotifications = viewModel.state.notifications.filter { !$0.isHidden } + + return List { Group { - if viewModel.state.notifications.isEmpty { + if visibleNotifications.isEmpty { HStack { Spacer() Text(String(localized: "push_notifications_empty")) @@ -94,8 +96,10 @@ struct PushNotificationListView: View { } .listRowSeparator(.hidden) } else { - let notifications = viewModel.state.notifications - ForEach(Array(zip(notifications.indices, notifications)), id: \.1.id) { idx, notification in + ForEach( + Array(zip(visibleNotifications.indices, visibleNotifications)), + id: \.1.id + ) { notificationIndex, notification in Button { viewModel.send(.tapNotification(notification)) } label: { @@ -104,15 +108,15 @@ struct PushNotificationListView: View { } .buttonStyle(.plain) .onAppear { - let lastID = viewModel.state.notifications.last?.id - if notification.id == lastID, viewModel.state.hasMore { + let lastId = visibleNotifications.last?.id + if notification.id == lastId, viewModel.state.hasMore { viewModel.send(.loadNextPage) } } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) .overlay(alignment: .top) { if #available(iOS 26.0, *) { - if idx == 0 { + if notificationIndex == 0 { Divider() .padding(.horizontal, -16) } From e5a27187a4d79f436dbe8653acca323e34665684 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 18 Apr 2026 22:47:16 +0900 Subject: [PATCH 05/10] =?UTF-8?q?style:=20=EB=B3=80=EC=88=98=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/PushNotification/PushNotificationListView.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index 9e05f41a..593b463e 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -84,7 +84,6 @@ struct PushNotificationListView: View { private var notificationList: some View { let visibleNotifications = viewModel.state.notifications.filter { !$0.isHidden } - return List { Group { if visibleNotifications.isEmpty { @@ -99,7 +98,7 @@ struct PushNotificationListView: View { ForEach( Array(zip(visibleNotifications.indices, visibleNotifications)), id: \.1.id - ) { notificationIndex, notification in + ) { index, notification in Button { viewModel.send(.tapNotification(notification)) } label: { @@ -116,7 +115,7 @@ struct PushNotificationListView: View { .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) .overlay(alignment: .top) { if #available(iOS 26.0, *) { - if notificationIndex == 0 { + if index == 0 { Divider() .padding(.horizontal, -16) } From 64e75255be49143a7eb603c3ad0ed63730cf0b65 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 18 Apr 2026 23:22:49 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=ED=98=B9=EC=8B=9C=EB=9D=BC?= =?UTF-8?q?=EB=8F=84=20=EB=82=A8=EC=95=84=EC=9E=88=EC=9D=84=20isHidden=20?= =?UTF-8?q?=EC=9A=94=EC=86=8C=EB=A5=BC=20=EB=AA=A8=EB=91=90=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=ED=95=98=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 --- DevLog/Presentation/ViewModel/HomeViewModel.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index 0d0151ae..229ac291 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -282,12 +282,7 @@ private extension HomeViewModel { case .setToast(let isPresented, let type): setToast(&state, isPresented: isPresented, for: type) if !isPresented { - if let deletedWebPageURLString, - let index = state.webPages.firstIndex(where: { - $0.url.absoluteString == deletedWebPageURLString && $0.isHidden - }) { - state.webPages.remove(at: index) - } + state.webPages.removeAll { $0.isHidden } deletedWebPageURLString = nil } case .tapTodoCategory(let category): From 0d4fa3414af9f7569ab6f56a2fe4d4ac8e28f5e7 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 19 Apr 2026 00:29:44 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=EC=9B=B9=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=A0=9C=EA=B1=B0,=20undo=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20=EB=9D=84=EC=9B=8C=20?= =?UTF-8?q?=ED=83=AD=ED=95=B4=EC=84=9C=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=EA=B0=80=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/HomeViewModel.swift | 22 ++++++++++++++----- DevLog/Resource/Localizable.xcstrings | 17 ++++++++++++++ DevLog/UI/Home/HomeView.swift | 13 +++++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index 229ac291..bb682889 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -14,6 +14,7 @@ final class HomeViewModel: Store { var preferences: [TodoCategoryItem] = [] var recentTodos: [RecentTodoItem] = [] var webPages: [WebPageItem] = [] + var needsWebPageRefresh = false var isNetworkConnected: Bool = true var showContentPicker: Bool = false var showTodoEditor: Bool = false @@ -40,8 +41,10 @@ final class HomeViewModel: Store { case setPresentation(Presentation, Bool) case setAlert(isPresented: Bool, type: AlertType? = nil) case setToast(isPresented: Bool, type: ToastType? = nil) + case refreshWebPages case setLoading(LoadingTarget, Bool) case setWebPageHidden(URL, Bool) + case handleWebPageDeleteFailure(URL) case tapTodoCategory(TodoCategory) case orderTodoCategory([TodoCategoryItem]) case setTodoCategory([TodoCategoryItem]) @@ -140,13 +143,13 @@ final class HomeViewModel: Store { switch action { case .networkStatusChanged(let isConnected): state.isNetworkConnected = isConnected - case .onAppear, .setPresentation, .setAlert, .setToast, .tapTodoCategory, - .orderTodoCategory, .addTodo, .updateWebPageURLInput, + case .onAppear, .setPresentation, .setAlert, .setToast, .refreshWebPages, + .tapTodoCategory, .orderTodoCategory, .addTodo, .updateWebPageURLInput, .addWebPage, .deleteWebPage, .undoDeleteWebPage: effects = reduceByView(action, state: &state) - case .setLoading, .setWebPageHidden, .setTodoCategory, .updateRecentTodos, - .updateWebPages: + case .setLoading, .setWebPageHidden, .handleWebPageDeleteFailure, .setTodoCategory, + .updateRecentTodos, .updateWebPages: effects = reduceByRun(action, state: &state) } @@ -223,7 +226,7 @@ final class HomeViewModel: Store { do { try await deleteWebPageUseCase.execute(page.url.absoluteString) } catch { - send(.setWebPageHidden(page.id, false)) + send(.handleWebPageDeleteFailure(page.id)) send(.setAlert(isPresented: true, type: .error)) } } @@ -271,6 +274,8 @@ private extension HomeViewModel { switch action { case .onAppear: return [.fetchTodoCategoryPreferences, .fetchRecentTodos, .fetchWebPages] + case .refreshWebPages: + return [.fetchWebPages] case .setPresentation(let presentation, let isPresented): setPresentation(&state, presentation: presentation, isPresented: isPresented) case .setAlert(let presented, let type): @@ -335,6 +340,12 @@ private extension HomeViewModel { if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { state.webPages[index].isHidden = isHidden } + case .handleWebPageDeleteFailure(let webPageURL): + if let index = state.webPages.firstIndex(where: { $0.id == webPageURL }) { + state.webPages[index].isHidden = false + } else { + state.needsWebPageRefresh = true + } case .setTodoCategory(let preferences): state.preferences = preferences state.recentTodos = syncRecentTodos(state.recentTodos, preferences: preferences) @@ -342,6 +353,7 @@ private extension HomeViewModel { state.recentTodos = todos case .updateWebPages(let pages): state.webPages = pages + state.needsWebPageRefresh = false default: break } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 62d9b01d..97a40473 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -459,6 +459,23 @@ } } }, + "home_web_refresh_required" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't update web pages. Tap to refresh." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "웹페이지를 불러오지 못했어요. 탭해서 새로고침해주세요." + } + } + } + }, "home_webpage_input_message" : { "extractionState" : "manual", "localizations" : { diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index 1f21413a..9a704266 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -231,6 +231,19 @@ struct HomeView: View { if viewModel.state.isWebPageLoading { LoadingView() .id(UUID()) // id 부여를 통해 렌더링 강제 + } else if viewModel.state.needsWebPageRefresh { + Button { + viewModel.send(.refreshWebPages) + } label: { + HStack { + Spacer() + Text(String(localized: "home_web_refresh_required")) + .font(.callout) + .multilineTextAlignment(.center) + Spacer() + } + } + .buttonStyle(.plain) } else if webPages.isEmpty { HStack { Spacer() From eeb3663e3cc7485cfe6bccabbba160e2f18da6d5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 19 Apr 2026 00:34:06 +0900 Subject: [PATCH 08/10] =?UTF-8?q?style:=20Delete=20=EB=8B=A8=EC=96=B4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationListViewModel.swift | 18 +++++++++--------- .../ViewModel/TodoListViewModel.swift | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift index 681a69af..9f1a06fb 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift @@ -61,7 +61,7 @@ final class PushNotificationListViewModel: Store { private let fetchQueryUseCase: FetchPushNotificationQueryUseCase private let updateQueryUseCase: UpdatePushNotificationQueryUseCase private let loadingState = LoadingState() - private var undoDeleteNotificationId: String? + private var undoNotificationId: String? private var cancellable: AnyCancellable? init( @@ -183,7 +183,7 @@ private extension PushNotificationListViewModel { switch action { case .deleteNotification(let item): if state.notifications.contains(where: { $0.id == item.id }) { - undoDeleteNotificationId = item.id + self.undoNotificationId = item.id setNotificationHidden(&state, notificationId: item.id, isHidden: true) setToast(&state, isPresented: true) return [.delete(item)] @@ -195,10 +195,10 @@ private extension PushNotificationListViewModel { return [.toggleRead(item.todoId)] } case .undoDelete: - guard let undoDeleteNotificationId else { return [] } - setNotificationHidden(&state, notificationId: undoDeleteNotificationId, isHidden: false) - self.undoDeleteNotificationId = nil - return [.undoDelete(undoDeleteNotificationId)] + guard let undoNotificationId else { return [] } + setNotificationHidden(&state, notificationId: undoNotificationId, isHidden: false) + self.undoNotificationId = nil + return [.undoDelete(undoNotificationId)] case .setAlert(let isPresented): setAlert(&state, isPresented: isPresented) case .toggleSortOption: @@ -244,10 +244,10 @@ private extension PushNotificationListViewModel { case .setToast(let isPresented): setToast(&state, isPresented: isPresented) if !isPresented { - if let undoDeleteNotificationId { - removeHiddenNotification(&state, notificationId: undoDeleteNotificationId) + if let undoNotificationId { + removeHiddenNotification(&state, notificationId: undoNotificationId) } - self.undoDeleteNotificationId = nil + self.undoNotificationId = nil } case .setSelectedTodoId(let todoId): state.selectedTodoId = todoId diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index 44b545ce..dc0f6def 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -88,7 +88,7 @@ final class TodoListViewModel: Store { private let deleteTodoUseCase: DeleteTodoUseCase private let undoDeleteTodoUseCase: UndoDeleteTodoUseCase private let loadingState = LoadingState() - private var undoDeleteTodoId: String? + private var undoTodoId: String? private var nextCursor: TodoCursor? private var searchTasks: [SearchTaskKind: Task] = [:] private let searchDebounceDelay: Double = 0.4 @@ -285,7 +285,7 @@ private extension TodoListViewModel { state.showEditor = value case .swipeTodo(let todo): if state.todos.contains(where: { $0.id == todo.id }) { - undoDeleteTodoId = todo.id + self.undoTodoId = todo.id setTodoHidden(&state, todoId: todo.id, isHidden: true) setToast(&state, isPresented: true) return [.delete(todo)] @@ -325,10 +325,10 @@ private extension TodoListViewModel { case .tapTogglePinned(let todo): return [.togglePinned(todo)] case .undoDelete: - guard let undoDeleteTodoId else { return [] } - setTodoHidden(&state, todoId: undoDeleteTodoId, isHidden: false) - self.undoDeleteTodoId = nil - return [.undoDelete(undoDeleteTodoId)] + guard let undoTodoId else { return [] } + setTodoHidden(&state, todoId: undoTodoId, isHidden: false) + self.undoTodoId = nil + return [.undoDelete(undoTodoId)] default: break } @@ -356,10 +356,10 @@ private extension TodoListViewModel { case .setToast(let isPresented): setToast(&state, isPresented: isPresented) if !isPresented { - if let undoDeleteTodoId { - removeHiddenTodo(&state, todoId: undoDeleteTodoId) + if let undoTodoId { + removeHiddenTodo(&state, todoId: undoTodoId) } - self.undoDeleteTodoId = nil + self.undoTodoId = nil } case .upsertTodo(let todo): return [.upsert(todo)] From 301b8dd9da1c6c7b81469531bec68c8940231f31 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 19 Apr 2026 00:39:40 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=ED=98=B9=EC=8B=9C=EB=9D=BC?= =?UTF-8?q?=EB=8F=84=20=EB=82=A8=EC=95=84=EC=9E=88=EC=9D=84=20isHidden=20?= =?UTF-8?q?=EC=9A=94=EC=86=8C=EB=A5=BC=20=EB=AA=A8=EB=91=90=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=ED=95=98=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 --- .../ViewModel/PushNotificationListViewModel.swift | 11 +---------- .../Presentation/ViewModel/TodoListViewModel.swift | 13 ++----------- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift index 9f1a06fb..8a5ef9f5 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift @@ -244,9 +244,7 @@ private extension PushNotificationListViewModel { case .setToast(let isPresented): setToast(&state, isPresented: isPresented) if !isPresented { - if let undoNotificationId { - removeHiddenNotification(&state, notificationId: undoNotificationId) - } + state.notifications.removeAll { $0.isHidden } self.undoNotificationId = nil } case .setSelectedTodoId(let todoId): @@ -318,13 +316,6 @@ private extension PushNotificationListViewModel { } } - func removeHiddenNotification( - _ state: inout State, - notificationId: String - ) { - state.notifications.removeAll { $0.id == notificationId && $0.isHidden } - } - func mergedHiddenNotifications( currentNotifications: [PushNotificationItem], incomingNotifications: [PushNotificationItem] diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index dc0f6def..9700fc49 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -356,9 +356,8 @@ private extension TodoListViewModel { case .setToast(let isPresented): setToast(&state, isPresented: isPresented) if !isPresented { - if let undoTodoId { - removeHiddenTodo(&state, todoId: undoTodoId) - } + state.todos.removeAll { $0.isHidden } + state.searchResults.removeAll { $0.isHidden } self.undoTodoId = nil } case .upsertTodo(let todo): @@ -442,14 +441,6 @@ private extension TodoListViewModel { } } - func removeHiddenTodo( - _ state: inout State, - todoId: String - ) { - state.todos.removeAll { $0.id == todoId && $0.isHidden } - state.searchResults.removeAll { $0.id == todoId && $0.isHidden } - } - func scheduleDebouncedSearch(_ query: String) { searchTasks[.debounce]?.cancel() let debounceTask = Task { [weak self] in From 306aec36a9c1b1828a4b08771599daf5c04aed59 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 19 Apr 2026 00:46:25 +0900 Subject: [PATCH 10/10] =?UTF-8?q?refactor:=20=EC=88=A8=EA=B9=80=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=B3=91=ED=95=A9=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=98=20=ED=8F=89=EA=B7=A0=20=EC=8B=9C=EA=B0=84=EB=B3=B5?= =?UTF-8?q?=EC=9E=A1=EB=8F=84=EB=A5=BC=20O(N*M)=EC=97=90=EC=84=9C=20O(N+M)?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/PushNotificationListViewModel.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift index 8a5ef9f5..f369bab4 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift @@ -320,10 +320,13 @@ private extension PushNotificationListViewModel { currentNotifications: [PushNotificationItem], incomingNotifications: [PushNotificationItem] ) -> [PushNotificationItem] { - incomingNotifications.map { incomingNotification in - guard let currentNotification = currentNotifications.first(where: { - $0.id == incomingNotification.id - }), currentNotification.isHidden else { + let hiddenNotificationIds = Set(currentNotifications + .filter(\.isHidden) + .map(\.id) + ) + + return incomingNotifications.map { incomingNotification in + guard hiddenNotificationIds.contains(incomingNotification.id) else { return incomingNotification }