diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index 6db71076..d4de7ea7 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -1,2 +1,9 @@ -# 지침사항 -당신은 iOS 수석 개발자 입니다. 당신은 한국인 이므로 코드 리뷰 및 요약을 한국어로 해야 합니다. +## Review Guidelines + +- Write all review comments in Korean. +- Keep review comments concise and high-signal. +- Prioritize findings about bugs, performance, and readability. +- Do not explain obvious, trivial, or low-signal issues. +- When useful, begin the review with a short summary of the main changes. +- Focus on actionable feedback rather than broad commentary. + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3e200b82..0b768b19 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -206,15 +206,6 @@ jobs: XC_STATUS=${PIPESTATUS[0]} set -e - if [ -f build.log ]; then - echo "== error: lines ==" - if grep -i "error:" build.log; then - if [ "$XC_STATUS" -eq 0 ]; then - XC_STATUS=1 - fi - fi - fi - exit $XC_STATUS - name: Comment build failure on PR @@ -228,9 +219,9 @@ jobs: if (fs.existsSync(path)) { const log = fs.readFileSync(path, 'utf8'); const lines = log.split(/\r?\n/); - const errorLines = lines.filter((line) => /error:/i.test(line)); + const errorLines = lines.filter((line) => /^(.*?):(\d+):(\d+):\s+error:/i.test(line)); if (errorLines.length > 0) { - body += "Lines containing 'error:':\n\n```\n" + errorLines.join('\n') + '\n```\n'; + body += "Compiler error lines:\n\n```\n" + errorLines.join('\n') + '\n```\n'; const repoRoot = process.env.GITHUB_WORKSPACE || process.cwd(); const pathMod = require('path'); @@ -258,7 +249,7 @@ jobs: body += "\nCode excerpts:\n\n```\n" + snippets.join('\n\n') + "\n```\n"; } } else { - body += "No lines containing 'error:' were found in build.log."; + body += "No compiler-style error diagnostics were found in build.log."; } } else { body += 'build.log not found.'; diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index 00397f35..30d60f15 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -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)) } diff --git a/DevLog/App/Delegate/AppDelegate.swift b/DevLog/App/Delegate/AppDelegate.swift index af8c6185..7a4e0339 100644 --- a/DevLog/App/Delegate/AppDelegate.swift +++ b/DevLog/App/Delegate/AppDelegate.swift @@ -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, @@ -52,7 +45,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { // Firebase Messaging 설정 Messaging.messaging().delegate = self - observeAuthState() // 앱이 완전 종료되어도, 알림을 통해 앱이 시작된 경우 처리 if let remoteNotification = launchOptions?[.remoteNotification] as? [AnyHashable: Any] { @@ -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]) } } } @@ -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 { - 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() - 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 { @@ -251,7 +122,3 @@ extension AppDelegate: UNUserNotificationCenterDelegate { completionHandler() } } - -extension Notification.Name { - static let fcmToken = Notification.Name("fcmToken") -} diff --git a/DevLog/App/RootView.swift b/DevLog/App/RootView.swift index c7b7c0b2..53e2c68b 100644 --- a/DevLog/App/RootView.swift +++ b/DevLog/App/RootView.swift @@ -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) + )) } else { LoginView(viewModel: LoginViewModel( signInUseCase: container.resolve(SignInUseCase.self), @@ -25,10 +27,7 @@ struct RootView: View { sessionUseCase: container.resolve(AuthSessionUseCase.self)) ) .onAppear { - if viewModel.state.isFirstLaunch { - viewModel.send(.setFirstLaunch(false)) - viewModel.send(.signOutAuto) - } + viewModel.send(.onAppear) } } } else { diff --git a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift index e5b7502d..34826bac 100644 --- a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift +++ b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift @@ -51,6 +51,11 @@ final class PushNotificationRepositoryImpl: PushNotificationRepository { .eraseToAnyPublisher() } + func observeUnreadPushCount() throws -> AnyPublisher { + try service.observeUnreadPushCount() + .eraseToAnyPublisher() + } + // 푸시 알림 기록 삭제 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 4c689b61..c58522fd 100644 --- a/DevLog/Domain/Protocol/PushNotificationRepository.swift +++ b/DevLog/Domain/Protocol/PushNotificationRepository.swift @@ -20,6 +20,7 @@ protocol PushNotificationRepository { _ query: PushNotificationQuery, limit: Int ) throws -> AnyPublisher + func observeUnreadPushCount() throws -> AnyPublisher func deleteNotification(_ notificationID: String) async throws func undoDeleteNotification(_ notificationID: String) async throws func toggleNotificationRead(_ todoId: String) async throws diff --git a/DevLog/Domain/UseCase/PushNotification/Fetch/ObserveUnreadPushCountUseCase.swift b/DevLog/Domain/UseCase/PushNotification/Fetch/ObserveUnreadPushCountUseCase.swift new file mode 100644 index 00000000..795d4ccb --- /dev/null +++ b/DevLog/Domain/UseCase/PushNotification/Fetch/ObserveUnreadPushCountUseCase.swift @@ -0,0 +1,12 @@ +// +// ObserveUnreadPushCountUseCase.swift +// DevLog +// +// Created by opfic on 3/17/26. +// + +import Combine + +protocol ObserveUnreadPushCountUseCase { + func execute() throws -> AnyPublisher +} diff --git a/DevLog/Domain/UseCase/PushNotification/Fetch/ObserveUnreadPushCountUseCaseImpl.swift b/DevLog/Domain/UseCase/PushNotification/Fetch/ObserveUnreadPushCountUseCaseImpl.swift new file mode 100644 index 00000000..75fb4a88 --- /dev/null +++ b/DevLog/Domain/UseCase/PushNotification/Fetch/ObserveUnreadPushCountUseCaseImpl.swift @@ -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 { + try repository.observeUnreadPushCount() + .removeDuplicates() + .eraseToAnyPublisher() + } +} diff --git a/DevLog/Infra/Service/PushNotificationService.swift b/DevLog/Infra/Service/PushNotificationService.swift index 66d7eb71..dd03ae36 100644 --- a/DevLog/Infra/Service/PushNotificationService.swift +++ b/DevLog/Infra/Service/PushNotificationService.swift @@ -178,6 +178,30 @@ final class PushNotificationService { .eraseToAnyPublisher() } + func observeUnreadPushCount() throws -> AnyPublisher { + guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated } + + let subject = PassthroughSubject() + 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 { diff --git a/DevLog/Presentation/ViewModel/MainViewModel.swift b/DevLog/Presentation/ViewModel/MainViewModel.swift new file mode 100644 index 00000000..25b786b6 --- /dev/null +++ b/DevLog/Presentation/ViewModel/MainViewModel.swift @@ -0,0 +1,115 @@ +// +// 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 onAppear + case setUnreadPushCount(Int) + case setAlert(Bool) + } + + enum SideEffect { + case observeUnreadPushCount + case updateBadgeCount(Int) + } + + private(set) var state = State() + private let logger = Logger(category: "MainViewModel") + private var cancellables = Set() + private var isObservingUnreadPushCount = false + private let observeUnreadPushCountUseCase: ObserveUnreadPushCountUseCase + + init( + observeUnreadPushCountUseCase: ObserveUnreadPushCountUseCase + ) { + self.observeUnreadPushCountUseCase = observeUnreadPushCountUseCase + } + + func reduce(with action: Action) -> [SideEffect] { + var state = self.state + var sideEffects: [SideEffect] = [] + + switch action { + case .onAppear: + if !isObservingUnreadPushCount { + isObservingUnreadPushCount = true + sideEffects = [.observeUnreadPushCount] + } + 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 .observeUnreadPushCount: + observeUnreadPushCount() + 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(let error) = completion { + logger.error("Failed to observe unread push count", error: error) + self.send(.setAlert(true)) + } + }, + receiveValue: { [weak self] count in + self?.send(.setUnreadPushCount(count)) + } + ) + .store(in: &cancellables) + } catch { + logger.error("Failed to start observing unread push count", error: error) + send(.setAlert(true)) + } + } + + func updateBadgeCount(_ count: Int) { + UNUserNotificationCenter.current().setBadgeCount(count) { [weak self] error in + if let error { + self?.logger.error("Failed to update application badge count", error: error) + } + } + } +} diff --git a/DevLog/Presentation/ViewModel/RootViewModel.swift b/DevLog/Presentation/ViewModel/RootViewModel.swift index 1f9b09b5..645ed295 100644 --- a/DevLog/Presentation/ViewModel/RootViewModel.swift +++ b/DevLog/Presentation/ViewModel/RootViewModel.swift @@ -7,6 +7,7 @@ import Foundation import Combine +import UserNotifications @Observable final class RootViewModel: Store { @@ -21,6 +22,7 @@ final class RootViewModel: Store { } enum Action { + case onAppear case setAlert(Bool) case networkStatusChanged(Bool) case setFirstLaunch(Bool) @@ -30,6 +32,7 @@ final class RootViewModel: Store { } enum SideEffect { + case clearApplicationBadgeCount case signOut } @@ -70,6 +73,13 @@ final class RootViewModel: Store { var effects: [SideEffect] = [] switch action { + case .onAppear: + effects = [.clearApplicationBadgeCount] + if state.isFirstLaunch { + state.isFirstLaunch = false + updateFirstLaunchUseCase.execute(false) + effects.append(.signOut) + } case .setAlert(let isPresented): setAlert(&state, isPresented: isPresented) case .networkStatusChanged(let isConnected): @@ -95,6 +105,8 @@ final class RootViewModel: Store { func run(_ effect: SideEffect) { switch effect { + case .clearApplicationBadgeCount: + clearApplicationBadgeCount() case .signOut: Task { try? await signOutUseCase.execute() @@ -107,6 +119,10 @@ final class RootViewModel: Store { // MARK: - Helper Methods private extension RootViewModel { + func clearApplicationBadgeCount() { + UNUserNotificationCenter.current().setBadgeCount(0) { _ in } + } + func setAlert( _ state: inout State, isPresented: Bool diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index c26676c6..aabecd7d 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -9,6 +9,7 @@ import SwiftUI struct MainView: View { @Environment(\.diContainer) var container: DIContainer + @State var viewModel: MainViewModel var body: some View { TabView { @@ -47,6 +48,7 @@ struct MainView: View { Image(systemName: "bell.fill") Text("알림") } + .badge(viewModel.state.unreadPushCount) ProfileView(viewModel: ProfileViewModel( fetchUserDataUseCase: container.resolve(FetchUserDataUseCase.self), fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), @@ -59,5 +61,19 @@ struct MainView: View { Text("프로필") } } + .onAppear { + viewModel.send(.onAppear) + } + .alert( + viewModel.state.alertTitle, + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert($0)) } + ) + ) { + Button("확인", role: .cancel) { } + } message: { + Text(viewModel.state.alertMessage) + } } }