Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -103,6 +103,10 @@ private extension DomainAssembler {
FetchPushNotificationsUseCaseImpl(container.resolve(PushNotificationRepository.self))
}

container.register(ObserveUnreadPushCountUseCase.self) {
ObserveUnreadPushCountUseCaseImpl(container.resolve(PushNotificationRepository.self))
}

container.register(TogglePushNotificationReadUseCase.self) {
TogglePushNotificationReadUseCaseImpl(container.resolve(PushNotificationRepository.self))
}
Expand Down
133 changes: 0 additions & 133 deletions DevLog/App/Delegate/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,10 @@
import UIKit
import Firebase
import FirebaseAuth
import FirebaseFirestore
import FirebaseMessaging
import GoogleSignIn
import Combine
import UserNotifications

class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
private let logger = Logger(category: "AppDelegate")
private var store: Firestore { Firestore.firestore() }
private var authStateListenerHandle: AuthStateDidChangeListenerHandle?
private var cancellable: AnyCancellable?

func application(
_ app: UIApplication,
Expand Down Expand Up @@ -52,7 +45,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {

// Firebase Messaging 설정
Messaging.messaging().delegate = self
observeAuthState()

// 앱이 완전 종료되어도, 알림을 통해 앱이 시작된 경우 처리
if let remoteNotification = launchOptions?[.remoteNotification] as? [AnyHashable: Any] {
Expand Down Expand Up @@ -80,17 +72,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
logger.error("Failed to register APNs token", error: error)
}

func applicationDidBecomeActive(_ application: UIApplication) {
syncBadgeCount()
}

// FCMToken 갱신
func messaging(
_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?
) {
if let fcmToken = fcmToken {
logger.info("FCM token: \(fcmToken)")
NotificationCenter.default.post(name: .fcmToken, object: nil, userInfo: ["fcmToken": fcmToken])
}
}
}
Expand All @@ -108,122 +95,6 @@ private extension AppDelegate {
}
}
}

func observeAuthState() {
authStateListenerHandle = Auth.auth().addStateDidChangeListener { [weak self] _, user in
guard let self else { return }

self.cancellable?.cancel()

guard user != nil else {
self.updateBadgeCount(0)
return
}

self.startObservingBadgeCount()
self.syncBadgeCount()
}
}

func syncBadgeCount() {
Task { @MainActor [weak self] in
guard let self else { return }
guard Auth.auth().currentUser != nil else {
self.updateBadgeCount(0)
return
}

do {
let unreadNotificationCount = try await self.fetchUnreadNotificationCount()
self.updateBadgeCount(unreadNotificationCount)
} catch {
self.logger.error("Failed to fetch unread notification count", error: error)
}
}
}

private func startObservingBadgeCount() {
do {
cancellable = try observeUnreadNotificationCount()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
guard let self else { return }

if case .failure(let error) = completion {
self.logger.error("Failed to observe unread notification count", error: error)
}
},
receiveValue: { [weak self] count in
self?.updateBadgeCount(count)
}
)
} catch {
logger.error("Failed to start observing badge count", error: error)
}
}

private func fetchUnreadNotificationCount() async throws -> Int {
logger.info("Fetching unread notification count")

guard let uid = Auth.auth().currentUser?.uid else {
logger.error("User not authenticated")
throw AuthError.notAuthenticated
}

do {
let snapshot = try await store.collection("users/\(uid)/notifications")
.whereField("isRead", isEqualTo: false)
.getDocuments()

let unreadNotificationCount = snapshot.documents.count
logger.info("Unread notification count: \(unreadNotificationCount)")
return unreadNotificationCount
} catch {
logger.error("Failed to fetch unread notification count", error: error)
throw error
}
}

private func observeUnreadNotificationCount() throws -> AnyPublisher<Int, Error> {
logger.info("Observing unread notification count")

guard let uid = Auth.auth().currentUser?.uid else {
logger.error("User not authenticated")
throw AuthError.notAuthenticated
}

let subject = PassthroughSubject<Int, Error>()
let listener = store.collection("users/\(uid)/notifications")
.whereField("isRead", isEqualTo: false)
.addSnapshotListener { [weak self] snapshot, error in
guard let self else { return }
if let error {
self.logger.error("Failed to observe unread notification count", error: error)
subject.send(completion: .failure(error))
return
}

guard let snapshot else { return }

let unreadNotificationCount = snapshot.documents.count
self.logger.info("Observed unread notification count: \(unreadNotificationCount)")
subject.send(unreadNotificationCount)
}

return subject
.handleEvents(receiveCancel: { listener.remove() })
.eraseToAnyPublisher()
}

