Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions DevLog/App/Assembler/DomainAssembler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
5 changes: 5 additions & 0 deletions DevLog/Data/Repository/PushNotificationRepositoryImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions DevLog/Domain/Protocol/PushNotificationRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// TogglePushNotificationReadUseCase.swift
// DevLog
//
// Created by opfic on 2/13/26.
//

protocol TogglePushNotificationReadUseCase {
func execute(_ todoID: String) async throws
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
26 changes: 26 additions & 0 deletions DevLog/Infra/Service/PushNotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
44 changes: 32 additions & 12 deletions DevLog/Presentation/ViewModel/PushNotificationViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -35,6 +36,7 @@ final class PushNotificationViewModel: Store {
enum SideEffect {
case fetch
case delete(PushNotification)
case toggleRead(String)
}

enum AlertType {
Expand All @@ -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):
Expand All @@ -91,7 +99,7 @@ final class PushNotificationViewModel: Store {
}

self.state = state
return []
return effects
}

func run(_ effect: SideEffect) {
Expand All @@ -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 = "오류"
Expand Down
6 changes: 3 additions & 3 deletions DevLog/Resource/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,9 @@
},
"미리보기" : {

},
"받은 알림이 없습니다." : {

},
"받은 푸시 알람" : {

Expand Down Expand Up @@ -337,9 +340,6 @@
},
"작성된 내용이 없습니다." : {

},
"작성된 알림이 없습니다." : {

},
"정렬 옵션" : {

Expand Down
3 changes: 2 additions & 1 deletion DevLog/UI/Common/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
14 changes: 10 additions & 4 deletions DevLog/UI/PushNotification/PushNotificationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ struct PushNotificationView: View {
VStack {
if viewModel.state.notifications.isEmpty {
Spacer()
Text("작성된 알림이 없습니다.")
Text("받은 알림이 없습니다.")
.foregroundStyle(Color.gray)
Spacer()
} else {
Expand All @@ -28,6 +28,7 @@ struct PushNotificationView: View {
}
.frame(maxWidth: .infinity, alignment: .center)
.background(Color(.secondarySystemBackground))
.onAppear { viewModel.send(.fetchNotifications) }
.navigationTitle("받은 푸시 알람")
.alert(
"",
Expand All @@ -52,9 +53,6 @@ struct PushNotificationView: View {
.multilineTextAlignment(.center)
.lineLimit(3)
}
.onAppear {
viewModel.send(.fetchNotifications)
}
}
}

Expand Down Expand Up @@ -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,
Expand Down