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
11 changes: 9 additions & 2 deletions .gemini/styleguide.md
Original file line number Diff line number Diff line change
@@ -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.

15 changes: 3 additions & 12 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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');
Expand Down Expand Up @@ -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.';
Expand Down
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")
}
9 changes: 4 additions & 5 deletions DevLog/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,17 @@ 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

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

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),
signOutUseCase: container.resolve(SignOutUseCase.self),
sessionUseCase: container.resolve(AuthSessionUseCase.self))
)
.onAppear {
if viewModel.state.isFirstLaunch {
viewModel.send(.setFirstLaunch(false))
viewModel.send(.signOutAuto)
}
viewModel.send(.onAppear)
}
}
} else {
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
Loading