@MainActor
private func updateBadgeCount(_ count: Int) {
UNUserNotificationCenter.current().setBadgeCount(count) { [weak self] error in
if let error {
self?.logger.error("Failed to update badge count", error: error)
}
}
}
}

extension AppDelegate: UNUserNotificationCenterDelegate {
Expand Down Expand Up @@ -251,7 +122,3 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
completionHandler()
}
}

extension Notification.Name {
static let fcmToken = Notification.Name("fcmToken")
}
4 changes: 3 additions & 1 deletion DevLog/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ struct RootView: View {
Color(UIColor.systemGroupedBackground).ignoresSafeArea()
if let signIn = viewModel.state.signIn {
if signIn && !viewModel.state.isFirstLaunch {
MainView()
MainView(viewModel: MainViewModel(
observeUnreadPushCountUseCase: container.resolve(ObserveUnreadPushCountUseCase.self)
))
Comment on lines +20 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge MainViewModel 생성을 body 재평가 경로에서 분리하세요

RootViewbody에서 MainViewModel(...)를 직접 만들면 RootViewModel 상태 변경(세션/테마/네트워크 등)마다 새 인스턴스가 계속 생성됩니다. MainViewModel.init이 즉시 observeUnreadPushCount()를 실행해 Firestore 구독을 시작하므로, 실제로는 MainView@State에 채택되지 않고 버려지는 인스턴스에서도 불필요한 리스너 등록/해제가 반복되어 성능과 네트워크 비용이 증가합니다.

Useful? React with 👍 / 👎.

} else {
Comment on lines 19 to 23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear app badge when signed-out flow is shown

Badge updates now only happen through MainViewModel, which is instantiated only in the signed-in branch here; when a user signs out after having unread notifications, MainView is removed and no code path resets the app icon badge to 0. The previous AppDelegate auth-state handler used to clear badges on unauthenticated state, so this change can leave stale badge counts visible on the login screen (and to the next user on shared devices).

Useful? React with 👍 / 👎.

LoginView(viewModel: LoginViewModel(
signInUseCase: container.resolve(SignInUseCase.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 @@ -51,6 +51,11 @@ final class PushNotificationRepositoryImpl: PushNotificationRepository {
.eraseToAnyPublisher()
}

func observeUnreadPushCount() throws -> AnyPublisher<Int, Error> {
try service.observeUnreadPushCount()
.eraseToAnyPublisher()
}

// 푸시 알림 기록 삭제
func deleteNotification(_ notificationID: String) async throws {
try await service.deleteNotification(notificationID)
Expand Down
1 change: 1 addition & 0 deletions DevLog/Domain/Protocol/PushNotificationRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ protocol PushNotificationRepository {
_ query: PushNotificationQuery,
limit: Int
) throws -> AnyPublisher<PushNotificationPage, Error>
func observeUnreadPushCount() throws -> AnyPublisher<Int, Error>
func deleteNotification(_ notificationID: String) async throws
func undoDeleteNotification(_ notificationID: String) async throws
func toggleNotificationRead(_ todoId: String) async throws
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// ObserveUnreadPushCountUseCase.swift
// DevLog
//
// Created by opfic on 3/17/26.
//

import Combine

protocol ObserveUnreadPushCountUseCase {
func execute() throws -> AnyPublisher<Int, Error>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// ObserveUnreadPushCountUseCaseImpl.swift
// DevLog
//
// Created by opfic on 3/17/26.
//

import Combine

final class ObserveUnreadPushCountUseCaseImpl: ObserveUnreadPushCountUseCase {
private let repository: PushNotificationRepository

init(_ repository: PushNotificationRepository) {
self.repository = repository
}

func execute() throws -> AnyPublisher<Int, Error> {
try repository.observeUnreadPushCount()
.removeDuplicates()
.eraseToAnyPublisher()
}
}
24 changes: 24 additions & 0 deletions DevLog/Infra/Service/PushNotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,30 @@ final class PushNotificationService {
.eraseToAnyPublisher()
}

func observeUnreadPushCount() throws -> AnyPublisher<Int, Error> {
guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated }

let subject = PassthroughSubject<Int, Error>()
let listener = store.collection("users/\(uid)/notifications")
.whereField("isRead", isEqualTo: false)
.addSnapshotListener { snapshot, error in
if let error {
subject.send(completion: .failure(error))
return
}

guard let snapshot else { return }
let unreadPushCount = snapshot.documents.filter { document in
!(document.data()[Key.deletingAt.rawValue] is Timestamp)
}.count
subject.send(unreadPushCount)
}

return subject
.handleEvents(receiveCancel: { listener.remove() })
.eraseToAnyPublisher()
}

/// 푸시 알림 기록 삭제
func deleteNotification(_ notificationID: String) async throws {
do {
Expand Down
99 changes: 99 additions & 0 deletions DevLog/Presentation/ViewModel/MainViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//
// MainViewModel.swift
// DevLog
//
// Created by opfic on 3/17/26.
//

import Foundation
import Combine
import UserNotifications

@Observable
final class MainViewModel: Store {
struct State: Equatable {
var unreadPushCount = 0
var showAlert = false
var alertTitle = ""
var alertMessage = ""
}

enum Action {
case setUnreadPushCount(Int)
case setAlert(Bool)
}

enum SideEffect {
case updateBadgeCount(Int)
}

private(set) var state = State()
private var cancellables = Set<AnyCancellable>()
private let observeUnreadPushCountUseCase: ObserveUnreadPushCountUseCase

init(
observeUnreadPushCountUseCase: ObserveUnreadPushCountUseCase
) {
self.observeUnreadPushCountUseCase = observeUnreadPushCountUseCase
observeUnreadPushCount()
}
Comment on lines +38 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

MainViewModel이 해제될 때(예: 사용자가 로그아웃할 때) 앱 아이콘의 배지 카운트를 0으로 설정하는 로직이 누락되었습니다. 이로 인해 로그아웃 후에도 배지 카운트가 그대로 남아있게 됩니다. deinit 블록을 추가하여 배지를 초기화하는 것을 제안합니다.

deinit {
    updateBadgeCount(0)
}


func reduce(with action: Action) -> [SideEffect] {
var state = self.state
var sideEffects: [SideEffect] = []

switch action {
case .setUnreadPushCount(let count):
state.unreadPushCount = count
sideEffects = [.updateBadgeCount(count)]
case .setAlert(let isPresented):
setAlert(&state, isPresented: isPresented)
}

if self.state != state { self.state = state }
return sideEffects
}

func run(_ effect: SideEffect) {
switch effect {
case .updateBadgeCount(let count):
updateBadgeCount(count)
}
}
}

private extension MainViewModel {
func setAlert(
_ state: inout State,
isPresented: Bool
) {
state.alertTitle = "오류"
state.alertMessage = "알림 배지를 불러오는 중 문제가 발생했습니다."
state.showAlert = isPresented
}

func observeUnreadPushCount() {
do {
try observeUnreadPushCountUseCase.execute()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
guard let self else { return }
if case .failure = completion {
self.send(.setAlert(true))
}
},
receiveValue: { [weak self] count in
self?.send(.setUnreadPushCount(count))
}
)
.store(in: &cancellables)
} catch {
send(.setAlert(true))
}
}

func updateBadgeCount(_ count: Int) {
UNUserNotificationCenter.current().setBadgeCount(count) { _ in }
}
Comment on lines +108 to +114

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

setBadgeCount의 completion handler에서 오류가 발생할 경우를 대비하여 로깅을 추가하는 것이 좋습니다. 이전 AppDelegate 구현에서는 오류를 로깅했지만, 현재는 무시되고 있습니다. 디버깅을 위해 오류를 기록하는 것을 고려해보세요. MainViewModelLogger를 추가하고 다음과 같이 수정할 수 있습니다.

// In MainViewModel
private let logger = Logger(category: "MainViewModel")

// ...

func updateBadgeCount(_ count: Int) {
    UNUserNotificationCenter.current().setBadgeCount(count) { [weak self] error in
        if let error {
            self?.logger.error("Failed to update badge count", error: error)
        }
    }
}

}
Loading