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
33 changes: 24 additions & 9 deletions StockMate/StockMate/app/feature/auth/ui/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct HomeView: View {
@StateObject private var inventoryViewModel = InventoryViewModel()
// @EnvironmentObject var dashboardViewModel: DashboardViewModel //preview 용
@StateObject private var dashboardViewModel = DashboardViewModel()
@StateObject private var notificationViewModel = NotificationViewModel() // 🔴 추가

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

NotificationViewModel 인스턴스 관리를 확인하세요.

HomeView에서 NotificationViewModel을 생성하고 NotificationListViewenvironmentObject를 통해 전달하고 있지만, NotificationListView.swift의 Line 10에서 자체적으로 @StateObject로 새로운 인스턴스를 생성하고 있습니다. 이로 인해 두 개의 독립적인 ViewModel 인스턴스가 존재하게 되어, HomeView의 unread count와 NotificationListView의 데이터가 동기화되지 않습니다.

다음 스크립트를 실행하여 NotificationViewModel 사용 패턴을 확인하세요:


🏁 Script executed:

#!/bin/bash
# NotificationViewModel이 어떻게 사용되는지 확인
rg -n "NotificationViewModel" --type swift -C 3

Length of output: 2869


HomeView에서 notificationViewModel을 어떻게 사용하는지 그리고 NotificationListView가 환경 객체를 받는지 확인하겠습니다.

이제 HomeView에서 실제로 notificationViewModel을 environmentObject로 전달하는지, 그리고 NotificationListView가 어떻게 선언되어 있는지 확인하겠습니다.


🏁 Script executed:

#!/bin/bash
# HomeView에서 notificationViewModel 사용 패턴 확인 (line 47-66 근처)
sed -n '45,70p' StockMate/StockMate/app/feature/auth/ui/HomeView.swift

Length of output: 1370


🏁 Script executed:

#!/bin/bash
# NotificationListView 선언 부분 확인 (line 8-12 근처)
sed -n '7,15p' StockMate/StockMate/app/feature/notification/ui/NotificationListView.swift

Length of output: 236


NotificationListView에서 @EnvironmentObject를 사용하여 HomeView의 ViewModel 인스턴스를 받으세요.

HomeView에서 notificationViewModel.environmentObject(notificationViewModel)로 전달하고 있으나, NotificationListView의 Line 10에서 @StateObject로 새로운 인스턴스를 생성하고 있습니다. SwiftUI에서는 @StateObject@EnvironmentObject보다 우선순위가 높으므로, NotificationListView는 전달받은 환경 객체를 무시하고 자신의 독립적인 인스턴스만 사용합니다. 이로 인해 두 뷰의 데이터가 동기화되지 않습니다.

필수 수정:

NotificationListView.swift, Line 10:
@StateObject private var viewModel = NotificationViewModel()
↓
@EnvironmentObject var notificationViewModel: NotificationViewModel
🤖 Prompt for AI Agents
In StockMate/StockMate/app/feature/auth/ui/HomeView.swift around line 17 and
NotificationListView.swift at line 10: HomeView currently creates and injects
notificationViewModel with .environmentObject(notificationViewModel) but
NotificationListView is creating its own @StateObject, which overrides the
environment object and prevents shared state; replace the @StateObject in
NotificationListView with an @EnvironmentObject property to receive the injected
instance (update the property declaration and any internal references to use
that environment object), and ensure HomeView continues to call
.environmentObject(notificationViewModel) when presenting NotificationListView
so both views share the same NotificationViewModel instance.


@State private var selectedMonth: String? = nil // ✅ 추가

Expand All @@ -39,11 +40,29 @@ struct HomeView: View {
}

Spacer()

Image("notification")
.font(.system(size: 20))
.foregroundColor(.gray)

NavigationLink(destination: NotificationListView()) { // 🔴 전달
ZStack(alignment: .topTrailing) {
Image("notification")
.resizable()
.scaledToFit()
.frame(width: 30, height: 30)
.foregroundColor(.gray)

if notificationViewModel.unreadCount > 0 {
Text("\(notificationViewModel.unreadCount)")
.font(.system(size: 10, weight: .bold))
.foregroundColor(.white)
.padding(5)
.background(Color.red)
.clipShape(Circle())
.offset(x: 5, y: -5)
}
}
}
.padding(.trailing, 5)


}
.padding(.horizontal)

