diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index 75b5f92..dad41ea 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -62,5 +62,13 @@ final class DomainAssembler: Assembler { container.register(DeleteWebPageUseCase.self) { DeleteWebPageUseCaseImpl(container.resolve(WebPageRepository.self)) } + + container.register(DeletePushNotificationUseCase.self) { + DeletePushNotificationUseCaseImpl(container.resolve(PushNotificationRepository.self)) + } + + container.register(FetchPushNotificationsUseCase.self) { + FetchPushNotificationsUseCaseImpl(container.resolve(PushNotificationRepository.self)) + } } } diff --git a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift index 21ac9e1..067f4fe 100644 --- a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift +++ b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift @@ -14,17 +14,42 @@ final class PushNotificationRepositoryImpl: PushNotificationRepository { self.service = pushNotificationService } + /// 푸시 알림 On/Off 설정 func fetchPushNotificationEnabled() async throws -> Bool { return try await service.fetchPushNotificationEnabled() } + /// 푸시 알림 시간 설정 func fetchPushNotificationTime() async throws -> DateComponents { return try await service.fetchPushNotificationTime() } + /// 푸시 알림 설정 업데이트 func updatePushNotificationSettings(_ settings: PushNotificationSettings) async throws { try await service.updatePushNotificationSettings( isEnabled: settings.isEnabled, components: settings.scheduledTime ) } + + /// 푸시 알림 기록 요청 + func requestNotifications() async throws -> [PushNotification] { + try await service.requestNotifications() + .compactMap { dto in + dto.id.map { id in + PushNotification( + id: id, + title: dto.title, + body: dto.body, + receivedAt: dto.receivedAt.dateValue(), + isRead: dto.isRead, + todoID: dto.todoID + ) + } + } + } + + // 푸시 알림 기록 삭제 + func deleteNotification(_ notificationID: String) async throws { + try await service.deleteNotification(notificationID) + } } diff --git a/DevLog/Domain/Protocol/PushNotificationRepository.swift b/DevLog/Domain/Protocol/PushNotificationRepository.swift index d21a595..af834ae 100644 --- a/DevLog/Domain/Protocol/PushNotificationRepository.swift +++ b/DevLog/Domain/Protocol/PushNotificationRepository.swift @@ -11,4 +11,6 @@ protocol PushNotificationRepository { func fetchPushNotificationEnabled() async throws -> Bool func fetchPushNotificationTime() async throws -> DateComponents func updatePushNotificationSettings(_ settings: PushNotificationSettings) async throws + func requestNotifications() async throws -> [PushNotification] + func deleteNotification(_ notificationID: String) async throws } diff --git a/DevLog/Domain/UseCase/PushNotification/Delete/DeletePushNotificationUseCase.swift b/DevLog/Domain/UseCase/PushNotification/Delete/DeletePushNotificationUseCase.swift new file mode 100644 index 0000000..237ca74 --- /dev/null +++ b/DevLog/Domain/UseCase/PushNotification/Delete/DeletePushNotificationUseCase.swift @@ -0,0 +1,10 @@ +// +// DeletePushNotificationUseCase.swift +// DevLog +// +// Created by 최윤진 on 2/10/26. +// + +protocol DeletePushNotificationUseCase { + func execute(_ notificationID: String) async throws +} diff --git a/DevLog/Domain/UseCase/PushNotification/Delete/DeletePushNotificationUseCaseImpl.swift b/DevLog/Domain/UseCase/PushNotification/Delete/DeletePushNotificationUseCaseImpl.swift new file mode 100644 index 0000000..af4fcfd --- /dev/null +++ b/DevLog/Domain/UseCase/PushNotification/Delete/DeletePushNotificationUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// DeletePushNotificationUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 2/10/26. +// + +final class DeletePushNotificationUseCaseImpl: DeletePushNotificationUseCase { + private let repository: PushNotificationRepository + + init(_ repository: PushNotificationRepository) { + self.repository = repository + } + + func execute(_ notificationID: String) async throws { + try await repository.deleteNotification(notificationID) + } +} diff --git a/DevLog/Domain/UseCase/PushNotification/Fetch/FetchPushNotificationsUseCase.swift b/DevLog/Domain/UseCase/PushNotification/Fetch/FetchPushNotificationsUseCase.swift new file mode 100644 index 0000000..263967a --- /dev/null +++ b/DevLog/Domain/UseCase/PushNotification/Fetch/FetchPushNotificationsUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchPushNotificationsUseCase.swift +// DevLog +// +// Created by 최윤진 on 2/10/26. +// + +protocol FetchPushNotificationsUseCase { + func execute() async throws -> [PushNotification] +} diff --git a/DevLog/Domain/UseCase/PushNotification/Fetch/FetchPushNotificationsUseCaseImpl.swift b/DevLog/Domain/UseCase/PushNotification/Fetch/FetchPushNotificationsUseCaseImpl.swift new file mode 100644 index 0000000..d40c9d6 --- /dev/null +++ b/DevLog/Domain/UseCase/PushNotification/Fetch/FetchPushNotificationsUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// FetchPushNotificationsUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 2/10/26. +// + +final class FetchPushNotificationsUseCaseImpl: FetchPushNotificationsUseCase { + private let repository: PushNotificationRepository + + init(_ repository: PushNotificationRepository) { + self.repository = repository + } + + func execute() async throws -> [PushNotification] { + try await repository.requestNotifications() + } +} diff --git a/DevLog/Infra/DTO/PushNotification.swift b/DevLog/Infra/DTO/PushNotification.swift index 42733f2..62eddda 100644 --- a/DevLog/Infra/DTO/PushNotification.swift +++ b/DevLog/Infra/DTO/PushNotification.swift @@ -6,41 +6,12 @@ // import Foundation -import FirebaseFirestore -struct PushNotification: Codable, Identifiable { - @DocumentID var id: String? - let title: String // 알림 제목 - let body: String // 알림 내용 - let receivedDate: Date // 알림 수신 날짜 - var isRead: Bool // 알림 읽음 여부 - let todoId: String // Todo ID - - init(from: QueryDocumentSnapshot) { - self.id = from.documentID - self.title = from["title"] as? String ?? "" - self.body = from["body"] as? String ?? "" - self.receivedDate = (from["receivedDate"] as? Timestamp)?.dateValue() ?? Date() - self.isRead = from["isRead"] as? Bool ?? false - self.todoId = from["todoId"] as? String ?? "" - } - - init(id: String? = nil, title: String, body: String, receivedDate: Date, isRead: Bool, todoId: String) { - self.id = id - self.title = title - self.body = body - self.receivedDate = receivedDate - self.isRead = isRead - self.todoId = todoId - } - - func toDictionary() -> [String: Any] { - return [ - "title": title, - "body": body, - "receivedDate": Timestamp(date: receivedDate), - "isRead": isRead, - "todoId": todoId - ] - } +struct PushNotification: Identifiable { + let id: String + let title: String + let body: String + let receivedAt: Date + var isRead: Bool + let todoID: String } diff --git a/DevLog/Infra/DTO/PushNotificationResponse.swift b/DevLog/Infra/DTO/PushNotificationResponse.swift new file mode 100644 index 0000000..1cadc89 --- /dev/null +++ b/DevLog/Infra/DTO/PushNotificationResponse.swift @@ -0,0 +1,17 @@ +// +// PushNotificationResponse.swift +// DevLog +// +// Created by 최윤진 on 2/10/26. +// + +import FirebaseFirestore + +struct PushNotificationResponse: Decodable { + @DocumentID var id: String? + let title: String + let body: String + let receivedAt: Timestamp + let isRead: Bool + let todoID: String +} diff --git a/DevLog/Infra/Service/PushNotificationService.swift b/DevLog/Infra/Service/PushNotificationService.swift index ef8366a..41db682 100644 --- a/DevLog/Infra/Service/PushNotificationService.swift +++ b/DevLog/Infra/Service/PushNotificationService.swift @@ -66,18 +66,19 @@ final class PushNotificationService { try await settingsRef.setData(dict, merge: true) } - /// 푸시 알림 데이터 요청 - func requestNotification() async throws -> [PushNotification] { + /// 푸시 알림 기록 요청 + func requestNotifications() async throws -> [PushNotificationResponse] { guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated } let collection = store.collection("users/\(uid)/notifications") - let snapshot = try await collection.getDocuments() - - return snapshot.documents.compactMap { PushNotification(from: $0) } + + return try snapshot.documents.compactMap { document in + try document.data(as: PushNotificationResponse.self) + } } - /// 푸시 알림 데이터 삭제 + /// 푸시 알림 기록 삭제 func deleteNotification(_ notificationID: String) async throws { guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated } diff --git a/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift index 0d09a2a..ddb2c46 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationViewModel.swift @@ -9,24 +9,141 @@ import Foundation final class PushNotificationViewModel: Store { struct State { - + var notifications: [PushNotification] = [] + var showAlert: Bool = false + var showToast: Bool = false + var alertTitle: String = "" + var alertType: AlertType? + var alertMessage: String = "" + var toastMessage: String = "" + var toastType: ToastType? + var isLoading: Bool = false + var pendingTask: (PushNotification, Int)? } enum Action { - + case fetchNotifications + case deleteNotification(PushNotification) + case undoDelete + case confirmDelete + case setAlert(isPresented: Bool, type: AlertType? = nil) + case setToast(isPresented: Bool, type: ToastType? = nil) + case setLoading(Bool) + case setNotifications([PushNotification]) } enum SideEffect { + case fetch + case delete(PushNotification) + } + enum AlertType { + case error + } + + enum ToastType { + case delete } @Published private(set) var state: State = .init() + private let fetchUseCase: FetchPushNotificationsUseCase + private let deleteUseCase: DeletePushNotificationUseCase + + init( + fetchUseCase: FetchPushNotificationsUseCase, + deleteUseCase: DeletePushNotificationUseCase + ) { + self.fetchUseCase = fetchUseCase + self.deleteUseCase = deleteUseCase + } func reduce(with action: Action) -> [SideEffect] { - + var state = self.state + + switch action { + case .fetchNotifications: + return [.fetch] + case .deleteNotification(let item): + guard let index = state.notifications.firstIndex(where: { $0.id == item.id }) else { + return [] + } + state.pendingTask = (item, index) + state.notifications.remove(at: index) + setToast(&state, isPresented: true, for: .delete) + case .undoDelete: + guard let (item, index) = state.pendingTask else { return [] } + state.notifications.insert(item, at: index) + state.pendingTask = nil + case .confirmDelete: + guard let (item, _ ) = state.pendingTask else { + return [] + } + return [.delete(item)] + case .setAlert(let isPresented, let type): + setAlert(isPresented: isPresented, for: type) + return [] + case .setToast(let isPresented, let type): + setToast(&state, isPresented: isPresented, for: type) + case .setLoading(let value): + state.isLoading = value + case .setNotifications(let notifications): + state.notifications = notifications + } + + self.state = state + return [] } func run(_ effect: SideEffect) { + switch effect { + case .fetch: + Task { + do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) + let notifications = try await fetchUseCase.execute() + send(.setNotifications(notifications)) + } catch { + send(.setAlert(isPresented: true, type: .error)) + } + } + case .delete(let notification): + Task { + do { + try await deleteUseCase.execute(notification.id) + } catch { + send(.setAlert(isPresented: true, type: .error)) + } + } + } + } +} + +private extension PushNotificationViewModel { + func setAlert(isPresented: Bool, for type: AlertType?) { + switch type { + case .error: + state.alertTitle = "오류" + state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요." + case .none: + state.alertTitle = "" + state.alertMessage = "" + } + state.alertType = type + state.showAlert = isPresented + } + func setToast( + _ state: inout State, + isPresented: Bool, + for type: ToastType? + ) { + switch type { + case .delete: + state.toastMessage = "실행 취소" + case .none: + state.toastMessage = "" + } + state.showToast = isPresented } } diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift index 104456b..83764e3 100644 --- a/DevLog/Presentation/ViewModel/SearchViewModel.swift +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -134,7 +134,6 @@ final class SearchViewModel: Store { do { defer { send(.setLoading(false)) } send(.setLoading(true)) - try await deleteWebPageUseCase.execute(item.url.absoluteString) send(.deleteWebPage(item: item, fromEffect: true)) } catch { @@ -153,7 +152,7 @@ private extension SearchViewModel { state.alertMessage = "" case .error: state.alertTitle = "오류" - state.alertMessage = "문제가 발생했습니다. 다시 시도해주세요." + state.alertMessage = "문제가 발생했습니다. 잠시 다시 시도해주세요." case .none: state.alertTitle = "" state.alertMessage = "" diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 1a2d501..889b655 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -274,6 +274,9 @@ }, "미리보기" : { + }, + "받은 푸시 알람" : { + }, "버전 정보" : { @@ -328,6 +331,9 @@ }, "작성된 내용이 없습니다." : { + }, + "작성된 알림이 없습니다." : { + }, "저장된 웹페이지가 없습니다.\n우측 '+' 버튼을 눌러 웹페이지를 추가해보세요." : { diff --git a/DevLog/UI/Common/Componeent/Toast.swift b/DevLog/UI/Common/Componeent/Toast.swift new file mode 100644 index 0000000..8f7cf4d --- /dev/null +++ b/DevLog/UI/Common/Componeent/Toast.swift @@ -0,0 +1,143 @@ +// +// Toast.swift +// DevLog +// +// Created by 최윤진 on 2/10/26. +// + +import SwiftUI + +extension View { + func toast( + isPresented: Binding, + duration: TimeInterval = 2, + action: (() -> Void)? = nil, + onDismiss: (() -> Void)? = nil, + @ViewBuilder label: @escaping () -> Label + ) -> some View { + self + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .bottom) { + ToastOverlayView( + isPresented: isPresented, + duration: duration, + action: action, + onDismiss: onDismiss, + label: label + ) + .padding(.horizontal, 12) + } + } +} + +private struct ToastOverlayView: View { + @Binding var isPresented: Bool + let duration: TimeInterval + let action: (() -> Void)? + let onDismiss: (() -> Void)? + @ViewBuilder let label: () -> Label + + @State private var yOffset: CGFloat = 0 + @State private var opacityValue: Double = 0 + @State private var dismissWorkItem: DispatchWorkItem? + @State private var isTapped: Bool = false + + var body: some View { + if isPresented { + ToastCardView( + label, + color: action == nil ? .primary : .blue + ) + .offset(y: yOffset) + .opacity(opacityValue) + .onAppear { + presentAnimated() + scheduleDismiss() + } + .onDisappear { + dismissWorkItem?.cancel() + dismissWorkItem = nil + isPresented = false + + // 토스트를 탭하지 않고 자동으로 사라진 경우에만 onDismiss 호출 + if !isTapped { + onDismiss?() + } + } + .onTapGesture { + isTapped = true + dismissAnimated() + action?() + } + .transition(.identity) + } + } + + private func presentAnimated() { + dismissWorkItem?.cancel() + dismissWorkItem = nil + + withAnimation(.spring(response: 0.5, dampingFraction: 1, blendDuration: 0.0)) { + yOffset = -100 + opacityValue = 1 + } + } + + private func scheduleDismiss() { + let workItem = DispatchWorkItem { + dismissAnimated() + } + dismissWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: workItem) + } + + private func dismissAnimated() { + dismissWorkItem?.cancel() + dismissWorkItem = nil + + withAnimation(.easeInOut(duration: 0.2)) { + yOffset = 0 + opacityValue = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + isPresented = false + } + } +} + +private struct ToastCardView: View { + @ViewBuilder let label: Label + let color: Color + + init( + @ViewBuilder _ label: @escaping () -> Label, + color: Color = .primary + ) { + self.label = label() + self.color = color + } + + var body: some View { + self.label + .foregroundStyle(color) + .padding(.vertical, 12) + .padding(.horizontal, 14) + .background { + if #available(iOS 26.0, *) { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .glassEffect() + } else { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + } + } + .overlay { + if #unavailable(iOS 26.0) { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color(.systemGray4).opacity(0.2), lineWidth: 1) + } + } + .shadow(color: Color(.systemGray2).opacity(0.4), radius: 18, x: 0, y: 10) + } +} diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 16c99ba..f4c6fe7 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -20,11 +20,14 @@ struct MainView: View { Image(systemName: "house.fill") Text("홈") } -// NotificationView(notiVM: container.notiVM) -// .tabItem { -// Image(systemName: "bell.fill") -// Text("알림") -// } + PushNotificationView(viewModel: PushNotificationViewModel( + fetchUseCase: container.resolve(FetchPushNotificationsUseCase.self), + deleteUseCase: container.resolve(DeletePushNotificationUseCase.self) + )) + .tabItem { + Image(systemName: "bell.fill") + Text("알림") + } SearchView(viewModel: SearchViewModel( fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), addWebPageUseCase: container.resolve(AddWebPageUseCase.self), diff --git a/DevLog/UI/PushNotification/PushNotificationView.swift b/DevLog/UI/PushNotification/PushNotificationView.swift index cbf4336..03d681c 100644 --- a/DevLog/UI/PushNotification/PushNotificationView.swift +++ b/DevLog/UI/PushNotification/PushNotificationView.swift @@ -8,56 +8,109 @@ import SwiftUI struct PushNotificationView: View { + @StateObject private var router = NavigationRouter() @StateObject var viewModel: PushNotificationViewModel var body: some View { -// NavigationStack { -// VStack { -// if notiVM.notifications.isEmpty { -// Spacer() -// Text("작성된 알림이 없습니다.") -// .foregroundStyle(Color.gray) -// Spacer() -// } else { -// List(notiVM.notifications) { noti in -// if let notiId = noti.id { -// VStack(alignment: .leading, spacing: 5) { -// Text(noti.title) -// .font(.headline) -// .lineLimit(1) -// Text(noti.body) -// .font(.subheadline) -// .foregroundStyle(Color.gray) -// .lineLimit(1) -// } -// .padding(.vertical, 5) -// .swipeActions(edge: .trailing) { -// Button(role: .destructive, action: { -// Task { -// await notiVM.deleteNotification(notificationId: notiId) -// } -// }) { -// Image(systemName: "trash") -// } -// } -// } -// } -// .listStyle(.plain) -// } -// } -// .frame(maxWidth: .infinity, alignment: .center) -// .background(Color(UIColor.secondarySystemBackground)) -// .navigationTitle("받은 푸시 알람") -// .alert("", isPresented: $notiVM.showAlert) { -// Button("확인", role: .cancel) { } -// } message: { -// Text(notiVM.alertMsg) -// } -// .onAppear { -// Task { -// await notiVM.requestNotifications() -// } -// } -// } + NavigationStack(path: $router.path) { + VStack { + if viewModel.state.notifications.isEmpty { + Spacer() + Text("작성된 알림이 없습니다.") + .foregroundStyle(Color.gray) + Spacer() + } else { + List(viewModel.state.notifications, id: \.id) { notification in + notificationRow(notification) + } + .listStyle(.plain) + } + } + .frame(maxWidth: .infinity, alignment: .center) + .background(Color(.secondarySystemBackground)) + .navigationTitle("받은 푸시 알람") + .alert( + "", + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert(isPresented: $0)) } + )) { + Button("확인", role: .cancel) { } + } message: { + Text(viewModel.state.alertMessage) + } + .toast( + isPresented: Binding( + get: { viewModel.state.showToast }, + set: { viewModel.send(.setToast(isPresented: $0)) }), + duration: 5, + action: { viewModel.send(.undoDelete) }, + onDismiss: { viewModel.send(.confirmDelete) } + ) { + Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") + .font(.caption) + .multilineTextAlignment(.center) + .lineLimit(3) + } + .onAppear { + viewModel.send(.fetchNotifications) + } + } + } + + private func notificationRow(_ notification: PushNotification) -> some View { + HStack { + Circle() + .fill(Color.blue) + .frame(width: 8, height: 8) + .opacity(notification.isRead ? 0 : 1) + + VStack(alignment: .leading, spacing: 5) { + Text(notification.title) + .font(.headline) + .lineLimit(1) + Text(notification.body) + .font(.subheadline) + .foregroundStyle(Color.gray) + .lineLimit(1) + } + + Spacer() + + TimelineView(.periodic(from: .now, by: 1.0)) { context in + Text(timeAgoText(from: notification.receivedAt, now: context.date)) + .font(.caption2) + .foregroundStyle(Color.gray) + } + } + .padding(.vertical, 5) + .listRowBackground(Color.clear) + .swipeActions(edge: .trailing) { + Button( + role: .destructive, + action: { + viewModel.send(.deleteNotification(notification)) + } + ) { + Image(systemName: "trash") + } + } + } + + private func timeAgoText(from date: Date, now: Date) -> String { + let seconds = Int(now.timeIntervalSince(date)) + + if seconds < 60 { + return "\(max(0, seconds))초 전" + } else if seconds < 3600 { + let minutes = seconds / 60 + return "\(minutes)분 전" + } else if seconds < 86400 { + let hours = seconds / 3600 + return "\(hours)시간 전" + } else { + let days = seconds / 86400 + return "\(days)일 전" + } } }