From a999658685fc5f3bd77e8ead21964b6fccc0ae1f Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 00:54:22 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20PushNotificationListView=20store?= =?UTF-8?q?=201=EC=B0=A8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationListFeature.swift | 343 ++++++++++++++++++ ...hNotificationListFeatureDependencies.swift | 86 +++++ .../PushNotificationListView.swift | 89 +++-- .../PushNotificationListViewCoordinator.swift | 27 +- .../DeletePushNotificationTests.swift | 128 ------- .../PushNotificationListFeatureTests.swift | 125 +++++++ .../PushNotificationListFixtures.swift | 32 ++ .../PushNotificationListTestAssertions.swift | 204 +++++++++++ .../PushNotificationListTestSupport.swift | 275 ++++++++++++++ .../PushNotificationListViewModelTests.swift | 125 +++++++ 10 files changed, 1261 insertions(+), 173 deletions(-) create mode 100644 Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift create mode 100644 Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeatureDependencies.swift delete mode 100644 Application/DevLogPresentation/Tests/PushNotification/DeletePushNotificationTests.swift create mode 100644 Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift create mode 100644 Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFixtures.swift create mode 100644 Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestAssertions.swift create mode 100644 Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift create mode 100644 Application/DevLogPresentation/Tests/PushNotification/PushNotificationListViewModelTests.swift diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift new file mode 100644 index 00000000..0196414c --- /dev/null +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift @@ -0,0 +1,343 @@ +// +// PushNotificationListFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/12/26. +// + +import Combine +import ComposableArchitecture +import DevLogCore +import DevLogDomain +import Foundation + +@Reducer +struct PushNotificationListFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + var notifications: [PushNotificationItem] = [] + var hasMore = false + var nextCursor: PushNotificationCursor? + var query: PushNotificationQuery + var selectedNotificationId: String? + var selectedTodoId: TodoIdItem? + var loading = LoadingFeature.State() + var undoNotificationId: String? + var deleteToastNotificationId: String? + + init(query: PushNotificationQuery = .default) { + self.query = query + } + + var isLoading: Bool { + loading.isLoading + } + + var appliedFilterCount: Int { + var count = 0 + if query.sortOrder != .latest { count += 1 } + if query.timeFilter != .none { count += 1 } + if query.unreadOnly { count += 1 } + return count + } + } + + enum Action { + case alert(PresentationAction) + case fetchNotifications + case loadNextPage + case deleteNotification(PushNotificationItem) + case toggleRead(PushNotificationItem) + case undoDelete + case finishDeleteToast(String) + case presentedDeleteToast + case setAlert + case appendNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?) + case resetPagination + case setHasMore(Bool) + case syncNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?, hasMore: Bool) + case setNotificationHidden(String, Bool) + case toggleSortOption + case setTimeFilter(PushNotificationQuery.TimeFilter) + case toggleUnreadOnly + case resetFilters + case selectNotification(String?) + case observeNotifications(PushNotificationQuery, Int) + case loading(LoadingFeature.Action) + } + + enum CancelID: Hashable { + case fetchNotifications + case observeNotifications + case toggleRead + } + + @Dependency(\.fetchPushNotificationsUseCase) var fetchPushNotificationsUseCase + @Dependency(\.deletePushNotificationUseCase) var deletePushNotificationUseCase + @Dependency(\.undoDeletePushNotificationUseCase) var undoDeletePushNotificationUseCase + @Dependency(\.togglePushNotificationReadUseCase) var togglePushNotificationReadUseCase + @Dependency(\.updatePushNotificationQueryUseCase) var updatePushNotificationQueryUseCase + + var body: some ReducerOf { + Scope(state: \.loading, action: \.loading) { + LoadingFeature() + } + Reduce { state, action in + reduce(action, state: &state) + } + .ifLet(\.$alert, action: \.alert) + } +} + +private extension PushNotificationListFeature { + func reduce( + _ action: Action, + state: inout State + ) -> Effect { + switch action { + case .alert: + break + case .fetchNotifications: + state.nextCursor = nil + return fetchNotificationsEffect(query: state.query, cursor: nil, existingCount: 0) + case .loadNextPage: + guard state.hasMore, !state.isLoading else { return .none } + return fetchNotificationsEffect( + query: state.query, + cursor: state.nextCursor, + existingCount: state.notifications.count + ) + case .deleteNotification(let item): + guard state.notifications.contains(where: { $0.id == item.id }) else { return .none } + state.undoNotificationId = item.id + state.deleteToastNotificationId = item.id + Self.setNotificationHidden(&state, notificationId: item.id, isHidden: true) + return deleteNotificationEffect(item) + case .toggleRead(let item): + guard let index = state.notifications.firstIndex(where: { $0.id == item.id }) else { + return .none + } + state.notifications[index].isRead.toggle() + return toggleReadEffect(item.todoId) + case .undoDelete: + guard let undoNotificationId = state.undoNotificationId else { return .none } + Self.setNotificationHidden(&state, notificationId: undoNotificationId, isHidden: false) + state.undoNotificationId = nil + return undoDeleteEffect(undoNotificationId) + case .finishDeleteToast(let notificationId): + state.notifications.removeAll { $0.id == notificationId && $0.isHidden } + if state.undoNotificationId == notificationId { + state.undoNotificationId = nil + } + case .presentedDeleteToast: + state.deleteToastNotificationId = nil + case .setAlert: + state.alert = Self.alertState() + case .appendNotifications(let notifications, let nextCursor): + state.notifications.append(contentsOf: Self.mergedHiddenNotifications( + currentNotifications: state.notifications, + incomingNotifications: notifications + )) + state.nextCursor = nextCursor + case .resetPagination: + state.notifications = [] + state.nextCursor = nil + case .setHasMore(let value): + state.hasMore = value + case .syncNotifications(let notifications, let nextCursor, let hasMore): + state.notifications = Self.mergedHiddenNotifications( + currentNotifications: state.notifications, + incomingNotifications: notifications + ) + state.nextCursor = nextCursor + state.hasMore = hasMore + case .setNotificationHidden(let notificationId, let isHidden): + Self.setNotificationHidden(&state, notificationId: notificationId, isHidden: isHidden) + case .toggleSortOption: + state.query.sortOrder = state.query.sortOrder == .latest ? .oldest : .latest + state.nextCursor = nil + return refreshForQueryChangeEffect(query: state.query) + case .setTimeFilter(let filter): + state.query.timeFilter = filter + state.nextCursor = nil + return refreshForQueryChangeEffect(query: state.query) + case .toggleUnreadOnly: + state.query.unreadOnly.toggle() + state.nextCursor = nil + return refreshForQueryChangeEffect(query: state.query) + case .resetFilters: + state.query = .default + state.nextCursor = nil + return refreshForQueryChangeEffect(query: state.query) + case .selectNotification(let notificationId): + state.selectedNotificationId = notificationId + guard let notificationId else { + state.selectedTodoId = nil + return .none + } + guard let index = state.notifications.firstIndex(where: { $0.id == notificationId }) else { + state.selectedTodoId = nil + return .none + } + let item = state.notifications[index] + state.selectedTodoId = TodoIdItem(id: item.todoId) + guard !item.isRead else { return .none } + state.notifications[index].isRead = true + return toggleReadEffect(item.todoId) + case .observeNotifications(let query, let limit): + return observeNotificationsEffect(query: query, limit: limit) + case .loading: + break + } + + return .none + } + + func refreshForQueryChangeEffect(query: PushNotificationQuery) -> Effect { + .merge( + updateQueryEffect(query: query), + fetchNotificationsEffect(query: query, cursor: nil, existingCount: 0) + ) + } + + func fetchNotificationsEffect( + query: PushNotificationQuery, + cursor: PushNotificationCursor?, + existingCount: Int + ) -> Effect { + let limit = max(query.pageSize, existingCount) + let fetchEffect: Effect = .run { [fetchPushNotificationsUseCase] send in + await send(.loading(.begin(target: .default, mode: .delayed))) + do { + let page = try await fetchPushNotificationsUseCase.execute(query, cursor: cursor) + if cursor == nil { + await send(.resetPagination) + } + await send( + .appendNotifications( + page.items.map(PushNotificationItem.init(from:)), + nextCursor: page.nextCursor + ) + ) + await send(.setHasMore(page.items.count == query.pageSize && page.nextCursor != nil)) + await send(.observeNotifications(query, max(limit, existingCount + page.items.count))) + await send(.loading(.end(target: .default, mode: .delayed))) + } catch { + await send(.loading(.end(target: .default, mode: .delayed))) + await send(.setAlert) + } + } + .cancellable(id: CancelID.fetchNotifications, cancelInFlight: true) + + if cursor == nil { + return .concatenate( + .cancel(id: CancelID.observeNotifications), + fetchEffect + ) + } + + return fetchEffect + } + + func observeNotificationsEffect( + query: PushNotificationQuery, + limit: Int + ) -> Effect { + .run { [fetchPushNotificationsUseCase] send in + do { + let publisher = try fetchPushNotificationsUseCase.observe(query, limit: limit) + for try await page in publisher.values { + let items = page.items.map(PushNotificationItem.init(from:)) + let hasMore = items.count == max(query.pageSize, limit) && page.nextCursor != nil + await send(.syncNotifications(items, nextCursor: page.nextCursor, hasMore: hasMore)) + } + } catch is CancellationError { + } catch { + await send(.setAlert) + } + } + .cancellable(id: CancelID.observeNotifications, cancelInFlight: true) + } + + func deleteNotificationEffect(_ item: PushNotificationItem) -> Effect { + .run { [deletePushNotificationUseCase] send in + do { + try await deletePushNotificationUseCase.execute(item.id) + } catch { + await send(.setNotificationHidden(item.id, false)) + await send(.setAlert) + } + } + } + + func undoDeleteEffect(_ notificationId: String) -> Effect { + .run { [undoDeletePushNotificationUseCase] send in + do { + try await undoDeletePushNotificationUseCase.execute(notificationId) + } catch { + await send(.setNotificationHidden(notificationId, true)) + await send(.setAlert) + } + } + } + + func toggleReadEffect(_ todoId: String) -> Effect { + .run { [togglePushNotificationReadUseCase] send in + await send(.loading(.begin(target: .default, mode: .delayed))) + do { + try await togglePushNotificationReadUseCase.execute(todoId) + await send(.loading(.end(target: .default, mode: .delayed))) + } catch { + await send(.loading(.end(target: .default, mode: .delayed))) + await send(.setAlert) + } + } + .cancellable(id: CancelID.toggleRead, cancelInFlight: true) + } + + func updateQueryEffect(query: PushNotificationQuery) -> Effect { + .run { [updatePushNotificationQueryUseCase] _ in + updatePushNotificationQueryUseCase.execute(query) + } + } + + static func alertState() -> AlertState { + AlertState { + TextState(String(localized: "common_error_title")) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(String(localized: "common_error_message")) + } + } + + static func setNotificationHidden( + _ state: inout State, + notificationId: String, + isHidden: Bool + ) { + if let index = state.notifications.firstIndex(where: { $0.id == notificationId }) { + state.notifications[index].isHidden = isHidden + } + } + + static func mergedHiddenNotifications( + currentNotifications: [PushNotificationItem], + incomingNotifications: [PushNotificationItem] + ) -> [PushNotificationItem] { + let hiddenNotificationIds = Set(currentNotifications.filter(\.isHidden).map(\.id)) + + return incomingNotifications.map { notification in + guard hiddenNotificationIds.contains(notification.id) else { + return notification + } + + var hiddenNotification = notification + hiddenNotification.isHidden = true + return hiddenNotification + } + } +} diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeatureDependencies.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeatureDependencies.swift new file mode 100644 index 00000000..b7786a2b --- /dev/null +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeatureDependencies.swift @@ -0,0 +1,86 @@ +// +// PushNotificationListFeatureDependencies.swift +// DevLogPresentation +// +// Created by opfic on 6/12/26. +// + +import ComposableArchitecture +import DevLogDomain + +extension DependencyValues { + var fetchPushNotificationsUseCase: FetchPushNotificationsUseCase { + get { self[FetchPushNotificationsUseCaseKey.self] } + set { self[FetchPushNotificationsUseCaseKey.self] = newValue } + } + + var deletePushNotificationUseCase: DeletePushNotificationUseCase { + get { self[DeletePushNotificationUseCaseKey.self] } + set { self[DeletePushNotificationUseCaseKey.self] = newValue } + } + + var undoDeletePushNotificationUseCase: UndoDeletePushNotificationUseCase { + get { self[UndoDeletePushNotificationUseCaseKey.self] } + set { self[UndoDeletePushNotificationUseCaseKey.self] = newValue } + } + + var togglePushNotificationReadUseCase: TogglePushNotificationReadUseCase { + get { self[TogglePushNotificationReadUseCaseKey.self] } + set { self[TogglePushNotificationReadUseCaseKey.self] = newValue } + } + + var updatePushNotificationQueryUseCase: UpdatePushNotificationQueryUseCase { + get { self[UpdatePushNotificationQueryUseCaseKey.self] } + set { self[UpdatePushNotificationQueryUseCaseKey.self] = newValue } + } +} + +private enum FetchPushNotificationsUseCaseKey: DependencyKey { + static var liveValue: FetchPushNotificationsUseCase { + preconditionFailure("FetchPushNotificationsUseCase must be provided.") + } + + static var testValue: FetchPushNotificationsUseCase { + liveValue + } +} + +private enum DeletePushNotificationUseCaseKey: DependencyKey { + static var liveValue: DeletePushNotificationUseCase { + preconditionFailure("DeletePushNotificationUseCase must be provided.") + } + + static var testValue: DeletePushNotificationUseCase { + liveValue + } +} + +private enum UndoDeletePushNotificationUseCaseKey: DependencyKey { + static var liveValue: UndoDeletePushNotificationUseCase { + preconditionFailure("UndoDeletePushNotificationUseCase must be provided.") + } + + static var testValue: UndoDeletePushNotificationUseCase { + liveValue + } +} + +private enum TogglePushNotificationReadUseCaseKey: DependencyKey { + static var liveValue: TogglePushNotificationReadUseCase { + preconditionFailure("TogglePushNotificationReadUseCase must be provided.") + } + + static var testValue: TogglePushNotificationReadUseCase { + liveValue + } +} + +private enum UpdatePushNotificationQueryUseCaseKey: DependencyKey { + static var liveValue: UpdatePushNotificationQueryUseCase { + preconditionFailure("UpdatePushNotificationQueryUseCase must be provided.") + } + + static var testValue: UpdatePushNotificationQueryUseCase { + liveValue + } +} diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift index f601a095..bfe3011a 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift @@ -6,8 +6,8 @@ // import SwiftUI +import ComposableArchitecture import DevLogCore -import DevLogDomain struct PushNotificationListView: View { @Environment(\.colorScheme) private var colorScheme @@ -15,11 +15,17 @@ struct PushNotificationListView: View { @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = 34 @State private var headerOffset: CGFloat = 0 @State private var isScrollTrackingEnabled = false + @State private var store: StoreOf let coordinator: PushNotificationListViewCoordinator let isCompactLayout: Bool - private var viewModel: PushNotificationListViewModel { - coordinator.viewModel + init( + coordinator: PushNotificationListViewCoordinator, + isCompactLayout: Bool + ) { + self.coordinator = coordinator + self.isCompactLayout = isCompactLayout + self._store = State(initialValue: coordinator.store) } var body: some View { @@ -32,19 +38,15 @@ struct PushNotificationListView: View { headerOffset = max(0, -offset) } .safeAreaInset(edge: .top) { safeAreaHeader } - .refreshable { viewModel.send(.fetchNotifications) } + .refreshable { store.send(.fetchNotifications) } .navigationTitle(String(localized: "nav_push_notifications")) .listStyle(.plain) } - .alert( - viewModel.state.alertTitle, - isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert(isPresented: $0)) } - )) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(viewModel.state.alertMessage) + .alert($store.scope(state: \.alert, action: \.alert)) + .onChange(of: store.deleteToastNotificationId) { _, notificationId in + guard let notificationId else { return } + presentDeleteNotificationToast(notificationId) + store.send(.presentedDeleteToast) } .sheet(item: Binding( get: { isCompactLayout ? coordinator.todoIdToPresent : nil }, @@ -56,7 +58,7 @@ struct PushNotificationListView: View { )) { item in NavigationStack { TodoDetailView(store: coordinator.makeTodoDetailStore(todoId: item.id)) - .id(item.id) + .id(item.id) .toolbar { ToolbarLeadingButton { selectNotification(nil) @@ -67,7 +69,7 @@ struct PushNotificationListView: View { .presentationDragIndicator(.visible) } .overlay { - if viewModel.state.isLoading { + if store.isLoading { LoadingView() } } @@ -75,7 +77,7 @@ struct PushNotificationListView: View { @ViewBuilder private var notificationList: some View { - let notifications = viewModel.state.notifications.filter { !$0.isHidden } + let notifications = store.notifications.filter { !$0.isHidden } if notifications.isEmpty { Text(String(localized: "push_notifications_empty")) .foregroundStyle(Color.gray) @@ -125,12 +127,12 @@ struct PushNotificationListView: View { ) -> some View { notificationRow( notification, - isSelected: !isCompactLayout && viewModel.state.selectedNotificationId == notification.id + isSelected: !isCompactLayout && store.selectedNotificationId == notification.id ) .onAppear { let lastId = notifications.last?.id - if notification.id == lastId, viewModel.state.hasMore { - viewModel.send(.loadNextPage) + if notification.id == lastId, store.hasMore { + store.send(.loadNextPage) } } .overlay(alignment: .top) { @@ -186,16 +188,16 @@ struct PushNotificationListView: View { private var headerContent: some View { HStack(spacing: 8) { - if 0 < viewModel.appliedFilterCount { + if 0 < store.appliedFilterCount { Menu { Text( String.localizedStringWithFormat( String(localized: "push_filters_applied_format"), - Int64(viewModel.appliedFilterCount) + Int64(store.appliedFilterCount) ) ) Button(role: .destructive) { - viewModel.send(.resetFilters) + store.send(.resetFilters) } label: { Text(String(localized: "push_clear_all_filters")) } @@ -210,14 +212,14 @@ struct PushNotificationListView: View { Button { DispatchQueue.main.async { - viewModel.send(.toggleSortOption) + store.send(.toggleSortOption) } } label: { - let condition = viewModel.state.query.sortOrder == .oldest + let condition = store.query.sortOrder == .oldest Text( String.localizedStringWithFormat( String(localized: "push_sort_format"), - viewModel.state.query.sortOrder.title + store.query.sortOrder.title ) ) .foregroundStyle(condition ? .white : Color(.label)) @@ -226,8 +228,8 @@ struct PushNotificationListView: View { Menu { Picker(selection: Binding( - get: { viewModel.state.query.timeFilter }, - set: { viewModel.send(.setTimeFilter($0)) } + get: { store.query.timeFilter }, + set: { store.send(.setTimeFilter($0)) } )) { ForEach(PushNotificationQuery.TimeFilter.availableOptions, id: \.self) { option in Text(option.title).tag(option) @@ -236,7 +238,7 @@ struct PushNotificationListView: View { Text(String(localized: "push_period")) } } label: { - let condition = viewModel.state.query.timeFilter == .none + let condition = store.query.timeFilter == .none HStack { Text(String(localized: "push_period")) Image(systemName: "chevron.down") @@ -247,10 +249,10 @@ struct PushNotificationListView: View { Button { DispatchQueue.main.async { - viewModel.send(.toggleUnreadOnly) + store.send(.toggleUnreadOnly) } } label: { - let condition = viewModel.state.query.unreadOnly + let condition = store.query.unreadOnly Text(String(localized: "push_unread")) .foregroundStyle(condition ? .white : Color(.label)) .adaptiveButtonStyle(color: condition ? .blue : .clear) @@ -264,7 +266,7 @@ struct PushNotificationListView: View { let textColor: Color = isDark ? blue : .white let backgroundColor: Color = isDark ? .white : blue - return Text("\(viewModel.appliedFilterCount)") + return Text("\(store.appliedFilterCount)") .font(.caption2.weight(.bold)) .foregroundColor(textColor) .lineLimit(1) @@ -322,7 +324,7 @@ struct PushNotificationListView: View { } .swipeActions(edge: .leading) { Button { - viewModel.send(.toggleRead(item)) + store.send(.toggleRead(item)) } label: { Image(systemName: "checkmark.circle\(item.isRead ? ".badge.xmark" : "")") .tint(.blue) @@ -332,7 +334,7 @@ struct PushNotificationListView: View { Button( role: .destructive, action: { - viewModel.send(.deleteNotification(item)) + store.send(.deleteNotification(item)) } ) { Image(systemName: "trash") @@ -371,7 +373,24 @@ struct PushNotificationListView: View { } private func selectNotification(_ notificationId: String?) { - viewModel.send(.selectNotification(notificationId)) - coordinator.todoIdToPresent = viewModel.state.selectedTodoId + store.send(.selectNotification(notificationId)) + coordinator.todoIdToPresent = store.selectedTodoId + } + + private func presentDeleteNotificationToast(_ notificationId: String) { + ToastPresenter.present( + message: String(localized: "common_undo"), + systemImage: "arrow.uturn.left", + duration: 5, + font: .caption, + multilineTextAlignment: .center, + lineLimit: 3, + action: { + store.send(.undoDelete) + }, + onDismiss: { + store.send(.finishDeleteToast(notificationId)) + } + ) } } diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift index 4dea112a..71cb95a4 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift @@ -13,7 +13,7 @@ import DevLogDomain @MainActor @Observable final class PushNotificationListViewCoordinator { - let viewModel: PushNotificationListViewModel + let store: StoreOf var todoIdToPresent: TodoIdItem? private let container: DIContainer @ObservationIgnored @@ -21,18 +21,25 @@ final class PushNotificationListViewCoordinator { init(container: DIContainer) { self.container = container - self.viewModel = PushNotificationListViewModel( - fetchUseCase: container.resolve(FetchPushNotificationsUseCase.self), - deleteUseCase: container.resolve(DeletePushNotificationUseCase.self), - undoDeleteUseCase: container.resolve(UndoDeletePushNotificationUseCase.self), - toggleReadUseCase: container.resolve(TogglePushNotificationReadUseCase.self), - fetchQueryUseCase: container.resolve(FetchPushNotificationQueryUseCase.self), - updateQueryUseCase: container.resolve(UpdatePushNotificationQueryUseCase.self) - ) + let fetchQueryUseCase = container.resolve(FetchPushNotificationQueryUseCase.self) + + self.store = Store( + initialState: PushNotificationListFeature.State( + query: fetchQueryUseCase.execute() + ) + ) { + PushNotificationListFeature() + } withDependencies: { + $0.fetchPushNotificationsUseCase = container.resolve(FetchPushNotificationsUseCase.self) + $0.deletePushNotificationUseCase = container.resolve(DeletePushNotificationUseCase.self) + $0.undoDeletePushNotificationUseCase = container.resolve(UndoDeletePushNotificationUseCase.self) + $0.togglePushNotificationReadUseCase = container.resolve(TogglePushNotificationReadUseCase.self) + $0.updatePushNotificationQueryUseCase = container.resolve(UpdatePushNotificationQueryUseCase.self) + } } func fetchData() { - viewModel.send(.fetchNotifications) + store.send(.fetchNotifications) } func makeTodoDetailStore(todoId: String) -> StoreOf { diff --git a/Application/DevLogPresentation/Tests/PushNotification/DeletePushNotificationTests.swift b/Application/DevLogPresentation/Tests/PushNotification/DeletePushNotificationTests.swift deleted file mode 100644 index d18c3a4e..00000000 --- a/Application/DevLogPresentation/Tests/PushNotification/DeletePushNotificationTests.swift +++ /dev/null @@ -1,128 +0,0 @@ -// -// DeletePushNotificationTests.swift -// DevLogPresentationTests -// -// Created by opfic on 4/6/26. -// - -import Testing -import Foundation -import DevLogCore -import DevLogDomain -@testable import DevLogPresentation - -@MainActor -struct DeletePushNotificationTests { - @Test("삭제하면 항목이 즉시 숨겨지고 되돌리기 토스트가 표시되며 삭제 유스케이스가 호출된다") - func 삭제하면_항목이_즉시_숨겨지고_되돌리기_토스트가_표시되며_삭제_유스케이스가_호출된다() async throws { - ToastPresenter.reset() - - let fetchPushNotificationsUseCaseSpy = FetchPushNotificationsUseCaseSpy( - pushNotificationPage: PushNotificationPage( - items: [ - PushNotification( - id: "notification-1", - title: "title", - body: "body", - receivedAt: .now, - isRead: false, - todoId: "todo-1", - todoCategory: .system(.feature) - ) - ], - nextCursor: nil - ) - ) - let deletePushNotificationUseCaseSpy = DeletePushNotificationUseCaseSpy() - let undoDeletePushNotificationUseCaseSpy = UndoDeletePushNotificationUseCaseSpy() - let togglePushNotificationReadUseCaseSpy = TogglePushNotificationReadUseCaseSpy() - let fetchPushNotificationQueryUseCaseSpy = FetchPushNotificationQueryUseCaseSpy() - let updatePushNotificationQueryUseCaseSpy = UpdatePushNotificationQueryUseCaseSpy() - - let pushNotificationListViewModel = PushNotificationListViewModel( - fetchUseCase: fetchPushNotificationsUseCaseSpy, - deleteUseCase: deletePushNotificationUseCaseSpy, - undoDeleteUseCase: undoDeletePushNotificationUseCaseSpy, - toggleReadUseCase: togglePushNotificationReadUseCaseSpy, - fetchQueryUseCase: fetchPushNotificationQueryUseCaseSpy, - updateQueryUseCase: updatePushNotificationQueryUseCaseSpy - ) - - pushNotificationListViewModel.send(.fetchNotifications) - await waitUntil { - !pushNotificationListViewModel.state.notifications.isEmpty - } - - let pushNotificationItem = try #require(pushNotificationListViewModel.state.notifications.first) - - pushNotificationListViewModel.send(.deleteNotification(pushNotificationItem)) - - #expect(pushNotificationListViewModel.state.notifications.filter { !$0.isHidden }.isEmpty) - #expect(ToastPresenter.item?.message == String(localized: "common_undo")) - - await waitUntil { - deletePushNotificationUseCaseSpy.calledNotificationIds == ["notification-1"] - } - - #expect(deletePushNotificationUseCaseSpy.calledNotificationIds == ["notification-1"]) - } - - @Test("삭제를 되돌리면 되돌리기 유스케이스가 호출되고 숨김 상태가 해제된다") - func 삭제를_되돌리면_되돌리기_유스케이스가_호출되고_숨김_상태가_해제된다() async throws { - ToastPresenter.reset() - - let fetchPushNotificationsUseCaseSpy = FetchPushNotificationsUseCaseSpy( - pushNotificationPage: PushNotificationPage( - items: [ - PushNotification( - id: "notification-1", - title: "title", - body: "body", - receivedAt: .now, - isRead: false, - todoId: "todo-1", - todoCategory: .system(.feature) - ) - ], - nextCursor: nil - ) - ) - let deletePushNotificationUseCaseSpy = DeletePushNotificationUseCaseSpy() - let undoDeletePushNotificationUseCaseSpy = UndoDeletePushNotificationUseCaseSpy() - let togglePushNotificationReadUseCaseSpy = TogglePushNotificationReadUseCaseSpy() - let fetchPushNotificationQueryUseCaseSpy = FetchPushNotificationQueryUseCaseSpy() - let updatePushNotificationQueryUseCaseSpy = UpdatePushNotificationQueryUseCaseSpy() - - let pushNotificationListViewModel = PushNotificationListViewModel( - fetchUseCase: fetchPushNotificationsUseCaseSpy, - deleteUseCase: deletePushNotificationUseCaseSpy, - undoDeleteUseCase: undoDeletePushNotificationUseCaseSpy, - toggleReadUseCase: togglePushNotificationReadUseCaseSpy, - fetchQueryUseCase: fetchPushNotificationQueryUseCaseSpy, - updateQueryUseCase: updatePushNotificationQueryUseCaseSpy - ) - - pushNotificationListViewModel.send(.fetchNotifications) - await waitUntil { - !pushNotificationListViewModel.state.notifications.isEmpty - } - - let pushNotificationItem = try #require(pushNotificationListViewModel.state.notifications.first) - - pushNotificationListViewModel.send(.deleteNotification(pushNotificationItem)) - pushNotificationListViewModel.send(.undoDelete) - - await waitUntil { - undoDeletePushNotificationUseCaseSpy.calledNotificationIds == ["notification-1"] - } - - let restoredPushNotificationItem = try #require( - pushNotificationListViewModel.state.notifications.first { - $0.id == "notification-1" - } - ) - - #expect(undoDeletePushNotificationUseCaseSpy.calledNotificationIds == ["notification-1"]) - #expect(!restoredPushNotificationItem.isHidden) - } -} diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift new file mode 100644 index 00000000..accb9a8f --- /dev/null +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift @@ -0,0 +1,125 @@ +// +// PushNotificationListFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/12/26. +// + +import Testing +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +struct PushNotificationListFeatureTests { + @Test("fetchNotifications는 첫 페이지를 조회하고 목록과 hasMore 상태를 갱신한다") + func fetchNotifications는_첫_페이지를_조회하고_목록과_hasMore_상태를_갱신한다() async throws { + let cursor = makePushNotificationCursor(documentID: "cursor-1") + let notifications = (0..<20).map { + makePushNotification(id: "notification-\($0)", number: $0, isRead: $0.isMultiple(of: 2)) + } + let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ + PushNotificationPage(items: notifications, nextCursor: cursor) + ]) + let adapter = PushNotificationListStoreTestAdapter(fetchUseCase: fetchSpy) + + try await verifyFetchNotifications(adapter: adapter, fetchUseCaseSpy: fetchSpy) + } + + @Test("loadNextPage는 다음 커서로 조회한 알림을 기존 목록 뒤에 추가한다") + func loadNextPage는_다음_커서로_조회한_알림을_기존_목록_뒤에_추가한다() async throws { + let cursor = makePushNotificationCursor(documentID: "cursor-1") + let firstPage = (0..<20).map { + makePushNotification(id: "notification-\($0)", number: $0) + } + let nextNotification = makePushNotification(id: "notification-next", number: 20) + let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ + PushNotificationPage(items: firstPage, nextCursor: cursor), + PushNotificationPage(items: [nextNotification], nextCursor: nil) + ]) + let adapter = PushNotificationListStoreTestAdapter(fetchUseCase: fetchSpy) + + try await verifyLoadNextPage( + adapter: adapter, + fetchUseCaseSpy: fetchSpy, + nextNotification: nextNotification + ) + } + + @Test("필터 액션은 query와 적용 필터 수를 갱신한다") + func 필터_액션은_query와_적용_필터_수를_갱신한다() async throws { + let updateSpy = UpdatePushNotificationQueryUseCaseSpy() + let adapter = PushNotificationListStoreTestAdapter(updateQueryUseCase: updateSpy) + + try await verifyFilterStateTransitions( + adapter: adapter, + updateQueryUseCaseSpy: updateSpy + ) + } + + @Test("selectNotification은 선택 상태를 바꾸고 읽지 않은 알림을 읽음 처리한다") + func selectNotification은_선택_상태를_바꾸고_읽지_않은_알림을_읽음_처리한다() async throws { + let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ + PushNotificationPage( + items: [ + makePushNotification(id: "notification-1", number: 1, isRead: false) + ], + nextCursor: nil + ) + ]) + let toggleSpy = TogglePushNotificationReadUseCaseSpy() + let adapter = PushNotificationListStoreTestAdapter( + fetchUseCase: fetchSpy, + toggleReadUseCase: toggleSpy + ) + + try await verifySelectNotification( + adapter: adapter, + toggleReadUseCaseSpy: toggleSpy + ) + } + + @Test("toggleRead는 알림 읽음 상태를 토글하고 유스케이스를 호출한다") + func toggleRead는_알림_읽음_상태를_토글하고_유스케이스를_호출한다() async throws { + let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ + PushNotificationPage( + items: [ + makePushNotification(id: "notification-1", number: 1, isRead: true) + ], + nextCursor: nil + ) + ]) + let toggleSpy = TogglePushNotificationReadUseCaseSpy() + let adapter = PushNotificationListStoreTestAdapter( + fetchUseCase: fetchSpy, + toggleReadUseCase: toggleSpy + ) + + try await verifyToggleRead( + adapter: adapter, + toggleReadUseCaseSpy: toggleSpy + ) + } + + @Test("delete와 undoDelete는 숨김 상태와 최종 제거 상태를 제어한다") + func delete와_undoDelete는_숨김_상태와_최종_제거_상태를_제어한다() async throws { + let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ + PushNotificationPage( + items: [makePushNotification(id: "notification-1", number: 1)], + nextCursor: nil + ) + ]) + let deleteSpy = DeletePushNotificationUseCaseSpy() + let undoSpy = UndoDeletePushNotificationUseCaseSpy() + let adapter = PushNotificationListStoreTestAdapter( + fetchUseCase: fetchSpy, + deleteUseCase: deleteSpy, + undoDeleteUseCase: undoSpy + ) + + try await verifyDeleteUndoAndFinishToast( + adapter: adapter, + deleteUseCaseSpy: deleteSpy, + undoDeleteUseCaseSpy: undoSpy + ) + } +} diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFixtures.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFixtures.swift new file mode 100644 index 00000000..7d9e8941 --- /dev/null +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFixtures.swift @@ -0,0 +1,32 @@ +// +// PushNotificationListFixtures.swift +// DevLogPresentationTests +// +// Created by opfic on 6/12/26. +// + +import DevLogDomain +import Foundation + +func makePushNotification( + id: String, + number: Int, + isRead: Bool = false +) -> PushNotification { + PushNotification( + id: id, + title: "title-\(number)", + body: "body-\(number)", + receivedAt: Date(timeIntervalSince1970: Double(number)), + isRead: isRead, + todoId: "todo-\(number)", + todoCategory: .system(.feature) + ) +} + +func makePushNotificationCursor(documentID: String) -> PushNotificationCursor { + PushNotificationCursor( + receivedAt: .now, + documentID: documentID + ) +} diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestAssertions.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestAssertions.swift new file mode 100644 index 00000000..a7794fd0 --- /dev/null +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestAssertions.swift @@ -0,0 +1,204 @@ +// +// PushNotificationListTestAssertions.swift +// DevLogPresentationTests +// +// Created by opfic on 6/12/26. +// + +import Testing +import DevLogCore +import DevLogDomain +import Foundation +@testable import DevLogPresentation + +@MainActor +func waitUntilMainActor( + timeout: Duration = .seconds(1), + pollInterval: Duration = .milliseconds(20), + _ condition: @escaping @MainActor () -> Bool +) async { + let continuousClock = ContinuousClock() + let deadline = continuousClock.now + timeout + + while !condition() && continuousClock.now < deadline { + try? await Task.sleep(for: pollInterval) + } +} + +@MainActor +func verifyFetchNotifications( + adapter: Adapter, + fetchUseCaseSpy: PushNotificationListFetchUseCaseSpy +) async throws { + let expectedItems = fetchUseCaseSpy.pages[0].items.map(PushNotificationItem.init(from:)) + + await adapter.fetchNotifications() + + await waitUntilMainActor { + adapter.notifications.count == expectedItems.count + } + + let notifications = adapter.notifications + let hasMore = adapter.hasMore + #expect(fetchUseCaseSpy.queries.map(\.pageSize) == [20]) + #expect(fetchUseCaseSpy.cursors.map { $0?.documentID } == [nil]) + #expect(notifications == expectedItems) + #expect(hasMore) +} + +@MainActor +func verifyLoadNextPage( + adapter: Adapter, + fetchUseCaseSpy: PushNotificationListFetchUseCaseSpy, + nextNotification: PushNotification +) async throws { + await adapter.fetchNotifications() + + await waitUntilMainActor { + adapter.notifications.count == 20 + } + + let nextCursorId = try #require(fetchUseCaseSpy.pages.first?.nextCursor?.documentID) + + await adapter.loadNextPage() + + await waitUntilMainActor { + adapter.notifications.count == 21 + } + + let notifications = adapter.notifications + let hasMore = adapter.hasMore + #expect(fetchUseCaseSpy.cursors.map { $0?.documentID } == [nil, nextCursorId]) + #expect(notifications.last == PushNotificationItem(from: nextNotification)) + #expect(!hasMore) +} + +@MainActor +func verifyFilterStateTransitions( + adapter: Adapter, + updateQueryUseCaseSpy: UpdatePushNotificationQueryUseCaseSpy +) async throws { + await adapter.toggleSortOption() + await adapter.setTimeFilter(.hours(24)) + await adapter.toggleUnreadOnly() + + let query = adapter.query + let appliedFilterCount = adapter.appliedFilterCount + #expect(query.sortOrder == .oldest) + #expect(query.timeFilter == .hours(24)) + #expect(query.unreadOnly) + #expect(appliedFilterCount == 3) + #expect(updateQueryUseCaseSpy.queries == [ + PushNotificationQuery(sortOrder: .oldest, timeFilter: .none, unreadOnly: false, pageSize: 20), + PushNotificationQuery(sortOrder: .oldest, timeFilter: .hours(24), unreadOnly: false, pageSize: 20), + PushNotificationQuery(sortOrder: .oldest, timeFilter: .hours(24), unreadOnly: true, pageSize: 20) + ]) + + await adapter.resetFilters() + + let resetQuery = adapter.query + let resetAppliedFilterCount = adapter.appliedFilterCount + #expect(resetQuery == .default) + #expect(resetAppliedFilterCount == 0) + #expect(updateQueryUseCaseSpy.queries.last == .default) +} + +@MainActor +func verifySelectNotification( + adapter: Adapter, + toggleReadUseCaseSpy: TogglePushNotificationReadUseCaseSpy +) async throws { + await adapter.fetchNotifications() + + await waitUntilMainActor { + adapter.notifications.count == 1 + } + + await adapter.selectNotification("notification-1") + + let selectedNotificationId = adapter.selectedNotificationId + let selectedTodoId = adapter.selectedTodoId + let notifications = adapter.notifications + #expect(selectedNotificationId == "notification-1") + #expect(selectedTodoId?.id == "todo-1") + #expect(notifications.first?.isRead == true) + + await waitUntilMainActor { + toggleReadUseCaseSpy.calledTodoIds == ["todo-1"] + } + + await adapter.selectNotification(nil) + + let resetSelectedNotificationId = adapter.selectedNotificationId + let resetSelectedTodoId = adapter.selectedTodoId + #expect(resetSelectedNotificationId == nil) + #expect(resetSelectedTodoId == nil) +} + +@MainActor +func verifyToggleRead( + adapter: Adapter, + toggleReadUseCaseSpy: TogglePushNotificationReadUseCaseSpy +) async throws { + await adapter.fetchNotifications() + + await waitUntilMainActor { + adapter.notifications.count == 1 + } + + let initialNotifications = adapter.notifications + let item = try #require(initialNotifications.first) + + await adapter.toggleRead(item) + + let notifications = adapter.notifications + #expect(notifications.first?.isRead == false) + + await waitUntilMainActor { + toggleReadUseCaseSpy.calledTodoIds == ["todo-1"] + } +} + +@MainActor +func verifyDeleteUndoAndFinishToast( + adapter: Adapter, + deleteUseCaseSpy: DeletePushNotificationUseCaseSpy, + undoDeleteUseCaseSpy: UndoDeletePushNotificationUseCaseSpy +) async throws { + ToastPresenter.reset() + + await adapter.fetchNotifications() + + await waitUntilMainActor { + adapter.notifications.count == 1 + } + + let initialNotifications = adapter.notifications + let item = try #require(initialNotifications.first) + + await adapter.deleteNotification(item) + + let deletedNotifications = adapter.notifications + let toastMessage = ToastPresenter.item?.message + #expect(deletedNotifications.first?.isHidden == true) + #expect(toastMessage == String(localized: "common_undo")) + + await waitUntilMainActor { + deleteUseCaseSpy.calledNotificationIds == ["notification-1"] + } + + await adapter.undoDelete() + + let restoredNotifications = adapter.notifications + #expect(restoredNotifications.first?.isHidden == false) + + await waitUntilMainActor { + undoDeleteUseCaseSpy.calledNotificationIds == ["notification-1"] + } + + await adapter.deleteNotification(item) + await adapter.finishDeleteToast("notification-1") + + let finalNotifications = adapter.notifications + #expect(finalNotifications.isEmpty) +} diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift new file mode 100644 index 00000000..85e1efc2 --- /dev/null +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift @@ -0,0 +1,275 @@ +// +// PushNotificationListTestSupport.swift +// DevLogPresentationTests +// +// Created by opfic on 6/12/26. +// + +import Testing +import Combine +import ComposableArchitecture +import DevLogCore +import DevLogDomain +import Foundation +@testable import DevLogPresentation + +@MainActor +protocol PushNotificationListStateDriving { + var notifications: [PushNotificationItem] { get } + var query: PushNotificationQuery { get } + var hasMore: Bool { get } + var selectedNotificationId: String? { get } + var selectedTodoId: TodoIdItem? { get } + var appliedFilterCount: Int { get } + + func fetchNotifications() async + func loadNextPage() async + func toggleSortOption() async + func setTimeFilter(_ filter: PushNotificationQuery.TimeFilter) async + func toggleUnreadOnly() async + func resetFilters() async + func selectNotification(_ notificationId: String?) async + func toggleRead(_ item: PushNotificationItem) async + func deleteNotification(_ item: PushNotificationItem) async + func undoDelete() async + func finishDeleteToast(_ notificationId: String) async +} + +@MainActor +struct PushNotificationListViewModelTestAdapter: PushNotificationListStateDriving { + private let viewModel: PushNotificationListViewModel + + var notifications: [PushNotificationItem] { viewModel.state.notifications } + var query: PushNotificationQuery { viewModel.state.query } + var hasMore: Bool { viewModel.state.hasMore } + var selectedNotificationId: String? { viewModel.state.selectedNotificationId } + var selectedTodoId: TodoIdItem? { viewModel.state.selectedTodoId } + var appliedFilterCount: Int { viewModel.appliedFilterCount } + + init( + fetchUseCase: FetchPushNotificationsUseCase = PushNotificationListFetchUseCaseSpy(), + deleteUseCase: DeletePushNotificationUseCase = DeletePushNotificationUseCaseSpy(), + undoDeleteUseCase: UndoDeletePushNotificationUseCase = UndoDeletePushNotificationUseCaseSpy(), + toggleReadUseCase: TogglePushNotificationReadUseCase = TogglePushNotificationReadUseCaseSpy(), + fetchQueryUseCase: FetchPushNotificationQueryUseCase = FetchPushNotificationQueryUseCaseSpy(), + updateQueryUseCase: UpdatePushNotificationQueryUseCase = UpdatePushNotificationQueryUseCaseSpy() + ) { + viewModel = PushNotificationListViewModel( + fetchUseCase: fetchUseCase, + deleteUseCase: deleteUseCase, + undoDeleteUseCase: undoDeleteUseCase, + toggleReadUseCase: toggleReadUseCase, + fetchQueryUseCase: fetchQueryUseCase, + updateQueryUseCase: updateQueryUseCase + ) + } + + func fetchNotifications() async { + viewModel.send(.fetchNotifications) + } + + func loadNextPage() async { + viewModel.send(.loadNextPage) + } + + func toggleSortOption() async { + viewModel.send(.toggleSortOption) + } + + func setTimeFilter(_ filter: PushNotificationQuery.TimeFilter) async { + viewModel.send(.setTimeFilter(filter)) + } + + func toggleUnreadOnly() async { + viewModel.send(.toggleUnreadOnly) + } + + func resetFilters() async { + viewModel.send(.resetFilters) + } + + func selectNotification(_ notificationId: String?) async { + viewModel.send(.selectNotification(notificationId)) + } + + func toggleRead(_ item: PushNotificationItem) async { + viewModel.send(.toggleRead(item)) + } + + func deleteNotification(_ item: PushNotificationItem) async { + viewModel.send(.deleteNotification(item)) + } + + func undoDelete() async { + viewModel.send(.undoDelete) + } + + func finishDeleteToast(_ notificationId: String) async { + viewModel.send(.finishDeleteToast(notificationId)) + } +} + +@MainActor +struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { + private let store: TestStoreOf + + var notifications: [PushNotificationItem] { store.state.notifications } + var query: PushNotificationQuery { store.state.query } + var hasMore: Bool { store.state.hasMore } + var selectedNotificationId: String? { store.state.selectedNotificationId } + var selectedTodoId: TodoIdItem? { store.state.selectedTodoId } + var appliedFilterCount: Int { store.state.appliedFilterCount } + + init( + fetchUseCase: FetchPushNotificationsUseCase = PushNotificationListFetchUseCaseSpy(), + deleteUseCase: DeletePushNotificationUseCase = DeletePushNotificationUseCaseSpy(), + undoDeleteUseCase: UndoDeletePushNotificationUseCase = UndoDeletePushNotificationUseCaseSpy(), + toggleReadUseCase: TogglePushNotificationReadUseCase = TogglePushNotificationReadUseCaseSpy(), + fetchQueryUseCase: FetchPushNotificationQueryUseCase = FetchPushNotificationQueryUseCaseSpy(), + updateQueryUseCase: UpdatePushNotificationQueryUseCase = UpdatePushNotificationQueryUseCaseSpy(), + configureDependencies: ((inout DependencyValues) -> Void)? = nil + ) { + store = TestStore( + initialState: PushNotificationListFeature.State( + query: fetchQueryUseCase.execute() + ) + ) { + PushNotificationListFeature() + } withDependencies: { + $0.fetchPushNotificationsUseCase = fetchUseCase + $0.deletePushNotificationUseCase = deleteUseCase + $0.undoDeletePushNotificationUseCase = undoDeleteUseCase + $0.togglePushNotificationReadUseCase = toggleReadUseCase + $0.updatePushNotificationQueryUseCase = updateQueryUseCase + $0.continuousClock = ContinuousClock() + configureDependencies?(&$0) + } + store.exhaustivity = .off(showSkippedAssertions: false) + } + + func fetchNotifications() async { + await store.send(.fetchNotifications) + await drainReceivedActions() + } + + func loadNextPage() async { + await store.send(.loadNextPage) + await drainReceivedActions() + } + + func toggleSortOption() async { + await store.send(.toggleSortOption) + await drainReceivedActions() + } + + func setTimeFilter(_ filter: PushNotificationQuery.TimeFilter) async { + await store.send(.setTimeFilter(filter)) + await drainReceivedActions() + } + + func toggleUnreadOnly() async { + await store.send(.toggleUnreadOnly) + await drainReceivedActions() + } + + func resetFilters() async { + await store.send(.resetFilters) + await drainReceivedActions() + } + + func selectNotification(_ notificationId: String?) async { + await store.send(.selectNotification(notificationId)) + await drainReceivedActions() + } + + func toggleRead(_ item: PushNotificationItem) async { + await store.send(.toggleRead(item)) + await drainReceivedActions() + } + + func deleteNotification(_ item: PushNotificationItem) async { + await store.send(.deleteNotification(item)) + if let notificationId = store.state.deleteToastNotificationId { + presentDeleteNotificationToast(notificationId) + await store.send(.presentedDeleteToast) + } + await drainReceivedActions() + } + + func undoDelete() async { + await store.send(.undoDelete) + await drainReceivedActions() + } + + func finishDeleteToast(_ notificationId: String) async { + await store.send(.finishDeleteToast(notificationId)) + } + + private func presentDeleteNotificationToast(_ notificationId: String) { + ToastPresenter.present( + message: String(localized: "common_undo"), + systemImage: "arrow.uturn.left", + duration: 5, + font: .caption, + multilineTextAlignment: .center, + lineLimit: 3, + action: { + Task { @MainActor in + await undoDelete() + } + }, + onDismiss: { + Task { @MainActor in + await finishDeleteToast(notificationId) + } + } + ) + } + + private func drainReceivedActions() async { + for _ in 0..<8 { + await store.skipReceivedActions(strict: false) + } + } +} + +final class PushNotificationListFetchUseCaseSpy: FetchPushNotificationsUseCase { + var pages: [PushNotificationPage] + var error: Error? + var observePublisher: AnyPublisher + private(set) var queries = [PushNotificationQuery]() + private(set) var cursors = [PushNotificationCursor?]() + + init( + pages: [PushNotificationPage] = [PushNotificationPage(items: [], nextCursor: nil)], + observePublisher: AnyPublisher = Empty().eraseToAnyPublisher() + ) { + self.pages = pages + self.observePublisher = observePublisher + } + + func execute( + _ query: PushNotificationQuery, + cursor: PushNotificationCursor? + ) async throws -> PushNotificationPage { + queries.append(query) + cursors.append(cursor) + + if let error { + throw error + } + + let index = queries.count - 1 + if pages.count <= index { + return pages.last ?? PushNotificationPage(items: [], nextCursor: nil) + } + return pages[index] + } + + func observe( + _ query: PushNotificationQuery, + limit: Int + ) throws -> AnyPublisher { + observePublisher + } +} diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListViewModelTests.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListViewModelTests.swift new file mode 100644 index 00000000..adbe141e --- /dev/null +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListViewModelTests.swift @@ -0,0 +1,125 @@ +// +// PushNotificationListViewModelTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/12/26. +// + +import Testing +import DevLogDomain +@testable import DevLogPresentation + +@MainActor +struct PushNotificationListViewModelTests { + @Test("fetchNotifications는 첫 페이지를 조회하고 목록과 hasMore 상태를 갱신한다") + func fetchNotifications는_첫_페이지를_조회하고_목록과_hasMore_상태를_갱신한다() async throws { + let cursor = makePushNotificationCursor(documentID: "cursor-1") + let notifications = (0..<20).map { + makePushNotification(id: "notification-\($0)", number: $0, isRead: $0.isMultiple(of: 2)) + } + let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ + PushNotificationPage(items: notifications, nextCursor: cursor) + ]) + let adapter = PushNotificationListViewModelTestAdapter(fetchUseCase: fetchSpy) + + try await verifyFetchNotifications(adapter: adapter, fetchUseCaseSpy: fetchSpy) + } + + @Test("loadNextPage는 다음 커서로 조회한 알림을 기존 목록 뒤에 추가한다") + func loadNextPage는_다음_커서로_조회한_알림을_기존_목록_뒤에_추가한다() async throws { + let cursor = makePushNotificationCursor(documentID: "cursor-1") + let firstPage = (0..<20).map { + makePushNotification(id: "notification-\($0)", number: $0) + } + let nextNotification = makePushNotification(id: "notification-next", number: 20) + let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ + PushNotificationPage(items: firstPage, nextCursor: cursor), + PushNotificationPage(items: [nextNotification], nextCursor: nil) + ]) + let adapter = PushNotificationListViewModelTestAdapter(fetchUseCase: fetchSpy) + + try await verifyLoadNextPage( + adapter: adapter, + fetchUseCaseSpy: fetchSpy, + nextNotification: nextNotification + ) + } + + @Test("필터 액션은 query와 적용 필터 수를 갱신한다") + func 필터_액션은_query와_적용_필터_수를_갱신한다() async throws { + let updateSpy = UpdatePushNotificationQueryUseCaseSpy() + let adapter = PushNotificationListViewModelTestAdapter(updateQueryUseCase: updateSpy) + + try await verifyFilterStateTransitions( + adapter: adapter, + updateQueryUseCaseSpy: updateSpy + ) + } + + @Test("selectNotification은 선택 상태를 바꾸고 읽지 않은 알림을 읽음 처리한다") + func selectNotification은_선택_상태를_바꾸고_읽지_않은_알림을_읽음_처리한다() async throws { + let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ + PushNotificationPage( + items: [ + makePushNotification(id: "notification-1", number: 1, isRead: false) + ], + nextCursor: nil + ) + ]) + let toggleSpy = TogglePushNotificationReadUseCaseSpy() + let adapter = PushNotificationListViewModelTestAdapter( + fetchUseCase: fetchSpy, + toggleReadUseCase: toggleSpy + ) + + try await verifySelectNotification( + adapter: adapter, + toggleReadUseCaseSpy: toggleSpy + ) + } + + @Test("toggleRead는 알림 읽음 상태를 토글하고 유스케이스를 호출한다") + func toggleRead는_알림_읽음_상태를_토글하고_유스케이스를_호출한다() async throws { + let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ + PushNotificationPage( + items: [ + makePushNotification(id: "notification-1", number: 1, isRead: true) + ], + nextCursor: nil + ) + ]) + let toggleSpy = TogglePushNotificationReadUseCaseSpy() + let adapter = PushNotificationListViewModelTestAdapter( + fetchUseCase: fetchSpy, + toggleReadUseCase: toggleSpy + ) + + try await verifyToggleRead( + adapter: adapter, + toggleReadUseCaseSpy: toggleSpy + ) + } + + @Test("delete와 undoDelete는 숨김 상태와 최종 제거 상태를 제어한다") + func delete와_undoDelete는_숨김_상태와_최종_제거_상태를_제어한다() async throws { + let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ + PushNotificationPage( + items: [makePushNotification(id: "notification-1", number: 1)], + nextCursor: nil + ) + ]) + let deleteSpy = DeletePushNotificationUseCaseSpy() + let undoSpy = UndoDeletePushNotificationUseCaseSpy() + let adapter = PushNotificationListViewModelTestAdapter( + fetchUseCase: fetchSpy, + deleteUseCase: deleteSpy, + undoDeleteUseCase: undoSpy + ) + + try await verifyDeleteUndoAndFinishToast( + adapter: adapter, + deleteUseCaseSpy: deleteSpy, + undoDeleteUseCaseSpy: undoSpy + ) + } +} From 268f0811ab25fe715c103b5cae7e65b2ab8f0abf Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:20:11 +0900 Subject: [PATCH 02/13] =?UTF-8?q?refactor:=20SheetState=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/Main/MainView.swift | 2 +- .../PushNotificationListFeature.swift | 33 +++++++++++++ .../PushNotificationListView.swift | 48 ++++++++++--------- .../PushNotificationListViewCoordinator.swift | 1 - .../PushNotificationListFeatureTests.swift | 13 +++++ .../PushNotificationListTestSupport.swift | 9 ++++ 6 files changed, 82 insertions(+), 24 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Main/MainView.swift b/Application/DevLogPresentation/Sources/Main/MainView.swift index 4f78e005..2a4cba44 100644 --- a/Application/DevLogPresentation/Sources/Main/MainView.swift +++ b/Application/DevLogPresentation/Sources/Main/MainView.swift @@ -309,7 +309,7 @@ struct MainView: View { @ViewBuilder private var notificationRegularDetailView: some View { - if let todoId = pushNotificationListViewCoordinator.todoIdToPresent?.id { + if let todoId = pushNotificationListViewCoordinator.store.selectedTodoId?.id { TodoDetailView( store: pushNotificationListViewCoordinator.makeTodoDetailStore( todoId: todoId diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift index 0196414c..6189c38c 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift @@ -16,6 +16,7 @@ struct PushNotificationListFeature { @ObservableState struct State: Equatable { @Presents var alert: AlertState? + @Presents var sheet: SheetState? var notifications: [PushNotificationItem] = [] var hasMore = false var nextCursor: PushNotificationCursor? @@ -43,8 +44,15 @@ struct PushNotificationListFeature { } } + @ObservableState + struct SheetState: Equatable, Identifiable { + let todoId: String + var id: String { todoId } + } + enum Action { case alert(PresentationAction) + case sheet(PresentationAction) case fetchNotifications case loadNextPage case deleteNotification(PushNotificationItem) @@ -63,8 +71,13 @@ struct PushNotificationListFeature { case toggleUnreadOnly case resetFilters case selectNotification(String?) + case setSheet(SheetState?) case observeNotifications(PushNotificationQuery, Int) case loading(LoadingFeature.Action) + + enum Sheet: Equatable { + case tapCloseButton + } } enum CancelID: Hashable { @@ -87,6 +100,18 @@ struct PushNotificationListFeature { reduce(action, state: &state) } .ifLet(\.$alert, action: \.alert) + .ifLet(\.$sheet, action: \.sheet) { + PushNotificationListSheetFeature() + } + } +} + +private struct PushNotificationListSheetFeature: Reducer { + typealias State = PushNotificationListFeature.SheetState + typealias Action = PushNotificationListFeature.Action.Sheet + + var body: some ReducerOf { + EmptyReducer() } } @@ -98,6 +123,12 @@ private extension PushNotificationListFeature { switch action { case .alert: break + case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)): + state.sheet = nil + state.selectedNotificationId = nil + state.selectedTodoId = nil + case .sheet: + break case .fetchNotifications: state.nextCursor = nil return fetchNotificationsEffect(query: state.query, cursor: nil, existingCount: 0) @@ -185,6 +216,8 @@ private extension PushNotificationListFeature { guard !item.isRead else { return .none } state.notifications[index].isRead = true return toggleReadEffect(item.todoId) + case .setSheet(let sheet): + state.sheet = sheet case .observeNotifications(let query, let limit): return observeNotificationsEffect(query: query, limit: limit) case .loading: diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift index bfe3011a..0caf49fb 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift @@ -18,7 +18,6 @@ struct PushNotificationListView: View { @State private var store: StoreOf let coordinator: PushNotificationListViewCoordinator let isCompactLayout: Bool - init( coordinator: PushNotificationListViewCoordinator, isCompactLayout: Bool @@ -43,31 +42,14 @@ struct PushNotificationListView: View { .listStyle(.plain) } .alert($store.scope(state: \.alert, action: \.alert)) + .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { sheetStore in + sheetContent(sheetStore) + } .onChange(of: store.deleteToastNotificationId) { _, notificationId in guard let notificationId else { return } presentDeleteNotificationToast(notificationId) store.send(.presentedDeleteToast) } - .sheet(item: Binding( - get: { isCompactLayout ? coordinator.todoIdToPresent : nil }, - set: { item in - if item == nil { - selectNotification(nil) - } - } - )) { item in - NavigationStack { - TodoDetailView(store: coordinator.makeTodoDetailStore(todoId: item.id)) - .id(item.id) - .toolbar { - ToolbarLeadingButton { - selectNotification(nil) - } - } - } - .background(Color(.systemGroupedBackground)) - .presentationDragIndicator(.visible) - } .overlay { if store.isLoading { LoadingView() @@ -372,9 +354,31 @@ struct PushNotificationListView: View { } } + @ViewBuilder + private func sheetContent( + _ sheetStore: Store + ) -> some View { + NavigationStack { + TodoDetailView(store: coordinator.makeTodoDetailStore(todoId: sheetStore.todoId)) + .id(sheetStore.todoId) + .toolbar { + ToolbarLeadingButton { + sheetStore.send(.tapCloseButton) + } + } + } + .background(Color(.systemGroupedBackground)) + .presentationDragIndicator(.visible) + } + private func selectNotification(_ notificationId: String?) { store.send(.selectNotification(notificationId)) - coordinator.todoIdToPresent = store.selectedTodoId + guard isCompactLayout else { return } + if let todoId = store.selectedTodoId?.id { + store.send(.setSheet(.init(todoId: todoId))) + } else { + store.send(.setSheet(nil)) + } } private func presentDeleteNotificationToast(_ notificationId: String) { diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift index 71cb95a4..28e34404 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift @@ -14,7 +14,6 @@ import DevLogDomain @Observable final class PushNotificationListViewCoordinator { let store: StoreOf - var todoIdToPresent: TodoIdItem? private let container: DIContainer @ObservationIgnored private var todoDetailStore: StoreOf? diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift index accb9a8f..3d2440d5 100644 --- a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift @@ -100,6 +100,19 @@ struct PushNotificationListFeatureTests { ) } + @Test("setSheet와 dismiss는 시트 상태를 제어한다") + func setSheet와_dismiss는_시트_상태를_제어한다() async { + let adapter = PushNotificationListStoreTestAdapter() + + await adapter.setSheet(.init(todoId: "todo-1")) + + #expect(adapter.sheetTodoId == "todo-1") + + await adapter.dismissSheet() + + #expect(adapter.sheetTodoId == nil) + } + @Test("delete와 undoDelete는 숨김 상태와 최종 제거 상태를 제어한다") func delete와_undoDelete는_숨김_상태와_최종_제거_상태를_제어한다() async throws { let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift index 85e1efc2..9f0fa2e3 100644 --- a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift @@ -119,6 +119,7 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { var selectedNotificationId: String? { store.state.selectedNotificationId } var selectedTodoId: TodoIdItem? { store.state.selectedTodoId } var appliedFilterCount: Int { store.state.appliedFilterCount } + var sheetTodoId: String? { store.state.sheet?.todoId } init( fetchUseCase: FetchPushNotificationsUseCase = PushNotificationListFetchUseCaseSpy(), @@ -205,6 +206,14 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { await store.send(.finishDeleteToast(notificationId)) } + func setSheet(_ sheet: PushNotificationListFeature.SheetState?) async { + await store.send(.setSheet(sheet)) + } + + func dismissSheet() async { + await store.send(.sheet(.dismiss)) + } + private func presentDeleteNotificationToast(_ notificationId: String) { ToastPresenter.present( message: String(localized: "common_undo"), From bfbd218b70719bc3552c8f4db108381b1a9cce4b Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:41:32 +0900 Subject: [PATCH 03/13] =?UTF-8?q?fix:=20compact=20/=20regular=20ui=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EC=8B=9C=ED=8A=B8=EA=B0=80=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=EA=B3=BC=20=EB=8B=A4=EB=A5=B4=EA=B2=8C=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=ED=95=98=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 --- .../PushNotificationListFeature.swift | 10 ++++-- .../PushNotificationListView.swift | 28 ++++++++++------- .../PushNotificationListFeatureTests.swift | 31 ++++++++++++++++--- .../PushNotificationListTestSupport.swift | 4 +-- 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift index 6189c38c..20b8f0b5 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift @@ -71,7 +71,7 @@ struct PushNotificationListFeature { case toggleUnreadOnly case resetFilters case selectNotification(String?) - case setSheet(SheetState?) + case syncSheetPresentation(isCompactLayout: Bool) case observeNotifications(PushNotificationQuery, Int) case loading(LoadingFeature.Action) @@ -216,8 +216,12 @@ private extension PushNotificationListFeature { guard !item.isRead else { return .none } state.notifications[index].isRead = true return toggleReadEffect(item.todoId) - case .setSheet(let sheet): - state.sheet = sheet + case .syncSheetPresentation(let isCompactLayout): + if let todoId = state.selectedTodoId?.id, isCompactLayout { + state.sheet = .init(todoId: todoId) + } else { + state.sheet = nil + } case .observeNotifications(let query, let limit): return observeNotificationsEffect(query: query, limit: limit) case .loading: diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift index 0caf49fb..993814a0 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift @@ -42,8 +42,14 @@ struct PushNotificationListView: View { .listStyle(.plain) } .alert($store.scope(state: \.alert, action: \.alert)) - .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { sheetStore in - sheetContent(sheetStore) + .sheet(item: sheetStore) { store in + sheetContent(store) + } + .task(id: isCompactLayout) { + store.send(.syncSheetPresentation(isCompactLayout: isCompactLayout)) + } + .onChange(of: store.selectedTodoId?.id, initial: true) { + store.send(.syncSheetPresentation(isCompactLayout: isCompactLayout)) } .onChange(of: store.deleteToastNotificationId) { _, notificationId in guard let notificationId else { return } @@ -85,7 +91,7 @@ struct PushNotificationListView: View { ) -> some View { if isCompactLayout { Button { - selectNotification(notification.id) + store.send(.selectNotification(notification.id)) } label: { notificationRowContent(notification, index: index, notifications: notifications) } @@ -93,11 +99,11 @@ struct PushNotificationListView: View { } else { notificationRowContent(notification, index: index, notifications: notifications) .onTapGesture { - selectNotification(notification.id) + store.send(.selectNotification(notification.id)) } .accessibilityAddTraits(.isButton) .accessibilityAction { - selectNotification(notification.id) + store.send(.selectNotification(notification.id)) } } } @@ -371,13 +377,13 @@ struct PushNotificationListView: View { .presentationDragIndicator(.visible) } - private func selectNotification(_ notificationId: String?) { - store.send(.selectNotification(notificationId)) - guard isCompactLayout else { return } - if let todoId = store.selectedTodoId?.id { - store.send(.setSheet(.init(todoId: todoId))) + private var sheetStore: Binding< + Store?> { + if isCompactLayout { + $store.scope(state: \.sheet, action: \.sheet) } else { - store.send(.setSheet(nil)) + .constant(nil) } } diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift index 3d2440d5..3456aad1 100644 --- a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift @@ -100,17 +100,40 @@ struct PushNotificationListFeatureTests { ) } - @Test("setSheet와 dismiss는 시트 상태를 제어한다") - func setSheet와_dismiss는_시트_상태를_제어한다() async { - let adapter = PushNotificationListStoreTestAdapter() + @Test("syncSheetPresentation은 layout에 따라 시트 상태를 동기화한다") + func syncSheetPresentation은_layout에_따라_시트_상태를_동기화한다() async throws { + let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ + PushNotificationPage( + items: [ + makePushNotification(id: "notification-1", number: 1, isRead: true) + ], + nextCursor: nil + ) + ]) + let adapter = PushNotificationListStoreTestAdapter(fetchUseCase: fetchSpy) + + await adapter.fetchNotifications() + await adapter.selectNotification("notification-1") + + await adapter.syncSheetPresentation(isCompactLayout: true) + + #expect(adapter.sheetTodoId == "todo-1") + + await adapter.syncSheetPresentation(isCompactLayout: false) + + #expect(adapter.sheetTodoId == nil) + #expect(adapter.selectedNotificationId == "notification-1") + #expect(adapter.selectedTodoId?.id == "todo-1") - await adapter.setSheet(.init(todoId: "todo-1")) + await adapter.syncSheetPresentation(isCompactLayout: true) #expect(adapter.sheetTodoId == "todo-1") await adapter.dismissSheet() #expect(adapter.sheetTodoId == nil) + #expect(adapter.selectedNotificationId == nil) + #expect(adapter.selectedTodoId == nil) } @Test("delete와 undoDelete는 숨김 상태와 최종 제거 상태를 제어한다") diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift index 9f0fa2e3..ba5f5432 100644 --- a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift @@ -206,8 +206,8 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { await store.send(.finishDeleteToast(notificationId)) } - func setSheet(_ sheet: PushNotificationListFeature.SheetState?) async { - await store.send(.setSheet(sheet)) + func syncSheetPresentation(isCompactLayout: Bool) async { + await store.send(.syncSheetPresentation(isCompactLayout: isCompactLayout)) } func dismissSheet() async { From 8c8b9b2d51c0c6dc83d9bf00760009ff77a62686 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:49:24 +0900 Subject: [PATCH 04/13] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=83=81=ED=83=9C=20=EC=B2=98=EB=A6=AC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotification/PushNotificationListFeature.swift | 5 ----- .../Sources/PushNotification/PushNotificationListView.swift | 6 +----- .../PushNotification/PushNotificationListTestSupport.swift | 5 +---- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift index 20b8f0b5..b01aa2c1 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift @@ -25,7 +25,6 @@ struct PushNotificationListFeature { var selectedTodoId: TodoIdItem? var loading = LoadingFeature.State() var undoNotificationId: String? - var deleteToastNotificationId: String? init(query: PushNotificationQuery = .default) { self.query = query @@ -59,7 +58,6 @@ struct PushNotificationListFeature { case toggleRead(PushNotificationItem) case undoDelete case finishDeleteToast(String) - case presentedDeleteToast case setAlert case appendNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?) case resetPagination @@ -142,7 +140,6 @@ private extension PushNotificationListFeature { case .deleteNotification(let item): guard state.notifications.contains(where: { $0.id == item.id }) else { return .none } state.undoNotificationId = item.id - state.deleteToastNotificationId = item.id Self.setNotificationHidden(&state, notificationId: item.id, isHidden: true) return deleteNotificationEffect(item) case .toggleRead(let item): @@ -161,8 +158,6 @@ private extension PushNotificationListFeature { if state.undoNotificationId == notificationId { state.undoNotificationId = nil } - case .presentedDeleteToast: - state.deleteToastNotificationId = nil case .setAlert: state.alert = Self.alertState() case .appendNotifications(let notifications, let nextCursor): diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift index 993814a0..7d53818a 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift @@ -51,11 +51,6 @@ struct PushNotificationListView: View { .onChange(of: store.selectedTodoId?.id, initial: true) { store.send(.syncSheetPresentation(isCompactLayout: isCompactLayout)) } - .onChange(of: store.deleteToastNotificationId) { _, notificationId in - guard let notificationId else { return } - presentDeleteNotificationToast(notificationId) - store.send(.presentedDeleteToast) - } .overlay { if store.isLoading { LoadingView() @@ -323,6 +318,7 @@ struct PushNotificationListView: View { role: .destructive, action: { store.send(.deleteNotification(item)) + presentDeleteNotificationToast(item.id) } ) { Image(systemName: "trash") diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift index ba5f5432..2b6bb20c 100644 --- a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift @@ -190,10 +190,7 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { func deleteNotification(_ item: PushNotificationItem) async { await store.send(.deleteNotification(item)) - if let notificationId = store.state.deleteToastNotificationId { - presentDeleteNotificationToast(notificationId) - await store.send(.presentedDeleteToast) - } + presentDeleteNotificationToast(item.id) await drainReceivedActions() } From acfc40eae1e19dda8b813329eb5103600bfc9111 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:31:02 +0900 Subject: [PATCH 05/13] =?UTF-8?q?refactor:=20=EA=B0=81=20store=20->=20stor?= =?UTF-8?q?e=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Detail/TodoDetailView.swift | 8 ++++---- .../Sources/Home/Editor/TodoEditorView.swift | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift index 57fd4a8c..b6f832e9 100644 --- a/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift +++ b/Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift @@ -34,13 +34,13 @@ struct TodoDetailView: View { .onAppear { store.send(.onAppear) } .navigationBarTitleDisplayMode(.inline) .alert($store.scope(state: \.alert, action: \.alert)) - .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { sheetStore in - sheetContent(sheetStore) + .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { store in + sheetContent(store) } .fullScreenCover( item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover) - ) { coverStore in - fullScreenCoverContent(coverStore) + ) { store in + fullScreenCoverContent(store) } .toolbar { toolbarContent } } diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift index 766f5014..c6e26ab7 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift @@ -53,8 +53,8 @@ struct TodoEditorView: View { .navigationTitle(store.navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.background, for: .navigationBar) - .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { sheetStore in - sheetContent(sheetStore) + .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { store in + sheetContent(store) } .toolbar { if !isiOSAppOnMac { From 8353bf0d708ced24918f0afa616572bdae9d2d3b Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:31:17 +0900 Subject: [PATCH 06/13] =?UTF-8?q?refactor:=20BindingAction=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 --- .../PushNotificationListFeature.swift | 14 ++++++++------ .../PushNotificationListView.swift | 5 +---- .../PushNotificationListTestSupport.swift | 2 +- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift index b01aa2c1..5b4c138b 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift @@ -49,9 +49,10 @@ struct PushNotificationListFeature { var id: String { todoId } } - enum Action { + enum Action: BindableAction { case alert(PresentationAction) case sheet(PresentationAction) + case binding(BindingAction) case fetchNotifications case loadNextPage case deleteNotification(PushNotificationItem) @@ -65,7 +66,6 @@ struct PushNotificationListFeature { case syncNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?, hasMore: Bool) case setNotificationHidden(String, Bool) case toggleSortOption - case setTimeFilter(PushNotificationQuery.TimeFilter) case toggleUnreadOnly case resetFilters case selectNotification(String?) @@ -94,6 +94,7 @@ struct PushNotificationListFeature { Scope(state: \.loading, action: \.loading) { LoadingFeature() } + BindingReducer() Reduce { state, action in reduce(action, state: &state) } @@ -127,6 +128,11 @@ private extension PushNotificationListFeature { state.selectedTodoId = nil case .sheet: break + case .binding(\.query.timeFilter): + state.nextCursor = nil + return refreshForQueryChangeEffect(query: state.query) + case .binding: + break case .fetchNotifications: state.nextCursor = nil return fetchNotificationsEffect(query: state.query, cursor: nil, existingCount: 0) @@ -184,10 +190,6 @@ private extension PushNotificationListFeature { state.query.sortOrder = state.query.sortOrder == .latest ? .oldest : .latest state.nextCursor = nil return refreshForQueryChangeEffect(query: state.query) - case .setTimeFilter(let filter): - state.query.timeFilter = filter - state.nextCursor = nil - return refreshForQueryChangeEffect(query: state.query) case .toggleUnreadOnly: state.query.unreadOnly.toggle() state.nextCursor = nil diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift index 7d53818a..6b2c9943 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift @@ -210,10 +210,7 @@ struct PushNotificationListView: View { } Menu { - Picker(selection: Binding( - get: { store.query.timeFilter }, - set: { store.send(.setTimeFilter($0)) } - )) { + Picker(selection: $store.query.timeFilter) { ForEach(PushNotificationQuery.TimeFilter.availableOptions, id: \.self) { option in Text(option.title).tag(option) } diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift index 2b6bb20c..338f28e2 100644 --- a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift @@ -164,7 +164,7 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { } func setTimeFilter(_ filter: PushNotificationQuery.TimeFilter) async { - await store.send(.setTimeFilter(filter)) + await store.send(.binding(.set(\.query.timeFilter, filter))) await drainReceivedActions() } From e892880e1064675e9a30e839bc22d936bb10acf6 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:55:12 +0900 Subject: [PATCH 07/13] =?UTF-8?q?refactor:=20Store=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EB=90=98=EB=8A=94=20=EC=95=A1=EC=85=98=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/List/TodoListFeature+Effects.swift | 24 ++++---- .../Sources/Home/List/TodoListFeature.swift | 50 ++++++++-------- .../PushNotificationListFeature.swift | 58 ++++++++++--------- .../Sources/Search/SearchFeature.swift | 28 +++++---- .../Home/TodoListFeatureTestDoubles.swift | 4 +- .../Search/SearchFeatureTestDoubles.swift | 10 ++-- 6 files changed, 93 insertions(+), 81 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift index 673bf419..10801119 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift @@ -20,12 +20,12 @@ extension TodoListFeature { let query = TodoQuery(categoryId: category.storageValue, keyword: keyword) let page = try await fetchTodosUseCase.execute(query, cursor: nil) try Task.checkCancellation() - await send(.fetchSearchResults(page.items.compactMap(TodoListItem.init(from:)))) + await send(.store(.fetchSearchResults(page.items.compactMap(TodoListItem.init(from:))))) await send(.loading(.end(target: .default, mode: .immediate))) } catch is CancellationError { return } catch { - await send(.setAlert(true)) + await send(.store(.setAlert(true))) await send(.loading(.end(target: .default, mode: .immediate))) } } @@ -47,14 +47,14 @@ extension TodoListFeature { trackAnalyticsEventUseCase?.execute(.todoComplete) } guard let todoListItem = TodoListItem(from: todo) else { - await send(.setAlert(true)) + await send(.store(.setAlert(true))) await send(.loading(.end(target: .default, mode: .delayed))) return } - await send(.didToggleCompleted(todoListItem)) + await send(.store(.didToggleCompleted(todoListItem))) await send(.loading(.end(target: .default, mode: .delayed))) } catch { - await send(.setAlert(true)) + await send(.store(.setAlert(true))) await send(.loading(.end(target: .default, mode: .delayed))) } } @@ -71,14 +71,14 @@ extension TodoListFeature { todo.updatedAt = Date() try await upsertTodoUseCase.execute(todo) guard let todoListItem = TodoListItem(from: todo) else { - await send(.setAlert(true)) + await send(.store(.setAlert(true))) await send(.loading(.end(target: .default, mode: .delayed))) return } - await send(.didTogglePinned(todoListItem)) + await send(.store(.didTogglePinned(todoListItem))) await send(.loading(.end(target: .default, mode: .delayed))) } catch { - await send(.setAlert(true)) + await send(.store(.setAlert(true))) await send(.loading(.end(target: .default, mode: .delayed))) } } @@ -98,8 +98,8 @@ extension TodoListFeature { do { try await deleteTodoUseCase.execute(item.id) } catch { - await send(.setTodoHidden(item.id, false)) - await send(.setAlert(true)) + await send(.store(.setTodoHidden(item.id, false))) + await send(.store(.setAlert(true))) } } } @@ -109,8 +109,8 @@ extension TodoListFeature { do { try await undoDeleteTodoUseCase.execute(todoId) } catch { - await send(.setTodoHidden(todoId, true)) - await send(.setAlert(true)) + await send(.store(.setTodoHidden(todoId, true))) + await send(.store(.setAlert(true))) } } } diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift index ef31edf5..6d711e18 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift @@ -65,7 +65,6 @@ struct TodoListFeature { case fullScreenCover(PresentationAction) case binding(BindingAction) case refresh - case setAlert(Bool) case setFullScreenCover(FullScreenCoverState?) case swipeTodo(TodoListItem) case resetFilters @@ -76,15 +75,20 @@ struct TodoListFeature { case undoDelete case onAppear case loadNextPage - case applySearchQuery(String) - case fetchSearchResults([TodoListItem]) - case didToggleCompleted(TodoListItem) - case didTogglePinned(TodoListItem) - case setTodoHidden(String, Bool) - case appendTodos([TodoListItem], nextCursor: TodoCursor?) - case resetPagination - case setHasMore(Bool) + case store(StoreAction) case loading(LoadingFeature.Action) + + enum StoreAction: Equatable { + case setAlert(Bool) + case applySearchQuery(String) + case fetchSearchResults([TodoListItem]) + case didToggleCompleted(TodoListItem) + case didTogglePinned(TodoListItem) + case setTodoHidden(String, Bool) + case appendTodos([TodoListItem], nextCursor: TodoCursor?) + case resetPagination + case setHasMore(Bool) + } } enum CancelID: Hashable { @@ -187,7 +191,7 @@ private extension TodoListFeature { break case .refresh, .onAppear: return fetchEffect(query: state.query, cursor: nil) - case .setAlert(let value): + case .store(.setAlert(let value)): Self.setAlert(&state, isPresented: value) case .setFullScreenCover(let cover): state.fullScreenCover = cover @@ -217,24 +221,24 @@ private extension TodoListFeature { case .loadNextPage: guard state.hasMore, !state.isLoading else { return .none } return fetchEffect(query: state.query, cursor: state.nextCursor, resetsPagination: false) - case .applySearchQuery(let query): + case .store(.applySearchQuery(let query)): return applySearchQueryEffect(query, state: &state) - case .fetchSearchResults(let items): + case .store(.fetchSearchResults(let items)): state.searchResults = items - case .didToggleCompleted(let todo), .didTogglePinned(let todo): + case .store(.didToggleCompleted(let todo)), .store(.didTogglePinned(let todo)): if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { state.todos[index] = todo } - case .setTodoHidden(let todoId, let isHidden): + case .store(.setTodoHidden(let todoId, let isHidden)): Self.setTodoHidden(&state, todoId: todoId, isHidden: isHidden) - case .appendTodos(let todos, let nextCursor): + case .store(.appendTodos(let todos, let nextCursor)): state.todos.append(contentsOf: todos) state.nextCursor = nextCursor - case .resetPagination: + case .store(.resetPagination): state.todos = [] state.nextCursor = nil state.hasMore = false - case .setHasMore(let value): + case .store(.setHasMore(let value)): state.hasMore = value case .loading: break @@ -254,18 +258,18 @@ private extension TodoListFeature { do { let page = try await fetchTodosUseCase.execute(query, cursor: cursor) if resetsPagination { - await send(.resetPagination) + await send(.store(.resetPagination)) } - await send(.appendTodos( + await send(.store(.appendTodos( page.items.compactMap(TodoListItem.init(from:)), nextCursor: page.nextCursor - )) - await send(.setHasMore(page.items.count == query.pageSize && page.nextCursor != nil)) + ))) + await send(.store(.setHasMore(page.items.count == query.pageSize && page.nextCursor != nil))) await send(.loading(.end(target: .default, mode: .delayed))) } catch is CancellationError { return } catch { - await send(.setAlert(true)) + await send(.store(.setAlert(true))) await send(.loading(.end(target: .default, mode: .delayed))) } } @@ -311,7 +315,7 @@ private extension TodoListFeature { .send(.loading(.begin(target: .default, mode: .immediate))), .run { [clock, searchDebounceDelay] send in try await clock.sleep(for: searchDebounceDelay) - await send(.applySearchQuery(keyword)) + await send(.store(.applySearchQuery(keyword))) } .cancellable(id: CancelID.debounce, cancelInFlight: true) ) diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift index 5b4c138b..a7ae5124 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift @@ -59,23 +59,27 @@ struct PushNotificationListFeature { case toggleRead(PushNotificationItem) case undoDelete case finishDeleteToast(String) - case setAlert - case appendNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?) - case resetPagination - case setHasMore(Bool) - case syncNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?, hasMore: Bool) - case setNotificationHidden(String, Bool) case toggleSortOption case toggleUnreadOnly case resetFilters case selectNotification(String?) case syncSheetPresentation(isCompactLayout: Bool) - case observeNotifications(PushNotificationQuery, Int) + case store(StoreAction) case loading(LoadingFeature.Action) enum Sheet: Equatable { case tapCloseButton } + + enum StoreAction: Equatable { + case setAlert + case appendNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?) + case resetPagination + case setHasMore(Bool) + case syncNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?, hasMore: Bool) + case setNotificationHidden(String, Bool) + case observeNotifications(PushNotificationQuery, Int) + } } enum CancelID: Hashable { @@ -164,27 +168,27 @@ private extension PushNotificationListFeature { if state.undoNotificationId == notificationId { state.undoNotificationId = nil } - case .setAlert: + case .store(.setAlert): state.alert = Self.alertState() - case .appendNotifications(let notifications, let nextCursor): + case .store(.appendNotifications(let notifications, let nextCursor)): state.notifications.append(contentsOf: Self.mergedHiddenNotifications( currentNotifications: state.notifications, incomingNotifications: notifications )) state.nextCursor = nextCursor - case .resetPagination: + case .store(.resetPagination): state.notifications = [] state.nextCursor = nil - case .setHasMore(let value): + case .store(.setHasMore(let value)): state.hasMore = value - case .syncNotifications(let notifications, let nextCursor, let hasMore): + case .store(.syncNotifications(let notifications, let nextCursor, let hasMore)): state.notifications = Self.mergedHiddenNotifications( currentNotifications: state.notifications, incomingNotifications: notifications ) state.nextCursor = nextCursor state.hasMore = hasMore - case .setNotificationHidden(let notificationId, let isHidden): + case .store(.setNotificationHidden(let notificationId, let isHidden)): Self.setNotificationHidden(&state, notificationId: notificationId, isHidden: isHidden) case .toggleSortOption: state.query.sortOrder = state.query.sortOrder == .latest ? .oldest : .latest @@ -219,7 +223,7 @@ private extension PushNotificationListFeature { } else { state.sheet = nil } - case .observeNotifications(let query, let limit): + case .store(.observeNotifications(let query, let limit)): return observeNotificationsEffect(query: query, limit: limit) case .loading: break @@ -246,20 +250,20 @@ private extension PushNotificationListFeature { do { let page = try await fetchPushNotificationsUseCase.execute(query, cursor: cursor) if cursor == nil { - await send(.resetPagination) + await send(.store(.resetPagination)) } await send( - .appendNotifications( + .store(.appendNotifications( page.items.map(PushNotificationItem.init(from:)), nextCursor: page.nextCursor - ) + )) ) - await send(.setHasMore(page.items.count == query.pageSize && page.nextCursor != nil)) - await send(.observeNotifications(query, max(limit, existingCount + page.items.count))) + await send(.store(.setHasMore(page.items.count == query.pageSize && page.nextCursor != nil))) + await send(.store(.observeNotifications(query, max(limit, existingCount + page.items.count)))) await send(.loading(.end(target: .default, mode: .delayed))) } catch { await send(.loading(.end(target: .default, mode: .delayed))) - await send(.setAlert) + await send(.store(.setAlert)) } } .cancellable(id: CancelID.fetchNotifications, cancelInFlight: true) @@ -284,11 +288,11 @@ private extension PushNotificationListFeature { for try await page in publisher.values { let items = page.items.map(PushNotificationItem.init(from:)) let hasMore = items.count == max(query.pageSize, limit) && page.nextCursor != nil - await send(.syncNotifications(items, nextCursor: page.nextCursor, hasMore: hasMore)) + await send(.store(.syncNotifications(items, nextCursor: page.nextCursor, hasMore: hasMore))) } } catch is CancellationError { } catch { - await send(.setAlert) + await send(.store(.setAlert)) } } .cancellable(id: CancelID.observeNotifications, cancelInFlight: true) @@ -299,8 +303,8 @@ private extension PushNotificationListFeature { do { try await deletePushNotificationUseCase.execute(item.id) } catch { - await send(.setNotificationHidden(item.id, false)) - await send(.setAlert) + await send(.store(.setNotificationHidden(item.id, false))) + await send(.store(.setAlert)) } } } @@ -310,8 +314,8 @@ private extension PushNotificationListFeature { do { try await undoDeletePushNotificationUseCase.execute(notificationId) } catch { - await send(.setNotificationHidden(notificationId, true)) - await send(.setAlert) + await send(.store(.setNotificationHidden(notificationId, true))) + await send(.store(.setAlert)) } } } @@ -324,7 +328,7 @@ private extension PushNotificationListFeature { await send(.loading(.end(target: .default, mode: .delayed))) } catch { await send(.loading(.end(target: .default, mode: .delayed))) - await send(.setAlert) + await send(.store(.setAlert)) } } .cancellable(id: CancelID.toggleRead, cancelInFlight: true) diff --git a/Application/DevLogPresentation/Sources/Search/SearchFeature.swift b/Application/DevLogPresentation/Sources/Search/SearchFeature.swift index 9fa0fda4..2a26ebcb 100644 --- a/Application/DevLogPresentation/Sources/Search/SearchFeature.swift +++ b/Application/DevLogPresentation/Sources/Search/SearchFeature.swift @@ -62,16 +62,20 @@ struct SearchFeature { enum Action: BindableAction, Equatable { case alert(PresentationAction) case binding(BindingAction) - case fetchWebPage([WebPageItem]) - case fetchTodos([TodoListItem]) case addRecentQuery(String) case removeRecentQuery(String) case clearRecentQueries - case applySearchQuery(String) - case setAlert(Bool) case setShowAllTodos(Bool) case setShowAllWebPages(Bool) + case store(StoreAction) case loading(LoadingFeature.Action) + + enum StoreAction: Equatable { + case fetchWebPage([WebPageItem]) + case fetchTodos([TodoListItem]) + case applySearchQuery(String) + case setAlert(Bool) + } } private enum CancelID: Hashable { @@ -116,9 +120,9 @@ struct SearchFeature { } case .binding: break - case .fetchWebPage(let items): + case .store(.fetchWebPage(let items)): state.webPages = items - case .fetchTodos(let items): + case .store(.fetchTodos(let items)): state.todos = items case .addRecentQuery(let query): let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) @@ -135,7 +139,7 @@ struct SearchFeature { case .clearRecentQueries: state.recentQueries = [] return saveRecentQueriesEffect([]) - case .applySearchQuery(let query): + case .store(.applySearchQuery(let query)): let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { state.webPages = [] @@ -144,7 +148,7 @@ struct SearchFeature { } else { return fetchEffect(trimmed, isLoading: state.isLoading) } - case .setAlert(let isPresented): + case .store(.setAlert(let isPresented)): state.alert = isPresented ? Self.alertState() : nil case .setShowAllTodos(let shouldShowAll): state.showAllTodos = shouldShowAll @@ -221,7 +225,7 @@ private extension SearchFeature { .send(.loading(.begin(target: .default, mode: .immediate))), .run { [clock, searchDebounceDelay] send in try await clock.sleep(for: searchDebounceDelay) - await send(.applySearchQuery(query)) + await send(.store(.applySearchQuery(query))) } .cancellable(id: CancelID.debounce, cancelInFlight: true) ) @@ -240,8 +244,8 @@ private extension SearchFeature { ) let todoItems = try await todos.items.compactMap { TodoListItem(from: $0) } let resolvedWebPageItems = try await webPageItems - await send(.fetchTodos(todoItems)) - await send(.fetchWebPage(resolvedWebPageItems)) + await send(.store(.fetchTodos(todoItems))) + await send(.store(.fetchWebPage(resolvedWebPageItems))) if isLoading { await send(.loading(.end(target: .default, mode: .immediate))) } @@ -251,7 +255,7 @@ private extension SearchFeature { if isLoading { await send(.loading(.end(target: .default, mode: .immediate))) } - await send(.setAlert(true)) + await send(.store(.setAlert(true))) } } .cancellable(id: CancelID.request, cancelInFlight: true) diff --git a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift index 4c913bdd..84cd8735 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift @@ -100,7 +100,7 @@ final class TodoListStoreTestAdapter { } func setSearchResults(_ results: [TodoListItem]) async { - await store.send(.fetchSearchResults(results)) + await store.send(.store(.fetchSearchResults(results))) } func setIsSearching(_ value: Bool) async { @@ -113,7 +113,7 @@ final class TodoListStoreTestAdapter { } func appendTodos(_ todos: [TodoListItem]) async { - await store.send(.appendTodos(todos, nextCursor: nil)) + await store.send(.store(.appendTodos(todos, nextCursor: nil))) } func setFullScreenCover(_ cover: TodoListFeature.FullScreenCoverState?) async { diff --git a/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift index 7a087483..8925ca5b 100644 --- a/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Search/SearchFeatureTestDoubles.swift @@ -114,7 +114,7 @@ struct SearchStoreTestAdapter { } func applySearchQuery(_ query: String) async { - await store.send(.applySearchQuery(query)) + await store.send(.store(.applySearchQuery(query))) } func setSearching(_ value: Bool) async { @@ -128,7 +128,7 @@ struct SearchStoreTestAdapter { } func receiveAppliedSearchQuery(_ query: String) async { - await store.receive(.applySearchQuery(query)) + await store.receive(.store(.applySearchQuery(query))) } func receiveSearchResults( @@ -136,10 +136,10 @@ struct SearchStoreTestAdapter { webPages: [WebPageItem] ) async { let wasLoading = store.state.isLoading - await store.receive(.fetchTodos(todos)) { + await store.receive(.store(.fetchTodos(todos))) { $0.todos = todos } - await store.receive(.fetchWebPage(webPages)) { + await store.receive(.store(.fetchWebPage(webPages))) { $0.webPages = webPages } if wasLoading { @@ -152,7 +152,7 @@ struct SearchStoreTestAdapter { if wasLoading { await receiveEndLoading() } - await store.receive(.setAlert(true)) { + await store.receive(.store(.setAlert(true))) { $0.alert = expectedSearchErrorAlert() } } From 751e87c6b6704b76b6fa95ef71d64b60e6af00e8 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 11:17:53 +0900 Subject: [PATCH 08/13] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=ED=99=94=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/List/TodoListView.swift | 111 ++++++++---------- 1 file changed, 52 insertions(+), 59 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift index ef59baf8..296623a9 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift @@ -327,8 +327,58 @@ struct TodoListView: View { } } - sortMenu - filterMenu + Menu { + Picker(selection: $store.query.sortTarget) { + ForEach([TodoQuery.SortTarget.createdAt, .updatedAt], id: \.self) { option in + Text(option.title).tag(option) + } + } label: { + Text(String(localized: "todo_list_sort_by")) + } + Picker(selection: $store.query.sortOrder) { + ForEach([TodoQuery.SortOrder.latest, .oldest], id: \.self) { option in + Text(option.title).tag(option) + } + } label: { + Text(String(localized: "todo_list_sort_order")) + } + } label: { + let condition = store.state.query.sortTarget == .createdAt && store.state.query.sortOrder == .latest + HStack { + Text( + String.localizedStringWithFormat( + String(localized: "todo_list_sort_format"), + store.state.query.sortTarget.title, + store.state.query.sortOrder.title + ) + ) + Image(systemName: "chevron.down") + } + .foregroundStyle(condition ? Color(.label) : .white) + .adaptiveButtonStyle(color: condition ? .clear : .blue) + } + + Menu { + Toggle(isOn: $store.query.isPinned) { + Text(String(localized: "todo_pinned")) + } + + Picker(selection: $store.query.completionFilter) { + ForEach([TodoQuery.CompletionFilter.all, .incomplete, .completed], id: \.self) { option in + Text(option.title).tag(option) + } + } label: { + Text(String(localized: "todo_list_completion_status")) + } + } label: { + let condition = store.state.query.isPinned || store.state.query.completionFilter != .all + HStack { + Text(String(localized: "todo_list_filter_options")) + Image(systemName: "chevron.down") + } + .foregroundStyle(condition ? .white : Color(.label)) + .adaptiveButtonStyle(color: condition ? .blue : .clear) + } } } .scrollIndicators(.never) @@ -344,63 +394,6 @@ struct TodoListView: View { } } - private var sortMenu: some View { - Menu { - Picker(selection: $store.query.sortTarget) { - ForEach([TodoQuery.SortTarget.createdAt, .updatedAt], id: \.self) { option in - Text(option.title).tag(option) - } - } label: { - Text(String(localized: "todo_list_sort_by")) - } - Picker(selection: $store.query.sortOrder) { - ForEach([TodoQuery.SortOrder.latest, .oldest], id: \.self) { option in - Text(option.title).tag(option) - } - } label: { - Text(String(localized: "todo_list_sort_order")) - } - } label: { - let condition = store.state.query.sortTarget == .createdAt && store.state.query.sortOrder == .latest - HStack { - Text( - String.localizedStringWithFormat( - String(localized: "todo_list_sort_format"), - store.state.query.sortTarget.title, - store.state.query.sortOrder.title - ) - ) - Image(systemName: "chevron.down") - } - .foregroundStyle(condition ? Color(.label) : .white) - .adaptiveButtonStyle(color: condition ? .clear : .blue) - } - } - - private var filterMenu: some View { - Menu { - Toggle(isOn: $store.query.isPinned) { - Text(String(localized: "todo_pinned")) - } - - Picker(selection: $store.query.completionFilter) { - ForEach([TodoQuery.CompletionFilter.all, .incomplete, .completed], id: \.self) { option in - Text(option.title).tag(option) - } - } label: { - Text(String(localized: "todo_list_completion_status")) - } - } label: { - let condition = store.state.query.isPinned || store.state.query.completionFilter != .all - HStack { - Text(String(localized: "todo_list_filter_options")) - Image(systemName: "chevron.down") - } - .foregroundStyle(condition ? .white : Color(.label)) - .adaptiveButtonStyle(color: condition ? .blue : .clear) - } - } - private var filterBadge: some View { let isDark = colorScheme == .dark let blue = Color(uiColor: .systemBlue) From 895c0558e9e6fba979e26307d1d6691b2a41cdaf Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:54:53 +0900 Subject: [PATCH 09/13] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=ED=98=95=ED=83=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationListView.swift | 144 +++++++++--------- 1 file changed, 68 insertions(+), 76 deletions(-) diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift index 6b2c9943..eaca32ef 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift @@ -29,7 +29,7 @@ struct PushNotificationListView: View { var body: some View { NavigationStack { - notificationList + notificationListContent .background(Color(.systemGroupedBackground)) .background(NavigationBarConfigurator(alwaysVisible: true)) .onScrollOffsetChange { offset in @@ -59,7 +59,7 @@ struct PushNotificationListView: View { } @ViewBuilder - private var notificationList: some View { + private var notificationListContent: some View { let notifications = store.notifications.filter { !$0.isHidden } if notifications.isEmpty { Text(String(localized: "push_notifications_empty")) @@ -147,95 +147,87 @@ struct PushNotificationListView: View { } private var headerView: some View { - Group { - if #available(iOS 18, *) { - ScrollView(.horizontal) { headerContent } - .scrollIndicators(.never) - .scrollDisabled(!isScrollTrackingEnabled) - .contentMargins(.leading, 16, for: .scrollContent) - } else { - headerContent - .padding(.leading, 16) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .frame(height: headerHeight) - .onAppear { - headerOffset = 0 - isScrollTrackingEnabled = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isScrollTrackingEnabled = true - } - } - } + ScrollView(.horizontal) { + HStack(spacing: 8) { + if 0 < store.appliedFilterCount { + Menu { + Text( + String.localizedStringWithFormat( + String(localized: "push_filters_applied_format"), + Int64(store.appliedFilterCount) + ) + ) + Button(role: .destructive) { + store.send(.resetFilters) + } label: { + Text(String(localized: "push_clear_all_filters")) + } + } label: { + HStack(spacing: 6) { + Image(systemName: "line.3.horizontal.decrease") + filterBadge + } + .adaptiveButtonStyle() + } + } - private var headerContent: some View { - HStack(spacing: 8) { - if 0 < store.appliedFilterCount { - Menu { + Button { + DispatchQueue.main.async { + store.send(.toggleSortOption) + } + } label: { + let condition = store.query.sortOrder == .oldest Text( String.localizedStringWithFormat( - String(localized: "push_filters_applied_format"), - Int64(store.appliedFilterCount) + String(localized: "push_sort_format"), + store.query.sortOrder.title ) ) - Button(role: .destructive) { - store.send(.resetFilters) + .foregroundStyle(condition ? .white : Color(.label)) + .adaptiveButtonStyle(color: condition ? .blue : .clear) + } + .frame(height: headerHeight) + + Menu { + Picker(selection: $store.query.timeFilter) { + ForEach(PushNotificationQuery.TimeFilter.availableOptions, id: \.self) { option in + Text(option.title).tag(option) + } } label: { - Text(String(localized: "push_clear_all_filters")) + Text(String(localized: "push_period")) } } label: { - HStack(spacing: 6) { - Image(systemName: "line.3.horizontal.decrease") - filterBadge + let condition = store.query.timeFilter == .none + HStack { + Text(String(localized: "push_period")) + Image(systemName: "chevron.down") } - .adaptiveButtonStyle() - } - } - - Button { - DispatchQueue.main.async { - store.send(.toggleSortOption) + .foregroundStyle(condition ? Color(.label) : .white) + .adaptiveButtonStyle(color: condition ? .clear : .blue) } - } label: { - let condition = store.query.sortOrder == .oldest - Text( - String.localizedStringWithFormat( - String(localized: "push_sort_format"), - store.query.sortOrder.title - ) - ) - .foregroundStyle(condition ? .white : Color(.label)) - .adaptiveButtonStyle(color: condition ? .blue : .clear) - } - Menu { - Picker(selection: $store.query.timeFilter) { - ForEach(PushNotificationQuery.TimeFilter.availableOptions, id: \.self) { option in - Text(option.title).tag(option) + Button { + DispatchQueue.main.async { + store.send(.toggleUnreadOnly) } } label: { - Text(String(localized: "push_period")) - } - } label: { - let condition = store.query.timeFilter == .none - HStack { - Text(String(localized: "push_period")) - Image(systemName: "chevron.down") + let condition = store.query.unreadOnly + Text(String(localized: "push_unread")) + .foregroundStyle(condition ? .white : Color(.label)) + .adaptiveButtonStyle(color: condition ? .blue : .clear) } - .foregroundStyle(condition ? Color(.label) : .white) - .adaptiveButtonStyle(color: condition ? .clear : .blue) + .frame(height: headerHeight) } - - Button { - DispatchQueue.main.async { - store.send(.toggleUnreadOnly) - } - } label: { - let condition = store.query.unreadOnly - Text(String(localized: "push_unread")) - .foregroundStyle(condition ? .white : Color(.label)) - .adaptiveButtonStyle(color: condition ? .blue : .clear) + } + .scrollIndicators(.never) + .scrollDisabled(!isScrollTrackingEnabled) + .contentMargins(.leading, 16, for: .scrollContent) + .frame(height: headerHeight) + .onAppear { + headerOffset = 0 + isScrollTrackingEnabled = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isScrollTrackingEnabled = true } } } From 7387543175b772782b28d70d1d1410ccd6f3696e Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:55:07 +0900 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20PushNotificationList=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EA=B9=9C=EB=B9=A1=EC=9E=84=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationListView.swift | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift index eaca32ef..f565551f 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift @@ -61,20 +61,28 @@ struct PushNotificationListView: View { @ViewBuilder private var notificationListContent: some View { let notifications = store.notifications.filter { !$0.isHidden } - if notifications.isEmpty { - Text(String(localized: "push_notifications_empty")) - .foregroundStyle(Color.gray) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List( - Array(zip(notifications.indices, notifications)), - id: \.1.id - ) { index, notification in - notificationListRow(notification, index: index, notifications: notifications) - .listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)) - .listSectionSeparator(.hidden, edges: .top) - .listRowBackground(Color.clear) + List { + Group { + if notifications.isEmpty { + HStack { + Spacer() + Text(String(localized: "push_notifications_empty")) + .foregroundStyle(Color.gray) + Spacer() + } + .listRowSeparator(.hidden) + } else { + ForEach( + Array(zip(notifications.indices, notifications)), + id: \.1.id + ) { index, notification in + notificationListRow(notification, index: index, notifications: notifications) + .listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)) + } + } } + .listSectionSeparator(.hidden, edges: .top) + .listRowBackground(Color.clear) } } From e6587df8f1288842dd17f896bb7e0e193f9d6dbf Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:12:53 +0900 Subject: [PATCH 11/13] =?UTF-8?q?ui:=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EA=B0=80=20=EC=97=86=EC=9D=84=20=EB=95=8C=EB=8A=94=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=EC=9D=B4=20=EC=95=88=EB=90=98=EB=8F=84?= =?UTF-8?q?=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 --- .../Sources/PushNotification/PushNotificationListView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift index f565551f..01ecb8bb 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift @@ -84,6 +84,7 @@ struct PushNotificationListView: View { .listSectionSeparator(.hidden, edges: .top) .listRowBackground(Color.clear) } + .scrollDisabled(notifications.isEmpty || store.isLoading) } @ViewBuilder From d392f036e5de0a2c408204f96ce2f5d121940d9a Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:30:00 +0900 Subject: [PATCH 12/13] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20onChange(of:)=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/List/TodoListFeature+Effects.swift | 1 - .../Sources/Home/List/TodoListFeature.swift | 4 ---- .../DevLogPresentation/Sources/Home/List/TodoListView.swift | 6 +----- .../Tests/Home/TodoListFeatureTestDoubles.swift | 1 - 4 files changed, 1 insertion(+), 11 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift index 10801119..75f8f9a9 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift @@ -88,7 +88,6 @@ extension TodoListFeature { func swipeTodoEffect(_ todo: TodoListItem, state: inout State) -> Effect { guard state.todos.contains(where: { $0.id == todo.id }) else { return .none } state.undoTodoId = todo.id - state.deleteToastTodoId = todo.id Self.setTodoHidden(&state, todoId: todo.id, isHidden: true) return deleteEffect(todo) } diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift index 6d711e18..7c5f77e9 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift @@ -27,7 +27,6 @@ struct TodoListFeature { var loading = LoadingFeature.State() var undoTodoId: String? var nextCursor: TodoCursor? - var deleteToastTodoId: String? let searchResultsLimit = 5 init(category: TodoCategory) { @@ -69,7 +68,6 @@ struct TodoListFeature { case swipeTodo(TodoListItem) case resetFilters case finishDeleteToast(String) - case presentedDeleteToast case tapToggleCompleted(TodoListItem) case tapTogglePinned(TodoListItem) case undoDelete @@ -207,8 +205,6 @@ private extension TodoListFeature { if state.undoTodoId == todoId { state.undoTodoId = nil } - case .presentedDeleteToast: - state.deleteToastTodoId = nil case .tapToggleCompleted(let todo): return toggleCompletedEffect(todo) case .tapTogglePinned(let todo): diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift index 296623a9..f6d767b4 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift @@ -81,11 +81,6 @@ struct TodoListView: View { .background(NavigationBarConfigurator()) .background(Color(.systemGroupedBackground)) .task { store.send(.onAppear) } - .onChange(of: store.deleteToastTodoId) { _, todoId in - guard let todoId else { return } - presentDeleteTodoToast(todoId) - store.send(.presentedDeleteToast) - } } @ViewBuilder @@ -143,6 +138,7 @@ struct TodoListView: View { .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive, action: { store.send(.swipeTodo(todo)) + presentDeleteTodoToast(todo.id) }) { Image(systemName: "trash") } diff --git a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift index 84cd8735..f6c9196f 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoListFeatureTestDoubles.swift @@ -126,7 +126,6 @@ final class TodoListStoreTestAdapter { func swipeTodo(_ todo: TodoListItem) async { await store.send(.swipeTodo(todo)) - await store.send(.presentedDeleteToast) await drainReceivedActions() } From eafe76f3742c3ec6e898d76cc8f59e1f4b1324ca Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:30:10 +0900 Subject: [PATCH 13/13] =?UTF-8?q?feat:=20=EB=82=99=EA=B4=80=EC=A0=81=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=9D=B4=ED=9B=84=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EB=A1=A4=EB=B0=B1=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationListFeature.swift | 19 ++++++++++--- .../PushNotificationListFeatureTests.swift | 27 +++++++++++++++++++ .../Tests/Support/TestSupport.swift | 4 +++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift index a7ae5124..d37d0e06 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift @@ -78,6 +78,7 @@ struct PushNotificationListFeature { case setHasMore(Bool) case syncNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?, hasMore: Bool) case setNotificationHidden(String, Bool) + case setNotificationRead(String, Bool) case observeNotifications(PushNotificationQuery, Int) } } @@ -156,8 +157,9 @@ private extension PushNotificationListFeature { guard let index = state.notifications.firstIndex(where: { $0.id == item.id }) else { return .none } - state.notifications[index].isRead.toggle() - return toggleReadEffect(item.todoId) + let isRead = !state.notifications[index].isRead + state.notifications[index].isRead = isRead + return toggleReadEffect(notificationId: item.id, todoId: item.todoId, rollbackRead: !isRead) case .undoDelete: guard let undoNotificationId = state.undoNotificationId else { return .none } Self.setNotificationHidden(&state, notificationId: undoNotificationId, isHidden: false) @@ -190,6 +192,10 @@ private extension PushNotificationListFeature { state.hasMore = hasMore case .store(.setNotificationHidden(let notificationId, let isHidden)): Self.setNotificationHidden(&state, notificationId: notificationId, isHidden: isHidden) + case .store(.setNotificationRead(let notificationId, let isRead)): + if let index = state.notifications.firstIndex(where: { $0.id == notificationId }) { + state.notifications[index].isRead = isRead + } case .toggleSortOption: state.query.sortOrder = state.query.sortOrder == .latest ? .oldest : .latest state.nextCursor = nil @@ -216,7 +222,7 @@ private extension PushNotificationListFeature { state.selectedTodoId = TodoIdItem(id: item.todoId) guard !item.isRead else { return .none } state.notifications[index].isRead = true - return toggleReadEffect(item.todoId) + return toggleReadEffect(notificationId: item.id, todoId: item.todoId, rollbackRead: false) case .syncSheetPresentation(let isCompactLayout): if let todoId = state.selectedTodoId?.id, isCompactLayout { state.sheet = .init(todoId: todoId) @@ -320,13 +326,18 @@ private extension PushNotificationListFeature { } } - func toggleReadEffect(_ todoId: String) -> Effect { + func toggleReadEffect( + notificationId: String, + todoId: String, + rollbackRead: Bool + ) -> Effect { .run { [togglePushNotificationReadUseCase] send in await send(.loading(.begin(target: .default, mode: .delayed))) do { try await togglePushNotificationReadUseCase.execute(todoId) await send(.loading(.end(target: .default, mode: .delayed))) } catch { + await send(.store(.setNotificationRead(notificationId, rollbackRead))) await send(.loading(.end(target: .default, mode: .delayed))) await send(.store(.setAlert)) } diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift index 3456aad1..e3c820fc 100644 --- a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift @@ -100,6 +100,33 @@ struct PushNotificationListFeatureTests { ) } + @Test("toggleRead 실패 시 읽음 상태를 원래 값으로 롤백한다") + func toggleRead_실패_시_읽음_상태를_원래_값으로_롤백한다() async throws { + struct DummyError: Error {} + + let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ + PushNotificationPage( + items: [ + makePushNotification(id: "notification-1", number: 1, isRead: true) + ], + nextCursor: nil + ) + ]) + let toggleSpy = TogglePushNotificationReadUseCaseSpy() + toggleSpy.error = DummyError() + let adapter = PushNotificationListStoreTestAdapter( + fetchUseCase: fetchSpy, + toggleReadUseCase: toggleSpy + ) + + await adapter.fetchNotifications() + let item = try #require(adapter.notifications.first) + + await adapter.toggleRead(item) + + #expect(adapter.notifications.first?.isRead == true) + } + @Test("syncSheetPresentation은 layout에 따라 시트 상태를 동기화한다") func syncSheetPresentation은_layout에_따라_시트_상태를_동기화한다() async throws { let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ diff --git a/Application/DevLogPresentation/Tests/Support/TestSupport.swift b/Application/DevLogPresentation/Tests/Support/TestSupport.swift index fbf466ab..2682568a 100644 --- a/Application/DevLogPresentation/Tests/Support/TestSupport.swift +++ b/Application/DevLogPresentation/Tests/Support/TestSupport.swift @@ -107,9 +107,13 @@ final class UndoDeletePushNotificationUseCaseSpy: UndoDeletePushNotificationUseC final class TogglePushNotificationReadUseCaseSpy: TogglePushNotificationReadUseCase { private(set) var calledTodoIds: [String] = [] + var error: Error? func execute(_ todoId: String) async throws { calledTodoIds.append(todoId) + if let error { + throw error + } } }