Expand Down Expand Up @@ -146,6 +165,7 @@ struct HomeView: View {
await inventoryViewModel.loadLackCountByCategory()
await dashboardViewModel.fetchMonthlySpending() // ✅ 추가
await dashboardViewModel.fetchCategorySpending() // ✅ 추가
await notificationViewModel.fetchUnreadCount() // 🔴 추가
}
.onAppear {
Task { await userViewModel.loadUserInfo() }
Expand Down Expand Up @@ -260,11 +280,6 @@ struct StatusItem: View {
}



//#Preview {
// HomeView()
//}

#Preview {
let dashboardVM = DashboardViewModel()
dashboardVM.categorySpendings = [
Expand Down
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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

formattedDate 함수가 정의되지 않았습니다.

formattedDate(item.createdAt) 함수를 호출하고 있지만, 이 함수가 현재 파일에서 정의되지 않았습니다. 이는 컴파일 에러를 발생시킬 수 있습니다.

다음을 확인하세요:

  1. formattedDate 함수가 extension이나 다른 파일에 정의되어 있는지 확인
  2. 정의되지 않았다면, 날짜 포맷팅 함수를 추가해야 합니다

다음 스크립트로 함수 정의를 찾을 수 있습니다:

만약 함수가 정의되지 않았다면, 다음과 같이 추가하세요:

// 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=swift

Length of output: 359


🏁 Script executed:

#!/bin/bash
# NotificationListView.swift 파일에서 formattedDate 관련 내용 검색
rg -n -C5 'formattedDate' StockMate/StockMate/app/feature/notification/ui/NotificationListView.swift

Length 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=swift

Length of output: 16


🏁 Script executed:

#!/bin/bash
# ReceiptView.swift의 formattedDate 함수 스코프 확인
rg -n -B2 'func formattedDate\(' StockMate/StockMate/app/feature/orders/ui/ReceiptView.swift --type=swift

Length of output: 189


🏁 Script executed:

#!/bin/bash
# ReceiptView.swift의 formattedDate 함수 정의 주변 상세 확인
rg -n -A10 'func formattedDate\(' StockMate/StockMate/app/feature/orders/ui/ReceiptView.swift --type=swift

Length of output: 940


🏁 Script executed:

#!/bin/bash
# NotificationListView.swift 전체 파일 구조 확인
cat StockMate/StockMate/app/feature/notification/ui/NotificationListView.swift

Length of output: 2968


formattedDate 함수가 NotificationListView.swift에 정의되지 않았습니다. 컴파일 에러가 발생합니다.

line 70에서 formattedDate(item.createdAt) 함수를 호출하고 있지만, 이 함수가 현재 파일에 정의되지 않았습니다.

다음과 같이 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
In StockMate/StockMate/app/feature/notification/ui/NotificationListView.swift
around line 70, the call to formattedDate(item.createdAt) fails because
formattedDate is not defined; add a private helper function outside the
NotificationCardView struct named formattedDate(_ date: String) -> String that
uses ISO8601DateFormatter to parse the input string, a DateFormatter with format
"yyyy.MM.dd HH:mm" and locale "ko_KR" to format the parsed date, returns the
formatted string when parsing succeeds and returns the original input string on
failure; place this function below the NotificationCardView declaration so the
view can call it.

.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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
StockMate/StockMate/app/feature/notification/viewmodel/NotificationViewModel.swift
lines 44-62: the ViewModel manually re-creates NotificationItem by copying every
field which is brittle; add a convenience instance method (e.g.,
markingAsRead()) on NotificationItem (place in NotificationApi.swift or the
model file) that returns a copy with read set to true, then replace the manual
construction with notifications[index] = notifications[index].markingAsRead();
keep the rest of the logic (index lookup and unreadCount decrement) unchanged.


// 전체 읽음 처리
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

markAsRead와 동일한 리팩토링을 적용하세요.

Lines 70-77에서도 NotificationItem을 수동으로 재생성하는 동일한 패턴이 반복됩니다. 앞서 제안한 markingAsRead() 메서드를 사용하면 이 메서드도 간결하게 개선할 수 있습니다.

 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)
     }
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
StockMate/StockMate/app/feature/notification/viewmodel/NotificationViewModel.swift
around lines 65 to 83, the method markAllAsRead repeats the manual
NotificationItem reconstruction loop (lines 70–77); replace that loop with the
previously introduced markingAsRead() refactor by mapping each notification to
its markingAsRead() variant and assign the resulting array back to
notifications, then set unreadCount = 0 on success; keep the existing repository
call and failure handling unchanged.

}
3 changes: 2 additions & 1 deletion StockMate/StockMate/app/feature/user/ui/ProfileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ struct ProfileView: View {
VStack(alignment: .leading, spacing: 12) {
VStack(spacing: 10) {
SettingNavigationRow(icon: "user", title: "프로필 확인", destination: UserProfileView())
SettingRow(icon: "notification", title: "알림")
SettingNavigationRow(icon: "notification", title: "알림", destination: NotificationListView())
// SettingRow(icon: "notification", title: "알림")
SettingNavigationRow(icon: "receipt", title: "예치금 히스토리", destination: TransactionTypeListView())
// SettingNavigationRow(icon: "receipt", title: "예치금 히스토리", destination: PaymentTransactionView())
SettingNavigationRow(icon: "bag", title: "주문 내역", destination: OrderListView())
Expand Down
5 changes: 3 additions & 2 deletions StockMate/StockMate/app/navigation/MainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ struct MainTabView: View {
// 메인 화면
ZStack {
switch selectedTab {
case 0: NavigationStack{ HomeView() }
case 0: HomeView()
case 1: NavigationStack{ OrderView(cartViewModel: cartVM) } //, inventoryViewModel: inventoryVM) }
// case 1: NavigationStack{ OrderView() }
case 2:
Expand All @@ -30,7 +30,8 @@ struct MainTabView: View {
// NavigationStack { InventoryView(selectedTab: $selectedTab, tabTappedTrigger: $tabTappedTrigger) }
// case 3: NavigationStack{ ContentView() }
// case 3: NavigationStack{ ReceiptView() }
case 3: NavigationStack{ ProfileView() }
// case 3: NavigationStack{ NotificationListView() }
case 3: ProfileView()
default: NavigationStack{ ContentView() }
}
}
Expand Down
Loading