diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index d2da148..80fcca3 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -74,6 +74,10 @@ final class DomainAssembler: Assembler { container.register(FetchPushNotificationsUseCase.self) { FetchPushNotificationsUseCaseImpl(container.resolve(PushNotificationRepository.self)) } + + container.register(TogglePushNotificationReadUseCase.self) { + TogglePushNotificationReadUseCaseImpl(container.resolve(PushNotificationRepository.self)) + } container.register(FetchAuthProvidersUseCase.self) { FetchAuthProvidersUseCaseImpl(container.resolve(AuthDataRepository.self)) diff --git a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift index 067f4fe..ed5b95b 100644 --- a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift +++ b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift @@ -52,4 +52,9 @@ final class PushNotificationRepositoryImpl: PushNotificationRepository { func deleteNotification(_ notificationID: String) async throws { try await service.deleteNotification(notificationID) } + + // 푸시 알림 읽음/안읽음 토글 + func toggleNotificationRead(_ todoID: String) async throws { + try await service.toggleNotificationRead(todoID) + } } diff --git a/DevLog/Domain/Protocol/PushNotificationRepository.swift b/DevLog/Domain/Protocol/PushNotificationRepository.swift index af834ae..8bc7f08 100644 --- a/DevLog/Domain/Protocol/PushNotificationRepository.swift +++ b/DevLog/Domain/Protocol/PushNotificationRepository.swift @@ -13,4 +13,5 @@ protocol PushNotificationRepository { func updatePushNotificationSettings(_ settings: PushNotificationSettings) async throws func requestNotifications() async throws -> [PushNotification] func deleteNotification(_ notificationID: String) async throws + func toggleNotificationRead(_ todoID: String) async throws } diff --git a/DevLog/Domain/UseCase/PushNotification/Read/TogglePushNotificationReadUseCase.swift b/DevLog/Domain/UseCase/PushNotification/Read/TogglePushNotificationReadUseCase.swift new file mode 100644 index 0000000..9e988e2 --- /dev/null +++ b/DevLog/Domain/UseCase/PushNotification/Read/TogglePushNotificationReadUseCase.swift @@ -0,0 +1,10 @@ +// +// TogglePushNotificationReadUseCase.swift +// DevLog +// +// Created by opfic on 2/13/26. +// + +protocol TogglePushNotificationReadUseCase { + func execute(_ todoID: String) async throws +} diff --git a/DevLog/Domain/UseCase/PushNotification/Read/TogglePushNotificationReadUseCaseImpl.swift b/DevLog/Domain/UseCase/PushNotification/Read/TogglePushNotificationReadUseCaseImpl.swift new file mode 100644 index 0000000..dc1610b --- /dev/null +++ b/DevLog/Domain/UseCase/PushNotification/Read/TogglePushNotificationReadUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// TogglePushNotificationReadUseCaseImpl.swift +// DevLog +// +// Created by opfic on 2/13/26. +// + +final class TogglePushNotificationReadUseCaseImpl: TogglePushNotificationReadUseCase { + private let repository: PushNotificationRepository + + init(_ repository: PushNotificationRepository) { + self.repository = repository + } + + func execute(_ todoID: String) async throws { + try await repository.toggleNotificationRead(todoID) + } +} diff --git a/DevLog/Infra/Service/PushNotificationService.swift b/DevLog/Infra/Service/PushNotificationService.swift index b909bc3..1f57713 100644 --- a/DevLog/Infra/Service/PushNotificationService.swift +++ b/DevLog/Infra/Service/PushNotificationService.swift @@ -108,4 +108,30 @@ final class PushNotificationService { try await docRef.delete() } + + /// 푸시 알림 읽음/안읽음 토글 + func toggleNotificationRead(_ todoID: String) async throws { + logger.info("Toggling notification read for todoID: \(todoID)") + + guard let uid = Auth.auth().currentUser?.uid else { + logger.error("User not authenticated") + throw AuthError.notAuthenticated + } + + let collection = store.collection("users/\(uid)/notifications") + let snapshot = try await collection.whereField("todoID", isEqualTo: todoID).getDocuments() + + guard let document = snapshot.documents.first else { + logger.error("Notification not found for todoID: \(todoID)") + throw FirestoreError.dataNotFound("notification") + } + + guard let currentValue = document.data()["isRead"] as? Bool else { + logger.error("isRead not found for notification: \(document.documentID)") + throw FirestoreError.dataNotFound("isRead") + } + + try await document.reference.updateData(["isRead": !currentValue]) + logger.info("Successfully toggled notification read") + } } diff --git a/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift index 5c6372f..2f26829 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift @@ -24,6 +24,7 @@ final class PushNotificationViewModel: Store { enum Action { case fetchNotifications case deleteNotification(PushNotification) + case toggleRead(PushNotification) case undoDelete case confirmDelete case setAlert(isPresented: Bool, type: AlertType? = nil) @@ -35,6 +36,7 @@ final class PushNotificationViewModel: Store { enum SideEffect { case fetch case delete(PushNotification) + case toggleRead(String) } enum AlertType { @@ -48,40 +50,46 @@ final class PushNotificationViewModel: Store { @Published private(set) var state: State = .init() private let fetchUseCase: FetchPushNotificationsUseCase private let deleteUseCase: DeletePushNotificationUseCase + private let toggleReadUseCase: TogglePushNotificationReadUseCase init( fetchUseCase: FetchPushNotificationsUseCase, - deleteUseCase: DeletePushNotificationUseCase + deleteUseCase: DeletePushNotificationUseCase, + toggleReadUseCase: TogglePushNotificationReadUseCase ) { self.fetchUseCase = fetchUseCase self.deleteUseCase = deleteUseCase + self.toggleReadUseCase = toggleReadUseCase } func reduce(with action: Action) -> [SideEffect] { var state = self.state + var effects: [SideEffect] = [] switch action { case .fetchNotifications: - return [.fetch] + effects = [.fetch] case .deleteNotification(let item): guard let index = state.notifications.firstIndex(where: { $0.id == item.id }) else { - return [] + break } state.pendingTask = (item, index) state.notifications.remove(at: index) setToast(&state, isPresented: true, for: .delete) + case .toggleRead(let item): + if let index = state.notifications.firstIndex(where: { $0.id == item.id }) { + state.notifications[index].isRead.toggle() + effects = [.toggleRead(item.todoID)] + } case .undoDelete: - guard let (item, index) = state.pendingTask else { return [] } + guard let (item, index) = state.pendingTask else { break } state.notifications.insert(item, at: index) state.pendingTask = nil case .confirmDelete: - guard let (item, _ ) = state.pendingTask else { - return [] - } - return [.delete(item)] + guard let (item, _ ) = state.pendingTask else { break } + effects = [.delete(item)] case .setAlert(let isPresented, let type): - setAlert(isPresented: isPresented, for: type) - return [] + setAlert(&state, isPresented: isPresented, for: type) case .setToast(let isPresented, let type): setToast(&state, isPresented: isPresented, for: type) case .setLoading(let value): @@ -91,7 +99,7 @@ final class PushNotificationViewModel: Store { } self.state = state - return [] + return effects } func run(_ effect: SideEffect) { @@ -115,12 +123,24 @@ final class PushNotificationViewModel: Store { send(.setAlert(isPresented: true, type: .error)) } } + case .toggleRead(let todoID): + Task { + do { + try await toggleReadUseCase.execute(todoID) + } catch { + send(.setAlert(isPresented: true, type: .error)) + } + } } } } private extension PushNotificationViewModel { - func setAlert(isPresented: Bool, for type: AlertType?) { + func setAlert( + _ state: inout State, + isPresented: Bool, + for type: AlertType? + ) { switch type { case .error: state.alertTitle = "오류" diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index b4cb40d..a59bc5f 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -280,6 +280,9 @@ }, "미리보기" : { + }, + "받은 알림이 없습니다." : { + }, "받은 푸시 알람" : { @@ -337,9 +340,6 @@ }, "작성된 내용이 없습니다." : { - }, - "작성된 알림이 없습니다." : { - }, "정렬 옵션" : { diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index e50cce1..767404e 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -22,7 +22,8 @@ struct MainView: View { } PushNotificationView(viewModel: PushNotificationViewModel( fetchUseCase: container.resolve(FetchPushNotificationsUseCase.self), - deleteUseCase: container.resolve(DeletePushNotificationUseCase.self) + deleteUseCase: container.resolve(DeletePushNotificationUseCase.self), + toggleReadUseCase: container.resolve(TogglePushNotificationReadUseCase.self) )) .tabItem { Image(systemName: "bell.fill") diff --git a/DevLog/UI/PushNotification/PushNotificationView.swift b/DevLog/UI/PushNotification/PushNotificationView.swift index b512eff..3536d13 100644 --- a/DevLog/UI/PushNotification/PushNotificationView.swift +++ b/DevLog/UI/PushNotification/PushNotificationView.swift @@ -16,7 +16,7 @@ struct PushNotificationView: View { VStack { if viewModel.state.notifications.isEmpty { Spacer() - Text("작성된 알림이 없습니다.") + Text("받은 알림이 없습니다.") .foregroundStyle(Color.gray) Spacer() } else { @@ -28,6 +28,7 @@ struct PushNotificationView: View { } .frame(maxWidth: .infinity, alignment: .center) .background(Color(.secondarySystemBackground)) + .onAppear { viewModel.send(.fetchNotifications) } .navigationTitle("받은 푸시 알람") .alert( "", @@ -52,9 +53,6 @@ struct PushNotificationView: View { .multilineTextAlignment(.center) .lineLimit(3) } - .onAppear { - viewModel.send(.fetchNotifications) - } } } @@ -85,6 +83,14 @@ struct PushNotificationView: View { } .padding(.vertical, 5) .listRowBackground(Color.clear) + .swipeActions(edge: .leading) { + Button { + viewModel.send(.toggleRead(notification)) + } label: { + Image(systemName: "checkmark.circle\(notification.isRead ? ".badge.xmark" : "")") + .tint(.blue) + } + } .swipeActions(edge: .trailing) { Button( role: .destructive,