From 43c051f81a26da7dc27ff365c4a6f636835703e5 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 7 Jul 2026 15:06:44 +0900 Subject: [PATCH 1/9] =?UTF-8?q?setting:=20AppGroup=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20DeploymentTarget=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Koin/koin.entitlements | 4 ++++ NotificationService/NotificationService.entitlements | 10 ++++++++++ koin.xcodeproj/project.pbxproj | 8 ++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 NotificationService/NotificationService.entitlements diff --git a/Koin/koin.entitlements b/Koin/koin.entitlements index a0227657f..e82ad8ae8 100644 --- a/Koin/koin.entitlements +++ b/Koin/koin.entitlements @@ -9,5 +9,9 @@ applinks:stage.koreatech.in applinks:koreatech.in + com.apple.security.application-groups + + group.com.bcsdlab.koin + diff --git a/NotificationService/NotificationService.entitlements b/NotificationService/NotificationService.entitlements new file mode 100644 index 000000000..fa470530d --- /dev/null +++ b/NotificationService/NotificationService.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.bcsdlab.koin + + + diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 3b3251271..ae20557b3 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -1281,6 +1281,7 @@ 7CA63A2D2F1B119200226F86 /* LostItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemListView.swift; sourceTree = ""; }; 7CA63A2F2F1B13F100226F86 /* LostItemStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemStats.swift; sourceTree = ""; }; 7CA63A322F1B1B0C00226F86 /* LostItemListFilterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemListFilterViewController.swift; sourceTree = ""; }; + 7CA686672FFC1FAF007937E9 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; 7CB061BE2F32312E00E83CA9 /* DPDConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPDConstants.swift; sourceTree = ""; }; 7CB061BF2F32312E00E83CA9 /* DPDKeyboardListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPDKeyboardListener.swift; sourceTree = ""; }; 7CB061C02F32312E00E83CA9 /* DPDUIView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DPDUIView+Extension.swift"; sourceTree = ""; }; @@ -4355,6 +4356,7 @@ 839D71172BEB1570001BC7F7 /* NotificationService */ = { isa = PBXGroup; children = ( + 7CA686672FFC1FAF007937E9 /* NotificationService.entitlements */, 839D71182BEB1570001BC7F7 /* NotificationService.swift */, 839D711A2BEB1570001BC7F7 /* Info.plist */, ); @@ -6502,6 +6504,7 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 317; DEVELOPMENT_TEAM = K626UYGR25; @@ -6511,7 +6514,7 @@ INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6534,6 +6537,7 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 317; DEVELOPMENT_TEAM = K626UYGR25; @@ -6543,7 +6547,7 @@ INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", From 56c6fde48f415f75321d0c84a6787f922c7b3f09 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 7 Jul 2026 15:07:25 +0900 Subject: [PATCH 2/9] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20Model=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Model/NewHome/NotificationIconType.swift | 15 --------------- koin.xcodeproj/project.pbxproj | 4 ---- 2 files changed, 19 deletions(-) delete mode 100644 Koin/Domain/Model/NewHome/NotificationIconType.swift diff --git a/Koin/Domain/Model/NewHome/NotificationIconType.swift b/Koin/Domain/Model/NewHome/NotificationIconType.swift deleted file mode 100644 index 9a584601f..000000000 --- a/Koin/Domain/Model/NewHome/NotificationIconType.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// NewHomeCounts.swift -// koin -// -// Created by 홍기정 on 6/3/26. -// - -import Foundation - -enum NotificationIconType { - case breakfast - case board - case callvanpot - case shuttleTicket -} diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index ae20557b3..68e5f26c2 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -234,7 +234,6 @@ 7C8BFCD32FD0272900963679 /* View+border.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCD22FD0272900963679 /* View+border.swift */; }; 7C8BFCD62FD032A000963679 /* View+linespacing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCD52FD032A000963679 /* View+linespacing.swift */; }; 7C8BFCE12FD04B8000963679 /* HomeDiningItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCDD2FD04B8000963679 /* HomeDiningItem.swift */; }; - 7C8BFCE22FD04B8000963679 /* NotificationIconType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCDF2FD04B8000963679 /* NotificationIconType.swift */; }; 7C8BFCE32FD04B8000963679 /* HomeHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCDE2FD04B8000963679 /* HomeHeader.swift */; }; 7C8BFCE42FD04B8000963679 /* NotificationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCE02FD04B8000963679 /* NotificationItem.swift */; }; 7C8BFCE52FD04B8000963679 /* CategoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCDC2FD04B8000963679 /* CategoryModels.swift */; }; @@ -1238,7 +1237,6 @@ 7C8BFCDC2FD04B8000963679 /* CategoryModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryModels.swift; sourceTree = ""; }; 7C8BFCDD2FD04B8000963679 /* HomeDiningItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeDiningItem.swift; sourceTree = ""; }; 7C8BFCDE2FD04B8000963679 /* HomeHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeHeader.swift; sourceTree = ""; }; - 7C8BFCDF2FD04B8000963679 /* NotificationIconType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationIconType.swift; sourceTree = ""; }; 7C8BFCE02FD04B8000963679 /* NotificationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItem.swift; sourceTree = ""; }; 7C8BFCE62FD04BA000963679 /* FetchHomeDiningListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchHomeDiningListUseCase.swift; sourceTree = ""; }; 7C8BFCE72FD04BA000963679 /* FetchHomeHeaderUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchHomeHeaderUseCase.swift; sourceTree = ""; }; @@ -2586,7 +2584,6 @@ 7C8BFCDD2FD04B8000963679 /* HomeDiningItem.swift */, 7C8BFCDE2FD04B8000963679 /* HomeHeader.swift */, 7C8BFCF02FD04B800963679 /* NewHomeCounts.swift */, - 7C8BFCDF2FD04B8000963679 /* NotificationIconType.swift */, 7C8BFCE02FD04B8000963679 /* NotificationItem.swift */, ); path = NewHome; @@ -6299,7 +6296,6 @@ D208A7D32CAD7436007040E7 /* DefaultAbTestRepository.swift in Sources */, 7CC42B362F58283500940CE1 /* CallVanNotificationViewController.swift in Sources */, 7C8BFCE12FD04B8000963679 /* HomeDiningItem.swift in Sources */, - 7C8BFCE22FD04B8000963679 /* NotificationIconType.swift in Sources */, 7C8BFCE32FD04B8000963679 /* HomeHeader.swift in Sources */, 7C8BFCE42FD04B8000963679 /* NotificationItem.swift in Sources */, 7C8BFCE52FD04B8000963679 /* CategoryModels.swift in Sources */, From 74f80b69f3158bd1279e75901ac3107be99397d4 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 7 Jul 2026 15:09:21 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20NotificationService=20Extension?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=91=B8=EC=8B=9C=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=88=98=EC=8B=A0=EC=8B=9C=20SwiftData=EC=97=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationHistoryService.swift | 128 ++++++++++++++++++ .../NotificationRecord.swift | 35 +++++ NotificationService/NotificationService.swift | 62 ++++++--- koin.xcodeproj/project.pbxproj | 28 ++++ 4 files changed, 232 insertions(+), 21 deletions(-) create mode 100644 Common/NotificationHistory/NotificationHistoryService.swift create mode 100644 Common/NotificationHistory/NotificationRecord.swift diff --git a/Common/NotificationHistory/NotificationHistoryService.swift b/Common/NotificationHistory/NotificationHistoryService.swift new file mode 100644 index 000000000..65dc2f00d --- /dev/null +++ b/Common/NotificationHistory/NotificationHistoryService.swift @@ -0,0 +1,128 @@ +// +// NotificationContainer.swift +// koin +// +// Created by 홍기정 on 7/6/26. +// + +import SwiftData +import Foundation + +protocol NotificationHistoryService { + func insert(record: NotificationRecord) async throws + func fetchAll() async throws -> [NotificationRecord] + func markAsRead(messageId: String) async throws + func markAllAsRead() async throws + func delete(messageId: String) async throws + func deleteAll() async throws +} + +final class DefaultNotificationHistoryService: NotificationHistoryService { + + private let container: ModelContainer? + + // MARK: - Initializer + init() { + container = try? ModelContainer( + for: NotificationRecord.self, + configurations: .init(groupContainer: .identifier("group.com.bcsdlab.koin")) + ) + } + + // MARK: - Create + func insert(record: NotificationRecord) async throws { + guard let container else { + throw SwiftDataError.loadIssueModelContainer + } + + switch record.category { + case AppPath.shop.rawValue, + AppPath.dining.rawValue, + AppPath.keyword.rawValue, + AppPath.chat.rawValue, + AppPath.callvan.rawValue, + AppPath.callvanChat.rawValue: + try await MainActor.run { [weak container] in + container?.mainContext.insert(record) + try container?.mainContext.save() + } + default: + throw SwiftDataError.modelValidationFailure + } + } + + // MARK: - Read + func fetchAll() async throws -> [NotificationRecord] { + guard let container else { + throw SwiftDataError.loadIssueModelContainer + } + + return try await MainActor.run { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.createdAt, order: .reverse)] + ) + descriptor.fetchLimit = .max + + return try container.mainContext.fetch(descriptor) + } + } + + // MARK: - Update + func markAsRead(messageId: String) async throws { + guard let container else { + throw SwiftDataError.loadIssueModelContainer + } + + try await MainActor.run { + var descriptor = FetchDescriptor( + predicate: #Predicate { notification in + notification.messageId == messageId + } + ) + descriptor.fetchLimit = 1 + if let notification = try container.mainContext.fetch(descriptor).first { + notification.isRead = true + } + try container.mainContext.save() + } + } + + func markAllAsRead() async throws { + guard let container else { + throw SwiftDataError.loadIssueModelContainer + } + + try await MainActor.run { + var descriptor = FetchDescriptor() + descriptor.fetchLimit = .max + try container.mainContext.enumerate(descriptor) { notification in + notification.isRead = true + } + try container.mainContext.save() + } + } + + // MARK: - Delete + func delete(messageId: String) async throws { + guard let container else { + throw SwiftDataError.loadIssueModelContainer + } + + try await MainActor.run { + try container.mainContext.delete(model: NotificationRecord.self, where: #Predicate { notification in + notification.messageId == messageId + }) + try container.mainContext.save() + } + } + + func deleteAll() async throws { + guard let container else { + throw SwiftDataError.loadIssueModelContainer + } + + try await MainActor.run { + try container.mainContext.delete(model: NotificationRecord.self) + } + } +} diff --git a/Common/NotificationHistory/NotificationRecord.swift b/Common/NotificationHistory/NotificationRecord.swift new file mode 100644 index 000000000..fcd03fe97 --- /dev/null +++ b/Common/NotificationHistory/NotificationRecord.swift @@ -0,0 +1,35 @@ +// +// NotificationRecord.swift +// koin +// +// Created by 홍기정 on 7/6/26. +// + +import SwiftData +import Foundation + +@Model +final class NotificationRecord: Sendable { + var body: String + var title: String + var category: String + var schemeUri: String + var messageId: String + + var isRead: Bool = false + var createdAt: Date = Date() + + init( + body: String, + title: String, + category: AppPath, + schemeUri: String, + messageId: String + ) { + self.body = body + self.title = title + self.category = category.rawValue + self.schemeUri = schemeUri + self.messageId = messageId + } +} diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 5ae8e133a..1421c49df 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -21,30 +21,21 @@ class NotificationService: UNNotificationServiceExtension { return } - - // ✅payload에 따라서 키 값이 달라진다. + + // MARK: - SwiftData에 Notification 데이터를 기록한다. + saveNotificationIfAvailable(userInfo: bestAttemptContent.userInfo) + + // MARK: - 푸시알림이 이미지를 포함하는 경우 처리 if let fcmOptionsUserInfo = bestAttemptContent.userInfo["fcm_options"] as? [String: Any], let imageURLString = fcmOptionsUserInfo["image"] as? String, - let imageURL = URL(string: imageURLString) { - - // 이미지 다운로드 - if let imageData = try? Data(contentsOf: imageURL) { - // UNNotificationAttachment 설정 - if let attachment = UNNotificationAttachment.saveImageToDisk(identifier: "certificationImage.jpg", data: imageData, options: nil) { - bestAttemptContent.attachments = [attachment] - contentHandler(bestAttemptContent) - } else { - contentHandler(bestAttemptContent) - } - } else { - contentHandler(bestAttemptContent) - } - } else { - contentHandler(bestAttemptContent) + let imageURL = URL(string: imageURLString), + let imageData = try? Data(contentsOf: imageURL), + let attachment = UNNotificationAttachment.saveImageToDisk(identifier: "certificationImage.jpg", data: imageData, options: nil) { + bestAttemptContent.attachments = [attachment] } + + contentHandler(bestAttemptContent) } - - override func serviceExtensionTimeWillExpire() { // Called just before the extension will be terminated by the system. @@ -53,8 +44,37 @@ class NotificationService: UNNotificationServiceExtension { contentHandler(bestAttemptContent) } } - +} +extension NotificationService { + private func saveNotificationIfAvailable(userInfo: [AnyHashable: Any]) { + guard let aps = userInfo["aps"] as? [String: Any], + let alert = aps["alert"] as? [String: Any], + let body = alert["body"] as? String, + let title = alert["title"] as? String, + let category = aps["category"] as? String, + let appPath = AppPath(rawValue: category), + let schemeUri = userInfo["schemeUri"] as? String, + let messageId = userInfo["gcm.message_id"] as? String else { + print("found nil parsing userInfo") + return + } + + let notificationRecord = NotificationRecord( + body: body, + title: title, + category: appPath, + schemeUri: schemeUri, + messageId: messageId + ) + Task { + do { + try await DefaultNotificationHistoryService().insert(record: notificationRecord) + } catch { + print(error) + } + } + } } extension UNNotificationAttachment { diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 68e5f26c2..b0169ada5 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -197,6 +197,10 @@ 7C7F43282F65F31500CC5860 /* CallVanReportRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F43272F65F31500CC5860 /* CallVanReportRequest.swift */; }; 7C7F432A2F65F42300CC5860 /* CallVanRecruitmentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F43292F65F42300CC5860 /* CallVanRecruitmentState.swift */; }; 7C7F56B42FFB6A6400847151 /* NotificationPopUpViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56B32FFB6A6400847151 /* NotificationPopUpViewController.swift */; }; + 7C7F56B72FFB9FE000847151 /* NotificationRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56B62FFB9FE000847151 /* NotificationRecord.swift */; }; + 7C7F56B82FFBA06200847151 /* NotificationRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56B62FFB9FE000847151 /* NotificationRecord.swift */; }; + 7C7F56BB2FFBA18100847151 /* NotificationHistoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56BA2FFBA18100847151 /* NotificationHistoryService.swift */; }; + 7C7F56BC2FFBA18100847151 /* NotificationHistoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56BA2FFBA18100847151 /* NotificationHistoryService.swift */; }; 7C82FF782E9D18C7006335A7 /* OrderShopDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C82FF772E9D18C7006335A7 /* OrderShopDetail.swift */; }; 7C85D2752EBDE374005E63FF /* FetchOrderShopMenusAndGroupsFromShopUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C85D2742EBDE374005E63FF /* FetchOrderShopMenusAndGroupsFromShopUseCase.swift */; }; 7C8A00012FD000000011D338 /* NewHomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A00022FD000000011D338 /* NewHomeViewModel.swift */; }; @@ -1198,6 +1202,8 @@ 7C7F43272F65F31500CC5860 /* CallVanReportRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanReportRequest.swift; sourceTree = ""; }; 7C7F43292F65F42300CC5860 /* CallVanRecruitmentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanRecruitmentState.swift; sourceTree = ""; }; 7C7F56B32FFB6A6400847151 /* NotificationPopUpViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPopUpViewController.swift; sourceTree = ""; }; + 7C7F56B62FFB9FE000847151 /* NotificationRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRecord.swift; sourceTree = ""; }; + 7C7F56BA2FFBA18100847151 /* NotificationHistoryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationHistoryService.swift; sourceTree = ""; }; 7C82FF772E9D18C7006335A7 /* OrderShopDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderShopDetail.swift; sourceTree = ""; }; 7C85D2742EBDE374005E63FF /* FetchOrderShopMenusAndGroupsFromShopUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchOrderShopMenusAndGroupsFromShopUseCase.swift; sourceTree = ""; }; 7C8A00022FD000000011D338 /* NewHomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewHomeViewModel.swift; sourceTree = ""; }; @@ -2577,6 +2583,14 @@ path = Core; sourceTree = ""; }; + 7C7F56B52FFB9F4A00847151 /* Common */ = { + isa = PBXGroup; + children = ( + 7CA686732FFCB6BE007937E9 /* NotificationHistory */, + ); + path = Common; + sourceTree = ""; + }; 7C8A00362FD400000011D338 /* NewHome */ = { isa = PBXGroup; children = ( @@ -2756,6 +2770,15 @@ path = LostItemListTableView; sourceTree = ""; }; + 7CA686732FFCB6BE007937E9 /* NotificationHistory */ = { + isa = PBXGroup; + children = ( + 7C7F56BA2FFBA18100847151 /* NotificationHistoryService.swift */, + 7C7F56B62FFB9FE000847151 /* NotificationRecord.swift */, + ); + path = NotificationHistory; + sourceTree = ""; + }; 7CB061C12F32312E00E83CA9 /* helpers */ = { isa = PBXGroup; children = ( @@ -4406,6 +4429,7 @@ D27DD0202BA608310081FD36 /* Koin */, A001E2D12845091F00D6C310 /* koinUITests */, 839D71172BEB1570001BC7F7 /* NotificationService */, + 7C7F56B52FFB9F4A00847151 /* Common */, A001E2B52845091D00D6C310 /* Products */, ); sourceTree = ""; @@ -5524,7 +5548,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7C7F56BC2FFBA18100847151 /* NotificationHistoryService.swift in Sources */, 839D71192BEB1570001BC7F7 /* NotificationService.swift in Sources */, + 7C7F56B82FFBA06200847151 /* NotificationRecord.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5693,6 +5719,7 @@ D27DD0CD2BA608310081FD36 /* Log.swift in Sources */, 7C05D0782FBC29ED00F4D825 /* ShopBenefitViewModel.swift in Sources */, D29F21F32C4FD23F00994554 /* LandItem.swift in Sources */, + 7C7F56B72FFB9FE000847151 /* NotificationRecord.swift in Sources */, D20AB95A2C55369F006BC684 /* NotiAPI.swift in Sources */, 7C457AE72FCDE7240011D338 /* SwiftUIViewModelProtocol.swift in Sources */, 833D9C392C7058C300982145 /* NoticeListRepository.swift in Sources */, @@ -5947,6 +5974,7 @@ 7CED91632EF2D1F900457128 /* HouseInfoView.swift in Sources */, 7CED91642EF2D1F900457128 /* ShopSummaryViewModel.swift in Sources */, 7CED91652EF2D1F900457128 /* NoticeListHeaderView.swift in Sources */, + 7C7F56BB2FFBA18100847151 /* NotificationHistoryService.swift in Sources */, 7CED91662EF2D1F900457128 /* CertificationFormViewController.swift in Sources */, 7CA60D832F66605E007C08C1 /* CallVanPlaceDto.swift in Sources */, 7CED91672EF2D1F900457128 /* DiningCollecitonFooterView.swift in Sources */, From 59c315f7fc70e40d527f82bb19312e0996ef6847 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 7 Jul 2026 15:09:53 +0900 Subject: [PATCH 4/9] =?UTF-8?q?chore:=20AppPath=EB=A5=BC=20Common/Core=20?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- {Koin/Domain/Model => Common}/Core/AppPath.swift | 0 koin.xcodeproj/project.pbxproj | 12 +++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) rename {Koin/Domain/Model => Common}/Core/AppPath.swift (100%) diff --git a/Koin/Domain/Model/Core/AppPath.swift b/Common/Core/AppPath.swift similarity index 100% rename from Koin/Domain/Model/Core/AppPath.swift rename to Common/Core/AppPath.swift diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index b0169ada5..3e5cc5b5e 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -199,6 +199,7 @@ 7C7F56B42FFB6A6400847151 /* NotificationPopUpViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56B32FFB6A6400847151 /* NotificationPopUpViewController.swift */; }; 7C7F56B72FFB9FE000847151 /* NotificationRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56B62FFB9FE000847151 /* NotificationRecord.swift */; }; 7C7F56B82FFBA06200847151 /* NotificationRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56B62FFB9FE000847151 /* NotificationRecord.swift */; }; + 7C7F56B92FFBA0A700847151 /* AppPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7CCE6F2F866E9400E3A54B /* AppPath.swift */; }; 7C7F56BB2FFBA18100847151 /* NotificationHistoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56BA2FFBA18100847151 /* NotificationHistoryService.swift */; }; 7C7F56BC2FFBA18100847151 /* NotificationHistoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56BA2FFBA18100847151 /* NotificationHistoryService.swift */; }; 7C82FF782E9D18C7006335A7 /* OrderShopDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C82FF772E9D18C7006335A7 /* OrderShopDetail.swift */; }; @@ -2578,7 +2579,6 @@ children = ( 7C3ECC792F41505000EE8F13 /* ErrorResponse.swift */, 7C7F429A2F629B8800CC5860 /* UploadDomain.swift */, - 7C7CCE6F2F866E9400E3A54B /* AppPath.swift */, ); path = Core; sourceTree = ""; @@ -2586,6 +2586,7 @@ 7C7F56B52FFB9F4A00847151 /* Common */ = { isa = PBXGroup; children = ( + 7CA686742FFCB6D8007937E9 /* Core */, 7CA686732FFCB6BE007937E9 /* NotificationHistory */, ); path = Common; @@ -2779,6 +2780,14 @@ path = NotificationHistory; sourceTree = ""; }; + 7CA686742FFCB6D8007937E9 /* Core */ = { + isa = PBXGroup; + children = ( + 7C7CCE6F2F866E9400E3A54B /* AppPath.swift */, + ); + path = Core; + sourceTree = ""; + }; 7CB061C12F32312E00E83CA9 /* helpers */ = { isa = PBXGroup; children = ( @@ -5550,6 +5559,7 @@ files = ( 7C7F56BC2FFBA18100847151 /* NotificationHistoryService.swift in Sources */, 839D71192BEB1570001BC7F7 /* NotificationService.swift in Sources */, + 7C7F56B92FFBA0A700847151 /* AppPath.swift in Sources */, 7C7F56B82FFBA06200847151 /* NotificationRecord.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; From 556b75fd11123cf46cac986cfee4f4b62b7af41f Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 7 Jul 2026 15:35:00 +0900 Subject: [PATCH 5/9] =?UTF-8?q?chore:=20id=20=ED=83=80=EC=9E=85=EC=9D=84?= =?UTF-8?q?=20Int=EC=97=90=EC=84=9C=20String=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD,=20mapper=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Model/NewHome/NotificationItem.swift | 61 ++++++++++++++++++- .../Notification/NotificationViewModel.swift | 2 +- .../NotificationTableView.swift | 4 +- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/Koin/Domain/Model/NewHome/NotificationItem.swift b/Koin/Domain/Model/NewHome/NotificationItem.swift index 8c6b55d4f..f4f66acaa 100644 --- a/Koin/Domain/Model/NewHome/NotificationItem.swift +++ b/Koin/Domain/Model/NewHome/NotificationItem.swift @@ -8,7 +8,7 @@ import Foundation struct NotificationItem { - let id: Int + let id: String let isRead: Bool let icon: ImageAsset let appPath: AppPath @@ -18,7 +18,7 @@ struct NotificationItem { let dateText: String init( - id: Int, + id: String, isRead: Bool, icon: ImageAsset, appPath: AppPath, @@ -37,3 +37,60 @@ struct NotificationItem { self.dateText = dateText } } + +extension NotificationItem { + + init?(from record: NotificationRecord) { + guard let appPath = AppPath(rawValue: record.category), + let icon = NotificationItem.icon(for: appPath) else { + return nil + } + self.id = record.messageId + self.isRead = record.isRead + self.icon = icon + self.appPath = appPath + self.uri = record.schemeUri + self.title = record.title + self.content = record.body + self.dateText = NotificationItem.dateText(for: record.createdAt) + } + + static func icon(for appPath: AppPath) -> ImageAsset? { + switch appPath { + case .shop: + return ImageAsset.notificationShop + case .dining: + return ImageAsset.notificationDining + case .keyword: + return ImageAsset.notificationLostItem + case .chat: + return ImageAsset.notificationChat + case .callvan: + return ImageAsset.notificationCallVan + case .callvanChat: + return ImageAsset.notificationChat + default: + return nil + } + } + + static func dateText(for createdAt: Date) -> String { + let now = Date() + let compareComponents = Calendar.current.dateComponents( + [.day, .hour, .minute], + from: createdAt, + to: now + ) + + if let day = compareComponents.day, 1 <= day { + return "\(day)일 전" + } + if let hour = compareComponents.hour, 1 <= hour { + return "\(hour)시간 전" + } + if let minute = compareComponents.minute, 1 <= minute { + return "\(minute)분 전" + } + return "지금" + } +} diff --git a/Koin/Presentation/NewHome/Notification/NotificationViewModel.swift b/Koin/Presentation/NewHome/Notification/NotificationViewModel.swift index e1f0a07ac..d7a017c6a 100644 --- a/Koin/Presentation/NewHome/Notification/NotificationViewModel.swift +++ b/Koin/Presentation/NewHome/Notification/NotificationViewModel.swift @@ -13,7 +13,7 @@ final class NotificationViewModel: ViewModelProtocol { enum Input { case viewDidLoad case reload - case deleteNotification(id: Int) + case deleteNotification(id: String) case logEvent(EventLabelType, EventParameter.EventCategory, Any) } diff --git a/Koin/Presentation/NewHome/Notification/Subviews/NotificationTableView/NotificationTableView.swift b/Koin/Presentation/NewHome/Notification/Subviews/NotificationTableView/NotificationTableView.swift index 4c102aa21..c273bd2a6 100644 --- a/Koin/Presentation/NewHome/Notification/Subviews/NotificationTableView/NotificationTableView.swift +++ b/Koin/Presentation/NewHome/Notification/Subviews/NotificationTableView/NotificationTableView.swift @@ -20,7 +20,7 @@ final class NotificationTableView: UITableView { } // MARK: - Publisher - let deletePublisher = PassthroughSubject() + let deletePublisher = PassthroughSubject() let tapNotificationPublisher = PassthroughSubject() // MARK: - UI Components @@ -54,7 +54,7 @@ final class NotificationTableView: UITableView { } extension NotificationTableView { - private func deleteNotification(id: Int, completion: (() -> Void)? = nil) { + private func deleteNotification(id: String, completion: (() -> Void)? = nil) { guard let index = notifications.firstIndex(where: { $0.id == id }) else { return } From d64df275fb8bae1a7fbdef9e01c80f3258cad26b Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 7 Jul 2026 15:36:11 +0900 Subject: [PATCH 6/9] =?UTF-8?q?chore:=20category=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20String=EC=97=90=EC=84=9C=20AppPath=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Common/Core/AppPath.swift | 2 +- .../NotificationHistory/NotificationHistoryService.swift | 7 +------ Common/NotificationHistory/NotificationRecord.swift | 4 ++-- Koin/Domain/Model/NewHome/NotificationItem.swift | 5 ++--- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/Common/Core/AppPath.swift b/Common/Core/AppPath.swift index cdeb478f2..4e756d34c 100644 --- a/Common/Core/AppPath.swift +++ b/Common/Core/AppPath.swift @@ -7,7 +7,7 @@ import Foundation -enum AppPath: String { +enum AppPath: String, Codable { case home case login case shop diff --git a/Common/NotificationHistory/NotificationHistoryService.swift b/Common/NotificationHistory/NotificationHistoryService.swift index 65dc2f00d..f02d9cb6e 100644 --- a/Common/NotificationHistory/NotificationHistoryService.swift +++ b/Common/NotificationHistory/NotificationHistoryService.swift @@ -36,12 +36,7 @@ final class DefaultNotificationHistoryService: NotificationHistoryService { } switch record.category { - case AppPath.shop.rawValue, - AppPath.dining.rawValue, - AppPath.keyword.rawValue, - AppPath.chat.rawValue, - AppPath.callvan.rawValue, - AppPath.callvanChat.rawValue: + case .shop, .dining, .keyword, .chat, .callvan, .callvanChat: try await MainActor.run { [weak container] in container?.mainContext.insert(record) try container?.mainContext.save() diff --git a/Common/NotificationHistory/NotificationRecord.swift b/Common/NotificationHistory/NotificationRecord.swift index fcd03fe97..f8b27f451 100644 --- a/Common/NotificationHistory/NotificationRecord.swift +++ b/Common/NotificationHistory/NotificationRecord.swift @@ -12,7 +12,7 @@ import Foundation final class NotificationRecord: Sendable { var body: String var title: String - var category: String + var category: AppPath var schemeUri: String var messageId: String @@ -28,7 +28,7 @@ final class NotificationRecord: Sendable { ) { self.body = body self.title = title - self.category = category.rawValue + self.category = category self.schemeUri = schemeUri self.messageId = messageId } diff --git a/Koin/Domain/Model/NewHome/NotificationItem.swift b/Koin/Domain/Model/NewHome/NotificationItem.swift index f4f66acaa..be78f08ab 100644 --- a/Koin/Domain/Model/NewHome/NotificationItem.swift +++ b/Koin/Domain/Model/NewHome/NotificationItem.swift @@ -41,14 +41,13 @@ struct NotificationItem { extension NotificationItem { init?(from record: NotificationRecord) { - guard let appPath = AppPath(rawValue: record.category), - let icon = NotificationItem.icon(for: appPath) else { + guard let icon = NotificationItem.icon(for: record.category) else { return nil } self.id = record.messageId self.isRead = record.isRead self.icon = icon - self.appPath = appPath + self.appPath = record.category self.uri = record.schemeUri self.title = record.title self.content = record.body From e19f6f68b2c866c6ba4b86c32272b3859a31ab28 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 7 Jul 2026 17:51:24 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20NotificationRecord=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=BD=EC=9D=84=20=EB=95=8C=2014=EC=9D=BC=EC=9D=B4=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=BC=ED=95=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationHistoryService.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Common/NotificationHistory/NotificationHistoryService.swift b/Common/NotificationHistory/NotificationHistoryService.swift index f02d9cb6e..d8d8cd1f9 100644 --- a/Common/NotificationHistory/NotificationHistoryService.swift +++ b/Common/NotificationHistory/NotificationHistoryService.swift @@ -19,6 +19,7 @@ protocol NotificationHistoryService { final class DefaultNotificationHistoryService: NotificationHistoryService { + // MARK: - Properties private let container: ModelContainer? // MARK: - Initializer @@ -52,6 +53,8 @@ final class DefaultNotificationHistoryService: NotificationHistoryService { throw SwiftDataError.loadIssueModelContainer } + try await deleteExpiredNotifications() + return try await MainActor.run { var descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.createdAt, order: .reverse)] @@ -121,3 +124,22 @@ final class DefaultNotificationHistoryService: NotificationHistoryService { } } } + +extension DefaultNotificationHistoryService { + private func deleteExpiredNotifications() async throws { + guard let container else { + throw SwiftDataError.loadIssueModelContainer + } + + guard let expirationDate = Calendar.current.date(byAdding: .day, value: -7, to: Date()) else { + throw NotificationHistoryError.calendarDidFail + } + + try await MainActor.run { + try container.mainContext.delete(model: NotificationRecord.self, where: #Predicate { notification in + notification.createdAt < expirationDate + }) + try container.mainContext.save() + } + } +} From 5b7f41b3a3f856173031aa4ec652269c76fb6922 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 7 Jul 2026 17:55:08 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Common/Core/NotificationHistoryError.swift | 14 ++++ .../NotificationHistoryService.swift | 4 +- ...DefaultNotificationHistoryRepository.swift | 44 ++++++++++++ .../Model/NewHome/NotificationItem.swift | 2 +- .../NotificationHistoryRepository.swift | 16 +++++ .../DeleteNotificationHistoryUseCase.swift | 30 ++++++++ .../FetchNotificationListUseCase.swift | 37 ++++------ .../UpdateNotificationHistoryUseCase.swift | 30 ++++++++ .../Category/CategoryHostingController.swift | 9 ++- .../NewHome/Home/HomeHostingController.swift | 9 ++- .../NotificationViewController.swift | 18 +++-- .../Notification/NotificationViewModel.swift | 71 ++++++++++++++----- .../NotificationPopUpViewController.swift | 6 +- .../NotificationTableView.swift | 30 +++++++- koin.xcodeproj/project.pbxproj | 22 ++++++ 15 files changed, 283 insertions(+), 59 deletions(-) create mode 100644 Common/Core/NotificationHistoryError.swift create mode 100644 Koin/Data/Repository/DefaultNotificationHistoryRepository.swift create mode 100644 Koin/Domain/Repository/NotificationHistoryRepository.swift create mode 100644 Koin/Domain/UseCase/NewHome/DeleteNotificationHistoryUseCase.swift create mode 100644 Koin/Domain/UseCase/NewHome/UpdateNotificationHistoryUseCase.swift diff --git a/Common/Core/NotificationHistoryError.swift b/Common/Core/NotificationHistoryError.swift new file mode 100644 index 000000000..72dee3588 --- /dev/null +++ b/Common/Core/NotificationHistoryError.swift @@ -0,0 +1,14 @@ +// +// NotificationHistoryError.swift +// koin +// +// Created by 홍기정 on 7/7/26. +// + +import Foundation + +struct NotificationHistoryError: Error { + let message: String + + static let calendarDidFail = Self.init(message: "캘린더 오류 발생") +} diff --git a/Common/NotificationHistory/NotificationHistoryService.swift b/Common/NotificationHistory/NotificationHistoryService.swift index d8d8cd1f9..b474e3846 100644 --- a/Common/NotificationHistory/NotificationHistoryService.swift +++ b/Common/NotificationHistory/NotificationHistoryService.swift @@ -38,12 +38,12 @@ final class DefaultNotificationHistoryService: NotificationHistoryService { switch record.category { case .shop, .dining, .keyword, .chat, .callvan, .callvanChat: - try await MainActor.run { [weak container] in + try? await MainActor.run { [weak container] in container?.mainContext.insert(record) try container?.mainContext.save() } default: - throw SwiftDataError.modelValidationFailure + break } } diff --git a/Koin/Data/Repository/DefaultNotificationHistoryRepository.swift b/Koin/Data/Repository/DefaultNotificationHistoryRepository.swift new file mode 100644 index 000000000..10920b76a --- /dev/null +++ b/Koin/Data/Repository/DefaultNotificationHistoryRepository.swift @@ -0,0 +1,44 @@ +// +// DefaultNotificationHistoryRepository.swift +// koin +// +// Created by 홍기정 on 7/7/26. +// + +import Foundation + +final class DefaultNotificationHistoryRepository: NotificationHistoryRepository { + + private let service: NotificationHistoryService + + init(service: NotificationHistoryService) { + self.service = service + } + + func insert(record: NotificationRecord) async throws { + try await service.insert(record: record) + } + + func fetchAll() async throws -> [NotificationItem] { + try await service.fetchAll() + .compactMap { + NotificationItem.init(from: $0) + } + } + + func deleteAll() async throws { + try await service.deleteAll() + } + + func delete(id: String) async throws { + try await service.delete(messageId: id) + } + + func markAsRead(id: String) async throws { + try await service.markAsRead(messageId: id) + } + + func makrAllAsRead() async throws { + try await service.markAllAsRead() + } +} diff --git a/Koin/Domain/Model/NewHome/NotificationItem.swift b/Koin/Domain/Model/NewHome/NotificationItem.swift index be78f08ab..82b28ee8f 100644 --- a/Koin/Domain/Model/NewHome/NotificationItem.swift +++ b/Koin/Domain/Model/NewHome/NotificationItem.swift @@ -9,7 +9,7 @@ import Foundation struct NotificationItem { let id: String - let isRead: Bool + var isRead: Bool let icon: ImageAsset let appPath: AppPath let uri: String? diff --git a/Koin/Domain/Repository/NotificationHistoryRepository.swift b/Koin/Domain/Repository/NotificationHistoryRepository.swift new file mode 100644 index 000000000..70a084712 --- /dev/null +++ b/Koin/Domain/Repository/NotificationHistoryRepository.swift @@ -0,0 +1,16 @@ +// +// NotificationHistoryRepository.swift +// koin +// +// Created by 홍기정 on 7/7/26. +// + +import Combine + +protocol NotificationHistoryRepository { + func fetchAll() async throws -> [NotificationItem] + func deleteAll() async throws + func delete(id: String) async throws + func markAsRead(id: String) async throws + func makrAllAsRead() async throws +} diff --git a/Koin/Domain/UseCase/NewHome/DeleteNotificationHistoryUseCase.swift b/Koin/Domain/UseCase/NewHome/DeleteNotificationHistoryUseCase.swift new file mode 100644 index 000000000..379ca7b5c --- /dev/null +++ b/Koin/Domain/UseCase/NewHome/DeleteNotificationHistoryUseCase.swift @@ -0,0 +1,30 @@ +// +// DeleteNotificationHistoryUseCase.swift +// koin +// +// Created by 홍기정 on 7/7/26. +// + +import Foundation + +protocol DeleteNotificationHistoryUseCase { + func delete(id: String) async throws + func deleteAll() async throws +} + +final class DefaultDeleteNotificationHistoryUseCase: DeleteNotificationHistoryUseCase { + + private let repository: NotificationHistoryRepository + + init(repository: NotificationHistoryRepository) { + self.repository = repository + } + + func delete(id: String) async throws { + try await repository.delete(id: id) + } + + func deleteAll() async throws { + try await repository.deleteAll() + } +} diff --git a/Koin/Domain/UseCase/NewHome/FetchNotificationListUseCase.swift b/Koin/Domain/UseCase/NewHome/FetchNotificationListUseCase.swift index 9e572ff74..99c651eca 100644 --- a/Koin/Domain/UseCase/NewHome/FetchNotificationListUseCase.swift +++ b/Koin/Domain/UseCase/NewHome/FetchNotificationListUseCase.swift @@ -8,32 +8,19 @@ import Combine import Foundation -protocol FetchNotificationListUseCase { - func execute() -> AnyPublisher<[NotificationItem], ErrorResponse> +protocol FetchNotificationHistoryUseCase { + func execute() async throws -> [NotificationItem] } -final class MockFetchNotificationListUseCase: FetchNotificationListUseCase { - func execute() -> AnyPublisher<[NotificationItem], ErrorResponse> { - - let items = [ - NotificationItem(id: 1, isRead: false, icon: .notificationCallVan, appPath: .callvan, uri: nil, title: "콜밴팟 새 참여자", content: "OOO님이 콜벤팟에 참여했어요.", dateText: "2시간 전"), - NotificationItem(id: 2, isRead: false, icon: .notificationLostItem, appPath: .callvan, uri: nil, title: "콜밴팟 새 참여자콜밴팟 새 참여자콜밴팟 새 참여자콜밴팟 새 참여자", content: "OOO님이 콜벤팟에 참여했어요.", dateText: "2시간 전"), - NotificationItem(id: 3, isRead: false, icon: .notificationChat, appPath: .callvan, uri: nil, title: "콜밴팟 새 참여자", content: "OOO님이 콜벤팟에 참여했어요.OOO님이 콜벤팟에 참여했어요.OOO님이 콜벤팟에 참여했어요.OOO님이 콜벤팟에 참여했어요.", dateText: "2시간 전"), - NotificationItem(id: 4, isRead: false, icon: .notificationChat, appPath: .callvan, uri: nil, title: "콜밴팟 새 참여자콜밴팟 새 참여자콜밴팟 새 참여자콜밴팟 새 참여자", content: "OOO님이 콜벤팟에 참여했어요.OOO님이 콜벤팟에 참여했어요.OOO님이 콜벤팟에 참여했어요.OOO님이 콜벤팟에 참여했어요.", dateText: "2시간 전"), - - NotificationItem(id: 5, isRead: false, icon: .notificationShop, appPath: .callvan, uri: nil, title: "콜밴팟 새 참여자", content: "OOO님이 콜벤팟에 참여했어요.", dateText: "2시간 전"), - NotificationItem(id: 6, isRead: false, icon: .notificationDining, appPath: .callvan, uri: nil, title: "콜밴팟 새 참여자", content: "OOO님이 콜벤팟에 참여했어요.", dateText: "2시간 전"), - - NotificationItem(id: 7, isRead: true, icon: .notificationCallVan, appPath: .callvan, uri: nil, title: "콜밴팟 새 참여자", content: "OOO님이 콜벤팟에 참여했어요.", dateText: "2시간 전"), - NotificationItem(id: 8, isRead: true, icon: .notificationLostItem, appPath: .callvan, uri: nil, title: "콜밴팟 새 참여자", content: "OOO님이 콜벤팟에 참여했어요.", dateText: "2시간 전"), - NotificationItem(id: 9, isRead: true, icon: .notificationChat, appPath: .callvan, uri: nil, title: "콜밴팟 새 참여자", content: "OOO님이 콜벤팟에 참여했어요.", dateText: "2시간 전"), - NotificationItem(id: 10, isRead: true, icon: .notificationShop, appPath: .callvan, uri: nil, title: "콜밴팟 새 참여자", content: "OOO님이 콜벤팟에 참여했어요.", dateText: "2시간 전"), - NotificationItem(id: 11, isRead: true, icon: .notificationDining, appPath: .callvan, uri: nil, title: "콜밴팟 새 참여자", content: "OOO님이 콜벤팟에 참여했어요.", dateText: "2시간 전"), - ] - - return Just(items) - .setFailureType(to: ErrorResponse.self) - .eraseToAnyPublisher() +final class DefaultFetchNotificationHistoryUseCase: FetchNotificationHistoryUseCase { + + private let notificationHistoryRepository: NotificationHistoryRepository + + init(notificationHistoryRepository: NotificationHistoryRepository) { + self.notificationHistoryRepository = notificationHistoryRepository + } + + func execute() async throws -> [NotificationItem] { + try await notificationHistoryRepository.fetchAll() } } - diff --git a/Koin/Domain/UseCase/NewHome/UpdateNotificationHistoryUseCase.swift b/Koin/Domain/UseCase/NewHome/UpdateNotificationHistoryUseCase.swift new file mode 100644 index 000000000..0ae55b2a5 --- /dev/null +++ b/Koin/Domain/UseCase/NewHome/UpdateNotificationHistoryUseCase.swift @@ -0,0 +1,30 @@ +// +// UpdateNotificationHistoryUseCase.swift +// koin +// +// Created by 홍기정 on 7/7/26. +// + +import Foundation + +protocol UpdateNotificationHistoryUseCase { + func markAsRead(id: String) async throws + func markAllAsRead() async throws +} + +final class DefaultUpdateNotificationHistoryUseCase: UpdateNotificationHistoryUseCase { + + private let repository: NotificationHistoryRepository + + init(repository: NotificationHistoryRepository) { + self.repository = repository + } + + func markAsRead(id: String) async throws { + try await repository.markAsRead(id: id) + } + + func markAllAsRead() async throws { + try await repository.makrAllAsRead() + } +} diff --git a/Koin/Presentation/NewHome/Category/CategoryHostingController.swift b/Koin/Presentation/NewHome/Category/CategoryHostingController.swift index a70f47cd3..606fec9ae 100644 --- a/Koin/Presentation/NewHome/Category/CategoryHostingController.swift +++ b/Koin/Presentation/NewHome/Category/CategoryHostingController.swift @@ -98,9 +98,16 @@ extension CategoryHostingController { } private func navigateToNotification() { + let notificatioHistoryRepository = DefaultNotificationHistoryRepository(service: DefaultNotificationHistoryService()) + let fetchNotificationHistoryUseCase = DefaultFetchNotificationHistoryUseCase(notificationHistoryRepository: notificatioHistoryRepository) + let deleteNotificationHistoryUseCase = DefaultDeleteNotificationHistoryUseCase(repository: notificatioHistoryRepository) + let updateNotificationHistoryUseCase = DefaultUpdateNotificationHistoryUseCase(repository: notificatioHistoryRepository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = NotificationViewModel( - fetchNotificationListUseCase: MockFetchNotificationListUseCase(), + fetchNotificationHistoryUseCase: fetchNotificationHistoryUseCase, + deleteNotificationHistoryUseCase: deleteNotificationHistoryUseCase, + updateNotificationHistoryUseCase: updateNotificationHistoryUseCase, logAnalyticsEventUseCase: logAnalyticsEventUseCase ) let viewController = NotificationViewController(viewModel: viewModel) diff --git a/Koin/Presentation/NewHome/Home/HomeHostingController.swift b/Koin/Presentation/NewHome/Home/HomeHostingController.swift index 6ffe38796..da0f8aeab 100644 --- a/Koin/Presentation/NewHome/Home/HomeHostingController.swift +++ b/Koin/Presentation/NewHome/Home/HomeHostingController.swift @@ -98,9 +98,16 @@ extension HomeHostingController { } private func navigateToNotification() { + let notificatioHistoryRepository = DefaultNotificationHistoryRepository(service: DefaultNotificationHistoryService()) + let fetchNotificationHistoryUseCase = DefaultFetchNotificationHistoryUseCase(notificationHistoryRepository: notificatioHistoryRepository) + let deleteNotificationHistoryUseCase = DefaultDeleteNotificationHistoryUseCase(repository: notificatioHistoryRepository) + let updateNotificationHistoryUseCase = DefaultUpdateNotificationHistoryUseCase(repository: notificatioHistoryRepository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = NotificationViewModel( - fetchNotificationListUseCase: MockFetchNotificationListUseCase(), + fetchNotificationHistoryUseCase: fetchNotificationHistoryUseCase, + deleteNotificationHistoryUseCase: deleteNotificationHistoryUseCase, + updateNotificationHistoryUseCase: updateNotificationHistoryUseCase, logAnalyticsEventUseCase: logAnalyticsEventUseCase ) let viewController = NotificationViewController(viewModel: viewModel) diff --git a/Koin/Presentation/NewHome/Notification/NotificationViewController.swift b/Koin/Presentation/NewHome/Notification/NotificationViewController.swift index 2ec816a55..a98309b0c 100644 --- a/Koin/Presentation/NewHome/Notification/NotificationViewController.swift +++ b/Koin/Presentation/NewHome/Notification/NotificationViewController.swift @@ -86,8 +86,9 @@ private extension NotificationViewController { } .store(in: &subscriptions) - notificationTableView.tapNotificationPublisher + notificationTableView.tapNotificationPublisher // TODO: Navigation .sink { [weak self] item in + self?.inputSubject.send(.markAsRead(id: item.id)) self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.notificationList, .click, item.title)) } .store(in: &subscriptions) @@ -98,6 +99,7 @@ private extension NotificationViewController { loadingIndicator.startAnimating() } else { loadingIndicator.stopAnimating() + refreshControl.endRefreshing() } updateStateViews(isEmpty: notificationTableView.isEmpty) @@ -133,14 +135,20 @@ private extension NotificationViewController { private func showPopUpView() { let popUpViewController = NotificationPopUpViewController( - markAllAsRead: {}, // TODO: - - deleteAll: {} // TODO: - + markAllAsRead: { [weak self] in + self?.inputSubject.send(.markAllAsRead) + self?.notificationTableView.markAllAsRead() + }, + deleteAll: { [weak self] in + self?.inputSubject.send(.deleteAllNotifications) + self?.notificationTableView.deleteAll() + self?.updateStateViews(isEmpty: true) + } ) - popUpViewController.modalTransitionStyle = .crossDissolve popUpViewController.modalPresentationStyle = .overFullScreen navigationController?.present( popUpViewController, - animated: true + animated: false ) } } diff --git a/Koin/Presentation/NewHome/Notification/NotificationViewModel.swift b/Koin/Presentation/NewHome/Notification/NotificationViewModel.swift index d7a017c6a..deb996092 100644 --- a/Koin/Presentation/NewHome/Notification/NotificationViewModel.swift +++ b/Koin/Presentation/NewHome/Notification/NotificationViewModel.swift @@ -14,6 +14,9 @@ final class NotificationViewModel: ViewModelProtocol { case viewDidLoad case reload case deleteNotification(id: String) + case deleteAllNotifications + case markAsRead(id: String) + case markAllAsRead case logEvent(EventLabelType, EventParameter.EventCategory, Any) } @@ -25,17 +28,23 @@ final class NotificationViewModel: ViewModelProtocol { // MARK: - Properties - private let fetchNotificationListUseCase: FetchNotificationListUseCase + private let fetchNotificationHistoryUseCase: FetchNotificationHistoryUseCase + private let deleteNotificationHistoryUseCase: DeleteNotificationHistoryUseCase + private let updateNotificationHistoryUseCase: UpdateNotificationHistoryUseCase private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase private let outputSubject = PassthroughSubject() private var subscriptions = Set() // MARK: - Initializer init( - fetchNotificationListUseCase: FetchNotificationListUseCase, + fetchNotificationHistoryUseCase: FetchNotificationHistoryUseCase, + deleteNotificationHistoryUseCase: DeleteNotificationHistoryUseCase, + updateNotificationHistoryUseCase: UpdateNotificationHistoryUseCase, logAnalyticsEventUseCase: LogAnalyticsEventUseCase ) { - self.fetchNotificationListUseCase = fetchNotificationListUseCase + self.fetchNotificationHistoryUseCase = fetchNotificationHistoryUseCase + self.deleteNotificationHistoryUseCase = deleteNotificationHistoryUseCase + self.updateNotificationHistoryUseCase = updateNotificationHistoryUseCase self.logAnalyticsEventUseCase = logAnalyticsEventUseCase } @@ -48,6 +57,12 @@ final class NotificationViewModel: ViewModelProtocol { self?.loadNotifications() case .deleteNotification(let id): self?.deleteNotification(id: id) + case .deleteAllNotifications: + self?.deleteAllNotifications() + case .markAsRead(let id): + self?.markAsRead(id: id) + case .markAllAsRead: + self?.markAllAsRead() case let .logEvent(label, category, value): self?.makeLogAnalyticsEvent(label: label, category: category, value: value) } @@ -64,24 +79,42 @@ private extension NotificationViewModel { private func loadNotifications() { outputSubject.send(.updateLoading(true)) - fetchNotificationListUseCase.execute().sink( - receiveCompletion: { [weak self] completion in - self?.outputSubject.send(.updateLoading(false)) - if case .failure(let error) = completion { - self?.outputSubject.send(.showToast(error.message)) - } - }, - receiveValue: { [weak self] notifications in - guard let self else { return } - self.outputSubject.send(.updateNotifications(notifications)) - self.outputSubject.send(.updateLoading(false)) - } - ) - .store(in: &subscriptions) + defer { + outputSubject.send(.updateLoading(false)) + } + + Task { + do { + let notifications = try await fetchNotificationHistoryUseCase.execute() + outputSubject.send(.updateNotifications(notifications)) + } catch { + outputSubject.send(.showToast(error.localizedDescription)) + } + } } - private func deleteNotification(id: Int) { - // TODO: API 연결 시 성공/실패와 관계없이 화면 상태는 되돌리지 않는다. + private func deleteNotification(id: String) { + Task { + try? await deleteNotificationHistoryUseCase.delete(id: id) + } + } + + private func deleteAllNotifications() { + Task { + try? await deleteNotificationHistoryUseCase.deleteAll() + } + } + + private func markAsRead(id: String) { + Task { + try? await updateNotificationHistoryUseCase.markAsRead(id: id) + } + } + + private func markAllAsRead() { + Task { + try? await updateNotificationHistoryUseCase.markAllAsRead() + } } private func makeLogAnalyticsEvent( diff --git a/Koin/Presentation/NewHome/Notification/Subviews/NotificationPopUpViewController.swift b/Koin/Presentation/NewHome/Notification/Subviews/NotificationPopUpViewController.swift index c99138aa9..430f0cdba 100644 --- a/Koin/Presentation/NewHome/Notification/Subviews/NotificationPopUpViewController.swift +++ b/Koin/Presentation/NewHome/Notification/Subviews/NotificationPopUpViewController.swift @@ -57,17 +57,17 @@ extension NotificationPopUpViewController { } @objc private func didTapMarkAllAsReadButton() { - dismissView() + dismiss(animated: false) markAllAsRead() } @objc private func didTapDeleteAllButton() { - dismissView() + dismiss(animated: false) deleteAll() } @objc private func didTapAround() { - dismissView() + dismiss(animated: false) } } diff --git a/Koin/Presentation/NewHome/Notification/Subviews/NotificationTableView/NotificationTableView.swift b/Koin/Presentation/NewHome/Notification/Subviews/NotificationTableView/NotificationTableView.swift index c273bd2a6..171f8740f 100644 --- a/Koin/Presentation/NewHome/Notification/Subviews/NotificationTableView/NotificationTableView.swift +++ b/Koin/Presentation/NewHome/Notification/Subviews/NotificationTableView/NotificationTableView.swift @@ -51,10 +51,22 @@ final class NotificationTableView: UITableView { reloadData() setNeedsLayout() } + + func markAllAsRead() { + for index in notifications.indices { + notifications[index].isRead = true + } + reloadSections([0], with: .fade) + } + + func deleteAll() { + notifications.removeAll() + reloadSections([0], with: .fade) + } } extension NotificationTableView { - private func deleteNotification(id: String, completion: (() -> Void)? = nil) { + private func didDeleteNotification(id: String, completion: (() -> Void)? = nil) { guard let index = notifications.firstIndex(where: { $0.id == id }) else { return } @@ -71,6 +83,19 @@ extension NotificationTableView { completion?() } } + + private func didSelectNotification(id: String) { + guard let index = notifications.firstIndex(where: { $0.id == id }) else { + return + } + + self.notifications[index].isRead = true + + reloadRows( + at: [IndexPath(row: index, section: 0)], + with: .fade + ) + } } // MARK: - Layout @@ -168,6 +193,7 @@ extension NotificationTableView: UITableViewDelegate { return } tapNotificationPublisher.send(notifications[indexPath.row]) + didSelectNotification(id: notifications[indexPath.row].id) } func tableView( @@ -185,7 +211,7 @@ extension NotificationTableView: UITableViewDelegate { completion(false) return } - self.deleteNotification(id: item.id) + self.didDeleteNotification(id: item.id) self.deletePublisher.send(item.id) completion(true) } diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 3e5cc5b5e..5470a957e 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -283,6 +283,12 @@ 7CA63A2E2F1B119200226F86 /* LostItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA63A2D2F1B119200226F86 /* LostItemListView.swift */; }; 7CA63A302F1B13F100226F86 /* LostItemStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA63A2F2F1B13F100226F86 /* LostItemStats.swift */; }; 7CA63A332F1B1B0C00226F86 /* LostItemListFilterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA63A322F1B1B0C00226F86 /* LostItemListFilterViewController.swift */; }; + 7CA686692FFC3242007937E9 /* NotificationHistoryRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA686682FFC3242007937E9 /* NotificationHistoryRepository.swift */; }; + 7CA6867B2FFCB758007937E9 /* DefaultNotificationHistoryRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA686792FFCB758007937E9 /* DefaultNotificationHistoryRepository.swift */; }; + 7CA6868B2FFCE750007937E9 /* DeleteNotificationHistoryUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA6868A2FFCE750007937E9 /* DeleteNotificationHistoryUseCase.swift */; }; + 7CA6868D2FFCE867007937E9 /* UpdateNotificationHistoryUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA6868C2FFCE867007937E9 /* UpdateNotificationHistoryUseCase.swift */; }; + 7CA6868F2FFCF42E007937E9 /* NotificationHistoryError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA6868E2FFCF42E007937E9 /* NotificationHistoryError.swift */; }; + 7CA686902FFCF42E007937E9 /* NotificationHistoryError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CA6868E2FFCF42E007937E9 /* NotificationHistoryError.swift */; }; 7CB0618F2F320F3A00E83CA9 /* NMapsMap in Frameworks */ = {isa = PBXBuildFile; productRef = 7CB0618E2F320F3A00E83CA9 /* NMapsMap */; }; 7CB061932F32100900E83CA9 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 7CB061922F32100900E83CA9 /* Alamofire */; }; 7CB061992F32186800E83CA9 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7CB061982F32186800E83CA9 /* SnapKit */; }; @@ -1287,6 +1293,11 @@ 7CA63A2F2F1B13F100226F86 /* LostItemStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemStats.swift; sourceTree = ""; }; 7CA63A322F1B1B0C00226F86 /* LostItemListFilterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemListFilterViewController.swift; sourceTree = ""; }; 7CA686672FFC1FAF007937E9 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; + 7CA686682FFC3242007937E9 /* NotificationHistoryRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationHistoryRepository.swift; sourceTree = ""; }; + 7CA686792FFCB758007937E9 /* DefaultNotificationHistoryRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultNotificationHistoryRepository.swift; sourceTree = ""; }; + 7CA6868A2FFCE750007937E9 /* DeleteNotificationHistoryUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteNotificationHistoryUseCase.swift; sourceTree = ""; }; + 7CA6868C2FFCE867007937E9 /* UpdateNotificationHistoryUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateNotificationHistoryUseCase.swift; sourceTree = ""; }; + 7CA6868E2FFCF42E007937E9 /* NotificationHistoryError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationHistoryError.swift; sourceTree = ""; }; 7CB061BE2F32312E00E83CA9 /* DPDConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPDConstants.swift; sourceTree = ""; }; 7CB061BF2F32312E00E83CA9 /* DPDKeyboardListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPDKeyboardListener.swift; sourceTree = ""; }; 7CB061C02F32312E00E83CA9 /* DPDUIView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DPDUIView+Extension.swift"; sourceTree = ""; }; @@ -2611,6 +2622,8 @@ 7C8BFCE72FD04BA000963679 /* FetchHomeHeaderUseCase.swift */, 7C8BFCE82FD04BA000963679 /* FetchNewHomeCountsUseCase.swift */, 7C8BFCE92FD04BA000963679 /* FetchNotificationListUseCase.swift */, + 7CA6868A2FFCE750007937E9 /* DeleteNotificationHistoryUseCase.swift */, + 7CA6868C2FFCE867007937E9 /* UpdateNotificationHistoryUseCase.swift */, ); path = NewHome; sourceTree = ""; @@ -2784,6 +2797,7 @@ isa = PBXGroup; children = ( 7C7CCE6F2F866E9400E3A54B /* AppPath.swift */, + 7CA6868E2FFCF42E007937E9 /* NotificationHistoryError.swift */, ); path = Core; sourceTree = ""; @@ -5201,6 +5215,7 @@ D208A7C52CABB426007040E7 /* CoreRepository.swift */, 7CA60D622F66197C007C08C1 /* CallVanRepository.swift */, 7C8A943F2FD426B400DEA6F5 /* HomeRepository.swift */, + 7CA686682FFC3242007937E9 /* NotificationHistoryRepository.swift */, ); path = Repository; sourceTree = ""; @@ -5222,6 +5237,7 @@ D208A7CE2CABB85B007040E7 /* DefaultCoreRepository.swift */, D2D4626D2D63BD1400C60864 /* DefaultChatRepository.swift */, 7CA60D642F6619B7007C08C1 /* DefaultCallVanRepository.swift */, + 7CA686792FFCB758007937E9 /* DefaultNotificationHistoryRepository.swift */, ); path = Repository; sourceTree = ""; @@ -5559,6 +5575,7 @@ files = ( 7C7F56BC2FFBA18100847151 /* NotificationHistoryService.swift in Sources */, 839D71192BEB1570001BC7F7 /* NotificationService.swift in Sources */, + 7CA686902FFCF42E007937E9 /* NotificationHistoryError.swift in Sources */, 7C7F56B92FFBA0A700847151 /* AppPath.swift in Sources */, 7C7F56B82FFBA06200847151 /* NotificationRecord.swift in Sources */, ); @@ -5569,6 +5586,7 @@ buildActionMask = 2147483647; files = ( D208A7CD2CABB7A3007040E7 /* CoreAPI.swift in Sources */, + 7CA6867B2FFCB758007937E9 /* DefaultNotificationHistoryRepository.swift in Sources */, 833D9C052C68F8BA00982145 /* ScreenActionType.swift in Sources */, 835DC2062C84D09400488506 /* FetchRecommendedSearchWordRequest.swift in Sources */, D22C54582C68B033000826DA /* ReportReviewRequest.swift in Sources */, @@ -5629,6 +5647,7 @@ D2B7EBDA2D382C8800EE46B0 /* UserTypeResponse.swift in Sources */, 7C61D3392F21FADF005FAC3A /* EditLostItemViewModel.swift in Sources */, 7CB11C7A2FB32716004E0C80 /* LostItemKeywordDto.swift in Sources */, + 7CA6868D2FFCE867007937E9 /* UpdateNotificationHistoryUseCase.swift in Sources */, 833D9C5C2C7371D900982145 /* FetchNoticeDataRequest.swift in Sources */, 7CED922B2EF2D25900457128 /* DIContainer+Notice.swift in Sources */, 7CED922C2EF2D25900457128 /* DIContainer+Dining.swift in Sources */, @@ -5938,6 +5957,7 @@ 7CB11C182FB1D017004E0C80 /* LostItemKeywordViewController.swift in Sources */, 7C372F322F1DB4C300149729 /* LostItemImagesCollectionViewCell.swift in Sources */, 7C372F332F1DB4C300149729 /* LostItemImagesCollectionView.swift in Sources */, + 7CA6868F2FFCF42E007937E9 /* NotificationHistoryError.swift in Sources */, 7CED91302EF2D1F900457128 /* NoticeListSearchTableViewFooter.swift in Sources */, 7CED91312EF2D1F900457128 /* ShopSummaryTableViewTableHeaderView.swift in Sources */, 7C8BFCD32FD0272900963679 /* View+border.swift in Sources */, @@ -6380,6 +6400,7 @@ D249CD262C12DC2C000813F9 /* FetchDiningListUseCase.swift in Sources */, B46B8CC42E76CC6300A8E797 /* FetchOrderShopSummaryFromShopUseCase.swift in Sources */, D20AD70B2E036BC60067760A /* ResetPasswordEmailUseCase.swift in Sources */, + 7CA6868B2FFCE750007937E9 /* DeleteNotificationHistoryUseCase.swift in Sources */, D27A5D9A2C6B97D200C4275F /* InsetLabel.swift in Sources */, 8380FDC32BC1CBB800120036 /* BusCourseDTO.swift in Sources */, 83DD0D742C4F6FD000278ABD /* BusType.swift in Sources */, @@ -6488,6 +6509,7 @@ 7CC42B152F56ABC000940CE1 /* CallVanListCollectionViewCell.swift in Sources */, D289873E2D21B30D00AA7285 /* DebouncedButton.swift in Sources */, D249CD3F2C159426000813F9 /* CurrentDiningTime.swift in Sources */, + 7CA686692FFC3242007937E9 /* NotificationHistoryRepository.swift in Sources */, D874DB512DBF200C0098EED0 /* SendVerificationCodeDTO.swift in Sources */, 7CC42B1E2F56F3B200940CE1 /* CallVanButton.swift in Sources */, 7C61D33E2F21FADF005FAC3A /* EditLostItemViewController.swift in Sources */, From b2c8b91e642653d104c3e5d04b6b5d81c4b4eb20 Mon Sep 17 00:00:00 2001 From: GI JEONG HONG Date: Tue, 7 Jul 2026 19:18:56 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=99=94=EB=A9=B4=20=EC=A0=84=ED=99=98=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationViewController.swift | 193 +++++++++++++++++- 1 file changed, 192 insertions(+), 1 deletion(-) diff --git a/Koin/Presentation/NewHome/Notification/NotificationViewController.swift b/Koin/Presentation/NewHome/Notification/NotificationViewController.swift index a98309b0c..182d5fe42 100644 --- a/Koin/Presentation/NewHome/Notification/NotificationViewController.swift +++ b/Koin/Presentation/NewHome/Notification/NotificationViewController.swift @@ -86,10 +86,11 @@ private extension NotificationViewController { } .store(in: &subscriptions) - notificationTableView.tapNotificationPublisher // TODO: Navigation + notificationTableView.tapNotificationPublisher .sink { [weak self] item in self?.inputSubject.send(.markAsRead(id: item.id)) self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.notificationList, .click, item.title)) + self?.handleNavigation(item) } .store(in: &subscriptions) } @@ -122,6 +123,196 @@ private extension NotificationViewController { } } +// MARK: - Navigation + +extension NotificationViewController { + private func handleNavigation(_ item: NotificationItem) { + guard let uri: String = item.uri, + let parsedQuery = parseQuery(uri: uri) else { + return + } + switch item.appPath { + case .shop: + if let shopIdString = parsedQuery["id"], + let shopIdInt = Int(shopIdString) { + navigateToShop(shopId: shopIdInt) + } + case .dining: + navigateToDining() + case .chat: + if let articleIdString = parsedQuery["articleId"], + let chatRoomIdString = parsedQuery["chatRoomId"], + let articleIdInt = Int(articleIdString), + let chatRoomIdInt = Int(chatRoomIdString) { + navigateToChat(articleId: articleIdInt, chatRoomId: chatRoomIdInt) + } + case .callvan: + if let postIdString = parsedQuery["postId"], + let postIdInt = Int(postIdString) { + navigateToCallVanData(postId: postIdInt) + } + case .callvanChat: + if let postIdString = parsedQuery["postId"], + let chatRoomIdString = parsedQuery["chatRoomId"], + let postIdInt = Int(postIdString), + let chatRoomIdInt = Int(chatRoomIdString) { + navigateToChat(postId: postIdInt, chatRoomId: chatRoomIdInt) + } + case .keyword: + if let noticeIdString = parsedQuery["id"], + let keyword = parsedQuery["keyword"], + let boardIdString = parsedQuery["board-id"], + let noticeIdInt = Int(noticeIdString), + let boardIdInt = Int(boardIdString) { + navigateToKeyword(boardId: boardIdInt, noticeId: noticeIdInt) + } + default: + break + } + } + + private func parseQuery(uri: String) -> [String: String]? { + if let components = URLComponents(string: uri), + let qureyItems = components.queryItems { + return qureyItems.reduce(into: [:]) { result, item in + result[item.name] = item.value + } + } else { + return nil + } + } + + private func navigateToShop(shopId: Int) { + let viewController = makeShopViewController() + navigationController?.pushViewController(viewController, animated: true) + } + + private func navigateToDining() { + let viewController = makeDiningViewController() + navigationController?.pushViewController(viewController, animated: true) + } + + private func navigateToChat(articleId: Int, chatRoomId: Int) { + let viewModel = ChatViewModel(articleId: articleId, chatRoomId: chatRoomId, articleTitle: nil) + let viewController = ChatViewController(viewModel: viewModel) + navigationController?.pushViewController(viewController, animated: true) + } + + private func navigateToCallVanData(postId: Int) { + let viewController = makeCallVanDataViewController(postId: postId) + navigationController?.pushViewController(viewController, animated: true) + } + + private func navigateToChat(postId: Int, chatRoomId: Int) { + let viewController = makeCallVanChatViewController(postId: postId) + navigationController?.pushViewController(viewController, animated: true) + } + + private func navigateToKeyword(boardId: Int, noticeId: Int) { + let viewController = makeNoticeDataViewController(noticeId: noticeId, boardId: boardId) + navigationController?.pushViewController(viewController, animated: true) + } + + private func makeCallVanDataViewController(postId: Int) -> CallVanDataViewController { + let callVanRepository = DefaultCallVanRepository(service: DefaultCallVanService()) + let fetchCallVanDataUseCase = DefaultFetchCallVanDataUseCase(repository: callVanRepository) + let fetchCallVanNotificationListUseCase = DefaultFetchCallVanNotificationListUseCase(repository: callVanRepository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) + let viewModel = CallVanDataViewModel( + postId: postId, + fetchCallVanDataUseCase: fetchCallVanDataUseCase, + fetchCallVanNotificationListUseCase: fetchCallVanNotificationListUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase + ) + return CallVanDataViewController(viewModel: viewModel) + } + + private func makeCallVanChatViewController(postId: Int) -> CallVanChatViewController { + let callVanRepository = DefaultCallVanRepository(service: DefaultCallVanService()) + let coreRepository = DefaultCoreRepository(service: DefaultCoreService()) + let fetchCallVanChatUseCase = DefaultFetchCallVanChatUseCase(repository: callVanRepository) + let postCallVanChatUseCase = DefaultPostCallVanChatUseCase(repository: callVanRepository) + let fetchCallVanDataUseCase = DefaultFetchCallVanDataUseCase(repository: callVanRepository) + let uploadFileUseCase = DefaultUploadFileUseCase(coreRepository: coreRepository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) + let viewModel = CallVanChatViewModel( + postId: postId, + fetchCallVanChatUseCase: fetchCallVanChatUseCase, + postCallVanChatUseCase: postCallVanChatUseCase, + fetchCallVanDataUseCase: fetchCallVanDataUseCase, + uploadFileUseCase: uploadFileUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase + ) + return CallVanChatViewController(viewModel: viewModel) + } + + private func makeNoticeDataViewController(noticeId: Int, boardId: Int) -> NoticeDataViewController { + let service = DefaultNoticeService() + let repository = DefaultNoticeListRepository(service: service) + let viewModel = NoticeDataViewModel( + fetchNoticeDataUseCase: DefaultFetchNoticeDataUseCase(noticeListRepository: repository), + fetchHotNoticeArticlesUseCase: DefaultFetchHotNoticeArticlesUseCase(noticeListRepository: repository), + downloadNoticeAttachmentUseCase: DefaultDownloadNoticeAttachmentsUseCase(noticeRepository: repository), + logAnalyticsEventUseCase: DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())), + noticeId: noticeId, + boardId: boardId + ) + return NoticeDataViewController(viewModel: viewModel) + } + + private func makeDiningViewController() -> DiningViewController { + let diningService = DefaultDiningService() + let shareService = KakaoShareService() + let diningRepository = DefaultDiningRepository(diningService: diningService, shareService: shareService) + let notiRepository = DefaultNotiRepository(service: DefaultNotiService()) + let fetchDiningListUseCase = DefaultFetchDiningListUseCase(diningRepository: diningRepository) + let diningLikeUseCase = DefaultDiningLikeUseCase(diningRepository: diningRepository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) + let dateProvider = DefaultDateProvider() + let shareMenuListUseCase = DefaultShareMenuListUseCase(diningRepository: diningRepository) + let changeNotiUseCase = DefaultChangeNotiUseCase(notiRepository: notiRepository) + let changeNotiDetailUseCase = DefaultChangeNotiDetailUseCase(notiRepository: notiRepository) + let fetchNotiListUseCase = DefaultFetchNotiListUseCase(notiRepository: notiRepository) + let viewModel = DiningViewModel( + fetchDiningListUseCase: fetchDiningListUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase, + dateProvder: dateProvider, + shareMenuListUseCase: shareMenuListUseCase, + diningLikeUseCase: diningLikeUseCase, + changeNotiUseCase: changeNotiUseCase, + fetchNotiListUsecase: fetchNotiListUseCase, + changeNotiDetailUseCase: changeNotiDetailUseCase + ) + let viewController = DiningViewController(viewModel: viewModel) + viewController.title = "식단" + return viewController + } + + private func makeShopViewController() -> ShopViewController { + let shopService = DefaultShopService() + let shopRepository = DefaultShopRepository(service: shopService) + let fetchShopListUseCase = DefaultFetchShopListUseCase(shopRepository: shopRepository) + let fetchEventListUseCase = DefaultFetchEventListUseCase(shopRepository: shopRepository) + let fetchShopCategoryListUseCase = DefaultFetchShopCategoryListUseCase(shopRepository: shopRepository) + let fetchShopBenefitUseCase = DefaultFetchShopBenefitUseCase(shopRepository: shopRepository) + let fetchBeneficialShopUseCase = DefaultFetchBeneficialShopUseCase(shopRepository: shopRepository) + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) + let getUserScreenTimeUseCase = DefaultGetUserScreenTimeUseCase() + let viewModel = ShopViewModel( + fetchShopListUseCase: fetchShopListUseCase, + fetchEventListUseCase: fetchEventListUseCase, + fetchShopCategoryListUseCase: fetchShopCategoryListUseCase, + fetchShopBenefitUseCase: fetchShopBenefitUseCase, + fetchBeneficialShopUseCase: fetchBeneficialShopUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase, + getUserScreenTimeUseCase: getUserScreenTimeUseCase + ) + let viewController = ShopViewController(viewModel: viewModel) + viewController.title = "주변상점" + return viewController + } +} + // MARK: - Action private extension NotificationViewController {