diff --git a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift index 5ec9125..586f352 100644 --- a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift +++ b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift @@ -32,24 +32,37 @@ final class PushNotificationRepositoryImpl: PushNotificationRepository { } /// 푸시 알림 기록 요청 - func requestNotifications() async throws -> [PushNotification] { - try await service.requestNotifications() - .compactMap { dto in - guard - let id = dto.id, - let todoKind = TodoKind(rawValue: dto.todoKind) - else { return nil } + func requestNotifications( + _ query: PushNotificationQuery, + cursor: PushNotificationCursor? + ) async throws -> PushNotificationPage { + let response = try await service.requestNotifications(query, cursor: cursor) - return PushNotification( - id: id, - title: dto.title, - body: dto.body, - receivedAt: dto.receivedAt.dateValue(), - isRead: dto.isRead, - todoID: dto.todoID, - todoKind: todoKind - ) - } + let items: [PushNotification] = response.items.compactMap { dto in + guard + let id = dto.id, + let todoKind = TodoKind(rawValue: dto.todoKind) + else { return nil } + + return PushNotification( + id: id, + title: dto.title, + body: dto.body, + receivedAt: dto.receivedAt.dateValue(), + isRead: dto.isRead, + todoID: dto.todoID, + todoKind: todoKind + ) + } + + let nextCursor = response.nextCursor.map { cursor in + PushNotificationCursor( + receivedAt: cursor.receivedAt.dateValue(), + documentID: cursor.documentID + ) + } + + return PushNotificationPage(items: items, nextCursor: nextCursor) } // 푸시 알림 기록 삭제 diff --git a/DevLog/Domain/Entity/PushNotificationCursor.swift b/DevLog/Domain/Entity/PushNotificationCursor.swift new file mode 100644 index 0000000..e8d2da5 --- /dev/null +++ b/DevLog/Domain/Entity/PushNotificationCursor.swift @@ -0,0 +1,13 @@ +// +// PushNotificationCursor.swift +// DevLog +// +// Created by opfic on 2/18/26. +// + +import Foundation + +struct PushNotificationCursor: Equatable { + let receivedAt: Date + let documentID: String +} diff --git a/DevLog/Domain/Entity/PushNotificationPage.swift b/DevLog/Domain/Entity/PushNotificationPage.swift new file mode 100644 index 0000000..cb9efc4 --- /dev/null +++ b/DevLog/Domain/Entity/PushNotificationPage.swift @@ -0,0 +1,17 @@ +// +// PushNotificationPage.swift +// DevLog +// +// Created by opfic on 2/18/26. +// + +import Foundation + +struct PushNotificationPage: Equatable { + let items: [PushNotification] + let nextCursor: PushNotificationCursor? + + static func == (lhs: PushNotificationPage, rhs: PushNotificationPage) -> Bool { + lhs.items.map { $0.id } == rhs.items.map { $0.id } && lhs.nextCursor == rhs.nextCursor + } +} diff --git a/DevLog/Domain/Entity/PushNotificationQuery.swift b/DevLog/Domain/Entity/PushNotificationQuery.swift new file mode 100644 index 0000000..0c2d62c --- /dev/null +++ b/DevLog/Domain/Entity/PushNotificationQuery.swift @@ -0,0 +1,46 @@ +// +// PushNotificationQuery.swift +// DevLog +// +// Created by opfic on 2/18/26. +// + +import Foundation + +struct PushNotificationQuery: Equatable { + enum SortOrder: Equatable { + case latest + case oldest + } + + enum TimeFilter: Equatable { + case none + case hours(Int) + case days(Int) + } + + var sortOrder: SortOrder + var timeFilter: TimeFilter + var unreadOnly: Bool + var pageSize: Int + + static let `default` = PushNotificationQuery( + sortOrder: .latest, + timeFilter: .none, + unreadOnly: false, + pageSize: 20 + ) +} + +extension PushNotificationQuery.TimeFilter { + var thresholdDate: Date? { + switch self { + case .none: + return nil + case .hours(let value): + return Date().addingTimeInterval(-Double(value) * 3600.0) + case .days(let value): + return Date().addingTimeInterval(-Double(value) * 86400.0) + } + } +} diff --git a/DevLog/Domain/Protocol/PushNotificationRepository.swift b/DevLog/Domain/Protocol/PushNotificationRepository.swift index 8bc7f08..7891f1b 100644 --- a/DevLog/Domain/Protocol/PushNotificationRepository.swift +++ b/DevLog/Domain/Protocol/PushNotificationRepository.swift @@ -11,7 +11,10 @@ protocol PushNotificationRepository { func fetchPushNotificationEnabled() async throws -> Bool func fetchPushNotificationTime() async throws -> DateComponents func updatePushNotificationSettings(_ settings: PushNotificationSettings) async throws - func requestNotifications() async throws -> [PushNotification] + func requestNotifications( + _ query: PushNotificationQuery, + cursor: PushNotificationCursor? + ) async throws -> PushNotificationPage func deleteNotification(_ notificationID: String) async throws func toggleNotificationRead(_ todoID: String) async throws } diff --git a/DevLog/Domain/UseCase/PushNotification/Fetch/FetchPushNotificationsUseCase.swift b/DevLog/Domain/UseCase/PushNotification/Fetch/FetchPushNotificationsUseCase.swift index 263967a..13d334f 100644 --- a/DevLog/Domain/UseCase/PushNotification/Fetch/FetchPushNotificationsUseCase.swift +++ b/DevLog/Domain/UseCase/PushNotification/Fetch/FetchPushNotificationsUseCase.swift @@ -6,5 +6,8 @@ // protocol FetchPushNotificationsUseCase { - func execute() async throws -> [PushNotification] + func execute( + _ query: PushNotificationQuery, + cursor: PushNotificationCursor? + ) async throws -> PushNotificationPage } diff --git a/DevLog/Domain/UseCase/PushNotification/Fetch/FetchPushNotificationsUseCaseImpl.swift b/DevLog/Domain/UseCase/PushNotification/Fetch/FetchPushNotificationsUseCaseImpl.swift index d40c9d6..19ef9f9 100644 --- a/DevLog/Domain/UseCase/PushNotification/Fetch/FetchPushNotificationsUseCaseImpl.swift +++ b/DevLog/Domain/UseCase/PushNotification/Fetch/FetchPushNotificationsUseCaseImpl.swift @@ -12,7 +12,10 @@ final class FetchPushNotificationsUseCaseImpl: FetchPushNotificationsUseCase { self.repository = repository } - func execute() async throws -> [PushNotification] { - try await repository.requestNotifications() + func execute( + _ query: PushNotificationQuery, + cursor: PushNotificationCursor? + ) async throws -> PushNotificationPage { + try await repository.requestNotifications(query, cursor: cursor) } } diff --git a/DevLog/Infra/DTO/PushNotificationCursorResponse.swift b/DevLog/Infra/DTO/PushNotificationCursorResponse.swift new file mode 100644 index 0000000..57c5dd8 --- /dev/null +++ b/DevLog/Infra/DTO/PushNotificationCursorResponse.swift @@ -0,0 +1,13 @@ +// +// PushNotificationCursorResponse.swift +// DevLog +// +// Created by opfic on 2/18/26. +// + +import FirebaseFirestore + +struct PushNotificationCursorResponse { + let receivedAt: Timestamp + let documentID: String +} diff --git a/DevLog/Infra/DTO/PushNotificationPageResponse.swift b/DevLog/Infra/DTO/PushNotificationPageResponse.swift new file mode 100644 index 0000000..a6efe06 --- /dev/null +++ b/DevLog/Infra/DTO/PushNotificationPageResponse.swift @@ -0,0 +1,11 @@ +// +// PushNotificationPageResponse.swift +// DevLog +// +// Created by opfic on 2/18/26. +// + +struct PushNotificationPageResponse { + let items: [PushNotificationResponse] + let nextCursor: PushNotificationCursorResponse? +} diff --git a/DevLog/Infra/Service/PushNotificationService.swift b/DevLog/Infra/Service/PushNotificationService.swift index 1f57713..0b496d3 100644 --- a/DevLog/Infra/Service/PushNotificationService.swift +++ b/DevLog/Infra/Service/PushNotificationService.swift @@ -89,15 +89,57 @@ final class PushNotificationService { } /// 푸시 알림 기록 요청 - func requestNotifications() async throws -> [PushNotificationResponse] { + func requestNotifications( + _ query: PushNotificationQuery, + cursor: PushNotificationCursor? + ) async throws -> PushNotificationPageResponse { guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated } - let collection = store.collection("users/\(uid)/notifications") - let snapshot = try await collection.getDocuments() + var firestoreQuery: Query = store.collection("users/\(uid)/notifications") + + if let thresholdDate = query.timeFilter.thresholdDate { + firestoreQuery = firestoreQuery.whereField( + "receivedAt", + isGreaterThanOrEqualTo: Timestamp(date: thresholdDate) + ) + } - return try snapshot.documents.compactMap { document in + if query.unreadOnly { + firestoreQuery = firestoreQuery.whereField("isRead", isEqualTo: false) + } + + let isDescending = query.sortOrder == .latest + firestoreQuery = firestoreQuery + .order(by: "receivedAt", descending: isDescending) + .order(by: FieldPath.documentID()) + + if let cursor { + firestoreQuery = firestoreQuery.start(after: [ + Timestamp(date: cursor.receivedAt), + cursor.documentID + ]) + } + + let snapshot = try await firestoreQuery + .limit(to: query.pageSize) + .getDocuments() + + let items = try snapshot.documents.compactMap { document in try document.data(as: PushNotificationResponse.self) } + + let nextCursor: PushNotificationCursorResponse? = snapshot.documents.last.map { document in + guard let receivedAt = document.data()["receivedAt"] as? Timestamp else { + return nil + } + + return PushNotificationCursorResponse( + receivedAt: receivedAt, + documentID: document.documentID + ) + } ?? nil + + return PushNotificationPageResponse(items: items, nextCursor: nextCursor) } /// 푸시 알림 기록 삭제 diff --git a/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift index fef4fbf..94cc4d6 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift @@ -18,15 +18,16 @@ final class PushNotificationViewModel: Store { var toastMessage: String = "" var toastType: ToastType? var isLoading: Bool = false + var hasMore: Bool = false + var nextCursor: PushNotificationCursor? var pendingTask: (PushNotification, Int)? - var sortOption: SortOption - var timeFilter: TimeFilter - var showUnreadOnly: Bool + var query: PushNotificationQuery var selectedTodoID: TodoIDItem? } enum Action { case fetchNotifications + case loadNextPage case deleteNotification(PushNotification) case toggleRead(PushNotification) case undoDelete @@ -34,9 +35,11 @@ final class PushNotificationViewModel: Store { case setAlert(isPresented: Bool, type: AlertType? = nil) case setToast(isPresented: Bool, type: ToastType? = nil) case setLoading(Bool) - case setNotifications([PushNotification]) + case appendNotifications([PushNotification], nextCursor: PushNotificationCursor?) + case resetPagination + case setHasMore(Bool) case toggleSortOption - case setTimeFilter(TimeFilter) + case setTimeFilter(PushNotificationQuery.TimeFilter) case toggleUnreadOnly case resetFilters case tapNotification(PushNotification) @@ -44,7 +47,7 @@ final class PushNotificationViewModel: Store { } enum SideEffect { - case fetchNotifications + case fetchNotifications(PushNotificationQuery, cursor: PushNotificationCursor?) case delete(PushNotification) case toggleRead(String) } @@ -57,67 +60,6 @@ final class PushNotificationViewModel: Store { case delete } - enum SortOption: CaseIterable { - case latest - case oldest - - var title: String { - switch self { - case .latest: return "최신순" - case .oldest: return "예전순" - } - } - } - - enum TimeFilter: Equatable { - case none - case hours(Int) - case days(Int) - - var id: String { - switch self { - case .none: return "none" - case .hours(let value): return "hours-\(value)" - case .days(let value): return "days-\(value)" - } - } - - var title: String { - switch self { - case .none: - return "전체" - case .hours(let value): - return "최근 \(value)시간" - case .days(let value): - return "최근 \(value)일" - } - } - - static var availableOptions: [TimeFilter] {[ - .none, - .hours(1), - .hours(6), - .hours(24), - .days(3), - .days(7) - ] - } - - init(id: String) { - if id == "none" { - self = .none - } else if id.hasPrefix("hours-") { - let value = Int(id.replacingOccurrences(of: "hours-", with: "")) ?? 0 - self = value > 0 ? .hours(value) : .none - } else if id.hasPrefix("days-") { - let value = Int(id.replacingOccurrences(of: "days-", with: "")) ?? 0 - self = value > 0 ? .days(value) : .none - } else { - self = .none - } - } - } - @Published private(set) var state: State private let fetchUseCase: FetchPushNotificationsUseCase private let deleteUseCase: DeletePushNotificationUseCase @@ -141,40 +83,15 @@ final class PushNotificationViewModel: Store { self.toggleReadUseCase = toggleReadUseCase self.userDefaults = userDefaults self.state = State( - sortOption: Self.loadSortOption(userDefaults: userDefaults), - timeFilter: Self.loadTimeFilter(userDefaults: userDefaults), - showUnreadOnly: userDefaults.bool(forKey: DefaultsKey.showUnreadOnly) + query: Self.loadQuery(userDefaults: userDefaults) ) } - var displayedNotifications: [PushNotification] { - var items = state.notifications - - if state.showUnreadOnly { - items = items.filter { $0.isRead == false } - } - - if case let .hours(value) = state.timeFilter { - let threshold = Date().addingTimeInterval(-Double(value) * 3600.0) - items = items.filter { $0.receivedAt >= threshold } - } else if case let .days(value) = state.timeFilter { - let threshold = Date().addingTimeInterval(-Double(value) * 86400.0) - items = items.filter { $0.receivedAt >= threshold } - } - - switch state.sortOption { - case .latest: - return items.sorted { $0.receivedAt > $1.receivedAt } - case .oldest: - return items.sorted { $0.receivedAt < $1.receivedAt } - } - } - var appliedFilterCount: Int { var count = 0 - if state.sortOption != .latest { count += 1 } - if state.timeFilter != .none { count += 1 } - if state.showUnreadOnly { count += 1 } + if state.query.sortOrder != .latest { count += 1 } + if state.query.timeFilter != .none { count += 1 } + if state.query.unreadOnly { count += 1 } return count } @@ -187,10 +104,10 @@ final class PushNotificationViewModel: Store { .setTimeFilter, .toggleUnreadOnly, .resetFilters, .tapNotification: effects = reduceByUser(action, state: &state) - case .fetchNotifications, .confirmDelete, .setToast, .setSelectedTodoID: + case .fetchNotifications, .confirmDelete, .setToast, .setSelectedTodoID, .loadNextPage: effects = reduceByView(action, state: &state) - case .setLoading, .setNotifications: + case .setLoading, .appendNotifications, .resetPagination, .setHasMore: effects = reduceByRun(action, state: &state) } @@ -200,20 +117,29 @@ final class PushNotificationViewModel: Store { func run(_ effect: SideEffect) { switch effect { - case .fetchNotifications: + case .fetchNotifications(let query, let cursor): Task { do { defer { send(.setLoading(false)) } send(.setLoading(true)) - let notifications = try await fetchUseCase.execute() - send(.setNotifications(notifications)) + + let page = try await fetchUseCase.execute(query, cursor: cursor) + + if cursor == nil { send(.resetPagination) } + send(.appendNotifications(page.items, nextCursor: page.nextCursor)) + + let hasMore = page.items.count == query.pageSize && page.nextCursor != nil + send(.setHasMore(hasMore)) } catch { send(.setAlert(isPresented: true, type: .error)) } + } case .delete(let notification): Task { do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) try await deleteUseCase.execute(notification.id) } catch { send(.setAlert(isPresented: true, type: .error)) @@ -222,6 +148,8 @@ final class PushNotificationViewModel: Store { case .toggleRead(let todoID): Task { do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) try await toggleReadUseCase.execute(todoID) } catch { send(.setAlert(isPresented: true, type: .error)) @@ -253,21 +181,27 @@ private extension PushNotificationViewModel { case .setAlert(let isPresented, let type): setAlert(&state, isPresented: isPresented, for: type) case .toggleSortOption: - state.sortOption = state.sortOption == .latest ? .oldest : .latest - saveSortOption(state.sortOption) + state.query.sortOrder = state.query.sortOrder == .latest ? .oldest : .latest + saveSortOrder(state.query.sortOrder) + state.nextCursor = nil + return [.fetchNotifications(state.query, cursor: nil)] case .setTimeFilter(let filter): - state.timeFilter = filter + state.query.timeFilter = filter saveTimeFilter(filter) + state.nextCursor = nil + return [.fetchNotifications(state.query, cursor: nil)] case .toggleUnreadOnly: - state.showUnreadOnly.toggle() - userDefaults.set(state.showUnreadOnly, forKey: DefaultsKey.showUnreadOnly) + state.query.unreadOnly.toggle() + userDefaults.set(state.query.unreadOnly, forKey: DefaultsKey.showUnreadOnly) + state.nextCursor = nil + return [.fetchNotifications(state.query, cursor: nil)] case .resetFilters: - state.sortOption = .latest - state.timeFilter = .none - state.showUnreadOnly = false - saveSortOption(.latest) + state.query = .default + saveSortOrder(.latest) saveTimeFilter(.none) userDefaults.set(false, forKey: DefaultsKey.showUnreadOnly) + state.nextCursor = nil + return [.fetchNotifications(state.query, cursor: nil)] case .tapNotification(let notification): state.selectedTodoID = TodoIDItem(id: notification.todoID) default: @@ -279,7 +213,11 @@ private extension PushNotificationViewModel { func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { switch action { case .fetchNotifications: - return [.fetchNotifications] + state.nextCursor = nil + return [.fetchNotifications(state.query, cursor: nil)] + case .loadNextPage: + guard state.hasMore, !state.isLoading else { return [] } + return [.fetchNotifications(state.query, cursor: state.nextCursor)] case .confirmDelete: guard let (item, _ ) = state.pendingTask else { return [] } return [.delete(item)] @@ -297,8 +235,14 @@ private extension PushNotificationViewModel { switch action { case .setLoading(let value): state.isLoading = value - case .setNotifications(let notifications): - state.notifications = notifications + case .setHasMore(let value): + state.hasMore = value + case .resetPagination: + state.notifications = [] + state.nextCursor = nil + case .appendNotifications(let notifications, let nextCursor): + state.notifications.append(contentsOf: notifications) + state.nextCursor = nextCursor default: break } @@ -338,24 +282,91 @@ private extension PushNotificationViewModel { state.showToast = isPresented } - static func loadSortOption(userDefaults: UserDefaults) -> SortOption { + static func loadQuery(userDefaults: UserDefaults) -> PushNotificationQuery { + let sortOrder = loadSortOrder(userDefaults: userDefaults) + let timeFilter = loadTimeFilter(userDefaults: userDefaults) + let unreadOnly = userDefaults.bool(forKey: DefaultsKey.showUnreadOnly) + + return PushNotificationQuery( + sortOrder: sortOrder, + timeFilter: timeFilter, + unreadOnly: unreadOnly, + pageSize: 20 + ) + } + + static func loadSortOrder(userDefaults: UserDefaults) -> PushNotificationQuery.SortOrder { guard let rawValue = userDefaults.string(forKey: DefaultsKey.sortOption) else { return .latest } return rawValue == "oldest" ? .oldest : .latest } - static func loadTimeFilter(userDefaults: UserDefaults) -> TimeFilter { + static func loadTimeFilter(userDefaults: UserDefaults) -> PushNotificationQuery.TimeFilter { let id = userDefaults.string(forKey: DefaultsKey.timeFilter) ?? "none" - return TimeFilter(id: id) + return PushNotificationQuery.TimeFilter(id: id) } - func saveSortOption(_ option: SortOption) { - let value = option == .oldest ? "oldest" : "latest" + func saveSortOrder(_ order: PushNotificationQuery.SortOrder) { + let value = order == .oldest ? "oldest" : "latest" userDefaults.set(value, forKey: DefaultsKey.sortOption) } - func saveTimeFilter(_ filter: TimeFilter) { + func saveTimeFilter(_ filter: PushNotificationQuery.TimeFilter) { userDefaults.set(filter.id, forKey: DefaultsKey.timeFilter) } } + +extension PushNotificationQuery.SortOrder { + var title: String { + switch self { + case .latest: return "최신순" + case .oldest: return "예전순" + } + } +} + +extension PushNotificationQuery.TimeFilter { + var id: String { + switch self { + case .none: return "none" + case .hours(let value): return "hours-\(value)" + case .days(let value): return "days-\(value)" + } + } + + var title: String { + switch self { + case .none: + return "전체" + case .hours(let value): + return "최근 \(value)시간" + case .days(let value): + return "최근 \(value)일" + } + } + + static var availableOptions: [PushNotificationQuery.TimeFilter] {[ + .none, + .hours(1), + .hours(6), + .hours(24), + .days(3), + .days(7) + ] + } + + init(id: String) { + if id == "none" { + self = .none + } else if id.hasPrefix("hours-") { + let value = Int(id.replacingOccurrences(of: "hours-", with: "")) ?? 0 + self = value > 0 ? .hours(value) : .none + } else if id.hasPrefix("days-") { + let value = Int(id.replacingOccurrences(of: "days-", with: "")) ?? 0 + self = value > 0 ? .days(value) : .none + } else { + self = .none + } + } +} diff --git a/DevLog/UI/PushNotification/PushNotificationView.swift b/DevLog/UI/PushNotification/PushNotificationView.swift index e58a43d..0bd84b1 100644 --- a/DevLog/UI/PushNotification/PushNotificationView.swift +++ b/DevLog/UI/PushNotification/PushNotificationView.swift @@ -18,7 +18,7 @@ struct PushNotificationView: View { NavigationStack(path: $router.path) { List { Section { - if viewModel.displayedNotifications.isEmpty { + if viewModel.state.notifications.isEmpty { HStack { Spacer() Text("받은 알림이 없습니다.") @@ -27,13 +27,19 @@ struct PushNotificationView: View { } .listRowSeparator(.hidden) } else { - ForEach(viewModel.displayedNotifications, id: \.id) { notification in + ForEach(viewModel.state.notifications, id: \.id) { notification in Button { viewModel.send(.tapNotification(notification)) } label: { notificationRow(notification) } .buttonStyle(.plain) + .onAppear { + let lastID = viewModel.state.notifications.last?.id + if notification.id == lastID, viewModel.state.hasMore { + viewModel.send(.loadNextPage) + } + } } } } header: { @@ -44,6 +50,7 @@ struct PushNotificationView: View { .listStyle(.plain) .background(Color(.secondarySystemBackground)) .onAppear { viewModel.send(.fetchNotifications) } + .refreshable { viewModel.send(.fetchNotifications) } .navigationTitle("받은 푸시 알람") .alert( "", @@ -83,6 +90,11 @@ struct PushNotificationView: View { .background(Color(.secondarySystemBackground)) .presentationDragIndicator(.visible) } + .overlay { + if viewModel.state.isLoading { + LoadingView() + } + } } } @@ -109,19 +121,19 @@ struct PushNotificationView: View { Button { viewModel.send(.toggleSortOption) } label: { - Text("정렬: \(viewModel.state.sortOption.title)") + Text("정렬: \(viewModel.state.query.sortOrder.title)") } - .adaptiveButtonStyle(viewModel.state.sortOption == .oldest ? .blue : .clear) + .adaptiveButtonStyle(viewModel.state.query.sortOrder == .oldest ? .blue : .clear) Menu { - ForEach(PushNotificationViewModel.TimeFilter.availableOptions, id: \.id) { option in + ForEach(PushNotificationQuery.TimeFilter.availableOptions, id: \.id) { option in Button { viewModel.send(.setTimeFilter(option)) } label: { HStack { Text(option.title) Spacer() - if viewModel.state.timeFilter == option { + if viewModel.state.query.timeFilter == option { Image(systemName: "checkmark") .tint(.blue) } @@ -132,14 +144,14 @@ struct PushNotificationView: View { } label: { Text("기간") } - .adaptiveButtonStyle(viewModel.state.timeFilter == .none ? .clear : .blue) + .adaptiveButtonStyle(viewModel.state.query.timeFilter == .none ? .clear : .blue) Button { viewModel.send(.toggleUnreadOnly) } label: { Text("읽지 않음") } - .adaptiveButtonStyle(viewModel.state.showUnreadOnly ? .blue : .clear) + .adaptiveButtonStyle(viewModel.state.query.unreadOnly ? .blue : .clear) } } .scrollIndicators(.never)