Skip to content

Commit f20d1a6

Browse files
authored
[#284] 탭뷰에 있는 알림 이미지에 읽지 않은 푸시알림 데이터가 있을 경우 해당 데이터의 갯수를 배지로 보이도록 구현한다 (#290)
* feat: 불필요 방출 제거 * feat: 앱델리에서 푸시 알림 관련 역학 책임 제거 * feat: 읽지 않은 푸시알림 갯수를 탭바와 앱 배지로 보이도록 구현 * fix: 리스너에 삭제되지 않은 임시 푸시알람 데이터인 경우를 걸러내어 삭제 예정인 데이터 갯수는 제외하도록 수정 * fix: 로그아웃 시 앱 아이콘에 푸시알람 배지가 떠있는 이슈 해결 * test: 코덱스 리뷰용 AGENTS.md 생성 * chore: 불필요 파일 제거 * chore: 제미나이용 가이드 업데이트 * fix: 뷰모델이 생성될 때마다 유즈케이스가 호출되는 이슈 해결 * feat: 에러 로깅 추가 * fix: error 문자열이 포함되었을 경우 빌드가 성공해도 CI가 실패했다고 뜨는 이슈 해결
1 parent b58bf27 commit f20d1a6

13 files changed

Lines changed: 231 additions & 152 deletions

File tree

.gemini/styleguide.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,9 @@
1-
# 지침사항
2-
당신은 iOS 수석 개발자 입니다. 당신은 한국인 이므로 코드 리뷰 및 요약을 한국어로 해야 합니다.
1+
## Review Guidelines
2+
3+
- Write all review comments in Korean.
4+
- Keep review comments concise and high-signal.
5+
- Prioritize findings about bugs, performance, and readability.
6+
- Do not explain obvious, trivial, or low-signal issues.
7+
- When useful, begin the review with a short summary of the main changes.
8+
- Focus on actionable feedback rather than broad commentary.
9+

.github/workflows/build.yml

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -206,15 +206,6 @@ jobs:
206206
XC_STATUS=${PIPESTATUS[0]}
207207
set -e
208208
209-
if [ -f build.log ]; then
210-
echo "== error: lines =="
211-
if grep -i "error:" build.log; then
212-
if [ "$XC_STATUS" -eq 0 ]; then
213-
XC_STATUS=1
214-
fi
215-
fi
216-
fi
217-
218209
exit $XC_STATUS
219210
220211
- name: Comment build failure on PR
@@ -228,9 +219,9 @@ jobs:
228219
if (fs.existsSync(path)) {
229220
const log = fs.readFileSync(path, 'utf8');
230221
const lines = log.split(/\r?\n/);
231-
const errorLines = lines.filter((line) => /error:/i.test(line));
222+
const errorLines = lines.filter((line) => /^(.*?):(\d+):(\d+):\s+error:/i.test(line));
232223
if (errorLines.length > 0) {
233-
body += "Lines containing 'error:':\n\n```\n" + errorLines.join('\n') + '\n```\n';
224+
body += "Compiler error lines:\n\n```\n" + errorLines.join('\n') + '\n```\n';
234225
235226
const repoRoot = process.env.GITHUB_WORKSPACE || process.cwd();
236227
const pathMod = require('path');
@@ -258,7 +249,7 @@ jobs:
258249
body += "\nCode excerpts:\n\n```\n" + snippets.join('\n\n') + "\n```\n";
259250
}
260251
} else {
261-
body += "No lines containing 'error:' were found in build.log.";
252+
body += "No compiler-style error diagnostics were found in build.log.";
262253
}
263254
} else {
264255
body += 'build.log not found.';

DevLog/App/Assembler/DomainAssembler.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ private extension DomainAssembler {
103103
FetchPushNotificationsUseCaseImpl(container.resolve(PushNotificationRepository.self))
104104
}
105105

106+
container.register(ObserveUnreadPushCountUseCase.self) {
107+
ObserveUnreadPushCountUseCaseImpl(container.resolve(PushNotificationRepository.self))
108+
}
109+
106110
container.register(TogglePushNotificationReadUseCase.self) {
107111
TogglePushNotificationReadUseCaseImpl(container.resolve(PushNotificationRepository.self))
108112
}

DevLog/App/Delegate/AppDelegate.swift

Lines changed: 0 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,10 @@
88
import UIKit
99
import Firebase
1010
import FirebaseAuth
11-
import FirebaseFirestore
12-
import FirebaseMessaging
1311
import GoogleSignIn
14-
import Combine
15-
import UserNotifications
1612

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

2316
func application(
2417
_ app: UIApplication,
@@ -52,7 +45,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
5245

5346
// Firebase Messaging 설정
5447
Messaging.messaging().delegate = self
55-
observeAuthState()
5648

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

83-
func applicationDidBecomeActive(_ application: UIApplication) {
84-
syncBadgeCount()
85-
}
86-
8775
// FCMToken 갱신
8876
func messaging(
8977
_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?
9078
) {
9179
if let fcmToken = fcmToken {
9280
logger.info("FCM token: \(fcmToken)")
93-
NotificationCenter.default.post(name: .fcmToken, object: nil, userInfo: ["fcmToken": fcmToken])
9481
}
9582
}
9683
}
@@ -108,122 +95,6 @@ private extension AppDelegate {
10895
}
10996
}
11097
}
111-
112-
func observeAuthState() {
113-
authStateListenerHandle = Auth.auth().addStateDidChangeListener { [weak self] _, user in
114-
guard let self else { return }
115-
116-
self.cancellable?.cancel()
117-
118-
guard user != nil else {
119-
self.updateBadgeCount(0)
120-
return
121-
}
122-
123-
self.startObservingBadgeCount()
124-
self.syncBadgeCount()
125-
}
126-
}
127-
128-
func syncBadgeCount() {
129-
Task { @MainActor [weak self] in
130-
guard let self else { return }
131-
guard Auth.auth().currentUser != nil else {
132-
self.updateBadgeCount(0)
133-
return
134-
}
135-
136-
do {
137-
let unreadNotificationCount = try await self.fetchUnreadNotificationCount()
138-
self.updateBadgeCount(unreadNotificationCount)
139-
} catch {
140-
self.logger.error("Failed to fetch unread notification count", error: error)
141-
}
142-
}
143-
}
144-
145-
private func startObservingBadgeCount() {
146-
do {
147-
cancellable = try observeUnreadNotificationCount()
148-
.receive(on: DispatchQueue.main)
149-
.sink(
150-
receiveCompletion: { [weak self] completion in
151-
guard let self else { return }
152-
153-
if case .failure(let error) = completion {
154-
self.logger.error("Failed to observe unread notification count", error: error)
155-
}
156-
},
157-
receiveValue: { [weak self] count in
158-
self?.updateBadgeCount(count)
159-
}
160-
)
161-
} catch {
162-
logger.error("Failed to start observing badge count", error: error)
163-
}
164-
}
165-
166-
private func fetchUnreadNotificationCount() async throws -> Int {
167-
logger.info("Fetching unread notification count")
168-
169-
guard let uid = Auth.auth().currentUser?.uid else {
170-
logger.error("User not authenticated")
171-
throw AuthError.notAuthenticated
172-
}
173-
174-
do {
175-
let snapshot = try await store.collection("users/\(uid)/notifications")
176-
.whereField("isRead", isEqualTo: false)
177-
.getDocuments()
178-
179-
let unreadNotificationCount = snapshot.documents.count
180-
logger.info("Unread notification count: \(unreadNotificationCount)")
181-
return unreadNotificationCount
182-
} catch {
183-
logger.error("Failed to fetch unread notification count", error: error)
184-
throw error
185-
}
186-
}
187-
188-
private func observeUnreadNotificationCount() throws -> AnyPublisher<Int, Error> {
189-
logger.info("Observing unread notification count")
190-
191-
guard let uid = Auth.auth().currentUser?.uid else {
192-
logger.error("User not authenticated")
193-
throw AuthError.notAuthenticated
194-
}
195-
196-
let subject = PassthroughSubject<Int, Error>()
197-
let listener = store.collection("users/\(uid)/notifications")
198-
.whereField("isRead", isEqualTo: false)
199-
.addSnapshotListener { [weak self] snapshot, error in
200-
guard let self else { return }
201-
if let error {
202-
self.logger.error("Failed to observe unread notification count", error: error)
203-
subject.send(completion: .failure(error))
204-
return
205-
}
206-
207-
guard let snapshot else { return }
208-
209-
let unreadNotificationCount = snapshot.documents.count
210-
self.logger.info("Observed unread notification count: \(unreadNotificationCount)")
211-
subject.send(unreadNotificationCount)
212-
}
213-
214-
return subject
215-
.handleEvents(receiveCancel: { listener.remove() })
216-
.eraseToAnyPublisher()
217-
}
218-
219-
@MainActor
220-
private func updateBadgeCount(_ count: Int) {
221-
UNUserNotificationCenter.current().setBadgeCount(count) { [weak self] error in
222-
if let error {
223-
self?.logger.error("Failed to update badge count", error: error)
224-
}
225-
}
226-
}
22798
}
22899

