Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ struct PushNotificationListFeature {
case loading(LoadingFeature.Action)

enum ViewAction: Equatable {
case stopObserving
case startObserving
case refresh
case fetchNotifications
case loadNextPage
Expand Down Expand Up @@ -151,23 +153,28 @@ private extension PushNotificationListFeature {
state: inout State
) -> Effect<Action> {
switch action {
case .stopObserving:
return .cancel(id: CancelID.observeNotifications)
case .startObserving:
return observeNotificationsEffect(
query: state.query,
limit: state.query.pageSize
)
case .refresh:
state.nextCursor = nil
return fetchNotificationsEffect(
return fetchNotificationsPageEffect(
query: state.query,
cursor: nil,
existingCount: 0,
showsIndicator: false
)
Comment thread
opficdev marked this conversation as resolved.
Outdated
Comment thread
opficdev marked this conversation as resolved.
Outdated
case .fetchNotifications:
state.nextCursor = nil
return fetchNotificationsEffect(query: state.query, cursor: nil, existingCount: 0)
return fetchNotificationsPageEffect(query: state.query, cursor: nil)
Comment thread
opficdev marked this conversation as resolved.
case .loadNextPage:
guard state.hasMore, !state.isLoading else { return .none }
return fetchNotificationsEffect(
return fetchNotificationsPageEffect(
query: state.query,
cursor: state.nextCursor,
existingCount: state.notifications.count
cursor: state.nextCursor
)
case .deleteNotification(let item):
guard state.notifications.contains(where: { $0.id == item.id }) else { return .none }
Expand Down Expand Up @@ -268,32 +275,12 @@ private extension PushNotificationListFeature {
func refreshForQueryChangeEffect(query: PushNotificationQuery) -> Effect<Action> {
.merge(
updateQueryEffect(query: query),
fetchNotificationsEffect(query: query, cursor: nil, existingCount: 0)
)
}

func fetchNotificationsEffect(
query: PushNotificationQuery,
cursor: PushNotificationCursor?,
existingCount: Int,
showsIndicator: Bool = true
) -> Effect<Action> {
let limit = max(query.pageSize, existingCount)
let fetchEffect = fetchNotificationsPageEffect(query: query, cursor: cursor, showsIndicator: showsIndicator)
let observeEffect = observeNotificationsEffect(
query: query,
limit: max(limit, existingCount + query.pageSize)
)

if cursor == nil {
return .concatenate(
.cancel(id: CancelID.observeNotifications),
fetchEffect,
observeEffect
.concatenate(
.send(.view(.stopObserving)),
fetchNotificationsPageEffect(query: query, cursor: nil),
.send(.view(.startObserving))
)
}

return fetchEffect
)
}

func fetchNotificationsPageEffect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ public struct PushNotificationListView: View {
headerOffset = max(0, -offset)
}
.safeAreaInset(edge: .top) { safeAreaHeader }
.refreshable { await store.send(.view(.refresh)).finish() }
.refreshable(isEnabled: PullToRefreshAvailability.isEnabled) {
await store.send(.view(.refresh)).finish()
}
.navigationTitle(String(localized: "nav_push_notifications"))
.listStyle(.plain)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public final class PushNotificationListViewCoordinator {
private let container: DIContainer
@ObservationIgnored
private var todoDetailStore: StoreOf<TodoDetailFeature>?
@ObservationIgnored
private var fetchNotificationsTask: Task<Void, Never>?

public init(container: DIContainer) {
self.container = container
Expand All @@ -42,7 +44,15 @@ public final class PushNotificationListViewCoordinator {
}

public func fetchData() {
store.send(.view(.fetchNotifications))
fetchNotificationsTask?.cancel()
store.send(.view(.stopObserving))
let query = store.query
let task = store.send(.view(.fetchNotifications))
fetchNotificationsTask = Task { [store] in
await task.finish()
guard !Task.isCancelled, store.query == query else { return }
store.send(.view(.startObserving))
}
}

public func makeTodoDetailStore(todoId: String) -> StoreOf<TodoDetailFeature> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// View+Refreshable.swift
// NotificationTab
//
// Created by opfic on 7/28/26.
//

import SwiftUI

enum PullToRefreshAvailability {
static var isEnabled: Bool {
if #available(iOS 18.0, *) {
return true
} else {
return false
}
}
}

extension View {
@ViewBuilder
func refreshable(
isEnabled: Bool,
action: @escaping @Sendable () async -> Void
) -> some View {
if isEnabled {
refreshable(action: action)
} else {
self
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,40 @@
// Created by opfic on 6/12/26.
//

import Combine
import Testing
import Domain
@testable import NotificationTab

@MainActor
struct PushNotificationListFeatureTests {
@Test("refresh는 listener와 분리되어 첫 페이지 조회 후 끝난다")
func refresh는_listener와_분리되어_첫_페이지_조회_후_끝난다() async {
let subject = PassthroughSubject<PushNotificationPage, Error>()
let notification = makePushNotification(id: "observed", number: 1)
let fetchSpy = PushNotificationListFetchUseCaseSpy(
pages: [PushNotificationPage(items: [], nextCursor: nil)],
observePublisher: subject.eraseToAnyPublisher()
)
let adapter = PushNotificationListStoreTestAdapter(fetchUseCase: fetchSpy)

await adapter.startObserving()
subject.send(PushNotificationPage(items: [notification], nextCursor: nil))
await waitUntilMainActor {
adapter.notifications.first?.id == notification.id
}

await adapter.refresh()

#expect(fetchSpy.queries == [.default])
#expect(fetchSpy.cursors == [nil])
#expect(fetchSpy.observedQueries == [.default])
#expect(fetchSpy.observedLimits == [adapter.query.pageSize])

subject.send(completion: .finished)
await adapter.finishEffects()
}

@Test("fetchNotifications는 첫 페이지를 조회하고 목록과 hasMore 상태를 갱신한다")
func fetchNotifications는_첫_페이지를_조회하고_목록과_hasMore_상태를_갱신한다() async throws {
let cursor = makePushNotificationCursor(documentID: "cursor-1")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ protocol PushNotificationListStateDriving {
var selectedTodoId: TodoIdItem? { get }
var appliedFilterCount: Int { get }

func startObserving() async
func refresh() async
func fetchNotifications() async
func loadNextPage() async
func toggleSortOption() async
Expand Down Expand Up @@ -74,6 +76,17 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving {
store.exhaustivity = .off(showSkippedAssertions: false)
}

func startObserving() async {
await store.send(.view(.startObserving))
await drainReceivedActions()
}

func refresh() async {
let task = await store.send(.view(.refresh))
await drainReceivedActions()
await task.finish()
}

func fetchNotifications() async {
await store.send(.view(.fetchNotifications))
await drainReceivedActions()
Expand Down Expand Up @@ -137,6 +150,11 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving {
await store.send(.sheet(.dismiss))
}

func finishEffects() async {
await drainReceivedActions()
await store.finish()
}

private func presentDeleteNotificationToast(_ notificationId: String) {
ToastPresenter.present(
message: String(localized: "common_undo"),
Expand Down Expand Up @@ -171,6 +189,8 @@ final class PushNotificationListFetchUseCaseSpy: FetchPushNotificationsUseCase {
var observePublisher: AnyPublisher<PushNotificationPage, Error>
private(set) var queries = [PushNotificationQuery]()
private(set) var cursors = [PushNotificationCursor?]()
private(set) var observedQueries = [PushNotificationQuery]()
private(set) var observedLimits = [Int]()

init(
pages: [PushNotificationPage] = [PushNotificationPage(items: [], nextCursor: nil)],
Expand Down Expand Up @@ -202,6 +222,8 @@ final class PushNotificationListFetchUseCaseSpy: FetchPushNotificationsUseCase {
_ query: PushNotificationQuery,
limit: Int
) throws -> AnyPublisher<PushNotificationPage, Error> {
observePublisher
observedQueries.append(query)
observedLimits.append(limit)
return observePublisher
}
}
Loading