-
Notifications
You must be signed in to change notification settings - Fork 1
[FEAT] 알림 기능 구현 #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[FEAT] 알림 기능 구현 #16
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| // | ||
| // NotificationApi.swift | ||
| // StockMate | ||
| // | ||
| // Created by Admin on 11/7/25. | ||
| // | ||
|
|
||
| import Foundation | ||
| import Alamofire | ||
|
|
||
| struct NotificationItem: Decodable, Identifiable { | ||
| let id: Int | ||
| let orderId: Int | ||
| let orderNumber: String | ||
| let message: String | ||
| let createdAt: String | ||
| let read: Bool | ||
| } | ||
|
|
||
| enum NotificationApi { | ||
|
|
||
| // 1️⃣ 알림 전체 조회 | ||
| static func getAllNotifications() -> DataRequest { | ||
| let url = ApiClient.baseURL + "api/v1/order/store/notifications/" | ||
| return ApiClient.shared.request(url, method: .get) | ||
| } | ||
|
|
||
| // 2️⃣ 읽지 않은 알림 개수 조회 | ||
| static func getUnreadCount() -> DataRequest { | ||
| let url = ApiClient.baseURL + "api/v1/order/store/notifications/unread/count" | ||
| return ApiClient.shared.request(url, method: .get) | ||
| } | ||
|
|
||
| // 3️⃣ 읽지 않은 알림 조회 | ||
| static func getUnreadNotifications() -> DataRequest { | ||
| let url = ApiClient.baseURL + "api/v1/order/store/notifications/unread" | ||
| return ApiClient.shared.request(url, method: .get) | ||
| } | ||
|
|
||
| // 4️⃣ 전체 알림 읽음 처리 | ||
| static func markAllAsRead() -> DataRequest { | ||
| let url = ApiClient.baseURL + "api/v1/order/store/notifications/read-all" | ||
| return ApiClient.shared.request(url, method: .patch) | ||
| } | ||
|
|
||
| // 5️⃣ 개별 알림 읽음 처리 | ||
| static func markAsRead(notificationId: Int) -> DataRequest { | ||
| let url = ApiClient.baseURL + "api/v1/order/store/notifications/read?notificationId=\(notificationId)" | ||
| return ApiClient.shared.request(url, method: .patch) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| // | ||
| // NotificationRepositoryImpl.swift | ||
| // StockMate | ||
| // | ||
| // Created by Admin on 11/7/25. | ||
| // | ||
|
|
||
| import SwiftUI | ||
| import Alamofire | ||
|
|
||
| final class NotificationRepositoryImpl: NotificationRepositoryProtocol { | ||
|
|
||
| func getAllNotifications() async -> AppResult<ApiResponse<[NotificationItem]>> { | ||
| let request = NotificationApi.getAllNotifications() | ||
| return await safeApi(request, decodeTo: ApiResponse<[NotificationItem]>.self) | ||
| } | ||
|
|
||
| func getUnreadCount() async -> AppResult<ApiResponse<Int>> { | ||
| let request = NotificationApi.getUnreadCount() | ||
| return await safeApi(request, decodeTo: ApiResponse<Int>.self) | ||
| } | ||
|
|
||
| func getUnreadNotifications() async -> AppResult<ApiResponse<[NotificationItem]>> { | ||
| let request = NotificationApi.getUnreadNotifications() | ||
| return await safeApi(request, decodeTo: ApiResponse<[NotificationItem]>.self) | ||
| } | ||
|
|
||
| func markAllAsRead() async -> AppResult<ApiResponse<String>> { | ||
| let request = NotificationApi.markAllAsRead() | ||
| return await safeApi(request, decodeTo: ApiResponse<String>.self) | ||
| } | ||
|
|
||
| func markAsRead(notificationId: Int) async -> AppResult<ApiResponse<String>> { | ||
| let request = NotificationApi.markAsRead(notificationId: notificationId) | ||
| return await safeApi(request, decodeTo: ApiResponse<String>.self) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| // | ||
| // NotificationRepositoryProtocol.swift | ||
| // StockMate | ||
| // | ||
| // Created by Admin on 11/7/25. | ||
| // | ||
|
|
||
| import SwiftUI | ||
| import Alamofire | ||
|
|
||
|
|
||
| protocol NotificationRepositoryProtocol { | ||
| // 1️⃣ 전체 알림 조회 | ||
| func getAllNotifications() async -> AppResult<ApiResponse<[NotificationItem]>> | ||
|
|
||
| // 2️⃣ 읽지 않은 알림 개수 조회 | ||
| func getUnreadCount() async -> AppResult<ApiResponse<Int>> | ||
|
|
||
| // 3️⃣ 읽지 않은 알림 조회 | ||
| func getUnreadNotifications() async -> AppResult<ApiResponse<[NotificationItem]>> | ||
|
|
||
| // 4️⃣ 전체 알림 읽음 처리 | ||
| func markAllAsRead() async -> AppResult<ApiResponse<String>> | ||
|
|
||
| // 5️⃣ 개별 알림 읽음 처리 | ||
| func markAsRead(notificationId: Int) async -> AppResult<ApiResponse<String>> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| // | ||
| // NotificationListView.swift | ||
| // StockMate | ||
| // | ||
| // Created by Admin on 11/7/25. | ||
| // | ||
| import SwiftUI | ||
|
|
||
| struct NotificationListView: View { | ||
| @StateObject private var notificationViewModel = NotificationViewModel() | ||
| @State private var selectedOrderId: Int? = nil | ||
|
|
||
| var body: some View { | ||
| VStack { | ||
| ScrollView { | ||
| LazyVStack(spacing: 12) { | ||
| ForEach(notificationViewModel.notifications) { notification in | ||
| NotificationCardView(item: notification) { | ||
| Task { | ||
| await notificationViewModel.markAsRead(notification.id) | ||
| selectedOrderId = notification.orderId | ||
| } | ||
| } | ||
| } | ||
| } | ||
| .padding() | ||
| } | ||
| } | ||
| .background(Color.Light) | ||
| .navigationTitle("알림") | ||
| .navigationBarTitleDisplayMode(.inline) | ||
| .toolbar { | ||
| ToolbarItem(placement: .navigationBarTrailing) { | ||
| Button("전체 읽음") { | ||
| Task { await notificationViewModel.markAllAsRead() } | ||
| } | ||
| .foregroundColor(.red) | ||
| .font(.subheadline) | ||
| } | ||
| } | ||
| .navigationDestination(item: $selectedOrderId) { orderId in | ||
| OrderDetailView(orderId: orderId, orderViewModel: OrderViewModel()) | ||
| } | ||
| .task { | ||
| await notificationViewModel.fetchNotifications() | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
| struct NotificationCardView: View { | ||
| let item: NotificationItem | ||
| let onTap: () -> Void | ||
|
|
||
| var body: some View { | ||
| Button(action: onTap) { | ||
| HStack(alignment: .center, spacing: 14) { | ||
| Image("notiImage") | ||
| .resizable() | ||
| .scaledToFit() | ||
| .frame(width: 38, height: 38) | ||
|
|
||
| VStack(alignment: .leading, spacing: 6) { | ||
| Text(item.message) | ||
| .font(.system(size: 15, weight: .semibold)) | ||
| .foregroundColor(.primary) | ||
| Text(item.orderNumber) | ||
| .font(.system(size: 13, weight: .regular)) | ||
| .foregroundColor(.gray) | ||
| Text(formattedDate(item.createdAt)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain
다음을 확인하세요:
다음 스크립트로 함수 정의를 찾을 수 있습니다: 만약 함수가 정의되지 않았다면, 다음과 같이 추가하세요: // NotificationCardView 외부에 추가
private func formattedDate(_ date: String) -> String {
let inputFormatter = ISO8601DateFormatter()
let outputFormatter = DateFormatter()
outputFormatter.dateFormat = "yyyy.MM.dd HH:mm"
outputFormatter.locale = Locale(identifier: "ko_KR")
if let date = inputFormatter.date(from: date) {
return outputFormatter.string(from: date)
}
return date
}🏁 Script executed: #!/bin/bash
# formattedDate 함수 정의 검색
rg -n 'func formattedDate' --type=swiftLength of output: 359 🏁 Script executed: #!/bin/bash
# NotificationListView.swift 파일에서 formattedDate 관련 내용 검색
rg -n -C5 'formattedDate' StockMate/StockMate/app/feature/notification/ui/NotificationListView.swiftLength of output: 513 🏁 Script executed: #!/bin/bash
# NotificationListView.swift 파일에서 import 문 및 함수 정의 확인
rg -n '^(import|func formattedDate|private func|extension)' StockMate/StockMate/app/feature/notification/ui/NotificationListView.swift --type=swiftLength of output: 16 🏁 Script executed: #!/bin/bash
# ReceiptView.swift의 formattedDate 함수 스코프 확인
rg -n -B2 'func formattedDate\(' StockMate/StockMate/app/feature/orders/ui/ReceiptView.swift --type=swiftLength of output: 189 🏁 Script executed: #!/bin/bash
# ReceiptView.swift의 formattedDate 함수 정의 주변 상세 확인
rg -n -A10 'func formattedDate\(' StockMate/StockMate/app/feature/orders/ui/ReceiptView.swift --type=swiftLength of output: 940 🏁 Script executed: #!/bin/bash
# NotificationListView.swift 전체 파일 구조 확인
cat StockMate/StockMate/app/feature/notification/ui/NotificationListView.swiftLength of output: 2968
line 70에서 다음과 같이 NotificationCardView 구조체 외부에 함수를 추가해야 합니다: private func formattedDate(_ date: String) -> String {
let inputFormatter = ISO8601DateFormatter()
let outputFormatter = DateFormatter()
outputFormatter.dateFormat = "yyyy.MM.dd HH:mm"
outputFormatter.locale = Locale(identifier: "ko_KR")
if let date = inputFormatter.date(from: date) {
return outputFormatter.string(from: date)
}
return date
}🤖 Prompt for AI Agents |
||
| .font(.caption) | ||
| .foregroundColor(.gray) | ||
| } | ||
| Spacer() | ||
|
|
||
| // 🔴 안 읽은 알림 표시 점 | ||
| if !item.read { | ||
| Circle() | ||
| .fill(Color.red) | ||
| .frame(width: 7, height: 7) | ||
| .padding(.trailing) | ||
| } | ||
|
|
||
| } | ||
| .padding() | ||
| .background(Color.white) | ||
| .cornerRadius(12) | ||
| .shadow(color: Color.black.opacity(0.05), radius: 3, x: 0, y: 1) | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| // | ||
| // NotificationViewModel.swift | ||
| // StockMate | ||
| // | ||
| // Created by Admin on 11/7/25. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| @MainActor | ||
| final class NotificationViewModel: ObservableObject { | ||
| @Published var notifications: [NotificationItem] = [] | ||
| @Published var unreadCount: Int = 0 // 🔴 추가 | ||
| @Published var isLoading = false | ||
|
|
||
| private let repository: NotificationRepositoryProtocol = NotificationRepositoryImpl() | ||
|
|
||
| // 전체 알림 조회 | ||
| func fetchNotifications() async { | ||
| isLoading = true | ||
| defer { isLoading = false } | ||
|
|
||
| let result = await repository.getAllNotifications() | ||
| switch result { | ||
| case .success(let response): | ||
| notifications = (response.data ?? []).sorted { $0.createdAt > $1.createdAt } | ||
| case .failure(let error): | ||
| print("❌ 알림 조회 실패:", error.localizedDescription) | ||
| } | ||
| } | ||
|
|
||
| // 🔴 읽지 않은 개수 조회 | ||
| func fetchUnreadCount() async { | ||
| let result = await repository.getUnreadCount() | ||
| switch result { | ||
| case .success(let response): | ||
| unreadCount = response.data ?? 0 | ||
| case .failure(let error): | ||
| print("❌ 읽지 않은 개수 조회 실패:", error.localizedDescription) | ||
| } | ||
| } | ||
|
|
||
| // 개별 알림 읽음 처리 | ||
| func markAsRead(_ id: Int) async { | ||
| let result = await repository.markAsRead(notificationId: id) | ||
| switch result { | ||
| case .success: | ||
| if let index = notifications.firstIndex(where: { $0.id == id }) { | ||
| notifications[index] = NotificationItem( | ||
| id: notifications[index].id, | ||
| orderId: notifications[index].orderId, | ||
| orderNumber: notifications[index].orderNumber, | ||
| message: notifications[index].message, | ||
| createdAt: notifications[index].createdAt, | ||
| read: true | ||
| ) | ||
| } | ||
| unreadCount = max(0, unreadCount - 1) // 🔴 카운트 즉시 반영 | ||
| case .failure(let error): | ||
| print("❌ 알림 읽음 처리 실패:", error.localizedDescription) | ||
| } | ||
| } | ||
|
Comment on lines
+44
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major NotificationItem 업데이트 로직을 개선하세요. Lines 49-56에서 NotificationItem의 모든 필드를 수동으로 복사하여 새 인스턴스를 생성하고 있습니다. 이 패턴은 NotificationItem에 필드가 추가되거나 변경될 때 유지보수가 어렵고 실수하기 쉽습니다. NotificationItem에 업데이트 메서드를 추가하는 것을 권장합니다: NotificationApi.swift에 다음을 추가: extension NotificationItem {
func markingAsRead() -> NotificationItem {
return NotificationItem(
id: id,
orderId: orderId,
orderNumber: orderNumber,
message: message,
createdAt: createdAt,
read: true
)
}
}그런 다음 ViewModel에서 다음과 같이 사용: func markAsRead(_ id: Int) async {
let result = await repository.markAsRead(notificationId: id)
switch result {
case .success:
if let index = notifications.firstIndex(where: { $0.id == id }) {
- notifications[index] = NotificationItem(
- id: notifications[index].id,
- orderId: notifications[index].orderId,
- orderNumber: notifications[index].orderNumber,
- message: notifications[index].message,
- createdAt: notifications[index].createdAt,
- read: true
- )
+ notifications[index] = notifications[index].markingAsRead()
}
unreadCount = max(0, unreadCount - 1)
case .failure(let error):
print("❌ 알림 읽음 처리 실패:", error.localizedDescription)
}
}🤖 Prompt for AI Agents |
||
|
|
||
| // 전체 읽음 처리 | ||
| func markAllAsRead() async { | ||
| let result = await repository.markAllAsRead() | ||
| switch result { | ||
| case .success: | ||
| for i in 0..<notifications.count { | ||
| notifications[i] = NotificationItem( | ||
| id: notifications[i].id, | ||
| orderId: notifications[i].orderId, | ||
| orderNumber: notifications[i].orderNumber, | ||
| message: notifications[i].message, | ||
| createdAt: notifications[i].createdAt, | ||
| read: true | ||
| ) | ||
| } | ||
| unreadCount = 0 // 🔴 전체 읽음 시 0으로 초기화 | ||
| case .failure(let error): | ||
| print("❌ 전체 읽음 실패:", error.localizedDescription) | ||
| } | ||
| } | ||
|
Comment on lines
+65
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major markAsRead와 동일한 리팩토링을 적용하세요. Lines 70-77에서도 NotificationItem을 수동으로 재생성하는 동일한 패턴이 반복됩니다. 앞서 제안한 func markAllAsRead() async {
let result = await repository.markAllAsRead()
switch result {
case .success:
- for i in 0..<notifications.count {
- notifications[i] = NotificationItem(
- id: notifications[i].id,
- orderId: notifications[i].orderId,
- orderNumber: notifications[i].orderNumber,
- message: notifications[i].message,
- createdAt: notifications[i].createdAt,
- read: true
- )
- }
+ notifications = notifications.map { $0.markingAsRead() }
unreadCount = 0
case .failure(let error):
print("❌ 전체 읽음 실패:", error.localizedDescription)
}
}
🤖 Prompt for AI Agents |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
NotificationViewModel 인스턴스 관리를 확인하세요.
HomeView에서
NotificationViewModel을 생성하고NotificationListView로environmentObject를 통해 전달하고 있지만,NotificationListView.swift의 Line 10에서 자체적으로@StateObject로 새로운 인스턴스를 생성하고 있습니다. 이로 인해 두 개의 독립적인 ViewModel 인스턴스가 존재하게 되어, HomeView의 unread count와 NotificationListView의 데이터가 동기화되지 않습니다.다음 스크립트를 실행하여 NotificationViewModel 사용 패턴을 확인하세요:
🏁 Script executed:
Length of output: 2869
HomeView에서
notificationViewModel을 어떻게 사용하는지 그리고 NotificationListView가 환경 객체를 받는지 확인하겠습니다.이제 HomeView에서 실제로 notificationViewModel을 environmentObject로 전달하는지, 그리고 NotificationListView가 어떻게 선언되어 있는지 확인하겠습니다.
🏁 Script executed:
Length of output: 1370
🏁 Script executed:
Length of output: 236
NotificationListView에서 @EnvironmentObject를 사용하여 HomeView의 ViewModel 인스턴스를 받으세요.
HomeView에서
notificationViewModel을.environmentObject(notificationViewModel)로 전달하고 있으나, NotificationListView의 Line 10에서@StateObject로 새로운 인스턴스를 생성하고 있습니다. SwiftUI에서는@StateObject이@EnvironmentObject보다 우선순위가 높으므로, NotificationListView는 전달받은 환경 객체를 무시하고 자신의 독립적인 인스턴스만 사용합니다. 이로 인해 두 뷰의 데이터가 동기화되지 않습니다.필수 수정:
🤖 Prompt for AI Agents