229100
extension AppDelegate: UNUserNotificationCenterDelegate {
@@ -251,7 +122,3 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
251122
completionHandler()
252123
}
253124
}
254-
255-
extension Notification.Name {
256-
static let fcmToken = Notification.Name("fcmToken")
257-
}

DevLog/App/RootView.swift

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,17 @@ struct RootView: View {
1717
Color(UIColor.systemGroupedBackground).ignoresSafeArea()
1818
if let signIn = viewModel.state.signIn {
1919
if signIn && !viewModel.state.isFirstLaunch {
20-
MainView()
20+
MainView(viewModel: MainViewModel(
21+
observeUnreadPushCountUseCase: container.resolve(ObserveUnreadPushCountUseCase.self)
22+
))
2123
} else {
2224
LoginView(viewModel: LoginViewModel(
2325
signInUseCase: container.resolve(SignInUseCase.self),
2426
signOutUseCase: container.resolve(SignOutUseCase.self),
2527
sessionUseCase: container.resolve(AuthSessionUseCase.self))
2628
)
2729
.onAppear {
28-
if viewModel.state.isFirstLaunch {
29-
viewModel.send(.setFirstLaunch(false))
30-
viewModel.send(.signOutAuto)
31-
}
30+
viewModel.send(.onAppear)
3231
}
3332
}
3433
} else {

DevLog/Data/Repository/PushNotificationRepositoryImpl.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ final class PushNotificationRepositoryImpl: PushNotificationRepository {
5151
.eraseToAnyPublisher()
5252
}
5353

54+
func observeUnreadPushCount() throws -> AnyPublisher<Int, Error> {
55+
try service.observeUnreadPushCount()
56+
.eraseToAnyPublisher()
57+
}
58+
5459
// 푸시 알림 기록 삭제
5560
func deleteNotification(_ notificationID: String) async throws {
5661
try await service.deleteNotification(notificationID)

DevLog/Domain/Protocol/PushNotificationRepository.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ protocol PushNotificationRepository {
2020
_ query: PushNotificationQuery,
2121
limit: Int
2222
) throws -> AnyPublisher<PushNotificationPage, Error>
23+
func observeUnreadPushCount() throws -> AnyPublisher<Int, Error>
2324
func deleteNotification(_ notificationID: String) async throws
2425
func undoDeleteNotification(_ notificationID: String) async throws
2526
func toggleNotificationRead(_ todoId: String) async throws
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// ObserveUnreadPushCountUseCase.swift
3+
// DevLog
4+
//
5+
// Created by opfic on 3/17/26.
6+
//
7+
8+
import Combine
9+
10+
protocol ObserveUnreadPushCountUseCase {
11+
func execute() throws -> AnyPublisher<Int, Error>
12+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//
2+
// ObserveUnreadPushCountUseCaseImpl.swift
3+
// DevLog
4+
//
5+
// Created by opfic on 3/17/26.
6+
//
7+
8+
import Combine
9+
10+
final class ObserveUnreadPushCountUseCaseImpl: ObserveUnreadPushCountUseCase {
11+
private let repository: PushNotificationRepository
12+
13+
init(_ repository: PushNotificationRepository) {
14+
self.repository = repository
15+
}
16+
17+
func execute() throws -> AnyPublisher<Int, Error> {
18+
try repository.observeUnreadPushCount()
19+
.removeDuplicates()
20+
.eraseToAnyPublisher()
21+
}
22+
}

DevLog/Infra/Service/PushNotificationService.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,30 @@ final class PushNotificationService {
178178
.eraseToAnyPublisher()
179179
}
180180

181+
func observeUnreadPushCount() throws -> AnyPublisher<Int, Error> {
182+
guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated }
183+
184+
let subject = PassthroughSubject<Int, Error>()
185+
let listener = store.collection("users/\(uid)/notifications")
186+
.whereField("isRead", isEqualTo: false)
187+
.addSnapshotListener { snapshot, error in
188+
if let error {
189+
subject.send(completion: .failure(error))
190+
return
191+
}
192+
193+
guard let snapshot else { return }
194+
let unreadPushCount = snapshot.documents.filter { document in
195+
!(document.data()[Key.deletingAt.rawValue] is Timestamp)
196+
}.count
197+
subject.send(unreadPushCount)
198+
}
199+
200+
return subject
201+
.handleEvents(receiveCancel: { listener.remove() })
202+
.eraseToAnyPublisher()
203+
}
204+
181205
/// 푸시 알림 기록 삭제
182206
func deleteNotification(_ notificationID: String) async throws {
183207
do {

0 commit comments

Comments
 (0)