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 { diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift index 673bf419..75f8f9a9 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))) } } @@ -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) } @@ -98,8 +97,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 +108,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..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) { @@ -65,26 +64,29 @@ struct TodoListFeature { case fullScreenCover(PresentationAction) case binding(BindingAction) case refresh - case setAlert(Bool) case setFullScreenCover(FullScreenCoverState?) case swipeTodo(TodoListItem) case resetFilters case finishDeleteToast(String) - case presentedDeleteToast case tapToggleCompleted(TodoListItem) case tapTogglePinned(TodoListItem) 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 +189,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 @@ -203,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): @@ -217,24 +217,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 +254,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 +311,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/Home/List/TodoListView.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListView.swift index ef59baf8..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") } @@ -327,8 +323,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 +390,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) 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 new file mode 100644 index 00000000..d37d0e06 --- /dev/null +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListFeature.swift @@ -0,0 +1,392 @@ +// +// 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? + @Presents var sheet: SheetState? + 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? + + 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 + } + } + + @ObservableState + struct SheetState: Equatable, Identifiable { + let todoId: String + var id: String { todoId } + } + + enum Action: BindableAction { + case alert(PresentationAction) + case sheet(PresentationAction) + case binding(BindingAction) + case fetchNotifications + case loadNextPage + case deleteNotification(PushNotificationItem) + case toggleRead(PushNotificationItem) + case undoDelete + case finishDeleteToast(String) + case toggleSortOption + case toggleUnreadOnly + case resetFilters + case selectNotification(String?) + case syncSheetPresentation(isCompactLayout: Bool) + 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 setNotificationRead(String, Bool) + case observeNotifications(PushNotificationQuery, Int) + } + } + + 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() + } + BindingReducer() + Reduce { state, action in + 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() + } +} + +private extension PushNotificationListFeature { + func reduce( + _ action: Action, + state: inout State + ) -> Effect { + switch action { + case .alert: + break + case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)): + state.sheet = nil + state.selectedNotificationId = nil + 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) + 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 + 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 + } + 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) + 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 .store(.setAlert): + state.alert = Self.alertState() + case .store(.appendNotifications(let notifications, let nextCursor)): + state.notifications.append(contentsOf: Self.mergedHiddenNotifications( + currentNotifications: state.notifications, + incomingNotifications: notifications + )) + state.nextCursor = nextCursor + case .store(.resetPagination): + state.notifications = [] + state.nextCursor = nil + case .store(.setHasMore(let value)): + state.hasMore = value + 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 .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 + 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(notificationId: item.id, todoId: item.todoId, rollbackRead: false) + case .syncSheetPresentation(let isCompactLayout): + if let todoId = state.selectedTodoId?.id, isCompactLayout { + state.sheet = .init(todoId: todoId) + } else { + state.sheet = nil + } + case .store(.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(.store(.resetPagination)) + } + await send( + .store(.appendNotifications( + page.items.map(PushNotificationItem.init(from:)), + nextCursor: page.nextCursor + )) + ) + 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(.store(.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(.store(.syncNotifications(items, nextCursor: page.nextCursor, hasMore: hasMore))) + } + } catch is CancellationError { + } catch { + await send(.store(.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(.store(.setNotificationHidden(item.id, false))) + await send(.store(.setAlert)) + } + } + } + + func undoDeleteEffect(_ notificationId: String) -> Effect { + .run { [undoDeletePushNotificationUseCase] send in + do { + try await undoDeletePushNotificationUseCase.execute(notificationId) + } catch { + await send(.store(.setNotificationHidden(notificationId, true))) + await send(.store(.setAlert)) + } + } + } + + 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)) + } + } + .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..01ecb8bb 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,16 +15,21 @@ 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 { NavigationStack { - notificationList + notificationListContent .background(Color(.systemGroupedBackground)) .background(NavigationBarConfigurator(alwaysVisible: true)) .onScrollOffsetChange { offset in @@ -32,65 +37,54 @@ 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)) + .sheet(item: sheetStore) { store in + sheetContent(store) } - .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) + .task(id: isCompactLayout) { + store.send(.syncSheetPresentation(isCompactLayout: isCompactLayout)) + } + .onChange(of: store.selectedTodoId?.id, initial: true) { + store.send(.syncSheetPresentation(isCompactLayout: isCompactLayout)) } .overlay { - if viewModel.state.isLoading { + if store.isLoading { LoadingView() } } } @ViewBuilder - private var notificationList: some View { - let notifications = viewModel.state.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) + private var notificationListContent: some View { + let notifications = store.notifications.filter { !$0.isHidden } + 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) } + .scrollDisabled(notifications.isEmpty || store.isLoading) } @ViewBuilder @@ -101,7 +95,7 @@ struct PushNotificationListView: View { ) -> some View { if isCompactLayout { Button { - selectNotification(notification.id) + store.send(.selectNotification(notification.id)) } label: { notificationRowContent(notification, index: index, notifications: notifications) } @@ -109,11 +103,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)) } } } @@ -125,12 +119,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) { @@ -162,98 +156,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 < viewModel.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(viewModel.appliedFilterCount) + String(localized: "push_sort_format"), + store.query.sortOrder.title ) ) - Button(role: .destructive) { - viewModel.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 { - viewModel.send(.toggleSortOption) + .foregroundStyle(condition ? Color(.label) : .white) + .adaptiveButtonStyle(color: condition ? .clear : .blue) } - } label: { - let condition = viewModel.state.query.sortOrder == .oldest - Text( - String.localizedStringWithFormat( - String(localized: "push_sort_format"), - viewModel.state.query.sortOrder.title - ) - ) - .foregroundStyle(condition ? .white : Color(.label)) - .adaptiveButtonStyle(color: condition ? .blue : .clear) - } - Menu { - Picker(selection: Binding( - get: { viewModel.state.query.timeFilter }, - set: { viewModel.send(.setTimeFilter($0)) } - )) { - 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 = viewModel.state.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 { - viewModel.send(.toggleUnreadOnly) - } - } label: { - let condition = viewModel.state.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 } } } @@ -264,7 +247,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 +305,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 +315,8 @@ struct PushNotificationListView: View { Button( role: .destructive, action: { - viewModel.send(.deleteNotification(item)) + store.send(.deleteNotification(item)) + presentDeleteNotificationToast(item.id) } ) { Image(systemName: "trash") @@ -370,8 +354,47 @@ struct PushNotificationListView: View { } } - private func selectNotification(_ notificationId: String?) { - viewModel.send(.selectNotification(notificationId)) - coordinator.todoIdToPresent = viewModel.state.selectedTodoId + @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 var sheetStore: Binding< + Store?> { + if isCompactLayout { + $store.scope(state: \.sheet, action: \.sheet) + } else { + .constant(nil) + } + } + + 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..28e34404 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewCoordinator.swift @@ -13,26 +13,32 @@ import DevLogDomain @MainActor @Observable final class PushNotificationListViewCoordinator { - let viewModel: PushNotificationListViewModel - var todoIdToPresent: TodoIdItem? + let store: StoreOf private let container: DIContainer @ObservationIgnored private var todoDetailStore: StoreOf? 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/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..f6c9196f 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 { @@ -126,7 +126,6 @@ final class TodoListStoreTestAdapter { func swipeTodo(_ todo: TodoListItem) async { await store.send(.swipeTodo(todo)) - await store.send(.presentedDeleteToast) await drainReceivedActions() } 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..e3c820fc --- /dev/null +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListFeatureTests.swift @@ -0,0 +1,188 @@ +// +// 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("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: [ + 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.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는 숨김 상태와 최종 제거 상태를 제어한다") + 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..338f28e2 --- /dev/null +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift @@ -0,0 +1,281 @@ +// +// 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 } + var sheetTodoId: String? { store.state.sheet?.todoId } + + 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(.binding(.set(\.query.timeFilter, 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)) + presentDeleteNotificationToast(item.id) + await drainReceivedActions() + } + + func undoDelete() async { + await store.send(.undoDelete) + await drainReceivedActions() + } + + func finishDeleteToast(_ notificationId: String) async { + await store.send(.finishDeleteToast(notificationId)) + } + + func syncSheetPresentation(isCompactLayout: Bool) async { + await store.send(.syncSheetPresentation(isCompactLayout: isCompactLayout)) + } + + func dismissSheet() async { + await store.send(.sheet(.dismiss)) + } + + 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 + ) + } +} 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() } } 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 + } } }